diff --git a/.github/workflows/create-release.yaml b/.github/workflows/create-release.yaml new file mode 100644 index 0000000..610bbf9 --- /dev/null +++ b/.github/workflows/create-release.yaml @@ -0,0 +1,71 @@ +name: Create Release + +on: + push: + branches: + - main + +jobs: + release: + name: Create GitHub release and tag + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Extract version + id: version + run: | + VERSION=$(grep '^version = ' pyproject.toml | cut -d'"' -f2) + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "tag=v$VERSION" >> "$GITHUB_OUTPUT" + + - name: Check if tag exists + id: tag-check + env: + TAG: ${{ steps.version.outputs.tag }} + run: | + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "Tag $TAG already exists, skipping release" + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "Tag $TAG does not exist, creating release" + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Extract changelog + if: steps.tag-check.outputs.exists == 'false' + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + # Extract changelog section for this version + awk "/^## \[$VERSION\]/,/^## \[/" CHANGELOG.md | sed '$d' | tail -n +2 > changelog.txt + + echo "### Release Notes for $VERSION" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + cat changelog.txt >> "$GITHUB_STEP_SUMMARY" + + - name: Create GitHub release + if: steps.tag-check.outputs.exists == 'false' + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.version.outputs.tag }} + VERSION: ${{ steps.version.outputs.version }} + run: | + gh release create "$TAG" \ + --title "Release $VERSION" \ + --notes-file changelog.txt + + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "✅ Created release $TAG" >> "$GITHUB_STEP_SUMMARY" + + - name: Skip summary + if: steps.tag-check.outputs.exists == 'true' + env: + TAG: ${{ steps.version.outputs.tag }} + run: | + echo "### Release Skipped" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "Tag $TAG already exists" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/prod-image.yaml b/.github/workflows/prod-image.yaml index f24eefa..77458ff 100644 --- a/.github/workflows/prod-image.yaml +++ b/.github/workflows/prod-image.yaml @@ -4,8 +4,6 @@ on: push: tags: - v* - branches: - - main jobs: build: diff --git a/.github/workflows/validate-release.yaml b/.github/workflows/validate-release.yaml new file mode 100644 index 0000000..ec030ae --- /dev/null +++ b/.github/workflows/validate-release.yaml @@ -0,0 +1,67 @@ +name: Validate Release + +on: + pull_request: + branches: + - main + +jobs: + validate: + name: Validate version and changelog + runs-on: ubuntu-latest + steps: + - name: Checkout PR branch + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + + - name: Fetch base branch + env: + BASE_REF: ${{ github.base_ref }} + run: git fetch origin "$BASE_REF" + + - name: Run all validation checks + env: + BASE_REF: ${{ github.base_ref }} + run: | + FAILED=0 + + echo "### Release Validation" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + + # Check 1: Version bump + BASE_VERSION=$(git show "origin/$BASE_REF:pyproject.toml" | grep '^version = ' | cut -d'"' -f2) + PR_VERSION=$(grep '^version = ' pyproject.toml | cut -d'"' -f2) + + if [ "$BASE_VERSION" = "$PR_VERSION" ]; then + echo "❌ Version not bumped in pyproject.toml (still $BASE_VERSION)" >> "$GITHUB_STEP_SUMMARY" + FAILED=1 + else + echo "✅ Version bumped from $BASE_VERSION to $PR_VERSION" >> "$GITHUB_STEP_SUMMARY" + fi + + # Check 2: CHANGELOG.md updated + if ! git diff --name-only "origin/$BASE_REF...HEAD" | grep -q '^CHANGELOG.md$'; then + echo "❌ CHANGELOG.md not updated" >> "$GITHUB_STEP_SUMMARY" + FAILED=1 + else + echo "✅ CHANGELOG.md updated" >> "$GITHUB_STEP_SUMMARY" + + # Check 3: Version in changelog (only if version was bumped and changelog updated) + if [ "$BASE_VERSION" != "$PR_VERSION" ]; then + if ! grep -q "^## \[$PR_VERSION\]" CHANGELOG.md; then + echo "❌ Version $PR_VERSION not found in CHANGELOG.md (expected: \`## [$PR_VERSION] - YYYY-MM-DD\`)" >> "$GITHUB_STEP_SUMMARY" + FAILED=1 + else + echo "✅ Version $PR_VERSION found in CHANGELOG.md" >> "$GITHUB_STEP_SUMMARY" + fi + fi + fi + + echo "" >> "$GITHUB_STEP_SUMMARY" + if [ $FAILED -eq 1 ]; then + echo "**Result:** ❌ Validation failed" >> "$GITHUB_STEP_SUMMARY" + exit 1 + else + echo "**Result:** ✅ All checks passed" >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..973f853 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,104 @@ +# Changelog + +All notable changes to Fragforce Reads will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] - 2026-07-01 + +Initial MVP release of Fragforce Reads - an audio playback service for the VTO Book Reading project. + +### Added + +**Core Recording Features** +- Browser-based audio recording workflow with mic access checks +- Pre-flight checklist for copyright compliance before recording +- Upload progress bar with user-friendly error handling +- Automatic audio remux pipeline for seeking support with ffmpeg +- Recording status tracking (pending, processing, ready, failed) +- Narrator dashboard showing available books and personal recordings +- Recording detail page with playback and flag-for-review functionality +- Profile page for narrators to update name and email +- Re-record capability for flagged recordings + +**QR Code System** +- Auto-generated QR codes with short codes and passwords +- QR code labels with book info, narrator name, and password +- Printable QR sheet view for admins +- Short URL redirect (`/q/`) to playback pages +- Case-insensitive password entry with rate limiting + +**Playback & Security** +- Public playback page with password protection +- Range request support for audio seeking +- License expiry enforcement at playback time +- Session-based password unlocking (7-day sessions) +- Rate limiting on password attempts (5 attempts, 5-minute lockout) +- Narrator attribution visible before password entry +- Duration display in mm:ss format + +**Registration & Authentication** +- Event code registration with rate limiting +- Invite link registration system +- Passphrase login with rate limiting +- Case-insensitive login +- Logout endpoint +- Welcome/login flow at `/login/` + +**Admin Features** +- Book management with max narrator limits +- Narrator management with passphrase generation +- Recording admin with status filters and retry action +- QR code management with admin links +- Event code and invite link management + +**Infrastructure & Deployment** +- Docker containerization (dev and prod variants) +- PostgreSQL 18.3 support +- GitHub Actions CI/CD (lint, test, coverage, SonarCloud) +- Docker image builds for dev and prod +- Whitenoise static file serving +- SSL security settings with SECURE_SSL flag +- Health check endpoint (`/healthz/`) for internal networks +- Persistent database connections (CONN_MAX_AGE=600) +- Non-root container execution +- Media file storage and serving + +**Testing & Quality** +- Comprehensive test suite (146+ tests) +- Coverage reporting +- SonarCloud quality gate integration +- Per-app test package structure +- CODEOWNERS for CI/build config protection + +### Security +- Rate limiting on login, event registration, and playback passwords +- Upload size validation (100MB limit) +- Recording duration bounds validation (max 3600s) +- Attestation text length validation (max 5000 chars) +- HTTP method restrictions on all views +- CSRF protection +- Session security with 7-day expiry +- File handle cleanup in range-request serving +- Healthz endpoint restricted to internal networks only + +### Changed +- Simplified QR password format from `4alphanumeric-word-word` to `word-word-2digits` +- Narrator attribution now visible before password entry +- Audio files served through view with auth checks (no direct static serving) +- Recovered stuck recordings on app startup (gunicorn/runserver only) + +### Fixed +- N+1 query optimization in dashboard view +- File handle leak in range-request audio serving +- Max narrators check when set to 0 +- Visited link color on buttons +- Audio URL routing conflicts +- Admin retry action queryset re-evaluation +- Remux claim race condition with SELECT FOR UPDATE SKIP LOCKED +- Signal registration and file cleanup on recording delete +- HTML accessibility and CSS deprecation warnings +- Cognitive complexity in views (SonarCloud) + +[1.0.0]: https://github.com/fragforce/read/releases/tag/v1.0.0 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 14092e5..9cb8fed 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -29,8 +29,8 @@ open http://localhost:8000 | Service | Purpose | Default Command | |---------|---------|-----------------| -| `web` | Development server with hot reload | `manage.py runserver` | -| `test` | Run the test suite | `pytest` | +| `web` | Development server with hot reload | `uv run --frozen python manage.py runserver 0.0.0.0:8000` | +| `test` | Run the test suite | `uv run --frozen pytest` | | `db` | PostgreSQL 18.3 | - | The `web` and `test` services use the same image (`Dockerfile`) which includes all dev dependencies. Source code is volume-mounted so changes are reflected immediately. @@ -41,32 +41,233 @@ The `web` and `test` services use the same image (`Dockerfile`) which includes a # Full test suite docker compose run --rm test +# Specific module +docker compose run --rm test uv run pytest books/tests + # Specific test file -docker compose run --rm test uv run --frozen pytest books/tests/test_playback.py +docker compose run --rm test uv run pytest books/tests/test_playback.py + +# Specific test +docker compose run --rm test uv run pytest books/tests/test_playback.py::PlaybackViewTest::test_narrator_shown_before_password_entry # With verbose output -docker compose run --rm test uv run --frozen pytest -v +docker compose run --rm test uv run pytest -v # With coverage -docker compose run --rm test uv run --frozen coverage run -m pytest -docker compose run --rm test uv run --frozen coverage report +docker compose run --rm test uv run coverage run -m pytest +docker compose run --rm test uv run coverage report ``` ## Linting ```bash -docker compose run --rm test uv run --frozen ruff check . -docker compose run --rm test uv run --frozen ruff format --check . +# Check for lint issues +docker compose run --rm test uv run ruff check . + +# Auto-fix lint issues +docker compose run --rm test uv run ruff check --fix . + +# Check formatting +docker compose run --rm test uv run ruff format --check . + +# Auto-format code +docker compose run --rm test uv run ruff format . +``` + +## Development Workflow + +### Pre-commit Checks + +Before committing code, always run: + +```bash +# Lint +docker compose run --rm test uv run ruff check --fix . +docker compose run --rm test uv run ruff format . + +# Tests +docker compose run --rm test ``` -## Django Management Commands +### Pull Request Requirements + +- All tests must pass (146+ tests) +- Lint must pass (ruff check and format) +- Coverage should remain at or above 85% +- SonarCloud quality gate must pass + +### Adding Dependencies ```bash -docker compose run --rm web uv run --frozen python manage.py migrate -docker compose run --rm web uv run --frozen python manage.py createsuperuser -docker compose run --rm web uv run --frozen python manage.py collectstatic +# Add a production dependency +docker compose run --rm web uv add + +# Add a dev dependency +docker compose run --rm web uv add --dev + +# Sync dependencies after pulling +docker compose run --rm web uv sync --frozen ``` +### When Tests Are Required + +- **Always** for new features +- **Always** for bug fixes +- **Always** when modifying existing behavior +- Tests should cover the happy path and edge cases + +## Database Management + +### Creating Migrations + +```bash +# Generate migration for model changes +docker compose run --rm web uv run python manage.py makemigrations + +# Apply migrations +docker compose run --rm web uv run python manage.py migrate + +# Show migration status +docker compose run --rm web uv run python manage.py showmigrations +``` + +### Resetting the Database + +```bash +# Stop services +docker compose down + +# Remove database volume +docker volume rm read_pgdata + +# Restart and migrate +docker compose up -d +docker compose run --rm web uv run python manage.py migrate +docker compose run --rm web uv run python manage.py createsuperuser +``` + +### Django Shell + +```bash +# Interactive Python shell with Django loaded +docker compose run --rm web uv run python manage.py shell + +# Example: Create test data +from books.models import Book +Book.objects.create(title="Test Book", slug="test", author="Author") +``` + +## Admin Setup + +### Creating a Superuser + +```bash +docker compose run --rm web uv run python manage.py createsuperuser +``` + +Then access the admin at `http://localhost:8000/admin/` + +### Admin Capabilities + +- Manage books, narrators, recordings, QR codes +- Retry failed recordings +- Generate narrator passphrases +- Create event codes and invite links +- Download QR code sheets + +## Debugging + +### Viewing Logs + +```bash +# Follow all service logs +docker compose logs -f + +# Follow specific service +docker compose logs -f web + +# View recent logs +docker compose logs --tail=100 web +``` + +### Django Debug Toolbar + +When `DEBUG=True`, the Debug Toolbar appears on all pages. Shows: +- SQL queries and performance +- Template rendering +- Request/response headers +- Cache usage + +### Container Shell Access + +```bash +# Web service shell +docker compose exec web bash + +# Database shell +docker compose exec db psql -U fragforce -d fragforce_read +``` + +## Common Development Tasks + +### Testing the Recording Workflow + +1. Create a superuser (see Admin Setup) +2. Log in at `/admin/` +3. Create a Book with `public_domain=True` +4. Create an InviteLink or EventCode +5. Register as narrator at `/register/invite/` or `/register/event/` +6. Log in at `/login/` with generated passphrase +7. Select book from dashboard and record + +### Creating Test Books + +```bash +docker compose run --rm web uv run python manage.py shell +``` + +```python +from books.models import Book + +# Public domain book (no copyright checks) +Book.objects.create( + title="Alice in Wonderland", + slug="alice", + author="Lewis Carroll", + public_domain=True, + estimated_duration="30 min" +) + +# Licensed book (requires physical book) +Book.objects.create( + title="Modern Book", + slug="modern", + author="Current Author", + public_domain=False, + publisher="Publisher Inc", + max_narrators=3, + estimated_duration="45 min" +) +``` + +### Creating Invite Links + +Via admin at `/admin/registration/invitelink/add/` or shell: + +```python +from registration.models import InviteLink +link = InviteLink.objects.create() +print(f"Invite URL: /register/invite/{link.token}/") +``` + +### Generating QR Codes + +QR codes are auto-generated when recordings finish processing. To regenerate: + +1. Go to `/admin/books/qrcode/` +2. Select existing codes +3. Delete and let remux recreate them, or manually create new ones + ## Project Structure ``` @@ -99,11 +300,67 @@ CI workflows (`dev-image.yaml`, `prod-image.yaml`) build and push `Dockerfile.pr ## Configuration -Environment variables are loaded from `.env` via django-environ. Key settings: +Environment variables are loaded from `.env` via django-environ. Copy `env.sample` to `.env` and customize. + +### Core Django Settings + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `SECRET_KEY` | string | *required* | Django secret key for cryptographic signing | +| `DEBUG` | bool | `False` | Enable debug mode (never in production) | +| `ALLOWED_HOSTS` | list | `[]` | Comma-separated list of allowed hostnames | +| `CSRF_TRUSTED_ORIGINS` | list | `[]` | Comma-separated list of trusted origins for CSRF | + +### Database Settings + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `DATABASE_URL` | string | *required* | PostgreSQL connection string (e.g. `postgres://user:pass@host:5432/dbname`) | +| `CONN_MAX_AGE` | int | `600` | Database connection max age in seconds (persistent connections) | +| `POSTGRES_DB` | string | `fragforce_read` | Database name (used by postgres container) | +| `POSTGRES_USER` | string | `fragforce` | Database user (used by postgres container) | +| `POSTGRES_PASSWORD` | string | *required* | Database password (used by postgres container) | -| Variable | Purpose | -|----------|---------| -| `SECRET_KEY` | Django secret key | -| `DATABASE_URL` | PostgreSQL connection string | -| `DEBUG` | Enable debug mode (never in production) | -| `ALLOWED_HOSTS` | Comma-separated list of allowed hostnames | +### Security Settings + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `SECURE_SSL` | bool | `not DEBUG` | Enable SSL/HTTPS security settings (HSTS, secure cookies) | + +### Application Settings + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `RECORDING_MAX_DURATION_SECONDS` | int | `3600` | Maximum allowed recording duration (1 hour) | +| `SESSION_COOKIE_AGE` | int | `604800` | Session lifetime in seconds (default 7 days) | + +### Rate Limiting Settings + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `LOGIN_MAX_ATTEMPTS` | int | `5` | Maximum login attempts before lockout | +| `LOGIN_LOCKOUT_SECONDS` | int | `300` | Passphrase login lockout duration (5 minutes) | +| `EVENT_LOGIN_LOCKOUT_SECONDS` | int | `60` | Event code registration lockout duration (1 minute) | +| `LOGIN_UNLOCK_CODE` | string | `""` | Optional bypass code for event registration lockout (leave empty to disable) | + +### Example Development .env + +```bash +# Django +SECRET_KEY=dev-secret-key-change-in-production +DEBUG=True +ALLOWED_HOSTS=localhost,127.0.0.1 +CSRF_TRUSTED_ORIGINS=http://localhost:8000 + +# Database +DATABASE_URL=postgres://fragforce:fragforce@db:5432/fragforce_read +POSTGRES_DB=fragforce_read +POSTGRES_USER=fragforce +POSTGRES_PASSWORD=fragforce + +# Application (using defaults, customize if needed) +# RECORDING_MAX_DURATION_SECONDS=3600 +# SESSION_COOKIE_AGE=604800 +# LOGIN_MAX_ATTEMPTS=5 +# LOGIN_LOCKOUT_SECONDS=300 +``` diff --git a/env.sample b/env.sample index 1bc654d..87ceca8 100644 --- a/env.sample +++ b/env.sample @@ -10,3 +10,14 @@ POSTGRES_DB=fragforce_read POSTGRES_USER=fragforce POSTGRES_PASSWORD=change-me-in-production DATABASE_URL=postgres://fragforce:change-me-in-production@db-read-dev:5432/fragforce_read +# CONN_MAX_AGE=600 + +# Application Settings +# RECORDING_MAX_DURATION_SECONDS=3600 +# SESSION_COOKIE_AGE=604800 + +# Rate Limiting +# LOGIN_MAX_ATTEMPTS=5 +# LOGIN_LOCKOUT_SECONDS=300 +# EVENT_LOGIN_LOCKOUT_SECONDS=60 +# LOGIN_UNLOCK_CODE= diff --git a/pyproject.toml b/pyproject.toml index 432764b..a835ba9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "fragforce-read" -version = "0.1.0" +version = "1.0.0" description = "Audio playback service for the VTO Book Reading project" requires-python = ">=3.13" dependencies = [ diff --git a/uv.lock b/uv.lock index b1f39fa..d6ebf80 100644 --- a/uv.lock +++ b/uv.lock @@ -112,7 +112,7 @@ wheels = [ [[package]] name = "fragforce-read" -version = "0.1.0" +version = "1.0.0" source = { virtual = "." } dependencies = [ { name = "django" },