From 9507fe901c7f40f98b5ca269d25914565c0dd310 Mon Sep 17 00:00:00 2001 From: Kenneth Reitz Date: Wed, 8 Apr 2026 15:02:49 -0400 Subject: [PATCH] Add photo manager, folder importer, Postgres Celery broker, UI polish - Photo manager (/manage/) with multi-select, bulk visibility/delete, faceted filtering by camera/lens/year, add-to-collection with inline creation - import_folder management command with concurrent workers - Use Postgres as Celery broker in production (no Redis needed on Fly) - Add Celery worker process to fly.toml - Thread fallback for image processing when Celery unavailable - Collection list with image preview cards - CSS cache-busting via content hash - Fix empty UUID validation errors in bulk actions - Makefile: remove redis start/stop (now a brew service) - Switch to ManifestStaticFilesStorage for production Co-Authored-By: Claude Opus 4.6 (1M context) --- AGENTS.md | 94 +++++++++ Dockerfile | 1 + Makefile | 2 - core/context_processors.py | 19 ++ core/management/commands/import_folder.py | 194 ++++++++++++++++++ core/views.py | 71 ++++++- exiftree/settings.py | 16 +- fly.toml | 5 + .../templates/gallery/collection_list.html | 19 +- gallery/views.py | 16 ++ groups/__init__.py | 0 groups/admin.py | 24 --- groups/apps.py | 5 - groups/migrations/0001_initial.py | 138 ------------- groups/migrations/__init__.py | 0 groups/models.py | 64 ------ groups/templates/groups/group_detail.html | 53 ----- groups/templates/groups/group_list.html | 25 --- groups/templates/groups/group_members.html | 29 --- groups/tests.py | 3 - groups/urls.py | 11 - groups/views.py | 17 -- ingest/views.py | 4 +- pyproject.toml | 1 + static/css/style.css | 48 ++++- templates/base.html | 2 +- templates/manage.html | 96 ++++++--- uv.lock | 87 ++++++++ 28 files changed, 618 insertions(+), 426 deletions(-) create mode 100644 AGENTS.md create mode 100644 core/management/commands/import_folder.py delete mode 100644 groups/__init__.py delete mode 100644 groups/admin.py delete mode 100644 groups/apps.py delete mode 100644 groups/migrations/0001_initial.py delete mode 100644 groups/migrations/__init__.py delete mode 100644 groups/models.py delete mode 100644 groups/templates/groups/group_detail.html delete mode 100644 groups/templates/groups/group_list.html delete mode 100644 groups/templates/groups/group_members.html delete mode 100644 groups/tests.py delete mode 100644 groups/urls.py delete mode 100644 groups/views.py diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..959962f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,94 @@ +# AGENTS.md + +## Project + +ExifTree — a community platform where visual creators showcase work organized by the gear used to create it. Browse photography through cameras, lenses, and EXIF metadata. + +**Domain:** exiftree.org +**Stack:** Django 5.x · Python 3.12+ · PostgreSQL · Celery + Redis +**Architecture doc:** `architecture.md` + +## Code Style + +- Python: follow PEP 8, use type hints on function signatures +- Django: fat models, thin views — business logic lives on the model or in service functions, not in views +- Imports: stdlib → third-party → django → local apps, separated by blank lines +- Strings: double quotes for user-facing text, single quotes for identifiers and dict keys +- Tests: use pytest + pytest-django, not unittest + +## Project Layout + +The Django project is created in the repo root (`django-admin startproject exiftree .`) — `manage.py` lives at the top level, not nested in a subdirectory. + +## Django App Structure + +The project is organized into focused Django apps: + +- `core` — foundational models (User, Image, Camera, Lens, ExifData). Other apps import from here but never the reverse. +- `tree` — browse-by-gear discovery pages. No models, reads from core. +- `gallery` — user portfolios and collections. +- `groups` — community spaces and memberships. +- `ingest` — upload pipeline, EXIF extraction, thumbnail generation. +- `search` — EXIF-powered filtering and search. + +**Dependency rule:** `core` depends on nothing. All other apps may depend on `core`. Avoid cross-dependencies between feature apps — if two apps need to share logic, it probably belongs in `core`. + +## Models + +- Always use `UUIDField` for primary keys (not auto-increment integers) +- Add `created_at` and `updated_at` timestamps to every model +- Use `SlugField` on anything that appears in a URL +- ExifData stores raw EXIF as a JSONField alongside parsed/indexed fields — never throw away the raw data +- Camera and Lens records are canonical/normalized — raw EXIF strings map to these via the normalization layer in `core/normalization.py` + +## EXIF Normalization + +This is critical infrastructure. EXIF strings are inconsistent across manufacturers. The normalization pipeline must: + +- Strip redundant manufacturer prefixes ("NIKON CORPORATION NIKON D850" → "Nikon", "D850") +- Handle case normalization +- Deduplicate via a lookup table of known aliases +- Create new Camera/Lens records only when no match exists +- Be idempotent — running normalization twice on the same input produces the same result + +## Image Pipeline + +Uploads flow through `ingest`: + +1. Validate format and size +2. Extract EXIF (Pillow / exifread) +3. Normalize camera/lens → core models +4. Generate thumbnails (small, medium, large) +5. Store originals + thumbnails in object storage (Cloudflare R2) +6. Create Image + ExifData records + +All processing after initial validation happens async via Celery tasks. Never block the request/response cycle on image processing. + +## Frontend + +Django templates + HTMX for interactivity unless otherwise decided. Keep JavaScript minimal. The site should work without JS enabled for core browsing. + +## Database + +PostgreSQL. Key indexing priorities: + +- ExifData: camera_id, lens_id, focal_length, aperture, iso, date_taken +- Image: user_id, upload_date, visibility +- Camera/Lens: slug, manufacturer + +## URLs + +- Camera tree: `/cameras/`, `/cameras//`, `/cameras///` +- Lens tree: `/lenses/`, `/lenses///` +- User profiles: `/@/` +- Collections: `/@/collections//` +- Groups: `/groups//` + +## When Working on This Project + +- Read `architecture.md` for full context on app structure and open decisions +- Don't add dependencies without discussing tradeoffs first +- Prefer Django's built-in tools over third-party packages when they're sufficient +- Write migrations that are reversible +- Keep the normalization lookup table in a format that's easy to contribute to (YAML or dict, not hardcoded if/else chains) +- If a piece of logic could live in core or a feature app, default to the feature app — keep core minimal diff --git a/Dockerfile b/Dockerfile index 52519a7..b7df1d9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,4 +27,5 @@ RUN uv run python manage.py collectstatic --noinput EXPOSE 8000 +# Default command — overridden by fly.toml [processes] CMD ["uv", "run", "python", "manage.py", "runbolt", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Makefile b/Makefile index 4cd1c8c..9c8f1d7 100644 --- a/Makefile +++ b/Makefile @@ -8,14 +8,12 @@ sync: run: sync fly proxy 5432 -a exiftree-db & - redis-server --daemonize yes 2>/dev/null || true uv run celery -A exiftree worker -l info -c 2 --detach --pidfile /tmp/exiftree-celery.pid 2>/dev/null || true sleep 1 uv run python manage.py collectstatic --noinput 2>/dev/null uv run python manage.py runbolt --dev kill $$(cat /tmp/exiftree-celery.pid 2>/dev/null) 2>/dev/null || true kill %1 2>/dev/null || true - redis-cli shutdown 2>/dev/null || true bolt: sync uv run python manage.py runbolt --dev diff --git a/core/context_processors.py b/core/context_processors.py index cb81cfa..b9bdcb9 100644 --- a/core/context_processors.py +++ b/core/context_processors.py @@ -1,9 +1,28 @@ +import hashlib +from pathlib import Path + +from django.conf import settings + from core.models import SiteConfig +_cache_bust = None + + +def _get_cache_bust() -> str: + global _cache_bust + if _cache_bust is None or settings.DEBUG: + css = Path(settings.STATICFILES_DIRS[0]) / 'css' / 'style.css' + if css.exists(): + _cache_bust = hashlib.md5(css.read_bytes()).hexdigest()[:8] + else: + _cache_bust = '0' + return _cache_bust + def site_context(request): config = SiteConfig.load() return { 'site_title': config.site_title, 'site_tagline': config.tagline, + 'cache_bust': _get_cache_bust(), } diff --git a/core/management/commands/import_folder.py b/core/management/commands/import_folder.py new file mode 100644 index 0000000..5493f29 --- /dev/null +++ b/core/management/commands/import_folder.py @@ -0,0 +1,194 @@ +""" +Import photos from a local folder. + +Usage: + manage.py import_folder /path/to/photos + manage.py import_folder /path/to/photos --collection="Trip to Japan" + manage.py import_folder /path/to/photos --recursive + manage.py import_folder /path/to/photos --visibility=private +""" + +import hashlib +from pathlib import Path + +from django.core.files.base import ContentFile +from django.core.management.base import BaseCommand +from django.utils.text import slugify + +from core.models import Image, User + +SUPPORTED_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.webp', '.tiff', '.tif'} + + +class Command(BaseCommand): + help = "Import photos from a local folder" + + def add_arguments(self, parser): + parser.add_argument('path', type=str, help="Path to folder of images") + parser.add_argument( + '--user', type=str, default='', + help="Username to import as (defaults to first user)", + ) + parser.add_argument( + '--collection', type=str, default='', + help="Create or add to a collection with this name", + ) + parser.add_argument( + '--recursive', action='store_true', + help="Recurse into subdirectories", + ) + parser.add_argument( + '--visibility', type=str, default='public', + choices=['public', 'private', 'unlisted'], + help="Visibility for imported images (default: public)", + ) + parser.add_argument( + '--workers', type=int, default=4, + help="Number of concurrent upload workers (default: 4)", + ) + parser.add_argument( + '--dry-run', action='store_true', + help="Show what would be imported without importing", + ) + + def handle(self, *args, **options): + folder = Path(options['path']).expanduser().resolve() + if not folder.is_dir(): + self.stderr.write(self.style.ERROR(f"Not a directory: {folder}")) + return + + # Resolve user + username = options['user'] + if username: + user = User.objects.filter(username=username).first() + if not user: + self.stderr.write(self.style.ERROR(f"User not found: {username}")) + return + else: + user = User.objects.first() + if not user: + self.stderr.write(self.style.ERROR("No users exist. Create one first.")) + return + + self.stdout.write(f"Importing as: {user.username}") + + # Collect files + if options['recursive']: + files = sorted(f for f in folder.rglob('*') if f.suffix.lower() in SUPPORTED_EXTENSIONS) + else: + files = sorted(f for f in folder.iterdir() if f.suffix.lower() in SUPPORTED_EXTENSIONS) + + if not files: + self.stderr.write(self.style.WARNING(f"No images found in {folder}")) + return + + self.stdout.write(f"Found {len(files)} images") + + if options['dry_run']: + for f in files: + self.stdout.write(f" {f.name}") + return + + # Create collection if requested + collection = None + if options['collection']: + from gallery.models import Collection + import uuid + col_title = options['collection'] + base_slug = slugify(col_title) or 'import' + slug = base_slug + while Collection.objects.filter(user=user, slug=slug).exists(): + slug = f"{base_slug}-{str(uuid.uuid4())[:8]}" + collection, created = Collection.objects.get_or_create( + user=user, slug=slug, + defaults={'title': col_title}, + ) + if created: + self.stdout.write(f"Created collection: {col_title}") + else: + self.stdout.write(f"Adding to collection: {col_title}") + + # Import concurrently + import threading + from concurrent.futures import ThreadPoolExecutor, as_completed + + imported = 0 + skipped = 0 + errors = 0 + lock = threading.Lock() + total = len(files) + + def import_one(i: int, filepath: Path) -> tuple[str, str]: + """Returns (status, filename). status is 'ok', 'skip', or 'error'.""" + try: + contents = filepath.read_bytes() + content_hash = hashlib.sha256(contents).hexdigest() + + existing = Image.objects.filter(content_hash=content_hash).first() + if existing: + if collection: + with lock: + self._add_to_collection(collection, existing, i) + return 'skip', filepath.name + + title = filepath.stem.replace('_', ' ').replace('-', ' ') + slug = slugify(title) or f"import-{i}" + + img = Image.objects.create( + user=user, + title=title, + slug=slug, + original=ContentFile(contents, name=filepath.name), + content_hash=content_hash, + visibility=options['visibility'], + is_processing=True, + ) + del contents + + from ingest.tasks import process_image_task + try: + process_image_task.delay(str(img.id)) + except Exception: + from ingest.pipeline import process_image + process_image(img) + + if collection: + with lock: + self._add_to_collection(collection, img, i) + + return 'ok', filepath.name + + except Exception as e: + return 'error', f"{filepath.name}: {e}" + + workers = options['workers'] + self.stdout.write(f"Importing with {workers} workers...") + + with ThreadPoolExecutor(max_workers=workers) as pool: + futures = { + pool.submit(import_one, i, fp): i + for i, fp in enumerate(files) + } + for future in as_completed(futures): + status, name = future.result() + idx = futures[future] + 1 + if status == 'ok': + imported += 1 + self.stdout.write(f" [{idx}/{total}] OK {name}") + elif status == 'skip': + skipped += 1 + self.stdout.write(f" [{idx}/{total}] SKIP (duplicate) {name}") + else: + errors += 1 + self.stderr.write(self.style.ERROR(f" [{idx}/{total}] ERROR {name}")) + + self.stdout.write(self.style.SUCCESS( + f"\nDone: {imported} imported, {skipped} skipped, {errors} errors" + )) + + def _add_to_collection(self, collection, image, sort_order: int): + from gallery.models import CollectionImage + CollectionImage.objects.get_or_create( + collection=collection, image=image, + defaults={'sort_order': sort_order}, + ) diff --git a/core/views.py b/core/views.py index 4597760..d63b640 100644 --- a/core/views.py +++ b/core/views.py @@ -2,6 +2,7 @@ from django.contrib.auth.decorators import login_required from django.db.models import Count from django.db.models.functions import ExtractYear from django.shortcuts import get_object_or_404, redirect, render +from django.utils.text import slugify from django.views.decorators.http import require_POST from core.models import ExifData, Image @@ -74,7 +75,6 @@ def dashboard(request): def dashboard_create_collection(request): title = request.POST.get('title', '').strip() if title: - from django.utils.text import slugify import uuid base_slug = slugify(title) or 'collection' slug = base_slug @@ -105,26 +105,67 @@ def dashboard_delete_image(request, image_id): @login_required def manage(request): - images = ( + from core.models import Camera, Lens + + qs = ( Image.objects.filter(user=request.user, is_processing=False) .select_related('exif', 'exif__camera', 'exif__lens') .order_by('-upload_date') ) + + # Filters + camera = request.GET.get('camera', '') + lens = request.GET.get('lens', '') + year = request.GET.get('year', '') + visibility = request.GET.get('visibility', '') + + if camera: + qs = qs.filter(exif__camera_id=camera) + if lens: + qs = qs.filter(exif__lens_id=lens) + if year: + qs = qs.filter(exif__date_taken__year=int(year)) + if visibility: + qs = qs.filter(visibility=visibility) + + # Facets: only cameras/lenses/years the user actually has + user_images = Image.objects.filter(user=request.user, is_processing=False) + cameras = ( + Camera.objects.filter(images__image__in=user_images) + .distinct().order_by('manufacturer', 'model') + ) + lenses = ( + Lens.objects.filter(images__image__in=user_images) + .distinct().order_by('manufacturer', 'model') + ) + years = ( + ExifData.objects.filter(image__in=user_images, date_taken__isnull=False) + .dates('date_taken', 'year', order='DESC') + ) + collections = ( Collection.objects.filter(user=request.user) .annotate(image_count=Count('collection_images')) .order_by('-created_at') ) + return render(request, 'manage.html', { - 'images': images, + 'images': qs, 'collections': collections, + 'cameras': cameras, + 'lenses': lenses, + 'years': years, + 'filter_camera': camera, + 'filter_lens': lens, + 'filter_year': year, + 'filter_visibility': visibility, }) @login_required @require_POST def manage_set_visibility(request): - image_ids = request.POST.getlist('image_ids') + image_ids = [x for x in request.POST.getlist('image_ids') if x.strip()] visibility = request.POST.get('visibility', 'public') if visibility in ('public', 'private', 'unlisted') and image_ids: Image.objects.filter(id__in=image_ids, user=request.user).update(visibility=visibility) @@ -134,7 +175,7 @@ def manage_set_visibility(request): @login_required @require_POST def manage_delete_images(request): - image_ids = request.POST.getlist('image_ids') + image_ids = [x for x in request.POST.getlist('image_ids') if x.strip()] if image_ids: Image.objects.filter(id__in=image_ids, user=request.user).delete() return redirect('manage') @@ -143,9 +184,24 @@ def manage_delete_images(request): @login_required @require_POST def manage_add_to_collection(request): + import uuid as _uuid from gallery.models import CollectionImage - image_ids = request.POST.getlist('image_ids') - collection_id = request.POST.get('collection_id') + + image_ids = [x for x in request.POST.getlist('image_ids') if x.strip()] + collection_id = request.POST.get('collection_id', '').strip() + new_collection_name = request.POST.get('new_collection', '').strip() + + # Create new collection if requested + if new_collection_name and not collection_id: + base_slug = slugify(new_collection_name) or 'collection' + slug = base_slug + while Collection.objects.filter(user=request.user, slug=slug).exists(): + slug = f"{base_slug}-{str(_uuid.uuid4())[:8]}" + col = Collection.objects.create( + user=request.user, title=new_collection_name, slug=slug, + ) + collection_id = str(col.id) + if image_ids and collection_id: collection = Collection.objects.filter(id=collection_id, user=request.user).first() if collection: @@ -156,7 +212,6 @@ def manage_add_to_collection(request): count = CollectionImage.objects.filter(collection=collection).count() new_entries = [] for img_id in image_ids: - import uuid as _uuid parsed = _uuid.UUID(img_id) if parsed not in existing: new_entries.append(CollectionImage( diff --git a/exiftree/settings.py b/exiftree/settings.py index 283205b..9cda6ad 100644 --- a/exiftree/settings.py +++ b/exiftree/settings.py @@ -147,7 +147,7 @@ if os.environ.get("AWS_STORAGE_BUCKET_NAME") or os.environ.get("BUCKET_NAME"): "BACKEND": "storages.backends.s3boto3.S3Boto3Storage", }, "staticfiles": { - "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", + "BACKEND": "django.contrib.staticfiles.storage.ManifestStaticFilesStorage", }, } AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_STORAGE_BUCKET_NAME") or os.environ.get("BUCKET_NAME", "") @@ -161,11 +161,19 @@ if os.environ.get("AWS_STORAGE_BUCKET_NAME") or os.environ.get("BUCKET_NAME"): AWS_DEFAULT_ACL = "public-read" AWS_QUERYSTRING_AUTH = False -# Celery -CELERY_BROKER_URL = os.environ.get("CELERY_BROKER_URL", "redis://localhost:6379/0") -CELERY_RESULT_BACKEND = os.environ.get("CELERY_RESULT_BACKEND", "redis://localhost:6379/0") +# Celery — use Postgres as broker in production, Redis locally +_db_url = os.environ.get("DATABASE_URL", "") +if _db_url and not os.environ.get("CELERY_BROKER_URL"): + # Convert postgres:// to sqla+postgresql:// for Celery's SQLAlchemy transport + _sqla_url = _db_url.replace("postgres://", "postgresql://", 1) + CELERY_BROKER_URL = f"sqla+{_sqla_url}" + CELERY_RESULT_BACKEND = "db+" + _sqla_url +else: + CELERY_BROKER_URL = os.environ.get("CELERY_BROKER_URL", "redis://localhost:6379/0") + CELERY_RESULT_BACKEND = os.environ.get("CELERY_RESULT_BACKEND", "redis://localhost:6379/0") CELERY_ACCEPT_CONTENT = ["json"] CELERY_TASK_SERIALIZER = "json" +CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True LOGIN_REDIRECT_URL = '/' LOGIN_URL = '/login/' diff --git a/fly.toml b/fly.toml index 810ec01..8e3c17d 100644 --- a/fly.toml +++ b/fly.toml @@ -3,12 +3,17 @@ primary_region = 'iad' [build] +[processes] + web = "uv run python manage.py runbolt --host 0.0.0.0 --port 8000" + worker = "uv run celery -A exiftree worker -l info -c 2" + [http_service] internal_port = 8000 force_https = true auto_stop_machines = 'stop' auto_start_machines = true min_machines_running = 0 + processes = ["web"] [env] DJANGO_SETTINGS_MODULE = 'exiftree.settings' diff --git a/gallery/templates/gallery/collection_list.html b/gallery/templates/gallery/collection_list.html index 9836d82..ba89d12 100644 --- a/gallery/templates/gallery/collection_list.html +++ b/gallery/templates/gallery/collection_list.html @@ -1,15 +1,24 @@ {% extends "base.html" %} -{% block title %}Collections — ExifTree{% endblock %} +{% block title %}Collections — {{ site_title }}{% endblock %} {% block content %} -
+
{% for c in collections %} - -

