2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-30 04:26:44 +00:00

Merge pull request #2233 from SchrodingersGat/subscription-refactor

Refactor "star" functionality for part model
This commit is contained in:
Oliver 2021-11-04 09:05:03 +11:00 committed by GitHub
commit 0e2e6211b6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 826 additions and 199 deletions

View File

@ -76,6 +76,12 @@ class InvenTreeConfig(AppConfig):
minutes=30, minutes=30,
) )
# Delete old notification records
InvenTree.tasks.schedule_task(
'common.tasks.delete_old_notifications',
schedule_type=Schedule.DAILY,
)
def update_exchange_rates(self): def update_exchange_rates(self):
""" """
Update exchange rates each time the server is started, *if*: Update exchange rates each time the server is started, *if*:

View File

@ -17,7 +17,7 @@ from company.models import Company
from part.models import Part from part.models import Part
logger = logging.getLogger("inventree-thumbnails") logger = logging.getLogger('inventree')
class Command(BaseCommand): class Command(BaseCommand):

View File

@ -180,10 +180,6 @@
float: right; float: right;
} }
.starred-part {
color: #ffbb00;
}
.red-cell { .red-cell {
background-color: #ec7f7f; background-color: #ec7f7f;
} }
@ -745,13 +741,7 @@ input[type="submit"] {
} }
.notification-area { .notification-area {
position: fixed; opacity: 0.8;
top: 0px;
margin-top: 20px;
width: 100%;
padding: 20px;
z-index: 5000;
pointer-events: none; /* Prevent this div from blocking links underneath */
} }
.notes { .notes {
@ -761,7 +751,6 @@ input[type="submit"] {
} }
.alert { .alert {
display: none;
border-radius: 5px; border-radius: 5px;
opacity: 0.9; opacity: 0.9;
pointer-events: all; pointer-events: all;
@ -771,9 +760,8 @@ input[type="submit"] {
display: block; display: block;
} }
.btn { .navbar .btn {
margin-left: 2px; margin-left: 5px;
margin-right: 2px;
} }
.btn-secondary { .btn-secondary {

View File

@ -1,44 +1,85 @@
function showAlert(target, message, timeout=5000) {
$(target).find(".alert-msg").html(message);
$(target).show();
$(target).delay(timeout).slideUp(200, function() {
$(this).alert(close);
});
}
function showAlertOrCache(alertType, message, cache, timeout=5000) { function showAlertOrCache(alertType, message, cache, timeout=5000) {
if (cache) { if (cache) {
sessionStorage.setItem("inventree-" + alertType, message); sessionStorage.setItem("inventree-" + alertType, message);
} }
else { else {
showAlert('#' + alertType, message, timeout); showMessage('#' + alertType, message, timeout);
} }
} }
/*
* Display cached alert messages when loading a page
*/
function showCachedAlerts() { function showCachedAlerts() {
// Success Message var styles = [
if (sessionStorage.getItem("inventree-alert-success")) { 'primary',
showAlert("#alert-success", sessionStorage.getItem("inventree-alert-success")); 'secondary',
sessionStorage.removeItem("inventree-alert-success"); 'success',
'info',
'warning',
'danger',
];
styles.forEach(function(style) {
var msg = sessionStorage.getItem(`inventree-alert-${style}`);
if (msg) {
showMessage(msg, {
style: style,
});
}
});
}
/*
* Display an alert message at the top of the screen.
* The message will contain a "close" button,
* and also dismiss automatically after a certain amount of time.
*
* arguments:
* - message: Text / HTML content to display
*
* options:
* - style: alert style e.g. 'success' / 'warning'
* - timeout: Time (in milliseconds) after which the message will be dismissed
*/
function showMessage(message, options={}) {
var style = options.style || 'info';
var timeout = options.timeout || 5000;
// Hacky function to get the next available ID
var id = 1;
while ($(`#alert-${id}`).exists()) {
id++;
} }
// Info Message var icon = '';
if (sessionStorage.getItem("inventree-alert-info")) {
showAlert("#alert-info", sessionStorage.getItem("inventree-alert-info")); if (options.icon) {
sessionStorage.removeItem("inventree-alert-info"); icon = `<span class='${options.icon}></span>`;
} }
// Warning Message // Construct the alert
if (sessionStorage.getItem("inventree-alert-warning")) { var html = `
showAlert("#alert-warning", sessionStorage.getItem("inventree-alert-warning")); <div id='alert-${id}' class='alert alert-${style} alert-dismissible fade show' role='alert'>
sessionStorage.removeItem("inventree-alert-warning"); ${icon}
} ${message}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`;
// Danger Message $('#alerts').append(html);
if (sessionStorage.getItem("inventree-alert-danger")) {
showAlert("#alert-danger", sessionStorage.getItem("inventree-alert-danger")); // Remove the alert automatically after a specified period of time
sessionStorage.removeItem("inventree-alert-danger"); $(`#alert-${id}`).delay(timeout).slideUp(200, function() {
} $(this).alert(close);
});
} }

View File

@ -655,17 +655,6 @@ class IndexView(TemplateView):
context = super(TemplateView, self).get_context_data(**kwargs) context = super(TemplateView, self).get_context_data(**kwargs)
# TODO - Re-implement this when a less expensive method is worked out
# context['starred'] = [star.part for star in self.request.user.starred_parts.all()]
# Generate a list of orderable parts which have stock below their minimum values
# TODO - Is there a less expensive way to get these from the database
# context['to_order'] = [part for part in Part.objects.filter(purchaseable=True) if part.need_to_restock()]
# Generate a list of assembly parts which have stock below their minimum values
# TODO - Is there a less expensive way to get these from the database
# context['to_build'] = [part for part in Part.objects.filter(assembly=True) if part.need_to_restock()]
return context return context

View File

@ -247,7 +247,9 @@
<span class='fas fa-tools'></span> <span class='caret'></span> <span class='fas fa-tools'></span> <span class='caret'></span>
</button> </button>
<ul class='dropdown-menu'> <ul class='dropdown-menu'>
<li><a class='dropdown-item' href='#' id='multi-output-complete' title='{% trans "Complete selected items" %}'><span class='fas fa-check-circle icon-green'></span> {% trans "Complete outputs" %}</a></li> <li><a class='dropdown-item' href='#' id='multi-output-complete' title='{% trans "Complete selected items" %}'>
<span class='fas fa-check-circle icon-green'></span> {% trans "Complete outputs" %}
</a></li>
</ul> </ul>
</div> </div>
</div> </div>

View File

@ -5,7 +5,7 @@ from django.contrib import admin
from import_export.admin import ImportExportModelAdmin from import_export.admin import ImportExportModelAdmin
from .models import InvenTreeSetting, InvenTreeUserSetting import common.models
class SettingsAdmin(ImportExportModelAdmin): class SettingsAdmin(ImportExportModelAdmin):
@ -18,5 +18,11 @@ class UserSettingsAdmin(ImportExportModelAdmin):
list_display = ('key', 'value', 'user', ) list_display = ('key', 'value', 'user', )
admin.site.register(InvenTreeSetting, SettingsAdmin) class NotificationEntryAdmin(admin.ModelAdmin):
admin.site.register(InvenTreeUserSetting, UserSettingsAdmin)
list_display = ('key', 'uid', 'updated', )
admin.site.register(common.models.InvenTreeSetting, SettingsAdmin)
admin.site.register(common.models.InvenTreeUserSetting, UserSettingsAdmin)
admin.site.register(common.models.NotificationEntry, NotificationEntryAdmin)

View File

@ -0,0 +1,25 @@
# Generated by Django 3.2.5 on 2021-11-03 13:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('common', '0011_auto_20210722_2114'),
]
operations = [
migrations.CreateModel(
name='NotificationEntry',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('key', models.CharField(max_length=250)),
('uid', models.IntegerField()),
('updated', models.DateTimeField(auto_now=True)),
],
options={
'unique_together': {('key', 'uid')},
},
),
]

View File

@ -9,6 +9,7 @@ from __future__ import unicode_literals
import os import os
import decimal import decimal
import math import math
from datetime import datetime, timedelta
from django.db import models, transaction from django.db import models, transaction
from django.contrib.auth.models import User, Group from django.contrib.auth.models import User, Group
@ -874,8 +875,14 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
GLOBAL_SETTINGS = { GLOBAL_SETTINGS = {
'HOMEPAGE_PART_STARRED': { 'HOMEPAGE_PART_STARRED': {
'name': _('Show starred parts'), 'name': _('Show subscribed parts'),
'description': _('Show starred parts on the homepage'), 'description': _('Show subscribed parts on the homepage'),
'default': True,
'validator': bool,
},
'HOMEPAGE_CATEGORY_STARRED': {
'name': _('Show subscribed categories'),
'description': _('Show subscribed part categories on the homepage'),
'default': True, 'default': True,
'validator': bool, 'validator': bool,
}, },
@ -1220,3 +1227,63 @@ class ColorTheme(models.Model):
return True return True
return False return False
class NotificationEntry(models.Model):
"""
A NotificationEntry records the last time a particular notifaction was sent out.
It is recorded to ensure that notifications are not sent out "too often" to users.
Attributes:
- key: A text entry describing the notification e.g. 'part.notify_low_stock'
- uid: An (optional) numerical ID for a particular instance
- date: The last time this notification was sent
"""
class Meta:
unique_together = [
('key', 'uid'),
]
key = models.CharField(
max_length=250,
blank=False,
)
uid = models.IntegerField(
)
updated = models.DateTimeField(
auto_now=True,
null=False,
)
@classmethod
def check_recent(cls, key: str, uid: int, delta: timedelta):
"""
Test if a particular notification has been sent in the specified time period
"""
since = datetime.now().date() - delta
entries = cls.objects.filter(
key=key,
uid=uid,
updated__gte=since
)
return entries.exists()
@classmethod
def notify(cls, key: str, uid: int):
"""
Notify the database that a particular notification has been sent out
"""
entry, created = cls.objects.get_or_create(
key=key,
uid=uid
)
entry.save()

29
InvenTree/common/tasks.py Normal file
View File

@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import logging
from datetime import timedelta, datetime
from django.core.exceptions import AppRegistryNotReady
logger = logging.getLogger('inventree')
def delete_old_notifications():
"""
Remove old notifications from the database.
Anything older than ~3 months is removed
"""
try:
from common.models import NotificationEntry
except AppRegistryNotReady:
logger.info("Could not perform 'delete_old_notifications' - App registry not ready")
return
before = datetime.now() - timedelta(days=90)
# Delete notification records before the specified date
NotificationEntry.objects.filter(updated__lte=before).delete()

View File

@ -1,10 +1,13 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
from datetime import timedelta
from django.test import TestCase from django.test import TestCase
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from .models import InvenTreeSetting from .models import InvenTreeSetting
from .models import NotificationEntry
class SettingsTest(TestCase): class SettingsTest(TestCase):
@ -85,3 +88,23 @@ class SettingsTest(TestCase):
if setting.default_value not in [True, False]: if setting.default_value not in [True, False]:
raise ValueError(f'Non-boolean default value specified for {key}') raise ValueError(f'Non-boolean default value specified for {key}')
class NotificationTest(TestCase):
def test_check_notification_entries(self):
# Create some notification entries
self.assertEqual(NotificationEntry.objects.count(), 0)
NotificationEntry.notify('test.notification', 1)
self.assertEqual(NotificationEntry.objects.count(), 1)
delta = timedelta(days=1)
self.assertFalse(NotificationEntry.check_recent('test.notification', 2, delta))
self.assertFalse(NotificationEntry.check_recent('test.notification2', 1, delta))
self.assertTrue(NotificationEntry.check_recent('test.notification', 1, delta))

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,18 @@ 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()
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): def filter_queryset(self, queryset):
""" """
Custom filtering: Custom filtering:
@ -110,6 +122,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 +173,29 @@ 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()
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): class CategoryParameterList(generics.ListAPIView):
""" API endpoint for accessing a list of PartCategoryParameterTemplate objects. """ API endpoint for accessing a list of PartCategoryParameterTemplate objects.
@ -389,7 +436,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()]
@ -418,9 +465,9 @@ 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', False))
self.get_object().setStarred(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

@ -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.core.validators import MinValueValidator
from django.contrib.auth.models import User 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 django.dispatch import receiver
from jinja2 import Template from jinja2 import Template
@ -47,6 +47,7 @@ from InvenTree import validators
from InvenTree.models import InvenTreeTree, InvenTreeAttachment from InvenTree.models import InvenTreeTree, InvenTreeAttachment
from InvenTree.fields import InvenTreeURLField from InvenTree.fields import InvenTreeURLField
from InvenTree.helpers import decimal2string, normalize, decimal2money from InvenTree.helpers import decimal2string, normalize, decimal2money
import InvenTree.tasks
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus, SalesOrderStatus from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus, SalesOrderStatus
@ -56,6 +57,7 @@ from company.models import SupplierPart
from stock import models as StockModels from stock import models as StockModels
import common.models import common.models
import part.settings as part_settings import part.settings as part_settings
@ -102,11 +104,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):
@ -201,6 +203,60 @@ class PartCategory(InvenTreeTree):
return prefetch.filter(category=self.id) 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') @receiver(pre_delete, sender=PartCategory, dispatch_uid='partcategory_delete_log')
def before_delete_part_category(sender, instance, using, **kwargs): def before_delete_part_category(sender, instance, using, **kwargs):
@ -332,9 +388,16 @@ class Part(MPTTModel):
context = {} context = {}
context['starred'] = self.isStarredBy(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
@ -1040,30 +1103,65 @@ class Part(MPTTModel):
return self.total_stock - self.allocation_count() + self.on_order return self.total_stock - self.allocation_count() + self.on_order
def isStarredBy(self, user): def get_subscribers(self, include_variants=True, include_categories=True):
""" 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):
""" """
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: if not user:
return return
# Do not duplicate efforts # Already subscribed?
if self.isStarredBy(user) == starred: if self.is_starred_by(user) == status:
return return
if starred: if status:
PartStar.objects.create(part=self, user=user) PartStar.objects.create(part=self, user=user)
else: 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() PartStar.objects.filter(part=self, user=user).delete()
def need_to_restock(self): def need_to_restock(self):
@ -1989,9 +2087,23 @@ class Part(MPTTModel):
return len(self.get_related_parts()) return len(self.get_related_parts())
def is_part_low_on_stock(self): 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 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): def attach_file(instance, filename):
""" Function for storing a file for a PartAttachment """ Function for storing a file for a PartAttachment
@ -2062,10 +2174,9 @@ class PartInternalPriceBreak(common.models.PriceBreak):
class PartStar(models.Model): 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, It is used to designate a Part as 'subscribed' for a given User.
so that the user can track a list of their favourite parts.
Attributes: Attributes:
part: Link to a Part object 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') user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_('User'), related_name='starred_parts')
class Meta: 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): class PartTestTemplate(models.Model):

View File

@ -33,12 +33,25 @@ from .models import (BomItem, BomItemSubstitute,
class CategorySerializer(InvenTreeModelSerializer): class CategorySerializer(InvenTreeModelSerializer):
""" Serializer for PartCategory """ """ 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) 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 +64,7 @@ class CategorySerializer(InvenTreeModelSerializer):
'parent', 'parent',
'parts', 'parts',
'pathstring', 'pathstring',
'starred',
'url', 'url',
] ]
@ -241,6 +255,9 @@ class PartSerializer(InvenTreeModelSerializer):
to reduce database trips. 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 # Annotate with the total 'in stock' quantity
queryset = queryset.annotate( queryset = queryset.annotate(
in_stock=Coalesce( in_stock=Coalesce(

View File

@ -2,34 +2,47 @@
from __future__ import unicode_literals from __future__ import unicode_literals
import logging import logging
from datetime import timedelta
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.template.loader import render_to_string from django.template.loader import render_to_string
from allauth.account.models import EmailAddress from allauth.account.models import EmailAddress
from common.models import InvenTree from common.models import NotificationEntry
import InvenTree.helpers import InvenTree.helpers
import InvenTree.tasks import InvenTree.tasks
from part.models import Part import part.models
logger = logging.getLogger("inventree") 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 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}") 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 # 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}") logger.info(f"Notify users regarding low stock of {part.name}")
context = { context = {
# Pass the "Part" object through to the template 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') subject = _(f'[InvenTree] {part.name} is low on stock')
html_message = render_to_string('email/low_stock_notification.html', context) 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) 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. 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 true, notify the users who have subscribed to the part
""" """
if part.is_part_low_on_stock(): # Run "up" the tree, to allow notification for "parent" parts
InvenTree.tasks.offload_task( parts = part.get_ancestors(include_self=True, ascending=True)
'part.tasks.notify_low_stock',
part 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 %} {% include "admin_button.html" with url=url %}
{% endif %} {% endif %}
{% if category %} {% if category %}
{% if roles.part_category.change %} {% if starred_directly %}
<button class='btn btn-outline-secondary' id='cat-edit' title='{% trans "Edit part category" %}'> <button type='button' class='btn btn-outline-secondary' id='toggle-starred' title='{% trans "You are subscribed to notifications for this category" %}'>
<span class='fas fa-edit'/> <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> </button>
{% endif %} {% endif %}
{% if roles.part_category.delete %} {% if roles.part_category.change or roles.part_category.delete %}
<button class='btn btn-outline-secondary' id='cat-delete' title='{% trans "Delete part category" %}'> <div class='btn-group' role='group'>
<span class='fas fa-trash-alt icon-red'/> <button id='category-options' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle='dropdown' title='{% trans "Category Actions" %}'>
</button> <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 %}
{% endif %} {% endif %}
{% if roles.part_category.add %} {% if roles.part_category.add %}
@ -198,6 +220,14 @@
data: {{ parameters|safe }}, data: {{ parameters|safe }},
} }
); );
$("#toggle-starred").click(function() {
toggleStar({
url: '{% url "api-part-category-detail" category.pk %}',
button: '#category-star-icon'
});
});
{% endif %} {% endif %}
enableSidebar('category'); enableSidebar('category');

