Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
run: uv python install 3.14

- name: Set up Node.js
uses: actions/setup-node@v6.3.0
uses: actions/setup-node@v6.4.0
Comment thread
PascalRepond marked this conversation as resolved.
with:
node-version: "24"
cache: "npm"
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,15 @@ jobs:

- name: Log in to Container Registry
if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.publish)
uses: docker/login-action@v4
uses: docker/login-action@v4.2.0
Comment thread
PascalRepond marked this conversation as resolved.
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Extract metadata
id: meta
uses: docker/metadata-action@v6.0.0
uses: docker/metadata-action@v6.1.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
Expand All @@ -47,7 +47,7 @@ jobs:
type=raw,value=latest,enable={{is_default_branch}}

- name: Build and push
uses: docker/build-push-action@v7.1.0
uses: docker/build-push-action@v7.2.0
with:
context: .
push: ${{ github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.publish) }}
Expand Down
159 changes: 159 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
# Datakult guide

## Overview

Datakult is a personal Django web app to track and rate media consumed: films, TV series, books, video games, music, podcasts, etc. It includes a rating system, markdown reviews, and filtering by type/status/year. Media metadata can be fetched from external APIs (TMDB, Google Books, MusicBrainz, OpenLibrary, IGDB).

**Stack**: Python 3.14.*, Django 6.x, SQLite, HTMX, Tailwind CSS, DaisyUI, Lucide icons, Pillow, WhiteNoise
**Package manager**: `uv` with `poethepoet` for task running

## Commands

During development, all commands are run through uv's virtual env with `uv run`.

### Daily development

```bash
uv run poe server # Start dev server (Django + Tailwind watch via honcho)
uv run poe migrate # Apply database migrations
uv run poe makemigrations # Generate new migrations
uv run poe shell # Open Django shell
```

### Linting and formatting

**IMPORTANT:** After editing files, make sure that there are no errors in the formatting and linting.

```bash
uv run poe lint # ruff check ./src
uv run poe format # ruff format ./src
```

### Tests and CI

```bash
uv run poe test # Run pytest with coverage
uv run poe ci # Full CI pipeline: format check, lint, audits, tests
uv run poe audit-python # pip-audit for Python dependency vulnerabilities
uv run poe audit-js # npm audit for JS dependency vulnerabilities
```

### Internationalisation

```bash
uv run poe makemessages # Extract translatable strings (en + fr)
uv run poe compilemessages # Compile .po files to .mo
```

### Setup (done by humans)

Human developers will run the app setup and the server on their own terms.

## Architecture

```text
src/
├── config/ # Django settings, urls, wsgi/asgi
├── core/ # Main app: Media model, views, forms, filters, queries
│ ├── services/ # External API clients: tmdb, googlebooks, musicbrainz, openlibrary, igdb
│ ├── templates/ # Core-specific templates
│ └── templatetags/
├── accounts/ # Custom user model (accounts.CustomUser), auth views
├── templates/ # Project-wide base templates and partials
├── static/ # Project-wide static files (JS, images)
├── theme/ # Tailwind CSS configuration (django-tailwind)
├── locale/ # Translation files (en, fr)
└── tests/
├── conftest.py # Shared pytest fixtures (agent, media, user, logged_in_client, etc.)
├── core/ # Tests for the core app
└── accounts/ # Tests for the accounts app
```

Key design choices:

- Single `Media` model covers all media types via a `media_type` field
- `Agent` model represents any contributor (author, director, artist, etc.)
- Custom user model from the start (`accounts.CustomUser`)
- Views use FBVs; HTMX handles dynamic interactions without full-page reloads
- Fixtures (sample JSON data) live in `src/core/fixtures/`

### Translations

The app supports **English** (`en`) and **French** (`fr`). Django's standard i18n framework is used (`gettext_lazy`, `.po`/`.mo` files in `src/locale/`). Run `uv run poe makemessages` after adding new translatable strings, then `uv run poe compilemessages` before testing translations.

## Testing Notes

- Tests use function-based style (no class-based tests).
- Tests are split into `src/tests/core/` and `src/tests/accounts/`.
- The project follows a test-driven development methodology. Each commit must be accompanied by tests that ensure that the functionality works as intended. Tests must follow DRY principles and should only test specific app behaviour and not the behaviour of external modules (e.g. Django or Python dependencies).
- Shared fixtures are in `src/tests/conftest.py` (provides `agent`, `media`, `media_factory`, `user`, `logged_in_client`).
- Sample fixture data is in `src/core/fixtures/sample_data.json`.

