diff --git a/InvenTree/InvenTree/filters.py b/InvenTree/InvenTree/filters.py index cd1b769646..94e6e1765b 100644 --- a/InvenTree/InvenTree/filters.py +++ b/InvenTree/InvenTree/filters.py @@ -34,18 +34,47 @@ class InvenTreeOrderingFilter(OrderingFilter): Ordering fields should be mapped to separate fields """ - for idx, field in enumerate(ordering): + ordering_initial = ordering + ordering = [] - reverse = False + for field in ordering_initial: + + reverse = field.startswith('-') - if field.startswith('-'): + if reverse: field = field[1:] - reverse = True + # Are aliases defined for this field? if field in aliases: - ordering[idx] = aliases[field] + alias = aliases[field] + else: + alias = field + """ + Potentially, a single field could be "aliased" to multiple field, + + (For example to enforce a particular ordering sequence) + + e.g. to filter first by the integer value... + + ordering_field_aliases = { + "reference": ["integer_ref", "reference"] + } + + """ + + if type(alias) is str: + alias = [alias] + elif type(alias) in [list, tuple]: + pass + else: + # Unsupported alias type + continue + + for a in alias: if reverse: - ordering[idx] = '-' + ordering[idx] + a = '-' + a + + ordering.append(a) return ordering diff --git a/InvenTree/InvenTree/forms.py b/InvenTree/InvenTree/forms.py index 8b4b87637c..279635756a 100644 --- a/InvenTree/InvenTree/forms.py +++ b/InvenTree/InvenTree/forms.py @@ -4,10 +4,12 @@ Helper forms which subclass Django forms to provide additional functionality # -*- coding: utf-8 -*- from __future__ import unicode_literals +import logging from django.utils.translation import ugettext_lazy as _ from django import forms -from django.contrib.auth.models import User +from django.contrib.auth.models import User, Group +from django.conf import settings from crispy_forms.helper import FormHelper from crispy_forms.layout import Layout, Field @@ -20,6 +22,8 @@ from allauth.socialaccount.adapter import DefaultSocialAccountAdapter from part.models import PartCategory from common.models import InvenTreeSetting +logger = logging.getLogger('inventree') + class HelperForm(forms.ModelForm): """ Provides simple integration of crispy_forms extension. """ @@ -223,11 +227,11 @@ class CustomSignupForm(SignupForm): # check for two mail fields if InvenTreeSetting.get_setting('LOGIN_SIGNUP_MAIL_TWICE'): self.fields["email2"] = forms.EmailField( - label=_("E-mail (again)"), + label=_("Email (again)"), widget=forms.TextInput( attrs={ "type": "email", - "placeholder": _("E-mail address confirmation"), + "placeholder": _("Email address confirmation"), } ), ) @@ -257,10 +261,22 @@ class RegistratonMixin: Mixin to check if registration should be enabled """ def is_open_for_signup(self, request): - if InvenTreeSetting.get_setting('EMAIL_HOST', None) and InvenTreeSetting.get_setting('LOGIN_ENABLE_REG', True): + if settings.EMAIL_HOST and InvenTreeSetting.get_setting('LOGIN_ENABLE_REG', True): return super().is_open_for_signup(request) return False + def save_user(self, request, user, form, commit=True): + user = super().save_user(request, user, form, commit=commit) + start_group = InvenTreeSetting.get_setting('SIGNUP_GROUP') + if start_group: + try: + group = Group.objects.get(id=start_group) + user.groups.add(group) + except Group.DoesNotExist: + logger.error('The setting `SIGNUP_GROUP` contains an non existant group', start_group) + user.save() + return user + class CustomAccountAdapter(RegistratonMixin, DefaultAccountAdapter): """ @@ -268,7 +284,7 @@ class CustomAccountAdapter(RegistratonMixin, DefaultAccountAdapter): """ def send_mail(self, template_prefix, email, context): """only send mail if backend configured""" - if InvenTreeSetting.get_setting('EMAIL_HOST', None): + if settings.EMAIL_HOST: return super().send_mail(template_prefix, email, context) return False diff --git a/InvenTree/InvenTree/locale_stats.json b/InvenTree/InvenTree/locale_stats.json deleted file mode 100644 index 9f003895c5..0000000000 --- a/InvenTree/InvenTree/locale_stats.json +++ /dev/null @@ -1 +0,0 @@ -{"de": 95, "el": 0, "en": 0, "es": 4, "fr": 6, "he": 0, "id": 0, "it": 0, "ja": 4, "ko": 0, "nl": 0, "no": 0, "pl": 27, "ru": 6, "sv": 0, "th": 0, "tr": 32, "vi": 0, "zh": 1} \ No newline at end of file diff --git a/InvenTree/InvenTree/models.py b/InvenTree/InvenTree/models.py index 2ca179bb40..0f8350f84f 100644 --- a/InvenTree/InvenTree/models.py +++ b/InvenTree/InvenTree/models.py @@ -4,6 +4,7 @@ Generic models which provide extra functionality over base Django model types. from __future__ import unicode_literals +import re import os import logging @@ -43,6 +44,48 @@ def rename_attachment(instance, filename): return os.path.join(instance.getSubdir(), filename) +class ReferenceIndexingMixin(models.Model): + """ + A mixin for keeping track of numerical copies of the "reference" field. + + Here, we attempt to convert a "reference" field value (char) to an integer, + for performing fast natural sorting. + + This requires extra database space (due to the extra table column), + but is required as not all supported database backends provide equivalent casting. + + This mixin adds a field named 'reference_int'. + + - If the 'reference' field can be cast to an integer, it is stored here + - If the 'reference' field *starts* with an integer, it is stored here + - Otherwise, we store zero + """ + + class Meta: + abstract = True + + def rebuild_reference_field(self): + + reference = getattr(self, 'reference', '') + + # Default value if we cannot convert to an integer + ref_int = 0 + + # Look at the start of the string - can it be "integerized"? + result = re.match(r"^(\d+)", reference) + + if result and len(result.groups()) == 1: + ref = result.groups()[0] + try: + ref_int = int(ref) + except: + ref_int = 0 + + self.reference_int = ref_int + + reference_int = models.IntegerField(default=0) + + class InvenTreeAttachment(models.Model): """ Provides an abstracted class for managing file attachments. diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index c6ce1e0948..e1dabedc49 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -396,39 +396,6 @@ Q_CLUSTER = { 'sync': False, } -# Markdownx configuration -# Ref: https://neutronx.github.io/django-markdownx/customization/ -MARKDOWNX_MEDIA_PATH = datetime.now().strftime('markdownx/%Y/%m/%d') - -# Markdownify configuration -# Ref: https://django-markdownify.readthedocs.io/en/latest/settings.html - -MARKDOWNIFY_WHITELIST_TAGS = [ - 'a', - 'abbr', - 'b', - 'blockquote', - 'em', - 'h1', 'h2', 'h3', - 'i', - 'img', - 'li', - 'ol', - 'p', - 'strong', - 'ul' -] - -MARKDOWNIFY_WHITELIST_ATTRS = [ - 'href', - 'src', - 'alt', -] - -MARKDOWNIFY_BLEACH = False - -DATABASES = {} - """ Configure the database backend based on the user-specified values. @@ -495,7 +462,47 @@ logger.info(f"DB_ENGINE: {db_engine}") logger.info(f"DB_NAME: {db_name}") logger.info(f"DB_HOST: {db_host}") -DATABASES['default'] = db_config +""" +In addition to base-level database configuration, we may wish to specify specific options to the database backend +Ref: https://docs.djangoproject.com/en/3.2/ref/settings/#std:setting-OPTIONS +""" + +# 'OPTIONS' or 'options' can be specified in config.yaml +db_options = db_config.get('OPTIONS', db_config.get('options', {})) + +# Specific options for postgres backend +if 'postgres' in db_engine: + from psycopg2.extensions import ISOLATION_LEVEL_READ_COMMITTED, ISOLATION_LEVEL_SERIALIZABLE + + # Connection timeout + if 'connect_timeout' not in db_options: + db_options['connect_timeout'] = int(os.getenv('INVENTREE_DB_TIMEOUT', 2)) + + # Postgres's default isolation level is Read Committed which is + # normally fine, but most developers think the database server is + # actually going to do Serializable type checks on the queries to + # protect against simultaneous changes. + if 'isolation_level' not in db_options: + serializable = _is_true(os.getenv("PG_ISOLATION_SERIALIZABLE", "true")) + db_options['isolation_level'] = ISOLATION_LEVEL_SERIALIZABLE if serializable else ISOLATION_LEVEL_READ_COMMITTED + +# Specific options for MySql / MariaDB backend +if 'mysql' in db_engine: + # TODO + pass + +# Specific options for sqlite backend +if 'sqlite' in db_engine: + # TODO + pass + +# Provide OPTIONS dict back to the database configuration dict +db_config['OPTIONS'] = db_options + +DATABASES = { + 'default': db_config +} + CACHES = { 'default': { @@ -695,6 +702,37 @@ ACCOUNT_FORMS = { SOCIALACCOUNT_ADAPTER = 'InvenTree.forms.CustomSocialAccountAdapter' ACCOUNT_ADAPTER = 'InvenTree.forms.CustomAccountAdapter' +# Markdownx configuration +# Ref: https://neutronx.github.io/django-markdownx/customization/ +MARKDOWNX_MEDIA_PATH = datetime.now().strftime('markdownx/%Y/%m/%d') + +# Markdownify configuration +# Ref: https://django-markdownify.readthedocs.io/en/latest/settings.html + +MARKDOWNIFY_WHITELIST_TAGS = [ + 'a', + 'abbr', + 'b', + 'blockquote', + 'em', + 'h1', 'h2', 'h3', + 'i', + 'img', + 'li', + 'ol', + 'p', + 'strong', + 'ul' +] + +MARKDOWNIFY_WHITELIST_ATTRS = [ + 'href', + 'src', + 'alt', +] + +MARKDOWNIFY_BLEACH = False + # Plugins PLUGIN_URL = 'plugin' diff --git a/InvenTree/InvenTree/static/css/inventree.css b/InvenTree/InvenTree/static/css/inventree.css index 71e518560b..eca502425a 100644 --- a/InvenTree/InvenTree/static/css/inventree.css +++ b/InvenTree/InvenTree/static/css/inventree.css @@ -455,6 +455,10 @@ -webkit-opacity: 10%; } +.table-condensed { + font-size: 90%; +} + /* grid display for part images */ .table-img-grid tr { diff --git a/InvenTree/InvenTree/validators.py b/InvenTree/InvenTree/validators.py index 1b6a6b3f0b..76d485cef9 100644 --- a/InvenTree/InvenTree/validators.py +++ b/InvenTree/InvenTree/validators.py @@ -5,6 +5,7 @@ Custom field validators for InvenTree from django.conf import settings from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ +from django.core.exceptions import FieldDoesNotExist from moneyed import CURRENCIES @@ -156,3 +157,33 @@ def validate_overage(value): raise ValidationError( _("Overage must be an integer value or a percentage") ) + + +def validate_part_name_format(self): + """ + Validate part name format. + Make sure that each template container has a field of Part Model + """ + + jinja_template_regex = re.compile('{{.*?}}') + field_name_regex = re.compile('(?<=part\\.)[A-z]+') + for jinja_template in jinja_template_regex.findall(str(self)): + # make sure at least one and only one field is present inside the parser + field_names = field_name_regex.findall(jinja_template) + if len(field_names) < 1: + raise ValidationError({ + 'value': 'At least one field must be present inside a jinja template container i.e {{}}' + }) + + # Make sure that the field_name exists in Part model + from part.models import Part + + for field_name in field_names: + try: + Part._meta.get_field(field_name) + except FieldDoesNotExist: + raise ValidationError({ + 'value': f'{field_name} does not exist in Part Model' + }) + + return True diff --git a/InvenTree/build/admin.py b/InvenTree/build/admin.py index 86592c7a81..a5ad838660 100644 --- a/InvenTree/build/admin.py +++ b/InvenTree/build/admin.py @@ -9,6 +9,10 @@ from .models import Build, BuildItem class BuildAdmin(ImportExportModelAdmin): + exclude = [ + 'reference_int', + ] + list_display = ( 'reference', 'title', diff --git a/InvenTree/build/api.py b/InvenTree/build/api.py index cc897d6ec9..cf4d44a03e 100644 --- a/InvenTree/build/api.py +++ b/InvenTree/build/api.py @@ -17,11 +17,12 @@ from django_filters import rest_framework as rest_filters from InvenTree.api import AttachmentMixin from InvenTree.helpers import str2bool, isNull +from InvenTree.filters import InvenTreeOrderingFilter from InvenTree.status_codes import BuildStatus from .models import Build, BuildItem, BuildOrderAttachment from .serializers import BuildAttachmentSerializer, BuildSerializer, BuildItemSerializer -from .serializers import BuildAllocationSerializer +from .serializers import BuildAllocationSerializer, BuildUnallocationSerializer class BuildFilter(rest_filters.FilterSet): @@ -68,7 +69,7 @@ class BuildList(generics.ListCreateAPIView): filter_backends = [ DjangoFilterBackend, filters.SearchFilter, - filters.OrderingFilter, + InvenTreeOrderingFilter, ] ordering_fields = [ @@ -83,6 +84,10 @@ class BuildList(generics.ListCreateAPIView): 'responsible', ] + ordering_field_aliases = { + 'reference': ['reference_int', 'reference'], + } + search_fields = [ 'reference', 'part__name', @@ -184,6 +189,42 @@ class BuildDetail(generics.RetrieveUpdateAPIView): serializer_class = BuildSerializer +class BuildUnallocate(generics.CreateAPIView): + """ + API endpoint for unallocating stock items from a build order + + - The BuildOrder object is specified by the URL + - "output" (StockItem) can optionally be specified + - "bom_item" can optionally be specified + """ + + queryset = Build.objects.none() + + serializer_class = BuildUnallocationSerializer + + def get_build(self): + """ + Returns the BuildOrder associated with this API endpoint + """ + + pk = self.kwargs.get('pk', None) + + try: + build = Build.objects.get(pk=pk) + except (ValueError, Build.DoesNotExist): + raise ValidationError(_("Matching build order does not exist")) + + return build + + def get_serializer_context(self): + + ctx = super().get_serializer_context() + ctx['build'] = self.get_build() + ctx['request'] = self.request + + return ctx + + class BuildAllocate(generics.CreateAPIView): """ API endpoint to allocate stock items to a build order @@ -349,6 +390,7 @@ build_api_urls = [ # Build Detail url(r'^(?P\d+)/', include([ url(r'^allocate/', BuildAllocate.as_view(), name='api-build-allocate'), + url(r'^unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'), url(r'^.*$', BuildDetail.as_view(), name='api-build-detail'), ])), diff --git a/InvenTree/build/forms.py b/InvenTree/build/forms.py index b3f6cd92de..bc7bdd50f5 100644 --- a/InvenTree/build/forms.py +++ b/InvenTree/build/forms.py @@ -137,32 +137,6 @@ class BuildOutputDeleteForm(HelperForm): ] -class UnallocateBuildForm(HelperForm): - """ - Form for auto-de-allocation of stock from a build - """ - - confirm = forms.BooleanField(required=False, label=_('Confirm'), help_text=_('Confirm unallocation of stock')) - - output_id = forms.IntegerField( - required=False, - widget=forms.HiddenInput() - ) - - part_id = forms.IntegerField( - required=False, - widget=forms.HiddenInput(), - ) - - class Meta: - model = Build - fields = [ - 'confirm', - 'output_id', - 'part_id', - ] - - class CompleteBuildForm(HelperForm): """ Form for marking a build as complete diff --git a/InvenTree/build/migrations/0031_build_reference_int.py b/InvenTree/build/migrations/0031_build_reference_int.py new file mode 100644 index 0000000000..c7fc2c16cc --- /dev/null +++ b/InvenTree/build/migrations/0031_build_reference_int.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.5 on 2021-10-14 06:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('build', '0030_alter_build_reference'), + ] + + operations = [ + migrations.AddField( + model_name='build', + name='reference_int', + field=models.IntegerField(default=0), + ), + ] diff --git a/InvenTree/build/migrations/0032_auto_20211014_0632.py b/InvenTree/build/migrations/0032_auto_20211014_0632.py new file mode 100644 index 0000000000..3dac2b30c6 --- /dev/null +++ b/InvenTree/build/migrations/0032_auto_20211014_0632.py @@ -0,0 +1,50 @@ +# Generated by Django 3.2.5 on 2021-10-14 06:32 + +import re + +from django.db import migrations + + +def build_refs(apps, schema_editor): + """ + Rebuild the integer "reference fields" for existing Build objects + """ + + BuildOrder = apps.get_model('build', 'build') + + for build in BuildOrder.objects.all(): + + ref = 0 + + result = re.match(r"^(\d+)", build.reference) + + if result and len(result.groups()) == 1: + try: + ref = int(result.groups()[0]) + except: + ref = 0 + + build.reference_int = ref + build.save() + +def unbuild_refs(apps, schema_editor): + """ + Provided only for reverse migration compatibility + """ + pass + + +class Migration(migrations.Migration): + + atomic = False + + dependencies = [ + ('build', '0031_build_reference_int'), + ] + + operations = [ + migrations.RunPython( + build_refs, + reverse_code=unbuild_refs + ) + ] diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index 9a7b40b52f..c477794e8c 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -28,7 +28,7 @@ from mptt.exceptions import InvalidMove from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode from InvenTree.validators import validate_build_order_reference -from InvenTree.models import InvenTreeAttachment +from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin import common.models @@ -69,7 +69,7 @@ def get_next_build_number(): return reference -class Build(MPTTModel): +class Build(MPTTModel, ReferenceIndexingMixin): """ A Build object organises the creation of new StockItem objects from other existing StockItem objects. Attributes: @@ -108,6 +108,8 @@ class Build(MPTTModel): def save(self, *args, **kwargs): + self.rebuild_reference_field() + try: super().save(*args, **kwargs) except InvalidMove: @@ -587,9 +589,13 @@ class Build(MPTTModel): self.save() @transaction.atomic - def unallocateOutput(self, output, part=None): + def unallocateStock(self, bom_item=None, output=None): """ - Unallocate all stock which are allocated against the provided "output" (StockItem) + Unallocate stock from this Build + + arguments: + - bom_item: Specify a particular BomItem to unallocate stock against + - output: Specify a particular StockItem (output) to unallocate stock against """ allocations = BuildItem.objects.filter( @@ -597,34 +603,8 @@ class Build(MPTTModel): install_into=output ) - if part: - allocations = allocations.filter(stock_item__part=part) - - allocations.delete() - - @transaction.atomic - def unallocateUntracked(self, part=None): - """ - Unallocate all "untracked" stock - """ - - allocations = BuildItem.objects.filter( - build=self, - install_into=None - ) - - if part: - allocations = allocations.filter(stock_item__part=part) - - allocations.delete() - - @transaction.atomic - def unallocateAll(self): - """ - Deletes all stock allocations for this build. - """ - - allocations = BuildItem.objects.filter(build=self) + if bom_item: + allocations = allocations.filter(bom_item=bom_item) allocations.delete() @@ -720,7 +700,7 @@ class Build(MPTTModel): raise ValidationError(_("Build output does not match Build Order")) # Unallocate all build items against the output - self.unallocateOutput(output) + self.unallocateStock(output=output) # Remove the build output from the database output.delete() @@ -1153,16 +1133,12 @@ class BuildItem(models.Model): i) The sub_part points to the same part as the referenced StockItem ii) The BomItem allows variants and the part referenced by the StockItem is a variant of the sub_part referenced by the BomItem + iii) The Part referenced by the StockItem is a valid substitute for the BomItem """ if self.build and self.build.part == self.bom_item.part: - # Check that the sub_part points to the stock_item (either directly or via a variant) - if self.bom_item.sub_part == self.stock_item.part: - bom_item_valid = True - - elif self.bom_item.allow_variants and self.stock_item.part in self.bom_item.sub_part.get_descendants(include_self=False): - bom_item_valid = True + bom_item_valid = self.bom_item.is_stock_item_valid(self.stock_item) # If the existing BomItem is *not* valid, try to find a match if not bom_item_valid: diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py index 53e71dbd27..547f565905 100644 --- a/InvenTree/build/serializers.py +++ b/InvenTree/build/serializers.py @@ -120,6 +120,61 @@ class BuildSerializer(InvenTreeModelSerializer): ] +class BuildUnallocationSerializer(serializers.Serializer): + """ + DRF serializer for unallocating stock from a BuildOrder + + Allocated stock can be unallocated with a number of filters: + + - output: Filter against a particular build output (blank = untracked stock) + - bom_item: Filter against a particular BOM line item + + """ + + bom_item = serializers.PrimaryKeyRelatedField( + queryset=BomItem.objects.all(), + many=False, + allow_null=True, + required=False, + label=_('BOM Item'), + ) + + output = serializers.PrimaryKeyRelatedField( + queryset=StockItem.objects.filter( + is_building=True, + ), + many=False, + allow_null=True, + required=False, + label=_("Build output"), + ) + + def validate_output(self, stock_item): + + # Stock item must point to the same build order! + build = self.context['build'] + + if stock_item and stock_item.build != build: + raise ValidationError(_("Build output must point to the same build")) + + return stock_item + + def save(self): + """ + 'Save' the serializer data. + This performs the actual unallocation against the build order + """ + + build = self.context['build'] + + data = self.validated_data + + build.unallocateStock( + bom_item=data['bom_item'], + output=data['output'] + ) + + class BuildAllocationItemSerializer(serializers.Serializer): """ A serializer for allocating a single stock item against a build order diff --git a/InvenTree/build/templates/build/detail.html b/InvenTree/build/templates/build/detail.html index 8fb259f8a4..cfba2046e3 100644 --- a/InvenTree/build/templates/build/detail.html +++ b/InvenTree/build/templates/build/detail.html @@ -197,7 +197,7 @@ -
+
@@ -462,12 +462,9 @@ $("#btn-auto-allocate").on('click', function() { }); $('#btn-unallocate').on('click', function() { - launchModalForm( - "{% url 'build-unallocate' build.id %}", - { - success: reloadTable, - } - ); + unallocateStock({{ build.id }}, { + table: '#allocation-table-untracked', + }); }); $('#allocate-selected-items').click(function() { diff --git a/InvenTree/build/templates/build/unallocate.html b/InvenTree/build/templates/build/unallocate.html deleted file mode 100644 index a650e95718..0000000000 --- a/InvenTree/build/templates/build/unallocate.html +++ /dev/null @@ -1,15 +0,0 @@ -{% extends "modal_form.html" %} -{% load i18n %} -{% load inventree_extras %} -{% block pre_form_content %} - -{{ block.super }} - - -
- {% trans "Are you sure you wish to unallocate all stock for this build?" %} -
- {% trans "All incomplete stock allocations will be removed from the build" %} -
- -{% endblock %} \ No newline at end of file diff --git a/InvenTree/build/test_build.py b/InvenTree/build/test_build.py index 04b46bbd26..df6253362e 100644 --- a/InvenTree/build/test_build.py +++ b/InvenTree/build/test_build.py @@ -118,6 +118,26 @@ class BuildTest(TestCase): self.stock_3_1 = StockItem.objects.create(part=self.sub_part_3, quantity=1000) + def test_ref_int(self): + """ + Test the "integer reference" field used for natural sorting + """ + + for ii in range(10): + build = Build( + reference=f"{ii}_abcde", + quantity=1, + part=self.assembly, + title="Making some parts" + ) + + self.assertEqual(build.reference_int, 0) + + build.save() + + # After saving, the integer reference should have been updated + self.assertEqual(build.reference_int, ii) + def test_init(self): # Perform some basic tests before we start the ball rolling @@ -250,7 +270,7 @@ class BuildTest(TestCase): self.assertEqual(len(unallocated), 1) - self.build.unallocateUntracked() + self.build.unallocateStock() unallocated = self.build.unallocatedParts(None) diff --git a/InvenTree/build/tests.py b/InvenTree/build/tests.py index 93c6bfd511..7b2568b1c7 100644 --- a/InvenTree/build/tests.py +++ b/InvenTree/build/tests.py @@ -323,22 +323,3 @@ class TestBuildViews(TestCase): b = Build.objects.get(pk=1) self.assertEqual(b.status, 30) # Build status is now CANCELLED - - def test_build_unallocate(self): - """ Test the build unallocation view (ajax form) """ - - url = reverse('build-unallocate', args=(1,)) - - # Test without confirmation - response = self.client.post(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - data = json.loads(response.content) - self.assertFalse(data['form_valid']) - - # Test with confirmation - response = self.client.post(url, {'confirm': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - data = json.loads(response.content) - self.assertTrue(data['form_valid']) diff --git a/InvenTree/build/urls.py b/InvenTree/build/urls.py index 050c32209b..d80b16056c 100644 --- a/InvenTree/build/urls.py +++ b/InvenTree/build/urls.py @@ -12,7 +12,6 @@ build_detail_urls = [ url(r'^create-output/', views.BuildOutputCreate.as_view(), name='build-output-create'), url(r'^delete-output/', views.BuildOutputDelete.as_view(), name='build-output-delete'), url(r'^complete-output/', views.BuildOutputComplete.as_view(), name='build-output-complete'), - url(r'^unallocate/', views.BuildUnallocate.as_view(), name='build-unallocate'), url(r'^complete/', views.BuildComplete.as_view(), name='build-complete'), url(r'^.*$', views.BuildDetail.as_view(), name='build-detail'), diff --git a/InvenTree/build/views.py b/InvenTree/build/views.py index 702b3b3596..8c63c1296c 100644 --- a/InvenTree/build/views.py +++ b/InvenTree/build/views.py @@ -10,14 +10,13 @@ from django.core.exceptions import ValidationError from django.views.generic import DetailView, ListView from django.forms import HiddenInput -from part.models import Part from .models import Build from . import forms from stock.models import StockLocation, StockItem from InvenTree.views import AjaxUpdateView, AjaxDeleteView from InvenTree.views import InvenTreeRoleMixin -from InvenTree.helpers import str2bool, extract_serial_numbers, isNull +from InvenTree.helpers import str2bool, extract_serial_numbers from InvenTree.status_codes import BuildStatus, StockStatus @@ -246,88 +245,6 @@ class BuildOutputDelete(AjaxUpdateView): } -class BuildUnallocate(AjaxUpdateView): - """ View to un-allocate all parts from a build. - - Provides a simple confirmation dialog with a BooleanField checkbox. - """ - - model = Build - form_class = forms.UnallocateBuildForm - ajax_form_title = _("Unallocate Stock") - ajax_template_name = "build/unallocate.html" - - def get_initial(self): - - initials = super().get_initial() - - # Pointing to a particular build output? - output = self.get_param('output') - - if output: - initials['output_id'] = output - - # Pointing to a particular part? - part = self.get_param('part') - - if part: - initials['part_id'] = part - - return initials - - def post(self, request, *args, **kwargs): - - build = self.get_object() - form = self.get_form() - - confirm = request.POST.get('confirm', False) - - output_id = request.POST.get('output_id', None) - - if output_id: - - # If a "null" output is provided, we are trying to unallocate "untracked" stock - if isNull(output_id): - output = None - else: - try: - output = StockItem.objects.get(pk=output_id) - except (ValueError, StockItem.DoesNotExist): - output = None - - part_id = request.POST.get('part_id', None) - - try: - part = Part.objects.get(pk=part_id) - except (ValueError, Part.DoesNotExist): - part = None - - valid = False - - if confirm is False: - form.add_error('confirm', _('Confirm unallocation of build stock')) - form.add_error(None, _('Check the confirmation box')) - else: - - valid = True - - # Unallocate the entire build - if not output_id: - build.unallocateAll() - # Unallocate a single output - elif output: - build.unallocateOutput(output, part=part) - # Unallocate "untracked" parts - else: - build.unallocateUntracked(part=part) - - data = { - 'form_valid': valid, - } - - return self.renderJsonResponse(request, form, data) - - class BuildComplete(AjaxUpdateView): """ View to mark the build as complete. diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 58e0ca3f25..2f32831c8e 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -12,7 +12,7 @@ import math import uuid from django.db import models, transaction -from django.contrib.auth.models import User +from django.contrib.auth.models import User, Group from django.db.utils import IntegrityError, OperationalError from django.conf import settings @@ -26,6 +26,7 @@ from django.core.exceptions import ValidationError import InvenTree.helpers import InvenTree.fields +import InvenTree.validators import logging @@ -182,12 +183,9 @@ class BaseInvenTreeSetting(models.Model): else: choices = None - """ - TODO: - if type(choices) is function: + if callable(choices): # Evaluate the function (we expect it will return a list of tuples...) return choices() - """ return choices @@ -479,6 +477,11 @@ class BaseInvenTreeSetting(models.Model): return value +def settings_group_options(): + """build up group tuple for settings based on gour choices""" + return [('', _('No group')), *[(str(a.id), str(a)) for a in Group.objects.all()]] + + class InvenTreeSetting(BaseInvenTreeSetting): """ An InvenTreeSetting object is a key:value pair used for storing @@ -703,6 +706,14 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'validator': bool }, + 'PART_NAME_FORMAT': { + 'name': _('Part Name Display Format'), + 'description': _('Format to display the part name'), + 'default': "{{ part.IPN if part.IPN }}{{ ' | ' if part.IPN }}{{ part.name }}{{ ' | ' if part.revision }}" + "{{ part.revision if part.revision }}", + 'validator': InvenTree.validators.validate_part_name_format + }, + 'REPORT_DEBUG_MODE': { 'name': _('Debug Mode'), 'description': _('Generate reports in debug mode (HTML output)'), @@ -794,43 +805,6 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'default': 'PO', }, - # enable/diable ui elements - 'BUILD_FUNCTION_ENABLE': { - 'name': _('Enable build'), - 'description': _('Enable build functionality in InvenTree interface'), - 'default': True, - 'validator': bool, - }, - 'BUY_FUNCTION_ENABLE': { - 'name': _('Enable buy'), - 'description': _('Enable buy functionality in InvenTree interface'), - 'default': True, - 'validator': bool, - }, - 'SELL_FUNCTION_ENABLE': { - 'name': _('Enable sell'), - 'description': _('Enable sell functionality in InvenTree interface'), - 'default': True, - 'validator': bool, - }, - 'STOCK_FUNCTION_ENABLE': { - 'name': _('Enable stock'), - 'description': _('Enable stock functionality in InvenTree interface'), - 'default': True, - 'validator': bool, - }, - 'SO_FUNCTION_ENABLE': { - 'name': _('Enable SO'), - 'description': _('Enable SO functionality in InvenTree interface'), - 'default': True, - 'validator': bool, - }, - 'PO_FUNCTION_ENABLE': { - 'name': _('Enable PO'), - 'description': _('Enable PO functionality in InvenTree interface'), - 'default': True, - 'validator': bool, - }, # login / SSO 'LOGIN_ENABLE_PWD_FORGOT': { 'name': _('Enable password forgot'), @@ -851,7 +825,7 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'validator': bool, }, 'LOGIN_MAIL_REQUIRED': { - 'name': _('E-Mail required'), + 'name': _('Email required'), 'description': _('Require user to supply mail on signup'), 'default': False, 'validator': bool, @@ -874,7 +848,14 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'default': True, 'validator': bool, }, - **settings.INTEGRATION_PLUGIN_SETTINGS + 'SIGNUP_GROUP': { + 'name': _('Group on signup'), + 'description': _('Group new user are asigned on registration'), + 'default': '', + 'choices': settings_group_options + }, + + **settings.INTEGRATION_PLUGIN_SETTINGS, } class Meta: diff --git a/InvenTree/common/test_views.py b/InvenTree/common/test_views.py index 56a244ba0c..76a0a4516e 100644 --- a/InvenTree/common/test_views.py +++ b/InvenTree/common/test_views.py @@ -136,3 +136,24 @@ class SettingsViewTest(TestCase): for value in [False, 'False']: self.post(url, {'value': value}, valid=True) self.assertFalse(InvenTreeSetting.get_setting('PART_COMPONENT')) + + def test_part_name_format(self): + """ + Try posting some valid and invalid name formats for PART_NAME_FORMAT + """ + setting = InvenTreeSetting.get_setting_object('PART_NAME_FORMAT') + + # test default value + self.assertEqual(setting.value, "{{ part.IPN if part.IPN }}{{ ' | ' if part.IPN }}{{ part.name }}" + "{{ ' | ' if part.revision }}{{ part.revision if part.revision }}") + + url = self.get_url(setting.pk) + + # Try posting an invalid part name format + invalid_values = ['{{asset.IPN}}', '{{part}}', '{{"|"}}', '{{part.falcon}}'] + for invalid_value in invalid_values: + self.post(url, {'value': invalid_value}, valid=False) + + # try posting valid value + new_format = "{{ part.name if part.name }} {{ ' with revision ' if part.revision }} {{ part.revision }}" + self.post(url, {'value': new_format}, valid=True) diff --git a/InvenTree/company/templates/company/navbar.html b/InvenTree/company/templates/company/navbar.html index 3c307704e6..b652d6b603 100644 --- a/InvenTree/company/templates/company/navbar.html +++ b/InvenTree/company/templates/company/navbar.html @@ -2,10 +2,6 @@ {% load static %} {% load inventree_extras %} -{% settings_value 'STOCK_FUNCTION_ENABLE' as enable_stock %} -{% settings_value 'SO_FUNCTION_ENABLE' as enable_so %} -{% settings_value 'PO_FUNCTION_ENABLE' as enable_po %} -