diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6098f83 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.env +.env.* +.git +.gitignore +.venv +__pycache__ +*.pyc +staticfiles/ +media/ +.ruff_cache/ +.pytest_cache/ +.mypy_cache/ diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..06f2157 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,7 @@ +/.github/ @fragforce/web-team +/Dockerfile @fragforce/web-team +/Dockerfile.prod @fragforce/web-team +/compose.yaml @fragforce/web-team +/sonar-project.properties @fragforce/web-team +/pyproject.toml @fragforce/web-team +/uv.lock @fragforce/web-team diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml new file mode 100644 index 0000000..336c645 --- /dev/null +++ b/.github/workflows/codeql.yaml @@ -0,0 +1,42 @@ +name: CodeQL + +on: + pull_request: + branches: + - dev + - main + push: + branches: + - dev + - main + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + actions: read + + strategy: + fail-fast: false + matrix: + language: ['python', 'javascript', 'actions'] + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Initialize CodeQL + uses: github/codeql-action/init@1a818fd5f97ed0ee9a823421bd5b171add01227f # v4.36.2 + with: + languages: ${{ matrix.language }} + + - name: Autobuild + uses: github/codeql-action/autobuild@1a818fd5f97ed0ee9a823421bd5b171add01227f # v4.36.2 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@1a818fd5f97ed0ee9a823421bd5b171add01227f # v4.36.2 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml new file mode 100644 index 0000000..e8493a5 --- /dev/null +++ b/.github/workflows/coverage.yaml @@ -0,0 +1,91 @@ +name: Coverage + +on: + push: + branches: + - dev + - master + pull_request: + +jobs: + coverage: + name: Lint and test with coverage + runs-on: ubuntu-latest + permissions: + contents: read + + services: + postgres: + image: postgres:18.3 + env: + POSTGRES_DB: fragforce_read_test + POSTGRES_PASSWORD: insecure + POSTGRES_HOST_AUTH_METHOD: trust + options: >- + --health-cmd pg_isready + --health-interval 5s + --health-timeout 5s + --health-retries 10 + ports: + - 5432:5432 + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + + - name: Install uv + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: '3.13' + + - name: Install dependencies + run: uv sync --frozen --extra dev + + - name: Lint with ruff + run: uv run --frozen ruff check . + + - name: Django system check + env: + DATABASE_URL: postgres://postgres:insecure@localhost/fragforce_read_test + SECRET_KEY: insecure-test-key-not-used-in-production + run: uv run --frozen python manage.py check + + - name: Run tests with coverage + continue-on-error: true + env: + DATABASE_URL: postgres://postgres:insecure@localhost/fragforce_read_test + SECRET_KEY: insecure-test-key-not-used-in-production + run: | + uv run --frozen coverage run -m pytest --junitxml=TEST-results.xml + uv run --frozen coverage xml + + - name: Save PR metadata + env: + PR_NUMBER: ${{ github.event_name == 'pull_request' && github.event.number || '' }} + COMMIT_SHA: ${{ github.sha }} + BASE_REF: ${{ github.base_ref }} + REF_NAME: ${{ github.ref_name }} + run: | + echo "$PR_NUMBER" > pr_number.txt + echo "$COMMIT_SHA" > pr_sha.txt + echo "$BASE_REF" > pr_base_ref.txt + echo "$REF_NAME" > branch_name.txt + + - name: Upload coverage and test artifacts + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: coverage-report + path: | + coverage.xml + TEST-*.xml + pr_number.txt + pr_sha.txt + pr_base_ref.txt + branch_name.txt + retention-days: 1 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/dev-image.yaml b/.github/workflows/dev-image.yaml new file mode 100644 index 0000000..a98508b --- /dev/null +++ b/.github/workflows/dev-image.yaml @@ -0,0 +1,52 @@ +name: Dev Image + +on: + push: + branches: + - dev + +jobs: + build: + name: Build & push dev image + if: github.repository == 'fragforce/read' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + env: + IMG_NAME: fragforce-read-dev + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 + with: + buildkitd-flags: --debug + + - name: Docker metadata + id: metadata + uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6 + with: + images: ghcr.io/${{ github.repository_owner }}/${{ env.IMG_NAME }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=ref,event=branch + type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }} + + - name: Login to GitHub Container Registry + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7 + with: + context: . + file: Dockerfile.prod + push: true + tags: ${{ steps.metadata.outputs.tags }} + labels: ${{ steps.metadata.outputs.labels }} diff --git a/.github/workflows/django-tests.yaml b/.github/workflows/django-tests.yaml new file mode 100644 index 0000000..7ebb73d --- /dev/null +++ b/.github/workflows/django-tests.yaml @@ -0,0 +1,43 @@ +name: Django Tests + +on: + workflow_run: + workflows: ["Coverage"] + types: + - completed + +jobs: + report: + name: Django Tests + if: github.repository == 'fragforce/read' + runs-on: ubuntu-latest + permissions: + contents: read + checks: write + pull-requests: write + + steps: + - name: Generate app token + id: app-token + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3 + with: + client-id: ${{ secrets.FRAGFORCE_CI_APP_ID }} + private-key: ${{ secrets.FRAGFORCE_CI_PRIVATE_KEY }} + + - name: Download coverage artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: coverage-report + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ github.event.workflow_run.id }} + + - name: Report test results + uses: mikepenz/action-junit-report@bccf2e31636835cf0874589931c4116687171386 # v6.4.0 + with: + report_paths: TEST-*.xml + check_name: Django Tests + commit: ${{ github.event.workflow_run.head_sha }} + token: ${{ steps.app-token.outputs.token }} + fail_on_failure: true + include_passed: true + detailed_summary: true diff --git a/.github/workflows/prod-image.yaml b/.github/workflows/prod-image.yaml new file mode 100644 index 0000000..77458ff --- /dev/null +++ b/.github/workflows/prod-image.yaml @@ -0,0 +1,52 @@ +name: Prod Image + +on: + push: + tags: + - v* + +jobs: + build: + name: Build & push prod image + if: github.repository == 'fragforce/read' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + env: + IMG_NAME: fragforce-read-prod + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 + with: + buildkitd-flags: --debug + + - name: Docker metadata + id: metadata + uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6 + with: + images: ghcr.io/${{ github.repository_owner }}/${{ env.IMG_NAME }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=ref,event=branch + type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }} + + - name: Login to GitHub Container Registry + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7 + with: + context: . + file: Dockerfile.prod + push: true + tags: ${{ steps.metadata.outputs.tags }} + labels: ${{ steps.metadata.outputs.labels }} diff --git a/.github/workflows/sonar.yaml b/.github/workflows/sonar.yaml new file mode 100644 index 0000000..b7837b6 --- /dev/null +++ b/.github/workflows/sonar.yaml @@ -0,0 +1,101 @@ +name: SonarCloud Analysis + +on: + workflow_run: + workflows: ["Coverage"] + types: + - completed + +jobs: + sonar: + name: SonarCloud scan + if: github.repository == 'fragforce/read' && github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + permissions: + contents: read + checks: write + pull-requests: write + + steps: + # S7631: This checks out fork code in a privileged context, but no user code + # is executed - only passed to SonarCloud for static analysis. Artifact data + # is sanitized below. The SONAR_TOKEN only grants SonarCloud API access. + - name: Checkout PR code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + ref: ${{ github.event.workflow_run.head_sha }} + fetch-depth: 0 + + - name: Generate app token + id: app-token + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3 + with: + client-id: ${{ secrets.FRAGFORCE_CI_APP_ID }} + private-key: ${{ secrets.FRAGFORCE_CI_PRIVATE_KEY }} + + - name: Download coverage artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: coverage-report + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ github.event.workflow_run.id }} + + - name: Set scan properties + run: | + PR_NUMBER=$(tr -dc '0-9' < pr_number.txt) + PR_BASE=$(tr -dc 'A-Za-z0-9/_.-' < pr_base_ref.txt) + BRANCH=$(tr -dc 'A-Za-z0-9/_.-' < branch_name.txt) + echo "SONAR_PR_NUMBER=$PR_NUMBER" >> "$GITHUB_ENV" + echo "SONAR_PR_BASE=$PR_BASE" >> "$GITHUB_ENV" + echo "SONAR_BRANCH_NAME=$BRANCH" >> "$GITHUB_ENV" + + - name: Set up Java + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + distribution: temurin + java-version: 21 + + - name: SonarCloud Scan (push) + if: github.event.workflow_run.event != 'pull_request' + uses: SonarSource/sonarqube-scan-action@299e4b793aaa83bf2aba7c9c14bedbb485688ec4 # v7 + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_SCANNER_OPTS: -Dsonar.branch.name=${{ env.SONAR_BRANCH_NAME }} + + - name: SonarCloud Scan (pull request) + id: sonar-scan + if: github.event.workflow_run.event == 'pull_request' + uses: SonarSource/sonarqube-scan-action@299e4b793aaa83bf2aba7c9c14bedbb485688ec4 # v7 + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_SCANNER_OPTS: >- + -Dsonar.pullrequest.key=${{ env.SONAR_PR_NUMBER }} + -Dsonar.pullrequest.branch=${{ github.event.workflow_run.head_branch }} + -Dsonar.pullrequest.base=${{ env.SONAR_PR_BASE }} + + - name: Create SonarCloud quality gate check run + if: github.event.workflow_run.event == 'pull_request' && always() + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + SCAN_CONCLUSION: ${{ steps.sonar-scan.conclusion }} + PR_NUMBER: ${{ env.SONAR_PR_NUMBER }} + REPO: ${{ github.repository }} + run: | + if [ "$SCAN_CONCLUSION" = "success" ]; then + CONCLUSION="success" + TITLE="Quality Gate passed" + else + CONCLUSION="failure" + TITLE="Quality Gate failed" + fi + gh api "repos/$REPO/check-runs" \ + --method POST \ + -f name="SonarCloud Quality Gate" \ + -f head_sha="$HEAD_SHA" \ + -f status="completed" \ + -f conclusion="$CONCLUSION" \ + -f "output[title]=$TITLE" \ + -f "output[summary]=See [SonarCloud dashboard](https://sonarcloud.io/dashboard?id=fragforce_read&pullRequest=$PR_NUMBER) for details." diff --git a/.github/workflows/validate-release.yaml b/.github/workflows/validate-release.yaml new file mode 100644 index 0000000..30bc372 --- /dev/null +++ b/.github/workflows/validate-release.yaml @@ -0,0 +1,69 @@ +name: Validate Release + +on: + pull_request: + branches: + - main + +jobs: + validate: + name: Validate version and changelog + runs-on: ubuntu-latest + permissions: + contents: read + 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/.gitignore b/.gitignore index 83972fa..9998bd1 100644 --- a/.gitignore +++ b/.gitignore @@ -216,3 +216,5 @@ __marimo__/ # Streamlit .streamlit/secrets.toml +staticfiles/ +media/ 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 new file mode 100644 index 0000000..9cb8fed --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,366 @@ +# Development + +## Prerequisites + +- Docker and Docker Compose +- Git + +## Quick Start + +```bash +# Clone the repository +git clone git@github.com:fragforce/read.git +cd read + +# Copy environment config +cp .env.example .env # edit with your settings + +# Start the dev stack +docker compose up -d + +# Run tests +docker compose run --rm test + +# Access the app +open http://localhost:8000 +``` + +## Docker Services + +| Service | Purpose | Default Command | +|---------|---------|-----------------| +| `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. + +## Running Tests + +```bash +# 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 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 pytest -v + +# With coverage +docker compose run --rm test uv run coverage run -m pytest +docker compose run --rm test uv run coverage report +``` + +## Linting + +```bash +# 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 +``` + +### 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 +# 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 + +``` +books/ Main app - playback, recording, QR codes, processing +registration/ Narrator registration and login +config/ Django project settings and root URL config +templates/ HTML templates +static/ CSS, JS, and static assets +tests/ Project-level tests (healthz, etc.) +``` + +## Docker Images + +| File | Purpose | Built by | +|------|---------|----------| +| `Dockerfile` | Dev/test image with all dependencies | `docker compose build` | +| `Dockerfile.prod` | Production image (no dev deps, non-root user, collectstatic) | CI workflows | + +CI workflows (`dev-image.yaml`, `prod-image.yaml`) build and push `Dockerfile.prod` to GHCR. The dev `Dockerfile` is never pushed to a registry. + +## CI Workflows + +| Workflow | Trigger | Purpose | +|----------|---------|---------| +| `coverage.yaml` | Push/PR | Lint, test, upload coverage artifact | +| `django-tests.yaml` | After coverage | Report test results as check run | +| `sonar.yaml` | After coverage | SonarCloud static analysis | +| `dev-image.yaml` | Push to `dev` | Build and push dev container image | +| `prod-image.yaml` | Push to `main` or tag | Build and push prod container image | + +## Configuration + +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) | + +### 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/Dockerfile b/Dockerfile index 632c474..c429fbf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,17 +3,16 @@ FROM python:3.13-slim ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 +RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg \ + && rm -rf /var/lib/apt/lists/* + WORKDIR /app COPY --from=ghcr.io/astral-sh/uv:0.7.12@sha256:f68150822129ccecd95525e0ee1582f6f9421fa2c04c5afa3efa9c92232a819e /uv /usr/local/bin/uv COPY pyproject.toml uv.lock ./ -RUN uv sync --frozen --no-dev +RUN uv sync --frozen --extra dev COPY . . -RUN uv run python manage.py collectstatic --noinput 2>/dev/null || true - -EXPOSE 8000 - -CMD ["uv", "run", "gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"] +CMD ["uv", "run", "--frozen", "pytest"] diff --git a/Dockerfile.prod b/Dockerfile.prod new file mode 100644 index 0000000..29f323e --- /dev/null +++ b/Dockerfile.prod @@ -0,0 +1,27 @@ +FROM python:3.13-slim + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV UV_NO_CACHE=1 + +RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg \ + && rm -rf /var/lib/apt/lists/* \ + && addgroup --system app && adduser --system --ingroup app app + +WORKDIR /app + +COPY --from=ghcr.io/astral-sh/uv:0.7.12@sha256:f68150822129ccecd95525e0ee1582f6f9421fa2c04c5afa3efa9c92232a819e /uv /usr/local/bin/uv + +COPY pyproject.toml uv.lock ./ +RUN uv sync --frozen --no-dev + +COPY . . + +RUN SECRET_KEY=build DATABASE_URL=sqlite:///dev/null uv run --frozen python manage.py collectstatic --noinput \ + && mkdir -p /app/media/recordings /app/media/processing /app/media/finalized \ + && chown -R app:app /app +USER app + +EXPOSE 8000 + +CMD ["uv", "run", "--frozen", "gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"] diff --git a/README.md b/README.md index 20b60b6..9a03064 100644 --- a/README.md +++ b/README.md @@ -13,14 +13,14 @@ Audio playback service for the VTO Book Reading project. Salesforce/Fragforce vo | Component | Description | |-----------|-------------| -| **Playback service** | Password-gated page at `read.fragforce.org/b/{id}` that streams audio via HLS/MSE | +| **Playback service** | Password-gated page at `read.fragforce.org/b/{id}` that streams audio via HTML5 `