The Django app behind www.pigscanfly.ca — the Pigs Can Fly Labs site and store.
Requires Python 3.13 (matching the Docker image; 3.10+ works).
python3.13 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt -r requirements-dev.txt
./manage.py migrate
./manage.py loaddata initial_products
./run_local.sh # runserver_plus with TLS via mkcertThe Dev configuration (sqlite, file-based email in sent_emails/) is the
default; set ENVIRONMENT=Prod (or DJANGO_CONFIGURATION=Prod) for the
production settings class.
Checks — one script shared by local dev, build.sh, and GitHub Actions
(.github/workflows/ci.yml):
./scripts/checks.sh| Variable | Used by | Notes |
|---|---|---|
SECRET_KEY |
Prod (required), Base (optional) | Base falls back to an insecure dev-only value. |
STRIPE_TEST_SECRET_KEY |
Dev / Base | Test-mode Stripe key. |
STRIPE_LIVE_SECRET_KEY |
Prod | Live Stripe key. |
STRIPE_WEBHOOK_SECRET |
all | Signing secret for /stripe/webhook. Orders are never marked paid without it — see Orders. |
ORDER_NOTIFICATION_EMAIL |
all | Where the "new paid order" mail goes; becomes ADMINS. Defaults to support@pigscanfly.ca. |
DBHOST / DBNAME / DBUSER / DBPASSWORD |
Prod | Postgres connection; wired in deploy.yaml to the in-cluster DB. |
EMAIL_HOST / EMAIL_HOST_USER / EMAIL_HOST_PASSWORD |
Prod | SMTP. |
MAXMIND_LICENSE_KEY |
build.sh (image build) | Bundles the GeoLite2 country DB for region-specific buy links; optional. |
GEOIP_PATH |
all | Directory holding GeoLite2-Country.mmdb; defaults to <repo>/geoip (set to /opt/app/geoip in the image). |
Note: a Stripe test key and a mkcert dev key were committed to this repo's history in the past. Both should be treated as burned — rotate the Stripe test key in the dashboard; the settings now only read keys from the environment.
A purchase is recorded as a PENDING Order (plus an OrderItem snapshot of
every cart line: name, unit price in cents, currency, quantity) before the
customer is sent to Stripe. The order id travels with the Checkout session as
client_reference_id, because by the time a webhook fires the cart is gone —
the success page empties it, and an anonymous cart is session-scoped.
POST /stripe/webhook is the only thing that marks an order PAID and emails
the owner. /checkout/success clears the cart and nothing else; it is an
unauthenticated GET and proves no payment.
Two things must be set up or no order is ever recorded as paid:
STRIPE_WEBHOOK_SECRETin the environment. Without it every delivery is rejected with a 400 (deliberately failing closed) and orders stay PENDING.- The endpoint registered in the Stripe Dashboard under
Developers → Webhooks, pointing at
https://www.pigscanfly.ca/stripe/webhookand subscribed tocheckout.session.completed,checkout.session.async_payment_succeeded,checkout.session.async_payment_failedandcheckout.session.expired. Copy that endpoint's signing secret intoSTRIPE_WEBHOOK_SECRET.
Checkout enables adjustable_quantity, so the customer can change quantities
on Stripe's hosted page after the snapshot is written. The webhook therefore
re-reads the billed line items from Stripe and writes those quantities onto
OrderItem.quantity, keeping the cart's original in
OrderItem.snapshot_quantity so the change stays auditable. The notification
email is the pick/pack list, so it must not be knowingly stale.
Fulfilment is manual: paid orders show up in the Django admin, and the owner flips the status to FULFILLED once it ships.
Everything after the payment is recorded is best-effort and cannot cost you the order — the webhook returns 200 either way, because a non-2xx makes Stripe retry for three days:
- notification email failed →
notification_errorset,notified_atnull; - line items could not be re-read →
reconciliation_errorset,reconciled_atnull, quantities left at the cart's, and the email says loudly that the list is unverified.
Note that setting ADMINS also switches on Django's built-in error mail, so
unhandled 500s now go to the same address.
Bootstrap products (Holden's books) live in
main/fixtures/initial_products.yaml. Rules:
- Fixture rows use pks 100+ so they never collide with rows created directly in prod.
- The primary pod re-runs
loaddata initial_productson every deploy, so admin edits to those pks get overwritten — edit the YAML instead. external_product_id(Stripe) is generated lazily on first add-to-cart, so loading fixtures needs no Stripe access.
Books carry amazon_link and bookshop_link (shown to everyone) plus
amazon_in_link / flipkart_link, which are shown first to visitors whose
IP resolves to India via MaxMind GeoLite2. Country detection needs
GeoLite2-Country.mmdb in GEOIP_PATH — the Docker build downloads it when
MAXMIND_LICENSE_KEY is set (passed as a BuildKit secret so it stays out of
the image history). Without the database the site quietly serves the default
links only.
./build.sh is the whole pipeline: mypy → migration check → tests →
template validation → collectstatic (assets are copied in from a sibling
pcfweb-assets checkout) → multi-arch Docker build/push
(holdenk/pcfweb:<tag>) → kubectl apply.
Before running ./build.sh, check out the image assets as a sibling
directory. They are deliberately kept out of this repo (.gitignore
excludes main/static/assets/images), so the build depends on
pcfweb-assets being
present one level up:
git clone https://github.com/pigsCanFlyLabs/pcfweb-assets.git ../pcfweb-assetsNote that build.sh does rm -rf main/static/assets/images before it
copies the new ones in, so running it without ../pcfweb-assets present
both fails the build and leaves your local images deleted — re-clone the
sibling repo and re-run to recover.
The Kubernetes objects:
pg-bootstrap.yaml— the database: a CloudNativePGCluster(3 instances, 10Giencrypted-local-pathstorage, nightly backups to B2), plus manualBackupand nightlyScheduledBackup.deploy.yaml— the app:web-primary(1 replica; runsmigrate+loaddataon start),web(3 replicas),web-svc, and the ingress forwww.pigscanfly.ca.
The app reaches Postgres through the operator-created pcfweb-pg-rw
Service; DBHOST/DBNAME/DBUSER are set directly in deploy.yaml and
DBPASSWORD comes from the pcfweb-internal-pg-secret Secret.
- CloudNativePG operator installed cluster-wide.
- The
encrypted-local-pathStorageClass. - Secrets in the
pcfwebnamespace:pcfweb-superuser-pg-secret—kubernetes.io/basic-auth, usernamepostgres+ password.pcfweb-internal-pg-secret—kubernetes.io/basic-auth, usernamepigscanfly+ password (the app role).pg-backup—PG_ACCESS_KEY_ID/PG_ACCESS_SECRET_KEYfor thepcfweb-pg-backupB2 bucket (use a bucket dedicated to pcfweb).pcfweb-secret— the app env (SECRET_KEY, Stripe, email, …).
The site previously ran against an external MySQL. To carry data over:
- From a checkout of the last pre-Postgres revision (which still has the
MySQL settings) with access to the old DB:
./manage.py dumpdata --natural-foreign -e contenttypes -e auth.Permission -e sessions -e cal_sync_magic -o prod-dump.json(calendar sync no longer ships in this repo, so its rows can't be loaded here; drop-e cal_sync_magicif that old checkout doesn't have the calendar app installed.) - On this revision:
kubectl -n pcfweb port-forward svc/pcfweb-pg-rw 5432:5432, setDBHOST=127.0.0.1etc., then./manage.py migrateand./manage.py loaddata prod-dump.json. - Reset sequences (explicit-pk loads don't advance them):
./manage.py sqlsequencereset main auth | kubectl -n pcfweb exec -i pcfweb-pg-1 -- psql -U postgres pigscanfly