2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-08-10 13:51:01 +00:00

Merge remote-tracking branch 'inventree/master' into partial-shipment

# Conflicts:
#	InvenTree/InvenTree/version.py
#	InvenTree/order/models.py
This commit is contained in:
Oliver
2021-11-11 12:35:59 +11:00
120 changed files with 3551 additions and 1760 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

@@ -7,7 +7,7 @@ from collections import OrderedDict
from django.utils.translation import gettext as _
from InvenTree.helpers import DownloadFile, GetExportFormats
from InvenTree.helpers import DownloadFile, GetExportFormats, normalize
from .admin import BomItemResource
from .models import BomItem
@@ -59,7 +59,7 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
uids = []
def add_items(items, level):
def add_items(items, level, cascade):
# Add items at a given layer
for item in items:
@@ -71,21 +71,13 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
bom_items.append(item)
if item.sub_part.assembly:
if cascade and item.sub_part.assembly:
if max_levels is None or level < max_levels:
add_items(item.sub_part.bom_items.all().order_by('id'), level + 1)
if cascade:
# Cascading (multi-level) BOM
top_level_items = part.get_bom_items().order_by('id')
# Start with the top level
items_to_process = part.bom_items.all().order_by('id')
add_items(items_to_process, 1)
else:
# No cascading needed - just the top-level items
bom_items = [item for item in part.bom_items.all().order_by('id')]
add_items(top_level_items, 1, cascade)
dataset = BomItemResource().export(queryset=bom_items, cascade=cascade)
@@ -148,8 +140,9 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
stock_data.append('')
except AttributeError:
stock_data.append('')
# Get part current stock
stock_data.append(str(bom_item.sub_part.available_stock))
stock_data.append(str(normalize(bom_item.sub_part.available_stock)))
for s_idx, header in enumerate(stock_headers):
try:
@@ -160,171 +153,108 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
# Add stock columns to dataset
add_columns_to_dataset(stock_cols, len(bom_items))
if manufacturer_data and supplier_data:
if manufacturer_data or supplier_data:
"""
If requested, add extra columns for each SupplierPart and ManufacturerPart associated with each line item
"""
# Expand dataset with manufacturer parts
manufacturer_headers = [
_('Manufacturer'),
_('MPN'),
]
supplier_headers = [
_('Supplier'),
_('SKU'),
]
# Keep track of the supplier parts we have already exported
supplier_parts_used = set()
manufacturer_cols = {}
for b_idx, bom_item in enumerate(bom_items):
for bom_idx, bom_item in enumerate(bom_items):
# Get part instance
b_part = bom_item.sub_part
# Filter manufacturer parts
manufacturer_parts = ManufacturerPart.objects.filter(part__pk=b_part.pk)
manufacturer_parts = manufacturer_parts.prefetch_related('supplier_parts')
# Include manufacturer data for each BOM item
if manufacturer_data:
# Process manufacturer part
for manufacturer_idx, manufacturer_part in enumerate(manufacturer_parts):
# Filter manufacturer parts
manufacturer_parts = ManufacturerPart.objects.filter(part__pk=b_part.pk).prefetch_related('supplier_parts')
for mp_idx, mp_part in enumerate(manufacturer_parts):
if manufacturer_part and manufacturer_part.manufacturer:
manufacturer_name = manufacturer_part.manufacturer.name
else:
manufacturer_name = ''
# Extract the "name" field of the Manufacturer (Company)
if mp_part and mp_part.manufacturer:
manufacturer_name = mp_part.manufacturer.name
else:
manufacturer_name = ''
if manufacturer_part:
manufacturer_mpn = manufacturer_part.MPN
else:
manufacturer_mpn = ''
# Extract the "MPN" field from the Manufacturer Part
if mp_part:
manufacturer_mpn = mp_part.MPN
else:
manufacturer_mpn = ''
# Generate column names for this manufacturer
k_man = manufacturer_headers[0] + "_" + str(manufacturer_idx)
k_mpn = manufacturer_headers[1] + "_" + str(manufacturer_idx)
# Generate a column name for this manufacturer
k_man = f'{_("Manufacturer")}_{mp_idx}'
k_mpn = f'{_("MPN")}_{mp_idx}'
try:
manufacturer_cols[k_man].update({bom_idx: manufacturer_name})
manufacturer_cols[k_mpn].update({bom_idx: manufacturer_mpn})
except KeyError:
manufacturer_cols[k_man] = {bom_idx: manufacturer_name}
manufacturer_cols[k_mpn] = {bom_idx: manufacturer_mpn}
try:
manufacturer_cols[k_man].update({b_idx: manufacturer_name})
manufacturer_cols[k_mpn].update({b_idx: manufacturer_mpn})
except KeyError:
manufacturer_cols[k_man] = {b_idx: manufacturer_name}
manufacturer_cols[k_mpn] = {b_idx: manufacturer_mpn}
# We wish to include supplier data for this manufacturer part
if supplier_data:
for sp_idx, sp_part in enumerate(mp_part.supplier_parts.all()):
# Process supplier parts
for supplier_idx, supplier_part in enumerate(manufacturer_part.supplier_parts.all()):
supplier_parts_used.add(sp_part)
if supplier_part.supplier and supplier_part.supplier:
supplier_name = supplier_part.supplier.name
if sp_part.supplier and sp_part.supplier:
supplier_name = sp_part.supplier.name
else:
supplier_name = ''
if sp_part:
supplier_sku = sp_part.SKU
else:
supplier_sku = ''
# Generate column names for this supplier
k_sup = str(_("Supplier")) + "_" + str(mp_idx) + "_" + str(sp_idx)
k_sku = str(_("SKU")) + "_" + str(mp_idx) + "_" + str(sp_idx)
try:
manufacturer_cols[k_sup].update({bom_idx: supplier_name})
manufacturer_cols[k_sku].update({bom_idx: supplier_sku})
except KeyError:
manufacturer_cols[k_sup] = {bom_idx: supplier_name}
manufacturer_cols[k_sku] = {bom_idx: supplier_sku}
if supplier_data:
# Add in any extra supplier parts, which are not associated with a manufacturer part
for sp_idx, sp_part in enumerate(SupplierPart.objects.filter(part__pk=b_part.pk)):
if sp_part in supplier_parts_used:
continue
supplier_parts_used.add(sp_part)
if sp_part.supplier:
supplier_name = sp_part.supplier.name
else:
supplier_name = ''
if supplier_part:
supplier_sku = supplier_part.SKU
else:
supplier_sku = ''
supplier_sku = sp_part.SKU
# Generate column names for this supplier
k_sup = str(supplier_headers[0]) + "_" + str(manufacturer_idx) + "_" + str(supplier_idx)
k_sku = str(supplier_headers[1]) + "_" + str(manufacturer_idx) + "_" + str(supplier_idx)
k_sup = str(_("Supplier")) + "_" + str(sp_idx)
k_sku = str(_("SKU")) + "_" + str(sp_idx)
try:
manufacturer_cols[k_sup].update({b_idx: supplier_name})
manufacturer_cols[k_sku].update({b_idx: supplier_sku})
manufacturer_cols[k_sup].update({bom_idx: supplier_name})
manufacturer_cols[k_sku].update({bom_idx: supplier_sku})
except KeyError:
manufacturer_cols[k_sup] = {b_idx: supplier_name}
manufacturer_cols[k_sku] = {b_idx: supplier_sku}
manufacturer_cols[k_sup] = {bom_idx: supplier_name}
manufacturer_cols[k_sku] = {bom_idx: supplier_sku}
# Add manufacturer columns to dataset
add_columns_to_dataset(manufacturer_cols, len(bom_items))
elif manufacturer_data:
"""
If requested, add extra columns for each ManufacturerPart associated with each line item
"""
# Expand dataset with manufacturer parts
manufacturer_headers = [
_('Manufacturer'),
_('MPN'),
]
manufacturer_cols = {}
for b_idx, bom_item in enumerate(bom_items):
# Get part instance
b_part = bom_item.sub_part
# Filter supplier parts
manufacturer_parts = ManufacturerPart.objects.filter(part__pk=b_part.pk)
for idx, manufacturer_part in enumerate(manufacturer_parts):
if manufacturer_part:
manufacturer_name = manufacturer_part.manufacturer.name
else:
manufacturer_name = ''
manufacturer_mpn = manufacturer_part.MPN
# Add manufacturer data to the manufacturer columns
# Generate column names for this manufacturer
k_man = manufacturer_headers[0] + "_" + str(idx)
k_mpn = manufacturer_headers[1] + "_" + str(idx)
try:
manufacturer_cols[k_man].update({b_idx: manufacturer_name})
manufacturer_cols[k_mpn].update({b_idx: manufacturer_mpn})
except KeyError:
manufacturer_cols[k_man] = {b_idx: manufacturer_name}
manufacturer_cols[k_mpn] = {b_idx: manufacturer_mpn}
# Add manufacturer columns to dataset
add_columns_to_dataset(manufacturer_cols, len(bom_items))
elif supplier_data:
"""
If requested, add extra columns for each SupplierPart associated with each line item
"""
# Expand dataset with manufacturer parts
manufacturer_headers = [
_('Supplier'),
_('SKU'),
]
manufacturer_cols = {}
for b_idx, bom_item in enumerate(bom_items):
# Get part instance
b_part = bom_item.sub_part
# Filter supplier parts
supplier_parts = SupplierPart.objects.filter(part__pk=b_part.pk)
for idx, supplier_part in enumerate(supplier_parts):
if supplier_part.supplier:
supplier_name = supplier_part.supplier.name
else:
supplier_name = ''
supplier_sku = supplier_part.SKU
# Add manufacturer data to the manufacturer columns
# Generate column names for this supplier
k_sup = manufacturer_headers[0] + "_" + str(idx)
k_sku = manufacturer_headers[1] + "_" + str(idx)
try:
manufacturer_cols[k_sup].update({b_idx: supplier_name})
manufacturer_cols[k_sku].update({b_idx: supplier_sku})
except KeyError:
manufacturer_cols[k_sup] = {b_idx: supplier_name}
manufacturer_cols[k_sku] = {b_idx: supplier_sku}
# Add manufacturer columns to dataset
# Add supplier columns to dataset
add_columns_to_dataset(manufacturer_cols, len(bom_items))
data = dataset.export(fmt)

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):
@@ -1226,6 +1324,17 @@ class Part(MPTTModel):
return query
def get_stock_count(self, include_variants=True):
"""
Return the total "in stock" count for this part
"""
entries = self.stock_entries(in_stock=True, include_variants=include_variants)
query = entries.aggregate(t=Coalesce(Sum('quantity'), Decimal(0)))
return query['t']
@property
def total_stock(self):
""" Return the total stock quantity for this part.
@@ -1234,11 +1343,7 @@ class Part(MPTTModel):
- If this part is a "template" (variants exist) then these are counted too
"""
entries = self.stock_entries(in_stock=True)
query = entries.aggregate(t=Coalesce(Sum('quantity'), Decimal(0)))
return query['t']
return self.get_stock_count()
def get_bom_item_filter(self, include_inherited=True):
"""
@@ -1287,6 +1392,27 @@ class Part(MPTTModel):
return BomItem.objects.filter(self.get_bom_item_filter(include_inherited=include_inherited))
def get_installed_part_options(self, include_inherited=True, include_variants=True):
"""
Return a set of all Parts which can be "installed" into this part, based on the BOM.
arguments:
include_inherited - If set, include BomItem entries defined for parent parts
include_variants - If set, include variant parts for BomItems which allow variants
"""
parts = set()
for bom_item in self.get_bom_items(include_inherited=include_inherited):
if include_variants and bom_item.allow_variants:
for part in bom_item.sub_part.get_descendants(include_self=True):
parts.add(part)
else:
parts.add(bom_item.sub_part)
return parts
def get_used_in_filter(self, include_inherited=True):
"""
Return a query filter for all parts that this part is used in.
@@ -1945,10 +2071,10 @@ class Part(MPTTModel):
if self.variant_of:
parts.append(self.variant_of)
siblings = self.get_siblings(include_self=False)
siblings = self.get_siblings(include_self=False)
for sib in siblings:
parts.append(sib)
for sib in siblings:
parts.append(sib)
filtered_parts = Part.objects.filter(pk__in=[part.pk for part in parts])
@@ -1988,6 +2114,26 @@ class Part(MPTTModel):
def related_count(self):
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.get_stock_count() < self.minimum_stock
@receiver(post_save, sender=Part, dispatch_uid='part_post_save_log')
def after_save_part(sender, instance: Part, created, **kwargs):
"""
Function to be executed after a Part is saved
"""
if not created:
# Check part stock only if we are *updating* the part (not creating it)
# 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
@@ -2059,10 +2205,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
@@ -2074,7 +2219,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(

77
InvenTree/part/tasks.py Normal file
View File

@@ -0,0 +1,77 @@
# -*- coding: utf-8 -*-
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 NotificationEntry
import InvenTree.helpers
import InvenTree.tasks
import part.models
logger = logging.getLogger("inventree")
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}")
# 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(emails) > 0:
logger.info(f"Notify users regarding low stock of {part.name}")
context = {
# Pass the "Part" object through to the template context
'part': part,
'link': InvenTree.helpers.construct_absolute_url(part.get_absolute_url()),
}
subject = "[InvenTree] " + _("Low stock notification")
html_message = render_to_string('email/low_stock_notification.html', context)
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.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
"""
# 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

@@ -34,11 +34,8 @@
<li><a class='dropdown-item' href='#' id='bom-item-delete'><span class='fas fa-trash-alt icon-red'></span> {% trans "Delete Items" %}</a></li>
</ul>
</div>
{% endif %}
<div class='filter-list' id='filter-list-bom'>
<!-- Empty div (will be filled out with avilable BOM filters) -->
</div>
{% endif %}
{% include "filter_list.html" with id="bom" %}
</div>
</div>

View File

@@ -8,58 +8,55 @@
{% include "sidebar_link.html" with url=url text="Return to BOM" icon="fa-undo" %}
{% endblock %}
{% block page_content %}
{% block heading %}
{% trans "Upload Bill of Materials" %}
{% endblock %}
<div class='panel' id='panel-upload-file'>
<div class='panel-heading'>
{% block heading %}
<h4>{% trans "Upload Bill of Materials" %}</h4>
{{ wizard.form.media }}
{% endblock %}
{% block actions %}
{% endblock %}
{% block page_info %}
<div class='panel-content'>
<p>{% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %}
{% if description %}- {{ description }}{% endif %}</p>
<form action="" method="post" class='js-modal-form' enctype="multipart/form-data">
{% csrf_token %}
{% load crispy_forms_tags %}
{% block form_buttons_top %}
{% endblock form_buttons_top %}
{% block form_alert %}
<div class='alert alert-info alert-block'>
<strong>{% trans "Requirements for BOM upload" %}:</strong>
<ul>
<li>{% trans "The BOM file must contain the required named columns as provided in the " %} <strong><a href="/part/bom_template/">{% trans "BOM Upload Template" %}</a></strong></li>
<li>{% trans "Each part must already exist in the database" %}</li>
</ul>
</div>
<div class='panel-content'>
{% block details %}
{% endblock %}
<p>{% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %}
{% if description %}- {{ description }}{% endif %}</p>
<table class='table table-striped' style='margin-top: 12px; margin-bottom: 0px'>
{{ wizard.management_form }}
{% block form_content %}
{% crispy wizard.form %}
{% endblock form_content %}
</table>
<form action="" method="post" class='js-modal-form' enctype="multipart/form-data">
{% csrf_token %}
{% load crispy_forms_tags %}
{% block form_buttons_top %}
{% endblock form_buttons_top %}
{% block form_alert %}
<div class='alert alert-info alert-block'>
<strong>{% trans "Requirements for BOM upload" %}:</strong>
<ul>
<li>{% trans "The BOM file must contain the required named columns as provided in the " %} <strong><a href="/part/bom_template/">{% trans "BOM Upload Template" %}</a></strong></li>
<li>{% trans "Each part must already exist in the database" %}</li>
</ul>
</div>
{% endblock %}
<table class='table table-striped' style='margin-top: 12px; margin-bottom: 0px'>
{{ wizard.management_form }}
{% block form_content %}
{% crispy wizard.form %}
{% endblock form_content %}
</table>
{% block form_buttons_bottom %}
{% if wizard.steps.prev %}
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="save btn btn-outline-secondary">{% trans "Previous Step" %}</button>
{% endif %}
<button type="submit" class="save btn btn-outline-secondary">{% trans "Upload File" %}</button>
</form>
{% endblock form_buttons_bottom %}
{% endblock details %}
</div>
{% endblock page_content %}
{% block form_buttons_bottom %}
{% if wizard.steps.prev %}
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="save btn btn-outline-secondary">{% trans "Previous Step" %}</button>
{% endif %}
<button type="submit" class="save btn btn-outline-secondary">{% trans "Upload File" %}</button>
</form>
{% endblock form_buttons_bottom %}
</div>
{% endblock page_info %}
{% block js_ready %}
{{ block.super }}
enableSidebar('bom-upload');
{% endblock js_ready %}

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 notifications 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 %}
@@ -116,7 +138,7 @@
<div class='panel panel-hidden' id='panel-parts'>
<div class='panel-heading'>
<div class='d-flex flex-row'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Parts" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
@@ -142,13 +164,13 @@
<li><a class='dropdown-item' href='#' id='multi-part-category' title='{% trans "Set category" %}'>{% trans "Set Category" %}</a></li>
{% endif %}
<li><a class='dropdown-item' href='#' id='multi-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li>
{% if report_enabled %}
<li><a class='dropdown-item' href='#' id='multi-part-print-label' title='{% trans "Print Labels" %}'>{% trans "Print Labels" %}</a></li>
{% endif %}
<li><a class='dropdown-item' href='#' id='multi-part-export' title='{% trans "Export" %}'>{% trans "Export Data" %}</a></li>
</ul>
</div>
<div class='filter-list' id='filter-list-parts'>
<!-- Empty div -->
</div>
{% include "filter_list.html" with id="parts" %}
</div>
</div>
<div class='panel-content'>
@@ -174,9 +196,7 @@
<div class='panel-content'>
<div id='subcategory-button-toolbar'>
<div class='btn-group' role='group'>
<div class='filter-list' id='filter-list-category'>
<!-- An empty div in which the filter list will be constructed -->
</div>
{% include "filter_list.html" with id="category" %}
</div>
</div>
@@ -202,6 +222,14 @@
data: {{ parameters|safe }},
}
);
$("#toggle-starred").click(function() {
toggleStar({
url: '{% url "api-part-category-detail" category.pk %}',
button: '#category-star-icon'
});
});
{% endif %}
enableSidebar('category');
@@ -214,7 +242,8 @@
{% else %}
parent: null,
{% endif %}
}
},
allowTreeView: true,
}
);

View File

@@ -20,13 +20,6 @@
<!-- Details Table -->
<table class="table table-striped table-condensed">
<col width='25'>
{% if part.IPN %}
<tr>
<td><span class='fas fa-tag'></span></td>
<td>{% trans "IPN" %}</td>
<td>{{ part.IPN }}{% include "clip.html"%}</td>
</tr>
{% endif %}
<tr>
<td><span class='fas fa-shapes'></span></td>
<td>{% trans "Name" %}</td>
@@ -37,6 +30,22 @@
<td>{% trans "Description" %}</td>
<td>{{ part.description }}{% include "clip.html"%}</td>
</tr>
{% if part.category %}
<tr>
<td><span class='fas fa-sitemap'></span></td>
<td>{% trans "Category" %}</td>
<td>
<a href='{% url "category-detail" part.category.pk %}'>{{ part.category.name }}</a>
</td>
</tr>
{% endif %}
{% if part.IPN %}
<tr>
<td><span class='fas fa-tag'></span></td>
<td>{% trans "IPN" %}</td>
<td>{{ part.IPN }}{% include "clip.html"%}</td>
</tr>
{% endif %}
{% if part.revision %}
<tr>
<td><span class='fas fa-code-branch'></span></td>
@@ -44,6 +53,20 @@
<td>{{ part.revision }}{% include "clip.html"%}</td>
</tr>
{% endif %}
{% if part.units %}
<tr>
<td></td>
<td>{% trans "Units" %}</td>
<td>{{ part.units }}</td>
</tr>
{% endif %}
{% if part.minimum_stock %}
<tr>
<td><span class='fas fa-flag'></span></td>
<td>{% trans "Minimum stock level" %}</td>
<td>{{ part.minimum_stock }}</td>
</tr>
{% endif %}
{% if part.keywords %}
<tr>
<td><span class='fas fa-key'></span></td>
@@ -64,7 +87,7 @@
<td>
{{ part.creation_date }}
{% if part.creation_user %}
<span class='badge'>{{ part.creation_user }}</span>
<span class='badge badge-right rounded-pill bg-dark'>{{ part.creation_user }}</span>
{% endif %}
</td>
</tr>
@@ -79,7 +102,9 @@
<tr>
<td><span class='fas fa-search-location'></span></td>
<td>{% trans "Default Location" %}</td>
<td>{{ part.default_location }}</td>
<td>
<a href='{% url "stock-location-detail" part.default_location.pk %}'>{{ part.default_location }}</a>
</td>
</tr>
{% endif %}
{% if part.default_supplier %}
@@ -95,7 +120,15 @@
<div class='panel panel-hidden' id='panel-part-stock'>
<div class='panel-heading'>
<h4>{% trans "Part Stock" %}</h4>
<div class='d-flex flex-wrap'>
<h4>{% trans "Part Stock" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
<button type='button' class='btn btn-success' id='new-stock-item' title='{% trans "Create new stock item" %}'>
<span class='fas fa-plus-circle'></span> {% trans "New Stock Item" %}
</button>
</div>
</div>
</div>
<div class='panel-content'>
{% if part.is_template %}
@@ -109,7 +142,7 @@
<div class='panel panel-hidden' id='panel-test-templates'>
<div class='panel-heading'>
<div class='d-flex flex-row'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Part Test Templates" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
@@ -123,10 +156,8 @@
</div>
<div class='panel-content'>
<div id='test-button-toolbar'>
<div class='button-toolbar container-fluid' style="float: right;">
<div class='filter-list' id='filter-list-parttests'>
<!-- Empty div -->
</div>
<div class='btn-group' role='group'>
{% include "filter_list.html" with id="parttests" %}
</div>
</div>
@@ -136,7 +167,7 @@
<div class='panel panel-hidden' id='panel-purchase-orders'>
<div class='panel-heading'>
<div class='d-flex flex-row'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Purchase Orders" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
@@ -149,9 +180,7 @@
<div class='panel-content'>
<div id='po-button-bar'>
<div class='button-toolbar container-fluid' style='float: right;'>
<div class='filter-list' id='filter-list-purchaseorder'>
<!-- An empty div in which the filter list will be constructed -->
</div>
{% include "filter_list.html" with id="purchaseorder" %}
</div>
</div>
@@ -166,13 +195,8 @@
</div>
<div class='panel-content'>
<div id='so-button-bar'>
<div class='button-toolbar container-fluid' style='float: right;'>
{% if 0 %}
<button class='btn btn-success' type='button' id='part-order2' title='{% trans "New sales order" %}'>{% trans "New Order" %}</button>
{% endif %}
<div class='filter-list' id='filter-list-salesorder'>
<!-- An empty div in which the filter list will be constructed -->
</div>
<div class='btn-group' role='group'>
{% include "filter_list.html" with id="salesorder" %}
</div>
</div>
@@ -221,7 +245,7 @@
<div class='panel panel-hidden' id='panel-variants'>
<div class='panel-heading'>
<div class='d-flex flex-row'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Part Variants" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
@@ -238,9 +262,7 @@
<div class='button-toolbar container-fluid'>
<div class='btn-group' role='group'>
</div>
<div class='filter-list' id='filter-list-variants'>
<!-- Empty div (will be filled out with available BOM filters) -->
</div>
{% include "filter_list.html" with id="variants" %}
</div>
</div>
@@ -251,7 +273,7 @@
<div class='panel panel-hidden' id='panel-part-parameters'>
<div class='panel-heading'>
<div class='d-flex flex-row'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Parameters" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
@@ -274,7 +296,7 @@
<div class='panel panel-hidden' id='panel-part-attachments'>
<div class='panel-heading'>
<div class='d-flex flex-row'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Attachments" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
@@ -289,7 +311,7 @@
<div class='panel panel-hidden' id='panel-related-parts'>
<div class='panel-heading'>
<div class='d-flex flex-row'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Related Parts" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
@@ -303,10 +325,8 @@
</div>
<div class='panel-content'>
<div id='related-button-bar'>
<div class='button-toolbar container-fluid' style='float: left;'>
<div class='filter-list' id='filter-list-related'>
<!-- An empty div in which the filter list will be constructed -->
</div>
<div class='btn-group' role='group'>
{% include "filter_list.html" with id="related" %}
</div>
</div>
@@ -342,7 +362,7 @@
<div class='panel panel-hidden' id='panel-bom'>
<div class='panel-heading'>
<div class='d-flex flex-row'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Bill of Materials" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
@@ -353,7 +373,9 @@
</button>
<ul class='dropdown-menu' role='menu'>
<li><a class='dropdown-item' href='#' id='download-bom'><span class='fas fa-file-download'></span> {% trans "Export BOM" %}</a></li>
{% if report_enabled %}
<li><a class='dropdown-item' href='#' id='print-bom-report'><span class='fas fa-file-pdf'></span> {% trans "Print BOM Report" %}</a></li>
{% endif %}
</ul>
</div>
<!-- Actions menu -->
@@ -391,8 +413,8 @@
</div>
<div class='panel-content'>
<div id='assembly-button-toolbar'>
<div class='filter-list' id='filter-list-usedin'>
<!-- Empty div (will be filled out with avilable BOM filters) -->
<div class='btn-group' role='group'>
{% include "filter_list.html" with id="usedin" %}
</div>
</div>
@@ -403,7 +425,7 @@
<div class='panel panel-hidden' id='panel-build-orders'>
<div class='panel-heading'>
<div class='d-flex flex-row'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Part Builds" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
@@ -419,10 +441,8 @@
</div>
<div class='panel-content'>
<div id='build-button-toolbar'>
<div class='button-toolbar container-fluid' style='float: right';>
<div class='filter-list' id='filter-list-build'>
<!-- Empty div for filters -->
</div>
<div class='btn-group' role='group'>
{% include "filter_list.html" with id="build" %}
</div>
</div>
@@ -440,7 +460,7 @@
<div class='panel panel-hidden' id='panel-suppliers'>
<div class='panel-heading'>
<div class='d-flex flex-row'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Part Suppliers" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
@@ -467,7 +487,7 @@
</div>
<div class='panel-heading'>
<div class='d-flex flex-row'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Part Manufacturers" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
@@ -748,9 +768,11 @@
);
});
{% if report_enabled %}
$("#print-bom-report").click(function() {
printBomReports([{{ part.pk }}]);
});
{% endif %}
});
// Load the "related parts" tab
@@ -866,11 +888,13 @@
});
onPanelLoad("part-stock", function() {
$('#add-stock-item').click(function () {
$('#new-stock-item').click(function () {
createNewStockItem({
reload: true,
data: {
part: {{ part.id }},
{% if part.default_location %}
location: {{ part.default_location.pk }},
{% endif %}
}
});
});
@@ -898,7 +922,6 @@
$('#item-create').click(function () {
createNewStockItem({
reload: true,
data: {
part: {{ part.id }},
}

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 notifications 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 notifications 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

@@ -122,6 +122,12 @@ def inventree_title(*args, **kwargs):
return version.inventreeInstanceTitle()
@register.simple_tag()
def inventree_base_url(*args, **kwargs):
""" Return the INVENTREE_BASE_URL setting """
return InvenTreeSetting.get_setting('INVENTREE_BASE_URL')
@register.simple_tag()
def python_version(*args, **kwargs):
"""

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

