[Feature] Stocktake reports (#4345)

* Add settings to control upcoming stocktake features

* Adds migration for "cost range" when performing stocktake

* Add cost data to PartStocktakeSerializer

Implement a new custom serializer for currency data type

* Refactor existing currency serializers

* Update stocktake table and forms

* Prevent trailing zeroes in forms

* Calculate cost range when adding manual stocktake entry

* Display interactive chart for part stocktake history

* Ensure chart data are converted to common currency

* Adds new model for building stocktake reports

* Add admin integration for new model

* Adds API endpoint to expose list of stocktake reports available for download

- No ability to edit or delete via API

* Add setting to control automated deletion of old stocktake reports

* Updates for settings page

- Load part stocktake report table
- Refactor function to render a downloadable media file
- Fix bug with forcing files to be downloaded
- Split js code into separate templates
- Make use of onPanelLoad functionalitty

* Fix conflicting migration files

* Adds API endpoint for manual generation of stocktake report

* Offload task to generate new stocktake report

* Adds python function to perform stocktake on a single part instance

* Small bug fixes

* Various tweaks

- Prevent new stocktake models from triggering plugin events when created
- Construct a simple csv dataset

* Generate new report

* Updates for report generation

- Prefetch related data
- Add extra columns
- Keep track of stocktake instances (for saving to database later on)

* Updates:

- Add confirmation message
- Serializer validation checks

* Ensure that background worker is running before manually scheduling a new stocktake report

* Add extra fields to stocktake models

Also move code from part/models.py to part/tasks.py

* Add 'part_count' to PartStocktakeReport table

* Updates for stocktake generation

- remove old performStocktake javascript code
- Now handled by automated server-side calculation
- Generate report for a single part

* Add a new "role" for stocktake

- Allows fine-grained control on viewing / creating / deleting stocktake data
- More in-line with existing permission controls
- Remove STOCKTAKE_OWNER setting

* Add serializer field to limit stocktake report to particular locations

* Use location restriction when generating a stocktake report

* Add UI buttons to perform stocktake for a whole category tree

* Add button to perform stocktake report for a location tree

* Adds a background tasks to handle periodic generation of stocktake reports

- Reports are generated at fixed intervals
- Deletes old reports after certain number of days

* Implement notifications for new stocktake reports

- If manually requested by a user, notify that user
- Cleanup notification table
- Amend PartStocktakeModel for better notification rendering

* Hide buttons on location and category page if stocktake is not enabled

* Cleanup log messages during server start

* Extend functionality of RoleRequired permission mixin

- Allow 'role_required' attribute to be added to an API view
- Useful when using a serializer class that does not have a model defined

* Add boolean option to toggle whether a report will be generated

* Update generateStocktake function

* Improve location filtering

- Don't limit the actual stock items
- Instead, select only parts which exist within a given location tree

* Update API version

* String tweaks

* Fix permissions for PartStocktake API

* More unit testing for stocktake functionality

* QoL fix

* Fix for assigning inherited permissions
This commit is contained in:
Oliver 2023-02-17 11:42:48 +11:00 committed by GitHub
parent e6c9db2ff3
commit 0f445ea6e4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 1700 additions and 713 deletions

View File

@ -67,8 +67,16 @@ class UserMixin:
self.client.login(username=self.username, password=self.password) self.client.login(username=self.username, password=self.password)
def assignRole(self, role=None, assign_all: bool = False): def assignRole(self, role=None, assign_all: bool = False):
"""Set the user roles for the registered user.""" """Set the user roles for the registered user.
# role is of the format 'rule.permission' e.g. 'part.add'
Arguments:
role: Role of the format 'rule.permission' e.g. 'part.add'
assign_all: Set to True to assign *all* roles
"""
if type(assign_all) is not bool:
# Raise exception if common mistake is made!
raise TypeError('assign_all must be a boolean value')
if not assign_all and role: if not assign_all and role:
rule, perm = role.split('.') rule, perm = role.split('.')

View File

@ -2,11 +2,14 @@
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 95 INVENTREE_API_VERSION = 96
""" """
Increment this API version number whenever there is a significant change to the API that any clients need to know about Increment this API version number whenever there is a significant change to the API that any clients need to know about
v96 -> 2023-02-16 : https://github.com/inventree/InvenTree/pull/4345
- Adds stocktake report generation functionality
v95 -> 2023-02-16 : https://github.com/inventree/InvenTree/pull/4346 v95 -> 2023-02-16 : https://github.com/inventree/InvenTree/pull/4346
- Adds "CompanyAttachment" model (and associated API endpoints) - Adds "CompanyAttachment" model (and associated API endpoints)

View File

@ -60,7 +60,10 @@ class InvenTreeConfig(AppConfig):
logger.info("Starting background tasks...") logger.info("Starting background tasks...")
for task in InvenTree.tasks.tasks.task_list: # List of collected tasks found with the @scheduled_task decorator
tasks = InvenTree.tasks.tasks.task_list
for task in tasks:
ref_name = f'{task.func.__module__}.{task.func.__name__}' ref_name = f'{task.func.__module__}.{task.func.__name__}'
InvenTree.tasks.schedule_task( InvenTree.tasks.schedule_task(
ref_name, ref_name,
@ -75,7 +78,7 @@ class InvenTreeConfig(AppConfig):
force_async=True, force_async=True,
) )
logger.info("Started background tasks...") logger.info(f"Started {len(tasks)} scheduled background tasks...")
def collect_tasks(self): def collect_tasks(self):
"""Collect all background tasks.""" """Collect all background tasks."""

View File

@ -49,6 +49,10 @@ class RolePermission(permissions.BasePermission):
permission = rolemap[request.method] permission = rolemap[request.method]
# The required role may be defined for the view class
if role := getattr(view, 'role_required', None):
return users.models.check_user_role(user, role, permission)
try: try:
# Extract the model name associated with this request # Extract the model name associated with this request
model = view.serializer_class.Meta.model model = view.serializer_class.Meta.model
@ -62,9 +66,7 @@ class RolePermission(permissions.BasePermission):
# then we don't need a permission # then we don't need a permission
return True return True
result = users.models.RuleSet.check_table_permission(user, table, permission) return users.models.RuleSet.check_table_permission(user, table, permission)
return result
class IsSuperuser(permissions.IsAdminUser): class IsSuperuser(permissions.IsAdminUser):

View File

@ -21,6 +21,7 @@ from rest_framework.serializers import DecimalField
from rest_framework.utils import model_meta from rest_framework.utils import model_meta
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
from common.settings import currency_code_default, currency_code_mappings
from InvenTree.fields import InvenTreeRestURLField, InvenTreeURLField from InvenTree.fields import InvenTreeRestURLField, InvenTreeURLField
from InvenTree.helpers import download_image_from_url from InvenTree.helpers import download_image_from_url
@ -66,6 +67,26 @@ class InvenTreeMoneySerializer(MoneyField):
return amount return amount
class InvenTreeCurrencySerializer(serializers.ChoiceField):
"""Custom serializers for selecting currency option"""
def __init__(self, *args, **kwargs):
"""Initialize the currency serializer"""
kwargs['choices'] = currency_code_mappings()
if 'default' not in kwargs and 'required' not in kwargs:
kwargs['default'] = currency_code_default
if 'label' not in kwargs:
kwargs['label'] = _('Currency')
if 'help_text' not in kwargs:
kwargs['help_text'] = _('Select currency from available options')
super().__init__(*args, **kwargs)
class InvenTreeModelSerializer(serializers.ModelSerializer): class InvenTreeModelSerializer(serializers.ModelSerializer):
"""Inherits the standard Django ModelSerializer class, but also ensures that the underlying model class data are checked on validation.""" """Inherits the standard Django ModelSerializer class, but also ensures that the underlying model class data are checked on validation."""

View File

@ -1568,6 +1568,35 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'validator': bool, 'validator': bool,
'requires_restart': True, 'requires_restart': True,
}, },
'STOCKTAKE_ENABLE': {
'name': _('Stocktake Functionality'),
'description': _('Enable stocktake functionality for recording stock levels and calculating stock value'),
'validator': bool,
'default': False,
},
'STOCKTAKE_AUTO_DAYS': {
'name': _('Automatic Stocktake Period'),
'description': _('Number of days between automatic stocktake recording (set to zero to disable)'),
'validator': [
int,
MinValueValidator(0),
],
'default': 0,
},
'STOCKTAKE_DELETE_REPORT_DAYS': {
'name': _('Delete Old Reports'),
'description': _('Stocktake reports will be deleted after specified number of days'),
'default': 30,
'units': 'days',
'validator': [
int,
MinValueValidator(7),
]
},
} }
typ = 'inventree' typ = 'inventree'
@ -1900,7 +1929,7 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'DISPLAY_STOCKTAKE_TAB': { 'DISPLAY_STOCKTAKE_TAB': {
'name': _('Part Stocktake'), 'name': _('Part Stocktake'),
'description': _('Display part stocktake information'), 'description': _('Display part stocktake information (if stocktake functionality is enabled)'),
'default': True, 'default': True,
'validator': bool, 'validator': bool,
}, },

View File

@ -180,7 +180,7 @@ class MethodStorageClass:
Args: Args:
selected_classes (class, optional): References to the classes that should be registered. Defaults to None. selected_classes (class, optional): References to the classes that should be registered. Defaults to None.
""" """
logger.info('collecting notification methods') logger.debug('Collecting notification methods')
current_method = InvenTree.helpers.inheritors(NotificationMethod) - IGNORED_NOTIFICATION_CLS current_method = InvenTree.helpers.inheritors(NotificationMethod) - IGNORED_NOTIFICATION_CLS
# for testing selective loading is made available # for testing selective loading is made available
@ -196,7 +196,7 @@ class MethodStorageClass:
filtered_list[ref] = item filtered_list[ref] = item
storage.liste = list(filtered_list.values()) storage.liste = list(filtered_list.values())
logger.info(f'found {len(storage.liste)} notification methods') logger.info(f'Found {len(storage.liste)} notification methods')
def get_usersettings(self, user) -> list: def get_usersettings(self, user) -> list:
"""Returns all user settings for a specific user. """Returns all user settings for a specific user.

View File

@ -141,27 +141,13 @@ class NotificationMessageSerializer(InvenTreeModelSerializer):
"""Serializer for the InvenTreeUserSetting model.""" """Serializer for the InvenTreeUserSetting model."""
target = serializers.SerializerMethodField(read_only=True) target = serializers.SerializerMethodField(read_only=True)
source = serializers.SerializerMethodField(read_only=True) source = serializers.SerializerMethodField(read_only=True)
user = serializers.PrimaryKeyRelatedField(read_only=True) user = serializers.PrimaryKeyRelatedField(read_only=True)
category = serializers.CharField(read_only=True)
name = serializers.CharField(read_only=True)
message = serializers.CharField(read_only=True)
creation = serializers.CharField(read_only=True)
age = serializers.IntegerField(read_only=True)
age_human = serializers.CharField(read_only=True)
read = serializers.BooleanField() read = serializers.BooleanField()
def get_target(self, obj): def get_target(self, obj):
"""Function to resolve generic object reference to target.""" """Function to resolve generic object reference to target."""
target = get_objectreference(obj, 'target_content_type', 'target_object_id') target = get_objectreference(obj, 'target_content_type', 'target_object_id')
if target and 'link' not in target: if target and 'link' not in target:
@ -202,6 +188,15 @@ class NotificationMessageSerializer(InvenTreeModelSerializer):
'read', 'read',
] ]
read_only_fields = [
'category',
'name',
'message',
'creation',
'age',
'age_human',
]
class NewsFeedEntrySerializer(InvenTreeModelSerializer): class NewsFeedEntrySerializer(InvenTreeModelSerializer):
"""Serializer for the NewsFeedEntry model.""" """Serializer for the NewsFeedEntry model."""

View File

@ -9,8 +9,8 @@ from rest_framework import serializers
from sql_util.utils import SubqueryCount from sql_util.utils import SubqueryCount
import part.filters import part.filters
from common.settings import currency_code_default, currency_code_mappings
from InvenTree.serializers import (InvenTreeAttachmentSerializer, from InvenTree.serializers import (InvenTreeAttachmentSerializer,
InvenTreeCurrencySerializer,
InvenTreeDecimalField, InvenTreeDecimalField,
InvenTreeImageSerializerField, InvenTreeImageSerializerField,
InvenTreeModelSerializer, InvenTreeModelSerializer,
@ -66,13 +66,7 @@ class CompanySerializer(RemoteImageMixin, InvenTreeModelSerializer):
parts_supplied = serializers.IntegerField(read_only=True) parts_supplied = serializers.IntegerField(read_only=True)
parts_manufactured = serializers.IntegerField(read_only=True) parts_manufactured = serializers.IntegerField(read_only=True)
currency = serializers.ChoiceField( currency = InvenTreeCurrencySerializer(help_text=_('Default currency used for this supplier'), required=True)
choices=currency_code_mappings(),
initial=currency_code_default,
help_text=_('Default currency used for this supplier'),
label=_('Currency Code'),
required=True,
)
class Meta: class Meta:
"""Metaclass options.""" """Metaclass options."""
@ -397,11 +391,7 @@ class SupplierPriceBreakSerializer(InvenTreeModelSerializer):
label=_('Price'), label=_('Price'),
) )
price_currency = serializers.ChoiceField( price_currency = InvenTreeCurrencySerializer()
choices=currency_code_mappings(),
default=currency_code_default,
label=_('Currency'),
)
supplier = serializers.PrimaryKeyRelatedField(source='part.supplier', many=False, read_only=True) supplier = serializers.PrimaryKeyRelatedField(source='part.supplier', many=False, read_only=True)

