Stand up Print Forge as a 3D-printed toy shop with color variants and printer photography.

Replaces the client_site template branding, adds shop/shipping, and points beta CI at master for easy deploy.

Closes #1
This commit is contained in:
2026-09-06 08:28:04 -05:00
parent 8a97e3fbe2
commit b2029b6c0f
336 changed files with 9241 additions and 2537 deletions
+32 -13
View File
@@ -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=
+41 -22
View File
@@ -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
+4 -4
View File
@@ -3,7 +3,7 @@ name: Deploy Beta
on: on:
push: push:
branches: branches:
- main - master
jobs: jobs:
unit-tests: unit-tests:
@@ -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,9 @@ 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 - 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 }}"
+3 -3
View File
@@ -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,9 @@ 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 - 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 }}"
+3
View File
@@ -177,3 +177,6 @@ cython_debug/
# Django collectstatic output # Django collectstatic output
staticfiles/ staticfiles/
# Downloaded Pexels originals used to regenerate public photos
.pexels-cache/
+1 -1
View File
@@ -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
+26 -55
View File
@@ -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,19 @@ 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, NPM, CI.
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`.
+21 -12
View File
@@ -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:-}
+4 -2
View File
@@ -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]
+7 -7
View File
@@ -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
+7 -2
View File
@@ -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}"
+174
View File
@@ -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()
+35
View File
@@ -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
+6 -1
View File
@@ -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
+2 -1
View File
@@ -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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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;
}
}
Binary file not shown.
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 647 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 455 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 412 KiB

File diff suppressed because one or more lines are too long
-193
View File
@@ -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 clients 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>&copy;&nbsp;</span><span>{% now "Y" %}&nbsp;</span><span>{{ SITE_NAME }}</span><span>.&nbsp;</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>
+3 -1
View File
@@ -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>
+16
View File
@@ -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,
),
),
]
+2
View File
@@ -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
+1
View File
@@ -22,6 +22,7 @@ GROUP_ORDER = {
"Outreach": 20, "Outreach": 20,
"Content": 30, "Content": 30,
"Social": 40, "Social": 40,
"Retail": 45,
"Billing": 50, "Billing": 50,
} }
+39 -4
View File
@@ -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,9 @@ 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("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,28 @@
<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">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">
View File
+24
View File
@@ -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",)
+12
View File
@@ -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()
+33
View File
@@ -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()
}
+74
View File
@@ -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'],
},
),
]
View File
+111
View File
@@ -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
+13
View File
@@ -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"),
]
+13
View File
@@ -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"),
]
+188
View File
@@ -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
+17
View File
@@ -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 %}
+8
View File
@@ -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 %}
+18
View File
@@ -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 %}
+18
View File
@@ -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 %}
+95
View File
@@ -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)
+194
View File
@@ -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")
+1 -1
View File
@@ -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:
View File
+16
View File
@@ -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")
+12
View File
@@ -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()
+35
View File
@@ -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()
+52
View File
@@ -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'],
},
),
]
+61
View File
@@ -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}"
+131
View File
@@ -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 %}
+115
View File
@@ -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)
+10
View File
@@ -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"),
]
+70
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -726,3 +726,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;
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,352 @@
/* Print Forge public brand overrides on the Ecoprint theme */
:root {
--pf-primary: #ff6252;
--pf-primary-dark: #e24f41;
--pf-ink: #151515;
--pf-muted: #6b7280;
}
/* Theme CSS starts `.page` at opacity:0 until a `.preloader` runs.
Keep this so a missed preloader never blanks the site. */
.page {
opacity: 1;
}
.rd-navbar-brand .brand img,
.rd-navbar-brand .brand-logo-dark,
.rd-navbar-brand .brand-logo-light,
.footer-classic-brand .brand img {
height: 39px;
width: auto;
max-width: 180px;
}
.flash {
list-style: none;
margin: 0;
padding: 12px 0 0;
}
.flash li {
max-width: 1200px;
margin: 0 auto 8px;
padding: 12px 16px;
background: #fde8e8;
border-left: 3px solid var(--pf-primary);
color: #7f1d1d;
font-size: 14px;
}
.flash li.success {
background: #ecfdf5;
border-left-color: #059669;
color: #065f46;
}
.flash li.error,
.flash li.danger {
background: #fee2e2;
border-left-color: #b91c1c;
color: #991b1b;
}
.form-wrap .errorlist,
.errorlist {
list-style: none;
margin: 6px 0 0;
padding: 0;
color: #b91c1c;
font-size: 13px;
}
.cookie-consent-banner {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 11000;
padding: 1rem 1.25rem;
background: rgba(21, 21, 21, 0.96);
border-top: 2px solid var(--pf-primary);
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.28);
}
.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;
}
.cookie-consent-text-short {
display: none;
}
.cookie-consent-actions {
display: flex;
gap: 0.75rem;
flex-shrink: 0;
}
.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: var(--pf-primary);
font: inherit;
font-size: 13px;
cursor: pointer;
text-decoration: none;
}
.footer-link-btn:hover {
text-decoration: underline;
color: var(--pf-primary-dark);
}
.cookie-consent-link {
color: #ffd3ce;
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(--pf-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(--pf-muted);
}
.footer-made-by {
margin: 10px 0 0;
font-size: 13px;
color: #6b7280;
}
.footer-made-by a {
color: var(--pf-primary);
text-decoration: underline;
}
.form-label-outside {
display: block;
margin-bottom: 6px;
font-weight: 500;
}
.product-figure {
min-height: 180px;
display: flex;
align-items: center;
justify-content: center;
}
.product-figure img,
.slick-product-figure img {
max-width: 100%;
height: auto;
object-fit: contain;
}
.product-color-dots {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: center;
margin-top: 10px;
}
.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-color-picker {
margin: 8px 0 20px;
}
.product-color-label {
margin: 0 0 10px;
font-weight: 500;
color: #151515;
}
.product-color-swatches {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.product-color-swatch {
cursor: pointer;
display: inline-flex;
}
.product-color-swatch input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.product-color-swatch span {
width: 28px;
height: 28px;
border-radius: 50%;
border: 2px solid #fff;
box-shadow: 0 0 0 1px #d1d5db;
display: block;
}
.product-color-swatch input:checked + span,
.product-color-swatch input:focus-visible + span {
box-shadow: 0 0 0 2px var(--pf-primary);
}
.product-gallery-layout {
display: grid;
grid-template-columns: 72px minmax(0, 1fr);
gap: 12px;
align-items: start;
}
.product-gallery-thumbs {
display: flex;
flex-direction: column;
gap: 8px;
}
.product-gallery-thumbs button {
width: 72px;
height: 72px;
padding: 4px;
border: 2px solid #dddddd;
background: #fff;
cursor: pointer;
overflow: hidden;
}
.product-gallery-thumbs button.active,
.product-gallery-thumbs button[aria-selected="true"] {
border-color: var(--pf-primary);
}
.product-gallery-thumbs img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.product-gallery-thumb-3d {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
color: #151515;
background: #f5f5f5;
}
.product-gallery-stage {
min-width: 0;
}
.product-media-hint {
margin: 8px 0 0;
font-size: 13px;
color: #6b7280;
text-align: center;
}
.stl-viewer {
position: relative;
width: 100%;
min-height: 360px;
height: 420px;
background: #f5f5f5;
border: 1px solid #dddddd;
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: 14px;
color: #6b7280;
pointer-events: none;
}
.stl-viewer.is-error .stl-viewer-status {
color: #991b1b;
}
@media (max-width: 767px) {
.product-gallery-layout {
grid-template-columns: 1fr;
}
.product-gallery-stage {
order: -1;
}
.product-gallery-thumbs {
flex-direction: row;
overflow-x: auto;
}
}
@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%;
}
}

Some files were not shown because too many files have changed in this diff Show More