@@ -42,11 +42,12 @@ from common.files import FileManager
from common.views import FileManagementFormView, FileManagementAjaxView
from common.forms import UploadFileForm, MatchFieldForm
from stock.models import StockLocation
from stock.models import StockItem, StockLocation
import common.settings as inventree_settings
from . import forms as part_forms
from . import settings as part_settings
from .bom import MakeBomTemplate, ExportBom, IsValidBOMFormat
from order.models import PurchaseOrderLineItem
@@ -245,6 +246,7 @@ class PartImport(FileManagementFormView):
'Category',
'default_location',
'default_supplier',
'variant_of',
]
OPTIONAL_HEADERS = [
@@ -256,6 +258,17 @@ class PartImport(FileManagementFormView):
'minimum_stock',
'Units',
'Notes',
'Active',
'base_cost',
'Multiple',
'Assembly',
'Component',
'is_template',
'Purchaseable',
'Salable',
'Trackable',
'Virtual',
'Stock',
]
name = 'part'
@@ -284,6 +297,18 @@ class PartImport(FileManagementFormView):
'category': 'category',
'default_location': 'default_location',
'default_supplier': 'default_supplier',
'variant_of': 'variant_of',
'active': 'active',
'base_cost': 'base_cost',
'multiple': 'multiple',
'assembly': 'assembly',
'component': 'component',
'is_template': 'is_template',
'purchaseable': 'purchaseable',
'salable': 'salable',
'trackable': 'trackable',
'virtual': 'virtual',
'stock': 'stock',
}
file_manager_class = PartFileManager
@@ -299,6 +324,8 @@ class PartImport(FileManagementFormView):
self.matches['default_location'] = ['name__contains']
self.allowed_items['default_supplier'] = SupplierPart.objects.all()
self.matches['default_supplier'] = ['SKU__contains']
self.allowed_items['variant_of'] = Part.objects.all()
self.matches['variant_of'] = ['name__contains']
# setup
self.file_manager.setup()
@@ -364,9 +391,29 @@ class PartImport(FileManagementFormView):
category=optional_matches['Category'],
default_location=optional_matches['default_location'],
default_supplier=optional_matches['default_supplier'],
variant_of=optional_matches['variant_of'],
active=str2bool(part_data.get('active', True)),
base_cost=part_data.get('base_cost', 0),
multiple=part_data.get('multiple', 1),
assembly=str2bool(part_data.get('assembly', part_settings.part_assembly_default())),
component=str2bool(part_data.get('component', part_settings.part_component_default())),
is_template=str2bool(part_data.get('is_template', part_settings.part_template_default())),
purchaseable=str2bool(part_data.get('purchaseable', part_settings.part_purchaseable_default())),
salable=str2bool(part_data.get('salable', part_settings.part_salable_default())),
trackable=str2bool(part_data.get('trackable', part_settings.part_trackable_default())),
virtual=str2bool(part_data.get('virtual', part_settings.part_virtual_default())),
)
try:
new_part.save()
# add stock item if set
if part_data.get('stock', None):
stock = StockItem(
part=new_part,
location=new_part.default_location,
quantity=int(part_data.get('stock', 1)),
)
stock.save()
import_done += 1
except ValidationError as _e:
import_error.append(', '.join(set(_e.messages)))
@@ -412,6 +459,7 @@ class PartDetail(InvenTreeRoleMixin, DetailView):
part = self.get_object()
ctx = part.get_context_data(self.request)
context.update(**ctx)
# Pricing information
@@ -1469,18 +1517,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