diff --git a/.github/actions/migration/action.yaml b/.github/actions/migration/action.yaml index a5c4c7a56f..22c5d45864 100644 --- a/.github/actions/migration/action.yaml +++ b/.github/actions/migration/action.yaml @@ -13,5 +13,5 @@ runs: invoke export-records -f data.json python3 ./src/backend/InvenTree/manage.py flush --noinput invoke migrate - invoke import-records -f data.json - invoke import-records -f data.json + invoke import-records -c -f data.json + invoke import-records -c -f data.json diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index 7e177aa3d6..565df81356 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -115,9 +115,10 @@ jobs: - name: Run Unit Tests run: | echo "GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}" >> contrib/container/docker.dev.env - docker compose --project-directory . -f contrib/container/dev-docker-compose.yml run inventree-dev-server invoke test --disable-pty - docker compose --project-directory . -f contrib/container/dev-docker-compose.yml run inventree-dev-server invoke test --migrations --disable-pty - docker compose --project-directory . -f contrib/container/dev-docker-compose.yml down + docker compose --project-directory . -f contrib/container/dev-docker-compose.yml run --rm inventree-dev-server invoke test --disable-pty + - name: Run Migration Tests + run: | + docker compose --project-directory . -f contrib/container/dev-docker-compose.yml run --rm inventree-dev-server invoke test --migrations - name: Clean up test folder run: | rm -rf InvenTree/_testfolder diff --git a/src/backend/InvenTree/InvenTree/api.py b/src/backend/InvenTree/InvenTree/api.py index c3c0db4af2..dd5bca8243 100644 --- a/src/backend/InvenTree/InvenTree/api.py +++ b/src/backend/InvenTree/InvenTree/api.py @@ -419,22 +419,6 @@ class APIDownloadMixin: raise NotImplementedError('download_queryset method not implemented!') -class AttachmentMixin: - """Mixin for creating attachment objects, and ensuring the user information is saved correctly.""" - - permission_classes = [permissions.IsAuthenticated, RolePermission] - - filter_backends = SEARCH_ORDER_FILTER - - search_fields = ['attachment', 'comment', 'link'] - - def perform_create(self, serializer): - """Save the user information when a file is uploaded.""" - attachment = serializer.save() - attachment.user = self.request.user - attachment.save() - - class APISearchViewSerializer(serializers.Serializer): """Serializer for the APISearchView.""" diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index cd371ed20a..04b82cf536 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,16 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 206 +INVENTREE_API_VERSION = 207 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v207 - 2024-06-09 : https://github.com/inventree/InvenTree/pull/7420 + - Moves all "Attachment" models into a single table + - All "Attachment" operations are now performed at /api/attachment/ + - Add permissions information to /api/user/roles/ endpoint + v206 - 2024-06-08 : https://github.com/inventree/InvenTree/pull/7417 - Adds "choices" field to the PartTestTemplate model diff --git a/src/backend/InvenTree/InvenTree/exchange.py b/src/backend/InvenTree/InvenTree/exchange.py index 2b118b696e..ab02883058 100644 --- a/src/backend/InvenTree/InvenTree/exchange.py +++ b/src/backend/InvenTree/InvenTree/exchange.py @@ -8,6 +8,7 @@ from djmoney.contrib.exchange.backends.base import SimpleExchangeBackend from djmoney.contrib.exchange.models import ExchangeBackend, Rate from common.currency import currency_code_default, currency_codes +from common.settings import get_global_setting logger = logging.getLogger('inventree') @@ -22,14 +23,13 @@ class InvenTreeExchange(SimpleExchangeBackend): def get_rates(self, **kwargs) -> dict: """Set the requested currency codes and get rates.""" - from common.models import InvenTreeSetting from plugin import registry base_currency = kwargs.get('base_currency', currency_code_default()) symbols = kwargs.get('symbols', currency_codes()) # Find the selected exchange rate plugin - slug = InvenTreeSetting.get_setting('CURRENCY_UPDATE_PLUGIN', '', create=False) + slug = get_global_setting('CURRENCY_UPDATE_PLUGIN', create=False) if slug: plugin = registry.get_plugin(slug) diff --git a/src/backend/InvenTree/InvenTree/fields.py b/src/backend/InvenTree/InvenTree/fields.py index b1b276abb1..01efe65ae1 100644 --- a/src/backend/InvenTree/InvenTree/fields.py +++ b/src/backend/InvenTree/InvenTree/fields.py @@ -33,7 +33,7 @@ class InvenTreeRestURLField(RestURLField): def run_validation(self, data=empty): """Override default validation behaviour for this field type.""" - strict_urls = get_global_setting('INVENTREE_STRICT_URLS', True, cache=False) + strict_urls = get_global_setting('INVENTREE_STRICT_URLS', cache=False) if not strict_urls and data is not empty and '://' not in data: # Validate as if there were a schema provided diff --git a/src/backend/InvenTree/InvenTree/middleware.py b/src/backend/InvenTree/InvenTree/middleware.py index d5463af22e..5790c8c9b9 100644 --- a/src/backend/InvenTree/InvenTree/middleware.py +++ b/src/backend/InvenTree/InvenTree/middleware.py @@ -12,6 +12,7 @@ from django.urls import Resolver404, include, path, resolve, reverse_lazy from allauth_2fa.middleware import AllauthTwoFactorMiddleware, BaseRequire2FAMiddleware from error_report.middleware import ExceptionProcessor +from common.settings import get_global_setting from InvenTree.urls import frontendpatterns from users.models import ApiToken @@ -153,11 +154,9 @@ class Check2FAMiddleware(BaseRequire2FAMiddleware): def require_2fa(self, request): """Use setting to check if MFA should be enforced for frontend page.""" - from common.models import InvenTreeSetting - try: if url_matcher.resolve(request.path[1:]): - return InvenTreeSetting.get_setting('LOGIN_ENFORCE_MFA') + return get_global_setting('LOGIN_ENFORCE_MFA') except Resolver404: pass return False diff --git a/src/backend/InvenTree/InvenTree/models.py b/src/backend/InvenTree/InvenTree/models.py index 0e2b4ca9e9..3437290a68 100644 --- a/src/backend/InvenTree/InvenTree/models.py +++ b/src/backend/InvenTree/InvenTree/models.py @@ -1,9 +1,7 @@ """Generic models which provide extra functionality over base Django model types.""" import logging -import os from datetime import datetime -from io import BytesIO from django.conf import settings from django.contrib.auth import get_user_model @@ -20,11 +18,11 @@ from error_report.models import Error from mptt.exceptions import InvalidMove from mptt.models import MPTTModel, TreeForeignKey +import common.settings import InvenTree.fields import InvenTree.format import InvenTree.helpers import InvenTree.helpers_model -from InvenTree.sanitizer import sanitize_svg logger = logging.getLogger('inventree') @@ -304,10 +302,7 @@ class ReferenceIndexingMixin(models.Model): if cls.REFERENCE_PATTERN_SETTING is None: return '' - # import at function level to prevent cyclic imports - from common.models import InvenTreeSetting - - return InvenTreeSetting.get_setting( + return common.settings.get_global_setting( cls.REFERENCE_PATTERN_SETTING, create=False ).strip() @@ -503,200 +498,64 @@ class InvenTreeMetadataModel(MetadataMixin, InvenTreeModel): abstract = True -def rename_attachment(instance, filename): - """Function for renaming an attachment file. The subdirectory for the uploaded file is determined by the implementing class. - - Args: - instance: Instance of a PartAttachment object - filename: name of uploaded file - - Returns: - path to store file, format: '//filename' - """ - # Construct a path to store a file attachment for a given model type - return os.path.join(instance.getSubdir(), filename) - - -class InvenTreeAttachment(InvenTreeModel): +class InvenTreeAttachmentMixin: """Provides an abstracted class for managing file attachments. - An attachment can be either an uploaded file, or an external URL + Links the implementing model to the common.models.Attachment table, + and provides the following methods: - Attributes: - attachment: Upload file - link: External URL - comment: String descriptor for the attachment - user: User associated with file upload - upload_date: Date the file was uploaded + - attachments: Return a queryset containing all attachments for this model """ - class Meta: - """Metaclass options. Abstract ensures no database table is created.""" + def delete(self): + """Handle the deletion of a model instance. - abstract = True - - def getSubdir(self): - """Return the subdirectory under which attachments should be stored. - - Note: Re-implement this for each subclass of InvenTreeAttachment + Before deleting the model instance, delete any associated attachments. """ - return 'attachments' - - def save(self, *args, **kwargs): - """Provide better validation error.""" - # Either 'attachment' or 'link' must be specified! - if not self.attachment and not self.link: - raise ValidationError({ - 'attachment': _('Missing file'), - 'link': _('Missing external link'), - }) - - if self.attachment and self.attachment.name.lower().endswith('.svg'): - self.attachment.file.file = self.clean_svg(self.attachment) - - super().save(*args, **kwargs) - - def clean_svg(self, field): - """Sanitize SVG file before saving.""" - cleaned = sanitize_svg(field.file.read()) - return BytesIO(bytes(cleaned, 'utf8')) - - def __str__(self): - """Human name for attachment.""" - if self.attachment is not None: - return os.path.basename(self.attachment.name) - return str(self.link) - - attachment = models.FileField( - upload_to=rename_attachment, - verbose_name=_('Attachment'), - help_text=_('Select file to attach'), - blank=True, - null=True, - ) - - link = InvenTree.fields.InvenTreeURLField( - blank=True, - null=True, - verbose_name=_('Link'), - help_text=_('Link to external URL'), - ) - - comment = models.CharField( - blank=True, - max_length=100, - verbose_name=_('Comment'), - help_text=_('File comment'), - ) - - user = models.ForeignKey( - User, - on_delete=models.SET_NULL, - blank=True, - null=True, - verbose_name=_('User'), - help_text=_('User'), - ) - - upload_date = models.DateField( - auto_now_add=True, null=True, blank=True, verbose_name=_('upload date') - ) + self.attachments.all().delete() + super().delete() @property - def basename(self): - """Base name/path for attachment.""" - if self.attachment: - return os.path.basename(self.attachment.name) - return None + def attachments(self): + """Return a queryset containing all attachments for this model.""" + return self.attachments_for_model().filter(model_id=self.pk) - @basename.setter - def basename(self, fn): - """Function to rename the attachment file. + @classmethod + def check_attachment_permission(cls, permission, user) -> bool: + """Check if the user has permission to perform the specified action on the attachment. - - Filename cannot be empty - - Filename cannot contain illegal characters - - Filename must specify an extension - - Filename cannot match an existing file + The default implementation runs a permission check against *this* model class, + but this can be overridden in the implementing class if required. + + Arguments: + permission: The permission to check (add / change / view / delete) + user: The user to check against + + Returns: + bool: True if the user has permission, False otherwise """ - fn = fn.strip() + perm = f'{cls._meta.app_label}.{permission}_{cls._meta.model_name}' + return user.has_perm(perm) - if len(fn) == 0: - raise ValidationError(_('Filename must not be empty')) + def attachments_for_model(self): + """Return all attachments for this model class.""" + from common.models import Attachment - attachment_dir = settings.MEDIA_ROOT.joinpath(self.getSubdir()) - old_file = settings.MEDIA_ROOT.joinpath(self.attachment.name) - new_file = settings.MEDIA_ROOT.joinpath(self.getSubdir(), fn).resolve() + model_type = self.__class__.__name__.lower() - # Check that there are no directory tricks going on... - if new_file.parent != attachment_dir: - logger.error( - "Attempted to rename attachment outside valid directory: '%s'", new_file - ) - raise ValidationError(_('Invalid attachment directory')) + return Attachment.objects.filter(model_type=model_type) - # Ignore further checks if the filename is not actually being renamed - if new_file == old_file: - return + def create_attachment(self, attachment=None, link=None, comment='', **kwargs): + """Create an attachment / link for this model.""" + from common.models import Attachment - forbidden = [ - "'", - '"', - '#', - '@', - '!', - '&', - '^', - '<', - '>', - ':', - ';', - '/', - '\\', - '|', - '?', - '*', - '%', - '~', - '`', - ] + kwargs['attachment'] = attachment + kwargs['link'] = link + kwargs['comment'] = comment + kwargs['model_type'] = self.__class__.__name__.lower() + kwargs['model_id'] = self.pk - for c in forbidden: - if c in fn: - raise ValidationError(_(f"Filename contains illegal character '{c}'")) - - if len(fn.split('.')) < 2: - raise ValidationError(_('Filename missing extension')) - - if not old_file.exists(): - logger.error( - "Trying to rename attachment '%s' which does not exist", old_file - ) - return - - if new_file.exists(): - raise ValidationError(_('Attachment with this filename already exists')) - - try: - os.rename(old_file, new_file) - self.attachment.name = os.path.join(self.getSubdir(), fn) - self.save() - except Exception: - raise ValidationError(_('Error renaming file')) - - def fully_qualified_url(self): - """Return a 'fully qualified' URL for this attachment. - - - If the attachment is a link to an external resource, return the link - - If the attachment is an uploaded file, return the fully qualified media URL - """ - if self.link: - return self.link - - if self.attachment: - media_url = InvenTree.helpers.getMediaUrl(self.attachment.url) - return InvenTree.helpers_model.construct_absolute_url(media_url) - - return '' + Attachment.objects.create(**kwargs) class InvenTreeTree(MetadataMixin, PluginValidationMixin, MPTTModel): diff --git a/src/backend/InvenTree/InvenTree/serializers.py b/src/backend/InvenTree/InvenTree/serializers.py index 1ee69b403a..dd906f42c9 100644 --- a/src/backend/InvenTree/InvenTree/serializers.py +++ b/src/backend/InvenTree/InvenTree/serializers.py @@ -509,43 +509,6 @@ class InvenTreeAttachmentSerializerField(serializers.FileField): return os.path.join(str(settings.MEDIA_URL), str(value)) -class InvenTreeAttachmentSerializer(InvenTreeModelSerializer): - """Special case of an InvenTreeModelSerializer, which handles an "attachment" model. - - The only real addition here is that we support "renaming" of the attachment file. - """ - - @staticmethod - def attachment_fields(extra_fields=None): - """Default set of fields for an attachment serializer.""" - fields = [ - 'pk', - 'attachment', - 'filename', - 'link', - 'comment', - 'upload_date', - 'user', - 'user_detail', - ] - - if extra_fields: - fields += extra_fields - - return fields - - user_detail = UserSerializer(source='user', read_only=True, many=False) - - attachment = InvenTreeAttachmentSerializerField(required=False, allow_null=False) - - # The 'filename' field must be present in the serializer - filename = serializers.CharField( - label=_('Filename'), required=False, source='basename', allow_blank=False - ) - - upload_date = serializers.DateField(read_only=True) - - class InvenTreeImageSerializerField(serializers.ImageField): """Custom image serializer. diff --git a/src/backend/InvenTree/InvenTree/settings.py b/src/backend/InvenTree/InvenTree/settings.py index 7aed4489a5..69698d140d 100644 --- a/src/backend/InvenTree/InvenTree/settings.py +++ b/src/backend/InvenTree/InvenTree/settings.py @@ -296,6 +296,7 @@ ADMIN_SHELL_IMPORT_MODELS = False if ( DEBUG and INVENTREE_ADMIN_ENABLED + and not TESTING and get_boolean_setting('INVENTREE_DEBUG_SHELL', 'debug_shell', False) ): # noqa try: diff --git a/src/backend/InvenTree/InvenTree/unit_test.py b/src/backend/InvenTree/InvenTree/unit_test.py index aee89660f3..f72654f730 100644 --- a/src/backend/InvenTree/InvenTree/unit_test.py +++ b/src/backend/InvenTree/InvenTree/unit_test.py @@ -152,6 +152,17 @@ class UserMixin: """Lougout current user.""" self.client.logout() + @classmethod + def clearRoles(cls): + """Remove all user roles from the registered user.""" + for ruleset in cls.group.rule_sets.all(): + ruleset.can_view = False + ruleset.can_change = False + ruleset.can_delete = False + ruleset.can_add = False + + ruleset.save() + @classmethod def assignRole(cls, role=None, assign_all: bool = False, group=None): """Set the user roles for the registered user. @@ -267,7 +278,7 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase): f'Query count exceeded at {url}: Expected < {value} queries, got {n}' ) # pragma: no cover - if verbose: + if verbose or n >= value: msg = '\r\n%s' % json.dumps( context.captured_queries, indent=4 ) # pragma: no cover @@ -296,7 +307,7 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase): if hasattr(response, 'content'): print('content:', response.content) - self.assertEqual(expected_code, response.status_code) + self.assertEqual(response.status_code, expected_code) def getActions(self, url): """Return a dict of the 'actions' available at a given endpoint. @@ -314,17 +325,17 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase): if data is None: data = {} - expected_code = kwargs.pop('expected_code', None) - kwargs['format'] = kwargs.get('format', 'json') - max_queries = kwargs.get('max_query_count', self.MAX_QUERY_COUNT) - max_query_time = kwargs.get('max_query_time', self.MAX_QUERY_TIME) + expected_code = kwargs.pop('expected_code', None) + max_queries = kwargs.pop('max_query_count', self.MAX_QUERY_COUNT) + max_query_time = kwargs.pop('max_query_time', self.MAX_QUERY_TIME) t1 = time.time() with self.assertNumQueriesLessThan(max_queries, url=url): response = method(url, data, **kwargs) + t2 = time.time() dt = t2 - t1 diff --git a/src/backend/InvenTree/InvenTree/validators.py b/src/backend/InvenTree/InvenTree/validators.py index d01d79f633..f2a85bc18f 100644 --- a/src/backend/InvenTree/InvenTree/validators.py +++ b/src/backend/InvenTree/InvenTree/validators.py @@ -13,6 +13,7 @@ from jinja2 import Template from moneyed import CURRENCIES import InvenTree.conversion +from common.settings import get_global_setting def validate_physical_units(unit): @@ -63,14 +64,10 @@ class AllowedURLValidator(validators.URLValidator): def __call__(self, value): """Validate the URL.""" - import common.models - self.schemes = allowable_url_schemes() # Determine if 'strict' URL validation is required (i.e. if the URL must have a schema prefix) - strict_urls = common.models.InvenTreeSetting.get_setting( - 'INVENTREE_STRICT_URLS', True, cache=False - ) + strict_urls = get_global_setting('INVENTREE_STRICT_URLS', cache=False) if not strict_urls: # Allow URLs which do not have a provided schema diff --git a/src/backend/InvenTree/InvenTree/version.py b/src/backend/InvenTree/InvenTree/version.py index 6c67752cb5..a3c110bf4c 100644 --- a/src/backend/InvenTree/InvenTree/version.py +++ b/src/backend/InvenTree/InvenTree/version.py @@ -53,13 +53,13 @@ def checkMinPythonVersion(): def inventreeInstanceName(): """Returns the InstanceName settings for the current database.""" - return get_global_setting('INVENTREE_INSTANCE', '') + return get_global_setting('INVENTREE_INSTANCE') def inventreeInstanceTitle(): """Returns the InstanceTitle for the current database.""" - if get_global_setting('INVENTREE_INSTANCE_TITLE', False): - return get_global_setting('INVENTREE_INSTANCE', 'InvenTree') + if get_global_setting('INVENTREE_INSTANCE_TITLE'): + return get_global_setting('INVENTREE_INSTANCE') return 'InvenTree' diff --git a/src/backend/InvenTree/build/api.py b/src/backend/InvenTree/build/api.py index dd0e3bb6b6..57c66a4182 100644 --- a/src/backend/InvenTree/build/api.py +++ b/src/backend/InvenTree/build/api.py @@ -11,7 +11,7 @@ from rest_framework.exceptions import ValidationError from django_filters.rest_framework import DjangoFilterBackend from django_filters import rest_framework as rest_filters -from InvenTree.api import AttachmentMixin, APIDownloadMixin, ListCreateDestroyAPIView, MetadataView +from InvenTree.api import APIDownloadMixin, MetadataView from generic.states.api import StatusView from InvenTree.helpers import str2bool, isNull, DownloadFile from build.status_codes import BuildStatus, BuildStatusGroups @@ -20,7 +20,7 @@ from InvenTree.mixins import CreateAPI, RetrieveUpdateDestroyAPI, ListCreateAPI import common.models import build.admin import build.serializers -from build.models import Build, BuildLine, BuildItem, BuildOrderAttachment +from build.models import Build, BuildLine, BuildItem import part.models from users.models import Owner from InvenTree.filters import SEARCH_ORDER_FILTER_ALIAS @@ -614,32 +614,8 @@ class BuildItemList(ListCreateAPI): ] -class BuildAttachmentList(AttachmentMixin, ListCreateDestroyAPIView): - """API endpoint for listing (and creating) BuildOrderAttachment objects.""" - - queryset = BuildOrderAttachment.objects.all() - serializer_class = build.serializers.BuildAttachmentSerializer - - filterset_fields = [ - 'build', - ] - - -class BuildAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI): - """Detail endpoint for a BuildOrderAttachment object.""" - - queryset = BuildOrderAttachment.objects.all() - serializer_class = build.serializers.BuildAttachmentSerializer - - build_api_urls = [ - # Attachments - path('attachment/', include([ - path('/', BuildAttachmentDetail.as_view(), name='api-build-attachment-detail'), - path('', BuildAttachmentList.as_view(), name='api-build-attachment-list'), - ])), - # Build lines path('line/', include([ path('/', BuildLineDetail.as_view(), name='api-build-line-detail'), diff --git a/src/backend/InvenTree/build/migrations/0022_buildorderattachment.py b/src/backend/InvenTree/build/migrations/0022_buildorderattachment.py index 0256649027..47ecbf7f98 100644 --- a/src/backend/InvenTree/build/migrations/0022_buildorderattachment.py +++ b/src/backend/InvenTree/build/migrations/0022_buildorderattachment.py @@ -18,7 +18,7 @@ class Migration(migrations.Migration): name='BuildOrderAttachment', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('attachment', models.FileField(help_text='Select file to attach', upload_to=InvenTree.models.rename_attachment)), + ('attachment', models.FileField(help_text='Select file to attach', upload_to='attachments')), ('comment', models.CharField(blank=True, help_text='File comment', max_length=100)), ('upload_date', models.DateField(auto_now_add=True, null=True)), ('build', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='build.Build')), diff --git a/src/backend/InvenTree/build/migrations/0027_auto_20210404_2016.py b/src/backend/InvenTree/build/migrations/0027_auto_20210404_2016.py index f4a2c1afde..6d34eae5b5 100644 --- a/src/backend/InvenTree/build/migrations/0027_auto_20210404_2016.py +++ b/src/backend/InvenTree/build/migrations/0027_auto_20210404_2016.py @@ -65,7 +65,7 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='buildorderattachment', name='attachment', - field=models.FileField(help_text='Select file to attach', upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'), + field=models.FileField(help_text='Select file to attach', upload_to='attachments', verbose_name='Attachment'), ), migrations.AlterField( model_name='buildorderattachment', diff --git a/src/backend/InvenTree/build/migrations/0033_auto_20211128_0151.py b/src/backend/InvenTree/build/migrations/0033_auto_20211128_0151.py index db8df848ce..5558fe8973 100644 --- a/src/backend/InvenTree/build/migrations/0033_auto_20211128_0151.py +++ b/src/backend/InvenTree/build/migrations/0033_auto_20211128_0151.py @@ -20,6 +20,6 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='buildorderattachment', name='attachment', - field=models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'), + field=models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to='attachments', verbose_name='Attachment'), ), ] diff --git a/src/backend/InvenTree/build/migrations/0051_delete_buildorderattachment.py b/src/backend/InvenTree/build/migrations/0051_delete_buildorderattachment.py new file mode 100644 index 0000000000..d600bd240f --- /dev/null +++ b/src/backend/InvenTree/build/migrations/0051_delete_buildorderattachment.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.12 on 2024-06-09 09:02 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('build', '0050_auto_20240508_0138'), + ('common', '0026_auto_20240608_1238'), + ('company', '0069_company_active'), + ('order', '0099_alter_salesorder_status'), + ('part', '0123_parttesttemplate_choices'), + ('stock', '0110_alter_stockitemtestresult_finished_datetime_and_more') + ] + + operations = [ + migrations.DeleteModel( + name='BuildOrderAttachment', + ), + ] diff --git a/src/backend/InvenTree/build/models.py b/src/backend/InvenTree/build/models.py index 31bf282d59..7430eb4a29 100644 --- a/src/backend/InvenTree/build/models.py +++ b/src/backend/InvenTree/build/models.py @@ -50,6 +50,7 @@ logger = logging.getLogger('inventree') class Build( report.mixins.InvenTreeReportMixin, + InvenTree.models.InvenTreeAttachmentMixin, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNotesMixin, InvenTree.models.MetadataMixin, @@ -1322,16 +1323,6 @@ def after_save_build(sender, instance: Build, created: bool, **kwargs): instance.update_build_line_items() -class BuildOrderAttachment(InvenTree.models.InvenTreeAttachment): - """Model for storing file attachments against a BuildOrder object.""" - - def getSubdir(self): - """Return the media file subdirectory for storing BuildOrder attachments""" - return os.path.join('bo_files', str(self.build.id)) - - build = models.ForeignKey(Build, on_delete=models.CASCADE, related_name='attachments') - - class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeModel): """A BuildLine object links a BOMItem to a Build. diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py index 4be7409fde..fe33f0a729 100644 --- a/src/backend/InvenTree/build/serializers.py +++ b/src/backend/InvenTree/build/serializers.py @@ -13,8 +13,7 @@ from django.db.models.functions import Coalesce from rest_framework import serializers from rest_framework.serializers import ValidationError -from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer -from InvenTree.serializers import UserSerializer +from InvenTree.serializers import InvenTreeModelSerializer, UserSerializer import InvenTree.helpers from InvenTree.serializers import InvenTreeDecimalField, NotesFieldMixin @@ -30,7 +29,7 @@ import part.filters from part.serializers import BomItemSerializer, PartSerializer, PartBriefSerializer from users.serializers import OwnerSerializer -from .models import Build, BuildLine, BuildItem, BuildOrderAttachment +from .models import Build, BuildLine, BuildItem class BuildSerializer(NotesFieldMixin, InvenTreeModelSerializer): @@ -1311,15 +1310,3 @@ class BuildLineSerializer(InvenTreeModelSerializer): ) return queryset - - -class BuildAttachmentSerializer(InvenTreeAttachmentSerializer): - """Serializer for a BuildAttachment.""" - - class Meta: - """Serializer metaclass""" - model = BuildOrderAttachment - - fields = InvenTreeAttachmentSerializer.attachment_fields([ - 'build', - ]) diff --git a/src/backend/InvenTree/build/templates/build/detail.html b/src/backend/InvenTree/build/templates/build/detail.html index 138f0a14d4..7daa1dc218 100644 --- a/src/backend/InvenTree/build/templates/build/detail.html +++ b/src/backend/InvenTree/build/templates/build/detail.html @@ -326,18 +326,7 @@ onPanelLoad('children', function() { }); onPanelLoad('attachments', function() { - - loadAttachmentTable('{% url "api-build-attachment-list" %}', { - filters: { - build: {{ build.pk }}, - }, - fields: { - build: { - value: {{ build.pk }}, - hidden: true, - } - } - }); + loadAttachmentTable('build', {{ build.pk }}); }); onPanelLoad('notes', function() { diff --git a/src/backend/InvenTree/build/test_migrations.py b/src/backend/InvenTree/build/test_migrations.py index dba739764a..4a0c720e5d 100644 --- a/src/backend/InvenTree/build/test_migrations.py +++ b/src/backend/InvenTree/build/test_migrations.py @@ -19,7 +19,6 @@ class TestForwardMigrations(MigratorTestCase): name='Widget', description='Buildable Part', active=True, - level=0, lft=0, rght=0, tree_id=0, ) Build = self.old_state.apps.get_model('build', 'build') @@ -61,7 +60,6 @@ class TestReferenceMigration(MigratorTestCase): part = Part.objects.create( name='Part', description='A test part', - level=0, lft=0, rght=0, tree_id=0, ) Build = self.old_state.apps.get_model('build', 'build') diff --git a/src/backend/InvenTree/common/admin.py b/src/backend/InvenTree/common/admin.py index 9dd3a05018..a0719f9ab4 100644 --- a/src/backend/InvenTree/common/admin.py +++ b/src/backend/InvenTree/common/admin.py @@ -5,6 +5,34 @@ from django.contrib import admin from import_export.admin import ImportExportModelAdmin import common.models +import common.validators + + +@admin.register(common.models.Attachment) +class AttachmentAdmin(admin.ModelAdmin): + """Admin interface for Attachment objects.""" + + def formfield_for_dbfield(self, db_field, request, **kwargs): + """Provide custom choices for 'model_type' field.""" + if db_field.name == 'model_type': + db_field.choices = common.validators.attachment_model_options() + + return super().formfield_for_dbfield(db_field, request, **kwargs) + + list_display = ( + 'model_type', + 'model_id', + 'attachment', + 'link', + 'upload_user', + 'upload_date', + ) + + list_filter = ['model_type', 'upload_user'] + + readonly_fields = ['file_size', 'upload_date', 'upload_user'] + + search_fields = ('content_type', 'comment') @admin.register(common.models.ProjectCode) @@ -16,6 +44,7 @@ class ProjectCodeAdmin(ImportExportModelAdmin): search_fields = ('code', 'description') +@admin.register(common.models.InvenTreeSetting) class SettingsAdmin(ImportExportModelAdmin): """Admin settings for InvenTreeSetting.""" @@ -28,6 +57,7 @@ class SettingsAdmin(ImportExportModelAdmin): return [] +@admin.register(common.models.InvenTreeUserSetting) class UserSettingsAdmin(ImportExportModelAdmin): """Admin settings for InvenTreeUserSetting.""" @@ -40,18 +70,21 @@ class UserSettingsAdmin(ImportExportModelAdmin): return [] +@admin.register(common.models.WebhookEndpoint) class WebhookAdmin(ImportExportModelAdmin): """Admin settings for Webhook.""" list_display = ('endpoint_id', 'name', 'active', 'user') +@admin.register(common.models.NotificationEntry) class NotificationEntryAdmin(admin.ModelAdmin): """Admin settings for NotificationEntry.""" list_display = ('key', 'uid', 'updated') +@admin.register(common.models.NotificationMessage) class NotificationMessageAdmin(admin.ModelAdmin): """Admin settings for NotificationMessage.""" @@ -70,16 +103,11 @@ class NotificationMessageAdmin(admin.ModelAdmin): search_fields = ('name', 'category', 'message') +@admin.register(common.models.NewsFeedEntry) class NewsFeedEntryAdmin(admin.ModelAdmin): """Admin settings for NewsFeedEntry.""" list_display = ('title', 'author', 'published', 'summary') -admin.site.register(common.models.InvenTreeSetting, SettingsAdmin) -admin.site.register(common.models.InvenTreeUserSetting, UserSettingsAdmin) -admin.site.register(common.models.WebhookEndpoint, WebhookAdmin) admin.site.register(common.models.WebhookMessage, ImportExportModelAdmin) -admin.site.register(common.models.NotificationEntry, NotificationEntryAdmin) -admin.site.register(common.models.NotificationMessage, NotificationMessageAdmin) -admin.site.register(common.models.NewsFeedEntry, NewsFeedEntryAdmin) diff --git a/src/backend/InvenTree/common/api.py b/src/backend/InvenTree/common/api.py index 6965b21ec8..9819543a5b 100644 --- a/src/backend/InvenTree/common/api.py +++ b/src/backend/InvenTree/common/api.py @@ -4,18 +4,21 @@ import json from django.conf import settings from django.contrib.contenttypes.models import ContentType +from django.db.models import Q from django.http.response import HttpResponse from django.urls import include, path, re_path from django.utils.decorators import method_decorator +from django.utils.translation import gettext_lazy as _ from django.views.decorators.csrf import csrf_exempt import django_q.models +from django_filters import rest_framework as rest_filters from django_q.tasks import async_task from djmoney.contrib.exchange.models import ExchangeBackend, Rate from drf_spectacular.utils import OpenApiResponse, extend_schema from error_report.models import Error from rest_framework import permissions, serializers -from rest_framework.exceptions import NotAcceptable, NotFound +from rest_framework.exceptions import NotAcceptable, NotFound, PermissionDenied from rest_framework.permissions import IsAdminUser from rest_framework.response import Response from rest_framework.views import APIView @@ -674,6 +677,71 @@ class ContentTypeModelDetail(ContentTypeDetail): raise NotFound() +class AttachmentFilter(rest_filters.FilterSet): + """Filterset for the AttachmentList API endpoint.""" + + class Meta: + """Metaclass options.""" + + model = common.models.Attachment + fields = ['model_type', 'model_id', 'upload_user'] + + is_link = rest_filters.BooleanFilter(label=_('Is Link'), method='filter_is_link') + + def filter_is_link(self, queryset, name, value): + """Filter attachments based on whether they are a link or not.""" + if value: + return queryset.exclude(link=None).exclude(link='') + return queryset.filter(Q(link=None) | Q(link='')).distinct() + + is_file = rest_filters.BooleanFilter(label=_('Is File'), method='filter_is_file') + + def filter_is_file(self, queryset, name, value): + """Filter attachments based on whether they are a file or not.""" + if value: + return queryset.exclude(attachment=None).exclude(attachment='') + return queryset.filter(Q(attachment=None) | Q(attachment='')).distinct() + + +class AttachmentList(ListCreateAPI): + """List API endpoint for Attachment objects.""" + + queryset = common.models.Attachment.objects.all() + serializer_class = common.serializers.AttachmentSerializer + permission_classes = [permissions.IsAuthenticated] + + filter_backends = SEARCH_ORDER_FILTER + filterset_class = AttachmentFilter + + ordering_fields = ['model_id', 'model_type', 'upload_date', 'file_size'] + search_fields = ['comment', 'model_id', 'model_type'] + + def perform_create(self, serializer): + """Save the user information when a file is uploaded.""" + attachment = serializer.save() + attachment.upload_user = self.request.user + attachment.save() + + +class AttachmentDetail(RetrieveUpdateDestroyAPI): + """Detail API endpoint for Attachment objects.""" + + queryset = common.models.Attachment.objects.all() + serializer_class = common.serializers.AttachmentSerializer + permission_classes = [permissions.IsAuthenticated] + + def destroy(self, request, *args, **kwargs): + """Check user permissions before deleting an attachment.""" + attachment = self.get_object() + + if not attachment.check_permission('delete', request.user): + raise PermissionDenied( + _('User does not have permission to delete this attachment') + ) + + return super().destroy(request, *args, **kwargs) + + settings_api_urls = [ # User settings path( @@ -742,6 +810,25 @@ common_api_urls = [ path('', BackgroundTaskOverview.as_view(), name='api-task-overview'), ]), ), + # Attachments + path( + 'attachment/', + include([ + path( + '/', + include([ + path( + 'metadata/', + MetadataView.as_view(), + {'model': common.models.Attachment}, + name='api-attachment-metadata', + ), + path('', AttachmentDetail.as_view(), name='api-attachment-detail'), + ]), + ), + path('', AttachmentList.as_view(), name='api-attachment-list'), + ]), + ), path( 'error-report/', include([ diff --git a/src/backend/InvenTree/common/currency.py b/src/backend/InvenTree/common/currency.py index 4c0c887b5e..5590505f12 100644 --- a/src/backend/InvenTree/common/currency.py +++ b/src/backend/InvenTree/common/currency.py @@ -28,9 +28,7 @@ def currency_code_default(): return cached_value try: - code = get_global_setting( - 'INVENTREE_DEFAULT_CURRENCY', backup_value='', create=True, cache=True - ) + code = get_global_setting('INVENTREE_DEFAULT_CURRENCY', create=True, cache=True) except Exception: # pragma: no cover # Database may not yet be ready, no need to throw an error here code = '' @@ -61,7 +59,7 @@ def currency_codes() -> list: """Returns the current currency codes.""" from common.settings import get_global_setting - codes = get_global_setting('CURRENCY_CODES', '', create=False).strip() + codes = get_global_setting('CURRENCY_CODES', create=False).strip() if not codes: codes = currency_codes_default_list() diff --git a/src/backend/InvenTree/common/migrations/0025_attachment.py b/src/backend/InvenTree/common/migrations/0025_attachment.py new file mode 100644 index 0000000000..63f9ec69a6 --- /dev/null +++ b/src/backend/InvenTree/common/migrations/0025_attachment.py @@ -0,0 +1,43 @@ +# Generated by Django 4.2.12 on 2024-06-08 12:37 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers + +import common.models +import common.validators +import InvenTree.fields +import InvenTree.models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('contenttypes', '0002_remove_content_type_name'), + ('common', '0024_notesimage_model_id_notesimage_model_type'), + ] + + operations = [ + migrations.CreateModel( + name='Attachment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('model_id', models.PositiveIntegerField()), + ('attachment', models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=common.models.rename_attachment, verbose_name='Attachment')), + ('link', InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', null=True, verbose_name='Link')), + ('comment', models.CharField(blank=True, help_text='Attachment comment', max_length=250, verbose_name='Comment')), + ('upload_date', models.DateField(auto_now_add=True, help_text='Date the file was uploaded', null=True, verbose_name='Upload date')), + ('file_size', models.PositiveIntegerField(default=0, help_text='File size in bytes', verbose_name='File size')), + ('model_type', models.CharField(help_text='Target model type for this image', max_length=100, validators=[common.validators.validate_attachment_model_type])), + ('upload_user', models.ForeignKey(blank=True, help_text='User', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='User')), + ('metadata', models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata')), + ('tags', taggit.managers.TaggableManager(blank=True, help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags')) + ], + bases=(InvenTree.models.PluginValidationMixin, models.Model), + options={ + 'verbose_name': 'Attachment', + } + ), + ] diff --git a/src/backend/InvenTree/common/migrations/0026_auto_20240608_1238.py b/src/backend/InvenTree/common/migrations/0026_auto_20240608_1238.py new file mode 100644 index 0000000000..09e12e90dd --- /dev/null +++ b/src/backend/InvenTree/common/migrations/0026_auto_20240608_1238.py @@ -0,0 +1,122 @@ +# Generated by Django 4.2.12 on 2024-06-08 12:38 + +from django.db import migrations +from django.core.files.storage import default_storage + + +def get_legacy_models(): + """Return a set of legacy attachment models.""" + + # Legacy attachment types to convert: + # app_label, table name, target model, model ref + return [ + ('build', 'BuildOrderAttachment', 'build', 'build'), + ('company', 'CompanyAttachment', 'company', 'company'), + ('company', 'ManufacturerPartAttachment', 'manufacturerpart', 'manufacturer_part'), + ('order', 'PurchaseOrderAttachment', 'purchaseorder', 'order'), + ('order', 'SalesOrderAttachment', 'salesorder', 'order'), + ('order', 'ReturnOrderAttachment', 'returnorder', 'order'), + ('part', 'PartAttachment', 'part', 'part'), + ('stock', 'StockItemAttachment', 'stockitem', 'stock_item') + ] + + +def update_attachments(apps, schema_editor): + """Migrate any existing attachment models to the new attachment table.""" + + Attachment = apps.get_model('common', 'attachment') + + N = 0 + + for app, model, target_model, model_ref in get_legacy_models(): + LegacyAttachmentModel = apps.get_model(app, model) + + if LegacyAttachmentModel.objects.count() == 0: + continue + + to_create = [] + + for attachment in LegacyAttachmentModel.objects.all(): + + # Find the size of the file (if exists) + if attachment.attachment and default_storage.exists(attachment.attachment.name): + try: + file_size = default_storage.size(attachment.attachment.name) + except NotImplementedError: + file_size = 0 + else: + file_size = 0 + + to_create.append( + Attachment( + model_type=target_model, + model_id=getattr(attachment, model_ref).pk, + attachment=attachment.attachment, + link=attachment.link, + comment=attachment.comment, + upload_date=attachment.upload_date, + upload_user=attachment.user, + file_size=file_size + ) + ) + + if len(to_create) > 0: + print(f"Migrating {len(to_create)} attachments for the legacy '{model}' model.") + Attachment.objects.bulk_create(to_create) + + N += len(to_create) + + # Check the correct number of Attachment objects has been created + assert(N == Attachment.objects.count()) + + +def reverse_attachments(apps, schema_editor): + """Reverse data migration, and map new Attachment model back to legacy models.""" + + Attachment = apps.get_model('common', 'attachment') + + N = 0 + + for app, model, target_model, model_ref in get_legacy_models(): + LegacyAttachmentModel = apps.get_model(app, model) + + to_create = [] + + for attachment in Attachment.objects.filter(model_type=target_model): + + TargetModel = apps.get_model(app, target_model) + + data = { + 'attachment': attachment.attachment, + 'link': attachment.link, + 'comment': attachment.comment, + 'upload_date': attachment.upload_date, + 'user': attachment.upload_user, + model_ref: TargetModel.objects.get(pk=attachment.model_id) + } + + to_create.append(LegacyAttachmentModel(**data)) + + if len(to_create) > 0: + print(f"Reversing {len(to_create)} attachments for the legacy '{model}' model.") + LegacyAttachmentModel.objects.bulk_create(to_create) + + N += len(to_create) + + # Check the correct number of LegacyAttachmentModel objects has been created + assert(N == Attachment.objects.count()) + +class Migration(migrations.Migration): + + dependencies = [ + ('build', '0050_auto_20240508_0138'), + ('common', '0025_attachment'), + ('company', '0069_company_active'), + ('order', '0099_alter_salesorder_status'), + ('part', '0123_parttesttemplate_choices'), + ('stock', '0110_alter_stockitemtestresult_finished_datetime_and_more') + ] + + operations = [ + migrations.RunPython(update_attachments, reverse_code=reverse_attachments), + ] diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index b4edd79cb1..3a5a7ad7c2 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -12,6 +12,7 @@ import os import uuid from datetime import timedelta, timezone from enum import Enum +from io import BytesIO from secrets import compare_digest from typing import Any, Callable, TypedDict, Union @@ -23,6 +24,7 @@ from django.contrib.contenttypes.models import ContentType from django.contrib.humanize.templatetags.humanize import naturaltime from django.core.cache import cache from django.core.exceptions import ValidationError +from django.core.files.storage import default_storage from django.core.validators import MaxValueValidator, MinValueValidator, URLValidator from django.db import models, transaction from django.db.models.signals import post_delete, post_save @@ -35,6 +37,7 @@ from django.utils.translation import gettext_lazy as _ from djmoney.contrib.exchange.exceptions import MissingRate from djmoney.contrib.exchange.models import convert_money from rest_framework.exceptions import PermissionDenied +from taggit.managers import TaggableManager import build.validators import common.currency @@ -48,6 +51,7 @@ import InvenTree.validators import order.validators import report.helpers import users.models +from InvenTree.sanitizer import sanitize_svg from plugin import registry logger = logging.getLogger('inventree') @@ -549,25 +553,25 @@ class BaseInvenTreeSetting(models.Model): """ key = str(key).strip().upper() - filters = { - 'key__iexact': key, - # Optionally filter by other keys - **cls.get_filters(**kwargs), - } - # Unless otherwise specified, attempt to create the setting create = kwargs.pop('create', True) # Specify if cache lookup should be performed do_cache = kwargs.pop('cache', django_settings.GLOBAL_CACHE_ENABLED) - # Prevent saving to the database during data import - if InvenTree.ready.isImportingData(): - create = False - do_cache = False + filters = { + 'key__iexact': key, + # Optionally filter by other keys + **cls.get_filters(**kwargs), + } - # Prevent saving to the database during migrations - if InvenTree.ready.isRunningMigrations(): + # Prevent saving to the database during certain operations + if ( + InvenTree.ready.isImportingData() + or InvenTree.ready.isRunningMigrations() + or InvenTree.ready.isRebuildingData() + or InvenTree.ready.isRunningBackup() + ): create = False do_cache = False @@ -594,33 +598,21 @@ class BaseInvenTreeSetting(models.Model): setting = None # Setting does not exist! (Try to create it) - if not setting: - # Prevent creation of new settings objects when importing data - if ( - InvenTree.ready.isImportingData() - or not InvenTree.ready.canAppAccessDatabase( - allow_test=True, allow_shell=True - ) - ): - create = False + if not setting and create: + # Attempt to create a new settings object + default_value = cls.get_setting_default(key, **kwargs) + setting = cls(key=key, value=default_value, **kwargs) - if create: - # Attempt to create a new settings object - - default_value = cls.get_setting_default(key, **kwargs) - - setting = cls(key=key, value=default_value, **kwargs) - - try: - # Wrap this statement in "atomic", so it can be rolled back if it fails - with transaction.atomic(): - setting.save(**kwargs) - except (IntegrityError, OperationalError, ProgrammingError): - # It might be the case that the database isn't created yet - pass - except ValidationError: - # The setting failed validation - might be due to duplicate keys - pass + try: + # Wrap this statement in "atomic", so it can be rolled back if it fails + with transaction.atomic(): + setting.save(**kwargs) + except (IntegrityError, OperationalError, ProgrammingError): + # It might be the case that the database isn't created yet + pass + except ValidationError: + # The setting failed validation - might be due to duplicate keys + pass if setting and do_cache: # Cache this setting object @@ -694,6 +686,15 @@ class BaseInvenTreeSetting(models.Model): if change_user is not None and not change_user.is_staff: return + # Do not write to the database under certain conditions + if ( + InvenTree.ready.isImportingData() + or InvenTree.ready.isRunningMigrations() + or InvenTree.ready.isRebuildingData() + or InvenTree.ready.isRunningBackup() + ): + return + attempts = int(kwargs.get('attempts', 3)) filters = { @@ -3062,3 +3063,184 @@ def after_custom_unit_updated(sender, instance, **kwargs): from InvenTree.conversion import reload_unit_registry reload_unit_registry() + + +def rename_attachment(instance, filename): + """Callback function to rename an uploaded attachment file. + + Arguments: + - instance: The Attachment instance + - filename: The original filename of the uploaded file + + Returns: + - The new filename for the uploaded file, e.g. 'attachments///' + """ + # Remove any illegal characters from the filename + illegal_chars = '\'"\\`~#|!@#$%^&*()[]{}<>?;:+=,' + + for c in illegal_chars: + filename = filename.replace(c, '') + + filename = os.path.basename(filename) + + # Generate a new filename for the attachment + return os.path.join( + 'attachments', str(instance.model_type), str(instance.model_id), filename + ) + + +class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel): + """Class which represents an uploaded file attachment. + + An attachment can be either an uploaded file, or an external URL. + + Attributes: + attachment: The uploaded file + url: An external URL + comment: A comment or description for the attachment + user: The user who uploaded the attachment + upload_date: The date the attachment was uploaded + file_size: The size of the uploaded file + metadata: Arbitrary metadata for the attachment (inherit from MetadataMixin) + tags: Tags for the attachment + """ + + class Meta: + """Metaclass options.""" + + verbose_name = _('Attachment') + + def save(self, *args, **kwargs): + """Custom 'save' method for the Attachment model. + + - Record the file size of the uploaded attachment (if applicable) + - Ensure that the 'content_type' and 'object_id' fields are set + - Run extra validations + """ + # Either 'attachment' or 'link' must be specified! + if not self.attachment and not self.link: + raise ValidationError({ + 'attachment': _('Missing file'), + 'link': _('Missing external link'), + }) + + if self.attachment: + if self.attachment.name.lower().endswith('.svg'): + self.attachment.file.file = self.clean_svg(self.attachment) + else: + self.file_size = 0 + + super().save(*args, **kwargs) + + # Update file size + if self.file_size == 0 and self.attachment: + # Get file size + if default_storage.exists(self.attachment.name): + try: + self.file_size = default_storage.size(self.attachment.name) + except Exception: + pass + + if self.file_size != 0: + super().save() + + def clean_svg(self, field): + """Sanitize SVG file before saving.""" + cleaned = sanitize_svg(field.file.read()) + return BytesIO(bytes(cleaned, 'utf8')) + + def __str__(self): + """Human name for attachment.""" + if self.attachment is not None: + return os.path.basename(self.attachment.name) + return str(self.link) + + model_type = models.CharField( + max_length=100, + validators=[common.validators.validate_attachment_model_type], + help_text=_('Target model type for this image'), + ) + + model_id = models.PositiveIntegerField() + + attachment = models.FileField( + upload_to=rename_attachment, + verbose_name=_('Attachment'), + help_text=_('Select file to attach'), + blank=True, + null=True, + ) + + link = InvenTree.fields.InvenTreeURLField( + blank=True, + null=True, + verbose_name=_('Link'), + help_text=_('Link to external URL'), + ) + + comment = models.CharField( + blank=True, + max_length=250, + verbose_name=_('Comment'), + help_text=_('Attachment comment'), + ) + + upload_user = models.ForeignKey( + User, + on_delete=models.SET_NULL, + blank=True, + null=True, + verbose_name=_('User'), + help_text=_('User'), + ) + + upload_date = models.DateField( + auto_now_add=True, + null=True, + blank=True, + verbose_name=_('Upload date'), + help_text=_('Date the file was uploaded'), + ) + + file_size = models.PositiveIntegerField( + default=0, verbose_name=_('File size'), help_text=_('File size in bytes') + ) + + tags = TaggableManager(blank=True) + + @property + def basename(self): + """Base name/path for attachment.""" + if self.attachment: + return os.path.basename(self.attachment.name) + return None + + def fully_qualified_url(self): + """Return a 'fully qualified' URL for this attachment. + + - If the attachment is a link to an external resource, return the link + - If the attachment is an uploaded file, return the fully qualified media URL + """ + if self.link: + return self.link + + if self.attachment: + import InvenTree.helpers_model + + media_url = InvenTree.helpers.getMediaUrl(self.attachment.url) + return InvenTree.helpers_model.construct_absolute_url(media_url) + + return '' + + def check_permission(self, permission, user): + """Check if the user has the required permission for this attachment.""" + from InvenTree.models import InvenTreeAttachmentMixin + + model_class = common.validators.attachment_model_class_from_label( + self.model_type + ) + + if not issubclass(model_class, InvenTreeAttachmentMixin): + raise ValueError(_('Invalid model type specified for attachment')) + + return model_class.check_attachment_permission(permission, user) diff --git a/src/backend/InvenTree/common/serializers.py b/src/backend/InvenTree/common/serializers.py index c3539a7a61..0e0c7d1850 100644 --- a/src/backend/InvenTree/common/serializers.py +++ b/src/backend/InvenTree/common/serializers.py @@ -9,13 +9,18 @@ import django_q.models from error_report.models import Error from flags.state import flag_state from rest_framework import serializers +from rest_framework.exceptions import PermissionDenied +from taggit.serializers import TagListSerializerField import common.models as common_models +import common.validators from InvenTree.helpers import get_objectreference from InvenTree.helpers_model import construct_absolute_url from InvenTree.serializers import ( + InvenTreeAttachmentSerializerField, InvenTreeImageSerializerField, InvenTreeModelSerializer, + UserSerializer, ) from plugin import registry as plugin_registry from users.serializers import OwnerSerializer @@ -474,3 +479,85 @@ class FailedTaskSerializer(InvenTreeModelSerializer): pk = serializers.CharField(source='id', read_only=True) result = serializers.CharField() + + +class AttachmentSerializer(InvenTreeModelSerializer): + """Serializer class for the Attachment model.""" + + class Meta: + """Serializer metaclass.""" + + model = common_models.Attachment + fields = [ + 'pk', + 'attachment', + 'filename', + 'link', + 'comment', + 'upload_date', + 'upload_user', + 'user_detail', + 'file_size', + 'model_type', + 'model_id', + 'tags', + ] + + read_only_fields = ['pk', 'file_size', 'upload_date', 'upload_user', 'filename'] + + def __init__(self, *args, **kwargs): + """Override the model_type field to provide dynamic choices.""" + super().__init__(*args, **kwargs) + + if len(self.fields['model_type'].choices) == 0: + self.fields[ + 'model_type' + ].choices = common.validators.attachment_model_options() + + tags = TagListSerializerField(required=False) + + user_detail = UserSerializer(source='upload_user', read_only=True, many=False) + + attachment = InvenTreeAttachmentSerializerField(required=False, allow_null=True) + + # The 'filename' field must be present in the serializer + filename = serializers.CharField( + label=_('Filename'), required=False, source='basename', allow_blank=False + ) + + upload_date = serializers.DateField(read_only=True) + + # Note: The choices are overridden at run-time on class initialization + model_type = serializers.ChoiceField( + label=_('Model Type'), + choices=common.validators.attachment_model_options(), + required=True, + allow_blank=False, + allow_null=False, + ) + + def save(self): + """Override the save method to handle the model_type field.""" + from InvenTree.models import InvenTreeAttachmentMixin + + model_type = self.validated_data.get('model_type', None) + + # Ensure that the user has permission to attach files to the specified model + user = self.context.get('request').user + + target_model_class = common.validators.attachment_model_class_from_label( + model_type + ) + + if not issubclass(target_model_class, InvenTreeAttachmentMixin): + raise PermissionDenied(_('Invalid model type specified for attachment')) + + # Check that the user has the required permissions to attach files to the target model + if not target_model_class.check_attachment_permission('change', user): + raise PermissionDenied( + _( + 'User does not have permission to create or edit attachments for this model' + ) + ) + + return super().save() diff --git a/src/backend/InvenTree/common/settings.py b/src/backend/InvenTree/common/settings.py index d27ddfa2f4..6788b427e2 100644 --- a/src/backend/InvenTree/common/settings.py +++ b/src/backend/InvenTree/common/settings.py @@ -5,7 +5,8 @@ def get_global_setting(key, backup_value=None, **kwargs): """Return the value of a global setting using the provided key.""" from common.models import InvenTreeSetting - kwargs['backup_value'] = backup_value + if backup_value is not None: + kwargs['backup_value'] = backup_value return InvenTreeSetting.get_setting(key, **kwargs) @@ -25,7 +26,9 @@ def get_user_setting(key, user, backup_value=None, **kwargs): from common.models import InvenTreeUserSetting kwargs['user'] = user - kwargs['backup_value'] = backup_value + + if backup_value is not None: + kwargs['backup_value'] = backup_value return InvenTreeUserSetting.get_setting(key, **kwargs) diff --git a/src/backend/InvenTree/common/test_migrations.py b/src/backend/InvenTree/common/test_migrations.py new file mode 100644 index 0000000000..71d9449ed6 --- /dev/null +++ b/src/backend/InvenTree/common/test_migrations.py @@ -0,0 +1,210 @@ +"""Data migration unit tests for the 'common' app.""" + +import io + +from django.core.files.base import ContentFile + +from django_test_migrations.contrib.unittest_case import MigratorTestCase + +from InvenTree import unit_test + + +def get_legacy_models(): + """Return a set of legacy attachment models.""" + # Legacy attachment types to convert: + # app_label, table name, target model, model ref + return [ + ('build', 'BuildOrderAttachment', 'build', 'build'), + ('company', 'CompanyAttachment', 'company', 'company'), + ( + 'company', + 'ManufacturerPartAttachment', + 'manufacturerpart', + 'manufacturer_part', + ), + ('order', 'PurchaseOrderAttachment', 'purchaseorder', 'order'), + ('order', 'SalesOrderAttachment', 'salesorder', 'order'), + ('order', 'ReturnOrderAttachment', 'returnorder', 'order'), + ('part', 'PartAttachment', 'part', 'part'), + ('stock', 'StockItemAttachment', 'stockitem', 'stock_item'), + ] + + +def generate_attachment(): + """Generate a file attachment object for test upload.""" + file_object = io.StringIO('Some dummy data') + file_object.seek(0) + + return ContentFile(file_object.getvalue(), 'test.txt') + + +class TestForwardMigrations(MigratorTestCase): + """Test entire schema migration sequence for the common app.""" + + migrate_from = ('common', '0024_notesimage_model_id_notesimage_model_type') + migrate_to = ('common', unit_test.getNewestMigrationFile('common')) + + def prepare(self): + """Create initial data. + + Legacy attachment model types are: + - BuildOrderAttachment + - CompanyAttachment + - ManufacturerPartAttachment + - PurchaseOrderAttachment + - SalesOrderAttachment + - ReturnOrderAttachment + - PartAttachment + - StockItemAttachment + """ + # Dummy MPPT data + tree = {'tree_id': 0, 'level': 0, 'lft': 0, 'rght': 0} + + # BuildOrderAttachment + Part = self.old_state.apps.get_model('part', 'Part') + Build = self.old_state.apps.get_model('build', 'Build') + + part = Part.objects.create( + name='Test Part', + description='Test Part Description', + active=True, + assembly=True, + purchaseable=True, + **tree, + ) + + build = Build.objects.create(part=part, title='Test Build', quantity=10, **tree) + + PartAttachment = self.old_state.apps.get_model('part', 'PartAttachment') + PartAttachment.objects.create( + part=part, attachment=generate_attachment(), comment='Test file attachment' + ) + PartAttachment.objects.create( + part=part, link='http://example.com', comment='Test link attachment' + ) + self.assertEqual(PartAttachment.objects.count(), 2) + + BuildOrderAttachment = self.old_state.apps.get_model( + 'build', 'BuildOrderAttachment' + ) + BuildOrderAttachment.objects.create( + build=build, link='http://example.com', comment='Test comment' + ) + BuildOrderAttachment.objects.create( + build=build, attachment=generate_attachment(), comment='a test file' + ) + self.assertEqual(BuildOrderAttachment.objects.count(), 2) + + StockItem = self.old_state.apps.get_model('stock', 'StockItem') + StockItemAttachment = self.old_state.apps.get_model( + 'stock', 'StockItemAttachment' + ) + + item = StockItem.objects.create(part=part, quantity=10, **tree) + + StockItemAttachment.objects.create( + stock_item=item, + attachment=generate_attachment(), + comment='Test file attachment', + ) + StockItemAttachment.objects.create( + stock_item=item, link='http://example.com', comment='Test link attachment' + ) + self.assertEqual(StockItemAttachment.objects.count(), 2) + + Company = self.old_state.apps.get_model('company', 'Company') + CompanyAttachment = self.old_state.apps.get_model( + 'company', 'CompanyAttachment' + ) + + company = Company.objects.create( + name='Test Company', + description='Test Company Description', + is_customer=True, + is_manufacturer=True, + is_supplier=True, + ) + + CompanyAttachment.objects.create( + company=company, + attachment=generate_attachment(), + comment='Test file attachment', + ) + CompanyAttachment.objects.create( + company=company, link='http://example.com', comment='Test link attachment' + ) + self.assertEqual(CompanyAttachment.objects.count(), 2) + + PurchaseOrder = self.old_state.apps.get_model('order', 'PurchaseOrder') + PurchaseOrderAttachment = self.old_state.apps.get_model( + 'order', 'PurchaseOrderAttachment' + ) + + po = PurchaseOrder.objects.create( + reference='PO-12345', + supplier=company, + description='Test Purchase Order Description', + ) + + PurchaseOrderAttachment.objects.create( + order=po, attachment=generate_attachment(), comment='Test file attachment' + ) + PurchaseOrderAttachment.objects.create( + order=po, link='http://example.com', comment='Test link attachment' + ) + self.assertEqual(PurchaseOrderAttachment.objects.count(), 2) + + SalesOrder = self.old_state.apps.get_model('order', 'SalesOrder') + SalesOrderAttachment = self.old_state.apps.get_model( + 'order', 'SalesOrderAttachment' + ) + + so = SalesOrder.objects.create( + reference='SO-12345', + customer=company, + description='Test Sales Order Description', + ) + + SalesOrderAttachment.objects.create( + order=so, attachment=generate_attachment(), comment='Test file attachment' + ) + SalesOrderAttachment.objects.create( + order=so, link='http://example.com', comment='Test link attachment' + ) + self.assertEqual(SalesOrderAttachment.objects.count(), 2) + + ReturnOrder = self.old_state.apps.get_model('order', 'ReturnOrder') + ReturnOrderAttachment = self.old_state.apps.get_model( + 'order', 'ReturnOrderAttachment' + ) + + ro = ReturnOrder.objects.create( + reference='RO-12345', + customer=company, + description='Test Return Order Description', + ) + + ReturnOrderAttachment.objects.create( + order=ro, attachment=generate_attachment(), comment='Test file attachment' + ) + ReturnOrderAttachment.objects.create( + order=ro, link='http://example.com', comment='Test link attachment' + ) + self.assertEqual(ReturnOrderAttachment.objects.count(), 2) + + def test_items_exist(self): + """Test to ensure that the attachments are correctly migrated.""" + Attachment = self.new_state.apps.get_model('common', 'Attachment') + + self.assertEqual(Attachment.objects.count(), 14) + + for model in [ + 'build', + 'company', + 'purchaseorder', + 'returnorder', + 'salesorder', + 'part', + 'stockitem', + ]: + self.assertEqual(Attachment.objects.filter(model_type=model).count(), 2) diff --git a/src/backend/InvenTree/common/test_views.py b/src/backend/InvenTree/common/test_views.py deleted file mode 100644 index 0e43770f02..0000000000 --- a/src/backend/InvenTree/common/test_views.py +++ /dev/null @@ -1 +0,0 @@ -"""Unit tests for the views associated with the 'common' app.""" diff --git a/src/backend/InvenTree/common/tests.py b/src/backend/InvenTree/common/tests.py index fda28be67e..f936fddfb9 100644 --- a/src/backend/InvenTree/common/tests.py +++ b/src/backend/InvenTree/common/tests.py @@ -11,6 +11,8 @@ from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.core.cache import cache from django.core.exceptions import ValidationError +from django.core.files.base import ContentFile +from django.core.files.storage import default_storage from django.core.files.uploadedfile import SimpleUploadedFile from django.test import Client, TestCase from django.test.utils import override_settings @@ -21,11 +23,13 @@ import PIL from common.settings import get_global_setting, set_global_setting from InvenTree.helpers import str2bool from InvenTree.unit_test import InvenTreeAPITestCase, InvenTreeTestCase, PluginMixin +from part.models import Part from plugin import registry from plugin.models import NotificationUserSetting from .api import WebhookView from .models import ( + Attachment, ColorTheme, CustomUnit, InvenTreeSetting, @@ -41,6 +45,131 @@ from .models import ( CONTENT_TYPE_JSON = 'application/json' +class AttachmentTest(InvenTreeAPITestCase): + """Unit tests for the 'Attachment' model.""" + + fixtures = ['part', 'category', 'location'] + + def generate_file(self, fn: str): + """Generate an attachment file object.""" + file_object = io.StringIO('Some dummy data') + file_object.seek(0) + + return ContentFile(file_object.getvalue(), fn) + + def test_filename_validation(self): + """Test that the filename validation works as expected. + + The django file-upload mechanism should sanitize filenames correctly. + """ + part = Part.objects.first() + + filenames = { + 'test.txt': 'test.txt', + 'r####at.mp4': 'rat.mp4', + '../../../win32.dll': 'win32.dll', + 'ABC!@#$%^&&&&&&&)-XYZ-(**&&&\\/QqQ.sqlite': 'QqQ.sqlite', + '/var/log/inventree.log': 'inventree.log', + 'c:\\Users\\admin\\passwd.txt': 'cUsersadminpasswd.txt', + '8&&&8.txt': '88.txt', + } + + for fn, expected in filenames.items(): + attachment = Attachment.objects.create( + attachment=self.generate_file(fn), + comment=f'Testing filename: {fn}', + model_type='part', + model_id=part.pk, + ) + + expected_path = f'attachments/part/{part.pk}/{expected}' + self.assertEqual(attachment.attachment.name, expected_path) + self.assertEqual(attachment.file_size, 15) + + self.assertEqual(part.attachments.count(), len(filenames.keys())) + + # Delete any attachments after the test is completed + for attachment in part.attachments.all(): + path = attachment.attachment.name + attachment.delete() + + # Remove uploaded files to prevent them sticking around + if default_storage.exists(path): + default_storage.delete(path) + + self.assertEqual( + Attachment.objects.filter(model_type='part', model_id=part.pk).count(), 0 + ) + + def test_mixin(self): + """Test that the mixin class works as expected.""" + part = Part.objects.first() + + self.assertEqual(part.attachments.count(), 0) + + part.create_attachment( + attachment=self.generate_file('test.txt'), comment='Hello world' + ) + + self.assertEqual(part.attachments.count(), 1) + + attachment = part.attachments.first() + + self.assertEqual(attachment.comment, 'Hello world') + self.assertIn(f'attachments/part/{part.pk}/test', attachment.attachment.name) + + def test_upload_via_api(self): + """Test that we can upload attachments via the API.""" + part = Part.objects.first() + url = reverse('api-attachment-list') + + data = { + 'model_type': 'part', + 'model_id': part.pk, + 'link': 'https://www.google.com', + 'comment': 'Some appropriate comment', + } + + # Start without appropriate permissions + # User must have 'part.change' to upload an attachment against a Part instance + self.logout() + self.user.is_staff = False + self.user.is_superuser = False + self.user.save() + self.clearRoles() + + # Check without login (401) + response = self.post(url, data, expected_code=401) + + self.login() + + response = self.post(url, data, expected_code=403) + + self.assertIn( + 'User does not have permission to create or edit attachments for this model', + str(response.data['detail']), + ) + + # Add the required permission + self.assignRole('part.change') + + # Upload should now work! + response = self.post(url, data, expected_code=201) + + # Try to delete the attachment via API (should fail) + attachment = part.attachments.first() + url = reverse('api-attachment-detail', kwargs={'pk': attachment.pk}) + response = self.delete(url, expected_code=403) + self.assertIn( + 'User does not have permission to delete this attachment', + str(response.data['detail']), + ) + + # Assign 'delete' permission to 'part' model + self.assignRole('part.delete') + response = self.delete(url, expected_code=204) + + class SettingsTest(InvenTreeTestCase): """Tests for the 'settings' model.""" diff --git a/src/backend/InvenTree/common/validators.py b/src/backend/InvenTree/common/validators.py index d97983f5a3..5edd4a0f9a 100644 --- a/src/backend/InvenTree/common/validators.py +++ b/src/backend/InvenTree/common/validators.py @@ -8,6 +8,41 @@ from django.utils.translation import gettext_lazy as _ from common.settings import get_global_setting +def attachment_model_types(): + """Return a list of valid attachment model choices.""" + import InvenTree.models + + return list( + InvenTree.helpers_model.getModelsWithMixin( + InvenTree.models.InvenTreeAttachmentMixin + ) + ) + + +def attachment_model_options(): + """Return a list of options for models which support attachments.""" + return [ + (model.__name__.lower(), model._meta.verbose_name) + for model in attachment_model_types() + ] + + +def attachment_model_class_from_label(label: str): + """Return the model class for the given label.""" + for model in attachment_model_types(): + if model.__name__.lower() == label.lower(): + return model + + raise ValueError(f'Invalid attachment model label: {label}') + + +def validate_attachment_model_type(value): + """Ensure that the provided attachment model is valid.""" + model_names = [el[0] for el in attachment_model_options()] + if value not in model_names: + raise ValidationError(f'Model type does not support attachments') + + def validate_notes_model_type(value): """Ensure that the provided model type is valid. diff --git a/src/backend/InvenTree/company/admin.py b/src/backend/InvenTree/company/admin.py index 7caf7f3b16..93ad536b91 100644 --- a/src/backend/InvenTree/company/admin.py +++ b/src/backend/InvenTree/company/admin.py @@ -14,7 +14,6 @@ from .models import ( Company, Contact, ManufacturerPart, - ManufacturerPartAttachment, ManufacturerPartParameter, SupplierPart, SupplierPriceBreak, @@ -120,15 +119,6 @@ class ManufacturerPartAdmin(ImportExportModelAdmin): autocomplete_fields = ('part', 'manufacturer') -@admin.register(ManufacturerPartAttachment) -class ManufacturerPartAttachmentAdmin(ImportExportModelAdmin): - """Admin class for ManufacturerPartAttachment model.""" - - list_display = ('manufacturer_part', 'attachment', 'comment') - - autocomplete_fields = ('manufacturer_part',) - - class ManufacturerPartParameterResource(InvenTreeResource): """Class for managing ManufacturerPartParameter data import/export.""" diff --git a/src/backend/InvenTree/company/api.py b/src/backend/InvenTree/company/api.py index aea8d5dc5e..de5118d805 100644 --- a/src/backend/InvenTree/company/api.py +++ b/src/backend/InvenTree/company/api.py @@ -7,7 +7,7 @@ from django.utils.translation import gettext_lazy as _ from django_filters import rest_framework as rest_filters import part.models -from InvenTree.api import AttachmentMixin, ListCreateDestroyAPIView, MetadataView +from InvenTree.api import ListCreateDestroyAPIView, MetadataView from InvenTree.filters import ( ORDER_FILTER, SEARCH_ORDER_FILTER, @@ -19,20 +19,16 @@ from InvenTree.mixins import ListCreateAPI, RetrieveUpdateDestroyAPI from .models import ( Address, Company, - CompanyAttachment, Contact, ManufacturerPart, - ManufacturerPartAttachment, ManufacturerPartParameter, SupplierPart, SupplierPriceBreak, ) from .serializers import ( AddressSerializer, - CompanyAttachmentSerializer, CompanySerializer, ContactSerializer, - ManufacturerPartAttachmentSerializer, ManufacturerPartParameterSerializer, ManufacturerPartSerializer, SupplierPartSerializer, @@ -88,22 +84,6 @@ class CompanyDetail(RetrieveUpdateDestroyAPI): return queryset -class CompanyAttachmentList(AttachmentMixin, ListCreateDestroyAPIView): - """API endpoint for listing, creating and bulk deleting a CompanyAttachment.""" - - queryset = CompanyAttachment.objects.all() - serializer_class = CompanyAttachmentSerializer - - filterset_fields = ['company'] - - -class CompanyAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI): - """Detail endpoint for CompanyAttachment model.""" - - queryset = CompanyAttachment.objects.all() - serializer_class = CompanyAttachmentSerializer - - class ContactList(ListCreateDestroyAPIView): """API endpoint for list view of Company model.""" @@ -227,22 +207,6 @@ class ManufacturerPartDetail(RetrieveUpdateDestroyAPI): serializer_class = ManufacturerPartSerializer -class ManufacturerPartAttachmentList(AttachmentMixin, ListCreateDestroyAPIView): - """API endpoint for listing, creating and bulk deleting a ManufacturerPartAttachment (file upload).""" - - queryset = ManufacturerPartAttachment.objects.all() - serializer_class = ManufacturerPartAttachmentSerializer - - filterset_fields = ['manufacturer_part'] - - -class ManufacturerPartAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI): - """Detail endpooint for ManufacturerPartAttachment model.""" - - queryset = ManufacturerPartAttachment.objects.all() - serializer_class = ManufacturerPartAttachmentSerializer - - class ManufacturerPartParameterFilter(rest_filters.FilterSet): """Custom filterset for the ManufacturerPartParameterList API endpoint.""" @@ -509,22 +473,6 @@ class SupplierPriceBreakDetail(RetrieveUpdateDestroyAPI): manufacturer_part_api_urls = [ - # Base URL for ManufacturerPartAttachment API endpoints - path( - 'attachment/', - include([ - path( - '/', - ManufacturerPartAttachmentDetail.as_view(), - name='api-manufacturer-part-attachment-detail', - ), - path( - '', - ManufacturerPartAttachmentList.as_view(), - name='api-manufacturer-part-attachment-list', - ), - ]), - ), path( 'parameter/', include([ @@ -611,19 +559,6 @@ company_api_urls = [ path('', CompanyDetail.as_view(), name='api-company-detail'), ]), ), - path( - 'attachment/', - include([ - path( - '/', - CompanyAttachmentDetail.as_view(), - name='api-company-attachment-detail', - ), - path( - '', CompanyAttachmentList.as_view(), name='api-company-attachment-list' - ), - ]), - ), path( 'contact/', include([ diff --git a/src/backend/InvenTree/company/migrations/0001_initial.py b/src/backend/InvenTree/company/migrations/0001_initial.py index c2de9ed453..cfc73bea20 100644 --- a/src/backend/InvenTree/company/migrations/0001_initial.py +++ b/src/backend/InvenTree/company/migrations/0001_initial.py @@ -31,6 +31,9 @@ class Migration(migrations.Migration): ('is_customer', models.BooleanField(default=False, help_text='Do you sell items to this company?')), ('is_supplier', models.BooleanField(default=True, help_text='Do you purchase items from this company?')), ], + options={ + 'verbose_name': 'Company', + } ), migrations.CreateModel( name='Contact', @@ -60,6 +63,7 @@ class Migration(migrations.Migration): ], options={ 'db_table': 'part_supplierpart', + 'verbose_name': 'Supplier Part', }, ), migrations.CreateModel( diff --git a/src/backend/InvenTree/company/migrations/0023_auto_20200808_0715.py b/src/backend/InvenTree/company/migrations/0023_auto_20200808_0715.py index 22097e8e2b..d184108c9f 100644 --- a/src/backend/InvenTree/company/migrations/0023_auto_20200808_0715.py +++ b/src/backend/InvenTree/company/migrations/0023_auto_20200808_0715.py @@ -12,6 +12,6 @@ class Migration(migrations.Migration): operations = [ migrations.AlterModelOptions( name='company', - options={'ordering': ['name']}, + options={'ordering': ['name'], 'verbose_name': 'Company'}, ), ] diff --git a/src/backend/InvenTree/company/migrations/0034_manufacturerpart.py b/src/backend/InvenTree/company/migrations/0034_manufacturerpart.py index 2e8a8bf82f..f50919d59f 100644 --- a/src/backend/InvenTree/company/migrations/0034_manufacturerpart.py +++ b/src/backend/InvenTree/company/migrations/0034_manufacturerpart.py @@ -22,6 +22,7 @@ class Migration(migrations.Migration): ], options={ 'unique_together': {('part', 'manufacturer', 'MPN')}, + 'verbose_name': 'Manufacturer Part', }, ), ] diff --git a/src/backend/InvenTree/company/migrations/0041_alter_company_options.py b/src/backend/InvenTree/company/migrations/0041_alter_company_options.py index 40849eed1d..e6b1bed978 100644 --- a/src/backend/InvenTree/company/migrations/0041_alter_company_options.py +++ b/src/backend/InvenTree/company/migrations/0041_alter_company_options.py @@ -12,6 +12,6 @@ class Migration(migrations.Migration): operations = [ migrations.AlterModelOptions( name='company', - options={'ordering': ['name'], 'verbose_name_plural': 'Companies'}, + options={'ordering': ['name'], 'verbose_name': 'Company', 'verbose_name_plural': 'Companies'}, ), ] diff --git a/src/backend/InvenTree/company/migrations/0043_manufacturerpartattachment.py b/src/backend/InvenTree/company/migrations/0043_manufacturerpartattachment.py index fe526992b0..a0152385eb 100644 --- a/src/backend/InvenTree/company/migrations/0043_manufacturerpartattachment.py +++ b/src/backend/InvenTree/company/migrations/0043_manufacturerpartattachment.py @@ -19,7 +19,7 @@ class Migration(migrations.Migration): name='ManufacturerPartAttachment', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('attachment', models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment')), + ('attachment', models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to='attachments', verbose_name='Attachment')), ('link', InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', null=True, verbose_name='Link')), ('comment', models.CharField(blank=True, help_text='File comment', max_length=100, verbose_name='Comment')), ('upload_date', models.DateField(auto_now_add=True, null=True, verbose_name='upload date')), diff --git a/src/backend/InvenTree/company/migrations/0054_companyattachment.py b/src/backend/InvenTree/company/migrations/0054_companyattachment.py index 44a415fce3..4996976ac1 100644 --- a/src/backend/InvenTree/company/migrations/0054_companyattachment.py +++ b/src/backend/InvenTree/company/migrations/0054_companyattachment.py @@ -19,7 +19,7 @@ class Migration(migrations.Migration): name='CompanyAttachment', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('attachment', models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment')), + ('attachment', models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to='attachments', verbose_name='Attachment')), ('link', InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', null=True, verbose_name='Link')), ('comment', models.CharField(blank=True, help_text='File comment', max_length=100, verbose_name='Comment')), ('upload_date', models.DateField(auto_now_add=True, null=True, verbose_name='upload date')), diff --git a/src/backend/InvenTree/company/migrations/0070_remove_manufacturerpartattachment_manufacturer_part_and_more.py b/src/backend/InvenTree/company/migrations/0070_remove_manufacturerpartattachment_manufacturer_part_and_more.py new file mode 100644 index 0000000000..d0bec93f7d --- /dev/null +++ b/src/backend/InvenTree/company/migrations/0070_remove_manufacturerpartattachment_manufacturer_part_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.12 on 2024-06-09 09:02 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('build', '0050_auto_20240508_0138'), + ('common', '0026_auto_20240608_1238'), + ('company', '0069_company_active'), + ('order', '0099_alter_salesorder_status'), + ('part', '0123_parttesttemplate_choices'), + ('stock', '0110_alter_stockitemtestresult_finished_datetime_and_more') + ] + + operations = [ + migrations.DeleteModel( + name='CompanyAttachment', + ), + migrations.DeleteModel( + name='ManufacturerPartAttachment', + ), + ] diff --git a/src/backend/InvenTree/company/models.py b/src/backend/InvenTree/company/models.py index 30b2dc5789..8683be248e 100644 --- a/src/backend/InvenTree/company/models.py +++ b/src/backend/InvenTree/company/models.py @@ -60,7 +60,9 @@ def rename_company_image(instance, filename): class Company( - InvenTree.models.InvenTreeNotesMixin, InvenTree.models.InvenTreeMetadataModel + InvenTree.models.InvenTreeAttachmentMixin, + InvenTree.models.InvenTreeNotesMixin, + InvenTree.models.InvenTreeMetadataModel, ): """A Company object represents an external company. @@ -95,7 +97,8 @@ class Company( constraints = [ UniqueConstraint(fields=['name', 'email'], name='unique_name_email_pair') ] - verbose_name_plural = 'Companies' + verbose_name = _('Company') + verbose_name_plural = _('Companies') @staticmethod def get_api_url(): @@ -255,26 +258,6 @@ class Company( ).distinct() -class CompanyAttachment(InvenTree.models.InvenTreeAttachment): - """Model for storing file or URL attachments against a Company object.""" - - @staticmethod - def get_api_url(): - """Return the API URL associated with this model.""" - return reverse('api-company-attachment-list') - - def getSubdir(self): - """Return the subdirectory where these attachments are uploaded.""" - return os.path.join('company_files', str(self.company.pk)) - - company = models.ForeignKey( - Company, - on_delete=models.CASCADE, - verbose_name=_('Company'), - related_name='attachments', - ) - - class Contact(InvenTree.models.InvenTreeMetadataModel): """A Contact represents a person who works at a particular company. A Company may have zero or more associated Contact objects. @@ -460,7 +443,9 @@ class Address(InvenTree.models.InvenTreeModel): class ManufacturerPart( - InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeMetadataModel + InvenTree.models.InvenTreeAttachmentMixin, + InvenTree.models.InvenTreeBarcodeMixin, + InvenTree.models.InvenTreeMetadataModel, ): """Represents a unique part as provided by a Manufacturer Each ManufacturerPart is identified by a MPN (Manufacturer Part Number) Each ManufacturerPart is also linked to a Part object. A Part may be available from multiple manufacturers. @@ -475,6 +460,7 @@ class ManufacturerPart( class Meta: """Metaclass defines extra model options.""" + verbose_name = _('Manufacturer Part') unique_together = ('part', 'manufacturer', 'MPN') @staticmethod @@ -563,26 +549,6 @@ class ManufacturerPart( return s -class ManufacturerPartAttachment(InvenTree.models.InvenTreeAttachment): - """Model for storing file attachments against a ManufacturerPart object.""" - - @staticmethod - def get_api_url(): - """Return the API URL associated with the ManufacturerPartAttachment model.""" - return reverse('api-manufacturer-part-attachment-list') - - def getSubdir(self): - """Return the subdirectory where attachment files for the ManufacturerPart model are located.""" - return os.path.join('manufacturer_part_files', str(self.manufacturer_part.id)) - - manufacturer_part = models.ForeignKey( - ManufacturerPart, - on_delete=models.CASCADE, - verbose_name=_('Manufacturer Part'), - related_name='attachments', - ) - - class ManufacturerPartParameter(InvenTree.models.InvenTreeModel): """A ManufacturerPartParameter represents a key:value parameter for a MnaufacturerPart. @@ -679,6 +645,8 @@ class SupplierPart( unique_together = ('part', 'supplier', 'SKU') + verbose_name = _('Supplier Part') + # This model was moved from the 'Part' app db_table = 'part_supplierpart' diff --git a/src/backend/InvenTree/company/serializers.py b/src/backend/InvenTree/company/serializers.py index c0425c68db..453ff21b42 100644 --- a/src/backend/InvenTree/company/serializers.py +++ b/src/backend/InvenTree/company/serializers.py @@ -11,7 +11,6 @@ from taggit.serializers import TagListSerializerField import part.filters from InvenTree.serializers import ( - InvenTreeAttachmentSerializer, InvenTreeCurrencySerializer, InvenTreeDecimalField, InvenTreeImageSerializerField, @@ -26,10 +25,8 @@ from part.serializers import PartBriefSerializer from .models import ( Address, Company, - CompanyAttachment, Contact, ManufacturerPart, - ManufacturerPartAttachment, ManufacturerPartParameter, SupplierPart, SupplierPriceBreak, @@ -186,17 +183,6 @@ class CompanySerializer(NotesFieldMixin, RemoteImageMixin, InvenTreeModelSeriali return self.instance -class CompanyAttachmentSerializer(InvenTreeAttachmentSerializer): - """Serializer for the CompanyAttachment class.""" - - class Meta: - """Metaclass defines serializer options.""" - - model = CompanyAttachment - - fields = InvenTreeAttachmentSerializer.attachment_fields(['company']) - - class ContactSerializer(InvenTreeModelSerializer): """Serializer class for the Contact model.""" @@ -260,17 +246,6 @@ class ManufacturerPartSerializer(InvenTreeTagModelSerializer): ) -class ManufacturerPartAttachmentSerializer(InvenTreeAttachmentSerializer): - """Serializer for the ManufacturerPartAttachment class.""" - - class Meta: - """Metaclass options.""" - - model = ManufacturerPartAttachment - - fields = InvenTreeAttachmentSerializer.attachment_fields(['manufacturer_part']) - - class ManufacturerPartParameterSerializer(InvenTreeModelSerializer): """Serializer for the ManufacturerPartParameter model.""" diff --git a/src/backend/InvenTree/company/templates/company/detail.html b/src/backend/InvenTree/company/templates/company/detail.html index afc6a813ea..d9fbf521e7 100644 --- a/src/backend/InvenTree/company/templates/company/detail.html +++ b/src/backend/InvenTree/company/templates/company/detail.html @@ -244,17 +244,7 @@ {{ block.super }} onPanelLoad("attachments", function() { - loadAttachmentTable('{% url "api-company-attachment-list" %}', { - filters: { - company: {{ company.pk }}, - }, - fields: { - company: { - value: {{ company.pk }}, - hidden: true - } - } - }); + loadAttachmentTable('company', {{ company.pk }}); }); // Callback function when the 'contacts' panel is loaded diff --git a/src/backend/InvenTree/company/templates/company/manufacturer_part.html b/src/backend/InvenTree/company/templates/company/manufacturer_part.html index 08e6f38568..a4676a9086 100644 --- a/src/backend/InvenTree/company/templates/company/manufacturer_part.html +++ b/src/backend/InvenTree/company/templates/company/manufacturer_part.html @@ -177,17 +177,7 @@ src="{% static 'img/blank_image.png' %}" {{ block.super }} onPanelLoad("attachments", function() { - loadAttachmentTable('{% url "api-manufacturer-part-attachment-list" %}', { - filters: { - manufacturer_part: {{ part.pk }}, - }, - fields: { - manufacturer_part: { - value: {{ part.pk }}, - hidden: true - } - } - }); + loadAttachmentTable('manufacturerpart', {{ part.pk }}); }); $('#parameter-create').click(function() { diff --git a/src/backend/InvenTree/company/test_migrations.py b/src/backend/InvenTree/company/test_migrations.py index bb5b6f27f9..305eaf6031 100644 --- a/src/backend/InvenTree/company/test_migrations.py +++ b/src/backend/InvenTree/company/test_migrations.py @@ -45,14 +45,7 @@ class TestManufacturerField(MigratorTestCase): SupplierPart = self.old_state.apps.get_model('company', 'supplierpart') # Create an initial part - part = Part.objects.create( - name='Screw', - description='A single screw', - level=0, - tree_id=0, - lft=0, - rght=0, - ) + part = Part.objects.create(name='Screw', description='A single screw') # Create a company to act as the supplier supplier = Company.objects.create( diff --git a/src/backend/InvenTree/order/api.py b/src/backend/InvenTree/order/api.py index b2dd13483b..2cd8e59351 100644 --- a/src/backend/InvenTree/order/api.py +++ b/src/backend/InvenTree/order/api.py @@ -17,15 +17,11 @@ from rest_framework import status from rest_framework.exceptions import ValidationError from rest_framework.response import Response -import common.models as common_models -from company.models import SupplierPart +import common.models +import common.settings +import company.models from generic.states.api import StatusView -from InvenTree.api import ( - APIDownloadMixin, - AttachmentMixin, - ListCreateDestroyAPIView, - MetadataView, -) +from InvenTree.api import APIDownloadMixin, ListCreateDestroyAPIView, MetadataView from InvenTree.filters import SEARCH_ORDER_FILTER, SEARCH_ORDER_FILTER_ALIAS from InvenTree.helpers import DownloadFile, str2bool from InvenTree.helpers_model import construct_absolute_url, get_base_url @@ -135,7 +131,7 @@ class OrderFilter(rest_filters.FilterSet): return queryset.exclude(status__in=self.Meta.model.get_status_class().OPEN) project_code = rest_filters.ModelChoiceFilter( - queryset=common_models.ProjectCode.objects.all(), field_name='project_code' + queryset=common.models.ProjectCode.objects.all(), field_name='project_code' ) has_project_code = rest_filters.BooleanFilter( @@ -306,11 +302,13 @@ class PurchaseOrderList(PurchaseOrderMixin, APIDownloadMixin, ListCreateAPI): if supplier_part is not None: try: - supplier_part = SupplierPart.objects.get(pk=supplier_part) + supplier_part = company.models.SupplierPart.objects.get( + pk=supplier_part + ) queryset = queryset.filter( id__in=[p.id for p in supplier_part.purchase_orders()] ) - except (ValueError, SupplierPart.DoesNotExist): + except (ValueError, company.models.SupplierPart.DoesNotExist): pass # Filter by 'date range' @@ -449,7 +447,9 @@ class PurchaseOrderLineItemFilter(LineItemFilter): return queryset.exclude(order__status=PurchaseOrderStatus.COMPLETE.value) part = rest_filters.ModelChoiceFilter( - queryset=SupplierPart.objects.all(), field_name='part', label=_('Supplier Part') + queryset=company.models.SupplierPart.objects.all(), + field_name='part', + label=_('Supplier Part'), ) base_part = rest_filters.ModelChoiceFilter( @@ -648,22 +648,6 @@ class PurchaseOrderExtraLineDetail(RetrieveUpdateDestroyAPI): serializer_class = serializers.PurchaseOrderExtraLineSerializer -class SalesOrderAttachmentList(AttachmentMixin, ListCreateDestroyAPIView): - """API endpoint for listing, creating and bulk deleting a SalesOrderAttachment (file upload).""" - - queryset = models.SalesOrderAttachment.objects.all() - serializer_class = serializers.SalesOrderAttachmentSerializer - - filterset_fields = ['order'] - - -class SalesOrderAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI): - """Detail endpoint for SalesOrderAttachment.""" - - queryset = models.SalesOrderAttachment.objects.all() - serializer_class = serializers.SalesOrderAttachmentSerializer - - class SalesOrderFilter(OrderFilter): """Custom API filters for the SalesOrderList endpoint.""" @@ -1150,22 +1134,6 @@ class SalesOrderShipmentComplete(CreateAPI): return ctx -class PurchaseOrderAttachmentList(AttachmentMixin, ListCreateDestroyAPIView): - """API endpoint for listing, creating and bulk deleting) a PurchaseOrderAttachment (file upload).""" - - queryset = models.PurchaseOrderAttachment.objects.all() - serializer_class = serializers.PurchaseOrderAttachmentSerializer - - filterset_fields = ['order'] - - -class PurchaseOrderAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI): - """Detail endpoint for a PurchaseOrderAttachment.""" - - queryset = models.PurchaseOrderAttachment.objects.all() - serializer_class = serializers.PurchaseOrderAttachmentSerializer - - class ReturnOrderFilter(OrderFilter): """Custom API filters for the ReturnOrderList endpoint.""" @@ -1416,22 +1384,6 @@ class ReturnOrderExtraLineDetail(RetrieveUpdateDestroyAPI): serializer_class = serializers.ReturnOrderExtraLineSerializer -class ReturnOrderAttachmentList(AttachmentMixin, ListCreateDestroyAPIView): - """API endpoint for listing, creating and bulk deleting a ReturnOrderAttachment (file upload).""" - - queryset = models.ReturnOrderAttachment.objects.all() - serializer_class = serializers.ReturnOrderAttachmentSerializer - - filterset_fields = ['order'] - - -class ReturnOrderAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI): - """Detail endpoint for the ReturnOrderAttachment model.""" - - queryset = models.ReturnOrderAttachment.objects.all() - serializer_class = serializers.ReturnOrderAttachmentSerializer - - class OrderCalendarExport(ICalFeed): """Calendar export for Purchase/Sales Orders. @@ -1514,7 +1466,9 @@ class OrderCalendarExport(ICalFeed): else: ordertype_title = _('Unknown') - return f'{common_models.InvenTreeSetting.get_setting("INVENTREE_COMPANY_NAME")} {ordertype_title}' + company_name = common.settings.get_global_setting('INVENTREE_COMPANY_NAME') + + return f'{company_name} {ordertype_title}' def product_id(self, obj): """Return calendar product id.""" @@ -1597,22 +1551,6 @@ order_api_urls = [ path( 'po/', include([ - # Purchase order attachments - path( - 'attachment/', - include([ - path( - '/', - PurchaseOrderAttachmentDetail.as_view(), - name='api-po-attachment-detail', - ), - path( - '', - PurchaseOrderAttachmentList.as_view(), - name='api-po-attachment-list', - ), - ]), - ), # Individual purchase order detail URLs path( '/', @@ -1704,21 +1642,6 @@ order_api_urls = [ path( 'so/', include([ - path( - 'attachment/', - include([ - path( - '/', - SalesOrderAttachmentDetail.as_view(), - name='api-so-attachment-detail', - ), - path( - '', - SalesOrderAttachmentList.as_view(), - name='api-so-attachment-list', - ), - ]), - ), path( 'shipment/', include([ @@ -1854,21 +1777,6 @@ order_api_urls = [ path( 'ro/', include([ - path( - 'attachment/', - include([ - path( - '/', - ReturnOrderAttachmentDetail.as_view(), - name='api-return-order-attachment-detail', - ), - path( - '', - ReturnOrderAttachmentList.as_view(), - name='api-return-order-attachment-list', - ), - ]), - ), # Return Order detail endpoints path( '/', diff --git a/src/backend/InvenTree/order/migrations/0016_purchaseorderattachment.py b/src/backend/InvenTree/order/migrations/0016_purchaseorderattachment.py index 25b43e222d..750642f6f9 100644 --- a/src/backend/InvenTree/order/migrations/0016_purchaseorderattachment.py +++ b/src/backend/InvenTree/order/migrations/0016_purchaseorderattachment.py @@ -16,7 +16,7 @@ class Migration(migrations.Migration): name='PurchaseOrderAttachment', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('attachment', models.FileField(help_text='Select file to attach', upload_to=InvenTree.models.rename_attachment)), + ('attachment', models.FileField(help_text='Select file to attach', upload_to='attachments')), ('comment', models.CharField(help_text='File comment', max_length=100)), ('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='order.PurchaseOrder')), ], diff --git a/src/backend/InvenTree/order/migrations/0020_auto_20200420_0940.py b/src/backend/InvenTree/order/migrations/0020_auto_20200420_0940.py index 76e903b45a..44d1401438 100644 --- a/src/backend/InvenTree/order/migrations/0020_auto_20200420_0940.py +++ b/src/backend/InvenTree/order/migrations/0020_auto_20200420_0940.py @@ -65,7 +65,7 @@ class Migration(migrations.Migration): name='SalesOrderAttachment', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('attachment', models.FileField(help_text='Select file to attach', upload_to=InvenTree.models.rename_attachment)), + ('attachment', models.FileField(help_text='Select file to attach', upload_to='attachments')), ('comment', models.CharField(help_text='File comment', max_length=100)), ('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='order.SalesOrder')), ], diff --git a/src/backend/InvenTree/order/migrations/0044_auto_20210404_2016.py b/src/backend/InvenTree/order/migrations/0044_auto_20210404_2016.py index ef69235545..d97c9accc1 100644 --- a/src/backend/InvenTree/order/migrations/0044_auto_20210404_2016.py +++ b/src/backend/InvenTree/order/migrations/0044_auto_20210404_2016.py @@ -67,7 +67,7 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='purchaseorderattachment', name='attachment', - field=models.FileField(help_text='Select file to attach', upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'), + field=models.FileField(help_text='Select file to attach', upload_to='attachments', verbose_name='Attachment'), ), migrations.AlterField( model_name='purchaseorderattachment', @@ -187,7 +187,7 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='salesorderattachment', name='attachment', - field=models.FileField(help_text='Select file to attach', upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'), + field=models.FileField(help_text='Select file to attach', upload_to='attachments', verbose_name='Attachment'), ), migrations.AlterField( model_name='salesorderattachment', diff --git a/src/backend/InvenTree/order/migrations/0053_auto_20211128_0151.py b/src/backend/InvenTree/order/migrations/0053_auto_20211128_0151.py index bbe029b4af..a4e0ec30a0 100644 --- a/src/backend/InvenTree/order/migrations/0053_auto_20211128_0151.py +++ b/src/backend/InvenTree/order/migrations/0053_auto_20211128_0151.py @@ -25,11 +25,11 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='purchaseorderattachment', name='attachment', - field=models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'), + field=models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to='attachments', verbose_name='Attachment'), ), migrations.AlterField( model_name='salesorderattachment', name='attachment', - field=models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'), + field=models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to='attachments', verbose_name='Attachment'), ), ] diff --git a/src/backend/InvenTree/order/migrations/0081_auto_20230314_0725.py b/src/backend/InvenTree/order/migrations/0081_auto_20230314_0725.py index f15b800ffe..ed815aa5a9 100644 --- a/src/backend/InvenTree/order/migrations/0081_auto_20230314_0725.py +++ b/src/backend/InvenTree/order/migrations/0081_auto_20230314_0725.py @@ -51,7 +51,7 @@ class Migration(migrations.Migration): name='ReturnOrderAttachment', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('attachment', models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment')), + ('attachment', models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to='attachments', verbose_name='Attachment')), ('link', InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', null=True, verbose_name='Link')), ('comment', models.CharField(blank=True, help_text='File comment', max_length=100, verbose_name='Comment')), ('upload_date', models.DateField(auto_now_add=True, null=True, verbose_name='upload date')), diff --git a/src/backend/InvenTree/order/migrations/0100_remove_returnorderattachment_order_and_more.py b/src/backend/InvenTree/order/migrations/0100_remove_returnorderattachment_order_and_more.py new file mode 100644 index 0000000000..01e5679ebd --- /dev/null +++ b/src/backend/InvenTree/order/migrations/0100_remove_returnorderattachment_order_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 4.2.12 on 2024-06-09 09:02 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('build', '0050_auto_20240508_0138'), + ('common', '0026_auto_20240608_1238'), + ('company', '0069_company_active'), + ('order', '0099_alter_salesorder_status'), + ('part', '0123_parttesttemplate_choices'), + ('stock', '0110_alter_stockitemtestresult_finished_datetime_and_more') + ] + + operations = [ + migrations.DeleteModel( + name='PurchaseOrderAttachment', + ), + migrations.DeleteModel( + name='ReturnOrderAttachment', + ), + migrations.DeleteModel( + name='SalesOrderAttachment', + ), + ] diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py index 8fb2c8449c..077dd94bda 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -184,6 +184,7 @@ class TotalPriceMixin(models.Model): class Order( StateTransitionMixin, + InvenTree.models.InvenTreeAttachmentMixin, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNotesMixin, report.mixins.InvenTreeReportMixin, @@ -1236,40 +1237,6 @@ def after_save_sales_order(sender, instance: SalesOrder, created: bool, **kwargs notify_responsible(instance, sender, exclude=instance.created_by) -class PurchaseOrderAttachment(InvenTree.models.InvenTreeAttachment): - """Model for storing file attachments against a PurchaseOrder object.""" - - @staticmethod - def get_api_url(): - """Return the API URL associated with the PurchaseOrderAttachment model.""" - return reverse('api-po-attachment-list') - - def getSubdir(self): - """Return the directory path where PurchaseOrderAttachment files are located.""" - return os.path.join('po_files', str(self.order.id)) - - order = models.ForeignKey( - PurchaseOrder, on_delete=models.CASCADE, related_name='attachments' - ) - - -class SalesOrderAttachment(InvenTree.models.InvenTreeAttachment): - """Model for storing file attachments against a SalesOrder object.""" - - @staticmethod - def get_api_url(): - """Return the API URL associated with the SalesOrderAttachment class.""" - return reverse('api-so-attachment-list') - - def getSubdir(self): - """Return the directory path where SalesOrderAttachment files are located.""" - return os.path.join('so_files', str(self.order.id)) - - order = models.ForeignKey( - SalesOrder, on_delete=models.CASCADE, related_name='attachments' - ) - - class OrderLineItem(InvenTree.models.InvenTreeMetadataModel): """Abstract model for an order line item. @@ -2315,20 +2282,3 @@ class ReturnOrderExtraLine(OrderExtraLine): verbose_name=_('Order'), help_text=_('Return Order'), ) - - -class ReturnOrderAttachment(InvenTree.models.InvenTreeAttachment): - """Model for storing file attachments against a ReturnOrder object.""" - - @staticmethod - def get_api_url(): - """Return the API URL associated with the ReturnOrderAttachment class.""" - return reverse('api-return-order-attachment-list') - - def getSubdir(self): - """Return the directory path where ReturnOrderAttachment files are located.""" - return os.path.join('return_files', str(self.order.id)) - - order = models.ForeignKey( - ReturnOrder, on_delete=models.CASCADE, related_name='attachments' - ) diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py index ee882beaa3..61e289eaf4 100644 --- a/src/backend/InvenTree/order/serializers.py +++ b/src/backend/InvenTree/order/serializers.py @@ -1,6 +1,5 @@ """JSON serializers for the Order API.""" -from datetime import datetime from decimal import Decimal from django.core.exceptions import ValidationError as DjangoValidationError @@ -42,7 +41,6 @@ from InvenTree.helpers import ( str2bool, ) from InvenTree.serializers import ( - InvenTreeAttachmentSerializer, InvenTreeCurrencySerializer, InvenTreeDecimalField, InvenTreeModelSerializer, @@ -757,17 +755,6 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer): raise ValidationError(detail=serializers.as_serializer_error(exc)) -class PurchaseOrderAttachmentSerializer(InvenTreeAttachmentSerializer): - """Serializers for the PurchaseOrderAttachment model.""" - - class Meta: - """Metaclass options.""" - - model = order.models.PurchaseOrderAttachment - - fields = InvenTreeAttachmentSerializer.attachment_fields(['order']) - - class SalesOrderSerializer( NotesFieldMixin, TotalPriceMixin, AbstractOrderSerializer, InvenTreeModelSerializer ): @@ -1525,17 +1512,6 @@ class SalesOrderExtraLineSerializer( order_detail = SalesOrderSerializer(source='order', many=False, read_only=True) -class SalesOrderAttachmentSerializer(InvenTreeAttachmentSerializer): - """Serializers for the SalesOrderAttachment model.""" - - class Meta: - """Metaclass options.""" - - model = order.models.SalesOrderAttachment - - fields = InvenTreeAttachmentSerializer.attachment_fields(['order']) - - class ReturnOrderSerializer( NotesFieldMixin, AbstractOrderSerializer, TotalPriceMixin, InvenTreeModelSerializer ): @@ -1778,14 +1754,3 @@ class ReturnOrderExtraLineSerializer( model = order.models.ReturnOrderExtraLine order_detail = ReturnOrderSerializer(source='order', many=False, read_only=True) - - -class ReturnOrderAttachmentSerializer(InvenTreeAttachmentSerializer): - """Serializer for the ReturnOrderAttachment model.""" - - class Meta: - """Metaclass options.""" - - model = order.models.ReturnOrderAttachment - - fields = InvenTreeAttachmentSerializer.attachment_fields(['order']) diff --git a/src/backend/InvenTree/order/templates/order/purchase_order_detail.html b/src/backend/InvenTree/order/templates/order/purchase_order_detail.html index 9abc95f365..78da5925ac 100644 --- a/src/backend/InvenTree/order/templates/order/purchase_order_detail.html +++ b/src/backend/InvenTree/order/templates/order/purchase_order_detail.html @@ -132,17 +132,7 @@ }); onPanelLoad('order-attachments', function() { - loadAttachmentTable('{% url "api-po-attachment-list" %}', { - filters: { - order: {{ order.pk }}, - }, - fields: { - order: { - value: {{ order.pk }}, - hidden: true, - } - } - }); + loadAttachmentTable('purchaseorder', {{ order.pk }}); }); loadStockTable($("#stock-table"), { diff --git a/src/backend/InvenTree/order/templates/order/return_order_detail.html b/src/backend/InvenTree/order/templates/order/return_order_detail.html index 8cabf3a9e2..279ddc66cc 100644 --- a/src/backend/InvenTree/order/templates/order/return_order_detail.html +++ b/src/backend/InvenTree/order/templates/order/return_order_detail.html @@ -189,17 +189,7 @@ onPanelLoad('order-notes', function() { // Callback function when the 'attachments' panel is loaded onPanelLoad('order-attachments', function() { - loadAttachmentTable('{% url "api-return-order-attachment-list" %}', { - filters: { - order: {{ order.pk }}, - }, - fields: { - order: { - value: {{ order.pk }}, - hidden: true, - }, - } - }); + loadAttachmentTable('returnorder', {{ order.pk }}); }); enableSidebar('returnorder'); diff --git a/src/backend/InvenTree/order/templates/order/sales_order_detail.html b/src/backend/InvenTree/order/templates/order/sales_order_detail.html index 3b92201f10..c135211ab5 100644 --- a/src/backend/InvenTree/order/templates/order/sales_order_detail.html +++ b/src/backend/InvenTree/order/templates/order/sales_order_detail.html @@ -203,17 +203,7 @@ onPanelLoad('order-attachments', function() { - loadAttachmentTable('{% url "api-so-attachment-list" %}', { - filters: { - order: {{ order.pk }}, - }, - fields: { - order: { - value: {{ order.pk }}, - hidden: true, - }, - } - }); + loadAttachmentTable('salesorder', {{ order.pk }}); }); loadBuildTable($("#builds-table"), { diff --git a/src/backend/InvenTree/order/test_api.py b/src/backend/InvenTree/order/test_api.py index 73518b7ac8..440a09668f 100644 --- a/src/backend/InvenTree/order/test_api.py +++ b/src/backend/InvenTree/order/test_api.py @@ -258,9 +258,9 @@ class PurchaseOrderTest(OrderTest): def test_po_attachments(self): """Test the list endpoint for the PurchaseOrderAttachment model.""" - url = reverse('api-po-attachment-list') + url = reverse('api-attachment-list') - response = self.get(url) + response = self.get(url, {'model_type': 'purchaseorder'}) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -1260,9 +1260,12 @@ class SalesOrderTest(OrderTest): def test_so_attachments(self): """Test the list endpoint for the SalesOrderAttachment model.""" - url = reverse('api-so-attachment-list') + url = reverse('api-attachment-list') - self.get(url) + # Filter by 'salesorder' + self.get( + url, data={'model_type': 'salesorder', 'model_id': 1}, expected_code=200 + ) def test_so_operations(self): """Test that we can create / edit and delete a SalesOrder via the API.""" diff --git a/src/backend/InvenTree/part/admin.py b/src/backend/InvenTree/part/admin.py index e526dfc7ab..7ca74d75bc 100644 --- a/src/backend/InvenTree/part/admin.py +++ b/src/backend/InvenTree/part/admin.py @@ -353,14 +353,6 @@ class PartRelatedAdmin(admin.ModelAdmin): autocomplete_fields = ('part_1', 'part_2') -class PartAttachmentAdmin(admin.ModelAdmin): - """Admin class for the PartAttachment model.""" - - list_display = ('part', 'attachment', 'comment') - - autocomplete_fields = ('part',) - - class PartTestTemplateAdmin(admin.ModelAdmin): """Admin class for the PartTestTemplate model.""" @@ -607,7 +599,6 @@ class PartInternalPriceBreakAdmin(admin.ModelAdmin): admin.site.register(models.Part, PartAdmin) admin.site.register(models.PartCategory, PartCategoryAdmin) admin.site.register(models.PartRelated, PartRelatedAdmin) -admin.site.register(models.PartAttachment, PartAttachmentAdmin) admin.site.register(models.BomItem, BomItemAdmin) admin.site.register(models.PartParameterTemplate, ParameterTemplateAdmin) admin.site.register(models.PartParameter, ParameterAdmin) diff --git a/src/backend/InvenTree/part/api.py b/src/backend/InvenTree/part/api.py index 327a03e75d..acfe95eaca 100644 --- a/src/backend/InvenTree/part/api.py +++ b/src/backend/InvenTree/part/api.py @@ -19,12 +19,7 @@ import order.models import part.filters from build.models import Build, BuildItem from build.status_codes import BuildStatusGroups -from InvenTree.api import ( - APIDownloadMixin, - AttachmentMixin, - ListCreateDestroyAPIView, - MetadataView, -) +from InvenTree.api import APIDownloadMixin, ListCreateDestroyAPIView, MetadataView from InvenTree.filters import ( ORDER_FILTER, ORDER_FILTER_ALIAS, @@ -56,7 +51,6 @@ from .models import ( BomItem, BomItemSubstitute, Part, - PartAttachment, PartCategory, PartCategoryParameterTemplate, PartInternalPriceBreak, @@ -404,22 +398,6 @@ class PartInternalPriceList(ListCreateAPI): ordering = 'quantity' -class PartAttachmentList(AttachmentMixin, ListCreateDestroyAPIView): - """API endpoint for listing, creating and bulk deleting a PartAttachment (file upload).""" - - queryset = PartAttachment.objects.all() - serializer_class = part_serializers.PartAttachmentSerializer - - filterset_fields = ['part'] - - -class PartAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI): - """Detail endpoint for PartAttachment model.""" - - queryset = PartAttachment.objects.all() - serializer_class = part_serializers.PartAttachmentSerializer - - class PartTestTemplateFilter(rest_filters.FilterSet): """Custom filterset class for the PartTestTemplateList endpoint.""" @@ -2059,18 +2037,6 @@ part_api_urls = [ ), ]), ), - # Base URL for PartAttachment API endpoints - path( - 'attachment/', - include([ - path( - '/', - PartAttachmentDetail.as_view(), - name='api-part-attachment-detail', - ), - path('', PartAttachmentList.as_view(), name='api-part-attachment-list'), - ]), - ), # Base URL for part sale pricing path( 'sale-price/', diff --git a/src/backend/InvenTree/part/migrations/0032_auto_20200322_0453.py b/src/backend/InvenTree/part/migrations/0032_auto_20200322_0453.py index 29fb25f1e7..6b1403b0be 100644 --- a/src/backend/InvenTree/part/migrations/0032_auto_20200322_0453.py +++ b/src/backend/InvenTree/part/migrations/0032_auto_20200322_0453.py @@ -14,6 +14,6 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='partattachment', name='attachment', - field=models.FileField(help_text='Select file to attach', upload_to=InvenTree.models.rename_attachment), + field=models.FileField(help_text='Select file to attach', upload_to='attachments'), ), ] diff --git a/src/backend/InvenTree/part/migrations/0064_auto_20210404_2016.py b/src/backend/InvenTree/part/migrations/0064_auto_20210404_2016.py index 57943347a1..90cc04f885 100644 --- a/src/backend/InvenTree/part/migrations/0064_auto_20210404_2016.py +++ b/src/backend/InvenTree/part/migrations/0064_auto_20210404_2016.py @@ -98,7 +98,7 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='partattachment', name='attachment', - field=models.FileField(help_text='Select file to attach', upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'), + field=models.FileField(help_text='Select file to attach', upload_to='attachments', verbose_name='Attachment'), ), migrations.AlterField( model_name='partattachment', diff --git a/src/backend/InvenTree/part/migrations/0075_auto_20211128_0151.py b/src/backend/InvenTree/part/migrations/0075_auto_20211128_0151.py index d484a7adce..f516846ae2 100644 --- a/src/backend/InvenTree/part/migrations/0075_auto_20211128_0151.py +++ b/src/backend/InvenTree/part/migrations/0075_auto_20211128_0151.py @@ -20,6 +20,6 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='partattachment', name='attachment', - field=models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'), + field=models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to='attachments', verbose_name='Attachment'), ), ] diff --git a/src/backend/InvenTree/part/migrations/0124_delete_partattachment.py b/src/backend/InvenTree/part/migrations/0124_delete_partattachment.py new file mode 100644 index 0000000000..5213211aa4 --- /dev/null +++ b/src/backend/InvenTree/part/migrations/0124_delete_partattachment.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.12 on 2024-06-09 09:02 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('build', '0050_auto_20240508_0138'), + ('common', '0026_auto_20240608_1238'), + ('company', '0069_company_active'), + ('order', '0099_alter_salesorder_status'), + ('part', '0123_parttesttemplate_choices'), + ('stock', '0110_alter_stockitemtestresult_finished_datetime_and_more') + ] + + operations = [ + migrations.DeleteModel( + name='PartAttachment', + ), + ] diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py index bd84892ff3..8fa973a964 100644 --- a/src/backend/InvenTree/part/models.py +++ b/src/backend/InvenTree/part/models.py @@ -341,6 +341,7 @@ class PartManager(TreeManager): @cleanup.ignore class Part( + InvenTree.models.InvenTreeAttachmentMixin, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNotesMixin, report.mixins.InvenTreeReportMixin, @@ -2208,24 +2209,6 @@ class Part( required=True, enabled=enabled, include_parent=include_parent ) - @property - def attachment_count(self): - """Count the number of attachments for this part. - - If the part is a variant of a template part, - include the number of attachments for the template part. - """ - return self.part_attachments.count() - - @property - def part_attachments(self): - """Return *all* attachments for this part, potentially including attachments for template parts above this one.""" - ancestors = self.get_ancestors(include_self=True) - - attachments = PartAttachment.objects.filter(part__in=ancestors) - - return attachments - def sales_orders(self): """Return a list of sales orders which reference this part.""" orders = [] @@ -3299,26 +3282,6 @@ class PartStocktakeReport(models.Model): ) -class PartAttachment(InvenTree.models.InvenTreeAttachment): - """Model for storing file attachments against a Part object.""" - - @staticmethod - def get_api_url(): - """Return the list API endpoint URL associated with the PartAttachment model.""" - return reverse('api-part-attachment-list') - - def getSubdir(self): - """Returns the media subdirectory where part attachments are stored.""" - return os.path.join('part_files', str(self.part.id)) - - part = models.ForeignKey( - Part, - on_delete=models.CASCADE, - verbose_name=_('Part'), - related_name='attachments', - ) - - class PartSellPriceBreak(common.models.PriceBreak): """Represents a price break for selling this part.""" diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index 3cf35becad..6bdc61cd6b 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -22,7 +22,6 @@ from sql_util.utils import SubqueryCount, SubquerySum from taggit.serializers import TagListSerializerField import common.currency -import common.models import common.settings import company.models import InvenTree.helpers @@ -41,7 +40,6 @@ from .models import ( BomItem, BomItemSubstitute, Part, - PartAttachment, PartCategory, PartCategoryParameterTemplate, PartInternalPriceBreak, @@ -147,19 +145,6 @@ class CategoryTree(InvenTree.serializers.InvenTreeModelSerializer): return queryset.annotate(subcategories=part.filters.annotate_sub_categories()) -class PartAttachmentSerializer(InvenTree.serializers.InvenTreeAttachmentSerializer): - """Serializer for the PartAttachment class.""" - - class Meta: - """Metaclass defining serializer fields.""" - - model = PartAttachment - - fields = InvenTree.serializers.InvenTreeAttachmentSerializer.attachment_fields([ - 'part' - ]) - - class PartTestTemplateSerializer(InvenTree.serializers.InvenTreeModelSerializer): """Serializer for the PartTestTemplate class.""" @@ -1171,7 +1156,7 @@ class PartStocktakeReportGenerateSerializer(serializers.Serializer): def validate(self, data): """Custom validation for this serializer.""" # Stocktake functionality must be enabled - if not common.models.InvenTreeSetting.get_setting('STOCKTAKE_ENABLE', False): + if not common.settings.get_global_setting('STOCKTAKE_ENABLE'): raise serializers.ValidationError( _('Stocktake functionality is not enabled') ) diff --git a/src/backend/InvenTree/part/templates/part/detail.html b/src/backend/InvenTree/part/templates/part/detail.html index f65a5e709f..71a0efd366 100644 --- a/src/backend/InvenTree/part/templates/part/detail.html +++ b/src/backend/InvenTree/part/templates/part/detail.html @@ -803,17 +803,7 @@ }); onPanelLoad("part-attachments", function() { - loadAttachmentTable('{% url "api-part-attachment-list" %}', { - filters: { - part: {{ part.pk }}, - }, - fields: { - part: { - value: {{ part.pk }}, - hidden: true - } - } - }); + loadAttachmentTable('part', {{ part.pk }}); }); onPanelLoad('pricing', function() { diff --git a/src/backend/InvenTree/part/test_api.py b/src/backend/InvenTree/part/test_api.py index da3f4df47c..6fb7265410 100644 --- a/src/backend/InvenTree/part/test_api.py +++ b/src/backend/InvenTree/part/test_api.py @@ -2513,22 +2513,28 @@ class PartAttachmentTest(InvenTreeAPITestCase): def test_add_attachment(self): """Test that we can create a new PartAttachment via the API.""" - url = reverse('api-part-attachment-list') + url = reverse('api-attachment-list') # Upload without permission - response = self.post(url, {}, expected_code=403) + response = self.post( + url, {'model_id': 1, 'model_type': 'part'}, expected_code=403 + ) # Add required permission self.assignRole('part.add') + self.assignRole('part.change') # Upload without specifying part (will fail) response = self.post(url, {'comment': 'Hello world'}, expected_code=400) - self.assertIn('This field is required', str(response.data['part'])) + self.assertIn('This field is required', str(response.data['model_id'])) + self.assertIn('This field is required', str(response.data['model_type'])) # Upload without file OR link (will fail) response = self.post( - url, {'part': 1, 'comment': 'Hello world'}, expected_code=400 + url, + {'model_id': 1, 'model_type': 'part', 'comment': 'Hello world'}, + expected_code=400, ) self.assertIn('Missing file', str(response.data['attachment'])) @@ -2536,7 +2542,9 @@ class PartAttachmentTest(InvenTreeAPITestCase): # Upload an invalid link (will fail) response = self.post( - url, {'part': 1, 'link': 'not-a-link.py'}, expected_code=400 + url, + {'model_id': 1, 'model_type': 'part', 'link': 'not-a-link.py'}, + expected_code=400, ) self.assertIn('Enter a valid URL', str(response.data['link'])) @@ -2545,12 +2553,20 @@ class PartAttachmentTest(InvenTreeAPITestCase): # Upload a valid link (will pass) response = self.post( - url, {'part': 1, 'link': link, 'comment': 'Hello world'}, expected_code=201 + url, + { + 'model_id': 1, + 'model_type': 'part', + 'link': link, + 'comment': 'Hello world', + }, + expected_code=201, ) data = response.data - self.assertEqual(data['part'], 1) + self.assertEqual(data['model_type'], 'part') + self.assertEqual(data['model_id'], 1) self.assertEqual(data['link'], link) self.assertEqual(data['comment'], 'Hello world') diff --git a/src/backend/InvenTree/plugin/base/integration/ScheduleMixin.py b/src/backend/InvenTree/plugin/base/integration/ScheduleMixin.py index e12cc2a25d..8b350a7ed1 100644 --- a/src/backend/InvenTree/plugin/base/integration/ScheduleMixin.py +++ b/src/backend/InvenTree/plugin/base/integration/ScheduleMixin.py @@ -5,6 +5,7 @@ import logging from django.conf import settings from django.db.utils import OperationalError, ProgrammingError +from common.settings import get_global_setting from plugin.helpers import MixinImplementationError logger = logging.getLogger('inventree') @@ -58,16 +59,12 @@ class ScheduleMixin: @classmethod def _activate_mixin(cls, registry, plugins, *args, **kwargs): """Activate schedules from plugins with the ScheduleMixin.""" - from common.models import InvenTreeSetting - logger.debug('Activating plugin tasks') # List of tasks we have activated task_keys = [] - if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting( - 'ENABLE_PLUGINS_SCHEDULE' - ): + if settings.PLUGIN_TESTING or get_global_setting('ENABLE_PLUGINS_SCHEDULE'): for _key, plugin in plugins: if plugin.mixin_enabled('schedule') and plugin.is_active(): # Only active tasks for plugins which are enabled diff --git a/src/backend/InvenTree/plugin/registry.py b/src/backend/InvenTree/plugin/registry.py index f837c64ce0..0d41c2963e 100644 --- a/src/backend/InvenTree/plugin/registry.py +++ b/src/backend/InvenTree/plugin/registry.py @@ -786,7 +786,7 @@ class PluginsRegistry: for k in self.plugin_settings_keys(): try: - val = get_global_setting(k, False, create=False) + val = get_global_setting(k) msg = f'{k}-{val}' data.update(msg.encode()) diff --git a/src/backend/InvenTree/plugin/urls.py b/src/backend/InvenTree/plugin/urls.py index ac454e588e..5d6485e4ec 100644 --- a/src/backend/InvenTree/plugin/urls.py +++ b/src/backend/InvenTree/plugin/urls.py @@ -3,20 +3,18 @@ from django.conf import settings from django.urls import include, re_path +from common.validators import get_global_setting + PLUGIN_BASE = 'plugin' # Constant for links def get_plugin_urls(): """Returns a urlpattern that can be integrated into the global urls.""" - from common.models import InvenTreeSetting from plugin.registry import registry urls = [] - if ( - InvenTreeSetting.get_setting('ENABLE_PLUGINS_URL', False) - or settings.PLUGIN_TESTING_SETUP - ): + if get_global_setting('ENABLE_PLUGINS_URL', False) or settings.PLUGIN_TESTING_SETUP: for plugin in registry.plugins.values(): if plugin.mixin_enabled('urls'): urls.append(plugin.urlpatterns) diff --git a/src/backend/InvenTree/report/helpers.py b/src/backend/InvenTree/report/helpers.py index a790b02662..8dcb196024 100644 --- a/src/backend/InvenTree/report/helpers.py +++ b/src/backend/InvenTree/report/helpers.py @@ -70,7 +70,7 @@ def page_size(page_code): def report_page_size_default(): """Returns the default page size for PDF reports.""" try: - page_size = get_global_setting('REPORT_DEFAULT_PAGE_SIZE', 'A4') + page_size = get_global_setting('REPORT_DEFAULT_PAGE_SIZE', 'A4', create=False) except Exception as exc: logger.exception('Error getting default page size: %s', str(exc)) page_size = 'A4' diff --git a/src/backend/InvenTree/report/models.py b/src/backend/InvenTree/report/models.py index 05584b5588..ec4ba2f2d5 100644 --- a/src/backend/InvenTree/report/models.py +++ b/src/backend/InvenTree/report/models.py @@ -21,6 +21,7 @@ import InvenTree.helpers import InvenTree.models import report.helpers import report.validators +from common.settings import get_global_setting from InvenTree.helpers_model import get_base_url from InvenTree.models import MetadataMixin from plugin.registry import registry @@ -311,8 +312,8 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase): def get_report_size(self): """Return the printable page size for this report.""" try: - page_size_default = common.models.InvenTreeSetting.get_setting( - 'REPORT_DEFAULT_PAGE_SIZE', 'A4' + page_size_default = get_global_setting( + 'REPORT_DEFAULT_PAGE_SIZE', 'A4', create=False ) except Exception: page_size_default = 'A4' diff --git a/src/backend/InvenTree/report/tests.py b/src/backend/InvenTree/report/tests.py index bf30286f15..b5ac753fd9 100644 --- a/src/backend/InvenTree/report/tests.py +++ b/src/backend/InvenTree/report/tests.py @@ -15,14 +15,14 @@ from PIL import Image import report.models as report_models from build.models import Build -from common.models import InvenTreeSetting +from common.models import Attachment, InvenTreeSetting from InvenTree.unit_test import InvenTreeAPITestCase from order.models import ReturnOrder, SalesOrder from plugin.registry import registry from report.models import LabelTemplate, ReportTemplate from report.templatetags import barcode as barcode_tags from report.templatetags import report as report_tags -from stock.models import StockItem, StockItemAttachment +from stock.models import StockItem class ReportTagTest(TestCase): @@ -502,7 +502,7 @@ class PrintTestMixins: }, expected_code=201, max_query_time=15, - max_query_count=1000, # TODO: Should look into this + max_query_count=500 * len(qs), ) @@ -548,7 +548,9 @@ class TestReportTest(PrintTestMixins, ReportTest): self.assertEqual(response.data['output'].startswith('/media/report/'), True) # By default, this should *not* have created an attachment against this stockitem - self.assertFalse(StockItemAttachment.objects.filter(stock_item=item).exists()) + self.assertFalse( + Attachment.objects.filter(model_id=item.pk, model_type='stockitem').exists() + ) return # TODO @matmair - Re-add this test after https://github.com/inventree/InvenTree/pull/7074/files#r1600694356 is resolved @@ -563,7 +565,9 @@ class TestReportTest(PrintTestMixins, ReportTest): self.assertEqual(response.data['output'].startswith('/media/report/'), True) # Check that a report has been uploaded - attachment = StockItemAttachment.objects.filter(stock_item=item).first() + attachment = Attachment.objects.filter( + model_id=item.pk, model_type='stockitem' + ).first() self.assertIsNotNone(attachment) def test_mdl_build(self): diff --git a/src/backend/InvenTree/stock/admin.py b/src/backend/InvenTree/stock/admin.py index eca6728dac..3ca5c6a664 100644 --- a/src/backend/InvenTree/stock/admin.py +++ b/src/backend/InvenTree/stock/admin.py @@ -16,7 +16,6 @@ from part.models import Part from .models import ( StockItem, - StockItemAttachment, StockItemTestResult, StockItemTracking, StockLocation, @@ -301,15 +300,6 @@ class StockItemAdmin(ImportExportModelAdmin): ] -@admin.register(StockItemAttachment) -class StockAttachmentAdmin(admin.ModelAdmin): - """Admin class for StockAttachment.""" - - list_display = ('stock_item', 'attachment', 'comment') - - autocomplete_fields = ['stock_item'] - - @admin.register(StockItemTracking) class StockTrackingAdmin(ImportExportModelAdmin): """Admin class for StockTracking.""" diff --git a/src/backend/InvenTree/stock/api.py b/src/backend/InvenTree/stock/api.py index 4142083006..4b6903bf96 100644 --- a/src/backend/InvenTree/stock/api.py +++ b/src/backend/InvenTree/stock/api.py @@ -28,12 +28,7 @@ from build.serializers import BuildSerializer from company.models import Company, SupplierPart from company.serializers import CompanySerializer from generic.states.api import StatusView -from InvenTree.api import ( - APIDownloadMixin, - AttachmentMixin, - ListCreateDestroyAPIView, - MetadataView, -) +from InvenTree.api import APIDownloadMixin, ListCreateDestroyAPIView, MetadataView from InvenTree.filters import ( ORDER_FILTER_ALIAS, SEARCH_ORDER_FILTER, @@ -68,7 +63,6 @@ from stock.admin import LocationResource, StockItemResource from stock.generators import generate_batch_code, generate_serial_number from stock.models import ( StockItem, - StockItemAttachment, StockItemTestResult, StockItemTracking, StockLocation, @@ -1221,22 +1215,6 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView): ] -class StockAttachmentList(AttachmentMixin, ListCreateDestroyAPIView): - """API endpoint for listing, creating and bulk deleting a StockItemAttachment (file upload).""" - - queryset = StockItemAttachment.objects.all() - serializer_class = StockSerializers.StockItemAttachmentSerializer - - filterset_fields = ['stock_item'] - - -class StockAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI): - """Detail endpoint for StockItemAttachment.""" - - queryset = StockItemAttachment.objects.all() - serializer_class = StockSerializers.StockItemAttachmentSerializer - - class StockItemTestResultMixin: """Mixin class for the StockItemTestResult API endpoints.""" @@ -1609,18 +1587,6 @@ stock_api_urls = [ path('assign/', StockAssign.as_view(), name='api-stock-assign'), path('merge/', StockMerge.as_view(), name='api-stock-merge'), path('change_status/', StockChangeStatus.as_view(), name='api-stock-change-status'), - # StockItemAttachment API endpoints - path( - 'attachment/', - include([ - path( - '/', - StockAttachmentDetail.as_view(), - name='api-stock-attachment-detail', - ), - path('', StockAttachmentList.as_view(), name='api-stock-attachment-list'), - ]), - ), # StockItemTestResult API endpoints path( 'test/', diff --git a/src/backend/InvenTree/stock/migrations/0036_stockitemattachment.py b/src/backend/InvenTree/stock/migrations/0036_stockitemattachment.py index 946f6251b0..1ffc87b1e2 100644 --- a/src/backend/InvenTree/stock/migrations/0036_stockitemattachment.py +++ b/src/backend/InvenTree/stock/migrations/0036_stockitemattachment.py @@ -16,7 +16,7 @@ class Migration(migrations.Migration): name='StockItemAttachment', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('attachment', models.FileField(help_text='Select file to attach', upload_to=InvenTree.models.rename_attachment)), + ('attachment', models.FileField(help_text='Select file to attach', upload_to='attachments')), ('comment', models.CharField(help_text='File comment', max_length=100)), ('stock_item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='stock.StockItem')), ], diff --git a/src/backend/InvenTree/stock/migrations/0059_auto_20210404_2016.py b/src/backend/InvenTree/stock/migrations/0059_auto_20210404_2016.py index b027a53854..3305b719e5 100644 --- a/src/backend/InvenTree/stock/migrations/0059_auto_20210404_2016.py +++ b/src/backend/InvenTree/stock/migrations/0059_auto_20210404_2016.py @@ -32,7 +32,7 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='stockitemattachment', name='attachment', - field=models.FileField(help_text='Select file to attach', upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'), + field=models.FileField(help_text='Select file to attach', upload_to='attachments', verbose_name='Attachment'), ), migrations.AlterField( model_name='stockitemattachment', diff --git a/src/backend/InvenTree/stock/migrations/0070_auto_20211128_0151.py b/src/backend/InvenTree/stock/migrations/0070_auto_20211128_0151.py index a2f6ef322d..3c23c28f65 100644 --- a/src/backend/InvenTree/stock/migrations/0070_auto_20211128_0151.py +++ b/src/backend/InvenTree/stock/migrations/0070_auto_20211128_0151.py @@ -20,6 +20,6 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='stockitemattachment', name='attachment', - field=models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'), + field=models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to='attachments', verbose_name='Attachment'), ), ] diff --git a/src/backend/InvenTree/stock/migrations/0111_delete_stockitemattachment.py b/src/backend/InvenTree/stock/migrations/0111_delete_stockitemattachment.py new file mode 100644 index 0000000000..1526a63799 --- /dev/null +++ b/src/backend/InvenTree/stock/migrations/0111_delete_stockitemattachment.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.12 on 2024-06-09 09:02 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('build', '0050_auto_20240508_0138'), + ('common', '0026_auto_20240608_1238'), + ('company', '0069_company_active'), + ('order', '0099_alter_salesorder_status'), + ('part', '0123_parttesttemplate_choices'), + ('stock', '0110_alter_stockitemtestresult_finished_datetime_and_more') + ] + + operations = [ + migrations.DeleteModel( + name='StockItemAttachment', + ), + ] diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py index 192b8fa62f..f587cdf71f 100644 --- a/src/backend/InvenTree/stock/models.py +++ b/src/backend/InvenTree/stock/models.py @@ -316,6 +316,7 @@ def default_delete_on_deplete(): class StockItem( + InvenTree.models.InvenTreeAttachmentMixin, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNotesMixin, report.mixins.InvenTreeReportMixin, @@ -2255,23 +2256,6 @@ def after_save_stock_item(sender, instance: StockItem, created, **kwargs): instance.part.schedule_pricing_update(create=True) -class StockItemAttachment(InvenTree.models.InvenTreeAttachment): - """Model for storing file attachments against a StockItem object.""" - - @staticmethod - def get_api_url(): - """Return API url.""" - return reverse('api-stock-attachment-list') - - def getSubdir(self): - """Override attachment location.""" - return os.path.join('stock_files', str(self.stock_item.id)) - - stock_item = models.ForeignKey( - StockItem, on_delete=models.CASCADE, related_name='attachments' - ) - - class StockItemTracking(InvenTree.models.InvenTreeModel): """Stock tracking entry - used for tracking history of a particular StockItem. diff --git a/src/backend/InvenTree/stock/serializers.py b/src/backend/InvenTree/stock/serializers.py index 2e0c2d2531..c727741895 100644 --- a/src/backend/InvenTree/stock/serializers.py +++ b/src/backend/InvenTree/stock/serializers.py @@ -31,7 +31,6 @@ from part.serializers import PartBriefSerializer, PartTestTemplateSerializer from .models import ( StockItem, - StockItemAttachment, StockItemTestResult, StockItemTracking, StockLocation, @@ -1101,21 +1100,6 @@ class LocationSerializer(InvenTree.serializers.InvenTreeTagModelSerializer): ) -class StockItemAttachmentSerializer( - InvenTree.serializers.InvenTreeAttachmentSerializer -): - """Serializer for StockItemAttachment model.""" - - class Meta: - """Metaclass options.""" - - model = StockItemAttachment - - fields = InvenTree.serializers.InvenTreeAttachmentSerializer.attachment_fields([ - 'stock_item' - ]) - - class StockTrackingSerializer(InvenTree.serializers.InvenTreeModelSerializer): """Serializer for StockItemTracking model.""" diff --git a/src/backend/InvenTree/stock/templates/stock/item.html b/src/backend/InvenTree/stock/templates/stock/item.html index 214a409bcd..f85c5694e9 100644 --- a/src/backend/InvenTree/stock/templates/stock/item.html +++ b/src/backend/InvenTree/stock/templates/stock/item.html @@ -220,17 +220,7 @@ }); onPanelLoad('attachments', function() { - loadAttachmentTable('{% url "api-stock-attachment-list" %}', { - filters: { - stock_item: {{ item.pk }}, - }, - fields: { - stock_item: { - value: {{ item.pk }}, - hidden: true, - } - } - }); + loadAttachmentTable('stockitem', {{ item.pk }}); }); {% settings_value "TEST_STATION_DATA" as test_station_fields %} diff --git a/src/backend/InvenTree/stock/test_api.py b/src/backend/InvenTree/stock/test_api.py index a0cf28c3bf..7848fd8234 100644 --- a/src/backend/InvenTree/stock/test_api.py +++ b/src/backend/InvenTree/stock/test_api.py @@ -885,13 +885,6 @@ class StockItemListTest(StockAPITestCase): def test_query_count(self): """Test that the number of queries required to fetch stock items is reasonable.""" - - def get_stock(data, expected_status=200): - """Helper function to fetch stock items.""" - response = self.client.get(self.list_url, data=data) - self.assertEqual(response.status_code, expected_status) - return response.data - # Create a bunch of StockItem objects prt = Part.objects.first() @@ -901,20 +894,18 @@ class StockItemListTest(StockAPITestCase): ]) # List *all* stock items - with self.assertNumQueriesLessThan(25): - get_stock({}) + self.get(self.list_url, {}, max_query_count=35) # List all stock items, with part detail - with self.assertNumQueriesLessThan(20): - get_stock({'part_detail': True}) + self.get(self.list_url, {'part_detail': True}, max_query_count=35) # List all stock items, with supplier_part detail - with self.assertNumQueriesLessThan(20): - get_stock({'supplier_part_detail': True}) + self.get(self.list_url, {'supplier_part_detail': True}, max_query_count=35) # List all stock items, with 'location' and 'tests' detail - with self.assertNumQueriesLessThan(20): - get_stock({'location_detail': True, 'tests': True}) + self.get( + self.list_url, {'location_detail': True, 'tests': True}, max_query_count=35 + ) class StockItemTest(StockAPITestCase): diff --git a/src/backend/InvenTree/templates/js/translated/attachment.js b/src/backend/InvenTree/templates/js/translated/attachment.js index 4ec0b4b625..19f2c33544 100644 --- a/src/backend/InvenTree/templates/js/translated/attachment.js +++ b/src/backend/InvenTree/templates/js/translated/attachment.js @@ -214,34 +214,41 @@ function makeAttachmentActions(permissions, options) { /* Load a table of attachments against a specific model. * Note that this is a 'generic' table which is used for multiple attachment model classes */ -function loadAttachmentTable(url, options) { +function loadAttachmentTable(model_type, model_id, options={}) { - var table = options.table || '#attachment-table'; + const url = '{% url "api-attachment-list" %}'; + const table = options.table || '#attachment-table'; - var permissions = {}; + let filters = { + model_type: model_type, + model_id: model_id, + }; - // First we determine which permissions the user has for this attachment table + let permissions = { + delete: false, + add: false, + change: false, + }; + + // Request the permissions for the current user $.ajax({ - url: url, + url: '{% url "api-user-roles" %}', async: false, - type: 'OPTIONS', - contentType: 'application/json', dataType: 'json', - accepts: { - json: 'application/json', - }, + contentType: 'application/json', success: function(response) { - if (response.actions.DELETE) { + if (response.is_superuser) { permissions.delete = true; + permissions.add = true; + permissions.change = true; + return; } - if (response.actions.POST) { - permissions.change = true; - permissions.add = true; - } - }, - error: function(xhr) { - showApiError(xhr, url); + let model_permissions = response?.permissions[model_type] ?? {}; + + permissions.delete = "delete" in model_permissions; + permissions.add = "add" in model_permissions; + permissions.change = "change" in model_permissions; } }); @@ -261,7 +268,19 @@ function loadAttachmentTable(url, options) { }); if (permissions.add) { - addAttachmentButtonCallbacks(url, options.fields || {}); + addAttachmentButtonCallbacks( + url, + { + model_type: { + value: model_type, + hidden: true, + }, + model_id: { + value: model_id, + hidden: true, + }, + } + ); } else { // Hide the buttons $('#new-attachment').hide(); @@ -276,7 +295,7 @@ function loadAttachmentTable(url, options) { }, sortable: true, search: true, - queryParams: options.filters || {}, + queryParams: filters, uniqueId: 'pk', sidePagination: 'server', onPostBody: function() { @@ -386,7 +405,10 @@ function loadAttachmentTable(url, options) { '#attachment-dropzone', url, { - data: options.filters, + data: { + model_type: model_type, + model_id: model_id, + }, label: 'attachment', method: 'POST', success: function() { diff --git a/src/backend/InvenTree/users/api.py b/src/backend/InvenTree/users/api.py index 6ff708dbca..a7b89e4ef1 100644 --- a/src/backend/InvenTree/users/api.py +++ b/src/backend/InvenTree/users/api.py @@ -4,7 +4,8 @@ import datetime import logging from django.contrib.auth import get_user, login, logout -from django.contrib.auth.models import Group, User +from django.contrib.auth.models import Group, Permission, User +from django.db.models import Q from django.urls import include, path, re_path from django.views.generic.base import RedirectView @@ -137,10 +138,29 @@ class RoleDetails(APIView): else: roles[role] = None # pragma: no cover + # Extract individual permissions for the user + if user.is_superuser: + permissions = Permission.objects.all() + else: + permissions = Permission.objects.filter( + Q(user=user) | Q(group__user=user) + ).distinct() + + perms = {} + + for permission in permissions: + perm, model = permission.codename.split('_') + + if model not in perms: + perms[model] = [] + + perms[model].append(perm) + data = { 'user': user.pk, 'username': user.username, 'roles': roles, + 'permissions': perms, 'is_staff': user.is_staff, 'is_superuser': user.is_superuser, } diff --git a/src/backend/InvenTree/users/migrations/0011_auto_20240523_1640.py b/src/backend/InvenTree/users/migrations/0011_auto_20240523_1640.py index cdda226fe9..a736de6f72 100644 --- a/src/backend/InvenTree/users/migrations/0011_auto_20240523_1640.py +++ b/src/backend/InvenTree/users/migrations/0011_auto_20240523_1640.py @@ -9,6 +9,10 @@ from django.db import migrations def clear_sessions(apps, schema_editor): """Clear all user sessions.""" + # Ignore in test mode + if settings.TESTING: + return + try: engine = import_module(settings.SESSION_ENGINE) engine.SessionStore.clear_expired() diff --git a/src/backend/InvenTree/users/models.py b/src/backend/InvenTree/users/models.py index 0a175b3c7a..df237c3416 100644 --- a/src/backend/InvenTree/users/models.py +++ b/src/backend/InvenTree/users/models.py @@ -258,7 +258,6 @@ class RuleSet(models.Model): 'part_partpricing', 'part_bomitem', 'part_bomitemsubstitute', - 'part_partattachment', 'part_partsellpricebreak', 'part_partinternalpricebreak', 'part_parttesttemplate', @@ -270,13 +269,11 @@ class RuleSet(models.Model): 'company_supplierpart', 'company_manufacturerpart', 'company_manufacturerpartparameter', - 'company_manufacturerpartattachment', ], 'stocktake': ['part_partstocktake', 'part_partstocktakereport'], 'stock_location': ['stock_stocklocation', 'stock_stocklocationtype'], 'stock': [ 'stock_stockitem', - 'stock_stockitemattachment', 'stock_stockitemtracking', 'stock_stockitemtestresult', ], @@ -288,13 +285,11 @@ class RuleSet(models.Model): 'build_build', 'build_builditem', 'build_buildline', - 'build_buildorderattachment', 'stock_stockitem', 'stock_stocklocation', ], 'purchase_order': [ 'company_company', - 'company_companyattachment', 'company_contact', 'company_address', 'company_manufacturerpart', @@ -302,31 +297,26 @@ class RuleSet(models.Model): 'company_supplierpart', 'company_supplierpricebreak', 'order_purchaseorder', - 'order_purchaseorderattachment', 'order_purchaseorderlineitem', 'order_purchaseorderextraline', ], 'sales_order': [ 'company_company', - 'company_companyattachment', 'company_contact', 'company_address', 'order_salesorder', 'order_salesorderallocation', - 'order_salesorderattachment', 'order_salesorderlineitem', 'order_salesorderextraline', 'order_salesordershipment', ], 'return_order': [ 'company_company', - 'company_companyattachment', 'company_contact', 'company_address', 'order_returnorder', 'order_returnorderlineitem', 'order_returnorderextraline', - 'order_returnorderattachment', ], } @@ -344,6 +334,7 @@ class RuleSet(models.Model): 'admin_logentry', 'contenttypes_contenttype', # Models which currently do not require permissions + 'common_attachment', 'common_colortheme', 'common_customunit', 'common_inventreesetting', diff --git a/src/frontend/src/defaults/formatters.tsx b/src/frontend/src/defaults/formatters.tsx index 892aa9e663..8a4c6acc21 100644 --- a/src/frontend/src/defaults/formatters.tsx +++ b/src/frontend/src/defaults/formatters.tsx @@ -117,7 +117,23 @@ export function formatPriceRange( )}`; } -interface RenderDateOptionsInterface { +/* + * Format a file size (in bytes) into a human-readable format + */ +export function formatFileSize(size: number) { + const suffixes: string[] = ['B', 'KB', 'MB', 'GB']; + + let idx = 0; + + while (size > 1024 && idx < suffixes.length) { + size /= 1024; + idx++; + } + + return `${size.toFixed(2)} ${suffixes[idx]}`; +} + +interface FormatDateOptionsInterface { showTime?: boolean; showSeconds?: boolean; } @@ -128,9 +144,9 @@ interface RenderDateOptionsInterface { * The provided "date" variable is a string, nominally ISO format e.g. 2022-02-22 * The user-configured setting DATE_DISPLAY_FORMAT determines how the date should be displayed. */ -export function renderDate( +export function formatDate( date: string, - options: RenderDateOptionsInterface = {} + options: FormatDateOptionsInterface = {} ) { if (!date) { return '-'; diff --git a/src/frontend/src/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx index 962da26c49..2775fa1845 100644 --- a/src/frontend/src/enums/ApiEndpoints.tsx +++ b/src/frontend/src/enums/ApiEndpoints.tsx @@ -57,7 +57,6 @@ export enum ApiEndpoints { build_output_complete = 'build/:id/complete/', build_output_scrap = 'build/:id/scrap-outputs/', build_output_delete = 'build/:id/delete-outputs/', - build_order_attachment_list = 'build/attachment/', build_line_list = 'build/line/', bom_list = 'bom/', @@ -76,18 +75,15 @@ export enum ApiEndpoints { category_tree = 'part/category/tree/', category_parameter_list = 'part/category/parameters/', related_part_list = 'part/related/', - part_attachment_list = 'part/attachment/', part_test_template_list = 'part/test-template/', // Company API endpoints company_list = 'company/', contact_list = 'company/contact/', address_list = 'company/address/', - company_attachment_list = 'company/attachment/', supplier_part_list = 'company/part/', supplier_part_pricing_list = 'company/price-break/', manufacturer_part_list = 'company/part/manufacturer/', - manufacturer_part_attachment_list = 'company/part/manufacturer/attachment/', manufacturer_part_parameter_list = 'company/part/manufacturer/parameter/', // Stock API endpoints @@ -96,7 +92,6 @@ export enum ApiEndpoints { stock_location_list = 'stock/location/', stock_location_type_list = 'stock/location-type/', stock_location_tree = 'stock/location/tree/', - stock_attachment_list = 'stock/attachment/', stock_test_result_list = 'stock/test/', stock_transfer = 'stock/transfer/', stock_remove = 'stock/remove/', @@ -115,16 +110,13 @@ export enum ApiEndpoints { // Order API endpoints purchase_order_list = 'order/po/', purchase_order_line_list = 'order/po-line/', - purchase_order_attachment_list = 'order/po/attachment/', purchase_order_receive = 'order/po/:id/receive/', sales_order_list = 'order/so/', sales_order_line_list = 'order/so-line/', - sales_order_attachment_list = 'order/so/attachment/', sales_order_shipment_list = 'order/so/shipment/', return_order_list = 'order/ro/', - return_order_attachment_list = 'order/ro/attachment/', // Template API endpoints label_list = 'label/template/', @@ -155,6 +147,7 @@ export enum ApiEndpoints { machine_setting_detail = 'machine/:machine/settings/:config_type/', // Miscellaneous API endpoints + attachment_list = 'attachment/', error_report_list = 'error-report/', project_code_list = 'project-code/', custom_unit_list = 'units/', diff --git a/src/frontend/src/pages/Index/Playground.tsx b/src/frontend/src/pages/Index/Playground.tsx index ec8e0ba7f8..8ce44f54f8 100644 --- a/src/frontend/src/pages/Index/Playground.tsx +++ b/src/frontend/src/pages/Index/Playground.tsx @@ -56,20 +56,6 @@ function ApiFormsPlayground() { fields: editPartFields }); - const newAttachment = useCreateApiFormModal({ - url: ApiEndpoints.part_attachment_list, - title: 'Create Attachment', - fields: { - part: {}, - attachment: {}, - comment: {} - }, - initialData: { - part: 1 - }, - successMessage: 'Attachment uploaded' - }); - const [active, setActive] = useState(true); const [name, setName] = useState('Hello'); @@ -130,9 +116,6 @@ function ApiFormsPlayground() { {editCategory.modal} - - {newAttachment.modal} - {createPartModal} diff --git a/src/frontend/src/pages/build/BuildDetail.tsx b/src/frontend/src/pages/build/BuildDetail.tsx index a6b5474fcd..ec49228af8 100644 --- a/src/frontend/src/pages/build/BuildDetail.tsx +++ b/src/frontend/src/pages/build/BuildDetail.tsx @@ -295,11 +295,7 @@ export default function BuildDetail() { label: t`Attachments`, icon: , content: ( - + ) }, { diff --git a/src/frontend/src/pages/company/CompanyDetail.tsx b/src/frontend/src/pages/company/CompanyDetail.tsx index 517eda346c..d8102cecfb 100644 --- a/src/frontend/src/pages/company/CompanyDetail.tsx +++ b/src/frontend/src/pages/company/CompanyDetail.tsx @@ -256,9 +256,8 @@ export default function CompanyDetail(props: Readonly) { icon: , content: ( ) }, diff --git a/src/frontend/src/pages/company/ManufacturerPartDetail.tsx b/src/frontend/src/pages/company/ManufacturerPartDetail.tsx index 8865d98b7a..7a145ad9b7 100644 --- a/src/frontend/src/pages/company/ManufacturerPartDetail.tsx +++ b/src/frontend/src/pages/company/ManufacturerPartDetail.tsx @@ -173,9 +173,8 @@ export default function ManufacturerPartDetail() { icon: , content: ( ) } diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index 08b330bde8..1b05a323d4 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -618,11 +618,7 @@ export default function PartDetail() { label: t`Attachments`, icon: , content: ( - + ) }, { diff --git a/src/frontend/src/pages/part/pricing/PricingOverviewPanel.tsx b/src/frontend/src/pages/part/pricing/PricingOverviewPanel.tsx index 3d3c5b571b..2f87f91855 100644 --- a/src/frontend/src/pages/part/pricing/PricingOverviewPanel.tsx +++ b/src/frontend/src/pages/part/pricing/PricingOverviewPanel.tsx @@ -22,7 +22,7 @@ import { DataTable } from 'mantine-datatable'; import { ReactNode, useMemo } from 'react'; import { tooltipFormatter } from '../../../components/charts/tooltipFormatter'; -import { formatCurrency, renderDate } from '../../../defaults/formatters'; +import { formatCurrency, formatDate } from '../../../defaults/formatters'; import { panelOptions } from '../PartPricingPanel'; interface PricingOverviewEntry { @@ -173,7 +173,7 @@ export default function PricingOverviewPanel({ {pricing?.updated && ( - {renderDate(pricing.updated)} + {formatDate(pricing.updated)} )} diff --git a/src/frontend/src/pages/part/pricing/PurchaseHistoryPanel.tsx b/src/frontend/src/pages/part/pricing/PurchaseHistoryPanel.tsx index 8f1550b340..1c4a4880eb 100644 --- a/src/frontend/src/pages/part/pricing/PurchaseHistoryPanel.tsx +++ b/src/frontend/src/pages/part/pricing/PurchaseHistoryPanel.tsx @@ -3,7 +3,7 @@ import { BarChart } from '@mantine/charts'; import { Group, SimpleGrid, Text } from '@mantine/core'; import { ReactNode, useCallback, useMemo } from 'react'; -import { formatCurrency, renderDate } from '../../../defaults/formatters'; +import { formatCurrency, formatDate } from '../../../defaults/formatters'; import { ApiEndpoints } from '../../../enums/ApiEndpoints'; import { useTable } from '../../../hooks/UseTable'; import { apiUrl } from '../../../states/ApiState'; @@ -40,7 +40,7 @@ export default function PurchaseHistoryPanel({ title: t`Date`, sortable: true, switchable: true, - render: (record: any) => renderDate(record.order_detail.complete_date) + render: (record: any) => formatDate(record.order_detail.complete_date) }, { accessor: 'purchase_price', diff --git a/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx b/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx index d01654cdf3..fe79915276 100644 --- a/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx +++ b/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx @@ -279,9 +279,8 @@ export default function PurchaseOrderDetail() { icon: , content: ( ) }, diff --git a/src/frontend/src/pages/sales/ReturnOrderDetail.tsx b/src/frontend/src/pages/sales/ReturnOrderDetail.tsx index 9dfc84e699..4d62470f1a 100644 --- a/src/frontend/src/pages/sales/ReturnOrderDetail.tsx +++ b/src/frontend/src/pages/sales/ReturnOrderDetail.tsx @@ -230,9 +230,8 @@ export default function ReturnOrderDetail() { icon: , content: ( ) }, diff --git a/src/frontend/src/pages/sales/SalesOrderDetail.tsx b/src/frontend/src/pages/sales/SalesOrderDetail.tsx index d342dc5e60..12b31bb9e6 100644 --- a/src/frontend/src/pages/sales/SalesOrderDetail.tsx +++ b/src/frontend/src/pages/sales/SalesOrderDetail.tsx @@ -280,9 +280,8 @@ export default function SalesOrderDetail() { icon: , content: ( ) }, diff --git a/src/frontend/src/pages/stock/StockDetail.tsx b/src/frontend/src/pages/stock/StockDetail.tsx index a2981bd295..14face5201 100644 --- a/src/frontend/src/pages/stock/StockDetail.tsx +++ b/src/frontend/src/pages/stock/StockDetail.tsx @@ -329,9 +329,8 @@ export default function StockDetail() { icon: , content: ( ) }, diff --git a/src/frontend/src/states/UserState.tsx b/src/frontend/src/states/UserState.tsx index 0292dfc106..ededb4ca4b 100644 --- a/src/frontend/src/states/UserState.tsx +++ b/src/frontend/src/states/UserState.tsx @@ -2,6 +2,7 @@ import { create } from 'zustand'; import { api, setApiDefaults } from '../App'; import { ApiEndpoints } from '../enums/ApiEndpoints'; +import { ModelType } from '../enums/ModelType'; import { UserPermissions, UserRoles } from '../enums/Roles'; import { clearCsrfCookie } from '../functions/auth'; import { apiUrl } from './ApiState'; @@ -22,6 +23,14 @@ interface UserStateProps { hasChangeRole: (role: UserRoles) => boolean; hasAddRole: (role: UserRoles) => boolean; hasViewRole: (role: UserRoles) => boolean; + checkUserPermission: ( + model: ModelType, + permission: UserPermissions + ) => boolean; + hasDeletePermission: (model: ModelType) => boolean; + hasChangePermission: (model: ModelType) => boolean; + hasAddPermission: (model: ModelType) => boolean; + hasViewPermission: (model: ModelType) => boolean; isLoggedIn: () => boolean; isStaff: () => boolean; isSuperuser: () => boolean; @@ -113,6 +122,7 @@ export const useUserState = create((set, get) => ({ // Update user with role data if (user) { user.roles = response.data?.roles ?? {}; + user.permissions = response.data?.permissions ?? {}; user.is_staff = response.data?.is_staff ?? false; user.is_superuser = response.data?.is_superuser ?? false; set({ user: user }); @@ -126,21 +136,6 @@ export const useUserState = create((set, get) => ({ get().clearUserState(); }); }, - checkUserRole: (role: UserRoles, permission: UserPermissions) => { - // Check if the user has the specified permission for the specified role - const user: UserProps = get().user as UserProps; - - if (!user) { - return false; - } - - if (user?.is_superuser) return true; - if (user?.roles === undefined) return false; - if (user?.roles[role] === undefined) return false; - if (user?.roles[role] === null) return false; - - return user?.roles[role]?.includes(permission) ?? false; - }, isLoggedIn: () => { if (!get().token) { return false; @@ -156,6 +151,21 @@ export const useUserState = create((set, get) => ({ const user: UserProps = get().user as UserProps; return user?.is_superuser ?? false; }, + checkUserRole: (role: UserRoles, permission: UserPermissions) => { + // Check if the user has the specified permission for the specified role + const user: UserProps = get().user as UserProps; + + if (!user) { + return false; + } + + if (user?.is_superuser) return true; + if (user?.roles === undefined) return false; + if (user?.roles[role] === undefined) return false; + if (user?.roles[role] === null) return false; + + return user?.roles[role]?.includes(permission) ?? false; + }, hasDeleteRole: (role: UserRoles) => { return get().checkUserRole(role, UserPermissions.delete); }, @@ -167,5 +177,33 @@ export const useUserState = create((set, get) => ({ }, hasViewRole: (role: UserRoles) => { return get().checkUserRole(role, UserPermissions.view); + }, + checkUserPermission: (model: ModelType, permission: UserPermissions) => { + // Check if the user has the specified permission for the specified model + const user: UserProps = get().user as UserProps; + + if (!user) { + return false; + } + + if (user?.is_superuser) return true; + + if (user?.permissions === undefined) return false; + if (user?.permissions[model] === undefined) return false; + if (user?.permissions[model] === null) return false; + + return user?.permissions[model]?.includes(permission) ?? false; + }, + hasDeletePermission: (model: ModelType) => { + return get().checkUserPermission(model, UserPermissions.delete); + }, + hasChangePermission: (model: ModelType) => { + return get().checkUserPermission(model, UserPermissions.change); + }, + hasAddPermission: (model: ModelType) => { + return get().checkUserPermission(model, UserPermissions.add); + }, + hasViewPermission: (model: ModelType) => { + return get().checkUserPermission(model, UserPermissions.view); } })); diff --git a/src/frontend/src/states/states.tsx b/src/frontend/src/states/states.tsx index 03e461d5a1..3cf2a94fff 100644 --- a/src/frontend/src/states/states.tsx +++ b/src/frontend/src/states/states.tsx @@ -23,6 +23,7 @@ export interface UserProps { is_staff?: boolean; is_superuser?: boolean; roles?: Record; + permissions?: Record; } // Type interface fully defining the current server diff --git a/src/frontend/src/tables/ColumnRenderers.tsx b/src/frontend/src/tables/ColumnRenderers.tsx index b3076954c8..342335a9d1 100644 --- a/src/frontend/src/tables/ColumnRenderers.tsx +++ b/src/frontend/src/tables/ColumnRenderers.tsx @@ -9,7 +9,7 @@ import { Thumbnail } from '../components/images/Thumbnail'; import { ProgressBar } from '../components/items/ProgressBar'; import { TableStatusRenderer } from '../components/render/StatusRenderer'; import { RenderOwner } from '../components/render/User'; -import { formatCurrency, renderDate } from '../defaults/formatters'; +import { formatCurrency, formatDate } from '../defaults/formatters'; import { ModelType } from '../enums/ModelType'; import { resolveItem } from '../functions/conversion'; import { cancelEvent } from '../functions/events'; @@ -180,7 +180,7 @@ export function DateColumn(props: TableColumnProps): TableColumn { title: t`Date`, switchable: true, render: (record: any) => - renderDate(resolveItem(record, props.accessor ?? 'date')), + formatDate(resolveItem(record, props.accessor ?? 'date')), ...props }; } diff --git a/src/frontend/src/tables/general/AttachmentTable.tsx b/src/frontend/src/tables/general/AttachmentTable.tsx index 330e75fb26..d30ede40d0 100644 --- a/src/frontend/src/tables/general/AttachmentTable.tsx +++ b/src/frontend/src/tables/general/AttachmentTable.tsx @@ -14,7 +14,9 @@ import { api } from '../../App'; import { ActionButton } from '../../components/buttons/ActionButton'; import { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField'; import { AttachmentLink } from '../../components/items/AttachmentLink'; +import { formatFileSize } from '../../defaults/formatters'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; +import { ModelType } from '../../enums/ModelType'; import { useCreateApiFormModal, useDeleteApiFormModal, @@ -22,7 +24,9 @@ import { } from '../../hooks/UseForm'; import { useTable } from '../../hooks/UseTable'; import { apiUrl } from '../../states/ApiState'; +import { useUserState } from '../../states/UserState'; import { TableColumn } from '../Column'; +import { TableFilter } from '../Filter'; import { InvenTreeTable } from '../InvenTreeTable'; import { RowAction, RowDeleteAction, RowEditAction } from '../RowActions'; @@ -36,7 +40,7 @@ function attachmentTableColumns(): TableColumn[] { sortable: false, switchable: false, noWrap: true, - render: function (record: any) { + render: (record: any) => { if (record.attachment) { return ; } else if (record.link) { @@ -50,7 +54,7 @@ function attachmentTableColumns(): TableColumn[] { accessor: 'comment', sortable: false, - render: function (record: any) { + render: (record: any) => { return record.comment; } }, @@ -58,7 +62,7 @@ function attachmentTableColumns(): TableColumn[] { accessor: 'upload_date', sortable: true, - render: function (record: any) { + render: (record: any) => { return ( {record.upload_date} @@ -68,6 +72,18 @@ function attachmentTableColumns(): TableColumn[] { ); } + }, + { + accessor: 'file_size', + sortable: true, + switchable: true, + render: (record: any) => { + if (!record.attachment) { + return '-'; + } else { + return formatFileSize(record.file_size); + } + } } ]; } @@ -76,50 +92,34 @@ function attachmentTableColumns(): TableColumn[] { * Construct a table for displaying uploaded attachments */ export function AttachmentTable({ - endpoint, - model, - pk + model_type, + model_id }: { - endpoint: ApiEndpoints; - pk: number; - model: string; + model_type: ModelType; + model_id: number; }): ReactNode { - const table = useTable(`${model}-attachments`); + const user = useUserState(); + const table = useTable(`${model_type}-attachments`); const tableColumns = useMemo(() => attachmentTableColumns(), []); - const [allowEdit, setAllowEdit] = useState(false); - const [allowDelete, setAllowDelete] = useState(false); + const url = apiUrl(ApiEndpoints.attachment_list); - const url = useMemo(() => apiUrl(endpoint), [endpoint]); - - const validPk = useMemo(() => pk > 0, [pk]); - - // Determine which permissions are available for this URL - useEffect(() => { - api - .options(url) - .then((response) => { - let actions: any = response.data?.actions ?? {}; - - setAllowEdit('POST' in actions); - setAllowDelete('DELETE' in actions); - - return response; - }) - .catch((error) => { - return error; - }); - }, [url]); + const validPk = useMemo(() => model_id > 0, [model_id]); const [isUploading, setIsUploading] = useState(false); + const allowDragAndDrop: boolean = useMemo(() => { + return user.hasAddPermission(model_type); + }, [user, model_type]); + // Callback to upload file attachment(s) function uploadFiles(files: File[]) { files.forEach((file) => { let formData = new FormData(); formData.append('attachment', file); - formData.append(model, pk.toString()); + formData.append('model_type', model_type); + formData.append('model_id', model_id.toString()); setIsUploading(true); @@ -161,8 +161,12 @@ export function AttachmentTable({ const uploadFields: ApiFormFieldSet = useMemo(() => { let fields: ApiFormFieldSet = { - [model]: { - value: pk, + model_type: { + value: model_type, + hidden: true + }, + model_id: { + value: model_id, hidden: true }, attachment: {}, @@ -180,10 +184,10 @@ export function AttachmentTable({ } return fields; - }, [endpoint, model, pk, attachmentType, selectedAttachment]); + }, [model_type, model_id, attachmentType, selectedAttachment]); const uploadAttachment = useCreateApiFormModal({ - url: endpoint, + url: url, title: t`Upload Attachment`, fields: uploadFields, onFormSuccess: () => { @@ -192,7 +196,7 @@ export function AttachmentTable({ }); const editAttachment = useEditApiFormModal({ - url: endpoint, + url: url, pk: selectedAttachment, title: t`Edit Attachment`, fields: uploadFields, @@ -206,7 +210,7 @@ export function AttachmentTable({ }); const deleteAttachment = useDeleteApiFormModal({ - url: endpoint, + url: url, pk: selectedAttachment, title: t`Delete Attachment`, onFormSuccess: () => { @@ -214,12 +218,27 @@ export function AttachmentTable({ } }); + const tableFilters: TableFilter[] = useMemo(() => { + return [ + { + name: 'is_link', + label: t`Is Link`, + description: t`Show link attachments` + }, + { + name: 'is_file', + label: t`Is File`, + description: t`Show file attachments` + } + ]; + }, []); + const tableActions: ReactNode[] = useMemo(() => { return [