Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions PLAN.md
Original file line number Diff line number Diff line change
@@ -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://<account-id>.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.
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
10 changes: 6 additions & 4 deletions docker/web-entrypoint.sh
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Binary file added images/Nav_Banner.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified images/UC_CleanUp-site-favicon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
22 changes: 11 additions & 11 deletions putnam_trashmap/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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:
Expand Down
50 changes: 31 additions & 19 deletions render.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions static/css/site.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Binary file added static/images/favicon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added static/images/nav-banner.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Lato:ital,wght@0,100;0,300;0,400;0,700;0,900;1,100;1,300;1,400;1,700;1,900&family=Open+Sans:ital,wght@0,300..800;1,300..800&display=swap" rel="stylesheet">
<link rel="icon" type="image/png" href="{% static 'images/favicon.png' %}">
<link rel="stylesheet" href="{% static 'css/palette.css' %}">
<link rel="stylesheet" href="{% static 'css/site.css' %}">
{% block extra_head %}{% endblock %}
Expand All @@ -18,7 +19,7 @@
<header class="topbar">
<div class="topbar-inner">
<a class="brand" href="{% url 'map' %}">
<span class="brand-title">Upper-Cumberland CleanUp</span>
<img class="brand-logo" src="{% static 'images/nav-banner.png' %}" alt="Upper-Cumberland CleanUp" height="36">
</a>
<button class="hamburger" id="hamburger-btn" type="button" aria-expanded="false" aria-controls="nav-collapse" aria-label="Toggle menu">
<span class="hamburger-bar"></span>
Expand Down
2 changes: 1 addition & 1 deletion templates/geoapp/cleanups.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<div class="page-intro">
<span class="section-kicker">Community Impact</span>
<h1>Completed Cleanups</h1>
<p>See the difference volunteers are making in District 3. Every cleanup counts.</p>
<p>See the difference volunteers are making in the Upper-Cumberland region. Every cleanup counts.</p>
</div>

{% if items %}
Expand Down
Loading