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:
@@ -1,3 +1,6 @@
|
|||||||
|
ignore:
|
||||||
|
- "src/backend/InvenTree/**/test_migrations.py"
|
||||||
|
|
||||||
coverage:
|
coverage:
|
||||||
status:
|
status:
|
||||||
project:
|
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.
|
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.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|
||||||
|
|||||||
+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.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',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
Reference in New Issue
Block a user