View File

@ -17,10 +17,10 @@ import order.models
import part.filters import part.filters
import stock.models import stock.models
import stock.serializers import stock.serializers
from common.settings import currency_code_mappings
from company.serializers import CompanyBriefSerializer, SupplierPartSerializer from company.serializers import CompanyBriefSerializer, SupplierPartSerializer
from InvenTree.helpers import extract_serial_numbers, normalize, str2bool from InvenTree.helpers import extract_serial_numbers, normalize, str2bool
from InvenTree.serializers import (InvenTreeAttachmentSerializer, from InvenTree.serializers import (InvenTreeAttachmentSerializer,
InvenTreeCurrencySerializer,
InvenTreeDecimalField, InvenTreeDecimalField,
InvenTreeModelSerializer, InvenTreeModelSerializer,
InvenTreeMoneySerializer) InvenTreeMoneySerializer)
@ -58,10 +58,7 @@ class AbstractExtraLineSerializer(serializers.Serializer):
allow_null=True allow_null=True
) )
price_currency = serializers.ChoiceField( price_currency = InvenTreeCurrencySerializer()
choices=currency_code_mappings(),
help_text=_('Price currency'),
)
class AbstractExtraLineMeta: class AbstractExtraLineMeta:
@ -316,16 +313,11 @@ class PurchaseOrderLineItemSerializer(InvenTreeModelSerializer):
supplier_part_detail = SupplierPartSerializer(source='part', many=False, read_only=True) supplier_part_detail = SupplierPartSerializer(source='part', many=False, read_only=True)
purchase_price = InvenTreeMoneySerializer( purchase_price = InvenTreeMoneySerializer(allow_null=True)
allow_null=True
)
destination_detail = stock.serializers.LocationBriefSerializer(source='get_destination', read_only=True) destination_detail = stock.serializers.LocationBriefSerializer(source='get_destination', read_only=True)
purchase_price_currency = serializers.ChoiceField( purchase_price_currency = InvenTreeCurrencySerializer(help_text=_('Purchase price currency'))
choices=currency_code_mappings(),
help_text=_('Purchase price currency'),
)
order_detail = PurchaseOrderSerializer(source='order', read_only=True, many=False) order_detail = PurchaseOrderSerializer(source='order', read_only=True, many=False)
@ -879,14 +871,9 @@ class SalesOrderLineItemSerializer(InvenTreeModelSerializer):
shipped = InvenTreeDecimalField(read_only=True) shipped = InvenTreeDecimalField(read_only=True)
sale_price = InvenTreeMoneySerializer( sale_price = InvenTreeMoneySerializer(allow_null=True)
allow_null=True
)
sale_price_currency = serializers.ChoiceField( sale_price_currency = InvenTreeCurrencySerializer(help_text=_('Sale price currency'))
choices=currency_code_mappings(),
help_text=_('Sale price currency'),
)
class Meta: class Meta:
"""Metaclass options.""" """Metaclass options."""

View File

@ -166,6 +166,12 @@ class PartStocktakeAdmin(admin.ModelAdmin):
list_display = ['part', 'date', 'quantity', 'user'] list_display = ['part', 'date', 'quantity', 'user']
class PartStocktakeReportAdmin(admin.ModelAdmin):
"""Admin class for PartStocktakeReport model"""
list_display = ['date', 'user']
class PartCategoryResource(InvenTreeResource): class PartCategoryResource(InvenTreeResource):
"""Class for managing PartCategory data import/export.""" """Class for managing PartCategory data import/export."""
@ -434,3 +440,4 @@ admin.site.register(models.PartSellPriceBreak, PartSellPriceBreakAdmin)
admin.site.register(models.PartInternalPriceBreak, PartInternalPriceBreakAdmin) admin.site.register(models.PartInternalPriceBreak, PartInternalPriceBreakAdmin)
admin.site.register(models.PartPricing, PartPricingAdmin) admin.site.register(models.PartPricing, PartPricingAdmin)
admin.site.register(models.PartStocktake, PartStocktakeAdmin) admin.site.register(models.PartStocktake, PartStocktakeAdmin)
admin.site.register(models.PartStocktakeReport, PartStocktakeReportAdmin)

View File

@ -10,9 +10,8 @@ from django.utils.translation import gettext_lazy as _
from django_filters import rest_framework as rest_filters from django_filters import rest_framework as rest_filters
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import filters, serializers, status from rest_framework import filters, permissions, serializers, status
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from rest_framework.permissions import IsAdminUser
from rest_framework.response import Response from rest_framework.response import Response
import order.models import order.models
@ -38,7 +37,7 @@ from .models import (BomItem, BomItemSubstitute, Part, PartAttachment,
PartCategory, PartCategoryParameterTemplate, PartCategory, PartCategoryParameterTemplate,
PartInternalPriceBreak, PartParameter, PartInternalPriceBreak, PartParameter,
PartParameterTemplate, PartRelated, PartSellPriceBreak, PartParameterTemplate, PartRelated, PartSellPriceBreak,
PartStocktake, PartTestTemplate) PartStocktake, PartStocktakeReport, PartTestTemplate)
class CategoryList(APIDownloadMixin, ListCreateAPI): class CategoryList(APIDownloadMixin, ListCreateAPI):
@ -1598,9 +1597,11 @@ class PartStocktakeList(ListCreateAPI):
ordering_fields = [ ordering_fields = [
'part', 'part',
'item_count',
'quantity', 'quantity',
'date', 'date',
'user', 'user',
'pk',
] ]
# Reverse date ordering by default # Reverse date ordering by default
@ -1615,11 +1616,47 @@ class PartStocktakeDetail(RetrieveUpdateDestroyAPI):
queryset = PartStocktake.objects.all() queryset = PartStocktake.objects.all()
serializer_class = part_serializers.PartStocktakeSerializer serializer_class = part_serializers.PartStocktakeSerializer
class PartStocktakeReportList(ListAPI):
"""API endpoint for listing part stocktake report information"""
queryset = PartStocktakeReport.objects.all()
serializer_class = part_serializers.PartStocktakeReportSerializer
filter_backends = [
DjangoFilterBackend,
filters.OrderingFilter,
]
ordering_fields = [
'date',
'pk',
]
# Newest first, by default
ordering = '-pk'
class PartStocktakeReportGenerate(CreateAPI):
"""API endpoint for manually generating a new PartStocktakeReport"""
serializer_class = part_serializers.PartStocktakeReportGenerateSerializer
permission_classes = [ permission_classes = [
IsAdminUser, permissions.IsAuthenticated,
RolePermission, RolePermission,
] ]
role_required = 'stocktake'
def get_serializer_context(self):
"""Extend serializer context data"""
context = super().get_serializer_context()
context['request'] = self.request
return context
class BomFilter(rest_filters.FilterSet): class BomFilter(rest_filters.FilterSet):
"""Custom filters for the BOM list.""" """Custom filters for the BOM list."""
@ -2038,6 +2075,12 @@ part_api_urls = [
# Part stocktake data # Part stocktake data
re_path(r'^stocktake/', include([ re_path(r'^stocktake/', include([
path(r'report/', include([
path('generate/', PartStocktakeReportGenerate.as_view(), name='api-part-stocktake-report-generate'),
re_path(r'^.*$', PartStocktakeReportList.as_view(), name='api-part-stocktake-report-list'),
])),
re_path(r'^(?P<pk>\d+)/', PartStocktakeDetail.as_view(), name='api-part-stocktake-detail'), re_path(r'^(?P<pk>\d+)/', PartStocktakeDetail.as_view(), name='api-part-stocktake-detail'),
re_path(r'^.*$', PartStocktakeList.as_view(), name='api-part-stocktake-list'), re_path(r'^.*$', PartStocktakeList.as_view(), name='api-part-stocktake-list'),
])), ])),

View File

@ -0,0 +1,36 @@
# Generated by Django 3.2.16 on 2023-02-11 00:29
import InvenTree.fields
from django.db import migrations
import djmoney.models.fields
import djmoney.models.validators
class Migration(migrations.Migration):
dependencies = [
('part', '0095_alter_part_responsible'),
]
operations = [
migrations.AddField(
model_name='partstocktake',
name='cost_max',
field=InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Estimated maximum cost of stock on hand', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Maximum Stock Cost'),
),
migrations.AddField(
model_name='partstocktake',
name='cost_max_currency',
field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3),
),
migrations.AddField(
model_name='partstocktake',
name='cost_min',
field=InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Estimated minimum cost of stock on hand', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Minimum Stock Cost'),
),
migrations.AddField(
model_name='partstocktake',
name='cost_min_currency',
field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3),
),
]

View File

@ -0,0 +1,26 @@
# Generated by Django 3.2.16 on 2023-02-12 08:29
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import part.models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('part', '0096_auto_20230211_0029'),
]
operations = [
migrations.CreateModel(
name='PartStocktakeReport',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField(auto_now_add=True, verbose_name='Date')),
('report', models.FileField(help_text='Stocktake report file (generated internally)', upload_to=part.models.save_stocktake_report, verbose_name='Report')),
('user', models.ForeignKey(blank=True, help_text='User who requested this stocktake report', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='stocktake_reports', to=settings.AUTH_USER_MODEL, verbose_name='User')),
],
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 3.2.16 on 2023-02-14 11:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('part', '0097_partstocktakereport'),
]
operations = [
migrations.AddField(
model_name='partstocktake',
name='item_count',
field=models.IntegerField(default=1, help_text='Number of individual stock entries at time of stocktake', verbose_name='Item Count'),
),
migrations.AddField(
model_name='partstocktakereport',
name='part_count',
field=models.IntegerField(default=0, help_text='Number of parts covered by stocktake', verbose_name='Part Count'),
),
]

View File

@ -2335,7 +2335,7 @@ class PartPricing(common.models.MetaMixin):
force_async=True force_async=True
) )
def update_pricing(self, counter: int = 0): def update_pricing(self, counter: int = 0, cascade: bool = True):
"""Recalculate all cost data for the referenced Part instance""" """Recalculate all cost data for the referenced Part instance"""
if self.pk is not None: if self.pk is not None:
@ -2362,6 +2362,7 @@ class PartPricing(common.models.MetaMixin):
pass pass
# Update parent assemblies and templates # Update parent assemblies and templates
if cascade:
self.update_assemblies(counter) self.update_assemblies(counter)
self.update_templates(counter) self.update_templates(counter)
@ -2890,6 +2891,7 @@ class PartStocktake(models.Model):
A 'stocktake' is a representative count of available stock: A 'stocktake' is a representative count of available stock:
- Performed on a given date - Performed on a given date
- Records quantity of part in stock (across multiple stock items) - Records quantity of part in stock (across multiple stock items)
- Records estimated value of "stock on hand"
- Records user information - Records user information
""" """
@ -2901,6 +2903,12 @@ class PartStocktake(models.Model):
help_text=_('Part for stocktake'), help_text=_('Part for stocktake'),
) )
item_count = models.IntegerField(
default=1,
verbose_name=_('Item Count'),
help_text=_('Number of individual stock entries at time of stocktake'),
)
quantity = models.DecimalField( quantity = models.DecimalField(
max_digits=19, decimal_places=5, max_digits=19, decimal_places=5,
validators=[MinValueValidator(0)], validators=[MinValueValidator(0)],
@ -2929,6 +2937,18 @@ class PartStocktake(models.Model):
help_text=_('User who performed this stocktake'), help_text=_('User who performed this stocktake'),
) )
cost_min = InvenTree.fields.InvenTreeModelMoneyField(
null=True, blank=True,
verbose_name=_('Minimum Stock Cost'),
help_text=_('Estimated minimum cost of stock on hand'),
)
cost_max = InvenTree.fields.InvenTreeModelMoneyField(
null=True, blank=True,
verbose_name=_('Maximum Stock Cost'),
help_text=_('Estimated maximum cost of stock on hand'),
)
@receiver(post_save, sender=PartStocktake, dispatch_uid='post_save_stocktake') @receiver(post_save, sender=PartStocktake, dispatch_uid='post_save_stocktake')
def update_last_stocktake(sender, instance, created, **kwargs): def update_last_stocktake(sender, instance, created, **kwargs):
@ -2944,6 +2964,68 @@ def update_last_stocktake(sender, instance, created, **kwargs):
pass pass
def save_stocktake_report(instance, filename):
"""Save stocktake reports to the correct subdirectory"""
filename = os.path.basename(filename)
return os.path.join('stocktake', 'report', filename)
class PartStocktakeReport(models.Model):
"""A PartStocktakeReport is a generated report which provides a summary of current stock on hand.
Reports are generated by the background worker process, and saved as .csv files for download.
Background processing is preferred as (for very large datasets), report generation may take a while.
A report can be manually requested by a user, or automatically generated periodically.
When generating a report, the "parts" to be reported can be filtered, e.g. by "category".
A stocktake report contains the following information, with each row relating to a single Part instance:
- Number of individual stock items on hand
- Total quantity of stock on hand
- Estimated total cost of stock on hand (min:max range)
"""
def __str__(self):
"""Construct a simple string representation for the report"""
return os.path.basename(self.report.name)
def get_absolute_url(self):
"""Return the URL for the associaed report file for download"""
if self.report:
return self.report.url
else:
return None
date = models.DateField(
verbose_name=_('Date'),
auto_now_add=True
)
report = models.FileField(
upload_to=save_stocktake_report,
unique=False, blank=False,
verbose_name=_('Report'),
help_text=_('Stocktake report file (generated internally)'),
)
part_count = models.IntegerField(
default=0,
verbose_name=_('Part Count'),
help_text=_('Number of parts covered by stocktake'),
)
user = models.ForeignKey(
User, blank=True, null=True,
on_delete=models.SET_NULL,
related_name='stocktake_reports',
verbose_name=_('User'),
help_text=_('User who requested this stocktake report'),
)
class PartAttachment(InvenTreeAttachment): class PartAttachment(InvenTreeAttachment):
"""Model for storing file attachments against a Part object.""" """Model for storing file attachments against a Part object."""

