Initial project scaffold: Django 6 + uv, six app structure

ExifTree project with core, tree, gallery, groups, ingest, and search apps.
Managed by uv with Django as the sole dependency.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 19:06:00 -04:00
commit 3cbd26e4f5
54 changed files with 686 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
__pycache__/
*.py[cod]
*.so
*.egg-info/
dist/
build/
.venv/
.env
db.sqlite3
*.sqlite3
media/
staticfiles/
.DS_Store
+1
View File
@@ -0,0 +1 @@
3.14
+94
View File
@@ -0,0 +1,94 @@
# CLAUDE.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
+218
View File
@@ -0,0 +1,218 @@
# ExifTree — Architecture
**Domain:** exiftree.org
**Stack:** Django · PostgreSQL · Python
**Tagline:** Browse photography through the gear that made it.
---
## Concept
ExifTree is a community platform where visual creators showcase their work, organized around the gear used to create it. Click a camera name and see images from that camera across all users. Click a lens and browse the world through that glass.
EXIF metadata is the infrastructure — extracted automatically on upload, normalized into a browsable tree of cameras, lenses, and settings.
---
## Django Apps
### `core`
Owns the fundamental data models that everything else depends on. If you deleted every other app, `core` would still make sense on its own.
**Models:**
- `User` — extends AbstractUser. Username, email, bio, avatar, website URL, join date.
- `Image` — the uploaded work. Title, description, file path, thumbnail paths, upload date, visibility (public/private/unlisted), view count. FK to User.
- `Camera` — canonical camera record. Manufacturer, model, slug, display name. Normalized from raw EXIF strings.
- `Lens` — canonical lens record. Manufacturer, model, slug, display name, focal length range, max aperture. Normalized from raw EXIF strings.
- `ExifData` — one-to-one with Image. Raw EXIF blob (JSONField) plus parsed/indexed fields: FK to Camera, FK to Lens, focal length, aperture, shutter speed, ISO, date taken, GPS (nullable).
**Normalization layer:** EXIF strings are messy. "NIKON CORPORATION NIKON D850" and "Nikon D850" need to resolve to the same Camera record. Core owns a normalization pipeline — a lookup table of known aliases plus heuristics for splitting manufacturer/model. This can start as a simple mapping dict and evolve into something smarter over time.
---
### `tree`
The flagship feature. Browse-by-gear discovery pages.
**Routes:**
- `/cameras/` — grid of all cameras with image counts and sample thumbnails
- `/cameras/<manufacturer>/` — all models from one brand
- `/cameras/<manufacturer>/<model>/` — gallery of all images shot on this camera
- `/lenses/` — same structure for lenses
- `/lenses/<manufacturer>/<model>/`
**No new models.** Tree reads entirely from `core` models. It's a views/templates app that queries Camera, Lens, Image, and ExifData.
**Sorting/filtering within a camera page:** recent, most viewed, by lens, by focal length, by ISO range. All derived from ExifData fields.
---
### `gallery`
User-facing portfolios and collections.
**Models:**
- `Collection` — user-curated set of images. Title, description, cover image, ordering, visibility. FK to User.
- `CollectionImage` — M2M through table. FK to Collection, FK to Image, sort order.
**Routes:**
- `/@<username>/` — user profile, shows their uploads
- `/@<username>/collections/` — their collections
- `/@<username>/collections/<slug>/` — single collection view
---
### `groups`
Community spaces. Not in core because the platform works without them.
**Models:**
- `Group` — name, slug, description, cover image, created date, visibility (public/private/invite-only).
- `GroupMembership` — FK to User, FK to Group, role (member/moderator/admin), join date.
- `GroupImage` — FK to Image, FK to Group, submitted date. An image can belong to multiple groups.
**Routes:**
- `/groups/` — browse/search groups
- `/groups/<slug>/` — group page with member images
- `/groups/<slug>/members/` — member list
**Open question:** Can groups be auto-generated from gear? e.g., "Fujifilm X100V Shooters" as a system group that anyone who uploads an X100V image is implicitly part of. Could blur the line between `tree` and `groups` in an interesting way.
---
### `ingest`
The upload and processing pipeline. Handles the heavy lifting so other apps don't have to.
**Responsibilities:**
1. Accept image upload (validate format, size limits)
2. Extract EXIF via Pillow / exifread
3. Run normalization — resolve Camera and Lens records
4. Generate thumbnail variants (small, medium, large)
5. Store original in object storage (S3/R2/B2)
6. Store thumbnails in object storage
7. Create Image + ExifData records in core
**Processing:** Celery task queue for async processing. Upload returns immediately, processing happens in background. Image marked as "processing" until complete.
**Bulk import:** Future feature — ingest from Lightroom catalogs, Flickr exports, or directories with EXIF intact.
---
### `search`
EXIF-powered search and filtering. Builds on top of core models.
**Capabilities:**
- Full-text search on image title/description
- Filter by camera, lens, focal length range, aperture range, ISO range, date range
- Filter by GPS/location (if present)
- Combined queries: "all images shot on 85mm f/1.4 between ISO 100-400"
**Implementation:** Start with Django ORM queries + Postgres indexes. Move to Elasticsearch/Meilisearch if needed at scale.
---
## Infrastructure
### Image Storage
- **Originals:** Object storage (Cloudflare R2 preferred — no egress fees). Never serve originals directly.
- **Thumbnails:** Generated on upload in 3 sizes. Served via CDN.
- **CDN:** Cloudflare in front of R2. Images are the bulk of bandwidth.
### Database
PostgreSQL. Key indexes:
- ExifData: camera_id, lens_id, focal_length, aperture, iso, date_taken
- Image: user_id, upload_date, visibility
- Camera/Lens: slug, manufacturer
JSONField on ExifData stores the full raw EXIF blob for future use without migrations.
### Task Queue
Celery + Redis for async image processing. Tasks:
- EXIF extraction
- Thumbnail generation
- Camera/Lens normalization and dedup
### DNS / Hosting
- **DNS:** DNSimple (registered)
- **App hosting:** TBD — Railway, Render, or DigitalOcean for MVP
- **Object storage:** Cloudflare R2
- **CDN:** Cloudflare
---
## Repo Structure
```
exiftree/
├── manage.py
├── exiftree/
│ ├── settings/
│ │ ├── base.py
│ │ ├── dev.py
│ │ └── prod.py
│ ├── urls.py
│ └── wsgi.py
├── core/
│ ├── models.py
│ ├── admin.py
│ ├── normalization.py # EXIF string → canonical Camera/Lens
│ ├── exif.py # extraction logic
│ └── migrations/
├── tree/
│ ├── views.py
│ ├── urls.py
│ └── templates/tree/
├── gallery/
│ ├── models.py
│ ├── views.py
│ ├── urls.py
│ └── templates/gallery/
├── groups/
│ ├── models.py
│ ├── views.py
│ ├── urls.py
│ └── templates/groups/
├── ingest/
│ ├── tasks.py # Celery tasks
│ ├── pipeline.py
│ ├── views.py # upload endpoints
│ └── urls.py
├── search/
│ ├── views.py
│ ├── filters.py
│ ├── urls.py
│ └── templates/search/
├── static/
├── templates/ # base templates, shared partials
├── pyproject.toml # uv-managed dependencies
├── uv.lock
└── docker-compose.yml
```
---
## Open Decisions
- **Frontend approach:** Django templates + HTMX for interactivity, or a separate SPA? HTMX keeps things simple and Pythonic. SPA gives richer interactions but doubles the stack.
- **Auth:** Django built-in vs django-allauth for social login.
- **Image formats:** Accept RAW files? Or JPEG/PNG/WebP only? RAW adds storage cost and processing complexity.
- **Moderation:** Community platform needs content moderation. Manual review queue? AI-assisted? Report system?
- **API:** REST API from day one (DRF) or add it later? Needed if a mobile app is ever on the table.
- **Auto-groups from gear:** Should the tree pages and groups merge in some way? A camera page is basically a group without the social features.
View File
+3
View File
@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.
+5
View File
@@ -0,0 +1,5 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
name = "core"
View File
+3
View File
@@ -0,0 +1,3 @@
from django.db import models
# Create your models here.
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+3
View File
@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.
View File
+16
View File
@@ -0,0 +1,16 @@
"""
ASGI config for exiftree project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "exiftree.settings")
application = get_asgi_application()
+117
View File
@@ -0,0 +1,117 @@
"""
Django settings for exiftree project.
Generated by 'django-admin startproject' using Django 6.0.4.
For more information on this file, see
https://docs.djangoproject.com/en/6.0/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/6.0/ref/settings/
"""
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = "django-insecure-x8w4k=pjv*+&gl02p#ucsdh7*5)k&m7av=ehc8_6v756pg@f=j"
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = []
# Application definition
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "exiftree.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "exiftree.wsgi.application"
# Database
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
# Password validation
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
# Internationalization
# https://docs.djangoproject.com/en/6.0/topics/i18n/
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/6.0/howto/static-files/
STATIC_URL = "static/"
+23
View File
@@ -0,0 +1,23 @@
"""
URL configuration for exiftree project.
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/6.0/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path
urlpatterns = [
path("admin/", admin.site.urls),
]
+16
View File
@@ -0,0 +1,16 @@
"""
WSGI config for exiftree project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "exiftree.settings")
application = get_wsgi_application()
View File
+3
View File
@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.
+5
View File
@@ -0,0 +1,5 @@
from django.apps import AppConfig
class GalleryConfig(AppConfig):
name = "gallery"
View File
+3
View File
@@ -0,0 +1,3 @@
from django.db import models
# Create your models here.
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+3
View File
@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.
View File
+3
View File
@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.
+5
View File
@@ -0,0 +1,5 @@
from django.apps import AppConfig
class GroupsConfig(AppConfig):
name = "groups"
View File
+3
View File
@@ -0,0 +1,3 @@
from django.db import models
# Create your models here.
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+3
View File
@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.
View File
+3
View File
@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.
+5
View File
@@ -0,0 +1,5 @@
from django.apps import AppConfig
class IngestConfig(AppConfig):
name = "ingest"
View File
+3
View File
@@ -0,0 +1,3 @@
from django.db import models
# Create your models here.
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+3
View File
@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.
Executable
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "exiftree.settings")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()
+8
View File
@@ -0,0 +1,8 @@
[project]
name = "exiftree"
version = "0.1.0"
description = "Browse photography through the gear that made it."
requires-python = ">=3.12"
dependencies = [
"django>=6.0.4",
]
View File
+3
View File
@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.
+5
View File
@@ -0,0 +1,5 @@
from django.apps import AppConfig
class SearchConfig(AppConfig):
name = "search"
View File
+3
View File
@@ -0,0 +1,3 @@
from django.db import models
# Create your models here.
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+3
View File
@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.
View File
+3
View File
@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.
+5
View File
@@ -0,0 +1,5 @@
from django.apps import AppConfig
class TreeConfig(AppConfig):
name = "tree"
View File
+3
View File
@@ -0,0 +1,3 @@
from django.db import models
# Create your models here.
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+3
View File
@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.
Generated
+55
View File
@@ -0,0 +1,55 @@
version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "asgiref"
version = "3.11.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" },
]
[[package]]
name = "django"
version = "6.0.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "sqlparse" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/60/b9/4155091ad1788b38563bd77a7258c0834e8c12a7f56f6975deaf54f8b61d/django-6.0.4.tar.gz", hash = "sha256:8cfa2572b3f2768b2e84983cf3c4811877a01edb64e817986ec5d60751c113ac", size = 10907407, upload-time = "2026-04-07T13:55:44.961Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/47/3d61d611609764aa71a37f7037b870e7bfb22937366974c4fd46cada7bab/django-6.0.4-py3-none-any.whl", hash = "sha256:14359c809fc16e8f81fd2b59d7d348e4d2d799da6840b10522b6edf7b8afc1da", size = 8368342, upload-time = "2026-04-07T13:55:37.999Z" },
]
[[package]]
name = "exiftree"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "django" },
]
[package.metadata]
requires-dist = [{ name = "django", specifier = ">=6.0.4" }]
[[package]]
name = "sqlparse"
version = "0.5.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" },
]
[[package]]
name = "tzdata"
version = "2026.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/19/f5/cd531b2d15a671a40c0f66cf06bc3570a12cd56eef98960068ebbad1bf5a/tzdata-2026.1.tar.gz", hash = "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98", size = 197639, upload-time = "2026-04-03T11:25:22.002Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/70/d460bd685a170790ec89317e9bd33047988e4bce507b831f5db771e142de/tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9", size = 348952, upload-time = "2026-04-03T11:25:20.313Z" },
]