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
|
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0
|
||||||
|
|
||||||
# Leave empty for SQLite, or point at local compose Postgres:
|
# 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_UNDER_CONSTRUCTION=false
|
||||||
SITE_NAME=Your Company
|
SITE_NAME="Print Forge"
|
||||||
SITE_TAGLINE=A tagline for the public site
|
SITE_TAGLINE="Handcrafted 3D printed toys"
|
||||||
PUBLIC_SITE_URL=http://127.0.0.1:8000
|
PUBLIC_SITE_URL=http://127.0.0.1:8000
|
||||||
CONTACT_PHONE=
|
CONTACT_PHONE=630-632-0202
|
||||||
CONTACT_EMAIL=hello@example.com
|
CONTACT_EMAIL=Printforgeprints@gmail.com
|
||||||
CONTACT_ADDRESS=
|
CONTACT_ADDRESS=
|
||||||
CONTACT_SERVICE_AREA=
|
CONTACT_SERVICE_AREA=Chicagoland
|
||||||
CREDIT_NAME=AI ML Operations, LLC
|
CREDIT_NAME="AI ML Operations, LLC"
|
||||||
CREDIT_URL=https://aimloperations.com
|
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).
|
# Disabled apps are omitted from INSTALLED_APPS (no models, admin, URLs, nav).
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
FEATURE_EMAIL_SMS=true
|
FEATURE_EMAIL_SMS=true
|
||||||
FEATURE_DIRECT_MAIL=true
|
FEATURE_DIRECT_MAIL=false
|
||||||
FEATURE_BLOG=true
|
FEATURE_BLOG=false
|
||||||
FEATURE_PAYMENTS=true
|
FEATURE_PAYMENTS=true
|
||||||
FEATURE_SOCIAL=true
|
FEATURE_SOCIAL=false
|
||||||
FEATURE_SOCIAL_AI=true
|
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 (optional locally — form skips captcha when empty)
|
||||||
RECAPTCHA_PUBLIC_KEY=
|
RECAPTCHA_PUBLIC_KEY=
|
||||||
@@ -48,8 +52,8 @@ EMAIL_PORT=2525
|
|||||||
EMAIL_USE_TLS=true
|
EMAIL_USE_TLS=true
|
||||||
# Inbox From. Docker Compose .env cannot parse: "Name" <addr>
|
# Inbox From. Docker Compose .env cannot parse: "Name" <addr>
|
||||||
# Use bare address (SITE_NAME is wrapped) OR one quoted string:
|
# Use bare address (SITE_NAME is wrapped) OR one quoted string:
|
||||||
# DEFAULT_FROM_EMAIL="Your Company <noreply@example.com>"
|
# DEFAULT_FROM_EMAIL="Your Company <noreply@printforgeprints.com>"
|
||||||
DEFAULT_FROM_EMAIL=noreply@example.com
|
DEFAULT_FROM_EMAIL=Printforgeprints@gmail.com
|
||||||
# Console (default in DJANGO_ENV=dev) prints mail to container logs.
|
# Console (default in DJANGO_ENV=dev) prints mail to container logs.
|
||||||
# EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
|
# EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
|
||||||
SMTP2GO_SMS_API_KEY=
|
SMTP2GO_SMS_API_KEY=
|
||||||
@@ -74,6 +78,21 @@ STRIPE_PUBLISHABLE_KEY=
|
|||||||
STRIPE_WEBHOOK_SECRET=
|
STRIPE_WEBHOOK_SECRET=
|
||||||
STRIPE_CURRENCY=usd
|
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
|
# Social — SOCIAL_TOKEN_ENCRYPTION_KEY required when FEATURE_SOCIAL=true
|
||||||
# LinkedIn / Instagram App ID + Secret: Portal → Social accounts (not env).
|
# LinkedIn / Instagram App ID + Secret: Portal → Social accounts (not env).
|
||||||
META_APP_ID=
|
META_APP_ID=
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Secret env files for server-infra deploy. Never commit.
|
# Secret env files for server-infra deploy. Never commit.
|
||||||
# Copy on the control node:
|
# Copy on the control node:
|
||||||
# ~/Documents/secrets/<slug>_site/<slug>_site_prod.env
|
# ~/Documents/secrets/print_forge/print_forge_prod.env
|
||||||
# ~/Documents/secrets/<slug>_site/<slug>_site_beta.env
|
# ~/Documents/secrets/print_forge/print_forge_beta.env
|
||||||
#
|
#
|
||||||
# Docker Compose: if a secret contains $ (e.g. DATABASE_URL password), escape
|
# Docker Compose: if a secret contains $ (e.g. DATABASE_URL password), escape
|
||||||
# each $ as $$ or compose will treat $word as a variable.
|
# each $ as $$ or compose will treat $word as a variable.
|
||||||
@@ -9,34 +9,38 @@
|
|||||||
DJANGO_ENV=prod
|
DJANGO_ENV=prod
|
||||||
DJANGO_DEBUG=false
|
DJANGO_DEBUG=false
|
||||||
DJANGO_SECRET_KEY=replace-with-a-long-random-secret
|
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_UNDER_CONSTRUCTION=true
|
||||||
|
|
||||||
SITE_NAME=Your Company
|
SITE_NAME="Print Forge"
|
||||||
SITE_TAGLINE=A tagline for the public site
|
SITE_TAGLINE="Handcrafted 3D printed toys"
|
||||||
PUBLIC_SITE_URL=https://example.com
|
PUBLIC_SITE_URL=https://printforgeprints.com
|
||||||
DEFAULT_FROM_EMAIL=noreply@example.com
|
DEFAULT_FROM_EMAIL=Printforgeprints@gmail.com
|
||||||
CONTACT_PHONE=
|
CONTACT_PHONE=630-632-0202
|
||||||
CONTACT_EMAIL=
|
CONTACT_EMAIL=Printforgeprints@gmail.com
|
||||||
CONTACT_ADDRESS=
|
CONTACT_ADDRESS=
|
||||||
CONTACT_SERVICE_AREA=
|
CONTACT_SERVICE_AREA=Chicagoland
|
||||||
CREDIT_NAME=AI ML Operations, LLC
|
CREDIT_NAME="AI ML Operations, LLC"
|
||||||
CREDIT_URL=https://aimloperations.com
|
CREDIT_URL=https://aimloperations.com
|
||||||
|
|
||||||
# Optional catalog features — false unless purchased.
|
# Optional catalog features — false unless purchased.
|
||||||
# Turning a flag on later: set true, redeploy, migrate. Do not auto-drop tables.
|
# 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_DIRECT_MAIL=false
|
||||||
FEATURE_BLOG=false
|
FEATURE_BLOG=false
|
||||||
FEATURE_PAYMENTS=false
|
FEATURE_PAYMENTS=true
|
||||||
FEATURE_SOCIAL=false
|
FEATURE_SOCIAL=false
|
||||||
FEATURE_SOCIAL_AI=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)
|
# 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_PUBLIC_KEY=replace-me
|
||||||
RECAPTCHA_PRIVATE_KEY=replace-me
|
RECAPTCHA_PRIVATE_KEY=replace-me
|
||||||
@@ -63,6 +67,21 @@ STRIPE_PUBLISHABLE_KEY=
|
|||||||
STRIPE_WEBHOOK_SECRET=
|
STRIPE_WEBHOOK_SECRET=
|
||||||
STRIPE_CURRENCY=usd
|
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
|
# Required when FEATURE_SOCIAL=true
|
||||||
META_APP_ID=
|
META_APP_ID=
|
||||||
META_APP_SECRET=
|
META_APP_SECRET=
|
||||||
@@ -78,8 +97,8 @@ OLLAMA_TIMEOUT_SECONDS=120
|
|||||||
# Token must match SHORTENER_API_TOKENS on url_shortening_service (name:secret).
|
# Token must match SHORTENER_API_TOKENS on url_shortening_service (name:secret).
|
||||||
# Generate: python -c "import secrets; print(secrets.token_urlsafe(32))"
|
# Generate: python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||||
SHORTENER_BASE_URL=https://shortener.aimloperations.com
|
SHORTENER_BASE_URL=https://shortener.aimloperations.com
|
||||||
SHORTENER_API_TOKEN=acme:replace-me
|
SHORTENER_API_TOKEN=print_forge:replace-me
|
||||||
# UTM_SOURCE=acme # optional; default is a slug of SITE_NAME
|
# UTM_SOURCE=print-forge # optional; default is a slug of SITE_NAME
|
||||||
|
|
||||||
NOMINATIM_BASE_URL=http://10.0.0.128:8089
|
NOMINATIM_BASE_URL=http://10.0.0.128:8089
|
||||||
NOMINATIM_TIMEOUT_SECONDS=8
|
NOMINATIM_TIMEOUT_SECONDS=8
|
||||||
@@ -92,11 +111,11 @@ TIANJI_WEBSITE_ID=
|
|||||||
GUNICORN_WORKERS=2
|
GUNICORN_WORKERS=2
|
||||||
GUNICORN_BIND=0.0.0.0:8000
|
GUNICORN_BIND=0.0.0.0:8000
|
||||||
|
|
||||||
# BETA overrides — <slug>-preview.aimloperations.com
|
# BETA overrides — print-forge-preview.aimloperations.com
|
||||||
# DJANGO_ENV=beta
|
# DJANGO_ENV=beta
|
||||||
# SITE_UNDER_CONSTRUCTION=false
|
# SITE_UNDER_CONSTRUCTION=false
|
||||||
# PUBLIC_SITE_URL=https://example-preview.aimloperations.com
|
# PUBLIC_SITE_URL=https://print-forge-preview.aimloperations.com
|
||||||
# DATABASE_URL=postgres://westfarn:replace-db-password@10.0.0.230:5432/client_site_beta
|
# DATABASE_URL=postgres://westfarn:replace-db-password@10.0.0.230:5432/print_forge_beta
|
||||||
# WEB_PORT=8014
|
# WEB_PORT=8019
|
||||||
# SHORTENER_BASE_URL=https://shortener-beta.aimloperations.com
|
# 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:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [master]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ name: Deploy Beta
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- master
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
unit-tests:
|
unit-tests:
|
||||||
@@ -45,7 +45,7 @@ jobs:
|
|||||||
COMPOSE_DATABASE_URL DJANGO_ENV DJANGO_SECRET_KEY DJANGO_DEBUG \
|
COMPOSE_DATABASE_URL DJANGO_ENV DJANGO_SECRET_KEY DJANGO_DEBUG \
|
||||||
DJANGO_ALLOWED_HOSTS || true
|
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; }
|
cleanup() { docker compose -p "$PROJECT" down -v --remove-orphans || true; }
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ jobs:
|
|||||||
-e DJANGO_SECRET_KEY=test-secret-key \
|
-e DJANGO_SECRET_KEY=test-secret-key \
|
||||||
-e DJANGO_DEBUG=true \
|
-e DJANGO_DEBUG=true \
|
||||||
-e DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,testserver \
|
-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 \
|
-e SITE_UNDER_CONSTRUCTION=false \
|
||||||
web uv run python manage.py test
|
web uv run python manage.py test
|
||||||
|
|
||||||
@@ -66,9 +66,10 @@ jobs:
|
|||||||
SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra
|
SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra
|
||||||
ANSIBLE_PRIVATE_KEY_FILE: /home/westfarn/.ssh/ansible_deploy
|
ANSIBLE_PRIVATE_KEY_FILE: /home/westfarn/.ssh/ansible_deploy
|
||||||
steps:
|
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: |
|
run: |
|
||||||
"$SERVER_INFRA_ROOT/scripts/deploy.sh" \
|
"$SERVER_INFRA_ROOT/scripts/deploy.sh" \
|
||||||
--app client_site \
|
--app print_forge \
|
||||||
--env beta \
|
--env beta \
|
||||||
--ref "${{ gitea.sha }}"
|
--ref "${{ gitea.sha }}"
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ jobs:
|
|||||||
COMPOSE_DATABASE_URL DJANGO_ENV DJANGO_SECRET_KEY DJANGO_DEBUG \
|
COMPOSE_DATABASE_URL DJANGO_ENV DJANGO_SECRET_KEY DJANGO_DEBUG \
|
||||||
DJANGO_ALLOWED_HOSTS || true
|
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; }
|
cleanup() { docker compose -p "$PROJECT" down -v --remove-orphans || true; }
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ jobs:
|
|||||||
-e DJANGO_SECRET_KEY=test-secret-key \
|
-e DJANGO_SECRET_KEY=test-secret-key \
|
||||||
-e DJANGO_DEBUG=true \
|
-e DJANGO_DEBUG=true \
|
||||||
-e DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,testserver \
|
-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 \
|
-e SITE_UNDER_CONSTRUCTION=false \
|
||||||
web uv run python manage.py test
|
web uv run python manage.py test
|
||||||
|
|
||||||
@@ -64,9 +64,10 @@ jobs:
|
|||||||
SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra
|
SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra
|
||||||
ANSIBLE_PRIVATE_KEY_FILE: /home/westfarn/.ssh/ansible_deploy
|
ANSIBLE_PRIVATE_KEY_FILE: /home/westfarn/.ssh/ansible_deploy
|
||||||
steps:
|
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: |
|
run: |
|
||||||
"$SERVER_INFRA_ROOT/scripts/deploy.sh" \
|
"$SERVER_INFRA_ROOT/scripts/deploy.sh" \
|
||||||
--app client_site \
|
--app print_forge \
|
||||||
--env prod \
|
--env prod \
|
||||||
--ref "${{ gitea.sha }}"
|
--ref "${{ gitea.sha }}"
|
||||||
|
|||||||
@@ -177,3 +177,6 @@ cython_debug/
|
|||||||
|
|
||||||
# Django collectstatic output
|
# Django collectstatic output
|
||||||
staticfiles/
|
staticfiles/
|
||||||
|
|
||||||
|
# Downloaded Pexels originals used to regenerate public photos
|
||||||
|
.pexels-cache/
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update \
|
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/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
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)).
|
([Web Design & Hosting](https://aimloperations.com/web_design)).
|
||||||
|
|
||||||
Clone this repo (or **Use this template**), run `scripts/bootstrap-client.sh`,
|
Public site + client portal, plus purchased catalog add-ons: email & SMS,
|
||||||
set branding + `FEATURE_*` flags in the secret env file, deploy.
|
payments, e-commerce, and shipping automation.
|
||||||
|
|
||||||
Always included: public site (landing, about, contact) and client portal
|
## Catalog
|
||||||
(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 item | Flag | App |
|
| Catalog item | Flag | App |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Public site | (always on) | `public` |
|
| Public site | (always on) | `public` |
|
||||||
| Client portal + UTM | (always on) | `accounts`, `dashboard`, `leads`, `analytics`, `contacts`, `core` |
|
| Client portal + UTM | (always on) | `accounts`, `dashboard`, `leads`, `analytics`, `contacts`, `core` |
|
||||||
| Email & SMS | `FEATURE_EMAIL_SMS` | `email_sms` |
|
| Email & SMS | `FEATURE_EMAIL_SMS` | `email_sms` |
|
||||||
| Direct mail (PCM) | `FEATURE_DIRECT_MAIL` | `directmail` |
|
| Payments (Stripe) | `FEATURE_PAYMENTS` | `payments` |
|
||||||
| Blog | `FEATURE_BLOG` | `blog` |
|
| E-commerce storefront | `FEATURE_SHOP` | `shop` |
|
||||||
| Payments (Stripe) | `FEATURE_PAYMENTS` | `payments` (requires email/SMS) |
|
| Shipping automation | `FEATURE_SHIPPING` | `shipping` |
|
||||||
| Social consolidation | `FEATURE_SOCIAL` | `social` |
|
|
||||||
| AI social generator | `FEATURE_SOCIAL_AI` | `social_ai` (requires social) |
|
|
||||||
|
|
||||||
## New client
|
Off for this client: direct mail, blog, social, social AI, POS sync, event ticketing.
|
||||||
|
|
||||||
```bash
|
## Contact
|
||||||
# 1. Create repo from this Gitea template, clone it.
|
|
||||||
# 2. Rename package + branding placeholders:
|
|
||||||
./scripts/bootstrap-client.sh acme_site "Acme HVAC" acmehvac.com
|
|
||||||
|
|
||||||
# 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:
|
## Local
|
||||||
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:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
@@ -54,30 +36,21 @@ uv run python manage.py runserver
|
|||||||
# or: docker compose up
|
# 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
|
## Stack
|
||||||
|
|
||||||
Django 6 + uv · Postgres (prod/beta); SQLite locally · dj-queue · Docker ·
|
Django 6 + uv · Postgres (prod/beta); SQLite locally · dj-queue · Docker ·
|
||||||
server-infra Ansible deploy.
|
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:
|
db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: client_site
|
POSTGRES_DB: print_forge
|
||||||
POSTGRES_USER: client_site
|
POSTGRES_USER: print_forge
|
||||||
POSTGRES_PASSWORD: client_site
|
POSTGRES_PASSWORD: print_forge
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
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
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
@@ -19,8 +19,11 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
# Bind-mount source so edit → runserver auto-reload (no rebuild).
|
# Bind-mount source so edit → runserver auto-reload (no rebuild).
|
||||||
|
# Lockfile mounts + entrypoint `uv sync` pick up new deps without a rebuild.
|
||||||
volumes:
|
volumes:
|
||||||
- ./site:/app/site
|
- ./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:
|
# No required env_file — CI has no .env. Defaults below; for local secrets:
|
||||||
# docker compose --env-file .env up
|
# docker compose --env-file .env up
|
||||||
#
|
#
|
||||||
@@ -32,21 +35,25 @@ services:
|
|||||||
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-dev-only-change-me}
|
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-dev-only-change-me}
|
||||||
DJANGO_DEBUG: ${DJANGO_DEBUG:-true}
|
DJANGO_DEBUG: ${DJANGO_DEBUG:-true}
|
||||||
DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0}
|
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}
|
SITE_UNDER_CONSTRUCTION: ${SITE_UNDER_CONSTRUCTION:-false}
|
||||||
FEATURE_EMAIL_SMS: ${FEATURE_EMAIL_SMS:-true}
|
FEATURE_EMAIL_SMS: ${FEATURE_EMAIL_SMS:-true}
|
||||||
FEATURE_DIRECT_MAIL: ${FEATURE_DIRECT_MAIL:-true}
|
FEATURE_DIRECT_MAIL: ${FEATURE_DIRECT_MAIL:-false}
|
||||||
FEATURE_BLOG: ${FEATURE_BLOG:-true}
|
FEATURE_BLOG: ${FEATURE_BLOG:-false}
|
||||||
FEATURE_PAYMENTS: ${FEATURE_PAYMENTS:-true}
|
FEATURE_PAYMENTS: ${FEATURE_PAYMENTS:-true}
|
||||||
FEATURE_SOCIAL: ${FEATURE_SOCIAL:-true}
|
FEATURE_SOCIAL: ${FEATURE_SOCIAL:-false}
|
||||||
FEATURE_SOCIAL_AI: ${FEATURE_SOCIAL_AI:-true}
|
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}
|
PUBLIC_SITE_URL: ${PUBLIC_SITE_URL:-http://127.0.0.1:8000}
|
||||||
EMAIL_HOST: ${EMAIL_HOST:-mail.smtp2go.com}
|
EMAIL_HOST: ${EMAIL_HOST:-mail.smtp2go.com}
|
||||||
EMAIL_HOST_USER: ${EMAIL_HOST_USER:-}
|
EMAIL_HOST_USER: ${EMAIL_HOST_USER:-}
|
||||||
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD:-}
|
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD:-}
|
||||||
EMAIL_PORT: ${EMAIL_PORT:-2525}
|
EMAIL_PORT: ${EMAIL_PORT:-2525}
|
||||||
EMAIL_USE_TLS: ${EMAIL_USE_TLS:-true}
|
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.
|
# Dev default = console. Set smtp backend in `.env` for real SMTP2GO sends.
|
||||||
EMAIL_BACKEND: ${EMAIL_BACKEND:-django.core.mail.backends.console.EmailBackend}
|
EMAIL_BACKEND: ${EMAIL_BACKEND:-django.core.mail.backends.console.EmailBackend}
|
||||||
SMTP2GO_SMS_API_KEY: ${SMTP2GO_SMS_API_KEY:-}
|
SMTP2GO_SMS_API_KEY: ${SMTP2GO_SMS_API_KEY:-}
|
||||||
@@ -78,12 +85,14 @@ services:
|
|||||||
entrypoint: ["/worker-entrypoint.sh"]
|
entrypoint: ["/worker-entrypoint.sh"]
|
||||||
volumes:
|
volumes:
|
||||||
- ./site:/app/site
|
- ./site:/app/site
|
||||||
|
- ./pyproject.toml:/app/pyproject.toml
|
||||||
|
- ./uv.lock:/app/uv.lock
|
||||||
environment:
|
environment:
|
||||||
DJANGO_ENV: ${DJANGO_ENV:-dev}
|
DJANGO_ENV: ${DJANGO_ENV:-dev}
|
||||||
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-dev-only-change-me}
|
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-dev-only-change-me}
|
||||||
DJANGO_DEBUG: ${DJANGO_DEBUG:-true}
|
DJANGO_DEBUG: ${DJANGO_DEBUG:-true}
|
||||||
DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0}
|
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}
|
PUBLIC_SITE_URL: ${PUBLIC_SITE_URL:-http://127.0.0.1:8000}
|
||||||
DISPATCH_DUE_INTERVAL_SECONDS: ${DISPATCH_DUE_INTERVAL_SECONDS:-15}
|
DISPATCH_DUE_INTERVAL_SECONDS: ${DISPATCH_DUE_INTERVAL_SECONDS:-15}
|
||||||
EMAIL_HOST: ${EMAIL_HOST:-mail.smtp2go.com}
|
EMAIL_HOST: ${EMAIL_HOST:-mail.smtp2go.com}
|
||||||
@@ -91,7 +100,7 @@ services:
|
|||||||
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD:-}
|
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD:-}
|
||||||
EMAIL_PORT: ${EMAIL_PORT:-2525}
|
EMAIL_PORT: ${EMAIL_PORT:-2525}
|
||||||
EMAIL_USE_TLS: ${EMAIL_USE_TLS:-true}
|
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}
|
EMAIL_BACKEND: ${EMAIL_BACKEND:-django.core.mail.backends.console.EmailBackend}
|
||||||
SMTP2GO_SMS_API_KEY: ${SMTP2GO_SMS_API_KEY:-}
|
SMTP2GO_SMS_API_KEY: ${SMTP2GO_SMS_API_KEY:-}
|
||||||
SMTP2GO_WEBHOOK_SECRET: ${SMTP2GO_WEBHOOK_SECRET:-}
|
SMTP2GO_WEBHOOK_SECRET: ${SMTP2GO_WEBHOOK_SECRET:-}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "web-django-template"
|
name = "print-forge"
|
||||||
version = "0.1.0"
|
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"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
@@ -16,6 +16,8 @@ dependencies = [
|
|||||||
"requests>=2.32.0",
|
"requests>=2.32.0",
|
||||||
"stripe>=11.0.0",
|
"stripe>=11.0.0",
|
||||||
"whitenoise>=6.9.0",
|
"whitenoise>=6.9.0",
|
||||||
|
"pillow>=12.3.0",
|
||||||
|
"rembg[cpu]>=2.0.83",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|||||||
@@ -16,19 +16,19 @@ fi
|
|||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
|
|
||||||
if [[ "$SLUG" == "client_site" ]]; then
|
if [[ "$SLUG" == "print_forge" ]]; then
|
||||||
echo "slug is already client_site — branding defaults only."
|
echo "slug is already print_forge — branding defaults only."
|
||||||
else
|
else
|
||||||
echo "Renaming package client_site → $SLUG"
|
echo "Renaming package print_forge → $SLUG"
|
||||||
if [[ -d site/client_site ]]; then
|
if [[ -d site/print_forge ]]; then
|
||||||
mv site/client_site "site/$SLUG"
|
mv site/print_forge "site/$SLUG"
|
||||||
fi
|
fi
|
||||||
grep -rl 'client_site' \
|
grep -rl 'print_forge' \
|
||||||
--exclude-dir=.git --exclude-dir=.venv --exclude-dir=__pycache__ \
|
--exclude-dir=.git --exclude-dir=.venv --exclude-dir=__pycache__ \
|
||||||
--exclude='*.png' --exclude='*.jpg' --exclude='*.woff*' --exclude='*.eot' \
|
--exclude='*.png' --exclude='*.jpg' --exclude='*.woff*' --exclude='*.eot' \
|
||||||
--exclude='uv.lock' . \
|
--exclude='uv.lock' . \
|
||||||
| while read -r f; do
|
| while read -r f; do
|
||||||
sed -i "s/client_site/${SLUG}/g" "$f"
|
sed -i "s/print_forge/${SLUG}/g" "$f"
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
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
|
cd /app/site
|
||||||
|
|
||||||
wait_for_database() {
|
wait_for_database() {
|
||||||
@@ -14,7 +19,7 @@ wait_for_database() {
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "client_site.settings")
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "print_forge.settings")
|
||||||
|
|
||||||
import django
|
import django
|
||||||
from django.db import connections
|
from django.db import connections
|
||||||
@@ -51,6 +56,6 @@ fi
|
|||||||
|
|
||||||
uv run python manage.py collectstatic --noinput
|
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}" \
|
--bind "${GUNICORN_BIND:-0.0.0.0:8000}" \
|
||||||
--workers "${GUNICORN_WORKERS:-2}"
|
--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
|
exit 1
|
||||||
fi
|
fi
|
||||||
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=()
|
missing=()
|
||||||
for var in "${required_vars[@]}"; do
|
for var in "${required_vars[@]}"; do
|
||||||
@@ -93,7 +128,7 @@ done
|
|||||||
if ((${#missing[@]} > 0)); then
|
if ((${#missing[@]} > 0)); then
|
||||||
echo "Missing required environment variables in $ENV_FILE:" >&2
|
echo "Missing required environment variables in $ENV_FILE:" >&2
|
||||||
printf ' - %s\n' "${missing[@]}" >&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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd /app
|
||||||
|
if [[ "${DJANGO_ENV:-}" == "dev" ]]; then
|
||||||
|
echo "Syncing Python deps..."
|
||||||
|
uv sync --frozen
|
||||||
|
fi
|
||||||
cd /app/site
|
cd /app/site
|
||||||
|
|
||||||
wait_for_database() {
|
wait_for_database() {
|
||||||
@@ -14,7 +19,7 @@ wait_for_database() {
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "client_site.settings")
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "print_forge.settings")
|
||||||
|
|
||||||
import django
|
import django
|
||||||
from django.db import connections
|
from django.db import connections
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Sign in · Portal · {{ SITE_NAME }}</title>
|
<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="//fonts.googleapis.com/css?family=Work+Sans:300,400,500,700%7CPoppins:400,600,700">
|
||||||
<link rel="stylesheet" href="{% static 'css/portal.css' %}">
|
<link rel="stylesheet" href="{% static 'css/portal.css' %}">
|
||||||
</head>
|
</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">
|
<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:import' %}">Import CSV / Excel</a>
|
||||||
<a class="btn btn-ghost btn-sm" href="{% url 'contacts:create' %}">New contact</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -219,3 +219,19 @@ class PortalCreateMatchPromptTests(TestCase):
|
|||||||
self.existing.refresh_from_db()
|
self.existing.refresh_from_db()
|
||||||
self.assertEqual(self.existing.first_name, "Alexander")
|
self.assertEqual(self.existing.first_name, "Alexander")
|
||||||
self.assertIn("alt@example.com", self.existing.notes)
|
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_IMAGE = "social_image", "Social image"
|
||||||
SOCIAL_VIDEO = "social_video", "Social video"
|
SOCIAL_VIDEO = "social_video", "Social video"
|
||||||
INVOICE_PDF = "invoice_pdf", "Invoice PDF"
|
INVOICE_PDF = "invoice_pdf", "Invoice PDF"
|
||||||
|
PRODUCT_IMAGE = "product_image", "Product image"
|
||||||
|
PRODUCT_STL = "product_stl", "Product STL"
|
||||||
|
|
||||||
kind = models.CharField(
|
kind = models.CharField(
|
||||||
max_length=32, choices=Kind.choices, default=Kind.CAMPAIGN_IMAGE
|
max_length=32, choices=Kind.choices, default=Kind.CAMPAIGN_IMAGE
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ GROUP_ORDER = {
|
|||||||
"Outreach": 20,
|
"Outreach": 20,
|
||||||
"Content": 30,
|
"Content": 30,
|
||||||
"Social": 40,
|
"Social": 40,
|
||||||
|
"Retail": 45,
|
||||||
"Billing": 50,
|
"Billing": 50,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from django.urls import reverse
|
|||||||
|
|
||||||
|
|
||||||
def _read_settings_source() -> str:
|
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):
|
class FeatureFlagSettingsTests(SimpleTestCase):
|
||||||
@@ -22,16 +22,30 @@ class FeatureFlagSettingsTests(SimpleTestCase):
|
|||||||
self.assertIn("FEATURE_PAYMENTS", src)
|
self.assertIn("FEATURE_PAYMENTS", src)
|
||||||
self.assertIn("FEATURE_SOCIAL", src)
|
self.assertIn("FEATURE_SOCIAL", src)
|
||||||
self.assertIn("FEATURE_SOCIAL_AI", 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("email_sms.apps.EmailSmsConfig", src)
|
||||||
self.assertIn("directmail.apps.DirectmailConfig", src)
|
self.assertIn("directmail.apps.DirectmailConfig", src)
|
||||||
self.assertIn("blog.apps.BlogConfig", src)
|
self.assertIn("blog.apps.BlogConfig", src)
|
||||||
self.assertIn("payments.apps.PaymentsConfig", src)
|
self.assertIn("payments.apps.PaymentsConfig", src)
|
||||||
self.assertIn("social_ai.apps.SocialAiConfig", 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):
|
def test_payments_requires_email_sms_in_source(self):
|
||||||
src = _read_settings_source()
|
src = _read_settings_source()
|
||||||
self.assertIn("FEATURE_PAYMENTS requires FEATURE_EMAIL_SMS", src)
|
self.assertIn("FEATURE_PAYMENTS requires FEATURE_EMAIL_SMS", src)
|
||||||
self.assertIn("FEATURE_SOCIAL_AI requires FEATURE_SOCIAL", 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):
|
class AlwaysOnImportIsolationTests(SimpleTestCase):
|
||||||
@@ -44,6 +58,10 @@ class AlwaysOnImportIsolationTests(SimpleTestCase):
|
|||||||
"payments",
|
"payments",
|
||||||
"social",
|
"social",
|
||||||
"social_ai",
|
"social_ai",
|
||||||
|
"shop",
|
||||||
|
"pos_sync",
|
||||||
|
"events",
|
||||||
|
"shipping",
|
||||||
}
|
}
|
||||||
|
|
||||||
ALWAYS_ON = [
|
ALWAYS_ON = [
|
||||||
@@ -52,7 +70,7 @@ class AlwaysOnImportIsolationTests(SimpleTestCase):
|
|||||||
"core/management/commands/dispatch_due.py",
|
"core/management/commands/dispatch_due.py",
|
||||||
"core/shortener.py",
|
"core/shortener.py",
|
||||||
"core/campaign_utm.py",
|
"core/campaign_utm.py",
|
||||||
"client_site/urls.py",
|
"print_forge/urls.py",
|
||||||
]
|
]
|
||||||
|
|
||||||
def test_no_module_level_optional_imports(self):
|
def test_no_module_level_optional_imports(self):
|
||||||
@@ -78,11 +96,22 @@ class AlwaysOnImportIsolationTests(SimpleTestCase):
|
|||||||
|
|
||||||
|
|
||||||
class InstalledOptionalAppsTests(TestCase):
|
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):
|
def test_dev_installs_catalog_apps(self):
|
||||||
labels = {c.label for c in apps.get_app_configs()}
|
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)
|
self.assertIn(label, labels)
|
||||||
|
|
||||||
def test_optional_urls_resolve_when_installed(self):
|
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("payments:invoice_list").startswith("/portal/payments/"))
|
||||||
self.assertTrue(reverse("social:composer").startswith("/portal/social/"))
|
self.assertTrue(reverse("social:composer").startswith("/portal/social/"))
|
||||||
self.assertTrue(reverse("social_ai:generate").startswith("/portal/social/api/generate/"))
|
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 class="value">{{ open_invoices|default:0 }}</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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>
|
||||||
|
|
||||||
<div class="split">
|
<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:
|
def main() -> None:
|
||||||
"""Run administrative tasks."""
|
"""Run administrative tasks."""
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "client_site.settings")
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "print_forge.settings")
|
||||||
try:
|
try:
|
||||||
from django.core.management import execute_from_command_line
|
from django.core.management import execute_from_command_line
|
||||||
except ImportError as exc:
|
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
|
import os
|
||||||
|
|
||||||
from django.core.asgi import get_asgi_application
|
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()
|
application = get_asgi_application()
|
||||||
@@ -9,6 +9,28 @@ from urllib.parse import urlparse
|
|||||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
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:
|
def env(key: str, default: str | None = None) -> str | None:
|
||||||
return os.environ.get(key, default)
|
return os.environ.get(key, default)
|
||||||
|
|
||||||
@@ -54,8 +76,8 @@ def database_config() -> dict:
|
|||||||
return {
|
return {
|
||||||
"default": {
|
"default": {
|
||||||
"ENGINE": "django.db.backends.postgresql",
|
"ENGINE": "django.db.backends.postgresql",
|
||||||
"NAME": env("DB_NAME", "client_site"),
|
"NAME": env("DB_NAME", "print_forge"),
|
||||||
"USER": env("DB_USER", "client_site"),
|
"USER": env("DB_USER", "print_forge"),
|
||||||
"PASSWORD": env("DB_PASSWORD", ""),
|
"PASSWORD": env("DB_PASSWORD", ""),
|
||||||
"HOST": env("DB_HOST"),
|
"HOST": env("DB_HOST"),
|
||||||
"PORT": env("DB_PORT", "5432"),
|
"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_PAYMENTS = env_bool("FEATURE_PAYMENTS", _feature_default)
|
||||||
FEATURE_SOCIAL = env_bool("FEATURE_SOCIAL", _feature_default)
|
FEATURE_SOCIAL = env_bool("FEATURE_SOCIAL", _feature_default)
|
||||||
FEATURE_SOCIAL_AI = env_bool("FEATURE_SOCIAL_AI", _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:
|
if FEATURE_PAYMENTS and not FEATURE_EMAIL_SMS:
|
||||||
raise ImproperlyConfigured("FEATURE_PAYMENTS requires FEATURE_EMAIL_SMS")
|
raise ImproperlyConfigured("FEATURE_PAYMENTS requires FEATURE_EMAIL_SMS")
|
||||||
if FEATURE_SOCIAL_AI and not FEATURE_SOCIAL:
|
if FEATURE_SOCIAL_AI and not FEATURE_SOCIAL:
|
||||||
raise ImproperlyConfigured("FEATURE_SOCIAL_AI requires 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 = [
|
||||||
"core.apps.CoreConfig",
|
"core.apps.CoreConfig",
|
||||||
@@ -137,6 +175,10 @@ OPTIONAL_APPS = [
|
|||||||
(FEATURE_PAYMENTS, "payments.apps.PaymentsConfig"),
|
(FEATURE_PAYMENTS, "payments.apps.PaymentsConfig"),
|
||||||
(FEATURE_SOCIAL, "social.apps.SocialConfig"),
|
(FEATURE_SOCIAL, "social.apps.SocialConfig"),
|
||||||
(FEATURE_SOCIAL_AI, "social_ai.apps.SocialAiConfig"),
|
(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_APPS = [
|
||||||
"django.contrib.admin",
|
"django.contrib.admin",
|
||||||
@@ -170,12 +212,12 @@ MIDDLEWARE = [
|
|||||||
"public.middleware.UnderConstructionMiddleware",
|
"public.middleware.UnderConstructionMiddleware",
|
||||||
]
|
]
|
||||||
|
|
||||||
ROOT_URLCONF = "client_site.urls"
|
ROOT_URLCONF = "print_forge.urls"
|
||||||
|
|
||||||
TEMPLATES = [
|
TEMPLATES = [
|
||||||
{
|
{
|
||||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
"DIRS": [BASE_DIR / "client_site" / "templates"],
|
"DIRS": [BASE_DIR / "print_forge" / "templates"],
|
||||||
"APP_DIRS": True,
|
"APP_DIRS": True,
|
||||||
"OPTIONS": {
|
"OPTIONS": {
|
||||||
"context_processors": [
|
"context_processors": [
|
||||||
@@ -185,13 +227,14 @@ TEMPLATES = [
|
|||||||
"django.contrib.messages.context_processors.messages",
|
"django.contrib.messages.context_processors.messages",
|
||||||
"public.context_processors.site_branding",
|
"public.context_processors.site_branding",
|
||||||
"public.context_processors.tianji_tracking",
|
"public.context_processors.tianji_tracking",
|
||||||
|
"public.context_processors.shop_cart",
|
||||||
"core.context_processors.feature_registry",
|
"core.context_processors.feature_registry",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
WSGI_APPLICATION = "client_site.wsgi.application"
|
WSGI_APPLICATION = "print_forge.wsgi.application"
|
||||||
|
|
||||||
DATABASES = database_config()
|
DATABASES = database_config()
|
||||||
|
|
||||||
@@ -218,7 +261,7 @@ USE_TZ = True
|
|||||||
STATIC_URL = "static/"
|
STATIC_URL = "static/"
|
||||||
STATIC_ROOT = BASE_DIR / "staticfiles"
|
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||||
STATICFILES_DIRS = [
|
STATICFILES_DIRS = [
|
||||||
BASE_DIR / "client_site" / "static",
|
BASE_DIR / "print_forge" / "static",
|
||||||
]
|
]
|
||||||
# Uploaded blobs live in the DB (core.StoredFile). Default storage is
|
# Uploaded blobs live in the DB (core.StoredFile). Default storage is
|
||||||
# in-memory only so nothing is written to disk accidentally.
|
# in-memory only so nothing is written to disk accidentally.
|
||||||
@@ -230,6 +273,10 @@ STORAGES = {
|
|||||||
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
"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"
|
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)
|
SITE_UNDER_CONSTRUCTION = env_bool("SITE_UNDER_CONSTRUCTION", False)
|
||||||
|
|
||||||
# --- Branding / public site ---
|
# --- Branding / public site ---
|
||||||
SITE_NAME = env("SITE_NAME", "Your Company")
|
SITE_NAME = env("SITE_NAME", "Print Forge")
|
||||||
SITE_TAGLINE = env("SITE_TAGLINE", "A tagline for the public site")
|
SITE_TAGLINE = env("SITE_TAGLINE", "Handcrafted 3D printed toys")
|
||||||
PUBLIC_SITE_URL = env("PUBLIC_SITE_URL", "")
|
PUBLIC_SITE_URL = env("PUBLIC_SITE_URL", "")
|
||||||
CONTACT_PHONE = env("CONTACT_PHONE", "")
|
CONTACT_PHONE = env("CONTACT_PHONE", "630-632-0202")
|
||||||
CONTACT_EMAIL = env("CONTACT_EMAIL", "hello@example.com")
|
CONTACT_EMAIL = env("CONTACT_EMAIL", "Printforgeprints@gmail.com")
|
||||||
CONTACT_ADDRESS = env("CONTACT_ADDRESS", "")
|
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_NAME = env("CREDIT_NAME", "AI ML Operations, LLC")
|
||||||
CREDIT_URL = env("CREDIT_URL", "https://aimloperations.com")
|
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_WEBHOOK_SECRET = env("STRIPE_WEBHOOK_SECRET", "")
|
||||||
STRIPE_CURRENCY = env("STRIPE_CURRENCY", "usd")
|
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) ---
|
# --- Nominatim (address suggest; server-side proxy only) ---
|
||||||
# Self-hosted on ai-server-4080. Nominatim has no native API keys — gate with
|
# 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
|
# 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_COUNTRY_CODES = env("NOMINATIM_COUNTRY_CODES", "us")
|
||||||
NOMINATIM_USER_AGENT = env(
|
NOMINATIM_USER_AGENT = env(
|
||||||
"NOMINATIM_USER_AGENT",
|
"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", "")
|
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;
|
line-height: 1.2;
|
||||||
white-space: nowrap;
|
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; }
|
.consent-pills { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
.preview-pane {
|
.preview-pane {
|
||||||
@@ -726,3 +783,162 @@ body.portal {
|
|||||||
.utm-qr-actions { min-width: 160px; }
|
.utm-qr-actions { min-width: 160px; }
|
||||||
.utm-qr-actions .btn { margin: 0 8px 8px 0; }
|
.utm-qr-actions .btn { margin: 0 8px 8px 0; }
|
||||||
#preview-body a { color: var(--monica-primary); }
|
#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;
|
||||||
|
}
|
||||||