diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..92497af --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example index f9bc157..ad58af7 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c77fa3..7fc280d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index 9c5a04f..e6efd2c 100644 --- a/.gitignore +++ b/.gitignore @@ -159,4 +159,7 @@ docker-compose.override.yml # ============================================================================== # Misc # ============================================================================== -*.orig \ No newline at end of file +*.orig + +# Frontend build copied into the backend (Docker / local production test) +backend/frontend_dist/ diff --git a/CHECKLIST.md b/CHECKLIST.md index 015a65f..b7369bb 100644 --- a/CHECKLIST.md +++ b/CHECKLIST.md @@ -77,7 +77,7 @@ - [x] Consistent Docstring - [x] Import Cleanup - [x] Query Optimization -- [x] 69 Automated Tests Passing +- [x] 80 Automated Tests Passing --- @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 3ed20eb..5bf0801 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ``` @@ -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. @@ -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. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5a16e9f --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 342fff5..cbfbfcf 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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`) | @@ -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. diff --git a/backend/apps/accounts/management/__init__.py b/backend/apps/accounts/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/accounts/management/commands/__init__.py b/backend/apps/accounts/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/accounts/management/commands/ensure_superuser.py b/backend/apps/accounts/management/commands/ensure_superuser.py new file mode 100644 index 0000000..c468eb9 --- /dev/null +++ b/backend/apps/accounts/management/commands/ensure_superuser.py @@ -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}.")) diff --git a/backend/apps/accounts/tests/test_ensure_superuser.py b/backend/apps/accounts/tests/test_ensure_superuser.py new file mode 100644 index 0000000..2188114 --- /dev/null +++ b/backend/apps/accounts/tests/test_ensure_superuser.py @@ -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"}) diff --git a/backend/apps/common/tests/test_frontend_view.py b/backend/apps/common/tests/test_frontend_view.py new file mode 100644 index 0000000..ead02ca --- /dev/null +++ b/backend/apps/common/tests/test_frontend_view.py @@ -0,0 +1,47 @@ +# ============================================================================== +# Frontend (SPA) Route Tests +# ============================================================================== + +import tempfile +from pathlib import Path + +from django.test import TestCase, override_settings + + +class FrontendRouteTests(TestCase): + """Verify client-side routes get index.html without shadowing the API.""" + + def setUp(self) -> None: + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.dist = Path(tmp.name) + (self.dist / "index.html").write_text("
") + + def test_client_routes_get_index_html(self) -> None: + """Ensure deep links like /leads/7 survive a reload.""" + + with override_settings(FRONTEND_DIST=self.dist): + for path in ["/", "/login", "/leads/7", "/leads?page=2"]: + response = self.client.get(path) + + self.assertEqual(response.status_code, 200, path) + self.assertIn(b"
", b"".join(response.streaming_content)) + self.assertEqual(response["Cache-Control"], "no-cache") + + def test_api_paths_are_not_swallowed(self) -> None: + """Ensure unknown API and admin URLs 404 instead of returning the app.""" + + with override_settings(FRONTEND_DIST=self.dist): + for path in ["/api/does-not-exist/", "/static/missing.css"]: + response = self.client.get(path) + + self.assertEqual(response.status_code, 404, path) + self.assertFalse(response.streaming, path) + + def test_missing_build_is_a_404(self) -> None: + """Ensure development (no build) doesn't crash on non-API paths.""" + + with override_settings(FRONTEND_DIST=self.dist / "missing"): + response = self.client.get("/leads/7") + + self.assertEqual(response.status_code, 404) diff --git a/backend/apps/common/tests/test_logging_settings.py b/backend/apps/common/tests/test_logging_settings.py new file mode 100644 index 0000000..99bc983 --- /dev/null +++ b/backend/apps/common/tests/test_logging_settings.py @@ -0,0 +1,75 @@ +# ============================================================================== +# Production Logging Tests +# ============================================================================== + +import os +import subprocess +import sys +from pathlib import Path + +from django.test import SimpleTestCase + +BACKEND_DIR = Path(__file__).resolve().parents[3] + +EMIT_LOGS = """ +import logging +import django + +django.setup() + +try: + raise ValueError("boom") +except ValueError: + logging.getLogger("django.request").exception("Internal Server Error: /api/leads/") + +logging.getLogger("apps.leads").info("lead created") +logging.getLogger("apps.leads").debug("noisy detail") +""" + + +def run_production(**overrides: str) -> subprocess.CompletedProcess[str]: + """Emit sample logs from a fresh interpreter using the production settings.""" + + env = { + **os.environ, + "DJANGO_SETTINGS_MODULE": "config.settings", + "DJANGO_ENV": "production", + "DEBUG": "False", + "SECRET_KEY": "test-only-secret-key-for-logging-settings-checks", + "DATABASE_URL": "sqlite:///:memory:", + "ALLOWED_HOSTS": "api.example.com", + "CORS_ALLOWED_ORIGINS": "https://app.example.com", + "CSRF_TRUSTED_ORIGINS": "https://app.example.com", + **overrides, + } + + return subprocess.run( + [sys.executable, "-c", EMIT_LOGS], + cwd=BACKEND_DIR, + env=env, + capture_output=True, + text=True, + ) + + +class ProductionLoggingTests(SimpleTestCase): + """Verify production writes errors and app logs to the console.""" + + def test_request_errors_are_logged_with_traceback(self) -> None: + """Ensure a 500's traceback reaches the container log.""" + + result = run_production() + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("ERROR django.request: Internal Server Error: /api/leads/", result.stderr) + self.assertIn("ValueError: boom", result.stderr) + + def test_app_info_logs_respect_log_level(self) -> None: + """Ensure app logs appear at INFO by default and LOG_LEVEL can raise the bar.""" + + default = run_production() + quiet = run_production(LOG_LEVEL="WARNING") + + self.assertIn("INFO apps.leads: lead created", default.stderr) + self.assertNotIn("noisy detail", default.stderr) + self.assertNotIn("lead created", quiet.stderr) diff --git a/backend/apps/common/tests/test_production_settings.py b/backend/apps/common/tests/test_production_settings.py index 03061a0..00c3043 100644 --- a/backend/apps/common/tests/test_production_settings.py +++ b/backend/apps/common/tests/test_production_settings.py @@ -89,3 +89,38 @@ def test_environment_overrides(self) -> None: self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.stdout.split(), ["31536000", "True", "True"]) + + +class RenderHostnameTests(SimpleTestCase): + """Verify Render's public hostname fills in unset host/origin lists.""" + + PRINT_LISTS = "print(s.ALLOWED_HOSTS, s.CORS_ALLOWED_ORIGINS, s.CSRF_TRUSTED_ORIGINS, sep='|')" + + def test_fills_unset_lists_from_render_hostname(self) -> None: + """Ensure a Render deploy starts with no host/origin variables set.""" + + result = load_production_settings( + self.PRINT_LISTS, + RENDER_EXTERNAL_HOSTNAME="leads-demo.onrender.com", + ALLOWED_HOSTS="", + CORS_ALLOWED_ORIGINS="", + CSRF_TRUSTED_ORIGINS="", + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + result.stdout.strip(), + "['leads-demo.onrender.com']|['https://leads-demo.onrender.com']|['https://leads-demo.onrender.com']", + ) + + def test_explicit_values_win(self) -> None: + """Ensure a custom domain configured explicitly isn't overridden.""" + + result = load_production_settings( + self.PRINT_LISTS, + RENDER_EXTERNAL_HOSTNAME="leads-demo.onrender.com", + ALLOWED_HOSTS="leads.example.com", + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue(result.stdout.startswith("['leads.example.com']|")) diff --git a/backend/apps/common/views.py b/backend/apps/common/views.py index f8ade1f..fb32fb0 100644 --- a/backend/apps/common/views.py +++ b/backend/apps/common/views.py @@ -1,3 +1,32 @@ # ============================================================================== # Views for the Common app # ============================================================================== + +from django.conf import settings +from django.http import FileResponse, Http404, HttpRequest + +# ============================================================================== +# Frontend (single-page app) +# ============================================================================== + + +def frontend_index(request: HttpRequest) -> FileResponse: + """Serve the built frontend's index.html so client-side routes survive a reload. + + In the Docker image the built frontend lives in FRONTEND_DIST; WhiteNoise + serves its asset files, and every other non-API path gets index.html so + React Router can render it. In development the directory doesn't exist + and the Vite dev server serves the frontend instead. + """ + + index = settings.FRONTEND_DIST / "index.html" + + if not index.is_file(): + raise Http404("Frontend build not found.") + + response = FileResponse(index.open("rb"), content_type="text/html") + + # Always revalidate: index.html points at the current hashed asset files + response["Cache-Control"] = "no-cache" + + return response diff --git a/backend/config/settings/base.py b/backend/config/settings/base.py index 2f48151..c2e2b58 100644 --- a/backend/config/settings/base.py +++ b/backend/config/settings/base.py @@ -136,6 +136,9 @@ def get_list(name: str) -> list[str]: if static_dir.exists(): STATICFILES_DIRS.append(static_dir) +# Built frontend copied into the Docker image; absent in development +FRONTEND_DIST = BASE_DIR / "frontend_dist" + MEDIA_URL = "media/" MEDIA_ROOT = BASE_DIR / "media" diff --git a/backend/config/settings/production.py b/backend/config/settings/production.py index ab7679d..c1b2b88 100644 --- a/backend/config/settings/production.py +++ b/backend/config/settings/production.py @@ -17,12 +17,62 @@ cast=bool, ) -SECURE_SSL_REDIRECT = True +# Off only to try the production image locally over plain HTTP +SECURE_SSL_REDIRECT = config( + "SECURE_SSL_REDIRECT", + default=True, + cast=bool, +) SESSION_COOKIE_SECURE = True CSRF_COOKIE_SECURE = True +# ============================================================================== +# Logging +# ============================================================================== + +# Django's defaults only log to the console when DEBUG is on and otherwise +# email ADMINS (none are set), so production errors, including 500 +# tracebacks, were silently dropped. Write everything to stderr, which +# container platforms collect. +LOG_LEVEL = config("LOG_LEVEL", default="INFO").upper() + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "plain": { + "format": "{asctime} {levelname} {name}: {message}", + "style": "{", + }, + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "plain", + }, + }, + "root": { + "handlers": ["console"], + "level": LOG_LEVEL, + }, + "loggers": { + # Replace Django's handlers (debug-only console, mail_admins) with the + # root console handler; request errors always get through. + "django": { + "handlers": [], + "level": "INFO", + "propagate": True, + }, + "django.request": { + "handlers": [], + "level": "ERROR", + "propagate": True, + }, + }, +} + # ============================================================================== # HTTP Strict Transport Security # ============================================================================== @@ -53,6 +103,16 @@ CSRF_TRUSTED_ORIGINS = get_list("CSRF_TRUSTED_ORIGINS") +# Render sets RENDER_EXTERNAL_HOSTNAME to the service's public host. With the +# frontend served from the same origin, that host is all three lists need, so +# use it for any list left unset. Explicit values always win. +RENDER_EXTERNAL_HOSTNAME = config("RENDER_EXTERNAL_HOSTNAME", default="") + +if RENDER_EXTERNAL_HOSTNAME: + ALLOWED_HOSTS = ALLOWED_HOSTS or [RENDER_EXTERNAL_HOSTNAME] + CORS_ALLOWED_ORIGINS = CORS_ALLOWED_ORIGINS or [f"https://{RENDER_EXTERNAL_HOSTNAME}"] + CSRF_TRUSTED_ORIGINS = CSRF_TRUSTED_ORIGINS or [f"https://{RENDER_EXTERNAL_HOSTNAME}"] + SECURE_PROXY_SSL_HEADER = ( "HTTP_X_FORWARDED_PROTO", "https", @@ -74,3 +134,30 @@ 2, "whitenoise.middleware.WhiteNoiseMiddleware", ) + +# ============================================================================== +# Static Files +# ============================================================================== + +# Compressed copies are written by collectstatic at image build time +STORAGES = { + "default": { + "BACKEND": "django.core.files.storage.FileSystemStorage", + }, + "staticfiles": { + "BACKEND": "whitenoise.storage.CompressedStaticFilesStorage", + }, +} + +# Serve the built frontend's files (assets/, favicon.svg) from the site root +if FRONTEND_DIST.is_dir(): + WHITENOISE_ROOT = FRONTEND_DIST + + +def _is_hashed_asset(path: str, url: str) -> bool: + """Vite's assets/ files have content hashes in their names, so they can be cached forever.""" + + return url.startswith("/assets/") + + +WHITENOISE_IMMUTABLE_FILE_TEST = _is_hashed_asset diff --git a/backend/config/urls.py b/backend/config/urls.py index cf8c9c7..511f0ce 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -3,9 +3,11 @@ # ============================================================================== from django.contrib import admin -from django.urls import include, path +from django.urls import include, path, re_path from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView +from apps.common.views import frontend_index + urlpatterns = [ path("admin/", admin.site.urls), path( @@ -34,4 +36,12 @@ SpectacularRedocView.as_view(url_name="schema"), name="redoc", ), + # -------------------------------------------------------------------------- + # Frontend: every other path is a client-side route (must stay last) + # -------------------------------------------------------------------------- + re_path( + r"^(?!api/|admin/|static/).*$", + frontend_index, + name="frontend", + ), ] diff --git a/backend/docker-entrypoint.sh b/backend/docker-entrypoint.sh new file mode 100755 index 0000000..d9d87d8 --- /dev/null +++ b/backend/docker-entrypoint.sh @@ -0,0 +1,16 @@ +#!/bin/sh +# Container start: apply migrations, create the first admin if configured, +# then serve. Hosts without a pre-deploy hook (e.g. Render's free plan) rely +# on this running migrations on every start; both steps are idempotent. +set -e + +python manage.py migrate --noinput +python manage.py ensure_superuser + +# 2 workers fit comfortably in a 512 MB free instance +exec gunicorn config.wsgi:application \ + --bind "0.0.0.0:${PORT}" \ + --workers "${WEB_CONCURRENCY:-2}" \ + --timeout 60 \ + --access-logfile - \ + --error-logfile - diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b279c0d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,38 @@ +# Run the production image locally against Postgres: +# docker compose up --build -> http://localhost:8000 +# Uses demo credentials; never reuse these values in a real deployment. +services: + db: + image: postgres:17-alpine + environment: + POSTGRES_DB: leads + POSTGRES_USER: leads + POSTGRES_PASSWORD: leads + healthcheck: + test: ["CMD-SHELL", "pg_isready -U leads -d leads"] + interval: 2s + retries: 15 + volumes: + - pgdata:/var/lib/postgresql/data + + web: + build: . + depends_on: + db: + condition: service_healthy + ports: + - "8000:8000" + environment: + SECRET_KEY: local-compose-only-not-secret-change-me-for-any-real-use + DATABASE_URL: postgres://leads:leads@db:5432/leads + ALLOWED_HOSTS: localhost,127.0.0.1 + CORS_ALLOWED_ORIGINS: http://localhost:8000 + CSRF_TRUSTED_ORIGINS: http://localhost:8000 + # Plain HTTP locally; the real host terminates TLS + SECURE_SSL_REDIRECT: "False" + HSTS_SECONDS: "0" + DJANGO_SUPERUSER_EMAIL: admin@example.com + DJANGO_SUPERUSER_PASSWORD: admin-demo-password + +volumes: + pgdata: diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md new file mode 100644 index 0000000..5d2fc1c --- /dev/null +++ b/docs/DEPLOY.md @@ -0,0 +1,117 @@ +# Deployment (free demo hosting) + +One Docker image serves the whole app: the Django API, the Django admin, and the +built React frontend (same origin, so no CORS setup). It runs on **Render's free +web service**, with the database on **Neon's free Postgres**. Neither needs a +credit card, and neither expires, so the demo can stay up for months. + +| Piece | Where | Free-tier limits that matter | +| --- | --- | --- | +| App (Docker) | [Render](https://render.com/docs/free) web service | Sleeps after 15 min without traffic; the next visit takes about a minute to wake it. 750 instance-hours/month, enough for one service running all month. | +| Database | [Neon](https://neon.com/docs/introduction/plans) Postgres | 0.5 GB storage, 100 compute-hours/month. Scales to zero when idle and wakes in about a second. | + +Render also offers free Postgres, but it is **deleted 30 days after creation**, +which is why the database lives on Neon. + +--- + +## 1. Create the database (Neon) + +1. Sign up at [neon.com](https://neon.com) (GitHub login works; no card). +2. Create a project: pick **Postgres 17** and the region closest to your users. + The blueprint puts the app in **Singapore**, so choose *AWS Asia Pacific + (Singapore)* to keep the two close. +3. Open **Connect**, turn **Connection pooling off**, and copy the connection + string. It looks like: + + ``` + postgresql://USER:PASSWORD@ep-xxxx.ap-southeast-1.aws.neon.tech/neondb?sslmode=require + ``` + + Use the direct (non-pooled) string: Django keeps its own connections open + and doesn't work well behind the pooler's transaction mode. + +## 2. Deploy the app (Render) + +1. Sign up at [render.com](https://render.com) with GitHub (no card) and give + Render access to this repository. +2. **New → Blueprint**, select the repository. Render reads `render.yaml` and + asks for the values marked `sync: false`: + + | Variable | Value | + | --- | --- | + | `DATABASE_URL` | The Neon connection string from step 1 | + | `DJANGO_SUPERUSER_EMAIL` | The email you'll log in with | + | `DJANGO_SUPERUSER_PASSWORD` | A strong password (it is not length-checked, so choose a long one) | + + `SECRET_KEY` is generated by Render. The host and origin settings default to + the service's `*.onrender.com` address. +3. Click **Apply**. The first build takes a few minutes. On start the container + runs migrations, creates the admin account once, then starts the server. +4. Open the service URL (shown at the top of the service page) and log in. + +## 3. After the first deploy + +- **Team members**: log in at `/admin/` with the admin account and add users. + New users get the *Member* role; set *Admin* where needed. There is no + self sign-up. +- **Changing the admin password**: do it in `/admin/`. Restarts never reset it; + `DJANGO_SUPERUSER_*` is only used to create the account the first time. +- **Updates**: every push to `main` redeploys automatically. Branch protection + only lets CI-passing PRs merge into `main`. +- **Logs**: Render's *Logs* tab shows requests and any errors with tracebacks + (`LOG_LEVEL` controls verbosity, default `INFO`). + +## Ending the demo + +Delete the service in Render and the project in Neon. Nothing is billed and no +card is on file, so there is nothing else to cancel. + +--- + +## Running the production image locally + +```bash +docker compose up --build # then open http://localhost:8000 +``` + +This builds the same image and runs it against a local Postgres, with demo +credentials from `docker-compose.yml` (admin: `admin@example.com` / +`admin-demo-password`). It serves plain HTTP, so HTTPS redirects and HSTS are +switched off there. The React app works fully; the Django admin login does not +work over plain HTTP, because its cookies are marked HTTPS-only. + +## Environment variables + +Required in production (startup fails with a message naming the missing one): + +| Variable | Notes | +| --- | --- | +| `SECRET_KEY` | Long random value | +| `DATABASE_URL` | Postgres connection URL | +| `ALLOWED_HOSTS`, `CORS_ALLOWED_ORIGINS`, `CSRF_TRUSTED_ORIGINS` | Comma-separated. On Render they default to the service's own host; set them for a custom domain. | + +Optional: + +| Variable | Default | Notes | +| --- | --- | --- | +| `DJANGO_SUPERUSER_EMAIL` / `_PASSWORD` | unset | Creates the first admin once. `_FIRST_NAME` / `_LAST_NAME` are optional. | +| `LOG_LEVEL` | `INFO` | Console log level | +| `HSTS_SECONDS` | `3600` | See the README before raising it | +| `WEB_CONCURRENCY` | `2` | Gunicorn workers; 2 fits a 512 MB instance | +| `SECURE_SSL_REDIRECT` | `True` | Only turn off to try the image over plain HTTP | + +## How the image is built + +`Dockerfile` has two stages: + +1. **Node**: installs the frontend with pnpm and builds it with + `VITE_API_URL=/api`, since the API is served from the same origin. +2. **Python**: installs the backend, copies in the built frontend and + pre-compresses it, runs `collectstatic`, and runs as a non-root user. + `docker-entrypoint.sh` applies migrations, runs `ensure_superuser`, then + starts gunicorn on `$PORT`. + +Django serves `index.html` for every path outside `/api/`, `/admin/` and +`/static/`, so deep links like `/leads/7` survive a reload. WhiteNoise serves +the hashed files under `/assets/` gzipped, with a one-year immutable cache. diff --git a/frontend/src/pages/dashboard/DashboardPage.test.tsx b/frontend/src/pages/dashboard/DashboardPage.test.tsx new file mode 100644 index 0000000..ab31314 --- /dev/null +++ b/frontend/src/pages/dashboard/DashboardPage.test.tsx @@ -0,0 +1,17 @@ +import { screen } from "@testing-library/react" +import { describe, expect, it } from "vitest" + +import { renderRoute } from "../../test/render" +import DashboardPage from "./DashboardPage" + +describe("DashboardPage", () => { + it("links to the API docs of the configured backend, not localhost", () => { + renderRoute(, { path: "/dashboard", url: "/dashboard" }) + + // Vitest pins VITE_API_URL to http://api.test/api + expect(screen.getByRole("link", { name: "Open Swagger" })).toHaveAttribute( + "href", + "http://api.test/api/docs/", + ) + }) +}) diff --git a/frontend/src/pages/dashboard/DashboardPage.tsx b/frontend/src/pages/dashboard/DashboardPage.tsx index 78f386a..084f39f 100644 --- a/frontend/src/pages/dashboard/DashboardPage.tsx +++ b/frontend/src/pages/dashboard/DashboardPage.tsx @@ -44,7 +44,7 @@ export default function DashboardPage() {