Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
77ea61e
Multiple Images feature for part
Reza98Sh Oct 4, 2025
69518c6
enhance DetailsImage component with conditional grid column rendering…
Reza98Sh Oct 4, 2025
3e6380f
Refactor image handling across the application
Reza98Sh Oct 23, 2025
cb8ba42
Merge branch 'feature/multiplePartImage#t2'
Reza98Sh Oct 23, 2025
c082bbe
Refactor image handling: remove unused method, and enhance tests for…
Reza98Sh Oct 24, 2025
41b3f36
Fix: Rename copy_image to copy_images in PartDetail and PartTable com…
Reza98Sh Oct 24, 2025
b080ea5
Merge branch 'master' into feature/multiplePartImage#t2
Reza98Sh Oct 24, 2025
f482024
Refactor code structure for improved readability and maintainability
Reza98Sh Oct 24, 2025
64de8d6
Enhance image prefetching in CompanySerializer: add optional images q…
Reza98Sh Oct 24, 2025
eeeedca
Fix: Remove 'images' field from PartOptionsAPITest actions check
Reza98Sh Oct 24, 2025
8e49133
Fix: Update references in serializers and API for InvenTreeImage class
Reza98Sh Oct 24, 2025
b732d4e
Enhance image handling: add save_image method, update image retrieval…
Reza98Sh Oct 27, 2025
dd78a76
Refactor: Update comments for clarity and remove debug logs in image …
Reza98Sh Oct 27, 2025
e6ff0b2
Refactor: Update image handling in DetailsImage component to improve …
Reza98Sh Oct 27, 2025
fa2d113
Merge branch 'master' into feature/multiplePartImage#t2
Reza98Sh Oct 27, 2025
2de4988
Fix: Correct action detection logic in DetailsImage component
Reza98Sh Oct 27, 2025
c4b209f
Refactor: Update model option retrieval methods in validators and ser…
Reza98Sh Oct 31, 2025
a0ced07
Merge branch 'master' into feature/multiplePartImage#t2
Reza98Sh Oct 31, 2025
af0386e
Refactor: Simplify image handling in InvenTreeImageMixin and update i…
Reza98Sh Nov 9, 2025
1845c51
Merge branch 'master' into feature/multiplePartImage#t2
Reza98Sh Nov 9, 2025
329ded9
refactor(migrations): rename reverse migration functions for clarity
Reza98Sh Nov 9, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/backend/InvenTree/InvenTree/api_version.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
"""InvenTree API version information."""

# InvenTree API version
INVENTREE_API_VERSION = 423
INVENTREE_API_VERSION = 424
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""

INVENTREE_API_TEXT = """

v424 -> 2025-11-01 : https://github.com/inventree/InvenTree/pull/...
- Expose InvenTreeImage model to api/image API endpoints
- Adds 'image_url' and 'thumbnail_url' fields to relevant API serializers
- Updates Part and Company API endpoints to use new image architecture

v423 -> 2025-11-05 : https://github.com/inventree/InvenTree/pull/10772
- Adds "category_detail" field to BomItem API endpoints
- Adds "category_detail" field to BuildLine API endpoints
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@
import structlog
from PIL import UnidentifiedImageError

from company.models import Company
from part.models import Part
from common.models import InvenTreeImage

logger = structlog.get_logger('inventree')

Expand Down Expand Up @@ -52,18 +51,11 @@ def handle(self, *args, **kwargs):
"""Rebuild all thumbnail images."""
logger.info('Rebuilding Part thumbnails')

for part in Part.objects.exclude(image=None):
for image in InvenTreeImage.objects.exclude(image=None):
try:
self.rebuild_thumbnail(part)
self.rebuild_thumbnail(image)
except (OperationalError, ProgrammingError):
logger.exception('ERROR: Database read error.')
break

logger.info('Rebuilding Company thumbnails')

for company in Company.objects.exclude(image=None):
try:
self.rebuild_thumbnail(company)
except (OperationalError, ProgrammingError):
logger.exception('ERROR: abase read error.')
break
92 changes: 92 additions & 0 deletions src/backend/InvenTree/InvenTree/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
from typing import Optional

from django.contrib.auth import get_user_model
from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import QuerySet
Expand Down Expand Up @@ -1062,6 +1064,96 @@ def delete(self, *args, **kwargs):
)


class InvenTreeImageMixin(models.Model):
"""A mixin to add image capability to any model.

Provides a GenericRelation back to InvenTreeImage, plus helpers for primary image logic.
"""

# if True, only one image may ever be attached
single_image = False

images = GenericRelation(
'common.InvenTreeImage',
content_type_field='content_type',
object_id_field='object_id',
related_query_name='%(app_label)s_%(class)ss',
)

class Meta:
"""Metaclass options for this mixin."""

abstract = True

def save_image(self, file, file_name, primary=False):
"""Save an image to this instance.

Args:
file: File object or file-like object (e.g., UploadedFile, BytesIO, or file path)
file_name: Name for the image file
primary: If True, set this image as the primary image (default: False)

