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:
OpenClaw Sub-agent
2026-08-03 16:08:34 +10:00
parent 84ec8c2c24
commit 366cc35ff5
11 changed files with 1444 additions and 580 deletions
+66 -30
View File
@@ -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):
+15
View File
@@ -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}')
# 23. 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
View File
@@ -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,
+5 -1
View File
@@ -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
+6
View File
@@ -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
+190 -35
View File
@@ -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">&nbsp;</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 %}
+206
View File
@@ -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