View File

@ -15,28 +15,32 @@ from django.utils.translation import gettext_lazy as _
from rest_framework import serializers from rest_framework import serializers
from sql_util.utils import SubqueryCount, SubquerySum from sql_util.utils import SubqueryCount, SubquerySum
import common.models
import company.models import company.models
import InvenTree.helpers import InvenTree.helpers
import InvenTree.status
import part.filters import part.filters
import part.tasks
import stock.models import stock.models
from common.settings import currency_code_default, currency_code_mappings
from InvenTree.serializers import (DataFileExtractSerializer, from InvenTree.serializers import (DataFileExtractSerializer,
DataFileUploadSerializer, DataFileUploadSerializer,
InvenTreeAttachmentSerializer, InvenTreeAttachmentSerializer,
InvenTreeAttachmentSerializerField, InvenTreeAttachmentSerializerField,
InvenTreeCurrencySerializer,
InvenTreeDecimalField, InvenTreeDecimalField,
InvenTreeImageSerializerField, InvenTreeImageSerializerField,
InvenTreeModelSerializer, InvenTreeModelSerializer,
InvenTreeMoneySerializer, RemoteImageMixin, InvenTreeMoneySerializer, RemoteImageMixin,
UserSerializer) UserSerializer)
from InvenTree.status_codes import BuildStatus from InvenTree.status_codes import BuildStatus
from InvenTree.tasks import offload_task
from .models import (BomItem, BomItemSubstitute, Part, PartAttachment, from .models import (BomItem, BomItemSubstitute, Part, PartAttachment,
PartCategory, PartCategoryParameterTemplate, PartCategory, PartCategoryParameterTemplate,
PartInternalPriceBreak, PartParameter, PartInternalPriceBreak, PartParameter,
PartParameterTemplate, PartPricing, PartRelated, PartParameterTemplate, PartPricing, PartRelated,
PartSellPriceBreak, PartStar, PartStocktake, PartSellPriceBreak, PartStar, PartStocktake,
PartTestTemplate) PartStocktakeReport, PartTestTemplate)
class CategorySerializer(InvenTreeModelSerializer): class CategorySerializer(InvenTreeModelSerializer):
@ -137,16 +141,9 @@ class PartSalePriceSerializer(InvenTreeModelSerializer):
quantity = InvenTreeDecimalField() quantity = InvenTreeDecimalField()
price = InvenTreeMoneySerializer( price = InvenTreeMoneySerializer(allow_null=True)
allow_null=True
)
price_currency = serializers.ChoiceField( price_currency = InvenTreeCurrencySerializer(help_text=_('Purchase currency of this stock item'))
choices=currency_code_mappings(),
default=currency_code_default,
label=_('Currency'),
help_text=_('Purchase currency of this stock item'),
)
class Meta: class Meta:
"""Metaclass defining serializer fields""" """Metaclass defining serializer fields"""
@ -169,12 +166,7 @@ class PartInternalPriceSerializer(InvenTreeModelSerializer):
allow_null=True allow_null=True
) )
price_currency = serializers.ChoiceField( price_currency = InvenTreeCurrencySerializer(help_text=_('Purchase currency of this stock item'))
choices=currency_code_mappings(),
default=currency_code_default,
label=_('Currency'),
help_text=_('Purchase currency of this stock item'),
)
class Meta: class Meta:
"""Metaclass defining serializer fields""" """Metaclass defining serializer fields"""
@ -720,6 +712,12 @@ class PartStocktakeSerializer(InvenTreeModelSerializer):
user_detail = UserSerializer(source='user', read_only=True, many=False) user_detail = UserSerializer(source='user', read_only=True, many=False)
cost_min = InvenTreeMoneySerializer(allow_null=True)
cost_min_currency = InvenTreeCurrencySerializer()
cost_max = InvenTreeMoneySerializer(allow_null=True)
cost_max_currency = InvenTreeCurrencySerializer()
class Meta: class Meta:
"""Metaclass options""" """Metaclass options"""
@ -728,7 +726,12 @@ class PartStocktakeSerializer(InvenTreeModelSerializer):
'pk', 'pk',
'date', 'date',
'part', 'part',
'item_count',
'quantity', 'quantity',
'cost_min',
'cost_min_currency',
'cost_max',
'cost_max_currency',
'note', 'note',
'user', 'user',
'user_detail', 'user_detail',
@ -751,6 +754,92 @@ class PartStocktakeSerializer(InvenTreeModelSerializer):
super().save() super().save()
class PartStocktakeReportSerializer(InvenTreeModelSerializer):
"""Serializer for stocktake report class"""
user_detail = UserSerializer(source='user', read_only=True, many=False)
report = InvenTreeAttachmentSerializerField(read_only=True)
class Meta:
"""Metaclass defines serializer fields"""
model = PartStocktakeReport
fields = [
'pk',
'date',
'report',
'part_count',
'user',
'user_detail',
]
class PartStocktakeReportGenerateSerializer(serializers.Serializer):
"""Serializer class for manually generating a new PartStocktakeReport via the API"""
part = serializers.PrimaryKeyRelatedField(
queryset=Part.objects.all(),
required=False, allow_null=True,
label=_('Part'), help_text=_('Limit stocktake report to a particular part, and any variant parts')
)
category = serializers.PrimaryKeyRelatedField(
queryset=PartCategory.objects.all(),
required=False, allow_null=True,
label=_('Category'), help_text=_('Limit stocktake report to a particular part category, and any child categories'),
)
location = serializers.PrimaryKeyRelatedField(
queryset=stock.models.StockLocation.objects.all(),
required=False, allow_null=True,
label=_('Location'), help_text=_('Limit stocktake report to a particular stock location, and any child locations')
)
generate_report = serializers.BooleanField(
default=True,
label=_('Generate Report'),
help_text=_('Generate report file containing calculated stocktake data'),
)
update_parts = serializers.BooleanField(
default=True,
label=_('Update Parts'),
help_text=_('Update specified parts with calculated stocktake data')
)
def validate(self, data):
"""Custom validation for this serializer"""
# Stocktake functionality must be enabled
if not common.models.InvenTreeSetting.get_setting('STOCKTAKE_ENABLE', False):
raise serializers.ValidationError(_("Stocktake functionality is not enabled"))
# Check that background worker is running
if not InvenTree.status.is_worker_running():
raise serializers.ValidationError(_("Background worker check failed"))
return data
def save(self):
"""Saving this serializer instance requests generation of a new stocktake report"""
data = self.validated_data
user = self.context['request'].user
# Generate a new report
offload_task(
part.tasks.generate_stocktake_report,
force_async=True,
user=user,
part=data.get('part', None),
category=data.get('category', None),
location=data.get('location', None),
generate_report=data.get('generate_report', True),
update_parts=data.get('update_parts', True),
)
class PartPricingSerializer(InvenTreeModelSerializer): class PartPricingSerializer(InvenTreeModelSerializer):
"""Serializer for Part pricing information""" """Serializer for Part pricing information"""

View File

