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) <noreply@anthropic.com>
This commit is contained in:
2026-04-08 15:02:49 -04:00
parent 54bd5bc122
commit 9507fe901c
28 changed files with 618 additions and 426 deletions
+94
View File
@@ -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/<manufacturer>/`, `/cameras/<manufacturer>/<model>/`
- Lens tree: `/lenses/`, `/lenses/<manufacturer>/<model>/`
- User profiles: `/@<username>/`
- Collections: `/@<username>/collections/<slug>/`
- Groups: `/groups/<slug>/`
## 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
+1
View File
@@ -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"]
-2
View File
@@ -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
+19
View File
@@ -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(),
}
+194
View File
@@ -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},
)
+63 -8
View File
@@ -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(
+12 -4
View File
@@ -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/'
+5
View File
@@ -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'
+14 -5
View File
@@ -1,15 +1,24 @@
{% extends "base.html" %}
{% block title %}Collections — ExifTree{% endblock %}
{% block title %}Collections — {{ site_title }}{% endblock %}
{% block content %}
<div class="page-header">
<h1>Collections</h1>
</div>
<div class="gear-grid">
<div class="collection-grid">
{% for c in collections %}
<a href="/collections/{{ c.slug }}/" class="gear-card" style="color: inherit;">
<h3>{{ c.title }}</h3>
<span class="count">{{ c.image_count }} images</span>
<a href="/collections/{{ c.slug }}/" class="collection-card">
<div class="collection-card-image">
{% if c.preview and c.preview.thumbnail_small %}
<img src="{{ c.preview.thumbnail_small.url }}" alt="{{ c.title }}">
{% elif c.preview and c.preview.thumbnail_medium %}
<img src="{{ c.preview.thumbnail_medium.url }}" alt="{{ c.title }}">
{% endif %}
</div>
<div class="collection-card-info">
<h3>{{ c.title }}</h3>
<span class="count">{{ c.image_count }} images</span>
</div>
</a>
{% empty %}
<div class="empty" style="grid-column: 1 / -1;">No collections yet.</div>
+16
View File
@@ -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,
})
View File
-24
View File
@@ -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]
-5
View File
@@ -1,5 +0,0 @@
from django.apps import AppConfig
class GroupsConfig(AppConfig):
name = "groups"
-138
View File
@@ -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")},
},
),
]
View File
-64
View File
@@ -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}"
-53
View File
@@ -1,53 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ group.name }} — ExifTree{% endblock %}
{% block content %}
<div x-data="{
group: null,
images: [],
async init() {
const [gRes, iRes] = await Promise.all([
fetch('/api/groups/{{ group.slug }}'),
fetch('/api/groups/{{ group.slug }}/images'),
]);
this.group = await gRes.json();
this.images = await iRes.json();
}
}">
<template x-if="group">
<div>
<div class="page-header">
<h1 x-text="group.name"></h1>
<p x-text="group.description" x-show="group.description"></p>
<p style="margin-top: 0.5rem; font-size: 0.85rem; color: var(--text-muted);">
<span x-text="group.member_count + ' members'"></span>
· <a href="{% url 'groups:group-members' slug=group.slug %}">View members</a>
</p>
</div>
<div class="image-grid">
<template x-for="img in images" :key="img.id">
<div>
<a :href="'/images/' + img.id + '/'" class="image-card">
<img :src="img.thumbnail_medium || img.thumbnail_small" :alt="img.title">
<div class="overlay">
<div class="exif-row">
<div class="exif-left">
<span x-text="img.camera" x-show="img.camera"></span>
</div>
<div class="exif-right">
<span x-show="img.focal_length" x-text="img.focal_length + 'mm'"></span>
<span x-show="img.aperture" x-text="'f/' + img.aperture"></span>
<span x-show="img.iso" x-text="'ISO ' + img.iso"></span>
</div>
</div>
</div>
</a>
{% if not SINGLE_TENANT %}<a :href="'/@' + img.user" class="image-card-user" x-text="'@' + img.user"></a>{% endif %}
</div>
</template>
<div class="empty" x-show="images.length === 0">No images in this group yet.</div>
</div>
</div>
</template>
</div>
{% endblock %}
-25
View File
@@ -1,25 +0,0 @@
{% extends "base.html" %}
{% block title %}Groups — ExifTree{% endblock %}
{% block content %}
<div class="page-header">
<h1>Groups</h1>
<p>Community spaces for photographers</p>
</div>
<div class="gear-grid" x-data="{
groups: [],
async init() {
const res = await fetch('/api/groups');
this.groups = await res.json();
}
}">
<template x-for="g in groups" :key="g.id">
<a :href="'/groups/' + g.slug + '/'" class="group-card" style="color: inherit;">
<h3 x-text="g.name"></h3>
<p class="desc" x-text="g.description"></p>
<span class="stats" x-text="g.member_count + ' members'"></span>
</a>
</template>
<div class="empty" x-show="groups.length === 0">No groups yet.</div>
</div>
{% endblock %}
@@ -1,29 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ group.name }} Members — ExifTree{% endblock %}
{% block content %}
<div x-data="{
group: null,
async init() {
const res = await fetch('/api/groups/{{ group.slug }}');
this.group = await res.json();
}
}">
<template x-if="group">
<div>
<div class="page-header">
<h1 x-text="group.name + ' — Members'"></h1>
</div>
<ul class="member-list">
<template x-for="m in group.members" :key="m.username">
<li>
<a :href="'/@' + m.username" x-text="'@' + m.username"></a>
<span class="role" x-text="m.role"></span>
</li>
</template>
</ul>
<div class="empty" x-show="group.members.length === 0">No members yet.</div>
</div>
</template>
</div>
{% endblock %}
-3
View File
@@ -1,3 +0,0 @@
from django.test import TestCase
# Create your tests here.
-11
View File
@@ -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/<slug:slug>/', views.group_detail, name='group-detail'),
path('groups/<slug:slug>/members/', views.group_members, name='group-members'),
]
-17
View File
@@ -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})
+3 -1
View File
@@ -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)
+1
View File
@@ -18,4 +18,5 @@ dependencies = [
"python-dotenv>=1.2.2",
"redis>=7.4.0",
"requests>=2.33.1",
"sqlalchemy>=2.0.49",
]
+43 -5
View File
@@ -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;
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ site_title }}{% endblock %}</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/style.css?v={{ cache_bust }}">
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
{% block head %}{% endblock %}
</head>
+65 -31
View File
@@ -1,7 +1,7 @@
{% extends "base.html" %}
{% block title %}Manage Photos — {{ site_title }}{% endblock %}
{% block content %}
<div id="manage-app">
<div id="manage-app" style="max-width: 100%;">
<!-- Header -->
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem;">
<div>
@@ -13,16 +13,41 @@
<!-- Toolbar -->
<div class="manage-toolbar">
<div style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
<button class="btn" onclick="manageToggleAll()" style="font-size: 0.85rem;" id="select-all-btn">Select all</button>
<form method="get" style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
<button type="button" class="btn" onclick="manageToggleAll()" style="font-size: 0.85rem;" id="select-all-btn">Select all</button>
<select id="filter-visibility" onchange="manageFilter()" style="padding: 0.4rem 0.6rem; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 0.85rem;">
<option value="all">All</option>
<option value="public">Public</option>
<option value="private">Private</option>
<option value="unlisted">Unlisted</option>
<select name="camera" onchange="this.form.submit()" style="padding: 0.4rem 0.6rem; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 0.85rem;">
<option value="">All cameras</option>
{% for c in cameras %}
<option value="{{ c.id }}" {% if filter_camera == c.id|slugify %}selected{% endif %}>{{ c.display_name }}</option>
{% endfor %}
</select>
</div>
<select name="lens" onchange="this.form.submit()" style="padding: 0.4rem 0.6rem; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 0.85rem;">
<option value="">All lenses</option>
{% for l in lenses %}
<option value="{{ l.id }}" {% if filter_lens == l.id|slugify %}selected{% endif %}>{{ l.display_name }}</option>
{% endfor %}
</select>
<select name="year" onchange="this.form.submit()" style="padding: 0.4rem 0.6rem; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 0.85rem;">
<option value="">All years</option>
{% for y in years %}
<option value="{{ y.year }}" {% if filter_year == y.year|stringformat:"d" %}selected{% endif %}>{{ y.year }}</option>
{% endfor %}
</select>
<select name="visibility" onchange="this.form.submit()" style="padding: 0.4rem 0.6rem; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 0.85rem;">
<option value="">All visibility</option>
<option value="public" {% if filter_visibility == "public" %}selected{% endif %}>Public</option>
<option value="private" {% if filter_visibility == "private" %}selected{% endif %}>Private</option>
<option value="unlisted" {% if filter_visibility == "unlisted" %}selected{% endif %}>Unlisted</option>
</select>
{% if filter_camera or filter_lens or filter_year or filter_visibility %}
<a href="{% url 'manage' %}" class="btn" style="font-size: 0.85rem;">Clear</a>
{% endif %}
</form>
<!-- Bulk actions -->
<div id="bulk-actions" style="display: none; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
@@ -40,9 +65,18 @@
<!-- Add to collection -->
<div style="position: relative; display: inline-block;">
<button class="btn" onclick="this.nextElementSibling.style.display = this.nextElementSibling.style.display === 'block' ? 'none' : 'block'" style="font-size: 0.85rem;">Add to collection</button>
<div class="dropdown-menu" style="display: none; left: 0; top: 100%; margin-top: 0.25rem; min-width: 180px;">
<div class="dropdown-menu" id="col-dropdown" style="display: none; left: 0; top: 100%; margin-top: 0.25rem; min-width: 220px;">
<div style="padding: 0.4rem 0.75rem;" id="new-col-row">
<div style="display: flex; gap: 0.25rem;">
<input type="text" id="new-col-name" placeholder="New collection..." onclick="event.stopPropagation()"
style="flex: 1; padding: 0.3rem 0.5rem; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 0.85rem;"
onkeydown="if(event.key==='Enter'){event.preventDefault(); submitNewCollection();}">
<button onclick="event.stopPropagation(); submitNewCollection();" class="btn btn-primary" style="font-size: 0.8rem; padding: 0.3rem 0.5rem;">+</button>
</div>
</div>
<hr>
{% for c in collections %}
<a href="#" onclick="event.preventDefault(); submitAddToCollection('{{ c.id }}'); this.parentElement.style.display='none';">{{ c.title }}</a>
<a href="#" onclick="event.preventDefault(); submitAddToCollection('{{ c.id }}'); document.getElementById('col-dropdown').style.display='none';">{{ c.title }}</a>
{% empty %}
<span style="padding: 0.5rem 1rem; color: var(--text-muted); font-size: 0.85rem; display: block;">No collections yet</span>
{% endfor %}
@@ -60,6 +94,7 @@
{% csrf_token %}
<input type="hidden" name="image_ids" id="add-col-ids">
<input type="hidden" name="collection_id" id="add-col-id">
<input type="hidden" name="new_collection" id="add-col-new-name">
</form>
</div>
</div>
@@ -68,8 +103,9 @@
<div class="manage-grid">
{% for img in images %}
<div class="manage-item" data-id="{{ img.id }}" data-vis="{{ img.visibility }}" onclick="manageToggle('{{ img.id }}', event)">
{% if img.thumbnail_medium %}<img src="{{ img.thumbnail_medium.url }}" alt="{{ img.title }}" loading="lazy">
{% elif img.thumbnail_small %}<img src="{{ img.thumbnail_small.url }}" alt="{{ img.title }}" loading="lazy">{% endif %}
{% if img.thumbnail_small %}<img src="{{ img.thumbnail_small.url }}" alt="{{ img.title }}" loading="lazy">
{% elif img.thumbnail_medium %}<img src="{{ img.thumbnail_medium.url }}" alt="{{ img.title }}" loading="lazy">
{% elif img.original %}<img src="{{ img.original.url }}" alt="{{ img.title }}" loading="lazy">{% endif %}
<div class="manage-check" id="check-{{ img.id }}"></div>
{% if img.visibility != 'public' %}
<div class="manage-vis-badge {% if img.visibility == 'private' %}vis-private{% else %}vis-unlisted{% endif %}">{{ img.visibility }}</div>
@@ -114,19 +150,6 @@
updateUI();
};
window.manageFilter = function() {
var vis = document.getElementById('filter-visibility').value;
document.querySelectorAll('.manage-item').forEach(function(el) {
if (vis === 'all' || el.dataset.vis === vis) {
el.style.display = '';
} else {
el.style.display = 'none';
}
});
selected.clear();
updateUI();
};
window.submitVis = function(vis) {
document.getElementById('vis-ids').value = [...selected].join(',');
document.getElementById('vis-ids').name = 'image_ids';
@@ -143,6 +166,21 @@
form.submit();
};
window.submitNewCollection = function() {
var name = document.getElementById('new-col-name').value.trim();
if (!name) return;
var form = document.getElementById('add-col-form');
form.querySelectorAll('.dyn').forEach(function(el) { el.remove(); });
selected.forEach(function(id) {
var inp = document.createElement('input');
inp.type = 'hidden'; inp.name = 'image_ids'; inp.value = id; inp.className = 'dyn';
form.appendChild(inp);
});
document.getElementById('add-col-id').value = '';
document.getElementById('add-col-new-name').value = name;
form.submit();
};
window.submitAddToCollection = function(colId) {
var form = document.getElementById('add-col-form');
form.querySelectorAll('.dyn').forEach(function(el) { el.remove(); });
@@ -167,11 +205,7 @@
});
function getVisibleIds() {
var ids = [];
document.querySelectorAll('.manage-item').forEach(function(el) {
if (el.style.display !== 'none') ids.push(el.dataset.id);
});
return ids;
return allIds;
}
function updateUI() {
Generated
+87
View File
@@ -320,6 +320,7 @@ dependencies = [
{ name = "python-dotenv" },
{ name = "redis" },
{ name = "requests" },
{ name = "sqlalchemy" },
]
[package.metadata]
@@ -338,6 +339,46 @@ requires-dist = [
{ name = "python-dotenv", specifier = ">=1.2.2" },
{ name = "redis", specifier = ">=7.4.0" },
{ name = "requests", specifier = ">=2.33.1" },
{ name = "sqlalchemy", specifier = ">=2.0.49" },
]
[[package]]
name = "greenlet"
version = "3.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/86/94/a5935717b307d7c71fe877b52b884c6af707d2d2090db118a03fbd799369/greenlet-3.4.0.tar.gz", hash = "sha256:f50a96b64dafd6169e595a5c56c9146ef80333e67d4476a65a9c55f400fc22ff", size = 195913, upload-time = "2026-04-08T17:08:00.863Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/65/8b/3669ad3b3f247a791b2b4aceb3aa5a31f5f6817bf547e4e1ff712338145a/greenlet-3.4.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:1a54a921561dd9518d31d2d3db4d7f80e589083063ab4d3e2e950756ef809e1a", size = 286902, upload-time = "2026-04-08T15:52:12.138Z" },
{ url = "https://files.pythonhosted.org/packages/38/3e/3c0e19b82900873e2d8469b590a6c4b3dfd2b316d0591f1c26b38a4879a5/greenlet-3.4.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16dec271460a9a2b154e3b1c2fa1050ce6280878430320e85e08c166772e3f97", size = 606099, upload-time = "2026-04-08T16:24:38.408Z" },
{ url = "https://files.pythonhosted.org/packages/b5/33/99fef65e7754fc76a4ed14794074c38c9ed3394a5bd129d7f61b705f3168/greenlet-3.4.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:90036ce224ed6fe75508c1907a77e4540176dcf0744473627785dd519c6f9996", size = 618837, upload-time = "2026-04-08T16:30:58.298Z" },
{ url = "https://files.pythonhosted.org/packages/36/f7/229f3aed6948faa20e0616a0b8568da22e365ede6a54d7d369058b128afd/greenlet-3.4.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1c4f6b453006efb8310affb2d132832e9bbb4fc01ce6df6b70d810d38f1f6dc", size = 615062, upload-time = "2026-04-08T15:56:33.766Z" },
{ url = "https://files.pythonhosted.org/packages/08/97/d988180011aa40135c46cd0d0cf01dd97f7162bae14139b4a3ef54889ba5/greenlet-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b2d9a138ffa0e306d0e2b72976d2fb10b97e690d40ab36a472acaab0838e2de", size = 1573511, upload-time = "2026-04-08T16:26:20.058Z" },
{ url = "https://files.pythonhosted.org/packages/d4/0f/a5a26fe152fb3d12e6a474181f6e9848283504d0afd095f353d85726374b/greenlet-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8424683caf46eb0eb6f626cb95e008e8cc30d0cb675bdfa48200925c79b38a08", size = 1640396, upload-time = "2026-04-08T15:57:30.88Z" },
{ url = "https://files.pythonhosted.org/packages/42/cf/bb2c32d9a100e36ee9f6e38fad6b1e082b8184010cb06259b49e1266ca01/greenlet-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0a53fb071531d003b075c444014ff8f8b1a9898d36bb88abd9ac7b3524648a2", size = 238892, upload-time = "2026-04-08T17:03:10.094Z" },
{ url = "https://files.pythonhosted.org/packages/b7/47/6c41314bac56e71436ce551c7fbe3cc830ed857e6aa9708dbb9c65142eb6/greenlet-3.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:f38b81880ba28f232f1f675893a39cf7b6db25b31cc0a09bb50787ecf957e85e", size = 235599, upload-time = "2026-04-08T15:52:54.3Z" },
{ url = "https://files.pythonhosted.org/packages/7a/75/7e9cd1126a1e1f0cd67b0eda02e5221b28488d352684704a78ed505bd719/greenlet-3.4.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:43748988b097f9c6f09364f260741aa73c80747f63389824435c7a50bfdfd5c1", size = 285856, upload-time = "2026-04-08T15:52:45.82Z" },
{ url = "https://files.pythonhosted.org/packages/9d/c4/3e2df392e5cb199527c4d9dbcaa75c14edcc394b45040f0189f649631e3c/greenlet-3.4.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5566e4e2cd7a880e8c27618e3eab20f3494452d12fd5129edef7b2f7aa9a36d1", size = 610208, upload-time = "2026-04-08T16:24:39.674Z" },
{ url = "https://files.pythonhosted.org/packages/da/af/750cdfda1d1bd30a6c28080245be8d0346e669a98fdbae7f4102aa95fff3/greenlet-3.4.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1054c5a3c78e2ab599d452f23f7adafef55062a783a8e241d24f3b633ba6ff82", size = 621269, upload-time = "2026-04-08T16:30:59.767Z" },
{ url = "https://files.pythonhosted.org/packages/54/78/0cbc693622cd54ebe25207efbb3a0eb07c2639cb8594f6e3aaaa0bb077a8/greenlet-3.4.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f82cb6cddc27dd81c96b1506f4aa7def15070c3b2a67d4e46fd19016aacce6cf", size = 617549, upload-time = "2026-04-08T15:56:34.893Z" },
{ url = "https://files.pythonhosted.org/packages/ba/c0/8966767de01343c1ff47e8b855dc78e7d1a8ed2b7b9c83576a57e289f81d/greenlet-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:227a46251ecba4ff46ae742bc5ce95c91d5aceb4b02f885487aff269c127a729", size = 1575310, upload-time = "2026-04-08T16:26:21.671Z" },
{ url = "https://files.pythonhosted.org/packages/b8/38/bcdc71ba05e9a5fda87f63ffc2abcd1f15693b659346df994a48c968003d/greenlet-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5b99e87be7eba788dd5b75ba1cde5639edffdec5f91fe0d734a249535ec3408c", size = 1640435, upload-time = "2026-04-08T15:57:32.572Z" },
{ url = "https://files.pythonhosted.org/packages/a1/c2/19b664b7173b9e4ef5f77e8cef9f14c20ec7fce7920dc1ccd7afd955d093/greenlet-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:849f8bc17acd6295fcb5de8e46d55cc0e52381c56eaf50a2afd258e97bc65940", size = 238760, upload-time = "2026-04-08T17:04:03.878Z" },
{ url = "https://files.pythonhosted.org/packages/9b/96/795619651d39c7fbd809a522f881aa6f0ead504cc8201c3a5b789dfaef99/greenlet-3.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:9390ad88b652b1903814eaabd629ca184db15e0eeb6fe8a390bbf8b9106ae15a", size = 235498, upload-time = "2026-04-08T17:05:00.584Z" },
{ url = "https://files.pythonhosted.org/packages/78/02/bde66806e8f169cf90b14d02c500c44cdbe02c8e224c9c67bafd1b8cadd1/greenlet-3.4.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:10a07aca6babdd18c16a3f4f8880acfffc2b88dfe431ad6aa5f5740759d7d75e", size = 286291, upload-time = "2026-04-08T17:09:34.307Z" },
{ url = "https://files.pythonhosted.org/packages/05/1f/39da1c336a87d47c58352fb8a78541ce63d63ae57c5b9dae1fe02801bbc2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:076e21040b3a917d3ce4ad68fb5c3c6b32f1405616c4a57aa83120979649bd3d", size = 656749, upload-time = "2026-04-08T16:24:41.721Z" },
{ url = "https://files.pythonhosted.org/packages/d3/6c/90ee29a4ee27af7aa2e2ec408799eeb69ee3fcc5abcecac6ddd07a5cd0f2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e82689eea4a237e530bb5cb41b180ef81fa2160e1f89422a67be7d90da67f615", size = 669084, upload-time = "2026-04-08T16:31:01.372Z" },
{ url = "https://files.pythonhosted.org/packages/07/49/d4cad6e5381a50947bb973d2f6cf6592621451b09368b8c20d9b8af49c5b/greenlet-3.4.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df3b0b2289ec686d3c821a5fee44259c05cfe824dd5e6e12c8e5f5df23085cf", size = 665621, upload-time = "2026-04-08T15:56:35.995Z" },
{ url = "https://files.pythonhosted.org/packages/37/31/d1edd54f424761b5d47718822f506b435b6aab2f3f93b465441143ea5119/greenlet-3.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8bff29d586ea415688f4cec96a591fcc3bf762d046a796cdadc1fdb6e7f2d5bf", size = 1622259, upload-time = "2026-04-08T16:26:23.201Z" },
{ url = "https://files.pythonhosted.org/packages/b0/c6/6d3f9cdcb21c4e12a79cb332579f1c6aa1af78eb68059c5a957c7812d95e/greenlet-3.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a569c2fb840c53c13a2b8967c63621fafbd1a0e015b9c82f408c33d626a2fda", size = 1686916, upload-time = "2026-04-08T15:57:34.282Z" },
{ url = "https://files.pythonhosted.org/packages/63/45/c1ca4a1ad975de4727e52d3ffe641ae23e1d7a8ffaa8ff7a0477e1827b92/greenlet-3.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:207ba5b97ea8b0b60eb43ffcacf26969dd83726095161d676aac03ff913ee50d", size = 239821, upload-time = "2026-04-08T17:03:48.423Z" },
{ url = "https://files.pythonhosted.org/packages/71/c4/6f621023364d7e85a4769c014c8982f98053246d142420e0328980933ceb/greenlet-3.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:f8296d4e2b92af34ebde81085a01690f26a51eb9ac09a0fcadb331eb36dbc802", size = 236932, upload-time = "2026-04-08T17:04:33.551Z" },
{ url = "https://files.pythonhosted.org/packages/d4/8f/18d72b629783f5e8d045a76f5325c1e938e659a9e4da79c7dcd10169a48d/greenlet-3.4.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d70012e51df2dbbccfaf63a40aaf9b40c8bed37c3e3a38751c926301ce538ece", size = 294681, upload-time = "2026-04-08T15:52:35.778Z" },
{ url = "https://files.pythonhosted.org/packages/9e/ad/5fa86ec46769c4153820d58a04062285b3b9e10ba3d461ee257b68dcbf53/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a58bec0751f43068cd40cff31bb3ca02ad6000b3a51ca81367af4eb5abc480c8", size = 658899, upload-time = "2026-04-08T16:24:43.32Z" },
{ url = "https://files.pythonhosted.org/packages/43/f0/4e8174ca0e87ae748c409f055a1ba161038c43cc0a5a6f1433a26ac2e5bf/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05fa0803561028f4b2e3b490ee41216a842eaee11aed004cc343a996d9523aa2", size = 665284, upload-time = "2026-04-08T16:31:02.833Z" },
{ url = "https://files.pythonhosted.org/packages/19/da/991cf7cd33662e2df92a1274b7eb4d61769294d38a1bba8a45f31364845e/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e60d38719cb80b3ab5e85f9f1aed4960acfde09868af6762ccb27b260d68f4ed", size = 661861, upload-time = "2026-04-08T15:56:37.269Z" },
{ url = "https://files.pythonhosted.org/packages/36/c5/6c2c708e14db3d9caea4b459d8464f58c32047451142fe2cfd90e7458f41/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f50c804733b43eded05ae694691c9aa68bca7d0a867d67d4a3f514742a2d53f", size = 1622182, upload-time = "2026-04-08T16:26:24.777Z" },
{ url = "https://files.pythonhosted.org/packages/7a/4c/50c5fed19378e11a29fabab1f6be39ea95358f4a0a07e115a51ca93385d8/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2d4f0635dc4aa638cda4b2f5a07ae9a2cff9280327b581a3fcb6f317b4fbc38a", size = 1685050, upload-time = "2026-04-08T15:57:36.453Z" },
{ url = "https://files.pythonhosted.org/packages/db/72/85ae954d734703ab48e622c59d4ce35d77ce840c265814af9c078cacc7aa/greenlet-3.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1a4a48f24681300c640f143ba7c404270e1ebbbcf34331d7104a4ff40f8ea705", size = 245554, upload-time = "2026-04-08T17:03:50.044Z" },
]
[[package]]
@@ -907,6 +948,52 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "sqlalchemy"
version = "2.0.49"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/09/45/461788f35e0364a8da7bda51a1fe1b09762d0c32f12f63727998d85a873b/sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f", size = 9898221, upload-time = "2026-04-03T16:38:11.704Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/b3/2de412451330756aaaa72d27131db6dde23995efe62c941184e15242a5fa/sqlalchemy-2.0.49-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbccb45260e4ff1b7db0be80a9025bb1e6698bdb808b83fff0000f7a90b2c0b", size = 2157681, upload-time = "2026-04-03T16:53:07.132Z" },
{ url = "https://files.pythonhosted.org/packages/50/84/b2a56e2105bd11ebf9f0b93abddd748e1a78d592819099359aa98134a8bf/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb37f15714ec2652d574f021d479e78cd4eb9d04396dca36568fdfffb3487982", size = 3338976, upload-time = "2026-04-03T17:07:40Z" },
{ url = "https://files.pythonhosted.org/packages/2c/fa/65fcae2ed62f84ab72cf89536c7c3217a156e71a2c111b1305ab6f0690e2/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb9ec6436a820a4c006aad1ac351f12de2f2dbdaad171692ee457a02429b672", size = 3351937, upload-time = "2026-04-03T17:12:23.374Z" },
{ url = "https://files.pythonhosted.org/packages/f8/2f/6fd118563572a7fe475925742eb6b3443b2250e346a0cc27d8d408e73773/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8d6efc136f44a7e8bc8088507eaabbb8c2b55b3dbb63fe102c690da0ddebe55e", size = 3281646, upload-time = "2026-04-03T17:07:41.949Z" },
{ url = "https://files.pythonhosted.org/packages/c5/d7/410f4a007c65275b9cf82354adb4bb8ba587b176d0a6ee99caa16fe638f8/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e06e617e3d4fd9e51d385dfe45b077a41e9d1b033a7702551e3278ac597dc750", size = 3316695, upload-time = "2026-04-03T17:12:25.642Z" },
{ url = "https://files.pythonhosted.org/packages/d9/95/81f594aa60ded13273a844539041ccf1e66c5a7bed0a8e27810a3b52d522/sqlalchemy-2.0.49-cp312-cp312-win32.whl", hash = "sha256:83101a6930332b87653886c01d1ee7e294b1fe46a07dd9a2d2b4f91bcc88eec0", size = 2117483, upload-time = "2026-04-03T17:05:40.896Z" },
{ url = "https://files.pythonhosted.org/packages/47/9e/fd90114059175cac64e4fafa9bf3ac20584384d66de40793ae2e2f26f3bb/sqlalchemy-2.0.49-cp312-cp312-win_amd64.whl", hash = "sha256:618a308215b6cececb6240b9abde545e3acdabac7ae3e1d4e666896bf5ba44b4", size = 2144494, upload-time = "2026-04-03T17:05:42.282Z" },
{ url = "https://files.pythonhosted.org/packages/ae/81/81755f50eb2478eaf2049728491d4ea4f416c1eb013338682173259efa09/sqlalchemy-2.0.49-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df2d441bacf97022e81ad047e1597552eb3f83ca8a8f1a1fdd43cd7fe3898120", size = 2154547, upload-time = "2026-04-03T16:53:08.64Z" },
{ url = "https://files.pythonhosted.org/packages/a2/bc/3494270da80811d08bcfa247404292428c4fe16294932bce5593f215cad9/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e20e511dc15265fb433571391ba313e10dd8ea7e509d51686a51313b4ac01a2", size = 3280782, upload-time = "2026-04-03T17:07:43.508Z" },
{ url = "https://files.pythonhosted.org/packages/cd/f5/038741f5e747a5f6ea3e72487211579d8cbea5eb9827a9cbd61d0108c4bd/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47604cb2159f8bbd5a1ab48a714557156320f20871ee64d550d8bf2683d980d3", size = 3297156, upload-time = "2026-04-03T17:12:27.697Z" },
{ url = "https://files.pythonhosted.org/packages/88/50/a6af0ff9dc954b43a65ca9b5367334e45d99684c90a3d3413fc19a02d43c/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:22d8798819f86720bc646ab015baff5ea4c971d68121cb36e2ebc2ee43ead2b7", size = 3228832, upload-time = "2026-04-03T17:07:45.38Z" },
{ url = "https://files.pythonhosted.org/packages/bc/d1/5f6bdad8de0bf546fc74370939621396515e0cdb9067402d6ba1b8afbe9a/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9b1c058c171b739e7c330760044803099c7fff11511e3ab3573e5327116a9c33", size = 3267000, upload-time = "2026-04-03T17:12:29.657Z" },
{ url = "https://files.pythonhosted.org/packages/f7/30/ad62227b4a9819a5e1c6abff77c0f614fa7c9326e5a3bdbee90f7139382b/sqlalchemy-2.0.49-cp313-cp313-win32.whl", hash = "sha256:a143af2ea6672f2af3f44ed8f9cd020e9cc34c56f0e8db12019d5d9ecf41cb3b", size = 2115641, upload-time = "2026-04-03T17:05:43.989Z" },
{ url = "https://files.pythonhosted.org/packages/17/3a/7215b1b7d6d49dc9a87211be44562077f5f04f9bb5a59552c1c8e2d98173/sqlalchemy-2.0.49-cp313-cp313-win_amd64.whl", hash = "sha256:12b04d1db2663b421fe072d638a138460a51d5a862403295671c4f3987fb9148", size = 2141498, upload-time = "2026-04-03T17:05:45.7Z" },
{ url = "https://files.pythonhosted.org/packages/28/4b/52a0cb2687a9cd1648252bb257be5a1ba2c2ded20ba695c65756a55a15a4/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24bd94bb301ec672d8f0623eba9226cc90d775d25a0c92b5f8e4965d7f3a1518", size = 3560807, upload-time = "2026-04-03T16:58:31.666Z" },
{ url = "https://files.pythonhosted.org/packages/8c/d8/fda95459204877eed0458550d6c7c64c98cc50c2d8d618026737de9ed41a/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a51d3db74ba489266ef55c7a4534eb0b8db9a326553df481c11e5d7660c8364d", size = 3527481, upload-time = "2026-04-03T17:06:00.155Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0a/2aac8b78ac6487240cf7afef8f203ca783e8796002dc0cf65c4ee99ff8bb/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:55250fe61d6ebfd6934a272ee16ef1244e0f16b7af6cd18ab5b1fc9f08631db0", size = 3468565, upload-time = "2026-04-03T16:58:33.414Z" },
{ url = "https://files.pythonhosted.org/packages/a5/3d/ce71cfa82c50a373fd2148b3c870be05027155ce791dc9a5dcf439790b8b/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:46796877b47034b559a593d7e4b549aba151dae73f9e78212a3478161c12ab08", size = 3477769, upload-time = "2026-04-03T17:06:02.787Z" },
{ url = "https://files.pythonhosted.org/packages/d5/e8/0a9f5c1f7c6f9ca480319bf57c2d7423f08d31445974167a27d14483c948/sqlalchemy-2.0.49-cp313-cp313t-win32.whl", hash = "sha256:9c4969a86e41454f2858256c39bdfb966a20961e9b58bf8749b65abf447e9a8d", size = 2143319, upload-time = "2026-04-03T17:02:04.328Z" },
{ url = "https://files.pythonhosted.org/packages/0e/51/fb5240729fbec73006e137c4f7a7918ffd583ab08921e6ff81a999d6517a/sqlalchemy-2.0.49-cp313-cp313t-win_amd64.whl", hash = "sha256:b9870d15ef00e4d0559ae10ee5bc71b654d1f20076dbe8bc7ed19b4c0625ceba", size = 2175104, upload-time = "2026-04-03T17:02:05.989Z" },
{ url = "https://files.pythonhosted.org/packages/55/33/bf28f618c0a9597d14e0b9ee7d1e0622faff738d44fe986ee287cdf1b8d0/sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e", size = 2156356, upload-time = "2026-04-03T16:53:09.914Z" },
{ url = "https://files.pythonhosted.org/packages/d1/a7/5f476227576cb8644650eff68cc35fa837d3802b997465c96b8340ced1e2/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a", size = 3276486, upload-time = "2026-04-03T17:07:46.9Z" },
{ url = "https://files.pythonhosted.org/packages/2e/84/efc7c0bf3a1c5eef81d397f6fddac855becdbb11cb38ff957888603014a7/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066", size = 3281479, upload-time = "2026-04-03T17:12:32.226Z" },
{ url = "https://files.pythonhosted.org/packages/91/68/bb406fa4257099c67bd75f3f2261b129c63204b9155de0d450b37f004698/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187", size = 3226269, upload-time = "2026-04-03T17:07:48.678Z" },
{ url = "https://files.pythonhosted.org/packages/67/84/acb56c00cca9f251f437cb49e718e14f7687505749ea9255d7bd8158a6df/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401", size = 3248260, upload-time = "2026-04-03T17:12:34.381Z" },
{ url = "https://files.pythonhosted.org/packages/56/19/6a20ea25606d1efd7bd1862149bb2a22d1451c3f851d23d887969201633f/sqlalchemy-2.0.49-cp314-cp314-win32.whl", hash = "sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5", size = 2118463, upload-time = "2026-04-03T17:05:47.093Z" },
{ url = "https://files.pythonhosted.org/packages/cf/4f/8297e4ed88e80baa1f5aa3c484a0ee29ef3c69c7582f206c916973b75057/sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl", hash = "sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5", size = 2144204, upload-time = "2026-04-03T17:05:48.694Z" },
{ url = "https://files.pythonhosted.org/packages/1f/33/95e7216df810c706e0cd3655a778604bbd319ed4f43333127d465a46862d/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977", size = 3565474, upload-time = "2026-04-03T16:58:35.128Z" },
{ url = "https://files.pythonhosted.org/packages/0c/a4/ed7b18d8ccf7f954a83af6bb73866f5bc6f5636f44c7731fbb741f72cc4f/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01", size = 3530567, upload-time = "2026-04-03T17:06:04.587Z" },
{ url = "https://files.pythonhosted.org/packages/73/a3/20faa869c7e21a827c4a2a42b41353a54b0f9f5e96df5087629c306df71e/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61", size = 3474282, upload-time = "2026-04-03T16:58:37.131Z" },
{ url = "https://files.pythonhosted.org/packages/b7/50/276b9a007aa0764304ad467eceb70b04822dc32092492ee5f322d559a4dc/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a", size = 3480406, upload-time = "2026-04-03T17:06:07.176Z" },
{ url = "https://files.pythonhosted.org/packages/e5/c3/c80fcdb41905a2df650c2a3e0337198b6848876e63d66fe9188ef9003d24/sqlalchemy-2.0.49-cp314-cp314t-win32.whl", hash = "sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158", size = 2149151, upload-time = "2026-04-03T17:02:07.281Z" },
{ url = "https://files.pythonhosted.org/packages/05/52/9f1a62feab6ed368aff068524ff414f26a6daebc7361861035ae00b05530/sqlalchemy-2.0.49-cp314-cp314t-win_amd64.whl", hash = "sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7", size = 2184178, upload-time = "2026-04-03T17:02:08.623Z" },
{ url = "https://files.pythonhosted.org/packages/e5/30/8519fdde58a7bdf155b714359791ad1dc018b47d60269d5d160d311fdc36/sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0", size = 1942158, upload-time = "2026-04-03T16:53:44.135Z" },
]
[[package]]
name = "sqlparse"
version = "0.5.5"