mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +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:
parent
e6c9db2ff3
commit
0f445ea6e4
@ -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,427 +63,17 @@
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
// 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,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
$('#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", {});
|
||||
});
|
||||
{% include "InvenTree/settings/settings_js.html" %}
|
||||
|
||||
{% 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 "$@"
|
||||
|
Loading…
Reference in New Issue
Block a user