@ -1,16 +1,27 @@
"""Background task definitions for the 'part' app""" """Background task definitions for the 'part' app"""
import io
import logging import logging
import random
import time
from datetime import datetime, timedelta from datetime import datetime, timedelta
from django.contrib.auth.models import User
from django.core.files.base import ContentFile
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import tablib
from djmoney.contrib.exchange.exceptions import MissingRate
from djmoney.contrib.exchange.models import convert_money
from djmoney.money import Money
import common.models import common.models
import common.notifications import common.notifications
import common.settings import common.settings
import InvenTree.helpers import InvenTree.helpers
import InvenTree.tasks import InvenTree.tasks
import part.models import part.models
import stock.models
from InvenTree.tasks import ScheduledTask, scheduled_task from InvenTree.tasks import ScheduledTask, scheduled_task
logger = logging.getLogger("inventree") logger = logging.getLogger("inventree")
@ -125,3 +136,293 @@ def check_missing_pricing(limit=250):
pricing = p.pricing pricing = p.pricing
pricing.save() pricing.save()
pricing.schedule_for_update() pricing.schedule_for_update()
def perform_stocktake(target: part.models.Part, user: User, note: str = '', commit=True, **kwargs):
"""Perform stocktake action on a single part.
arguments:
target: A single Part model instance
commit: If True (default) save the result to the database
user: User who requested this stocktake
Returns:
PartStocktake: A new PartStocktake model instance (for the specified Part)
"""
# Grab all "available" stock items for the Part
stock_entries = target.stock_entries(in_stock=True, include_variants=True)
# Cache min/max pricing information for this Part
pricing = target.pricing
if not pricing.is_valid:
# If pricing is not valid, let's update
logger.info(f"Pricing not valid for {target} - updating")
pricing.update_pricing(cascade=False)
pricing.refresh_from_db()
base_currency = common.settings.currency_code_default()
total_quantity = 0
total_cost_min = Money(0, base_currency)
total_cost_max = Money(0, base_currency)
for entry in stock_entries:
# Update total quantity value
total_quantity += entry.quantity
has_pricing = False
# Update price range values
if entry.purchase_price:
# If purchase price is available, use that
try:
pp = convert_money(entry.purchase_price, base_currency) * entry.quantity
total_cost_min += pp
total_cost_max += pp
has_pricing = True
except MissingRate:
logger.warning(f"MissingRate exception occured converting {entry.purchase_price} to {base_currency}")
if not has_pricing:
# Fall back to the part pricing data
p_min = pricing.overall_min or pricing.overall_max
p_max = pricing.overall_max or pricing.overall_min
if p_min or p_max:
try:
total_cost_min += convert_money(p_min, base_currency) * entry.quantity
total_cost_max += convert_money(p_max, base_currency) * entry.quantity
except MissingRate:
logger.warning(f"MissingRate exception occurred converting {p_min}:{p_max} to {base_currency}")
# Construct PartStocktake instance
instance = part.models.PartStocktake(
part=target,
item_count=stock_entries.count(),
quantity=total_quantity,
cost_min=total_cost_min,
cost_max=total_cost_max,
note=note,
user=user,
)
if commit:
instance.save()
return instance
def generate_stocktake_report(**kwargs):
"""Generated a new stocktake report.
Note that this method should be called only by the background worker process!
Unless otherwise specified, the stocktake report is generated for *all* Part instances.
Optional filters can by supplied via the kwargs
kwargs:
user: The user who requested this stocktake (set to None for automated stocktake)
part: Optional Part instance to filter by (including variant parts)
category: Optional PartCategory to filter results
location: Optional StockLocation to filter results
generate_report: If True, generate a stocktake report from the calculated data (default=True)
update_parts: If True, save stocktake information against each filtered Part (default = True)
"""
parts = part.models.Part.objects.all()
user = kwargs.get('user', None)
generate_report = kwargs.get('generate_report', True)
update_parts = kwargs.get('update_parts', True)
# Filter by 'Part' instance
if p := kwargs.get('part', None):
variants = p.get_descendants(include_self=True)
parts = parts.filter(
pk__in=[v.pk for v in variants]
)
# Filter by 'Category' instance (cascading)
if category := kwargs.get('category', None):
categories = category.get_descendants(include_self=True)
parts = parts.filter(category__in=categories)
# Filter by 'Location' instance (cascading)
# Stocktake report will be limited to parts which have stock items within this location
if location := kwargs.get('location', None):
# Extract flat list of all sublocations
locations = list(location.get_descendants(include_self=True))
# Items which exist within these locations
items = stock.models.StockItem.objects.filter(location__in=locations)
# List of parts which exist within these locations
unique_parts = items.order_by().values('part').distinct()
parts = parts.filter(
pk__in=[result['part'] for result in unique_parts]
)
# Exit if filters removed all parts
n_parts = parts.count()
if n_parts == 0:
logger.info("No parts selected for stocktake report - exiting")
return
logger.info(f"Generating new stocktake report for {n_parts} parts")
base_currency = common.settings.currency_code_default()
# Construct an initial dataset for the stocktake report
dataset = tablib.Dataset(
headers=[
_('Part ID'),
_('Part Name'),
_('Part Description'),
_('Category ID'),
_('Category Name'),
_('Stock Items'),
_('Total Quantity'),
_('Total Cost Min') + f' ({base_currency})',
_('Total Cost Max') + f' ({base_currency})',
]
)
parts = parts.prefetch_related('category', 'stock_items')
# Simple profiling for this task
t_start = time.time()
# Keep track of each individual "stocktake" we perform.
# They may be bulk-commited to the database afterwards
stocktake_instances = []
total_parts = 0
# Iterate through each Part which matches the filters above
for p in parts:
# Create a new stocktake for this part (do not commit, this will take place later on)
stocktake = perform_stocktake(p, user, commit=False)
if stocktake.quantity == 0:
# Skip rows with zero total quantity
continue
total_parts += 1
stocktake_instances.append(stocktake)
# Add a row to the dataset
dataset.append([
p.pk,
p.full_name,
p.description,
p.category.pk if p.category else '',
p.category.name if p.category else '',
stocktake.item_count,
stocktake.quantity,
InvenTree.helpers.normalize(stocktake.cost_min.amount),
InvenTree.helpers.normalize(stocktake.cost_max.amount),
])
# Save a new PartStocktakeReport instance
buffer = io.StringIO()
buffer.write(dataset.export('csv'))
today = datetime.now().date().isoformat()
filename = f"InvenTree_Stocktake_{today}.csv"
report_file = ContentFile(buffer.getvalue(), name=filename)
if generate_report:
report_instance = part.models.PartStocktakeReport.objects.create(
report=report_file,
part_count=total_parts,
user=user
)
# Notify the requesting user
if user:
common.notifications.trigger_notification(
report_instance,
category='generate_stocktake_report',
context={
'name': _('Stocktake Report Available'),
'message': _('A new stocktake report is available for download'),
},
targets=[
user,
]
)
# If 'update_parts' is set, we save stocktake entries for each individual part
if update_parts:
# Use bulk_create for efficient insertion of stocktake
part.models.PartStocktake.objects.bulk_create(
stocktake_instances,
batch_size=500,
)
t_stocktake = time.time() - t_start
logger.info(f"Generated stocktake report for {total_parts} parts in {round(t_stocktake, 2)}s")
@scheduled_task(ScheduledTask.DAILY)
def scheduled_stocktake_reports():
"""Scheduled tasks for creating automated stocktake reports.
This task runs daily, and performs the following functions:
- Delete 'old' stocktake report files after the specified period
- Generate new reports at the specified period
"""
# Sleep a random number of seconds to prevent worker conflict
time.sleep(random.randint(1, 5))
# First let's delete any old stocktake reports
delete_n_days = int(common.models.InvenTreeSetting.get_setting('STOCKTAKE_DELETE_REPORT_DAYS', 30, cache=False))
threshold = datetime.now() - timedelta(days=delete_n_days)
old_reports = part.models.PartStocktakeReport.objects.filter(date__lt=threshold)
if old_reports.count() > 0:
logger.info(f"Deleting {old_reports.count()} stale stocktake reports")
old_reports.delete()
# Next, check if stocktake functionality is enabled
if not common.models.InvenTreeSetting.get_setting('STOCKTAKE_ENABLE', False, cache=False):
logger.info("Stocktake functionality is not enabled - exiting")
return
report_n_days = int(common.models.InvenTreeSetting.get_setting('STOCKTAKE_AUTO_DAYS', 0, cache=False))
if report_n_days < 1:
logger.info("Stocktake auto reports are disabled, exiting")
return
# How long ago was last full stocktake report generated?
last_report = common.models.InvenTreeSetting.get_setting('STOCKTAKE_RECENT_REPORT', '', cache=False)
try:
last_report = datetime.fromisoformat(last_report)
except ValueError:
last_report = None
if last_report:
# Do not attempt if the last report was within the minimum reporting period
threshold = datetime.now() - timedelta(days=report_n_days)
if last_report > threshold:
logger.info("Automatic stocktake report was recently generated - exiting")
return
# Let's start a new stocktake report for all parts
generate_stocktake_report(update_parts=True)
# Record the date of this report
common.models.InvenTreeSetting.set_setting('STOCKTAKE_RECENT_REPORT', datetime.now().isoformat(), None)

View File

@ -29,6 +29,12 @@
{% url 'admin:part_partcategory_change' category.pk as url %} {% url 'admin:part_partcategory_change' category.pk as url %}
{% include "admin_button.html" with url=url %} {% include "admin_button.html" with url=url %}
{% endif %} {% endif %}
{% settings_value "STOCKTAKE_ENABLE" as stocktake_enable %}
{% if stocktake_enable and roles.stocktake.add %}
<button type='button' class='btn btn-outline-secondary' id='category-stocktake' title='{% trans "Perform stocktake for this part category" %}'>
<span class='fas fa-clipboard-check'></span>
</button>
{% endif %}
{% if category %} {% if category %}
{% if starred_directly %} {% if starred_directly %}
<button type='button' class='btn btn-outline-secondary' id='toggle-starred' title='{% trans "You are subscribed to notifications for this category" %}'> <button type='button' class='btn btn-outline-secondary' id='toggle-starred' title='{% trans "You are subscribed to notifications for this category" %}'>
@ -253,6 +259,20 @@
{% block js_ready %} {% block js_ready %}
{{ block.super }} {{ block.super }}
{% settings_value "STOCKTAKE_ENABLE" as stocktake_enable %}
{% if stocktake_enable and roles.stocktake.add %}
$('#category-stocktake').click(function() {
generateStocktakeReport({
category: {
{% if category %}value: {{ category.pk }},{% endif %}
},
location: {},
generate_report: {},
update_parts: {},
});
});
{% endif %}
{% if category %} {% if category %}
onPanelLoad('stock', function() { onPanelLoad('stock', function() {

View File

@ -53,15 +53,16 @@
</div> </div>
{% endif %} {% endif %}
{% settings_value 'STOCKTAKE_ENABLE' as stocktake_enable %}
{% settings_value 'DISPLAY_STOCKTAKE_TAB' user=request.user as show_stocktake %} {% settings_value 'DISPLAY_STOCKTAKE_TAB' user=request.user as show_stocktake %}
{% if show_stocktake %} {% if stocktake_enable and show_stocktake %}
<div class='panel panel-hidden' id='panel-stocktake'> <div class='panel panel-hidden' id='panel-stocktake'>
<div class='panel-heading'> <div class='panel-heading'>
<div class='d-flex flex-wrap'> <div class='d-flex flex-wrap'>
<h4>{% trans "Part Stocktake" %}</h4> <h4>{% trans "Part Stocktake" %}</h4>
{% include "spacer.html" %} {% include "spacer.html" %}
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
{% if roles.part.add %} {% if roles.stocktake.add %}
<button class='btn btn-success' type='button' id='btn-stocktake' title='{% trans "Add stocktake information" %}'> <button class='btn btn-success' type='button' id='btn-stocktake' title='{% trans "Add stocktake information" %}'>
<span class='fas fa-clipboard-check'></span> {% trans "Stocktake" %} <span class='fas fa-clipboard-check'></span> {% trans "Stocktake" %}
</button> </button>
@ -468,18 +469,24 @@
// Load the "stocktake" tab // Load the "stocktake" tab
onPanelLoad('stocktake', function() { onPanelLoad('stocktake', function() {
loadPartStocktakeTable({{ part.pk }}, { loadPartStocktakeTable({{ part.pk }}, {
admin: {% js_bool user.is_staff %}, allow_edit: {% js_bool roles.stocktake.change %},
allow_edit: {% js_bool roles.part.change %}, allow_delete: {% js_bool roles.stocktake.delete %},
allow_delete: {% js_bool roles.part.delete %},
}); });
{% if roles.stocktake.add %}
$('#btn-stocktake').click(function() { $('#btn-stocktake').click(function() {
performStocktake({{ part.pk }}, { generateStocktakeReport({
onSuccess: function() { part: {
$('#part-stocktake-table').bootstrapTable('refresh'); value: {{ part.pk }}
} },
location: {},
generate_report: {
value: false,
},
update_parts: {},
}); });
}); });
{% endif %}
}); });
// Load the "suppliers" tab // Load the "suppliers" tab

View File

@ -342,12 +342,12 @@
{% if stocktake %} {% if stocktake %}
<tr> <tr>
<td><span class='fas fa-clipboard-check'></span></td> <td><span class='fas fa-clipboard-check'></span></td>
<td>{% trans "Last Stocktake" %}</td>
<td> <td>
{% decimal stocktake.quantity %} <span class='fas fa-calendar-alt' title='{% render_date stocktake.date %}'></span> {% trans "Last Stocktake" %}
<span class='badge bg-dark rounded-pill float-right'> </td>
{{ stocktake.user.username }} <td>
</span> {% decimal stocktake.quantity %}
<span class='badge bg-dark rounded-pill float-right'>{{ stocktake.user.username }}</span>
</td> </td>
</tr> </tr>
{% endif %} {% endif %}

View File

@ -44,8 +44,9 @@
{% trans "Scheduling" as text %} {% trans "Scheduling" as text %}
{% include "sidebar_item.html" with label="scheduling" text=text icon="fa-calendar-alt" %} {% include "sidebar_item.html" with label="scheduling" text=text icon="fa-calendar-alt" %}
{% endif %} {% endif %}
{% settings_value 'STOCKTAKE_ENABLE' as stocktake_enable %}
{% settings_value 'DISPLAY_STOCKTAKE_TAB' user=request.user as show_stocktake %} {% settings_value 'DISPLAY_STOCKTAKE_TAB' user=request.user as show_stocktake %}
{% if show_stocktake %} {% if roles.stocktake.view and stocktake_enable and show_stocktake %}
{% trans "Stocktake" as text %} {% trans "Stocktake" as text %}
{% include "sidebar_item.html" with label="stocktake" text=text icon="fa-clipboard-check" %} {% include "sidebar_item.html" with label="stocktake" text=text icon="fa-clipboard-check" %}
{% endif %} {% endif %}

View File

@ -1,6 +1,10 @@
{% load i18n %} {% load i18n %}
{% load inventree_extras %} {% load inventree_extras %}
<div id='part-stocktake' style='max-height: 300px;'>
<canvas id='part-stocktake-chart' width='100%' style='max-height: 300px;'></canvas>
</div>
<div id='part-stocktake-toolbar'> <div id='part-stocktake-toolbar'>
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
{% include "filter_list.html" with id="partstocktake" %} {% include "filter_list.html" with id="partstocktake" %}

View File

@ -2839,6 +2839,7 @@ class PartStocktakeTest(InvenTreeAPITestCase):
'category', 'category',
'part', 'part',
'location', 'location',
'stock',
] ]
def test_list_endpoint(self): def test_list_endpoint(self):
@ -2887,8 +2888,8 @@ class PartStocktakeTest(InvenTreeAPITestCase):
url = reverse('api-part-stocktake-list') url = reverse('api-part-stocktake-list')
self.assignRole('part.add') self.assignRole('stocktake.add')
self.assignRole('part.view') self.assignRole('stocktake.view')
for p in Part.objects.all(): for p in Part.objects.all():
@ -2930,12 +2931,6 @@ class PartStocktakeTest(InvenTreeAPITestCase):
self.assignRole('part.view') self.assignRole('part.view')
# Test we can retrieve via API # Test we can retrieve via API
self.get(url, expected_code=403)
# Assign staff permission
self.user.is_staff = True
self.user.save()
self.get(url, expected_code=200) self.get(url, expected_code=200)
# Try to edit data # Try to edit data
@ -2948,7 +2943,7 @@ class PartStocktakeTest(InvenTreeAPITestCase):
) )
# Assign 'edit' role permission # Assign 'edit' role permission
self.assignRole('part.change') self.assignRole('stocktake.change')
# Try again # Try again
self.patch( self.patch(
@ -2962,6 +2957,59 @@ class PartStocktakeTest(InvenTreeAPITestCase):
# Try to delete # Try to delete
self.delete(url, expected_code=403) self.delete(url, expected_code=403)
self.assignRole('part.delete') self.assignRole('stocktake.delete')
self.delete(url, expected_code=204) self.delete(url, expected_code=204)
def test_report_list(self):
"""Test for PartStocktakeReport list endpoint"""
from part.tasks import generate_stocktake_report
n_parts = Part.objects.count()
# Initially, no stocktake records are available
self.assertEqual(PartStocktake.objects.count(), 0)
# Generate stocktake data for all parts (default configuration)
generate_stocktake_report()
# There should now be 1 stocktake entry for each part
self.assertEqual(PartStocktake.objects.count(), n_parts)
self.assignRole('stocktake.view')
response = self.get(reverse('api-part-stocktake-list'), expected_code=200)
self.assertEqual(len(response.data), n_parts)
# Stocktake report should be available via the API, also
response = self.get(reverse('api-part-stocktake-report-list'), expected_code=200)
self.assertEqual(len(response.data), 1)
data = response.data[0]
self.assertEqual(data['part_count'], 14)
self.assertEqual(data['user'], None)
self.assertTrue(data['report'].endswith('.csv'))
def test_report_generate(self):
"""Test API functionality for generating a new stocktake report"""
url = reverse('api-part-stocktake-report-generate')
# Permission denied, initially
self.assignRole('stocktake.view')
response = self.post(url, data={}, expected_code=403)
# Stocktake functionality disabled
InvenTreeSetting.set_setting('STOCKTAKE_ENABLE', False, None)
self.assignRole('stocktake.add')
response = self.post(url, data={}, expected_code=400)
self.assertIn('Stocktake functionality is not enabled', str(response.data))
InvenTreeSetting.set_setting('STOCKTAKE_ENABLE', True, None)
response = self.post(url, data={}, expected_code=400)
self.assertIn('Background worker check failed', str(response.data))

View File

@ -125,6 +125,8 @@ def allow_table_event(table_name):
'common_webhookendpoint', 'common_webhookendpoint',
'common_webhookmessage', 'common_webhookmessage',
'part_partpricing', 'part_partpricing',
'part_partstocktake',
'part_partstocktakereport',
] ]
if table_name in ignore_tables: if table_name in ignore_tables:

