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
import import_export.widgets as widgets
from .models import PartCategory, Part
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
import part.models as models
from stock.models import StockLocation
from company.models import SupplierPart
@ -24,7 +18,7 @@ class PartResource(ModelResource):
""" Class for managing Part data import/export """
# 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))
@ -32,7 +26,7 @@ class PartResource(ModelResource):
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)
@ -48,7 +42,7 @@ class PartResource(ModelResource):
building = Field(attribute='quantity_being_built', readonly=True, widget=widgets.IntegerWidget())
class Meta:
model = Part
model = models.Part
skip_unchanged = True
report_skipped = False
clean_model_instances = True
@ -86,14 +80,14 @@ class PartAdmin(ImportExportModelAdmin):
class PartCategoryResource(ModelResource):
""" 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)
default_location = Field(attribute='default_location', widget=widgets.ForeignKeyWidget(StockLocation))
class Meta:
model = PartCategory
model = models.PartCategory
skip_unchanged = True
report_skipped = False
clean_model_instances = True
@ -108,14 +102,14 @@ class PartCategoryResource(ModelResource):
super().after_import(dataset, result, using_transactions, dry_run, **kwargs)
# Rebuild the PartCategory tree(s)
PartCategory.objects.rebuild()
models.PartCategory.objects.rebuild()
class PartCategoryInline(admin.TabularInline):
"""
Inline for PartCategory model
"""
model = PartCategory
model = models.PartCategory
class PartCategoryAdmin(ImportExportModelAdmin):
@ -146,6 +140,11 @@ class PartStarAdmin(admin.ModelAdmin):
list_display = ('part', 'user')
class PartCategoryStarAdmin(admin.ModelAdmin):
list_display = ('category', 'user')
class PartTestTemplateAdmin(admin.ModelAdmin):
list_display = ('part', 'test_name', 'required')
@ -159,7 +158,7 @@ class BomItemResource(ModelResource):
bom_id = Field(attribute='pk')
# 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
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)
# 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
part_ipn = Field(attribute='sub_part__IPN', readonly=True)
@ -233,7 +232,7 @@ class BomItemResource(ModelResource):
return fields
class Meta:
model = BomItem
model = models.BomItem
skip_unchanged = True
report_skipped = False
clean_model_instances = True
@ -262,16 +261,16 @@ class ParameterTemplateAdmin(ImportExportModelAdmin):
class ParameterResource(ModelResource):
""" 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)
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)
class Meta:
model = PartParameter
model = models.PartParameter
skip_unchanged = True
report_skipped = False
clean_model_instance = True
@ -292,7 +291,7 @@ class PartCategoryParameterAdmin(admin.ModelAdmin):
class PartSellPriceBreakAdmin(admin.ModelAdmin):
class Meta:
model = PartSellPriceBreak
model = models.PartSellPriceBreak
list_display = ('part', 'quantity', 'price',)
@ -300,20 +299,21 @@ class PartSellPriceBreakAdmin(admin.ModelAdmin):
class PartInternalPriceBreakAdmin(admin.ModelAdmin):
class Meta:
model = PartInternalPriceBreak
model = models.PartInternalPriceBreak
list_display = ('part', 'quantity', 'price',)
admin.site.register(Part, PartAdmin)
admin.site.register(PartCategory, PartCategoryAdmin)
admin.site.register(PartRelated, PartRelatedAdmin)
admin.site.register(PartAttachment, PartAttachmentAdmin)
admin.site.register(PartStar, PartStarAdmin)
admin.site.register(BomItem, BomItemAdmin)
admin.site.register(PartParameterTemplate, ParameterTemplateAdmin)
admin.site.register(PartParameter, ParameterAdmin)
admin.site.register(PartCategoryParameterTemplate, PartCategoryParameterAdmin)
admin.site.register(PartTestTemplate, PartTestTemplateAdmin)
admin.site.register(PartSellPriceBreak, PartSellPriceBreakAdmin)
admin.site.register(PartInternalPriceBreak, PartInternalPriceBreakAdmin)
admin.site.register(models.Part, PartAdmin)
admin.site.register(models.PartCategory, PartCategoryAdmin)
admin.site.register(models.PartRelated, PartRelatedAdmin)
admin.site.register(models.PartAttachment, PartAttachmentAdmin)
admin.site.register(models.PartStar, PartStarAdmin)
admin.site.register(models.PartCategoryStar, PartCategoryStarAdmin)
admin.site.register(models.BomItem, BomItemAdmin)
admin.site.register(models.PartParameterTemplate, ParameterTemplateAdmin)
admin.site.register(models.PartParameter, ParameterAdmin)
admin.site.register(models.PartCategoryParameterTemplate, PartCategoryParameterAdmin)
admin.site.register(models.PartTestTemplate, PartTestTemplateAdmin)
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()
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):
"""
Custom filtering:
@ -110,6 +118,18 @@ class CategoryList(generics.ListCreateAPIView):
except (ValueError, PartCategory.DoesNotExist):
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
filter_backends = [
@ -149,6 +169,14 @@ class CategoryDetail(generics.RetrieveUpdateDestroyAPIView):
serializer_class = part_serializers.CategorySerializer
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):
""" API endpoint for accessing a list of PartCategoryParameterTemplate objects.
@ -389,7 +417,7 @@ class PartDetail(generics.RetrieveUpdateDestroyAPIView):
# Ensure the request context is passed through
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!
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()]
@ -420,7 +448,7 @@ class PartDetail(generics.RetrieveUpdateDestroyAPIView):
if 'starred' in request.data:
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)

