mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-30 20:55:42 +00:00 
			
		
		
		
	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
This commit is contained in:
		| @@ -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 | ||||
|  | ||||
|   | ||||
| @@ -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) | ||||
|  | ||||
|   | ||||
| @@ -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 = [] | ||||
|  | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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)'), | ||||
|     ) | ||||
|   | ||||
| @@ -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): | ||||
|   | ||||
| @@ -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})') | ||||
|   | ||||
| @@ -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'<int:pk>/', 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'), | ||||
|   | ||||
							
								
								
									
										21
									
								
								InvenTree/common/migrations/0018_projectcode.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								InvenTree/common/migrations/0018_projectcode.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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')), | ||||
|             ], | ||||
|         ), | ||||
|     ] | ||||
							
								
								
									
										18
									
								
								InvenTree/common/migrations/0019_projectcode_metadata.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								InvenTree/common/migrations/0019_projectcode_metadata.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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'), | ||||
|         ), | ||||
|     ] | ||||
| @@ -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'), | ||||
|   | ||||
| @@ -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' | ||||
|         ] | ||||
|   | ||||
| @@ -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 | ||||
|         ) | ||||
|   | ||||
| @@ -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): | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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: | ||||
|   | ||||
| @@ -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' | ||||
|   | ||||
							
								
								
									
										30
									
								
								InvenTree/order/migrations/0092_auto_20230419_0250.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								InvenTree/order/migrations/0092_auto_20230419_0250.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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'), | ||||
|         ), | ||||
|     ] | ||||
| @@ -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( | ||||
|   | ||||
| @@ -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', | ||||
|   | ||||
| @@ -115,6 +115,7 @@ src="{% static 'img/blank_image.png' %}" | ||||
|         <td>{% trans "Order Description" %}</td> | ||||
|         <td>{{ order.description }}{% include "clip.html" %}</td> | ||||
|     </tr> | ||||
|     {% include "project_code_data.html" with instance=order %} | ||||
|     {% include "barcode_data.html" with instance=order %} | ||||
|     <tr> | ||||
|         <td><span class='fas fa-info'></span></td> | ||||
|   | ||||
| @@ -107,9 +107,8 @@ src="{% static 'img/blank_image.png' %}" | ||||
|         <td>{% trans "Order Description" %}</td> | ||||
|         <td>{{ order.description }}{% include "clip.html" %}</td> | ||||
|     </tr> | ||||
|  | ||||
|     {% include "project_code_data.html" with instance=order %} | ||||
|     {% include "barcode_data.html" with instance=order %} | ||||
|  | ||||
|     <tr> | ||||
|         <td><span class='fas fa-info'></span></td> | ||||
|         <td>{% trans "Order Status" %}</td> | ||||
|   | ||||
| @@ -112,6 +112,7 @@ src="{% static 'img/blank_image.png' %}" | ||||
|         <td>{% trans "Order Description" %}</td> | ||||
|         <td>{{ order.description }}{% include "clip.html" %}</td> | ||||
|     </tr> | ||||
|     {% include "project_code_data.html" with instance=order %} | ||||
|     {% include "barcode_data.html" with instance=order %} | ||||
|     <tr> | ||||
|         <td><span class='fas fa-info'></span></td> | ||||
|   | ||||
| @@ -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") | ||||
|   | ||||
| @@ -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. | ||||
|  | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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 | ||||
|  | ||||
|  | ||||
|   | ||||
							
								
								
									
										35
									
								
								InvenTree/templates/InvenTree/settings/project_codes.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								InvenTree/templates/InvenTree/settings/project_codes.html
									
									
									
									
									
										Normal file
									
								
							| @@ -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 %} | ||||
|  | ||||
| <!-- Project code settings --> | ||||
| <table class='table table-striped table-condensed'> | ||||
|     <tbody> | ||||
|         {% include "InvenTree/settings/setting.html" with key="PROJECT_CODES_ENABLED" icon='fa-toggle-on' %} | ||||
|     </tbody> | ||||
| </table> | ||||
|  | ||||
| <div class='panel-heading'> | ||||
|     <div class='d-flex flex-span'> | ||||
|         <h4>{% trans "Project Codes" %}</h4> | ||||
|         {% include "spacer.html" %} | ||||
|         <div class='btn-group' role='group'> | ||||
|             <button class='btn btn-success' id='new-project-code'> | ||||
|                 <span class='fas fa-plus-circle'></span> {% trans "New Project Code" %} | ||||
|             </button> | ||||
|         </div> | ||||
|     </div> | ||||
| </div> | ||||
|  | ||||
| <table class='table table-striped table-condensed' id='project-code-table'> | ||||
| </table> | ||||
| {% endblock content %} | ||||
| @@ -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" %} | ||||
|   | ||||
| @@ -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 = "<button title='{% trans "Edit Template" %}' class='template-edit btn btn-outline-secondary' type='button' pk='" + row.pk + "'><span class='fas fa-edit'></span></button>"; | ||||
|                     var bDel = "<button title='{% trans "Delete Template" %}' class='template-delete btn btn-outline-secondary' type='button' pk='" + row.pk + "'><span class='fas fa-trash-alt icon-red'></span></button>"; | ||||
|                     let buttons = ''; | ||||
|                     buttons += makeEditButton('template-edit', row.pk, '{% trans "Edit Template" %}'); | ||||
|                     buttons += makeDeleteButton('template-delete', row.pk, '{% trans "Delete Template" %}'); | ||||
|  | ||||
|                     var html = value | ||||
|                     html += "<div class='btn-group float-right' role='group'>" + bEdit + bDel + "</div>"; | ||||
|                     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: { | ||||
|   | ||||
| @@ -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 %} | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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 | ||||
|     ); | ||||
| } | ||||
|   | ||||
| @@ -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 `<span title='${row.project_code_detail.description}'>${row.project_code_detail.code}</span>`; | ||||
|                     } | ||||
|                 } | ||||
|             }, | ||||
|             { | ||||
|                 field: 'status', | ||||
|                 title: '{% trans "Status" %}', | ||||
|   | ||||
| @@ -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 `<span title='${row.project_code_detail.description}'>${row.project_code_detail.code}</span>`; | ||||
|                     } | ||||
|                 } | ||||
|             }, | ||||
|             { | ||||
|                 sortable: true, | ||||
|                 field: 'status', | ||||
|   | ||||
| @@ -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 `<span title='${row.project_code_detail.description}'>${row.project_code_detail.code}</span>`; | ||||
|                     } | ||||
|                 } | ||||
|             }, | ||||
|             { | ||||
|                 sortable: true, | ||||
|                 field: 'status', | ||||
|   | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										11
									
								
								InvenTree/templates/project_code_data.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								InvenTree/templates/project_code_data.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| {% load i18n %} | ||||
|  | ||||
| {% if instance and instance.project_code %} | ||||
| <tr> | ||||
|     <td><span class='fas fa-list'></span></td> | ||||
|     <td>{% trans "Project Code" %}</td> | ||||
|     <td> | ||||
|         {{ instance.project_code.code }} - <em><small>{{ instance.project_code.description }}</small></em> | ||||
|     </td> | ||||
| </tr> | ||||
| {% endif %} | ||||
| @@ -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 | ||||
|   | ||||
		Reference in New Issue
	
	Block a user