From 27ca0836e7c7e46ebb41ea98d507972ae7a6b018 Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 22 May 2026 23:37:32 +1000 Subject: [PATCH] [refactor] Attachment images (#11961) * Add new Attachment model fields: - is_image - thumbnail * Cache if the attachment is an image * Add new setting for controlling max upload size * Validate uploaded attachment file * Add tqdm for progress bars * Refactor migrations - Don't need is_image field - Can introspect from the thumbnail * Data migration for existing attachments * Bump API version * Update tests and validators * Add "is_image" field to the Attachment model * Offload to background task * Implement unit tests * Docs * Add unit test for data migration * Additional unit test * Omit migration tests from code coverage * Additional unit tests --- codecov.yml | 3 + docs/docs/concepts/attachments.md | 13 ++ docs/docs/settings/global.md | 1 + pyproject.toml | 1 + .../InvenTree/InvenTree/api_version.py | 5 +- src/backend/InvenTree/common/api.py | 12 +- ...ttachment_is_image_attachment_thumbnail.py | 48 ++++ .../migrations/0043_auto_20260518_1206.py | 58 +++++ src/backend/InvenTree/common/models.py | 103 ++++++++- src/backend/InvenTree/common/serializers.py | 13 +- .../InvenTree/common/setting/system.py | 7 + src/backend/InvenTree/common/tasks.py | 18 ++ src/backend/InvenTree/common/test_api.py | 210 ++++++++++++++++++ .../InvenTree/common/test_migrations.py | 81 +++++++ src/backend/InvenTree/common/tests.py | 6 +- src/backend/InvenTree/common/validators.py | 18 +- src/backend/requirements-3.14.txt | 6 + src/backend/requirements.in | 1 + src/backend/requirements.txt | 4 + .../src/components/items/AttachmentLink.tsx | 11 +- .../pages/Index/Settings/SystemSettings.tsx | 1 + .../src/tables/general/AttachmentTable.tsx | 12 +- 22 files changed, 624 insertions(+), 8 deletions(-) create mode 100644 src/backend/InvenTree/common/migrations/0042_attachment_is_image_attachment_thumbnail.py create mode 100644 src/backend/InvenTree/common/migrations/0043_auto_20260518_1206.py diff --git a/codecov.yml b/codecov.yml index b06601b57b..b49179c3ed 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,3 +1,6 @@ +ignore: + - "src/backend/InvenTree/**/test_migrations.py" + coverage: status: project: diff --git a/docs/docs/concepts/attachments.md b/docs/docs/concepts/attachments.md index 55f42736f0..b376cace6d 100644 --- a/docs/docs/concepts/attachments.md +++ b/docs/docs/concepts/attachments.md @@ -25,6 +25,19 @@ The following types of attachments are supported: File attachments allow users to upload files directly to InvenTree. These files are stored on the server and can be downloaded or viewed by users with appropriate permissions. +### Image Thumbnails + +When a file attachment is uploaded, InvenTree automatically determines whether the file is a valid image. If it is, a thumbnail is generated and stored alongside the attachment. + +- The thumbnail is created with a reduced image size, while preserving the original aspect ratio. +- Thumbnail generation is performed in the background after upload. +- The `is_image` flag on the attachment record is set to `True` for valid images, and `False` for all other file types. +- If the uploaded file has an image extension but contains invalid or corrupt image data, no thumbnail is generated and `is_image` remains `False`. +- Link attachments (external URLs) are never assigned a thumbnail. + +!!! info "Supported Formats" + Any image format recognised by the [Pillow](https://pillow.readthedocs.io/) library (e.g. PNG, JPEG, GIF, BMP, WEBP) will be treated as a valid image and have a thumbnail generated automatically. + ### Link Attachments Link attachments allow users to associate external URLs with an object. This can be useful for linking to external documentation, resources, or other relevant web content. diff --git a/docs/docs/settings/global.md b/docs/docs/settings/global.md index d5f33061cb..4ae4d02f40 100644 --- a/docs/docs/settings/global.md +++ b/docs/docs/settings/global.md @@ -33,6 +33,7 @@ Configuration of basic server settings: {{ globalsetting("DISPLAY_FULL_NAMES") }} {{ globalsetting("DISPLAY_PROFILE_INFO") }} {{ globalsetting("WEEK_STARTS_ON") }} +{{ globalsetting("INVENTREE_UPLOAD_MAX_SIZE") }} {{ globalsetting("INVENTREE_STRICT_URLS") }} Configuration of various scheduled tasks: diff --git a/pyproject.toml b/pyproject.toml index 63a0fd5a6e..169e5a1f89 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -120,6 +120,7 @@ possibly-unbound-attribute="ignore" # 21 [tool.coverage.run] source = ["src/backend/InvenTree", "InvenTree"] dynamic_context = "test_function" +omit = ["*/test_migrations.py"] [tool.coverage.html] show_contexts = true diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 64b1ffdf46..de8640d308 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,14 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 492 +INVENTREE_API_VERSION = 493 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v493 -> 2026-05-18 : https://github.com/inventree/InvenTree/pull/11961 + - Adds "thumbnail" field to the Attachment API endpoint, which provides a URL to a thumbnail image for image attachments (if available) + v492 -> 2026-05-22 : https://github.com/inventree/InvenTree/pull/11281 - Add Transfer Order model and associated API endpoint diff --git a/src/backend/InvenTree/common/api.py b/src/backend/InvenTree/common/api.py index 05575f73a8..5726b32f91 100644 --- a/src/backend/InvenTree/common/api.py +++ b/src/backend/InvenTree/common/api.py @@ -717,7 +717,17 @@ class AttachmentFilter(FilterSet): """Metaclass options.""" model = common.models.Attachment - fields = ['model_type', 'model_id', 'upload_user'] + fields = ['model_type', 'model_id', 'upload_user', 'is_image'] + + has_thumbnail = rest_filters.BooleanFilter( + label=_('Has Thumbnail'), method='filter_has_thumbnail' + ) + + def filter_has_thumbnail(self, queryset, name, value): + """Filter attachments based on whether they have a thumbnail or not.""" + if value: + return queryset.exclude(thumbnail=None).exclude(thumbnail='') + return queryset.filter(Q(thumbnail=None) | Q(thumbnail='')).distinct() is_link = rest_filters.BooleanFilter(label=_('Is Link'), method='filter_is_link') diff --git a/src/backend/InvenTree/common/migrations/0042_attachment_is_image_attachment_thumbnail.py b/src/backend/InvenTree/common/migrations/0042_attachment_is_image_attachment_thumbnail.py new file mode 100644 index 0000000000..d4bf86df20 --- /dev/null +++ b/src/backend/InvenTree/common/migrations/0042_attachment_is_image_attachment_thumbnail.py @@ -0,0 +1,48 @@ +# Generated by Django 5.2.14 on 2026-05-18 11:34 + +from django.db import migrations, models + +import common.models +import common.validators + + +class Migration(migrations.Migration): + + dependencies = [ + ("common", "0041_auto_20251203_1244"), + ] + + operations = [ + migrations.AddField( + model_name="attachment", + name="thumbnail", + field=models.ImageField( + blank=True, + help_text="Thumbnail image for this attachment", + null=True, + upload_to="", + verbose_name="Thumbnail", + ), + ), + migrations.AddField( + model_name="attachment", + name="is_image", + field=models.BooleanField( + default=False, + help_text="True if this attachment is a valid image file", + verbose_name="Is image", + ), + ), + migrations.AlterField( + model_name="attachment", + name="attachment", + field=models.FileField( + blank=True, + help_text="Select file to attach", + null=True, + upload_to=common.models.rename_attachment, + validators=[common.validators.validate_attachment_file], + verbose_name="Attachment", + ), + ), + ] diff --git a/src/backend/InvenTree/common/migrations/0043_auto_20260518_1206.py b/src/backend/InvenTree/common/migrations/0043_auto_20260518_1206.py new file mode 100644 index 0000000000..597ad45cf7 --- /dev/null +++ b/src/backend/InvenTree/common/migrations/0043_auto_20260518_1206.py @@ -0,0 +1,58 @@ +# Generated by Django 5.2.14 on 2026-05-18 12:06 + +import io +import os + +from PIL import Image +from tqdm import tqdm + +from time import sleep + +from django.db import migrations +from django.core.files.base import ContentFile +from django.core.files.storage import default_storage + + +def update_image_attachments(apps, schema_editor): + """Update existing attachments to ensure that image attachments have thumbnails. + + For each existing attachment, check if it is an image. + If it is, generate a thumbnail for it. + + Note: This function mirrors the logic used in the Attachment model's + check_is_image method, at the time of writing this migration (2026-05-18). + """ + + import common.tasks + + Attachment = apps.get_model('common', 'Attachment') + + # Find all Attachment instances which (potentially) have a file attached + attachments = Attachment.objects.exclude(attachment__isnull=True).exclude(attachment='') + + if attachments.count() == 0: + return + + progress = tqdm(total=attachments.count(), desc='Migration common.0043: Updating attachments') + + for attachment in attachments: + progress.update(1) + + if not attachment.attachment: + continue + + if not attachment.attachment.name or not default_storage.exists(attachment.attachment.name): + continue + + common.tasks.rebuild_attachment(attachment.pk) + + +class Migration(migrations.Migration): + + dependencies = [ + ("common", "0042_attachment_is_image_attachment_thumbnail"), + ] + + operations = [ + migrations.RunPython(update_image_attachments, reverse_code=migrations.RunPython.noop), + ] diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index 72a7866568..a53d2640b4 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -48,6 +48,7 @@ from django_q.signals import post_spawn from djmoney.contrib.exchange.exceptions import MissingRate from djmoney.contrib.exchange.models import convert_money from opentelemetry import trace +from PIL import Image from rest_framework.exceptions import PermissionDenied from taggit.managers import TaggableManager @@ -1932,6 +1933,8 @@ class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel model_id: The ID of the model to which this attachment is linked attachment: The uploaded file url: An external URL + thumbnail: A generated thumbnail for the uploaded file (if applicable) + is_image: True if this attachment is a valid image file comment: A comment or description for the attachment user: The user who uploaded the attachment upload_date: The date the attachment was uploaded @@ -1940,6 +1943,8 @@ class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel tags: Tags for the attachment """ + THUMBNAIL_SIZE = 256 + class Meta: """Metaclass options.""" @@ -1956,9 +1961,11 @@ class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel - Ensure that the attached file is deleted from storage when the database entry is removed """ attachment = self.attachment + thumbnail = self.thumbnail super().delete(*args, **kwargs) + # Delete the associated files from storage (if they exist)W if attachment and default_storage.exists(attachment.name): try: # Remove the attached file from storage @@ -1966,6 +1973,13 @@ class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel except Exception: # pragma: no cover pass + if thumbnail and default_storage.exists(thumbnail.name): + try: + # Remove the thumbnail file from storage + default_storage.delete(thumbnail.name) + except Exception: # pragma: no cover + pass + def save(self, *args, **kwargs): """Custom 'save' method for the Attachment model. @@ -1973,6 +1987,10 @@ class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel - Ensure that the 'content_type' and 'object_id' fields are set - Run extra validations """ + import common.tasks + + rebuild = kwargs.pop('rebuild', True) + # Either 'attachment' or 'link' must be specified! if not self.attachment and not self.link: raise ValidationError({ @@ -2000,6 +2018,12 @@ class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel if self.file_size != 0: super().save() + # Offload a background task to update the thumbnail for this attachment + if rebuild: + InvenTree.tasks.offload_task( + common.tasks.rebuild_attachment, self.pk, group='attachments' + ) + def clean_svg(self, field): """Sanitize SVG file before saving.""" cleaned = sanitize_svg(field.file.read()) @@ -2077,11 +2101,19 @@ class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel attachment = models.FileField( upload_to=rename_attachment, verbose_name=_('Attachment'), + validators=[common.validators.validate_attachment_file], help_text=_('Select file to attach'), blank=True, null=True, ) + thumbnail = models.ImageField( + verbose_name=_('Thumbnail'), + help_text=_('Thumbnail image for this attachment'), + blank=True, + null=True, + ) + link = InvenTree.fields.InvenTreeURLField( blank=True, null=True, @@ -2114,6 +2146,12 @@ class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel help_text=_('Date the file was uploaded'), ) + is_image = models.BooleanField( + default=False, + verbose_name=_('Is image'), + help_text=_('True if this attachment is a valid image file'), + ) + file_size = models.PositiveIntegerField( default=0, verbose_name=_('File size'), help_text=_('File size in bytes') ) @@ -2157,6 +2195,69 @@ class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel return model_class.check_related_permission(permission, user) + def check_is_image(self) -> bool: + """Check if the attached file is an image. + + We consider it a valid image if: + + - The file exists in storage + - The file can be opened and verified by the PIL library + + """ + if not self.attachment: + return False + + if not self.attachment.name: + return False + + try: + if not default_storage.exists(self.attachment.name): + return False + except Exception: + return False + + img_data = default_storage.open(self.attachment.name).read() + + try: + Image.open(BytesIO(img_data)).verify() + return True + except Exception: + return False + + def generate_thumbnail(self): + """Generate a thumbnail for the attached image.""" + # Remove any existing thumbnail + if self.thumbnail: + self.thumbnail.delete(save=False) + + if not self.attachment: + return + + if not self.attachment.name or not default_storage.exists(self.attachment.name): + return + + # TODO: Offload to plugins, for creating custom thumbnails for different file types + # TODO: If a plugin provides a thumbnail, return early + + # Default action is to generate a thumbnail for image files + try: + img_data = default_storage.open(self.attachment.name).read() + except Exception: + # No file found, or file cannot be read - cannot generate thumbnail + return + + try: + img = Image.open(BytesIO(img_data)) + img.thumbnail((self.THUMBNAIL_SIZE, self.THUMBNAIL_SIZE)) + thumb_io = BytesIO() + img.save(thumb_io, format='PNG') + thumb_io.seek(0) + + thumb_name = f'thumb_{os.path.basename(self.attachment.name)}' + self.thumbnail.save(thumb_name, ContentFile(thumb_io.read()), save=False) + except Exception: + pass + class InvenTreeCustomUserStateModel(models.Model): """Custom model to extends any registered state with extra custom, user defined states. @@ -2684,7 +2785,7 @@ def post_save_parameter_template(sender, instance, created, **kwargs): common.tasks.rebuild_parameters, instance.pk, force_async=True, - group='part', + group='parameters', ) diff --git a/src/backend/InvenTree/common/serializers.py b/src/backend/InvenTree/common/serializers.py index 025c28f6d0..c8e2f13920 100644 --- a/src/backend/InvenTree/common/serializers.py +++ b/src/backend/InvenTree/common/serializers.py @@ -730,9 +730,11 @@ class AttachmentSerializer(FilterableSerializerMixin, InvenTreeModelSerializer): fields = [ 'pk', 'attachment', + 'thumbnail', 'filename', 'link', 'comment', + 'is_image', 'upload_date', 'upload_user', 'user_detail', @@ -742,7 +744,14 @@ class AttachmentSerializer(FilterableSerializerMixin, InvenTreeModelSerializer): 'tags', ] - read_only_fields = ['pk', 'file_size', 'upload_date', 'upload_user', 'filename'] + read_only_fields = [ + 'pk', + 'file_size', + 'upload_date', + 'upload_user', + 'filename', + 'is_image', + ] def __init__(self, *args, **kwargs): """Override the model_type field to provide dynamic choices.""" @@ -759,6 +768,8 @@ class AttachmentSerializer(FilterableSerializerMixin, InvenTreeModelSerializer): attachment = InvenTreeAttachmentSerializerField(required=False, allow_null=True) + thumbnail = InvenTreeImageSerializerField(read_only=True, allow_null=True) + # The 'filename' field must be present in the serializer filename = serializers.CharField( label=_('Filename'), required=False, source='basename', allow_blank=False diff --git a/src/backend/InvenTree/common/setting/system.py b/src/backend/InvenTree/common/setting/system.py index 456d7de095..0f1283be06 100644 --- a/src/backend/InvenTree/common/setting/system.py +++ b/src/backend/InvenTree/common/setting/system.py @@ -286,6 +286,13 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { 'choices': common.currency.currency_exchange_plugins, 'default': 'inventreecurrencyexchange', }, + 'INVENTREE_UPLOAD_MAX_SIZE': { + 'name': _('Upload Size Limit'), + 'description': _('Maximum allowable upload size for images and files'), + 'units': 'MB', + 'default': 10, + 'validator': [int, MinValueValidator(1)], + }, 'INVENTREE_STRICT_URLS': { 'name': _('Strict URL Validation'), 'description': _('Require schema specification when validating URLs'), diff --git a/src/backend/InvenTree/common/tasks.py b/src/backend/InvenTree/common/tasks.py index 6a86367937..43afef5245 100644 --- a/src/backend/InvenTree/common/tasks.py +++ b/src/backend/InvenTree/common/tasks.py @@ -204,3 +204,21 @@ def rebuild_parameters(template_id): if n > 0: logger.info("Rebuilt %s parameters for template '%s'", n, template.name) + + +@tracer.start_as_current_span('rebuild_attachment') +def rebuild_attachment(attachment_id: int): + """Rebuild the given attachment, if possible. + + This task is called whenever an attachment is saved, and perform the following tasks: + + - Check if the attachment is an image file, and update the "is_image" field accordingly + - Attempt to generate a thumbnail for the attachment + """ + from common.models import Attachment + + attachment = Attachment.objects.get(pk=attachment_id) + + attachment.is_image = attachment.check_is_image() + attachment.generate_thumbnail() + attachment.save(rebuild=False) diff --git a/src/backend/InvenTree/common/test_api.py b/src/backend/InvenTree/common/test_api.py index febbf5622e..d4875c9014 100644 --- a/src/backend/InvenTree/common/test_api.py +++ b/src/backend/InvenTree/common/test_api.py @@ -4,8 +4,11 @@ import io from django.core.files.base import ContentFile from django.core.files.storage import default_storage +from django.core.files.uploadedfile import SimpleUploadedFile from django.urls import reverse +from PIL import Image + import common.models from InvenTree.unit_test import InvenTreeAPITestCase @@ -789,3 +792,210 @@ class AttachmentAPITests(InvenTreeAPITestCase): for att in attachments: # Ensure that the file associated with each attachment has been removed self.assertFalse(default_storage.exists(att.attachment.path)) + + +class AttachmentThumbnailAPITests(InvenTreeAPITestCase): + """Tests for thumbnail generation when uploading attachments via the API.""" + + def setUp(self): + """Set up a Part instance and required roles.""" + from part.models import Part + + super().setUp() + self.assignRole('part.add') + self.assignRole('part.delete') + self.part = Part.objects.create( + name='Thumbnail Test Part', description='Part for thumbnail testing' + ) + + def _make_image_file(self, name='test.png', size=(100, 100), color='red'): + """Return a SimpleUploadedFile containing a valid PNG image.""" + buf = io.BytesIO() + Image.new('RGB', size, color=color).save(buf, format='PNG') + return SimpleUploadedFile(name, buf.getvalue(), content_type='image/png') + + def _upload_attachment(self, file_obj, expected_code=201): + """Upload a file attachment against the test part and return the response.""" + return self.post( + reverse('api-attachment-list'), + data={ + 'model_type': 'part', + 'model_id': self.part.pk, + 'attachment': file_obj, + }, + format='multipart', + expected_code=expected_code, + ) + + def test_thumbnail_valid_image(self): + """Uploading a valid image file should set is_image=True and generate a thumbnail.""" + from common.models import Attachment + + response = self._upload_attachment(self._make_image_file()) + att = Attachment.objects.get(pk=response.data['pk']) + + self.assertTrue(att.is_image) + self.assertTrue(att.thumbnail) + self.assertTrue(default_storage.exists(att.thumbnail.name)) + + def test_thumbnail_invalid_image(self): + """Uploading a file with an image extension but invalid image data should not create a thumbnail.""" + from common.models import Attachment + + bad_file = SimpleUploadedFile( + 'corrupt.png', b'this is not image data', content_type='image/png' + ) + response = self._upload_attachment(bad_file) + att = Attachment.objects.get(pk=response.data['pk']) + + self.assertFalse(att.is_image) + self.assertFalse(att.thumbnail) + + def test_thumbnail_non_image_file(self): + """Uploading a non-image file should leave is_image=False with no thumbnail.""" + from common.models import Attachment + + txt_file = SimpleUploadedFile( + 'document.txt', b'Hello, InvenTree!', content_type='text/plain' + ) + response = self._upload_attachment(txt_file) + att = Attachment.objects.get(pk=response.data['pk']) + + self.assertFalse(att.is_image) + self.assertFalse(att.thumbnail) + + def test_thumbnail_large_image(self): + """A large image attachment should produce a thumbnail no larger than THUMBNAIL_SIZE on each side.""" + from common.models import Attachment + + response = self._upload_attachment(self._make_image_file(size=(1000, 1000))) + att = Attachment.objects.get(pk=response.data['pk']) + + self.assertTrue(att.is_image) + self.assertTrue(att.thumbnail) + + thumb_data = default_storage.open(att.thumbnail.name).read() + thumb_img = Image.open(io.BytesIO(thumb_data)) + self.assertLessEqual(thumb_img.width, Attachment.THUMBNAIL_SIZE) + self.assertLessEqual(thumb_img.height, Attachment.THUMBNAIL_SIZE) + + def test_thumbnail_deleted_with_attachment(self): + """Deleting an attachment via the API should also remove its thumbnail from storage.""" + from common.models import Attachment + + response = self._upload_attachment(self._make_image_file()) + att = Attachment.objects.get(pk=response.data['pk']) + + self.assertTrue(att.thumbnail) + thumb_name = att.thumbnail.name + att_name = att.attachment.name + + self.assertTrue(default_storage.exists(att_name)) + self.assertTrue(default_storage.exists(thumb_name)) + + self.delete( + reverse('api-attachment-detail', kwargs={'pk': att.pk}), expected_code=204 + ) + + self.assertFalse(default_storage.exists(att_name)) + self.assertFalse(default_storage.exists(thumb_name)) + + def test_thumbnail_zero_byte_file(self): + """Uploading a zero-byte file should be rejected by Django's file validation before reaching thumbnail logic.""" + empty_file = SimpleUploadedFile('empty.png', b'', content_type='image/png') + # Django's FileField rejects empty uploads at the serializer/validation layer + response = self._upload_attachment(empty_file, expected_code=400) + self.assertIn('attachment', response.data) + + def test_thumbnail_link_attachment(self): + """An attachment created with an external link (no file) should not generate a thumbnail.""" + from common.models import Attachment + + response = self.post( + reverse('api-attachment-list'), + data={ + 'model_type': 'part', + 'model_id': self.part.pk, + 'link': 'https://example.com/some/resource', + }, + format='multipart', + expected_code=201, + ) + + att = Attachment.objects.get(pk=response.data['pk']) + + self.assertFalse(att.is_image) + self.assertFalse(att.thumbnail) + + def test_is_image_filter(self): + """The is_image filter on the attachment list endpoint should return only matching attachments.""" + url = reverse('api-attachment-list') + base_filters = {'model_type': 'part', 'model_id': self.part.pk} + + # Upload one valid image and three non-image attachments + self._upload_attachment(self._make_image_file('img1.png')) + self._upload_attachment( + SimpleUploadedFile( + 'corrupt.png', b'not image data', content_type='image/png' + ) + ) + self._upload_attachment( + SimpleUploadedFile('doc.txt', b'hello', content_type='text/plain') + ) + self.post( + url, + data={**base_filters, 'link': 'https://example.com/resource'}, + format='multipart', + expected_code=201, + ) + + all_attachments = self.get(url, base_filters, expected_code=200).data + self.assertEqual(len(all_attachments), 4) + + # is_image=true → only the valid image + images = self.get( + url, {**base_filters, 'is_image': 'true'}, expected_code=200 + ).data + self.assertEqual(len(images), 1) + self.assertTrue(images[0]['is_image']) + + # is_image=false → the three non-image attachments + non_images = self.get( + url, {**base_filters, 'is_image': 'false'}, expected_code=200 + ).data + self.assertEqual(len(non_images), 3) + self.assertTrue(all(not a['is_image'] for a in non_images)) + + def test_upload_exceeds_size_limit(self): + """Uploading a file that exceeds INVENTREE_UPLOAD_MAX_SIZE should be rejected with a 400 error.""" + from common.settings import get_global_setting, set_global_setting + + original_limit = get_global_setting('INVENTREE_UPLOAD_MAX_SIZE') + # Use a 1 MB ceiling so the test file stays small and fast + set_global_setting('INVENTREE_UPLOAD_MAX_SIZE', 1, change_user=None) + + limit_bytes = 1 * 1024 * 1024 + + try: + # File exactly at the limit — validator uses >, so this must be accepted + self._upload_attachment( + SimpleUploadedFile( + 'at_limit.txt', b'\x00' * limit_bytes, content_type='text/plain' + ), + expected_code=201, + ) + + # File one byte over the limit — must be rejected + response = self._upload_attachment( + SimpleUploadedFile( + 'over_limit.txt', + b'\x00' * (limit_bytes + 1), + content_type='text/plain', + ), + expected_code=400, + ) + self.assertIn('attachment', response.data) + finally: + set_global_setting( + 'INVENTREE_UPLOAD_MAX_SIZE', original_limit, change_user=None + ) diff --git a/src/backend/InvenTree/common/test_migrations.py b/src/backend/InvenTree/common/test_migrations.py index 6c21dc1074..c6e62cefef 100644 --- a/src/backend/InvenTree/common/test_migrations.py +++ b/src/backend/InvenTree/common/test_migrations.py @@ -4,8 +4,10 @@ import io import os from django.core.files.base import ContentFile +from django.core.files.storage import default_storage from django_test_migrations.contrib.unittest_case import MigratorTestCase +from PIL import Image def get_legacy_models(): @@ -209,6 +211,85 @@ class TestForwardMigrations(MigratorTestCase): self.assertEqual(Attachment.objects.filter(model_type=model).count(), 2) +class TestAttachmentThumbnailMigration(MigratorTestCase): + """Test that migration 0043 correctly populates is_image and generates thumbnails for existing attachments.""" + + migrate_from = ('common', '0041_auto_20251203_1244') + migrate_to = ('common', '0043_auto_20260518_1206') + + def prepare(self): + """Create a set of attachments with different file types in the pre-migration state. + + At this point the Attachment model has no is_image or thumbnail fields yet. + Files are written to storage directly through the FileField so that the + data migration can find them at their stored paths. + """ + Attachment = self.old_state.apps.get_model('common', 'Attachment') + + # 1. Valid PNG image — migration should set is_image=True and create a thumbnail + buf = io.BytesIO() + Image.new('RGB', (100, 100), color='blue').save(buf, format='PNG') + Attachment.objects.create( + model_type='part', + model_id=1, + attachment=ContentFile(buf.getvalue(), name='valid_image.png'), + comment='valid_image', + ) + + # 2. File with a .png extension but non-image content — migration should leave is_image=False + Attachment.objects.create( + model_type='part', + model_id=1, + attachment=ContentFile(b'this is not image data', name='corrupt.png'), + comment='corrupt_image', + ) + + # 3. Plain text file — migration should leave is_image=False with no thumbnail + Attachment.objects.create( + model_type='part', + model_id=1, + attachment=ContentFile(b'Hello, InvenTree!', name='document.txt'), + comment='text_file', + ) + + # 4. Link attachment (no file at all) — migration should skip it entirely + Attachment.objects.create( + model_type='part', + model_id=1, + link='https://example.com/resource', + comment='link_attachment', + ) + + self.assertEqual(Attachment.objects.count(), 4) + + def test_attachment_thumbnails_after_migration(self): + """After applying migrations 0042 and 0043, verify is_image and thumbnail are correct.""" + Attachment = self.new_state.apps.get_model('common', 'Attachment') + + self.assertEqual(Attachment.objects.count(), 4) + + # Valid image → is_image set, thumbnail file created in storage + att = Attachment.objects.get(comment='valid_image') + self.assertTrue(att.is_image) + self.assertTrue(att.thumbnail) + self.assertTrue(default_storage.exists(att.thumbnail.name)) + + # Corrupt image → is_image not set, no thumbnail + att = Attachment.objects.get(comment='corrupt_image') + self.assertFalse(att.is_image) + self.assertFalse(att.thumbnail) + + # Text file → is_image not set, no thumbnail + att = Attachment.objects.get(comment='text_file') + self.assertFalse(att.is_image) + self.assertFalse(att.thumbnail) + + # Link attachment → is_image not set, no thumbnail + att = Attachment.objects.get(comment='link_attachment') + self.assertFalse(att.is_image) + self.assertFalse(att.thumbnail) + + def prep_currency_migration(self, vals: str): """Prepare the environment for the currency migration tests.""" # Set keys diff --git a/src/backend/InvenTree/common/tests.py b/src/backend/InvenTree/common/tests.py index 21a9474328..af0420e62d 100644 --- a/src/backend/InvenTree/common/tests.py +++ b/src/backend/InvenTree/common/tests.py @@ -86,6 +86,11 @@ class AttachmentTest(InvenTreeAPITestCase): } for fn, expected in filenames.items(): + expected_path = f'attachments/part/{part.pk}/{expected}' + # Remove the file if it already exists (i.e. from a previous test run) + if default_storage.exists(expected_path): + default_storage.delete(expected_path) + attachment = Attachment.objects.create( attachment=self.generate_file(fn), comment=f'Testing filename: {fn}', @@ -93,7 +98,6 @@ class AttachmentTest(InvenTreeAPITestCase): 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) diff --git a/src/backend/InvenTree/common/validators.py b/src/backend/InvenTree/common/validators.py index 02c3805f96..226a42b7d2 100644 --- a/src/backend/InvenTree/common/validators.py +++ b/src/backend/InvenTree/common/validators.py @@ -2,7 +2,8 @@ import re -from django.core.exceptions import ValidationError +from django.core.exceptions import SuspiciousFileOperation, ValidationError +from django.core.files.storage import default_storage from django.utils.translation import gettext_lazy as _ import common.icons @@ -76,6 +77,21 @@ def validate_attachment_model_type(value): raise ValidationError('Model type does not support attachments') +def validate_attachment_file(attachment): + """Ensure that the provided attachment file is valid.""" + max_size = get_global_setting('INVENTREE_UPLOAD_MAX_SIZE', create=False) + + if attachment.size > (max_size * 1024 * 1024): + raise ValidationError( + _(f'File size exceeds maximum upload limit of {max_size} MB') + ) + + try: + default_storage.generate_filename(attachment.name) + except SuspiciousFileOperation: # pragma: no cover + raise ValidationError(_('Invalid file name')) + + def validate_notes_model_type(value): """Ensure that the provided model type is valid. diff --git a/src/backend/requirements-3.14.txt b/src/backend/requirements-3.14.txt index 96528aea96..b396bc0fa9 100644 --- a/src/backend/requirements-3.14.txt +++ b/src/backend/requirements-3.14.txt @@ -2152,6 +2152,12 @@ tinyhtml5==2.1.0 \ # via # -c src/backend/requirements.txt # weasyprint +tqdm==4.67.3 \ + --hash=sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb \ + --hash=sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf + # via + # -c src/backend/requirements.txt + # -r src/backend/requirements.in typing-extensions==4.15.0 \ --hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \ --hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548 diff --git a/src/backend/requirements.in b/src/backend/requirements.in index 5268cc9241..18e64dcbca 100644 --- a/src/backend/requirements.in +++ b/src/backend/requirements.in @@ -52,6 +52,7 @@ rapidfuzz # Fuzzy string matching sentry-sdk # Error reporting (optional) setuptools # Standard dependency tablib[xls,xlsx,yaml] # Support for XLS and XLSX formats +tqdm # Progress bars for CLI weasyprint # PDF generation whitenoise # Enhanced static file serving diff --git a/src/backend/requirements.txt b/src/backend/requirements.txt index 2d730180a4..0bcd65f018 100644 --- a/src/backend/requirements.txt +++ b/src/backend/requirements.txt @@ -1916,6 +1916,10 @@ tinyhtml5==2.1.0 \ --hash=sha256:60a50ec3d938a37e491efa01af895853060943dcebb5627de5b10d188b338a67 \ --hash=sha256:6e11cfff38515834268daf89d5f85bbde0b6dd02e8d9e212d1385c2289b89f0a # via weasyprint +tqdm==4.67.3 \ + --hash=sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb \ + --hash=sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf + # via -r src/backend/requirements.in typing-extensions==4.15.0 \ --hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \ --hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548 diff --git a/src/frontend/src/components/items/AttachmentLink.tsx b/src/frontend/src/components/items/AttachmentLink.tsx index 8922e0cb37..4836cd122b 100644 --- a/src/frontend/src/components/items/AttachmentLink.tsx +++ b/src/frontend/src/components/items/AttachmentLink.tsx @@ -12,6 +12,7 @@ import { } from '@tabler/icons-react'; import { type ReactNode, useMemo } from 'react'; import { generateUrl } from '../../functions/urls'; +import { Thumbnail } from '../images/Thumbnail'; /** * Return an icon based on the provided filename @@ -59,9 +60,11 @@ export function attachmentIcon(attachment: string): ReactNode { */ export function AttachmentLink({ attachment, + thumbnail, external }: Readonly<{ attachment: string; + thumbnail?: string; external?: boolean; }>): ReactNode { const url = useMemo(() => { @@ -82,7 +85,13 @@ export function AttachmentLink({ return ( - {external ? : attachmentIcon(attachment)} + {thumbnail ? ( + + ) : external ? ( + + ) : ( + attachmentIcon(attachment) + )} {!!attachment ? ( {text} diff --git a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx index 409110efee..03d369be91 100644 --- a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx +++ b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx @@ -57,6 +57,7 @@ export default function SystemSettings() { 'DISPLAY_FULL_NAMES', 'DISPLAY_PROFILE_INFO', 'WEEK_STARTS_ON', + 'INVENTREE_UPLOAD_MAX_SIZE', 'INVENTREE_STRICT_URLS' ]} /> diff --git a/src/frontend/src/tables/general/AttachmentTable.tsx b/src/frontend/src/tables/general/AttachmentTable.tsx index c31993ae80..6db7a83766 100644 --- a/src/frontend/src/tables/general/AttachmentTable.tsx +++ b/src/frontend/src/tables/general/AttachmentTable.tsx @@ -49,7 +49,12 @@ function attachmentTableColumns(): TableColumn[] { noWrap: true, render: (record: any) => { if (record.attachment) { - return ; + return ( + + ); } else if (record.link) { return ; } else { @@ -300,6 +305,11 @@ export function AttachmentTable({ name: 'is_file', label: t`Is File`, description: t`Show file attachments` + }, + { + name: 'is_image', + label: t`Is Image`, + description: t`Show image attachments` } ]; }, []);