## Behavioral guidelines

**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.

### 1. Think Before Coding

**Don't assume. Don't hide confusion. Surface tradeoffs.**

Before implementing:

- State your assumptions explicitly. If uncertain, ask.
- If multiple interpretations exist, present them - don't pick silently.
- If a simpler approach exists, say so. Push back when warranted.
- If something is unclear, stop. Name what's confusing. Ask.

### 2. Simplicity First

**Minimum code that solves the problem. Nothing speculative.**

- No features beyond what was asked.
- No abstractions for single-use code.
- No "flexibility" or "configurability" that wasn't requested.
- No error handling for impossible scenarios.
- If you write 200 lines and it could be 50, rewrite it.

Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.

### 3. Surgical Changes

**Touch only what you must. Clean up only your own mess.**

When editing existing code:

- Don't "improve" adjacent code, comments, or formatting.
- Don't refactor things that aren't broken.
- Match existing style, even if you'd do it differently.
- If you notice unrelated dead code, mention it - don't delete it.

When your changes create orphans:

- Remove imports/variables/functions that YOUR changes made unused.
- Don't remove pre-existing dead code unless asked.

The test: Every changed line should trace directly to the user's request.

### 4. Goal-Driven Execution

**Define success criteria. Loop until verified.**

Transform tasks into verifiable goals:

- "Add validation" → "Write tests for invalid inputs, then make them pass"
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
- "Refactor X" → "Ensure tests pass before and after"

For multi-step tasks, state a brief plan:

```md
1. [Step] → verify: [check]
2. [Step] → verify: [check]
3. [Step] → verify: [check]
```

Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.

---

**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
101 changes: 79 additions & 22 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@ name = "datakult"
version = "1.7.0"
description = "Review and analyse the media and culture that you consume."
readme = "README.md"
license = "AGPL-3.0"
license = "AGPL-3.0-only"
authors = [
{ name = "Pascal Repond", email = "pascalr.92+dev@gmail.com" },
]

