Ship Print Forge shop site (colors, photos, 3D viewer) (#2)
## Summary - Rebrand the Django client template as Print Forge (`client_site` → `print_forge`) with shop + shipping enabled. - Product listings support multiple photos, color variants (shared price/description/STL, per-color stock and photos), and a photo-first / 3D-second gallery. - Public pages use 3D printer / printed-toy photography instead of leftover t-shirt mockups; beta CI deploys on `master`. Closes #1 Infra: [server-infra#27](ai_ml_operations/server-infra#27) (easy deploy beta). ## Test plan - [ ] Product page shows photo first, 3D model second; color swatches swap photos and stock - [ ] Portal can upload multiple photos and per-color qty/images; STL stays shared - [ ] Public home/about/gallery have no t-shirt mockups - [ ] `manage.py test` passes - [ ] After server-infra#27: beta deploy to `print-forge-preview.aimloperations.com` Reviewed-on: #2
@@ -10,17 +10,17 @@ DJANGO_SECRET_KEY=dev-only-change-me
|
||||
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0
|
||||
|
||||
# Leave empty for SQLite, or point at local compose Postgres:
|
||||
# DATABASE_URL=postgres://client_site:client_site@127.0.0.1:5432/client_site
|
||||
# DATABASE_URL=postgres://print_forge:print_forge@127.0.0.1:5432/print_forge
|
||||
|
||||
SITE_UNDER_CONSTRUCTION=false
|
||||
SITE_NAME=Your Company
|
||||
SITE_TAGLINE=A tagline for the public site
|
||||
SITE_NAME="Print Forge"
|
||||
SITE_TAGLINE="Handcrafted 3D printed toys"
|
||||
PUBLIC_SITE_URL=http://127.0.0.1:8000
|
||||
CONTACT_PHONE=
|
||||
CONTACT_EMAIL=hello@example.com
|
||||
CONTACT_PHONE=630-632-0202
|
||||
CONTACT_EMAIL=Printforgeprints@gmail.com
|
||||
CONTACT_ADDRESS=
|
||||
CONTACT_SERVICE_AREA=
|
||||
CREDIT_NAME=AI ML Operations, LLC
|
||||
CONTACT_SERVICE_AREA=Chicagoland
|
||||
CREDIT_NAME="AI ML Operations, LLC"
|
||||
CREDIT_URL=https://aimloperations.com
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -30,11 +30,15 @@ CREDIT_URL=https://aimloperations.com
|
||||
# Disabled apps are omitted from INSTALLED_APPS (no models, admin, URLs, nav).
|
||||
# ---------------------------------------------------------------------------
|
||||
FEATURE_EMAIL_SMS=true
|
||||
FEATURE_DIRECT_MAIL=true
|
||||
FEATURE_BLOG=true
|
||||
FEATURE_DIRECT_MAIL=false
|
||||
FEATURE_BLOG=false
|
||||
FEATURE_PAYMENTS=true
|
||||
FEATURE_SOCIAL=true
|
||||
FEATURE_SOCIAL_AI=true
|
||||
FEATURE_SOCIAL=false
|
||||
FEATURE_SOCIAL_AI=false
|
||||
FEATURE_SHOP=true
|
||||
FEATURE_POS_SYNC=false
|
||||
FEATURE_EVENTS=false
|
||||
FEATURE_SHIPPING=true
|
||||
|
||||
# reCAPTCHA (optional locally — form skips captcha when empty)
|
||||
RECAPTCHA_PUBLIC_KEY=
|
||||
@@ -48,8 +52,8 @@ EMAIL_PORT=2525
|
||||
EMAIL_USE_TLS=true
|
||||
# Inbox From. Docker Compose .env cannot parse: "Name" <addr>
|
||||
# Use bare address (SITE_NAME is wrapped) OR one quoted string:
|
||||
# DEFAULT_FROM_EMAIL="Your Company <noreply@example.com>"
|
||||
DEFAULT_FROM_EMAIL=noreply@example.com
|
||||
# DEFAULT_FROM_EMAIL="Your Company <noreply@printforgeprints.com>"
|
||||
DEFAULT_FROM_EMAIL=Printforgeprints@gmail.com
|
||||
# Console (default in DJANGO_ENV=dev) prints mail to container logs.
|
||||
# EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
|
||||
SMTP2GO_SMS_API_KEY=
|
||||
@@ -74,6 +78,21 @@ STRIPE_PUBLISHABLE_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
STRIPE_CURRENCY=usd
|
||||
|
||||
# Shop — FEATURE_SHOP (requires email/SMS + payments)
|
||||
# SHOP_PRINT_QUEUE_LIMIT_MINUTES=0
|
||||
|
||||
# POS sync — FEATURE_POS_SYNC (requires shop). Required in beta/prod.
|
||||
POS_WEBHOOK_SECRET=
|
||||
POS_API_BASE_URL=
|
||||
POS_API_TOKEN=
|
||||
|
||||
# Shipping — FEATURE_SHIPPING (requires shop). EasyPost optional; CSV export always works.
|
||||
EASYPOST_API_KEY=
|
||||
SHIP_FROM_LINE1=
|
||||
SHIP_FROM_CITY=
|
||||
SHIP_FROM_STATE=
|
||||
SHIP_FROM_ZIP=
|
||||
|
||||
# Social — SOCIAL_TOKEN_ENCRYPTION_KEY required when FEATURE_SOCIAL=true
|
||||
# LinkedIn / Instagram App ID + Secret: Portal → Social accounts (not env).
|
||||
META_APP_ID=
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Secret env files for server-infra deploy. Never commit.
|
||||
# Copy on the control node:
|
||||
# ~/Documents/secrets/<slug>_site/<slug>_site_prod.env
|
||||
# ~/Documents/secrets/<slug>_site/<slug>_site_beta.env
|
||||
# ~/Documents/secrets/print_forge/print_forge_prod.env
|
||||
# ~/Documents/secrets/print_forge/print_forge_beta.env
|
||||
#
|
||||
# Docker Compose: if a secret contains $ (e.g. DATABASE_URL password), escape
|
||||
# each $ as $$ or compose will treat $word as a variable.
|
||||
@@ -9,34 +9,38 @@
|
||||
DJANGO_ENV=prod
|
||||
DJANGO_DEBUG=false
|
||||
DJANGO_SECRET_KEY=replace-with-a-long-random-secret
|
||||
DJANGO_ALLOWED_HOSTS=example.com,www.example.com
|
||||
DJANGO_ALLOWED_HOSTS=printforgeprints.com,www.printforgeprints.com
|
||||
|
||||
SITE_UNDER_CONSTRUCTION=true
|
||||
|
||||
SITE_NAME=Your Company
|
||||
SITE_TAGLINE=A tagline for the public site
|
||||
PUBLIC_SITE_URL=https://example.com
|
||||
DEFAULT_FROM_EMAIL=noreply@example.com
|
||||
CONTACT_PHONE=
|
||||
CONTACT_EMAIL=
|
||||
SITE_NAME="Print Forge"
|
||||
SITE_TAGLINE="Handcrafted 3D printed toys"
|
||||
PUBLIC_SITE_URL=https://printforgeprints.com
|
||||
DEFAULT_FROM_EMAIL=Printforgeprints@gmail.com
|
||||
CONTACT_PHONE=630-632-0202
|
||||
CONTACT_EMAIL=Printforgeprints@gmail.com
|
||||
CONTACT_ADDRESS=
|
||||
CONTACT_SERVICE_AREA=
|
||||
CREDIT_NAME=AI ML Operations, LLC
|
||||
CONTACT_SERVICE_AREA=Chicagoland
|
||||
CREDIT_NAME="AI ML Operations, LLC"
|
||||
CREDIT_URL=https://aimloperations.com
|
||||
|
||||
# Optional catalog features — false unless purchased.
|
||||
# Turning a flag on later: set true, redeploy, migrate. Do not auto-drop tables.
|
||||
FEATURE_EMAIL_SMS=false
|
||||
FEATURE_EMAIL_SMS=true
|
||||
FEATURE_DIRECT_MAIL=false
|
||||
FEATURE_BLOG=false
|
||||
FEATURE_PAYMENTS=false
|
||||
FEATURE_PAYMENTS=true
|
||||
FEATURE_SOCIAL=false
|
||||
FEATURE_SOCIAL_AI=false
|
||||
FEATURE_SHOP=true
|
||||
FEATURE_POS_SYNC=false
|
||||
FEATURE_EVENTS=false
|
||||
FEATURE_SHIPPING=true
|
||||
|
||||
# Shared external Postgres (10.0.0.230)
|
||||
DATABASE_URL=postgres://westfarn:replace-db-password@10.0.0.230:5432/client_site
|
||||
DATABASE_URL=postgres://westfarn:replace-db-password@10.0.0.230:5432/print_forge
|
||||
|
||||
WEB_PORT=8004
|
||||
WEB_PORT=8007
|
||||
|
||||
RECAPTCHA_PUBLIC_KEY=replace-me
|
||||
RECAPTCHA_PRIVATE_KEY=replace-me
|
||||
@@ -63,6 +67,21 @@ STRIPE_PUBLISHABLE_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
STRIPE_CURRENCY=usd
|
||||
|
||||
# Required when FEATURE_SHOP=true (also requires FEATURE_EMAIL_SMS + FEATURE_PAYMENTS)
|
||||
# SHOP_PRINT_QUEUE_LIMIT_MINUTES=0
|
||||
|
||||
# Required when FEATURE_POS_SYNC=true in beta/prod (also requires FEATURE_SHOP)
|
||||
POS_WEBHOOK_SECRET=
|
||||
POS_API_BASE_URL=
|
||||
POS_API_TOKEN=
|
||||
|
||||
# Optional when FEATURE_SHIPPING=true (also requires FEATURE_SHOP)
|
||||
EASYPOST_API_KEY=
|
||||
SHIP_FROM_LINE1=
|
||||
SHIP_FROM_CITY=
|
||||
SHIP_FROM_STATE=
|
||||
SHIP_FROM_ZIP=
|
||||
|
||||
# Required when FEATURE_SOCIAL=true
|
||||
META_APP_ID=
|
||||
META_APP_SECRET=
|
||||
@@ -78,8 +97,8 @@ OLLAMA_TIMEOUT_SECONDS=120
|
||||
# Token must match SHORTENER_API_TOKENS on url_shortening_service (name:secret).
|
||||
# Generate: python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||
SHORTENER_BASE_URL=https://shortener.aimloperations.com
|
||||
SHORTENER_API_TOKEN=acme:replace-me
|
||||
# UTM_SOURCE=acme # optional; default is a slug of SITE_NAME
|
||||
SHORTENER_API_TOKEN=print_forge:replace-me
|
||||
# UTM_SOURCE=print-forge # optional; default is a slug of SITE_NAME
|
||||
|
||||
NOMINATIM_BASE_URL=http://10.0.0.128:8089
|
||||
NOMINATIM_TIMEOUT_SECONDS=8
|
||||
@@ -92,11 +111,11 @@ TIANJI_WEBSITE_ID=
|
||||
GUNICORN_WORKERS=2
|
||||
GUNICORN_BIND=0.0.0.0:8000
|
||||
|
||||
# BETA overrides — <slug>-preview.aimloperations.com
|
||||
# BETA overrides — print-forge-preview.aimloperations.com
|
||||
# DJANGO_ENV=beta
|
||||
# SITE_UNDER_CONSTRUCTION=false
|
||||
# PUBLIC_SITE_URL=https://example-preview.aimloperations.com
|
||||
# DATABASE_URL=postgres://westfarn:replace-db-password@10.0.0.230:5432/client_site_beta
|
||||
# WEB_PORT=8014
|
||||
# PUBLIC_SITE_URL=https://print-forge-preview.aimloperations.com
|
||||
# DATABASE_URL=postgres://westfarn:replace-db-password@10.0.0.230:5432/print_forge_beta
|
||||
# WEB_PORT=8019
|
||||
# SHORTENER_BASE_URL=https://shortener-beta.aimloperations.com
|
||||
# SHORTENER_API_TOKEN=acme:replace-with-a-different-token
|
||||
# SHORTENER_API_TOKEN=print_forge:replace-with-a-different-token
|
||||
|
||||
@@ -2,7 +2,7 @@ name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
|
||||
@@ -3,7 +3,7 @@ name: Deploy Beta
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
|
||||
jobs:
|
||||
unit-tests:
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
COMPOSE_DATABASE_URL DJANGO_ENV DJANGO_SECRET_KEY DJANGO_DEBUG \
|
||||
DJANGO_ALLOWED_HOSTS || true
|
||||
|
||||
PROJECT="client-ci-${{ gitea.sha }}"
|
||||
PROJECT="print-forge-ci-${{ gitea.sha }}"
|
||||
cleanup() { docker compose -p "$PROJECT" down -v --remove-orphans || true; }
|
||||
trap cleanup EXIT
|
||||
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
-e DJANGO_SECRET_KEY=test-secret-key \
|
||||
-e DJANGO_DEBUG=true \
|
||||
-e DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,testserver \
|
||||
-e DATABASE_URL=postgres://client_site:client_site@db:5432/client_site \
|
||||
-e DATABASE_URL=postgres://print_forge:print_forge@db:5432/print_forge \
|
||||
-e SITE_UNDER_CONSTRUCTION=false \
|
||||
web uv run python manage.py test
|
||||
|
||||
@@ -66,9 +66,10 @@ jobs:
|
||||
SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra
|
||||
ANSIBLE_PRIVATE_KEY_FILE: /home/westfarn/.ssh/ansible_deploy
|
||||
steps:
|
||||
- name: Deploy client_site beta to all webservers
|
||||
# --app must match server-infra app_catalog (print_forge).
|
||||
- name: Deploy print_forge beta to all webservers
|
||||
run: |
|
||||
"$SERVER_INFRA_ROOT/scripts/deploy.sh" \
|
||||
--app client_site \
|
||||
--app print_forge \
|
||||
--env beta \
|
||||
--ref "${{ gitea.sha }}"
|
||||
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
COMPOSE_DATABASE_URL DJANGO_ENV DJANGO_SECRET_KEY DJANGO_DEBUG \
|
||||
DJANGO_ALLOWED_HOSTS || true
|
||||
|
||||
PROJECT="client-ci-${{ gitea.sha }}"
|
||||
PROJECT="print-forge-ci-${{ gitea.sha }}"
|
||||
cleanup() { docker compose -p "$PROJECT" down -v --remove-orphans || true; }
|
||||
trap cleanup EXIT
|
||||
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
-e DJANGO_SECRET_KEY=test-secret-key \
|
||||
-e DJANGO_DEBUG=true \
|
||||
-e DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,testserver \
|
||||
-e DATABASE_URL=postgres://client_site:client_site@db:5432/client_site \
|
||||
-e DATABASE_URL=postgres://print_forge:print_forge@db:5432/print_forge \
|
||||
-e SITE_UNDER_CONSTRUCTION=false \
|
||||
web uv run python manage.py test
|
||||
|
||||
@@ -64,9 +64,10 @@ jobs:
|
||||
SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra
|
||||
ANSIBLE_PRIVATE_KEY_FILE: /home/westfarn/.ssh/ansible_deploy
|
||||
steps:
|
||||
- name: Deploy client_site prod to all webservers
|
||||
# --app must match server-infra app_catalog (print_forge).
|
||||
- name: Deploy print_forge prod to all webservers
|
||||
run: |
|
||||
"$SERVER_INFRA_ROOT/scripts/deploy.sh" \
|
||||
--app client_site \
|
||||
--app print_forge \
|
||||
--env prod \
|
||||
--ref "${{ gitea.sha }}"
|
||||
|
||||
@@ -177,3 +177,6 @@ cython_debug/
|
||||
|
||||
# Django collectstatic output
|
||||
staticfiles/
|
||||
|
||||
# Downloaded Pexels originals used to regenerate public photos
|
||||
.pexels-cache/
|
||||
|
||||
@@ -9,7 +9,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends libpq5 \
|
||||
&& apt-get install -y --no-install-recommends libpq5 libgomp1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||
|
||||
@@ -1,49 +1,31 @@
|
||||
# web_django_template
|
||||
# Print Forge
|
||||
|
||||
Gitea template for AI ML Operations client websites
|
||||
3D printed toy shop for Print Forge
|
||||
([Web Design & Hosting](https://aimloperations.com/web_design)).
|
||||
|
||||
Clone this repo (or **Use this template**), run `scripts/bootstrap-client.sh`,
|
||||
set branding + `FEATURE_*` flags in the secret env file, deploy.
|
||||
Public site + client portal, plus purchased catalog add-ons: email & SMS,
|
||||
payments, e-commerce, and shipping automation.
|
||||
|
||||
Always included: public site (landing, about, contact) and client portal
|
||||
(login, dashboard, leads, UTM analytics). Optional catalog add-ons are
|
||||
separate Django apps. A flag that is off keeps the app out of
|
||||
`INSTALLED_APPS` — no models, admin, URLs, or portal nav.
|
||||
|
||||
## Catalog → flags
|
||||
## Catalog
|
||||
|
||||
| Catalog item | Flag | App |
|
||||
|---|---|---|
|
||||
| Public site | (always on) | `public` |
|
||||
| Client portal + UTM | (always on) | `accounts`, `dashboard`, `leads`, `analytics`, `contacts`, `core` |
|
||||
| Email & SMS | `FEATURE_EMAIL_SMS` | `email_sms` |
|
||||
| Direct mail (PCM) | `FEATURE_DIRECT_MAIL` | `directmail` |
|
||||
| Blog | `FEATURE_BLOG` | `blog` |
|
||||
| Payments (Stripe) | `FEATURE_PAYMENTS` | `payments` (requires email/SMS) |
|
||||
| Social consolidation | `FEATURE_SOCIAL` | `social` |
|
||||
| AI social generator | `FEATURE_SOCIAL_AI` | `social_ai` (requires social) |
|
||||
| Payments (Stripe) | `FEATURE_PAYMENTS` | `payments` |
|
||||
| E-commerce storefront | `FEATURE_SHOP` | `shop` |
|
||||
| Shipping automation | `FEATURE_SHIPPING` | `shipping` |
|
||||
|
||||
## New client
|
||||
Off for this client: direct mail, blog, social, social AI, POS sync, event ticketing.
|
||||
|
||||
```bash
|
||||
# 1. Create repo from this Gitea template, clone it.
|
||||
# 2. Rename package + branding placeholders:
|
||||
./scripts/bootstrap-client.sh acme_site "Acme HVAC" acmehvac.com
|
||||
## Contact
|
||||
|
||||
# 3. Brand public templates and static/brand/ (logo, colors, copy).
|
||||
- Phone: 630-632-0202
|
||||
- Email: Printforgeprints@gmail.com
|
||||
- Public URL (placeholder until DNS): https://printforgeprints.com
|
||||
|
||||
# 4. Secrets on the control node:
|
||||
mkdir -p ~/Documents/secrets/acme_site
|
||||
cp .env.prod.example ~/Documents/secrets/acme_site/acme_site_prod.env
|
||||
# Flip only purchased FEATURE_* flags. Fill provider secrets.
|
||||
./scripts/validate-env.sh ~/Documents/secrets/acme_site/acme_site_prod.env
|
||||
|
||||
# 5. Infra: Postgres on 10.0.0.230, server-infra app_catalog, NPM, CI.
|
||||
# 6. Deploy. Create a portal user (createsuperuser).
|
||||
```
|
||||
|
||||
Local:
|
||||
## Local
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
@@ -54,30 +36,21 @@ uv run python manage.py runserver
|
||||
# or: docker compose up
|
||||
```
|
||||
|
||||
## Deploy
|
||||
|
||||
```bash
|
||||
mkdir -p ~/Documents/secrets/print_forge
|
||||
cp .env.prod.example ~/Documents/secrets/print_forge/print_forge_prod.env
|
||||
# Fill Stripe, SMTP2GO, shortener, and DB secrets.
|
||||
./scripts/validate-env.sh ~/Documents/secrets/print_forge/print_forge_prod.env
|
||||
```
|
||||
|
||||
Infra: Postgres on 10.0.0.230, server-infra `app_catalog.print_forge`, NPM, CI.
|
||||
Gitea deploy calls `deploy.sh --app print_forge` (must match that catalog key).
|
||||
Secrets: `~/Documents/secrets/print_forge/print_forge_{prod,beta}.env`.
|
||||
Create a portal user with `createsuperuser` after first deploy.
|
||||
|
||||
## Stack
|
||||
|
||||
Django 6 + uv · Postgres (prod/beta); SQLite locally · dj-queue · Docker ·
|
||||
server-infra Ansible deploy.
|
||||
|
||||
Ollama for social drafts lives on the LAN at `http://10.0.0.128:11434`.
|
||||
Do not call a public Ollama host.
|
||||
|
||||
## Campaign tracked links (piha.li)
|
||||
|
||||
When `FEATURE_EMAIL_SMS` or `FEATURE_DIRECT_MAIL` is on, composers auto-insert a
|
||||
homepage URL with `utm_source` (slug of `SITE_NAME`, or `UTM_SOURCE`),
|
||||
`utm_medium` = channel, and `utm_campaign` = campaign name. Configured
|
||||
`SHORTENER_BASE_URL` + `SHORTENER_API_TOKEN` mint that URL via
|
||||
[url_shortening_service](https://git.aimloperations.com/ai_ml_operations/url_shortening_service)
|
||||
and put `piha.li` / `beta.piha.li` short links in SMS, email hrefs, and postcard QR codes.
|
||||
|
||||
Caller secrets (this repo's env file):
|
||||
|
||||
```text
|
||||
SHORTENER_BASE_URL=https://shortener.aimloperations.com
|
||||
SHORTENER_API_TOKEN=<slug>:<secret>
|
||||
```
|
||||
|
||||
Operator secrets (`url_shortening_service_*`): `SHORTENER_API_TOKENS=<slug>:<same-secret>`
|
||||
and `SHORT_ALLOWED_HOSTS` must include the client domain. Mint against the **API host**,
|
||||
never `piha.li`.
|
||||
|
||||
@@ -2,13 +2,13 @@ services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: client_site
|
||||
POSTGRES_USER: client_site
|
||||
POSTGRES_PASSWORD: client_site
|
||||
POSTGRES_DB: print_forge
|
||||
POSTGRES_USER: print_forge
|
||||
POSTGRES_PASSWORD: print_forge
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U client_site -d client_site"]
|
||||
test: ["CMD-SHELL", "pg_isready -U print_forge -d print_forge"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
@@ -19,8 +19,11 @@ services:
|
||||
ports:
|
||||
- "8000:8000"
|
||||
# Bind-mount source so edit → runserver auto-reload (no rebuild).
|
||||
# Lockfile mounts + entrypoint `uv sync` pick up new deps without a rebuild.
|
||||
volumes:
|
||||
- ./site:/app/site
|
||||
- ./pyproject.toml:/app/pyproject.toml
|
||||
- ./uv.lock:/app/uv.lock
|
||||
# No required env_file — CI has no .env. Defaults below; for local secrets:
|
||||
# docker compose --env-file .env up
|
||||
#
|
||||
@@ -32,21 +35,25 @@ services:
|
||||
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-dev-only-change-me}
|
||||
DJANGO_DEBUG: ${DJANGO_DEBUG:-true}
|
||||
DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0}
|
||||
DATABASE_URL: ${COMPOSE_DATABASE_URL:-postgres://client_site:client_site@db:5432/client_site}
|
||||
DATABASE_URL: ${COMPOSE_DATABASE_URL:-postgres://print_forge:print_forge@db:5432/print_forge}
|
||||
SITE_UNDER_CONSTRUCTION: ${SITE_UNDER_CONSTRUCTION:-false}
|
||||
FEATURE_EMAIL_SMS: ${FEATURE_EMAIL_SMS:-true}
|
||||
FEATURE_DIRECT_MAIL: ${FEATURE_DIRECT_MAIL:-true}
|
||||
FEATURE_BLOG: ${FEATURE_BLOG:-true}
|
||||
FEATURE_DIRECT_MAIL: ${FEATURE_DIRECT_MAIL:-false}
|
||||
FEATURE_BLOG: ${FEATURE_BLOG:-false}
|
||||
FEATURE_PAYMENTS: ${FEATURE_PAYMENTS:-true}
|
||||
FEATURE_SOCIAL: ${FEATURE_SOCIAL:-true}
|
||||
FEATURE_SOCIAL_AI: ${FEATURE_SOCIAL_AI:-true}
|
||||
FEATURE_SOCIAL: ${FEATURE_SOCIAL:-false}
|
||||
FEATURE_SOCIAL_AI: ${FEATURE_SOCIAL_AI:-false}
|
||||
FEATURE_SHOP: ${FEATURE_SHOP:-true}
|
||||
FEATURE_POS_SYNC: ${FEATURE_POS_SYNC:-false}
|
||||
FEATURE_EVENTS: ${FEATURE_EVENTS:-false}
|
||||
FEATURE_SHIPPING: ${FEATURE_SHIPPING:-true}
|
||||
PUBLIC_SITE_URL: ${PUBLIC_SITE_URL:-http://127.0.0.1:8000}
|
||||
EMAIL_HOST: ${EMAIL_HOST:-mail.smtp2go.com}
|
||||
EMAIL_HOST_USER: ${EMAIL_HOST_USER:-}
|
||||
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD:-}
|
||||
EMAIL_PORT: ${EMAIL_PORT:-2525}
|
||||
EMAIL_USE_TLS: ${EMAIL_USE_TLS:-true}
|
||||
DEFAULT_FROM_EMAIL: ${DEFAULT_FROM_EMAIL:-noreply@example.com}
|
||||
DEFAULT_FROM_EMAIL: ${DEFAULT_FROM_EMAIL:-Printforgeprints@gmail.com}
|
||||
# Dev default = console. Set smtp backend in `.env` for real SMTP2GO sends.
|
||||
EMAIL_BACKEND: ${EMAIL_BACKEND:-django.core.mail.backends.console.EmailBackend}
|
||||
SMTP2GO_SMS_API_KEY: ${SMTP2GO_SMS_API_KEY:-}
|
||||
@@ -78,12 +85,14 @@ services:
|
||||
entrypoint: ["/worker-entrypoint.sh"]
|
||||
volumes:
|
||||
- ./site:/app/site
|
||||
- ./pyproject.toml:/app/pyproject.toml
|
||||
- ./uv.lock:/app/uv.lock
|
||||
environment:
|
||||
DJANGO_ENV: ${DJANGO_ENV:-dev}
|
||||
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-dev-only-change-me}
|
||||
DJANGO_DEBUG: ${DJANGO_DEBUG:-true}
|
||||
DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0}
|
||||
DATABASE_URL: ${COMPOSE_DATABASE_URL:-postgres://client_site:client_site@db:5432/client_site}
|
||||
DATABASE_URL: ${COMPOSE_DATABASE_URL:-postgres://print_forge:print_forge@db:5432/print_forge}
|
||||
PUBLIC_SITE_URL: ${PUBLIC_SITE_URL:-http://127.0.0.1:8000}
|
||||
DISPATCH_DUE_INTERVAL_SECONDS: ${DISPATCH_DUE_INTERVAL_SECONDS:-15}
|
||||
EMAIL_HOST: ${EMAIL_HOST:-mail.smtp2go.com}
|
||||
@@ -91,7 +100,7 @@ services:
|
||||
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD:-}
|
||||
EMAIL_PORT: ${EMAIL_PORT:-2525}
|
||||
EMAIL_USE_TLS: ${EMAIL_USE_TLS:-true}
|
||||
DEFAULT_FROM_EMAIL: ${DEFAULT_FROM_EMAIL:-noreply@example.com}
|
||||
DEFAULT_FROM_EMAIL: ${DEFAULT_FROM_EMAIL:-Printforgeprints@gmail.com}
|
||||
EMAIL_BACKEND: ${EMAIL_BACKEND:-django.core.mail.backends.console.EmailBackend}
|
||||
SMTP2GO_SMS_API_KEY: ${SMTP2GO_SMS_API_KEY:-}
|
||||
SMTP2GO_WEBHOOK_SECRET: ${SMTP2GO_WEBHOOK_SECRET:-}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "web-django-template"
|
||||
name = "print-forge"
|
||||
version = "0.1.0"
|
||||
description = "Template for AI ML Operations client websites (public site + portal + optional catalog apps)"
|
||||
description = "Print Forge — 3D printed toy shop (public site, portal, shop, shipping)"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
@@ -16,6 +16,8 @@ dependencies = [
|
||||
"requests>=2.32.0",
|
||||
"stripe>=11.0.0",
|
||||
"whitenoise>=6.9.0",
|
||||
"pillow>=12.3.0",
|
||||
"rembg[cpu]>=2.0.83",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@@ -16,19 +16,19 @@ fi
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
if [[ "$SLUG" == "client_site" ]]; then
|
||||
echo "slug is already client_site — branding defaults only."
|
||||
if [[ "$SLUG" == "print_forge" ]]; then
|
||||
echo "slug is already print_forge — branding defaults only."
|
||||
else
|
||||
echo "Renaming package client_site → $SLUG"
|
||||
if [[ -d site/client_site ]]; then
|
||||
mv site/client_site "site/$SLUG"
|
||||
echo "Renaming package print_forge → $SLUG"
|
||||
if [[ -d site/print_forge ]]; then
|
||||
mv site/print_forge "site/$SLUG"
|
||||
fi
|
||||
grep -rl 'client_site' \
|
||||
grep -rl 'print_forge' \
|
||||
--exclude-dir=.git --exclude-dir=.venv --exclude-dir=__pycache__ \
|
||||
--exclude='*.png' --exclude='*.jpg' --exclude='*.woff*' --exclude='*.eot' \
|
||||
--exclude='uv.lock' . \
|
||||
| while read -r f; do
|
||||
sed -i "s/client_site/${SLUG}/g" "$f"
|
||||
sed -i "s/print_forge/${SLUG}/g" "$f"
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd /app
|
||||
if [[ "${DJANGO_ENV:-}" == "dev" || "${DJANGO_USE_RUNSERVER:-}" == "true" ]]; then
|
||||
echo "Syncing Python deps..."
|
||||
uv sync --frozen
|
||||
fi
|
||||
cd /app/site
|
||||
|
||||
wait_for_database() {
|
||||
@@ -14,7 +19,7 @@ wait_for_database() {
|
||||
import os
|
||||
import sys
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "client_site.settings")
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "print_forge.settings")
|
||||
|
||||
import django
|
||||
from django.db import connections
|
||||
@@ -51,6 +56,6 @@ fi
|
||||
|
||||
uv run python manage.py collectstatic --noinput
|
||||
|
||||
exec uv run gunicorn client_site.wsgi:application \
|
||||
exec uv run gunicorn print_forge.wsgi:application \
|
||||
--bind "${GUNICORN_BIND:-0.0.0.0:8000}" \
|
||||
--workers "${GUNICORN_WORKERS:-2}"
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Replace public-site t-shirt mockups with 3D printer / printed-toy photos."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
IMAGES = ROOT / "site" / "print_forge" / "static" / "images"
|
||||
CACHE = ROOT / ".pexels-cache"
|
||||
USER_HERO = [
|
||||
Path(
|
||||
"/home/westfarn/.cursor/projects/home-westfarn-Documents-repos-print-forge/assets/"
|
||||
"pexels-jakubzerdzicki-20688553-82987c3e-5bc2-4514-9eb0-2eb9518d5d63.png"
|
||||
),
|
||||
Path(
|
||||
"/home/westfarn/.cursor/projects/home-westfarn-Documents-repos-print-forge/assets/"
|
||||
"pexels-jakubzerdzicki-24859620-43b11020-90b7-4d02-9c78-d28b2cc14a4e.png"
|
||||
),
|
||||
]
|
||||
|
||||
PEXELS = {
|
||||
"figurine": 13624760,
|
||||
"miniatures": 35063297,
|
||||
"tray": 19583534,
|
||||
"neon": 30720501,
|
||||
"extruder": 31336922,
|
||||
"spool": 30658376,
|
||||
"lab": 23533991,
|
||||
"neon2": 18296466,
|
||||
"workshop": 30482193,
|
||||
"nozzle": 23533982,
|
||||
"hex": 30620861,
|
||||
"orange_parts": 20688553,
|
||||
"maker": 24859620,
|
||||
}
|
||||
|
||||
UA = (
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
|
||||
def fetch_pexels(photo_id: int) -> Path:
|
||||
CACHE.mkdir(parents=True, exist_ok=True)
|
||||
dest = CACHE / f"pexels-{photo_id}.jpg"
|
||||
if dest.exists() and dest.stat().st_size > 20_000:
|
||||
return dest
|
||||
url = (
|
||||
f"https://images.pexels.com/photos/{photo_id}/"
|
||||
f"pexels-photo-{photo_id}.jpeg?auto=compress&cs=tinysrgb&dpr=2&w=2000"
|
||||
)
|
||||
req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept": "image/*"})
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
data = resp.read()
|
||||
if len(data) < 5000:
|
||||
raise RuntimeError(f"Pexels {photo_id} too small ({len(data)} bytes)")
|
||||
dest.write_bytes(data)
|
||||
return dest
|
||||
|
||||
|
||||
def open_image(path: Path) -> Image.Image:
|
||||
image = Image.open(path)
|
||||
image.load()
|
||||
image = ImageOps.exif_transpose(image) or image
|
||||
return image.convert("RGB")
|
||||
|
||||
|
||||
def cover_crop(image: Image.Image, width: int, height: int, focus: str = "center") -> Image.Image:
|
||||
src_w, src_h = image.size
|
||||
scale = max(width / src_w, height / src_h)
|
||||
new_w = max(width, int(src_w * scale + 0.5))
|
||||
new_h = max(height, int(src_h * scale + 0.5))
|
||||
resized = image.resize((new_w, new_h), Image.Resampling.LANCZOS)
|
||||
if focus == "left":
|
||||
left = 0
|
||||
elif focus == "right":
|
||||
left = new_w - width
|
||||
else:
|
||||
left = (new_w - width) // 2
|
||||
if focus == "top":
|
||||
top = 0
|
||||
elif focus == "bottom":
|
||||
top = new_h - height
|
||||
else:
|
||||
top = (new_h - height) // 2
|
||||
return resized.crop((left, top, left + width, top + height))
|
||||
|
||||
|
||||
def save_jpeg(image: Image.Image, dest: Path, quality: int = 86) -> None:
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
image.save(dest, format="JPEG", quality=quality, optimize=True, progressive=True)
|
||||
|
||||
|
||||
def save_png(image: Image.Image, dest: Path) -> None:
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
image.save(dest, format="PNG", optimize=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
sources: dict[str, Image.Image] = {
|
||||
"hero1": open_image(USER_HERO[0]),
|
||||
"hero2": open_image(USER_HERO[1]),
|
||||
}
|
||||
failed = []
|
||||
for name, photo_id in PEXELS.items():
|
||||
try:
|
||||
sources[name] = open_image(fetch_pexels(photo_id))
|
||||
print(f"ok {name} {photo_id} {sources[name].size}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
failed.append(f"{name}/{photo_id}: {exc}")
|
||||
print(f"fail {name} {photo_id}: {exc}")
|
||||
|
||||
def src(*names: str) -> Image.Image:
|
||||
for name in names:
|
||||
if name in sources:
|
||||
return sources[name]
|
||||
raise KeyError(names)
|
||||
|
||||
jobs = [
|
||||
("slider-image-01-1132x657.jpg", src("hero1"), (1132, 657), "center", "jpeg"),
|
||||
("slider-image-02-1132x657.jpg", src("hero2"), (1132, 657), "center", "jpeg"),
|
||||
("box-nina-img-364x474.jpg", src("figurine", "tray", "hero1"), (364, 474), "center", "jpeg"),
|
||||
("box-nina-img-mini-217x328.jpg", src("spool", "extruder", "hero2"), (217, 328), "center", "jpeg"),
|
||||
("gallery-22-1200x800-original.jpg", src("figurine", "hero1"), (1200, 800), "center", "jpeg"),
|
||||
("gallery-22-582x299.jpg", src("figurine", "hero1"), (582, 299), "center", "jpeg"),
|
||||
("gallery-22-582x558.jpg", src("workshop", "hero2"), (582, 558), "center", "jpeg"),
|
||||
("gallery-4-1200x800-original.jpg", src("miniatures", "tray", "hero1"), (1200, 800), "center", "jpeg"),
|
||||
("gallery-23-287x252.jpg", src("miniatures", "tray", "hero1"), (287, 252), "center", "jpeg"),
|
||||
("gallery-23-1200x800-original.jpg", src("tray", "miniatures", "hero1"), (1200, 800), "center", "jpeg"),
|
||||
("quote-1-287x252.jpg", src("tray", "miniatures", "hero1"), (287, 252), "center", "jpeg"),
|
||||
("gallery-20-800x1200-original.jpg", src("spool", "extruder", "hero2"), (800, 1200), "center", "jpeg"),
|
||||
("gallery-20-287x252.jpg", src("spool", "extruder", "hero2"), (287, 252), "center", "jpeg"),
|
||||
("gallery-2-1200x800-original.jpg", src("neon", "neon2", "hero2"), (1200, 800), "center", "jpeg"),
|
||||
("gallery-21-287x252.jpg", src("neon", "neon2", "hero2"), (287, 252), "center", "jpeg"),
|
||||
("gallery-21-1200x800-original.jpg", src("lab", "workshop", "hero2"), (1200, 800), "center", "jpeg"),
|
||||
("video-1-549x384.jpg", src("nozzle", "extruder", "hero1"), (549, 384), "center", "jpeg"),
|
||||
("image-1-333x262.jpg", src("tray", "miniatures", "hero1"), (333, 262), "center", "jpeg"),
|
||||
("bg-breadcrumbs.jpg", src("hero2", "workshop", "lab"), (1920, 414), "center", "jpeg"),
|
||||
("bg-image-1.jpg", src("hero2", "workshop", "lab"), (1920, 950), "center", "jpeg"),
|
||||
("product-1-292x256.png", src("figurine", "hero1"), (292, 256), "center", "png"),
|
||||
("product-2-292x256.png", src("miniatures", "tray", "hero1"), (292, 256), "center", "png"),
|
||||
("product-3-292x256.png", src("tray", "hero1"), (292, 256), "center", "png"),
|
||||
("product-4-292x256.png", src("hex", "neon", "hero2"), (292, 256), "center", "png"),
|
||||
("product-5-292x256.png", src("spool", "extruder", "hero2"), (292, 256), "center", "png"),
|
||||
("product-6-292x256.png", src("lab", "workshop", "hero2"), (292, 256), "center", "png"),
|
||||
("product-mini-1-146x132.png", src("figurine", "hero1"), (146, 132), "center", "png"),
|
||||
("product-mini-2-146x132.png", src("tray", "hero1"), (146, 132), "center", "png"),
|
||||
("single-product-1-530x480.png", src("figurine", "hero1"), (530, 480), "center", "png"),
|
||||
("single-product-2-530x480.png", src("miniatures", "tray", "hero1"), (530, 480), "center", "png"),
|
||||
("single-product-3-530x480.png", src("hero1", "tray"), (530, 480), "center", "png"),
|
||||
]
|
||||
|
||||
for filename, image, size, focus, kind in jobs:
|
||||
framed = cover_crop(image, *size, focus=focus)
|
||||
dest = IMAGES / filename
|
||||
if kind == "png":
|
||||
save_png(framed, dest)
|
||||
else:
|
||||
save_jpeg(framed, dest)
|
||||
print(f"wrote {filename} {framed.size}")
|
||||
|
||||
if failed:
|
||||
print("download warnings:")
|
||||
for item in failed:
|
||||
print(" ", item)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -82,6 +82,41 @@ if _true "${FEATURE_SOCIAL_AI:-}"; then
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if _true "${FEATURE_SHOP:-}"; then
|
||||
if ! _true "${FEATURE_EMAIL_SMS:-}"; then
|
||||
echo "FEATURE_SHOP requires FEATURE_EMAIL_SMS" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! _true "${FEATURE_PAYMENTS:-}"; then
|
||||
echo "FEATURE_SHOP requires FEATURE_PAYMENTS" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if _true "${FEATURE_POS_SYNC:-}"; then
|
||||
if ! _true "${FEATURE_SHOP:-}"; then
|
||||
echo "FEATURE_POS_SYNC requires FEATURE_SHOP" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$DJANGO_ENV" == "prod" || "$DJANGO_ENV" == "beta" ]]; then
|
||||
required_vars+=(POS_WEBHOOK_SECRET)
|
||||
fi
|
||||
fi
|
||||
if _true "${FEATURE_EVENTS:-}"; then
|
||||
if ! _true "${FEATURE_EMAIL_SMS:-}"; then
|
||||
echo "FEATURE_EVENTS requires FEATURE_EMAIL_SMS" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! _true "${FEATURE_PAYMENTS:-}"; then
|
||||
echo "FEATURE_EVENTS requires FEATURE_PAYMENTS" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if _true "${FEATURE_SHIPPING:-}"; then
|
||||
if ! _true "${FEATURE_SHOP:-}"; then
|
||||
echo "FEATURE_SHIPPING requires FEATURE_SHOP" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
missing=()
|
||||
for var in "${required_vars[@]}"; do
|
||||
@@ -93,7 +128,7 @@ done
|
||||
if ((${#missing[@]} > 0)); then
|
||||
echo "Missing required environment variables in $ENV_FILE:" >&2
|
||||
printf ' - %s\n' "${missing[@]}" >&2
|
||||
echo "Copy .env.prod.example to ~/Documents/secrets/<slug>_site/<slug>_site_${DJANGO_ENV}.env and set values." >&2
|
||||
echo "Copy .env.prod.example to ~/Documents/secrets/print_forge/print_forge_${DJANGO_ENV}.env and set values." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd /app
|
||||
if [[ "${DJANGO_ENV:-}" == "dev" ]]; then
|
||||
echo "Syncing Python deps..."
|
||||
uv sync --frozen
|
||||
fi
|
||||
cd /app/site
|
||||
|
||||
wait_for_database() {
|
||||
@@ -14,7 +19,7 @@ wait_for_database() {
|
||||
import os
|
||||
import sys
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "client_site.settings")
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "print_forge.settings")
|
||||
|
||||
import django
|
||||
from django.db import connections
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sign in · Portal · {{ SITE_NAME }}</title>
|
||||
<link rel="icon" href="{% static 'brand/favicon-32.png' %}" type="image/png">
|
||||
<link rel="icon" href="{% static 'brand/favicon.ico' %}" type="image/x-icon">
|
||||
<link rel="apple-touch-icon" href="{% static 'brand/apple-touch-icon.png' %}">
|
||||
<link rel="stylesheet" href="//fonts.googleapis.com/css?family=Work+Sans:300,400,500,700%7CPoppins:400,600,700">
|
||||
<link rel="stylesheet" href="{% static 'css/portal.css' %}">
|
||||
</head>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.5 KiB |
@@ -1,373 +0,0 @@
|
||||
/* EXIT Realty / MKDRealtor public brand overrides on Real Estate theme */
|
||||
|
||||
:root {
|
||||
--monica-primary: #00626c;
|
||||
--monica-primary-light: #008898;
|
||||
--monica-ink: #212121;
|
||||
--monica-muted: #6b7280;
|
||||
--monica-surface: #f4f7f7;
|
||||
--monica-border: #d9e3e4;
|
||||
}
|
||||
|
||||
.footer-brand-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.footer-brand-row img {
|
||||
height: 40px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.about-subtitle {
|
||||
font-size: 18px;
|
||||
color: var(--monica-muted);
|
||||
}
|
||||
|
||||
.page .button-primary,
|
||||
.page .button-primary:focus,
|
||||
.page .button-primary:active,
|
||||
.cookie-consent-banner .button-primary,
|
||||
.cookie-consent-banner .button-primary:focus,
|
||||
.cookie-consent-banner .button-primary:active {
|
||||
background-color: var(--monica-primary) !important;
|
||||
border-color: var(--monica-primary) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.page .button-primary:hover,
|
||||
.cookie-consent-banner .button-primary:hover {
|
||||
background-color: var(--monica-primary-light) !important;
|
||||
border-color: var(--monica-primary-light) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
/* Theme default blue (#0045b6) → EXIT Realty teal on public chrome */
|
||||
html .page .text-primary {
|
||||
color: var(--monica-primary) !important;
|
||||
}
|
||||
.page .breadcrumbs-custom-path a,
|
||||
.page a.link-default {
|
||||
color: var(--monica-primary);
|
||||
}
|
||||
.page a.link-default:hover {
|
||||
color: var(--monica-primary-light);
|
||||
}
|
||||
|
||||
.page .button-gray-bordered:hover,
|
||||
.page .button-gray-bordered:active,
|
||||
.page .button-default:hover,
|
||||
.page .button-default:active,
|
||||
.page .button-accent-outline:hover,
|
||||
.page .button-accent-outline:active,
|
||||
.page .button-primary-outline:hover,
|
||||
.page .button-primary-outline:active,
|
||||
.page .button-primary-white:hover,
|
||||
.page .button-primary-white:active,
|
||||
.page .button.button-primary-lighten,
|
||||
.page .button.button-primary-lighten:focus {
|
||||
background-color: var(--monica-primary) !important;
|
||||
border-color: var(--monica-primary) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.page .button.button-primary-lighten:hover,
|
||||
.page .button.button-primary-lighten:active {
|
||||
background-color: var(--monica-ink) !important;
|
||||
border-color: var(--monica-ink) !important;
|
||||
}
|
||||
.page .button-accent-outline,
|
||||
.page .button-accent-outline:focus {
|
||||
border-color: var(--monica-primary) !important;
|
||||
}
|
||||
.page .button-video:hover {
|
||||
color: var(--monica-primary) !important;
|
||||
}
|
||||
.page .btn-primary,
|
||||
.page .btn-primary:active,
|
||||
.page .btn-primary:focus {
|
||||
background: var(--monica-primary) !important;
|
||||
border-color: var(--monica-primary) !important;
|
||||
}
|
||||
.page .box-chloe__icon,
|
||||
.page .box-light-icon,
|
||||
.page .box-minimal-icon .box-chloe__icon {
|
||||
color: var(--monica-primary);
|
||||
}
|
||||
|
||||
/* Mobile drawer active/hover — theme uses #0045b6 */
|
||||
.rd-navbar-fixed .rd-nav-item:hover .rd-nav-link,
|
||||
.rd-navbar-fixed .rd-nav-item.focus .rd-nav-link,
|
||||
.rd-navbar-fixed .rd-nav-item.active .rd-nav-link,
|
||||
.rd-navbar-fixed .rd-nav-item.opened .rd-nav-link {
|
||||
background: var(--monica-primary) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
/* Scroll-to-top (injected outside .page) */
|
||||
.ui-to-top {
|
||||
background: var(--monica-primary) !important;
|
||||
color: #fff !important;
|
||||
box-shadow: 0 6px 16px rgba(0, 98, 108, 0.35);
|
||||
}
|
||||
.ui-to-top:hover,
|
||||
.ui-to-top:focus {
|
||||
background: var(--monica-primary-light) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
/* Django flash messages */
|
||||
.flash {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 12px 0 0;
|
||||
}
|
||||
.flash li {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto 8px;
|
||||
padding: 12px 16px;
|
||||
background: #e8f4f5;
|
||||
border-left: 3px solid #00626c;
|
||||
color: #0d4a52;
|
||||
font-size: 14px;
|
||||
}
|
||||
.flash li.error,
|
||||
.flash li.danger {
|
||||
background: #fee2e2;
|
||||
border-left-color: #b91c1c;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
/* Form field errors */
|
||||
.form-wrap .errorlist {
|
||||
list-style: none;
|
||||
margin: 6px 0 0;
|
||||
padding: 0;
|
||||
color: #b91c1c;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Cookie consent — match public EXIT Realty teal brand */
|
||||
.cookie-consent-banner {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 11000;
|
||||
padding: 1rem 1.25rem;
|
||||
background: rgba(17, 24, 28, 0.96);
|
||||
border-top: 2px solid var(--monica-primary);
|
||||
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.28);
|
||||
font-family: "Work Sans", sans-serif;
|
||||
}
|
||||
.cookie-consent-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
.cookie-consent-text {
|
||||
flex: 1 1 420px;
|
||||
color: #d7dee3;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.45;
|
||||
margin: 0;
|
||||
font-family: "Work Sans", sans-serif;
|
||||
font-weight: 400;
|
||||
}
|
||||
.cookie-consent-text-short {
|
||||
display: none;
|
||||
}
|
||||
.cookie-consent-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cookie-consent-banner .button {
|
||||
min-width: 10rem;
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
font-family: "Work Sans", sans-serif;
|
||||
font-weight: 500;
|
||||
}
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
.footer-link-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: #7dd3da;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.footer-link-btn:hover {
|
||||
text-decoration: underline;
|
||||
color: #a8e8ed;
|
||||
}
|
||||
|
||||
.cookie-consent-link {
|
||||
color: #7dd3da;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.cookie-consent-link:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.legal-prose {
|
||||
max-width: 42rem;
|
||||
}
|
||||
.legal-prose .legal-lead {
|
||||
font-size: 1.05rem;
|
||||
color: var(--monica-ink);
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
.legal-prose h2 {
|
||||
font-size: 1.35rem;
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 0.75rem;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.legal-prose p {
|
||||
margin-bottom: 1rem;
|
||||
color: #4b5563;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.legal-prose .legal-updated {
|
||||
margin-top: 2.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--monica-muted);
|
||||
}
|
||||
|
||||
/* Contact form — breathe on narrow viewports */
|
||||
@media (max-width: 767.98px) {
|
||||
.contact-form-section > .container {
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
.contact-form-section .section-lg {
|
||||
padding-top: 2.5rem;
|
||||
padding-bottom: 2.5rem;
|
||||
}
|
||||
.contact-form-section h3 {
|
||||
margin-bottom: 1.25rem;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
.contact-message-form .form-wrap {
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.contact-message-form .form-label-outside {
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.contact-message-form .row-10 {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
.contact-message-form .row-10 > [class*="col-"] {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.footer-aside-copy {
|
||||
text-align: right;
|
||||
}
|
||||
.footer-aside-copy .rights {
|
||||
margin: 0;
|
||||
}
|
||||
.footer-made-by {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
color: #9ca3af;
|
||||
font-family: "Work Sans", sans-serif;
|
||||
}
|
||||
.footer-made-by a {
|
||||
color: #d1d5db;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.footer-made-by a:hover {
|
||||
color: #fff;
|
||||
}
|
||||
.footer-minimal .footer-made-by {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.footer-minimal-inner {
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pref-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 15px;
|
||||
color: var(--monica-ink);
|
||||
cursor: pointer;
|
||||
}
|
||||
.pref-check input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.cookie-consent-banner {
|
||||
padding: 0.625rem 0.75rem;
|
||||
padding-bottom: max(0.625rem, env(safe-area-inset-bottom));
|
||||
}
|
||||
.cookie-consent-content {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
.cookie-consent-text-short {
|
||||
display: inline;
|
||||
}
|
||||
.cookie-consent-text-full {
|
||||
display: none;
|
||||
}
|
||||
.cookie-consent-text {
|
||||
flex: none;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.cookie-consent-actions {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Keep hero slide-2 heading visually aligned with slide-1 h1 (SEO: one h1). */
|
||||
.swiper-slider-minimal .jumbotron-classic-content h1.font-weight-bold,
|
||||
.swiper-slider-minimal .jumbotron-classic-content h2.font-weight-bold {
|
||||
font-size: 2.5rem;
|
||||
line-height: 1.15;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.swiper-slider-minimal .jumbotron-classic-content h1.font-weight-bold,
|
||||
.swiper-slider-minimal .jumbotron-classic-content h2.font-weight-bold {
|
||||
font-size: 3.5rem;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
.swiper-slider-minimal .jumbotron-classic-content h1.font-weight-bold,
|
||||
.swiper-slider-minimal .jumbotron-classic-content h2.font-weight-bold {
|
||||
font-size: 4.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 270 KiB |
|
Before Width: | Height: | Size: 647 KiB |
|
Before Width: | Height: | Size: 455 KiB |
|
Before Width: | Height: | Size: 163 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 412 KiB |
@@ -1,193 +0,0 @@
|
||||
{% load static %}
|
||||
<!DOCTYPE html>
|
||||
<html class="wide wow-animation" lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}{{ SITE_NAME }}{% endblock %}</title>
|
||||
<meta name="description" content="{% block meta_description %}{{ SITE_NAME }} — {{ SITE_TAGLINE }}. {{ CONTACT_SERVICE_AREA }}.{% endblock %}">
|
||||
<meta name="robots" content="{% block meta_robots %}index, follow{% endblock %}">
|
||||
<link rel="canonical" href="{% block canonical_url %}{{ PUBLIC_SITE_URL }}{{ request.path }}{% endblock %}">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="{{ SITE_NAME }}">
|
||||
<meta property="og:locale" content="en_US">
|
||||
<meta property="og:title" content="{% block og_title %}{{ SITE_NAME }}{% endblock %}">
|
||||
<meta property="og:description" content="{% block og_description %}{{ SITE_NAME }} — {{ SITE_TAGLINE }}. {{ CONTACT_SERVICE_AREA }}.{% endblock %}">
|
||||
<meta property="og:url" content="{% block og_url %}{{ PUBLIC_SITE_URL }}{{ request.path }}{% endblock %}">
|
||||
{% block og_image %}
|
||||
<meta property="og:image" content="{{ PUBLIC_SITE_URL }}{% static 'images/slider-minimal-slide-1-1920x968.jpg' %}">
|
||||
<meta property="og:image:alt" content="{{ SITE_NAME }}">
|
||||
{% endblock %}
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="{% block twitter_title %}{{ SITE_NAME }}{% endblock %}">
|
||||
<meta name="twitter:description" content="{% block twitter_description %}{{ SITE_NAME }} — {{ SITE_TAGLINE }}. {{ CONTACT_SERVICE_AREA }}.{% endblock %}">
|
||||
<link rel="icon" href="{% static 'brand/favicon-32.png' %}" type="image/png">
|
||||
<link rel="apple-touch-icon" href="{% static 'brand/apple-touch-icon.png' %}">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Work+Sans:300,400,500,700,800%7CPoppins:300,400,700">
|
||||
<link rel="stylesheet" href="{% static 'css/bootstrap.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'css/fonts.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'css/style.css' %}" id="main-styles-link">
|
||||
<link rel="stylesheet" href="{% static 'css/theme-overrides.css' %}">
|
||||
{% block structured_data %}{% endblock %}
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body class="{% block body_class %}{% endblock %}">
|
||||
{% if tianji_enabled %}
|
||||
<div id="tianji-config"
|
||||
data-tracker-url="{{ tianji_tracker_url }}"
|
||||
data-website-id="{{ tianji_website_id }}"
|
||||
data-page-name="{{ page_name }}"
|
||||
{% if user.is_authenticated %}data-user-id="{{ user.pk }}"{% endif %}
|
||||
hidden></div>
|
||||
{% endif %}
|
||||
{% block body %}
|
||||
<div class="page">
|
||||
{% block header %}
|
||||
<header class="section page-header">
|
||||
<div class="rd-navbar-wrap">
|
||||
<nav class="rd-navbar rd-navbar-corporate" data-layout="rd-navbar-fixed" data-sm-layout="rd-navbar-fixed" data-md-layout="rd-navbar-fixed" data-md-device-layout="rd-navbar-fixed" data-lg-layout="rd-navbar-static" data-lg-device-layout="rd-navbar-static" data-lg-stick-up="true" data-lg-stick-up-offset="118px" data-xl-layout="rd-navbar-static" data-xl-device-layout="rd-navbar-static" data-xl-stick-up="true" data-xl-stick-up-offset="118px" data-xxl-layout="rd-navbar-static" data-xxl-device-layout="rd-navbar-static" data-xxl-stick-up-offset="118px" data-xxl-stick-up="true">
|
||||
<div class="rd-navbar-aside-outer">
|
||||
<div class="rd-navbar-aside">
|
||||
<div class="rd-navbar-panel">
|
||||
<button class="rd-navbar-toggle" data-rd-navbar-toggle="#rd-navbar-nav-wrap-1"><span></span></button>
|
||||
<a class="rd-navbar-brand" href="{% url 'public:home' %}" data-tianji-event="nav_home">
|
||||
<img src="{% static 'brand/logo.png' %}" alt="{{ SITE_NAME }}" width="151" height="44"/>
|
||||
</a>
|
||||
</div>
|
||||
<div class="rd-navbar-collapse">
|
||||
<button class="rd-navbar-collapse-toggle rd-navbar-fixed-element-1" data-rd-navbar-toggle="#rd-navbar-collapse-content-1"><span></span></button>
|
||||
<div class="rd-navbar-collapse-content" id="rd-navbar-collapse-content-1">
|
||||
{% if CONTACT_PHONE %}
|
||||
<article class="unit align-items-center">
|
||||
<div class="unit-left"><span class="icon icon-md icon-modern mdi mdi-phone"></span></div>
|
||||
<div class="unit-body"><a class="link-default" href="tel:{{ CONTACT_PHONE_TEL }}">{{ CONTACT_PHONE }}</a></div>
|
||||
</article>
|
||||
{% endif %}
|
||||
<article class="unit align-items-center">
|
||||
<div class="unit-left"><span class="icon icon-md icon-modern mdi mdi-map-marker"></span></div>
|
||||
<div class="unit-body"><span class="link-default">{% if CONTACT_ADDRESS %}{{ CONTACT_ADDRESS }}{% else %}{{ CONTACT_SERVICE_AREA }}{% endif %}</span></div>
|
||||
</article>
|
||||
<a class="button button-gray-bordered button-winona" href="{% url 'public:contact' %}" data-tianji-event="nav_get_in_touch">Get in touch</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rd-navbar-main-outer">
|
||||
<div class="rd-navbar-main">
|
||||
<div class="rd-navbar-nav-wrap" id="rd-navbar-nav-wrap-1">
|
||||
<ul class="rd-navbar-nav">
|
||||
<li class="rd-nav-item {% if page_name == 'home' %}active{% endif %}">
|
||||
<a class="rd-nav-link" href="{% url 'public:home' %}" data-tianji-event="nav_home">Home</a>
|
||||
</li>
|
||||
<li class="rd-nav-item {% if page_name == 'about' %}active{% endif %}">
|
||||
<a class="rd-nav-link" href="{% url 'public:about' %}" data-tianji-event="nav_about">About</a>
|
||||
</li>
|
||||
<li class="rd-nav-item {% if page_name == 'contact' %}active{% endif %}">
|
||||
<a class="rd-nav-link" href="{% url 'public:contact' %}" data-tianji-event="nav_contact">Contact</a>
|
||||
</li>
|
||||
{% for item in public_nav_extra %}
|
||||
<li class="rd-nav-item {% if page_name == item.section %}active{% endif %}">
|
||||
<a class="rd-nav-link" href="{% url item.url_name %}">{{ item.label }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
{% endblock %}
|
||||
|
||||
{% if messages %}
|
||||
<ul class="flash">
|
||||
{% for message in messages %}
|
||||
<li class="{{ message.tags }}">{{ message }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
{% block footer %}
|
||||
<footer class="section footer-advanced bg-gray-700">
|
||||
<div class="footer-advanced-main">
|
||||
<div class="container">
|
||||
<div class="row row-50">
|
||||
<div class="col-lg-6">
|
||||
<div class="footer-brand-row">
|
||||
<img src="{% static 'brand/logo.png' %}" alt="{{ SITE_NAME }}">
|
||||
<div>
|
||||
<h5 class="font-weight-bold text-uppercase text-white" style="margin:0">{{ SITE_NAME }}</h5>
|
||||
<span style="color:#7dd3da;font-size:12px">{{ SITE_TAGLINE }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="footer-advanced-text">Replace this footer blurb with the client’s positioning.{% if CONTACT_ADDRESS %} {{ CONTACT_ADDRESS }}.{% endif %}</p>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<h5 class="font-weight-bold text-uppercase text-white">Navigate</h5>
|
||||
<ul class="list-marked list-marked-sm">
|
||||
<li><a href="{% url 'public:home' %}">Home</a></li>
|
||||
<li><a href="{% url 'public:about' %}">About</a></li>
|
||||
<li><a href="{% url 'public:contact' %}">Contact</a></li>
|
||||
<li><a href="{% url 'public:terms' %}">Terms of Service</a></li>
|
||||
{% for item in public_nav_extra %}
|
||||
<li><a href="{% url item.url_name %}">{{ item.label }}</a></li>
|
||||
{% endfor %}
|
||||
{% if user.is_authenticated %}
|
||||
<li><a href="{% url 'dashboard:home' %}">Client portal</a></li>
|
||||
{% else %}
|
||||
<li><a href="{% url 'accounts:login' %}">Client portal</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
<p style="margin-top:16px">
|
||||
<a class="footer-link-btn" href="{% url 'public:terms' %}" data-tianji-event="footer_cookie_preferences">Cookie Preferences</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-advanced-aside">
|
||||
<div class="container">
|
||||
<div class="footer-advanced-layout">
|
||||
<a class="brand" href="{% url 'public:home' %}">
|
||||
<img src="{% static 'brand/logo.png' %}" alt="{{ SITE_NAME }}" width="151" height="44"/>
|
||||
</a>
|
||||
<div class="footer-aside-copy">
|
||||
<p class="rights">
|
||||
<span>© </span><span>{% now "Y" %} </span><span>{{ SITE_NAME }}</span><span>. </span>
|
||||
</p>
|
||||
<p class="footer-made-by">
|
||||
Made by <a href="{{ CREDIT_URL }}" target="_blank" rel="noopener noreferrer">{{ CREDIT_NAME }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
{% endblock %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% if tianji_enabled %}
|
||||
<div id="cookie-consent-banner" class="cookie-consent-banner" hidden role="dialog" aria-modal="true"
|
||||
aria-labelledby="cookie-consent-title" aria-describedby="cookie-consent-description">
|
||||
<div class="cookie-consent-content">
|
||||
<h2 id="cookie-consent-title" class="visually-hidden">Analytics notice</h2>
|
||||
<p class="cookie-consent-text" id="cookie-consent-description">
|
||||
<span class="cookie-consent-text-full">We use analytics to understand how visitors use our site. This helps us improve performance and content. We never sell or share your personal data with third parties for marketing. <a class="cookie-consent-link" href="{% url 'public:terms' %}">Terms of Service</a></span>
|
||||
<span class="cookie-consent-text-short">We use analytics on this site. <a class="cookie-consent-link" href="{% url 'public:terms' %}">Terms</a></span>
|
||||
</p>
|
||||
<div class="cookie-consent-actions">
|
||||
<button type="button" id="cookie-consent-acknowledge" class="button button-primary button-winona">Acknowledge</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="{% static 'js/tianji-consent.js' %}"></script>
|
||||
{% endif %}
|
||||
{% block tracking_events %}{% endblock %}
|
||||
<script src="{% static 'js/core.min.js' %}"></script>
|
||||
<script src="{% static 'js/script.js' %}"></script>
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -10,7 +10,9 @@
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<a class="btn btn-ghost btn-sm" href="{% url 'contacts:import' %}">Import CSV / Excel</a>
|
||||
<a class="btn btn-ghost btn-sm" href="{% url 'contacts:create' %}">New contact</a>
|
||||
<a class="btn btn-primary btn-sm" href="{% url 'messaging:campaign_list' %}">New campaign</a>
|
||||
{% if 'email_sms' in enabled_features %}
|
||||
<a class="btn btn-primary btn-sm" href="{% url 'email_sms:campaign_list' %}">New campaign</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -219,3 +219,19 @@ class PortalCreateMatchPromptTests(TestCase):
|
||||
self.existing.refresh_from_db()
|
||||
self.assertEqual(self.existing.first_name, "Alexander")
|
||||
self.assertIn("alt@example.com", self.existing.notes)
|
||||
|
||||
|
||||
class ContactListTests(TestCase):
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user(
|
||||
username="lister", password="test-pass-123"
|
||||
)
|
||||
self.client = Client()
|
||||
self.client.login(username="lister", password="test-pass-123")
|
||||
|
||||
def test_list_renders_and_links_campaigns(self):
|
||||
response = self.client.get(reverse("contacts:list"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Mailing list")
|
||||
self.assertContains(response, reverse("email_sms:campaign_list"))
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 6.1
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("core", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="storedfile",
|
||||
name="kind",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("campaign_image", "Campaign image"),
|
||||
("social_image", "Social image"),
|
||||
("social_video", "Social video"),
|
||||
("invoice_pdf", "Invoice PDF"),
|
||||
("product_image", "Product image"),
|
||||
],
|
||||
default="campaign_image",
|
||||
max_length=32,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 6.1
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("core", "0002_storedfile_product_image"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="storedfile",
|
||||
name="kind",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("campaign_image", "Campaign image"),
|
||||
("social_image", "Social image"),
|
||||
("social_video", "Social video"),
|
||||
("invoice_pdf", "Invoice PDF"),
|
||||
("product_image", "Product image"),
|
||||
("product_stl", "Product STL"),
|
||||
],
|
||||
default="campaign_image",
|
||||
max_length=32,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -29,6 +29,8 @@ class StoredFile(UUIDPrimaryKeyModel, TimeStampedModel):
|
||||
SOCIAL_IMAGE = "social_image", "Social image"
|
||||
SOCIAL_VIDEO = "social_video", "Social video"
|
||||
INVOICE_PDF = "invoice_pdf", "Invoice PDF"
|
||||
PRODUCT_IMAGE = "product_image", "Product image"
|
||||
PRODUCT_STL = "product_stl", "Product STL"
|
||||
|
||||
kind = models.CharField(
|
||||
max_length=32, choices=Kind.choices, default=Kind.CAMPAIGN_IMAGE
|
||||
|
||||
@@ -22,6 +22,7 @@ GROUP_ORDER = {
|
||||
"Outreach": 20,
|
||||
"Content": 30,
|
||||
"Social": 40,
|
||||
"Retail": 45,
|
||||
"Billing": 50,
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ from django.urls import reverse
|
||||
|
||||
|
||||
def _read_settings_source() -> str:
|
||||
return (Path(__file__).resolve().parent.parent / "client_site/settings/base.py").read_text()
|
||||
return (Path(__file__).resolve().parent.parent / "print_forge/settings/base.py").read_text()
|
||||
|
||||
|
||||
class FeatureFlagSettingsTests(SimpleTestCase):
|
||||
@@ -22,16 +22,30 @@ class FeatureFlagSettingsTests(SimpleTestCase):
|
||||
self.assertIn("FEATURE_PAYMENTS", src)
|
||||
self.assertIn("FEATURE_SOCIAL", src)
|
||||
self.assertIn("FEATURE_SOCIAL_AI", src)
|
||||
self.assertIn("FEATURE_SHOP", src)
|
||||
self.assertIn("FEATURE_POS_SYNC", src)
|
||||
self.assertIn("FEATURE_EVENTS", src)
|
||||
self.assertIn("FEATURE_SHIPPING", src)
|
||||
self.assertIn("email_sms.apps.EmailSmsConfig", src)
|
||||
self.assertIn("directmail.apps.DirectmailConfig", src)
|
||||
self.assertIn("blog.apps.BlogConfig", src)
|
||||
self.assertIn("payments.apps.PaymentsConfig", src)
|
||||
self.assertIn("social_ai.apps.SocialAiConfig", src)
|
||||
self.assertIn("shop.apps.ShopConfig", src)
|
||||
self.assertIn("pos_sync.apps.PosSyncConfig", src)
|
||||
self.assertIn("events.apps.EventsConfig", src)
|
||||
self.assertIn("shipping.apps.ShippingConfig", src)
|
||||
|
||||
def test_payments_requires_email_sms_in_source(self):
|
||||
src = _read_settings_source()
|
||||
self.assertIn("FEATURE_PAYMENTS requires FEATURE_EMAIL_SMS", src)
|
||||
self.assertIn("FEATURE_SOCIAL_AI requires FEATURE_SOCIAL", src)
|
||||
self.assertIn("FEATURE_SHOP requires FEATURE_EMAIL_SMS", src)
|
||||
self.assertIn("FEATURE_SHOP requires FEATURE_PAYMENTS", src)
|
||||
self.assertIn("FEATURE_POS_SYNC requires FEATURE_SHOP", src)
|
||||
self.assertIn("FEATURE_EVENTS requires FEATURE_EMAIL_SMS", src)
|
||||
self.assertIn("FEATURE_EVENTS requires FEATURE_PAYMENTS", src)
|
||||
self.assertIn("FEATURE_SHIPPING requires FEATURE_SHOP", src)
|
||||
|
||||
|
||||
class AlwaysOnImportIsolationTests(SimpleTestCase):
|
||||
@@ -44,6 +58,10 @@ class AlwaysOnImportIsolationTests(SimpleTestCase):
|
||||
"payments",
|
||||
"social",
|
||||
"social_ai",
|
||||
"shop",
|
||||
"pos_sync",
|
||||
"events",
|
||||
"shipping",
|
||||
}
|
||||
|
||||
ALWAYS_ON = [
|
||||
@@ -52,7 +70,7 @@ class AlwaysOnImportIsolationTests(SimpleTestCase):
|
||||
"core/management/commands/dispatch_due.py",
|
||||
"core/shortener.py",
|
||||
"core/campaign_utm.py",
|
||||
"client_site/urls.py",
|
||||
"print_forge/urls.py",
|
||||
]
|
||||
|
||||
def test_no_module_level_optional_imports(self):
|
||||
@@ -78,11 +96,22 @@ class AlwaysOnImportIsolationTests(SimpleTestCase):
|
||||
|
||||
|
||||
class InstalledOptionalAppsTests(TestCase):
|
||||
"""Dev defaults install optional apps so the template is a working seed."""
|
||||
"""Dev defaults install optional apps so the catalog seed still tests."""
|
||||
|
||||
def test_dev_installs_catalog_apps(self):
|
||||
labels = {c.label for c in apps.get_app_configs()}
|
||||
for label in ("email_sms", "directmail", "blog", "payments", "social", "social_ai"):
|
||||
for label in (
|
||||
"email_sms",
|
||||
"directmail",
|
||||
"blog",
|
||||
"payments",
|
||||
"social",
|
||||
"social_ai",
|
||||
"shop",
|
||||
"pos_sync",
|
||||
"events",
|
||||
"shipping",
|
||||
):
|
||||
self.assertIn(label, labels)
|
||||
|
||||
def test_optional_urls_resolve_when_installed(self):
|
||||
@@ -92,3 +121,10 @@ class InstalledOptionalAppsTests(TestCase):
|
||||
self.assertTrue(reverse("payments:invoice_list").startswith("/portal/payments/"))
|
||||
self.assertTrue(reverse("social:composer").startswith("/portal/social/"))
|
||||
self.assertTrue(reverse("social_ai:generate").startswith("/portal/social/api/generate/"))
|
||||
self.assertTrue(reverse("shop:list").startswith("/shop/"))
|
||||
self.assertTrue(reverse("shop_portal:product_list").startswith("/portal/shop/"))
|
||||
self.assertTrue(reverse("shop_portal:sales").startswith("/portal/shop/sales/"))
|
||||
self.assertTrue(reverse("pos_sync:event_list").startswith("/portal/pos/"))
|
||||
self.assertTrue(reverse("events:list").startswith("/events/"))
|
||||
self.assertTrue(reverse("events_portal:event_list").startswith("/portal/events/"))
|
||||
self.assertTrue(reverse("shipping:shipment_list").startswith("/portal/shipping/"))
|
||||
|
||||
@@ -27,6 +27,36 @@
|
||||
<div class="value">{{ open_invoices|default:0 }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if 'shop' in enabled_features %}
|
||||
<div class="stat-card">
|
||||
<div class="label">Open shop orders</div>
|
||||
<div class="value">{{ open_shop_orders|default:0 }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Sales (30 days)</div>
|
||||
<div class="value">{{ shop_sales_30d|default:0 }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Shop revenue (30 days)</div>
|
||||
<div class="value" style="font-size:22px">{{ shop_revenue_30d|default:0 }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Low stock</div>
|
||||
<div class="value">{{ low_stock_products|default:0 }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if 'events' in enabled_features %}
|
||||
<div class="stat-card">
|
||||
<div class="label">Upcoming events</div>
|
||||
<div class="value">{{ upcoming_events|default:0 }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if 'shipping' in enabled_features %}
|
||||
<div class="stat-card">
|
||||
<div class="label">Unshipped orders</div>
|
||||
<div class="value">{{ unshipped_orders|default:0 }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="split">
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from events.models import Event, Ticket, TicketOrder
|
||||
|
||||
|
||||
@admin.register(Event)
|
||||
class EventAdmin(admin.ModelAdmin):
|
||||
list_display = ("title", "starts_at", "capacity", "price", "is_published")
|
||||
list_filter = ("is_published",)
|
||||
search_fields = ("title", "venue")
|
||||
prepopulated_fields = {"slug": ("title",)}
|
||||
|
||||
|
||||
@admin.register(TicketOrder)
|
||||
class TicketOrderAdmin(admin.ModelAdmin):
|
||||
list_display = ("number", "event", "email", "quantity", "status")
|
||||
list_filter = ("status",)
|
||||
search_fields = ("number", "email")
|
||||
|
||||
|
||||
@admin.register(Ticket)
|
||||
class TicketAdmin(admin.ModelAdmin):
|
||||
list_display = ("code", "event", "order")
|
||||
search_fields = ("code",)
|
||||
@@ -0,0 +1,12 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class EventsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "events"
|
||||
verbose_name = "Event ticketing"
|
||||
|
||||
def ready(self):
|
||||
from events import hooks
|
||||
|
||||
hooks.register()
|
||||
@@ -0,0 +1,33 @@
|
||||
from core.registry import (
|
||||
register_dashboard_collector,
|
||||
register_feature,
|
||||
register_portal_nav,
|
||||
register_public_nav,
|
||||
)
|
||||
|
||||
|
||||
def register() -> None:
|
||||
register_feature("events")
|
||||
register_public_nav(
|
||||
section="events", label="Events", url_name="events:list", order=35
|
||||
)
|
||||
register_portal_nav(
|
||||
section="events",
|
||||
label="Events",
|
||||
url_name="events_portal:event_list",
|
||||
group="Retail",
|
||||
order=30,
|
||||
)
|
||||
register_dashboard_collector(_dashboard)
|
||||
|
||||
|
||||
def _dashboard(request) -> dict:
|
||||
from django.utils import timezone
|
||||
|
||||
from events.models import Event
|
||||
|
||||
return {
|
||||
"upcoming_events": Event.objects.filter(
|
||||
is_published=True, starts_at__gte=timezone.now()
|
||||
).count()
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
# Generated by Django 6.1 on 2026-09-06 11:17
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Event',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('title', models.CharField(max_length=200)),
|
||||
('slug', models.SlugField(max_length=220, unique=True)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('starts_at', models.DateTimeField()),
|
||||
('venue', models.CharField(blank=True, max_length=200)),
|
||||
('capacity', models.PositiveIntegerField(default=0)),
|
||||
('price', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=10)),
|
||||
('currency', models.CharField(default='usd', max_length=8)),
|
||||
('is_published', models.BooleanField(default=False)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['starts_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TicketOrder',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('number', models.CharField(max_length=32, unique=True)),
|
||||
('email', models.EmailField(max_length=254)),
|
||||
('customer_name', models.CharField(blank=True, max_length=200)),
|
||||
('quantity', models.PositiveIntegerField(default=1)),
|
||||
('amount', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||
('currency', models.CharField(default='usd', max_length=8)),
|
||||
('status', models.CharField(choices=[('draft', 'Draft'), ('open', 'Open'), ('paid', 'Paid'), ('cancelled', 'Cancelled')], default='draft', max_length=16)),
|
||||
('stripe_checkout_session_id', models.CharField(blank=True, max_length=255)),
|
||||
('hosted_checkout_url', models.URLField(blank=True)),
|
||||
('paid_at', models.DateTimeField(blank=True, null=True)),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='ticket_orders', to='events.event')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Ticket',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('code', models.CharField(max_length=16, unique=True)),
|
||||
('attendee_name', models.CharField(blank=True, max_length=200)),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='tickets', to='events.event')),
|
||||
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tickets', to='events.ticketorder')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,111 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.text import slugify
|
||||
|
||||
from core.models import TimeStampedModel, UUIDPrimaryKeyModel
|
||||
|
||||
|
||||
class Event(UUIDPrimaryKeyModel, TimeStampedModel):
|
||||
title = models.CharField(max_length=200)
|
||||
slug = models.SlugField(max_length=220, unique=True)
|
||||
description = models.TextField(blank=True)
|
||||
starts_at = models.DateTimeField()
|
||||
venue = models.CharField(max_length=200, blank=True)
|
||||
capacity = models.PositiveIntegerField(default=0)
|
||||
price = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal("0"))
|
||||
currency = models.CharField(max_length=8, default="usd")
|
||||
is_published = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
ordering = ["starts_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.title
|
||||
|
||||
def get_absolute_url(self) -> str:
|
||||
return reverse("events:detail", kwargs={"slug": self.slug})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.slug:
|
||||
base = slugify(self.title)[:200] or "event"
|
||||
slug = base
|
||||
n = 2
|
||||
while Event.objects.filter(slug=slug).exclude(pk=self.pk).exists():
|
||||
slug = f"{base}-{n}"
|
||||
n += 1
|
||||
self.slug = slug
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def tickets_held(self, *, exclude_order_id=None) -> int:
|
||||
qs = self.ticket_orders.filter(
|
||||
status__in=[
|
||||
TicketOrder.Status.DRAFT,
|
||||
TicketOrder.Status.OPEN,
|
||||
TicketOrder.Status.PAID,
|
||||
]
|
||||
)
|
||||
if exclude_order_id:
|
||||
qs = qs.exclude(pk=exclude_order_id)
|
||||
return qs.aggregate(total=models.Sum("quantity"))["total"] or 0
|
||||
|
||||
@property
|
||||
def tickets_sold(self) -> int:
|
||||
return self.tickets_held()
|
||||
|
||||
@property
|
||||
def seats_remaining(self) -> int | None:
|
||||
if not self.capacity:
|
||||
return None
|
||||
return max(self.capacity - self.tickets_sold, 0)
|
||||
|
||||
@property
|
||||
def is_upcoming(self) -> bool:
|
||||
return self.starts_at >= timezone.now()
|
||||
|
||||
|
||||
class TicketOrder(UUIDPrimaryKeyModel, TimeStampedModel):
|
||||
class Status(models.TextChoices):
|
||||
DRAFT = "draft", "Draft"
|
||||
OPEN = "open", "Open"
|
||||
PAID = "paid", "Paid"
|
||||
CANCELLED = "cancelled", "Cancelled"
|
||||
|
||||
number = models.CharField(max_length=32, unique=True)
|
||||
event = models.ForeignKey(
|
||||
Event, on_delete=models.PROTECT, related_name="ticket_orders"
|
||||
)
|
||||
email = models.EmailField()
|
||||
customer_name = models.CharField(max_length=200, blank=True)
|
||||
quantity = models.PositiveIntegerField(default=1)
|
||||
amount = models.DecimalField(max_digits=10, decimal_places=2)
|
||||
currency = models.CharField(max_length=8, default="usd")
|
||||
status = models.CharField(
|
||||
max_length=16, choices=Status.choices, default=Status.DRAFT
|
||||
)
|
||||
stripe_checkout_session_id = models.CharField(max_length=255, blank=True)
|
||||
hosted_checkout_url = models.URLField(blank=True)
|
||||
paid_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.number} · {self.event}"
|
||||
|
||||
|
||||
class Ticket(UUIDPrimaryKeyModel, TimeStampedModel):
|
||||
order = models.ForeignKey(
|
||||
TicketOrder, on_delete=models.CASCADE, related_name="tickets"
|
||||
)
|
||||
event = models.ForeignKey(Event, on_delete=models.PROTECT, related_name="tickets")
|
||||
code = models.CharField(max_length=16, unique=True)
|
||||
attendee_name = models.CharField(max_length=200, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.code
|
||||
@@ -0,0 +1,13 @@
|
||||
from django.urls import path
|
||||
|
||||
from events import views
|
||||
|
||||
app_name = "events_portal"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.portal_event_list, name="event_list"),
|
||||
path("new/", views.portal_event_edit, name="event_new"),
|
||||
path("<uuid:pk>/", views.portal_event_detail, name="event_detail"),
|
||||
path("<uuid:pk>/edit/", views.portal_event_edit, name="event_edit"),
|
||||
path("webhooks/stripe/", views.stripe_webhook, name="stripe_webhook"),
|
||||
]
|
||||
@@ -0,0 +1,13 @@
|
||||
from django.urls import path
|
||||
|
||||
from events import views
|
||||
|
||||
app_name = "events"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.event_list, name="list"),
|
||||
path("order/<uuid:pk>/success/", views.checkout_success, name="checkout_success"),
|
||||
path("order/<uuid:pk>/cancel/", views.checkout_cancel, name="checkout_cancel"),
|
||||
path("<slug:slug>/", views.event_detail, name="detail"),
|
||||
path("<slug:slug>/buy/", views.buy_tickets, name="buy"),
|
||||
]
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Event capacity, Stripe ticket checkout, and confirmation email."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from events.models import Event, Ticket, TicketOrder
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EventsError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _stripe():
|
||||
secret = (settings.STRIPE_SECRET_KEY or "").strip()
|
||||
if not secret:
|
||||
raise EventsError("STRIPE_SECRET_KEY is not configured")
|
||||
try:
|
||||
import stripe
|
||||
except ImportError as exc:
|
||||
raise EventsError("stripe package is not installed") from exc
|
||||
stripe.api_key = secret
|
||||
return stripe
|
||||
|
||||
|
||||
def next_ticket_order_number() -> str:
|
||||
today = date.today().strftime("%Y%m%d")
|
||||
prefix = f"TIX-{today}-"
|
||||
existing = TicketOrder.objects.filter(number__startswith=prefix).count()
|
||||
return f"{prefix}{existing + 1:03d}"
|
||||
|
||||
|
||||
def new_ticket_code() -> str:
|
||||
while True:
|
||||
code = secrets.token_hex(4).upper()
|
||||
if not Ticket.objects.filter(code=code).exists():
|
||||
return code
|
||||
|
||||
|
||||
def assert_capacity(
|
||||
event: Event, quantity: int, *, exclude_order_id=None
|
||||
) -> None:
|
||||
if quantity < 1:
|
||||
raise EventsError("Quantity must be at least 1.")
|
||||
if not event.capacity:
|
||||
return
|
||||
held = event.tickets_held(exclude_order_id=exclude_order_id)
|
||||
remaining = max(event.capacity - held, 0)
|
||||
if quantity > remaining:
|
||||
raise EventsError(f"Only {remaining} seats left for {event.title}.")
|
||||
|
||||
|
||||
def create_ticket_order(
|
||||
event: Event, *, email: str, customer_name: str = "", quantity: int = 1
|
||||
) -> TicketOrder:
|
||||
email = (email or "").strip()
|
||||
if not email:
|
||||
raise EventsError("Email is required.")
|
||||
if not event.is_published:
|
||||
raise EventsError("This event is not on sale.")
|
||||
assert_capacity(event, quantity)
|
||||
amount = event.price * quantity
|
||||
return TicketOrder.objects.create(
|
||||
number=next_ticket_order_number(),
|
||||
event=event,
|
||||
email=email,
|
||||
customer_name=(customer_name or "").strip(),
|
||||
quantity=quantity,
|
||||
amount=amount,
|
||||
currency=(event.currency or settings.STRIPE_CURRENCY or "usd").lower(),
|
||||
status=TicketOrder.Status.DRAFT,
|
||||
)
|
||||
|
||||
|
||||
def create_checkout_session(
|
||||
order: TicketOrder, *, success_url: str, cancel_url: str
|
||||
) -> str:
|
||||
stripe = _stripe()
|
||||
session = stripe.checkout.Session.create(
|
||||
mode="payment",
|
||||
customer_email=order.email or None,
|
||||
line_items=[
|
||||
{
|
||||
"quantity": order.quantity,
|
||||
"price_data": {
|
||||
"currency": (order.currency or "usd").lower(),
|
||||
"unit_amount": int(
|
||||
(order.event.price * Decimal("100")).quantize(Decimal("1"))
|
||||
),
|
||||
"product_data": {"name": order.event.title},
|
||||
},
|
||||
}
|
||||
],
|
||||
metadata={
|
||||
"ticket_order_id": str(order.pk),
|
||||
"ticket_order_number": order.number,
|
||||
},
|
||||
success_url=success_url,
|
||||
cancel_url=cancel_url,
|
||||
)
|
||||
order.stripe_checkout_session_id = session.id
|
||||
order.hosted_checkout_url = session.url or ""
|
||||
order.status = TicketOrder.Status.OPEN
|
||||
order.save(
|
||||
update_fields=[
|
||||
"stripe_checkout_session_id",
|
||||
"hosted_checkout_url",
|
||||
"status",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
return session.url or ""
|
||||
|
||||
|
||||
def _issue_tickets(order: TicketOrder) -> list[Ticket]:
|
||||
created = []
|
||||
for _ in range(order.quantity):
|
||||
created.append(
|
||||
Ticket.objects.create(
|
||||
order=order,
|
||||
event=order.event,
|
||||
code=new_ticket_code(),
|
||||
attendee_name=order.customer_name,
|
||||
)
|
||||
)
|
||||
return created
|
||||
|
||||
|
||||
def mark_paid(order: TicketOrder, *, stripe_id: str = "") -> None:
|
||||
if order.status == TicketOrder.Status.PAID:
|
||||
return
|
||||
with transaction.atomic():
|
||||
locked = TicketOrder.objects.select_for_update().get(pk=order.pk)
|
||||
if locked.status == TicketOrder.Status.PAID:
|
||||
return
|
||||
assert_capacity(
|
||||
locked.event, locked.quantity, exclude_order_id=locked.pk
|
||||
)
|
||||
_issue_tickets(locked)
|
||||
locked.status = TicketOrder.Status.PAID
|
||||
locked.paid_at = timezone.now()
|
||||
locked.save(update_fields=["status", "paid_at", "updated_at"])
|
||||
order.refresh_from_db()
|
||||
try:
|
||||
send_ticket_email(order)
|
||||
except Exception:
|
||||
logger.exception("ticket email failed for %s", order.number)
|
||||
|
||||
|
||||
def send_ticket_email(order: TicketOrder) -> bool:
|
||||
to_email = (order.email or "").strip()
|
||||
if not to_email:
|
||||
raise EventsError("Order has no email address")
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
|
||||
codes = ", ".join(t.code for t in order.tickets.all())
|
||||
name = order.customer_name or "there"
|
||||
when = timezone.localtime(order.event.starts_at).strftime("%b %d, %Y %-I:%M %p")
|
||||
subject = f"Tickets for {order.event.title} — {settings.SITE_NAME}"
|
||||
text = (
|
||||
f"Hi {name},\n\n"
|
||||
f"Your tickets for {order.event.title} on {when}:\n"
|
||||
f"{codes}\n\nOrder {order.number}\n"
|
||||
)
|
||||
html = (
|
||||
f"<p>Hi {name},</p>"
|
||||
f"<p>Tickets for <strong>{order.event.title}</strong> on {when}:</p>"
|
||||
f"<p><strong>{codes}</strong></p>"
|
||||
f"<p>Order {order.number}</p>"
|
||||
)
|
||||
mail = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=text,
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
to=[to_email],
|
||||
)
|
||||
mail.attach_alternative(html, "text/html")
|
||||
mail.send(fail_silently=False)
|
||||
return True
|
||||
@@ -0,0 +1,17 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Tickets · {{ event.title }}{% endblock %}
|
||||
{% block content %}
|
||||
<section class="section section-lg">
|
||||
<div class="container">
|
||||
<h1>Tickets · {{ event.title }}</h1>
|
||||
<p>{{ event.price }} {{ event.currency|upper }} each{% if event.seats_remaining is not None %} · {{ event.seats_remaining }} left{% endif %}</p>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<p><label>Email <input name="email" type="email" required></label></p>
|
||||
<p><label>Name <input name="customer_name"></label></p>
|
||||
<p><label>Quantity <input name="quantity" type="number" min="1" value="1"></label></p>
|
||||
<button class="button button-primary" type="submit">Pay with Stripe</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,8 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Checkout cancelled{% endblock %}
|
||||
{% block content %}
|
||||
<section class="section section-lg"><div class="container">
|
||||
<h1>Checkout cancelled</h1>
|
||||
<p>No charge for {{ order.event.title }}. <a href="{% url 'events:buy' order.event.slug %}">Try again</a>.</p>
|
||||
</div></section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,18 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ event.title }} · {{ SITE_NAME }}{% endblock %}
|
||||
{% block content %}
|
||||
<section class="section section-lg">
|
||||
<div class="container">
|
||||
<p><a href="{% url 'events:list' %}">← Events</a></p>
|
||||
<h1>{{ event.title }}</h1>
|
||||
<p class="muted">{{ event.starts_at|date:"F j, Y g:i A" }}{% if event.venue %} · {{ event.venue }}{% endif %}</p>
|
||||
<div>{{ event.description|linebreaks }}</div>
|
||||
<p>{{ event.price }} {{ event.currency|upper }}</p>
|
||||
{% if event.seats_remaining == 0 %}
|
||||
<p>Sold out.</p>
|
||||
{% else %}
|
||||
<p><a class="button button-primary" href="{% url 'events:buy' event.slug %}">Buy tickets</a></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,18 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Events · {{ SITE_NAME }}{% endblock %}
|
||||
{% block content %}
|
||||
<section class="section section-lg">
|
||||
<div class="container">
|
||||
<h1 class="text-uppercase">Events</h1>
|
||||
{% for event in events %}
|
||||
<article style="margin:0 0 32px">
|
||||
<h2><a href="{{ event.get_absolute_url }}">{{ event.title }}</a></h2>
|
||||
<p class="muted">{{ event.starts_at|date:"F j, Y g:i A" }}{% if event.venue %} · {{ event.venue }}{% endif %}</p>
|
||||
<p>{{ event.price }} {{ event.currency|upper }}{% if event.seats_remaining is not None %} · {{ event.seats_remaining }} seats left{% endif %}</p>
|
||||
</article>
|
||||
{% empty %}
|
||||
<p>No upcoming events.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,23 @@
|
||||
{% extends "portal_base.html" %}
|
||||
{% block title %}{{ event.title }} · Portal{% endblock %}
|
||||
{% block topbar_title %}{{ event.title }}{% endblock %}
|
||||
{% block portal_content %}
|
||||
<p><a class="btn btn-ghost" href="{% url 'events_portal:event_edit' event.pk %}">Edit</a></p>
|
||||
<p>{{ event.starts_at }} · {{ event.tickets_sold }} / {{ event.capacity|default:"∞" }} seats · {{ event.price }} {{ event.currency|upper }}</p>
|
||||
<table class="table">
|
||||
<thead><tr><th>Order</th><th>Email</th><th>Qty</th><th>Status</th><th>Codes</th></tr></thead>
|
||||
<tbody>
|
||||
{% for order in orders %}
|
||||
<tr>
|
||||
<td>{{ order.number }}</td>
|
||||
<td>{{ order.email }}</td>
|
||||
<td>{{ order.quantity }}</td>
|
||||
<td>{{ order.get_status_display }}</td>
|
||||
<td>{% for ticket in order.tickets.all %}{{ ticket.code }}{% if not forloop.last %}, {% endif %}{% endfor %}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="5" class="empty-state">No ticket orders yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,17 @@
|
||||
{% extends "portal_base.html" %}
|
||||
{% block title %}{% if event %}Edit{% else %}New{% endif %} event · Portal{% endblock %}
|
||||
{% block topbar_title %}{% if event %}Edit event{% else %}New event{% endif %}{% endblock %}
|
||||
{% block portal_content %}
|
||||
<form method="post" class="form-grid">
|
||||
{% csrf_token %}
|
||||
<div class="field"><label>Title</label><input name="title" required value="{{ event.title|default:'' }}"></div>
|
||||
<div class="field"><label>Slug</label><input name="slug" value="{{ event.slug|default:'' }}" placeholder="auto from title"></div>
|
||||
<div class="field"><label>Starts</label><input name="starts_at" type="datetime-local" required value="{% if event %}{{ event.starts_at|date:'Y-m-d\\TH:i' }}{% endif %}"></div>
|
||||
<div class="field"><label>Venue</label><input name="venue" value="{{ event.venue|default:'' }}"></div>
|
||||
<div class="field"><label>Capacity</label><input name="capacity" type="number" min="0" value="{{ event.capacity|default:0 }}"></div>
|
||||
<div class="field"><label>Price</label><input name="price" type="number" step="0.01" min="0" required value="{{ event.price|default:'0' }}"></div>
|
||||
<div class="field"><label>Description</label><textarea name="description" style="min-height:120px">{{ event.description|default:'' }}</textarea></div>
|
||||
<label><input type="checkbox" name="is_published" {% if event.is_published %}checked{% endif %}> Published</label>
|
||||
<button class="btn btn-primary" type="submit">Save</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,21 @@
|
||||
{% extends "portal_base.html" %}
|
||||
{% block title %}Events · Portal{% endblock %}
|
||||
{% block topbar_title %}Events{% endblock %}
|
||||
{% block portal_content %}
|
||||
<p><a class="btn btn-primary" href="{% url 'events_portal:event_new' %}">New event</a></p>
|
||||
<table class="table">
|
||||
<thead><tr><th>Title</th><th>When</th><th>Capacity</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
{% for event in events %}
|
||||
<tr>
|
||||
<td><a href="{% url 'events_portal:event_detail' event.pk %}">{{ event.title }}</a></td>
|
||||
<td>{{ event.starts_at }}</td>
|
||||
<td>{{ event.tickets_sold }} / {{ event.capacity|default:"∞" }}</td>
|
||||
<td>{% if event.is_published %}Published{% else %}Draft{% endif %}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="4" class="empty-state">No events yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,9 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Tickets {{ order.number }}{% endblock %}
|
||||
{% block content %}
|
||||
<section class="section section-lg"><div class="container">
|
||||
<h1>You're in</h1>
|
||||
<p>Order {{ order.number }} for {{ order.event.title }} is {{ order.get_status_display|lower }}.</p>
|
||||
<p>Ticket codes will be emailed to {{ order.email }}.</p>
|
||||
</div></section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,95 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core import mail
|
||||
from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from events.models import Event, Ticket, TicketOrder
|
||||
from events.services import EventsError, create_ticket_order, mark_paid
|
||||
|
||||
|
||||
def _event(**kwargs):
|
||||
defaults = dict(
|
||||
title="Friday Night Prerelease",
|
||||
starts_at=timezone.now() + timezone.timedelta(days=7),
|
||||
venue="Main shop",
|
||||
capacity=8,
|
||||
price=Decimal("25.00"),
|
||||
is_published=True,
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return Event.objects.create(**defaults)
|
||||
|
||||
|
||||
class EventPublicTests(TestCase):
|
||||
def test_list_hides_drafts_and_past(self):
|
||||
_event(title="Live prerelease")
|
||||
_event(title="Draft night", is_published=False)
|
||||
_event(
|
||||
title="Last week",
|
||||
starts_at=timezone.now() - timezone.timedelta(days=2),
|
||||
)
|
||||
response = Client().get(reverse("events:list"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Live prerelease")
|
||||
self.assertNotContains(response, "Draft night")
|
||||
self.assertNotContains(response, "Last week")
|
||||
|
||||
def test_buy_form_renders(self):
|
||||
event = _event()
|
||||
response = Client().get(reverse("events:buy", kwargs={"slug": event.slug}))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Pay with Stripe")
|
||||
|
||||
|
||||
class EventCapacityTests(TestCase):
|
||||
def test_capacity_blocks_overbook(self):
|
||||
event = _event(capacity=2)
|
||||
create_ticket_order(event, email="a@example.com", quantity=2)
|
||||
with self.assertRaises(EventsError):
|
||||
create_ticket_order(event, email="b@example.com", quantity=1)
|
||||
|
||||
def test_mark_paid_issues_tickets_and_email(self):
|
||||
event = _event(capacity=4)
|
||||
order = create_ticket_order(
|
||||
event, email="fan@example.com", customer_name="Lee", quantity=2
|
||||
)
|
||||
mark_paid(order)
|
||||
order.refresh_from_db()
|
||||
self.assertEqual(order.status, TicketOrder.Status.PAID)
|
||||
self.assertEqual(Ticket.objects.filter(order=order).count(), 2)
|
||||
self.assertEqual(event.tickets_sold, 2)
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
self.assertIn(event.title, mail.outbox[0].subject)
|
||||
|
||||
|
||||
class EventPortalTests(TestCase):
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user("host", password="test-pass-123")
|
||||
self.client = Client()
|
||||
self.client.login(username="host", password="test-pass-123")
|
||||
|
||||
def test_list_requires_login(self):
|
||||
self.assertEqual(Client().get(reverse("events_portal:event_list")).status_code, 302)
|
||||
|
||||
def test_create_published_event(self):
|
||||
starts = (timezone.now() + timezone.timedelta(days=3)).strftime("%Y-%m-%dT%H:%M")
|
||||
response = self.client.post(
|
||||
reverse("events_portal:event_new"),
|
||||
{
|
||||
"title": "Commander night",
|
||||
"starts_at": starts,
|
||||
"venue": "Back room",
|
||||
"capacity": "16",
|
||||
"price": "10.00",
|
||||
"is_published": "on",
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
event = Event.objects.get()
|
||||
self.assertEqual(event.slug, "commander-night")
|
||||
self.assertTrue(event.is_published)
|
||||
self.assertEqual(event.capacity, 16)
|
||||
@@ -0,0 +1,194 @@
|
||||
import logging
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpResponse, HttpResponseBadRequest
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from datetime import datetime
|
||||
from django.utils.text import slugify
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from events.models import Event, TicketOrder
|
||||
from events.services import (
|
||||
EventsError,
|
||||
create_checkout_session,
|
||||
create_ticket_order,
|
||||
mark_paid,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _site_base(request) -> str:
|
||||
base = (settings.PUBLIC_SITE_URL or "").rstrip("/")
|
||||
if base:
|
||||
return base
|
||||
return request.build_absolute_uri("/").rstrip("/")
|
||||
|
||||
|
||||
def event_list(request):
|
||||
events = Event.objects.filter(is_published=True, starts_at__gte=timezone.now())
|
||||
return render(request, "events/list.html", {"events": events})
|
||||
|
||||
|
||||
def event_detail(request, slug):
|
||||
event = get_object_or_404(Event, slug=slug, is_published=True)
|
||||
return render(request, "events/detail.html", {"event": event})
|
||||
|
||||
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def buy_tickets(request, slug):
|
||||
event = get_object_or_404(Event, slug=slug, is_published=True)
|
||||
if request.method == "POST":
|
||||
email = (request.POST.get("email") or "").strip()
|
||||
name = (request.POST.get("customer_name") or "").strip()
|
||||
try:
|
||||
quantity = int(request.POST.get("quantity") or "1")
|
||||
except ValueError:
|
||||
quantity = 0
|
||||
try:
|
||||
order = create_ticket_order(
|
||||
event, email=email, customer_name=name, quantity=quantity
|
||||
)
|
||||
base = _site_base(request)
|
||||
success = base + reverse(
|
||||
"events:checkout_success", kwargs={"pk": order.pk}
|
||||
)
|
||||
cancel = base + reverse("events:checkout_cancel", kwargs={"pk": order.pk})
|
||||
url = create_checkout_session(
|
||||
order,
|
||||
success_url=success + "?session_id={CHECKOUT_SESSION_ID}",
|
||||
cancel_url=cancel,
|
||||
)
|
||||
except EventsError as exc:
|
||||
messages.error(request, str(exc))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("ticket checkout failed")
|
||||
messages.error(request, f"Could not start checkout: {exc}")
|
||||
else:
|
||||
return redirect(url)
|
||||
return render(request, "events/buy.html", {"event": event})
|
||||
|
||||
|
||||
def checkout_success(request, pk):
|
||||
order = get_object_or_404(TicketOrder.objects.select_related("event"), pk=pk)
|
||||
return render(request, "events/success.html", {"order": order})
|
||||
|
||||
|
||||
def checkout_cancel(request, pk):
|
||||
order = get_object_or_404(TicketOrder.objects.select_related("event"), pk=pk)
|
||||
return render(request, "events/cancel.html", {"order": order})
|
||||
|
||||
|
||||
@login_required
|
||||
def portal_event_list(request):
|
||||
events = Event.objects.all().order_by("-starts_at")
|
||||
return render(request, "events/portal/list.html", {"events": events})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def portal_event_edit(request, pk=None):
|
||||
event = get_object_or_404(Event, pk=pk) if pk else None
|
||||
if request.method == "POST":
|
||||
title = (request.POST.get("title") or "").strip()
|
||||
description = (request.POST.get("description") or "").strip()
|
||||
venue = (request.POST.get("venue") or "").strip()
|
||||
slug = (request.POST.get("slug") or "").strip()
|
||||
starts_raw = (request.POST.get("starts_at") or "").strip()
|
||||
errors = []
|
||||
if not title:
|
||||
errors.append("Title is required.")
|
||||
starts_at = None
|
||||
if starts_raw:
|
||||
try:
|
||||
starts_at = datetime.fromisoformat(starts_raw)
|
||||
except ValueError:
|
||||
starts_at = None
|
||||
if starts_at is None:
|
||||
errors.append("Start date/time is required.")
|
||||
elif timezone.is_naive(starts_at):
|
||||
starts_at = timezone.make_aware(starts_at, timezone.get_current_timezone())
|
||||
try:
|
||||
capacity = int(request.POST.get("capacity") or "0")
|
||||
except ValueError:
|
||||
capacity = 0
|
||||
errors.append("Capacity must be a number.")
|
||||
try:
|
||||
price = Decimal(request.POST.get("price") or "0")
|
||||
if price < 0:
|
||||
raise InvalidOperation
|
||||
except Exception:
|
||||
price = None
|
||||
errors.append("Enter a valid price.")
|
||||
if errors:
|
||||
for err in errors:
|
||||
messages.error(request, err)
|
||||
else:
|
||||
if event is None:
|
||||
event = Event()
|
||||
event.title = title
|
||||
event.description = description
|
||||
event.venue = venue
|
||||
event.slug = slugify(slug)[:220] if slug else ""
|
||||
event.starts_at = starts_at
|
||||
event.capacity = max(capacity, 0)
|
||||
event.price = price
|
||||
event.currency = (settings.STRIPE_CURRENCY or "usd").lower()
|
||||
event.is_published = request.POST.get("is_published") == "on"
|
||||
event.save()
|
||||
messages.success(request, f"Saved {event.title}.")
|
||||
return redirect("events_portal:event_list")
|
||||
return render(request, "events/portal/edit.html", {"event": event})
|
||||
|
||||
|
||||
@login_required
|
||||
def portal_event_detail(request, pk):
|
||||
event = get_object_or_404(Event, pk=pk)
|
||||
orders = event.ticket_orders.select_related().prefetch_related("tickets")
|
||||
return render(
|
||||
request,
|
||||
"events/portal/detail.html",
|
||||
{"event": event, "orders": orders},
|
||||
)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_http_methods(["POST"])
|
||||
def stripe_webhook(request):
|
||||
secret = (settings.STRIPE_WEBHOOK_SECRET or "").strip()
|
||||
if not secret:
|
||||
logger.error("STRIPE_WEBHOOK_SECRET unset")
|
||||
return HttpResponseBadRequest("webhook not configured")
|
||||
try:
|
||||
import stripe
|
||||
except ImportError:
|
||||
return HttpResponseBadRequest("stripe not installed")
|
||||
sig = request.headers.get("Stripe-Signature", "")
|
||||
try:
|
||||
event = stripe.Webhook.construct_event(request.body, sig, secret)
|
||||
except Exception:
|
||||
logger.exception("events stripe webhook signature failed")
|
||||
return HttpResponseBadRequest("invalid signature")
|
||||
|
||||
obj = event.get("data", {}).get("object", {}) or {}
|
||||
if event.get("type") != "checkout.session.completed":
|
||||
return HttpResponse("ok")
|
||||
order_id = (obj.get("metadata") or {}).get("ticket_order_id") or ""
|
||||
order = None
|
||||
if order_id:
|
||||
order = TicketOrder.objects.filter(pk=order_id).first()
|
||||
if order is None:
|
||||
session_id = obj.get("id") or ""
|
||||
order = TicketOrder.objects.filter(
|
||||
stripe_checkout_session_id=session_id
|
||||
).first()
|
||||
if order and order.status != TicketOrder.Status.PAID:
|
||||
mark_paid(order, stripe_id=obj.get("id") or "")
|
||||
logger.info("ticket order %s marked paid", order.number)
|
||||
return HttpResponse("ok")
|
||||
@@ -7,7 +7,7 @@ import sys
|
||||
|
||||
def main() -> None:
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "client_site.settings")
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "print_forge.settings")
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from pos_sync.models import POSConnection, SyncEvent
|
||||
|
||||
|
||||
@admin.register(POSConnection)
|
||||
class POSConnectionAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "provider", "is_active")
|
||||
list_filter = ("provider", "is_active")
|
||||
|
||||
|
||||
@admin.register(SyncEvent)
|
||||
class SyncEventAdmin(admin.ModelAdmin):
|
||||
list_display = ("direction", "sku", "quantity", "status", "created_at")
|
||||
list_filter = ("direction", "status")
|
||||
search_fields = ("sku", "external_id")
|
||||
@@ -0,0 +1,12 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PosSyncConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "pos_sync"
|
||||
verbose_name = "POS API sync"
|
||||
|
||||
def ready(self):
|
||||
from pos_sync import hooks
|
||||
|
||||
hooks.register()
|
||||
@@ -0,0 +1,35 @@
|
||||
from core.registry import (
|
||||
register_dashboard_collector,
|
||||
register_dispatcher,
|
||||
register_feature,
|
||||
register_portal_nav,
|
||||
)
|
||||
|
||||
|
||||
def register() -> None:
|
||||
register_feature("pos_sync")
|
||||
register_portal_nav(
|
||||
section="pos_sync",
|
||||
label="POS sync",
|
||||
url_name="pos_sync:event_list",
|
||||
group="Retail",
|
||||
order=40,
|
||||
)
|
||||
register_dashboard_collector(_dashboard)
|
||||
register_dispatcher(_dispatch)
|
||||
|
||||
|
||||
def _dashboard(request) -> dict:
|
||||
from pos_sync.models import SyncEvent
|
||||
|
||||
return {
|
||||
"pos_failed_syncs": SyncEvent.objects.filter(
|
||||
status=SyncEvent.Status.FAILED
|
||||
).count()
|
||||
}
|
||||
|
||||
|
||||
def _dispatch() -> int:
|
||||
from pos_sync.services import dispatch_pending_outbound
|
||||
|
||||
return dispatch_pending_outbound()
|
||||
@@ -0,0 +1,52 @@
|
||||
# Generated by Django 6.1 on 2026-09-06 11:17
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='POSConnection',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=120)),
|
||||
('provider', models.CharField(choices=[('generic', 'Generic REST'), ('square', 'Square'), ('lightspeed', 'Lightspeed'), ('binderpos', 'BinderPOS'), ('crystalcommerce', 'CrystalCommerce')], default='generic', max_length=32)),
|
||||
('api_base_url', models.URLField(blank=True)),
|
||||
('api_token', models.CharField(blank=True, max_length=255)),
|
||||
('webhook_secret', models.CharField(blank=True, max_length=255)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SyncEvent',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('direction', models.CharField(choices=[('inbound', 'POS → site'), ('outbound', 'Site → POS')], max_length=16)),
|
||||
('status', models.CharField(choices=[('pending', 'Pending'), ('done', 'Done'), ('failed', 'Failed')], default='pending', max_length=16)),
|
||||
('sku', models.CharField(max_length=64)),
|
||||
('quantity', models.IntegerField(default=0)),
|
||||
('external_id', models.CharField(blank=True, max_length=255)),
|
||||
('payload', models.JSONField(blank=True, default=dict)),
|
||||
('error', models.TextField(blank=True)),
|
||||
('connection', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='events', to='pos_sync.posconnection')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,61 @@
|
||||
from django.db import models
|
||||
|
||||
from core.models import TimeStampedModel, UUIDPrimaryKeyModel
|
||||
|
||||
|
||||
class POSConnection(UUIDPrimaryKeyModel, TimeStampedModel):
|
||||
class Provider(models.TextChoices):
|
||||
GENERIC = "generic", "Generic REST"
|
||||
SQUARE = "square", "Square"
|
||||
LIGHTSPEED = "lightspeed", "Lightspeed"
|
||||
BINDERPOS = "binderpos", "BinderPOS"
|
||||
CRYSTALCOMMERCE = "crystalcommerce", "CrystalCommerce"
|
||||
|
||||
name = models.CharField(max_length=120)
|
||||
provider = models.CharField(
|
||||
max_length=32, choices=Provider.choices, default=Provider.GENERIC
|
||||
)
|
||||
api_base_url = models.URLField(blank=True)
|
||||
api_token = models.CharField(max_length=255, blank=True)
|
||||
webhook_secret = models.CharField(max_length=255, blank=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["name"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
class SyncEvent(UUIDPrimaryKeyModel, TimeStampedModel):
|
||||
class Direction(models.TextChoices):
|
||||
INBOUND = "inbound", "POS → site"
|
||||
OUTBOUND = "outbound", "Site → POS"
|
||||
|
||||
class Status(models.TextChoices):
|
||||
PENDING = "pending", "Pending"
|
||||
DONE = "done", "Done"
|
||||
FAILED = "failed", "Failed"
|
||||
|
||||
connection = models.ForeignKey(
|
||||
POSConnection,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="events",
|
||||
)
|
||||
direction = models.CharField(max_length=16, choices=Direction.choices)
|
||||
status = models.CharField(
|
||||
max_length=16, choices=Status.choices, default=Status.PENDING
|
||||
)
|
||||
sku = models.CharField(max_length=64)
|
||||
quantity = models.IntegerField(default=0)
|
||||
external_id = models.CharField(max_length=255, blank=True)
|
||||
payload = models.JSONField(default=dict, blank=True)
|
||||
error = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.direction} {self.sku} × {self.quantity} · {self.status}"
|
||||
@@ -0,0 +1,131 @@
|
||||
"""POS inventory sync. Inbound webhooks decrement shop stock; outbound queues reserve POS."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
|
||||
from pos_sync.models import POSConnection, SyncEvent
|
||||
from shop.models import Product
|
||||
from shop.services import ShopError, adjust_stock
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class POSSyncError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def active_connection() -> POSConnection | None:
|
||||
return POSConnection.objects.filter(is_active=True).order_by("created_at").first()
|
||||
|
||||
|
||||
def apply_inbound_sale(
|
||||
*,
|
||||
sku: str,
|
||||
quantity: int,
|
||||
external_id: str = "",
|
||||
payload: dict | None = None,
|
||||
connection: POSConnection | None = None,
|
||||
) -> SyncEvent:
|
||||
sku = (sku or "").strip()
|
||||
if not sku:
|
||||
raise POSSyncError("SKU is required.")
|
||||
if quantity < 1:
|
||||
raise POSSyncError("Quantity must be at least 1.")
|
||||
product = Product.objects.filter(sku__iexact=sku).first()
|
||||
if product is None:
|
||||
raise POSSyncError(f"Unknown SKU {sku}.")
|
||||
event = SyncEvent.objects.create(
|
||||
connection=connection or active_connection(),
|
||||
direction=SyncEvent.Direction.INBOUND,
|
||||
status=SyncEvent.Status.PENDING,
|
||||
sku=product.sku,
|
||||
quantity=quantity,
|
||||
external_id=external_id,
|
||||
payload=payload or {},
|
||||
)
|
||||
try:
|
||||
with transaction.atomic():
|
||||
locked = Product.objects.select_for_update().get(pk=product.pk)
|
||||
if locked.track_inventory and locked.fulfillment == Product.Fulfillment.STOCKED:
|
||||
adjust_stock(locked, -quantity)
|
||||
event.status = SyncEvent.Status.DONE
|
||||
event.save(update_fields=["status", "updated_at"])
|
||||
except ShopError as exc:
|
||||
event.status = SyncEvent.Status.FAILED
|
||||
event.error = str(exc)
|
||||
event.save(update_fields=["status", "error", "updated_at"])
|
||||
raise POSSyncError(str(exc)) from exc
|
||||
return event
|
||||
|
||||
|
||||
def enqueue_online_sale(order) -> list[SyncEvent]:
|
||||
"""Queue outbound POS reserves after an online shop sale."""
|
||||
events = []
|
||||
connection = active_connection()
|
||||
for item in order.items.all():
|
||||
sku = item.sku
|
||||
if not sku:
|
||||
continue
|
||||
events.append(
|
||||
SyncEvent.objects.create(
|
||||
connection=connection,
|
||||
direction=SyncEvent.Direction.OUTBOUND,
|
||||
status=SyncEvent.Status.PENDING,
|
||||
sku=sku,
|
||||
quantity=item.quantity,
|
||||
external_id=order.number,
|
||||
payload={"order_id": str(order.pk), "order_number": order.number},
|
||||
)
|
||||
)
|
||||
return events
|
||||
|
||||
|
||||
def _post_reserve(connection: POSConnection, event: SyncEvent) -> None:
|
||||
base = (connection.api_base_url or settings.POS_API_BASE_URL or "").rstrip("/")
|
||||
token = connection.api_token or (settings.POS_API_TOKEN or "")
|
||||
if not base:
|
||||
raise POSSyncError("POS API base URL is not configured.")
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
response = requests.post(
|
||||
f"{base}/inventory/reserve",
|
||||
json={
|
||||
"sku": event.sku,
|
||||
"quantity": event.quantity,
|
||||
"external_id": event.external_id,
|
||||
"source": "online",
|
||||
},
|
||||
headers=headers,
|
||||
timeout=15,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
def dispatch_pending_outbound() -> int:
|
||||
sent = 0
|
||||
for event in SyncEvent.objects.filter(
|
||||
direction=SyncEvent.Direction.OUTBOUND,
|
||||
status=SyncEvent.Status.PENDING,
|
||||
)[:50]:
|
||||
connection = event.connection or active_connection()
|
||||
try:
|
||||
if connection is None:
|
||||
raise POSSyncError("No active POS connection.")
|
||||
_post_reserve(connection, event)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("POS outbound failed for %s", event.sku)
|
||||
event.status = SyncEvent.Status.FAILED
|
||||
event.error = str(exc)
|
||||
event.save(update_fields=["status", "error", "updated_at"])
|
||||
else:
|
||||
event.status = SyncEvent.Status.DONE
|
||||
event.error = ""
|
||||
event.save(update_fields=["status", "error", "updated_at"])
|
||||
sent += 1
|
||||
return sent
|
||||
@@ -0,0 +1,22 @@
|
||||
{% extends "portal_base.html" %}
|
||||
{% block title %}POS sync · Portal{% endblock %}
|
||||
{% block topbar_title %}POS sync{% endblock %}
|
||||
{% block portal_content %}
|
||||
<p class="muted">Inbound POS sales decrement shop stock. Online sales queue outbound reserves.</p>
|
||||
<table class="table">
|
||||
<thead><tr><th>When</th><th>Direction</th><th>SKU</th><th>Qty</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
{% for event in events %}
|
||||
<tr>
|
||||
<td>{{ event.created_at }}</td>
|
||||
<td>{{ event.get_direction_display }}</td>
|
||||
<td>{{ event.sku }}</td>
|
||||
<td>{{ event.quantity }}</td>
|
||||
<td>{{ event.get_status_display }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="5" class="empty-state">No sync events yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,115 @@
|
||||
from decimal import Decimal
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import Client, TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
from pos_sync.models import POSConnection, SyncEvent
|
||||
from pos_sync.services import apply_inbound_sale, dispatch_pending_outbound, enqueue_online_sale
|
||||
from shop.models import Product
|
||||
from shop.services import add_to_cart, create_order_from_cart, mark_paid
|
||||
|
||||
|
||||
class POSInboundTests(TestCase):
|
||||
def setUp(self):
|
||||
self.product = Product.objects.create(
|
||||
name="Booster pack",
|
||||
sku="TCG-PACK",
|
||||
price=Decimal("4.99"),
|
||||
stock_qty=10,
|
||||
is_published=True,
|
||||
)
|
||||
|
||||
def test_inbound_sale_decrements_stock(self):
|
||||
event = apply_inbound_sale(sku="tcg-pack", quantity=3, external_id="pos-1")
|
||||
self.product.refresh_from_db()
|
||||
self.assertEqual(self.product.stock_qty, 7)
|
||||
self.assertEqual(event.status, SyncEvent.Status.DONE)
|
||||
|
||||
@override_settings(POS_WEBHOOK_SECRET="pos-secret")
|
||||
def test_webhook_requires_secret_and_updates_stock(self):
|
||||
url = reverse("pos_sync:inventory_webhook")
|
||||
anon = Client().post(
|
||||
url,
|
||||
data='{"sku":"TCG-PACK","quantity":1}',
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(anon.status_code, 401)
|
||||
ok = Client().post(
|
||||
url,
|
||||
data='{"sku":"TCG-PACK","quantity":2,"external_id":"reg-9"}',
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION="Bearer pos-secret",
|
||||
)
|
||||
self.assertEqual(ok.status_code, 200)
|
||||
self.product.refresh_from_db()
|
||||
self.assertEqual(self.product.stock_qty, 8)
|
||||
|
||||
|
||||
class POSOutboundTests(TestCase):
|
||||
def setUp(self):
|
||||
self.product = Product.objects.create(
|
||||
name="Figure",
|
||||
sku="FIG-1",
|
||||
price=Decimal("12.00"),
|
||||
stock_qty=5,
|
||||
is_published=True,
|
||||
)
|
||||
self.connection = POSConnection.objects.create(
|
||||
name="Counter",
|
||||
api_base_url="https://pos.example.com",
|
||||
api_token="tok",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
def test_paid_order_enqueues_outbound(self):
|
||||
session = self.client.session
|
||||
add_to_cart(session, self.product, 1)
|
||||
session.save()
|
||||
order = create_order_from_cart(self.client.session, email="a@example.com")
|
||||
mark_paid(order)
|
||||
event = SyncEvent.objects.get(direction=SyncEvent.Direction.OUTBOUND)
|
||||
self.assertEqual(event.sku, "FIG-1")
|
||||
self.assertEqual(event.status, SyncEvent.Status.PENDING)
|
||||
|
||||
def test_dispatch_posts_reserve(self):
|
||||
enqueue_online_sale(
|
||||
type(
|
||||
"O",
|
||||
(),
|
||||
{
|
||||
"number": "ORD-1",
|
||||
"pk": "x",
|
||||
"items": type(
|
||||
"M",
|
||||
(),
|
||||
{
|
||||
"all": lambda self: [
|
||||
type("I", (), {"sku": "FIG-1", "quantity": 1})()
|
||||
]
|
||||
},
|
||||
)(),
|
||||
},
|
||||
)()
|
||||
)
|
||||
with patch("pos_sync.services.requests.post") as post:
|
||||
post.return_value.raise_for_status = lambda: None
|
||||
sent = dispatch_pending_outbound()
|
||||
self.assertEqual(sent, 1)
|
||||
post.assert_called_once()
|
||||
self.assertIn("/inventory/reserve", post.call_args.args[0])
|
||||
event = SyncEvent.objects.get()
|
||||
self.assertEqual(event.status, SyncEvent.Status.DONE)
|
||||
|
||||
|
||||
class POSPortalTests(TestCase):
|
||||
def test_list_requires_login(self):
|
||||
self.assertEqual(Client().get(reverse("pos_sync:event_list")).status_code, 302)
|
||||
|
||||
def test_list_ok_when_logged_in(self):
|
||||
User = get_user_model()
|
||||
User.objects.create_user("clerk", password="test-pass-123")
|
||||
client = Client()
|
||||
client.login(username="clerk", password="test-pass-123")
|
||||
self.assertEqual(client.get(reverse("pos_sync:event_list")).status_code, 200)
|
||||
@@ -0,0 +1,10 @@
|
||||
from django.urls import path
|
||||
|
||||
from pos_sync import views
|
||||
|
||||
app_name = "pos_sync"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.event_list, name="event_list"),
|
||||
path("webhooks/inventory/", views.inventory_webhook, name="inventory_webhook"),
|
||||
]
|
||||
@@ -0,0 +1,70 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpResponse, HttpResponseBadRequest, JsonResponse
|
||||
from django.shortcuts import render
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from pos_sync.models import POSConnection, SyncEvent
|
||||
from pos_sync.services import POSSyncError, apply_inbound_sale
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _authorized(request) -> bool:
|
||||
expected = (settings.POS_WEBHOOK_SECRET or "").strip()
|
||||
if not expected:
|
||||
conn = POSConnection.objects.filter(is_active=True).exclude(
|
||||
webhook_secret=""
|
||||
).first()
|
||||
expected = (conn.webhook_secret if conn else "").strip()
|
||||
if not expected:
|
||||
return False
|
||||
header = request.headers.get("Authorization", "")
|
||||
token = ""
|
||||
if header.startswith("Bearer "):
|
||||
token = header[7:].strip()
|
||||
token = token or request.GET.get("token") or ""
|
||||
return token == expected
|
||||
|
||||
|
||||
@login_required
|
||||
def event_list(request):
|
||||
events = SyncEvent.objects.select_related("connection")[:200]
|
||||
connections = POSConnection.objects.all()
|
||||
return render(
|
||||
request,
|
||||
"pos_sync/list.html",
|
||||
{"events": events, "connections": connections},
|
||||
)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_http_methods(["POST"])
|
||||
def inventory_webhook(request):
|
||||
if not _authorized(request):
|
||||
return HttpResponse("unauthorized", status=401)
|
||||
try:
|
||||
payload = json.loads(request.body.decode() or "{}")
|
||||
except json.JSONDecodeError:
|
||||
return HttpResponseBadRequest("invalid json")
|
||||
sku = payload.get("sku") or payload.get("SKU") or ""
|
||||
try:
|
||||
quantity = int(payload.get("quantity") or payload.get("qty") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return HttpResponseBadRequest("invalid quantity")
|
||||
external_id = str(payload.get("external_id") or payload.get("id") or "")
|
||||
try:
|
||||
event = apply_inbound_sale(
|
||||
sku=sku,
|
||||
quantity=quantity,
|
||||
external_id=external_id,
|
||||
payload=payload,
|
||||
)
|
||||
except POSSyncError as exc:
|
||||
logger.warning("POS inbound rejected: %s", exc)
|
||||
return JsonResponse({"ok": False, "error": str(exc)}, status=400)
|
||||
return JsonResponse({"ok": True, "id": str(event.pk), "status": event.status})
|
||||
@@ -1,9 +1,9 @@
|
||||
"""ASGI config for client_site."""
|
||||
"""ASGI config for print_forge."""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "client_site.settings")
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "print_forge.settings")
|
||||
|
||||
application = get_asgi_application()
|
||||
@@ -9,6 +9,28 @@ from urllib.parse import urlparse
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
|
||||
def _load_dotenv(path: Path) -> None:
|
||||
"""Load repo-root `.env` for local runserver. Does not override existing env."""
|
||||
if not path.is_file():
|
||||
return
|
||||
for raw in path.read_text().splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key = key.strip()
|
||||
if not key or key in os.environ:
|
||||
continue
|
||||
value = value.strip()
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"\"", "'"}:
|
||||
value = value[1:-1]
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
if "test" not in sys.argv:
|
||||
_load_dotenv(BASE_DIR.parent / ".env")
|
||||
|
||||
|
||||
def env(key: str, default: str | None = None) -> str | None:
|
||||
return os.environ.get(key, default)
|
||||
|
||||
@@ -54,8 +76,8 @@ def database_config() -> dict:
|
||||
return {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"NAME": env("DB_NAME", "client_site"),
|
||||
"USER": env("DB_USER", "client_site"),
|
||||
"NAME": env("DB_NAME", "print_forge"),
|
||||
"USER": env("DB_USER", "print_forge"),
|
||||
"PASSWORD": env("DB_PASSWORD", ""),
|
||||
"HOST": env("DB_HOST"),
|
||||
"PORT": env("DB_PORT", "5432"),
|
||||
@@ -115,11 +137,27 @@ FEATURE_BLOG = env_bool("FEATURE_BLOG", _feature_default)
|
||||
FEATURE_PAYMENTS = env_bool("FEATURE_PAYMENTS", _feature_default)
|
||||
FEATURE_SOCIAL = env_bool("FEATURE_SOCIAL", _feature_default)
|
||||
FEATURE_SOCIAL_AI = env_bool("FEATURE_SOCIAL_AI", _feature_default)
|
||||
FEATURE_SHOP = env_bool("FEATURE_SHOP", _feature_default)
|
||||
FEATURE_POS_SYNC = env_bool("FEATURE_POS_SYNC", _feature_default)
|
||||
FEATURE_EVENTS = env_bool("FEATURE_EVENTS", _feature_default)
|
||||
FEATURE_SHIPPING = env_bool("FEATURE_SHIPPING", _feature_default)
|
||||
|
||||
if FEATURE_PAYMENTS and not FEATURE_EMAIL_SMS:
|
||||
raise ImproperlyConfigured("FEATURE_PAYMENTS requires FEATURE_EMAIL_SMS")
|
||||
if FEATURE_SOCIAL_AI and not FEATURE_SOCIAL:
|
||||
raise ImproperlyConfigured("FEATURE_SOCIAL_AI requires FEATURE_SOCIAL")
|
||||
if FEATURE_SHOP and not FEATURE_EMAIL_SMS:
|
||||
raise ImproperlyConfigured("FEATURE_SHOP requires FEATURE_EMAIL_SMS")
|
||||
if FEATURE_SHOP and not FEATURE_PAYMENTS:
|
||||
raise ImproperlyConfigured("FEATURE_SHOP requires FEATURE_PAYMENTS")
|
||||
if FEATURE_POS_SYNC and not FEATURE_SHOP:
|
||||
raise ImproperlyConfigured("FEATURE_POS_SYNC requires FEATURE_SHOP")
|
||||
if FEATURE_EVENTS and not FEATURE_EMAIL_SMS:
|
||||
raise ImproperlyConfigured("FEATURE_EVENTS requires FEATURE_EMAIL_SMS")
|
||||
if FEATURE_EVENTS and not FEATURE_PAYMENTS:
|
||||
raise ImproperlyConfigured("FEATURE_EVENTS requires FEATURE_PAYMENTS")
|
||||
if FEATURE_SHIPPING and not FEATURE_SHOP:
|
||||
raise ImproperlyConfigured("FEATURE_SHIPPING requires FEATURE_SHOP")
|
||||
|
||||
CORE_APPS = [
|
||||
"core.apps.CoreConfig",
|
||||
@@ -137,6 +175,10 @@ OPTIONAL_APPS = [
|
||||
(FEATURE_PAYMENTS, "payments.apps.PaymentsConfig"),
|
||||
(FEATURE_SOCIAL, "social.apps.SocialConfig"),
|
||||
(FEATURE_SOCIAL_AI, "social_ai.apps.SocialAiConfig"),
|
||||
(FEATURE_SHOP, "shop.apps.ShopConfig"),
|
||||
(FEATURE_POS_SYNC, "pos_sync.apps.PosSyncConfig"),
|
||||
(FEATURE_EVENTS, "events.apps.EventsConfig"),
|
||||
(FEATURE_SHIPPING, "shipping.apps.ShippingConfig"),
|
||||
]
|
||||
DJANGO_APPS = [
|
||||
"django.contrib.admin",
|
||||
@@ -170,12 +212,12 @@ MIDDLEWARE = [
|
||||
"public.middleware.UnderConstructionMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "client_site.urls"
|
||||
ROOT_URLCONF = "print_forge.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [BASE_DIR / "client_site" / "templates"],
|
||||
"DIRS": [BASE_DIR / "print_forge" / "templates"],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
@@ -185,13 +227,14 @@ TEMPLATES = [
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
"public.context_processors.site_branding",
|
||||
"public.context_processors.tianji_tracking",
|
||||
"public.context_processors.shop_cart",
|
||||
"core.context_processors.feature_registry",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = "client_site.wsgi.application"
|
||||
WSGI_APPLICATION = "print_forge.wsgi.application"
|
||||
|
||||
DATABASES = database_config()
|
||||
|
||||
@@ -218,7 +261,7 @@ USE_TZ = True
|
||||
STATIC_URL = "static/"
|
||||
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||
STATICFILES_DIRS = [
|
||||
BASE_DIR / "client_site" / "static",
|
||||
BASE_DIR / "print_forge" / "static",
|
||||
]
|
||||
# Uploaded blobs live in the DB (core.StoredFile). Default storage is
|
||||
# in-memory only so nothing is written to disk accidentally.
|
||||
@@ -230,6 +273,10 @@ STORAGES = {
|
||||
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
||||
},
|
||||
}
|
||||
# Keep product photos (15 MB) and STLs (25 MB) in RAM so Django never
|
||||
# spools them to /tmp. Larger social videos still use a request temp
|
||||
# file, then the bytes go into StoredFile.
|
||||
FILE_UPLOAD_MAX_MEMORY_SIZE = 26 * 1024 * 1024
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
@@ -244,13 +291,13 @@ LOGOUT_REDIRECT_URL = "accounts:login"
|
||||
SITE_UNDER_CONSTRUCTION = env_bool("SITE_UNDER_CONSTRUCTION", False)
|
||||
|
||||
# --- Branding / public site ---
|
||||
SITE_NAME = env("SITE_NAME", "Your Company")
|
||||
SITE_TAGLINE = env("SITE_TAGLINE", "A tagline for the public site")
|
||||
SITE_NAME = env("SITE_NAME", "Print Forge")
|
||||
SITE_TAGLINE = env("SITE_TAGLINE", "Handcrafted 3D printed toys")
|
||||
PUBLIC_SITE_URL = env("PUBLIC_SITE_URL", "")
|
||||
CONTACT_PHONE = env("CONTACT_PHONE", "")
|
||||
CONTACT_EMAIL = env("CONTACT_EMAIL", "hello@example.com")
|
||||
CONTACT_PHONE = env("CONTACT_PHONE", "630-632-0202")
|
||||
CONTACT_EMAIL = env("CONTACT_EMAIL", "Printforgeprints@gmail.com")
|
||||
CONTACT_ADDRESS = env("CONTACT_ADDRESS", "")
|
||||
CONTACT_SERVICE_AREA = env("CONTACT_SERVICE_AREA", "")
|
||||
CONTACT_SERVICE_AREA = env("CONTACT_SERVICE_AREA", "Chicagoland")
|
||||
CREDIT_NAME = env("CREDIT_NAME", "AI ML Operations, LLC")
|
||||
CREDIT_URL = env("CREDIT_URL", "https://aimloperations.com")
|
||||
|
||||
@@ -352,6 +399,20 @@ STRIPE_PUBLISHABLE_KEY = env("STRIPE_PUBLISHABLE_KEY", "")
|
||||
STRIPE_WEBHOOK_SECRET = env("STRIPE_WEBHOOK_SECRET", "")
|
||||
STRIPE_CURRENCY = env("STRIPE_CURRENCY", "usd")
|
||||
|
||||
# --- Shop / POS / shipping (FEATURE_SHOP and add-ons) ---
|
||||
# 0 = unlimited made-to-order print queue.
|
||||
SHOP_PRINT_QUEUE_LIMIT_MINUTES = int(
|
||||
env("SHOP_PRINT_QUEUE_LIMIT_MINUTES", "0") or "0"
|
||||
)
|
||||
POS_WEBHOOK_SECRET = env("POS_WEBHOOK_SECRET", "")
|
||||
POS_API_BASE_URL = env("POS_API_BASE_URL", "")
|
||||
POS_API_TOKEN = env("POS_API_TOKEN", "")
|
||||
EASYPOST_API_KEY = env("EASYPOST_API_KEY", "")
|
||||
SHIP_FROM_LINE1 = env("SHIP_FROM_LINE1", "")
|
||||
SHIP_FROM_CITY = env("SHIP_FROM_CITY", "")
|
||||
SHIP_FROM_STATE = env("SHIP_FROM_STATE", "")
|
||||
SHIP_FROM_ZIP = env("SHIP_FROM_ZIP", "")
|
||||
|
||||
# --- Nominatim (address suggest; server-side proxy only) ---
|
||||
# Self-hosted on ai-server-4080. Nominatim has no native API keys — gate with
|
||||
# LAN UFW + this Django proxy. Optional NOMINATIM_API_KEY is forwarded as
|
||||
@@ -361,7 +422,7 @@ NOMINATIM_TIMEOUT_SECONDS = int(env("NOMINATIM_TIMEOUT_SECONDS", "8") or "8")
|
||||
NOMINATIM_COUNTRY_CODES = env("NOMINATIM_COUNTRY_CODES", "us")
|
||||
NOMINATIM_USER_AGENT = env(
|
||||
"NOMINATIM_USER_AGENT",
|
||||
"client_site/1.0 (address-suggest; contact admin)",
|
||||
"print_forge/1.0 (address-suggest; contact admin)",
|
||||
)
|
||||
NOMINATIM_API_KEY = env("NOMINATIM_API_KEY", "")
|
||||
|
||||
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -264,6 +264,63 @@ body.portal {
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chart-placeholder.chart-daily {
|
||||
gap: 3px;
|
||||
padding: 16px 10px 8px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.chart-placeholder.chart-daily .chart-bar-col {
|
||||
flex: 1 0 8px;
|
||||
min-width: 6px;
|
||||
}
|
||||
.chart-placeholder.chart-daily .chart-bar-col .bar {
|
||||
min-height: 0;
|
||||
}
|
||||
.chart-placeholder.chart-daily .chart-bar-col .bar.is-zero {
|
||||
height: 0 !important;
|
||||
min-height: 0;
|
||||
opacity: 0.2;
|
||||
}
|
||||
.chart-placeholder.chart-daily .chart-bar-label {
|
||||
font-size: 10px;
|
||||
}
|
||||
.chart-placeholder.chart-revenue .chart-bar-col .bar {
|
||||
background: var(--monica-primary-light);
|
||||
}
|
||||
.hbar-chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.hbar-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(72px, 160px) 1fr auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
.hbar-label {
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.hbar-track {
|
||||
height: 16px;
|
||||
background: #e8f4f5;
|
||||
overflow: hidden;
|
||||
}
|
||||
.hbar-fill {
|
||||
height: 100%;
|
||||
background: var(--monica-primary);
|
||||
opacity: 0.8;
|
||||
min-width: 0;
|
||||
}
|
||||
.hbar-value {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
min-width: 2.5ch;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.consent-pills { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.preview-pane {
|
||||
@@ -726,3 +783,162 @@ body.portal {
|
||||
.utm-qr-actions { min-width: 160px; }
|
||||
.utm-qr-actions .btn { margin: 0 8px 8px 0; }
|
||||
#preview-body a { color: var(--monica-primary); }
|
||||
|
||||
.product-edit-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(240px, 320px);
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.product-edit-layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
.product-preview-col {
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
}
|
||||
.product-card-preview {
|
||||
background: #fff;
|
||||
border: 1px solid #dddddd;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.product-card-preview-body {
|
||||
padding: 20px 15px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.product-card-preview-figure {
|
||||
min-height: 160px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.product-card-preview-figure img {
|
||||
max-width: 100%;
|
||||
max-height: 200px;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
.product-card-preview-title {
|
||||
margin: 12px 0 6px;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.product-card-preview-price {
|
||||
font-weight: 600;
|
||||
color: var(--monica-ink);
|
||||
}
|
||||
.product-card-preview-desc {
|
||||
margin: 10px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--monica-muted);
|
||||
min-height: 1.2em;
|
||||
}
|
||||
.product-card-preview .product-color-dots {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.product-card-preview .product-color-dot {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 0 0 1px #d1d5db;
|
||||
display: inline-block;
|
||||
}
|
||||
.product-card-preview-panel {
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid #dddddd;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.color-rows { display: grid; gap: 12px; margin-bottom: 10px; }
|
||||
.color-row {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--monica-border);
|
||||
background: #fff;
|
||||
}
|
||||
.color-row-main {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 48px 72px auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.color-row input[type="color"] {
|
||||
width: 48px;
|
||||
height: 38px;
|
||||
padding: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.color-row-photos {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.photo-thumbs,
|
||||
.color-row-photos {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.photo-thumb {
|
||||
width: 72px;
|
||||
font-size: 11px;
|
||||
color: var(--monica-muted);
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
justify-items: center;
|
||||
}
|
||||
.photo-thumb img {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
object-fit: contain;
|
||||
background: #f3f4f6;
|
||||
border: 1px solid var(--monica-border);
|
||||
display: block;
|
||||
}
|
||||
.field .inline { display: flex; align-items: center; gap: 8px; margin-top: 8px; font-size: 13px; }
|
||||
.product-thumb {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
.product-thumb-empty { display: inline-block; }
|
||||
.stl-viewer {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 220px;
|
||||
height: 260px;
|
||||
background: #f5f5f5;
|
||||
border: 1px solid var(--monica-border);
|
||||
overflow: hidden;
|
||||
}
|
||||
.stl-viewer canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
touch-action: none;
|
||||
}
|
||||
.stl-viewer-status {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
color: var(--monica-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||