mirror of
https://github.com/inventree/InvenTree.git
synced 2026-05-25 03:10:44 -05: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:
@@ -1,3 +1,6 @@
|
||||
ignore:
|
||||
- "src/backend/InvenTree/**/test_migrations.py"
|
||||
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
+48
@@ -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),
|
||||
]
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<Group justify='left' gap='sm' wrap='nowrap'>
|
||||
{external ? <IconLink /> : attachmentIcon(attachment)}
|
||||
{thumbnail ? (
|
||||
<Thumbnail src={thumbnail} hover size={16} />
|
||||
) : external ? (
|
||||
<IconLink />
|
||||
) : (
|
||||
attachmentIcon(attachment)
|
||||
)}
|
||||
{!!attachment ? (
|
||||
<Anchor href={url} target='_blank' rel='noopener noreferrer'>
|
||||
{text}
|
||||
|
||||
@@ -57,6 +57,7 @@ export default function SystemSettings() {
|
||||
'DISPLAY_FULL_NAMES',
|
||||
'DISPLAY_PROFILE_INFO',
|
||||
'WEEK_STARTS_ON',
|
||||
'INVENTREE_UPLOAD_MAX_SIZE',
|
||||
'INVENTREE_STRICT_URLS'
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -49,7 +49,12 @@ function attachmentTableColumns(): TableColumn[] {
|
||||
noWrap: true,
|
||||
render: (record: any) => {
|
||||
if (record.attachment) {
|
||||
return <AttachmentLink attachment={record.attachment} />;
|
||||
return (
|
||||
<AttachmentLink
|
||||
thumbnail={record.thumbnail}
|
||||
attachment={record.attachment}
|
||||
/>
|
||||
);
|
||||
} else if (record.link) {
|
||||
return <AttachmentLink attachment={record.link} external />;
|
||||
} 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`
|
||||
}
|
||||
];
|
||||
}, []);
|
||||
|
||||
Reference in New Issue
Block a user