2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-05-28 03:49:20 +00:00

[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
This commit is contained in:
Oliver
2026-05-22 23:37:32 +10:00
committed by GitHub
parent 74d9ab6d11
commit 27ca0836e7
22 changed files with 624 additions and 8 deletions
+3
View File
@@ -1,3 +1,6 @@
ignore:
- "src/backend/InvenTree/**/test_migrations.py"
coverage: coverage:
status: status:
project: project:
+13
View File
@@ -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. 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
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. 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.
+1
View File
@@ -33,6 +33,7 @@ Configuration of basic server settings:
{{ globalsetting("DISPLAY_FULL_NAMES") }} {{ globalsetting("DISPLAY_FULL_NAMES") }}
{{ globalsetting("DISPLAY_PROFILE_INFO") }} {{ globalsetting("DISPLAY_PROFILE_INFO") }}
{{ globalsetting("WEEK_STARTS_ON") }} {{ globalsetting("WEEK_STARTS_ON") }}
{{ globalsetting("INVENTREE_UPLOAD_MAX_SIZE") }}
{{ globalsetting("INVENTREE_STRICT_URLS") }} {{ globalsetting("INVENTREE_STRICT_URLS") }}
Configuration of various scheduled tasks: Configuration of various scheduled tasks:
+1
View File
@@ -120,6 +120,7 @@ possibly-unbound-attribute="ignore" # 21
[tool.coverage.run] [tool.coverage.run]
source = ["src/backend/InvenTree", "InvenTree"] source = ["src/backend/InvenTree", "InvenTree"]
dynamic_context = "test_function" dynamic_context = "test_function"
omit = ["*/test_migrations.py"]
[tool.coverage.html] [tool.coverage.html]
show_contexts = true show_contexts = true
@@ -1,11 +1,14 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # 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.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ 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 v492 -> 2026-05-22 : https://github.com/inventree/InvenTree/pull/11281
- Add Transfer Order model and associated API endpoint - Add Transfer Order model and associated API endpoint
+11 -1
View File
@@ -717,7 +717,17 @@ class AttachmentFilter(FilterSet):
"""Metaclass options.""" """Metaclass options."""
model = common.models.Attachment 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') is_link = rest_filters.BooleanFilter(label=_('Is Link'), method='filter_is_link')
@@ -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",
),
),
]
@@ -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),
]
+102 -1
View File
@@ -48,6 +48,7 @@ from django_q.signals import post_spawn
from djmoney.contrib.exchange.exceptions import MissingRate from djmoney.contrib.exchange.exceptions import MissingRate
from djmoney.contrib.exchange.models import convert_money from djmoney.contrib.exchange.models import convert_money
from opentelemetry import trace from opentelemetry import trace
from PIL import Image
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
from taggit.managers import TaggableManager 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 model_id: The ID of the model to which this attachment is linked
attachment: The uploaded file attachment: The uploaded file
url: An external URL 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 comment: A comment or description for the attachment
user: The user who uploaded the attachment user: The user who uploaded the attachment
upload_date: The date the attachment was uploaded 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 tags: Tags for the attachment
""" """
THUMBNAIL_SIZE = 256
class Meta: class Meta:
"""Metaclass options.""" """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 - Ensure that the attached file is deleted from storage when the database entry is removed
""" """
attachment = self.attachment attachment = self.attachment
thumbnail = self.thumbnail
super().delete(*args, **kwargs) super().delete(*args, **kwargs)
# Delete the associated files from storage (if they exist)W
if attachment and default_storage.exists(attachment.name): if attachment and default_storage.exists(attachment.name):
try: try:
# Remove the attached file from storage # Remove the attached file from storage
@@ -1966,6 +1973,13 @@ class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel
except Exception: # pragma: no cover except Exception: # pragma: no cover
pass 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): def save(self, *args, **kwargs):
"""Custom 'save' method for the Attachment model. """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 - Ensure that the 'content_type' and 'object_id' fields are set
- Run extra validations - Run extra validations
""" """
import common.tasks
rebuild = kwargs.pop('rebuild', True)
# Either 'attachment' or 'link' must be specified! # Either 'attachment' or 'link' must be specified!
if not self.attachment and not self.link: if not self.attachment and not self.link:
raise ValidationError({ raise ValidationError({
@@ -2000,6 +2018,12 @@ class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel
if self.file_size != 0: if self.file_size != 0:
super().save() 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): def clean_svg(self, field):
"""Sanitize SVG file before saving.""" """Sanitize SVG file before saving."""
cleaned = sanitize_svg(field.file.read()) cleaned = sanitize_svg(field.file.read())
@@ -2077,11 +2101,19 @@ class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel
attachment = models.FileField( attachment = models.FileField(
upload_to=rename_attachment, upload_to=rename_attachment,
verbose_name=_('Attachment'), verbose_name=_('Attachment'),
validators=[common.validators.validate_attachment_file],
help_text=_('Select file to attach'), help_text=_('Select file to attach'),
blank=True, blank=True,
null=True, null=True,
) )
thumbnail = models.ImageField(
verbose_name=_('Thumbnail'),
help_text=_('Thumbnail image for this attachment'),
blank=True,
null=True,
)
link = InvenTree.fields.InvenTreeURLField( link = InvenTree.fields.InvenTreeURLField(
blank=True, blank=True,
null=True, null=True,
@@ -2114,6 +2146,12 @@ class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel
help_text=_('Date the file was uploaded'), 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( file_size = models.PositiveIntegerField(
default=0, verbose_name=_('File size'), help_text=_('File size in bytes') 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) 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): class InvenTreeCustomUserStateModel(models.Model):
"""Custom model to extends any registered state with extra custom, user defined states. """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, common.tasks.rebuild_parameters,
instance.pk, instance.pk,
force_async=True, force_async=True,
group='part', group='parameters',
) )
+12 -1
View File
@@ -730,9 +730,11 @@ class AttachmentSerializer(FilterableSerializerMixin, InvenTreeModelSerializer):
fields = [ fields = [
'pk', 'pk',
'attachment', 'attachment',
'thumbnail',
'filename', 'filename',
'link', 'link',
'comment', 'comment',
'is_image',
'upload_date', 'upload_date',
'upload_user', 'upload_user',
'user_detail', 'user_detail',
@@ -742,7 +744,14 @@ class AttachmentSerializer(FilterableSerializerMixin, InvenTreeModelSerializer):
'tags', '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): def __init__(self, *args, **kwargs):
"""Override the model_type field to provide dynamic choices.""" """Override the model_type field to provide dynamic choices."""
@@ -759,6 +768,8 @@ class AttachmentSerializer(FilterableSerializerMixin, InvenTreeModelSerializer):
attachment = InvenTreeAttachmentSerializerField(required=False, allow_null=True) attachment = InvenTreeAttachmentSerializerField(required=False, allow_null=True)
thumbnail = InvenTreeImageSerializerField(read_only=True, allow_null=True)
# The 'filename' field must be present in the serializer # The 'filename' field must be present in the serializer
filename = serializers.CharField( filename = serializers.CharField(
label=_('Filename'), required=False, source='basename', allow_blank=False label=_('Filename'), required=False, source='basename', allow_blank=False
@@ -286,6 +286,13 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'choices': common.currency.currency_exchange_plugins, 'choices': common.currency.currency_exchange_plugins,
'default': 'inventreecurrencyexchange', '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': { 'INVENTREE_STRICT_URLS': {
'name': _('Strict URL Validation'), 'name': _('Strict URL Validation'),
'description': _('Require schema specification when validating URLs'), 'description': _('Require schema specification when validating URLs'),
+18
View File
@@ -204,3 +204,21 @@ def rebuild_parameters(template_id):
if n > 0: if n > 0:
logger.info("Rebuilt %s parameters for template '%s'", n, template.name) 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)
+210
View File
@@ -4,8 +4,11 @@ import io
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.files.storage import default_storage from django.core.files.storage import default_storage
from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import reverse from django.urls import reverse
from PIL import Image
import common.models import common.models
from InvenTree.unit_test import InvenTreeAPITestCase from InvenTree.unit_test import InvenTreeAPITestCase
@@ -789,3 +792,210 @@ class AttachmentAPITests(InvenTreeAPITestCase):
for att in attachments: for att in attachments:
# Ensure that the file associated with each attachment has been removed # Ensure that the file associated with each attachment has been removed
self.assertFalse(default_storage.exists(att.attachment.path)) 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
)
@@ -4,8 +4,10 @@ import io
import os import os
from django.core.files.base import ContentFile 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 django_test_migrations.contrib.unittest_case import MigratorTestCase
from PIL import Image
def get_legacy_models(): def get_legacy_models():
@@ -209,6 +211,85 @@ class TestForwardMigrations(MigratorTestCase):
self.assertEqual(Attachment.objects.filter(model_type=model).count(), 2) 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): def prep_currency_migration(self, vals: str):
"""Prepare the environment for the currency migration tests.""" """Prepare the environment for the currency migration tests."""
# Set keys # Set keys
+5 -1
View File
@@ -86,6 +86,11 @@ class AttachmentTest(InvenTreeAPITestCase):
} }
for fn, expected in filenames.items(): 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 = Attachment.objects.create(
attachment=self.generate_file(fn), attachment=self.generate_file(fn),
comment=f'Testing filename: {fn}', comment=f'Testing filename: {fn}',
@@ -93,7 +98,6 @@ class AttachmentTest(InvenTreeAPITestCase):
model_id=part.pk, model_id=part.pk,
) )
expected_path = f'attachments/part/{part.pk}/{expected}'
self.assertEqual(attachment.attachment.name, expected_path) self.assertEqual(attachment.attachment.name, expected_path)
self.assertEqual(attachment.file_size, 15) self.assertEqual(attachment.file_size, 15)
+17 -1
View File
@@ -2,7 +2,8 @@
import re 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 _ from django.utils.translation import gettext_lazy as _
import common.icons import common.icons
@@ -76,6 +77,21 @@ def validate_attachment_model_type(value):
raise ValidationError('Model type does not support attachments') 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): def validate_notes_model_type(value):
"""Ensure that the provided model type is valid. """Ensure that the provided model type is valid.
+6
View File
@@ -2152,6 +2152,12 @@ tinyhtml5==2.1.0 \
# via # via
# -c src/backend/requirements.txt # -c src/backend/requirements.txt
# weasyprint # 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 \ typing-extensions==4.15.0 \
--hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \ --hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \
--hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548 --hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548
+1
View File
@@ -52,6 +52,7 @@ rapidfuzz # Fuzzy string matching
sentry-sdk # Error reporting (optional) sentry-sdk # Error reporting (optional)
setuptools # Standard dependency setuptools # Standard dependency
tablib[xls,xlsx,yaml] # Support for XLS and XLSX formats tablib[xls,xlsx,yaml] # Support for XLS and XLSX formats
tqdm # Progress bars for CLI
weasyprint # PDF generation weasyprint # PDF generation
whitenoise # Enhanced static file serving whitenoise # Enhanced static file serving
+4
View File
@@ -1916,6 +1916,10 @@ tinyhtml5==2.1.0 \
--hash=sha256:60a50ec3d938a37e491efa01af895853060943dcebb5627de5b10d188b338a67 \ --hash=sha256:60a50ec3d938a37e491efa01af895853060943dcebb5627de5b10d188b338a67 \
--hash=sha256:6e11cfff38515834268daf89d5f85bbde0b6dd02e8d9e212d1385c2289b89f0a --hash=sha256:6e11cfff38515834268daf89d5f85bbde0b6dd02e8d9e212d1385c2289b89f0a
# via weasyprint # via weasyprint
tqdm==4.67.3 \
--hash=sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb \
--hash=sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf
# via -r src/backend/requirements.in
typing-extensions==4.15.0 \ typing-extensions==4.15.0 \
--hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \ --hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \
--hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548 --hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548
@@ -12,6 +12,7 @@ import {
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { type ReactNode, useMemo } from 'react'; import { type ReactNode, useMemo } from 'react';
import { generateUrl } from '../../functions/urls'; import { generateUrl } from '../../functions/urls';
import { Thumbnail } from '../images/Thumbnail';
/** /**
* Return an icon based on the provided filename * Return an icon based on the provided filename
@@ -59,9 +60,11 @@ export function attachmentIcon(attachment: string): ReactNode {
*/ */
export function AttachmentLink({ export function AttachmentLink({
attachment, attachment,
thumbnail,
external external
}: Readonly<{ }: Readonly<{
attachment: string; attachment: string;
thumbnail?: string;
external?: boolean; external?: boolean;
}>): ReactNode { }>): ReactNode {
const url = useMemo(() => { const url = useMemo(() => {
@@ -82,7 +85,13 @@ export function AttachmentLink({
return ( return (
<Group justify='left' gap='sm' wrap='nowrap'> <Group justify='left' gap='sm' wrap='nowrap'>
{external ? <IconLink /> : attachmentIcon(attachment)} {thumbnail ? (
<Thumbnail src={thumbnail} hover size={16} />
) : external ? (
<IconLink />
) : (
attachmentIcon(attachment)
)}
{!!attachment ? ( {!!attachment ? (
<Anchor href={url} target='_blank' rel='noopener noreferrer'> <Anchor href={url} target='_blank' rel='noopener noreferrer'>
{text} {text}
@@ -57,6 +57,7 @@ export default function SystemSettings() {
'DISPLAY_FULL_NAMES', 'DISPLAY_FULL_NAMES',
'DISPLAY_PROFILE_INFO', 'DISPLAY_PROFILE_INFO',
'WEEK_STARTS_ON', 'WEEK_STARTS_ON',
'INVENTREE_UPLOAD_MAX_SIZE',
'INVENTREE_STRICT_URLS' 'INVENTREE_STRICT_URLS'
]} ]}
/> />
@@ -49,7 +49,12 @@ function attachmentTableColumns(): TableColumn[] {
noWrap: true, noWrap: true,
render: (record: any) => { render: (record: any) => {
if (record.attachment) { if (record.attachment) {
return <AttachmentLink attachment={record.attachment} />; return (
<AttachmentLink
thumbnail={record.thumbnail}
attachment={record.attachment}
/>
);
} else if (record.link) { } else if (record.link) {
return <AttachmentLink attachment={record.link} external />; return <AttachmentLink attachment={record.link} external />;
} else { } else {
@@ -300,6 +305,11 @@ export function AttachmentTable({
name: 'is_file', name: 'is_file',
label: t`Is File`, label: t`Is File`,
description: t`Show file attachments` description: t`Show file attachments`
},
{
name: 'is_image',
label: t`Is Image`,
description: t`Show image attachments`
} }
]; ];
}, []); }, []);