2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-19 21:45:39 +00:00

Merge branch 'master' into stock-item-forms

This commit is contained in:
Oliver
2021-11-04 10:34:49 +11:00
33 changed files with 826 additions and 199 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,18 @@ class CategoryList(generics.ListCreateAPIView):
queryset = PartCategory.objects.all()
serializer_class = part_serializers.CategorySerializer
def get_serializer_context(self):
ctx = super().get_serializer_context()
try:
ctx['starred_categories'] = [star.category for star in self.request.user.starred_categories.all()]
except AttributeError:
# Error is thrown if the view does not have an associated request
ctx['starred_categories'] = []
return ctx
def filter_queryset(self, queryset):
"""
Custom filtering:
@ -110,6 +122,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 +173,29 @@ class CategoryDetail(generics.RetrieveUpdateDestroyAPIView):
serializer_class = part_serializers.CategorySerializer
queryset = PartCategory.objects.all()
def get_serializer_context(self):
ctx = super().get_serializer_context()
try:
ctx['starred_categories'] = [star.category for star in self.request.user.starred_categories.all()]
except AttributeError:
# Error is thrown if the view does not have an associated request
ctx['starred_categories'] = []
return ctx
def update(self, request, *args, **kwargs):
if 'starred' in request.data:
starred = str2bool(request.data.get('starred', False))
self.get_object().set_starred(request.user, starred)
response = super().update(request, *args, **kwargs)
return response
class CategoryParameterList(generics.ListAPIView):
""" API endpoint for accessing a list of PartCategoryParameterTemplate objects.
@ -389,7 +436,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()]
@ -418,9 +465,9 @@ class PartDetail(generics.RetrieveUpdateDestroyAPIView):
"""
if 'starred' in request.data:
starred = str2bool(request.data.get('starred', None))
starred = str2bool(request.data.get('starred', False))
self.get_object().setStarred(request.user, starred)
self.get_object().set_starred(request.user, starred)
response = super().update(request, *args, **kwargs)

View File

@ -0,0 +1,27 @@
# Generated by Django 3.2.5 on 2021-11-03 07:03
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('part', '0073_auto_20211013_1048'),
]
operations = [
migrations.CreateModel(
name='PartCategoryStar',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='starred_users', to='part.partcategory', verbose_name='Category')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='starred_categories', to=settings.AUTH_USER_MODEL, verbose_name='User')),
],
options={
'unique_together': {('category', 'user')},
},
),
]

View File

@ -20,7 +20,7 @@ from django.db.models.functions import Coalesce
from django.core.validators import MinValueValidator
from django.contrib.auth.models import User
from django.db.models.signals import pre_delete
from django.db.models.signals import pre_delete, post_save
from django.dispatch import receiver
from jinja2 import Template
@ -47,6 +47,7 @@ from InvenTree import validators
from InvenTree.models import InvenTreeTree, InvenTreeAttachment
from InvenTree.fields import InvenTreeURLField
from InvenTree.helpers import decimal2string, normalize, decimal2money
import InvenTree.tasks
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus, SalesOrderStatus
@ -56,6 +57,7 @@ from company.models import SupplierPart
from stock import models as StockModels
import common.models
import part.settings as part_settings
@ -102,11 +104,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):
@ -201,6 +203,60 @@ class PartCategory(InvenTreeTree):
return prefetch.filter(category=self.id)
def get_subscribers(self, include_parents=True):
"""
Return a list of users who subscribe to this PartCategory
"""
cats = self.get_ancestors(include_self=True)
subscribers = set()
if include_parents:
queryset = PartCategoryStar.objects.filter(
category__pk__in=[cat.pk for cat in cats]
)
else:
queryset = PartCategoryStar.objects.filter(
category=self,
)
for result in queryset:
subscribers.add(result.user)
return [s for s in subscribers]
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_starred(self, user, status):
"""
Set the "subscription" status of this PartCategory against the specified user
"""
if not user:
return
if self.is_starred_by(user) == status:
return
if status:
PartCategoryStar.objects.create(
category=self,
user=user
)
else:
# Note that this won't actually stop the user being subscribed,
# if the user is subscribed to a parent category
PartCategoryStar.objects.filter(
category=self,
user=user,
).delete()
@receiver(pre_delete, sender=PartCategory, dispatch_uid='partcategory_delete_log')
def before_delete_part_category(sender, instance, using, **kwargs):
@ -332,9 +388,16 @@ class Part(MPTTModel):
context = {}
context['starred'] = self.isStarredBy(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
@ -1040,30 +1103,65 @@ class Part(MPTTModel):
return self.total_stock - self.allocation_count() + self.on_order
def isStarredBy(self, user):
""" Return True if this part has been starred by a particular user """
try:
PartStar.objects.get(part=self, user=user)
return True
except PartStar.DoesNotExist:
return False
def setStarred(self, user, starred):
def get_subscribers(self, include_variants=True, include_categories=True):
"""
Set the "starred" status of this Part for the given user
Return a list of users who are 'subscribed' to this part.
A user may 'subscribe' to this part in the following ways:
a) Subscribing to the part instance directly
b) Subscribing to a template part "above" this part (if it is a variant)
c) Subscribing to the part category that this part belongs to
d) Subscribing to a parent category of the category in c)
"""
subscribers = set()
# Start by looking at direct subscriptions to a Part model
queryset = PartStar.objects.all()
if include_variants:
queryset = queryset.filter(
part__pk__in=[part.pk for part in self.get_ancestors(include_self=True)]
)
else:
queryset = queryset.filter(part=self)
for star in queryset:
subscribers.add(star.user)
if include_categories and self.category:
for sub in self.category.get_subscribers():
subscribers.add(sub)
return [s for s in subscribers]
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_starred(self, user, status):
"""
Set the "subscription" status of this Part against the specified user
"""
if not user:
return
# Do not duplicate efforts
if self.isStarredBy(user) == starred:
# Already subscribed?
if self.is_starred_by(user) == status:
return
if starred:
if status:
PartStar.objects.create(part=self, user=user)
else:
# Note that this won't actually stop the user being subscribed,
# if the user is subscribed to a parent part or category
PartStar.objects.filter(part=self, user=user).delete()
def need_to_restock(self):
@ -1989,9 +2087,23 @@ class Part(MPTTModel):
return len(self.get_related_parts())
def is_part_low_on_stock(self):
"""
Returns True if the total stock for this part is less than the minimum stock level
"""
return self.total_stock <= self.minimum_stock
@receiver(post_save, sender=Part, dispatch_uid='part_post_save_log')
def after_save_part(sender, instance: Part, **kwargs):
"""
Function to be executed after a Part is saved
"""
# Run this check in the background
InvenTree.tasks.offload_task('part.tasks.notify_low_stock_if_required', instance)
def attach_file(instance, filename):
""" Function for storing a file for a PartAttachment
@ -2062,10 +2174,9 @@ class PartInternalPriceBreak(common.models.PriceBreak):
class PartStar(models.Model):
""" A PartStar object creates a relationship between a User and a Part.
""" A PartStar object creates a subscription relationship between a User and a Part.
It is used to designate a Part as 'starred' (or favourited) for a given User,
so that the user can track a list of their favourite parts.
It is used to designate a Part as 'subscribed' for a given User.
Attributes:
part: Link to a Part object
@ -2077,7 +2188,30 @@ class PartStar(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_('User'), related_name='starred_parts')
class Meta:
unique_together = ['part', 'user']
unique_together = [
'part',
'user'
]
class PartCategoryStar(models.Model):
"""
A PartCategoryStar creates a subscription relationship between a User and a PartCategory.
Attributes:
category: Link to a PartCategory object
user: Link to a User object
"""
category = models.ForeignKey(PartCategory, on_delete=models.CASCADE, verbose_name=_('Category'), related_name='starred_users')
user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_('User'), related_name='starred_categories')
class Meta:
unique_together = [
'category',
'user',
]
class PartTestTemplate(models.Model):

