MOAR FEATURES:

- Add admin view for PartCategoryStar
- Add starred status to partcategory API
- Can filter by "starred" status
- Rename internal functions back to using "starred" (front-end now uses the term "subscribe")
This commit is contained in:
Oliver 2021-11-03 23:22:31 +11:00
parent f9a00b7a90
commit 7567b8dd63
7 changed files with 126 additions and 70 deletions

View File

@ -8,13 +8,7 @@ from import_export.resources import ModelResource
from import_export.fields import Field from import_export.fields import Field
import import_export.widgets as widgets import import_export.widgets as widgets
from .models import PartCategory, Part import part.models as models
from .models import PartAttachment, PartStar, PartRelated
from .models import BomItem
from .models import PartParameterTemplate, PartParameter
from .models import PartCategoryParameterTemplate
from .models import PartTestTemplate
from .models import PartSellPriceBreak, PartInternalPriceBreak
from stock.models import StockLocation from stock.models import StockLocation
from company.models import SupplierPart from company.models import SupplierPart
@ -24,7 +18,7 @@ class PartResource(ModelResource):
""" Class for managing Part data import/export """ """ Class for managing Part data import/export """
# ForeignKey fields # ForeignKey fields
category = Field(attribute='category', widget=widgets.ForeignKeyWidget(PartCategory)) category = Field(attribute='category', widget=widgets.ForeignKeyWidget(models.PartCategory))
default_location = Field(attribute='default_location', widget=widgets.ForeignKeyWidget(StockLocation)) default_location = Field(attribute='default_location', widget=widgets.ForeignKeyWidget(StockLocation))
@ -32,7 +26,7 @@ class PartResource(ModelResource):
category_name = Field(attribute='category__name', readonly=True) category_name = Field(attribute='category__name', readonly=True)
variant_of = Field(attribute='variant_of', widget=widgets.ForeignKeyWidget(Part)) variant_of = Field(attribute='variant_of', widget=widgets.ForeignKeyWidget(models.Part))
suppliers = Field(attribute='supplier_count', readonly=True) suppliers = Field(attribute='supplier_count', readonly=True)
@ -48,7 +42,7 @@ class PartResource(ModelResource):
building = Field(attribute='quantity_being_built', readonly=True, widget=widgets.IntegerWidget()) building = Field(attribute='quantity_being_built', readonly=True, widget=widgets.IntegerWidget())
class Meta: class Meta:
model = Part model = models.Part
skip_unchanged = True skip_unchanged = True
report_skipped = False report_skipped = False
clean_model_instances = True clean_model_instances = True
@ -86,14 +80,14 @@ class PartAdmin(ImportExportModelAdmin):
class PartCategoryResource(ModelResource): class PartCategoryResource(ModelResource):
""" Class for managing PartCategory data import/export """ """ Class for managing PartCategory data import/export """
parent = Field(attribute='parent', widget=widgets.ForeignKeyWidget(PartCategory)) parent = Field(attribute='parent', widget=widgets.ForeignKeyWidget(models.PartCategory))
parent_name = Field(attribute='parent__name', readonly=True) parent_name = Field(attribute='parent__name', readonly=True)
default_location = Field(attribute='default_location', widget=widgets.ForeignKeyWidget(StockLocation)) default_location = Field(attribute='default_location', widget=widgets.ForeignKeyWidget(StockLocation))
class Meta: class Meta:
model = PartCategory model = models.PartCategory
skip_unchanged = True skip_unchanged = True
report_skipped = False report_skipped = False
clean_model_instances = True clean_model_instances = True
@ -108,14 +102,14 @@ class PartCategoryResource(ModelResource):
super().after_import(dataset, result, using_transactions, dry_run, **kwargs) super().after_import(dataset, result, using_transactions, dry_run, **kwargs)
# Rebuild the PartCategory tree(s) # Rebuild the PartCategory tree(s)
PartCategory.objects.rebuild() models.PartCategory.objects.rebuild()
class PartCategoryInline(admin.TabularInline): class PartCategoryInline(admin.TabularInline):
""" """
Inline for PartCategory model Inline for PartCategory model
""" """
model = PartCategory model = models.PartCategory
class PartCategoryAdmin(ImportExportModelAdmin): class PartCategoryAdmin(ImportExportModelAdmin):
@ -146,6 +140,11 @@ class PartStarAdmin(admin.ModelAdmin):
list_display = ('part', 'user') list_display = ('part', 'user')
class PartCategoryStarAdmin(admin.ModelAdmin):
list_display = ('category', 'user')
class PartTestTemplateAdmin(admin.ModelAdmin): class PartTestTemplateAdmin(admin.ModelAdmin):
list_display = ('part', 'test_name', 'required') list_display = ('part', 'test_name', 'required')
@ -159,7 +158,7 @@ class BomItemResource(ModelResource):
bom_id = Field(attribute='pk') bom_id = Field(attribute='pk')
# ID of the parent part # ID of the parent part
parent_part_id = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part)) parent_part_id = Field(attribute='part', widget=widgets.ForeignKeyWidget(models.Part))
# IPN of the parent part # IPN of the parent part
parent_part_ipn = Field(attribute='part__IPN', readonly=True) parent_part_ipn = Field(attribute='part__IPN', readonly=True)
@ -168,7 +167,7 @@ class BomItemResource(ModelResource):
parent_part_name = Field(attribute='part__name', readonly=True) parent_part_name = Field(attribute='part__name', readonly=True)
# ID of the sub-part # ID of the sub-part
part_id = Field(attribute='sub_part', widget=widgets.ForeignKeyWidget(Part)) part_id = Field(attribute='sub_part', widget=widgets.ForeignKeyWidget(models.Part))
# IPN of the sub-part # IPN of the sub-part
part_ipn = Field(attribute='sub_part__IPN', readonly=True) part_ipn = Field(attribute='sub_part__IPN', readonly=True)
@ -233,7 +232,7 @@ class BomItemResource(ModelResource):
return fields return fields
class Meta: class Meta:
model = BomItem model = models.BomItem
skip_unchanged = True skip_unchanged = True
report_skipped = False report_skipped = False
clean_model_instances = True clean_model_instances = True
@ -262,16 +261,16 @@ class ParameterTemplateAdmin(ImportExportModelAdmin):
class ParameterResource(ModelResource): class ParameterResource(ModelResource):
""" Class for managing PartParameter data import/export """ """ Class for managing PartParameter data import/export """
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part)) part = Field(attribute='part', widget=widgets.ForeignKeyWidget(models.Part))
part_name = Field(attribute='part__name', readonly=True) part_name = Field(attribute='part__name', readonly=True)
template = Field(attribute='template', widget=widgets.ForeignKeyWidget(PartParameterTemplate)) template = Field(attribute='template', widget=widgets.ForeignKeyWidget(models.PartParameterTemplate))
template_name = Field(attribute='template__name', readonly=True) template_name = Field(attribute='template__name', readonly=True)
class Meta: class Meta:
model = PartParameter model = models.PartParameter
skip_unchanged = True skip_unchanged = True
report_skipped = False report_skipped = False
clean_model_instance = True clean_model_instance = True
@ -292,7 +291,7 @@ class PartCategoryParameterAdmin(admin.ModelAdmin):
class PartSellPriceBreakAdmin(admin.ModelAdmin): class PartSellPriceBreakAdmin(admin.ModelAdmin):
class Meta: class Meta:
model = PartSellPriceBreak model = models.PartSellPriceBreak
list_display = ('part', 'quantity', 'price',) list_display = ('part', 'quantity', 'price',)
@ -300,20 +299,21 @@ class PartSellPriceBreakAdmin(admin.ModelAdmin):
class PartInternalPriceBreakAdmin(admin.ModelAdmin): class PartInternalPriceBreakAdmin(admin.ModelAdmin):
class Meta: class Meta:
model = PartInternalPriceBreak model = models.PartInternalPriceBreak
list_display = ('part', 'quantity', 'price',) list_display = ('part', 'quantity', 'price',)
admin.site.register(Part, PartAdmin) admin.site.register(models.Part, PartAdmin)
admin.site.register(PartCategory, PartCategoryAdmin) admin.site.register(models.PartCategory, PartCategoryAdmin)
admin.site.register(PartRelated, PartRelatedAdmin) admin.site.register(models.PartRelated, PartRelatedAdmin)
admin.site.register(PartAttachment, PartAttachmentAdmin) admin.site.register(models.PartAttachment, PartAttachmentAdmin)
admin.site.register(PartStar, PartStarAdmin) admin.site.register(models.PartStar, PartStarAdmin)
admin.site.register(BomItem, BomItemAdmin) admin.site.register(models.PartCategoryStar, PartCategoryStarAdmin)
admin.site.register(PartParameterTemplate, ParameterTemplateAdmin) admin.site.register(models.BomItem, BomItemAdmin)
admin.site.register(PartParameter, ParameterAdmin) admin.site.register(models.PartParameterTemplate, ParameterTemplateAdmin)
admin.site.register(PartCategoryParameterTemplate, PartCategoryParameterAdmin) admin.site.register(models.PartParameter, ParameterAdmin)
admin.site.register(PartTestTemplate, PartTestTemplateAdmin) admin.site.register(models.PartCategoryParameterTemplate, PartCategoryParameterAdmin)
admin.site.register(PartSellPriceBreak, PartSellPriceBreakAdmin) admin.site.register(models.PartTestTemplate, PartTestTemplateAdmin)
admin.site.register(PartInternalPriceBreak, PartInternalPriceBreakAdmin) admin.site.register(models.PartSellPriceBreak, PartSellPriceBreakAdmin)
admin.site.register(models.PartInternalPriceBreak, PartInternalPriceBreakAdmin)

