diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..47af605 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,116 @@ +# Production Release Plan — Upper-Cumberland CleanUp + +## Status: Pre-production on `main` branch + +--- + +## Phase 1 — Infrastructure (Blockers) + +These must be resolved before any real users hit the app. + +### 1.1 PostGIS Database +**Problem:** Render's `starter` database plan is plain PostgreSQL. GeoDjango requires PostGIS, or every spatial query and migration will fail at boot. + +**Fix options (pick one):** +- **Supabase** — free tier includes PostGIS. Get the connection string, set `DATABASE_URL` in Render env vars. +- **Neon** — free tier, enable PostGIS extension manually after creating the DB. +- **Render paid DB** — upgrade to a plan that allows extensions, then run `CREATE EXTENSION postgis;` via their console. + +**Action:** Provision a PostGIS-enabled DB, grab its connection string, and update `DATABASE_URL` in Render's environment variables dashboard. + +--- + +### 1.2 Media Storage (Cloudflare R2) +**Problem:** `render.yaml` sets `USE_S3_MEDIA=1` but the R2 credentials are all `sync: false` (unpopulated). Photo uploads will fail silently or 500 in production. + +**Action:** +1. Create a Cloudflare R2 bucket named `upper-cumberland-cleanup`. +2. Generate an R2 API token with Object Read & Write. +3. In Render's environment dashboard, fill in: + - `AWS_ACCESS_KEY_ID` — R2 Access Key ID + - `AWS_SECRET_ACCESS_KEY` — R2 Secret Access Key + - `AWS_STORAGE_BUCKET_NAME` — `upper-cumberland-cleanup` + - `AWS_S3_ENDPOINT_URL` — `https://.r2.cloudflarestorage.com` + - `AWS_S3_CUSTOM_DOMAIN` — your R2 public bucket domain (or leave blank to use the endpoint) + +--- + +### 1.3 Run the Test Suite +**Problem:** The test suite was completely rewritten for the new architecture but has never been executed against a real DB. + +**Action:** Start Docker Desktop and run: +```powershell +docker compose up -d db +docker compose run --rm web python manage.py test --verbosity=2 +``` +Fix any failures before deploying. + +--- + +## Phase 2 — Security & Config + +### 2.1 `ALLOWED_HOSTS` and `CSRF_TRUSTED_ORIGINS` +Current `render.yaml` only allows `upper-cumberland-cleanup.onrender.com`. If you add a custom domain, update both env vars in Render's dashboard — not just in the yaml. + +### 2.2 Create Superuser on First Deploy +Render doesn't have a one-time post-deploy hook. After first successful deploy: +```bash +# Via Render shell tab on the web service +python manage.py createsuperuser +``` + +### 2.3 Redis for Rate Limiting (Optional but Recommended) +Without Redis, `django-ratelimit` uses in-memory cache — limits won't be shared across Gunicorn workers. On Render you can add a Redis instance: +1. Add a Redis service in Render dashboard. +2. Set `REDIS_URL` env var to the internal Redis URL. + +--- + +## Phase 3 — Seeding Districts on Production + +The entrypoint auto-seeds Putnam County and District 3 from the GeoJSON files baked into the Docker image. This will run automatically on every deploy — it uses `update_or_create` so it's idempotent. + +**Verify after first deploy** via `/api/districts/` — should return both `putnam-county` and `district-3`. + +--- + +## Phase 4 — Custom Domain (Optional) + +1. Add domain in Render dashboard → Custom Domains. +2. Update DNS at your registrar (CNAME → `upper-cumberland-cleanup.onrender.com`). +3. Update Render env vars: + - `ALLOWED_HOSTS` → add your domain + - `CSRF_TRUSTED_ORIGINS` → add `https://yourdomain.com` + +--- + +## Phase 5 — Pre-Launch Checklist + +- [ ] PostGIS DB provisioned and `DATABASE_URL` set in Render +- [ ] R2 bucket created and all 5 media env vars filled in Render +- [ ] Test suite passes locally (`python manage.py test`) +- [ ] First deploy succeeds — `/healthz` returns `{"ok": true, "database": "up"}` +- [ ] `/api/districts/` returns both district boundaries +- [ ] Superuser created via shell +- [ ] Photo upload works end-to-end (report a site with a photo) +- [ ] Mark cleaned with before/after photos works +- [ ] `/cleanups/` page shows completed cleanups +- [ ] Redis added (optional — do if rate limiting matters at launch) +- [ ] Custom domain configured (optional) + +--- + +## Deployment Trigger + +Render auto-deploys on push to `main` (set in `render.yaml` `autoDeploy: true`). Merging any PR to `main` triggers a deploy. + +Current branch with all changes: `feat/district-3-pivot` (already merged to `main` per your note). + +--- + +## Known Gaps (Post-Launch) + +- **Password reset** — no email backend configured. Users cannot reset passwords yet. Needs `EMAIL_BACKEND` + SMTP or SendGrid env vars. +- **E2e Playwright tests** — `seedScreenshotDemo` management command was removed in the pivot. Smoke tests will fail in CI until updated. +- **Admin hardening** — consider restricting `/admin/` to staff-only IP ranges if exposed publicly. +- **Backup strategy** — Render starter DB has limited backup options. Consider daily export or upgrading the DB plan. diff --git a/docker-compose.yml b/docker-compose.yml index f8dd313..16b2983 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,7 +29,7 @@ services: environment: SECRET_KEY: ${SECRET_KEY:-dev-secret-change-me} DEBUG: ${DEBUG:-1} - ALLOWED_HOSTS: ${ALLOWED_HOSTS:-127.0.0.1,localhost} + ALLOWED_HOSTS: ${ALLOWED_HOSTS:-127.0.0.1,localhost,192.168.1.37} TIME_ZONE: ${TIME_ZONE:-America/Chicago} POSTGRES_DB: ${POSTGRES_DB:-putnam_trashmap} POSTGRES_USER: ${POSTGRES_USER:-putnam} diff --git a/docker/web-entrypoint.sh b/docker/web-entrypoint.sh index 55e6525..466aa04 100644 --- a/docker/web-entrypoint.sh +++ b/docker/web-entrypoint.sh @@ -1,10 +1,12 @@ #!/bin/sh set -e -echo "Waiting for PostGIS at ${POSTGRES_HOST}:${POSTGRES_PORT}..." -until pg_isready -h "${POSTGRES_HOST}" -p "${POSTGRES_PORT}" -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" >/dev/null 2>&1; do - sleep 1 -done +if [ -n "${POSTGRES_HOST}" ]; then + echo "Waiting for PostGIS at ${POSTGRES_HOST}:${POSTGRES_PORT}..." + until pg_isready -h "${POSTGRES_HOST}" -p "${POSTGRES_PORT}" -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" >/dev/null 2>&1; do + sleep 1 + done +fi echo "Applying migrations..." python manage.py migrate --noinput diff --git a/images/Nav_Banner.svg b/images/Nav_Banner.svg new file mode 100644 index 0000000..8217c16 Binary files /dev/null and b/images/Nav_Banner.svg differ diff --git a/images/UC_CleanUp-site-favicon.png b/images/UC_CleanUp-site-favicon.png index 565e373..9d382a5 100644 Binary files a/images/UC_CleanUp-site-favicon.png and b/images/UC_CleanUp-site-favicon.png differ diff --git a/putnam_trashmap/settings.py b/putnam_trashmap/settings.py index 83852c9..19d110f 100644 --- a/putnam_trashmap/settings.py +++ b/putnam_trashmap/settings.py @@ -76,7 +76,7 @@ def _env_list(name, default=""): DATABASES = { "default": dj_database_url.config( - default=os.getenv("DATABASE_URL", default_database_url), + default=os.getenv("DB_CONN_STRING", os.getenv("DATABASE_URL", default_database_url)), engine="django.contrib.gis.db.backends.postgis", conn_max_age=int(os.getenv("CONN_MAX_AGE", "60")), ssl_require=os.getenv("DATABASE_SSL_REQUIRE", "0") == "1", @@ -151,12 +151,12 @@ def _env_list(name, default=""): USE_S3_MEDIA = os.getenv("USE_S3_MEDIA", "0") == "1" if USE_S3_MEDIA: - AWS_ACCESS_KEY_ID = os.getenv("AWS_ACCESS_KEY_ID", "") - AWS_SECRET_ACCESS_KEY = os.getenv("AWS_SECRET_ACCESS_KEY", "") - AWS_STORAGE_BUCKET_NAME = os.getenv("AWS_STORAGE_BUCKET_NAME", "") - AWS_S3_REGION_NAME = os.getenv("AWS_S3_REGION_NAME", "auto") - AWS_S3_ENDPOINT_URL = os.getenv("AWS_S3_ENDPOINT_URL", "") - AWS_S3_CUSTOM_DOMAIN = os.getenv("AWS_S3_CUSTOM_DOMAIN", "") + AWS_ACCESS_KEY_ID = os.getenv("CF_ACCESS_KEY_ID", "") + AWS_SECRET_ACCESS_KEY = os.getenv("CF_SECRET_ACCESS_KEY", "") + AWS_STORAGE_BUCKET_NAME = os.getenv("CF_BUCKET_NAME", "") + AWS_S3_REGION_NAME = "auto" + AWS_S3_ENDPOINT_URL = os.getenv("CF_S3_ENDPOINT_URL", "") + AWS_S3_CUSTOM_DOMAIN = os.getenv("CF_S3_CUSTOM_DOMAIN", "") AWS_QUERYSTRING_AUTH = False AWS_S3_FILE_OVERWRITE = False STORAGES = { @@ -210,10 +210,10 @@ def _env_list(name, default=""): if not DEBUG and USE_S3_MEDIA: required_storage_values = { - "AWS_ACCESS_KEY_ID": os.getenv("AWS_ACCESS_KEY_ID", ""), - "AWS_SECRET_ACCESS_KEY": os.getenv("AWS_SECRET_ACCESS_KEY", ""), - "AWS_STORAGE_BUCKET_NAME": os.getenv("AWS_STORAGE_BUCKET_NAME", ""), - "AWS_S3_ENDPOINT_URL": os.getenv("AWS_S3_ENDPOINT_URL", ""), + "CF_ACCESS_KEY_ID": os.getenv("CF_ACCESS_KEY_ID", ""), + "CF_SECRET_ACCESS_KEY": os.getenv("CF_SECRET_ACCESS_KEY", ""), + "CF_BUCKET_NAME": os.getenv("CF_BUCKET_NAME", ""), + "CF_S3_ENDPOINT_URL": os.getenv("CF_S3_ENDPOINT_URL", ""), } missing_storage_values = [key for key, value in required_storage_values.items() if not value] if missing_storage_values: diff --git a/render.yaml b/render.yaml index 38f3aed..6ccff2d 100644 --- a/render.yaml +++ b/render.yaml @@ -5,42 +5,54 @@ services: plan: starter healthCheckPath: /healthz autoDeploy: true + + # Run migrations before new instance receives traffic (zero-downtime deploys). + # The entrypoint also runs migrate as a fallback for local Docker usage. + preDeployCommand: python manage.py migrate --noinput + envVars: + # Generated once by Render and persisted — never regenerated on redeploy. - key: SECRET_KEY generateValue: true + - key: DEBUG value: "0" + + # Update both values if you add a custom domain. - key: ALLOWED_HOSTS value: upper-cumberland-cleanup.onrender.com - key: CSRF_TRUSTED_ORIGINS value: https://upper-cumberland-cleanup.onrender.com - - key: DATABASE_URL - fromDatabase: - name: upper-cumberland-cleanup-db - property: connectionString + + # Set manually in Render dashboard — Supabase connection string. + # Format: postgresql://user:password@host:5432/db?sslmode=require + - key: DB_CONN_STRING + sync: false - key: DATABASE_SSL_REQUIRE value: "1" - - key: RUN_COLLECTSTATIC - value: "1" + - key: LOG_LEVEL value: INFO + + # Gunicorn — starter plan has 512 MB RAM; 2 workers is safe. + # Increase to 3-4 if you upgrade the plan. + - key: GUNICORN_WORKERS + value: "2" + + # collectstatic runs inside the Docker entrypoint when this is "1". + - key: RUN_COLLECTSTATIC + value: "1" + + # Cloudflare R2 (S3-compatible) — set all five in Render dashboard. - key: USE_S3_MEDIA value: "1" - - key: AWS_S3_REGION_NAME - value: auto - - key: AWS_ACCESS_KEY_ID + - key: CF_ACCESS_KEY_ID sync: false - - key: AWS_SECRET_ACCESS_KEY + - key: CF_SECRET_ACCESS_KEY sync: false - - key: AWS_STORAGE_BUCKET_NAME + - key: CF_BUCKET_NAME sync: false - - key: AWS_S3_ENDPOINT_URL + - key: CF_S3_ENDPOINT_URL sync: false - - key: AWS_S3_CUSTOM_DOMAIN + - key: CF_S3_CUSTOM_DOMAIN sync: false - -databases: - - name: upper-cumberland-cleanup-db - plan: starter - databaseName: putnam_trashmap - user: putnam diff --git a/static/css/site.css b/static/css/site.css index b5e0c06..f275bf7 100644 --- a/static/css/site.css +++ b/static/css/site.css @@ -476,6 +476,12 @@ button:disabled, text-decoration: none; } +.brand-logo { + height: 36px; + width: auto; + display: block; +} + .brand-kicker { font-size: 0.68rem; text-transform: uppercase; diff --git a/static/images/favicon.png b/static/images/favicon.png new file mode 100644 index 0000000..9d382a5 Binary files /dev/null and b/static/images/favicon.png differ diff --git a/static/images/nav-banner.png b/static/images/nav-banner.png new file mode 100644 index 0000000..435dc61 Binary files /dev/null and b/static/images/nav-banner.png differ diff --git a/templates/base.html b/templates/base.html index f72d698..f2336dc 100644 --- a/templates/base.html +++ b/templates/base.html @@ -9,6 +9,7 @@ + {% block extra_head %}{% endblock %} @@ -18,7 +19,7 @@
- Upper-Cumberland CleanUp +