View File

@ -33,12 +33,25 @@ from .models import (BomItem, BomItemSubstitute,
class CategorySerializer(InvenTreeModelSerializer):
""" Serializer for PartCategory """
def __init__(self, *args, **kwargs):
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.context.get('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 +64,7 @@ class CategorySerializer(InvenTreeModelSerializer):
'parent',
'parts',
'pathstring',
'starred',
'url',
]
@ -241,6 +255,9 @@ class PartSerializer(InvenTreeModelSerializer):
to reduce database trips.
"""
# TODO: Update the "in_stock" annotation to include stock for variants of the part
# Ref: https://github.com/inventree/InvenTree/issues/2240
# Annotate with the total 'in stock' quantity
queryset = queryset.annotate(
in_stock=Coalesce(

View File

@ -2,34 +2,47 @@
from __future__ import unicode_literals
import logging
from datetime import timedelta
from django.utils.translation import ugettext_lazy as _
from django.template.loader import render_to_string
from allauth.account.models import EmailAddress
from common.models import InvenTree
from common.models import NotificationEntry
import InvenTree.helpers
import InvenTree.tasks
from part.models import Part
import part.models
logger = logging.getLogger("inventree")
def notify_low_stock(part: Part):
def notify_low_stock(part: part.models.Part):
"""
Notify users who have starred a part when its stock quantity falls below the minimum threshold
"""
# Check if we have notified recently...
delta = timedelta(days=1)
if NotificationEntry.check_recent('part.notify_low_stock', part.pk, delta):
logger.info(f"Low stock notification has recently been sent for '{part.full_name}' - SKIPPING")
return
logger.info(f"Sending low stock notification email for {part.full_name}")
starred_users_email = EmailAddress.objects.filter(user__starred_parts__part=part)
# Get a list of users who are subcribed to this part
subscribers = part.get_subscribers()
emails = EmailAddress.objects.filter(
user__in=subscribers,
)
# TODO: In the future, include the part image in the email template
if len(starred_users_email) > 0:
if len(emails) > 0:
logger.info(f"Notify users regarding low stock of {part.name}")
context = {
# Pass the "Part" object through to the template context
@ -39,20 +52,26 @@ def notify_low_stock(part: Part):
subject = _(f'[InvenTree] {part.name} is low on stock')
html_message = render_to_string('email/low_stock_notification.html', context)
recipients = starred_users_email.values_list('email', flat=True)
recipients = emails.values_list('email', flat=True)
InvenTree.tasks.send_email(subject, '', recipients, html_message=html_message)
NotificationEntry.notify('part.notify_low_stock', part.pk)
def notify_low_stock_if_required(part: Part):
def notify_low_stock_if_required(part: part.models.Part):
"""
Check if the stock quantity has fallen below the minimum threshold of part.
If true, notify the users who have subscribed to the part
"""
if part.is_part_low_on_stock():
InvenTree.tasks.offload_task(
'part.tasks.notify_low_stock',
part
)
# Run "up" the tree, to allow notification for "parent" parts
parts = part.get_ancestors(include_self=True, ascending=True)
for p in parts:
if p.is_part_low_on_stock():
InvenTree.tasks.offload_task(
'part.tasks.notify_low_stock',
p
)

View File

@ -20,15 +20,37 @@
{% include "admin_button.html" with url=url %}
{% endif %}
{% if category %}
{% if roles.part_category.change %}
<button class='btn btn-outline-secondary' id='cat-edit' title='{% trans "Edit part category" %}'>
<span class='fas fa-edit'/>
{% if starred_directly %}
<button type='button' class='btn btn-outline-secondary' id='toggle-starred' title='{% trans "You are subscribed to notifications for this category" %}'>
<span id='category-star-icon' class='fas fa-bell icon-green'></span>
</button>
{% elif starred %}
<button type='button' class='btn btn-outline-secondary' title='{% trans "You are subscribed to notifications for this category" %}' 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 category" %}'>
<span id='category-star-icon' class='fa fa-bell-slash'/>
</button>
{% endif %}
{% if roles.part_category.delete %}
<button class='btn btn-outline-secondary' id='cat-delete' title='{% trans "Delete part category" %}'>
<span class='fas fa-trash-alt icon-red'/>
</button>
{% if roles.part_category.change or roles.part_category.delete %}
<div class='btn-group' role='group'>
<button id='category-options' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle='dropdown' title='{% trans "Category Actions" %}'>
<span class='fas fa-tools'></span> <span class='caret'></span>
</button>
<ul class='dropdown-menu'>
{% if roles.part_category.change %}
<li><a class='dropdown-item' href='#' id='cat-edit' title='{% trans "Edit category" %}'>
<span class='fas fa-edit icon-green'></span> {% trans "Edit Category" %}
</a></li>
{% endif %}
{% if roles.part_category.delete %}
<li><a class='dropdown-item' href='#' id='cat-delete' title='{% trans "Delete category" %}'>
<span class='fas fa-trash-alt icon-red'></span> {% trans "Delete Category" %}
</a></li>
{% endif %}
</ul>
</div>
{% endif %}
{% endif %}
{% if roles.part_category.add %}
@ -198,6 +220,14 @@
data: {{ parameters|safe }},
}
);
$("#toggle-starred").click(function() {
toggleStar({
url: '{% url "api-part-category-detail" category.pk %}',
button: '#category-star-icon'
});
});
{% endif %}
enableSidebar('category');

View File

@ -35,7 +35,7 @@
<td><span class='fas fa-sitemap'></span></td>
<td>{% trans "Category" %}</td>
<td>
<a href='{% url "category-detail" part.category.pk %}'>{{ part.category }}</a>
<a href='{% url "category-detail" part.category.pk %}'>{{ part.category.name }}</a>
</td>
</tr>
{% endif %}
@ -62,7 +62,7 @@
{% endif %}
{% if part.minimum_stock %}
<tr>
<td><span class='fas fa-less-than-equal'></span></td>
<td><span class='fas fa-flag'></span></td>
<td>{% trans "Minimum stock level" %}</td>
<td>{{ part.minimum_stock }}</td>
</tr>

View File

@ -23,9 +23,19 @@
{% include "admin_button.html" with url=url %}
{% endif %}
<button type='button' class='btn btn-outline-secondary' id='toggle-starred' title='{% trans "Star this part" %}'>
<span id='part-star-icon' class='fas fa-star {% if starred %}icon-yellow{% endif %}'/>
{% 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'/>
</button>
{% endif %}
{% if barcodes %}
<!-- Barcode actions menu -->
@ -137,8 +147,6 @@
</div>
</h4>
<!-- Part info messages -->
<div class='info-messages'>
{% if part.variant_of %}
@ -164,6 +172,13 @@
<td>{% trans "In Stock" %}</td>
<td>{% include "part/stock_count.html" %}</td>
</tr>
{% if part.minimum_stock %}
<tr>
<td><span class='fas fa-flag'></span></td>
<td>{% trans "Minimum Stock" %}</td>
<td>{{ part.minimum_stock }}</td>
</tr>
{% endif %}
{% if on_order > 0 %}
<tr>
<td><span class='fas fa-shopping-cart'></span></td>
@ -310,7 +325,7 @@
$("#toggle-starred").click(function() {
toggleStar({
part: {{ part.id }},
url: '{% url "api-part-detail" part.pk %}',
button: '#part-star-icon',
});
});

View File

@ -11,7 +11,7 @@ from django.core.exceptions import ValidationError
import os
from .models import Part, PartCategory, PartTestTemplate
from .models import Part, PartCategory, PartCategoryStar, PartStar, PartTestTemplate
from .models import rename_part_image
from .templatetags import inventree_extras
@ -347,3 +347,120 @@ class PartSettingsTest(TestCase):
with self.assertRaises(ValidationError):
part = Part(name='Hello', description='A thing', IPN='IPN123', revision='C')
part.full_clean()
class PartSubscriptionTests(TestCase):
fixtures = [
'location',
'category',
'part',
]
def setUp(self):
# Create a user for auth
user = get_user_model()
self.user = user.objects.create_user(
username='testuser',
email='test@testing.com',
password='password',
is_staff=True
)
# electronics / IC / MCU
self.category = PartCategory.objects.get(pk=4)
self.part = Part.objects.create(
category=self.category,
name='STM32F103',
description='Currently worth a lot of money',
is_template=True,
)
def test_part_subcription(self):
"""
Test basic subscription against a part
"""
# First check that the user is *not* subscribed to the part
self.assertFalse(self.part.is_starred_by(self.user))
# Now, subscribe directly to the part
self.part.set_starred(self.user, True)
self.assertEqual(PartStar.objects.count(), 1)
self.assertTrue(self.part.is_starred_by(self.user))
# Now, unsubscribe
self.part.set_starred(self.user, False)
self.assertFalse(self.part.is_starred_by(self.user))
def test_variant_subscription(self):
"""
Test subscription against a parent part
"""
# Construct a sub-part to star against
sub_part = Part.objects.create(
name='sub_part',
description='a sub part',
variant_of=self.part,
)
self.assertFalse(sub_part.is_starred_by(self.user))
# Subscribe to the "parent" part
self.part.set_starred(self.user, True)
self.assertTrue(self.part.is_starred_by(self.user))
self.assertTrue(sub_part.is_starred_by(self.user))
def test_category_subscription(self):
"""
Test subscription against a PartCategory
"""
self.assertEqual(PartCategoryStar.objects.count(), 0)
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_starred(self.user, True)
self.assertEqual(PartStar.objects.count(), 0)
self.assertEqual(PartCategoryStar.objects.count(), 1)
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_starred_by(self.user))
# Un-subscribe
self.category.set_starred(self.user, False)
self.assertFalse(self.category.is_starred_by(self.user))
self.assertFalse(self.part.is_starred_by(self.user))
def test_parent_category_subscription(self):
"""
Check that a parent category can be subscribed to
"""
# Top-level "electronics" category
cat = PartCategory.objects.get(pk=1)
cat.set_starred(self.user, True)
# Check base category
self.assertTrue(cat.is_starred_by(self.user))
# Check lower level category
self.assertTrue(self.category.is_starred_by(self.user))
# Check part
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
@ -1469,18 +1470,29 @@ class CategoryDetail(InvenTreeRoleMixin, DetailView):
if category:
cascade = kwargs.get('cascade', True)
# Prefetch parts parameters
parts_parameters = category.prefetch_parts_parameters(cascade=cascade)
# Get table headers (unique parameters names)
context['headers'] = category.get_unique_parameters(cascade=cascade,
prefetch=parts_parameters)
# Insert part information
context['headers'].insert(0, 'description')
context['headers'].insert(0, 'part')
# Get parameters data
context['parameters'] = category.get_parts_parameters(cascade=cascade,
prefetch=parts_parameters)
# Insert "starred" information
context['starred'] = category.is_starred_by(self.request.user)
context['starred_directly'] = context['starred'] and category.is_starred_by(
self.request.user,
include_parents=False,
)
return context