@@ -290,6 +302,10 @@
{% block js_ready %}
{{ block.super }}
+onPanelLoad('allocated', function() {
+ loadBuildOrderAllocatedStockTable($('#allocated-stock-table'), {{ build.pk }});
+});
+
onPanelLoad('consumed', function() {
loadStockTable($('#consumed-stock-table'), {
filterTarget: '#filter-list-consumed-stock',
diff --git a/src/backend/InvenTree/build/templates/build/sidebar.html b/src/backend/InvenTree/build/templates/build/sidebar.html
index c038b7782a..1c20429f2f 100644
--- a/src/backend/InvenTree/build/templates/build/sidebar.html
+++ b/src/backend/InvenTree/build/templates/build/sidebar.html
@@ -5,15 +5,19 @@
{% trans "Build Order Details" as text %}
{% include "sidebar_item.html" with label='details' text=text icon="fa-info-circle" %}
{% if build.is_active %}
-{% trans "Allocate Stock" as text %}
-{% include "sidebar_item.html" with label='allocate' text=text icon="fa-tasks" %}
+{% trans "Line Items" as text %}
+{% include "sidebar_item.html" with label='allocate' text=text icon="fa-list-ol" %}
{% trans "Incomplete Outputs" as text %}
{% include "sidebar_item.html" with label='outputs' text=text icon="fa-tools" %}
{% endif %}
{% trans "Completed Outputs" as text %}
{% include "sidebar_item.html" with label='completed' text=text icon="fa-boxes" %}
+{% if build.is_active %}
+{% trans "Allocated Stock" as text %}
+{% include "sidebar_item.html" with label='allocated' text=text icon="fa-list" %}
+{% endif %}
{% trans "Consumed Stock" as text %}
-{% include "sidebar_item.html" with label='consumed' text=text icon="fa-list" %}
+{% include "sidebar_item.html" with label='consumed' text=text icon="fa-tasks" %}
{% trans "Child Build Orders" as text %}
{% include "sidebar_item.html" with label='children' text=text icon="fa-sitemap" %}
{% trans "Attachments" as text %}
diff --git a/src/backend/InvenTree/build/test_api.py b/src/backend/InvenTree/build/test_api.py
index b240521db6..435db8367e 100644
--- a/src/backend/InvenTree/build/test_api.py
+++ b/src/backend/InvenTree/build/test_api.py
@@ -564,16 +564,16 @@ class BuildTest(BuildAPITest):
def test_download_build_orders(self):
"""Test that we can download a list of build orders via the API"""
required_cols = [
- 'reference',
- 'status',
- 'completed',
- 'batch',
- 'notes',
- 'title',
- 'part',
- 'part_name',
- 'id',
- 'quantity',
+ 'Reference',
+ 'Build Status',
+ 'Completed items',
+ 'Batch Code',
+ 'Notes',
+ 'Description',
+ 'Part',
+ 'Part Name',
+ 'ID',
+ 'Quantity',
]
excluded_cols = [
@@ -597,13 +597,13 @@ class BuildTest(BuildAPITest):
for row in data:
- build = Build.objects.get(pk=row['id'])
+ build = Build.objects.get(pk=row['ID'])
- self.assertEqual(str(build.part.pk), row['part'])
- self.assertEqual(build.part.full_name, row['part_name'])
+ self.assertEqual(str(build.part.pk), row['Part'])
+ self.assertEqual(build.part.name, row['Part Name'])
- self.assertEqual(build.reference, row['reference'])
- self.assertEqual(build.title, row['title'])
+ self.assertEqual(build.reference, row['Reference'])
+ self.assertEqual(build.title, row['Description'])
class BuildAllocationTest(BuildAPITest):
diff --git a/src/backend/InvenTree/build/tests.py b/src/backend/InvenTree/build/tests.py
index 4dd7ee0fee..22d38cbeb8 100644
--- a/src/backend/InvenTree/build/tests.py
+++ b/src/backend/InvenTree/build/tests.py
@@ -1,6 +1,7 @@
"""Basic unit tests for the BuildOrder app"""
from django.conf import settings
+from django.core.exceptions import ValidationError
from django.test import tag
from django.urls import reverse
@@ -9,8 +10,10 @@ from datetime import datetime, timedelta
from InvenTree.unit_test import InvenTreeTestCase
from .models import Build
+from part.models import Part, BomItem
from stock.models import StockItem
+from common.settings import get_global_setting, set_global_setting
from build.status_codes import BuildStatus
@@ -88,6 +91,79 @@ class BuildTestSimple(InvenTreeTestCase):
self.assertEqual(build.status, BuildStatus.CANCELLED)
+ def test_build_create(self):
+ """Test creation of build orders via API."""
+
+ n = Build.objects.count()
+
+ # Find an assembly part
+ assembly = Part.objects.filter(assembly=True).first()
+
+ assembly.active = True
+ assembly.locked = False
+ assembly.save()
+
+ self.assertEqual(assembly.get_bom_items().count(), 0)
+
+ # Let's create some BOM items for this assembly
+ for component in Part.objects.filter(assembly=False, component=True)[:15]:
+
+ try:
+ BomItem.objects.create(
+ part=assembly,
+ sub_part=component,
+ reference='xxx',
+ quantity=5
+ )
+ except ValidationError:
+ pass
+
+ # The assembly has a BOM, and is now *invalid*
+ self.assertGreater(assembly.get_bom_items().count(), 0)
+ self.assertFalse(assembly.is_bom_valid())
+
+ # Create a build for an assembly with an *invalid* BOM
+ set_global_setting('BUILDORDER_REQUIRE_VALID_BOM', False)
+ set_global_setting('BUILDORDER_REQUIRE_ACTIVE_PART', True)
+ set_global_setting('BUILDORDER_REQUIRE_LOCKED_PART', False)
+
+ bo = Build.objects.create(part=assembly, quantity=10, reference='BO-9990')
+ bo.save()
+
+ # Now, require a *valid* BOM
+ set_global_setting('BUILDORDER_REQUIRE_VALID_BOM', True)
+
+ with self.assertRaises(ValidationError):
+ bo = Build.objects.create(part=assembly, quantity=10, reference='BO-9991')
+
+ # Now, validate the BOM, and try again
+ assembly.validate_bom(None)
+ self.assertTrue(assembly.is_bom_valid())
+
+ bo = Build.objects.create(part=assembly, quantity=10, reference='BO-9992')
+
+ # Now, try and create a build for an inactive assembly
+ assembly.active = False
+ assembly.save()
+
+ with self.assertRaises(ValidationError):
+ bo = Build.objects.create(part=assembly, quantity=10, reference='BO-9993')
+
+ set_global_setting('BUILDORDER_REQUIRE_ACTIVE_PART', False)
+ Build.objects.create(part=assembly, quantity=10, reference='BO-9994')
+
+ # Check that the "locked" requirement works
+ set_global_setting('BUILDORDER_REQUIRE_LOCKED_PART', True)
+ with self.assertRaises(ValidationError):
+ Build.objects.create(part=assembly, quantity=10, reference='BO-9995')
+
+ assembly.locked = True
+ assembly.save()
+
+ Build.objects.create(part=assembly, quantity=10, reference='BO-9996')
+
+ # Check that expected quantity of new builds is created
+ self.assertEqual(Build.objects.count(), n + 4)
class TestBuildViews(InvenTreeTestCase):
"""Tests for Build app views."""
diff --git a/src/backend/InvenTree/common/api.py b/src/backend/InvenTree/common/api.py
index 9819543a5b..268e2680d2 100644
--- a/src/backend/InvenTree/common/api.py
+++ b/src/backend/InvenTree/common/api.py
@@ -27,6 +27,7 @@ import common.models
import common.serializers
from common.settings import get_global_setting
from generic.states.api import AllStatusViews, StatusView
+from importer.mixins import DataExportViewMixin
from InvenTree.api import BulkDeleteMixin, MetadataView
from InvenTree.config import CONFIG_LOOKUPS
from InvenTree.filters import ORDER_FILTER, SEARCH_ORDER_FILTER
@@ -494,7 +495,7 @@ class NotesImageList(ListCreateAPI):
image.save()
-class ProjectCodeList(ListCreateAPI):
+class ProjectCodeList(DataExportViewMixin, ListCreateAPI):
"""List view for all project codes."""
queryset = common.models.ProjectCode.objects.all()
@@ -515,7 +516,7 @@ class ProjectCodeDetail(RetrieveUpdateDestroyAPI):
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly]
-class CustomUnitList(ListCreateAPI):
+class CustomUnitList(DataExportViewMixin, ListCreateAPI):
"""List view for custom units."""
queryset = common.models.CustomUnit.objects.all()
@@ -949,7 +950,7 @@ common_api_urls = [
'/', ContentTypeDetail.as_view(), name='api-contenttype-detail'
),
path(
- '/',
+ 'model//',
ContentTypeModelDetail.as_view(),
name='api-contenttype-detail-modelname',
),
diff --git a/src/backend/InvenTree/common/currency.py b/src/backend/InvenTree/common/currency.py
index 5590505f12..1769cf8c36 100644
--- a/src/backend/InvenTree/common/currency.py
+++ b/src/backend/InvenTree/common/currency.py
@@ -59,7 +59,9 @@ def currency_codes() -> list:
"""Returns the current currency codes."""
from common.settings import get_global_setting
- codes = get_global_setting('CURRENCY_CODES', create=False).strip()
+ codes = get_global_setting(
+ 'CURRENCY_CODES', create=False, enviroment_key='INVENTREE_CURRENCY_CODES'
+ ).strip()
if not codes:
codes = currency_codes_default_list()
diff --git a/src/backend/InvenTree/common/forms.py b/src/backend/InvenTree/common/forms.py
index b991eec06b..4800149013 100644
--- a/src/backend/InvenTree/common/forms.py
+++ b/src/backend/InvenTree/common/forms.py
@@ -51,6 +51,9 @@ class MatchFieldForm(forms.Form):
super().__init__(*args, **kwargs)
+ if not file_manager: # pragma: no cover
+ return
+
# Setup FileManager
file_manager.setup()
# Get columns
@@ -87,6 +90,9 @@ class MatchItemForm(forms.Form):
super().__init__(*args, **kwargs)
+ if not file_manager: # pragma: no cover
+ return
+
# Setup FileManager
file_manager.setup()
diff --git a/src/backend/InvenTree/common/migrations/0018_projectcode.py b/src/backend/InvenTree/common/migrations/0018_projectcode.py
index 6ce6184ffb..544007012a 100644
--- a/src/backend/InvenTree/common/migrations/0018_projectcode.py
+++ b/src/backend/InvenTree/common/migrations/0018_projectcode.py
@@ -17,5 +17,8 @@ class Migration(migrations.Migration):
('code', models.CharField(help_text='Unique project code', max_length=50, unique=True, verbose_name='Project Code')),
('description', models.CharField(blank=True, help_text='Project description', max_length=200, verbose_name='Description')),
],
+ options={
+ 'verbose_name': 'Project Code',
+ },
),
]
diff --git a/src/backend/InvenTree/common/migrations/0020_customunit.py b/src/backend/InvenTree/common/migrations/0020_customunit.py
index 500d34c683..2c27252cf6 100644
--- a/src/backend/InvenTree/common/migrations/0020_customunit.py
+++ b/src/backend/InvenTree/common/migrations/0020_customunit.py
@@ -18,5 +18,8 @@ class Migration(migrations.Migration):
('symbol', models.CharField(blank=True, help_text='Optional unit symbol', max_length=10, unique=True, verbose_name='Symbol')),
('definition', models.CharField(help_text='Unit definition', max_length=50, verbose_name='Definition')),
],
+ options={
+ 'verbose_name': 'Custom Unit',
+ },
),
]
diff --git a/src/backend/InvenTree/common/migrations/0023_auto_20240602_1332.py b/src/backend/InvenTree/common/migrations/0023_auto_20240602_1332.py
index a3b964cbaa..66b78f6476 100644
--- a/src/backend/InvenTree/common/migrations/0023_auto_20240602_1332.py
+++ b/src/backend/InvenTree/common/migrations/0023_auto_20240602_1332.py
@@ -1,5 +1,6 @@
# Generated by Django 4.2.12 on 2024-06-02 13:32
+from django.conf import settings
from django.db import migrations
from moneyed import CURRENCIES
@@ -47,16 +48,20 @@ def set_currencies(apps, schema_editor):
return
value = ','.join(valid_codes)
- print(f"Found existing currency codes:", value)
+
+ if not settings.TESTING:
+ print(f"Found existing currency codes:", value)
setting = InvenTreeSetting.objects.filter(key=key).first()
if setting:
- print(f"- Updating existing setting for currency codes")
+ if not settings.TESTING:
+ print(f"- Updating existing setting for currency codes")
setting.value = value
setting.save()
else:
- print(f"- Creating new setting for currency codes")
+ if not settings.TESTING:
+ print(f"- Creating new setting for currency codes")
setting = InvenTreeSetting(key=key, value=value)
setting.save()
diff --git a/src/backend/InvenTree/common/migrations/0027_alter_customunit_symbol.py b/src/backend/InvenTree/common/migrations/0027_alter_customunit_symbol.py
new file mode 100644
index 0000000000..a897773201
--- /dev/null
+++ b/src/backend/InvenTree/common/migrations/0027_alter_customunit_symbol.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.2.12 on 2024-07-04 10:32
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('common', '0026_auto_20240608_1238'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='customunit',
+ name='symbol',
+ field=models.CharField(blank=True, help_text='Optional unit symbol', max_length=10, verbose_name='Symbol'),
+ ),
+ ]
diff --git a/src/backend/InvenTree/common/migrations/0028_colortheme_user_obj.py b/src/backend/InvenTree/common/migrations/0028_colortheme_user_obj.py
new file mode 100644
index 0000000000..5e2ea45c01
--- /dev/null
+++ b/src/backend/InvenTree/common/migrations/0028_colortheme_user_obj.py
@@ -0,0 +1,39 @@
+# Generated by Django 4.2.12 on 2024-07-04 10:23
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+def migrate_userthemes(apps, schema_editor):
+ """Mgrate text-based user references to ForeignKey references."""
+ ColorTheme = apps.get_model("common", "ColorTheme")
+ User = apps.get_model(settings.AUTH_USER_MODEL)
+
+ for theme in ColorTheme.objects.all():
+ try:
+ theme.user_obj = User.objects.get(username=theme.user)
+ theme.save()
+ except User.DoesNotExist:
+ pass
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ("common", "0027_alter_customunit_symbol"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="colortheme",
+ name="user_obj",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.RunPython(migrate_userthemes, migrations.RunPython.noop),
+ ]
diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py
index 645abd28e6..a55e829cc8 100644
--- a/src/backend/InvenTree/common/models.py
+++ b/src/backend/InvenTree/common/models.py
@@ -14,7 +14,7 @@ from datetime import timedelta, timezone
from enum import Enum
from io import BytesIO
from secrets import compare_digest
-from typing import Any, Callable, TypedDict, Union
+from typing import Any, Callable, Collection, TypedDict, Union
from django.apps import apps
from django.conf import settings as django_settings
@@ -116,6 +116,11 @@ class BaseURLValidator(URLValidator):
class ProjectCode(InvenTree.models.InvenTreeMetadataModel):
"""A ProjectCode is a unique identifier for a project."""
+ class Meta:
+ """Class options for the ProjectCode model."""
+
+ verbose_name = _('Project Code')
+
@staticmethod
def get_api_url():
"""Return the API URL for this model."""
@@ -1391,12 +1396,24 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'default': True,
'validator': bool,
},
+ 'BARCODE_SHOW_TEXT': {
+ 'name': _('Barcode Show Data'),
+ 'description': _('Display barcode data in browser as text'),
+ 'default': False,
+ 'validator': bool,
+ },
'PART_ENABLE_REVISION': {
'name': _('Part Revisions'),
'description': _('Enable revision field for Part'),
'validator': bool,
'default': True,
},
+ 'PART_REVISION_ASSEMBLY_ONLY': {
+ 'name': _('Assembly Revision Only'),
+ 'description': _('Only allow revisions for assembly parts'),
+ 'validator': bool,
+ 'default': False,
+ },
'PART_ALLOW_DELETE_FROM_ASSEMBLY': {
'name': _('Allow Deletion from Assembly'),
'description': _('Allow deletion of parts which are used in an assembly'),
@@ -1780,6 +1797,26 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'default': False,
'validator': bool,
},
+ 'BUILDORDER_REQUIRE_ACTIVE_PART': {
+ 'name': _('Require Active Part'),
+ 'description': _('Prevent build order creation for inactive parts'),
+ 'default': False,
+ 'validator': bool,
+ },
+ 'BUILDORDER_REQUIRE_LOCKED_PART': {
+ 'name': _('Require Locked Part'),
+ 'description': _('Prevent build order creation for unlocked parts'),
+ 'default': False,
+ 'validator': bool,
+ },
+ 'BUILDORDER_REQUIRE_VALID_BOM': {
+ 'name': _('Require Valid BOM'),
+ 'description': _(
+ 'Prevent build order creation unless BOM has been validated'
+ ),
+ 'default': False,
+ 'validator': bool,
+ },
'PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS': {
'name': _('Block Until Tests Pass'),
'description': _(
@@ -1909,6 +1946,38 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'default': False,
'validator': bool,
},
+ 'LOGIN_ENABLE_SSO_GROUP_SYNC': {
+ 'name': _('Enable SSO group sync'),
+ 'description': _(
+ 'Enable synchronizing InvenTree groups with groups provided by the IdP'
+ ),
+ 'default': False,
+ 'validator': bool,
+ },
+ 'SSO_GROUP_KEY': {
+ 'name': _('SSO group key'),
+ 'description': _(
+ 'The name of the groups claim attribute provided by the IdP'
+ ),
+ 'default': 'groups',
+ 'validator': str,
+ },
+ 'SSO_GROUP_MAP': {
+ 'name': _('SSO group map'),
+ 'description': _(
+ 'A mapping from SSO groups to local InvenTree groups. If the local group does not exist, it will be created.'
+ ),
+ 'validator': json.loads,
+ 'default': '{}',
+ },
+ 'SSO_REMOVE_GROUPS': {
+ 'name': _('Remove groups outside of SSO'),
+ 'description': _(
+ 'Whether groups assigned to the user should be removed if they are not backend by the IdP. Disabling this setting might cause security issues'
+ ),
+ 'default': True,
+ 'validator': bool,
+ },
'LOGIN_MAIL_REQUIRED': {
'name': _('Email required'),
'description': _('Require user to supply mail on signup'),
@@ -1945,7 +2014,9 @@ class InvenTreeSetting(BaseInvenTreeSetting):
},
'SIGNUP_GROUP': {
'name': _('Group on signup'),
- 'description': _('Group to which new users are assigned on registration'),
+ 'description': _(
+ 'Group to which new users are assigned on registration. If SSO group sync is enabled, this group is only set if no group can be assigned from the IdP.'
+ ),
'default': '',
'choices': settings_group_options,
},
@@ -2426,36 +2497,6 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'validator': [int, MinValueValidator(0)],
'default': 100,
},
- 'DEFAULT_PART_LABEL_TEMPLATE': {
- 'name': _('Default part label template'),
- 'description': _('The part label template to be automatically selected'),
- 'validator': [int],
- 'default': '',
- },
- 'DEFAULT_ITEM_LABEL_TEMPLATE': {
- 'name': _('Default stock item template'),
- 'description': _(
- 'The stock item label template to be automatically selected'
- ),
- 'validator': [int],
- 'default': '',
- },
- 'DEFAULT_LOCATION_LABEL_TEMPLATE': {
- 'name': _('Default stock location label template'),
- 'description': _(
- 'The stock location label template to be automatically selected'
- ),
- 'validator': [int],
- 'default': '',
- },
- 'DEFAULT_LINE_LABEL_TEMPLATE': {
- 'name': _('Default build line label template'),
- 'description': _(
- 'The build line label template to be automatically selected'
- ),
- 'validator': [int],
- 'default': '',
- },
'NOTIFICATION_ERROR_REPORT': {
'name': _('Receive error reports'),
'description': _('Receive notifications for system errors'),
@@ -2543,6 +2584,7 @@ class ColorTheme(models.Model):
name = models.CharField(max_length=20, default='', blank=True)
user = models.CharField(max_length=150, unique=True)
+ user_obj = models.ForeignKey(User, on_delete=models.CASCADE, blank=True, null=True)
@classmethod
def get_color_themes_choices(cls):
@@ -2993,6 +3035,11 @@ class CustomUnit(models.Model):
https://pint.readthedocs.io/en/stable/advanced/defining.html
"""
+ class Meta:
+ """Class meta options."""
+
+ verbose_name = _('Custom Unit')
+
def fmt_string(self):
"""Construct a unit definition string e.g. 'dog_year = 52 * day = dy'."""
fmt = f'{self.name} = {self.definition}'
@@ -3002,6 +3049,18 @@ class CustomUnit(models.Model):
return fmt
+ def validate_unique(self, exclude=None) -> None:
+ """Ensure that the custom unit is unique."""
+ super().validate_unique(exclude)
+
+ if self.symbol:
+ if (
+ CustomUnit.objects.filter(symbol=self.symbol)
+ .exclude(pk=self.pk)
+ .exists()
+ ):
+ raise ValidationError({'symbol': _('Unit symbol must be unique')})
+
def clean(self):
"""Validate that the provided custom unit is indeed valid."""
super().clean()
@@ -3043,7 +3102,6 @@ class CustomUnit(models.Model):
max_length=10,
verbose_name=_('Symbol'),
help_text=_('Optional unit symbol'),
- unique=True,
blank=True,
)
diff --git a/src/backend/InvenTree/common/serializers.py b/src/backend/InvenTree/common/serializers.py
index 0e0c7d1850..45b681cb19 100644
--- a/src/backend/InvenTree/common/serializers.py
+++ b/src/backend/InvenTree/common/serializers.py
@@ -14,6 +14,8 @@ from taggit.serializers import TagListSerializerField
import common.models as common_models
import common.validators
+from importer.mixins import DataImportExportSerializerMixin
+from importer.registry import register_importer
from InvenTree.helpers import get_objectreference
from InvenTree.helpers_model import construct_absolute_url
from InvenTree.serializers import (
@@ -293,7 +295,8 @@ class NotesImageSerializer(InvenTreeModelSerializer):
image = InvenTreeImageSerializerField(required=True)
-class ProjectCodeSerializer(InvenTreeModelSerializer):
+@register_importer()
+class ProjectCodeSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer):
"""Serializer for the ProjectCode model."""
class Meta:
@@ -341,7 +344,8 @@ class ContentTypeSerializer(serializers.Serializer):
return obj.app_label in plugin_registry.installed_apps
-class CustomUnitSerializer(InvenTreeModelSerializer):
+@register_importer()
+class CustomUnitSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer):
"""DRF serializer for CustomUnit model."""
class Meta:
diff --git a/src/backend/InvenTree/common/settings.py b/src/backend/InvenTree/common/settings.py
index 6788b427e2..08b8b6be38 100644
--- a/src/backend/InvenTree/common/settings.py
+++ b/src/backend/InvenTree/common/settings.py
@@ -1,10 +1,17 @@
"""User-configurable settings for the common app."""
+from os import environ
-def get_global_setting(key, backup_value=None, **kwargs):
+
+def get_global_setting(key, backup_value=None, enviroment_key=None, **kwargs):
"""Return the value of a global setting using the provided key."""
from common.models import InvenTreeSetting
+ if enviroment_key:
+ value = environ.get(enviroment_key)
+ if value:
+ return value
+
if backup_value is not None:
kwargs['backup_value'] = backup_value
diff --git a/src/backend/InvenTree/common/tests.py b/src/backend/InvenTree/common/tests.py
index f936fddfb9..9c3dea87d7 100644
--- a/src/backend/InvenTree/common/tests.py
+++ b/src/backend/InvenTree/common/tests.py
@@ -1376,7 +1376,7 @@ class ProjectCodesTest(InvenTreeAPITestCase):
)
self.assertIn(
- 'project code with this Project Code already exists',
+ 'Project Code with this Project Code already exists',
str(response.data['code']),
)
diff --git a/src/backend/InvenTree/company/admin.py b/src/backend/InvenTree/company/admin.py
index 93ad536b91..ae33a409ba 100644
--- a/src/backend/InvenTree/company/admin.py
+++ b/src/backend/InvenTree/company/admin.py
@@ -6,6 +6,8 @@ from import_export import widgets
from import_export.admin import ImportExportModelAdmin
from import_export.fields import Field
+import company.serializers
+import importer.admin
from InvenTree.admin import InvenTreeResource
from part.models import Part
@@ -33,9 +35,10 @@ class CompanyResource(InvenTreeResource):
@admin.register(Company)
-class CompanyAdmin(ImportExportModelAdmin):
+class CompanyAdmin(importer.admin.DataExportAdmin, ImportExportModelAdmin):
"""Admin class for the Company model."""
+ serializer_class = company.serializers.CompanySerializer
resource_class = CompanyResource
list_display = ('name', 'website', 'contact')
diff --git a/src/backend/InvenTree/company/api.py b/src/backend/InvenTree/company/api.py
index de5118d805..b0b9fcfe98 100644
--- a/src/backend/InvenTree/company/api.py
+++ b/src/backend/InvenTree/company/api.py
@@ -7,12 +7,9 @@ from django.utils.translation import gettext_lazy as _
from django_filters import rest_framework as rest_filters
import part.models
+from importer.mixins import DataExportViewMixin
from InvenTree.api import ListCreateDestroyAPIView, MetadataView
-from InvenTree.filters import (
- ORDER_FILTER,
- SEARCH_ORDER_FILTER,
- SEARCH_ORDER_FILTER_ALIAS,
-)
+from InvenTree.filters import SEARCH_ORDER_FILTER, SEARCH_ORDER_FILTER_ALIAS
from InvenTree.helpers import str2bool
from InvenTree.mixins import ListCreateAPI, RetrieveUpdateDestroyAPI
@@ -36,7 +33,7 @@ from .serializers import (
)
-class CompanyList(ListCreateAPI):
+class CompanyList(DataExportViewMixin, ListCreateAPI):
"""API endpoint for accessing a list of Company objects.
Provides two methods:
@@ -84,7 +81,7 @@ class CompanyDetail(RetrieveUpdateDestroyAPI):
return queryset
-class ContactList(ListCreateDestroyAPIView):
+class ContactList(DataExportViewMixin, ListCreateDestroyAPIView):
"""API endpoint for list view of Company model."""
queryset = Contact.objects.all()
@@ -108,7 +105,7 @@ class ContactDetail(RetrieveUpdateDestroyAPI):
serializer_class = ContactSerializer
-class AddressList(ListCreateDestroyAPIView):
+class AddressList(DataExportViewMixin, ListCreateDestroyAPIView):
"""API endpoint for list view of Address model."""
queryset = Address.objects.all()
@@ -149,7 +146,7 @@ class ManufacturerPartFilter(rest_filters.FilterSet):
)
-class ManufacturerPartList(ListCreateDestroyAPIView):
+class ManufacturerPartList(DataExportViewMixin, ListCreateDestroyAPIView):
"""API endpoint for list view of ManufacturerPart object.
- GET: Return list of ManufacturerPart objects
@@ -297,7 +294,7 @@ class SupplierPartFilter(rest_filters.FilterSet):
)
-class SupplierPartList(ListCreateDestroyAPIView):
+class SupplierPartList(DataExportViewMixin, ListCreateDestroyAPIView):
"""API endpoint for list view of SupplierPart object.
- GET: Return list of SupplierPart objects
diff --git a/src/backend/InvenTree/company/migrations/0001_initial.py b/src/backend/InvenTree/company/migrations/0001_initial.py
index cfc73bea20..6c6f31f938 100644
--- a/src/backend/InvenTree/company/migrations/0001_initial.py
+++ b/src/backend/InvenTree/company/migrations/0001_initial.py
@@ -44,6 +44,9 @@ class Migration(migrations.Migration):
('email', models.EmailField(blank=True, max_length=254)),
('role', models.CharField(blank=True, max_length=100)),
],
+ options={
+ 'verbose_name': 'Contact',
+ }
),
migrations.CreateModel(
name='SupplierPart',
@@ -75,6 +78,7 @@ class Migration(migrations.Migration):
('part', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pricebreaks', to='company.SupplierPart')),
],
options={
+ 'verbose_name': 'Supplier Price Break',
'db_table': 'part_supplierpricebreak',
},
),
diff --git a/src/backend/InvenTree/company/migrations/0032_auto_20210403_1837.py b/src/backend/InvenTree/company/migrations/0032_auto_20210403_1837.py
index d9c83d75ed..8b5f6fb89f 100644
--- a/src/backend/InvenTree/company/migrations/0032_auto_20210403_1837.py
+++ b/src/backend/InvenTree/company/migrations/0032_auto_20210403_1837.py
@@ -23,17 +23,17 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='company',
name='is_customer',
- field=models.BooleanField(default=False, help_text='Do you sell items to this company?', verbose_name='is customer'),
+ field=models.BooleanField(default=False, help_text='Do you sell items to this company?', verbose_name='Is customer'),
),
migrations.AlterField(
model_name='company',
name='is_manufacturer',
- field=models.BooleanField(default=False, help_text='Does this company manufacture parts?', verbose_name='is manufacturer'),
+ field=models.BooleanField(default=False, help_text='Does this company manufacture parts?', verbose_name='Is manufacturer'),
),
migrations.AlterField(
model_name='company',
name='is_supplier',
- field=models.BooleanField(default=True, help_text='Do you purchase items from this company?', verbose_name='is supplier'),
+ field=models.BooleanField(default=True, help_text='Do you purchase items from this company?', verbose_name='Is supplier'),
),
migrations.AlterField(
model_name='company',
diff --git a/src/backend/InvenTree/company/migrations/0038_manufacturerpartparameter.py b/src/backend/InvenTree/company/migrations/0038_manufacturerpartparameter.py
index dccfa715e8..dd833ccfa3 100644
--- a/src/backend/InvenTree/company/migrations/0038_manufacturerpartparameter.py
+++ b/src/backend/InvenTree/company/migrations/0038_manufacturerpartparameter.py
@@ -21,6 +21,7 @@ class Migration(migrations.Migration):
('manufacturer_part', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='parameters', to='company.manufacturerpart', verbose_name='Manufacturer Part')),
],
options={
+ 'verbose_name': 'Manufacturer Part Parameter',
'unique_together': {('manufacturer_part', 'name')},
},
),
diff --git a/src/backend/InvenTree/company/migrations/0066_auto_20230616_2059.py b/src/backend/InvenTree/company/migrations/0066_auto_20230616_2059.py
index 19ce798301..f5160f3255 100644
--- a/src/backend/InvenTree/company/migrations/0066_auto_20230616_2059.py
+++ b/src/backend/InvenTree/company/migrations/0066_auto_20230616_2059.py
@@ -12,7 +12,10 @@ class Migration(migrations.Migration):
operations = [
migrations.AlterModelOptions(
name='address',
- options={'verbose_name_plural': 'Addresses'},
+ options={
+ 'verbose_name': 'Address',
+ 'verbose_name_plural': 'Addresses'
+ },
),
migrations.AlterField(
model_name='address',
diff --git a/src/backend/InvenTree/company/migrations/0071_manufacturerpart_notes_supplierpart_notes.py b/src/backend/InvenTree/company/migrations/0071_manufacturerpart_notes_supplierpart_notes.py
new file mode 100644
index 0000000000..b7d6c8caf7
--- /dev/null
+++ b/src/backend/InvenTree/company/migrations/0071_manufacturerpart_notes_supplierpart_notes.py
@@ -0,0 +1,24 @@
+# Generated by Django 4.2.11 on 2024-07-16 12:58
+
+import InvenTree.fields
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('company', '0070_remove_manufacturerpartattachment_manufacturer_part_and_more'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='manufacturerpart',
+ name='notes',
+ field=InvenTree.fields.InvenTreeNotesField(blank=True, help_text='Markdown notes (optional)', max_length=50000, null=True, verbose_name='Notes'),
+ ),
+ migrations.AddField(
+ model_name='supplierpart',
+ name='notes',
+ field=InvenTree.fields.InvenTreeNotesField(blank=True, help_text='Markdown notes (optional)', max_length=50000, null=True, verbose_name='Notes'),
+ ),
+ ]
diff --git a/src/backend/InvenTree/company/models.py b/src/backend/InvenTree/company/models.py
index 8683be248e..52eeb80cdc 100644
--- a/src/backend/InvenTree/company/models.py
+++ b/src/backend/InvenTree/company/models.py
@@ -165,19 +165,19 @@ class Company(
is_customer = models.BooleanField(
default=False,
- verbose_name=_('is customer'),
+ verbose_name=_('Is customer'),
help_text=_('Do you sell items to this company?'),
)
is_supplier = models.BooleanField(
default=True,
- verbose_name=_('is supplier'),
+ verbose_name=_('Is supplier'),
help_text=_('Do you purchase items from this company?'),
)
is_manufacturer = models.BooleanField(
default=False,
- verbose_name=_('is manufacturer'),
+ verbose_name=_('Is manufacturer'),
help_text=_('Does this company manufacture parts?'),
)
@@ -269,6 +269,11 @@ class Contact(InvenTree.models.InvenTreeMetadataModel):
role: position in company
"""
+ class Meta:
+ """Metaclass defines extra model options."""
+
+ verbose_name = _('Contact')
+
@staticmethod
def get_api_url():
"""Return the API URL associated with the Contcat model."""
@@ -306,7 +311,8 @@ class Address(InvenTree.models.InvenTreeModel):
class Meta:
"""Metaclass defines extra model options."""
- verbose_name_plural = 'Addresses'
+ verbose_name = _('Address')
+ verbose_name_plural = _('Addresses')
def __init__(self, *args, **kwargs):
"""Custom init function."""
@@ -445,6 +451,7 @@ class Address(InvenTree.models.InvenTreeModel):
class ManufacturerPart(
InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeBarcodeMixin,
+ InvenTree.models.InvenTreeNotesMixin,
InvenTree.models.InvenTreeMetadataModel,
):
"""Represents a unique part as provided by a Manufacturer Each ManufacturerPart is identified by a MPN (Manufacturer Part Number) Each ManufacturerPart is also linked to a Part object. A Part may be available from multiple manufacturers.
@@ -560,6 +567,7 @@ class ManufacturerPartParameter(InvenTree.models.InvenTreeModel):
class Meta:
"""Metaclass defines extra model options."""
+ verbose_name = _('Manufacturer Part Parameter')
unique_together = ('manufacturer_part', 'name')
@staticmethod
@@ -617,6 +625,7 @@ class SupplierPartManager(models.Manager):
class SupplierPart(
InvenTree.models.MetadataMixin,
InvenTree.models.InvenTreeBarcodeMixin,
+ InvenTree.models.InvenTreeNotesMixin,
common.models.MetaMixin,
InvenTree.models.InvenTreeModel,
):
@@ -1005,6 +1014,7 @@ class SupplierPriceBreak(common.models.PriceBreak):
class Meta:
"""Metaclass defines extra model options."""
+ verbose_name = _('Supplier Price Break')
unique_together = ('part', 'quantity')
# This model was moved from the 'Part' app
diff --git a/src/backend/InvenTree/company/serializers.py b/src/backend/InvenTree/company/serializers.py
index 453ff21b42..428ea7b5b8 100644
--- a/src/backend/InvenTree/company/serializers.py
+++ b/src/backend/InvenTree/company/serializers.py
@@ -10,6 +10,9 @@ from sql_util.utils import SubqueryCount
from taggit.serializers import TagListSerializerField
import part.filters
+import part.serializers as part_serializers
+from importer.mixins import DataImportExportSerializerMixin
+from importer.registry import register_importer
from InvenTree.serializers import (
InvenTreeCurrencySerializer,
InvenTreeDecimalField,
@@ -20,7 +23,6 @@ from InvenTree.serializers import (
NotesFieldMixin,
RemoteImageMixin,
)
-from part.serializers import PartBriefSerializer
from .models import (
Address,
@@ -56,7 +58,8 @@ class CompanyBriefSerializer(InvenTreeModelSerializer):
thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True)
-class AddressSerializer(InvenTreeModelSerializer):
+@register_importer()
+class AddressSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer):
"""Serializer for the Address Model."""
class Meta:
@@ -100,9 +103,19 @@ class AddressBriefSerializer(InvenTreeModelSerializer):
]
-class CompanySerializer(NotesFieldMixin, RemoteImageMixin, InvenTreeModelSerializer):
+@register_importer()
+class CompanySerializer(
+ DataImportExportSerializerMixin,
+ NotesFieldMixin,
+ RemoteImageMixin,
+ InvenTreeModelSerializer,
+):
"""Serializer for Company object (full detail)."""
+ export_exclude_fields = ['url', 'primary_address']
+
+ import_exclude_fields = ['image']
+
class Meta:
"""Metaclass options."""
@@ -183,17 +196,25 @@ class CompanySerializer(NotesFieldMixin, RemoteImageMixin, InvenTreeModelSeriali
return self.instance
-class ContactSerializer(InvenTreeModelSerializer):
+@register_importer()
+class ContactSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer):
"""Serializer class for the Contact model."""
class Meta:
"""Metaclass options."""
model = Contact
- fields = ['pk', 'company', 'name', 'phone', 'email', 'role']
+ fields = ['pk', 'company', 'company_name', 'name', 'phone', 'email', 'role']
+
+ company_name = serializers.CharField(
+ label=_('Company Name'), source='company.name', read_only=True
+ )
-class ManufacturerPartSerializer(InvenTreeTagModelSerializer):
+@register_importer()
+class ManufacturerPartSerializer(
+ DataImportExportSerializerMixin, InvenTreeTagModelSerializer, NotesFieldMixin
+):
"""Serializer for ManufacturerPart object."""
class Meta:
@@ -211,6 +232,7 @@ class ManufacturerPartSerializer(InvenTreeTagModelSerializer):
'MPN',
'link',
'barcode_hash',
+ 'notes',
'tags',
]
@@ -225,15 +247,17 @@ class ManufacturerPartSerializer(InvenTreeTagModelSerializer):
super().__init__(*args, **kwargs)
if part_detail is not True:
- self.fields.pop('part_detail')
+ self.fields.pop('part_detail', None)
if manufacturer_detail is not True:
- self.fields.pop('manufacturer_detail')
+ self.fields.pop('manufacturer_detail', None)
if prettify is not True:
- self.fields.pop('pretty_name')
+ self.fields.pop('pretty_name', None)
- part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
+ part_detail = part_serializers.PartBriefSerializer(
+ source='part', many=False, read_only=True
+ )
manufacturer_detail = CompanyBriefSerializer(
source='manufacturer', many=False, read_only=True
@@ -246,7 +270,10 @@ class ManufacturerPartSerializer(InvenTreeTagModelSerializer):
)
-class ManufacturerPartParameterSerializer(InvenTreeModelSerializer):
+@register_importer()
+class ManufacturerPartParameterSerializer(
+ DataImportExportSerializerMixin, InvenTreeModelSerializer
+):
"""Serializer for the ManufacturerPartParameter model."""
class Meta:
@@ -270,14 +297,17 @@ class ManufacturerPartParameterSerializer(InvenTreeModelSerializer):
super().__init__(*args, **kwargs)
if not man_detail:
- self.fields.pop('manufacturer_part_detail')
+ self.fields.pop('manufacturer_part_detail', None)
manufacturer_part_detail = ManufacturerPartSerializer(
source='manufacturer_part', many=False, read_only=True
)
-class SupplierPartSerializer(InvenTreeTagModelSerializer):
+@register_importer()
+class SupplierPartSerializer(
+ DataImportExportSerializerMixin, InvenTreeTagModelSerializer, NotesFieldMixin
+):
"""Serializer for SupplierPart object."""
class Meta:
@@ -311,6 +341,7 @@ class SupplierPartSerializer(InvenTreeTagModelSerializer):
'supplier_detail',
'url',
'updated',
+ 'notes',
'tags',
]
@@ -341,17 +372,17 @@ class SupplierPartSerializer(InvenTreeTagModelSerializer):
super().__init__(*args, **kwargs)
if part_detail is not True:
- self.fields.pop('part_detail')
+ self.fields.pop('part_detail', None)
if supplier_detail is not True:
- self.fields.pop('supplier_detail')
+ self.fields.pop('supplier_detail', None)
if manufacturer_detail is not True:
- self.fields.pop('manufacturer_detail')
- self.fields.pop('manufacturer_part_detail')
+ self.fields.pop('manufacturer_detail', None)
+ self.fields.pop('manufacturer_part_detail', None)
if prettify is not True:
- self.fields.pop('pretty_name')
+ self.fields.pop('pretty_name', None)
# Annotated field showing total in-stock quantity
in_stock = serializers.FloatField(read_only=True, label=_('In Stock'))
@@ -360,7 +391,9 @@ class SupplierPartSerializer(InvenTreeTagModelSerializer):
pack_quantity_native = serializers.FloatField(read_only=True)
- part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
+ part_detail = part_serializers.PartBriefSerializer(
+ source='part', many=False, read_only=True
+ )
supplier_detail = CompanyBriefSerializer(
source='supplier', many=False, read_only=True
@@ -435,7 +468,10 @@ class SupplierPartSerializer(InvenTreeTagModelSerializer):
return supplier_part
-class SupplierPriceBreakSerializer(InvenTreeModelSerializer):
+@register_importer()
+class SupplierPriceBreakSerializer(
+ DataImportExportSerializerMixin, InvenTreeModelSerializer
+):
"""Serializer for SupplierPriceBreak object."""
class Meta:
@@ -462,10 +498,10 @@ class SupplierPriceBreakSerializer(InvenTreeModelSerializer):
super().__init__(*args, **kwargs)
if not supplier_detail:
- self.fields.pop('supplier_detail')
+ self.fields.pop('supplier_detail', None)
if not part_detail:
- self.fields.pop('part_detail')
+ self.fields.pop('part_detail', None)
quantity = InvenTreeDecimalField()
diff --git a/src/backend/InvenTree/company/templates/company/manufacturer_part.html b/src/backend/InvenTree/company/templates/company/manufacturer_part.html
index a4676a9086..7d07fe282e 100644
--- a/src/backend/InvenTree/company/templates/company/manufacturer_part.html
+++ b/src/backend/InvenTree/company/templates/company/manufacturer_part.html
@@ -171,11 +171,40 @@ src="{% static 'img/blank_image.png' %}"
+
{% endblock page_content %}
{% block js_ready %}
{{ block.super }}
+// Load the "notes" tab
+onPanelLoad('manufacturer-part-notes', function() {
+
+ setupNotesField(
+ 'manufacturer-part-notes',
+ '{% url "api-manufacturer-part-detail" part.pk %}',
+ {
+ model_type: "manufacturerpart",
+ model_id: {{ part.pk }},
+ editable: {% js_bool roles.purchase_order.change %},
+ }
+ );
+});
+
onPanelLoad("attachments", function() {
loadAttachmentTable('manufacturerpart', {{ part.pk }});
});
diff --git a/src/backend/InvenTree/company/templates/company/manufacturer_part_sidebar.html b/src/backend/InvenTree/company/templates/company/manufacturer_part_sidebar.html
index 64f8f950b0..fb538cb290 100644
--- a/src/backend/InvenTree/company/templates/company/manufacturer_part_sidebar.html
+++ b/src/backend/InvenTree/company/templates/company/manufacturer_part_sidebar.html
@@ -8,3 +8,5 @@
{% include "sidebar_item.html" with label='supplier-parts' text=text icon="fa-building" %}
{% trans "Attachments" as text %}
{% include "sidebar_item.html" with label='attachments' text=text icon="fa-paperclip" %}
+{% trans "Notes" as text %}
+{% include "sidebar_item.html" with label="manufacturer-part-notes" text=text icon="fa-clipboard" %}
diff --git a/src/backend/InvenTree/company/templates/company/supplier_part.html b/src/backend/InvenTree/company/templates/company/supplier_part.html
index 2949926cb7..16841c5570 100644
--- a/src/backend/InvenTree/company/templates/company/supplier_part.html
+++ b/src/backend/InvenTree/company/templates/company/supplier_part.html
@@ -264,11 +264,40 @@ src="{% static 'img/blank_image.png' %}"