Returns:
InvenTreeImage: The created image instance

Raises:
ValueError: If single_image is True and an image already exists
"""
from django.core.files import File

from common.models import InvenTreeImage

# Check if single_image constraint is violated
if self.single_image and self.images.exists():
raise ValueError('This object can only have a single image')

# If setting as primary, unset any existing primary images
if primary:
self.images.filter(primary=True).update(primary=False)

# Create the InvenTreeImage instance
img = InvenTreeImage(content_object=self, primary=primary)

# Handle different file input types
if isinstance(file, str):
# If file is a path string, open it
with open(file, 'rb') as f:
img.image.save(file_name, File(f), save=True)
else:
# Assume it's a file-like object (UploadedFile, BytesIO, etc.)
img.image.save(file_name, file, save=True)

return img

def delete(self, *args, **kwargs):
"""Ensure related images are deleted first."""
# delete all related images
self.images.all().delete()
return super().delete(*args, **kwargs)

@property
def image(self):
"""Return the primary image, or None."""
return self.images.filter(primary=True).first()

def copy_images_to(self, target_pk):
"""Copy all images from this instance to another instance of the same model with pk."""
from common.models import InvenTreeImage

ct = ContentType.objects.get_for_model(self, for_concrete_model=False)
new_images = []
for img in self.images.all():
new_img = InvenTreeImage(
content_type=ct,
object_id=target_pk,
primary=img.primary,
image=img.image.name,
)
new_images.append(new_img)

InvenTreeImage.objects.bulk_create(new_images)


class InvenTreeBarcodeMixin(models.Model):
"""A mixin class for adding barcode functionality to a model class.

Expand Down
3 changes: 3 additions & 0 deletions src/backend/InvenTree/build/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import stock.serializers
from build.models import Build, BuildItem, BuildLine
from build.status_codes import BuildStatus, BuildStatusGroups
from common.filters import prefetch_related_images
from data_exporter.mixins import DataExportViewMixin
from generic.states.api import StatusView
from InvenTree.api import BulkDeleteMixin, MetadataView
Expand Down Expand Up @@ -329,6 +330,8 @@ def get_queryset(self):
'part__pricing_data',
)

queryset = prefetch_related_images(queryset, reference='part__')

return queryset


Expand Down
8 changes: 8 additions & 0 deletions src/backend/InvenTree/build/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import InvenTree.helpers
import part.filters
import part.serializers as part_serializers
from common.filters import prefetch_related_images
from common.serializers import ProjectCodeSerializer
from common.settings import get_global_setting
from generic.states.fields import InvenTreeCustomStatusSerializerMixin
Expand Down Expand Up @@ -1712,6 +1713,13 @@ def annotate_queryset(queryset, build=None):
)
)

queryset = prefetch_related_images(
queryset, reference='allocations__stock_item__part__'
)
queryset = prefetch_related_images(queryset, reference='build__part__')
queryset = prefetch_related_images(queryset, reference='bom_item__part__')
queryset = prefetch_related_images(queryset, reference='bom_item__sub_part__')

return queryset


Expand Down
48 changes: 47 additions & 1 deletion src/backend/InvenTree/common/admin.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
"""Admin for the common app."""

from django.contrib import admin
from django.utils.html import format_html

import common.models
import common.validators
import InvenTree.models


@admin.register(common.models.Attachment)
Expand All @@ -13,7 +15,9 @@ class AttachmentAdmin(admin.ModelAdmin):
def formfield_for_dbfield(self, db_field, request, **kwargs):
"""Provide custom choices for 'model_type' field."""
if db_field.name == 'model_type':
db_field.choices = common.validators.attachment_model_options()
db_field.choices = common.validators.get_model_options_for_mixin(
InvenTree.models.InvenTreeAttachmentMixin
)

return super().formfield_for_dbfield(db_field, request, **kwargs)

Expand All @@ -33,6 +37,48 @@ def formfield_for_dbfield(self, db_field, request, **kwargs):
search_fields = ('content_type', 'comment')


@admin.register(common.models.InvenTreeImage)
class InvenTreeImageAdmin(admin.ModelAdmin):
"""Admin interface for InvenTreeImage objects."""

list_display = (
'id',
'content_type',
'object_id',
'primary',
'image',
'image_thumbnail',
)
list_filter = ('content_type', 'primary', 'image')
search_fields = ('object_id',)
readonly_fields = ('image_thumbnail',)
fieldsets = (
(
None,
{
'fields': (
'content_type',
'object_id',
'primary',
'image',
'image_thumbnail',
)
},
),
)

def image_thumbnail(self, obj):
"""Returns a small preview of the uploaded image."""
if obj.image and hasattr(obj.image, 'url'):
return format_html(
'<img src="{}" style="max-height: 100px; max-width: 100px;" />',
obj.image.url,
)
return '-'

image_thumbnail.short_description = 'Preview'


@admin.register(common.models.DataOutput)
class DataOutputAdmin(admin.ModelAdmin):
"""Admin interface for DataOutput objects."""
Expand Down
Loading
Loading