View File

@ -35,7 +35,7 @@
<td><span class='fas fa-sitemap'></span></td> <td><span class='fas fa-sitemap'></span></td>
<td>{% trans "Category" %}</td> <td>{% trans "Category" %}</td>
<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> </td>
</tr> </tr>
{% endif %} {% endif %}
@ -62,7 +62,7 @@
{% endif %} {% endif %}
{% if part.minimum_stock %} {% if part.minimum_stock %}
<tr> <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>{% trans "Minimum stock level" %}</td>
<td>{{ part.minimum_stock }}</td> <td>{{ part.minimum_stock }}</td>
</tr> </tr>

View File

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

View File

@ -11,7 +11,7 @@ from django.core.exceptions import ValidationError
import os import os
from .models import Part, PartCategory, PartTestTemplate from .models import Part, PartCategory, PartCategoryStar, PartStar, PartTestTemplate
from .models import rename_part_image from .models import rename_part_image
from .templatetags import inventree_extras from .templatetags import inventree_extras
@ -347,3 +347,120 @@ class PartSettingsTest(TestCase):
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
part = Part(name='Hello', description='A thing', IPN='IPN123', revision='C') part = Part(name='Hello', description='A thing', IPN='IPN123', revision='C')
part.full_clean() 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() 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
@ -1469,18 +1470,29 @@ class CategoryDetail(InvenTreeRoleMixin, DetailView):
if category: if category:
cascade = kwargs.get('cascade', True) cascade = kwargs.get('cascade', True)
# Prefetch parts parameters # Prefetch parts parameters
parts_parameters = category.prefetch_parts_parameters(cascade=cascade) parts_parameters = category.prefetch_parts_parameters(cascade=cascade)
# Get table headers (unique parameters names) # Get table headers (unique parameters names)
context['headers'] = category.get_unique_parameters(cascade=cascade, context['headers'] = category.get_unique_parameters(cascade=cascade,
prefetch=parts_parameters) prefetch=parts_parameters)
# Insert part information # Insert part information
context['headers'].insert(0, 'description') context['headers'].insert(0, 'description')
context['headers'].insert(0, 'part') context['headers'].insert(0, 'part')
# Get parameters data # Get parameters data
context['parameters'] = category.get_parts_parameters(cascade=cascade, context['parameters'] = category.get_parts_parameters(cascade=cascade,
prefetch=parts_parameters) 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 return context

View File

@ -27,7 +27,9 @@ from mptt.managers import TreeManager
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from datetime import datetime, timedelta from datetime import datetime, timedelta
from InvenTree import helpers from InvenTree import helpers
import InvenTree.tasks
import common.models import common.models
import report.models import report.models
@ -41,7 +43,6 @@ from users.models import Owner
from company import models as CompanyModels from company import models as CompanyModels
from part import models as PartModels from part import models as PartModels
from part import tasks as part_tasks
class StockLocation(InvenTreeTree): class StockLocation(InvenTreeTree):
@ -1658,16 +1659,18 @@ def after_delete_stock_item(sender, instance: StockItem, **kwargs):
Function to be executed after a StockItem object is deleted Function to be executed after a StockItem object is deleted
""" """
part_tasks.notify_low_stock_if_required(instance.part) # Run this check in the background
InvenTree.tasks.offload_task('part.tasks.notify_low_stock_if_required', instance.part)
@receiver(post_save, sender=StockItem, dispatch_uid='stock_item_post_save_log') @receiver(post_save, sender=StockItem, dispatch_uid='stock_item_post_save_log')
def after_save_stock_item(sender, instance: StockItem, **kwargs): def after_save_stock_item(sender, instance: StockItem, **kwargs):
""" """
Hook function to be executed after StockItem object is saved/updated Hook function to be executed after StockItem object is saved/updated
""" """
part_tasks.notify_low_stock_if_required(instance.part) # Run this check in the background
InvenTree.tasks.offload_task('part.tasks.notify_low_stock_if_required', instance.part)
class StockItemAttachment(InvenTreeAttachment): class StockItemAttachment(InvenTreeAttachment):

View File

@ -76,6 +76,7 @@ function addHeaderAction(label, title, icon, options) {
} }
{% settings_value 'HOMEPAGE_PART_STARRED' user=request.user as setting_part_starred %} {% settings_value 'HOMEPAGE_PART_STARRED' user=request.user as setting_part_starred %}
{% settings_value 'HOMEPAGE_CATEGORY_STARRED' user=request.user as setting_category_starred %}
{% settings_value 'HOMEPAGE_PART_LATEST' user=request.user as setting_part_latest %} {% settings_value 'HOMEPAGE_PART_LATEST' user=request.user as setting_part_latest %}
{% settings_value 'HOMEPAGE_BOM_VALIDATION' user=request.user as setting_bom_validation %} {% settings_value 'HOMEPAGE_BOM_VALIDATION' user=request.user as setting_bom_validation %}
{% to_list setting_part_starred setting_part_latest setting_bom_validation as settings_list_part %} {% to_list setting_part_starred setting_part_latest setting_bom_validation as settings_list_part %}
@ -84,15 +85,25 @@ function addHeaderAction(label, title, icon, options) {
addHeaderTitle('{% trans "Parts" %}'); addHeaderTitle('{% trans "Parts" %}');
{% if setting_part_starred %} {% if setting_part_starred %}
addHeaderAction('starred-parts', '{% trans "Starred Parts" %}', 'fa-star'); addHeaderAction('starred-parts', '{% trans "Subscribed Parts" %}', 'fa-bell');
loadSimplePartTable("#table-starred-parts", "{% url 'api-part-list' %}", { loadSimplePartTable("#table-starred-parts", "{% url 'api-part-list' %}", {
params: { params: {
"starred": true, starred: true,
}, },
name: 'starred_parts', name: 'starred_parts',
}); });
{% endif %} {% endif %}
{% if setting_category_starred %}
addHeaderAction('starred-categories', '{% trans "Subscribed Categories" %}', 'fa-bell');
loadPartCategoryTable($('#table-starred-categories'), {
params: {
starred: true,
},
name: 'starred_categories'
});
{% endif %}
{% if setting_part_latest %} {% if setting_part_latest %}
addHeaderAction('latest-parts', '{% trans "Latest Parts" %}', 'fa-newspaper'); addHeaderAction('latest-parts', '{% trans "Latest Parts" %}', 'fa-newspaper');
loadSimplePartTable("#table-latest-parts", "{% url 'api-part-list' %}", { loadSimplePartTable("#table-latest-parts", "{% url 'api-part-list' %}", {
@ -128,8 +139,7 @@ loadSimplePartTable("#table-bom-validation", "{% url 'api-part-list' %}", {
{% to_list setting_stock_recent setting_stock_low setting_stock_depleted setting_stock_needed as settings_list_stock %} {% to_list setting_stock_recent setting_stock_low setting_stock_depleted setting_stock_needed as settings_list_stock %}
{% endif %} {% endif %}
{% if roles.stock.view and True in settings_list_stock %} {% if roles.stock.view %}
addHeaderTitle('{% trans "Stock" %}');
{% if setting_stock_recent %} {% if setting_stock_recent %}
addHeaderAction('recently-updated-stock', '{% trans "Recently Updated" %}', 'fa-clock'); addHeaderAction('recently-updated-stock', '{% trans "Recently Updated" %}', 'fa-clock');
@ -145,7 +155,7 @@ loadStockTable($('#table-recently-updated-stock'), {
{% endif %} {% endif %}
{% if setting_stock_low %} {% if setting_stock_low %}
addHeaderAction('low-stock', '{% trans "Low Stock" %}', 'fa-shopping-cart'); addHeaderAction('low-stock', '{% trans "Low Stock" %}', 'fa-flag');
loadSimplePartTable("#table-low-stock", "{% url 'api-part-list' %}", { loadSimplePartTable("#table-low-stock", "{% url 'api-part-list' %}", {
params: { params: {
low_stock: true, low_stock: true,

View File

@ -14,7 +14,8 @@
<div class='row'> <div class='row'>
<table class='table table-striped table-condensed'> <table class='table table-striped table-condensed'>
<tbody> <tbody>
{% include "InvenTree/settings/setting.html" with key="HOMEPAGE_PART_STARRED" icon='fa-star' user_setting=True %} {% include "InvenTree/settings/setting.html" with key="HOMEPAGE_PART_STARRED" icon='fa-bell' user_setting=True %}
{% include "InvenTree/settings/setting.html" with key="HOMEPAGE_CATEGORY_STARRED" icon='fa-bell' user_setting=True %}
{% include "InvenTree/settings/setting.html" with key="HOMEPAGE_PART_LATEST" icon='fa-history' user_setting=True %} {% include "InvenTree/settings/setting.html" with key="HOMEPAGE_PART_LATEST" icon='fa-history' user_setting=True %}
{% include "InvenTree/settings/setting.html" with key="PART_RECENT_COUNT" icon="fa-clock" user_setting=True %} {% include "InvenTree/settings/setting.html" with key="PART_RECENT_COUNT" icon="fa-clock" user_setting=True %}
{% include "InvenTree/settings/setting.html" with key="HOMEPAGE_BOM_VALIDATION" user_setting=True %} {% include "InvenTree/settings/setting.html" with key="HOMEPAGE_BOM_VALIDATION" user_setting=True %}

View File

@ -84,6 +84,13 @@
</div> </div>
</div> </div>
<main class='col ps-md-2 pt-2'> <main class='col ps-md-2 pt-2'>
{% block alerts %}
<div class='notification-area' id='alerts'>
<!-- Div for displayed alerts -->
</div>
{% endblock %}
{% block breadcrumb_list %} {% block breadcrumb_list %}
<div class='container-fluid navigation'> <div class='container-fluid navigation'>
<nav aria-label='breadcrumb'> <nav aria-label='breadcrumb'>
@ -102,7 +109,6 @@
</div> </div>
{% include 'modals.html' %} {% include 'modals.html' %}
{% include 'about.html' %} {% include 'about.html' %}
{% include 'notification.html' %}
</div> </div>
<!-- Scripts --> <!-- Scripts -->

View File

@ -17,13 +17,15 @@
{% block body %} {% block body %}
<tr style="height: 3rem; border-bottom: 1px solid"> <tr style="height: 3rem; border-bottom: 1px solid">
<th>{% trans "Part Name" %}</th> <th>{% trans "Part Name" %}</th>
<th>{% trans "Available Quantity" %}</th> <th>{% trans "Total Stock" %}</th>
<th>{% trans "Available" %}</th>
<th>{% trans "Minimum Quantity" %}</th> <th>{% trans "Minimum Quantity" %}</th>
</tr> </tr>
<tr style="height: 3rem"> <tr style="height: 3rem">
<td style="text-align: center;">{{ part.full_name }}</td> <td style="text-align: center;">{{ part.full_name }}</td>
<td style="text-align: center;">{{ part.total_stock }}</td> <td style="text-align: center;">{{ part.total_stock }}</td>
<td style="text-align: center;">{{ part.available_stock }}</td>
<td style="text-align: center;">{{ part.minimum_stock }}</td> <td style="text-align: center;">{{ part.minimum_stock }}</td>
</tr> </tr>
{% endblock %} {% endblock %}

View File

@ -169,7 +169,12 @@ function inventreeDocReady() {
html += '</span>'; html += '</span>';
if (user_settings.SEARCH_SHOW_STOCK_LEVELS) { if (user_settings.SEARCH_SHOW_STOCK_LEVELS) {
html += partStockLabel(item.data); html += partStockLabel(
item.data,
{
classes: 'badge-right',
}
);
} }
html += '</a>'; html += '</a>';

View File

@ -373,24 +373,23 @@ function duplicatePart(pk, options={}) {
} }
/* Toggle the 'starred' status of a part.
* Performs AJAX queries and updates the display on the button.
*
* options:
* - button: ID of the button (default = '#part-star-icon')
* - URL: API url of the object
* - user: pk of the user
*/
function toggleStar(options) { function toggleStar(options) {
/* Toggle the 'starred' status of a part.
* Performs AJAX queries and updates the display on the button.
*
* options:
* - button: ID of the button (default = '#part-star-icon')
* - part: pk of the part object
* - user: pk of the user
*/
var url = `/api/part/${options.part}/`; inventreeGet(options.url, {}, {
inventreeGet(url, {}, {
success: function(response) { success: function(response) {
var starred = response.starred; var starred = response.starred;
inventreePut( inventreePut(
url, options.url,
{ {
starred: !starred, starred: !starred,
}, },
@ -398,9 +397,19 @@ function toggleStar(options) {
method: 'PATCH', method: 'PATCH',
success: function(response) { success: function(response) {
if (response.starred) { if (response.starred) {
$(options.button).addClass('icon-yellow'); $(options.button).removeClass('fa fa-bell-slash').addClass('fas fa-bell icon-green');
$(options.button).attr('title', '{% trans "You are subscribed to notifications for this item" %}');
showMessage('{% trans "You have subscribed to notifications for this item" %}', {
style: 'success',
});
} else { } else {
$(options.button).removeClass('icon-yellow'); $(options.button).removeClass('fas fa-bell icon-green').addClass('fa fa-bell-slash');
$(options.button).attr('title', '{% trans "Subscribe to notifications for this item" %}');
showMessage('{% trans "You have unsubscribed to notifications for this item" %}', {
style: 'warning',
});
} }
} }
} }
@ -410,12 +419,12 @@ function toggleStar(options) {
} }
function partStockLabel(part) { function partStockLabel(part, options={}) {
if (part.in_stock) { if (part.in_stock) {
return `<span class='badge rounded-pill bg-success'>{% trans "Stock" %}: ${part.in_stock}</span>`; return `<span class='badge rounded-pill bg-success ${options.classes}'>{% trans "Stock" %}: ${part.in_stock}</span>`;
} else { } else {
return `<span class='badge rounded-pill bg-danger'>{% trans "No Stock" %}</span>`; return `<span class='badge rounded-pill bg-danger ${options.classes}'>{% trans "No Stock" %}</span>`;
} }
} }
@ -443,7 +452,7 @@ function makePartIcons(part) {
} }
if (part.starred) { if (part.starred) {
html += makeIconBadge('fa-star', '{% trans "Starred part" %}'); html += makeIconBadge('fa-bell icon-green', '{% trans "Subscribed part" %}');
} }
if (part.salable) { if (part.salable) {
@ -451,7 +460,7 @@ function makePartIcons(part) {
} }
if (!part.active) { if (!part.active) {
html += `<span class='badge badge-right rounded-pill bg-warning'>{% trans "Inactive" %}</span>`; html += `<span class='badge badge-right rounded-pill bg-warning'>{% trans "Inactive" %}</span> `;
} }
return html; return html;
@ -1258,10 +1267,17 @@ function loadPartCategoryTable(table, options) {
switchable: true, switchable: true,
sortable: true, sortable: true,
formatter: function(value, row) { formatter: function(value, row) {
return renderLink(
var html = renderLink(
value, value,
`/part/category/${row.pk}/` `/part/category/${row.pk}/`
); );
if (row.starred) {
html += makeIconBadge('fa-bell icon-green', '{% trans "Subscribed category" %}');
}
return html;
} }
}, },
{ {

View File

@ -103,6 +103,10 @@ function getAvailableTableFilters(tableKey) {
title: '{% trans "Include subcategories" %}', title: '{% trans "Include subcategories" %}',
description: '{% trans "Include subcategories" %}', description: '{% trans "Include subcategories" %}',
}, },
starred: {
type: 'bool',
title: '{% trans "Subscribed" %}',
},
}; };
} }
@ -368,7 +372,7 @@ function getAvailableTableFilters(tableKey) {
}, },
starred: { starred: {
type: 'bool', type: 'bool',
title: '{% trans "Starred" %}', title: '{% trans "Subscribed" %}',
}, },
salable: { salable: {
type: 'bool', type: 'bool',

View File

@ -1,18 +0,0 @@
<div class='notification-area'>
<div class="alert alert-success alert-dismissable" id="alert-success">
<a href="#" class="close" data-bs-dismiss="alert" aria-label="close">&times;</a>
<div class='alert-msg'>Success alert</div>
</div>
<div class='alert alert-info alert-dismissable' id='alert-info'>
<a href="#" class="close" data-bs-dismiss="alert" aria-label="close">&times;</a>
<div class='alert-msg'>Info alert</div>
</div>
<div class='alert alert-warning alert-dismissable' id='alert-warning'>
<a href="#" class="close" data-bs-dismiss="alert" aria-label="close">&times;</a>
<div class='alert-msg'>Warning alert</div>
</div>
<div class='alert alert-danger alert-dismissable' id='alert-danger'>
<a href="#" class="close" data-bs-dismiss="alert" aria-label="close">&times;</a>
<div class='alert-msg'>Danger alert</div>
</div>
</div>

View File

@ -77,6 +77,7 @@ class RuleSet(models.Model):
'part_category': [ 'part_category': [
'part_partcategory', 'part_partcategory',
'part_partcategoryparametertemplate', 'part_partcategoryparametertemplate',
'part_partcategorystar',
], ],
'part': [ 'part': [
'part_part', 'part_part',
@ -90,6 +91,7 @@ class RuleSet(models.Model):
'part_partparameter', 'part_partparameter',
'part_partrelated', 'part_partrelated',
'part_partstar', 'part_partstar',
'part_partcategorystar',
'company_supplierpart', 'company_supplierpart',
'company_manufacturerpart', 'company_manufacturerpart',
'company_manufacturerpartparameter', 'company_manufacturerpartparameter',
@ -149,6 +151,7 @@ class RuleSet(models.Model):
'common_colortheme', 'common_colortheme',
'common_inventreesetting', 'common_inventreesetting',
'common_inventreeusersetting', 'common_inventreeusersetting',
'common_notificationentry',
'company_contact', 'company_contact',
'users_owner', 'users_owner',

View File

@ -286,6 +286,7 @@ def content_excludes():
"users.owner", "users.owner",
"exchange.rate", "exchange.rate",
"exchange.exchangebackend", "exchange.exchangebackend",
"common.notificationentry",
] ]
output = "" output = ""