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
16 changes: 16 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Keep the build context small and never bake secrets into the image
.git
.github
.env
.env.*
!.env.example
**/node_modules
**/__pycache__
**/*.pyc
frontend/dist
backend/frontend_dist
backend/staticfiles
backend/media
**/*.sqlite3
docs
screenshots
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,11 @@ CSRF_TRUSTED_ORIGINS=https://your-frontend.example.com
HSTS_SECONDS=3600
HSTS_INCLUDE_SUBDOMAINS=False
HSTS_PRELOAD=False

# Production only: console log level
LOG_LEVEL=INFO

# Optional: create the first admin on container start (skipped if unset or
# if the account already exists)
# DJANGO_SUPERUSER_EMAIL=admin@example.com
# DJANGO_SUPERUSER_PASSWORD=change-me
35 changes: 35 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,38 @@ jobs:

- name: Build
run: pnpm build

docker:
name: Docker image
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7

- name: Build
run: docker build -t lead-platform:ci .

# Start the real image (SQLite stands in for Postgres) and check that it
# migrates, serves the SPA, static files and the API
- name: Smoke test
run: |
docker run -d --name app -p 8000:8000 \
-e SECRET_KEY=ci-only-insecure-secret-key-not-used-anywhere-else \
-e DATABASE_URL=sqlite:////tmp/ci.sqlite3 \
-e ALLOWED_HOSTS=localhost \
-e CORS_ALLOWED_ORIGINS=http://localhost:8000 \
-e CSRF_TRUSTED_ORIGINS=http://localhost:8000 \
-e SECURE_SSL_REDIRECT=False \
-e DJANGO_SUPERUSER_EMAIL=admin@example.com \
-e DJANGO_SUPERUSER_PASSWORD=ci-admin-password \
lead-platform:ci
for i in $(seq 60); do curl -fsS -o /dev/null http://localhost:8000/ && break; sleep 1; done
curl -fsS http://localhost:8000/leads/7 | grep -q 'id="root"'
curl -fsS -o /dev/null http://localhost:8000/static/admin/css/base.css
test "$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8000/api/leads/)" = 401
curl -fsS -X POST http://localhost:8000/api/auth/login/ \
-H 'Content-Type: application/json' \
-d '{"email":"admin@example.com","password":"ci-admin-password"}' | grep -q '"access"'

- name: Container logs
if: always()
run: docker logs app || true
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -159,4 +159,7 @@ docker-compose.override.yml
# ==============================================================================
# Misc
# ==============================================================================
*.orig
*.orig

# Frontend build copied into the backend (Docker / local production test)
backend/frontend_dist/
6 changes: 3 additions & 3 deletions CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@
- [x] Consistent Docstring
- [x] Import Cleanup
- [x] Query Optimization
- [x] 69 Automated Tests Passing
- [x] 80 Automated Tests Passing

---

Expand Down Expand Up @@ -177,8 +177,8 @@

- [x] Frontend Build (`pnpm build`)
- [x] Frontend Lint (`pnpm lint`)
- [x] Frontend Tests (68/68, `pnpm test`)
- [x] Backend Tests (69/69)
- [x] Frontend Tests (69/69, `pnpm test`)
- [x] Backend Tests (80/80)
- [x] Manual End-to-End Testing
- [x] Swagger Verification

Expand Down
18 changes: 15 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ frontend/
layouts/ page shells
pages/ route-level views
routes/ routing and guards
docs/ architecture and written assessments
docs/ architecture, assessments, DEPLOY.md
Dockerfile one image: builds the frontend, serves API + admin + SPA (gunicorn + WhiteNoise)
render.yaml Render blueprint (free Docker web service; database on Neon)
screenshots/ UI captures referenced by the README
```

Expand Down Expand Up @@ -72,11 +74,21 @@ Swagger is served at `/api/docs/` once the backend is running.
CI (`.github/workflows/ci.yml`) runs on PRs and pushes to `main`: flake8, a missing-migrations
check, a stale-schema check (`backend/schema.yml` must match `manage.py spectacular`), pytest,
a stale-types check (`frontend/src/types/api.ts` must match `openapi-typescript` output), then
`pnpm lint`, `pnpm test` and `pnpm build`. After changing views or serializers, regenerate both files:
`pnpm lint`, `pnpm test` and `pnpm build`. A third job builds the Docker image and smoke-tests the
running container (SPA route, admin static, API 401, admin login). After changing views or serializers, regenerate both files:
`python manage.py spectacular --file schema.yml` (from `backend/`) and
`pnpm exec openapi-typescript ../backend/schema.yml -o src/types/api.ts` (from `frontend/`).
Python lint config is `.flake8` (max line length 120).

## Deployment

See `docs/DEPLOY.md`. The image builds the frontend with `VITE_API_URL=/api` and copies it
to `backend/frontend_dist/`; Django serves `index.html` for every path outside `/api/`,
`/admin/` and `/static/` (`apps/common/views.py#frontend_index`, last route in
`config/urls.py`), and WhiteNoise serves the assets. `docker-entrypoint.sh` runs `migrate`
and `ensure_superuser` (idempotent) on every start. `docker compose up --build` runs it
locally against Postgres.

