From 7567b8dd63d1d8c26a663cf7eb2146bb4eb38ab4 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 3 Nov 2021 23:22:31 +1100 Subject: [PATCH] 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") --- InvenTree/part/admin.py | 68 ++++++++++---------- InvenTree/part/api.py | 32 ++++++++- InvenTree/part/models.py | 29 +++++---- InvenTree/part/serializers.py | 16 +++++ InvenTree/part/templates/part/part_base.html | 6 +- InvenTree/part/test_part.py | 44 ++++++------- InvenTree/part/views.py | 1 + 7 files changed, 126 insertions(+), 70 deletions(-) diff --git a/InvenTree/part/admin.py b/InvenTree/part/admin.py index 2e434d928d..90543d429d 100644 --- a/InvenTree/part/admin.py +++ b/InvenTree/part/admin.py @@ -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) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 0b754dffe8..20447a4d26 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -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) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 0b99b8dac5..dada6f125b 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -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: diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index ff1fb2c8c6..981d143507 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -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', ] diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html index 1dcb509a59..21e26c64c6 100644 --- a/InvenTree/part/templates/part/part_base.html +++ b/InvenTree/part/templates/part/part_base.html @@ -23,10 +23,14 @@ {% include "admin_button.html" with url=url %} {% endif %} -{% if starred %} +{% if starred_directly %} +{% elif starred %} + {% else %}