View File

@ -109,7 +109,7 @@ class PluginsRegistry:
full_reload (bool, optional): Reload everything - including plugin mechanism. Defaults to False. full_reload (bool, optional): Reload everything - including plugin mechanism. Defaults to False.
""" """
logger.info('Start loading plugins') logger.info('Loading plugins')
# Set maintanace mode # Set maintanace mode
_maintenance = bool(get_maintenance_mode()) _maintenance = bool(get_maintenance_mode())
@ -268,7 +268,7 @@ class PluginsRegistry:
# Collect plugins from paths # Collect plugins from paths
for plugin in self.plugin_dirs(): for plugin in self.plugin_dirs():
logger.info(f"Loading plugins from directory '{plugin}'") logger.debug(f"Loading plugins from directory '{plugin}'")
parent_path = None parent_path = None
parent_obj = Path(plugin) parent_obj = Path(plugin)
@ -306,7 +306,7 @@ class PluginsRegistry:
# Log collected plugins # Log collected plugins
logger.info(f'Collected {len(collected_plugins)} plugins!') logger.info(f'Collected {len(collected_plugins)} plugins!')
logger.info(", ".join([a.__module__ for a in collected_plugins])) logger.debug(", ".join([a.__module__ for a in collected_plugins]))
return collected_plugins return collected_plugins
@ -383,7 +383,7 @@ class PluginsRegistry:
self.plugins_inactive[key] = plugin.db self.plugins_inactive[key] = plugin.db
self.plugins_full[key] = plugin self.plugins_full[key] = plugin
logger.info('Starting plugin initialisation') logger.debug('Starting plugin initialisation')
# Initialize plugins # Initialize plugins
for plg in self.plugin_modules: for plg in self.plugin_modules:
@ -425,9 +425,10 @@ class PluginsRegistry:
# Initialize package - we can be sure that an admin has activated the plugin # Initialize package - we can be sure that an admin has activated the plugin
logger.info(f'Loading plugin `{plg_name}`') logger.info(f'Loading plugin `{plg_name}`')
try: try:
plg_i: InvenTreePlugin = plg() plg_i: InvenTreePlugin = plg()
logger.info(f'Loaded plugin `{plg_name}`') logger.debug(f'Loaded plugin `{plg_name}`')
except Exception as error: except Exception as error:
handle_error(error, log_name='init') # log error and raise it -> disable plugin handle_error(error, log_name='init') # log error and raise it -> disable plugin

View File