View File

@ -15,7 +15,7 @@ from django.urls import reverse
from django.db import models, transaction
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.core.validators import MinValueValidator
@ -102,11 +102,11 @@ class PartCategory(InvenTreeTree):
if cascade:
""" 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:
query = Part.objects.filter(category=self.pk)
queryset = Part.objects.filter(category=self.pk)
return query
return queryset
@property
def item_count(self):
@ -224,14 +224,14 @@ class PartCategory(InvenTreeTree):
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
"""
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
"""
@ -239,7 +239,7 @@ class PartCategory(InvenTreeTree):
if not user:
return
if self.is_subscribed_by(user) == status:
if self.is_starred_by(user) == status:
return
if status:
@ -386,9 +386,16 @@ class Part(MPTTModel):
context = {}
context['starred'] = self.is_subscribed_by(request.user)
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
context['total_stock'] = self.total_stock
@ -1129,14 +1136,14 @@ class Part(MPTTModel):
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 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
"""
@ -1145,7 +1152,7 @@ class Part(MPTTModel):
return
# Already subscribed?
if self.is_subscribed_by(user) == status:
if self.is_starred_by(user) == status:
return
if status:

View File

@ -33,12 +33,27 @@ from .models import (BomItem, BomItemSubstitute,
class CategorySerializer(InvenTreeModelSerializer):
""" 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)
parts = serializers.IntegerField(source='item_count', read_only=True)
level = serializers.IntegerField(read_only=True)
starred = serializers.SerializerMethodField()
class Meta:
model = PartCategory
fields = [
@ -51,6 +66,7 @@ class CategorySerializer(InvenTreeModelSerializer):
'parent',
'parts',
'pathstring',
'starred',
'url',
]

View File

@ -23,10 +23,14 @@
{% include "admin_button.html" with url=url %}
{% 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" %}'>
<span id='part-star-icon' class='fas fa-bell icon-green'/>
</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 %}
<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'/>

View File

@ -384,19 +384,19 @@ class PartSubscriptionTests(TestCase):
"""
# 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
self.part.set_subscription(self.user, True)
self.part.set_starred(self.user, True)
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
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):
"""
@ -410,13 +410,13 @@ class PartSubscriptionTests(TestCase):
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
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(sub_part.is_subscribed_by(self.user))
self.assertTrue(self.part.is_starred_by(self.user))
self.assertTrue(sub_part.is_starred_by(self.user))
def test_category_subscription(self):
"""
@ -425,26 +425,26 @@ class PartSubscriptionTests(TestCase):
self.assertEqual(PartCategoryStar.objects.count(), 0)
self.assertFalse(self.part.is_subscribed_by(self.user))
self.assertFalse(self.category.is_subscribed_by(self.user))
self.assertFalse(self.part.is_starred_by(self.user))
self.assertFalse(self.category.is_starred_by(self.user))
# 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(PartCategoryStar.objects.count(), 1)
self.assertTrue(self.category.is_subscribed_by(self.user))
self.assertTrue(self.part.is_subscribed_by(self.user))
self.assertTrue(self.category.is_starred_by(self.user))
self.assertTrue(self.part.is_starred_by(self.user))
# 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
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.part.is_subscribed_by(self.user))
self.assertFalse(self.category.is_starred_by(self.user))
self.assertFalse(self.part.is_starred_by(self.user))
def test_parent_category_subscription(self):
"""
@ -454,13 +454,13 @@ class PartSubscriptionTests(TestCase):
# Top-level "electronics" category
cat = PartCategory.objects.get(pk=1)
cat.set_subscription(self.user, True)
cat.set_starred(self.user, True)
# Check base category
self.assertTrue(cat.is_subscribed_by(self.user))
self.assertTrue(cat.is_starred_by(self.user))
# Check lower level category
self.assertTrue(self.category.is_subscribed_by(self.user))
self.assertTrue(self.category.is_starred_by(self.user))
# 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()
ctx = part.get_context_data(self.request)
context.update(**ctx)
# Pricing information