diff --git a/docs/docs/start/docker_install.md b/docs/docs/start/docker_install.md index 3ae72c5082..0e53c85f7d 100644 --- a/docs/docs/start/docker_install.md +++ b/docs/docs/start/docker_install.md @@ -235,6 +235,20 @@ The [Caddy](./docker.md#ssl-certificates) container will automatically generate Any persistent files generated by the Caddy container (such as certificates, etc) will be stored in the `caddy` directory within the external volume. +### Web Server Bind Address + +By default, the Dockerized InvenTree web server binds to all available network interfaces and listens for IPv4 traffic on port 8000. +This can be adjusted using the following environment variables: + +| Environment Variable | Default | +| --- | --- | --- | --- | +| INVENTREE_WEB_ADDR | 0.0.0.0 | +| INVENTREE_WEB_PORT | 8000 | + +These variables are combined in the [Dockerfile](../../../contrib/container/Dockerfile) to build the bind string passed to the InvenTree server on startup. + +To enable IPv6/Dual Stack support, set `INVENTREE_WEB_ADDR` to `[::]` when you create/start the container. + ### Demo Dataset To quickly get started with a [demo dataset](../demo.md), you can run the following command: diff --git a/src/backend/InvenTree/InvenTree/models.py b/src/backend/InvenTree/InvenTree/models.py index 5301141881..c8e8eb4ecf 100644 --- a/src/backend/InvenTree/InvenTree/models.py +++ b/src/backend/InvenTree/InvenTree/models.py @@ -451,7 +451,17 @@ class ReferenceIndexingMixin(models.Model): reference_int = models.BigIntegerField(default=0) -class InvenTreeModel(PluginValidationMixin, models.Model): +class ContentTypeMixin: + """Mixin class which supports retrieval of the ContentType for a model instance.""" + + def get_content_type(self): + """Return the ContentType object associated with this model.""" + from django.contrib.contenttypes.models import ContentType + + return ContentType.objects.get_for_model(self.__class__) + + +class InvenTreeModel(ContentTypeMixin, PluginValidationMixin, models.Model): """Base class for InvenTree models, which provides some common functionality. Includes the following mixins by default: @@ -658,7 +668,7 @@ class InvenTreeAttachmentMixin(InvenTreePermissionCheckMixin): Attachment.objects.create(**kwargs) -class InvenTreeTree(MPTTModel): +class InvenTreeTree(ContentTypeMixin, MPTTModel): """Provides an abstracted self-referencing tree model, based on the MPTTModel class. Our implementation provides the following key improvements: diff --git a/src/backend/InvenTree/common/api.py b/src/backend/InvenTree/common/api.py index 2a9cd5faed..6dea5af93d 100644 --- a/src/backend/InvenTree/common/api.py +++ b/src/backend/InvenTree/common/api.py @@ -37,7 +37,7 @@ from common.icons import get_icon_packs from common.settings import get_global_setting from data_exporter.mixins import DataExportViewMixin from generic.states.api import urlpattern as generic_states_api_urls -from InvenTree.api import BulkDeleteMixin, MetadataView +from InvenTree.api import BulkCreateMixin, BulkDeleteMixin, MetadataView from InvenTree.config import CONFIG_LOOKUPS from InvenTree.filters import ( ORDER_FILTER, @@ -898,6 +898,7 @@ class ParameterMixin: class ParameterList( OutputOptionsMixin, ParameterMixin, + BulkCreateMixin, BulkDeleteMixin, DataExportViewMixin, ListCreateAPI, diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index 7ee07f7175..d60f384fb6 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -2631,7 +2631,7 @@ class Parameter( from plugin import PluginMixinEnum, registry for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION): - # Note: The validate_part_parameter function may raise a ValidationError + # Note: The validate_parameter function may raise a ValidationError try: if hasattr(plugin, 'validate_parameter'): result = plugin.validate_parameter(self, self.data) diff --git a/src/backend/InvenTree/order/test_api.py b/src/backend/InvenTree/order/test_api.py index 93610e80ed..4a08a2b66c 100644 --- a/src/backend/InvenTree/order/test_api.py +++ b/src/backend/InvenTree/order/test_api.py @@ -204,8 +204,8 @@ class PurchaseOrderTest(OrderTest): self.LIST_URL, data={'limit': limit}, expected_code=200 ) - # Total database queries must be below 20, independent of the number of results - self.assertLess(len(ctx), 20) + # Total database queries must be below 25, independent of the number of results + self.assertLess(len(ctx), 25) for result in response.data['results']: self.assertIn('total_price', result) @@ -1428,8 +1428,8 @@ class SalesOrderTest(OrderTest): self.LIST_URL, data={'limit': limit}, expected_code=200 ) - # Total database queries must be less than 20 - self.assertLess(len(ctx), 20) + # Total database queries must be less than 25 + self.assertLess(len(ctx), 25) n = len(response.data['results']) diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py index b1c1d3979d..357ebf1940 100644 --- a/src/backend/InvenTree/part/models.py +++ b/src/backend/InvenTree/part/models.py @@ -226,7 +226,7 @@ class PartCategory( """Prefectch parts parameters.""" return ( self.get_parts(cascade=cascade) - .prefetch_related('parameters', 'parameters__template') + .prefetch_related('parameters_list', 'parameters_list__template') .all() ) @@ -237,7 +237,7 @@ class PartCategory( parts = prefetch or self.prefetch_parts_parameters(cascade=cascade) for part in parts: - for parameter in part.parameters.all(): + for parameter in part.parameters_list.all(): parameter_name = parameter.template.name if parameter_name not in unique_parameters_names: unique_parameters_names.append(parameter_name) @@ -260,7 +260,7 @@ class PartCategory( if part.IPN: part_parameters['IPN'] = part.IPN - for parameter in part.parameters.all(): + for parameter in part.parameters_list.all(): parameter_name = parameter.template.name parameter_value = parameter.data part_parameters[parameter_name] = parameter_value @@ -3761,7 +3761,7 @@ class PartCategoryParameterTemplate(InvenTree.models.InvenTreeMetadataModel): if ( self.default_value and get_global_setting( - 'PART_PARAMETER_ENFORCE_UNITS', True, cache=False, create=False + 'PARAMETER_ENFORCE_UNITS', True, cache=False, create=False ) and self.template.units ): diff --git a/src/backend/InvenTree/part/test_api.py b/src/backend/InvenTree/part/test_api.py index caf0060493..e0f248e866 100644 --- a/src/backend/InvenTree/part/test_api.py +++ b/src/backend/InvenTree/part/test_api.py @@ -236,9 +236,7 @@ class PartCategoryAPITest(InvenTreeAPITestCase): n = ParameterTemplate.objects.count() # Ensure validation of parameter values is disabled for these checks - InvenTreeSetting.set_setting( - 'PART_PARAMETER_ENFORCE_UNITS', False, change_user=None - ) + InvenTreeSetting.set_setting('PARAMETER_ENFORCE_UNITS', False, change_user=None) for template in ParameterTemplate.objects.all(): response = self.post( diff --git a/src/backend/InvenTree/part/test_category.py b/src/backend/InvenTree/part/test_category.py index 85d6d6c246..a5bb5e6a2d 100644 --- a/src/backend/InvenTree/part/test_category.py +++ b/src/backend/InvenTree/part/test_category.py @@ -163,7 +163,7 @@ class CategoryTest(TestCase): # Iterate through all parts and parameters for fastener in fasteners: self.assertIsInstance(fastener, Part) - for parameter in fastener.parameters.all(): + for parameter in fastener.parameters_list.all(): self.assertIsInstance(parameter, Parameter) self.assertIsInstance(parameter.template, ParameterTemplate) diff --git a/src/backend/InvenTree/part/test_migrations.py b/src/backend/InvenTree/part/test_migrations.py index 1a15e57ebf..65844b2fd4 100644 --- a/src/backend/InvenTree/part/test_migrations.py +++ b/src/backend/InvenTree/part/test_migrations.py @@ -273,3 +273,81 @@ class TestPartTestParameterMigration(MigratorTestCase): for key, value in self.test_keys.items(): template = PartTestTemplate.objects.get(test_name=value) self.assertEqual(template.key, key) + + +class TestPartParameterDeletion(MigratorTestCase): + """Test for PartParameter deletion migration. + + Ref: https://github.com/inventree/InvenTree/pull/10699 + + In the linked PR: + + 1. The Parameter and ParameterTemplate models are added + 2. Data is migrated from PartParameter to Parameter and PartParameterTemplate to ParameterTemplate + 3. The PartParameter and PartParameterTemplate models are deleted + """ + + UNITS = ['mm', 'Ampere', 'kg'] + + migrate_from = ('part', '0143_alter_part_image') + migrate_to = ('part', '0145_remove_partparametertemplate_selectionlist_and_more') + + def prepare(self): + """Prepare some parts and parameters.""" + Part = self.old_state.apps.get_model('part', 'part') + PartParameter = self.old_state.apps.get_model('part', 'partparameter') + PartParameterTemplate = self.old_state.apps.get_model( + 'part', 'partparametertemplate' + ) + + # Create some parts + for i in range(3): + Part.objects.create( + name=f'Part {i + 1}', + description=f'My part {i + 1}', + level=0, + lft=0, + rght=0, + tree_id=0, + ) + + # Create some parameter templates + for idx, units in enumerate(self.UNITS): + PartParameterTemplate.objects.create( + name=f'Template {idx + 1}', + description=f'Description for template {idx + 1}', + units=units, + ) + + # Create some parameters + for ii, part in enumerate(Part.objects.all()): + for jj, template in enumerate(PartParameterTemplate.objects.all()): + PartParameter.objects.create( + part=part, template=template, data=str(ii * jj) + ) + + self.assertEqual(Part.objects.count(), 3) + self.assertEqual(PartParameterTemplate.objects.count(), 3) + self.assertEqual(PartParameter.objects.count(), 9) + + def test_parameter_deletion(self): + """Test that PartParameter objects have been deleted.""" + # Test that the PartParameter objects have been deleted + with self.assertRaises(ModuleNotFoundError): + self.new_state.apps.get_model('part', 'partparameter') + + # Load the new PartParameter model + ParameterTemplate = self.new_state.apps.get_model('common', 'parametertemplate') + Parameter = self.new_state.apps.get_model('common', 'parameter') + Part = self.new_state.apps.get_model('part', 'part') + + self.assertEqual(ParameterTemplate.objects.count(), 3) + self.assertEqual(Parameter.objects.count(), 9) + self.assertEqual(Part.objects.count(), 3) + + for p in Part.objects.all(): + params = p.parameters_list.all() + self.assertEqual(len(params), 3) + + for unit in self.UNITS: + self.assertTrue(params.filter(units=unit).exists()) diff --git a/src/backend/InvenTree/part/test_param.py b/src/backend/InvenTree/part/test_param.py index ae0ecc14b1..c1a536bc25 100644 --- a/src/backend/InvenTree/part/test_param.py +++ b/src/backend/InvenTree/part/test_param.py @@ -249,12 +249,8 @@ class ParameterTests(TestCase): bad_values = ['3 Amps', '-3 zogs', '3.14F'] - raise ValueError('This test must be refactored...') - # Disable enforcing of part parameter units - InvenTreeSetting.set_setting( - 'PART_PARAMETER_ENFORCE_UNITS', False, change_user=None - ) + InvenTreeSetting.set_setting('PARAMETER_ENFORCE_UNITS', False, change_user=None) # Invalid units also pass, but will be converted to the template units for value in bad_values: @@ -262,9 +258,7 @@ class ParameterTests(TestCase): param.full_clean() # Enable enforcing of part parameter units - InvenTreeSetting.set_setting( - 'PART_PARAMETER_ENFORCE_UNITS', True, change_user=None - ) + InvenTreeSetting.set_setting('PARAMETER_ENFORCE_UNITS', True, change_user=None) for value in bad_values: param = Parameter(content_object=prt, template=template, data=value) @@ -375,13 +369,21 @@ class PartParameterTest(InvenTreeAPITestCase): # test that having non unique part/template combinations fails res = self.post(url, data, expected_code=400) + self.assertEqual(len(res.data), 3) self.assertEqual(len(res.data[1]), 0) for err in [res.data[0], res.data[2]]: - self.assertEqual(len(err), 2) + self.assertEqual(len(err), 3) self.assertEqual(str(err['model_id'][0]), 'This field must be unique.') + self.assertEqual(str(err['model_type'][0]), 'This field must be unique.') self.assertEqual(str(err['template'][0]), 'This field must be unique.') - self.assertEqual(Parameter.objects.filter(content_object=part4).count(), 0) + + self.assertEqual( + Parameter.objects.filter( + model_type=part4.get_content_type(), model_id=part4.pk + ).count(), + 0, + ) # Now, create a valid set of parameters data = [ @@ -390,7 +392,13 @@ class PartParameterTest(InvenTreeAPITestCase): ] res = self.post(url, data, expected_code=201) self.assertEqual(len(res.data), 2) - self.assertEqual(Parameter.objects.filter(content_object=part4).count(), 2) + + self.assertEqual( + Parameter.objects.filter( + model_type=part4.get_content_type(), model_id=part4.pk + ).count(), + 2, + ) def test_param_detail(self): """Tests for the Parameter detail endpoint.""" diff --git a/src/backend/InvenTree/report/templatetags/report.py b/src/backend/InvenTree/report/templatetags/report.py index 146bcd6539..6329b5a9dc 100644 --- a/src/backend/InvenTree/report/templatetags/report.py +++ b/src/backend/InvenTree/report/templatetags/report.py @@ -343,7 +343,10 @@ def parameter( Returns: A Parameter object, or None if not found """ - if not hasattr(instance, 'parameters'): + if instance is None: + raise ValueError('parameter tag requires a valid Model instance') + + if not isinstance(instance, Model) or not hasattr(instance, 'parameters'): raise TypeError("parameter tag requires a Model with 'parameters' attribute") return ( @@ -353,6 +356,15 @@ def parameter( ) +@register.simple_tag() +def part_parameter(instance, parameter_name): + """Included for backwards compatibility - use 'parameter' tag instead. + + Ref: https://github.com/inventree/InvenTree/pull/10699 + """ + return parameter(instance, parameter_name) + + @register.simple_tag() def company_image( company: Company, preview: bool = False, thumbnail: bool = False, **kwargs diff --git a/src/backend/InvenTree/report/test_tags.py b/src/backend/InvenTree/report/test_tags.py index d5ca46e4a0..337b46e6b2 100644 --- a/src/backend/InvenTree/report/test_tags.py +++ b/src/backend/InvenTree/report/test_tags.py @@ -4,6 +4,7 @@ from decimal import Decimal from zoneinfo import ZoneInfo from django.conf import settings +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.test import TestCase, override_settings from django.utils import timezone @@ -12,7 +13,7 @@ from django.utils.safestring import SafeString from djmoney.money import Money from PIL import Image -from common.models import InvenTreeSetting +from common.models import InvenTreeSetting, Parameter, ParameterTemplate from InvenTree.config import get_testfolder_dir from InvenTree.unit_test import InvenTreeTestCase from part.models import Part # TODO fix import: PartParameter, PartParameterTemplate @@ -406,16 +407,26 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase): def test_part_parameter(self): """Test the part_parameter template tag.""" - # TODO fix import: PartParameter, PartParameterTemplate - # # Test with a valid part - # part = Part.objects.create(name='test', description='test') - # t1 = PartParameterTemplate.objects.create(name='Template 1', units='mm') - # parameter = PartParameter.objects.create(part=part, template=t1, data='test') + # Test with a valid part + part = Part.objects.create(name='test', description='test') + t1 = ParameterTemplate.objects.create(name='Template 1', units='mm') - # self.assertEqual(report_tags.part_parameter(part, 'name'), None) - # self.assertEqual(report_tags.part_parameter(part, 'Template 1'), parameter) - # # Test with an invalid part - # self.assertEqual(report_tags.part_parameter(None, 'name'), None) + content_type = ContentType.objects.get_for_model(Part) + parameter = Parameter.objects.create( + model_type=content_type, model_id=part.pk, template=t1, data='test' + ) + + # Note, use the 'parameter' and 'part_parameter' tags interchangeably here + self.assertEqual(report_tags.part_parameter(part, 'name'), None) + self.assertEqual(report_tags.parameter(part, 'Template 1'), parameter) + + # Test with a null part + with self.assertRaises(ValueError): + report_tags.parameter(None, 'name') + + # Test with an invalid model type + with self.assertRaises(TypeError): + report_tags.parameter(parameter, 'name') def test_render_currency(self): """Test the render_currency template tag."""