requires-python = ">=3.12"
requires-python = ">=3.14,<4.0.0"
dependencies = [
"django>=6.0.0",
"django-htmx>=1.23.2",
Expand Down Expand Up @@ -41,25 +44,79 @@ prod = [
[tool.poe.tasks]

# Dev tasks
server = "./src/manage.py tailwind dev"
collectstatic = "./src/manage.py collectstatic --noinput"
makemigrations = "./src/manage.py makemigrations"
migrate = "./src/manage.py migrate"
shell = "./src/manage.py shell"
setup = "./scripts/setup_dev.sh"
bootstrap = "./scripts/bootstrap.sh"
[tool.poe.tasks.server]
cmd = "./src/manage.py tailwind dev"
help = "Start the dev server (Django + Tailwind watch via honcho)"

[tool.poe.tasks.collectstatic]
cmd = "./src/manage.py collectstatic --noinput"
help = "Collect static files into STATIC_ROOT"

[tool.poe.tasks.makemigrations]
cmd = "./src/manage.py makemigrations"
help = "Generate new database migrations"

[tool.poe.tasks.migrate]
cmd = "./src/manage.py migrate"
help = "Apply pending database migrations"

[tool.poe.tasks.shell]
cmd = "./src/manage.py shell"
help = "Open the Django interactive shell"

[tool.poe.tasks.setup]
cmd = "./scripts/setup_dev.sh"
help = "Set up the development environment"

[tool.poe.tasks.bootstrap]
cmd = "./scripts/bootstrap.sh"
help = "Bootstrap the project from scratch"

# Localisation
makemessages = "./src/manage.py makemessages -l fr --ignore=theme/* --ignore=staticfiles/* --ignore=venv/*"
compilemessages = "./src/manage.py compilemessages"
[tool.poe.tasks.makemessages]
cmd = "./src/manage.py makemessages -l fr --ignore=theme/* --ignore=staticfiles/* --ignore=venv/*"
help = "Extract translatable strings for all supported locales"

[tool.poe.tasks.compilemessages]
cmd = "./src/manage.py compilemessages"
help = "Compile .po translation files to .mo"

# Tests and quality
test = "pytest ./src --cov=src --cov-report=term-missing"
format = "ruff format ./src"
lint = "ruff check ./src"
check-format = "ruff format ./src --check"
audit-python = "pip-audit"
audit-js = { shell = "cd ./src/theme/static_src && npm audit" }
[tool.poe.tasks.test]
cmd = "pytest ./src --cov=src --cov-report=term-missing"
help = "Run the test suite with coverage report"

[tool.poe.tasks.format]
cmd = "ruff format ./src"
help = "Auto-format source code with ruff"

[tool.poe.tasks.lint]
cmd = "ruff check ./src"
help = "Lint source code with ruff"

[tool.poe.tasks.check-format]
cmd = "ruff format ./src --check"
help = "Check formatting without modifying files (CI)"

[tool.poe.tasks.audit-python]
cmd = "pip-audit"
help = "Audit Python dependencies for known vulnerabilities"

[tool.poe.tasks.audit-js]
shell = "cd ./src/theme/static_src && npm audit"
help = "Audit JS dependencies for known vulnerabilities"

[tool.poe.tasks.update-python]
cmd = "uv lock --upgrade"
help = "Upgrade all Python dependencies in the lock file"

[tool.poe.tasks.update-js]
shell = "cd ./src/theme/static_src && npm update"
help = "Upgrade all Node dependencies"

[tool.poe.tasks.update]
sequence = ["update-python", "update-js"]
help = "Upgrade all dependencies (Python and Node)"

[tool.poe.tasks.ci]
sequence = ["check-format", "lint", "audit-python", "audit-js", "test"]
Expand Down Expand Up @@ -93,11 +150,11 @@ extend-exclude = ["./src/config/settings.py"]
# https://docs.astral.sh/ruff/rules/
lint.select = ["ALL"]
lint.ignore = [
"ANN",
"D",
"PGH004",
"RUF012",
"COM812",
"ANN", # type annotations not required project-wide
"D", # docstrings not enforced project-wide
"PGH004", # allow blanket `noqa` without specific rule codes
"RUF012", # mutable class-level defaults don't need ClassVar in Django models
"COM812", # trailing comma conflicts with ruff formatter
]

[tool.ruff.lint.per-file-ignores]
Expand Down
2 changes: 1 addition & 1 deletion src/config/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ def service_worker(_request):
try:
with sw_path.open() as f:
return HttpResponse(f.read(), content_type="application/javascript")
except (FileNotFoundError, PermissionError):
except FileNotFoundError, PermissionError:
return HttpResponseNotFound("Service worker not found")


Expand Down
7 changes: 5 additions & 2 deletions src/core/utils.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import json
import tarfile
import tomllib
from collections.abc import Iterable
from io import BytesIO, StringIO
from pathlib import Path
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from collections.abc import Iterable

import django
from django.conf import settings
Expand All @@ -25,7 +28,7 @@ def get_datakult_version() -> str:
with pyproject_path.open("rb") as f:
pyproject_data = tomllib.load(f)
return pyproject_data.get("project", {}).get("version", "unknown")
except (FileNotFoundError, KeyError, tomllib.TOMLDecodeError):
except FileNotFoundError, KeyError, tomllib.TOMLDecodeError:
return "unknown"


Expand Down
8 changes: 4 additions & 4 deletions src/core/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ def _fetch_tmdb_data(tmdb_id: str, media_type: str, language: str = DEFAULT_TMDB

try:
details = client.get_full_details(int(tmdb_id), media_type, language=language)
except (requests.RequestException, ValueError):
except requests.RequestException, ValueError:
logger.exception("Failed to fetch TMDB data for %s/%s", media_type, tmdb_id)
return None

Expand All @@ -311,7 +311,7 @@ def _fetch_igdb_data(igdb_id: str) -> dict | None:

try:
details = client.get_game_details(int(igdb_id))
except (requests.RequestException, ValueError):
except requests.RequestException, ValueError:
logger.exception("Failed to fetch IGDB data for game %s", igdb_id)
return None

Expand Down Expand Up @@ -725,7 +725,7 @@ def validate_saved_view_data(post_data): # noqa: C901, PLR0912
contributor_id_int = int(contributor_id)
if not Agent.objects.filter(pk=contributor_id_int).exists():
errors.append(_("Contributor does not exist: ID %(id)s") % {"id": contributor_id})
except (ValueError, TypeError):
except ValueError, TypeError:
errors.append(_("Invalid contributor ID format: %(id)s") % {"id": contributor_id})

# Validate review dates (if present)
Expand All @@ -734,7 +734,7 @@ def validate_saved_view_data(post_data): # noqa: C901, PLR0912
if date_value:
try:
PartialDate(date_value)
except (ValueError, TypeError, DjangoValidationError):
except ValueError, TypeError, DjangoValidationError:
errors.append(_("Invalid %(label)s: %(value)s") % {"label": field_label, "value": date_value})

# Validate has_review and has_cover
Expand Down
Loading
Loading