mirror of
https://github.com/wahyd4/links.git
synced 2026-08-08 21:04:53 +10:00
feat: unify image storage onto files backend + Apple-style collections UI
- Image model: new file OneToOneField → FileUpload; get_url/get_thumbnail_url serve via /ui/files/ (Pillow thumbs) with legacy R2 fallback until backfill - upload_images API now persists through _save_uploaded_file (files backend); deletes clean up FileUpload disk+record; collection destroy cascades files - R2Storage.delete_file implemented (was silently missing → legacy R2 delete no-op) - manage.py migrate_image_storage: idempotent R2→files backfill (dry-run default, --commit, optional --delete-r2); nothing removed from R2 without explicit flag - Collections list: Apple design system, cover cards, hover actions, mobile-first - Collection detail: native drag&drop upload (CDN Dropzone removed), lightbox with keyboard/swipe nav + description edit + delete, valid JSON image data via json_script - Slideshow untouched (automatically uses new backend) - tests: 10 new (upload/delete/URLs/backfill), 148 total green
This commit is contained in:
+66
-30
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.'))
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
+21
-3
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,14 +2,195 @@
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
[x-cloak] { display: none !important; }
|
||||
|
||||
/* ============================================================
|
||||
Apple-inspired design system — Collections list
|
||||
Mirrors the Files page tokens so the whole app shares one
|
||||
visual language.
|
||||
============================================================ */
|
||||
:root {
|
||||
--apple-bg: #f5f5f7;
|
||||
--apple-text: #1d1d1f;
|
||||
--apple-gray: #6e6e73;
|
||||
--apple-blue: #0071e3;
|
||||
--apple-blue-hover: #0077ed;
|
||||
--apple-red: #ff3b30;
|
||||
--apple-green: #1e7b34;
|
||||
--apple-separator: rgba(0, 0, 0, 0.06);
|
||||
--apple-ease: cubic-bezier(0.28, 0.11, 0.32, 1);
|
||||
--apple-radius-lg: 28px;
|
||||
--apple-radius-md: 22px;
|
||||
--apple-shadow-card: 0 4px 24px rgba(0, 0, 0, 0.05);
|
||||
--apple-shadow-lift: 0 18px 44px rgba(0, 0, 0, 0.10);
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--apple-bg) !important;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
|
||||
"Helvetica Neue", "Segoe UI", Roboto, Arial, sans-serif;
|
||||
color: var(--apple-text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
.apple-wrap { max-width: 76rem; margin: 0 auto; padding: 0 1rem; }
|
||||
|
||||
/* ---------- Page header ---------- */
|
||||
.page-header {
|
||||
display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between;
|
||||
gap: 1rem; padding: 1.9rem 0.25rem 1.4rem;
|
||||
}
|
||||
.page-title-group { display: flex; flex-direction: column; gap: 0.2rem; }
|
||||
.section-title { font-size: clamp(1.5rem, 3.2vw, 2rem); font-weight: 700; letter-spacing: -0.025em; margin: 0; }
|
||||
.section-sub { font-size: 0.92rem; color: var(--apple-gray); margin: 0; }
|
||||
|
||||
/* ---------- Buttons ---------- */
|
||||
.apple-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 0.45rem;
|
||||
min-height: 44px; padding: 0.6rem 1.4rem; border-radius: 980px;
|
||||
font-weight: 600; font-size: 0.95rem; border: none; cursor: pointer; text-decoration: none;
|
||||
transition: background 0.3s var(--apple-ease), transform 0.3s var(--apple-ease), box-shadow 0.3s var(--apple-ease);
|
||||
}
|
||||
.apple-btn:active { transform: scale(0.97); }
|
||||
.apple-btn-primary { background: var(--apple-blue); color: #fff; box-shadow: 0 6px 18px rgba(0, 113, 227, 0.28); }
|
||||
.apple-btn-primary:hover { background: var(--apple-blue-hover); color: #fff; }
|
||||
.apple-btn svg { width: 1.05rem; height: 1.05rem; }
|
||||
|
||||
/* ---------- Collection cards ---------- */
|
||||
.collection-grid {
|
||||
display: grid; gap: 1.1rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
}
|
||||
.collection-card {
|
||||
position: relative; display: block; overflow: hidden;
|
||||
background: #fff; border-radius: var(--apple-radius-lg);
|
||||
border: 1px solid rgba(0, 0, 0, 0.04); box-shadow: var(--apple-shadow-card);
|
||||
transition: transform 0.45s var(--apple-ease), box-shadow 0.45s var(--apple-ease);
|
||||
text-decoration: none; color: inherit;
|
||||
}
|
||||
.collection-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--apple-shadow-lift);
|
||||
}
|
||||
.collection-card:active { transform: translateY(-1px) scale(0.99); }
|
||||
|
||||
.cover-wrap {
|
||||
position: relative; aspect-ratio: 16 / 10; overflow: hidden;
|
||||
background: linear-gradient(135deg, #ececf0, #fafafa);
|
||||
}
|
||||
.cover-img {
|
||||
width: 100%; height: 100%; object-fit: cover;
|
||||
transition: transform 0.6s var(--apple-ease);
|
||||
}
|
||||
.collection-card:hover .cover-img { transform: scale(1.045); }
|
||||
.cover-scrim {
|
||||
position: absolute; inset: 0;
|
||||
background: linear-gradient(to top, rgba(0,0,0,0.42), transparent 55%);
|
||||
}
|
||||
.cover-badge {
|
||||
position: absolute; bottom: 0.8rem; left: 1rem; right: 1rem;
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 0.5rem;
|
||||
color: #fff;
|
||||
}
|
||||
.cover-count {
|
||||
display: inline-flex; align-items: center; gap: 0.35rem;
|
||||
font-size: 0.78rem; font-weight: 600;
|
||||
background: rgba(255,255,255,0.22); backdrop-filter: blur(8px);
|
||||
padding: 0.3rem 0.7rem; border-radius: 980px;
|
||||
}
|
||||
.cover-count svg { width: 0.85rem; height: 0.85rem; }
|
||||
|
||||
/* Cover placeholder when a collection has no images */
|
||||
.cover-empty {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.4rem;
|
||||
height: 100%; color: var(--apple-gray);
|
||||
}
|
||||
.cover-empty svg { width: 2.4rem; height: 2.4rem; opacity: 0.5; }
|
||||
.cover-empty span { font-size: 0.82rem; font-weight: 500; }
|
||||
|
||||
/* Card body */
|
||||
.card-body { padding: 0.95rem 1.1rem 1.1rem; }
|
||||
.card-title {
|
||||
font-size: 1.05rem; font-weight: 700; letter-spacing: -0.01em; margin: 0 0 0.2rem;
|
||||
display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden;
|
||||
}
|
||||
.card-desc {
|
||||
font-size: 0.85rem; color: var(--apple-gray); margin: 0; line-height: 1.45;
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||
min-height: 2.45em;
|
||||
}
|
||||
.card-footer {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-top: 0.75rem; padding-top: 0.7rem; border-top: 1px solid var(--apple-separator);
|
||||
}
|
||||
.card-updated { font-size: 0.74rem; color: #a1a1a6; }
|
||||
|
||||
/* Overlay action pills (top-right of cover) */
|
||||
.card-actions {
|
||||
position: absolute; top: 0.7rem; right: 0.7rem;
|
||||
display: flex; gap: 0.35rem;
|
||||
opacity: 1;
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
.act-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 36px; height: 36px; border-radius: 50%;
|
||||
background: rgba(255,255,255,0.92); backdrop-filter: blur(8px);
|
||||
color: #3a3a3c; border: none; cursor: pointer; text-decoration: none;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.12);
|
||||
transition: background 0.25s ease, color 0.25s ease, transform 0.25s ease;
|
||||
}
|
||||
.act-btn:hover { transform: translateY(-1px); }
|
||||
.act-btn.blue:hover { background: var(--apple-blue); color: #fff; }
|
||||
.act-btn.red:hover { background: var(--apple-red); color: #fff; }
|
||||
.act-btn svg { width: 1.05rem; height: 1.05rem; }
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.card-actions { opacity: 0; }
|
||||
.collection-card:hover .card-actions { opacity: 1; }
|
||||
.collection-card:focus-within .card-actions { opacity: 1; }
|
||||
}
|
||||
|
||||
/* ---------- Empty state ---------- */
|
||||
.empty-state {
|
||||
grid-column: 1 / -1;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
padding: 4rem 1.5rem; text-align: center;
|
||||
background: #fff; border-radius: var(--apple-radius-lg);
|
||||
border: 1px dashed rgba(0, 0, 0, 0.14);
|
||||
}
|
||||
.empty-state svg { width: 3.4rem; height: 3.4rem; color: #c7c7cc; }
|
||||
.empty-title { font-size: 1.15rem; font-weight: 700; margin: 1rem 0 0.25rem; }
|
||||
.empty-sub { font-size: 0.9rem; color: var(--apple-gray); margin: 0 0 1.4rem; }
|
||||
|
||||
/* ---------- Pagination (Apple pills) ---------- */
|
||||
.pagination { display: flex; justify-content: center; gap: 0.4rem; padding: 2.2rem 0 1rem; flex-wrap: wrap; }
|
||||
.pagination a, .pagination span.current {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
min-width: 38px; height: 38px; padding: 0 0.9rem;
|
||||
border-radius: 980px; font-size: 0.88rem; font-weight: 600; text-decoration: none;
|
||||
background: #fff; color: var(--apple-blue);
|
||||
border: 1px solid rgba(0, 0, 0, 0.06); box-shadow: 0 1px 4px rgba(0,0,0,0.04);
|
||||
transition: background 0.25s ease, transform 0.25s ease;
|
||||
}
|
||||
.pagination a:hover { background: #f0f0f3; }
|
||||
.pagination span.current { background: var(--apple-blue); color: #fff; border-color: var(--apple-blue); }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8 py-2 sm:py-8">
|
||||
<div class="flex justify-between items-center mb-4 sm:mb-6">
|
||||
<h1 class="text-xl sm:text-2xl font-bold text-gray-900">{% trans "Image Collections" %}</h1>
|
||||
<a href="{% url 'collection-create' %}"
|
||||
class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700">
|
||||
<svg class="w-4 h-4 sm:w-5 sm:h-5 mr-1.5 sm:mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||
<div class="apple-wrap">
|
||||
<div class="page-header">
|
||||
<div class="page-title-group">
|
||||
<h1 class="section-title">{% trans "Image Collections" %}</h1>
|
||||
<p class="section-sub">{{ collections.paginator.count }} {% trans "collections" %} · {% trans "photos, wallpapers & memories" %}</p>
|
||||
</div>
|
||||
<a href="{% url 'collection-create' %}" class="apple-btn apple-btn-primary">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
{% trans "New Collection" %}
|
||||
</a>
|
||||
@@ -19,9 +200,10 @@
|
||||
{% include "links/includes/collection_list_items.html" %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Add getCookie function
|
||||
function getCookie(name) {
|
||||
let cookieValue = null;
|
||||
if (document.cookie && document.cookie !== '') {
|
||||
@@ -57,32 +239,5 @@ function deleteCollection(collectionId) {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const colors = [
|
||||
'bg-blue-50 hover:bg-blue-100 text-blue-700',
|
||||
'bg-green-50 hover:bg-green-100 text-green-700',
|
||||
'bg-yellow-50 hover:bg-yellow-100 text-yellow-700',
|
||||
'bg-red-50 hover:bg-red-100 text-red-700',
|
||||
'bg-indigo-50 hover:bg-indigo-100 text-indigo-700',
|
||||
'bg-purple-50 hover:bg-purple-100 text-purple-700',
|
||||
'bg-pink-50 hover:bg-pink-100 text-pink-700',
|
||||
'bg-cyan-50 hover:bg-cyan-100 text-cyan-700',
|
||||
'bg-orange-50 hover:bg-orange-100 text-orange-700',
|
||||
'bg-teal-50 hover:bg-teal-100 text-teal-700',
|
||||
'bg-lime-50 hover:bg-lime-100 text-lime-700',
|
||||
'bg-emerald-50 hover:bg-emerald-100 text-emerald-700',
|
||||
'bg-sky-50 hover:bg-sky-100 text-sky-700',
|
||||
'bg-violet-50 hover:bg-violet-100 text-violet-700',
|
||||
'bg-rose-50 hover:bg-rose-100 text-rose-700',
|
||||
'bg-amber-50 hover:bg-amber-100 text-amber-700'
|
||||
];
|
||||
|
||||
document.querySelectorAll('.tag-card').forEach(tag => {
|
||||
const randomColor = colors[Math.floor(Math.random() * colors.length)];
|
||||
tag.classList.add(...randomColor.split(' '));
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,89 +1,104 @@
|
||||
{% load i18n %}
|
||||
|
||||
<!-- Collections Grid -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 sm:gap-6 md:gap-8">
|
||||
<!-- Collections Grid — Apple cover cards -->
|
||||
<div class="collection-grid">
|
||||
{% for collection in collections %}
|
||||
<div class="group relative w-full">
|
||||
<!-- Fixed size container -->
|
||||
<div class="bg-white rounded-lg shadow-sm overflow-hidden hover:shadow-md transition-all duration-200 h-[280px] sm:h-[320px]">
|
||||
<a href="{% url 'collection-detail' collection.pk %}" class="block h-full">
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Image Preview Container - Fixed Height -->
|
||||
<div class="h-[160px] sm:h-[200px] bg-gradient-to-br from-gray-50 to-gray-100 p-2 sm:p-3">
|
||||
<div class="grid grid-cols-2 gap-1.5 sm:gap-2 h-full">
|
||||
{% with images=collection.images.all|slice:":4" %}
|
||||
{% for image in images %}
|
||||
<div class="aspect-w-1 aspect-h-1 overflow-hidden rounded-lg bg-gray-200 shadow-sm
|
||||
{% if forloop.counter > 2 %}hidden sm:block{% endif %}">
|
||||
<img src="{{ image.get_thumbnail_url }}"
|
||||
alt="{{ image.title }}"
|
||||
class="object-cover w-full h-full">
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="col-span-2 flex flex-col items-center justify-center h-full bg-gradient-to-br from-gray-50 to-gray-100 rounded-lg border-2 border-dashed border-gray-200">
|
||||
<svg class="w-8 h-8 sm:w-12 sm:h-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<p class="mt-1 sm:mt-2 text-xs sm:text-sm text-gray-500">{% trans "No images yet" %}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
<div class="collection-card" role="group">
|
||||
<a href="{% url 'collection-detail' collection.pk %}" class="block" aria-label="{{ collection.name }}">
|
||||
<div class="cover-wrap">
|
||||
{% with images=collection.images.all|slice:":1" %}
|
||||
{% for image in images %}
|
||||
<img class="cover-img" src="{{ image.get_thumbnail_url }}"
|
||||
alt="{{ collection.name }}" loading="lazy" decoding="async">
|
||||
{% empty %}
|
||||
<div class="cover-empty">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0022.5 18.75V5.25A2.25 2.25 0 0020.25 3H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21zm10.5-11.25h.008v.008h-.008v-.008z"/>
|
||||
</svg>
|
||||
<span>{% trans "No images yet" %}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Collection Info - Flex Grow to Fill Remaining Space -->
|
||||
<div class="flex-1 p-3 sm:p-4 flex flex-col">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">{{ collection.name }}</h3>
|
||||
{% if collection.description %}
|
||||
<p class="text-sm text-gray-600 mb-4">{{ collection.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
<!-- Image Count -->
|
||||
<p class="mt-0.5 text-xs sm:text-sm text-gray-500">
|
||||
{{ collection.images.count }} {% trans "images" %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
<div class="cover-scrim"></div>
|
||||
<div class="cover-badge">
|
||||
<span class="cover-count">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6.878V6a2.25 2.25 0 012.25-2.25h7.5A2.25 2.25 0 0118 6v.878m-12 0c.235-.083.487-.128.75-.128h10.5c.263 0 .515.045.75.128m-12 0A2.25 2.25 0 004.5 9v9a2.25 2.25 0 002.25 2.25h10.5A2.25 2.25 0 0019.5 18V9a2.25 2.25 0 00-2.25-2.25"/>
|
||||
</svg>
|
||||
{{ collection.images.count }}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="absolute top-1.5 sm:top-2 right-1.5 sm:right-2 flex space-x-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<a href="{% url 'collection-update' collection.pk %}"
|
||||
class="p-1 sm:p-1.5 bg-white text-gray-600 hover:text-blue-600 rounded-full hover:bg-blue-50 shadow-sm transition-colors duration-200"
|
||||
title="{% trans 'Edit Collection' %}">
|
||||
<svg class="w-4 h-4 sm:w-5 sm:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<button onclick="deleteCollection('{{ collection.pk }}'); event.preventDefault();"
|
||||
class="p-1 sm:p-1.5 bg-white text-gray-600 hover:text-red-600 rounded-full hover:bg-red-50 shadow-sm transition-colors duration-200"
|
||||
title="{% trans 'Delete Collection' %}">
|
||||
<svg class="w-4 h-4 sm:w-5 sm:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3 class="card-title">{{ collection.name }}</h3>
|
||||
{% if collection.description %}
|
||||
<p class="card-desc">{{ collection.description }}</p>
|
||||
{% else %}
|
||||
<p class="card-desc"> </p>
|
||||
{% endif %}
|
||||
<div class="card-footer">
|
||||
<span class="card-updated">{{ collection.created_at|date:"Y-m-d" }}</span>
|
||||
<span class="apple-link" style="color: var(--apple-blue); font-size: 0.82rem; font-weight: 600;">
|
||||
{% trans "Open" %} →
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Overlay actions (hover on desktop, always on mobile) -->
|
||||
<div class="card-actions">
|
||||
<a href="{% url 'collection-slideshow' collection.pk %}"
|
||||
class="act-btn blue" title="{% trans 'Slideshow' %}" aria-label="{% trans 'Slideshow' %}">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 3l14 9-14 9V3z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="{% url 'collection-update' collection.pk %}"
|
||||
class="act-btn" title="{% trans 'Edit Collection' %}" aria-label="{% trans 'Edit Collection' %}">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<button type="button" onclick="deleteCollection('{{ collection.pk }}')"
|
||||
class="act-btn red" title="{% trans 'Delete Collection' %}" aria-label="{% trans 'Delete Collection' %}">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="col-span-full flex flex-col items-center justify-center py-12 bg-gradient-to-br from-gray-50 to-gray-100 rounded-lg border-2 border-dashed border-gray-300">
|
||||
<svg class="w-16 h-16 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
<div class="empty-state">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0022.5 18.75V5.25A2.25 2.25 0 0020.25 3H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21zm10.5-11.25h.008v.008h-.008v-.008z"/>
|
||||
</svg>
|
||||
<h3 class="mt-4 text-lg font-medium text-gray-900">{% trans "No collections" %}</h3>
|
||||
<p class="mt-2 text-base text-gray-500">{% trans "Get started by creating a new collection." %}</p>
|
||||
<a href="{% url 'collection-create' %}"
|
||||
class="mt-6 inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700">
|
||||
<h3 class="empty-title">{% trans "No collections" %}</h3>
|
||||
<p class="empty-sub">{% trans "Get started by creating a new collection." %}</p>
|
||||
<a href="{% url 'collection-create' %}" class="apple-btn apple-btn-primary">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
{% trans "Create Collection" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% include "links/includes/pagination.html" %}
|
||||
<!-- Apple-style pagination (page-local; shared partial untouched) -->
|
||||
{% if is_paginated %}
|
||||
<div class="pagination" aria-label="Pagination">
|
||||
{% if page_obj.has_previous %}
|
||||
<a href="?page={{ page_obj.previous_page_number }}"
|
||||
hx-get="?page={{ page_obj.previous_page_number }}"
|
||||
hx-target="#paginated-content" hx-swap="innerHTML" hx-push-url="true"
|
||||
aria-label="{% trans 'Previous' %}">←</a>
|
||||
{% endif %}
|
||||
<span class="current">{{ page_obj.number }} / {{ page_obj.paginator.num_pages }}</span>
|
||||
{% if page_obj.has_next %}
|
||||
<a href="?page={{ page_obj.next_page_number }}"
|
||||
hx-get="?page={{ page_obj.next_page_number }}"
|
||||
hx-target="#paginated-content" hx-swap="innerHTML" hx-push-url="true"
|
||||
aria-label="{% trans 'Next' %}">→</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
"""
|
||||
Tests for Image Collections: unified files-backend storage, upload/delete
|
||||
API flows, URL resolution and the R2→files backfill command.
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.core.management import call_command
|
||||
from PIL import Image as PILImage
|
||||
|
||||
from links.models import ImageCollection, Image, FileUpload
|
||||
|
||||
|
||||
def _real_png(name="photo.png"):
|
||||
"""A fully decodable 8x8 PNG (Pillow can open it → thumbnails work)."""
|
||||
buf = io.BytesIO()
|
||||
PILImage.new("RGB", (8, 8), (200, 60, 60)).save(buf, format="PNG")
|
||||
return SimpleUploadedFile(name, buf.getvalue(), content_type="image/png")
|
||||
|
||||
|
||||
def _text_file():
|
||||
return SimpleUploadedFile("note.txt", b"hello", content_type="text/plain")
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestImageFilesBackend:
|
||||
def _collection(self):
|
||||
return ImageCollection.objects.create(name="Test Collection")
|
||||
|
||||
def test_upload_images_uses_files_backend(self, api_client):
|
||||
coll = self._collection()
|
||||
r = api_client.post(
|
||||
f"/api/collections/{coll.pk}/upload_images",
|
||||
{"file": _real_png()},
|
||||
format="multipart",
|
||||
)
|
||||
assert r.status_code == 201
|
||||
assert r.json()["status"] == "success"
|
||||
|
||||
image = coll.images.get()
|
||||
assert image.file is not None
|
||||
# The image is served through the files backend URL, not R2
|
||||
assert image.file_key == ""
|
||||
assert image.file.mime_type == "image/png"
|
||||
assert image.file.size > 0
|
||||
assert os.path.exists(image.file.file_path)
|
||||
assert image.get_url() == image.file.download_url
|
||||
assert image.get_url().startswith("/ui/files/")
|
||||
assert image.get_thumbnail_url() == image.file.thumbnail_url
|
||||
|
||||
def test_upload_rejects_non_image(self, api_client):
|
||||
coll = self._collection()
|
||||
r = api_client.post(
|
||||
f"/api/collections/{coll.pk}/upload_images",
|
||||
{"file": _text_file()},
|
||||
format="multipart",
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert coll.images.count() == 0
|
||||
assert FileUpload.objects.count() == 0
|
||||
|
||||
def test_upload_multiple_images(self, api_client):
|
||||
coll = self._collection()
|
||||
r = api_client.post(
|
||||
f"/api/collections/{coll.pk}/upload_images",
|
||||
{"file": [_real_png("a.png"), _real_png("b.png")]},
|
||||
format="multipart",
|
||||
)
|
||||
assert r.status_code == 201
|
||||
assert coll.images.count() == 2
|
||||
assert FileUpload.objects.count() == 2
|
||||
|
||||
def test_serializer_exposes_url_and_thumbnail(self, api_client):
|
||||
coll = self._collection()
|
||||
api_client.post(
|
||||
f"/api/collections/{coll.pk}/upload_images",
|
||||
{"file": _real_png()},
|
||||
format="multipart",
|
||||
)
|
||||
data = api_client.get(f"/api/collections/{coll.pk}").json()
|
||||
assert data["image_count"] == 1
|
||||
r = api_client.get(f"/api/images/{coll.images.get().pk}")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["url"].startswith("/ui/files/")
|
||||
assert body["thumbnail_url"] == "/ui/files/" + str(coll.images.get().file.pk) + "/thumb/"
|
||||
|
||||
def test_delete_image_removes_file_and_record(self, api_client):
|
||||
coll = self._collection()
|
||||
api_client.post(
|
||||
f"/api/collections/{coll.pk}/upload_images",
|
||||
{"file": _real_png()},
|
||||
format="multipart",
|
||||
)
|
||||
image = coll.images.get()
|
||||
path = image.file.file_path
|
||||
assert os.path.exists(path)
|
||||
|
||||
r = api_client.delete(f"/api/images/{image.pk}")
|
||||
assert r.status_code == 204 or r.status_code == 200
|
||||
assert not Image.objects.filter(pk=image.pk).exists()
|
||||
assert not FileUpload.objects.filter(pk=image.file.pk).exists()
|
||||
assert not os.path.exists(path)
|
||||
|
||||
def test_delete_collection_cascades(self, api_client):
|
||||
coll = self._collection()
|
||||
api_client.post(
|
||||
f"/api/collections/{coll.pk}/upload_images",
|
||||
{"file": [_real_png("a.png"), _real_png("b.png")]},
|
||||
format="multipart",
|
||||
)
|
||||
paths = [img.file.file_path for img in coll.images.all()]
|
||||
assert all(os.path.exists(p) for p in paths)
|
||||
|
||||
r = api_client.delete(f"/api/collections/{coll.pk}")
|
||||
assert r.status_code == 204 or r.status_code == 200
|
||||
assert not ImageCollection.objects.filter(pk=coll.pk).exists()
|
||||
assert not Image.objects.filter(collection=coll).exists()
|
||||
assert FileUpload.objects.count() == 0
|
||||
assert not any(os.path.exists(p) for p in paths)
|
||||
|
||||
def test_legacy_r2_image_falls_back_to_r2_url(self):
|
||||
"""Images not yet backfilled (file_key set, no file) still resolve via R2."""
|
||||
coll = self._collection()
|
||||
image = Image.objects.create(
|
||||
collection=coll, title="legacy", file_key="images/legacy/x.jpg",
|
||||
content_type="image/jpeg", size=123,
|
||||
)
|
||||
with mock.patch("links.models.R2Storage") as fake:
|
||||
fake.return_value.get_url.return_value = "https://r2.example/x.jpg?sig=1"
|
||||
assert image.get_url().startswith("https://r2.example")
|
||||
assert fake.return_value.get_url.call_count == 1
|
||||
# And thumbnails request the cdn-cgi transformation variant
|
||||
with mock.patch("links.models.R2Storage") as fake:
|
||||
fake.return_value.get_url.return_value = "https://cdn.example/x.jpg?w=200"
|
||||
image.get_thumbnail_url()
|
||||
kwargs = fake.return_value.get_url.call_args.kwargs
|
||||
assert kwargs.get("width") == 200
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestMigrateImageStorageCommand:
|
||||
def _legacy_image(self, coll, key="images/old/cat.jpg", raw=None):
|
||||
return Image.objects.create(
|
||||
collection=coll, title="old.png", file_key=key,
|
||||
content_type="image/png", size=len(raw or b"data"),
|
||||
)
|
||||
|
||||
def test_dry_run_does_nothing(self, tmp_path):
|
||||
coll = ImageCollection.objects.create(name="C")
|
||||
self._legacy_image(coll, raw=b"\x89PNGdata")
|
||||
call_command("migrate_image_storage")
|
||||
img = Image.objects.get()
|
||||
assert img.file is None
|
||||
assert img.file_key == "images/old/cat.jpg"
|
||||
|
||||
def test_backfill_creates_fileupload_and_links(self, tmp_path):
|
||||
coll = ImageCollection.objects.create(name="C")
|
||||
raw = io.BytesIO()
|
||||
PILImage.new("RGB", (8, 8)).save(raw, format="PNG")
|
||||
raw = raw.getvalue()
|
||||
self._legacy_image(coll, raw=raw)
|
||||
|
||||
fake_storage = mock.Mock()
|
||||
fake_storage.bucket = "bucket"
|
||||
fake_storage.client.get_object.return_value = {"Body": io.BytesIO(raw)}
|
||||
with mock.patch(
|
||||
"links.management.commands.migrate_image_storage.R2Storage",
|
||||
return_value=fake_storage,
|
||||
):
|
||||
call_command("migrate_image_storage", "--commit")
|
||||
|
||||
img = Image.objects.get()
|
||||
assert img.file is not None
|
||||
assert img.file_key == ""
|
||||
assert os.path.exists(img.file.file_path)
|
||||
assert img.file.size == len(raw)
|
||||
# Idempotent: second run has nothing to do
|
||||
with mock.patch(
|
||||
"links.management.commands.migrate_image_storage.R2Storage",
|
||||
return_value=fake_storage,
|
||||
):
|
||||
call_command("migrate_image_storage", "--commit")
|
||||
assert Image.objects.get().file is not None
|
||||
|
||||
def test_backfill_failure_leaves_record_for_retry(self, tmp_path):
|
||||
coll = ImageCollection.objects.create(name="C")
|
||||
self._legacy_image(coll, raw=b"\x89PNGdata")
|
||||
|
||||
fake_storage = mock.Mock()
|
||||
fake_storage.bucket = "bucket"
|
||||
fake_storage.client.get_object.side_effect = Exception("network down")
|
||||
with mock.patch(
|
||||
"links.management.commands.migrate_image_storage.R2Storage",
|
||||
return_value=fake_storage,
|
||||
):
|
||||
call_command("migrate_image_storage", "--commit")
|
||||
|
||||
img = Image.objects.get()
|
||||
assert img.file is None
|
||||
assert img.file_key == "images/old/cat.jpg"
|
||||
assert FileUpload.objects.count() == 0
|
||||
Reference in New Issue
Block a user