From 5f7eb169b8c97a1e6adb56d925bd7af26c2bf7c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 15:27:20 +0000 Subject: [PATCH 1/5] fix(settings): log production errors to the console Django's default logging only writes to the console when DEBUG is on and otherwise emails ADMINS, which aren't configured, so in production every 500 traceback was silently dropped. Add a LOGGING config for production: everything goes to stderr (which container platforms collect) with a timestamp, level and logger name; django.request errors always get through; the level for everything else defaults to INFO and is set with LOG_LEVEL. Tests run the production settings in a subprocess and check that a request error's traceback and app INFO logs reach stderr, and that LOG_LEVEL filters (both fail against the previous settings). Test count now 71. Document LOG_LEVEL in the README. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018oMHDqDASywmwc7ncmxtCY --- CHECKLIST.md | 4 +- CLAUDE.md | 2 +- README.md | 1 + .../common/tests/test_logging_settings.py | 75 +++++++++++++++++++ backend/config/settings/production.py | 45 +++++++++++ 5 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 backend/apps/common/tests/test_logging_settings.py diff --git a/CHECKLIST.md b/CHECKLIST.md index 015a65f..04a5af6 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] 71 Automated Tests Passing --- @@ -178,7 +178,7 @@ - [x] Frontend Build (`pnpm build`) - [x] Frontend Lint (`pnpm lint`) - [x] Frontend Tests (68/68, `pnpm test`) -- [x] Backend Tests (69/69) +- [x] Backend Tests (71/71) - [x] Manual End-to-End Testing - [x] Swagger Verification diff --git a/CLAUDE.md b/CLAUDE.md index 3ed20eb..cc451fc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,4 +111,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 71 tests. diff --git a/README.md b/README.md index 342fff5..44ae3dc 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,7 @@ 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`) | | `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`) | 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/config/settings/production.py b/backend/config/settings/production.py index ab7679d..7b3f088 100644 --- a/backend/config/settings/production.py +++ b/backend/config/settings/production.py @@ -23,6 +23,51 @@ 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 # ============================================================================== From a131670550b67cfa1b479ceb058099c4e8622b2f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 15:38:06 +0000 Subject: [PATCH 2/5] feat: serve the built frontend from Django for single-origin deploys Prepares for running everything from one container: - frontend_index serves FRONTEND_DIST/index.html (no-cache) for every path outside /api/, /admin/ and /static/, so deep links like /leads/7 survive a reload; it 404s when no build is present (development) - production: WhiteNoise serves the build's files from the site root, with Vite's hashed /assets/ files marked immutable; compressed static storage - production: RENDER_EXTERNAL_HOSTNAME fills ALLOWED_HOSTS and the CORS/CSRF origin lists when they are unset (explicit values win), so a Render service starts without knowing its final URL in advance - SECURE_SSL_REDIRECT can be turned off to try the image over plain HTTP - the dashboard's "Open Swagger" link was hard-coded to http://127.0.0.1:8000/api/docs/; build it from VITE_API_URL Tests cover SPA routes, API/static paths not being swallowed, the missing build case, the Render fallback and explicit overrides, and the Swagger link. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018oMHDqDASywmwc7ncmxtCY --- .gitignore | 5 +- .../apps/common/tests/test_frontend_view.py | 47 +++++++++++++++++++ .../common/tests/test_production_settings.py | 35 ++++++++++++++ backend/apps/common/views.py | 29 ++++++++++++ backend/config/settings/base.py | 3 ++ backend/config/settings/production.py | 44 ++++++++++++++++- backend/config/urls.py | 12 ++++- .../pages/dashboard/DashboardPage.test.tsx | 17 +++++++ .../src/pages/dashboard/DashboardPage.tsx | 2 +- 9 files changed, 190 insertions(+), 4 deletions(-) create mode 100644 backend/apps/common/tests/test_frontend_view.py create mode 100644 frontend/src/pages/dashboard/DashboardPage.test.tsx 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/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_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 7b3f088..c1b2b88 100644 --- a/backend/config/settings/production.py +++ b/backend/config/settings/production.py @@ -17,7 +17,12 @@ 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 @@ -98,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", @@ -119,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/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() {

