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:
Oliver 2023-04-20 00:47:07 +10:00 committed by GitHub
parent eafd2ac966
commit 070e2afcea
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 1315 additions and 683 deletions

View File

@ -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

View File

@ -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)

View File

@ -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 = []

View File

@ -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)

View File

@ -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)'),
)

View File

@ -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):

View File

@ -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})')

View File

@ -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'),

View 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')),
],
),
]

View 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'),
),
]

View File

@ -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'),

View File

@ -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'
]

View File

@ -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
)

View File

@ -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):

View File

@ -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

View File

@ -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:

View File

@ -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'

View 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'),
),
]

View File

@ -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(

View File

@ -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',

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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")

View File

@ -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.

View File

@ -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

View File

@ -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

View 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 %}

View File

@ -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" %}

View File

@ -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: {

View File

@ -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 %}

View File

@ -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,

View File

@ -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
);
}

View File

@ -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" %}',

View File

@ -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',

View File

@ -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

View 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 %}

View File

@ -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