From 070e2afcead7172e9436dc9b1510ff066dfa4f6e Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 20 Apr 2023 00:47:07 +1000 Subject: [PATCH] Project code support (#4636) * Support image uploads in the "notes" markdown fields - Implemented using the existing EasyMDE library - Copy / paste support - Drag / drop support * Remove debug message * Updated API version * Better UX when saving notes * Pin PIP version (for testing) * Bug fixes - Fix typo - Use correct serializer type * Add unit testing * Update role permissions * Typo fix * Update migration file * Adds a notes mixin class to be used for refactoring * Refactor existing models with notes to use the new mixin * Add helper function for finding all model types with a certain mixin * Refactor barcode plugin to use new method * Typo fix * Add daily task to delete old / unused notes * Add ProjectCode model (cherry picked from commit 382a0a2fc32c930d46ed3fe0c6d2cae654c2209d) * Adds IsStaffOrReadyOnly permissions - Authenticated users get read-only access - Staff users get read/write access (cherry picked from commit 53d04da86c4c866fd9c909d147d93844186470b4) * Adds API endpoints for project codes (cherry picked from commit 5ae1da23b2eae4e1168bc6fe28a3544dedc4a1b4) * Add migration file for projectcode model (cherry picked from commit 5f8717712c65df853ea69907d33e185fd91df7ee) * Add project code configuration page to the global settings view * Add 'project code' field to orders * Add ability to set / edit the project code for various order models * Add project code info to order list tables * Add configuration options for project code integration * Allow orders to be filtered by project code * Refactor table_filters.js - Allow orders to be filtered dynamically by project code * Bump API version * Fixes * Add resource mixin for exporting project code in order list * Add "has_project_code" filter * javascript fix * Edit / delete project codes via API - Also refactor some existing JS * Move MetadataMixin to InvenTree.models To prevent circular imports (cherry picked from commit d23b013881eaffe612dfbfcdfc5dff6d729068c6) * Fixes for circular imports * Add metadata for ProjectCode model * Add Metadata API endpoint for ProjectCode * Add unit testing for ProjectCode API endpoints --- InvenTree/InvenTree/api_version.py | 5 +- InvenTree/InvenTree/fields.py | 4 +- InvenTree/InvenTree/filters.py | 6 +- InvenTree/InvenTree/helpers.py | 14 +- InvenTree/InvenTree/models.py | 64 +- InvenTree/InvenTree/permissions.py | 8 + InvenTree/build/models.py | 19 +- InvenTree/common/api.py | 29 +- .../common/migrations/0018_projectcode.py | 21 + .../migrations/0019_projectcode_metadata.py | 18 + InvenTree/common/models.py | 35 + InvenTree/common/serializers.py | 17 +- InvenTree/common/tests.py | 112 +- InvenTree/company/models.py | 3 +- InvenTree/label/models.py | 2 +- InvenTree/order/admin.py | 20 +- InvenTree/order/api.py | 26 +- .../migrations/0092_auto_20230419_0250.py | 30 + InvenTree/order/models.py | 7 +- InvenTree/order/serializers.py | 6 + .../order/templates/order/order_base.html | 1 + .../templates/order/return_order_base.html | 3 +- .../templates/order/sales_order_base.html | 1 + InvenTree/part/models.py | 3 +- InvenTree/plugin/models.py | 54 - InvenTree/report/models.py | 2 +- InvenTree/stock/models.py | 4 +- .../InvenTree/settings/project_codes.html | 35 + .../InvenTree/settings/settings.html | 1 + .../InvenTree/settings/settings_staff_js.html | 82 +- .../templates/InvenTree/settings/sidebar.html | 2 + InvenTree/templates/js/translated/company.js | 4 +- .../js/translated/model_renderers.js | 16 + .../templates/js/translated/purchase_order.js | 19 + .../templates/js/translated/return_order.js | 19 + .../templates/js/translated/sales_order.js | 19 + .../templates/js/translated/table_filters.js | 1271 +++++++++-------- InvenTree/templates/project_code_data.html | 11 + InvenTree/users/models.py | 5 +- 39 files changed, 1315 insertions(+), 683 deletions(-) create mode 100644 InvenTree/common/migrations/0018_projectcode.py create mode 100644 InvenTree/common/migrations/0019_projectcode_metadata.py create mode 100644 InvenTree/order/migrations/0092_auto_20230419_0250.py create mode 100644 InvenTree/templates/InvenTree/settings/project_codes.html create mode 100644 InvenTree/templates/project_code_data.html diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index cbcc6b5da2..a9fa115293 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -2,11 +2,14 @@ # InvenTree API version -INVENTREE_API_VERSION = 108 +INVENTREE_API_VERSION = 109 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about +v109 -> 2023-04-19 : https://github.com/inventree/InvenTree/pull/4636 + - Adds API endpoints for the "ProjectCode" model + v108 -> 2023-04-17 : https://github.com/inventree/InvenTree/pull/4615 - Adds functionality to upload images for rendering in markdown notes diff --git a/InvenTree/InvenTree/fields.py b/InvenTree/InvenTree/fields.py index e93d9a795e..f0ebb23c9e 100644 --- a/InvenTree/InvenTree/fields.py +++ b/InvenTree/InvenTree/fields.py @@ -12,8 +12,6 @@ from djmoney.models.fields import MoneyField as ModelMoneyField from djmoney.models.validators import MinMoneyValidator from rest_framework.fields import URLField as RestURLField -import InvenTree.helpers - from .validators import AllowedURLValidator, allowable_url_schemes @@ -150,6 +148,8 @@ class DatePickerFormField(forms.DateField): def round_decimal(value, places, normalize=False): """Round value to the specified number of places.""" + import InvenTree.helpers + if type(value) in [Decimal, float]: value = round(value, places) diff --git a/InvenTree/InvenTree/filters.py b/InvenTree/InvenTree/filters.py index 658de57d87..6a4e76ab51 100644 --- a/InvenTree/InvenTree/filters.py +++ b/InvenTree/InvenTree/filters.py @@ -3,7 +3,7 @@ from django_filters import rest_framework as rest_filters from rest_framework import filters -from InvenTree.helpers import str2bool +import InvenTree.helpers class InvenTreeSearchFilter(filters.SearchFilter): @@ -16,7 +16,7 @@ class InvenTreeSearchFilter(filters.SearchFilter): - search_regex: If True, search is perfomed on 'regex' comparison """ - regex = str2bool(request.query_params.get('search_regex', False)) + regex = InvenTree.helpers.str2bool(request.query_params.get('search_regex', False)) search_fields = super().get_search_fields(view, request) @@ -37,7 +37,7 @@ class InvenTreeSearchFilter(filters.SearchFilter): Depending on the request parameters, we may "augment" these somewhat """ - whole = str2bool(request.query_params.get('search_whole', False)) + whole = InvenTree.helpers.str2bool(request.query_params.get('search_whole', False)) terms = [] diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py index d985209bb8..6d6af87eaf 100644 --- a/InvenTree/InvenTree/helpers.py +++ b/InvenTree/InvenTree/helpers.py @@ -29,8 +29,8 @@ from djmoney.contrib.exchange.models import convert_money from djmoney.money import Money from PIL import Image +import common.models import InvenTree.version -from common.models import InvenTreeSetting from common.notifications import (InvenTreeNotificationBodies, NotificationBody, trigger_notification) from common.settings import currency_code_default @@ -43,7 +43,7 @@ logger = logging.getLogger('inventree') def getSetting(key, backup_value=None): """Shortcut for reading a setting value from the database.""" - return InvenTreeSetting.get_setting(key, backup_value=backup_value) + return common.models.InvenTreeSetting.get_setting(key, backup_value=backup_value) def generateTestKey(test_name): @@ -96,7 +96,7 @@ def construct_absolute_url(*arg): This requires the BASE_URL configuration option to be set! """ - base = str(InvenTreeSetting.get_setting('INVENTREE_BASE_URL')) + base = str(common.models.InvenTreeSetting.get_setting('INVENTREE_BASE_URL')) url = '/'.join(arg) @@ -145,10 +145,10 @@ def download_image_from_url(remote_url, timeout=2.5): validator(remote_url) # Calculate maximum allowable image size (in bytes) - max_size = int(InvenTreeSetting.get_setting('INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE')) * 1024 * 1024 + max_size = int(common.models.InvenTreeSetting.get_setting('INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE')) * 1024 * 1024 # Add user specified user-agent to request (if specified) - user_agent = InvenTreeSetting.get_setting('INVENTREE_DOWNLOAD_FROM_URL_USER_AGENT') + user_agent = common.models.InvenTreeSetting.get_setting('INVENTREE_DOWNLOAD_FROM_URL_USER_AGENT') if user_agent: headers = {"User-Agent": user_agent} else: @@ -1138,10 +1138,10 @@ def render_currency(money, decimal_places=None, currency=None, include_symbol=Tr pass if decimal_places is None: - decimal_places = InvenTreeSetting.get_setting('PRICING_DECIMAL_PLACES', 6) + decimal_places = common.models.InvenTreeSetting.get_setting('PRICING_DECIMAL_PLACES', 6) if min_decimal_places is None: - min_decimal_places = InvenTreeSetting.get_setting('PRICING_DECIMAL_PLACES_MIN', 0) + min_decimal_places = common.models.InvenTreeSetting.get_setting('PRICING_DECIMAL_PLACES_MIN', 0) value = Decimal(str(money.amount)).normalize() value = str(value) diff --git a/InvenTree/InvenTree/models.py b/InvenTree/InvenTree/models.py index 1098002b3e..86d561337e 100644 --- a/InvenTree/InvenTree/models.py +++ b/InvenTree/InvenTree/models.py @@ -21,10 +21,10 @@ from error_report.models import Error from mptt.exceptions import InvalidMove from mptt.models import MPTTModel, TreeForeignKey +import common.models +import InvenTree.fields import InvenTree.format import InvenTree.helpers -from common.models import InvenTreeSetting -from InvenTree.fields import InvenTreeNotesField, InvenTreeURLField from InvenTree.sanitizer import sanitize_svg logger = logging.getLogger('inventree') @@ -44,6 +44,60 @@ def rename_attachment(instance, filename): return os.path.join(instance.getSubdir(), filename) +class MetadataMixin(models.Model): + """Model mixin class which adds a JSON metadata field to a model, for use by any (and all) plugins. + + The intent of this mixin is to provide a metadata field on a model instance, + for plugins to read / modify as required, to store any extra information. + + The assumptions for models implementing this mixin are: + + - The internal InvenTree business logic will make no use of this field + - Multiple plugins may read / write to this metadata field, and not assume they have sole rights + """ + + class Meta: + """Meta for MetadataMixin.""" + abstract = True + + metadata = models.JSONField( + blank=True, null=True, + verbose_name=_('Plugin Metadata'), + help_text=_('JSON metadata field, for use by external plugins'), + ) + + def get_metadata(self, key: str, backup_value=None): + """Finds metadata for this model instance, using the provided key for lookup. + + Args: + key: String key for requesting metadata. e.g. if a plugin is accessing the metadata, the plugin slug should be used + + Returns: + Python dict object containing requested metadata. If no matching metadata is found, returns None + """ + if self.metadata is None: + return backup_value + + return self.metadata.get(key, backup_value) + + def set_metadata(self, key: str, data, commit: bool = True): + """Save the provided metadata under the provided key. + + Args: + key (str): Key for saving metadata + data (Any): Data object to save - must be able to be rendered as a JSON string + commit (bool, optional): If true, existing metadata with the provided key will be overwritten. If false, a merge will be attempted. Defaults to True. + """ + if self.metadata is None: + # Handle a null field value + self.metadata = {} + + self.metadata[key] = data + + if commit: + self.save() + + class DataImportMixin(object): """Model mixin class which provides support for 'data import' functionality. @@ -132,7 +186,7 @@ class ReferenceIndexingMixin(models.Model): if cls.REFERENCE_PATTERN_SETTING is None: return '' - return InvenTreeSetting.get_setting(cls.REFERENCE_PATTERN_SETTING, create=False).strip() + return common.models.InvenTreeSetting.get_setting(cls.REFERENCE_PATTERN_SETTING, create=False).strip() @classmethod def get_reference_context(cls): @@ -411,7 +465,7 @@ class InvenTreeAttachment(models.Model): blank=True, null=True ) - link = InvenTreeURLField( + link = InvenTree.fields.InvenTreeURLField( blank=True, null=True, verbose_name=_('Link'), help_text=_('Link to external URL') @@ -685,7 +739,7 @@ class InvenTreeNotesMixin(models.Model): """ abstract = True - notes = InvenTreeNotesField( + notes = InvenTree.fields.InvenTreeNotesField( verbose_name=_('Notes'), help_text=_('Markdown notes (optional)'), ) diff --git a/InvenTree/InvenTree/permissions.py b/InvenTree/InvenTree/permissions.py index 596e924c9e..e46367400b 100644 --- a/InvenTree/InvenTree/permissions.py +++ b/InvenTree/InvenTree/permissions.py @@ -92,6 +92,14 @@ class IsSuperuser(permissions.IsAdminUser): return bool(request.user and request.user.is_superuser) +class IsStaffOrReadOnly(permissions.IsAdminUser): + """Allows read-only access to any user, but write access is restricted to staff users.""" + + def has_permission(self, request, view): + """Check if the user is a superuser.""" + return bool(request.user and request.user.is_staff or request.method in permissions.SAFE_METHODS) + + def auth_exempt(view_func): """Mark a view function as being exempt from auth requirements.""" def wrapped_view(*args, **kwargs): diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index abca3e96b9..2a25af1a2d 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -22,27 +22,24 @@ from mptt.exceptions import InvalidMove from rest_framework import serializers from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode -from InvenTree.helpers import increment, normalize, notify_responsible -from InvenTree.models import InvenTreeAttachment, InvenTreeBarcodeMixin, InvenTreeNotesMixin, ReferenceIndexingMixin from build.validators import generate_next_build_reference, validate_build_order_reference import InvenTree.fields import InvenTree.helpers +import InvenTree.models import InvenTree.ready import InvenTree.tasks from plugin.events import trigger_event -from plugin.models import MetadataMixin import common.notifications - import part.models import stock.models import users.models -class Build(MPTTModel, InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, ReferenceIndexingMixin): +class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNotesMixin, InvenTree.models.MetadataMixin, InvenTree.models.ReferenceIndexingMixin): """A Build object organises the creation of new StockItem objects from other existing StockItem objects. Attributes: @@ -464,7 +461,7 @@ class Build(MPTTModel, InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin new_ref = ref while 1: - new_ref = increment(new_ref) + new_ref = InvenTree.helpers.increment(new_ref) if new_ref in tries: # We are potentially stuck in a loop - simply return the original reference @@ -1125,10 +1122,10 @@ def after_save_build(sender, instance: Build, created: bool, **kwargs): InvenTree.tasks.offload_task(build_tasks.check_build_stock, instance) # Notify the responsible users that the build order has been created - notify_responsible(instance, sender, exclude=instance.issued_by) + InvenTree.helpers.notify_responsible(instance, sender, exclude=instance.issued_by) -class BuildOrderAttachment(InvenTreeAttachment): +class BuildOrderAttachment(InvenTree.models.InvenTreeAttachment): """Model for storing file attachments against a BuildOrder object.""" def getSubdir(self): @@ -1138,7 +1135,7 @@ class BuildOrderAttachment(InvenTreeAttachment): build = models.ForeignKey(Build, on_delete=models.CASCADE, related_name='attachments') -class BuildItem(MetadataMixin, models.Model): +class BuildItem(InvenTree.models.MetadataMixin, models.Model): """A BuildItem links multiple StockItem objects to a Build. These are used to allocate part stock to a build. Once the Build is completed, the parts are removed from stock and the BuildItemAllocation objects are removed. @@ -1188,8 +1185,8 @@ class BuildItem(MetadataMixin, models.Model): # Allocated quantity cannot exceed available stock quantity if self.quantity > self.stock_item.quantity: - q = normalize(self.quantity) - a = normalize(self.stock_item.quantity) + q = InvenTree.helpers.normalize(self.quantity) + a = InvenTree.helpers.normalize(self.stock_item.quantity) raise ValidationError({ 'quantity': _(f'Allocated quantity ({q}) must not exceed available stock quantity ({a})') diff --git a/InvenTree/common/api.py b/InvenTree/common/api.py index d9070702a7..319916dee7 100644 --- a/InvenTree/common/api.py +++ b/InvenTree/common/api.py @@ -17,13 +17,13 @@ from rest_framework.views import APIView import common.models import common.serializers -from InvenTree.api import BulkDeleteMixin +from InvenTree.api import BulkDeleteMixin, MetadataView from InvenTree.config import CONFIG_LOOKUPS from InvenTree.filters import ORDER_FILTER, SEARCH_ORDER_FILTER from InvenTree.helpers import inheritors from InvenTree.mixins import (ListAPI, ListCreateAPI, RetrieveAPI, RetrieveUpdateAPI, RetrieveUpdateDestroyAPI) -from InvenTree.permissions import IsSuperuser +from InvenTree.permissions import IsStaffOrReadOnly, IsSuperuser from plugin.models import NotificationUserSetting from plugin.serializers import NotificationUserSettingSerializer @@ -454,6 +454,22 @@ class NotesImageList(ListCreateAPI): image.save() +class ProjectCodeList(ListCreateAPI): + """List view for all project codes.""" + + queryset = common.models.ProjectCode.objects.all() + serializer_class = common.serializers.ProjectCodeSerializer + permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly] + + +class ProjectCodeDetail(RetrieveUpdateDestroyAPI): + """Detail view for a particular project code""" + + queryset = common.models.ProjectCode.objects.all() + serializer_class = common.serializers.ProjectCodeSerializer + permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly] + + settings_api_urls = [ # User settings re_path(r'^user/', include([ @@ -490,6 +506,15 @@ common_api_urls = [ # Uploaded images for notes re_path(r'^notes-image-upload/', NotesImageList.as_view(), name='api-notes-image-list'), + # Project codes + re_path(r'^project-code/', include([ + path(r'/', include([ + re_path(r'^metadata/', MetadataView.as_view(), {'model': common.models.ProjectCode}, name='api-project-code-metadata'), + re_path(r'^.*$', ProjectCodeDetail.as_view(), name='api-project-code-detail'), + ])), + re_path(r'^.*$', ProjectCodeList.as_view(), name='api-project-code-list'), + ])), + # Currencies re_path(r'^currency/', include([ re_path(r'^exchange/', CurrencyExchangeView.as_view(), name='api-currency-exchange'), diff --git a/InvenTree/common/migrations/0018_projectcode.py b/InvenTree/common/migrations/0018_projectcode.py new file mode 100644 index 0000000000..6ce6184ffb --- /dev/null +++ b/InvenTree/common/migrations/0018_projectcode.py @@ -0,0 +1,21 @@ +# Generated by Django 3.2.18 on 2023-04-19 02:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('common', '0017_notesimage'), + ] + + operations = [ + migrations.CreateModel( + name='ProjectCode', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('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')), + ], + ), + ] diff --git a/InvenTree/common/migrations/0019_projectcode_metadata.py b/InvenTree/common/migrations/0019_projectcode_metadata.py new file mode 100644 index 0000000000..8f3afe022a --- /dev/null +++ b/InvenTree/common/migrations/0019_projectcode_metadata.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.18 on 2023-04-19 13:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('common', '0018_projectcode'), + ] + + operations = [ + migrations.AddField( + model_name='projectcode', + name='metadata', + field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'), + ), + ] diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 3c751e8009..941107e7fc 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -42,6 +42,7 @@ from rest_framework.exceptions import PermissionDenied import build.validators import InvenTree.fields import InvenTree.helpers +import InvenTree.models import InvenTree.ready import InvenTree.tasks import InvenTree.validators @@ -84,6 +85,33 @@ class EmptyURLValidator(URLValidator): super().__call__(value) +class ProjectCode(InvenTree.models.MetadataMixin, models.Model): + """A ProjectCode is a unique identifier for a project.""" + + @staticmethod + def get_api_url(): + """Return the API URL for this model.""" + return reverse('api-project-code-list') + + def __str__(self): + """String representation of a ProjectCode.""" + return self.code + + code = models.CharField( + max_length=50, + unique=True, + verbose_name=_('Project Code'), + help_text=_('Unique project code'), + ) + + description = models.CharField( + max_length=200, + blank=True, + verbose_name=_('Description'), + help_text=_('Project description'), + ) + + class BaseInvenTreeSetting(models.Model): """An base InvenTreeSetting object is a key:value pair used for storing single values (e.g. one-off settings values).""" @@ -1631,6 +1659,13 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'requires_restart': True, }, + "PROJECT_CODES_ENABLED": { + 'name': _('Enable project codes'), + 'description': _('Enable project codes for tracking projects'), + 'default': False, + 'validator': bool, + }, + 'STOCKTAKE_ENABLE': { 'name': _('Stocktake Functionality'), 'description': _('Enable stocktake functionality for recording stock levels and calculating stock value'), diff --git a/InvenTree/common/serializers.py b/InvenTree/common/serializers.py index 2d9f20c9c2..dff7a570e4 100644 --- a/InvenTree/common/serializers.py +++ b/InvenTree/common/serializers.py @@ -5,7 +5,8 @@ from django.urls import reverse from rest_framework import serializers from common.models import (InvenTreeSetting, InvenTreeUserSetting, - NewsFeedEntry, NotesImage, NotificationMessage) + NewsFeedEntry, NotesImage, NotificationMessage, + ProjectCode) from InvenTree.helpers import construct_absolute_url, get_objectreference from InvenTree.serializers import (InvenTreeImageSerializerField, InvenTreeModelSerializer) @@ -253,3 +254,17 @@ class NotesImageSerializer(InvenTreeModelSerializer): ] image = InvenTreeImageSerializerField(required=True) + + +class ProjectCodeSerializer(InvenTreeModelSerializer): + """Serializer for the ProjectCode model.""" + + class Meta: + """Meta options for ProjectCodeSerializer.""" + + model = ProjectCode + fields = [ + 'pk', + 'code', + 'description' + ] diff --git a/InvenTree/common/tests.py b/InvenTree/common/tests.py index 738e175319..27fef1c9c1 100644 --- a/InvenTree/common/tests.py +++ b/InvenTree/common/tests.py @@ -22,7 +22,7 @@ from plugin.models import NotificationUserSetting from .api import WebhookView from .models import (ColorTheme, InvenTreeSetting, InvenTreeUserSetting, NotesImage, NotificationEntry, NotificationMessage, - WebhookEndpoint, WebhookMessage) + ProjectCode, WebhookEndpoint, WebhookMessage) CONTENT_TYPE_JSON = 'application/json' @@ -1001,3 +1001,113 @@ class NotesImageTest(InvenTreeAPITestCase): # Check that a new file has been created self.assertEqual(NotesImage.objects.count(), n + 1) + + +class ProjectCodesTest(InvenTreeAPITestCase): + """Units tests for the ProjectCodes model and API endpoints""" + + @property + def url(self): + """Return the URL for the project code list endpoint""" + return reverse('api-project-code-list') + + @classmethod + def setUpTestData(cls): + """Create some initial project codes""" + super().setUpTestData() + + codes = [ + ProjectCode(code='PRJ-001', description='Test project code'), + ProjectCode(code='PRJ-002', description='Test project code'), + ProjectCode(code='PRJ-003', description='Test project code'), + ProjectCode(code='PRJ-004', description='Test project code'), + ] + + ProjectCode.objects.bulk_create(codes) + + def test_list(self): + """Test that the list endpoint works as expected""" + + response = self.get(self.url, expected_code=200) + self.assertEqual(len(response.data), ProjectCode.objects.count()) + + def test_delete(self): + """Test we can delete a project code via the API""" + + n = ProjectCode.objects.count() + + # Get the first project code + code = ProjectCode.objects.first() + + # Delete it + self.delete( + reverse('api-project-code-detail', kwargs={'pk': code.pk}), + expected_code=204 + ) + + # Check it is gone + self.assertEqual(ProjectCode.objects.count(), n - 1) + + def test_duplicate_code(self): + """Test that we cannot create two project codes with the same code""" + + # Create a new project code + response = self.post( + self.url, + data={ + 'code': 'PRJ-001', + 'description': 'Test project code', + }, + expected_code=400 + ) + + self.assertIn('project code with this Project Code already exists', str(response.data['code'])) + + def test_write_access(self): + """Test that non-staff users have read-only access""" + + # By default user has staff access, can create a new project code + response = self.post( + self.url, + data={ + 'code': 'PRJ-xxx', + 'description': 'Test project code', + }, + expected_code=201 + ) + + pk = response.data['pk'] + + # Test we can edit, also + response = self.patch( + reverse('api-project-code-detail', kwargs={'pk': pk}), + data={ + 'code': 'PRJ-999', + }, + expected_code=200 + ) + + self.assertEqual(response.data['code'], 'PRJ-999') + + # Restrict user access to non-staff + self.user.is_staff = False + self.user.save() + + # As user does not have staff access, should return 403 for list endpoint + response = self.post( + self.url, + data={ + 'code': 'PRJ-123', + 'description': 'Test project code' + }, + expected_code=403 + ) + + # Should also return 403 for detail endpoint + response = self.patch( + reverse('api-project-code-detail', kwargs={'pk': pk}), + data={ + 'code': 'PRJ-999', + }, + expected_code=403 + ) diff --git a/InvenTree/company/models.py b/InvenTree/company/models.py index e16cdffcdf..91818095ac 100644 --- a/InvenTree/company/models.py +++ b/InvenTree/company/models.py @@ -26,9 +26,8 @@ import InvenTree.validators from common.settings import currency_code_default from InvenTree.fields import InvenTreeURLField, RoundingDecimalField from InvenTree.models import (InvenTreeAttachment, InvenTreeBarcodeMixin, - InvenTreeNotesMixin) + InvenTreeNotesMixin, MetadataMixin) from InvenTree.status_codes import PurchaseOrderStatus -from plugin.models import MetadataMixin def rename_company_image(instance, filename): diff --git a/InvenTree/label/models.py b/InvenTree/label/models.py index a862860436..98096328c4 100644 --- a/InvenTree/label/models.py +++ b/InvenTree/label/models.py @@ -17,7 +17,7 @@ import common.models import part.models import stock.models from InvenTree.helpers import normalize, validateFilterString -from plugin.models import MetadataMixin +from InvenTree.models import MetadataMixin try: from django_weasyprint import WeasyTemplateResponseMixin diff --git a/InvenTree/order/admin.py b/InvenTree/order/admin.py index c1123bd5c4..479c350256 100644 --- a/InvenTree/order/admin.py +++ b/InvenTree/order/admin.py @@ -1,6 +1,7 @@ """Admin functionality for the 'order' app""" from django.contrib import admin +from django.utils.translation import gettext_lazy as _ import import_export.widgets as widgets from import_export.admin import ImportExportModelAdmin @@ -10,6 +11,19 @@ import order.models as models from InvenTree.admin import InvenTreeResource +class ProjectCodeResourceMixin: + """Mixin for exporting project code data""" + + project_code = Field(attribute='project_code', column_name=_('Project Code')) + + def dehydrate_project_code(self, order): + """Return the project code value, not the pk""" + if order.project_code: + return order.project_code.code + else: + return '' + + # region general classes class GeneralExtraLineAdmin: """Admin class template for the 'ExtraLineItem' models""" @@ -94,7 +108,7 @@ class SalesOrderAdmin(ImportExportModelAdmin): autocomplete_fields = ('customer',) -class PurchaseOrderResource(InvenTreeResource): +class PurchaseOrderResource(ProjectCodeResourceMixin, InvenTreeResource): """Class for managing import / export of PurchaseOrder data.""" class Meta: @@ -141,7 +155,7 @@ class PurchaseOrderExtraLineResource(InvenTreeResource): model = models.PurchaseOrderExtraLine -class SalesOrderResource(InvenTreeResource): +class SalesOrderResource(ProjectCodeResourceMixin, InvenTreeResource): """Class for managing import / export of SalesOrder data.""" class Meta: @@ -276,7 +290,7 @@ class SalesOrderAllocationAdmin(ImportExportModelAdmin): autocomplete_fields = ('line', 'shipment', 'item',) -class ReturnOrderResource(InvenTreeResource): +class ReturnOrderResource(ProjectCodeResourceMixin, InvenTreeResource): """Class for managing import / export of ReturnOrder data""" class Meta: diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index 5769101799..4f05393aeb 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -16,7 +16,7 @@ from rest_framework.response import Response import order.models as models import order.serializers as serializers -from common.models import InvenTreeSetting +from common.models import InvenTreeSetting, ProjectCode from common.settings import settings from company.models import SupplierPart from InvenTree.api import (APIDownloadMixin, AttachmentMixin, @@ -136,6 +136,21 @@ class OrderFilter(rest_filters.FilterSet): else: return queryset.exclude(status__in=self.Meta.model.get_status_class().OPEN) + project_code = rest_filters.ModelChoiceFilter( + queryset=ProjectCode.objects.all(), + field_name='project_code' + ) + + has_project_code = rest_filters.BooleanFilter(label='has_project_code', method='filter_has_project_code') + + def filter_has_project_code(self, queryset, name, value): + """Filter by whether or not the order has a project code""" + + if str2bool(value): + return queryset.exclude(project_code=None) + else: + return queryset.filter(project_code=None) + class LineItemFilter(rest_filters.FilterSet): """Base class for custom API filters for order line item list(s)""" @@ -307,12 +322,14 @@ class PurchaseOrderList(PurchaseOrderMixin, APIDownloadMixin, ListCreateAPI): ordering_field_aliases = { 'reference': ['reference_int', 'reference'], + 'project_code': ['project_code__code'], } search_fields = [ 'reference', 'supplier__name', 'supplier_reference', + 'project_code__code', 'description', ] @@ -325,6 +342,7 @@ class PurchaseOrderList(PurchaseOrderMixin, APIDownloadMixin, ListCreateAPI): 'status', 'responsible', 'total_price', + 'project_code', ] ordering = '-reference' @@ -685,6 +703,7 @@ class SalesOrderList(SalesOrderMixin, APIDownloadMixin, ListCreateAPI): ordering_field_aliases = { 'reference': ['reference_int', 'reference'], + 'project_code': ['project_code__code'], } filterset_fields = [ @@ -701,6 +720,7 @@ class SalesOrderList(SalesOrderMixin, APIDownloadMixin, ListCreateAPI): 'line_items', 'shipment_date', 'total_price', + 'project_code', ] search_fields = [ @@ -708,6 +728,7 @@ class SalesOrderList(SalesOrderMixin, APIDownloadMixin, ListCreateAPI): 'reference', 'description', 'customer_reference', + 'project_code__code', ] ordering = '-reference' @@ -1138,6 +1159,7 @@ class ReturnOrderList(ReturnOrderMixin, APIDownloadMixin, ListCreateAPI): ordering_field_aliases = { 'reference': ['reference_int', 'reference'], + 'project_code': ['project_code__code'], } ordering_fields = [ @@ -1148,6 +1170,7 @@ class ReturnOrderList(ReturnOrderMixin, APIDownloadMixin, ListCreateAPI): 'line_items', 'status', 'target_date', + 'project_code', ] search_fields = [ @@ -1155,6 +1178,7 @@ class ReturnOrderList(ReturnOrderMixin, APIDownloadMixin, ListCreateAPI): 'reference', 'description', 'customer_reference', + 'project_code__code', ] ordering = '-reference' diff --git a/InvenTree/order/migrations/0092_auto_20230419_0250.py b/InvenTree/order/migrations/0092_auto_20230419_0250.py new file mode 100644 index 0000000000..d2b4aa2aef --- /dev/null +++ b/InvenTree/order/migrations/0092_auto_20230419_0250.py @@ -0,0 +1,30 @@ +# Generated by Django 3.2.18 on 2023-04-19 02:50 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('common', '0018_projectcode'), + ('order', '0091_auto_20230419_0037'), + ] + + operations = [ + migrations.AddField( + model_name='purchaseorder', + name='project_code', + field=models.ForeignKey(blank=True, help_text='Select project code for this order', null=True, on_delete=django.db.models.deletion.SET_NULL, to='common.projectcode', verbose_name='Project Code'), + ), + migrations.AddField( + model_name='returnorder', + name='project_code', + field=models.ForeignKey(blank=True, help_text='Select project code for this order', null=True, on_delete=django.db.models.deletion.SET_NULL, to='common.projectcode', verbose_name='Project Code'), + ), + migrations.AddField( + model_name='salesorder', + name='project_code', + field=models.ForeignKey(blank=True, help_text='Select project code for this order', null=True, on_delete=django.db.models.deletion.SET_NULL, to='common.projectcode', verbose_name='Project Code'), + ), + ] diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index a9c2ebd950..6146305aef 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -28,6 +28,7 @@ import InvenTree.tasks import order.validators import stock.models import users.models as UserModels +from common.models import ProjectCode from common.notifications import InvenTreeNotificationBodies from common.settings import currency_code_default from company.models import Company, Contact, SupplierPart @@ -36,13 +37,13 @@ from InvenTree.fields import (InvenTreeModelMoneyField, InvenTreeURLField, RoundingDecimalField) from InvenTree.helpers import decimal2string, getSetting, notify_responsible from InvenTree.models import (InvenTreeAttachment, InvenTreeBarcodeMixin, - InvenTreeNotesMixin, ReferenceIndexingMixin) + InvenTreeNotesMixin, MetadataMixin, + ReferenceIndexingMixin) from InvenTree.status_codes import (PurchaseOrderStatus, ReturnOrderLineStatus, ReturnOrderStatus, SalesOrderStatus, StockHistoryCode, StockStatus) from part import models as PartModels from plugin.events import trigger_event -from plugin.models import MetadataMixin logger = logging.getLogger('inventree') @@ -199,6 +200,8 @@ class Order(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, Reference description = models.CharField(max_length=250, blank=True, verbose_name=_('Description'), help_text=_('Order description (optional)')) + project_code = models.ForeignKey(ProjectCode, on_delete=models.SET_NULL, blank=True, null=True, verbose_name=_('Project Code'), help_text=_('Select project code for this order')) + link = InvenTreeURLField(blank=True, verbose_name=_('Link'), help_text=_('Link to external page')) target_date = models.DateField( diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index 9624394943..251bfe8b6b 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -13,6 +13,7 @@ from rest_framework import serializers from rest_framework.serializers import ValidationError from sql_util.utils import SubqueryCount +import common.serializers import order.models import part.filters import stock.models @@ -64,6 +65,9 @@ class AbstractOrderSerializer(serializers.Serializer): # Detail for responsible field responsible_detail = OwnerSerializer(source='responsible', read_only=True, many=False) + # Detail for project code field + project_code_detail = common.serializers.ProjectCodeSerializer(source='project_code', read_only=True, many=False) + # Boolean field indicating if this order is overdue (Note: must be annotated) overdue = serializers.BooleanField(required=False, read_only=True) @@ -96,6 +100,8 @@ class AbstractOrderSerializer(serializers.Serializer): 'description', 'line_items', 'link', + 'project_code', + 'project_code_detail', 'reference', 'responsible', 'responsible_detail', diff --git a/InvenTree/order/templates/order/order_base.html b/InvenTree/order/templates/order/order_base.html index 3160ade55c..3594703afb 100644 --- a/InvenTree/order/templates/order/order_base.html +++ b/InvenTree/order/templates/order/order_base.html @@ -115,6 +115,7 @@ src="{% static 'img/blank_image.png' %}" {% trans "Order Description" %} {{ order.description }}{% include "clip.html" %} + {% include "project_code_data.html" with instance=order %} {% include "barcode_data.html" with instance=order %} diff --git a/InvenTree/order/templates/order/return_order_base.html b/InvenTree/order/templates/order/return_order_base.html index b6d1d11994..0499cfa232 100644 --- a/InvenTree/order/templates/order/return_order_base.html +++ b/InvenTree/order/templates/order/return_order_base.html @@ -107,9 +107,8 @@ src="{% static 'img/blank_image.png' %}" {% trans "Order Description" %} {{ order.description }}{% include "clip.html" %} - + {% include "project_code_data.html" with instance=order %} {% include "barcode_data.html" with instance=order %} - {% trans "Order Status" %} diff --git a/InvenTree/order/templates/order/sales_order_base.html b/InvenTree/order/templates/order/sales_order_base.html index a2fe28b813..15615c4fee 100644 --- a/InvenTree/order/templates/order/sales_order_base.html +++ b/InvenTree/order/templates/order/sales_order_base.html @@ -112,6 +112,7 @@ src="{% static 'img/blank_image.png' %}" {% trans "Order Description" %} {{ order.description }}{% include "clip.html" %} + {% include "project_code_data.html" with instance=order %} {% include "barcode_data.html" with instance=order %} diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index b04dbf9e04..7dd89bf04c 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -47,11 +47,10 @@ from InvenTree.fields import InvenTreeURLField from InvenTree.helpers import decimal2money, decimal2string, normalize from InvenTree.models import (DataImportMixin, InvenTreeAttachment, InvenTreeBarcodeMixin, InvenTreeNotesMixin, - InvenTreeTree) + InvenTreeTree, MetadataMixin) from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus, SalesOrderStatus) from order import models as OrderModels -from plugin.models import MetadataMixin from stock import models as StockModels logger = logging.getLogger("inventree") diff --git a/InvenTree/plugin/models.py b/InvenTree/plugin/models.py index 7afd741298..3b8c540916 100644 --- a/InvenTree/plugin/models.py +++ b/InvenTree/plugin/models.py @@ -12,60 +12,6 @@ import common.models from plugin import InvenTreePlugin, registry -class MetadataMixin(models.Model): - """Model mixin class which adds a JSON metadata field to a model, for use by any (and all) plugins. - - The intent of this mixin is to provide a metadata field on a model instance, - for plugins to read / modify as required, to store any extra information. - - The assumptions for models implementing this mixin are: - - - The internal InvenTree business logic will make no use of this field - - Multiple plugins may read / write to this metadata field, and not assume they have sole rights - """ - - class Meta: - """Meta for MetadataMixin.""" - abstract = True - - metadata = models.JSONField( - blank=True, null=True, - verbose_name=_('Plugin Metadata'), - help_text=_('JSON metadata field, for use by external plugins'), - ) - - def get_metadata(self, key: str, backup_value=None): - """Finds metadata for this model instance, using the provided key for lookup. - - Args: - key: String key for requesting metadata. e.g. if a plugin is accessing the metadata, the plugin slug should be used - - Returns: - Python dict object containing requested metadata. If no matching metadata is found, returns None - """ - if self.metadata is None: - return backup_value - - return self.metadata.get(key, backup_value) - - def set_metadata(self, key: str, data, commit: bool = True): - """Save the provided metadata under the provided key. - - Args: - key (str): Key for saving metadata - data (Any): Data object to save - must be able to be rendered as a JSON string - commit (bool, optional): If true, existing metadata with the provided key will be overwritten. If false, a merge will be attempted. Defaults to True. - """ - if self.metadata is None: - # Handle a null field value - self.metadata = {} - - self.metadata[key] = data - - if commit: - self.save() - - class PluginConfig(models.Model): """A PluginConfig object holds settings for plugins. diff --git a/InvenTree/report/models.py b/InvenTree/report/models.py index 9776fb0077..c9a5f5d15e 100644 --- a/InvenTree/report/models.py +++ b/InvenTree/report/models.py @@ -21,7 +21,7 @@ import order.models import part.models import stock.models from InvenTree.helpers import validateFilterString -from plugin.models import MetadataMixin +from InvenTree.models import MetadataMixin try: from django_weasyprint import WeasyTemplateResponseMixin diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 683d976a34..94aa168dc0 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -31,12 +31,12 @@ import report.models from company import models as CompanyModels from InvenTree.fields import InvenTreeModelMoneyField, InvenTreeURLField from InvenTree.models import (InvenTreeAttachment, InvenTreeBarcodeMixin, - InvenTreeNotesMixin, InvenTreeTree, extract_int) + InvenTreeNotesMixin, InvenTreeTree, + MetadataMixin, extract_int) from InvenTree.status_codes import (SalesOrderStatus, StockHistoryCode, StockStatus) from part import models as PartModels from plugin.events import trigger_event -from plugin.models import MetadataMixin from users.models import Owner diff --git a/InvenTree/templates/InvenTree/settings/project_codes.html b/InvenTree/templates/InvenTree/settings/project_codes.html new file mode 100644 index 0000000000..cb9d972a3c --- /dev/null +++ b/InvenTree/templates/InvenTree/settings/project_codes.html @@ -0,0 +1,35 @@ +{% extends "panel.html" %} + +{% load i18n %} +{% load inventree_extras %} + +{% block label %}project-codes{% endblock label %} + +{% block heading %}{% trans "Project Code Settings" %}{% endblock heading %} + + + +{% block content %} + + + + + {% include "InvenTree/settings/setting.html" with key="PROJECT_CODES_ENABLED" icon='fa-toggle-on' %} + +
+ +
+
+

{% trans "Project Codes" %}

+ {% include "spacer.html" %} +
+ +
+
+
+ + +
+{% endblock content %} diff --git a/InvenTree/templates/InvenTree/settings/settings.html b/InvenTree/templates/InvenTree/settings/settings.html index 128efa932b..5b8937b673 100644 --- a/InvenTree/templates/InvenTree/settings/settings.html +++ b/InvenTree/templates/InvenTree/settings/settings.html @@ -31,6 +31,7 @@ {% include "InvenTree/settings/global.html" %} {% include "InvenTree/settings/login.html" %} {% include "InvenTree/settings/barcode.html" %} +{% include "InvenTree/settings/project_codes.html" %} {% include "InvenTree/settings/notifications.html" %} {% include "InvenTree/settings/label.html" %} {% include "InvenTree/settings/report.html" %} diff --git a/InvenTree/templates/InvenTree/settings/settings_staff_js.html b/InvenTree/templates/InvenTree/settings/settings_staff_js.html index 3e6ccd1f0e..16e7e10731 100644 --- a/InvenTree/templates/InvenTree/settings/settings_staff_js.html +++ b/InvenTree/templates/InvenTree/settings/settings_staff_js.html @@ -52,6 +52,78 @@ onPanelLoad('pricing', function() { }); }); +// Javascript for project codes panel +onPanelLoad('project-codes', function() { + + // Construct the project code table + $('#project-code-table').bootstrapTable({ + url: '{% url "api-project-code-list" %}', + search: true, + sortable: true, + formatNoMatches: function() { + return '{% trans "No project codes found" %}'; + }, + columns: [ + { + field: 'code', + sortable: true, + title: '{% trans "Project Code" %}', + }, + { + field: 'description', + sortable: false, + title: '{% trans "Description" %}', + formatter: function(value, row) { + let html = value; + let buttons = ''; + + buttons += makeEditButton('button-project-code-edit', row.pk, '{% trans "Edit Project Code" %}'); + buttons += makeDeleteButton('button-project-code-delete', row.pk, '{% trans "Delete Project Code" %}'); + + html += wrapButtons(buttons); + return html; + } + } + ] + }); + + $('#project-code-table').on('click', '.button-project-code-edit', function() { + let pk = $(this).attr('pk'); + + constructForm(`{% url "api-project-code-list" %}${pk}/`, { + title: '{% trans "Edit Project Code" %}', + fields: { + code: {}, + description: {}, + }, + refreshTable: '#project-code-table', + }); + }); + + $('#project-code-table').on('click', '.button-project-code-delete', function() { + let pk = $(this).attr('pk'); + + constructForm(`{% url "api-project-code-list" %}${pk}/`, { + title: '{% trans "Delete Project Code" %}', + method: 'DELETE', + refreshTable: '#project-code-table', + }); + }); + + $('#new-project-code').click(function() { + // Construct a new project code + constructForm('{% url "api-project-code-list" %}', { + fields: { + code: {}, + description: {}, + }, + title: '{% trans "New Project Code" %}', + method: 'POST', + refreshTable: '#project-code-table', + }); + }) +}); + // Javascript for Part Category panel onPanelLoad('category', function() { $('#category-select').select2({ @@ -136,11 +208,12 @@ onPanelLoad('category', function() { title: '{% trans "Default Value" %}', sortable: 'true', formatter: function(value, row, index, field) { - var bEdit = ""; - var bDel = ""; + let buttons = ''; + buttons += makeEditButton('template-edit', row.pk, '{% trans "Edit Template" %}'); + buttons += makeDeleteButton('template-delete', row.pk, '{% trans "Delete Template" %}'); - var html = value - html += "
" + bEdit + bDel + "
"; + let html = value + html += wrapButtons(buttons); return html; } @@ -154,6 +227,7 @@ onPanelLoad('category', function() { var pk = $(this).attr('pk'); constructForm(`/api/part/category/parameters/${pk}/`, { + title: '{% trans "Edit Category Parameter Template" %}', fields: { parameter_template: {}, category: { diff --git a/InvenTree/templates/InvenTree/settings/sidebar.html b/InvenTree/templates/InvenTree/settings/sidebar.html index 3ba4aa1d47..9db6f6620f 100644 --- a/InvenTree/templates/InvenTree/settings/sidebar.html +++ b/InvenTree/templates/InvenTree/settings/sidebar.html @@ -30,6 +30,8 @@ {% include "sidebar_item.html" with label='login' text=text icon="fa-fingerprint" %} {% trans "Barcode Support" as text %} {% include "sidebar_item.html" with label='barcodes' text=text icon="fa-qrcode" %} +{% trans "Project Codes" as text %} +{% include "sidebar_item.html" with label='project-codes' text=text icon="fa-list" %} {% trans "Notifications" as text %} {% include "sidebar_item.html" with label='global-notifications' text=text icon="fa-bell" %} {% trans "Pricing" as text %} diff --git a/InvenTree/templates/js/translated/company.js b/InvenTree/templates/js/translated/company.js index f5393909d2..f91a97c793 100644 --- a/InvenTree/templates/js/translated/company.js +++ b/InvenTree/templates/js/translated/company.js @@ -1123,9 +1123,9 @@ function loadSupplierPartTable(table, url, options) { var params = options.params || {}; // Load filters - var filters = loadTableFilters('supplier-part', params); + var filters = loadTableFilters('supplierpart', params); - setupFilterList('supplier-part', $(table)); + setupFilterList('supplierpart', $(table)); $(table).inventreeTable({ url: url, diff --git a/InvenTree/templates/js/translated/model_renderers.js b/InvenTree/templates/js/translated/model_renderers.js index f69c0ac531..b9583ac8a8 100644 --- a/InvenTree/templates/js/translated/model_renderers.js +++ b/InvenTree/templates/js/translated/model_renderers.js @@ -16,6 +16,7 @@ renderOwner, renderPart, renderPartCategory, + renderProjectCode, renderReturnOrder, renderStockItem, renderStockLocation, @@ -78,6 +79,8 @@ function getModelRenderer(model) { return renderUser; case 'group': return renderGroup; + case 'projectcode': + return renderProjectCode; default: // Un-handled model type console.error(`Rendering not implemented for model '${model}'`); @@ -476,3 +479,16 @@ function renderSupplierPart(data, parameters={}) { parameters ); } + + +// Renderer for "ProjectCode" model +function renderProjectCode(data, parameters={}) { + + return renderModel( + { + text: data.code, + textSecondary: data.description, + }, + parameters + ); +} diff --git a/InvenTree/templates/js/translated/purchase_order.js b/InvenTree/templates/js/translated/purchase_order.js index 77156d9a3e..dfe049a0a9 100644 --- a/InvenTree/templates/js/translated/purchase_order.js +++ b/InvenTree/templates/js/translated/purchase_order.js @@ -62,6 +62,9 @@ function purchaseOrderFields(options={}) { } }, supplier_reference: {}, + project_code: { + icon: 'fa-list', + }, target_date: { icon: 'fa-calendar-alt', }, @@ -126,6 +129,10 @@ function purchaseOrderFields(options={}) { }; } + if (!global_settings.PROJECT_CODES_ENABLED) { + delete fields.project_code; + } + return fields; } @@ -1614,6 +1621,18 @@ function loadPurchaseOrderTable(table, options) { field: 'description', title: '{% trans "Description" %}', }, + { + field: 'project_code', + title: '{% trans "Project Code" %}', + switchable: global_settings.PROJECT_CODES_ENABLED, + visible: global_settings.PROJECT_CODES_ENABLED, + sortable: true, + formatter: function(value, row) { + if (row.project_code_detail) { + return `${row.project_code_detail.code}`; + } + } + }, { field: 'status', title: '{% trans "Status" %}', diff --git a/InvenTree/templates/js/translated/return_order.js b/InvenTree/templates/js/translated/return_order.js index 884d9e2a10..31855438ce 100644 --- a/InvenTree/templates/js/translated/return_order.js +++ b/InvenTree/templates/js/translated/return_order.js @@ -46,6 +46,9 @@ function returnOrderFields(options={}) { } }, customer_reference: {}, + project_code: { + icon: 'fa-list', + }, target_date: { icon: 'fa-calendar-alt', }, @@ -69,6 +72,10 @@ function returnOrderFields(options={}) { } }; + if (!global_settings.PROJECT_CODES_ENABLED) { + delete fields.project_code; + } + return fields; } @@ -271,6 +278,18 @@ function loadReturnOrderTable(table, options={}) { field: 'description', title: '{% trans "Description" %}', }, + { + field: 'project_code', + title: '{% trans "Project Code" %}', + switchable: global_settings.PROJECT_CODES_ENABLED, + visible: global_settings.PROJECT_CODES_ENABLED, + sortable: true, + formatter: function(value, row) { + if (row.project_code_detail) { + return `${row.project_code_detail.code}`; + } + } + }, { sortable: true, field: 'status', diff --git a/InvenTree/templates/js/translated/sales_order.js b/InvenTree/templates/js/translated/sales_order.js index 29834f6caf..095160726b 100644 --- a/InvenTree/templates/js/translated/sales_order.js +++ b/InvenTree/templates/js/translated/sales_order.js @@ -59,6 +59,9 @@ function salesOrderFields(options={}) { } }, customer_reference: {}, + project_code: { + icon: 'fa-list', + }, target_date: { icon: 'fa-calendar-alt', }, @@ -82,6 +85,10 @@ function salesOrderFields(options={}) { } }; + if (!global_settings.PROJECT_CODES_ENABLED) { + delete fields.project_code; + } + return fields; } @@ -739,6 +746,18 @@ function loadSalesOrderTable(table, options) { field: 'description', title: '{% trans "Description" %}', }, + { + field: 'project_code', + title: '{% trans "Project Code" %}', + switchable: global_settings.PROJECT_CODES_ENABLED, + visible: global_settings.PROJECT_CODES_ENABLED, + sortable: true, + formatter: function(value, row) { + if (row.project_code_detail) { + return `${row.project_code_detail.code}`; + } + } + }, { sortable: true, field: 'status', diff --git a/InvenTree/templates/js/translated/table_filters.js b/InvenTree/templates/js/translated/table_filters.js index 8e32862f2d..6c5d94a4c4 100644 --- a/InvenTree/templates/js/translated/table_filters.js +++ b/InvenTree/templates/js/translated/table_filters.js @@ -14,581 +14,708 @@ */ +// Construct a dynamic API filter for the "project" field +function constructProjectCodeFilter() { + return { + title: '{% trans "Project Code" %}', + options: function() { + let project_codes = {}; + + inventreeGet('{% url "api-project-code-list" %}', {}, { + async: false, + success: function(response) { + for (let code of response) { + project_codes[code.pk] = { + key: code.pk, + value: code.code + }; + } + } + }); + + return project_codes; + } + }; +} + + +// Construct a filter for the "has project code" field +function constructHasProjectCodeFilter() { + return { + type: 'bool', + title: '{% trans "Has project code" %}', + }; +} + + +// Return a dictionary of filters for the return order table +function getReturnOrderFilters() { + var filters = { + status: { + title: '{% trans "Order status" %}', + options: returnOrderCodes + }, + outstanding: { + type: 'bool', + title: '{% trans "Outstanding" %}', + }, + overdue: { + type: 'bool', + title: '{% trans "Overdue" %}', + }, + assigned_to_me: { + type: 'bool', + title: '{% trans "Assigned to me" %}', + }, + }; + + if (global_settings.PROJECT_CODES_ENABLED) { + filters['has_project_code'] = constructHasProjectCodeFilter(); + filters['project_code'] = constructProjectCodeFilter(); + } + + return filters; +} + + +// Return a dictionary of filters for the return order line item table +function getReturnOrderLineItemFilters() { + return { + received: { + type: 'bool', + title: '{% trans "Received" %}', + }, + outcome: { + title: '{% trans "Outcome" %}', + options: returnOrderLineItemCodes, + } + }; +} + + +// Return a dictionary of filters for the variants table +function getVariantsTableFilters() { + return { + active: { + type: 'bool', + title: '{% trans "Active" %}', + }, + template: { + type: 'bool', + title: '{% trans "Template" %}', + }, + virtual: { + type: 'bool', + title: '{% trans "Virtual" %}', + }, + trackable: { + type: 'bool', + title: '{% trans "Trackable" %}', + }, + }; +} + + +// Return a dictionary of filters for the BOM table +function getBOMTableFilters() { + return { + sub_part_trackable: { + type: 'bool', + title: '{% trans "Trackable Part" %}', + }, + sub_part_assembly: { + type: 'bool', + title: '{% trans "Assembled Part" %}', + }, + available_stock: { + type: 'bool', + title: '{% trans "Has Available Stock" %}', + }, + on_order: { + type: 'bool', + title: '{% trans "On Order" %}', + }, + validated: { + type: 'bool', + title: '{% trans "Validated" %}', + }, + inherited: { + type: 'bool', + title: '{% trans "Gets inherited" %}', + }, + allow_variants: { + type: 'bool', + title: '{% trans "Allow Variant Stock" %}', + }, + optional: { + type: 'bool', + title: '{% trans "Optional" %}', + }, + consumable: { + type: 'bool', + title: '{% trans "Consumable" %}', + }, + has_pricing: { + type: 'bool', + title: '{% trans "Has Pricing" %}', + }, + }; +} + + +// Return a dictionary of filters for the "related parts" table +function getRelatedTableFilters() { + return {}; +} + + +// Return a dictionary of filters for the "used in" table +function getUsedInTableFilters() { + return { + 'inherited': { + type: 'bool', + title: '{% trans "Gets inherited" %}', + }, + 'optional': { + type: 'bool', + title: '{% trans "Optional" %}', + }, + 'part_active': { + type: 'bool', + title: '{% trans "Active" %}', + }, + 'part_trackable': { + type: 'bool', + title: '{% trans "Trackable" %}', + }, + }; +} + + +// Return a dictionary of filters for the "stock location" table +function getStockLocationFilters() { + return { + cascade: { + type: 'bool', + title: '{% trans "Include sublocations" %}', + description: '{% trans "Include locations" %}', + }, + structural: { + type: 'bool', + title: '{% trans "Structural" %}', + }, + external: { + type: 'bool', + title: '{% trans "External" %}', + }, + }; +} + + +// Return a dictionary of filters for the "part category" table +function getPartCategoryFilters() { + return { + cascade: { + type: 'bool', + title: '{% trans "Include subcategories" %}', + description: '{% trans "Include subcategories" %}', + }, + structural: { + type: 'bool', + title: '{% trans "Structural" %}', + }, + starred: { + type: 'bool', + title: '{% trans "Subscribed" %}', + }, + }; +} + + +// Return a dictionary of filters for the "customer stock" table +function getCustomerStockFilters() { + return { + serialized: { + type: 'bool', + title: '{% trans "Is Serialized" %}', + }, + serial_gte: { + title: '{% trans "Serial number GTE" %}', + description: '{% trans "Serial number greater than or equal to" %}', + }, + serial_lte: { + title: '{% trans "Serial number LTE" %}', + description: '{% trans "Serial number less than or equal to" %}', + }, + serial: { + title: '{% trans "Serial number" %}', + description: '{% trans "Serial number" %}', + }, + batch: { + title: '{% trans "Batch" %}', + description: '{% trans "Batch code" %}', + }, + }; +} + + +// Return a dictionary of filters for the "stock" table +function getStockTableFilters() { + var filters = { + active: { + type: 'bool', + title: '{% trans "Active parts" %}', + description: '{% trans "Show stock for active parts" %}', + }, + assembly: { + type: 'bool', + title: '{% trans "Assembly" %}', + description: '{% trans "Part is an assembly" %}', + }, + allocated: { + type: 'bool', + title: '{% trans "Is allocated" %}', + description: '{% trans "Item has been allocated" %}', + }, + available: { + type: 'bool', + title: '{% trans "Available" %}', + description: '{% trans "Stock is available for use" %}', + }, + cascade: { + type: 'bool', + title: '{% trans "Include sublocations" %}', + description: '{% trans "Include stock in sublocations" %}', + }, + depleted: { + type: 'bool', + title: '{% trans "Depleted" %}', + description: '{% trans "Show stock items which are depleted" %}', + }, + in_stock: { + type: 'bool', + title: '{% trans "In Stock" %}', + description: '{% trans "Show items which are in stock" %}', + }, + is_building: { + type: 'bool', + title: '{% trans "In Production" %}', + description: '{% trans "Show items which are in production" %}', + }, + include_variants: { + type: 'bool', + title: '{% trans "Include Variants" %}', + description: '{% trans "Include stock items for variant parts" %}', + }, + installed: { + type: 'bool', + title: '{% trans "Installed" %}', + description: '{% trans "Show stock items which are installed in another item" %}', + }, + sent_to_customer: { + type: 'bool', + title: '{% trans "Sent to customer" %}', + description: '{% trans "Show items which have been assigned to a customer" %}', + }, + serialized: { + type: 'bool', + title: '{% trans "Is Serialized" %}', + }, + serial: { + title: '{% trans "Serial number" %}', + description: '{% trans "Serial number" %}', + }, + serial_gte: { + title: '{% trans "Serial number GTE" %}', + description: '{% trans "Serial number greater than or equal to" %}', + }, + serial_lte: { + title: '{% trans "Serial number LTE" %}', + description: '{% trans "Serial number less than or equal to" %}', + }, + status: { + options: stockCodes, + title: '{% trans "Stock status" %}', + description: '{% trans "Stock status" %}', + }, + has_batch: { + title: '{% trans "Has batch code" %}', + type: 'bool', + }, + batch: { + title: '{% trans "Batch" %}', + description: '{% trans "Batch code" %}', + }, + tracked: { + title: '{% trans "Tracked" %}', + description: '{% trans "Stock item is tracked by either batch code or serial number" %}', + type: 'bool', + }, + has_purchase_price: { + type: 'bool', + title: '{% trans "Has purchase price" %}', + description: '{% trans "Show stock items which have a purchase price set" %}', + }, + expiry_date_lte: { + type: 'date', + title: '{% trans "Expiry Date before" %}', + }, + expiry_date_gte: { + type: 'date', + title: '{% trans "Expiry Date after" %}', + }, + external: { + type: 'bool', + title: '{% trans "External Location" %}', + } + }; + + // Optional filters if stock expiry functionality is enabled + if (global_settings.STOCK_ENABLE_EXPIRY) { + filters.expired = { + type: 'bool', + title: '{% trans "Expired" %}', + description: '{% trans "Show stock items which have expired" %}', + }; + + filters.stale = { + type: 'bool', + title: '{% trans "Stale" %}', + description: '{% trans "Show stock which is close to expiring" %}', + }; + } + + return filters; +} + + +// Return a dictionary of filters for the "stock tests" table +function getStockTestTableFilters() { + + return { + result: { + type: 'bool', + title: '{% trans "Test Passed" %}', + }, + include_installed: { + type: 'bool', + title: '{% trans "Include Installed Items" %}', + } + }; +} + + +// Return a dictionary of filters for the "part tests" table +function getPartTestTemplateFilters() { + return { + required: { + type: 'bool', + title: '{% trans "Required" %}', + }, + }; +} + + +// Return a dictionary of filters for the "build" table +function getBuildTableFilters() { + + return { + status: { + title: '{% trans "Build status" %}', + options: buildCodes, + }, + active: { + type: 'bool', + title: '{% trans "Active" %}', + }, + overdue: { + type: 'bool', + title: '{% trans "Overdue" %}', + }, + assigned_to_me: { + type: 'bool', + title: '{% trans "Assigned to me" %}', + }, + assigned_to: { + title: '{% trans "Responsible" %}', + options: function() { + var ownersList = {}; + inventreeGet('{% url "api-owner-list" %}', {}, { + async: false, + success: function(response) { + for (key in response) { + var owner = response[key]; + ownersList[owner.pk] = { + key: owner.pk, + value: `${owner.name} (${owner.label})`, + }; + } + } + }); + return ownersList; + }, + }, + }; +} + + +// Return a dictionary of filters for the "purchase order line item" table +function getPurchaseOrderLineItemFilters() { + return { + pending: { + type: 'bool', + title: '{% trans "Pending" %}', + }, + received: { + type: 'bool', + title: '{% trans "Received" %}', + }, + order_status: { + title: '{% trans "Order status" %}', + options: purchaseOrderCodes, + }, + }; +} + + +// Return a dictionary of filters for the "purchase order" table +function getPurchaseOrderFilters() { + + var filters = { + status: { + title: '{% trans "Order status" %}', + options: purchaseOrderCodes, + }, + outstanding: { + type: 'bool', + title: '{% trans "Outstanding" %}', + }, + overdue: { + type: 'bool', + title: '{% trans "Overdue" %}', + }, + assigned_to_me: { + type: 'bool', + title: '{% trans "Assigned to me" %}', + }, + }; + + if (global_settings.PROJECT_CODES_ENABLED) { + filters['has_project_code'] = constructHasProjectCodeFilter(); + filters['project_code'] = constructProjectCodeFilter(); + } + + return filters; +} + + +// Return a dictionary of filters for the "sales order allocation" table +function getSalesOrderAllocationFilters() { + return { + outstanding: { + type: 'bool', + title: '{% trans "Outstanding" %}', + } + }; +} + + +// Return a dictionary of filters for the "sales order" table +function getSalesOrderFilters() { + var filters = { + status: { + title: '{% trans "Order status" %}', + options: salesOrderCodes, + }, + outstanding: { + type: 'bool', + title: '{% trans "Outstanding" %}', + }, + overdue: { + type: 'bool', + title: '{% trans "Overdue" %}', + }, + assigned_to_me: { + type: 'bool', + title: '{% trans "Assigned to me" %}', + }, + }; + + if (global_settings.PROJECT_CODES_ENABLED) { + filters['has_project_code'] = constructHasProjectCodeFilter(); + filters['project_code'] = constructProjectCodeFilter(); + } + + return filters; +} + + +// Return a dictionary of filters for the "sales order line item" table +function getSalesOrderLineItemFilters() { + return { + completed: { + type: 'bool', + title: '{% trans "Completed" %}', + }, + }; +} + + +// Return a dictionary of filters for the "supplier part" table +function getSupplierPartFilters() { + return { + active: { + type: 'bool', + title: '{% trans "Active parts" %}', + }, + }; +} + + +// Return a dictionary of filters for the "part" table +function getPartTableFilters() { + return { + cascade: { + type: 'bool', + title: '{% trans "Include subcategories" %}', + description: '{% trans "Include parts in subcategories" %}', + }, + active: { + type: 'bool', + title: '{% trans "Active" %}', + description: '{% trans "Show active parts" %}', + }, + assembly: { + type: 'bool', + title: '{% trans "Assembly" %}', + }, + unallocated_stock: { + type: 'bool', + title: '{% trans "Available stock" %}', + }, + component: { + type: 'bool', + title: '{% trans "Component" %}', + }, + has_ipn: { + type: 'bool', + title: '{% trans "Has IPN" %}', + description: '{% trans "Part has internal part number" %}', + }, + has_stock: { + type: 'bool', + title: '{% trans "In stock" %}', + }, + low_stock: { + type: 'bool', + title: '{% trans "Low stock" %}', + }, + purchaseable: { + type: 'bool', + title: '{% trans "Purchasable" %}', + }, + salable: { + type: 'bool', + title: '{% trans "Salable" %}', + }, + starred: { + type: 'bool', + title: '{% trans "Subscribed" %}', + }, + stocktake: { + type: 'bool', + title: '{% trans "Has stocktake entries" %}', + }, + is_template: { + type: 'bool', + title: '{% trans "Template" %}', + }, + trackable: { + type: 'bool', + title: '{% trans "Trackable" %}', + }, + virtual: { + type: 'bool', + title: '{% trans "Virtual" %}', + }, + has_pricing: { + type: 'bool', + title: '{% trans "Has Pricing" %}', + }, + }; +} + + +// Return a dictionary of filters for the "company" table +function getCompanyFilters() { + return { + is_manufacturer: { + type: 'bool', + title: '{% trans "Manufacturer" %}', + }, + is_supplier: { + type: 'bool', + title: '{% trans "Supplier" %}', + }, + is_customer: { + type: 'bool', + title: '{% trans "Customer" %}', + }, + }; +} + + + +// Return a dictionary of filters for a given table, based on the name of the table function getAvailableTableFilters(tableKey) { tableKey = tableKey.toLowerCase(); - // Filters for "returnorder" table - if (tableKey == 'returnorder') { - return { - status: { - title: '{% trans "Order status" %}', - options: returnOrderCodes - }, - outstanding: { - type: 'bool', - title: '{% trans "Outstanding" %}', - }, - overdue: { - type: 'bool', - title: '{% trans "Overdue" %}', - }, - assigned_to_me: { - type: 'bool', - title: '{% trans "Assigned to me" %}', - }, - }; + switch (tableKey) { + case 'category': + return getPartCategoryFilters(); + case 'company': + return getCompanyFilters(); + case 'customerstock': + return getCustomerStockFilters(); + case 'bom': + return getBOMTableFilters(); + case 'build': + return getBuildTableFilters(); + case 'location': + return getStockLocationFilters(); + case 'parts': + return getPartTableFilters(); + case 'parttests': + return getPartTestTemplateFilters(); + case 'purchaseorder': + return getPurchaseOrderFilters(); + case 'purchaseorderlineitem': + return getPurchaseOrderLineItemFilters(); + case 'related': + return getRelatedTableFilters(); + case 'returnorder': + return getReturnOrderFilters(); + case 'returnorderlineitem': + return getReturnOrderLineItemFilters(); + case 'salesorder': + return getSalesOrderFilters(); + case 'salesorderallocation': + return getSalesOrderAllocationFilters(); + case 'salesorderlineitem': + return getSalesOrderLineItemFilters(); + case 'stock': + return getStockTableFilters(); + case 'stocktests': + return getStockTestTableFilters(); + case 'supplierpart': + return getSupplierPartFilters(); + case 'usedin': + return getUsedInTableFilters(); + case 'variants': + return getVariantsTableFilters(); + default: + console.warn(`No filters defined for table ${tableKey}`); + return {}; } - - // Filters for "returnorderlineitem" table - if (tableKey == 'returnorderlineitem') { - return { - received: { - type: 'bool', - title: '{% trans "Received" %}', - }, - outcome: { - title: '{% trans "Outcome" %}', - options: returnOrderLineItemCodes, - } - }; - } - - // Filters for "variant" table - if (tableKey == 'variants') { - return { - active: { - type: 'bool', - title: '{% trans "Active" %}', - }, - template: { - type: 'bool', - title: '{% trans "Template" %}', - }, - virtual: { - type: 'bool', - title: '{% trans "Virtual" %}', - }, - trackable: { - type: 'bool', - title: '{% trans "Trackable" %}', - }, - }; - } - - // Filters for Bill of Materials table - if (tableKey == 'bom') { - return { - sub_part_trackable: { - type: 'bool', - title: '{% trans "Trackable Part" %}', - }, - sub_part_assembly: { - type: 'bool', - title: '{% trans "Assembled Part" %}', - }, - available_stock: { - type: 'bool', - title: '{% trans "Has Available Stock" %}', - }, - on_order: { - type: 'bool', - title: '{% trans "On Order" %}', - }, - validated: { - type: 'bool', - title: '{% trans "Validated" %}', - }, - inherited: { - type: 'bool', - title: '{% trans "Gets inherited" %}', - }, - allow_variants: { - type: 'bool', - title: '{% trans "Allow Variant Stock" %}', - }, - optional: { - type: 'bool', - title: '{% trans "Optional" %}', - }, - consumable: { - type: 'bool', - title: '{% trans "Consumable" %}', - }, - has_pricing: { - type: 'bool', - title: '{% trans "Has Pricing" %}', - }, - }; - } - - // Filters for the "related parts" table - if (tableKey == 'related') { - return { - }; - } - - // Filters for the "used in" table - if (tableKey == 'usedin') { - return { - 'inherited': { - type: 'bool', - title: '{% trans "Gets inherited" %}', - }, - 'optional': { - type: 'bool', - title: '{% trans "Optional" %}', - }, - 'part_active': { - type: 'bool', - title: '{% trans "Active" %}', - }, - 'part_trackable': { - type: 'bool', - title: '{% trans "Trackable" %}', - }, - }; - } - - // Filters for "stock location" table - if (tableKey == 'location') { - return { - cascade: { - type: 'bool', - title: '{% trans "Include sublocations" %}', - description: '{% trans "Include locations" %}', - }, - structural: { - type: 'bool', - title: '{% trans "Structural" %}', - }, - external: { - type: 'bool', - title: '{% trans "External" %}', - }, - }; - } - - // Filters for "part category" table - if (tableKey == 'category') { - return { - cascade: { - type: 'bool', - title: '{% trans "Include subcategories" %}', - description: '{% trans "Include subcategories" %}', - }, - structural: { - type: 'bool', - title: '{% trans "Structural" %}', - }, - starred: { - type: 'bool', - title: '{% trans "Subscribed" %}', - }, - }; - } - - // Filters for the "customer stock" table (really a subset of "stock") - if (tableKey == 'customerstock') { - return { - serialized: { - type: 'bool', - title: '{% trans "Is Serialized" %}', - }, - serial_gte: { - title: '{% trans "Serial number GTE" %}', - description: '{% trans "Serial number greater than or equal to" %}', - }, - serial_lte: { - title: '{% trans "Serial number LTE" %}', - description: '{% trans "Serial number less than or equal to" %}', - }, - serial: { - title: '{% trans "Serial number" %}', - description: '{% trans "Serial number" %}', - }, - batch: { - title: '{% trans "Batch" %}', - description: '{% trans "Batch code" %}', - }, - }; - } - - // Filters for the "Stock" table - if (tableKey == 'stock') { - - var filters = { - active: { - type: 'bool', - title: '{% trans "Active parts" %}', - description: '{% trans "Show stock for active parts" %}', - }, - assembly: { - type: 'bool', - title: '{% trans "Assembly" %}', - description: '{% trans "Part is an assembly" %}', - }, - allocated: { - type: 'bool', - title: '{% trans "Is allocated" %}', - description: '{% trans "Item has been allocated" %}', - }, - available: { - type: 'bool', - title: '{% trans "Available" %}', - description: '{% trans "Stock is available for use" %}', - }, - cascade: { - type: 'bool', - title: '{% trans "Include sublocations" %}', - description: '{% trans "Include stock in sublocations" %}', - }, - depleted: { - type: 'bool', - title: '{% trans "Depleted" %}', - description: '{% trans "Show stock items which are depleted" %}', - }, - in_stock: { - type: 'bool', - title: '{% trans "In Stock" %}', - description: '{% trans "Show items which are in stock" %}', - }, - is_building: { - type: 'bool', - title: '{% trans "In Production" %}', - description: '{% trans "Show items which are in production" %}', - }, - include_variants: { - type: 'bool', - title: '{% trans "Include Variants" %}', - description: '{% trans "Include stock items for variant parts" %}', - }, - installed: { - type: 'bool', - title: '{% trans "Installed" %}', - description: '{% trans "Show stock items which are installed in another item" %}', - }, - sent_to_customer: { - type: 'bool', - title: '{% trans "Sent to customer" %}', - description: '{% trans "Show items which have been assigned to a customer" %}', - }, - serialized: { - type: 'bool', - title: '{% trans "Is Serialized" %}', - }, - serial: { - title: '{% trans "Serial number" %}', - description: '{% trans "Serial number" %}', - }, - serial_gte: { - title: '{% trans "Serial number GTE" %}', - description: '{% trans "Serial number greater than or equal to" %}', - }, - serial_lte: { - title: '{% trans "Serial number LTE" %}', - description: '{% trans "Serial number less than or equal to" %}', - }, - status: { - options: stockCodes, - title: '{% trans "Stock status" %}', - description: '{% trans "Stock status" %}', - }, - has_batch: { - title: '{% trans "Has batch code" %}', - type: 'bool', - }, - batch: { - title: '{% trans "Batch" %}', - description: '{% trans "Batch code" %}', - }, - tracked: { - title: '{% trans "Tracked" %}', - description: '{% trans "Stock item is tracked by either batch code or serial number" %}', - type: 'bool', - }, - has_purchase_price: { - type: 'bool', - title: '{% trans "Has purchase price" %}', - description: '{% trans "Show stock items which have a purchase price set" %}', - }, - expiry_date_lte: { - type: 'date', - title: '{% trans "Expiry Date before" %}', - }, - expiry_date_gte: { - type: 'date', - title: '{% trans "Expiry Date after" %}', - }, - external: { - type: 'bool', - title: '{% trans "External Location" %}', - } - }; - - // Optional filters if stock expiry functionality is enabled - if (global_settings.STOCK_ENABLE_EXPIRY) { - filters.expired = { - type: 'bool', - title: '{% trans "Expired" %}', - description: '{% trans "Show stock items which have expired" %}', - }; - - filters.stale = { - type: 'bool', - title: '{% trans "Stale" %}', - description: '{% trans "Show stock which is close to expiring" %}', - }; - } - - return filters; - } - - // Filters for the 'stock test' table - if (tableKey == 'stocktests') { - return { - result: { - type: 'bool', - title: '{% trans "Test Passed" %}', - }, - include_installed: { - type: 'bool', - title: '{% trans "Include Installed Items" %}', - } - }; - } - - // Filters for the 'part test template' table - if (tableKey == 'parttests') { - return { - required: { - type: 'bool', - title: '{% trans "Required" %}', - }, - }; - } - - // Filters for the "Build" table - if (tableKey == 'build') { - return { - status: { - title: '{% trans "Build status" %}', - options: buildCodes, - }, - active: { - type: 'bool', - title: '{% trans "Active" %}', - }, - overdue: { - type: 'bool', - title: '{% trans "Overdue" %}', - }, - assigned_to_me: { - type: 'bool', - title: '{% trans "Assigned to me" %}', - }, - assigned_to: { - title: '{% trans "Responsible" %}', - options: function() { - var ownersList = {}; - inventreeGet('{% url "api-owner-list" %}', {}, { - async: false, - success: function(response) { - for (key in response) { - var owner = response[key]; - ownersList[owner.pk] = { - key: owner.pk, - value: `${owner.name} (${owner.label})`, - }; - } - } - }); - return ownersList; - }, - }, - }; - } - - // Filters for PurchaseOrderLineItem table - if (tableKey == 'purchaseorderlineitem') { - return { - pending: { - type: 'bool', - title: '{% trans "Pending" %}', - }, - received: { - type: 'bool', - title: '{% trans "Received" %}', - }, - order_status: { - title: '{% trans "Order status" %}', - options: purchaseOrderCodes, - }, - }; - } - - // Filters for the PurchaseOrder table - if (tableKey == 'purchaseorder') { - - return { - status: { - title: '{% trans "Order status" %}', - options: purchaseOrderCodes, - }, - outstanding: { - type: 'bool', - title: '{% trans "Outstanding" %}', - }, - overdue: { - type: 'bool', - title: '{% trans "Overdue" %}', - }, - assigned_to_me: { - type: 'bool', - title: '{% trans "Assigned to me" %}', - }, - }; - } - - if (tableKey == 'salesorderallocation') { - return { - outstanding: { - type: 'bool', - title: '{% trans "Outstanding" %}', - } - }; - } - - if (tableKey == 'salesorder') { - return { - status: { - title: '{% trans "Order status" %}', - options: salesOrderCodes, - }, - outstanding: { - type: 'bool', - title: '{% trans "Outstanding" %}', - }, - overdue: { - type: 'bool', - title: '{% trans "Overdue" %}', - }, - assigned_to_me: { - type: 'bool', - title: '{% trans "Assigned to me" %}', - }, - }; - } - - if (tableKey == 'salesorderlineitem') { - return { - completed: { - type: 'bool', - title: '{% trans "Completed" %}', - }, - }; - } - - if (tableKey == 'supplier-part') { - return { - active: { - type: 'bool', - title: '{% trans "Active parts" %}', - }, - }; - } - - // Filters for "company" table - if (tableKey == 'company') { - return { - is_manufacturer: { - type: 'bool', - title: '{% trans "Manufacturer" %}', - }, - is_supplier: { - type: 'bool', - title: '{% trans "Supplier" %}', - }, - is_customer: { - type: 'bool', - title: '{% trans "Customer" %}', - }, - }; - } - - // Filters for the "Parts" table - if (tableKey == 'parts') { - return { - cascade: { - type: 'bool', - title: '{% trans "Include subcategories" %}', - description: '{% trans "Include parts in subcategories" %}', - }, - active: { - type: 'bool', - title: '{% trans "Active" %}', - description: '{% trans "Show active parts" %}', - }, - assembly: { - type: 'bool', - title: '{% trans "Assembly" %}', - }, - unallocated_stock: { - type: 'bool', - title: '{% trans "Available stock" %}', - }, - component: { - type: 'bool', - title: '{% trans "Component" %}', - }, - has_ipn: { - type: 'bool', - title: '{% trans "Has IPN" %}', - description: '{% trans "Part has internal part number" %}', - }, - has_stock: { - type: 'bool', - title: '{% trans "In stock" %}', - }, - low_stock: { - type: 'bool', - title: '{% trans "Low stock" %}', - }, - purchaseable: { - type: 'bool', - title: '{% trans "Purchasable" %}', - }, - salable: { - type: 'bool', - title: '{% trans "Salable" %}', - }, - starred: { - type: 'bool', - title: '{% trans "Subscribed" %}', - }, - stocktake: { - type: 'bool', - title: '{% trans "Has stocktake entries" %}', - }, - is_template: { - type: 'bool', - title: '{% trans "Template" %}', - }, - trackable: { - type: 'bool', - title: '{% trans "Trackable" %}', - }, - virtual: { - type: 'bool', - title: '{% trans "Virtual" %}', - }, - has_pricing: { - type: 'bool', - title: '{% trans "Has Pricing" %}', - }, - }; - } - - // Finally, no matching key - return {}; } diff --git a/InvenTree/templates/project_code_data.html b/InvenTree/templates/project_code_data.html new file mode 100644 index 0000000000..daef7fcec6 --- /dev/null +++ b/InvenTree/templates/project_code_data.html @@ -0,0 +1,11 @@ +{% load i18n %} + +{% if instance and instance.project_code %} + + + {% trans "Project Code" %} + + {{ instance.project_code.code }} - {{ instance.project_code.description }} + + +{% endif %} diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py index b8d784c2af..9f08f1a892 100644 --- a/InvenTree/users/models.py +++ b/InvenTree/users/models.py @@ -181,11 +181,12 @@ class RuleSet(models.Model): 'common_colortheme', 'common_inventreesetting', 'common_inventreeusersetting', - 'common_webhookendpoint', - 'common_webhookmessage', 'common_notificationentry', 'common_notificationmessage', 'common_notesimage', + 'common_projectcode', + 'common_webhookendpoint', + 'common_webhookmessage', 'users_owner', # Third-party tables