diff --git a/links/api_views.py b/links/api_views.py index a7f7e79..90a589c 100644 --- a/links/api_views.py +++ b/links/api_views.py @@ -2,13 +2,35 @@ from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.pagination import PageNumberPagination -from .models import Link, ImageCollection, Image +from .models import Link, ImageCollection, Image, FileUpload from .serializers import LinkSerializer, ImageCollectionSerializer, ImageSerializer, ImageDescriptionSerializer from .storage import R2Storage +from .file_views import _save_uploaded_file, FileSaveError import uuid import logging -from django.conf import settings import os +from django.conf import settings + + +def _delete_image_file(image): + """Remove the FileUpload backing an Image (disk file + DB record). + + The Image→FileUpload link is SET_NULL, so deleting the FileUpload never + cascades to the Image — callers delete the Image record themselves. + """ + if not image.file: + return + try: + path = image.file.file_path + if os.path.exists(path): + os.remove(path) + # Also remove the generated thumbnail, if any + thumb = image.file.thumb_path + if os.path.exists(thumb): + os.remove(thumb) + except OSError: + logger.warning("Failed to remove file on disk for %s", image.file.pk, exc_info=True) + image.file.delete() logger = logging.getLogger(__name__) @@ -58,53 +80,64 @@ class ImageCollectionViewSet(viewsets.ModelViewSet): headers=headers ) + def perform_destroy(self, instance): + """Delete the collection AND every backing file (disk + FileUpload). + + Django's CASCADE on Image only removes the Image rows; the linked + FileUpload records (SET_NULL) and their files on disk would otherwise + be orphaned. Clean them up explicitly first. + """ + for image in instance.images.select_related('file').all(): + # Legacy R2 objects, if any + if image.file_key: + try: + R2Storage().delete_file(image.file_key) + except Exception as e: + logger.error(f"Failed to delete from storage: {e}") + _delete_image_file(image) + instance.delete() + @action(detail=True, methods=['post']) def upload_images(self, request, pk=None): - """Upload images to a collection""" + """Upload images to a collection (files backend — same storage as FileUpload).""" collection = self.get_object() files = request.FILES.getlist('file') descriptions = request.data.getlist('descriptions', []) # Get descriptions list - storage = R2Storage() logger.debug(f"Processing upload request for collection {collection.id}") logger.debug(f"Number of files: {len(files)}") - logger.debug(f"Number of descriptions: {len(descriptions)}") uploaded_images = [] for idx, file in enumerate(files): try: logger.debug(f"Processing file: {file.name}") - logger.debug(f"File size: {file.size}") - logger.debug(f"Content type: {file.content_type}") # Get description for this file if available description = descriptions[idx] if idx < len(descriptions) else None - # Generate unique file key - file_key = f"images/{collection.id}/{uuid.uuid4()}/{file.name}" - # Validate file if not hasattr(file, 'read'): raise ValueError("Invalid file object - missing read method") - if not file.content_type.startswith('image/'): + if not (file.content_type or '').startswith('image/'): raise ValueError("Invalid file type. Only images are allowed.") - # Upload to R2 + # Persist to the files backend (FILE_UPLOADS_FOLDER + FileUpload record) try: - storage.upload_file(file, file_key, file.content_type) - except Exception as e: - logger.error(f"Storage upload failed", exc_info=True) - raise ValueError(f"Storage upload failed: {str(e)}") + upload = _save_uploaded_file(file) + except FileSaveError as exc: + logger.error(f"Storage upload failed: {exc}", exc_info=True) + raise ValueError(f"Storage upload failed: {exc}") - # Create image record + # Create image record linked to the FileUpload image = Image.objects.create( collection=collection, title=file.name, description=description, - file_key=file_key, - content_type=file.content_type, - size=file.size + file=upload, + file_key='', # legacy R2 key no longer used for new uploads + content_type=upload.mime_type, + size=upload.size, ) logger.debug(f"Created image record: {image.id}") @@ -131,15 +164,17 @@ class ImageCollectionViewSet(viewsets.ModelViewSet): try: image = collection.images.get(id=image_id) - storage = R2Storage() - # Delete from storage + # Delete legacy R2 object if this image predates the files backend if image.file_key: try: - storage.delete_file(image.file_key) + R2Storage().delete_file(image.file_key) except Exception as e: logger.error(f"Failed to delete from storage: {e}") + # Delete the FileUpload (disk + record) when present + _delete_image_file(image) + # Delete from database image.delete() @@ -179,13 +214,14 @@ class ImageViewSet(viewsets.ModelViewSet): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def perform_destroy(self, instance): - """Delete image from storage when deleting record""" - storage = R2Storage() - try: - if instance.file_key: - storage.delete_file(instance.file_key) - except Exception as e: - logger.error(f"Failed to delete from storage: {e}") + """Delete image record + backing file storage when deleting record""" + # Delete legacy R2 object if this image predates the files backend + if instance.file_key: + try: + R2Storage().delete_file(instance.file_key) + except Exception as e: + logger.error(f"Failed to delete from storage: {e}") + _delete_image_file(instance) instance.delete() class MusicViewSet(viewsets.ViewSet): diff --git a/links/collection_views.py b/links/collection_views.py index 80b88b3..23d48e7 100644 --- a/links/collection_views.py +++ b/links/collection_views.py @@ -21,6 +21,21 @@ class CollectionDetailView(DetailView): template_name = 'links/collection_detail.html' context_object_name = 'collection' + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + # Serialised image list for the lightbox (valid JSON via json_script). + context['image_data'] = [ + { + 'id': str(img.id), + 'title': img.title, + 'description': img.description or '', + 'url': img.get_url(), + 'thumbnail_url': img.get_thumbnail_url(), + } + for img in self.object.images.select_related('file').all() + ] + return context + class CollectionCreateView(CreateView): model = ImageCollection form_class = ImageCollectionForm diff --git a/links/management/commands/migrate_image_storage.py b/links/management/commands/migrate_image_storage.py new file mode 100644 index 0000000..7360612 --- /dev/null +++ b/links/management/commands/migrate_image_storage.py @@ -0,0 +1,117 @@ +"""Backfill images from the legacy R2 storage onto the unified files backend. + +One-time / idempotent migration helper. For every Image that still has a +legacy ``file_key`` and no linked ``file`` (FileUpload), this command: + + 1. Downloads the object from R2 (via the same R2Storage client). + 2. Writes it to FILE_UPLOADS_FOLDER using the exact same persistence logic + as regular file uploads (temp file + fsync + byte-count verification). + 3. Creates a FileUpload record and links it to the Image (``image.file``). + 4. Clears ``image.file_key`` so the record is fully on the files backend. + +Nothing is deleted from R2 — the bucket keeps its objects until the operator +decides to purge them (run with ``--delete-r2`` for that). + +Usage: + python manage.py migrate_image_storage # dry-run report + python manage.py migrate_image_storage --commit # actually backfill + python manage.py migrate_image_storage --commit --delete-r2 +""" +from io import BytesIO + +from django.core.files.uploadedfile import InMemoryUploadedFile +from django.core.management.base import BaseCommand + +from links.file_views import _save_uploaded_file +from links.models import Image +from links.storage import R2Storage + + +class Command(BaseCommand): + help = "Migrate legacy R2-backed images onto the unified files backend." + + def add_arguments(self, parser): + parser.add_argument( + '--commit', action='store_true', + help='Actually perform the migration (default is a dry-run report).', + ) + parser.add_argument( + '--delete-r2', action='store_true', + help='After a successful backfill, delete the legacy object from R2.', + ) + + def handle(self, *args, **options): + commit = options['commit'] + delete_r2 = options['delete_r2'] + + pending = Image.objects.filter(file__isnull=True).exclude(file_key='') + total = pending.count() + self.stdout.write(f'Images pending R2→files backfill: {total}') + + if not commit: + self.stdout.write(self.style.WARNING( + 'Dry-run: re-run with --commit to perform the migration.')) + return + + storage = R2Storage() + done = skipped = failed = 0 + for image in pending.iterator(): + if not image.file_key: + skipped += 1 + continue + try: + # 1. Download from R2 + obj = storage.client.get_object( + Bucket=storage.bucket, Key=image.file_key) + raw = obj['Body'].read() + if not raw: + raise ValueError(f'Empty object for {image.file_key}') + + # 2–3. Persist through the standard files-backend path + fake = InMemoryUploadedFile( + BytesIO(raw), None, image.title or 'image', + image.content_type or 'application/octet-stream', + len(raw), None, + ) + upload = _save_uploaded_file(fake) + + legacy_key = image.file_key # capture before clearing + + # 4. Link + clear legacy key + image.file = upload + image.file_key = '' + image.content_type = upload.mime_type + image.size = upload.size + image.save(update_fields=['file', 'file_key', 'content_type', 'size', 'updated_at']) + + if delete_r2 and legacy_key: + try: + storage.delete_file(legacy_key) + except Exception as exc: # noqa: BLE001 + self.stderr.write(f' ⚠ R2 delete failed for {legacy_key}: {exc}') + + done += 1 + if done % 25 == 0: + self.stdout.write(f' … {done}/{total}') + except Exception as exc: # noqa: BLE001 + failed += 1 + self.stderr.write(f' ✗ {image.pk} ({image.file_key}): {exc}') + + self.stdout.write(self.style.SUCCESS( + f'Backfill complete: {done} migrated, {skipped} skipped, {failed} failed ' + f'(of {total}).')) + remaining = Image.objects.filter(file__isnull=True).exclude(file_key='').count() + if remaining: + self.stdout.write(self.style.WARNING(f'Still pending: {remaining} (re-run to retry).')) + else: + self.stdout.write(self.style.SUCCESS('All images are now on the files backend.')) + + # Sanity check: every migrated image has a real file on disk + missing = 0 + for img in Image.objects.select_related('file').filter(file__isnull=False): + if not img.file.file_path or not __import__('os').path.exists(img.file.file_path): + missing += 1 + if missing: + self.stderr.write(self.style.ERROR(f'{missing} images reference a missing file on disk!')) + else: + self.stdout.write(self.style.SUCCESS('Disk verification passed for all files-backed images.')) diff --git a/links/migrations/0056_add_image_file_upload_link.py b/links/migrations/0056_add_image_file_upload_link.py new file mode 100644 index 0000000..0062ce6 --- /dev/null +++ b/links/migrations/0056_add_image_file_upload_link.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2.16 on 2026-08-03 05:55 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('links', '0055_merge_template_back_into_link'), + ] + + operations = [ + migrations.AddField( + model_name='image', + name='file', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='image_record', to='links.fileupload', verbose_name='File'), + ), + migrations.AlterField( + model_name='image', + name='file_key', + field=models.CharField(blank=True, max_length=255, verbose_name='File Key'), + ), + ] diff --git a/links/models.py b/links/models.py index 763ba69..ba53a43 100644 --- a/links/models.py +++ b/links/models.py @@ -392,7 +392,12 @@ class Image(models.Model): collection = models.ForeignKey(ImageCollection, on_delete=models.CASCADE, related_name='images') title = models.CharField(_('Title'), max_length=200) description = models.TextField(_('Description'), blank=True, null=True) - file_key = models.CharField(_('File Key'), max_length=255) # R2 storage key + # Unified storage: images now live on the same files backend as FileUpload. + # file_key/content_type/size remain only as legacy R2-era fields until the + # backfill (manage.py migrate_image_storage) links every image to a FileUpload. + file = models.OneToOneField('FileUpload', on_delete=models.SET_NULL, null=True, blank=True, + related_name='image_record', verbose_name=_('File')) + file_key = models.CharField(_('File Key'), max_length=255, blank=True) # legacy R2 storage key content_type = models.CharField(_('Content Type'), max_length=100) size = models.BigIntegerField(_('Size in bytes')) created_at = models.DateTimeField(_('Created at'), auto_now_add=True) @@ -407,11 +412,24 @@ class Image(models.Model): return self.title def get_url(self, expires_in=3600): - """Get a signed URL for the image that expires after the specified time""" + """URL of the image. + + Preferred backend: the FileUpload (files) storage, served by Django at + /ui/files/{pk}-{name}. Falls back to the legacy R2 signed URL for images + that have not been backfilled yet (pre-migration data). + """ + if self.file: + return self.file.download_url return R2Storage().get_url(self.file_key, expires_in=expires_in) def get_thumbnail_url(self, width=200, height=200, expires_in=3600): - """Get a signed URL for the image thumbnail that expires after the specified time""" + """URL of the image thumbnail. + + Files backend: server-generated Pillow thumbnail via /ui/files/{pk}/thumb/. + Legacy fallback: R2 cdn-cgi/image transformation. + """ + if self.file: + return self.file.thumbnail_url return R2Storage().get_url( self.file_key, expires_in=expires_in, diff --git a/links/serializers.py b/links/serializers.py index 1fb9afb..5e5d140 100644 --- a/links/serializers.py +++ b/links/serializers.py @@ -101,15 +101,19 @@ class PostSerializer(serializers.ModelSerializer): class ImageSerializer(serializers.ModelSerializer): url = serializers.SerializerMethodField() + thumbnail_url = serializers.SerializerMethodField() class Meta: model = Image - fields = ['id', 'collection', 'title', 'description', 'content_type', 'size', 'created_at', 'updated_at', 'url'] + fields = ['id', 'collection', 'title', 'description', 'content_type', 'size', 'created_at', 'updated_at', 'url', 'thumbnail_url'] read_only_fields = ['id', 'collection', 'content_type', 'size', 'created_at', 'updated_at'] def get_url(self, obj): return obj.get_url() + def get_thumbnail_url(self, obj): + return obj.get_thumbnail_url() + class ImageDescriptionSerializer(serializers.ModelSerializer): class Meta: model = Image diff --git a/links/storage.py b/links/storage.py index 26b51aa..78766d4 100644 --- a/links/storage.py +++ b/links/storage.py @@ -48,6 +48,12 @@ class R2Storage: logger.error(f"Upload failed: {str(e)}", exc_info=True) raise + def delete_file(self, key): + """Delete an object from R2 (used for legacy pre-files-backend images).""" + if not key: + return + self.client.delete_object(Bucket=self.bucket, Key=key) + def get_url(self, key, expires_in=3600, width=None, height=None, fit=None): """Generate a signed URL that expires after the specified time""" if not key: diff --git a/links/templates/links/collection_detail.html b/links/templates/links/collection_detail.html index 37cd523..beccfc5 100644 --- a/links/templates/links/collection_detail.html +++ b/links/templates/links/collection_detail.html @@ -3,503 +3,771 @@ {% load static %} {% block extra_css %} - - {% endblock %} {% block content %} -
{{ collection.description }}
+{{ collection.description }}
+ {% endif %} + {% if collection.tags.exists %} +