mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 05:05:42 +00:00 
			
		
		
		
	[Feature] Stocktake reports (#4345)
* Add settings to control upcoming stocktake features * Adds migration for "cost range" when performing stocktake * Add cost data to PartStocktakeSerializer Implement a new custom serializer for currency data type * Refactor existing currency serializers * Update stocktake table and forms * Prevent trailing zeroes in forms * Calculate cost range when adding manual stocktake entry * Display interactive chart for part stocktake history * Ensure chart data are converted to common currency * Adds new model for building stocktake reports * Add admin integration for new model * Adds API endpoint to expose list of stocktake reports available for download - No ability to edit or delete via API * Add setting to control automated deletion of old stocktake reports * Updates for settings page - Load part stocktake report table - Refactor function to render a downloadable media file - Fix bug with forcing files to be downloaded - Split js code into separate templates - Make use of onPanelLoad functionalitty * Fix conflicting migration files * Adds API endpoint for manual generation of stocktake report * Offload task to generate new stocktake report * Adds python function to perform stocktake on a single part instance * Small bug fixes * Various tweaks - Prevent new stocktake models from triggering plugin events when created - Construct a simple csv dataset * Generate new report * Updates for report generation - Prefetch related data - Add extra columns - Keep track of stocktake instances (for saving to database later on) * Updates: - Add confirmation message - Serializer validation checks * Ensure that background worker is running before manually scheduling a new stocktake report * Add extra fields to stocktake models Also move code from part/models.py to part/tasks.py * Add 'part_count' to PartStocktakeReport table * Updates for stocktake generation - remove old performStocktake javascript code - Now handled by automated server-side calculation - Generate report for a single part * Add a new "role" for stocktake - Allows fine-grained control on viewing / creating / deleting stocktake data - More in-line with existing permission controls - Remove STOCKTAKE_OWNER setting * Add serializer field to limit stocktake report to particular locations * Use location restriction when generating a stocktake report * Add UI buttons to perform stocktake for a whole category tree * Add button to perform stocktake report for a location tree * Adds a background tasks to handle periodic generation of stocktake reports - Reports are generated at fixed intervals - Deletes old reports after certain number of days * Implement notifications for new stocktake reports - If manually requested by a user, notify that user - Cleanup notification table - Amend PartStocktakeModel for better notification rendering * Hide buttons on location and category page if stocktake is not enabled * Cleanup log messages during server start * Extend functionality of RoleRequired permission mixin - Allow 'role_required' attribute to be added to an API view - Useful when using a serializer class that does not have a model defined * Add boolean option to toggle whether a report will be generated * Update generateStocktake function * Improve location filtering - Don't limit the actual stock items - Instead, select only parts which exist within a given location tree * Update API version * String tweaks * Fix permissions for PartStocktake API * More unit testing for stocktake functionality * QoL fix * Fix for assigning inherited permissions
This commit is contained in:
		| @@ -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('.') | ||||
|   | ||||
| @@ -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) | ||||
|  | ||||
|   | ||||
| @@ -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.""" | ||||
|   | ||||
| @@ -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): | ||||
|   | ||||
| @@ -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.""" | ||||
|  | ||||
|   | ||||
| @@ -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, | ||||
|         }, | ||||
|   | ||||
| @@ -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. | ||||
|   | ||||
| @@ -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.""" | ||||
|   | ||||
| @@ -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) | ||||
|  | ||||
|   | ||||
| @@ -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.""" | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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<pk>\d+)/', PartStocktakeDetail.as_view(), name='api-part-stocktake-detail'), | ||||
|         re_path(r'^.*$', PartStocktakeList.as_view(), name='api-part-stocktake-list'), | ||||
|     ])), | ||||
|   | ||||
							
								
								
									
										36
									
								
								InvenTree/part/migrations/0096_auto_20230211_0029.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								InvenTree/part/migrations/0096_auto_20230211_0029.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,36 @@ | ||||
| # Generated by Django 3.2.16 on 2023-02-11 00:29 | ||||
|  | ||||
| import InvenTree.fields | ||||
| from django.db import migrations | ||||
| import djmoney.models.fields | ||||
| import djmoney.models.validators | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('part', '0095_alter_part_responsible'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name='partstocktake', | ||||
|             name='cost_max', | ||||
|             field=InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Estimated maximum cost of stock on hand', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Maximum Stock Cost'), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name='partstocktake', | ||||
|             name='cost_max_currency', | ||||
|             field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name='partstocktake', | ||||
|             name='cost_min', | ||||
|             field=InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Estimated minimum cost of stock on hand', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Minimum Stock Cost'), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name='partstocktake', | ||||
|             name='cost_min_currency', | ||||
|             field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3), | ||||
|         ), | ||||
|     ] | ||||
							
								
								
									
										26
									
								
								InvenTree/part/migrations/0097_partstocktakereport.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								InvenTree/part/migrations/0097_partstocktakereport.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,26 @@ | ||||
| # Generated by Django 3.2.16 on 2023-02-12 08:29 | ||||
|  | ||||
| from django.conf import settings | ||||
| from django.db import migrations, models | ||||
| import django.db.models.deletion | ||||
| import part.models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         migrations.swappable_dependency(settings.AUTH_USER_MODEL), | ||||
|         ('part', '0096_auto_20230211_0029'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.CreateModel( | ||||
|             name='PartStocktakeReport', | ||||
|             fields=[ | ||||
|                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), | ||||
|                 ('date', models.DateField(auto_now_add=True, verbose_name='Date')), | ||||
|                 ('report', models.FileField(help_text='Stocktake report file (generated internally)', upload_to=part.models.save_stocktake_report, verbose_name='Report')), | ||||
|                 ('user', models.ForeignKey(blank=True, help_text='User who requested this stocktake report', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='stocktake_reports', to=settings.AUTH_USER_MODEL, verbose_name='User')), | ||||
|             ], | ||||
|         ), | ||||
|     ] | ||||
							
								
								
									
										23
									
								
								InvenTree/part/migrations/0098_auto_20230214_1115.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								InvenTree/part/migrations/0098_auto_20230214_1115.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| # Generated by Django 3.2.16 on 2023-02-14 11:15 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('part', '0097_partstocktakereport'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name='partstocktake', | ||||