@ -19,10 +19,10 @@ import InvenTree.helpers
import InvenTree.serializers import InvenTree.serializers
import part.models as part_models import part.models as part_models
import stock.filters import stock.filters
from common.settings import currency_code_default, currency_code_mappings
from company.serializers import SupplierPartSerializer from company.serializers import SupplierPartSerializer
from InvenTree.models import extract_int from InvenTree.models import extract_int
from InvenTree.serializers import InvenTreeDecimalField from InvenTree.serializers import (InvenTreeCurrencySerializer,
InvenTreeDecimalField)
from part.serializers import PartBriefSerializer from part.serializers import PartBriefSerializer
from .models import (StockItem, StockItemAttachment, StockItemTestResult, from .models import (StockItem, StockItemAttachment, StockItemTestResult,
@ -171,17 +171,11 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
purchase_price = InvenTree.serializers.InvenTreeMoneySerializer( purchase_price = InvenTree.serializers.InvenTreeMoneySerializer(
label=_('Purchase Price'), label=_('Purchase Price'),
max_digits=19, decimal_places=6,
allow_null=True, allow_null=True,
help_text=_('Purchase price of this stock item'), help_text=_('Purchase price of this stock item'),
) )
purchase_price_currency = serializers.ChoiceField( purchase_price_currency = InvenTreeCurrencySerializer(help_text=_('Purchase currency of this stock item'))
choices=currency_code_mappings(),
default=currency_code_default,
label=_('Currency'),
help_text=_('Purchase currency of this stock item'),
)
purchase_order_reference = serializers.CharField(source='purchase_order.reference', read_only=True) purchase_order_reference = serializers.CharField(source='purchase_order.reference', read_only=True)
sales_order_reference = serializers.CharField(source='sales_order.reference', read_only=True) sales_order_reference = serializers.CharField(source='sales_order.reference', read_only=True)

View File

@ -32,6 +32,12 @@
{% url 'admin:stock_stocklocation_change' location.pk as url %} {% url 'admin:stock_stocklocation_change' location.pk as url %}
{% include "admin_button.html" with url=url %} {% include "admin_button.html" with url=url %}
{% endif %} {% endif %}
{% settings_value "STOCKTAKE_ENABLE" as stocktake_enable %}
{% if stocktake_enable and roles.stocktake.add %}
<button type='button' class='btn btn-outline-secondary' id='location-stocktake' title='{% trans "Perform stocktake for this stock location" %}'>
<span class='fas fa-clipboard-check'></span>
</button>
{% endif %}
{% mixin_available "locate" as locate_available %} {% mixin_available "locate" as locate_available %}
{% if location and plugins_enabled and locate_available %} {% if location and plugins_enabled and locate_available %}
@ -246,6 +252,20 @@
{% block js_ready %} {% block js_ready %}
{{ block.super }} {{ block.super }}
{% settings_value "STOCKTAKE_ENABLE" as stocktake_enable %}
{% if stocktake_enable and roles.stocktake.add %}
$('#location-stocktake').click(function() {
generateStocktakeReport({
category: {},
location: {
{% if location %}value: {{ location.pk }},{% endif %}
},
generate_report: {},
update_parts: {},
});
});
{% endif %}
{% if plugins_enabled and location %} {% if plugins_enabled and location %}
$('#locate-location-button').click(function() { $('#locate-location-button').click(function() {
locateItemOrLocation({ locateItemOrLocation({

View File

@ -0,0 +1,45 @@
{% extends "panel.html" %}
{% load i18n %}
{% block label %}stocktake{% endblock %}
{% block heading %}
{% trans "Stocktake Settings" %}
{% endblock %}
{% block panel_content %}
<div class='panel-content'>
<table class='table table-striped table-condensed'>
<tbody>
{% include "InvenTree/settings/setting.html" with key="STOCKTAKE_ENABLE" icon="fa-clipboard-check" %}
{% include "InvenTree/settings/setting.html" with key="STOCKTAKE_AUTO_DAYS" icon="fa-calendar-alt" %}
{% include "InvenTree/settings/setting.html" with key="STOCKTAKE_DELETE_REPORT_DAYS" icon="fa-trash-alt" %}
</tbody>
</table>
</div>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Stocktake Reports" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
{% if roles.stocktake.add %}
<button type='button' id='btn-generate-stocktake' class='btn btn-primary float-right'>
<span class='fas fa-clipboard-check'></span> {% trans "Stocktake" %}
</button>
{% endif %}
</div>
</div>
</div>
<div class='panel-content'>
<div id='part-stocktake-report-toolbar'>
<div class='btn-group' role='group'>
{% include "filter_list.html" with id="stocktakereport" %}
</div>
</div>
<table class='table table-striped table-condensed' id='stocktake-report-table' data-toolbar='#part-stocktake-report-toolbar'></table>
</div>
{% endblock panel_content %}

View File

@ -36,8 +36,9 @@
{% include "InvenTree/settings/label.html" %} {% include "InvenTree/settings/label.html" %}
{% include "InvenTree/settings/report.html" %} {% include "InvenTree/settings/report.html" %}
{% include "InvenTree/settings/part.html" %} {% include "InvenTree/settings/part.html" %}
{% include "InvenTree/settings/pricing.html" %} {% include "InvenTree/settings/part_stocktake.html" %}
{% include "InvenTree/settings/category.html" %} {% include "InvenTree/settings/category.html" %}
{% include "InvenTree/settings/pricing.html" %}
{% include "InvenTree/settings/stock.html" %} {% include "InvenTree/settings/stock.html" %}
{% include "InvenTree/settings/build.html" %} {% include "InvenTree/settings/build.html" %}
{% include "InvenTree/settings/po.html" %} {% include "InvenTree/settings/po.html" %}
@ -62,426 +63,16 @@
{% block js_ready %} {% block js_ready %}
{{ block.super }} {{ block.super }}
// Callback for when boolean settings are edited {% include "InvenTree/settings/settings_js.html" %}
$('table').find('.boolean-setting').change(function() {
var pk = $(this).attr('pk'); {% if user.is_staff %}
var setting = $(this).attr('setting'); {% include "InvenTree/settings/settings_staff_js.html" %}
var plugin = $(this).attr('plugin'); {% plugins_enabled as plug %}
var user = $(this).attr('user'); {% if plug %}
var notification = $(this).attr('notification'); $("#install-plugin").click(function() {
var checked = this.checked;
// Global setting by default
var url = `/api/settings/global/${setting}/`;
if (notification) {
url = `/api/settings/notification/${pk}/`;
} else if (plugin) {
url = `/api/plugins/settings/${plugin}/${setting}/`;
} else if (user) {
url = `/api/settings/user/${setting}/`;
}
inventreePut(
url,
{
value: checked.toString(),
},
{
method: 'PATCH',
success: function(data) {
},
error: function(xhr) {
showApiError(xhr, url);
}
}
);
});
// Callback for when non-boolean settings are edited
$('table').find('.btn-edit-setting').click(function() {
var setting = $(this).attr('setting');
var plugin = $(this).attr('plugin');
var is_global = true;
var notification = $(this).attr('notification');
if ($(this).attr('user')){
is_global = false;
}
var title = '';
if (plugin != null) {
title = '{% trans "Edit Plugin Setting" %}';
} else if (notification) {
title = '{% trans "Edit Notification Setting" %}';
setting = $(this).attr('pk');
} else if (is_global) {
title = '{% trans "Edit Global Setting" %}';
} else {
title = '{% trans "Edit User Setting" %}';
}
editSetting(setting, {
plugin: plugin,
global: is_global,
notification: notification,
title: title,
});
});
$("#edit-user").on('click', function() {
launchModalForm(
"{% url 'edit-user' %}",
{
reload: true,
}
);
});
$("#edit-password").on('click', function() {
launchModalForm(
"{% url 'set-password' %}",
{
reload: true,
}
);
});
$('#btn-update-rates').click(function() {
inventreePut(
'{% url "api-currency-refresh" %}',
{},
{
method: 'POST',
success: function(data) {
location.reload();
}
}
);
});
$('#exchange-rate-table').inventreeTable({
url: '{% url "api-currency-exchange" %}',
search: false,
showColumns: false,
sortable: true,
sidePagination: 'client',
onLoadSuccess: function(response) {
var data = response.exchange_rates || {};
var rows = [];
for (var currency in data) {
rows.push({
'currency': currency,
'rate': data[currency],
});
}
$('#exchange-rate-table').bootstrapTable('load', rows);
},
columns: [
{
field: 'currency',
sortable: true,
title: '{% trans "Currency" %}',
},
{
field: 'rate',
sortable: true,
title: '{% trans "Rate" %}',
}
]
});
$('#category-select').select2({
placeholder: '',
width: '100%',
ajax: {
url: '{% url "api-part-category-list" %}',
dataType: 'json',
delay: 250,
cache: false,
data: function(params) {
if (!params.page) {
offset = 0;
} else {
offset = (params.page - 1) * 25;
}
return {
search: params.term,
offset: offset,
limit: 25,
};
},
processResults: function(response) {
var data = [];
var more = false;
if ('count' in response && 'results' in response) {
// Response is paginated
data = response.results;
// Any more data available?
if (response.next) {
more = true;
}
} else {
// Non-paginated response
data = response;
}
// Each 'row' must have the 'id' attribute
for (var idx = 0; idx < data.length; idx++) {
data[idx].id = data[idx].pk;
data[idx].text = data[idx].pathstring;
}
// Ref: https://select2.org/data-sources/formats
var results = {
results: data,
pagination: {
more: more,
}
};
return results;
}
},
});
$('#cat-param-table').inventreeTable({
formatNoMatches: function() { return '{% trans "No category parameter templates found" %}'; },
columns: [
{
field: 'pk',
title: 'ID',
visible: false,
switchable: false,
},
{
field: 'parameter_template_detail.name',
title: '{% trans "Parameter Template" %}',
sortable: 'true',
},
{
field: 'category_detail.pathstring',
title: '{% trans "Category" %}',
},
{
field: 'default_value',
title: '{% trans "Default Value" %}',
sortable: 'true',
formatter: function(value, row, index, field) {
var bEdit = "<button title='{% trans "Edit Template" %}' class='template-edit btn btn-outline-secondary' type='button' pk='" + row.pk + "'><span class='fas fa-edit'></span></button>";
var bDel = "<button title='{% trans "Delete Template" %}' class='template-delete btn btn-outline-secondary' type='button' pk='" + row.pk + "'><span class='fas fa-trash-alt icon-red'></span></button>";
var html = value
html += "<div class='btn-group float-right' role='group'>" + bEdit + bDel + "</div>";
return html;
}
}
]
});
function loadTemplateTable(pk) {
var query = {};
if (pk) {
query['category'] = pk;
}
// Load the parameter table
$("#cat-param-table").bootstrapTable('refresh', {
query: query,
url: '{% url "api-part-category-parameter-list" %}',
});
}
// Initially load table with *all* categories
loadTemplateTable();
$('body').on('change', '#category-select', function() {
var pk = $(this).val();
loadTemplateTable(pk);
});
$("#new-cat-param").click(function() {
var pk = $('#category-select').val();
constructForm('{% url "api-part-category-parameter-list" %}', {
title: '{% trans "Create Category Parameter Template" %}',
method: 'POST',
fields: {
parameter_template: {},
category: {
icon: 'fa-sitemap',
value: pk,
},
default_value: {},
},
onSuccess: function() {
loadTemplateTable(pk);
}
});
});
$("#cat-param-table").on('click', '.template-edit', function() {
var category = $('#category-select').val();
var pk = $(this).attr('pk');
constructForm(`/api/part/category/parameters/${pk}/`, {
fields: {
parameter_template: {},
category: {
icon: 'fa-sitemap',
},
default_value: {},
},
onSuccess: function() {
loadTemplateTable(pk);
}
});
});
$("#cat-param-table").on('click', '.template-delete', function() {
var category = $('#category-select').val();
var pk = $(this).attr('pk');
var url = `/part/category/${category}/parameters/${pk}/delete/`;
constructForm(`/api/part/category/parameters/${pk}/`, {
method: 'DELETE',
title: '{% trans "Delete Category Parameter Template" %}',
onSuccess: function() {
loadTemplateTable(pk);
}
});
});
$("#param-table").inventreeTable({
url: "{% url 'api-part-parameter-template-list' %}",
queryParams: {
ordering: 'name',
},
formatNoMatches: function() { return '{% trans "No part parameter templates found" %}'; },
columns: [
{
field: 'pk',
title: '{% trans "ID" %}',
visible: false,
switchable: false,
},
{
field: 'name',
title: '{% trans "Name" %}',
sortable: true,
},
{
field: 'units',
title: '{% trans "Units" %}',
sortable: true,
switchable: true,
},
{
field: 'description',
title: '{% trans "Description" %}',
sortable: false,
switchable: true,
},
{
formatter: function(value, row, index, field) {
var bEdit = "<button title='{% trans "Edit Template" %}' class='template-edit btn btn-outline-secondary' type='button' pk='" + row.pk + "'><span class='fas fa-edit icon-green'></span></button>";
var bDel = "<button title='{% trans "Delete Template" %}' class='template-delete btn btn-outline-secondary' type='button' pk='" + row.pk + "'><span class='fas fa-trash-alt icon-red'></span></button>";
var html = "<div class='btn-group float-right' role='group'>" + bEdit + bDel + "</div>";
return html;
}
}
]
});
$("#new-param").click(function() {
constructForm('{% url "api-part-parameter-template-list" %}', {
fields: {
name: {},
units: {},
description: {},
},
method: 'POST',
title: '{% trans "Create Part Parameter Template" %}',
onSuccess: function() {
$("#param-table").bootstrapTable('refresh');
},
});
});
$("#param-table").on('click', '.template-edit', function() {
var button = $(this);
var pk = button.attr('pk');
constructForm(
`/api/part/parameter/template/${pk}/`,
{
fields: {
name: {},
units: {},
description: {},
},
title: '{% trans "Edit Part Parameter Template" %}',
onSuccess: function() {
$("#param-table").bootstrapTable('refresh');
},
}
);
});
$("#param-table").on('click', '.template-delete', function() {
var button = $(this);
var pk = button.attr('pk');
var html = `
<div class='alert alert-block alert-danger'>
{% trans "Any parameters which reference this template will also be deleted" %}
</div>`;
constructForm(
`/api/part/parameter/template/${pk}/`,
{
method: 'DELETE',
preFormContent: html,
title: '{% trans "Delete Part Parameter Template" %}',
onSuccess: function() {
$("#param-table").bootstrapTable('refresh');
},
}
);
});
$("#import-part").click(function() {
launchModalForm("{% url 'api-part-import' %}?reset", {});
});
{% plugins_enabled as plug %}
{% if plug %}
$("#install-plugin").click(function() {
installPlugin(); installPlugin();
}); });
{% endif %}
{% endif %} {% endif %}
enableSidebar('settings'); enableSidebar('settings');

View File

@ -0,0 +1,92 @@
{% load i18n %}
{% load static %}
{% load inventree_extras %}
// Callback for when boolean settings are edited
$('table').find('.boolean-setting').change(function() {
var pk = $(this).attr('pk');
var setting = $(this).attr('setting');
var plugin = $(this).attr('plugin');
var user = $(this).attr('user');
var notification = $(this).attr('notification');
var checked = this.checked;
// Global setting by default
var url = `/api/settings/global/${setting}/`;
if (notification) {
url = `/api/settings/notification/${pk}/`;
} else if (plugin) {
url = `/api/plugins/settings/${plugin}/${setting}/`;
} else if (user) {
url = `/api/settings/user/${setting}/`;
}
inventreePut(
url,
{
value: checked.toString(),
},
{
method: 'PATCH',
success: function(data) {
},
error: function(xhr) {
showApiError(xhr, url);
}
}
);
});
// Callback for when non-boolean settings are edited
$('table').find('.btn-edit-setting').click(function() {
var setting = $(this).attr('setting');
var plugin = $(this).attr('plugin');
var is_global = true;
var notification = $(this).attr('notification');
if ($(this).attr('user')){
is_global = false;
}
var title = '';
if (plugin != null) {
title = '{% trans "Edit Plugin Setting" %}';
} else if (notification) {
title = '{% trans "Edit Notification Setting" %}';
setting = $(this).attr('pk');
} else if (is_global) {
title = '{% trans "Edit Global Setting" %}';
} else {
title = '{% trans "Edit User Setting" %}';
}
editSetting(setting, {
plugin: plugin,
global: is_global,
notification: notification,
title: title,
});
});
$("#edit-user").on('click', function() {
launchModalForm(
"{% url 'edit-user' %}",
{
reload: true,
}
);
});
$("#edit-password").on('click', function() {
launchModalForm(
"{% url 'set-password' %}",
{
reload: true,
}
);
});

View File

@ -0,0 +1,401 @@
{% load i18n %}
{% load static %}
{% load inventree_extras %}
// Javascript for Pricing panel
onPanelLoad('pricing', function() {
$('#btn-update-rates').click(function() {
inventreePut(
'{% url "api-currency-refresh" %}',
{},
{
method: 'POST',
success: function(data) {
location.reload();
}
}
);
});
$('#exchange-rate-table').inventreeTable({
url: '{% url "api-currency-exchange" %}',
search: false,
showColumns: false,
sortable: true,
sidePagination: 'client',
onLoadSuccess: function(response) {
var data = response.exchange_rates || {};
var rows = [];
for (var currency in data) {
rows.push({
'currency': currency,
'rate': data[currency],
});
}
$('#exchange-rate-table').bootstrapTable('load', rows);
},
columns: [
{
field: 'currency',
sortable: true,
title: '{% trans "Currency" %}',
},
{
field: 'rate',
sortable: true,
title: '{% trans "Rate" %}',
}
]
});
});
// Javascript for Part Category panel
onPanelLoad('category', function() {
$('#category-select').select2({
placeholder: '',
width: '100%',
ajax: {
url: '{% url "api-part-category-list" %}',
dataType: 'json',
delay: 250,
cache: false,
data: function(params) {
if (!params.page) {
offset = 0;
} else {
offset = (params.page - 1) * 25;
}
return {
search: params.term,
offset: offset,
limit: 25,
};
},
processResults: function(response) {
var data = [];
var more = false;
if ('count' in response && 'results' in response) {
// Response is paginated
data = response.results;
// Any more data available?
if (response.next) {
more = true;
}
} else {
// Non-paginated response
data = response;
}
// Each 'row' must have the 'id' attribute
for (var idx = 0; idx < data.length; idx++) {
data[idx].id = data[idx].pk;
data[idx].text = data[idx].pathstring;
}
// Ref: https://select2.org/data-sources/formats
var results = {
results: data,
pagination: {
more: more,
}
};
return results;
}
},
});
$('#cat-param-table').inventreeTable({
formatNoMatches: function() { return '{% trans "No category parameter templates found" %}'; },
columns: [
{
field: 'pk',
title: 'ID',
visible: false,
switchable: false,
},
{
field: 'parameter_template_detail.name',
title: '{% trans "Parameter Template" %}',
sortable: 'true',
},
{
field: 'category_detail.pathstring',
title: '{% trans "Category" %}',
},
{
field: 'default_value',
title: '{% trans "Default Value" %}',
sortable: 'true',
formatter: function(value, row, index, field) {
var bEdit = "<button title='{% trans "Edit Template" %}' class='template-edit btn btn-outline-secondary' type='button' pk='" + row.pk + "'><span class='fas fa-edit'></span></button>";
var bDel = "<button title='{% trans "Delete Template" %}' class='template-delete btn btn-outline-secondary' type='button' pk='" + row.pk + "'><span class='fas fa-trash-alt icon-red'></span></button>";
var html = value
html += "<div class='btn-group float-right' role='group'>" + bEdit + bDel + "</div>";
return html;
}
}
]
});
$("#cat-param-table").on('click', '.template-edit', function() {
var category = $('#category-select').val();
var pk = $(this).attr('pk');
constructForm(`/api/part/category/parameters/${pk}/`, {
fields: {
parameter_template: {},
category: {
icon: 'fa-sitemap',
},
default_value: {},
},
onSuccess: function() {
loadTemplateTable(pk);
}
});
});
$("#cat-param-table").on('click', '.template-delete', function() {
var category = $('#category-select').val();
var pk = $(this).attr('pk');
var url = `/part/category/${category}/parameters/${pk}/delete/`;
constructForm(`/api/part/category/parameters/${pk}/`, {
method: 'DELETE',
title: '{% trans "Delete Category Parameter Template" %}',
onSuccess: function() {
loadTemplateTable(pk);
}
});
});
function loadTemplateTable(pk) {
var query = {};
if (pk) {
query['category'] = pk;
}
// Load the parameter table
$("#cat-param-table").bootstrapTable('refresh', {
query: query,
url: '{% url "api-part-category-parameter-list" %}',
});
}
// Initially load table with *all* categories
loadTemplateTable();
$('body').on('change', '#category-select', function() {
var pk = $(this).val();
loadTemplateTable(pk);
});
$("#new-cat-param").click(function() {
var pk = $('#category-select').val();
constructForm('{% url "api-part-category-parameter-list" %}', {
title: '{% trans "Create Category Parameter Template" %}',
method: 'POST',
fields: {
parameter_template: {},
category: {
icon: 'fa-sitemap',
value: pk,
},
default_value: {},
},
onSuccess: function() {
loadTemplateTable(pk);
}
});
});
});
// Javascript for the Part settings panel
onPanelLoad('parts', function() {
$("#param-table").inventreeTable({
url: "{% url 'api-part-parameter-template-list' %}",
queryParams: {
ordering: 'name',
},
formatNoMatches: function() { return '{% trans "No part parameter templates found" %}'; },
columns: [
{
field: 'pk',
title: '{% trans "ID" %}',
visible: false,
switchable: false,
},
{
field: 'name',
title: '{% trans "Name" %}',
sortable: true,
},
{
field: 'units',
title: '{% trans "Units" %}',
sortable: true,
switchable: true,
},
{
field: 'description',
title: '{% trans "Description" %}',
sortable: false,
switchable: true,
},
{
formatter: function(value, row, index, field) {
var bEdit = "<button title='{% trans "Edit Template" %}' class='template-edit btn btn-outline-secondary' type='button' pk='" + row.pk + "'><span class='fas fa-edit icon-green'></span></button>";
var bDel = "<button title='{% trans "Delete Template" %}' class='template-delete btn btn-outline-secondary' type='button' pk='" + row.pk + "'><span class='fas fa-trash-alt icon-red'></span></button>";
var html = "<div class='btn-group float-right' role='group'>" + bEdit + bDel + "</div>";
return html;
}
}
]
});
$("#new-param").click(function() {
constructForm('{% url "api-part-parameter-template-list" %}', {
fields: {
name: {},
units: {},
description: {},
},
method: 'POST',
title: '{% trans "Create Part Parameter Template" %}',
onSuccess: function() {
$("#param-table").bootstrapTable('refresh');
},
});
});
$("#param-table").on('click', '.template-edit', function() {
var button = $(this);
var pk = button.attr('pk');
constructForm(
`/api/part/parameter/template/${pk}/`,
{
fields: {
name: {},
units: {},
description: {},
},
title: '{% trans "Edit Part Parameter Template" %}',
onSuccess: function() {
$("#param-table").bootstrapTable('refresh');
},
}
);
});
$("#param-table").on('click', '.template-delete', function() {
var button = $(this);
var pk = button.attr('pk');
var html = `
<div class='alert alert-block alert-danger'>
{% trans "Any parameters which reference this template will also be deleted" %}
</div>`;
constructForm(
`/api/part/parameter/template/${pk}/`,
{
method: 'DELETE',
preFormContent: html,
title: '{% trans "Delete Part Parameter Template" %}',
onSuccess: function() {
$("#param-table").bootstrapTable('refresh');
},
}
);
});
$("#import-part").click(function() {
launchModalForm("{% url 'api-part-import' %}?reset", {});
});
});
// Javascript for the Stocktake settings panel
onPanelLoad('stocktake', function() {
{% if roles.stocktake.view %}
var table = '#stocktake-report-table';
var filters = loadTableFilters('stocktakereport');
setupFilterList('stocktakereport', $(table), '#filter-list-stocktakereport');
$(table).inventreeTable({
url: '{% url "api-part-stocktake-report-list" %}',
search: false,
queryParams: filters,
name: 'stocktakereport',
showColumns: false,
sidePagination: 'server',
sortable: true,
sortName: 'date',
sortOrder: 'desc',
columns: [
{
field: 'report',
title: '{% trans "Report" %}',
formatter: function(value, row) {
return attachmentLink(value);
}
},
{
field: 'part_count',
title: '{% trans "Part Count" %}',
},
{
field: 'date',
title: '{% trans "Date" %}',
sortable: true,
formatter: function(value, row) {
let html = renderDate(value);
if (row.user_detail) {
html += `<span class='badge bg-dark rounded-pill float-right'>${row.user_detail.username}</span>`;
}
return html;
}
},
]
});
{% endif %}
{% if roles.stocktake.add %}
$('#btn-generate-stocktake').click(function() {
generateStocktakeReport({
part: {},
category: {},
location: {},
generate_report: {},
update_parts: {},
});
});
{% endif %}
});

View File

@ -40,12 +40,14 @@
{% include "sidebar_item.html" with label='labels' text=text icon='fa-tag' %} {% include "sidebar_item.html" with label='labels' text=text icon='fa-tag' %}
{% trans "Reporting" as text %} {% trans "Reporting" as text %}
{% include "sidebar_item.html" with label='reporting' text=text icon="fa-file-pdf" %} {% include "sidebar_item.html" with label='reporting' text=text icon="fa-file-pdf" %}
{% trans "Parts" as text %}
{% include "sidebar_item.html" with label='parts' text=text icon="fa-shapes" %}
{% trans "Categories" as text %} {% trans "Categories" as text %}
{% include "sidebar_item.html" with label='category' text=text icon="fa-sitemap" %} {% include "sidebar_item.html" with label='category' text=text icon="fa-sitemap" %}
{% trans "Parts" as text %}
{% include "sidebar_item.html" with label='parts' text=text icon="fa-shapes" %}
{% trans "Stock" as text %} {% trans "Stock" as text %}
{% include "sidebar_item.html" with label='stock' text=text icon="fa-boxes" %} {% include "sidebar_item.html" with label='stock' text=text icon="fa-boxes" %}
{% trans "Stocktake" as text %}
{% include "sidebar_item.html" with label='stocktake' text=text icon="fa-clipboard-check" %}
{% trans "Build Orders" as text %} {% trans "Build Orders" as text %}
{% include "sidebar_item.html" with label='build-order' text=text icon="fa-tools" %} {% include "sidebar_item.html" with label='build-order' text=text icon="fa-tools" %}
{% trans "Purchase Orders" as text %} {% trans "Purchase Orders" as text %}

View File

@ -21,6 +21,7 @@
{% include "InvenTree/settings/setting.html" with key="STOCK_ALLOW_EXPIRED_BUILD" icon="fa-tools" %} {% include "InvenTree/settings/setting.html" with key="STOCK_ALLOW_EXPIRED_BUILD" icon="fa-tools" %}
{% include "InvenTree/settings/setting.html" with key="STOCK_OWNERSHIP_CONTROL" icon="fa-users" %} {% include "InvenTree/settings/setting.html" with key="STOCK_OWNERSHIP_CONTROL" icon="fa-users" %}
{% include "InvenTree/settings/setting.html" with key="STOCK_LOCATION_DEFAULT_ICON" icon="fa-icons" %} {% include "InvenTree/settings/setting.html" with key="STOCK_LOCATION_DEFAULT_ICON" icon="fa-icons" %}
</tbody> </tbody>
</table> </table>
{% endblock %} {% endblock %}

View File

@ -6,6 +6,7 @@
*/ */
/* exported /* exported
attachmentLink,
addAttachmentButtonCallbacks, addAttachmentButtonCallbacks,
loadAttachmentTable, loadAttachmentTable,
reloadAttachmentTable, reloadAttachmentTable,
@ -130,6 +131,50 @@ function reloadAttachmentTable() {
} }
/*
* Render a link (with icon) to an internal attachment (file)
*/
function attachmentLink(filename) {
if (!filename) {
return null;
}
// Default file icon (if no better choice is found)
let icon = 'fa-file-alt';
let fn = filename.toLowerCase();
// Look for some "known" file types
if (fn.endsWith('.csv')) {
icon = 'fa-file-csv';
} else if (fn.endsWith('.pdf')) {
icon = 'fa-file-pdf';
} else if (fn.endsWith('.xls') || fn.endsWith('.xlsx')) {
icon = 'fa-file-excel';
} else if (fn.endsWith('.doc') || fn.endsWith('.docx')) {
icon = 'fa-file-word';
} else if (fn.endsWith('.zip') || fn.endsWith('.7z')) {
icon = 'fa-file-archive';
} else {
let images = ['.png', '.jpg', '.bmp', '.gif', '.svg', '.tif'];
images.forEach(function(suffix) {
if (fn.endsWith(suffix)) {
icon = 'fa-file-image';
}
});
}
let split = filename.split('/');
fn = split[split.length - 1];
let html = `<span class='fas ${icon}'></span> ${fn}`;
return renderLink(html, filename, {download: true});
}
/* Load a table of attachments against a specific model. /* Load a table of attachments against a specific model.
* Note that this is a 'generic' table which is used for multiple attachment model classes * Note that this is a 'generic' table which is used for multiple attachment model classes
*/ */
@ -242,36 +287,7 @@ function loadAttachmentTable(url, options) {
formatter: function(value, row) { formatter: function(value, row) {
if (row.attachment) { if (row.attachment) {
var icon = 'fa-file-alt'; return attachmentLink(row.attachment);
var fn = value.toLowerCase();
if (fn.endsWith('.csv')) {
icon = 'fa-file-csv';
} else if (fn.endsWith('.pdf')) {
icon = 'fa-file-pdf';
} else if (fn.endsWith('.xls') || fn.endsWith('.xlsx')) {
icon = 'fa-file-excel';
} else if (fn.endsWith('.doc') || fn.endsWith('.docx')) {
icon = 'fa-file-word';
} else if (fn.endsWith('.zip') || fn.endsWith('.7z')) {
icon = 'fa-file-archive';
} else {
var images = ['.png', '.jpg', '.bmp', '.gif', '.svg', '.tif'];
images.forEach(function(suffix) {
if (fn.endsWith(suffix)) {
icon = 'fa-file-image';
}
});
}
var split = value.split('/');
var filename = split[split.length - 1];
var html = `<span class='fas ${icon}'></span> ${filename}`;
return renderLink(html, value, {download: true});
} else if (row.link) { } else if (row.link) {
var html = `<span class='fas fa-link'></span> ${row.link}`; var html = `<span class='fas fa-link'></span> ${row.link}`;
return renderLink(html, row.link); return renderLink(html, row.link);

View File

@ -974,6 +974,10 @@ function updateFieldValue(name, value, field, options) {
} }
switch (field.type) { switch (field.type) {
case 'decimal':
// Strip trailing zeros
el.val(formatDecimal(value));
break;
case 'boolean': case 'boolean':
if (value == true || value.toString().toLowerCase() == 'true') { if (value == true || value.toString().toLowerCase() == 'true') {
el.prop('checked'); el.prop('checked');

View File

@ -274,6 +274,10 @@ function renderLink(text, url, options={}) {
extras += ` title="${url}"`; extras += ` title="${url}"`;
} }
if (options.download) {
extras += ` download`;
}
return `<a href="${url}" ${extras}>${text}</a>`; return `<a href="${url}" ${extras}>${text}</a>`;
} }

View File

@ -50,25 +50,16 @@ function loadNotificationTable(table, options={}, enableDelete=false) {
title: '{% trans "Category" %}', title: '{% trans "Category" %}',
sortable: 'true', sortable: 'true',
}, },
{
field: 'target',
title: '{% trans "Item" %}',
sortable: 'true',
formatter: function(value, row, index, field) {
if (value == null) {
return '';
}
var html = `${value.model}: ${value.name}`;
if (value.link ) {
html = `<a href='${value.link}'>${html}</a>`;
}
return html;
}
},
{ {
field: 'name', field: 'name',
title: '{% trans "Name" %}', title: '{% trans "Notification" %}',
formatter: function(value, row) {
if (row.target && row.target.link) {
return renderLink(value, row.target.link);
} else {
return value;
}
}
}, },
{ {
field: 'message', field: 'message',

View File

@ -27,6 +27,7 @@
duplicatePart, duplicatePart,
editCategory, editCategory,
editPart, editPart,
generateStocktakeReport,
loadParametricPartTable, loadParametricPartTable,
loadPartCategoryTable, loadPartCategoryTable,
loadPartParameterTable, loadPartParameterTable,
@ -40,7 +41,6 @@
loadSimplePartTable, loadSimplePartTable,
partDetail, partDetail,
partStockLabel, partStockLabel,
performStocktake,
toggleStar, toggleStar,
validateBom, validateBom,
*/ */
@ -702,133 +702,178 @@ function partDetail(part, options={}) {
/* /*
* Guide user through "stocktake" process * Initiate generation of a stocktake report
*/ */
function performStocktake(partId, options={}) { function generateStocktakeReport(options={}) {
var part_quantity = 0; let fields = {
};
var date_threshold = moment().subtract(30, 'days'); if (options.part != null) {
fields.part = options.part;
// Helper function for formatting a StockItem row
function buildStockItemRow(item) {
var pk = item.pk;
// Part detail
var part = partDetail(item.part_detail, {
thumb: true,
});
// Location detail
var location = locationDetail(item);
// Quantity detail
var quantity = item.quantity;
part_quantity += item.quantity;
if (item.serial && item.quantity == 1) {
quantity = `{% trans "Serial" %}: ${item.serial}`;
} }
quantity += stockStatusDisplay(item.status, {classes: 'float-right'}); if (options.category != null) {
fields.category = options.category;
// Last update
var updated = item.stocktake_date || item.updated;
var update_rendered = renderDate(updated);
if (updated) {
if (moment(updated) < date_threshold) {
update_rendered += `<div class='float-right' title='{% trans "Stock item has not been checked recently" %}'><span class='fas fa-calendar-alt icon-red'></span></div>`;
}
} }
// Actions if (options.location != null) {
var actions = `<div class='btn-group float-right' role='group'>`; fields.location = options.location;
// TODO: Future work
// actions += makeIconButton('fa-check-circle icon-green', 'button-line-count', pk, '{% trans "Update item" %}');
// actions += makeIconButton('fa-trash-alt icon-red', 'button-line-delete', pk, '{% trans "Delete item" %}');
actions += `</div>`;
return `
<tr>
<td id='part-${pk}'>${part}</td>
<td id='loc-${pk}'>${location}</td>
<td id='quantity-${pk}'>${quantity}</td>
<td id='updated-${pk}'>${update_rendered}</td>
<td id='actions-${pk}'>${actions}</td>
</tr>`;
} }
// First, load stock information for the part if (options.generate_report) {
inventreeGet( fields.generate_report = options.generate_report;
'{% url "api-stock-list" %}', }
{
part: partId,
in_stock: true,
location_detail: true,
part_detail: true,
include_variants: true,
ordering: '-stock',
},
{
success: function(response) {
var html = '';
html += ` if (options.update_parts) {
<table class='table table-striped table-condensed'> fields.update_parts = options.update_parts;
<thead> }
<tr>
<th>{% trans "Stock Item" %}</th> let content = `
<th>{% trans "Location" %}</th> <div class='alert alert-block alert-info'>
<th>{% trans "Quantity" %}</th> {% trans "Schedule generation of a new stocktake report." %} {% trans "Once complete, the stocktake report will be available for download." %}
<th>{% trans "Updated" %}</th> </div>
<th><!-- Actions --></th>
</tr>
</thead>
<tbody>
`; `;
response.forEach(function(item) { constructForm(
html += buildStockItemRow(item); '{% url "api-part-stocktake-report-generate" %}',
}); {
html += `</tbody></table>`;
constructForm(`/api/part/stocktake/`, {
preFormContent: html,
method: 'POST', method: 'POST',
title: '{% trans "Part Stocktake" %}', title: '{% trans "Generate Stocktake Report" %}',
confirm: true, preFormContent: content,
fields: { fields: fields,
part: {
value: partId,
hidden: true,
},
quantity: {
value: part_quantity,
},
note: {},
},
onSuccess: function(response) { onSuccess: function(response) {
handleFormSuccess(response, options); showMessage('{% trans "Stocktake report scheduled" %}', {
} style: 'success',
}); });
} }
} }
); );
} }
var stocktakeChart = null;
/*
* Load chart to display part stocktake information
*/
function loadStocktakeChart(data, options={}) {
var chart = 'part-stocktake-chart';
var context = document.getElementById(chart);
var quantity_data = [];
var cost_min_data = [];
var cost_max_data = [];
var base_currency = baseCurrency();
var rate_data = getCurrencyConversionRates();
data.forEach(function(row) {
var date = moment(row.date);
quantity_data.push({
x: date,
y: row.quantity
});
if (row.cost_min) {
cost_min_data.push({
x: date,
y: convertCurrency(
row.cost_min,
row.cost_min_currency || base_currency,
base_currency,
rate_data
),
});
}
if (row.cost_max) {
cost_max_data.push({
x: date,
y: convertCurrency(
row.cost_max,
row.cost_max_currency || base_currency,
base_currency,
rate_data
),
});
}
});
var chart_data = {
datasets: [
{
label: '{% trans "Quantity" %}',
data: quantity_data,
backgroundColor: 'rgba(160, 80, 220, 0.75)',
borderWidth: 3,
borderColor: 'rgb(160, 80, 220)',
yAxisID: 'y',
},
{
label: '{% trans "Minimum Cost" %}',
data: cost_min_data,
backgroundColor: 'rgba(220, 160, 80, 0.25)',
borderWidth: 2,
borderColor: 'rgba(220, 160, 80, 0.35)',
borderDash: [10, 5],
yAxisID: 'y1',
fill: '+1',
},
{
label: '{% trans "Maximum Cost" %}',
data: cost_max_data,
backgroundColor: 'rgba(220, 160, 80, 0.25)',
borderWidth: 2,
borderColor: 'rgba(220, 160, 80, 0.35)',
borderDash: [10, 5],
yAxisID: 'y1',
fill: '-1',
}
]
};
if (stocktakeChart != null) {
stocktakeChart.destroy();
}
stocktakeChart = new Chart(context, {
type: 'scatter',
data: chart_data,
options: {
showLine: true,
scales: {
x: {
type: 'time',
// suggestedMax: today.format(),
position: 'bottom',
time: {
minUnit: 'day',
}
},
y: {
type: 'linear',
display: true,
position: 'left',
},
y1: {
type: 'linear',
display: true,
position: 'right',
}
},
}
});
}
/* /*
* Load table for part stocktake information * Load table for part stocktake information
*/ */
function loadPartStocktakeTable(partId, options={}) { function loadPartStocktakeTable(partId, options={}) {
// HTML elements
var table = options.table || '#part-stocktake-table'; var table = options.table || '#part-stocktake-table';
var params = options.params || {}; var params = options.params || {};
@ -853,13 +898,32 @@ function loadPartStocktakeTable(partId, options={}) {
formatNoMatches: function() { formatNoMatches: function() {
return '{% trans "No stocktake information available" %}'; return '{% trans "No stocktake information available" %}';
}, },
onLoadSuccess: function(response) {
var data = response.results || response;
loadStocktakeChart(data);
},
columns: [ columns: [
{
field: 'item_count',
title: '{% trans "Stock Items" %}',
switchable: true,
sortable: true,
},
{ {
field: 'quantity', field: 'quantity',
title: '{% trans "Quantity" %}', title: '{% trans "Total Quantity" %}',
switchable: false, switchable: false,
sortable: true, sortable: true,
}, },
{
field: 'cost',
title: '{% trans "Total Cost" %}',
switchable: false,
formatter: function(value, row) {
return formatPriceRange(row.cost_min, row.cost_max);
}
},
{ {
field: 'note', field: 'note',
title: '{% trans "Notes" %}', title: '{% trans "Notes" %}',
@ -883,7 +947,7 @@ function loadPartStocktakeTable(partId, options={}) {
{ {
field: 'actions', field: 'actions',
title: '', title: '',
visible: options.admin, visible: options.allow_edit || options.allow_delete,
switchable: false, switchable: false,
sortable: false, sortable: false,
formatter: function(value, row) { formatter: function(value, row) {
@ -910,7 +974,12 @@ function loadPartStocktakeTable(partId, options={}) {
constructForm(`/api/part/stocktake/${pk}/`, { constructForm(`/api/part/stocktake/${pk}/`, {
fields: { fields: {
item_count: {},
quantity: {}, quantity: {},
cost_min: {},
cost_min_currency: {},
cost_max: {},
cost_max_currency: {},
note: {}, note: {},
}, },
title: '{% trans "Edit Stocktake Entry" %}', title: '{% trans "Edit Stocktake Entry" %}',

View File

@ -205,6 +205,11 @@ function calculateTotalPrice(dataset, value_func, currency_func, options={}) {
total += value; total += value;
} }
// Return raw total instead of formatted value
if (options.raw) {
return total;
}
return formatCurrency(total, { return formatCurrency(total, {
currency: currency, currency: currency,
}); });

View File

@ -84,7 +84,7 @@ class RoleGroupAdmin(admin.ModelAdmin): # pragma: no cover
RuleSetInline, RuleSetInline,
] ]
list_display = ('name', 'admin', 'part_category', 'part', 'stock_location', list_display = ('name', 'admin', 'part_category', 'part', 'stocktake', 'stock_location',
'stock_item', 'build', 'purchase_order', 'sales_order') 'stock_item', 'build', 'purchase_order', 'sales_order')
def get_rule_set(self, obj, rule_set_type): def get_rule_set(self, obj, rule_set_type):
@ -137,6 +137,10 @@ class RoleGroupAdmin(admin.ModelAdmin): # pragma: no cover
"""Return the ruleset for the Part role""" """Return the ruleset for the Part role"""
return self.get_rule_set(obj, 'part') return self.get_rule_set(obj, 'part')
def stocktake(self, obj):
"""Return the ruleset for the Stocktake role"""
return self.get_rule_set(obj, 'stocktake')
def stock_location(self, obj): def stock_location(self, obj):
"""Return the ruleset for the StockLocation role""" """Return the ruleset for the StockLocation role"""
return self.get_rule_set(obj, 'stock_location') return self.get_rule_set(obj, 'stock_location')

View File

@ -0,0 +1,18 @@
# Generated by Django 3.2.16 on 2023-02-16 22:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0005_owner_model'),
]
operations = [
migrations.AlterField(
model_name='ruleset',
name='name',
field=models.CharField(choices=[('admin', 'Admin'), ('part_category', 'Part Categories'), ('part', 'Parts'), ('stocktake', 'Stocktake'), ('stock_location', 'Stock Locations'), ('stock', 'Stock Items'), ('build', 'Build Orders'), ('purchase_order', 'Purchase Orders'), ('sales_order', 'Sales Orders')], help_text='Permission set', max_length=50),
),
]

View File

@ -36,6 +36,7 @@ class RuleSet(models.Model):
('admin', _('Admin')), ('admin', _('Admin')),
('part_category', _('Part Categories')), ('part_category', _('Part Categories')),
('part', _('Parts')), ('part', _('Parts')),
('stocktake', _('Stocktake')),
('stock_location', _('Stock Locations')), ('stock_location', _('Stock Locations')),
('stock', _('Stock Items')), ('stock', _('Stock Items')),
('build', _('Build Orders')), ('build', _('Build Orders')),
@ -97,13 +98,16 @@ class RuleSet(models.Model):
'part_partrelated', 'part_partrelated',
'part_partstar', 'part_partstar',
'part_partcategorystar', 'part_partcategorystar',
'part_partstocktake',
'company_supplierpart', 'company_supplierpart',
'company_manufacturerpart', 'company_manufacturerpart',
'company_manufacturerpartparameter', 'company_manufacturerpartparameter',
'company_manufacturerpartattachment', 'company_manufacturerpartattachment',
'label_partlabel', 'label_partlabel',
], ],
'stocktake': [
'part_partstocktake',
'part_partstocktakereport',
],
'stock_location': [ 'stock_location': [
'stock_stocklocation', 'stock_stocklocation',
'label_stocklocationlabel', 'label_stocklocationlabel',
@ -467,13 +471,13 @@ def update_group_roles(group, debug=False):
# Enable all action permissions for certain children models # Enable all action permissions for certain children models
# if parent model has 'change' permission # if parent model has 'change' permission
for (parent, child) in RuleSet.RULESET_CHANGE_INHERIT: for (parent, child) in RuleSet.RULESET_CHANGE_INHERIT:
parent_change_perm = f'{parent}.change_{parent}'
parent_child_string = f'{parent}_{child}' parent_child_string = f'{parent}_{child}'
# Check if parent change permission exists # Check each type of permission
if parent_change_perm in group_permissions: for action in ['view', 'change', 'add', 'delete']:
# Add child model permissions parent_perm = f'{parent}.{action}_{parent}'
for action in ['add', 'change', 'delete']:
if parent_perm in group_permissions:
child_perm = f'{parent}.{action}_{child}' child_perm = f'{parent}.{action}_{child}'
# Check if child permission not already in group # Check if child permission not already in group

View File

@ -126,6 +126,7 @@ class RuleSetModelTest(TestCase):
# Add some more rules # Add some more rules
for rule in rulesets: for rule in rulesets:
rule.can_view = True
rule.can_add = True rule.can_add = True
rule.can_change = True rule.can_change = True

View File

@ -49,8 +49,5 @@ fi
cd ${INVENTREE_HOME} cd ${INVENTREE_HOME}
# Collect translation file stats
invoke translate-stats
# Launch the CMD *after* the ENTRYPOINT completes # Launch the CMD *after* the ENTRYPOINT completes
exec "$@" exec "$@"