mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
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:
parent
f9a00b7a90
commit
7567b8dd63
@ -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)
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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',
|
||||
]
|
||||
|
||||
|
@ -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'/>
|
||||
|
@ -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))
|
||||
|
@ -412,6 +412,7 @@ class PartDetail(InvenTreeRoleMixin, DetailView):
|
||||
part = self.get_object()
|
||||
|
||||
ctx = part.get_context_data(self.request)
|
||||
|
||||
context.update(**ctx)
|
||||
|
||||
# Pricing information
|
||||
|
Loading…
Reference in New Issue
Block a user