View File

@ -58,6 +58,14 @@ class CategoryList(generics.ListCreateAPIView):
queryset = PartCategory.objects.all() queryset = PartCategory.objects.all()
serializer_class = part_serializers.CategorySerializer serializer_class = part_serializers.CategorySerializer
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['starred_categories'] = [star.category for star in self.request.user.starred_categories.all()]
return ctx
def filter_queryset(self, queryset): def filter_queryset(self, queryset):
""" """
Custom filtering: Custom filtering:
@ -110,6 +118,18 @@ class CategoryList(generics.ListCreateAPIView):
except (ValueError, PartCategory.DoesNotExist): except (ValueError, PartCategory.DoesNotExist):
pass pass
# Filter by "starred" status
starred = params.get('starred', None)
if starred is not None:
starred = str2bool(starred)
starred_categories = [star.category.pk for star in self.request.user.starred_categories.all()]
if starred:
queryset = queryset.filter(pk__in=starred_categories)
else:
queryset = queryset.exclude(pk__in=starred_categories)
return queryset return queryset
filter_backends = [ filter_backends = [
@ -149,6 +169,14 @@ class CategoryDetail(generics.RetrieveUpdateDestroyAPIView):
serializer_class = part_serializers.CategorySerializer serializer_class = part_serializers.CategorySerializer
queryset = PartCategory.objects.all() queryset = PartCategory.objects.all()
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['starred_categories'] = [star.category for star in self.request.user.starred_categories.all()]
return ctx
class CategoryParameterList(generics.ListAPIView): class CategoryParameterList(generics.ListAPIView):
""" API endpoint for accessing a list of PartCategoryParameterTemplate objects. """ API endpoint for accessing a list of PartCategoryParameterTemplate objects.
@ -389,7 +417,7 @@ class PartDetail(generics.RetrieveUpdateDestroyAPIView):
# Ensure the request context is passed through # Ensure the request context is passed through
kwargs['context'] = self.get_serializer_context() kwargs['context'] = self.get_serializer_context()
# Pass a list of "starred" parts fo the current user to the serializer # Pass a list of "starred" parts of the current user to the serializer
# We do this to reduce the number of database queries required! # We do this to reduce the number of database queries required!
if self.starred_parts is None and self.request is not None: if self.starred_parts is None and self.request is not None:
self.starred_parts = [star.part for star in self.request.user.starred_parts.all()] self.starred_parts = [star.part for star in self.request.user.starred_parts.all()]
@ -420,7 +448,7 @@ class PartDetail(generics.RetrieveUpdateDestroyAPIView):
if 'starred' in request.data: if 'starred' in request.data:
starred = str2bool(request.data.get('starred', None)) starred = str2bool(request.data.get('starred', None))
self.get_object().set_subscription(request.user, starred) self.get_object().set_starred(request.user, starred)
response = super().update(request, *args, **kwargs) response = super().update(request, *args, **kwargs)

View File

@ -15,7 +15,7 @@ from django.urls import reverse
from django.db import models, transaction from django.db import models, transaction
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
from django.db.models import Q, Sum, UniqueConstraint, query from django.db.models import Q, Sum, UniqueConstraint
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
@ -102,11 +102,11 @@ class PartCategory(InvenTreeTree):
if cascade: if cascade:
""" Select any parts which exist in this category or any child categories """ """ Select any parts which exist in this category or any child categories """
query = Part.objects.filter(category__in=self.getUniqueChildren(include_self=True)) queryset = Part.objects.filter(category__in=self.getUniqueChildren(include_self=True))
else: else:
query = Part.objects.filter(category=self.pk) queryset = Part.objects.filter(category=self.pk)
return query return queryset
@property @property
def item_count(self): def item_count(self):
@ -224,14 +224,14 @@ class PartCategory(InvenTreeTree):
return [s for s in subscribers] return [s for s in subscribers]
def is_subscribed_by(self, user, **kwargs): def is_starred_by(self, user, **kwargs):
""" """
Returns True if the specified user subscribes to this category Returns True if the specified user subscribes to this category
""" """
return user in self.get_subscribers(**kwargs) return user in self.get_subscribers(**kwargs)
def set_subscription(self, user, status): def set_starred(self, user, status):
""" """
Set the "subscription" status of this PartCategory against the specified user Set the "subscription" status of this PartCategory against the specified user
""" """
@ -239,7 +239,7 @@ class PartCategory(InvenTreeTree):
if not user: if not user:
return return
if self.is_subscribed_by(user) == status: if self.is_starred_by(user) == status:
return return
if status: if status:
@ -386,9 +386,16 @@ class Part(MPTTModel):
context = {} context = {}
context['starred'] = self.is_subscribed_by(request.user)
context['disabled'] = not self.active context['disabled'] = not self.active
# Subscription status
context['starred'] = self.is_starred_by(request.user)
context['starred_directly'] = context['starred'] and self.is_starred_by(
request.user,
include_variants=False,
include_categories=False
)
# Pre-calculate complex queries so they only need to be performed once # Pre-calculate complex queries so they only need to be performed once
context['total_stock'] = self.total_stock context['total_stock'] = self.total_stock
@ -1129,14 +1136,14 @@ class Part(MPTTModel):
return [s for s in subscribers] return [s for s in subscribers]
def is_subscribed_by(self, user, **kwargs): def is_starred_by(self, user, **kwargs):
""" """
Return True if the specified user subscribes to this part Return True if the specified user subscribes to this part
""" """
return user in self.get_subscribers(**kwargs) return user in self.get_subscribers(**kwargs)
def set_subscription(self, user, status): def set_starred(self, user, status):
""" """
Set the "subscription" status of this Part against the specified user Set the "subscription" status of this Part against the specified user
""" """
@ -1145,7 +1152,7 @@ class Part(MPTTModel):
return return
# Already subscribed? # Already subscribed?
if self.is_subscribed_by(user) == status: if self.is_starred_by(user) == status:
return return
if status: if status:

View File

@ -33,12 +33,27 @@ from .models import (BomItem, BomItemSubstitute,
class CategorySerializer(InvenTreeModelSerializer): class CategorySerializer(InvenTreeModelSerializer):
""" Serializer for PartCategory """ """ Serializer for PartCategory """
def __init__(self, *args, **kwargs):
self.starred_categories = kwargs.pop('starred_categories', [])
super().__init__(*args, **kwargs)
def get_starred(self, category):
"""
Return True if the category is directly "starred" by the current user
"""
return category in self.starred_categories
url = serializers.CharField(source='get_absolute_url', read_only=True) url = serializers.CharField(source='get_absolute_url', read_only=True)
parts = serializers.IntegerField(source='item_count', read_only=True) parts = serializers.IntegerField(source='item_count', read_only=True)
level = serializers.IntegerField(read_only=True) level = serializers.IntegerField(read_only=True)
starred = serializers.SerializerMethodField()
class Meta: class Meta:
model = PartCategory model = PartCategory
fields = [ fields = [
@ -51,6 +66,7 @@ class CategorySerializer(InvenTreeModelSerializer):
'parent', 'parent',
'parts', 'parts',
'pathstring', 'pathstring',
'starred',
'url', 'url',
] ]

View File

@ -23,10 +23,14 @@
{% include "admin_button.html" with url=url %} {% include "admin_button.html" with url=url %}
{% endif %} {% endif %}
{% if starred %} {% if starred_directly %}
<button type='button' class='btn btn-outline-secondary' id='toggle-starred' title='{% trans "You are subscribed to nofications for this part" %}'> <button type='button' class='btn btn-outline-secondary' id='toggle-starred' title='{% trans "You are subscribed to nofications for this part" %}'>
<span id='part-star-icon' class='fas fa-bell icon-green'/> <span id='part-star-icon' class='fas fa-bell icon-green'/>
</button> </button>
{% elif starred %}
<button type='button' class='btn btn-outline-secondary' title='{% trans "You are subscribed to notifications for this part" %}' disabled='true'>
<span class='fas fa-bell icon-green'></span>
</button>
{% else %} {% else %}
<button type='button' class='btn btn-outline-secondary' id='toggle-starred' title='{% trans "Subscribe to nofications for this part" %}'> <button type='button' class='btn btn-outline-secondary' id='toggle-starred' title='{% trans "Subscribe to nofications for this part" %}'>
<span id='part-star-icon' class='fa fa-bell-slash'/> <span id='part-star-icon' class='fa fa-bell-slash'/>

View File

@ -384,19 +384,19 @@ class PartSubscriptionTests(TestCase):
""" """
# First check that the user is *not* subscribed to the part # First check that the user is *not* subscribed to the part
self.assertFalse(self.part.is_subscribed_by(self.user)) self.assertFalse(self.part.is_starred_by(self.user))
# Now, subscribe directly to the part # Now, subscribe directly to the part
self.part.set_subscription(self.user, True) self.part.set_starred(self.user, True)
self.assertEqual(PartStar.objects.count(), 1) self.assertEqual(PartStar.objects.count(), 1)
self.assertTrue(self.part.is_subscribed_by(self.user)) self.assertTrue(self.part.is_starred_by(self.user))
# Now, unsubscribe # Now, unsubscribe
self.part.set_subscription(self.user, False) self.part.set_starred(self.user, False)
self.assertFalse(self.part.is_subscribed_by(self.user)) self.assertFalse(self.part.is_starred_by(self.user))
def test_variant_subscription(self): def test_variant_subscription(self):
""" """
@ -410,13 +410,13 @@ class PartSubscriptionTests(TestCase):
variant_of=self.part, variant_of=self.part,
) )
self.assertFalse(sub_part.is_subscribed_by(self.user)) self.assertFalse(sub_part.is_starred_by(self.user))
# Subscribe to the "parent" part # Subscribe to the "parent" part
self.part.set_subscription(self.user, True) self.part.set_starred(self.user, True)
self.assertTrue(self.part.is_subscribed_by(self.user)) self.assertTrue(self.part.is_starred_by(self.user))
self.assertTrue(sub_part.is_subscribed_by(self.user)) self.assertTrue(sub_part.is_starred_by(self.user))
def test_category_subscription(self): def test_category_subscription(self):
""" """
@ -425,26 +425,26 @@ class PartSubscriptionTests(TestCase):
self.assertEqual(PartCategoryStar.objects.count(), 0) self.assertEqual(PartCategoryStar.objects.count(), 0)
self.assertFalse(self.part.is_subscribed_by(self.user)) self.assertFalse(self.part.is_starred_by(self.user))
self.assertFalse(self.category.is_subscribed_by(self.user)) self.assertFalse(self.category.is_starred_by(self.user))
# Subscribe to the direct parent category # Subscribe to the direct parent category
self.category.set_subscription(self.user, True) self.category.set_starred(self.user, True)
self.assertEqual(PartStar.objects.count(), 0) self.assertEqual(PartStar.objects.count(), 0)
self.assertEqual(PartCategoryStar.objects.count(), 1) self.assertEqual(PartCategoryStar.objects.count(), 1)
self.assertTrue(self.category.is_subscribed_by(self.user)) self.assertTrue(self.category.is_starred_by(self.user))
self.assertTrue(self.part.is_subscribed_by(self.user)) self.assertTrue(self.part.is_starred_by(self.user))
# Check that the "parent" category is not starred # Check that the "parent" category is not starred
self.assertFalse(self.category.parent.is_subscribed_by(self.user)) self.assertFalse(self.category.parent.is_starred_by(self.user))
# Un-subscribe # Un-subscribe
self.category.set_subscription(self.user, False) self.category.set_starred(self.user, False)
self.assertFalse(self.category.is_subscribed_by(self.user)) self.assertFalse(self.category.is_starred_by(self.user))
self.assertFalse(self.part.is_subscribed_by(self.user)) self.assertFalse(self.part.is_starred_by(self.user))
def test_parent_category_subscription(self): def test_parent_category_subscription(self):
""" """
@ -454,13 +454,13 @@ class PartSubscriptionTests(TestCase):
# Top-level "electronics" category # Top-level "electronics" category
cat = PartCategory.objects.get(pk=1) cat = PartCategory.objects.get(pk=1)
cat.set_subscription(self.user, True) cat.set_starred(self.user, True)
# Check base category # Check base category
self.assertTrue(cat.is_subscribed_by(self.user)) self.assertTrue(cat.is_starred_by(self.user))
# Check lower level category # Check lower level category
self.assertTrue(self.category.is_subscribed_by(self.user)) self.assertTrue(self.category.is_starred_by(self.user))
# Check part # Check part
self.assertTrue(self.part.is_subscribed_by(self.user)) self.assertTrue(self.part.is_starred_by(self.user))

View File

@ -412,6 +412,7 @@ class PartDetail(InvenTreeRoleMixin, DetailView):
part = self.get_object() part = self.get_object()
ctx = part.get_context_data(self.request) ctx = part.get_context_data(self.request)
context.update(**ctx) context.update(**ctx)
# Pricing information # Pricing information