Date: Sat, 26 Sep 2026 15:38:06 +0000 Subject: [PATCH 3/5] feat(accounts): add ensure_superuser for hosts without a shell Free hosting plans (e.g. Render's) have no shell, so createsuperuser can't be run by hand. `manage.py ensure_superuser` creates the admin from DJANGO_SUPERUSER_EMAIL / _PASSWORD (+ optional _FIRST_NAME / _LAST_NAME) and is safe to run on every start: it skips when unset, leaves an existing account (matched case-insensitively) untouched so a changed password is never reset, and fails loudly when only half configured. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018oMHDqDASywmwc7ncmxtCY --- backend/apps/accounts/management/__init__.py | 0 .../accounts/management/commands/__init__.py | 0 .../management/commands/ensure_superuser.py | 49 +++++++++++ .../accounts/tests/test_ensure_superuser.py | 87 +++++++++++++++++++ 4 files changed, 136 insertions(+) create mode 100644 backend/apps/accounts/management/__init__.py create mode 100644 backend/apps/accounts/management/commands/__init__.py create mode 100644 backend/apps/accounts/management/commands/ensure_superuser.py create mode 100644 backend/apps/accounts/tests/test_ensure_superuser.py 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"}) From 3a30a21da31734078d0fa6f63f014ff1bb6ca41a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 15:38:06 +0000 Subject: [PATCH 4/5] feat: Docker image and free demo deployment (Render + Neon) - Dockerfile: Node stage builds the frontend with VITE_API_URL=/api; Python 3.12 stage installs the backend, copies and gzips the build, runs collectstatic and runs as a non-root user - docker-entrypoint.sh: migrate, ensure_superuser, then gunicorn on $PORT (2 workers for a 512 MB instance; logs to stdout/stderr) - docker-compose.yml: the production image against local Postgres - render.yaml: free Docker web service in Singapore, SECRET_KEY generated, DATABASE_URL and admin credentials entered in the dashboard. The database is Neon's free Postgres, because Render's free Postgres is deleted after 30 days - docs/DEPLOY.md: step-by-step Neon + Render setup, free-tier limits, operating and tearing down the demo, env var reference - README: correct prerequisites (Python 3.12+), deployment section; .env.example, CLAUDE.md and CHECKLIST.md updated (80 backend tests, 69 frontend) Verified outside Docker with the same layout: frontend built with VITE_API_URL=/api, collectstatic with production settings, and the real entrypoint against Postgres 17 (migrations, admin created once, second start leaves it unchanged); SPA routes, assets (gzip, immutable cache), admin static, API and a full browser flow all work. The image itself could not be built in this sandbox (its proxy breaks TLS inside builds). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018oMHDqDASywmwc7ncmxtCY --- .dockerignore | 16 +++++ .env.example | 8 +++ CHECKLIST.md | 6 +- CLAUDE.md | 15 ++++- Dockerfile | 61 ++++++++++++++++++ README.md | 19 +++++- backend/docker-entrypoint.sh | 16 +++++ docker-compose.yml | 38 ++++++++++++ docs/DEPLOY.md | 117 +++++++++++++++++++++++++++++++++++ render.yaml | 30 +++++++++ 10 files changed, 319 insertions(+), 7 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100755 backend/docker-entrypoint.sh create mode 100644 docker-compose.yml create mode 100644 docs/DEPLOY.md create mode 100644 render.yaml 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/CHECKLIST.md b/CHECKLIST.md index 04a5af6..b7369bb 100644 --- a/CHECKLIST.md +++ b/CHECKLIST.md @@ -77,7 +77,7 @@ - [x] Consistent Docstring - [x] Import Cleanup - [x] Query Optimization -- [x] 71 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 (71/71) +- [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 cc451fc..41ab1c5 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 ``` @@ -77,6 +79,15 @@ a stale-types check (`frontend/src/types/api.ts` must match `openapi-typescript` `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 +122,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 71 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 44ae3dc..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 @@ -129,6 +130,7 @@ cp .env.example .env | `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`) | @@ -184,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/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/render.yaml b/render.yaml new file mode 100644 index 0000000..101ec01 --- /dev/null +++ b/render.yaml @@ -0,0 +1,30 @@ +# Render Blueprint: one free Docker web service serving the API, admin and +# frontend. The database is external (Neon free Postgres); Render's own free +# Postgres is deleted 30 days after creation. See docs/DEPLOY.md. +services: + - type: web + name: lead-platform + runtime: docker + plan: free + # Fixed at creation; pick the region closest to users and to the database + region: singapore + branch: main + autoDeploy: true + dockerfilePath: ./Dockerfile + dockerContext: . + # index.html is served without touching the database + healthCheckPath: / + envVars: + # Random 256-bit value generated by Render on first deploy + - key: SECRET_KEY + generateValue: true + # Neon connection string, entered in the dashboard (never committed) + - key: DATABASE_URL + sync: false + # First admin account, created once on start (see ensure_superuser) + - key: DJANGO_SUPERUSER_EMAIL + sync: false + - key: DJANGO_SUPERUSER_PASSWORD + sync: false + # ALLOWED_HOSTS / CORS_ALLOWED_ORIGINS / CSRF_TRUSTED_ORIGINS default to + # the service's onrender.com host; set them only for a custom domain. From 5f1a88fae62971801caabc2e5ec81ef3edc06ba1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 15:38:35 +0000 Subject: [PATCH 5/5] ci: build the Docker image and smoke-test the container The image couldn't be built in the development sandbox, so let CI prove it: build it, start it with SQLite standing in for Postgres, and check that it migrates, creates the admin, serves an SPA deep link and admin static files, answers the API with 401 when logged out, and accepts the admin's login. Container logs are printed even on failure. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018oMHDqDASywmwc7ncmxtCY --- .github/workflows/ci.yml | 35 +++++++++++++++++++++++++++++++++++ CLAUDE.md | 3 ++- 2 files changed, 37 insertions(+), 1 deletion(-) 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/CLAUDE.md b/CLAUDE.md index 41ab1c5..5bf0801 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,7 +74,8 @@ 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).