|             name='item_count', | ||||
|             field=models.IntegerField(default=1, help_text='Number of individual stock entries at time of stocktake', verbose_name='Item Count'), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name='partstocktakereport', | ||||
|             name='part_count', | ||||
|             field=models.IntegerField(default=0, help_text='Number of parts covered by stocktake', verbose_name='Part Count'), | ||||
|         ), | ||||
|     ] | ||||
| @@ -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,6 +2362,7 @@ class PartPricing(common.models.MetaMixin): | ||||
|             pass | ||||
|  | ||||
|         # Update parent assemblies and templates | ||||
|         if cascade: | ||||
|             self.update_assemblies(counter) | ||||
|             self.update_templates(counter) | ||||
|  | ||||
| @@ -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.""" | ||||
|  | ||||
|   | ||||
| @@ -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""" | ||||
|  | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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 %} | ||||
| <button type='button' class='btn btn-outline-secondary' id='category-stocktake' title='{% trans "Perform stocktake for this part category" %}'> | ||||
|     <span class='fas fa-clipboard-check'></span> | ||||
| </button> | ||||
| {% endif %} | ||||
| {% if category %} | ||||
| {% if starred_directly %} | ||||
| <button type='button' class='btn btn-outline-secondary' id='toggle-starred' title='{% trans "You are subscribed to notifications for this category" %}'> | ||||
| @@ -253,6 +259,20 @@ | ||||
| {% block js_ready %} | ||||
| {{ block.super }} | ||||
|  | ||||
|     {% settings_value "STOCKTAKE_ENABLE" as stocktake_enable %} | ||||
|     {% if stocktake_enable and roles.stocktake.add %} | ||||
|     $('#category-stocktake').click(function() { | ||||
|         generateStocktakeReport({ | ||||
|             category: { | ||||
|                 {% if category %}value: {{ category.pk }},{% endif %} | ||||
|             }, | ||||
|             location: {}, | ||||
|             generate_report: {}, | ||||
|             update_parts: {}, | ||||
|         }); | ||||
|     }); | ||||
|     {% endif %} | ||||
|  | ||||
|     {% if category %} | ||||
|  | ||||
|     onPanelLoad('stock', function() { | ||||
|   | ||||
| @@ -53,15 +53,16 @@ | ||||
| </div> | ||||
| {% endif %} | ||||
|  | ||||
| {% settings_value 'STOCKTAKE_ENABLE' as stocktake_enable %} | ||||
| {% settings_value 'DISPLAY_STOCKTAKE_TAB' user=request.user as show_stocktake %} | ||||
| {% if show_stocktake %} | ||||
| {% if stocktake_enable and show_stocktake %} | ||||
| <div class='panel panel-hidden' id='panel-stocktake'> | ||||
|     <div class='panel-heading'> | ||||
|         <div class='d-flex flex-wrap'> | ||||
|             <h4>{% trans "Part Stocktake" %}</h4> | ||||
|             {% include "spacer.html" %} | ||||
|             <div class='btn-group' role='group'> | ||||
|                 {% if roles.part.add %} | ||||
|                 {% if roles.stocktake.add %} | ||||
|                 <button class='btn btn-success' type='button' id='btn-stocktake' title='{% trans "Add stocktake information" %}'> | ||||
|                     <span class='fas fa-clipboard-check'></span> {% trans "Stocktake" %} | ||||
|                 </button> | ||||
| @@ -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 | ||||
|   | ||||
| @@ -342,12 +342,12 @@ | ||||
|                 {% if stocktake %} | ||||
|                 <tr> | ||||
|                     <td><span class='fas fa-clipboard-check'></span></td> | ||||
|                     <td>{% trans "Last Stocktake" %}</td> | ||||
|                     <td> | ||||
|                         {% decimal stocktake.quantity %} <span class='fas fa-calendar-alt' title='{% render_date stocktake.date %}'></span> | ||||
|                         <span class='badge bg-dark rounded-pill float-right'> | ||||
|                             {{ stocktake.user.username }} | ||||
|                         </span> | ||||
|                         {% trans "Last Stocktake" %} | ||||
|                     </td> | ||||
|                     <td> | ||||
|                         {% decimal stocktake.quantity %} | ||||
|                         <span class='badge bg-dark rounded-pill float-right'>{{ stocktake.user.username }}</span> | ||||
|                     </td> | ||||
|                 </tr> | ||||
|                 {% endif %} | ||||
|   | ||||
| @@ -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 %} | ||||
|   | ||||
| @@ -1,6 +1,10 @@ | ||||
| {% load i18n %} | ||||
| {% load inventree_extras %} | ||||
|  | ||||
| <div id='part-stocktake' style='max-height: 300px;'> | ||||
|     <canvas id='part-stocktake-chart' width='100%' style='max-height: 300px;'></canvas> | ||||
| </div> | ||||
|  | ||||
| <div id='part-stocktake-toolbar'> | ||||
|     <div class='btn-group' role='group'> | ||||
|         {% include "filter_list.html" with id="partstocktake" %} | ||||
|   | ||||
| @@ -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)) | ||||
|   | ||||
| @@ -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: | ||||
|   | ||||
| @@ -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 | ||||
|  | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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 %} | ||||
| <button type='button' class='btn btn-outline-secondary' id='location-stocktake' title='{% trans "Perform stocktake for this stock location" %}'> | ||||
|     <span class='fas fa-clipboard-check'></span> | ||||
| </button> | ||||
| {% endif %} | ||||
|  | ||||
| {% mixin_available "locate" as locate_available %} | ||||
| {% 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({ | ||||
|   | ||||
							
								
								
									
										45
									
								
								InvenTree/templates/InvenTree/settings/part_stocktake.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								InvenTree/templates/InvenTree/settings/part_stocktake.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,45 @@ | ||||
| {% extends "panel.html" %} | ||||
| {% load i18n %} | ||||
|  | ||||
| {% block label %}stocktake{% endblock %} | ||||
|  | ||||
| {% block heading %} | ||||
| {% trans "Stocktake Settings" %} | ||||
| {% endblock %} | ||||
|  | ||||
| {% block panel_content %} | ||||
|  | ||||
| <div class='panel-content'> | ||||
|     <table class='table table-striped table-condensed'> | ||||
|         <tbody> | ||||
|             {% include "InvenTree/settings/setting.html" with key="STOCKTAKE_ENABLE" icon="fa-clipboard-check" %} | ||||
|             {% include "InvenTree/settings/setting.html" with key="STOCKTAKE_AUTO_DAYS" icon="fa-calendar-alt" %} | ||||
|             {% include "InvenTree/settings/setting.html" with key="STOCKTAKE_DELETE_REPORT_DAYS" icon="fa-trash-alt" %} | ||||
|         </tbody> | ||||
|     </table> | ||||
| </div> | ||||
|  | ||||
| <div class='panel-heading'> | ||||
|     <div class='d-flex flex-wrap'> | ||||
|         <h4>{% trans "Stocktake Reports" %}</h4> | ||||
|         {% include "spacer.html" %} | ||||
|         <div class='btn-group' role='group'> | ||||
|             {% if roles.stocktake.add %} | ||||
|             <button type='button' id='btn-generate-stocktake' class='btn btn-primary float-right'> | ||||
|                 <span class='fas fa-clipboard-check'></span> {% trans "Stocktake" %} | ||||
|             </button> | ||||
|             {% endif %} | ||||
|         </div> | ||||
|     </div> | ||||
| </div> | ||||
|  | ||||
| <div class='panel-content'> | ||||
|     <div id='part-stocktake-report-toolbar'> | ||||
|         <div class='btn-group' role='group'> | ||||
|             {% include "filter_list.html" with id="stocktakereport" %} | ||||
|         </div> | ||||
|     </div> | ||||
|     <table class='table table-striped table-condensed' id='stocktake-report-table' data-toolbar='#part-stocktake-report-toolbar'></table> | ||||
| </div> | ||||
|  | ||||
| {% endblock panel_content %} | ||||
| @@ -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 = "<button title='{% trans "Edit Template" %}' class='template-edit btn btn-outline-secondary' type='button' pk='" + row.pk + "'><span class='fas fa-edit'></span></button>"; | ||||
|                 var bDel = "<button title='{% trans "Delete Template" %}' class='template-delete btn btn-outline-secondary' type='button' pk='" + row.pk + "'><span class='fas fa-trash-alt icon-red'></span></button>"; | ||||
|  | ||||
|                 var html = value | ||||
|                 html += "<div class='btn-group float-right' role='group'>" + bEdit + bDel + "</div>"; | ||||
|  | ||||
|                 return html; | ||||
|             } | ||||
|         } | ||||
|     ] | ||||
| }); | ||||
|  | ||||
| function loadTemplateTable(pk) { | ||||
|  | ||||
|     var query = {}; | ||||
|  | ||||
|     if (pk) { | ||||
|         query['category'] = pk; | ||||
|     } | ||||
|  | ||||
|     // Load the parameter table | ||||
|     $("#cat-param-table").bootstrapTable('refresh', { | ||||
|         query: query, | ||||
|         url: '{% url "api-part-category-parameter-list" %}', | ||||
|     }); | ||||
| } | ||||
|  | ||||
|  | ||||
| // Initially load table with *all* categories | ||||
| loadTemplateTable(); | ||||
|  | ||||
| $('body').on('change', '#category-select', function() { | ||||
|     var pk = $(this).val(); | ||||
|     loadTemplateTable(pk); | ||||
| }); | ||||
|  | ||||
| $("#new-cat-param").click(function() { | ||||
|  | ||||
|     var pk = $('#category-select').val(); | ||||
|  | ||||
|     constructForm('{% url "api-part-category-parameter-list" %}', { | ||||
|         title: '{% trans "Create Category Parameter Template" %}', | ||||
|         method: 'POST', | ||||
|         fields: { | ||||
|             parameter_template: {}, | ||||
|             category: { | ||||
|                 icon: 'fa-sitemap', | ||||
|                 value: pk, | ||||
|             }, | ||||
|             default_value: {}, | ||||
|         }, | ||||
|         onSuccess: function() { | ||||
|             loadTemplateTable(pk); | ||||
|         } | ||||
|     }); | ||||
| }); | ||||
|  | ||||
| $("#cat-param-table").on('click', '.template-edit', function() { | ||||
|  | ||||
|     var category = $('#category-select').val(); | ||||
|     var pk = $(this).attr('pk'); | ||||
|  | ||||
|     constructForm(`/api/part/category/parameters/${pk}/`, { | ||||
|         fields: { | ||||
|             parameter_template: {}, | ||||
|             category: { | ||||
|                 icon: 'fa-sitemap', | ||||
|             }, | ||||
|             default_value: {}, | ||||
|         }, | ||||
|         onSuccess: function() { | ||||
|             loadTemplateTable(pk); | ||||
|         } | ||||
|     }); | ||||
| }); | ||||
|  | ||||
|  | ||||
| $("#cat-param-table").on('click', '.template-delete', function() { | ||||
|  | ||||
|     var category = $('#category-select').val(); | ||||
|     var pk = $(this).attr('pk'); | ||||
|  | ||||
|     var url = `/part/category/${category}/parameters/${pk}/delete/`; | ||||
|  | ||||
|     constructForm(`/api/part/category/parameters/${pk}/`, { | ||||
|         method: 'DELETE', | ||||
|         title: '{% trans "Delete Category Parameter Template" %}', | ||||
|         onSuccess: function() { | ||||
|             loadTemplateTable(pk); | ||||
|         } | ||||
|     }); | ||||
| }); | ||||
|  | ||||
| $("#param-table").inventreeTable({ | ||||
|     url: "{% url 'api-part-parameter-template-list' %}", | ||||
|     queryParams: { | ||||
|         ordering: 'name', | ||||
|     }, | ||||
|     formatNoMatches: function() { return '{% trans "No part parameter templates found" %}'; }, | ||||
|     columns: [ | ||||
|         { | ||||
|             field: 'pk', | ||||
|             title: '{% trans "ID" %}', | ||||
|             visible: false, | ||||
|             switchable: false, | ||||
|         }, | ||||
|         { | ||||
|             field: 'name', | ||||
|             title: '{% trans "Name" %}', | ||||
|             sortable: true, | ||||
|         }, | ||||
|         { | ||||
|             field: 'units', | ||||
|             title: '{% trans "Units" %}', | ||||
|             sortable: true, | ||||
|             switchable: true, | ||||
|         }, | ||||
|         { | ||||
|             field: 'description', | ||||
|             title: '{% trans "Description" %}', | ||||
|             sortable: false, | ||||
|             switchable: true, | ||||
|         }, | ||||
|         { | ||||
|             formatter: function(value, row, index, field) { | ||||
|                 var bEdit = "<button title='{% trans "Edit Template" %}' class='template-edit btn btn-outline-secondary' type='button' pk='" + row.pk + "'><span class='fas fa-edit icon-green'></span></button>"; | ||||
|                 var bDel = "<button title='{% trans "Delete Template" %}' class='template-delete btn btn-outline-secondary' type='button' pk='" + row.pk + "'><span class='fas fa-trash-alt icon-red'></span></button>"; | ||||
|  | ||||
|                 var html = "<div class='btn-group float-right' role='group'>" + bEdit + bDel + "</div>"; | ||||
|  | ||||
|                 return html; | ||||
|             } | ||||
|         } | ||||
|     ] | ||||
| }); | ||||
|  | ||||
| $("#new-param").click(function() { | ||||
|     constructForm('{% url "api-part-parameter-template-list" %}', { | ||||
|         fields: { | ||||
|             name: {}, | ||||
|             units: {}, | ||||
|             description: {}, | ||||
|         }, | ||||
|         method: 'POST', | ||||
|         title: '{% trans "Create Part Parameter Template" %}', | ||||
|         onSuccess: function() { | ||||
|             $("#param-table").bootstrapTable('refresh'); | ||||
|         }, | ||||
|     }); | ||||
| }); | ||||
|  | ||||
| $("#param-table").on('click', '.template-edit', function() { | ||||
|     var button = $(this); | ||||
|     var pk = button.attr('pk'); | ||||
|  | ||||
|     constructForm( | ||||
|         `/api/part/parameter/template/${pk}/`, | ||||
|         { | ||||
|             fields: { | ||||
|                 name: {}, | ||||
|                 units: {}, | ||||
|                 description: {}, | ||||
|             }, | ||||
|             title: '{% trans "Edit Part Parameter Template" %}', | ||||
|             onSuccess: function() { | ||||
|                 $("#param-table").bootstrapTable('refresh'); | ||||
|             }, | ||||
|         } | ||||
|     ); | ||||
| }); | ||||
|  | ||||
| $("#param-table").on('click', '.template-delete', function() { | ||||
|     var button = $(this); | ||||
|     var pk = button.attr('pk'); | ||||
|  | ||||
|     var html = ` | ||||
|     <div class='alert alert-block alert-danger'> | ||||
|         {% trans "Any parameters which reference this template will also be deleted" %} | ||||
|     </div>`; | ||||
|  | ||||
|     constructForm( | ||||
|         `/api/part/parameter/template/${pk}/`, | ||||
|         { | ||||
|             method: 'DELETE', | ||||
|             preFormContent: html, | ||||
|             title: '{% trans "Delete Part Parameter Template" %}', | ||||
|             onSuccess: function() { | ||||
|                 $("#param-table").bootstrapTable('refresh'); | ||||
|             }, | ||||
|         } | ||||
|     ); | ||||
| }); | ||||
|  | ||||
| $("#import-part").click(function() { | ||||
|     launchModalForm("{% url 'api-part-import' %}?reset", {}); | ||||
| }); | ||||
|  | ||||
| {% plugins_enabled as plug %} | ||||
| {% if plug %} | ||||
| $("#install-plugin").click(function() { | ||||
| {% 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'); | ||||
|   | ||||
							
								
								
									
										92
									
								
								InvenTree/templates/InvenTree/settings/settings_js.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								InvenTree/templates/InvenTree/settings/settings_js.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,92 @@ | ||||
| {% load i18n %} | ||||
| {% load static %} | ||||
| {% load inventree_extras %} | ||||
|  | ||||
| // Callback for when boolean settings are edited | ||||
| $('table').find('.boolean-setting').change(function() { | ||||
|  | ||||
|     var pk = $(this).attr('pk'); | ||||
|     var setting = $(this).attr('setting'); | ||||
|     var plugin = $(this).attr('plugin'); | ||||
|     var user = $(this).attr('user'); | ||||
|     var notification = $(this).attr('notification'); | ||||
|  | ||||
|     var checked = this.checked; | ||||
|  | ||||
|     // Global setting by default | ||||
|     var url = `/api/settings/global/${setting}/`; | ||||
|  | ||||
|     if (notification) { | ||||
|         url = `/api/settings/notification/${pk}/`; | ||||
|     } else if (plugin) { | ||||
|         url = `/api/plugins/settings/${plugin}/${setting}/`; | ||||
|     } else if (user) { | ||||
|         url = `/api/settings/user/${setting}/`; | ||||
|     } | ||||
|  | ||||
|     inventreePut( | ||||
|         url, | ||||
|         { | ||||
|             value: checked.toString(), | ||||
|         }, | ||||
|         { | ||||
|             method: 'PATCH', | ||||
|             success: function(data) { | ||||
|             }, | ||||
|             error: function(xhr) { | ||||
|                 showApiError(xhr, url); | ||||
|             } | ||||
|         } | ||||
|     ); | ||||
|  | ||||
| }); | ||||
|  | ||||
| // Callback for when non-boolean settings are edited | ||||
| $('table').find('.btn-edit-setting').click(function() { | ||||
|     var setting = $(this).attr('setting'); | ||||
|     var plugin = $(this).attr('plugin'); | ||||
|     var is_global = true; | ||||
|     var notification = $(this).attr('notification'); | ||||
|  | ||||
|     if ($(this).attr('user')){ | ||||
|         is_global = false; | ||||
|     } | ||||
|  | ||||
|     var title = ''; | ||||
|  | ||||
|     if (plugin != null) { | ||||
|         title = '{% trans "Edit Plugin Setting" %}'; | ||||
|     } else if (notification) { | ||||
|         title = '{% trans "Edit Notification Setting" %}'; | ||||
|         setting = $(this).attr('pk'); | ||||
|     } else if (is_global) { | ||||
|         title = '{% trans "Edit Global Setting" %}'; | ||||
|     } else { | ||||
|         title = '{% trans "Edit User Setting" %}'; | ||||
|     } | ||||
|  | ||||
|     editSetting(setting, { | ||||
|         plugin: plugin, | ||||
|         global: is_global, | ||||
|         notification: notification, | ||||
|         title: title, | ||||
|     }); | ||||
| }); | ||||
|  | ||||
| $("#edit-user").on('click', function() { | ||||
|     launchModalForm( | ||||
|         "{% url 'edit-user' %}", | ||||
|         { | ||||
|             reload: true, | ||||
|         } | ||||
|     ); | ||||
| }); | ||||
|  | ||||
| $("#edit-password").on('click', function() { | ||||
|     launchModalForm( | ||||
|         "{% url 'set-password' %}", | ||||
|         { | ||||
|             reload: true, | ||||
|         } | ||||
|     ); | ||||
| }); | ||||
							
								
								
									
										401
									
								
								InvenTree/templates/InvenTree/settings/settings_staff_js.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										401
									
								
								InvenTree/templates/InvenTree/settings/settings_staff_js.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,401 @@ | ||||
| {% load i18n %} | ||||
| {% load static %} | ||||
| {% load inventree_extras %} | ||||
|  | ||||
| // Javascript for Pricing panel | ||||
| onPanelLoad('pricing', function() { | ||||
|     $('#btn-update-rates').click(function() { | ||||
|         inventreePut( | ||||
|             '{% url "api-currency-refresh" %}', | ||||
|             {}, | ||||
|             { | ||||
|                 method: 'POST', | ||||
|                 success: function(data) { | ||||
|                     location.reload(); | ||||
|                 } | ||||
|             } | ||||
|         ); | ||||
|     }); | ||||
|  | ||||
|     $('#exchange-rate-table').inventreeTable({ | ||||
|         url: '{% url "api-currency-exchange" %}', | ||||
|         search: false, | ||||
|         showColumns: false, | ||||
|         sortable: true, | ||||
|         sidePagination: 'client', | ||||
|         onLoadSuccess: function(response) { | ||||
|             var data = response.exchange_rates || {}; | ||||
|  | ||||
|             var rows = []; | ||||
|  | ||||
|             for (var currency in data) { | ||||
|                 rows.push({ | ||||
|                     'currency': currency, | ||||
|                     'rate': data[currency], | ||||
|                 }); | ||||
|             } | ||||
|  | ||||
|             $('#exchange-rate-table').bootstrapTable('load', rows); | ||||
|         }, | ||||
|         columns: [ | ||||
|             { | ||||
|                 field: 'currency', | ||||
|                 sortable: true, | ||||
|                 title: '{% trans "Currency" %}', | ||||
|             }, | ||||
|             { | ||||
|                 field: 'rate', | ||||
|                 sortable: true, | ||||
|                 title: '{% trans "Rate" %}', | ||||
|             } | ||||
|         ] | ||||
|     }); | ||||
| }); | ||||
|  | ||||
| // Javascript for Part Category panel | ||||
| onPanelLoad('category', function() { | ||||
|     $('#category-select').select2({ | ||||
|         placeholder: '', | ||||
|         width: '100%', | ||||
|         ajax: { | ||||
|             url: '{% url "api-part-category-list" %}', | ||||
|             dataType: 'json', | ||||
|             delay: 250, | ||||
|             cache: false, | ||||
|             data: function(params) { | ||||
|                 if (!params.page) { | ||||
|                     offset = 0; | ||||
|                 } else { | ||||
|                     offset = (params.page - 1) * 25; | ||||
|                 } | ||||
|  | ||||
|                 return { | ||||
|                     search: params.term, | ||||
|                     offset: offset, | ||||
|                     limit: 25, | ||||
|                 }; | ||||
|             }, | ||||
|             processResults: function(response) { | ||||
|                 var data = []; | ||||
|  | ||||
|                 var more = false; | ||||
|  | ||||
|                 if ('count' in response && 'results' in response) { | ||||
|                     // Response is paginated | ||||
|                     data = response.results; | ||||
|  | ||||
|                     // Any more data available? | ||||
|                     if (response.next) { | ||||
|                         more = true; | ||||
|                     } | ||||
|  | ||||
|                 } else { | ||||
|                     // Non-paginated response | ||||
|                     data = response; | ||||
|                 } | ||||
|  | ||||
|                 // Each 'row' must have the 'id' attribute | ||||
|                 for (var idx = 0; idx < data.length; idx++) { | ||||
|                     data[idx].id = data[idx].pk; | ||||
|                     data[idx].text = data[idx].pathstring; | ||||
|                 } | ||||
|  | ||||
|                 // Ref: https://select2.org/data-sources/formats | ||||
|                 var results = { | ||||
|                     results: data, | ||||
|                     pagination: { | ||||
|                         more: more, | ||||
|                     } | ||||
|                 }; | ||||
|  | ||||
|                 return results; | ||||
|             } | ||||
|         }, | ||||
|     }); | ||||
|  | ||||
|     $('#cat-param-table').inventreeTable({ | ||||
|         formatNoMatches: function() { return '{% trans "No category parameter templates found" %}'; }, | ||||
|         columns: [ | ||||
|             { | ||||
|                 field: 'pk', | ||||
|                 title: 'ID', | ||||
|                 visible: false, | ||||
|                 switchable: false, | ||||
|             }, | ||||
|             { | ||||
|                 field: 'parameter_template_detail.name', | ||||
|                 title: '{% trans "Parameter Template" %}', | ||||
|                 sortable: 'true', | ||||
|             }, | ||||
|             { | ||||
|                 field: 'category_detail.pathstring', | ||||
|                 title: '{% trans "Category" %}', | ||||
|             }, | ||||
|             { | ||||
|                 field: 'default_value', | ||||
|                 title: '{% trans "Default Value" %}', | ||||
|                 sortable: 'true', | ||||
|                 formatter: function(value, row, index, field) { | ||||
|                     var bEdit = "<button title='{% trans "Edit Template" %}' class='template-edit btn btn-outline-secondary' type='button' pk='" + row.pk + "'><span class='fas fa-edit'></span></button>"; | ||||
|                     var bDel = "<button title='{% trans "Delete Template" %}' class='template-delete btn btn-outline-secondary' type='button' pk='" + row.pk + "'><span class='fas fa-trash-alt icon-red'></span></button>"; | ||||
|  | ||||
|                     var html = value | ||||
|                     html += "<div class='btn-group float-right' role='group'>" + bEdit + bDel + "</div>"; | ||||
|  | ||||
|                     return html; | ||||
|                 } | ||||
|             } | ||||
|         ] | ||||
|     }); | ||||
|  | ||||
|     $("#cat-param-table").on('click', '.template-edit', function() { | ||||
|  | ||||
|         var category = $('#category-select').val(); | ||||
|         var pk = $(this).attr('pk'); | ||||
|  | ||||
|         constructForm(`/api/part/category/parameters/${pk}/`, { | ||||
|             fields: { | ||||
|                 parameter_template: {}, | ||||
|                 category: { | ||||
|                     icon: 'fa-sitemap', | ||||
|                 }, | ||||
|                 default_value: {}, | ||||
|             }, | ||||
|             onSuccess: function() { | ||||
|                 loadTemplateTable(pk); | ||||
|             } | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     $("#cat-param-table").on('click', '.template-delete', function() { | ||||
|  | ||||
|         var category = $('#category-select').val(); | ||||
|         var pk = $(this).attr('pk'); | ||||
|  | ||||
|         var url = `/part/category/${category}/parameters/${pk}/delete/`; | ||||
|  | ||||
|         constructForm(`/api/part/category/parameters/${pk}/`, { | ||||
|             method: 'DELETE', | ||||
|             title: '{% trans "Delete Category Parameter Template" %}', | ||||
|             onSuccess: function() { | ||||
|                 loadTemplateTable(pk); | ||||
|             } | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     function loadTemplateTable(pk) { | ||||
|  | ||||
|         var query = {}; | ||||
|  | ||||
|         if (pk) { | ||||
|             query['category'] = pk; | ||||
|         } | ||||
|  | ||||
|         // Load the parameter table | ||||
|         $("#cat-param-table").bootstrapTable('refresh', { | ||||
|             query: query, | ||||
|             url: '{% url "api-part-category-parameter-list" %}', | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|  | ||||
|     // Initially load table with *all* categories | ||||
|     loadTemplateTable(); | ||||
|  | ||||
|     $('body').on('change', '#category-select', function() { | ||||
|         var pk = $(this).val(); | ||||
|         loadTemplateTable(pk); | ||||
|     }); | ||||
|  | ||||
|     $("#new-cat-param").click(function() { | ||||
|  | ||||
|         var pk = $('#category-select').val(); | ||||
|  | ||||
|         constructForm('{% url "api-part-category-parameter-list" %}', { | ||||
|             title: '{% trans "Create Category Parameter Template" %}', | ||||
|             method: 'POST', | ||||
|             fields: { | ||||
|                 parameter_template: {}, | ||||
|                 category: { | ||||
|                     icon: 'fa-sitemap', | ||||
|                     value: pk, | ||||
|                 }, | ||||
|                 default_value: {}, | ||||
|             }, | ||||
|             onSuccess: function() { | ||||
|                 loadTemplateTable(pk); | ||||
|             } | ||||
|         }); | ||||
|     }); | ||||
| }); | ||||
|  | ||||
|  | ||||
| // Javascript for the Part settings panel | ||||
| onPanelLoad('parts', function() { | ||||
|     $("#param-table").inventreeTable({ | ||||
|         url: "{% url 'api-part-parameter-template-list' %}", | ||||
|         queryParams: { | ||||
|             ordering: 'name', | ||||
|         }, | ||||
|         formatNoMatches: function() { return '{% trans "No part parameter templates found" %}'; }, | ||||
|         columns: [ | ||||
|             { | ||||
|                 field: 'pk', | ||||
|                 title: '{% trans "ID" %}', | ||||
|                 visible: false, | ||||
|                 switchable: false, | ||||
|             }, | ||||
|             { | ||||
|                 field: 'name', | ||||
|                 title: '{% trans "Name" %}', | ||||
|                 sortable: true, | ||||
|             }, | ||||
|             { | ||||
|                 field: 'units', | ||||
|                 title: '{% trans "Units" %}', | ||||
|                 sortable: true, | ||||
|                 switchable: true, | ||||
|             }, | ||||
|             { | ||||
|                 field: 'description', | ||||
|                 title: '{% trans "Description" %}', | ||||
|                 sortable: false, | ||||
|                 switchable: true, | ||||
|             }, | ||||
|             { | ||||
|                 formatter: function(value, row, index, field) { | ||||
|                     var bEdit = "<button title='{% trans "Edit Template" %}' class='template-edit btn btn-outline-secondary' type='button' pk='" + row.pk + "'><span class='fas fa-edit icon-green'></span></button>"; | ||||
|                     var bDel = "<button title='{% trans "Delete Template" %}' class='template-delete btn btn-outline-secondary' type='button' pk='" + row.pk + "'><span class='fas fa-trash-alt icon-red'></span></button>"; | ||||
|  | ||||
|                     var html = "<div class='btn-group float-right' role='group'>" + bEdit + bDel + "</div>"; | ||||
|  | ||||
|                     return html; | ||||
|                 } | ||||
|             } | ||||
|         ] | ||||
|     }); | ||||
|  | ||||
|     $("#new-param").click(function() { | ||||
|         constructForm('{% url "api-part-parameter-template-list" %}', { | ||||
|             fields: { | ||||
|                 name: {}, | ||||
|                 units: {}, | ||||
|                 description: {}, | ||||
|             }, | ||||
|             method: 'POST', | ||||
|             title: '{% trans "Create Part Parameter Template" %}', | ||||
|             onSuccess: function() { | ||||
|                 $("#param-table").bootstrapTable('refresh'); | ||||
|             }, | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     $("#param-table").on('click', '.template-edit', function() { | ||||
|         var button = $(this); | ||||
|         var pk = button.attr('pk'); | ||||
|  | ||||
|         constructForm( | ||||
|             `/api/part/parameter/template/${pk}/`, | ||||
|             { | ||||
|                 fields: { | ||||
|                     name: {}, | ||||
|                     units: {}, | ||||
|                     description: {}, | ||||
|                 }, | ||||
|                 title: '{% trans "Edit Part Parameter Template" %}', | ||||
|                 onSuccess: function() { | ||||
|                     $("#param-table").bootstrapTable('refresh'); | ||||
|                 }, | ||||
|             } | ||||
|         ); | ||||
|     }); | ||||
|  | ||||
|     $("#param-table").on('click', '.template-delete', function() { | ||||
|         var button = $(this); | ||||
|         var pk = button.attr('pk'); | ||||
|  | ||||
|         var html = ` | ||||
|         <div class='alert alert-block alert-danger'> | ||||
|             {% trans "Any parameters which reference this template will also be deleted" %} | ||||
|         </div>`; | ||||
|  | ||||
|         constructForm( | ||||
|             `/api/part/parameter/template/${pk}/`, | ||||
|             { | ||||
|                 method: 'DELETE', | ||||
|                 preFormContent: html, | ||||
|                 title: '{% trans "Delete Part Parameter Template" %}', | ||||
|                 onSuccess: function() { | ||||
|                     $("#param-table").bootstrapTable('refresh'); | ||||
|                 }, | ||||
|             } | ||||
|         ); | ||||
|     }); | ||||
|  | ||||
|     $("#import-part").click(function() { | ||||
|         launchModalForm("{% url 'api-part-import' %}?reset", {}); | ||||
|     }); | ||||
| }); | ||||
|  | ||||
|  | ||||
| // Javascript for the Stocktake settings panel | ||||
| onPanelLoad('stocktake', function() { | ||||
|  | ||||
|     {% if roles.stocktake.view %} | ||||
|     var table = '#stocktake-report-table'; | ||||
|  | ||||
|     var filters = loadTableFilters('stocktakereport'); | ||||
|     setupFilterList('stocktakereport', $(table), '#filter-list-stocktakereport'); | ||||
|  | ||||
|     $(table).inventreeTable({ | ||||
|         url: '{% url "api-part-stocktake-report-list" %}', | ||||
|         search: false, | ||||
|         queryParams: filters, | ||||
|         name: 'stocktakereport', | ||||
|         showColumns: false, | ||||
|         sidePagination: 'server', | ||||
|         sortable: true, | ||||
|         sortName: 'date', | ||||
|         sortOrder: 'desc', | ||||
|         columns: [ | ||||
|             { | ||||
|                 field: 'report', | ||||
|                 title: '{% trans "Report" %}', | ||||
|                 formatter: function(value, row) { | ||||
|                     return attachmentLink(value); | ||||
|                 } | ||||
|             }, | ||||
|             { | ||||
|                 field: 'part_count', | ||||
|                 title: '{% trans "Part Count" %}', | ||||
|             }, | ||||
|             { | ||||
|                 field: 'date', | ||||
|                 title: '{% trans "Date" %}', | ||||
|                 sortable: true, | ||||
|                 formatter: function(value, row) { | ||||
|                     let html = renderDate(value); | ||||
|  | ||||
|                     if (row.user_detail) { | ||||
|                         html += `<span class='badge bg-dark rounded-pill float-right'>${row.user_detail.username}</span>`; | ||||
|                     } | ||||
|  | ||||
|                     return html; | ||||
|                 } | ||||
|             }, | ||||
|         ] | ||||
|     }); | ||||
|     {% endif %} | ||||
|  | ||||
|     {% if roles.stocktake.add %} | ||||
|     $('#btn-generate-stocktake').click(function() { | ||||
|         generateStocktakeReport({ | ||||
|             part: {}, | ||||
|             category: {}, | ||||
|             location: {}, | ||||
|             generate_report: {}, | ||||
|             update_parts: {}, | ||||
|         }); | ||||
|     }); | ||||
|     {% endif %} | ||||
| }); | ||||
| @@ -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 %} | ||||
|   | ||||
| @@ -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" %} | ||||
|  | ||||
|     </tbody> | ||||
| </table> | ||||
| {% endblock %} | ||||
|   | ||||
| @@ -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 = `<span class='fas ${icon}'></span> ${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 = `<span class='fas ${icon}'></span> ${filename}`; | ||||
|  | ||||
|                         return renderLink(html, value, {download: true}); | ||||
|                         return attachmentLink(row.attachment); | ||||
|                     } else if (row.link) { | ||||
|                         var html = `<span class='fas fa-link'></span> ${row.link}`; | ||||
|                         return renderLink(html, row.link); | ||||
|   | ||||
| @@ -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'); | ||||
|   | ||||
| @@ -274,6 +274,10 @@ function renderLink(text, url, options={}) { | ||||
|         extras += ` title="${url}"`; | ||||
|     } | ||||
|  | ||||
|     if (options.download) { | ||||
|         extras += ` download`; | ||||
|     } | ||||
|  | ||||
|     return `<a href="${url}" ${extras}>${text}</a>`; | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -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 = `<a href='${value.link}'>${html}</a>`; | ||||
|                     } | ||||
|                     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', | ||||
|   | ||||
| @@ -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}`; | ||||
|     if (options.part != null) { | ||||
|         fields.part = options.part; | ||||
|     } | ||||
|  | ||||
|         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 += `<div class='float-right' title='{% trans "Stock item has not been checked recently" %}'><span class='fas fa-calendar-alt icon-red'></span></div>`; | ||||
|             } | ||||
|     if (options.category != null) { | ||||
|         fields.category = options.category; | ||||
|     } | ||||
|  | ||||
|         // Actions | ||||
|         var actions = `<div class='btn-group float-right' role='group'>`; | ||||
|  | ||||
|         // TODO: Future work | ||||
|         // actions += makeIconButton('fa-check-circle icon-green', 'button-line-count', pk, '{% trans "Update item" %}'); | ||||
|         // actions += makeIconButton('fa-trash-alt icon-red', 'button-line-delete', pk, '{% trans "Delete item" %}'); | ||||
|  | ||||
|         actions += `</div>`; | ||||
|  | ||||
|         return ` | ||||
|         <tr> | ||||
|             <td id='part-${pk}'>${part}</td> | ||||
|             <td id='loc-${pk}'>${location}</td> | ||||
|             <td id='quantity-${pk}'>${quantity}</td> | ||||
|             <td id='updated-${pk}'>${update_rendered}</td> | ||||
|             <td id='actions-${pk}'>${actions}</td> | ||||
|         </tr>`; | ||||
|     if (options.location != null) { | ||||
|         fields.location = options.location; | ||||
|     } | ||||
|  | ||||
|     // First, load stock information for the part | ||||
|     inventreeGet( | ||||
|         '{% url "api-stock-list" %}', | ||||
|         { | ||||
|             part: partId, | ||||
|             in_stock: true, | ||||
|             location_detail: true, | ||||
|             part_detail: true, | ||||
|             include_variants: true, | ||||
|             ordering: '-stock', | ||||
|         }, | ||||
|         { | ||||
|             success: function(response) { | ||||
|                 var html = ''; | ||||
|     if (options.generate_report) { | ||||
|         fields.generate_report = options.generate_report; | ||||
|     } | ||||
|  | ||||
|                 html += ` | ||||
|                 <table class='table table-striped table-condensed'> | ||||
|                     <thead> | ||||
|                         <tr> | ||||
|                             <th>{% trans "Stock Item" %}</th> | ||||
|                             <th>{% trans "Location" %}</th> | ||||
|                             <th>{% trans "Quantity" %}</th> | ||||
|                             <th>{% trans "Updated" %}</th> | ||||
|                             <th><!-- Actions --></th> | ||||
|                         </tr> | ||||
|                     </thead> | ||||
|                     <tbody> | ||||
|     if (options.update_parts) { | ||||
|         fields.update_parts = options.update_parts; | ||||
|     } | ||||
|  | ||||
|     let content = ` | ||||
|     <div class='alert alert-block alert-info'> | ||||
|     {% trans "Schedule generation of a new stocktake report." %} {% trans "Once complete, the stocktake report will be available for download." %} | ||||
|     </div> | ||||
|     `; | ||||
|  | ||||
|                 response.forEach(function(item) { | ||||
|                     html += buildStockItemRow(item); | ||||
|                 }); | ||||
|  | ||||
|                 html += `</tbody></table>`; | ||||
|  | ||||
|                 constructForm(`/api/part/stocktake/`, { | ||||
|                     preFormContent: html, | ||||
|     constructForm( | ||||
|         '{% url "api-part-stocktake-report-generate" %}', | ||||
|         { | ||||
|             method: 'POST', | ||||
|                     title: '{% trans "Part Stocktake" %}', | ||||
|                     confirm: true, | ||||
|                     fields: { | ||||
|                         part: { | ||||
|                             value: partId, | ||||
|                             hidden: true, | ||||
|                         }, | ||||
|                         quantity: { | ||||
|                             value: part_quantity, | ||||
|                         }, | ||||
|                         note: {}, | ||||
|                     }, | ||||
|             title: '{% trans "Generate Stocktake Report" %}', | ||||
|             preFormContent: content, | ||||
|             fields: fields, | ||||
|             onSuccess: function(response) { | ||||
|                         handleFormSuccess(response, options); | ||||
|                     } | ||||
|                 showMessage('{% trans "Stocktake report scheduled" %}', { | ||||
|                     style: 'success', | ||||
|                 }); | ||||
|             } | ||||
|         } | ||||
|     ); | ||||
| } | ||||
|  | ||||
| var stocktakeChart = null; | ||||
|  | ||||
| /* | ||||
|  * Load chart to display part stocktake information | ||||
|  */ | ||||
| function loadStocktakeChart(data, options={}) { | ||||
|  | ||||
|     var chart = 'part-stocktake-chart'; | ||||
|     var context = document.getElementById(chart); | ||||
|  | ||||
|     var quantity_data = []; | ||||
|     var cost_min_data = []; | ||||
|     var cost_max_data = []; | ||||
|  | ||||
|     var base_currency = baseCurrency(); | ||||
|     var rate_data = getCurrencyConversionRates(); | ||||
|  | ||||
|     data.forEach(function(row) { | ||||
|         var date = moment(row.date); | ||||
|         quantity_data.push({ | ||||
|             x: date, | ||||
|             y: row.quantity | ||||
|         }); | ||||
|  | ||||
|         if (row.cost_min) { | ||||
|             cost_min_data.push({ | ||||
|                 x: date, | ||||
|                 y: convertCurrency( | ||||
|                     row.cost_min, | ||||
|                     row.cost_min_currency || base_currency, | ||||
|                     base_currency, | ||||
|                     rate_data | ||||
|                 ), | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         if (row.cost_max) { | ||||
|             cost_max_data.push({ | ||||
|                 x: date, | ||||
|                 y: convertCurrency( | ||||
|                     row.cost_max, | ||||
|                     row.cost_max_currency || base_currency, | ||||
|                     base_currency, | ||||
|                     rate_data | ||||
|                 ), | ||||
|             }); | ||||
|         } | ||||
|     }); | ||||
|  | ||||
|     var chart_data = { | ||||
|         datasets: [ | ||||
|             { | ||||
|                 label: '{% trans "Quantity" %}', | ||||
|                 data: quantity_data, | ||||
|                 backgroundColor: 'rgba(160, 80, 220, 0.75)', | ||||
|                 borderWidth: 3, | ||||
|                 borderColor: 'rgb(160, 80, 220)', | ||||
|                 yAxisID: 'y', | ||||
|             }, | ||||
|             { | ||||
|                 label: '{% trans "Minimum Cost" %}', | ||||
|                 data: cost_min_data, | ||||
|                 backgroundColor: 'rgba(220, 160, 80, 0.25)', | ||||
|                 borderWidth: 2, | ||||
|                 borderColor: 'rgba(220, 160, 80, 0.35)', | ||||
|                 borderDash: [10, 5], | ||||
|                 yAxisID: 'y1', | ||||
|                 fill: '+1', | ||||
|             }, | ||||
|             { | ||||
|                 label: '{% trans "Maximum Cost" %}', | ||||
|                 data: cost_max_data, | ||||
|                 backgroundColor: 'rgba(220, 160, 80, 0.25)', | ||||
|                 borderWidth: 2, | ||||
|                 borderColor: 'rgba(220, 160, 80, 0.35)', | ||||
|                 borderDash: [10, 5], | ||||
|                 yAxisID: 'y1', | ||||
|                 fill: '-1', | ||||
|             } | ||||
|         ] | ||||
|     }; | ||||
|  | ||||
|     if (stocktakeChart != null) { | ||||
|         stocktakeChart.destroy(); | ||||
|     } | ||||
|  | ||||
|     stocktakeChart = new Chart(context, { | ||||
|         type: 'scatter', | ||||
|         data: chart_data, | ||||
|         options: { | ||||
|             showLine: true, | ||||
|             scales: { | ||||
|                 x: { | ||||
|                     type: 'time', | ||||
|                     // suggestedMax: today.format(), | ||||
|                     position: 'bottom', | ||||
|                     time: { | ||||
|                         minUnit: 'day', | ||||
|                     } | ||||
|                 }, | ||||
|                 y: { | ||||
|                     type: 'linear', | ||||
|                     display: true, | ||||
|                     position: 'left', | ||||
|                 }, | ||||
|                 y1: { | ||||
|                     type: 'linear', | ||||
|                     display: true, | ||||
|                     position: 'right', | ||||
|                 } | ||||
|             }, | ||||
|         } | ||||
|     }); | ||||
| } | ||||
|  | ||||
|  | ||||
|  | ||||
| /* | ||||
|  * Load table for part stocktake information | ||||
|  */ | ||||
| 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" %}', | ||||
|   | ||||
| @@ -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, | ||||
|     }); | ||||
|   | ||||
| @@ -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') | ||||
|   | ||||
							
								
								
									
										18
									
								
								InvenTree/users/migrations/0006_alter_ruleset_name.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								InvenTree/users/migrations/0006_alter_ruleset_name.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | ||||
| # Generated by Django 3.2.16 on 2023-02-16 22:22 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('users', '0005_owner_model'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AlterField( | ||||
|             model_name='ruleset', | ||||
|             name='name', | ||||
|             field=models.CharField(choices=[('admin', 'Admin'), ('part_category', 'Part Categories'), ('part', 'Parts'), ('stocktake', 'Stocktake'), ('stock_location', 'Stock Locations'), ('stock', 'Stock Items'), ('build', 'Build Orders'), ('purchase_order', 'Purchase Orders'), ('sales_order', 'Sales Orders')], help_text='Permission set', max_length=50), | ||||
|         ), | ||||
|     ] | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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 | ||||
|  | ||||
|   | ||||
| @@ -49,8 +49,5 @@ fi | ||||
|  | ||||
| cd ${INVENTREE_HOME} | ||||
|  | ||||
| # Collect translation file stats | ||||
| invoke translate-stats | ||||
|  | ||||
| # Launch the CMD *after* the ENTRYPOINT completes | ||||
| exec "$@" | ||||
|   | ||||
		Reference in New Issue
	
	Block a user