## Conventions

- Frontend source is **tab-indented**; match the surrounding file.
Expand Down Expand Up @@ -111,4 +123,4 @@ Python lint config is `.flake8` (max line length 120).
## Known issues

- The backend requires **Python ≥ 3.12** (Django 6.0). Tests need `SECRET_KEY` and
`DATABASE_URL` set (e.g. `DATABASE_URL=sqlite:///:memory:`); the full suite is 69 tests.
`DATABASE_URL` set (e.g. `DATABASE_URL=sqlite:///:memory:`); the full suite is 80 tests.
61 changes: 61 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# syntax=docker/dockerfile:1

# One image serves everything: the Django API, the admin, and the built
# React app (same origin, so the frontend calls /api directly).

# ------------------------------------------------------------------------------
# Stage 1: build the frontend
# ------------------------------------------------------------------------------
FROM node:22-slim AS frontend

WORKDIR /app/frontend

RUN npm install --global pnpm@10.33.0

COPY frontend/package.json frontend/pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile

COPY frontend/ ./

# Baked in at build time: the API is served from the same origin
ENV VITE_API_URL=/api
RUN pnpm build

# ------------------------------------------------------------------------------
# Stage 2: runtime
# ------------------------------------------------------------------------------
FROM python:3.12-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
DJANGO_ENV=production \
PORT=8000

WORKDIR /app

COPY backend/requirements.txt ./
RUN pip install -r requirements.txt

COPY backend/ ./
COPY --from=frontend /app/frontend/dist ./frontend_dist

# Pre-compress the frontend (gzip) so WhiteNoise serves smaller files
RUN python -m whitenoise.compress frontend_dist

# Collect admin/DRF static files. The values below exist only so production
# settings load during the build; real ones come from the host at runtime.
RUN SECRET_KEY=build-only \
DATABASE_URL=sqlite:///:memory: \
ALLOWED_HOSTS=build \
CORS_ALLOWED_ORIGINS=https://build \
CSRF_TRUSTED_ORIGINS=https://build \
python manage.py collectstatic --noinput

RUN useradd --create-home --uid 10001 app
USER app

EXPOSE 8000

CMD ["./docker-entrypoint.sh"]
20 changes: 18 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,10 @@ A lead may also transition to **LOST** from any intermediate stage where permitt

### Prerequisites

