diff --git a/shimatta_kenkyusho/api/serializers.py b/shimatta_kenkyusho/api/serializers.py index 183e134..e31505b 100644 --- a/shimatta_kenkyusho/api/serializers.py +++ b/shimatta_kenkyusho/api/serializers.py @@ -44,6 +44,7 @@ class ComponentSerializer(serializers.HyperlinkedModelSerializer): ro_component_type = serializers.ReadOnlyField(source='component_type.class_name') ro_parameters = ComponentParameterSerializer(many=True, source='componentparameter_set', read_only=True) ro_distributor_numbers = ComponentDistributorNumSerializer(many=True, source='distributornum_set', read_only=True) + ro_dynamic_description = serializers.ReadOnlyField(source='dynamic_description') class Meta: model = parts_models.Component @@ -60,12 +61,14 @@ class ComponentSerializer(serializers.HyperlinkedModelSerializer): 'ro_image', 'ro_component_type', 'ro_parameters', - 'ro_distributor_numbers'] + 'ro_distributor_numbers', + 'ro_dynamic_description'] class StockSerializer(serializers.HyperlinkedModelSerializer): id = serializers.ReadOnlyField() ro_package_name = serializers.ReadOnlyField(source='component.package.name') ro_component_name = serializers.ReadOnlyField(source='component.name') + ro_component_dynamic_description = serializers.ReadOnlyField(source='component.dynamic_description') ro_manufacturer_name = serializers.ReadOnlyField(source='component.manufacturer.name') ro_image = serializers.ReadOnlyField(source='component.get_resolved_image') class Meta: @@ -80,7 +83,7 @@ class StorageSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = parts_models.Storage - fields = ['url', 'id', 'name', 'verbose_name', 'parent_storage', 'responsible', 'template', 'full_path'] + fields = ['url', 'id', 'name', 'verbose_name', 'parent_storage', 'responsible', 'template', 'full_path', 'full_path_verbose'] class StorageSerializerStocksExpanded(StorageSerializer): ro_stocks = StockSerializerExpandComponent(many=True, read_only=True, source='stock_set') diff --git a/shimatta_kenkyusho/api/views.py b/shimatta_kenkyusho/api/views.py index c6d8ef7..241abe8 100644 --- a/shimatta_kenkyusho/api/views.py +++ b/shimatta_kenkyusho/api/views.py @@ -43,8 +43,8 @@ class GroupViewSet(viewsets.ReadOnlyModelViewSet): class PartsStorageViewSet(viewsets.ModelViewSet): queryset = parts_models.Storage.objects.all() permission_classes = [permissions.DjangoModelPermissions] - filter_backends = [django_filters.rest_framework.DjangoFilterBackend] - search_fields = ['id', 'name', 'parent_storage'] + filter_backends = [django_filters.rest_framework.DjangoFilterBackend, filters.SearchFilter] + search_fields = ['id', 'name', 'verbose_name'] filterset_fields = ['id', 'name', 'parent_storage'] def get_serializer_class(self): diff --git a/shimatta_kenkyusho/parts/forms.py b/shimatta_kenkyusho/parts/forms.py index 44752e6..8eaaa2f 100644 --- a/shimatta_kenkyusho/parts/forms.py +++ b/shimatta_kenkyusho/parts/forms.py @@ -53,7 +53,7 @@ class AutoCompleteWidget(widgets.Input): 'name_field_name': self.name_field_name, 'prepend': self.prepend, 'name': display_name, - } + } return context class AutocompleteForeingKeyField(forms.UUIDField): @@ -72,7 +72,7 @@ class AutocompleteForeingKeyField(forms.UUIDField): prepend) self.foreign_model = foreign_model - + def clean(self, value): try: pre_cleaned_uuid = super().clean(value) @@ -101,7 +101,8 @@ class ChangeStorageForm(forms.Form): foreign_model=get_user_model(), prepend='@') - is_template = forms.BooleanField(label='is_template', required=False) + expand_sub_storage_stocks = forms.BooleanField(label='Expand sub storage Stocks', required=False) + is_template = forms.BooleanField(label='Is template', required=False) class AddSubStorageForm(ChangeStorageForm): template = AutocompleteForeingKeyField(api_search_url='storage-template-list', @@ -112,15 +113,13 @@ class AddSubStorageForm(ChangeStorageForm): class DeleteStockForm(forms.Form): stock_uuid = forms.UUIDField() -class EditWatermarkForm(forms.Form): +class EditStockBaseForm(forms.Form): stock_uuid = forms.UUIDField() - watermark_active = forms.BooleanField(required=False) #If it is false, the webbrowser won't send it at all. Therefore we have to set it to required=False - watermark = forms.IntegerField(min_value=0) def clean(self): cleaned_data = super().clean() id = cleaned_data.get("stock_uuid") - + if not id: raise ValidationError("No stock UUID given") @@ -133,6 +132,10 @@ class EditWatermarkForm(forms.Form): return cleaned_data +class EditWatermarkForm(EditStockBaseForm): + watermark_active = forms.BooleanField(required=False) #If it is false, the webbrowser won't send it at all. Therefore we have to set it to required=False + watermark = forms.IntegerField(min_value=0) + def save(self): stock = self.cleaned_data['stock'] active = self.cleaned_data['watermark_active'] @@ -144,32 +147,36 @@ class EditWatermarkForm(forms.Form): stock.watermark = watermark stock.save() -class EditStockAmountForm(forms.Form): - stock_uuid = forms.UUIDField() +class EditLotForm(EditStockBaseForm): + lot = forms.IntegerField(min_value=0) + + def save(self): + stock = self.cleaned_data['stock'] + lot = self.cleaned_data['lot'] + + stock.lot = lot + stock.save() + +class RelocateStockForm(forms.ModelForm): + storage = AutocompleteForeingKeyField(api_search_url='storage-list', + foreign_model=parts_models.Storage, + name_field_name='full_path_verbose', + image_field_name=None, + required=True) + + class Meta: + model = parts_models.Stock + fields = ['storage'] + +class EditStockAmountForm(EditStockBaseForm): amount = forms.IntegerField(min_value=0) - def clean(self): - cleaned_data = super().clean() - id = cleaned_data.get("stock_uuid") - - if not id: - raise ValidationError("No stock UUID given") - - stock = None - try: - stock = parts_models.Stock.objects.get(id=id) - except: - raise ValidationError("Stock with uuid %s does not exist" % (id)) - cleaned_data['stock'] = stock - - return cleaned_data - def save(self, increase: bool): stock = self.cleaned_data['stock'] amount = self.cleaned_data['amount'] if not increase: - amount = -amount + amount = -amount return stock.atomic_increment(amount) @@ -194,7 +201,7 @@ class AddStockForm(forms.Form): cleaned_data['component'] = component return cleaned_data - + def save(self, storage): component = self.cleaned_data.get('component') amount = self.cleaned_data.get('amount') @@ -253,7 +260,7 @@ class DistributorNumberDeleteForm(forms.Form): class ComponentParameterDeleteForm(forms.Form): param_num = forms.UUIDField(required=True) model = parts_models.ComponentParameter - + def clean_param_num(self): my_uuid = self.cleaned_data['param_num'] try: @@ -322,7 +329,7 @@ class ComponentParameterCreateForm(forms.Form): if not ptype: raise ValidationError('No valid parameter type selected') - + if not value: raise ValidationError('No valid parameter value') @@ -334,7 +341,7 @@ class ComponentParameterCreateForm(forms.Form): data['number_value'] = number else: pass - + def save(self, component): param_type = self.cleaned_data['parameter_type'] if param_type.parameter_type == 'F': @@ -363,6 +370,6 @@ class QrSearchForm(forms.Form): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - + qr_search = forms.CharField(label='qr_search', validators=[my_qr_validator]) \ No newline at end of file diff --git a/shimatta_kenkyusho/parts/migrations/0014_storage_expand_sub_storage_stocks.py b/shimatta_kenkyusho/parts/migrations/0014_storage_expand_sub_storage_stocks.py new file mode 100644 index 0000000..9dc3c30 --- /dev/null +++ b/shimatta_kenkyusho/parts/migrations/0014_storage_expand_sub_storage_stocks.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.3 on 2025-01-25 14:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('parts', '0013_packageparameter'), + ] + + operations = [ + migrations.AddField( + model_name='storage', + name='expand_sub_storage_stocks', + field=models.BooleanField(default=False), + ), + ] diff --git a/shimatta_kenkyusho/parts/migrations/0015_componenttype_description_template.py b/shimatta_kenkyusho/parts/migrations/0015_componenttype_description_template.py new file mode 100644 index 0000000..0d40d19 --- /dev/null +++ b/shimatta_kenkyusho/parts/migrations/0015_componenttype_description_template.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.3 on 2025-01-31 21:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('parts', '0014_storage_expand_sub_storage_stocks'), + ] + + operations = [ + migrations.AddField( + model_name='componenttype', + name='description_template', + field=models.TextField(blank=True), + ), + ] diff --git a/shimatta_kenkyusho/parts/migrations/0016_componentparametertype_interfix.py b/shimatta_kenkyusho/parts/migrations/0016_componentparametertype_interfix.py new file mode 100644 index 0000000..ded8b86 --- /dev/null +++ b/shimatta_kenkyusho/parts/migrations/0016_componentparametertype_interfix.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.3 on 2025-01-31 21:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('parts', '0015_componenttype_description_template'), + ] + + operations = [ + migrations.AddField( + model_name='componentparametertype', + name='interfix', + field=models.CharField(blank=True, max_length=10), + ), + ] diff --git a/shimatta_kenkyusho/parts/migrations/0017_alter_componentparametertype_interfix_and_more.py b/shimatta_kenkyusho/parts/migrations/0017_alter_componentparametertype_interfix_and_more.py new file mode 100644 index 0000000..d1c6b53 --- /dev/null +++ b/shimatta_kenkyusho/parts/migrations/0017_alter_componentparametertype_interfix_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.3 on 2025-02-02 10:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('parts', '0016_componentparametertype_interfix'), + ] + + operations = [ + migrations.AlterField( + model_name='componentparametertype', + name='interfix', + field=models.CharField(blank=True, help_text='char to be used as decimal point in dynamic description eg. 2R2', max_length=10), + ), + migrations.AlterField( + model_name='componenttype', + name='description_template', + field=models.TextField(blank=True, help_text="Template to assemble the dynamic description. Use template syntax, access the component with 'object', parameters with 'param_*'."), + ), + ] diff --git a/shimatta_kenkyusho/parts/models.py b/shimatta_kenkyusho/parts/models.py index f5d4ec4..c5b448e 100644 --- a/shimatta_kenkyusho/parts/models.py +++ b/shimatta_kenkyusho/parts/models.py @@ -6,6 +6,7 @@ from django.core.exceptions import ValidationError from django.core.validators import RegexValidator from django.dispatch import receiver from django.core.validators import MinValueValidator, MaxValueValidator +from django.template import engines import os import uuid from shimatta_modules.EngineeringNumberConverter import EngineeringNumberConverter as NumConv @@ -29,6 +30,7 @@ class ComponentParameterType(models.Model): parameter_name = models.CharField(max_length=50, unique=True) parameter_description = models.TextField(null=False, blank=True) unit = models.CharField(max_length=10, null=False, blank=True) + interfix = models.CharField(max_length=10, null=False, blank=True, help_text="char to be used as decimal point in dynamic description eg. 2R2") parameter_type = models.CharField(max_length=1, choices=TYPE_CHOICES, default='N') def __str__(self): @@ -49,6 +51,9 @@ class ComponentType(models.Model): class_name = models.CharField(max_length=50, unique=True) passive = models.BooleanField() possible_parameter = models.ManyToManyField(ComponentParameterType, blank=True) + description_template = models.TextField(blank=True, + help_text="Template to assemble the dynamic description. " + "Use template syntax, access the component with 'object', parameters with 'param_*'.") def __str__(self): return '[' + self.class_name + ']' @@ -65,6 +70,7 @@ class Storage(models.Model): verbose_name = models.CharField(max_length=100, null=True, blank=True) parent_storage = models.ForeignKey('self', on_delete=models.CASCADE, blank=True, null=True) responsible = models.ForeignKey(get_user_model(), on_delete=models.SET_NULL, blank=True, null=True) + expand_sub_storage_stocks = models.BooleanField(default=False) # allow storages to be templates which can be selected when adding new storages is_template = models.BooleanField(default=False) @@ -74,25 +80,40 @@ class Storage(models.Model): null=True, related_name='template_of') - def get_path_components(self): + # caching variable for subtrees + storage_list = None + + def get_path_components(self, top_level=None): chain = [] iterator = self chain.append(self) while iterator.parent_storage is not None: + + if top_level and iterator.parent_storage == top_level: + break + chain.append(iterator.parent_storage) iterator = iterator.parent_storage return chain - def get_full_path(self): + def get_full_path(self, top_level=None): output = '' - chain = self.get_path_components() + chain = self.get_path_components(top_level) for i in range(len(chain) - 1, -1, -1): output = output + '/' + chain[i].name return output + @property + def full_path_verbose(self): + full_path = f'{self.get_full_path()} ({self.id})' + + if self.verbose_name: + full_path += f' ({self.verbose_name})' + return full_path + def get_qr_code(self): qrdata = '[stor_uuid]' + str(self.id) return qrdata @@ -106,6 +127,19 @@ class Storage(models.Model): else: return self.storage_set.all() + @classmethod + def get_substorage_list(cls, sub_storages): + sub_sub_storages = cls.objects.filter(parent_storage__in=sub_storages) + + final_sub_storages = sub_storages | sub_sub_storages + if sub_sub_storages: + final_sub_storages |= cls.get_substorage_list(sub_sub_storages) + + return final_sub_storages + + def get_storage_list(self): + return Storage.objects.filter(id=self.id) | self.get_substorage_list(self.storage_set.all()) + def validate_unique(self, exclude=None): if Storage.objects.exclude(id=self.id).filter(name=self.name, parent_storage__isnull=True).exists(): if self.parent_storage is None: @@ -116,12 +150,18 @@ class Storage(models.Model): raise def get_total_stock_amount(self): - stocks = Stock.objects.filter(storage=self) + stocks = Stock.objects.filter(storage__in=self.storage_list or self.get_storage_list()) sum = stocks.aggregate(Sum('amount'))['amount__sum'] if sum is None: sum = 0 return sum + def get_total_stock_count(self): + return Stock.objects.filter(storage__in=self.storage_list or self.get_storage_list()).count() + + def get_total_substorage_amount(self): + return len(self.storage_list or self.get_storage_list()) - 1 # -1 as thhe storage list counts the parent storage as well + @classmethod def from_path(cls, path, root_storage=None): ''' @@ -237,6 +277,22 @@ class Component(models.Model): if sum is None: sum = 0 return sum + + @property + def dynamic_description(self): + + if not self.component_type or not self.component_type.description_template: + return '' + + django_engine = engines["django"] + template = django_engine.from_string(self.component_type.description_template) + + parameters = list(ComponentParameter.objects.filter(component=self)) + parameters += list(PackageParameter.objects.filter(package=self.package)) + + context = {f'param_{param.parameter_type.parameter_name}': param for param in parameters} + context.update({'object': self}) + return template.render(context) class AbstractParameter(models.Model): class Meta: @@ -271,6 +327,25 @@ class AbstractParameter(models.Model): elif my_type == 'F': return self.text_value + def resolved_value_as_short_string(self): + my_type = self.parameter_type.parameter_type + + if my_type == 'E' or my_type == 'I': + # Engineering float number + (num, prefix) = NumConv.number_to_engineering(self.value, it_unit=(True if my_type=='I' else False)) + result = f'{round(num, 3):g}' + interpostfix = (prefix if prefix else self.parameter_type.interfix or '.') + if '.' in result: + result = result.replace('.', interpostfix) + else: + result = result + interpostfix + return result + elif my_type == 'N': + # Standard float number + return f'{round(self.value, 3):g}{self.parameter_type.unit}' + else: + return self.resolved_value_as_string() + class ComponentParameter(AbstractParameter): class Meta: unique_together = ('component', 'parameter_type') @@ -308,7 +383,7 @@ class Stock(models.Model): return True def get_qr_code(self): - qr_data = '[stock]'+str(self.id) + qr_data = '[stck_uuid]'+str(self.id) return qr_data def __str__(self): @@ -407,5 +482,6 @@ def auto_apply_template_structure(sender, instance, created, **kwargs): Storage.objects.create(name=sub_storage.name, parent_storage=instance, responsible=instance.responsible, + expand_sub_storage_stocks=instance.expand_sub_storage_stocks, is_template=False, template=sub_storage) diff --git a/shimatta_kenkyusho/parts/qr_parser.py b/shimatta_kenkyusho/parts/qr_parser.py index 4e5020d..9e3854a 100644 --- a/shimatta_kenkyusho/parts/qr_parser.py +++ b/shimatta_kenkyusho/parts/qr_parser.py @@ -2,7 +2,7 @@ from django.core.exceptions import ValidationError, ObjectDoesNotExist from django.urls import reverse as url_reverse import re -from .models import Storage, Component +from .models import Storage, Component, Stock class QrCode: prefix = '' @@ -19,6 +19,7 @@ class QrCodeValidator: qr_patterns = { 'stor_uuid': QrCode('stor_uuid', 'parts-stocks-detail', Storage), 'comp_uuid': QrCode('comp_uuid', 'parts-components-detail', Component), + 'stck_uuid': QrCode('stck_uuid', 'parts-stock-detail', Stock), } def __init__(self): @@ -32,16 +33,13 @@ class QrCodeValidator: qr_type = matches.group('prefix') qr_uuid = matches.group('uuid') - url_name = self.qr_patterns[qr_type].detail_view - model = None try: + url_name = self.qr_patterns[qr_type].detail_view model = self.qr_patterns[qr_type].model - except: - model = None - if model is None: - raise ValidationError('QR Pattern not registered') - return (model,qr_uuid, url_name) + except KeyError as ex: + raise ValidationError('QR Pattern not registered') from ex + return (model, qr_uuid, url_name) def get_redirect_url(self, data): model, uuid, url_name = self._get_model_from_qr(data) diff --git a/shimatta_kenkyusho/parts/templatetags/__init__.py b/shimatta_kenkyusho/parts/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shimatta_kenkyusho/parts/templatetags/storage_tags.py b/shimatta_kenkyusho/parts/templatetags/storage_tags.py new file mode 100644 index 0000000..85ce3b2 --- /dev/null +++ b/shimatta_kenkyusho/parts/templatetags/storage_tags.py @@ -0,0 +1,9 @@ +import datetime +from django import template + +register = template.Library() + + +@register.filter(name="get_relative_storage_path") +def get_relative_storage_path(storage, top_level): + return f'.{storage.get_full_path(top_level)}' diff --git a/shimatta_kenkyusho/parts/views/storage_views.py b/shimatta_kenkyusho/parts/views/storage_views.py index 1e1e402..e8b0614 100644 --- a/shimatta_kenkyusho/parts/views/storage_views.py +++ b/shimatta_kenkyusho/parts/views/storage_views.py @@ -74,7 +74,13 @@ class StockViewDetail(LoginRequiredMixin, BaseTemplateMixin, DetailView): return crumbs def search_stock_queryset(self, search): - stocks_in_storage = Stock.objects.filter(storage=self.object).order_by(Lower('component__name')) + + if self.object.expand_sub_storage_stocks: + stocks_in_storage = Stock.objects.filter(storage__in=self.object.get_storage_list()) + else: + stocks_in_storage = Stock.objects.filter(storage=self.object) + + stocks_in_storage = stocks_in_storage.order_by(Lower('component__name')) if search is None or search == '': return stocks_in_storage @@ -115,14 +121,16 @@ class StockViewDetail(LoginRequiredMixin, BaseTemplateMixin, DetailView): context['storages'] = storage_paginator.get_page(storage_page) stocks = stock_paginator.get_page(componente_stock_page) context['stocks'] = stocks + context['stocks_with_forms'] = [{'object': s, 'relocate_form': RelocateStockForm(instance=s, prefix=str(s.id))} for s in stocks] context['stock_search'] = stock_search_input add_storage_form = AddSubStorageForm() add_storage_form.fields['responsible'].initial = self.request.user.id context['add_storage_form'] = add_storage_form - change_storage_form = ChangeStorageForm() + change_storage_form = ChangeStorageForm(prefix='change_storage') change_storage_form.fields['storage_name'].initial = self.object.name change_storage_form.fields['verbose_name'].initial = self.object.verbose_name change_storage_form.fields['responsible'].initial = self.object.responsible.id + change_storage_form.fields['expand_sub_storage_stocks'].initial = self.object.expand_sub_storage_stocks change_storage_form.fields['is_template'].initial = self.object.is_template context['change_storage_form'] = change_storage_form context['delete_storage_error'] = None @@ -139,6 +147,7 @@ class StockViewDetail(LoginRequiredMixin, BaseTemplateMixin, DetailView): verbose_name=f.cleaned_data.get('verbose_name'), parent_storage=self.object, responsible=f.cleaned_data['responsible'], + expand_sub_storage_stocks=f.cleaned_data['expand_sub_storage_stocks'], is_template=f.cleaned_data['is_template'], template=f.cleaned_data.get('template')) except ValidationError as v_err: @@ -148,12 +157,13 @@ class StockViewDetail(LoginRequiredMixin, BaseTemplateMixin, DetailView): return self.render_to_response(context) def handle_change_storage_post(self, request, **kwargs): - f = ChangeStorageForm(data=request.POST) + f = ChangeStorageForm(data=request.POST, prefix='change_storage') if f.is_valid(): try: self.object.name = f.cleaned_data['storage_name'] self.object.verbose_name = f.cleaned_data.get('verbose_name') self.object.responsible = f.cleaned_data['responsible'] + self.object.expand_sub_storage_stocks = f.cleaned_data['expand_sub_storage_stocks'] self.object.is_template = f.cleaned_data['is_template'] self.object.save() except ValidationError as v_err: @@ -205,6 +215,27 @@ class StockViewDetail(LoginRequiredMixin, BaseTemplateMixin, DetailView): context = self.get_context_data(**kwargs) return self.render_to_response(context) + def handle_update_lot(self, request, **kwargs): + edit_form = EditLotForm(data=request.POST) + if edit_form.is_valid(): + edit_form.save() + else: + pass # Todo: Handle error + + context = self.get_context_data(**kwargs) + return self.render_to_response(context) + + def handle_relocate_stock(self, request, **kwargs): + instance = Stock.objects.get(id=request.POST['prefix']) + edit_form = RelocateStockForm(instance=instance, data=request.POST, prefix=request.POST['prefix']) + if edit_form.is_valid(): + edit_form.save() + else: + pass # Todo: Handle error + + context = self.get_context_data(**kwargs) + return self.render_to_response(context) + def handle_amount_change_post(self, request, increase, **kwargs): edit_form = EditStockAmountForm(data=request.POST) if edit_form.is_valid(): @@ -244,6 +275,10 @@ class StockViewDetail(LoginRequiredMixin, BaseTemplateMixin, DetailView): return self.handle_del_stock_post(request, **kwargs) elif 'submit-edit-watermark' in request.POST: return self.handle_update_watermark(request, **kwargs) + elif 'submit-edit-lot' in request.POST: + return self.handle_update_lot(request, **kwargs) + elif 'submit-relocate-stock' in request.POST: + return self.handle_relocate_stock(request, **kwargs) elif 'submit-amount-reduce' in request.POST: return self.handle_amount_change_post(request, False, **kwargs) elif 'submit-amount-increase' in request.POST: diff --git a/shimatta_kenkyusho/templates/base.html b/shimatta_kenkyusho/templates/base.html index ac6acad..ef28046 100644 --- a/shimatta_kenkyusho/templates/base.html +++ b/shimatta_kenkyusho/templates/base.html @@ -83,6 +83,13 @@ const popoverTriggerList = document.querySelectorAll('[data-bs-toggle="popover"]') const popoverList = [...popoverTriggerList].map(popoverTriggerEl => new bootstrap.Popover(popoverTriggerEl)) + +