From 0f445ea6e4f3ddf3bd85b4206d6edd7cc4d25f1c Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 17 Feb 2023 11:42:48 +1100 Subject: [PATCH] [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 --- InvenTree/InvenTree/api_tester.py | 12 +- InvenTree/InvenTree/api_version.py | 5 +- InvenTree/InvenTree/apps.py | 7 +- InvenTree/InvenTree/permissions.py | 8 +- InvenTree/InvenTree/serializers.py | 21 + InvenTree/common/models.py | 31 +- InvenTree/common/notifications.py | 4 +- InvenTree/common/serializers.py | 25 +- InvenTree/company/serializers.py | 16 +- InvenTree/order/serializers.py | 25 +- InvenTree/part/admin.py | 7 + InvenTree/part/api.py | 51 ++- .../migrations/0096_auto_20230211_0029.py | 36 ++ .../migrations/0097_partstocktakereport.py | 26 ++ .../migrations/0098_auto_20230214_1115.py | 23 + InvenTree/part/models.py | 88 +++- InvenTree/part/serializers.py | 123 ++++- InvenTree/part/tasks.py | 301 ++++++++++++ InvenTree/part/templates/part/category.html | 20 + InvenTree/part/templates/part/detail.html | 25 +- InvenTree/part/templates/part/part_base.html | 10 +- .../part/templates/part/part_sidebar.html | 3 +- .../part/templates/part/part_stocktake.html | 4 + InvenTree/part/test_api.py | 68 ++- InvenTree/plugin/base/event/events.py | 2 + InvenTree/plugin/registry.py | 11 +- InvenTree/stock/serializers.py | 12 +- InvenTree/stock/templates/stock/location.html | 20 + .../InvenTree/settings/part_stocktake.html | 45 ++ .../InvenTree/settings/settings.html | 431 +----------------- .../InvenTree/settings/settings_js.html | 92 ++++ .../InvenTree/settings/settings_staff_js.html | 401 ++++++++++++++++ .../templates/InvenTree/settings/sidebar.html | 6 +- .../templates/InvenTree/settings/stock.html | 1 + .../templates/js/translated/attachment.js | 76 +-- InvenTree/templates/js/translated/forms.js | 4 + InvenTree/templates/js/translated/helpers.js | 4 + .../templates/js/translated/notification.js | 25 +- InvenTree/templates/js/translated/part.js | 293 +++++++----- InvenTree/templates/js/translated/pricing.js | 5 + InvenTree/users/admin.py | 6 +- .../migrations/0006_alter_ruleset_name.py | 18 + InvenTree/users/models.py | 18 +- InvenTree/users/tests.py | 1 + docker/init.sh | 3 - 45 files changed, 1700 insertions(+), 713 deletions(-) create mode 100644 InvenTree/part/migrations/0096_auto_20230211_0029.py create mode 100644 InvenTree/part/migrations/0097_partstocktakereport.py create mode 100644 InvenTree/part/migrations/0098_auto_20230214_1115.py create mode 100644 InvenTree/templates/InvenTree/settings/part_stocktake.html create mode 100644 InvenTree/templates/InvenTree/settings/settings_js.html create mode 100644 InvenTree/templates/InvenTree/settings/settings_staff_js.html create mode 100644 InvenTree/users/migrations/0006_alter_ruleset_name.py diff --git a/InvenTree/InvenTree/api_tester.py b/InvenTree/InvenTree/api_tester.py index e76610292d..95db39d44e 100644 --- a/InvenTree/InvenTree/api_tester.py +++ b/InvenTree/InvenTree/api_tester.py @@ -67,8 +67,16 @@ class UserMixin: self.client.login(username=self.username, password=self.password) def assignRole(self, role=None, assign_all: bool = False): - """Set the user roles for the registered user.""" - # role is of the format 'rule.permission' e.g. 'part.add' + """Set the user roles for the registered user. + + 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: rule, perm = role.split('.') diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index 155b065b3c..c1defa567b 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -2,11 +2,14 @@ # 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 +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 - Adds "CompanyAttachment" model (and associated API endpoints) diff --git a/InvenTree/InvenTree/apps.py b/InvenTree/InvenTree/apps.py index 3e81933b10..bd4e039122 100644 --- a/InvenTree/InvenTree/apps.py +++ b/InvenTree/InvenTree/apps.py @@ -60,7 +60,10 @@ class InvenTreeConfig(AppConfig): 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__}' InvenTree.tasks.schedule_task( ref_name, @@ -75,7 +78,7 @@ class InvenTreeConfig(AppConfig): force_async=True, ) - logger.info("Started background tasks...") + logger.info(f"Started {len(tasks)} scheduled background tasks...") def collect_tasks(self): """Collect all background tasks.""" diff --git a/InvenTree/InvenTree/permissions.py b/InvenTree/InvenTree/permissions.py index 714ff99139..4eb087420d 100644 --- a/InvenTree/InvenTree/permissions.py +++ b/InvenTree/InvenTree/permissions.py @@ -49,6 +49,10 @@ class RolePermission(permissions.BasePermission): 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: # Extract the model name associated with this request model = view.serializer_class.Meta.model @@ -62,9 +66,7 @@ class RolePermission(permissions.BasePermission): # then we don't need a permission return True - result = users.models.RuleSet.check_table_permission(user, table, permission) - - return result + return users.models.RuleSet.check_table_permission(user, table, permission) class IsSuperuser(permissions.IsAdminUser): diff --git a/InvenTree/InvenTree/serializers.py b/InvenTree/InvenTree/serializers.py index 39f9aecd4c..018f2332f7 100644 --- a/InvenTree/InvenTree/serializers.py +++ b/InvenTree/InvenTree/serializers.py @@ -21,6 +21,7 @@ from rest_framework.serializers import DecimalField from rest_framework.utils import model_meta from common.models import InvenTreeSetting +from common.settings import currency_code_default, currency_code_mappings from InvenTree.fields import InvenTreeRestURLField, InvenTreeURLField from InvenTree.helpers import download_image_from_url @@ -66,6 +67,26 @@ class InvenTreeMoneySerializer(MoneyField): 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): """Inherits the standard Django ModelSerializer class, but also ensures that the underlying model class data are checked on validation.""" diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index d71d9e3aba..e906e81b70 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -1568,6 +1568,35 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'validator': bool, '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' @@ -1900,7 +1929,7 @@ class InvenTreeUserSetting(BaseInvenTreeSetting): 'DISPLAY_STOCKTAKE_TAB': { 'name': _('Part Stocktake'), - 'description': _('Display part stocktake information'), + 'description': _('Display part stocktake information (if stocktake functionality is enabled)'), 'default': True, 'validator': bool, }, diff --git a/InvenTree/common/notifications.py b/InvenTree/common/notifications.py index db8c5d11a7..34b2190851 100644 --- a/InvenTree/common/notifications.py +++ b/InvenTree/common/notifications.py @@ -180,7 +180,7 @@ class MethodStorageClass: Args: 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 # for testing selective loading is made available @@ -196,7 +196,7 @@ class MethodStorageClass: filtered_list[ref] = item 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: """Returns all user settings for a specific user. diff --git a/InvenTree/common/serializers.py b/InvenTree/common/serializers.py index c84d478548..8444e85085 100644 --- a/InvenTree/common/serializers.py +++ b/InvenTree/common/serializers.py @@ -141,27 +141,13 @@ class NotificationMessageSerializer(InvenTreeModelSerializer): """Serializer for the InvenTreeUserSetting model.""" target = serializers.SerializerMethodField(read_only=True) - source = serializers.SerializerMethodField(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() def get_target(self, obj): """Function to resolve generic object reference to target.""" + target = get_objectreference(obj, 'target_content_type', 'target_object_id') if target and 'link' not in target: @@ -202,6 +188,15 @@ class NotificationMessageSerializer(InvenTreeModelSerializer): 'read', ] + read_only_fields = [ + 'category', + 'name', + 'message', + 'creation', + 'age', + 'age_human', + ] + class NewsFeedEntrySerializer(InvenTreeModelSerializer): """Serializer for the NewsFeedEntry model.""" diff --git a/InvenTree/company/serializers.py b/InvenTree/company/serializers.py index 79c8da357f..7c498456bf 100644 --- a/InvenTree/company/serializers.py +++ b/InvenTree/company/serializers.py @@ -9,8 +9,8 @@ from rest_framework import serializers from sql_util.utils import SubqueryCount import part.filters -from common.settings import currency_code_default, currency_code_mappings from InvenTree.serializers import (InvenTreeAttachmentSerializer, + InvenTreeCurrencySerializer, InvenTreeDecimalField, InvenTreeImageSerializerField, InvenTreeModelSerializer, @@ -66,13 +66,7 @@ class CompanySerializer(RemoteImageMixin, InvenTreeModelSerializer): parts_supplied = serializers.IntegerField(read_only=True) parts_manufactured = serializers.IntegerField(read_only=True) - currency = serializers.ChoiceField( - choices=currency_code_mappings(), - initial=currency_code_default, - help_text=_('Default currency used for this supplier'), - label=_('Currency Code'), - required=True, - ) + currency = InvenTreeCurrencySerializer(help_text=_('Default currency used for this supplier'), required=True) class Meta: """Metaclass options.""" @@ -397,11 +391,7 @@ class SupplierPriceBreakSerializer(InvenTreeModelSerializer): label=_('Price'), ) - price_currency = serializers.ChoiceField( - choices=currency_code_mappings(), - default=currency_code_default, - label=_('Currency'), - ) + price_currency = InvenTreeCurrencySerializer() supplier = serializers.PrimaryKeyRelatedField(source='part.supplier', many=False, read_only=True) diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index fe960e7cf0..ffab3930aa 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -17,10 +17,10 @@ import order.models import part.filters import stock.models import stock.serializers -from common.settings import currency_code_mappings from company.serializers import CompanyBriefSerializer, SupplierPartSerializer from InvenTree.helpers import extract_serial_numbers, normalize, str2bool from InvenTree.serializers import (InvenTreeAttachmentSerializer, + InvenTreeCurrencySerializer, InvenTreeDecimalField, InvenTreeModelSerializer, InvenTreeMoneySerializer) @@ -58,10 +58,7 @@ class AbstractExtraLineSerializer(serializers.Serializer): allow_null=True ) - price_currency = serializers.ChoiceField( - choices=currency_code_mappings(), - help_text=_('Price currency'), - ) + price_currency = InvenTreeCurrencySerializer() class AbstractExtraLineMeta: @@ -316,16 +313,11 @@ class PurchaseOrderLineItemSerializer(InvenTreeModelSerializer): supplier_part_detail = SupplierPartSerializer(source='part', many=False, read_only=True) - purchase_price = InvenTreeMoneySerializer( - allow_null=True - ) + purchase_price = InvenTreeMoneySerializer(allow_null=True) destination_detail = stock.serializers.LocationBriefSerializer(source='get_destination', read_only=True) - purchase_price_currency = serializers.ChoiceField( - choices=currency_code_mappings(), - help_text=_('Purchase price currency'), - ) + purchase_price_currency = InvenTreeCurrencySerializer(help_text=_('Purchase price currency')) order_detail = PurchaseOrderSerializer(source='order', read_only=True, many=False) @@ -879,14 +871,9 @@ class SalesOrderLineItemSerializer(InvenTreeModelSerializer): shipped = InvenTreeDecimalField(read_only=True) - sale_price = InvenTreeMoneySerializer( - allow_null=True - ) + sale_price = InvenTreeMoneySerializer(allow_null=True) - sale_price_currency = serializers.ChoiceField( - choices=currency_code_mappings(), - help_text=_('Sale price currency'), - ) + sale_price_currency = InvenTreeCurrencySerializer(help_text=_('Sale price currency')) class Meta: """Metaclass options.""" diff --git a/InvenTree/part/admin.py b/InvenTree/part/admin.py index 1c8140afee..3e6d61de3a 100644 --- a/InvenTree/part/admin.py +++ b/InvenTree/part/admin.py @@ -166,6 +166,12 @@ class PartStocktakeAdmin(admin.ModelAdmin): list_display = ['part', 'date', 'quantity', 'user'] +class PartStocktakeReportAdmin(admin.ModelAdmin): + """Admin class for PartStocktakeReport model""" + + list_display = ['date', 'user'] + + class PartCategoryResource(InvenTreeResource): """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.PartPricing, PartPricingAdmin) admin.site.register(models.PartStocktake, PartStocktakeAdmin) +admin.site.register(models.PartStocktakeReport, PartStocktakeReportAdmin) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 3b11941562..bfc2a426ea 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -10,9 +10,8 @@ from django.utils.translation import gettext_lazy as _ from django_filters import rest_framework as rest_filters 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.permissions import IsAdminUser from rest_framework.response import Response import order.models @@ -38,7 +37,7 @@ from .models import (BomItem, BomItemSubstitute, Part, PartAttachment, PartCategory, PartCategoryParameterTemplate, PartInternalPriceBreak, PartParameter, PartParameterTemplate, PartRelated, PartSellPriceBreak, - PartStocktake, PartTestTemplate) + PartStocktake, PartStocktakeReport, PartTestTemplate) class CategoryList(APIDownloadMixin, ListCreateAPI): @@ -1598,9 +1597,11 @@ class PartStocktakeList(ListCreateAPI): ordering_fields = [ 'part', + 'item_count', 'quantity', 'date', 'user', + 'pk', ] # Reverse date ordering by default @@ -1615,11 +1616,47 @@ class PartStocktakeDetail(RetrieveUpdateDestroyAPI): queryset = PartStocktake.objects.all() 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 = [ - IsAdminUser, + permissions.IsAuthenticated, 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): """Custom filters for the BOM list.""" @@ -2038,6 +2075,12 @@ part_api_urls = [ # Part stocktake data 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\d+)/', PartStocktakeDetail.as_view(), name='api-part-stocktake-detail'), re_path(r'^.*$', PartStocktakeList.as_view(), name='api-part-stocktake-list'), ])), diff --git a/InvenTree/part/migrations/0096_auto_20230211_0029.py b/InvenTree/part/migrations/0096_auto_20230211_0029.py new file mode 100644 index 0000000000..dce4afcdd8 --- /dev/null +++ b/InvenTree/part/migrations/0096_auto_20230211_0029.py @@ -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), + ), + ] diff --git a/InvenTree/part/migrations/0097_partstocktakereport.py b/InvenTree/part/migrations/0097_partstocktakereport.py new file mode 100644 index 0000000000..a376ee2c88 --- /dev/null +++ b/InvenTree/part/migrations/0097_partstocktakereport.py @@ -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')), + ], + ), + ] diff --git a/InvenTree/part/migrations/0098_auto_20230214_1115.py b/InvenTree/part/migrations/0098_auto_20230214_1115.py new file mode 100644 index 0000000000..dd28185fee --- /dev/null +++ b/InvenTree/part/migrations/0098_auto_20230214_1115.py @@ -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'), + ), + ] diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index c91d0d393f..04e230eb2a 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -2335,7 +2335,7 @@ class PartPricing(common.models.MetaMixin): 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""" if self.pk is not None: @@ -2362,8 +2362,9 @@ class PartPricing(common.models.MetaMixin): pass # Update parent assemblies and templates - self.update_assemblies(counter) - self.update_templates(counter) + if cascade: + self.update_assemblies(counter) + self.update_templates(counter) def update_assemblies(self, counter: int = 0): """Schedule updates for any assemblies which use this part""" @@ -2890,6 +2891,7 @@ class PartStocktake(models.Model): A 'stocktake' is a representative count of available stock: - Performed on a given date - Records quantity of part in stock (across multiple stock items) + - Records estimated value of "stock on hand" - Records user information """ @@ -2901,6 +2903,12 @@ class PartStocktake(models.Model): 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( max_digits=19, decimal_places=5, validators=[MinValueValidator(0)], @@ -2929,6 +2937,18 @@ class PartStocktake(models.Model): 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') def update_last_stocktake(sender, instance, created, **kwargs): @@ -2944,6 +2964,68 @@ def update_last_stocktake(sender, instance, created, **kwargs): 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): """Model for storing file attachments against a Part object.""" diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index ad065bc5e1..643f1d8d2d 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -15,28 +15,32 @@ from django.utils.translation import gettext_lazy as _ from rest_framework import serializers from sql_util.utils import SubqueryCount, SubquerySum +import common.models import company.models import InvenTree.helpers +import InvenTree.status import part.filters +import part.tasks import stock.models -from common.settings import currency_code_default, currency_code_mappings from InvenTree.serializers import (DataFileExtractSerializer, DataFileUploadSerializer, InvenTreeAttachmentSerializer, InvenTreeAttachmentSerializerField, + InvenTreeCurrencySerializer, InvenTreeDecimalField, InvenTreeImageSerializerField, InvenTreeModelSerializer, InvenTreeMoneySerializer, RemoteImageMixin, UserSerializer) from InvenTree.status_codes import BuildStatus +from InvenTree.tasks import offload_task from .models import (BomItem, BomItemSubstitute, Part, PartAttachment, PartCategory, PartCategoryParameterTemplate, PartInternalPriceBreak, PartParameter, PartParameterTemplate, PartPricing, PartRelated, PartSellPriceBreak, PartStar, PartStocktake, - PartTestTemplate) + PartStocktakeReport, PartTestTemplate) class CategorySerializer(InvenTreeModelSerializer): @@ -137,16 +141,9 @@ class PartSalePriceSerializer(InvenTreeModelSerializer): quantity = InvenTreeDecimalField() - price = InvenTreeMoneySerializer( - allow_null=True - ) + price = InvenTreeMoneySerializer(allow_null=True) - price_currency = serializers.ChoiceField( - choices=currency_code_mappings(), - default=currency_code_default, - label=_('Currency'), - help_text=_('Purchase currency of this stock item'), - ) + price_currency = InvenTreeCurrencySerializer(help_text=_('Purchase currency of this stock item')) class Meta: """Metaclass defining serializer fields""" @@ -169,12 +166,7 @@ class PartInternalPriceSerializer(InvenTreeModelSerializer): allow_null=True ) - price_currency = serializers.ChoiceField( - choices=currency_code_mappings(), - default=currency_code_default, - label=_('Currency'), - help_text=_('Purchase currency of this stock item'), - ) + price_currency = InvenTreeCurrencySerializer(help_text=_('Purchase currency of this stock item')) class Meta: """Metaclass defining serializer fields""" @@ -720,6 +712,12 @@ class PartStocktakeSerializer(InvenTreeModelSerializer): 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: """Metaclass options""" @@ -728,7 +726,12 @@ class PartStocktakeSerializer(InvenTreeModelSerializer): 'pk', 'date', 'part', + 'item_count', 'quantity', + 'cost_min', + 'cost_min_currency', + 'cost_max', + 'cost_max_currency', 'note', 'user', 'user_detail', @@ -751,6 +754,92 @@ class PartStocktakeSerializer(InvenTreeModelSerializer): 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): """Serializer for Part pricing information""" diff --git a/InvenTree/part/tasks.py b/InvenTree/part/tasks.py index c4a566a07e..924914850e 100644 --- a/InvenTree/part/tasks.py +++ b/InvenTree/part/tasks.py @@ -1,16 +1,27 @@ """Background task definitions for the 'part' app""" +import io import logging +import random +import time 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 _ +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.notifications import common.settings import InvenTree.helpers import InvenTree.tasks import part.models +import stock.models from InvenTree.tasks import ScheduledTask, scheduled_task logger = logging.getLogger("inventree") @@ -125,3 +136,293 @@ def check_missing_pricing(limit=250): pricing = p.pricing pricing.save() 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) diff --git a/InvenTree/part/templates/part/category.html b/InvenTree/part/templates/part/category.html index 133a9387a1..408d261c29 100644 --- a/InvenTree/part/templates/part/category.html +++ b/InvenTree/part/templates/part/category.html @@ -29,6 +29,12 @@ {% url 'admin:part_partcategory_change' category.pk as url %} {% include "admin_button.html" with url=url %} {% endif %} +{% settings_value "STOCKTAKE_ENABLE" as stocktake_enable %} +{% if stocktake_enable and roles.stocktake.add %} + +{% endif %} {% if category %} {% if starred_directly %} @@ -468,18 +469,24 @@ // Load the "stocktake" tab onPanelLoad('stocktake', function() { loadPartStocktakeTable({{ part.pk }}, { - admin: {% js_bool user.is_staff %}, - allow_edit: {% js_bool roles.part.change %}, - allow_delete: {% js_bool roles.part.delete %}, + allow_edit: {% js_bool roles.stocktake.change %}, + allow_delete: {% js_bool roles.stocktake.delete %}, }); + {% if roles.stocktake.add %} $('#btn-stocktake').click(function() { - performStocktake({{ part.pk }}, { - onSuccess: function() { - $('#part-stocktake-table').bootstrapTable('refresh'); - } + generateStocktakeReport({ + part: { + value: {{ part.pk }} + }, + location: {}, + generate_report: { + value: false, + }, + update_parts: {}, }); }); + {% endif %} }); // Load the "suppliers" tab diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html index 85d78dcf37..e4df634c13 100644 --- a/InvenTree/part/templates/part/part_base.html +++ b/InvenTree/part/templates/part/part_base.html @@ -342,12 +342,12 @@ {% if stocktake %} - {% trans "Last Stocktake" %} - {% decimal stocktake.quantity %} - - {{ stocktake.user.username }} - + {% trans "Last Stocktake" %} + + + {% decimal stocktake.quantity %} + {{ stocktake.user.username }} {% endif %} diff --git a/InvenTree/part/templates/part/part_sidebar.html b/InvenTree/part/templates/part/part_sidebar.html index ff35246739..368110d729 100644 --- a/InvenTree/part/templates/part/part_sidebar.html +++ b/InvenTree/part/templates/part/part_sidebar.html @@ -44,8 +44,9 @@ {% trans "Scheduling" as text %} {% include "sidebar_item.html" with label="scheduling" text=text icon="fa-calendar-alt" %} {% endif %} +{% settings_value 'STOCKTAKE_ENABLE' as stocktake_enable %} {% 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 %} {% include "sidebar_item.html" with label="stocktake" text=text icon="fa-clipboard-check" %} {% endif %} diff --git a/InvenTree/part/templates/part/part_stocktake.html b/InvenTree/part/templates/part/part_stocktake.html index cb1d97cdc4..1a5ab06619 100644 --- a/InvenTree/part/templates/part/part_stocktake.html +++ b/InvenTree/part/templates/part/part_stocktake.html @@ -1,6 +1,10 @@ {% load i18n %} {% load inventree_extras %} +
+ +
+
{% include "filter_list.html" with id="partstocktake" %} diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index cf1f132c86..426fc37e05 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -2839,6 +2839,7 @@ class PartStocktakeTest(InvenTreeAPITestCase): 'category', 'part', 'location', + 'stock', ] def test_list_endpoint(self): @@ -2887,8 +2888,8 @@ class PartStocktakeTest(InvenTreeAPITestCase): url = reverse('api-part-stocktake-list') - self.assignRole('part.add') - self.assignRole('part.view') + self.assignRole('stocktake.add') + self.assignRole('stocktake.view') for p in Part.objects.all(): @@ -2930,12 +2931,6 @@ class PartStocktakeTest(InvenTreeAPITestCase): self.assignRole('part.view') # 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) # Try to edit data @@ -2948,7 +2943,7 @@ class PartStocktakeTest(InvenTreeAPITestCase): ) # Assign 'edit' role permission - self.assignRole('part.change') + self.assignRole('stocktake.change') # Try again self.patch( @@ -2962,6 +2957,59 @@ class PartStocktakeTest(InvenTreeAPITestCase): # Try to delete self.delete(url, expected_code=403) - self.assignRole('part.delete') + self.assignRole('stocktake.delete') 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)) diff --git a/InvenTree/plugin/base/event/events.py b/InvenTree/plugin/base/event/events.py index 61bdb6ae91..3b899d4ca9 100644 --- a/InvenTree/plugin/base/event/events.py +++ b/InvenTree/plugin/base/event/events.py @@ -125,6 +125,8 @@ def allow_table_event(table_name): 'common_webhookendpoint', 'common_webhookmessage', 'part_partpricing', + 'part_partstocktake', + 'part_partstocktakereport', ] if table_name in ignore_tables: diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py index 05a67193ba..2132fd7ee6 100644 --- a/InvenTree/plugin/registry.py +++ b/InvenTree/plugin/registry.py @@ -109,7 +109,7 @@ class PluginsRegistry: full_reload (bool, optional): Reload everything - including plugin mechanism. Defaults to False. """ - logger.info('Start loading plugins') + logger.info('Loading plugins') # Set maintanace mode _maintenance = bool(get_maintenance_mode()) @@ -268,7 +268,7 @@ class PluginsRegistry: # Collect plugins from paths 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_obj = Path(plugin) @@ -306,7 +306,7 @@ class PluginsRegistry: # Log collected 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 @@ -383,7 +383,7 @@ class PluginsRegistry: self.plugins_inactive[key] = plugin.db self.plugins_full[key] = plugin - logger.info('Starting plugin initialisation') + logger.debug('Starting plugin initialisation') # Initialize plugins 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 logger.info(f'Loading plugin `{plg_name}`') + try: plg_i: InvenTreePlugin = plg() - logger.info(f'Loaded plugin `{plg_name}`') + logger.debug(f'Loaded plugin `{plg_name}`') except Exception as error: handle_error(error, log_name='init') # log error and raise it -> disable plugin diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index 1a3b14af23..58871a55b2 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -19,10 +19,10 @@ import InvenTree.helpers import InvenTree.serializers import part.models as part_models import stock.filters -from common.settings import currency_code_default, currency_code_mappings from company.serializers import SupplierPartSerializer from InvenTree.models import extract_int -from InvenTree.serializers import InvenTreeDecimalField +from InvenTree.serializers import (InvenTreeCurrencySerializer, + InvenTreeDecimalField) from part.serializers import PartBriefSerializer from .models import (StockItem, StockItemAttachment, StockItemTestResult, @@ -171,17 +171,11 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeModelSerializer): purchase_price = InvenTree.serializers.InvenTreeMoneySerializer( label=_('Purchase Price'), - max_digits=19, decimal_places=6, allow_null=True, help_text=_('Purchase price of this stock item'), ) - purchase_price_currency = serializers.ChoiceField( - choices=currency_code_mappings(), - default=currency_code_default, - label=_('Currency'), - help_text=_('Purchase currency of this stock item'), - ) + purchase_price_currency = InvenTreeCurrencySerializer(help_text=_('Purchase currency of this stock item')) purchase_order_reference = serializers.CharField(source='purchase_order.reference', read_only=True) sales_order_reference = serializers.CharField(source='sales_order.reference', read_only=True) diff --git a/InvenTree/stock/templates/stock/location.html b/InvenTree/stock/templates/stock/location.html index 4e04906a18..d7bae4ec18 100644 --- a/InvenTree/stock/templates/stock/location.html +++ b/InvenTree/stock/templates/stock/location.html @@ -32,6 +32,12 @@ {% url 'admin:stock_stocklocation_change' location.pk as url %} {% include "admin_button.html" with url=url %} {% endif %} +{% settings_value "STOCKTAKE_ENABLE" as stocktake_enable %} +{% if stocktake_enable and roles.stocktake.add %} + +{% endif %} {% mixin_available "locate" as locate_available %} {% if location and plugins_enabled and locate_available %} @@ -246,6 +252,20 @@ {% block js_ready %} {{ 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 %} $('#locate-location-button').click(function() { locateItemOrLocation({ diff --git a/InvenTree/templates/InvenTree/settings/part_stocktake.html b/InvenTree/templates/InvenTree/settings/part_stocktake.html new file mode 100644 index 0000000000..3db0630358 --- /dev/null +++ b/InvenTree/templates/InvenTree/settings/part_stocktake.html @@ -0,0 +1,45 @@ +{% extends "panel.html" %} +{% load i18n %} + +{% block label %}stocktake{% endblock %} + +{% block heading %} +{% trans "Stocktake Settings" %} +{% endblock %} + +{% block panel_content %} + +
+ + + {% 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" %} + +
+
+ +
+
+

{% trans "Stocktake Reports" %}

+ {% include "spacer.html" %} +
+ {% if roles.stocktake.add %} + + {% endif %} +
+
+
+ +
+
+
+ {% include "filter_list.html" with id="stocktakereport" %} +
+
+
+
+ +{% endblock panel_content %} diff --git a/InvenTree/templates/InvenTree/settings/settings.html b/InvenTree/templates/InvenTree/settings/settings.html index 138b32ce3d..77247a29c5 100644 --- a/InvenTree/templates/InvenTree/settings/settings.html +++ b/InvenTree/templates/InvenTree/settings/settings.html @@ -36,8 +36,9 @@ {% include "InvenTree/settings/label.html" %} {% include "InvenTree/settings/report.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/pricing.html" %} {% include "InvenTree/settings/stock.html" %} {% include "InvenTree/settings/build.html" %} {% include "InvenTree/settings/po.html" %} @@ -62,426 +63,16 @@ {% block js_ready %} {{ block.super }} -// Callback for when boolean settings are edited -$('table').find('.boolean-setting').change(function() { +{% include "InvenTree/settings/settings_js.html" %} - 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, - } - ); -}); - -$('#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 = ""; - var bDel = ""; - - var html = value - html += "
" + bEdit + bDel + "
"; - - 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 = ""; - var bDel = ""; - - var html = "
" + bEdit + bDel + "
"; - - 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 = ` -
- {% trans "Any parameters which reference this template will also be deleted" %} -
`; - - 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(); -}); +{% if user.is_staff %} + {% include "InvenTree/settings/settings_staff_js.html" %} + {% plugins_enabled as plug %} + {% if plug %} + $("#install-plugin").click(function() { + installPlugin(); + }); + {% endif %} {% endif %} enableSidebar('settings'); diff --git a/InvenTree/templates/InvenTree/settings/settings_js.html b/InvenTree/templates/InvenTree/settings/settings_js.html new file mode 100644 index 0000000000..9eff1e7e43 --- /dev/null +++ b/InvenTree/templates/InvenTree/settings/settings_js.html @@ -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, + } + ); +}); diff --git a/InvenTree/templates/InvenTree/settings/settings_staff_js.html b/InvenTree/templates/InvenTree/settings/settings_staff_js.html new file mode 100644 index 0000000000..3da3bd804f --- /dev/null +++ b/InvenTree/templates/InvenTree/settings/settings_staff_js.html @@ -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 = ""; + var bDel = ""; + + var html = value + html += "
" + bEdit + bDel + "
"; + + 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 = ""; + var bDel = ""; + + var html = "
" + bEdit + bDel + "
"; + + 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 = ` +
+ {% trans "Any parameters which reference this template will also be deleted" %} +
`; + + 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 += `${row.user_detail.username}`; + } + + return html; + } + }, + ] + }); + {% endif %} + + {% if roles.stocktake.add %} + $('#btn-generate-stocktake').click(function() { + generateStocktakeReport({ + part: {}, + category: {}, + location: {}, + generate_report: {}, + update_parts: {}, + }); + }); + {% endif %} +}); diff --git a/InvenTree/templates/InvenTree/settings/sidebar.html b/InvenTree/templates/InvenTree/settings/sidebar.html index 49826e0be8..e4f7482366 100644 --- a/InvenTree/templates/InvenTree/settings/sidebar.html +++ b/InvenTree/templates/InvenTree/settings/sidebar.html @@ -40,12 +40,14 @@ {% include "sidebar_item.html" with label='labels' text=text icon='fa-tag' %} {% trans "Reporting" as text %} {% 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 %} {% 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 %} {% 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 %} {% include "sidebar_item.html" with label='build-order' text=text icon="fa-tools" %} {% trans "Purchase Orders" as text %} diff --git a/InvenTree/templates/InvenTree/settings/stock.html b/InvenTree/templates/InvenTree/settings/stock.html index 149fdd8e2f..2826b4429d 100644 --- a/InvenTree/templates/InvenTree/settings/stock.html +++ b/InvenTree/templates/InvenTree/settings/stock.html @@ -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_OWNERSHIP_CONTROL" icon="fa-users" %} {% include "InvenTree/settings/setting.html" with key="STOCK_LOCATION_DEFAULT_ICON" icon="fa-icons" %} + {% endblock %} diff --git a/InvenTree/templates/js/translated/attachment.js b/InvenTree/templates/js/translated/attachment.js index 1cbd241e20..e56f5928ef 100644 --- a/InvenTree/templates/js/translated/attachment.js +++ b/InvenTree/templates/js/translated/attachment.js @@ -6,6 +6,7 @@ */ /* exported + attachmentLink, addAttachmentButtonCallbacks, loadAttachmentTable, 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 = ` ${fn}`; + + return renderLink(html, filename, {download: true}); + +} + + /* Load a table of attachments against a specific model. * 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) { if (row.attachment) { - var icon = 'fa-file-alt'; - - 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 = ` ${filename}`; - - return renderLink(html, value, {download: true}); + return attachmentLink(row.attachment); } else if (row.link) { var html = ` ${row.link}`; return renderLink(html, row.link); diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index c7fc9452d1..8c85beb575 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -974,6 +974,10 @@ function updateFieldValue(name, value, field, options) { } switch (field.type) { + case 'decimal': + // Strip trailing zeros + el.val(formatDecimal(value)); + break; case 'boolean': if (value == true || value.toString().toLowerCase() == 'true') { el.prop('checked'); diff --git a/InvenTree/templates/js/translated/helpers.js b/InvenTree/templates/js/translated/helpers.js index 4f18f53093..54be4db016 100644 --- a/InvenTree/templates/js/translated/helpers.js +++ b/InvenTree/templates/js/translated/helpers.js @@ -274,6 +274,10 @@ function renderLink(text, url, options={}) { extras += ` title="${url}"`; } + if (options.download) { + extras += ` download`; + } + return `${text}`; } diff --git a/InvenTree/templates/js/translated/notification.js b/InvenTree/templates/js/translated/notification.js index b6957741b4..c5b941e0da 100644 --- a/InvenTree/templates/js/translated/notification.js +++ b/InvenTree/templates/js/translated/notification.js @@ -50,25 +50,16 @@ function loadNotificationTable(table, options={}, enableDelete=false) { title: '{% trans "Category" %}', 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 = `${html}`; - } - return html; - } - }, { 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', diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index c4c0d115b5..a5a09945fa 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -27,6 +27,7 @@ duplicatePart, editCategory, editPart, + generateStocktakeReport, loadParametricPartTable, loadPartCategoryTable, loadPartParameterTable, @@ -40,7 +41,6 @@ loadSimplePartTable, partDetail, partStockLabel, - performStocktake, toggleStar, 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'); - - // 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'}); - - // Last update - var updated = item.stocktake_date || item.updated; - - var update_rendered = renderDate(updated); - - if (updated) { - if (moment(updated) < date_threshold) { - update_rendered += `
`; - } - } - - // Actions - var actions = `
`; - - // 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 += `
`; - - return ` - - ${part} - ${location} - ${quantity} - ${update_rendered} - ${actions} - `; + if (options.part != null) { + fields.part = options.part; } - // First, load stock information for the part - inventreeGet( - '{% url "api-stock-list" %}', + if (options.category != null) { + fields.category = options.category; + } + + if (options.location != null) { + fields.location = options.location; + } + + if (options.generate_report) { + fields.generate_report = options.generate_report; + } + + if (options.update_parts) { + fields.update_parts = options.update_parts; + } + + let content = ` +
+ {% trans "Schedule generation of a new stocktake report." %} {% trans "Once complete, the stocktake report will be available for download." %} +
+ `; + + constructForm( + '{% url "api-part-stocktake-report-generate" %}', { - part: partId, - in_stock: true, - location_detail: true, - part_detail: true, - include_variants: true, - ordering: '-stock', - }, - { - success: function(response) { - var html = ''; - - html += ` - - - - - - - - - - - - `; - - response.forEach(function(item) { - html += buildStockItemRow(item); - }); - - html += `
{% trans "Stock Item" %}{% trans "Location" %}{% trans "Quantity" %}{% trans "Updated" %}
`; - - constructForm(`/api/part/stocktake/`, { - preFormContent: html, - method: 'POST', - title: '{% trans "Part Stocktake" %}', - confirm: true, - fields: { - part: { - value: partId, - hidden: true, - }, - quantity: { - value: part_quantity, - }, - note: {}, - }, - onSuccess: function(response) { - handleFormSuccess(response, options); - } + method: 'POST', + title: '{% trans "Generate Stocktake Report" %}', + preFormContent: content, + fields: fields, + onSuccess: function(response) { + 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 */ function loadPartStocktakeTable(partId, options={}) { + // HTML elements var table = options.table || '#part-stocktake-table'; var params = options.params || {}; @@ -853,13 +898,32 @@ function loadPartStocktakeTable(partId, options={}) { formatNoMatches: function() { return '{% trans "No stocktake information available" %}'; }, + onLoadSuccess: function(response) { + var data = response.results || response; + + loadStocktakeChart(data); + }, columns: [ + { + field: 'item_count', + title: '{% trans "Stock Items" %}', + switchable: true, + sortable: true, + }, { field: 'quantity', - title: '{% trans "Quantity" %}', + title: '{% trans "Total Quantity" %}', switchable: false, sortable: true, }, + { + field: 'cost', + title: '{% trans "Total Cost" %}', + switchable: false, + formatter: function(value, row) { + return formatPriceRange(row.cost_min, row.cost_max); + } + }, { field: 'note', title: '{% trans "Notes" %}', @@ -883,7 +947,7 @@ function loadPartStocktakeTable(partId, options={}) { { field: 'actions', title: '', - visible: options.admin, + visible: options.allow_edit || options.allow_delete, switchable: false, sortable: false, formatter: function(value, row) { @@ -910,7 +974,12 @@ function loadPartStocktakeTable(partId, options={}) { constructForm(`/api/part/stocktake/${pk}/`, { fields: { + item_count: {}, quantity: {}, + cost_min: {}, + cost_min_currency: {}, + cost_max: {}, + cost_max_currency: {}, note: {}, }, title: '{% trans "Edit Stocktake Entry" %}', diff --git a/InvenTree/templates/js/translated/pricing.js b/InvenTree/templates/js/translated/pricing.js index 83102e8204..020d717728 100644 --- a/InvenTree/templates/js/translated/pricing.js +++ b/InvenTree/templates/js/translated/pricing.js @@ -205,6 +205,11 @@ function calculateTotalPrice(dataset, value_func, currency_func, options={}) { total += value; } + // Return raw total instead of formatted value + if (options.raw) { + return total; + } + return formatCurrency(total, { currency: currency, }); diff --git a/InvenTree/users/admin.py b/InvenTree/users/admin.py index 30d63a2156..d26a7f97fc 100644 --- a/InvenTree/users/admin.py +++ b/InvenTree/users/admin.py @@ -84,7 +84,7 @@ class RoleGroupAdmin(admin.ModelAdmin): # pragma: no cover 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') 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 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): """Return the ruleset for the StockLocation role""" return self.get_rule_set(obj, 'stock_location') diff --git a/InvenTree/users/migrations/0006_alter_ruleset_name.py b/InvenTree/users/migrations/0006_alter_ruleset_name.py new file mode 100644 index 0000000000..0acf9404fa --- /dev/null +++ b/InvenTree/users/migrations/0006_alter_ruleset_name.py @@ -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), + ), + ] diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py index 985b1228bd..c4b81fc174 100644 --- a/InvenTree/users/models.py +++ b/InvenTree/users/models.py @@ -21,7 +21,7 @@ logger = logging.getLogger("inventree") class RuleSet(models.Model): - """A RuleSet is somewhat like a superset of the django permission class, in that in encapsulates a bunch of permissions. + """A RuleSet is somewhat like a superset of the django permission class, in that in encapsulates a bunch of permissions. There are *many* apps models used within InvenTree, so it makes sense to group them into "roles". @@ -36,6 +36,7 @@ class RuleSet(models.Model): ('admin', _('Admin')), ('part_category', _('Part Categories')), ('part', _('Parts')), + ('stocktake', _('Stocktake')), ('stock_location', _('Stock Locations')), ('stock', _('Stock Items')), ('build', _('Build Orders')), @@ -97,13 +98,16 @@ class RuleSet(models.Model): 'part_partrelated', 'part_partstar', 'part_partcategorystar', - 'part_partstocktake', 'company_supplierpart', 'company_manufacturerpart', 'company_manufacturerpartparameter', 'company_manufacturerpartattachment', 'label_partlabel', ], + 'stocktake': [ + 'part_partstocktake', + 'part_partstocktakereport', + ], 'stock_location': [ 'stock_stocklocation', 'label_stocklocationlabel', @@ -467,13 +471,13 @@ def update_group_roles(group, debug=False): # Enable all action permissions for certain children models # if parent model has 'change' permission for (parent, child) in RuleSet.RULESET_CHANGE_INHERIT: - parent_change_perm = f'{parent}.change_{parent}' parent_child_string = f'{parent}_{child}' - # Check if parent change permission exists - if parent_change_perm in group_permissions: - # Add child model permissions - for action in ['add', 'change', 'delete']: + # Check each type of permission + for action in ['view', 'change', 'add', 'delete']: + parent_perm = f'{parent}.{action}_{parent}' + + if parent_perm in group_permissions: child_perm = f'{parent}.{action}_{child}' # Check if child permission not already in group diff --git a/InvenTree/users/tests.py b/InvenTree/users/tests.py index 5243d111b9..20455ba696 100644 --- a/InvenTree/users/tests.py +++ b/InvenTree/users/tests.py @@ -126,6 +126,7 @@ class RuleSetModelTest(TestCase): # Add some more rules for rule in rulesets: + rule.can_view = True rule.can_add = True rule.can_change = True diff --git a/docker/init.sh b/docker/init.sh index 75f6f53d26..caf0266c51 100644 --- a/docker/init.sh +++ b/docker/init.sh @@ -49,8 +49,5 @@ fi cd ${INVENTREE_HOME} -# Collect translation file stats -invoke translate-stats - # Launch the CMD *after* the ENTRYPOINT completes exec "$@"