- Python 3.11+
- Node.js 20+ and pnpm
- Python 3.12+ (Django 6.0)
- Node.js 22 and pnpm 10 (the versions CI uses)
- PostgreSQL (or set `DATABASE_URL` to a hosted instance)
- Or just Docker: `docker compose up --build` runs the production image locally (see [Deployment](#deployment))

### Environment

Expand All @@ -128,6 +129,8 @@ cp .env.example .env
| `CSRF_TRUSTED_ORIGINS` | Production only, required: comma-separated trusted origins for CSRF |
| `HSTS_SECONDS` | Production only: HSTS max-age in seconds (default `3600`); raise in steps once HTTPS is stable |
| `HSTS_INCLUDE_SUBDOMAINS` / `HSTS_PRELOAD` | Production only: default `False`; enable only if every subdomain serves HTTPS |
| `LOG_LEVEL` | Production only: minimum level written to the console log (default `INFO`) |
| `DJANGO_SUPERUSER_EMAIL` / `DJANGO_SUPERUSER_PASSWORD` | Optional: create the first admin account on start, once (see `docs/DEPLOY.md`) |
| `DATABASE_URL` | Database connection URL, e.g. `postgres://user:pass@localhost:5432/db` |
| `VITE_API_URL` | Base URL of the API, e.g. `http://127.0.0.1:8000/api` (read by Vite from this same root `.env`) |

Expand Down Expand Up @@ -183,6 +186,19 @@ Application screenshots are in [`screenshots/`](screenshots/).

---

## Deployment

A single Docker image serves the API, the admin and the built frontend from one
origin. [`docs/DEPLOY.md`](docs/DEPLOY.md) walks through a free demo deployment
on Render (app) and Neon (Postgres), using the `render.yaml` blueprint, plus the
full list of production environment variables.

```bash
docker compose up --build # production image + Postgres at http://localhost:8000
```

---

## Documentation

Additional design notes live in [`docs/`](docs/), including `ARCHITECTURE.md` and a set of assessment, migration, refactor, and standards write-ups.
Expand Down
Empty file.
Empty file.
49 changes: 49 additions & 0 deletions backend/apps/accounts/management/commands/ensure_superuser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# ==============================================================================
# Create the first admin account from environment variables
# ==============================================================================

import os
from typing import Any

from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand, CommandError

REQUIRED = ("DJANGO_SUPERUSER_EMAIL", "DJANGO_SUPERUSER_PASSWORD")


class Command(BaseCommand):
"""Create an admin from DJANGO_SUPERUSER_* variables if it doesn't exist.

Runs on every container start (hosts without a shell can't run
createsuperuser). An existing account is left untouched, so a changed
password is never overwritten by the environment.
"""

help = "Create the admin account from DJANGO_SUPERUSER_* environment variables, once."

def handle(self, *args: Any, **options: Any) -> None:
email = os.environ.get("DJANGO_SUPERUSER_EMAIL", "").strip()
password = os.environ.get("DJANGO_SUPERUSER_PASSWORD", "")

if not email and not password:
self.stdout.write("ensure_superuser: DJANGO_SUPERUSER_EMAIL not set, skipping.")
return

missing = [name for name in REQUIRED if not os.environ.get(name, "").strip()]
if missing:
raise CommandError(f"ensure_superuser: set {' and '.join(missing)}.")

User = get_user_model()

if User.objects.filter(email__iexact=email).exists():
self.stdout.write(f"ensure_superuser: {email} already exists, leaving it unchanged.")
return

User.objects.create_superuser(
email=email,
password=password,
first_name=os.environ.get("DJANGO_SUPERUSER_FIRST_NAME", "Admin"),
last_name=os.environ.get("DJANGO_SUPERUSER_LAST_NAME", "User"),
)

self.stdout.write(self.style.SUCCESS(f"ensure_superuser: created admin {email}."))
87 changes: 87 additions & 0 deletions backend/apps/accounts/tests/test_ensure_superuser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# ==============================================================================
# ensure_superuser Command Tests
# ==============================================================================

import os
from io import StringIO
from unittest import mock

from apps.accounts.choices import UserRole
from django.contrib.auth import get_user_model
from django.core.management import call_command
from django.core.management.base import CommandError
from django.test import TestCase

User = get_user_model()

ENV = {
"DJANGO_SUPERUSER_EMAIL": "admin@example.com",
"DJANGO_SUPERUSER_PASSWORD": "a-strong-demo-password",
"DJANGO_SUPERUSER_FIRST_NAME": "Ada",
"DJANGO_SUPERUSER_LAST_NAME": "Admin",
}


def run(env: dict[str, str]) -> str:
"""Run the command with the given DJANGO_SUPERUSER_* variables added."""

out = StringIO()

with mock.patch.dict(os.environ, env):
call_command("ensure_superuser", stdout=out)

return out.getvalue()


class EnsureSuperuserTests(TestCase):
"""Verify the admin bootstrap is safe to run on every start."""

def setUp(self) -> None:
# Start each test without any DJANGO_SUPERUSER_* variables
patcher = mock.patch.dict(os.environ)
patcher.start()
self.addCleanup(patcher.stop)

for name in ENV:
os.environ.pop(name, None)

def test_skips_when_not_configured(self) -> None:
"""Ensure nothing happens without the variables."""

output = run({})

self.assertIn("skipping", output)
self.assertFalse(User.objects.exists())

def test_creates_admin_once(self) -> None:
"""Ensure the account is created with the Admin role and a working password."""

output = run(ENV)

user = User.objects.get(email="admin@example.com")
self.assertIn("created admin", output)
self.assertTrue(user.is_superuser)
self.assertEqual(user.role, UserRole.ADMIN)
self.assertEqual((user.first_name, user.last_name), ("Ada", "Admin"))
self.assertTrue(user.check_password("a-strong-demo-password"))

def test_rerun_leaves_existing_account_unchanged(self) -> None:
"""Ensure a restart never resets a password changed after creation."""

run(ENV)
user = User.objects.get(email="admin@example.com")
user.set_password("changed-later")
user.save()

output = run({**ENV, "DJANGO_SUPERUSER_EMAIL": "ADMIN@example.com"})

user.refresh_from_db()
self.assertIn("already exists", output)
self.assertEqual(User.objects.count(), 1)
self.assertTrue(user.check_password("changed-later"))

def test_half_configured_is_an_error(self) -> None:
"""Ensure an email without a password fails loudly instead of skipping."""

with self.assertRaisesMessage(CommandError, "DJANGO_SUPERUSER_PASSWORD"):
run({"DJANGO_SUPERUSER_EMAIL": "admin@example.com"})
Loading