{{ c.title }}

- {{ c.image_count }} images +
+
+ {% if c.preview and c.preview.thumbnail_small %} + {{ c.title }} + {% elif c.preview and c.preview.thumbnail_medium %} + {{ c.title }} + {% endif %} +
+
+

{{ c.title }}

+ {{ c.image_count }} images +
{% empty %}
No collections yet.
diff --git a/gallery/views.py b/gallery/views.py index 0fcbef4..32f3dc5 100644 --- a/gallery/views.py +++ b/gallery/views.py @@ -8,9 +8,25 @@ from gallery.models import Collection def collection_list(request): collections = ( Collection.objects.filter(visibility=Image.Visibility.PUBLIC) + .select_related('cover_image') .annotate(image_count=Count('collection_images')) .order_by('-created_at') ) + # For collections without a cover_image, grab the first image + for col in collections: + if not col.cover_image: + first = ( + Image.objects.filter( + collection_entries__collection=col, + is_processing=False, + ) + .order_by('collection_entries__sort_order') + .first() + ) + col.preview = first + else: + col.preview = col.cover_image + return render(request, 'gallery/collection_list.html', { 'collections': collections, }) diff --git a/groups/__init__.py b/groups/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/groups/admin.py b/groups/admin.py deleted file mode 100644 index 412c973..0000000 --- a/groups/admin.py +++ /dev/null @@ -1,24 +0,0 @@ -from django.contrib import admin - -from groups.models import Group, GroupImage, GroupMembership - - -class GroupMembershipInline(admin.TabularInline): - model = GroupMembership - extra = 1 - raw_id_fields = ['user'] - - -class GroupImageInline(admin.TabularInline): - model = GroupImage - extra = 1 - raw_id_fields = ['image'] - - -@admin.register(Group) -class GroupAdmin(admin.ModelAdmin): - list_display = ['name', 'slug', 'visibility', 'created_at'] - list_filter = ['visibility'] - search_fields = ['name', 'description'] - prepopulated_fields = {'slug': ('name',)} - inlines = [GroupMembershipInline, GroupImageInline] diff --git a/groups/apps.py b/groups/apps.py deleted file mode 100644 index ac20952..0000000 --- a/groups/apps.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.apps import AppConfig - - -class GroupsConfig(AppConfig): - name = "groups" diff --git a/groups/migrations/0001_initial.py b/groups/migrations/0001_initial.py deleted file mode 100644 index da77581..0000000 --- a/groups/migrations/0001_initial.py +++ /dev/null @@ -1,138 +0,0 @@ -# Generated by Django 6.0.4 on 2026-04-07 23:07 - -import django.db.models.deletion -import uuid -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ("core", "0001_initial"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name="Group", - fields=[ - ( - "id", - models.UUIDField( - default=uuid.uuid4, - editable=False, - primary_key=True, - serialize=False, - ), - ), - ("name", models.CharField(max_length=255)), - ("slug", models.SlugField(max_length=255, unique=True)), - ("description", models.TextField(blank=True)), - ( - "cover_image", - models.ImageField(blank=True, upload_to="groups/covers/"), - ), - ( - "visibility", - models.CharField( - choices=[ - ("public", "Public"), - ("private", "Private"), - ("invite_only", "Invite Only"), - ], - default="public", - max_length=12, - ), - ), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ], - options={ - "ordering": ["-created_at"], - }, - ), - migrations.CreateModel( - name="GroupImage", - fields=[ - ( - "id", - models.UUIDField( - default=uuid.uuid4, - editable=False, - primary_key=True, - serialize=False, - ), - ), - ("submitted_at", models.DateTimeField(auto_now_add=True)), - ( - "group", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="group_images", - to="groups.group", - ), - ), - ( - "image", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="group_entries", - to="core.image", - ), - ), - ], - options={ - "ordering": ["-submitted_at"], - "unique_together": {("image", "group")}, - }, - ), - migrations.CreateModel( - name="GroupMembership", - fields=[ - ( - "id", - models.UUIDField( - default=uuid.uuid4, - editable=False, - primary_key=True, - serialize=False, - ), - ), - ( - "role", - models.CharField( - choices=[ - ("member", "Member"), - ("moderator", "Moderator"), - ("admin", "Admin"), - ], - default="member", - max_length=10, - ), - ), - ("joined_at", models.DateTimeField(auto_now_add=True)), - ( - "group", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="memberships", - to="groups.group", - ), - ), - ( - "user", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="group_memberships", - to=settings.AUTH_USER_MODEL, - ), - ), - ], - options={ - "unique_together": {("user", "group")}, - }, - ), - ] diff --git a/groups/migrations/__init__.py b/groups/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/groups/models.py b/groups/models.py deleted file mode 100644 index 3c5d99e..0000000 --- a/groups/models.py +++ /dev/null @@ -1,64 +0,0 @@ -import uuid - -from django.db import models - -from core.models import Image, User - - -class Group(models.Model): - class Visibility(models.TextChoices): - PUBLIC = 'public', "Public" - PRIVATE = 'private', "Private" - INVITE_ONLY = 'invite_only', "Invite Only" - - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - name = models.CharField(max_length=255) - slug = models.SlugField(max_length=255, unique=True) - description = models.TextField(blank=True) - cover_image = models.ImageField(upload_to='groups/covers/', blank=True) - visibility = models.CharField( - max_length=12, choices=Visibility.choices, default=Visibility.PUBLIC - ) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - class Meta: - ordering = ['-created_at'] - - def __str__(self) -> str: - return self.name - - -class GroupMembership(models.Model): - class Role(models.TextChoices): - MEMBER = 'member', "Member" - MODERATOR = 'moderator', "Moderator" - ADMIN = 'admin', "Admin" - - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='group_memberships') - group = models.ForeignKey(Group, on_delete=models.CASCADE, related_name='memberships') - role = models.CharField( - max_length=10, choices=Role.choices, default=Role.MEMBER - ) - joined_at = models.DateTimeField(auto_now_add=True) - - class Meta: - unique_together = [('user', 'group')] - - def __str__(self) -> str: - return f"{self.user} in {self.group} ({self.role})" - - -class GroupImage(models.Model): - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - image = models.ForeignKey(Image, on_delete=models.CASCADE, related_name='group_entries') - group = models.ForeignKey(Group, on_delete=models.CASCADE, related_name='group_images') - submitted_at = models.DateTimeField(auto_now_add=True) - - class Meta: - unique_together = [('image', 'group')] - ordering = ['-submitted_at'] - - def __str__(self) -> str: - return f"{self.image} in {self.group}" diff --git a/groups/templates/groups/group_detail.html b/groups/templates/groups/group_detail.html deleted file mode 100644 index 89a579e..0000000 --- a/groups/templates/groups/group_detail.html +++ /dev/null @@ -1,53 +0,0 @@ -{% extends "base.html" %} -{% block title %}{{ group.name }} — ExifTree{% endblock %} -{% block content %} -
- -
-{% endblock %} diff --git a/groups/templates/groups/group_list.html b/groups/templates/groups/group_list.html deleted file mode 100644 index 7e52d91..0000000 --- a/groups/templates/groups/group_list.html +++ /dev/null @@ -1,25 +0,0 @@ -{% extends "base.html" %} -{% block title %}Groups — ExifTree{% endblock %} -{% block content %} - - -
- -
No groups yet.
-
-{% endblock %} diff --git a/groups/templates/groups/group_members.html b/groups/templates/groups/group_members.html deleted file mode 100644 index b0060df..0000000 --- a/groups/templates/groups/group_members.html +++ /dev/null @@ -1,29 +0,0 @@ -{% extends "base.html" %} -{% block title %}{{ group.name }} Members — ExifTree{% endblock %} -{% block content %} -
- -
-{% endblock %} diff --git a/groups/tests.py b/groups/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/groups/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/groups/urls.py b/groups/urls.py deleted file mode 100644 index a4da4d1..0000000 --- a/groups/urls.py +++ /dev/null @@ -1,11 +0,0 @@ -from django.urls import path - -from groups import views - -app_name = 'groups' - -urlpatterns = [ - path('groups/', views.group_list, name='group-list'), - path('groups//', views.group_detail, name='group-detail'), - path('groups//members/', views.group_members, name='group-members'), -] diff --git a/groups/views.py b/groups/views.py deleted file mode 100644 index e301104..0000000 --- a/groups/views.py +++ /dev/null @@ -1,17 +0,0 @@ -from django.shortcuts import get_object_or_404, render - -from groups.models import Group - - -def group_list(request): - return render(request, 'groups/group_list.html') - - -def group_detail(request, slug): - group = get_object_or_404(Group, slug=slug) - return render(request, 'groups/group_detail.html', {'group': group}) - - -def group_members(request, slug): - group = get_object_or_404(Group, slug=slug) - return render(request, 'groups/group_members.html', {'group': group}) diff --git a/ingest/views.py b/ingest/views.py index 33d7169..071e7f6 100644 --- a/ingest/views.py +++ b/ingest/views.py @@ -52,7 +52,9 @@ def upload_image(request): try: process_image_task.apply_async(args=[str(img.id)], ignore_result=True) except Exception: + # No Celery — process in a background thread + import threading from ingest.pipeline import process_image - process_image(img) + threading.Thread(target=process_image, args=(img,), daemon=True).start() return JsonResponse({'id': str(img.id), 'title': img.title}, status=201) diff --git a/pyproject.toml b/pyproject.toml index 8a19f79..0154a01 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,4 +18,5 @@ dependencies = [ "python-dotenv>=1.2.2", "redis>=7.4.0", "requests>=2.33.1", + "sqlalchemy>=2.0.49", ] diff --git a/static/css/style.css b/static/css/style.css index da4e956..39d8cb0 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -346,7 +346,45 @@ a:hover { color: var(--accent-hover); } } .member-list .role { color: var(--text-muted); font-size: 0.8rem; } -/* Collection */ +/* Collection grid */ +.collection-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; +} +@media (max-width: 768px) { .collection-grid { grid-template-columns: repeat(2, 1fr); } } +@media (max-width: 480px) { .collection-grid { grid-template-columns: 1fr; } } + +.collection-card { + display: block; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + color: inherit; + transition: border-color 0.15s; +} +.collection-card:hover { border-color: var(--text-muted); color: inherit; } + +.collection-card-image { + aspect-ratio: 1; + background: var(--bg); + overflow: hidden; +} +.collection-card-image img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.collection-card-info { + padding: 0.75rem 1rem; +} +.collection-card-info h3 { font-size: 1rem; margin-bottom: 0.15rem; } +.collection-card-info .count { color: var(--text-muted); font-size: 0.85rem; } + +/* Collection detail */ .collection-header { margin-bottom: 2rem; } .collection-header h1 { margin-bottom: 0.25rem; } .collection-header .desc { color: var(--text-muted); } @@ -409,8 +447,8 @@ a:hover { color: var(--accent-hover); } } .manage-grid { - display: grid; - grid-template-columns: repeat(5, 1fr); + display: grid !important; + grid-template-columns: repeat(5, 1fr) !important; gap: 4px; } @media (max-width: 768px) { .manage-grid { grid-template-columns: repeat(3, 1fr); } } @@ -423,6 +461,7 @@ a:hover { color: var(--accent-hover); } cursor: pointer; background: var(--surface); transition: transform 0.1s; + min-width: 0; } .manage-item img { width: 100%; @@ -435,9 +474,8 @@ a:hover { color: var(--accent-hover); } .manage-item-selected { outline: 3px solid var(--accent); outline-offset: -3px; - transform: scale(0.95); } -.manage-item-selected:hover { transform: scale(0.95); } +.manage-item-selected:hover { transform: none; } .manage-check { position: absolute; diff --git a/templates/base.html b/templates/base.html index 08baa8d..0510aea 100644 --- a/templates/base.html +++ b/templates/base.html @@ -4,7 +4,7 @@ {% block title %}{{ site_title }}{% endblock %} - + {% block head %}{% endblock %} diff --git a/templates/manage.html b/templates/manage.html index dc09de7..0263da7 100644 --- a/templates/manage.html +++ b/templates/manage.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% block title %}Manage Photos — {{ site_title }}{% endblock %} {% block content %} -
+
@@ -13,16 +13,41 @@
-
- +
+ - + + {% for c in cameras %} + + {% endfor %} -
+ + + + + + + + {% if filter_camera or filter_lens or filter_year or filter_visibility %} + Clear + {% endif %} +