[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:
status:
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.
### 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.
+1
View File
@@ -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:
+1
View File
@@ -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
+11 -1
View File
@@ -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')
@@ -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.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',
)
+12 -1
View File
@@ -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'),
+18
View File
@@ -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)
+210
View File
@@ -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
+5 -1
View File
@@ -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)
+17 -1
View File
@@ -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.
+6
View File
@@ -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
+1
View File
@@ -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
+4
View File
@@ -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`
}
];
}, []);