Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04226abc19 | ||
|
|
a590fb8979 | ||
|
|
f759efc2aa | ||
|
|
9ca0cf95be | ||
|
|
975d846678 | ||
|
|
33859fa28b | ||
|
|
fe4fcfdd13 | ||
|
|
11de91faf7 | ||
|
|
109aea2242 | ||
|
|
cb7b39bf43 | ||
|
|
cbdeb93810 | ||
|
|
73448806cc | ||
|
|
2fdd322640 | ||
|
|
eefa59abad | ||
|
|
d63c6af374 | ||
|
|
71e64cfc45 | ||
|
|
db95cabe9c | ||
|
|
f932fcbdba | ||
|
|
bccab2b6e9 | ||
|
|
5fa61d02e8 | ||
|
|
139f375f73 | ||
|
|
c97bd16445 | ||
|
|
8ccf17655d | ||
|
|
04d842d799 | ||
|
|
ae0f8a8bc5 | ||
|
|
16dfb3faae | ||
|
|
a5fa08d4a0 | ||
|
|
9a383c0ee9 | ||
|
|
6f97f6084d | ||
|
|
426cc82f04 |
@@ -0,0 +1,11 @@
|
|||||||
|
.git
|
||||||
|
.venv
|
||||||
|
**/__pycache__
|
||||||
|
*.py[cod]
|
||||||
|
db.sqlite3
|
||||||
|
.env
|
||||||
|
htmlcov/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
*.log
|
||||||
|
staticfiles/
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Local development environment file (copy to .env).
|
||||||
|
# For production server values, use .env.prod.example instead.
|
||||||
|
DJANGO_ENV=dev
|
||||||
|
DJANGO_DEBUG=true
|
||||||
|
DJANGO_SECRET_KEY=change-me-for-local-development
|
||||||
|
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0
|
||||||
|
# Optional; when unset, http:// origins are derived for local hosts.
|
||||||
|
# DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost:8000,http://127.0.0.1:8000
|
||||||
|
|
||||||
|
# Database (docker-compose sets DATABASE_URL for the web service)
|
||||||
|
DATABASE_URL=postgres://company_site:company_site@db:5432/company_site
|
||||||
|
# DB_HOST=db
|
||||||
|
# DB_PORT=5432
|
||||||
|
# DB_NAME=company_site
|
||||||
|
# DB_USER=company_site
|
||||||
|
# DB_PASSWORD=company_site
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
# DJANGO_LOG_LEVEL=DEBUG
|
||||||
|
|
||||||
|
# Analytics
|
||||||
|
TIANJI_ENABLED=false
|
||||||
|
TIANJI_TRACKER_URL=https://tianji.aimloperations.com/tracker.js
|
||||||
|
TIANJI_WEBSITE_ID=
|
||||||
|
|
||||||
|
# reCAPTCHA
|
||||||
|
RECAPTCHA_PUBLIC_KEY=
|
||||||
|
RECAPTCHA_PRIVATE_KEY=
|
||||||
|
|
||||||
|
# Email (SMTP2GO)
|
||||||
|
EMAIL_HOST=mail.smtp2go.com
|
||||||
|
EMAIL_HOST_USER=
|
||||||
|
EMAIL_HOST_PASSWORD=
|
||||||
|
EMAIL_PORT=2525
|
||||||
|
EMAIL_USE_TLS=true
|
||||||
|
# DEFAULT_FROM_EMAIL=AI ML Operations, LLC <info@aimloperations.com>
|
||||||
|
|
||||||
|
# Stripe (test keys from https://dashboard.stripe.com/test/apikeys)
|
||||||
|
STRIPE_SECRET_KEY=
|
||||||
|
STRIPE_PUBLISHABLE_KEY=
|
||||||
|
# From `stripe listen` or Dashboard → Developers → Webhooks
|
||||||
|
STRIPE_WEBHOOK_SECRET=
|
||||||
|
|
||||||
|
# Gunicorn
|
||||||
|
GUNICORN_WORKERS=2
|
||||||
|
GUNICORN_BIND=0.0.0.0:8000
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Server-side production environment file.
|
||||||
|
# Copy to /home/westfarn/Documents/django_live_sites/Company_Site/.env
|
||||||
|
# This file is NOT deployed from git — rsync excludes .env so server values persist.
|
||||||
|
|
||||||
|
# Environment level: dev | beta | prod
|
||||||
|
DJANGO_ENV=prod
|
||||||
|
DJANGO_DEBUG=false
|
||||||
|
DJANGO_SECRET_KEY=replace-with-a-long-random-secret
|
||||||
|
DJANGO_ALLOWED_HOSTS=aimloperations.com,www.aimloperations.com
|
||||||
|
# Optional override; when unset, https:// origins are derived from DJANGO_ALLOWED_HOSTS.
|
||||||
|
# DJANGO_CSRF_TRUSTED_ORIGINS=https://aimloperations.com,https://www.aimloperations.com
|
||||||
|
|
||||||
|
# Logging (optional override; defaults: dev=DEBUG, beta=INFO, prod=WARNING)
|
||||||
|
# DJANGO_LOG_LEVEL=WARNING
|
||||||
|
|
||||||
|
# PostgreSQL — DATABASE_URL is used by Django; DB_* are used by the db container.
|
||||||
|
DATABASE_URL=postgres://company_site:replace-db-password@db:5432/company_site
|
||||||
|
DB_NAME=company_site
|
||||||
|
DB_USER=company_site
|
||||||
|
DB_PASSWORD=replace-db-password
|
||||||
|
|
||||||
|
# Analytics
|
||||||
|
TIANJI_ENABLED=true
|
||||||
|
TIANJI_TRACKER_URL=https://tianji.aimloperations.com/tracker.js
|
||||||
|
TIANJI_WEBSITE_ID=replace-with-production-website-id
|
||||||
|
|
||||||
|
# reCAPTCHA
|
||||||
|
RECAPTCHA_PUBLIC_KEY=replace-with-production-public-key
|
||||||
|
RECAPTCHA_PRIVATE_KEY=replace-with-production-private-key
|
||||||
|
|
||||||
|
# Email (SMTP2GO)
|
||||||
|
EMAIL_HOST=mail.smtp2go.com
|
||||||
|
EMAIL_HOST_USER=replace-with-smtp-user
|
||||||
|
EMAIL_HOST_PASSWORD=replace-with-smtp-password
|
||||||
|
EMAIL_PORT=2525
|
||||||
|
EMAIL_USE_TLS=true
|
||||||
|
# DEFAULT_FROM_EMAIL=AI ML Operations, LLC <info@aimloperations.com>
|
||||||
|
|
||||||
|
# Stripe (live keys from https://dashboard.stripe.com/apikeys)
|
||||||
|
# Secret key starts with sk_live_; publishable with pk_live_
|
||||||
|
STRIPE_SECRET_KEY=replace-with-stripe-secret-key
|
||||||
|
STRIPE_PUBLISHABLE_KEY=replace-with-stripe-publishable-key
|
||||||
|
# Dashboard → Developers → Webhooks → endpoint
|
||||||
|
# URL: https://aimloperations.com/financial/stripe/webhook/
|
||||||
|
# Events: invoice.paid, invoice.payment_failed, invoice.finalized,
|
||||||
|
# customer.subscription.updated, customer.subscription.deleted,
|
||||||
|
# checkout.session.completed
|
||||||
|
STRIPE_WEBHOOK_SECRET=replace-with-stripe-webhook-signing-secret
|
||||||
|
|
||||||
|
# Gunicorn
|
||||||
|
GUNICORN_WORKERS=2
|
||||||
|
GUNICORN_BIND=0.0.0.0:8000
|
||||||
|
|
||||||
|
# Host port exposed by docker-compose.prod.yml
|
||||||
|
WEB_PORT=8000
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: self-hosted
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
run: |
|
||||||
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --frozen
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
env:
|
||||||
|
DJANGO_ENV: dev
|
||||||
|
DJANGO_SECRET_KEY: test-secret-key
|
||||||
|
run: |
|
||||||
|
cd company_site
|
||||||
|
uv run python manage.py test
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
name: Deploy Company Site
|
name: Deploy Company Site
|
||||||
|
|
||||||
|
# Runs after Unit Tests completes on master. Direct pushes only (not PRs).
|
||||||
on:
|
on:
|
||||||
workflow_run:
|
workflow_run:
|
||||||
workflows: [Unit Tests]
|
workflows: [Unit Tests]
|
||||||
@@ -7,7 +8,7 @@ on:
|
|||||||
branches: [master]
|
branches: [master]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
docker:
|
||||||
if: gitea.event.workflow_run.conclusion == 'success' && gitea.event.workflow_run.event == 'push'
|
if: gitea.event.workflow_run.conclusion == 'success' && gitea.event.workflow_run.event == 'push'
|
||||||
runs-on: self-hosted
|
runs-on: self-hosted
|
||||||
steps:
|
steps:
|
||||||
@@ -16,5 +17,29 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
ref: ${{ gitea.event.workflow_run.head_sha }}
|
ref: ${{ gitea.event.workflow_run.head_sha }}
|
||||||
|
|
||||||
- name: Deploy to live site
|
- name: Build Docker image
|
||||||
run: bash scripts/deploy.sh "${{ gitea.workspace }}"
|
run: docker compose build
|
||||||
|
|
||||||
|
- name: Run containerized tests
|
||||||
|
run: |
|
||||||
|
docker compose up -d db
|
||||||
|
docker compose run --rm --entrypoint "" \
|
||||||
|
-e DJANGO_ENV=dev \
|
||||||
|
-e DJANGO_SECRET_KEY=test-secret-key \
|
||||||
|
-e DATABASE_URL=postgres://company_site:company_site@db:5432/company_site \
|
||||||
|
web uv run python manage.py test
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
if: gitea.event.workflow_run.conclusion == 'success' && gitea.event.workflow_run.event == 'push'
|
||||||
|
runs-on: self-hosted
|
||||||
|
needs: docker
|
||||||
|
env:
|
||||||
|
SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra
|
||||||
|
steps:
|
||||||
|
- name: Deploy company_site prod
|
||||||
|
run: |
|
||||||
|
"${SERVER_INFRA_ROOT}/scripts/deploy.sh" \
|
||||||
|
--app company_site \
|
||||||
|
--env prod \
|
||||||
|
--ref "${{ gitea.event.workflow_run.head_sha }}"
|
||||||
|
|||||||
@@ -13,13 +13,18 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Python environment
|
- name: Install uv
|
||||||
run: |
|
run: |
|
||||||
python3 -m venv .venv
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
.venv/bin/pip install --upgrade pip
|
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||||
.venv/bin/pip install -r requirements.txt
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --frozen
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
|
env:
|
||||||
|
DJANGO_ENV: dev
|
||||||
|
DJANGO_SECRET_KEY: test-secret-key
|
||||||
run: |
|
run: |
|
||||||
cd company_site
|
cd company_site
|
||||||
../.venv/bin/python manage.py test
|
uv run python manage.py test
|
||||||
|
|||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
UV_COMPILE_BYTECODE=1 \
|
||||||
|
UV_LINK_MODE=copy \
|
||||||
|
UV_PROJECT_ENVIRONMENT=/app/.venv
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends libpq5 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||||
|
|
||||||
|
COPY pyproject.toml uv.lock ./
|
||||||
|
RUN uv sync --frozen --no-dev
|
||||||
|
|
||||||
|
COPY company_site ./company_site
|
||||||
|
COPY scripts/docker-entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
|
WORKDIR /app/company_site
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
@@ -1,4 +1,58 @@
|
|||||||
# company_site
|
# company_site
|
||||||
|
|
||||||
Django site for compan
|
Django site for AIML Operations.
|
||||||
Somethinf
|
|
||||||
|
## Local development (uv)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync
|
||||||
|
cp .env.example .env
|
||||||
|
cd company_site
|
||||||
|
DJANGO_ENV=dev uv run python manage.py migrate
|
||||||
|
DJANGO_ENV=dev uv run python manage.py runserver
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker (dev + Postgres)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
App: http://localhost:8000
|
||||||
|
|
||||||
|
## Environments
|
||||||
|
|
||||||
|
Set `DJANGO_ENV` to one of:
|
||||||
|
|
||||||
|
| Value | DEBUG default | Logging level |
|
||||||
|
|-------|---------------|---------------|
|
||||||
|
| `dev` | true | DEBUG |
|
||||||
|
| `beta` | false | INFO |
|
||||||
|
| `prod` | false | WARNING |
|
||||||
|
|
||||||
|
Secrets and service config come from environment variables. See `.env.example`.
|
||||||
|
|
||||||
|
## CI / deploy workflows
|
||||||
|
|
||||||
|
| Workflow | Trigger | What runs |
|
||||||
|
|----------|---------|-----------|
|
||||||
|
| `.gitea/workflows/ci.yml` | Pull requests to `master` | Unit tests only |
|
||||||
|
| `.gitea/workflows/deploy.yml` | Push to `master` | Unit tests → Docker build/test → deploy |
|
||||||
|
|
||||||
|
Deploy never runs on pull requests. Uses separate workflow files (not job `if` conditions) so Gitea runners handle it reliably.
|
||||||
|
|
||||||
|
## Production deploy
|
||||||
|
|
||||||
|
Server keeps its own `.env` at the live site path. Deploy rsyncs code but **never overwrites `.env`**.
|
||||||
|
|
||||||
|
1. On the server, copy `.env.prod.example` to `.env` and fill in production values.
|
||||||
|
2. Run `bash scripts/validate-env.sh /path/to/.env` to verify required variables.
|
||||||
|
3. Push to `master` — the deploy workflow runs `scripts/deploy.sh`, which:
|
||||||
|
- rsyncs checkout to live site (preserving `.env`)
|
||||||
|
- validates environment variables
|
||||||
|
- `docker compose -f docker-compose.prod.yml build`
|
||||||
|
- `docker compose up -d`
|
||||||
|
- runs migrations in the web container
|
||||||
|
|
||||||
|
Legacy venv/systemd deploy: `DEPLOY_MODE=legacy bash scripts/deploy.sh <checkout>`.
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
"""
|
|
||||||
Django settings for company_site project.
|
|
||||||
|
|
||||||
Generated by 'django-admin startproject' using Django 5.0.
|
|
||||||
|
|
||||||
For more information on this file, see
|
|
||||||
https://docs.djangoproject.com/en/5.0/topics/settings/
|
|
||||||
|
|
||||||
For the full list of settings and their values, see
|
|
||||||
https://docs.djangoproject.com/en/5.0/ref/settings/
|
|
||||||
"""
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
import os
|
|
||||||
|
|
||||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
|
||||||
|
|
||||||
|
|
||||||
# Quick-start development settings - unsuitable for production
|
|
||||||
# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/
|
|
||||||
|
|
||||||
# SECURITY WARNING: keep the secret key used in production secret!
|
|
||||||
SECRET_KEY = 'django-insecure-0$+hho_6%-(ud^t%0zos(q&i@2&)9m+u&dgj77&51g$m#hr^0s'
|
|
||||||
|
|
||||||
# SECURITY WARNING: don't run with debug turned on in production!
|
|
||||||
DEBUG = True
|
|
||||||
TIANJI_ENABLED = not DEBUG
|
|
||||||
WEBMCP_ENABLED = False
|
|
||||||
|
|
||||||
ALLOWED_HOSTS = ["*"]
|
|
||||||
|
|
||||||
|
|
||||||
# Application definition
|
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
|
||||||
'public.apps.PublicConfig',
|
|
||||||
'financial.apps.FinancialConfig',
|
|
||||||
'planning.apps.PlanningConfig',
|
|
||||||
'django.contrib.admin',
|
|
||||||
'django.contrib.auth',
|
|
||||||
'django.contrib.contenttypes',
|
|
||||||
'django.contrib.sessions',
|
|
||||||
'django.contrib.messages',
|
|
||||||
'django.contrib.staticfiles',
|
|
||||||
'phonenumber_field',
|
|
||||||
'django_recaptcha',
|
|
||||||
|
|
||||||
]
|
|
||||||
|
|
||||||
MIDDLEWARE = [
|
|
||||||
'django.middleware.security.SecurityMiddleware',
|
|
||||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
|
||||||
'django.middleware.common.CommonMiddleware',
|
|
||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
|
||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
|
||||||
'django.contrib.messages.middleware.MessageMiddleware',
|
|
||||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
|
||||||
]
|
|
||||||
|
|
||||||
ROOT_URLCONF = 'company_site.urls'
|
|
||||||
|
|
||||||
TEMPLATES = [
|
|
||||||
{
|
|
||||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
|
||||||
'DIRS': [],
|
|
||||||
'APP_DIRS': True,
|
|
||||||
'OPTIONS': {
|
|
||||||
'context_processors': [
|
|
||||||
'django.template.context_processors.debug',
|
|
||||||
'django.template.context_processors.request',
|
|
||||||
'django.contrib.auth.context_processors.auth',
|
|
||||||
'django.contrib.messages.context_processors.messages',
|
|
||||||
'public.context_processors.tianji_tracking',
|
|
||||||
'public.context_processors.webmcp_context',
|
|
||||||
'public.context_processors.financial_access',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
WSGI_APPLICATION = 'company_site.wsgi.application'
|
|
||||||
|
|
||||||
|
|
||||||
# Database
|
|
||||||
# https://docs.djangoproject.com/en/5.0/ref/settings/#databases
|
|
||||||
|
|
||||||
DATABASES = {
|
|
||||||
'default': {
|
|
||||||
'ENGINE': 'django.db.backends.sqlite3',
|
|
||||||
'NAME': BASE_DIR / 'db.sqlite3',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# Password validation
|
|
||||||
# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators
|
|
||||||
|
|
||||||
AUTH_PASSWORD_VALIDATORS = [
|
|
||||||
{
|
|
||||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# Internationalization
|
|
||||||
# https://docs.djangoproject.com/en/5.0/topics/i18n/
|
|
||||||
|
|
||||||
LANGUAGE_CODE = 'en-us'
|
|
||||||
|
|
||||||
TIME_ZONE = 'UTC'
|
|
||||||
|
|
||||||
USE_I18N = True
|
|
||||||
|
|
||||||
USE_TZ = True
|
|
||||||
|
|
||||||
|
|
||||||
# Static files (CSS, JavaScript, Images)
|
|
||||||
# https://docs.djangoproject.com/en/5.0/howto/static-files/
|
|
||||||
|
|
||||||
STATIC_URL = 'static/'
|
|
||||||
|
|
||||||
# Default primary key field type
|
|
||||||
# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field
|
|
||||||
|
|
||||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
|
||||||
|
|
||||||
# # email settings
|
|
||||||
# EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
|
|
||||||
# EMAIL_USE_TLS = True
|
|
||||||
# EMAIL_HOST = 'smtp.outlook.office365.com'#'smtp-mail.outlook.com'
|
|
||||||
# EMAIL_HOST_USERNAME = 'ryan@aimloperations.com'
|
|
||||||
# EMAIL_HOST_USER = 'ryan@aimloperations.com'
|
|
||||||
# EMAIL_HOST_PASSWORD = '!HopeThisW0rkz'
|
|
||||||
# EMAIL_PORT = 587
|
|
||||||
# SERVER_EMAIL = EMAIL_HOST_USER
|
|
||||||
|
|
||||||
# Recapcha Stuff
|
|
||||||
RECAPTCHA_PUBLIC_KEY = '6LdXRbopAAAAAL9NT7C2J3Fuu_b6rvhhsPyxTd9Z'
|
|
||||||
RECAPTCHA_PRIVATE_KEY = '6LdXRbopAAAAAPt31zdQJaOwLseognmZHZEHmWlt'
|
|
||||||
|
|
||||||
# SMTP2GO
|
|
||||||
EMAIL_HOST = 'mail.smtp2go.com'
|
|
||||||
EMAIL_HOST_USER = 'info.aimloperations.com'
|
|
||||||
EMAIL_HOST_PASSWORD = 'ZDErIII2sipNNVMz'
|
|
||||||
EMAIL_PORT = 2525
|
|
||||||
EMAIL_USE_TLS = True
|
|
||||||
|
|
||||||
# Authentication Redirects
|
|
||||||
LOGIN_REDIRECT_URL = '/'
|
|
||||||
LOGOUT_REDIRECT_URL = '/'
|
|
||||||
|
|
||||||
# Tianji analytics (loaded on page load; users acknowledge via notice banner)
|
|
||||||
TIANJI_TRACKER_URL = 'https://tianji.aimloperations.com/tracker.js'
|
|
||||||
TIANJI_WEBSITE_ID = 'cm7w80pyy020oddswy2evl957'
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
"""Load environment-specific Django settings based on DJANGO_ENV."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
_environment = os.environ.get("DJANGO_ENV", "dev").lower()
|
||||||
|
|
||||||
|
if _environment == "prod":
|
||||||
|
from .prod import * # noqa: F403
|
||||||
|
elif _environment == "beta":
|
||||||
|
from .beta import * # noqa: F403
|
||||||
|
else:
|
||||||
|
from .dev import * # noqa: F403
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
"""Shared Django settings for all environments."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
def env(key: str, default: str | None = None) -> str | None:
|
||||||
|
return os.environ.get(key, default)
|
||||||
|
|
||||||
|
|
||||||
|
def env_bool(key: str, default: bool = False) -> bool:
|
||||||
|
value = os.environ.get(key)
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
return value.lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
def env_list(key: str, default: str = "") -> list[str]:
|
||||||
|
value = os.environ.get(key, default)
|
||||||
|
if not value:
|
||||||
|
return []
|
||||||
|
value = value.strip()
|
||||||
|
# Accept a JSON array (e.g. '["a","b"]') as well as a comma-separated list.
|
||||||
|
if value.startswith("["):
|
||||||
|
try:
|
||||||
|
parsed = json.loads(value)
|
||||||
|
except ValueError:
|
||||||
|
parsed = None
|
||||||
|
if isinstance(parsed, list):
|
||||||
|
return [str(item).strip() for item in parsed if str(item).strip()]
|
||||||
|
return [item.strip() for item in value.split(",") if item.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def database_config() -> dict:
|
||||||
|
database_url = env("DATABASE_URL")
|
||||||
|
if database_url:
|
||||||
|
parsed = urlparse(database_url)
|
||||||
|
return {
|
||||||
|
"default": {
|
||||||
|
"ENGINE": "django.db.backends.postgresql",
|
||||||
|
"NAME": parsed.path.lstrip("/"),
|
||||||
|
"USER": parsed.username or "",
|
||||||
|
"PASSWORD": parsed.password or "",
|
||||||
|
"HOST": parsed.hostname or "",
|
||||||
|
"PORT": str(parsed.port or 5432),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if env("DB_HOST"):
|
||||||
|
return {
|
||||||
|
"default": {
|
||||||
|
"ENGINE": "django.db.backends.postgresql",
|
||||||
|
"NAME": env("DB_NAME", "company_site"),
|
||||||
|
"USER": env("DB_USER", "company_site"),
|
||||||
|
"PASSWORD": env("DB_PASSWORD", ""),
|
||||||
|
"HOST": env("DB_HOST"),
|
||||||
|
"PORT": env("DB_PORT", "5432"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"default": {
|
||||||
|
"ENGINE": "django.db.backends.sqlite3",
|
||||||
|
"NAME": BASE_DIR / "db.sqlite3",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
SECRET_KEY = env(
|
||||||
|
"DJANGO_SECRET_KEY",
|
||||||
|
"django-insecure-dev-only-change-me-before-production",
|
||||||
|
)
|
||||||
|
|
||||||
|
DEBUG = env_bool("DJANGO_DEBUG", False)
|
||||||
|
TIANJI_ENABLED = env_bool("TIANJI_ENABLED", not DEBUG)
|
||||||
|
WEBMCP_ENABLED = env_bool("WEBMCP_ENABLED", False)
|
||||||
|
|
||||||
|
allowed_hosts = env_list("DJANGO_ALLOWED_HOSTS", "*")
|
||||||
|
ALLOWED_HOSTS = allowed_hosts if allowed_hosts else ["*"]
|
||||||
|
|
||||||
|
|
||||||
|
def build_csrf_trusted_origins(
|
||||||
|
allowed_hosts: list[str], explicit: list[str] | None = None
|
||||||
|
) -> list[str]:
|
||||||
|
"""Build CSRF_TRUSTED_ORIGINS for Django 4+ Origin checks on HTTPS POSTs.
|
||||||
|
|
||||||
|
Prefer DJANGO_CSRF_TRUSTED_ORIGINS when set. Otherwise derive from ALLOWED_HOSTS:
|
||||||
|
https for public hosts, http for local loopback hosts.
|
||||||
|
"""
|
||||||
|
if explicit:
|
||||||
|
return explicit
|
||||||
|
|
||||||
|
local_hosts = {"localhost", "127.0.0.1", "0.0.0.0"}
|
||||||
|
origins: list[str] = []
|
||||||
|
for host in allowed_hosts:
|
||||||
|
if not host or host == "*" or host.startswith("."):
|
||||||
|
continue
|
||||||
|
hostname = host.split(":")[0]
|
||||||
|
scheme = "http" if hostname in local_hosts else "https"
|
||||||
|
origins.append(f"{scheme}://{host}")
|
||||||
|
return origins
|
||||||
|
|
||||||
|
|
||||||
|
CSRF_TRUSTED_ORIGINS = build_csrf_trusted_origins(
|
||||||
|
ALLOWED_HOSTS,
|
||||||
|
env_list("DJANGO_CSRF_TRUSTED_ORIGINS"),
|
||||||
|
)
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
"public.apps.PublicConfig",
|
||||||
|
"financial.apps.FinancialConfig",
|
||||||
|
"planning.apps.PlanningConfig",
|
||||||
|
"django.contrib.admin",
|
||||||
|
"django.contrib.auth",
|
||||||
|
"django.contrib.contenttypes",
|
||||||
|
"django.contrib.sessions",
|
||||||
|
"django.contrib.messages",
|
||||||
|
"whitenoise.runserver_nostatic",
|
||||||
|
"django.contrib.staticfiles",
|
||||||
|
"phonenumber_field",
|
||||||
|
"django_recaptcha",
|
||||||
|
]
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
"django.middleware.security.SecurityMiddleware",
|
||||||
|
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||||
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
|
"django.middleware.common.CommonMiddleware",
|
||||||
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
|
"public.middleware.UTMTrackingMiddleware",
|
||||||
|
]
|
||||||
|
|
||||||
|
ROOT_URLCONF = "company_site.urls"
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
|
"DIRS": [],
|
||||||
|
"APP_DIRS": True,
|
||||||
|
"OPTIONS": {
|
||||||
|
"context_processors": [
|
||||||
|
"django.template.context_processors.debug",
|
||||||
|
"django.template.context_processors.request",
|
||||||
|
"django.contrib.auth.context_processors.auth",
|
||||||
|
"django.contrib.messages.context_processors.messages",
|
||||||
|
"public.context_processors.tianji_tracking",
|
||||||
|
"public.context_processors.webmcp_context",
|
||||||
|
"public.context_processors.financial_access",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
WSGI_APPLICATION = "company_site.wsgi.application"
|
||||||
|
|
||||||
|
DATABASES = database_config()
|
||||||
|
|
||||||
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
|
{
|
||||||
|
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
LANGUAGE_CODE = "en-us"
|
||||||
|
TIME_ZONE = "UTC"
|
||||||
|
USE_I18N = True
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
|
STATIC_URL = "static/"
|
||||||
|
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||||
|
STORAGES = {
|
||||||
|
"staticfiles": {
|
||||||
|
"BACKEND": "company_site.storage.TolerantManifestStaticFilesStorage",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||||
|
|
||||||
|
RECAPTCHA_PUBLIC_KEY = env("RECAPTCHA_PUBLIC_KEY", "")
|
||||||
|
RECAPTCHA_PRIVATE_KEY = env("RECAPTCHA_PRIVATE_KEY", "")
|
||||||
|
|
||||||
|
EMAIL_HOST = env("EMAIL_HOST", "mail.smtp2go.com")
|
||||||
|
EMAIL_HOST_USER = env("EMAIL_HOST_USER", "")
|
||||||
|
EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD", "")
|
||||||
|
EMAIL_PORT = int(env("EMAIL_PORT", "2525"))
|
||||||
|
EMAIL_USE_TLS = env_bool("EMAIL_USE_TLS", True)
|
||||||
|
DEFAULT_FROM_EMAIL = env(
|
||||||
|
"DEFAULT_FROM_EMAIL",
|
||||||
|
"AI ML Operations, LLC <info@aimloperations.com>",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stripe (invoices / subscriptions — customers pay us)
|
||||||
|
STRIPE_SECRET_KEY = env("STRIPE_SECRET_KEY", "")
|
||||||
|
STRIPE_PUBLISHABLE_KEY = env("STRIPE_PUBLISHABLE_KEY", "")
|
||||||
|
STRIPE_WEBHOOK_SECRET = env("STRIPE_WEBHOOK_SECRET", "")
|
||||||
|
|
||||||
|
LOGIN_REDIRECT_URL = "/"
|
||||||
|
LOGOUT_REDIRECT_URL = "/"
|
||||||
|
|
||||||
|
TIANJI_TRACKER_URL = env(
|
||||||
|
"TIANJI_TRACKER_URL",
|
||||||
|
"https://tianji.aimloperations.com/tracker.js",
|
||||||
|
)
|
||||||
|
TIANJI_WEBSITE_ID = env("TIANJI_WEBSITE_ID", "")
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""Beta/staging settings."""
|
||||||
|
|
||||||
|
from .base import * # noqa: F403
|
||||||
|
from .logging import build_logging_config, logging_level_for_env
|
||||||
|
|
||||||
|
DEBUG = env_bool("DJANGO_DEBUG", False) # noqa: F405
|
||||||
|
TIANJI_ENABLED = env_bool("TIANJI_ENABLED", True) # noqa: F405
|
||||||
|
|
||||||
|
if DEBUG:
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
warnings.warn("DEBUG is enabled in beta environment.", stacklevel=1)
|
||||||
|
|
||||||
|
# Same reverse-proxy assumptions as production when TLS is terminated upstream.
|
||||||
|
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||||
|
USE_X_FORWARDED_HOST = True
|
||||||
|
SESSION_COOKIE_SECURE = not DEBUG
|
||||||
|
CSRF_COOKIE_SECURE = not DEBUG
|
||||||
|
|
||||||
|
LOGGING = build_logging_config(logging_level_for_env("beta"), "beta")
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
"""Development settings."""
|
||||||
|
|
||||||
|
from .base import * # noqa: F403
|
||||||
|
from .logging import build_logging_config, logging_level_for_env
|
||||||
|
|
||||||
|
DEBUG = True
|
||||||
|
TIANJI_ENABLED = env_bool("TIANJI_ENABLED", False) # noqa: F405
|
||||||
|
|
||||||
|
STORAGES = {
|
||||||
|
"staticfiles": {
|
||||||
|
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
LOGGING = build_logging_config(logging_level_for_env("dev"), "dev")
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""Environment-specific logging configuration."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def build_logging_config(level: str, environment: str) -> dict:
|
||||||
|
"""Return a Django LOGGING dict for the given level and environment name."""
|
||||||
|
return {
|
||||||
|
"version": 1,
|
||||||
|
"disable_existing_loggers": False,
|
||||||
|
"formatters": {
|
||||||
|
"verbose": {
|
||||||
|
"format": (
|
||||||
|
f"{{levelname}} {{asctime}} {{name}} {{process:d}} {{thread:d}} "
|
||||||
|
f"[env={environment}] {{message}}"
|
||||||
|
),
|
||||||
|
"style": "{",
|
||||||
|
},
|
||||||
|
"simple": {
|
||||||
|
"format": f"{{levelname}} [env={environment}] {{message}}",
|
||||||
|
"style": "{",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"handlers": {
|
||||||
|
"console": {
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
|
"formatter": "verbose" if environment == "dev" else "simple",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"handlers": ["console"],
|
||||||
|
"level": level,
|
||||||
|
},
|
||||||
|
"loggers": {
|
||||||
|
"django": {
|
||||||
|
"handlers": ["console"],
|
||||||
|
"level": level,
|
||||||
|
"propagate": False,
|
||||||
|
},
|
||||||
|
"django.request": {
|
||||||
|
"handlers": ["console"],
|
||||||
|
"level": "ERROR" if environment == "prod" else level,
|
||||||
|
"propagate": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def logging_level_for_env(environment: str) -> str:
|
||||||
|
override = os.environ.get("DJANGO_LOG_LEVEL")
|
||||||
|
if override:
|
||||||
|
return override.upper()
|
||||||
|
|
||||||
|
if environment == "dev":
|
||||||
|
return "DEBUG"
|
||||||
|
if environment == "beta":
|
||||||
|
return "INFO"
|
||||||
|
return "WARNING"
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""Production settings."""
|
||||||
|
|
||||||
|
from .base import * # noqa: F403
|
||||||
|
from .logging import build_logging_config, logging_level_for_env
|
||||||
|
|
||||||
|
DEBUG = False
|
||||||
|
TIANJI_ENABLED = env_bool("TIANJI_ENABLED", True) # noqa: F405
|
||||||
|
|
||||||
|
if not env("DJANGO_SECRET_KEY"): # noqa: F405
|
||||||
|
raise ValueError("DJANGO_SECRET_KEY must be set in production.")
|
||||||
|
|
||||||
|
# App sits behind a reverse proxy that terminates TLS (docker :8000).
|
||||||
|
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||||
|
USE_X_FORWARDED_HOST = True
|
||||||
|
SESSION_COOKIE_SECURE = True
|
||||||
|
CSRF_COOKIE_SECURE = True
|
||||||
|
|
||||||
|
LOGGING = build_logging_config(logging_level_for_env("prod"), "prod")
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Custom static files storage.
|
||||||
|
|
||||||
|
WhiteNoise's manifest storage post-processes JS/CSS during ``collectstatic`` and
|
||||||
|
strictly resolves every referenced file, including ``sourceMappingURL`` comments
|
||||||
|
in vendored bundles. Some third-party assets reference ``.map`` files that are
|
||||||
|
not shipped, which makes ``collectstatic`` fail hard.
|
||||||
|
|
||||||
|
``TolerantManifestStaticFilesStorage`` leaves such unresolved references
|
||||||
|
untouched instead of raising, so a missing source map can't break the build.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from whitenoise.storage import CompressedManifestStaticFilesStorage
|
||||||
|
|
||||||
|
|
||||||
|
class TolerantManifestStaticFilesStorage(CompressedManifestStaticFilesStorage):
|
||||||
|
# Don't 500 at runtime when a {% static %} reference isn't in the manifest;
|
||||||
|
# fall back to the plain name (mirrors non-manifest storage behaviour).
|
||||||
|
manifest_strict = False
|
||||||
|
|
||||||
|
def _stored_name(self, name, hashed_files):
|
||||||
|
"""Tolerate missing references during collectstatic post-processing."""
|
||||||
|
try:
|
||||||
|
return super()._stored_name(name, hashed_files)
|
||||||
|
except ValueError:
|
||||||
|
return name
|
||||||
|
|
||||||
|
def stored_name(self, name):
|
||||||
|
"""Tolerate missing manifest entries at request time."""
|
||||||
|
try:
|
||||||
|
return super().stored_name(name)
|
||||||
|
except ValueError:
|
||||||
|
return name
|
||||||
@@ -31,7 +31,7 @@ Test at minimum:
|
|||||||
|
|
||||||
When `WEBMCP_ENABLED=True`:
|
When `WEBMCP_ENABLED=True`:
|
||||||
|
|
||||||
- Navigation tools (`list_services`, `get_page_content`, `navigate_to_service`, `open_contact_with_subject`) load on all public pages
|
- Navigation tools (`list_services`, `get_page_content`, `navigate_to_service`, `open_contact_with_subject`, `estimate_web_design_cost`) load on all public pages
|
||||||
- Contact page registers `submit_contact_inquiry` via declarative form annotations (`toolname`, `tooldescription`, `toolparamdescription`); reCAPTCHA renders outside the annotated form
|
- Contact page registers `submit_contact_inquiry` via declarative form annotations (`toolname`, `tooldescription`, `toolparamdescription`); reCAPTCHA renders outside the annotated form
|
||||||
- Default is **disabled** (`WEBMCP_ENABLED=False`) until deliberately enabled per environment
|
- Default is **disabled** (`WEBMCP_ENABLED=False`) until deliberately enabled per environment
|
||||||
|
|
||||||
|
|||||||
@@ -47,8 +47,30 @@ Use `https://` URLs to avoid redirect warnings. Run with `WEBMCP_ENABLED=True` o
|
|||||||
| `get_page_content` | All public pages | Yes | Look up a page by slug or display name |
|
| `get_page_content` | All public pages | Yes | Look up a page by slug or display name |
|
||||||
| `navigate_to_service` | All public pages | Yes | Resolve a service to its canonical URL |
|
| `navigate_to_service` | All public pages | Yes | Resolve a service to its canonical URL |
|
||||||
| `open_contact_with_subject` | All public pages | Yes | Build a contact URL with `?subject=` pre-filled |
|
| `open_contact_with_subject` | All public pages | Yes | Build a contact URL with `?subject=` pre-filled |
|
||||||
|
| `estimate_web_design_cost` | All public pages | Yes | Estimate web design build + monthly package pricing |
|
||||||
| `submit_contact_inquiry` | `/contact` only | No | POST a contact inquiry to the Django contact endpoint |
|
| `submit_contact_inquiry` | `/contact` only | No | POST a contact inquiry to the Django contact endpoint |
|
||||||
|
|
||||||
|
### `estimate_web_design_cost`
|
||||||
|
|
||||||
|
**Input schema:**
|
||||||
|
|
||||||
|
| Field | Type | Required |
|
||||||
|
|-------|------|----------|
|
||||||
|
| `features` | string[] | No — feature ids to add beyond the required base |
|
||||||
|
|
||||||
|
**Valid feature ids:** `public_site`, `client_portal`, `email_sms`, `direct_mail`, `blog`, `payments`, `social`, `ai_social`
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- `public_site` and `client_portal` are always included
|
||||||
|
- Selecting `payments` auto-selects `email_sms`
|
||||||
|
- Selecting `ai_social` auto-selects `social`
|
||||||
|
- Returns one-time build total, monthly total, selected features, included-with-every-site notes, and the full catalog
|
||||||
|
- Included-with-every-site covers brand-tailored design, client ownership of site/data, three-instance hosting, UTM/leads, SEO/accessibility/LLM readiness, and Grafana metrics/alerts
|
||||||
|
|
||||||
|
**Example:** `{ "features": ["email_sms", "payments"] }` → base + Email/SMS + Payments
|
||||||
|
|
||||||
|
Pricing catalog and included benefits are sourced from `public/web_design_pricing.py` (same data as `/web_design` and `/llms.txt`).
|
||||||
|
|
||||||
### `submit_contact_inquiry`
|
### `submit_contact_inquiry`
|
||||||
|
|
||||||
**Input schema:**
|
**Input schema:**
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from .models import Contract, Employee, ChargeNumber, TimeCard, TimeCardCell, UserProfile
|
from .models import (
|
||||||
|
Contract,
|
||||||
|
Employee,
|
||||||
|
ChargeNumber,
|
||||||
|
TimeCard,
|
||||||
|
TimeCardCell,
|
||||||
|
UserProfile,
|
||||||
|
BillingCustomer,
|
||||||
|
Invoice,
|
||||||
|
RecurringSubscription,
|
||||||
|
)
|
||||||
|
|
||||||
class ContractAdmin(admin.ModelAdmin):
|
class ContractAdmin(admin.ModelAdmin):
|
||||||
pass
|
pass
|
||||||
@@ -20,9 +30,25 @@ class TimeCardAdmin(admin.ModelAdmin):
|
|||||||
class TimeCardCellAdmin(admin.ModelAdmin):
|
class TimeCardCellAdmin(admin.ModelAdmin):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
class BillingCustomerAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("name", "email", "company", "stripe_customer_id")
|
||||||
|
search_fields = ("name", "email", "company")
|
||||||
|
|
||||||
|
class InvoiceAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("id", "customer", "description", "amount_cents", "status", "created")
|
||||||
|
list_filter = ("status",)
|
||||||
|
search_fields = ("description", "stripe_invoice_id", "customer__email")
|
||||||
|
|
||||||
|
class RecurringSubscriptionAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("id", "customer", "description", "amount_cents", "interval", "status")
|
||||||
|
list_filter = ("status", "interval")
|
||||||
|
|
||||||
admin.site.register(Contract, ContractAdmin)
|
admin.site.register(Contract, ContractAdmin)
|
||||||
admin.site.register(Employee, EmployeeAdmin)
|
admin.site.register(Employee, EmployeeAdmin)
|
||||||
admin.site.register(UserProfile, UserProfileAdmin)
|
admin.site.register(UserProfile, UserProfileAdmin)
|
||||||
admin.site.register(ChargeNumber, ChargeNumberAdmin)
|
admin.site.register(ChargeNumber, ChargeNumberAdmin)
|
||||||
admin.site.register(TimeCard, TimeCardAdmin)
|
admin.site.register(TimeCard, TimeCardAdmin)
|
||||||
admin.site.register(TimeCardCell, TimeCardCellAdmin)
|
admin.site.register(TimeCardCell, TimeCardCellAdmin)
|
||||||
|
admin.site.register(BillingCustomer, BillingCustomerAdmin)
|
||||||
|
admin.site.register(Invoice, InvoiceAdmin)
|
||||||
|
admin.site.register(RecurringSubscription, RecurringSubscriptionAdmin)
|
||||||
|
|||||||
@@ -2,7 +2,19 @@ import datetime
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.forms import ModelForm
|
from django.forms import ModelForm
|
||||||
from .models import Employee, Contract, ChargeNumber, TimeCardCell, AddressModel, UserProfile, set_user_type
|
from .models import (
|
||||||
|
Employee,
|
||||||
|
Contract,
|
||||||
|
ChargeNumber,
|
||||||
|
TimeCardCell,
|
||||||
|
AddressModel,
|
||||||
|
UserProfile,
|
||||||
|
set_user_type,
|
||||||
|
BillingCustomer,
|
||||||
|
Invoice,
|
||||||
|
RecurringSubscription,
|
||||||
|
)
|
||||||
|
from .stripe_billing import dollars_to_cents
|
||||||
|
|
||||||
class NewEmployeeForm(ModelForm):
|
class NewEmployeeForm(ModelForm):
|
||||||
first_name = forms.CharField(max_length=30, required=False, label="First Name")
|
first_name = forms.CharField(max_length=30, required=False, label="First Name")
|
||||||
@@ -99,3 +111,144 @@ class TimeLogForm(ModelForm):
|
|||||||
cleaned_data['hour'] = duration
|
cleaned_data['hour'] = duration
|
||||||
|
|
||||||
return cleaned_data
|
return cleaned_data
|
||||||
|
|
||||||
|
|
||||||
|
class BillingCustomerForm(ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = BillingCustomer
|
||||||
|
fields = ["name", "email", "company", "notes"]
|
||||||
|
|
||||||
|
|
||||||
|
class OneOffInvoiceForm(forms.Form):
|
||||||
|
customer = forms.ModelChoiceField(
|
||||||
|
queryset=BillingCustomer.objects.all(),
|
||||||
|
required=False,
|
||||||
|
help_text="Pick an existing customer, or fill New customer fields below.",
|
||||||
|
)
|
||||||
|
new_customer_name = forms.CharField(max_length=200, required=False, label="New customer name")
|
||||||
|
new_customer_email = forms.EmailField(required=False, label="New customer email")
|
||||||
|
new_customer_company = forms.CharField(
|
||||||
|
max_length=200, required=False, label="New customer company"
|
||||||
|
)
|
||||||
|
description = forms.CharField(max_length=500)
|
||||||
|
amount = forms.DecimalField(
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=2,
|
||||||
|
min_value=0.50,
|
||||||
|
label="Amount (USD)",
|
||||||
|
help_text="Minimum $0.50",
|
||||||
|
)
|
||||||
|
due_date = forms.DateField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.DateInput(attrs={"type": "date"}),
|
||||||
|
)
|
||||||
|
notes = forms.CharField(required=False, widget=forms.Textarea(attrs={"rows": 3}))
|
||||||
|
send_email = forms.BooleanField(
|
||||||
|
required=False,
|
||||||
|
initial=True,
|
||||||
|
label="Email payment link to customer",
|
||||||
|
)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned = super().clean()
|
||||||
|
customer = cleaned.get("customer")
|
||||||
|
name = (cleaned.get("new_customer_name") or "").strip()
|
||||||
|
email = (cleaned.get("new_customer_email") or "").strip()
|
||||||
|
if not customer and not (name and email):
|
||||||
|
raise forms.ValidationError(
|
||||||
|
"Select an existing customer or provide new customer name and email."
|
||||||
|
)
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
def resolve_customer(self, user):
|
||||||
|
customer = self.cleaned_data.get("customer")
|
||||||
|
if customer:
|
||||||
|
return customer
|
||||||
|
customer = BillingCustomer(
|
||||||
|
name=self.cleaned_data["new_customer_name"].strip(),
|
||||||
|
email=self.cleaned_data["new_customer_email"].strip(),
|
||||||
|
company=(self.cleaned_data.get("new_customer_company") or "").strip(),
|
||||||
|
created_by=user,
|
||||||
|
last_modified_BY=user,
|
||||||
|
)
|
||||||
|
customer.save()
|
||||||
|
return customer
|
||||||
|
|
||||||
|
def build_invoice(self, user) -> Invoice:
|
||||||
|
customer = self.resolve_customer(user)
|
||||||
|
return Invoice(
|
||||||
|
customer=customer,
|
||||||
|
description=self.cleaned_data["description"],
|
||||||
|
amount_cents=dollars_to_cents(self.cleaned_data["amount"]),
|
||||||
|
due_date=self.cleaned_data.get("due_date"),
|
||||||
|
notes=self.cleaned_data.get("notes") or "",
|
||||||
|
created_by=user,
|
||||||
|
last_modified_BY=user,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RecurringSubscriptionForm(forms.Form):
|
||||||
|
customer = forms.ModelChoiceField(
|
||||||
|
queryset=BillingCustomer.objects.all(),
|
||||||
|
required=False,
|
||||||
|
help_text="Pick an existing customer, or fill New customer fields below.",
|
||||||
|
)
|
||||||
|
new_customer_name = forms.CharField(max_length=200, required=False, label="New customer name")
|
||||||
|
new_customer_email = forms.EmailField(required=False, label="New customer email")
|
||||||
|
new_customer_company = forms.CharField(
|
||||||
|
max_length=200, required=False, label="New customer company"
|
||||||
|
)
|
||||||
|
description = forms.CharField(max_length=500)
|
||||||
|
amount = forms.DecimalField(
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=2,
|
||||||
|
min_value=0.50,
|
||||||
|
label="Amount per period (USD)",
|
||||||
|
)
|
||||||
|
interval = forms.ChoiceField(
|
||||||
|
choices=RecurringSubscription.Interval.choices,
|
||||||
|
initial=RecurringSubscription.Interval.MONTH,
|
||||||
|
)
|
||||||
|
notes = forms.CharField(required=False, widget=forms.Textarea(attrs={"rows": 3}))
|
||||||
|
send_email = forms.BooleanField(
|
||||||
|
required=False,
|
||||||
|
initial=True,
|
||||||
|
label="Email checkout / payment link to customer",
|
||||||
|
)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned = super().clean()
|
||||||
|
customer = cleaned.get("customer")
|
||||||
|
name = (cleaned.get("new_customer_name") or "").strip()
|
||||||
|
email = (cleaned.get("new_customer_email") or "").strip()
|
||||||
|
if not customer and not (name and email):
|
||||||
|
raise forms.ValidationError(
|
||||||
|
"Select an existing customer or provide new customer name and email."
|
||||||
|
)
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
def resolve_customer(self, user):
|
||||||
|
customer = self.cleaned_data.get("customer")
|
||||||
|
if customer:
|
||||||
|
return customer
|
||||||
|
customer = BillingCustomer(
|
||||||
|
name=self.cleaned_data["new_customer_name"].strip(),
|
||||||
|
email=self.cleaned_data["new_customer_email"].strip(),
|
||||||
|
company=(self.cleaned_data.get("new_customer_company") or "").strip(),
|
||||||
|
created_by=user,
|
||||||
|
last_modified_BY=user,
|
||||||
|
)
|
||||||
|
customer.save()
|
||||||
|
return customer
|
||||||
|
|
||||||
|
def build_subscription(self, user) -> RecurringSubscription:
|
||||||
|
customer = self.resolve_customer(user)
|
||||||
|
return RecurringSubscription(
|
||||||
|
customer=customer,
|
||||||
|
description=self.cleaned_data["description"],
|
||||||
|
amount_cents=dollars_to_cents(self.cleaned_data["amount"]),
|
||||||
|
interval=self.cleaned_data["interval"],
|
||||||
|
notes=self.cleaned_data.get("notes") or "",
|
||||||
|
created_by=user,
|
||||||
|
last_modified_BY=user,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
"""Admin invoice / subscription views and Stripe webhook."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib import messages
|
||||||
|
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 django.views.decorators.csrf import csrf_exempt
|
||||||
|
from django.views.decorators.http import require_POST
|
||||||
|
|
||||||
|
from .forms import OneOffInvoiceForm, RecurringSubscriptionForm
|
||||||
|
from .models import BillingCustomer, Invoice, RecurringSubscription
|
||||||
|
from .permissions import financial_admin_required
|
||||||
|
from .stripe_billing import (
|
||||||
|
StripeAPIError,
|
||||||
|
StripeNotConfigured,
|
||||||
|
apply_checkout_session_completed,
|
||||||
|
apply_stripe_invoice_event,
|
||||||
|
apply_subscription_event,
|
||||||
|
construct_webhook_event,
|
||||||
|
create_one_off_invoice,
|
||||||
|
create_subscription_checkout,
|
||||||
|
send_pay_link_email,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@financial_admin_required
|
||||||
|
def invoice_list(request):
|
||||||
|
invoices = Invoice.objects.select_related("customer").all()[:100]
|
||||||
|
subscriptions = RecurringSubscription.objects.select_related("customer").all()[:100]
|
||||||
|
customers = BillingCustomer.objects.all()[:50]
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"financial/invoices.html",
|
||||||
|
{
|
||||||
|
"invoices": invoices,
|
||||||
|
"subscriptions": subscriptions,
|
||||||
|
"customers": customers,
|
||||||
|
"stripe_configured": bool(getattr(settings, "STRIPE_SECRET_KEY", "")),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@financial_admin_required
|
||||||
|
def invoice_new(request):
|
||||||
|
if request.method == "POST":
|
||||||
|
form = OneOffInvoiceForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
invoice = form.build_invoice(request.user)
|
||||||
|
invoice.save()
|
||||||
|
try:
|
||||||
|
create_one_off_invoice(invoice)
|
||||||
|
if form.cleaned_data.get("send_email") and invoice.hosted_invoice_url:
|
||||||
|
send_pay_link_email(
|
||||||
|
to_email=invoice.customer.email,
|
||||||
|
customer_name=invoice.customer.name,
|
||||||
|
description=invoice.description,
|
||||||
|
amount_dollars=invoice.amount_dollars,
|
||||||
|
pay_url=invoice.hosted_invoice_url,
|
||||||
|
kind="invoice",
|
||||||
|
)
|
||||||
|
invoice.pay_link_emailed_at = timezone.now()
|
||||||
|
invoice.save(update_fields=["pay_link_emailed_at"])
|
||||||
|
messages.success(
|
||||||
|
request,
|
||||||
|
f"Invoice created and payment link emailed to {invoice.customer.email}.",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
messages.success(
|
||||||
|
request,
|
||||||
|
"Invoice created in Stripe. Payment link saved on the detail page.",
|
||||||
|
)
|
||||||
|
return redirect("invoice_detail", invoice_id=invoice.pk)
|
||||||
|
except StripeNotConfigured as exc:
|
||||||
|
messages.error(request, str(exc))
|
||||||
|
except StripeAPIError as exc:
|
||||||
|
messages.error(request, f"Stripe error: {exc.user_message}")
|
||||||
|
else:
|
||||||
|
form = OneOffInvoiceForm()
|
||||||
|
|
||||||
|
return render(request, "financial/invoice_new.html", {"form": form})
|
||||||
|
|
||||||
|
|
||||||
|
@financial_admin_required
|
||||||
|
def invoice_detail(request, invoice_id):
|
||||||
|
invoice = get_object_or_404(
|
||||||
|
Invoice.objects.select_related("customer"), pk=invoice_id
|
||||||
|
)
|
||||||
|
return render(request, "financial/invoice_detail.html", {"invoice": invoice})
|
||||||
|
|
||||||
|
|
||||||
|
@financial_admin_required
|
||||||
|
def invoice_resend_email(request, invoice_id):
|
||||||
|
invoice = get_object_or_404(
|
||||||
|
Invoice.objects.select_related("customer"), pk=invoice_id
|
||||||
|
)
|
||||||
|
if request.method != "POST":
|
||||||
|
return redirect("invoice_detail", invoice_id=invoice.pk)
|
||||||
|
if not invoice.hosted_invoice_url:
|
||||||
|
messages.error(request, "No payment link available for this invoice.")
|
||||||
|
return redirect("invoice_detail", invoice_id=invoice.pk)
|
||||||
|
try:
|
||||||
|
send_pay_link_email(
|
||||||
|
to_email=invoice.customer.email,
|
||||||
|
customer_name=invoice.customer.name,
|
||||||
|
description=invoice.description,
|
||||||
|
amount_dollars=invoice.amount_dollars,
|
||||||
|
pay_url=invoice.hosted_invoice_url,
|
||||||
|
kind="invoice",
|
||||||
|
)
|
||||||
|
invoice.pay_link_emailed_at = timezone.now()
|
||||||
|
invoice.save(update_fields=["pay_link_emailed_at"])
|
||||||
|
messages.success(request, f"Payment link resent to {invoice.customer.email}.")
|
||||||
|
except Exception as exc: # noqa: BLE001 — surface mail errors to admin UI
|
||||||
|
messages.error(request, f"Email failed: {exc}")
|
||||||
|
return redirect("invoice_detail", invoice_id=invoice.pk)
|
||||||
|
|
||||||
|
|
||||||
|
@financial_admin_required
|
||||||
|
def subscription_new(request):
|
||||||
|
if request.method == "POST":
|
||||||
|
form = RecurringSubscriptionForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
subscription = form.build_subscription(request.user)
|
||||||
|
subscription.save()
|
||||||
|
success_url = (
|
||||||
|
request.build_absolute_uri(
|
||||||
|
reverse("subscription_detail", args=[subscription.pk])
|
||||||
|
)
|
||||||
|
+ "?checkout=success"
|
||||||
|
)
|
||||||
|
cancel_url = (
|
||||||
|
request.build_absolute_uri(
|
||||||
|
reverse("subscription_detail", args=[subscription.pk])
|
||||||
|
)
|
||||||
|
+ "?checkout=cancel"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
create_subscription_checkout(
|
||||||
|
subscription,
|
||||||
|
success_url=success_url,
|
||||||
|
cancel_url=cancel_url,
|
||||||
|
)
|
||||||
|
if form.cleaned_data.get("send_email") and subscription.checkout_url:
|
||||||
|
send_pay_link_email(
|
||||||
|
to_email=subscription.customer.email,
|
||||||
|
customer_name=subscription.customer.name,
|
||||||
|
description=subscription.description,
|
||||||
|
amount_dollars=subscription.amount_dollars,
|
||||||
|
pay_url=subscription.checkout_url,
|
||||||
|
kind="subscription",
|
||||||
|
)
|
||||||
|
subscription.pay_link_emailed_at = timezone.now()
|
||||||
|
subscription.save(update_fields=["pay_link_emailed_at"])
|
||||||
|
messages.success(
|
||||||
|
request,
|
||||||
|
f"Subscription checkout created and emailed to {subscription.customer.email}.",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
messages.success(
|
||||||
|
request,
|
||||||
|
"Subscription checkout created. Share the link from the detail page.",
|
||||||
|
)
|
||||||
|
return redirect("subscription_detail", subscription_id=subscription.pk)
|
||||||
|
except StripeNotConfigured as exc:
|
||||||
|
messages.error(request, str(exc))
|
||||||
|
except StripeAPIError as exc:
|
||||||
|
messages.error(request, f"Stripe error: {exc.user_message}")
|
||||||
|
else:
|
||||||
|
form = RecurringSubscriptionForm()
|
||||||
|
|
||||||
|
return render(request, "financial/subscription_new.html", {"form": form})
|
||||||
|
|
||||||
|
|
||||||
|
@financial_admin_required
|
||||||
|
def subscription_detail(request, subscription_id):
|
||||||
|
subscription = get_object_or_404(
|
||||||
|
RecurringSubscription.objects.select_related("customer"),
|
||||||
|
pk=subscription_id,
|
||||||
|
)
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"financial/subscription_detail.html",
|
||||||
|
{"subscription": subscription},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@financial_admin_required
|
||||||
|
def subscription_resend_email(request, subscription_id):
|
||||||
|
subscription = get_object_or_404(
|
||||||
|
RecurringSubscription.objects.select_related("customer"),
|
||||||
|
pk=subscription_id,
|
||||||
|
)
|
||||||
|
if request.method != "POST":
|
||||||
|
return redirect("subscription_detail", subscription_id=subscription.pk)
|
||||||
|
if not subscription.checkout_url:
|
||||||
|
messages.error(request, "No checkout link available for this subscription.")
|
||||||
|
return redirect("subscription_detail", subscription_id=subscription.pk)
|
||||||
|
try:
|
||||||
|
send_pay_link_email(
|
||||||
|
to_email=subscription.customer.email,
|
||||||
|
customer_name=subscription.customer.name,
|
||||||
|
description=subscription.description,
|
||||||
|
amount_dollars=subscription.amount_dollars,
|
||||||
|
pay_url=subscription.checkout_url,
|
||||||
|
kind="subscription",
|
||||||
|
)
|
||||||
|
subscription.pay_link_emailed_at = timezone.now()
|
||||||
|
subscription.save(update_fields=["pay_link_emailed_at"])
|
||||||
|
messages.success(
|
||||||
|
request, f"Checkout link resent to {subscription.customer.email}."
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
messages.error(request, f"Email failed: {exc}")
|
||||||
|
return redirect("subscription_detail", subscription_id=subscription.pk)
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_POST
|
||||||
|
def stripe_webhook(request):
|
||||||
|
"""Receive Stripe webhooks; verify signature when secret is configured."""
|
||||||
|
payload = request.body
|
||||||
|
sig_header = request.META.get("HTTP_STRIPE_SIGNATURE", "")
|
||||||
|
webhook_secret = getattr(settings, "STRIPE_WEBHOOK_SECRET", "") or ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
if webhook_secret:
|
||||||
|
event = construct_webhook_event(payload, sig_header, webhook_secret)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"STRIPE_WEBHOOK_SECRET unset — accepting unsigned webhook (dev only)"
|
||||||
|
)
|
||||||
|
event = json.loads(payload.decode("utf-8"))
|
||||||
|
except (ValueError, json.JSONDecodeError) as exc:
|
||||||
|
return HttpResponseBadRequest(str(exc))
|
||||||
|
|
||||||
|
event_type = event.get("type")
|
||||||
|
data_object = event.get("data", {}).get("object", {})
|
||||||
|
|
||||||
|
if event_type in {
|
||||||
|
"invoice.paid",
|
||||||
|
"invoice.payment_failed",
|
||||||
|
"invoice.finalized",
|
||||||
|
"invoice.voided",
|
||||||
|
"invoice.marked_uncollectible",
|
||||||
|
}:
|
||||||
|
apply_stripe_invoice_event(data_object)
|
||||||
|
elif event_type in {
|
||||||
|
"customer.subscription.updated",
|
||||||
|
"customer.subscription.deleted",
|
||||||
|
}:
|
||||||
|
apply_subscription_event(data_object)
|
||||||
|
elif event_type == "checkout.session.completed":
|
||||||
|
apply_checkout_session_completed(data_object)
|
||||||
|
|
||||||
|
return HttpResponse(status=200)
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# Generated by Django 5.0 on 2026-07-31 11:31
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django.utils.timezone
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('financial', '0015_userprofile'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='BillingCustomer',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created', models.DateTimeField(default=django.utils.timezone.now)),
|
||||||
|
('last_modified', models.DateTimeField(default=django.utils.timezone.now)),
|
||||||
|
('slug', models.SlugField(blank=True, null=True, unique=True)),
|
||||||
|
('name', models.CharField(max_length=200)),
|
||||||
|
('email', models.EmailField(max_length=254)),
|
||||||
|
('company', models.CharField(blank=True, max_length=200)),
|
||||||
|
('stripe_customer_id', models.CharField(blank=True, default='', max_length=255)),
|
||||||
|
('notes', models.TextField(blank=True)),
|
||||||
|
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('last_modified_BY', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Invoice',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created', models.DateTimeField(default=django.utils.timezone.now)),
|
||||||
|
('last_modified', models.DateTimeField(default=django.utils.timezone.now)),
|
||||||
|
('slug', models.SlugField(blank=True, null=True, unique=True)),
|
||||||
|
('description', models.CharField(max_length=500)),
|
||||||
|
('amount_cents', models.PositiveIntegerField(help_text='Amount in cents (USD)')),
|
||||||
|
('currency', models.CharField(default='usd', max_length=3)),
|
||||||
|
('status', models.CharField(choices=[('draft', 'Draft'), ('open', 'Open'), ('paid', 'Paid'), ('void', 'Void'), ('uncollectible', 'Uncollectible'), ('failed', 'Failed')], default='draft', max_length=20)),
|
||||||
|
('due_date', models.DateField(blank=True, null=True)),
|
||||||
|
('notes', models.TextField(blank=True)),
|
||||||
|
('stripe_invoice_id', models.CharField(blank=True, default='', max_length=255)),
|
||||||
|
('hosted_invoice_url', models.URLField(blank=True, default='', max_length=500)),
|
||||||
|
('invoice_pdf_url', models.URLField(blank=True, default='', max_length=500)),
|
||||||
|
('pay_link_emailed_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='invoices', to='financial.billingcustomer')),
|
||||||
|
('last_modified_BY', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='RecurringSubscription',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created', models.DateTimeField(default=django.utils.timezone.now)),
|
||||||
|
('last_modified', models.DateTimeField(default=django.utils.timezone.now)),
|
||||||
|
('slug', models.SlugField(blank=True, null=True, unique=True)),
|
||||||
|
('description', models.CharField(max_length=500)),
|
||||||
|
('amount_cents', models.PositiveIntegerField(help_text='Amount per period in cents (USD)')),
|
||||||
|
('currency', models.CharField(default='usd', max_length=3)),
|
||||||
|
('interval', models.CharField(choices=[('month', 'Monthly'), ('year', 'Yearly'), ('week', 'Weekly')], default='month', max_length=10)),
|
||||||
|
('status', models.CharField(choices=[('incomplete', 'Incomplete'), ('active', 'Active'), ('past_due', 'Past due'), ('canceled', 'Canceled'), ('unpaid', 'Unpaid'), ('trialing', 'Trialing'), ('paused', 'Paused')], default='incomplete', max_length=20)),
|
||||||
|
('notes', models.TextField(blank=True)),
|
||||||
|
('stripe_product_id', models.CharField(blank=True, default='', max_length=255)),
|
||||||
|
('stripe_price_id', models.CharField(blank=True, default='', max_length=255)),
|
||||||
|
('stripe_subscription_id', models.CharField(blank=True, default='', max_length=255)),
|
||||||
|
('checkout_session_id', models.CharField(blank=True, default='', max_length=255)),
|
||||||
|
('checkout_url', models.URLField(blank=True, default='', max_length=500)),
|
||||||
|
('pay_link_emailed_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='subscriptions', to='financial.billingcustomer')),
|
||||||
|
('last_modified_BY', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -302,6 +302,108 @@ class TimeCardCell(IdMixin, TimeMixin):
|
|||||||
charge_number = models.ForeignKey(ChargeNumber, on_delete=models.CASCADE, null=True, blank=True)
|
charge_number = models.ForeignKey(ChargeNumber, on_delete=models.CASCADE, null=True, blank=True)
|
||||||
|
|
||||||
|
|
||||||
|
class BillingCustomer(IdMixin, TimeMixin):
|
||||||
|
"""Customer who pays us (one-off invoices or subscriptions)."""
|
||||||
|
|
||||||
|
name = models.CharField(max_length=200)
|
||||||
|
email = models.EmailField()
|
||||||
|
company = models.CharField(max_length=200, blank=True)
|
||||||
|
stripe_customer_id = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
notes = models.TextField(blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["name"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name} <{self.email}>"
|
||||||
|
|
||||||
|
|
||||||
|
class Invoice(IdMixin, TimeMixin):
|
||||||
|
"""One-off invoice — customer pays us via Stripe hosted invoice URL."""
|
||||||
|
|
||||||
|
class Status(models.TextChoices):
|
||||||
|
DRAFT = "draft", "Draft"
|
||||||
|
OPEN = "open", "Open"
|
||||||
|
PAID = "paid", "Paid"
|
||||||
|
VOID = "void", "Void"
|
||||||
|
UNCOLLECTIBLE = "uncollectible", "Uncollectible"
|
||||||
|
FAILED = "failed", "Failed"
|
||||||
|
|
||||||
|
customer = models.ForeignKey(
|
||||||
|
BillingCustomer, on_delete=models.PROTECT, related_name="invoices"
|
||||||
|
)
|
||||||
|
description = models.CharField(max_length=500)
|
||||||
|
amount_cents = models.PositiveIntegerField(help_text="Amount in cents (USD)")
|
||||||
|
currency = models.CharField(max_length=3, default="usd")
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=20, choices=Status.choices, default=Status.DRAFT
|
||||||
|
)
|
||||||
|
due_date = models.DateField(null=True, blank=True)
|
||||||
|
notes = models.TextField(blank=True)
|
||||||
|
stripe_invoice_id = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
hosted_invoice_url = models.URLField(max_length=500, blank=True, default="")
|
||||||
|
invoice_pdf_url = models.URLField(max_length=500, blank=True, default="")
|
||||||
|
pay_link_emailed_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Invoice {self.pk} — {self.customer} — {self.status}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def amount_dollars(self):
|
||||||
|
return self.amount_cents / 100.0
|
||||||
|
|
||||||
|
|
||||||
|
class RecurringSubscription(IdMixin, TimeMixin):
|
||||||
|
"""Monthly (or custom) recurring charge — customer pays us via Stripe Subscription."""
|
||||||
|
|
||||||
|
class Status(models.TextChoices):
|
||||||
|
INCOMPLETE = "incomplete", "Incomplete"
|
||||||
|
ACTIVE = "active", "Active"
|
||||||
|
PAST_DUE = "past_due", "Past due"
|
||||||
|
CANCELED = "canceled", "Canceled"
|
||||||
|
UNPAID = "unpaid", "Unpaid"
|
||||||
|
TRIALING = "trialing", "Trialing"
|
||||||
|
PAUSED = "paused", "Paused"
|
||||||
|
|
||||||
|
class Interval(models.TextChoices):
|
||||||
|
MONTH = "month", "Monthly"
|
||||||
|
YEAR = "year", "Yearly"
|
||||||
|
WEEK = "week", "Weekly"
|
||||||
|
|
||||||
|
customer = models.ForeignKey(
|
||||||
|
BillingCustomer, on_delete=models.PROTECT, related_name="subscriptions"
|
||||||
|
)
|
||||||
|
description = models.CharField(max_length=500)
|
||||||
|
amount_cents = models.PositiveIntegerField(help_text="Amount per period in cents (USD)")
|
||||||
|
currency = models.CharField(max_length=3, default="usd")
|
||||||
|
interval = models.CharField(
|
||||||
|
max_length=10, choices=Interval.choices, default=Interval.MONTH
|
||||||
|
)
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=20, choices=Status.choices, default=Status.INCOMPLETE
|
||||||
|
)
|
||||||
|
notes = models.TextField(blank=True)
|
||||||
|
stripe_product_id = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
stripe_price_id = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
stripe_subscription_id = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
checkout_session_id = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
checkout_url = models.URLField(max_length=500, blank=True, default="")
|
||||||
|
pay_link_emailed_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Subscription {self.pk} — {self.customer} — {self.status}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def amount_dollars(self):
|
||||||
|
return self.amount_cents / 100.0
|
||||||
|
|
||||||
|
|
||||||
def set_user_type(user, user_type):
|
def set_user_type(user, user_type):
|
||||||
"""Set user type and sync the Employee record (mutually exclusive types)."""
|
"""Set user type and sync the Employee record (mutually exclusive types)."""
|
||||||
user.__dict__.pop("profile", None)
|
user.__dict__.pop("profile", None)
|
||||||
|
|||||||
@@ -0,0 +1,383 @@
|
|||||||
|
"""Stripe REST helpers (stdlib HTTP) for invoices and subscriptions.
|
||||||
|
|
||||||
|
Customers pay us — one-off hosted invoices and Checkout subscription sessions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.mail import EmailMultiAlternatives
|
||||||
|
from django.template.loader import get_template
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from .models import BillingCustomer, Invoice, RecurringSubscription
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
STRIPE_API_BASE = "https://api.stripe.com/v1"
|
||||||
|
|
||||||
|
|
||||||
|
class StripeNotConfigured(Exception):
|
||||||
|
"""Raised when Stripe secret key is missing."""
|
||||||
|
|
||||||
|
|
||||||
|
class StripeAPIError(Exception):
|
||||||
|
"""Raised when Stripe API returns an error."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, status: int | None = None):
|
||||||
|
super().__init__(message)
|
||||||
|
self.status = status
|
||||||
|
self.user_message = message
|
||||||
|
|
||||||
|
|
||||||
|
def _secret_key() -> str:
|
||||||
|
key = getattr(settings, "STRIPE_SECRET_KEY", "") or ""
|
||||||
|
if not key:
|
||||||
|
raise StripeNotConfigured(
|
||||||
|
"STRIPE_SECRET_KEY is not set. Add it to the environment."
|
||||||
|
)
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def _stripe_request(method: str, path: str, data: dict | None = None) -> dict:
|
||||||
|
"""Call Stripe API with form-encoded body (Stripe default)."""
|
||||||
|
url = f"{STRIPE_API_BASE}{path}"
|
||||||
|
body = None
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {_secret_key()}",
|
||||||
|
}
|
||||||
|
if data is not None:
|
||||||
|
body = urllib.parse.urlencode(_flatten_params(data)).encode("utf-8")
|
||||||
|
headers["Content-Type"] = "application/x-www-form-urlencoded"
|
||||||
|
|
||||||
|
request = urllib.request.Request(url, data=body, headers=headers, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=30) as response:
|
||||||
|
return json.loads(response.read().decode("utf-8"))
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
raw = exc.read().decode("utf-8", errors="replace")
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw)
|
||||||
|
message = payload.get("error", {}).get("message") or raw
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
message = raw or str(exc)
|
||||||
|
raise StripeAPIError(message, status=exc.code) from exc
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
raise StripeAPIError(f"Could not reach Stripe: {exc.reason}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _flatten_params(data: dict, prefix: str = "") -> list[tuple[str, str]]:
|
||||||
|
"""Flatten nested dicts/lists into Stripe form keys (a[b]=1, items[0][x]=y)."""
|
||||||
|
items: list[tuple[str, str]] = []
|
||||||
|
for key, value in data.items():
|
||||||
|
full_key = f"{prefix}[{key}]" if prefix else str(key)
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
if isinstance(value, dict):
|
||||||
|
items.extend(_flatten_params(value, full_key))
|
||||||
|
elif isinstance(value, (list, tuple)):
|
||||||
|
for index, entry in enumerate(value):
|
||||||
|
indexed = f"{full_key}[{index}]"
|
||||||
|
if isinstance(entry, dict):
|
||||||
|
items.extend(_flatten_params(entry, indexed))
|
||||||
|
else:
|
||||||
|
items.append((indexed, str(entry)))
|
||||||
|
elif isinstance(value, bool):
|
||||||
|
items.append((full_key, "true" if value else "false"))
|
||||||
|
else:
|
||||||
|
items.append((full_key, str(value)))
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def construct_webhook_event(payload: bytes, sig_header: str, secret: str) -> dict:
|
||||||
|
"""Verify Stripe-Signature and return event dict."""
|
||||||
|
if not sig_header:
|
||||||
|
raise ValueError("Missing Stripe-Signature header")
|
||||||
|
|
||||||
|
elements = {part.split("=", 1)[0]: part.split("=", 1)[1] for part in sig_header.split(",") if "=" in part}
|
||||||
|
timestamp = elements.get("t")
|
||||||
|
signatures = [part.split("=", 1)[1] for part in sig_header.split(",") if part.startswith("v1=")]
|
||||||
|
if not timestamp or not signatures:
|
||||||
|
raise ValueError("Invalid Stripe-Signature header")
|
||||||
|
|
||||||
|
signed_payload = f"{timestamp}.{payload.decode('utf-8')}".encode("utf-8")
|
||||||
|
expected = hmac.new(secret.encode("utf-8"), signed_payload, hashlib.sha256).hexdigest()
|
||||||
|
if not any(hmac.compare_digest(expected, candidate) for candidate in signatures):
|
||||||
|
raise ValueError("Webhook signature verification failed")
|
||||||
|
|
||||||
|
# Reject stale timestamps (>5 minutes)
|
||||||
|
if abs(time.time() - int(timestamp)) > 300:
|
||||||
|
raise ValueError("Webhook timestamp outside tolerance")
|
||||||
|
|
||||||
|
return json.loads(payload.decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_stripe_customer(customer: BillingCustomer) -> str:
|
||||||
|
"""Create Stripe Customer if needed; return stripe_customer_id."""
|
||||||
|
if customer.stripe_customer_id:
|
||||||
|
return customer.stripe_customer_id
|
||||||
|
|
||||||
|
stripe_customer = _stripe_request(
|
||||||
|
"POST",
|
||||||
|
"/customers",
|
||||||
|
{
|
||||||
|
"email": customer.email,
|
||||||
|
"name": customer.name,
|
||||||
|
"metadata": {
|
||||||
|
"local_customer_id": str(customer.pk),
|
||||||
|
"company": customer.company or "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
customer.stripe_customer_id = stripe_customer["id"]
|
||||||
|
customer.save(update_fields=["stripe_customer_id", "last_modified"])
|
||||||
|
return customer.stripe_customer_id
|
||||||
|
|
||||||
|
|
||||||
|
def create_one_off_invoice(invoice: Invoice) -> Invoice:
|
||||||
|
"""Create + finalize a Stripe Invoice; store hosted pay URL."""
|
||||||
|
stripe_customer_id = ensure_stripe_customer(invoice.customer)
|
||||||
|
|
||||||
|
_stripe_request(
|
||||||
|
"POST",
|
||||||
|
"/invoiceitems",
|
||||||
|
{
|
||||||
|
"customer": stripe_customer_id,
|
||||||
|
"amount": invoice.amount_cents,
|
||||||
|
"currency": invoice.currency,
|
||||||
|
"description": invoice.description,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
create_data: dict = {
|
||||||
|
"customer": stripe_customer_id,
|
||||||
|
"collection_method": "send_invoice",
|
||||||
|
"auto_advance": True,
|
||||||
|
"metadata": {"local_invoice_id": str(invoice.pk)},
|
||||||
|
}
|
||||||
|
if invoice.due_date:
|
||||||
|
due_dt = datetime.combine(invoice.due_date, datetime.min.time())
|
||||||
|
if timezone.is_naive(due_dt):
|
||||||
|
due_dt = timezone.make_aware(due_dt, timezone.get_current_timezone())
|
||||||
|
create_data["due_date"] = int(due_dt.timestamp())
|
||||||
|
else:
|
||||||
|
create_data["days_until_due"] = 30
|
||||||
|
|
||||||
|
stripe_invoice = _stripe_request("POST", "/invoices", create_data)
|
||||||
|
stripe_invoice = _stripe_request(
|
||||||
|
"POST", f"/invoices/{stripe_invoice['id']}/finalize", {}
|
||||||
|
)
|
||||||
|
|
||||||
|
invoice.stripe_invoice_id = stripe_invoice["id"]
|
||||||
|
invoice.hosted_invoice_url = stripe_invoice.get("hosted_invoice_url") or ""
|
||||||
|
invoice.invoice_pdf_url = stripe_invoice.get("invoice_pdf") or ""
|
||||||
|
status = stripe_invoice.get("status") or Invoice.Status.OPEN
|
||||||
|
if status in dict(Invoice.Status.choices):
|
||||||
|
invoice.status = status
|
||||||
|
invoice.last_modified = timezone.now()
|
||||||
|
invoice.save(
|
||||||
|
update_fields=[
|
||||||
|
"stripe_invoice_id",
|
||||||
|
"hosted_invoice_url",
|
||||||
|
"invoice_pdf_url",
|
||||||
|
"status",
|
||||||
|
"last_modified",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return invoice
|
||||||
|
|
||||||
|
|
||||||
|
def create_subscription_checkout(
|
||||||
|
subscription: RecurringSubscription,
|
||||||
|
*,
|
||||||
|
success_url: str,
|
||||||
|
cancel_url: str,
|
||||||
|
) -> RecurringSubscription:
|
||||||
|
"""Create Stripe Product/Price + Checkout Session (subscription mode)."""
|
||||||
|
stripe_customer_id = ensure_stripe_customer(subscription.customer)
|
||||||
|
|
||||||
|
product = _stripe_request(
|
||||||
|
"POST",
|
||||||
|
"/products",
|
||||||
|
{
|
||||||
|
"name": subscription.description,
|
||||||
|
"metadata": {"local_subscription_id": str(subscription.pk)},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
price = _stripe_request(
|
||||||
|
"POST",
|
||||||
|
"/prices",
|
||||||
|
{
|
||||||
|
"product": product["id"],
|
||||||
|
"unit_amount": subscription.amount_cents,
|
||||||
|
"currency": subscription.currency,
|
||||||
|
"recurring": {"interval": subscription.interval},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
session = _stripe_request(
|
||||||
|
"POST",
|
||||||
|
"/checkout/sessions",
|
||||||
|
{
|
||||||
|
"mode": "subscription",
|
||||||
|
"customer": stripe_customer_id,
|
||||||
|
"line_items": [{"price": price["id"], "quantity": 1}],
|
||||||
|
"success_url": success_url,
|
||||||
|
"cancel_url": cancel_url,
|
||||||
|
"metadata": {"local_subscription_id": str(subscription.pk)},
|
||||||
|
"subscription_data": {
|
||||||
|
"metadata": {"local_subscription_id": str(subscription.pk)},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
subscription.stripe_product_id = product["id"]
|
||||||
|
subscription.stripe_price_id = price["id"]
|
||||||
|
subscription.checkout_session_id = session["id"]
|
||||||
|
subscription.checkout_url = session.get("url") or ""
|
||||||
|
subscription.status = RecurringSubscription.Status.INCOMPLETE
|
||||||
|
subscription.last_modified = timezone.now()
|
||||||
|
subscription.save(
|
||||||
|
update_fields=[
|
||||||
|
"stripe_product_id",
|
||||||
|
"stripe_price_id",
|
||||||
|
"checkout_session_id",
|
||||||
|
"checkout_url",
|
||||||
|
"status",
|
||||||
|
"last_modified",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return subscription
|
||||||
|
|
||||||
|
|
||||||
|
def dollars_to_cents(amount: Decimal | float | str) -> int:
|
||||||
|
return int(round(Decimal(str(amount)) * 100))
|
||||||
|
|
||||||
|
|
||||||
|
def send_pay_link_email(
|
||||||
|
*,
|
||||||
|
to_email: str,
|
||||||
|
customer_name: str,
|
||||||
|
description: str,
|
||||||
|
amount_dollars: float,
|
||||||
|
pay_url: str,
|
||||||
|
kind: str = "invoice",
|
||||||
|
) -> None:
|
||||||
|
"""Email customer a Stripe payment / checkout link."""
|
||||||
|
subject = (
|
||||||
|
f"Payment request from AI ML Operations — {description}"
|
||||||
|
if kind == "invoice"
|
||||||
|
else f"Subscription signup — {description}"
|
||||||
|
)
|
||||||
|
from_email = getattr(
|
||||||
|
settings,
|
||||||
|
"DEFAULT_FROM_EMAIL",
|
||||||
|
"AI ML Operations, LLC <info@aimloperations.com>",
|
||||||
|
)
|
||||||
|
context = {
|
||||||
|
"customer_name": customer_name,
|
||||||
|
"description": description,
|
||||||
|
"amount_dollars": f"{amount_dollars:.2f}",
|
||||||
|
"pay_url": pay_url,
|
||||||
|
"kind": kind,
|
||||||
|
"subject": subject,
|
||||||
|
}
|
||||||
|
html_content = get_template("emails/invoice_pay_link.html").render(context)
|
||||||
|
text_content = get_template("emails/invoice_pay_link.txt").render(context)
|
||||||
|
msg = EmailMultiAlternatives(subject, text_content, from_email, [to_email])
|
||||||
|
msg.attach_alternative(html_content, "text/html")
|
||||||
|
msg.send(fail_silently=False)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_stripe_invoice_event(stripe_invoice: dict) -> Invoice | None:
|
||||||
|
"""Sync local Invoice from Stripe invoice object dict."""
|
||||||
|
local_id = (stripe_invoice.get("metadata") or {}).get("local_invoice_id")
|
||||||
|
stripe_id = stripe_invoice.get("id")
|
||||||
|
invoice = None
|
||||||
|
if local_id:
|
||||||
|
invoice = Invoice.objects.filter(pk=local_id).first()
|
||||||
|
if invoice is None and stripe_id:
|
||||||
|
invoice = Invoice.objects.filter(stripe_invoice_id=stripe_id).first()
|
||||||
|
if invoice is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
status = stripe_invoice.get("status") or invoice.status
|
||||||
|
if status in dict(Invoice.Status.choices):
|
||||||
|
invoice.status = status
|
||||||
|
invoice.hosted_invoice_url = (
|
||||||
|
stripe_invoice.get("hosted_invoice_url") or invoice.hosted_invoice_url
|
||||||
|
)
|
||||||
|
invoice.invoice_pdf_url = (
|
||||||
|
stripe_invoice.get("invoice_pdf") or invoice.invoice_pdf_url
|
||||||
|
)
|
||||||
|
if stripe_id:
|
||||||
|
invoice.stripe_invoice_id = stripe_id
|
||||||
|
invoice.last_modified = timezone.now()
|
||||||
|
invoice.save(
|
||||||
|
update_fields=[
|
||||||
|
"status",
|
||||||
|
"hosted_invoice_url",
|
||||||
|
"invoice_pdf_url",
|
||||||
|
"stripe_invoice_id",
|
||||||
|
"last_modified",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return invoice
|
||||||
|
|
||||||
|
|
||||||
|
def apply_subscription_event(stripe_sub: dict) -> RecurringSubscription | None:
|
||||||
|
"""Sync local RecurringSubscription from Stripe subscription object."""
|
||||||
|
local_id = (stripe_sub.get("metadata") or {}).get("local_subscription_id")
|
||||||
|
stripe_id = stripe_sub.get("id")
|
||||||
|
sub = None
|
||||||
|
if local_id:
|
||||||
|
sub = RecurringSubscription.objects.filter(pk=local_id).first()
|
||||||
|
if sub is None and stripe_id:
|
||||||
|
sub = RecurringSubscription.objects.filter(
|
||||||
|
stripe_subscription_id=stripe_id
|
||||||
|
).first()
|
||||||
|
if sub is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
status = stripe_sub.get("status") or sub.status
|
||||||
|
if status in dict(RecurringSubscription.Status.choices):
|
||||||
|
sub.status = status
|
||||||
|
if stripe_id:
|
||||||
|
sub.stripe_subscription_id = stripe_id
|
||||||
|
sub.last_modified = timezone.now()
|
||||||
|
sub.save(update_fields=["status", "stripe_subscription_id", "last_modified"])
|
||||||
|
return sub
|
||||||
|
|
||||||
|
|
||||||
|
def apply_checkout_session_completed(session: dict) -> RecurringSubscription | None:
|
||||||
|
"""Attach subscription id after Checkout completes."""
|
||||||
|
if session.get("mode") != "subscription":
|
||||||
|
return None
|
||||||
|
local_id = (session.get("metadata") or {}).get("local_subscription_id")
|
||||||
|
sub = None
|
||||||
|
if local_id:
|
||||||
|
sub = RecurringSubscription.objects.filter(pk=local_id).first()
|
||||||
|
if sub is None and session.get("id"):
|
||||||
|
sub = RecurringSubscription.objects.filter(
|
||||||
|
checkout_session_id=session["id"]
|
||||||
|
).first()
|
||||||
|
if sub is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
stripe_sub_id = session.get("subscription")
|
||||||
|
if stripe_sub_id:
|
||||||
|
sub.stripe_subscription_id = stripe_sub_id
|
||||||
|
sub.status = RecurringSubscription.Status.ACTIVE
|
||||||
|
sub.last_modified = timezone.now()
|
||||||
|
sub.save(update_fields=["stripe_subscription_id", "status", "last_modified"])
|
||||||
|
return sub
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{ subject }}</title>
|
||||||
|
<style>
|
||||||
|
body { margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f4f4; }
|
||||||
|
.email-container { max-width: 600px; margin: 0 auto; background-color: #ffffff; border: 1px solid #dddddd; }
|
||||||
|
.header { background-color: #0b3d5c; color: #ffffff; padding: 20px; text-align: center; }
|
||||||
|
.content { padding: 24px; color: #333333; font-size: 15px; line-height: 1.5; }
|
||||||
|
.btn {
|
||||||
|
display: inline-block; margin: 20px 0; padding: 12px 24px;
|
||||||
|
background-color: #0b3d5c; color: #ffffff !important; text-decoration: none;
|
||||||
|
border-radius: 4px; font-weight: bold;
|
||||||
|
}
|
||||||
|
.footer { background-color: #f4f4f4; color: #777777; text-align: center; padding: 12px; font-size: 12px; }
|
||||||
|
.amount { font-size: 22px; font-weight: bold; margin: 12px 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="email-container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>AI ML Operations</h1>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<p>Hello {{ customer_name }},</p>
|
||||||
|
{% if kind == "subscription" %}
|
||||||
|
<p>Please complete checkout to start your recurring payment:</p>
|
||||||
|
{% else %}
|
||||||
|
<p>You have a new invoice ready for payment:</p>
|
||||||
|
{% endif %}
|
||||||
|
<p><strong>{{ description }}</strong></p>
|
||||||
|
<p class="amount">${{ amount_dollars }} USD</p>
|
||||||
|
<p>
|
||||||
|
<a class="btn" href="{{ pay_url }}">
|
||||||
|
{% if kind == "subscription" %}Complete checkout{% else %}Pay invoice{% endif %}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p style="font-size: 13px; color: #666;">Or open this link:<br>{{ pay_url }}</p>
|
||||||
|
<p>Thank you,<br>AI ML Operations, LLC</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>This is an automated message. Please do not reply to this email.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
Hello {{ customer_name }},
|
||||||
|
|
||||||
|
{% if kind == "subscription" %}Please complete checkout to start your recurring payment:{% else %}You have a new invoice ready for payment:{% endif %}
|
||||||
|
|
||||||
|
{{ description }}
|
||||||
|
Amount: ${{ amount_dollars }} USD
|
||||||
|
|
||||||
|
{% if kind == "subscription" %}Checkout link:{% else %}Pay invoice:{% endif %}
|
||||||
|
{{ pay_url }}
|
||||||
|
|
||||||
|
Thank you,
|
||||||
|
AI ML Operations, LLC
|
||||||
@@ -29,6 +29,11 @@
|
|||||||
<span class="card-title">Manage Users</span>
|
<span class="card-title">Manage Users</span>
|
||||||
<p class="card-text">Set Employee or Client type for user accounts.</p>
|
<p class="card-text">Set Employee or Client type for user accounts.</p>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="{% url 'invoice_list' %}" class="card"
|
||||||
|
data-tianji-event="financial_nav" data-tianji-event-destination="invoices">
|
||||||
|
<span class="card-title">Invoices</span>
|
||||||
|
<p class="card-text">Create one-off invoices and monthly subscriptions (Stripe).</p>
|
||||||
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if can_write_financials %}
|
{% if can_write_financials %}
|
||||||
<a href="{% url 'Timekeeping' %}" class="card"
|
<a href="{% url 'Timekeeping' %}" class="card"
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Invoice {{ invoice.pk }} - AI ML Operations{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="section">
|
||||||
|
<div class="container">
|
||||||
|
<div style="margin-bottom: 2rem;">
|
||||||
|
<a href="{% url 'invoice_list' %}" class="btn">Back to Invoices</a>
|
||||||
|
</div>
|
||||||
|
<h1 class="section-title" style="text-align: left;">Invoice #{{ invoice.pk }}</h1>
|
||||||
|
<div class="card" style="max-width: 720px;">
|
||||||
|
<p><strong>Customer:</strong> {{ invoice.customer.name }} <{{ invoice.customer.email }}></p>
|
||||||
|
<p><strong>Description:</strong> {{ invoice.description }}</p>
|
||||||
|
<p><strong>Amount:</strong> ${{ invoice.amount_dollars|floatformat:2 }} {{ invoice.currency|upper }}</p>
|
||||||
|
<p><strong>Status:</strong> {{ invoice.get_status_display }}</p>
|
||||||
|
<p><strong>Due:</strong> {{ invoice.due_date|default:"—" }}</p>
|
||||||
|
<p><strong>Stripe invoice:</strong> {{ invoice.stripe_invoice_id|default:"—" }}</p>
|
||||||
|
{% if invoice.hosted_invoice_url %}
|
||||||
|
<p><strong>Pay link:</strong> <a href="{{ invoice.hosted_invoice_url }}" target="_blank" rel="noopener">Open hosted invoice</a></p>
|
||||||
|
{% endif %}
|
||||||
|
{% if invoice.invoice_pdf_url %}
|
||||||
|
<p><strong>PDF:</strong> <a href="{{ invoice.invoice_pdf_url }}" target="_blank" rel="noopener">Download</a></p>
|
||||||
|
{% endif %}
|
||||||
|
<p><strong>Email sent:</strong> {{ invoice.pay_link_emailed_at|default:"Not yet" }}</p>
|
||||||
|
{% if invoice.notes %}<p><strong>Notes:</strong> {{ invoice.notes }}</p>{% endif %}
|
||||||
|
|
||||||
|
{% if invoice.hosted_invoice_url %}
|
||||||
|
<form method="post" action="{% url 'invoice_resend_email' invoice.pk %}" style="margin-top: 1.5rem;">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn">Resend payment email</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}New Invoice - AI ML Operations{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="section">
|
||||||
|
<div class="container">
|
||||||
|
<div style="margin-bottom: 2rem;">
|
||||||
|
<a href="{% url 'invoice_list' %}" class="btn">Back to Invoices</a>
|
||||||
|
</div>
|
||||||
|
<h1 class="section-title" style="text-align: left;">New one-off invoice</h1>
|
||||||
|
<p style="color: var(--text-muted); margin-bottom: 1.5rem;">
|
||||||
|
Creates a Stripe invoice (customer pays us) and optionally emails the pay link.
|
||||||
|
</p>
|
||||||
|
<div class="card" style="max-width: 640px;">
|
||||||
|
<form method="post" action="{% url 'invoice_new' %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form.as_p }}
|
||||||
|
<button type="submit" class="btn" style="margin-top: 1rem;">Create & send</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Invoices - AI ML Operations{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="section">
|
||||||
|
<div class="container">
|
||||||
|
<div style="margin-bottom: 2rem;">
|
||||||
|
<a href="{% url 'financial_index' %}" class="btn">Back to Dashboard</a>
|
||||||
|
</div>
|
||||||
|
<h1 class="section-title" style="text-align: left;">Invoices & Subscriptions</h1>
|
||||||
|
|
||||||
|
{% if not stripe_configured %}
|
||||||
|
<p style="color: #c0392b; margin-bottom: 1.5rem;">
|
||||||
|
Stripe is not configured. Set <code>STRIPE_SECRET_KEY</code> (and webhook secret) in the environment before creating invoices.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card-grid" style="margin-bottom: 2rem;">
|
||||||
|
<a href="{% url 'invoice_new' %}" class="card">
|
||||||
|
<span class="card-title">New one-off invoice</span>
|
||||||
|
<p class="card-text">Create a Stripe invoice and email the customer a pay link.</p>
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'subscription_new' %}" class="card">
|
||||||
|
<span class="card-title">New monthly subscription</span>
|
||||||
|
<p class="card-text">Customer pays us on a recurring schedule via Checkout.</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="section-title" style="font-size: 1.5rem; margin-bottom: 1rem;">One-off invoices</h2>
|
||||||
|
<div class="table-responsive" style="margin-bottom: 3rem;">
|
||||||
|
{% if invoices %}
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Customer</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Amount</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Created</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for inv in invoices %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{% url 'invoice_detail' inv.pk %}">{{ inv.customer.name }}</a></td>
|
||||||
|
<td>{{ inv.description }}</td>
|
||||||
|
<td>${{ inv.amount_dollars|floatformat:2 }}</td>
|
||||||
|
<td>{{ inv.get_status_display }}</td>
|
||||||
|
<td>{{ inv.created|date:"Y-m-d" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p style="color: var(--text-muted);">No invoices yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="section-title" style="font-size: 1.5rem; margin-bottom: 1rem;">Recurring subscriptions</h2>
|
||||||
|
<div class="table-responsive">
|
||||||
|
{% if subscriptions %}
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Customer</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Amount</th>
|
||||||
|
<th>Interval</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for sub in subscriptions %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{% url 'subscription_detail' sub.pk %}">{{ sub.customer.name }}</a></td>
|
||||||
|
<td>{{ sub.description }}</td>
|
||||||
|
<td>${{ sub.amount_dollars|floatformat:2 }}</td>
|
||||||
|
<td>{{ sub.get_interval_display }}</td>
|
||||||
|
<td>{{ sub.get_status_display }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p style="color: var(--text-muted);">No subscriptions yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Subscription {{ subscription.pk }} - AI ML Operations{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="section">
|
||||||
|
<div class="container">
|
||||||
|
<div style="margin-bottom: 2rem;">
|
||||||
|
<a href="{% url 'invoice_list' %}" class="btn">Back to Invoices</a>
|
||||||
|
</div>
|
||||||
|
<h1 class="section-title" style="text-align: left;">Subscription #{{ subscription.pk }}</h1>
|
||||||
|
<div class="card" style="max-width: 720px;">
|
||||||
|
<p><strong>Customer:</strong> {{ subscription.customer.name }} <{{ subscription.customer.email }}></p>
|
||||||
|
<p><strong>Description:</strong> {{ subscription.description }}</p>
|
||||||
|
<p><strong>Amount:</strong> ${{ subscription.amount_dollars|floatformat:2 }} / {{ subscription.get_interval_display }}</p>
|
||||||
|
<p><strong>Status:</strong> {{ subscription.get_status_display }}</p>
|
||||||
|
<p><strong>Stripe subscription:</strong> {{ subscription.stripe_subscription_id|default:"—" }}</p>
|
||||||
|
{% if subscription.checkout_url %}
|
||||||
|
<p><strong>Checkout link:</strong> <a href="{{ subscription.checkout_url }}" target="_blank" rel="noopener">Open Checkout</a></p>
|
||||||
|
{% endif %}
|
||||||
|
<p><strong>Email sent:</strong> {{ subscription.pay_link_emailed_at|default:"Not yet" }}</p>
|
||||||
|
{% if subscription.notes %}<p><strong>Notes:</strong> {{ subscription.notes }}</p>{% endif %}
|
||||||
|
|
||||||
|
{% if subscription.checkout_url %}
|
||||||
|
<form method="post" action="{% url 'subscription_resend_email' subscription.pk %}" style="margin-top: 1.5rem;">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn">Resend checkout email</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}New Subscription - AI ML Operations{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="section">
|
||||||
|
<div class="container">
|
||||||
|
<div style="margin-bottom: 2rem;">
|
||||||
|
<a href="{% url 'invoice_list' %}" class="btn">Back to Invoices</a>
|
||||||
|
</div>
|
||||||
|
<h1 class="section-title" style="text-align: left;">New recurring subscription</h1>
|
||||||
|
<p style="color: var(--text-muted); margin-bottom: 1.5rem;">
|
||||||
|
Customer pays us on a schedule via Stripe Checkout. Default interval is monthly.
|
||||||
|
</p>
|
||||||
|
<div class="card" style="max-width: 640px;">
|
||||||
|
<form method="post" action="{% url 'subscription_new' %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form.as_p }}
|
||||||
|
<button type="submit" class="btn" style="margin-top: 1rem;">Create & send</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -157,3 +157,62 @@ class FinancialAccessTests(TestCase):
|
|||||||
usernames = [e.user.username for e in employees]
|
usernames = [e.user.username for e in employees]
|
||||||
self.assertIn("employee", usernames)
|
self.assertIn("employee", usernames)
|
||||||
self.assertNotIn("extra", usernames)
|
self.assertNotIn("extra", usernames)
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceAccessTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.admin = User.objects.create_superuser(
|
||||||
|
username="billing_admin", password="pass", email="admin@example.com"
|
||||||
|
)
|
||||||
|
self.employee_user = User.objects.create_user(username="emp2", password="pass")
|
||||||
|
set_user_type(self.employee_user, UserProfile.UserType.EMPLOYEE)
|
||||||
|
self.client_http = Client()
|
||||||
|
|
||||||
|
def test_non_admin_cannot_view_invoices(self):
|
||||||
|
self.client_http.login(username="emp2", password="pass")
|
||||||
|
response = self.client_http.get(reverse("invoice_list"))
|
||||||
|
self.assertIn(response.status_code, (302, 403))
|
||||||
|
|
||||||
|
def test_admin_can_view_invoices(self):
|
||||||
|
self.client_http.login(username="billing_admin", password="pass")
|
||||||
|
response = self.client_http.get(reverse("invoice_list"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_webhook_rejects_bad_signature_when_secret_set(self):
|
||||||
|
from django.test import override_settings
|
||||||
|
|
||||||
|
with override_settings(STRIPE_WEBHOOK_SECRET="whsec_test"):
|
||||||
|
response = self.client_http.post(
|
||||||
|
reverse("stripe_webhook"),
|
||||||
|
data=b'{"type":"invoice.paid","data":{"object":{}}}',
|
||||||
|
content_type="application/json",
|
||||||
|
HTTP_STRIPE_SIGNATURE="t=1,v1=bad",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_apply_invoice_webhook_updates_status(self):
|
||||||
|
from financial.models import BillingCustomer, Invoice
|
||||||
|
from financial.stripe_billing import apply_stripe_invoice_event
|
||||||
|
|
||||||
|
customer = BillingCustomer.objects.create(
|
||||||
|
name="Acme", email="acme@example.com"
|
||||||
|
)
|
||||||
|
invoice = Invoice.objects.create(
|
||||||
|
customer=customer,
|
||||||
|
description="Work",
|
||||||
|
amount_cents=5000,
|
||||||
|
stripe_invoice_id="in_test_123",
|
||||||
|
status=Invoice.Status.OPEN,
|
||||||
|
)
|
||||||
|
apply_stripe_invoice_event(
|
||||||
|
{
|
||||||
|
"id": "in_test_123",
|
||||||
|
"status": "paid",
|
||||||
|
"metadata": {"local_invoice_id": str(invoice.pk)},
|
||||||
|
"hosted_invoice_url": "https://pay.stripe.com/test",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
invoice.refresh_from_db()
|
||||||
|
self.assertEqual(invoice.status, Invoice.Status.PAID)
|
||||||
|
self.assertEqual(invoice.hosted_invoice_url, "https://pay.stripe.com/test")
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from . import views
|
from . import views
|
||||||
|
from . import invoice_views
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", views.financial_home, name="financial_home"),
|
path("", views.financial_home, name="financial_home"),
|
||||||
@@ -21,4 +22,24 @@ urlpatterns = [
|
|||||||
path("profile", views.profile, name="profile"),
|
path("profile", views.profile, name="profile"),
|
||||||
path("manage_users", views.manage_users, name="manage_users"),
|
path("manage_users", views.manage_users, name="manage_users"),
|
||||||
path("client_reports", views.client_reports, name="client_reports"),
|
path("client_reports", views.client_reports, name="client_reports"),
|
||||||
|
path("invoices", invoice_views.invoice_list, name="invoice_list"),
|
||||||
|
path("invoices/new", invoice_views.invoice_new, name="invoice_new"),
|
||||||
|
path("invoices/<int:invoice_id>", invoice_views.invoice_detail, name="invoice_detail"),
|
||||||
|
path(
|
||||||
|
"invoices/<int:invoice_id>/resend",
|
||||||
|
invoice_views.invoice_resend_email,
|
||||||
|
name="invoice_resend_email",
|
||||||
|
),
|
||||||
|
path("subscriptions/new", invoice_views.subscription_new, name="subscription_new"),
|
||||||
|
path(
|
||||||
|
"subscriptions/<int:subscription_id>",
|
||||||
|
invoice_views.subscription_detail,
|
||||||
|
name="subscription_detail",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"subscriptions/<int:subscription_id>/resend",
|
||||||
|
invoice_views.subscription_resend_email,
|
||||||
|
name="subscription_resend_email",
|
||||||
|
),
|
||||||
|
path("stripe/webhook/", invoice_views.stripe_webhook, name="stripe_webhook"),
|
||||||
]
|
]
|
||||||
@@ -1,20 +1,34 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from .models import Contact, EmailMessage
|
|
||||||
from .views import preview_email
|
|
||||||
from django.shortcuts import render, get_object_or_404
|
|
||||||
from django.urls import path
|
|
||||||
from django.template.loader import get_template
|
|
||||||
from django.core.mail import EmailMultiAlternatives
|
from django.core.mail import EmailMultiAlternatives
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.template.loader import get_template
|
||||||
from django.template.response import TemplateResponse
|
from django.template.response import TemplateResponse
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
# Register your models here.
|
from .models import Contact, EmailMessage, PageVisit
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Contact, site=admin.site)
|
@admin.register(Contact, site=admin.site)
|
||||||
class ContactAdmin(admin.ModelAdmin):
|
class ContactAdmin(admin.ModelAdmin):
|
||||||
list_display = ("email", "name", "contacted")
|
list_display = (
|
||||||
list_filter = ("email", "name", "contacted")
|
"email",
|
||||||
search_fields = ("email", "name")
|
"name",
|
||||||
|
"contacted",
|
||||||
|
"utm_source",
|
||||||
|
"utm_campaign",
|
||||||
|
"created",
|
||||||
|
)
|
||||||
|
list_filter = ("contacted", "utm_source", "utm_medium", "utm_campaign")
|
||||||
|
search_fields = ("email", "name", "utm_source", "utm_campaign")
|
||||||
|
readonly_fields = (
|
||||||
|
"utm_source",
|
||||||
|
"utm_medium",
|
||||||
|
"utm_campaign",
|
||||||
|
"utm_term",
|
||||||
|
"utm_content",
|
||||||
|
"created",
|
||||||
|
"last_modified",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@admin.action(description="Send seelcted emails")
|
@admin.action(description="Send seelcted emails")
|
||||||
@@ -25,8 +39,8 @@ def send_emails(modeladmin, request, queryset):
|
|||||||
from_email = "AI ML Operations, LLC <info@aimloperations.com>"
|
from_email = "AI ML Operations, LLC <info@aimloperations.com>"
|
||||||
d = {"title": email.subject, "content": email.body}
|
d = {"title": email.subject, "content": email.body}
|
||||||
|
|
||||||
html_content = get_template(f"emails/marketing_email.html").render(d)
|
html_content = get_template("emails/marketing_email.html").render(d)
|
||||||
text_content = get_template(f"emails/marketing_email.txt").render(d)
|
text_content = get_template("emails/marketing_email.txt").render(d)
|
||||||
|
|
||||||
msg = EmailMultiAlternatives(
|
msg = EmailMultiAlternatives(
|
||||||
email.subject, text_content, from_email, [email.recipient]
|
email.subject, text_content, from_email, [email.recipient]
|
||||||
@@ -65,3 +79,40 @@ class EmailMessageAdmin(admin.ModelAdmin):
|
|||||||
email_instance = get_object_or_404(EmailMessage, pk=pk)
|
email_instance = get_object_or_404(EmailMessage, pk=pk)
|
||||||
context = {"title": email_instance.subject, "content": email_instance.body}
|
context = {"title": email_instance.subject, "content": email_instance.body}
|
||||||
return TemplateResponse(request, "public/preview_email.html", context)
|
return TemplateResponse(request, "public/preview_email.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(PageVisit)
|
||||||
|
class PageVisitAdmin(admin.ModelAdmin):
|
||||||
|
list_display = (
|
||||||
|
"created",
|
||||||
|
"path",
|
||||||
|
"traffic_type",
|
||||||
|
"utm_source",
|
||||||
|
"utm_medium",
|
||||||
|
"utm_campaign",
|
||||||
|
"is_landing",
|
||||||
|
)
|
||||||
|
list_filter = (
|
||||||
|
"traffic_type",
|
||||||
|
"is_landing",
|
||||||
|
"utm_source",
|
||||||
|
"utm_medium",
|
||||||
|
"utm_campaign",
|
||||||
|
)
|
||||||
|
search_fields = ("path", "utm_source", "utm_campaign", "user_agent", "referrer")
|
||||||
|
readonly_fields = (
|
||||||
|
"created",
|
||||||
|
"path",
|
||||||
|
"query_string",
|
||||||
|
"referrer",
|
||||||
|
"user_agent",
|
||||||
|
"traffic_type",
|
||||||
|
"utm_source",
|
||||||
|
"utm_medium",
|
||||||
|
"utm_campaign",
|
||||||
|
"utm_term",
|
||||||
|
"utm_content",
|
||||||
|
"session_key",
|
||||||
|
"is_landing",
|
||||||
|
)
|
||||||
|
date_hierarchy = "created"
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ from django.conf import settings
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from .seo import PUBLIC_PAGE_ENTRIES, get_service_entries
|
from .seo import PUBLIC_PAGE_ENTRIES, get_service_entries
|
||||||
|
from .web_design_pricing import (
|
||||||
|
WEB_DESIGN_INCLUDED,
|
||||||
|
WEB_DESIGN_PRICING_DISCLAIMER,
|
||||||
|
features_for_json,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def tianji_tracking(request):
|
def tianji_tracking(request):
|
||||||
@@ -41,6 +46,15 @@ def webmcp_context(request):
|
|||||||
}
|
}
|
||||||
for url_name, title, _changefreq, _priority, summary in PUBLIC_PAGE_ENTRIES
|
for url_name, title, _changefreq, _priority, summary in PUBLIC_PAGE_ENTRIES
|
||||||
}
|
}
|
||||||
|
web_design_pricing = {
|
||||||
|
"features": features_for_json(),
|
||||||
|
"included_with_every_site": [
|
||||||
|
{"title": item["title"], "description": item["description"]}
|
||||||
|
for item in WEB_DESIGN_INCLUDED
|
||||||
|
],
|
||||||
|
"disclaimer": WEB_DESIGN_PRICING_DISCLAIMER,
|
||||||
|
"page_url": request.build_absolute_uri(reverse("web_design")),
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'webmcp_enabled': getattr(settings, 'WEBMCP_ENABLED', False),
|
'webmcp_enabled': getattr(settings, 'WEBMCP_ENABLED', False),
|
||||||
@@ -49,6 +63,7 @@ def webmcp_context(request):
|
|||||||
'webmcp_recaptcha_required': not settings.DEBUG,
|
'webmcp_recaptcha_required': not settings.DEBUG,
|
||||||
'webmcp_services_json': json.dumps(services),
|
'webmcp_services_json': json.dumps(services),
|
||||||
'webmcp_pages_json': json.dumps(page_lookup),
|
'webmcp_pages_json': json.dumps(page_lookup),
|
||||||
|
'webmcp_web_design_pricing_json': json.dumps(web_design_pricing),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
"""Capture page visits, UTM params, and traffic classification."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from django.db import IntegrityError, OperationalError
|
||||||
|
from django.utils.encoding import force_str
|
||||||
|
|
||||||
|
from .models import PageVisit
|
||||||
|
from .traffic import classify_user_agent
|
||||||
|
|
||||||
|
UTM_SESSION_KEY = "utm_attribution"
|
||||||
|
UTM_PARAMS = (
|
||||||
|
"utm_source",
|
||||||
|
"utm_medium",
|
||||||
|
"utm_campaign",
|
||||||
|
"utm_term",
|
||||||
|
"utm_content",
|
||||||
|
)
|
||||||
|
|
||||||
|
SKIP_PREFIXES = (
|
||||||
|
"/static/",
|
||||||
|
"/media/",
|
||||||
|
"/admin/",
|
||||||
|
"/favicon",
|
||||||
|
"/robots.txt",
|
||||||
|
"/sitemap.xml",
|
||||||
|
"/llms.txt",
|
||||||
|
"/__debug__",
|
||||||
|
)
|
||||||
|
|
||||||
|
SKIP_NAMES = frozenset(
|
||||||
|
{
|
||||||
|
"utm_dashboard",
|
||||||
|
"leads_list",
|
||||||
|
"lead_detail",
|
||||||
|
"lead_toggle_contacted",
|
||||||
|
"robots_txt",
|
||||||
|
"sitemap_xml",
|
||||||
|
"llms_txt",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate(value: str, max_len: int) -> str:
|
||||||
|
value = force_str(value or "")
|
||||||
|
if len(value) <= max_len:
|
||||||
|
return value
|
||||||
|
return value[: max_len - 1] + "…"
|
||||||
|
|
||||||
|
|
||||||
|
def extract_utm_from_get(get) -> dict[str, str]:
|
||||||
|
found = {}
|
||||||
|
for key in UTM_PARAMS:
|
||||||
|
raw = get.get(key)
|
||||||
|
if raw:
|
||||||
|
found[key] = _truncate(raw.strip(), 255)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_utm(session) -> dict[str, str]:
|
||||||
|
stored = session.get(UTM_SESSION_KEY) or {}
|
||||||
|
return {k: stored[k] for k in UTM_PARAMS if stored.get(k)}
|
||||||
|
|
||||||
|
|
||||||
|
def store_session_utm(session, utm: dict[str, str]) -> None:
|
||||||
|
if not utm:
|
||||||
|
return
|
||||||
|
existing = dict(session.get(UTM_SESSION_KEY) or {})
|
||||||
|
existing.update(utm)
|
||||||
|
session[UTM_SESSION_KEY] = existing
|
||||||
|
session.modified = True
|
||||||
|
|
||||||
|
|
||||||
|
def should_track_request(request) -> bool:
|
||||||
|
if request.method != "GET":
|
||||||
|
return False
|
||||||
|
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
|
||||||
|
return False
|
||||||
|
path = request.path or "/"
|
||||||
|
if any(path.startswith(prefix) for prefix in SKIP_PREFIXES):
|
||||||
|
return False
|
||||||
|
match = getattr(request, "resolver_match", None)
|
||||||
|
if match and match.url_name in SKIP_NAMES:
|
||||||
|
return False
|
||||||
|
accept = request.headers.get("Accept", "")
|
||||||
|
if accept and "text/html" not in accept and "*/*" not in accept:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def record_page_visit(request) -> PageVisit | None:
|
||||||
|
if not should_track_request(request):
|
||||||
|
return None
|
||||||
|
|
||||||
|
landing_utm = extract_utm_from_get(request.GET)
|
||||||
|
if landing_utm:
|
||||||
|
store_session_utm(request.session, landing_utm)
|
||||||
|
|
||||||
|
attribution = landing_utm or get_session_utm(request.session)
|
||||||
|
user_agent = _truncate(request.META.get("HTTP_USER_AGENT", ""), 512)
|
||||||
|
referrer = _truncate(request.META.get("HTTP_REFERER", ""), 1024)
|
||||||
|
session_key = ""
|
||||||
|
if hasattr(request, "session"):
|
||||||
|
# Ensure session exists so return visits can keep first-touch UTM.
|
||||||
|
if not request.session.session_key:
|
||||||
|
request.session.save()
|
||||||
|
session_key = request.session.session_key or ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
return PageVisit.objects.create(
|
||||||
|
path=_truncate(request.path or "/", 512),
|
||||||
|
query_string=_truncate(request.META.get("QUERY_STRING", ""), 1024),
|
||||||
|
referrer=referrer,
|
||||||
|
user_agent=user_agent,
|
||||||
|
traffic_type=classify_user_agent(user_agent),
|
||||||
|
utm_source=attribution.get("utm_source", ""),
|
||||||
|
utm_medium=attribution.get("utm_medium", ""),
|
||||||
|
utm_campaign=attribution.get("utm_campaign", ""),
|
||||||
|
utm_term=attribution.get("utm_term", ""),
|
||||||
|
utm_content=attribution.get("utm_content", ""),
|
||||||
|
session_key=session_key,
|
||||||
|
is_landing=bool(landing_utm),
|
||||||
|
)
|
||||||
|
except (OperationalError, IntegrityError):
|
||||||
|
# Avoid breaking page loads if DB is unavailable or migration pending.
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class UTMTrackingMiddleware:
|
||||||
|
"""Record HTML GET page views after the view runs successfully."""
|
||||||
|
|
||||||
|
def __init__(self, get_response):
|
||||||
|
self.get_response = get_response
|
||||||
|
|
||||||
|
def __call__(self, request):
|
||||||
|
response = self.get_response(request)
|
||||||
|
if 200 <= response.status_code < 300:
|
||||||
|
content_type = response.get("Content-Type", "")
|
||||||
|
if not content_type or "text/html" in content_type:
|
||||||
|
record_page_visit(request)
|
||||||
|
return response
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Generated by Django 5.0 on 2026-07-25 01:15
|
||||||
|
|
||||||
|
import django.utils.timezone
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('public', '0005_emailmessage'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='contact',
|
||||||
|
name='utm_campaign',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='contact',
|
||||||
|
name='utm_content',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='contact',
|
||||||
|
name='utm_medium',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='contact',
|
||||||
|
name='utm_source',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='contact',
|
||||||
|
name='utm_term',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=255),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='PageVisit',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created', models.DateTimeField(db_index=True, default=django.utils.timezone.now)),
|
||||||
|
('path', models.CharField(db_index=True, max_length=512)),
|
||||||
|
('query_string', models.CharField(blank=True, default='', max_length=1024)),
|
||||||
|
('referrer', models.URLField(blank=True, default='', max_length=1024)),
|
||||||
|
('user_agent', models.CharField(blank=True, default='', max_length=512)),
|
||||||
|
('traffic_type', models.CharField(choices=[('human', 'Human traffic'), ('ai_bot', 'AI bot / AI search'), ('search_indexer', 'Search indexing'), ('social_bot', 'Social / preview bot'), ('monitoring', 'Monitoring / uptime'), ('other_bot', 'Other bot'), ('unknown', 'Unknown')], db_index=True, default='unknown', max_length=32)),
|
||||||
|
('utm_source', models.CharField(blank=True, db_index=True, default='', max_length=255)),
|
||||||
|
('utm_medium', models.CharField(blank=True, db_index=True, default='', max_length=255)),
|
||||||
|
('utm_campaign', models.CharField(blank=True, db_index=True, default='', max_length=255)),
|
||||||
|
('utm_term', models.CharField(blank=True, default='', max_length=255)),
|
||||||
|
('utm_content', models.CharField(blank=True, default='', max_length=255)),
|
||||||
|
('session_key', models.CharField(blank=True, db_index=True, default='', max_length=64)),
|
||||||
|
('is_landing', models.BooleanField(default=False, help_text='True when this request carried UTM params (campaign landing).')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created'],
|
||||||
|
'indexes': [models.Index(fields=['-created', 'traffic_type'], name='public_page_created_8c59a5_idx'), models.Index(fields=['utm_source', 'utm_campaign'], name='public_page_utm_sou_96ed78_idx')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.0 on 2026-07-25 01:16
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('public', '0006_pagevisit_and_contact_utm'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='pagevisit',
|
||||||
|
name='referrer',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=1024),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from .traffic import TrafficType, traffic_type_label
|
||||||
|
|
||||||
|
|
||||||
class TimeInfoBase(models.Model):
|
class TimeInfoBase(models.Model):
|
||||||
|
|
||||||
created = models.DateTimeField(default=timezone.now)
|
created = models.DateTimeField(default=timezone.now)
|
||||||
@@ -17,13 +20,19 @@ class TimeInfoBase(models.Model):
|
|||||||
|
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
# Create your models here.
|
|
||||||
class Contact(TimeInfoBase):
|
class Contact(TimeInfoBase):
|
||||||
email = models.EmailField(max_length=128)
|
email = models.EmailField(max_length=128)
|
||||||
name = models.CharField(max_length=128)
|
name = models.CharField(max_length=128)
|
||||||
blurb = models.CharField(max_length=254, blank=True)
|
blurb = models.CharField(max_length=254, blank=True)
|
||||||
subject = models.CharField(max_length=128)
|
subject = models.CharField(max_length=128)
|
||||||
contacted = models.BooleanField(default=False)
|
contacted = models.BooleanField(default=False)
|
||||||
|
utm_source = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
utm_medium = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
utm_campaign = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
utm_term = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
utm_content = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
|
||||||
|
|
||||||
class EmailMessage(TimeInfoBase):
|
class EmailMessage(TimeInfoBase):
|
||||||
subject = models.CharField(max_length=255)
|
subject = models.CharField(max_length=255)
|
||||||
@@ -33,3 +42,52 @@ class EmailMessage(TimeInfoBase):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.recipient + " | " + self.subject
|
return self.recipient + " | " + self.subject
|
||||||
|
|
||||||
|
|
||||||
|
class PageVisit(models.Model):
|
||||||
|
"""First-party page view with UTM attribution and traffic classification."""
|
||||||
|
|
||||||
|
class TrafficTypeChoices(models.TextChoices):
|
||||||
|
HUMAN = TrafficType.HUMAN, traffic_type_label(TrafficType.HUMAN)
|
||||||
|
AI_BOT = TrafficType.AI_BOT, traffic_type_label(TrafficType.AI_BOT)
|
||||||
|
SEARCH_INDEXER = TrafficType.SEARCH_INDEXER, traffic_type_label(
|
||||||
|
TrafficType.SEARCH_INDEXER
|
||||||
|
)
|
||||||
|
SOCIAL_BOT = TrafficType.SOCIAL_BOT, traffic_type_label(TrafficType.SOCIAL_BOT)
|
||||||
|
MONITORING = TrafficType.MONITORING, traffic_type_label(TrafficType.MONITORING)
|
||||||
|
OTHER_BOT = TrafficType.OTHER_BOT, traffic_type_label(TrafficType.OTHER_BOT)
|
||||||
|
UNKNOWN = TrafficType.UNKNOWN, traffic_type_label(TrafficType.UNKNOWN)
|
||||||
|
|
||||||
|
created = models.DateTimeField(default=timezone.now, db_index=True)
|
||||||
|
path = models.CharField(max_length=512, db_index=True)
|
||||||
|
query_string = models.CharField(max_length=1024, blank=True, default="")
|
||||||
|
referrer = models.CharField(max_length=1024, blank=True, default="")
|
||||||
|
user_agent = models.CharField(max_length=512, blank=True, default="")
|
||||||
|
traffic_type = models.CharField(
|
||||||
|
max_length=32,
|
||||||
|
choices=TrafficTypeChoices.choices,
|
||||||
|
default=TrafficTypeChoices.UNKNOWN,
|
||||||
|
db_index=True,
|
||||||
|
)
|
||||||
|
utm_source = models.CharField(max_length=255, blank=True, default="", db_index=True)
|
||||||
|
utm_medium = models.CharField(max_length=255, blank=True, default="", db_index=True)
|
||||||
|
utm_campaign = models.CharField(
|
||||||
|
max_length=255, blank=True, default="", db_index=True
|
||||||
|
)
|
||||||
|
utm_term = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
utm_content = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
session_key = models.CharField(max_length=64, blank=True, default="", db_index=True)
|
||||||
|
is_landing = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="True when this request carried UTM params (campaign landing).",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created"]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["-created", "traffic_type"]),
|
||||||
|
models.Index(fields=["utm_source", "utm_campaign"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.path} ({self.traffic_type}) @ {self.created:%Y-%m-%d %H:%M}"
|
||||||
@@ -75,7 +75,10 @@ PUBLIC_PAGE_ENTRIES = (
|
|||||||
"Web Design and Hosting",
|
"Web Design and Hosting",
|
||||||
"monthly",
|
"monthly",
|
||||||
"0.8",
|
"0.8",
|
||||||
"Web design, development, and managed hosting for business sites and apps.",
|
"Custom brand-tailored sites with interactive package pricing. You own the site and data. "
|
||||||
|
"Public site + client portal (UTM/leads) always included; add-ons for Email/SMS, direct mail, "
|
||||||
|
"blog, Stripe payments, social, and AI social. Every build includes three-instance hosting, "
|
||||||
|
"SEO/accessibility/LLM readiness, and Grafana metrics and alerts.",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"contact",
|
"contact",
|
||||||
@@ -127,7 +130,10 @@ def robots_txt(request):
|
|||||||
sitemap_url = _absolute_url(request, "sitemap_xml")
|
sitemap_url = _absolute_url(request, "sitemap_xml")
|
||||||
content = render_to_string(
|
content = render_to_string(
|
||||||
"public/robots.txt",
|
"public/robots.txt",
|
||||||
{"sitemap_url": sitemap_url},
|
{
|
||||||
|
"sitemap_url": sitemap_url,
|
||||||
|
"llms_url": _absolute_url(request, "llms_txt"),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
return HttpResponse(content, content_type="text/plain; charset=utf-8")
|
return HttpResponse(content, content_type="text/plain; charset=utf-8")
|
||||||
|
|
||||||
@@ -146,18 +152,27 @@ def sitemap_xml(request):
|
|||||||
|
|
||||||
|
|
||||||
def llms_txt(request):
|
def llms_txt(request):
|
||||||
|
from .web_design_pricing import (
|
||||||
|
WEB_DESIGN_INCLUDED,
|
||||||
|
features_for_json,
|
||||||
|
)
|
||||||
|
|
||||||
pages = [
|
pages = [
|
||||||
{
|
{
|
||||||
"title": title,
|
"title": title,
|
||||||
"url": _absolute_url(request, url_name),
|
"url": _absolute_url(request, url_name),
|
||||||
|
"summary": summary,
|
||||||
}
|
}
|
||||||
for url_name, title, _changefreq, _priority, _summary in PUBLIC_PAGE_ENTRIES
|
for url_name, title, _changefreq, _priority, summary in PUBLIC_PAGE_ENTRIES
|
||||||
]
|
]
|
||||||
content = render_to_string(
|
content = render_to_string(
|
||||||
"public/llms.txt",
|
"public/llms.txt",
|
||||||
{
|
{
|
||||||
"site_url": request.build_absolute_uri("/"),
|
"site_url": request.build_absolute_uri("/"),
|
||||||
"contact_url": _absolute_url(request, "contact"),
|
"contact_url": _absolute_url(request, "contact"),
|
||||||
|
"web_design_url": _absolute_url(request, "web_design"),
|
||||||
|
"web_design_features": features_for_json(),
|
||||||
|
"web_design_included": WEB_DESIGN_INCLUDED,
|
||||||
"pages": pages,
|
"pages": pages,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -878,3 +878,214 @@ input:focus, select:focus, textarea:focus {
|
|||||||
text-transform: none;
|
text-transform: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Web design interactive pricing estimator */
|
||||||
|
.pricing-intro {
|
||||||
|
text-align: center;
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto 2.5rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-estimator {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.6fr) minmax(260px, 0.9fr);
|
||||||
|
gap: 1.5rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-features-panel,
|
||||||
|
.pricing-estimate-panel {
|
||||||
|
background: var(--bg-color);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-panel-title {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
margin: 0 0 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-feature-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-feature {
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-feature:first-child {
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-feature-label {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
gap: 0.85rem 1rem;
|
||||||
|
align-items: start;
|
||||||
|
padding: 1rem 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-feature.is-required .pricing-feature-label {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-feature-check {
|
||||||
|
appearance: none;
|
||||||
|
width: 1.15rem;
|
||||||
|
height: 1.15rem;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
border: 1.5px solid rgba(255, 255, 255, 0.35);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-feature-check::before {
|
||||||
|
content: "";
|
||||||
|
width: 0.65rem;
|
||||||
|
height: 0.65rem;
|
||||||
|
transform: scale(0);
|
||||||
|
transition: transform 0.12s ease-in-out;
|
||||||
|
box-shadow: inset 1em 1em var(--bg-color);
|
||||||
|
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-feature-check:checked {
|
||||||
|
background: var(--primary-color);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-feature-check:checked::before {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-feature-check:disabled {
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
border-color: rgba(255, 255, 255, 0.25);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-feature-check:disabled:checked::before {
|
||||||
|
box-shadow: inset 1em 1em rgba(10, 10, 10, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-feature-check:focus-visible {
|
||||||
|
outline: 2px solid var(--primary-color);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-feature.is-selected:not(.is-required) {
|
||||||
|
background: rgba(0, 243, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-feature-name {
|
||||||
|
display: block;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-feature-desc {
|
||||||
|
display: block;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-feature-note {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
color: #ffb347;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-feature-costs {
|
||||||
|
text-align: right;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-feature-costs strong {
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-feature-monthly {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-estimate-panel {
|
||||||
|
position: sticky;
|
||||||
|
top: 5.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-estimate-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-estimate-row strong {
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-estimate-count {
|
||||||
|
margin: 1.25rem 0 0.75rem;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-estimate-disclaimer {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-estimate-cta {
|
||||||
|
display: inline-block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.pricing-estimator {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-estimate-panel {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-feature-label {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-feature-costs {
|
||||||
|
grid-column: 2;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-feature-monthly {
|
||||||
|
display: inline;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
@@ -16,6 +16,7 @@
|
|||||||
var recaptchaRequired = configEl.dataset.recaptchaRequired === 'true';
|
var recaptchaRequired = configEl.dataset.recaptchaRequired === 'true';
|
||||||
var services = [];
|
var services = [];
|
||||||
var pages = {};
|
var pages = {};
|
||||||
|
var webDesignPricing = { features: [], included_with_every_site: [], disclaimer: '' };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
services = JSON.parse(configEl.dataset.services || '[]');
|
services = JSON.parse(configEl.dataset.services || '[]');
|
||||||
@@ -29,6 +30,15 @@
|
|||||||
pages = {};
|
pages = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
webDesignPricing = JSON.parse(configEl.dataset.webDesignPricing || '{}');
|
||||||
|
if (!webDesignPricing.features) {
|
||||||
|
webDesignPricing.features = [];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
webDesignPricing = { features: [], included_with_every_site: [], disclaimer: '' };
|
||||||
|
}
|
||||||
|
|
||||||
function textResult(payload) {
|
function textResult(payload) {
|
||||||
return {
|
return {
|
||||||
content: [{
|
content: [{
|
||||||
@@ -82,6 +92,85 @@
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function estimateWebDesignCost(selectedIds) {
|
||||||
|
var catalog = {};
|
||||||
|
var features = webDesignPricing.features || [];
|
||||||
|
features.forEach(function (feature) {
|
||||||
|
catalog[feature.id] = feature;
|
||||||
|
});
|
||||||
|
|
||||||
|
var selected = {};
|
||||||
|
(selectedIds || []).forEach(function (id) {
|
||||||
|
if (catalog[id]) {
|
||||||
|
selected[id] = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
features.forEach(function (feature) {
|
||||||
|
if (feature.required) {
|
||||||
|
selected[feature.id] = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var changed = true;
|
||||||
|
while (changed) {
|
||||||
|
changed = false;
|
||||||
|
Object.keys(selected).forEach(function (featureId) {
|
||||||
|
var feature = catalog[featureId];
|
||||||
|
if (!feature || !feature.requires) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
feature.requires.forEach(function (dep) {
|
||||||
|
if (!selected[dep] && catalog[dep]) {
|
||||||
|
selected[dep] = true;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolved = features.filter(function (feature) {
|
||||||
|
return selected[feature.id];
|
||||||
|
});
|
||||||
|
|
||||||
|
var unknown = (selectedIds || []).filter(function (id) {
|
||||||
|
return !catalog[id];
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
selected: resolved.map(function (feature) {
|
||||||
|
return {
|
||||||
|
id: feature.id,
|
||||||
|
name: feature.name,
|
||||||
|
build: feature.build,
|
||||||
|
monthly: feature.monthly,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
selected_count: resolved.length,
|
||||||
|
one_time_build: resolved.reduce(function (sum, feature) {
|
||||||
|
return sum + feature.build;
|
||||||
|
}, 0),
|
||||||
|
monthly: resolved.reduce(function (sum, feature) {
|
||||||
|
return sum + feature.monthly;
|
||||||
|
}, 0),
|
||||||
|
included_with_every_site: webDesignPricing.included_with_every_site || [],
|
||||||
|
disclaimer: webDesignPricing.disclaimer || '',
|
||||||
|
page_url: webDesignPricing.page_url || resolvePageUrl('web_design'),
|
||||||
|
available_features: features.map(function (feature) {
|
||||||
|
return {
|
||||||
|
id: feature.id,
|
||||||
|
name: feature.name,
|
||||||
|
description: feature.description,
|
||||||
|
build: feature.build,
|
||||||
|
monthly: feature.monthly,
|
||||||
|
required: !!feature.required,
|
||||||
|
requires: feature.requires || [],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
unknown_feature_ids: unknown,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function getRecaptchaToken() {
|
async function getRecaptchaToken() {
|
||||||
if (!recaptchaRequired) {
|
if (!recaptchaRequired) {
|
||||||
return null;
|
return null;
|
||||||
@@ -265,6 +354,35 @@
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelContext.registerTool({
|
||||||
|
name: 'estimate_web_design_cost',
|
||||||
|
description:
|
||||||
|
'Estimate one-time build and monthly cost for an AI ML Operations web design package. ' +
|
||||||
|
'Sites are brand-tailored; clients own the site and data. Public site and client portal are ' +
|
||||||
|
'always included, along with three-instance hosting, UTM/leads, SEO/accessibility/LLM readiness, ' +
|
||||||
|
'and Grafana metrics/alerts. Pass optional feature ids to add Email/SMS, direct mail, blog, ' +
|
||||||
|
'Stripe payments, social consolidation, or AI social generator. Dependencies ' +
|
||||||
|
'(payments→email_sms, ai_social→social) are auto-selected. Call with an empty features array ' +
|
||||||
|
'to list catalog pricing and included-with-every-site benefits.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
features: {
|
||||||
|
type: 'array',
|
||||||
|
description:
|
||||||
|
'Optional feature ids to include beyond the required base. Valid ids: ' +
|
||||||
|
'public_site, client_portal, email_sms, direct_mail, blog, payments, social, ai_social.',
|
||||||
|
items: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
annotations: { readOnlyHint: true },
|
||||||
|
execute: function (input) {
|
||||||
|
var selected = (input && input.features) || [];
|
||||||
|
return textResult(estimateWebDesignCost(selected));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (pageName === 'contact') {
|
if (pageName === 'contact') {
|
||||||
modelContext.registerTool({
|
modelContext.registerTool({
|
||||||
name: 'submit_contact_inquiry',
|
name: 'submit_contact_inquiry',
|
||||||
|
|||||||
@@ -52,6 +52,7 @@
|
|||||||
data-recaptcha-required="{{ webmcp_recaptcha_required|yesno:'true,false' }}"
|
data-recaptcha-required="{{ webmcp_recaptcha_required|yesno:'true,false' }}"
|
||||||
data-services='{{ webmcp_services_json|escapejs }}'
|
data-services='{{ webmcp_services_json|escapejs }}'
|
||||||
data-pages='{{ webmcp_pages_json|escapejs }}'
|
data-pages='{{ webmcp_pages_json|escapejs }}'
|
||||||
|
data-web-design-pricing='{{ webmcp_web_design_pricing_json|escapejs }}'
|
||||||
hidden></div>
|
hidden></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</head>
|
</head>
|
||||||
@@ -109,7 +110,12 @@
|
|||||||
<li><a href="{% url 'profile' %}">Profile</a></li>
|
<li><a href="{% url 'profile' %}">Profile</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if is_financial_admin %}
|
{% if is_financial_admin %}
|
||||||
|
<li><a href="{% url 'utm_dashboard' %}">UTM Analytics</a></li>
|
||||||
|
<li><a href="{% url 'leads_list' %}">Leads</a></li>
|
||||||
<li><a href="{% url 'manage_users' %}">Manage Users</a></li>
|
<li><a href="{% url 'manage_users' %}">Manage Users</a></li>
|
||||||
|
{% elif user.is_staff %}
|
||||||
|
<li><a href="{% url 'utm_dashboard' %}">UTM Analytics</a></li>
|
||||||
|
<li><a href="{% url 'leads_list' %}">Leads</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<li><a href="{% url 'change_password' %}">Change Password</a></li>
|
<li><a href="{% url 'change_password' %}">Change Password</a></li>
|
||||||
<li>
|
<li>
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Lead: {{ lead.name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="section">
|
||||||
|
<div class="container" style="max-width: 48rem;">
|
||||||
|
<p style="margin-bottom: 1rem;">
|
||||||
|
<a href="{% url 'leads_list' %}" style="color: var(--text-muted);">← All leads</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="display: flex; flex-wrap: wrap; justify-content: space-between; gap: 1rem; align-items: start; margin-bottom: 1.5rem;">
|
||||||
|
<div>
|
||||||
|
<h1 class="section-title" style="margin-bottom: 0.35rem;">{{ lead.name }}</h1>
|
||||||
|
<p style="color: var(--text-muted); margin: 0;">
|
||||||
|
{{ lead.created|date:"F j, Y g:i A" }}
|
||||||
|
·
|
||||||
|
{% if lead.contacted %}
|
||||||
|
<span>Contacted</span>
|
||||||
|
{% else %}
|
||||||
|
<span style="color: var(--primary-color);">New</span>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form action="{% url 'lead_toggle_contacted' lead.pk %}" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="next" value="{% url 'lead_detail' lead.pk %}">
|
||||||
|
<button type="submit" class="btn" style="border: 1px solid var(--primary-color); padding: 0.5rem 1.25rem; border-radius: 4px; color: var(--primary-color); background: transparent; cursor: pointer;">
|
||||||
|
{% if lead.contacted %}Mark as new{% else %}Mark as contacted{% endif %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if messages %}
|
||||||
|
<ul style="list-style: none; margin-bottom: 1.5rem; padding: 0;">
|
||||||
|
{% for message in messages %}
|
||||||
|
<li style="color: var(--primary-color); margin-bottom: 0.35rem;">{{ message }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card" style="margin-bottom: 1.5rem; cursor: default;">
|
||||||
|
<span class="card-title" style="font-size: 1.1rem;">Contact</span>
|
||||||
|
<p class="card-text" style="margin-top: 0.75rem;">
|
||||||
|
<strong>Email:</strong> <a href="mailto:{{ lead.email }}">{{ lead.email }}</a><br>
|
||||||
|
<strong>Subject:</strong> {{ lead.subject }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="margin-bottom: 1.5rem; cursor: default;">
|
||||||
|
<span class="card-title" style="font-size: 1.1rem;">Message</span>
|
||||||
|
<p class="card-text" style="margin-top: 0.75rem; white-space: pre-wrap;">{% if lead.blurb %}{{ lead.blurb }}{% else %}<span style="color: var(--text-muted);">(No message body)</span>{% endif %}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="margin-bottom: 1.5rem; cursor: default;">
|
||||||
|
<span class="card-title" style="font-size: 1.1rem;">UTM attribution</span>
|
||||||
|
{% if lead.utm_source or lead.utm_medium or lead.utm_campaign or lead.utm_term or lead.utm_content %}
|
||||||
|
<div class="table-responsive" style="margin-top: 0.75rem;">
|
||||||
|
<table class="table">
|
||||||
|
<tbody>
|
||||||
|
<tr><th style="width: 8rem;">Source</th><td>{{ lead.utm_source|default:"—" }}</td></tr>
|
||||||
|
<tr><th>Medium</th><td>{{ lead.utm_medium|default:"—" }}</td></tr>
|
||||||
|
<tr><th>Campaign</th><td>{{ lead.utm_campaign|default:"—" }}</td></tr>
|
||||||
|
<tr><th>Term</th><td>{{ lead.utm_term|default:"—" }}</td></tr>
|
||||||
|
<tr><th>Content</th><td>{{ lead.utm_content|default:"—" }}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="card-text" style="margin-top: 0.75rem; color: var(--text-muted);">No UTM params on this lead’s session.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="color: var(--text-muted); font-size: 0.85rem;">
|
||||||
|
Last modified {{ lead.last_modified|date:"Y-m-d H:i" }} · ID {{ lead.pk }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Contact Leads{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="section">
|
||||||
|
<div class="container">
|
||||||
|
<div style="display: flex; flex-wrap: wrap; justify-content: space-between; gap: 1rem; align-items: baseline; margin-bottom: 0.5rem;">
|
||||||
|
<h1 class="section-title" style="margin-bottom: 0;">Contact Leads</h1>
|
||||||
|
<a href="{% url 'utm_dashboard' %}" style="color: var(--text-muted); font-size: 0.9rem;">UTM Analytics →</a>
|
||||||
|
</div>
|
||||||
|
<p style="color: var(--text-muted); margin-bottom: 1.5rem; max-width: 42rem;">
|
||||||
|
Inquiries from the contact form, with message details and any UTM attribution. Staff only.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method="get" style="display: flex; flex-wrap: wrap; gap: 1rem; align-items: end; margin-bottom: 2rem;">
|
||||||
|
<label style="display: flex; flex-direction: column; gap: 0.35rem; color: var(--text-muted); font-size: 0.85rem;">
|
||||||
|
Period
|
||||||
|
<select name="days" style="background: var(--surface-color); color: var(--text-color); border: 1px solid rgba(255,255,255,0.15); padding: 0.5rem 0.75rem; border-radius: 4px;">
|
||||||
|
<option value="7" {% if days == 7 %}selected{% endif %}>Last 7 days</option>
|
||||||
|
<option value="30" {% if days == 30 %}selected{% endif %}>Last 30 days</option>
|
||||||
|
<option value="90" {% if days == 90 %}selected{% endif %}>Last 90 days</option>
|
||||||
|
<option value="365" {% if days == 365 %}selected{% endif %}>Last year</option>
|
||||||
|
<option value="0" {% if days == 0 %}selected{% endif %}>All time</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label style="display: flex; flex-direction: column; gap: 0.35rem; color: var(--text-muted); font-size: 0.85rem;">
|
||||||
|
Status
|
||||||
|
<select name="status" style="background: var(--surface-color); color: var(--text-color); border: 1px solid rgba(255,255,255,0.15); padding: 0.5rem 0.75rem; border-radius: 4px;">
|
||||||
|
<option value="" {% if not status %}selected{% endif %}>All</option>
|
||||||
|
<option value="new" {% if status == "new" %}selected{% endif %}>New</option>
|
||||||
|
<option value="contacted" {% if status == "contacted" %}selected{% endif %}>Contacted</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label style="display: flex; flex-direction: column; gap: 0.35rem; color: var(--text-muted); font-size: 0.85rem; flex: 1; min-width: 12rem;">
|
||||||
|
Search
|
||||||
|
<input type="search" name="q" value="{{ q }}" placeholder="Name, email, subject…"
|
||||||
|
style="background: var(--surface-color); color: var(--text-color); border: 1px solid rgba(255,255,255,0.15); padding: 0.5rem 0.75rem; border-radius: 4px;">
|
||||||
|
</label>
|
||||||
|
<label style="display: flex; align-items: center; gap: 0.5rem; color: var(--text-muted); font-size: 0.9rem; padding-bottom: 0.55rem;">
|
||||||
|
<input type="checkbox" name="utm" value="1" {% if utm_only %}checked{% endif %}>
|
||||||
|
UTM only
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="btn" style="border: 1px solid var(--primary-color); padding: 0.5rem 1.25rem; border-radius: 4px; color: var(--primary-color); background: transparent; cursor: pointer;">
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="card-grid" style="margin-bottom: 2.5rem;">
|
||||||
|
<div class="card">
|
||||||
|
<span class="card-title">{{ total }}</span>
|
||||||
|
<p class="card-text">Total in period</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<span class="card-title">{{ new_count }}</span>
|
||||||
|
<p class="card-text">New (not contacted)</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<span class="card-title">{{ contacted_count }}</span>
|
||||||
|
<p class="card-text">Contacted</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<span class="card-title">{{ with_utm }}</span>
|
||||||
|
<p class="card-text">With UTM attribution</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="color: var(--text-muted); margin-bottom: 1rem; font-size: 0.9rem;">
|
||||||
|
Showing {{ lead_count }} lead{% if lead_count != 1 %}s{% endif %}{% if lead_count > 200 %} (first 200){% endif %}.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
{% if leads %}
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>When</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Subject</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Source</th>
|
||||||
|
<th>Campaign</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for lead in leads %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ lead.created|date:"Y-m-d H:i" }}</td>
|
||||||
|
<td>{{ lead.name }}</td>
|
||||||
|
<td><a href="mailto:{{ lead.email }}">{{ lead.email }}</a></td>
|
||||||
|
<td>{{ lead.subject|truncatechars:40 }}</td>
|
||||||
|
<td>{% if lead.contacted %}Contacted{% else %}<span style="color: var(--primary-color);">New</span>{% endif %}</td>
|
||||||
|
<td>{{ lead.utm_source|default:"—" }}</td>
|
||||||
|
<td>{{ lead.utm_campaign|default:"—" }}</td>
|
||||||
|
<td><a href="{% url 'lead_detail' lead.pk %}">Details</a></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p style="color: var(--text-muted);">No leads match these filters.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -2,12 +2,28 @@
|
|||||||
|
|
||||||
> Forward-deployed AI engineering. We embed with your team to architect, build, and deploy custom agentic workflows, integrations, and production AI systems.
|
> Forward-deployed AI engineering. We embed with your team to architect, build, and deploy custom agentic workflows, integrations, and production AI systems.
|
||||||
|
|
||||||
AI ML Operations helps organizations move from AI pilots to production systems. Core services include forward-deployed AI engineering, custom AI agents, ML model development, secure hosted chat, sensor algorithms, education, hardware builds, and web hosting.
|
AI ML Operations helps organizations move from AI pilots to production systems. Core services include forward-deployed AI engineering, custom AI agents, ML model development, secure hosted chat, sensor algorithms, education, hardware builds, and web design/hosting.
|
||||||
|
|
||||||
## Key pages
|
## Key pages
|
||||||
|
|
||||||
{% for page in pages %}- [{{ page.title }}]({{ page.url }})
|
{% for page in pages %}- [{{ page.title }}]({{ page.url }}){% if page.summary %} — {{ page.summary }}{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
## Web design & hosting
|
||||||
|
|
||||||
|
Interactive package estimator: {{ web_design_url }}
|
||||||
|
|
||||||
|
Custom brand-tailored sites. Clients own the site and data and can leave anytime with everything. Use the estimator to toggle catalog add-ons and see one-time build + monthly totals (draft pricing; not a formal quote; Stripe/SMS/postage usage billed separately).
|
||||||
|
|
||||||
|
### Included with every site
|
||||||
|
|
||||||
|
{% for item in web_design_included %}- **{{ item.title }}**: {{ item.description }}
|
||||||
|
{% endfor %}
|
||||||
|
### Catalog features
|
||||||
|
|
||||||
|
{% for feature in web_design_features %}- **{{ feature.name }}** (`{{ feature.id }}`): ${{ feature.build }} build, ${{ feature.monthly }}/mo{% if feature.required %} — always included{% endif %}{% if feature.requires %} — requires {{ feature.requires|join:", " }}{% endif %} — {{ feature.description }}
|
||||||
|
{% endfor %}
|
||||||
|
Agents can call the WebMCP tool `estimate_web_design_cost` with optional feature ids to compute build + monthly totals and return the included-with-every-site list.
|
||||||
|
|
||||||
## Contact
|
## Contact
|
||||||
|
|
||||||
- [Contact form]({{ contact_url }})
|
- [Contact form]({{ contact_url }})
|
||||||
|
|||||||
@@ -186,7 +186,7 @@
|
|||||||
data-tianji-event="service_click" data-tianji-event-service="Web Design">
|
data-tianji-event="service_click" data-tianji-event-service="Web Design">
|
||||||
<span class="card-badge">Primary</span>
|
<span class="card-badge">Primary</span>
|
||||||
<span class="card-title">Web Design and Hosting</span>
|
<span class="card-title">Web Design and Hosting</span>
|
||||||
<p class="card-text">Modern websites with reliable hosting for your online presence.</p>
|
<p class="card-text">Brand-tailored sites you own, with multi-instance hosting, UTM/leads, Grafana alerts, and an interactive package estimator.</p>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a href="{% url 'ml_model' %}" class="card"
|
<a href="{% url 'ml_model' %}" class="card"
|
||||||
|
|||||||
@@ -18,3 +18,4 @@ User-agent: PerplexityBot
|
|||||||
Allow: /
|
Allow: /
|
||||||
|
|
||||||
Sitemap: {{ sitemap_url }}
|
Sitemap: {{ sitemap_url }}
|
||||||
|
# llms.txt: {{ llms_url }}
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}UTM & Traffic Analytics{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="section">
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="section-title">UTM & Traffic Analytics</h1>
|
||||||
|
<p style="color: var(--text-muted); margin-bottom: 1.5rem; max-width: 42rem;">
|
||||||
|
First-party page views with campaign tags and traffic classification
|
||||||
|
(human, AI bots, search indexers, and more). Staff only.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method="get" class="utm-filters" style="display: flex; flex-wrap: wrap; gap: 1rem; align-items: end; margin-bottom: 2rem;">
|
||||||
|
<label style="display: flex; flex-direction: column; gap: 0.35rem; color: var(--text-muted); font-size: 0.85rem;">
|
||||||
|
Period
|
||||||
|
<select name="days" style="background: var(--surface-color); color: var(--text-color); border: 1px solid rgba(255,255,255,0.15); padding: 0.5rem 0.75rem; border-radius: 4px;">
|
||||||
|
<option value="7" {% if days == 7 %}selected{% endif %}>Last 7 days</option>
|
||||||
|
<option value="30" {% if days == 30 %}selected{% endif %}>Last 30 days</option>
|
||||||
|
<option value="90" {% if days == 90 %}selected{% endif %}>Last 90 days</option>
|
||||||
|
<option value="365" {% if days == 365 %}selected{% endif %}>Last year</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label style="display: flex; flex-direction: column; gap: 0.35rem; color: var(--text-muted); font-size: 0.85rem;">
|
||||||
|
Traffic type
|
||||||
|
<select name="traffic" style="background: var(--surface-color); color: var(--text-color); border: 1px solid rgba(255,255,255,0.15); padding: 0.5rem 0.75rem; border-radius: 4px;">
|
||||||
|
<option value="">All types</option>
|
||||||
|
{% for value, label in traffic_choices %}
|
||||||
|
<option value="{{ value }}" {% if traffic_filter == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="btn" style="border: 1px solid var(--primary-color); padding: 0.5rem 1.25rem; border-radius: 4px; color: var(--primary-color); background: transparent; cursor: pointer;">
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="card-grid" style="margin-bottom: 3rem;">
|
||||||
|
<div class="card">
|
||||||
|
<span class="card-title">{{ total_visits }}</span>
|
||||||
|
<p class="card-text">Total page views</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<span class="card-title">{{ human_count }}</span>
|
||||||
|
<p class="card-text">Human traffic</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<span class="card-title">{{ ai_count }}</span>
|
||||||
|
<p class="card-text">AI bot / AI search</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<span class="card-title">{{ indexer_count }}</span>
|
||||||
|
<p class="card-text">Search indexing</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<span class="card-title">{{ utm_landings }}</span>
|
||||||
|
<p class="card-text">UTM landings</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<span class="card-title">{{ attributed_visits }}</span>
|
||||||
|
<p class="card-text">Views with UTM attribution</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="section-title" style="font-size: 1.75rem; margin-bottom: 1rem;">Traffic mix</h2>
|
||||||
|
<div class="table-responsive" style="margin-bottom: 3rem;">
|
||||||
|
{% if type_counts %}
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Views</th>
|
||||||
|
<th>Share</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in type_counts %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ row.label }}</td>
|
||||||
|
<td>{{ row.total }}</td>
|
||||||
|
<td>{% widthratio row.total total_visits 100 %}%</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p style="color: var(--text-muted);">No visits in this period yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 2rem; margin-bottom: 3rem;">
|
||||||
|
<div>
|
||||||
|
<h2 class="section-title" style="font-size: 1.35rem; margin-bottom: 1rem;">Top sources</h2>
|
||||||
|
<div class="table-responsive">
|
||||||
|
{% if top_sources %}
|
||||||
|
<table class="table">
|
||||||
|
<thead><tr><th>utm_source</th><th>Views</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in top_sources %}
|
||||||
|
<tr><td>{{ row.utm_source }}</td><td>{{ row.total }}</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p style="color: var(--text-muted);">No UTM sources yet. Share links like <code>?utm_source=linkedin&utm_medium=social&utm_campaign=spring</code>.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="section-title" style="font-size: 1.35rem; margin-bottom: 1rem;">Top mediums</h2>
|
||||||
|
<div class="table-responsive">
|
||||||
|
{% if top_mediums %}
|
||||||
|
<table class="table">
|
||||||
|
<thead><tr><th>utm_medium</th><th>Views</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in top_mediums %}
|
||||||
|
<tr><td>{{ row.utm_medium }}</td><td>{{ row.total }}</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p style="color: var(--text-muted);">No UTM mediums yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="section-title" style="font-size: 1.35rem; margin-bottom: 1rem;">Top campaigns</h2>
|
||||||
|
<div class="table-responsive">
|
||||||
|
{% if top_campaigns %}
|
||||||
|
<table class="table">
|
||||||
|
<thead><tr><th>utm_campaign</th><th>Views</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in top_campaigns %}
|
||||||
|
<tr><td>{{ row.utm_campaign }}</td><td>{{ row.total }}</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p style="color: var(--text-muted);">No UTM campaigns yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="section-title" style="font-size: 1.75rem; margin-bottom: 1rem;">Top pages{% if traffic_filter %} (filtered){% endif %}</h2>
|
||||||
|
<div class="table-responsive" style="margin-bottom: 3rem;">
|
||||||
|
{% if top_pages %}
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Path</th><th>Views</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in top_pages %}
|
||||||
|
<tr><td><code>{{ row.path }}</code></td><td>{{ row.total }}</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p style="color: var(--text-muted);">No page data for this filter.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if contacts_with_utm %}
|
||||||
|
<div style="display: flex; flex-wrap: wrap; justify-content: space-between; gap: 1rem; align-items: baseline; margin-bottom: 1rem;">
|
||||||
|
<h2 class="section-title" style="font-size: 1.75rem; margin-bottom: 0;">Contact leads with UTM</h2>
|
||||||
|
<a href="{% url 'leads_list' %}?utm=1" style="color: var(--text-muted); font-size: 0.9rem;">All leads →</a>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive" style="margin-bottom: 3rem;">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>When</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Source</th>
|
||||||
|
<th>Medium</th>
|
||||||
|
<th>Campaign</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for c in contacts_with_utm %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ c.created|date:"Y-m-d H:i" }}</td>
|
||||||
|
<td>{{ c.name }}</td>
|
||||||
|
<td>{{ c.email }}</td>
|
||||||
|
<td>{{ c.utm_source|default:"—" }}</td>
|
||||||
|
<td>{{ c.utm_medium|default:"—" }}</td>
|
||||||
|
<td>{{ c.utm_campaign|default:"—" }}</td>
|
||||||
|
<td><a href="{% url 'lead_detail' c.pk %}">Details</a></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<h2 class="section-title" style="font-size: 1.75rem; margin-bottom: 1rem;">Recent visits</h2>
|
||||||
|
<div class="table-responsive">
|
||||||
|
{% if recent_visits %}
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>When</th>
|
||||||
|
<th>Path</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Source</th>
|
||||||
|
<th>Medium</th>
|
||||||
|
<th>Campaign</th>
|
||||||
|
<th>Landing</th>
|
||||||
|
<th>Referrer</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for v in recent_visits %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ v.created|date:"Y-m-d H:i" }}</td>
|
||||||
|
<td><code>{{ v.path }}</code></td>
|
||||||
|
<td>{{ v.get_traffic_type_display }}</td>
|
||||||
|
<td>{{ v.utm_source|default:"—" }}</td>
|
||||||
|
<td>{{ v.utm_medium|default:"—" }}</td>
|
||||||
|
<td>{{ v.utm_campaign|default:"—" }}</td>
|
||||||
|
<td>{% if v.is_landing %}Yes{% else %}—{% endif %}</td>
|
||||||
|
<td style="max-width: 12rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="{{ v.referrer }}">{{ v.referrer|default:"—"|truncatechars:40 }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p style="color: var(--text-muted);">No visits recorded yet. Browse the public site (optionally with UTM query params) and refresh this page.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -2,54 +2,51 @@
|
|||||||
{% load static %}
|
{% load static %}
|
||||||
|
|
||||||
{% block title %}Web Design & Hosting - AI ML Operations, LLC{% endblock %}
|
{% block title %}Web Design & Hosting - AI ML Operations, LLC{% endblock %}
|
||||||
{% block meta_description %}Professional web design and reliable hosting solutions by AI ML Operations, LLC. We create
|
{% block meta_description %}Custom brand-tailored websites with interactive package pricing. You own the site and data. Public site + client portal with UTM/leads included; add Email/SMS, blog, Stripe, social, and more. Three-instance hosting, SEO, accessibility, LLM integration, and Grafana alerts on every build.{% endblock %}
|
||||||
visually stunning, functional websites tailored to your business.{% endblock %}
|
{% block og_title %}Web Design & Hosting - AI ML Operations, LLC{% endblock %}
|
||||||
|
{% block og_description %}Build a custom site package with live pricing. You own the site and data. Base site + portal included; three-instance hosting, UTM/leads, SEO, accessibility, LLM readiness, and Grafana metrics on every project.{% endblock %}
|
||||||
|
{% block twitter_title %}Web Design & Hosting - AI ML Operations, LLC{% endblock %}
|
||||||
|
{% block twitter_description %}Custom brand-tailored sites with interactive package pricing. You own the site and data — leave anytime and take it with you.{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<!-- Hero Section -->
|
<!-- Hero Section -->
|
||||||
<div class="hero-section" style="height: 40vh; min-height: 300px;">
|
<div class="hero-section" style="height: 40vh; min-height: 300px;">
|
||||||
<div class="hero-content">
|
<div class="hero-content">
|
||||||
<h1 class="hero-title">Web Design & Hosting</h1>
|
<h1 class="hero-title">Web Design & Hosting</h1>
|
||||||
<p class="hero-subtitle">Crafting Beautiful, Functional Websites with Reliable Hosting</p>
|
<p class="hero-subtitle">Crafted sites with reliable multi-instance hosting and built-in growth tools</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- About Web Design & Hosting Section -->
|
<!-- About -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2 class="section-title">About Our Web Design & Hosting Service</h2>
|
<h2 class="section-title">About Our Web Design & Hosting Service</h2>
|
||||||
<p style="text-align: center; max-width: 800px; margin: 0 auto; color: var(--text-muted); font-size: 1.1rem;">
|
<p style="text-align: center; max-width: 800px; margin: 0 auto; color: var(--text-muted); font-size: 1.1rem;">
|
||||||
At AI ML Operations, we specialize in creating visually stunning, highly functional websites tailored to your
|
At AI ML Operations, we design and host custom websites tailored to your brand and how your
|
||||||
business needs. From design to deployment, we handle every aspect of your online presence. Our reliable hosting
|
customers use the site. You own the site and data — leave anytime and take everything with you.
|
||||||
solutions ensure your website is always fast, secure, and accessible. Whether you need a simple portfolio site or
|
Every project ships with three-instance hosting, UTM tracking and lead capture, optimized SEO,
|
||||||
a complex e-commerce platform, we’ve got you covered.
|
accessibility, performance, LLM integration, and Grafana metrics and alerts.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Features Section -->
|
<!-- Included with every site -->
|
||||||
<div class="section" style="background: var(--surface-color);">
|
<div class="section" style="background: var(--surface-color);">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2 class="section-title">What We Offer</h2>
|
<h2 class="section-title">Included With Every Site</h2>
|
||||||
<div class="card-grid">
|
<div class="card-grid">
|
||||||
|
{% for item in pricing_included %}
|
||||||
<div class="card" style="text-align: center;">
|
<div class="card" style="text-align: center;">
|
||||||
<h5 class="card-title">Custom Web Design</h5>
|
<h5 class="card-title">{{ item.title }}</h5>
|
||||||
<p class="card-text">Tailored designs that reflect your brand and engage your audience.</p>
|
<p class="card-text">{{ item.description }}</p>
|
||||||
</div>
|
|
||||||
<div class="card" style="text-align: center;">
|
|
||||||
<h5 class="card-title">Reliable Hosting</h5>
|
|
||||||
<p class="card-text">Fast, secure, and scalable hosting solutions for your website.</p>
|
|
||||||
</div>
|
|
||||||
<div class="card" style="text-align: center;">
|
|
||||||
<h5 class="card-title">Ongoing Support</h5>
|
|
||||||
<p class="card-text">Continuous maintenance and support to keep your site running smoothly.</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Services Section -->
|
<!-- Services -->
|
||||||
<div class="section">
|
<!-- <div class="section">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2 class="section-title">Our Services</h2>
|
<h2 class="section-title">Our Services</h2>
|
||||||
<div class="card-grid">
|
<div class="card-grid">
|
||||||
@@ -60,134 +57,246 @@ visually stunning, functional websites tailored to your business.{% endblock %}
|
|||||||
<p class="card-text">Unique, responsive designs tailored to your brand and audience.</p>
|
<p class="card-text">Unique, responsive designs tailored to your brand and audience.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<img src="{% static 'public/img/web_design/card-2.jpg' %}" alt="E-Commerce Solutions"
|
<img src="{% static 'public/img/web_design/card-2.jpg' %}" alt="Client Portal"
|
||||||
style="width: 100%; border-radius: 8px; margin-bottom: 1rem;">
|
style="width: 100%; border-radius: 8px; margin-bottom: 1rem;">
|
||||||
<span class="card-title">E-Commerce Solutions</span>
|
<span class="card-title">Client Portal + UTM</span>
|
||||||
<p class="card-text">Build and optimize online stores for seamless shopping experiences.</p>
|
<p class="card-text">Login, dashboard, leads, and UTM analytics — included with every site.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<img src="{% static 'public/img/web_design/card-3.jpg' %}" alt="Website Hosting"
|
<img src="{% static 'public/img/web_design/card-3.jpg' %}" alt="Website Hosting"
|
||||||
style="width: 100%; border-radius: 8px; margin-bottom: 1rem;">
|
style="width: 100%; border-radius: 8px; margin-bottom: 1rem;">
|
||||||
<span class="card-title">Website Hosting</span>
|
<span class="card-title">Multi-Instance Hosting</span>
|
||||||
<p class="card-text">Secure, high-performance hosting with 99.9% uptime guarantee.</p>
|
<p class="card-text">At least three instances for reliability, failover, and scale.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<img src="{% static 'public/img/web_design/card-4.jpg' %}" alt="SEO Optimization"
|
<img src="{% static 'public/img/web_design/card-4.jpg' %}" alt="SEO and Accessibility"
|
||||||
style="width: 100%; border-radius: 8px; margin-bottom: 1rem;">
|
style="width: 100%; border-radius: 8px; margin-bottom: 1rem;">
|
||||||
<span class="card-title">SEO Optimization</span>
|
<span class="card-title">SEO, Accessibility & Performance</span>
|
||||||
<p class="card-text">Improve your website's visibility and ranking on search engines.</p>
|
<p class="card-text">Search-ready, accessible, and fast — plus LLM-friendly site structure.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<img src="{% static 'public/img/web_design/card-5.jpg' %}" alt="Maintenance & Support"
|
<img src="{% static 'public/img/web_design/card-5.jpg' %}" alt="Growth Add-ons"
|
||||||
style="width: 100%; border-radius: 8px; margin-bottom: 1rem;">
|
style="width: 100%; border-radius: 8px; margin-bottom: 1rem;">
|
||||||
<span class="card-title">Maintenance & Support</span>
|
<span class="card-title">Growth Add-ons</span>
|
||||||
<p class="card-text">Regular updates, backups, and troubleshooting to keep your site running smoothly.</p>
|
<p class="card-text">Email/SMS, direct mail, blog, Stripe payments, social, and AI social drafts.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div> -->
|
||||||
|
|
||||||
<!-- Pricing Section -->
|
<!-- Interactive pricing -->
|
||||||
<div class="section" style="background: var(--surface-color);">
|
<div class="section" style="background: var(--surface-color);">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2 class="section-title">Web Hosting Plans</h2>
|
<h2 class="section-title">Build Your Package</h2>
|
||||||
|
<p class="pricing-intro">
|
||||||
|
Public site and client portal (with UTM) are always included. Toggle add-ons to see one-time
|
||||||
|
build and monthly totals. Stripe / SMS / postage usage billed separately.
|
||||||
|
</p>
|
||||||
|
|
||||||
<!-- Pricing toggle -->
|
<div class="pricing-estimator" id="pricingEstimator"
|
||||||
<div class="pricing-toggle-wrap">
|
data-disclaimer="{{ pricing_disclaimer|escape }}">
|
||||||
<div class="pricing-toggle" role="group" aria-label="Billing period">
|
<div class="pricing-features-panel">
|
||||||
<span class="pricing-toggle-label active" id="monthlyLabel">Monthly</span>
|
<h3 class="pricing-panel-title">Features</h3>
|
||||||
<label class="pricing-switch" for="pricingToggle">
|
<ul class="pricing-feature-list" role="list">
|
||||||
<input type="checkbox" id="pricingToggle" aria-label="Toggle between monthly and yearly billing">
|
{% for feature in pricing_features %}
|
||||||
<span class="pricing-switch-slider"></span>
|
<li class="pricing-feature{% if feature.required %} is-required{% endif %}"
|
||||||
|
data-feature-id="{{ feature.id }}">
|
||||||
|
<label class="pricing-feature-label">
|
||||||
|
<input type="checkbox"
|
||||||
|
class="pricing-feature-check"
|
||||||
|
value="{{ feature.id }}"
|
||||||
|
data-build="{{ feature.build }}"
|
||||||
|
data-monthly="{{ feature.monthly }}"
|
||||||
|
data-requires="{{ feature.requires|join:',' }}"
|
||||||
|
{% if feature.required %}checked disabled{% endif %}>
|
||||||
|
<span class="pricing-feature-body">
|
||||||
|
<span class="pricing-feature-name">{{ feature.name }}</span>
|
||||||
|
<span class="pricing-feature-desc">{{ feature.description }}</span>
|
||||||
|
{% if feature.requires_note %}
|
||||||
|
<span class="pricing-feature-note" hidden data-note-for="{{ feature.id }}">{{ feature.requires_note }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
<span class="pricing-feature-costs">
|
||||||
|
<strong>${{ feature.build }}</strong> build
|
||||||
|
<span class="pricing-feature-monthly"><strong>${{ feature.monthly }}</strong> /mo</span>
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<span class="pricing-toggle-label" id="yearlyLabel">Yearly</span>
|
</li>
|
||||||
</div>
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pricing cards -->
|
<aside class="pricing-estimate-panel" aria-live="polite">
|
||||||
<div class="card-grid" style="grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));">
|
<h3 class="pricing-panel-title">Estimate</h3>
|
||||||
<!-- Monthly Card -->
|
<div class="pricing-estimate-row">
|
||||||
<div class="card" style="text-align: center;">
|
<span>One-time build</span>
|
||||||
<span class="card-title" style="font-size: 1.5rem;">Standard Plan</span>
|
<strong id="estimateBuild">${{ pricing_base_estimate.one_time_build }}</strong>
|
||||||
<h4 style="font-size: 3rem; color: var(--primary-color); margin: 1rem 0;">$<span class="price">10</span></h4>
|
|
||||||
<p class="card-text" style="margin-bottom: 2rem;"><span class="billing-period">per month</span></p>
|
|
||||||
<ul style="text-align: left; margin-bottom: 2rem; list-style: none;">
|
|
||||||
<li style="margin-bottom: 0.5rem;">✓ Web Hosting</li>
|
|
||||||
<li style="margin-bottom: 0.5rem;">✓ Weekly Backups</li>
|
|
||||||
<li style="margin-bottom: 0.5rem;">✓ SSL Certificate</li>
|
|
||||||
<li style="margin-bottom: 0.5rem;">✓ CAPTCHA Protection</li>
|
|
||||||
<li style="margin-bottom: 0.5rem;">✓ Email Notifications</li>
|
|
||||||
<li style="margin-bottom: 0.5rem;">✓ Backend Admin Access</li>
|
|
||||||
</ul>
|
|
||||||
<a href="{% url 'contact' %}?subject=Web%20Hosting%20Standard%20Plan" class="btn">Get Started</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="pricing-estimate-row">
|
||||||
<!-- Yearly Card -->
|
<span>Monthly</span>
|
||||||
<div class="card" style="text-align: center; border-color: var(--secondary-color);">
|
<strong id="estimateMonthly">${{ pricing_base_estimate.monthly }}</strong>
|
||||||
<span class="card-title" style="font-size: 1.5rem; color: var(--secondary-color);">Premium Plan</span>
|
|
||||||
<h4 style="font-size: 3rem; color: var(--secondary-color); margin: 1rem 0;">$<span class="price">15</span></h4>
|
|
||||||
<p class="card-text" style="margin-bottom: 2rem;"><span class="billing-period">per month</span></p>
|
|
||||||
<ul style="text-align: left; margin-bottom: 2rem; list-style: none;">
|
|
||||||
<li style="margin-bottom: 0.5rem;">✓ All Standard Features</li>
|
|
||||||
<li style="margin-bottom: 0.5rem;">✓ Monthly Analytics Reports</li>
|
|
||||||
<li style="margin-bottom: 0.5rem;">✓ Site Optimization Reports</li>
|
|
||||||
<li style="margin-bottom: 0.5rem;">✓ HTML Marketing Emails</li>
|
|
||||||
<li style="margin-bottom: 0.5rem;">✓ 2 Months Free (Yearly)</li>
|
|
||||||
<li style="margin-bottom: 0.5rem;">✓ Priority Support</li>
|
|
||||||
</ul>
|
|
||||||
<a href="{% url 'contact' %}?subject=Web%20Hosting%20Premium%20Plan" class="btn" style="background: var(--secondary-color); color: white;">Save 20%</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="pricing-estimate-count" id="estimateCount">
|
||||||
|
Selected: {{ pricing_base_estimate.selected_count }} features
|
||||||
|
</p>
|
||||||
|
<p class="pricing-estimate-disclaimer">{{ pricing_disclaimer }}</p>
|
||||||
|
<a href="{% url 'contact' %}?subject=Web%20Design%20Package%20Estimate"
|
||||||
|
class="btn pricing-estimate-cta"
|
||||||
|
id="estimateContactCta"
|
||||||
|
data-tianji-event="web_design_estimate_submit"
|
||||||
|
data-tianji-event-page="web_design"
|
||||||
|
data-tianji-event-action="request_quote"
|
||||||
|
data-tianji-event-build="{{ pricing_base_estimate.one_time_build }}"
|
||||||
|
data-tianji-event-monthly="{{ pricing_base_estimate.monthly }}"
|
||||||
|
data-tianji-event-selected-count="{{ pricing_base_estimate.selected_count }}"
|
||||||
|
data-tianji-event-features="{% for item in pricing_base_estimate.selected %}{{ item.id }}{% if not forloop.last %},{% endif %}{% endfor %}">Request this package</a>
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const pricingToggle = document.getElementById('pricingToggle');
|
(function () {
|
||||||
const monthlyLabel = document.getElementById('monthlyLabel');
|
var root = document.getElementById('pricingEstimator');
|
||||||
const yearlyLabel = document.getElementById('yearlyLabel');
|
if (!root) return;
|
||||||
|
|
||||||
function updatePricing(isYearly) {
|
var checks = Array.prototype.slice.call(root.querySelectorAll('.pricing-feature-check'));
|
||||||
const prices = document.querySelectorAll('.price');
|
var buildEl = document.getElementById('estimateBuild');
|
||||||
const periods = document.querySelectorAll('.billing-period');
|
var monthlyEl = document.getElementById('estimateMonthly');
|
||||||
|
var countEl = document.getElementById('estimateCount');
|
||||||
|
var ctaEl = document.getElementById('estimateContactCta');
|
||||||
|
var byId = {};
|
||||||
|
|
||||||
monthlyLabel.classList.toggle('active', !isYearly);
|
checks.forEach(function (input) {
|
||||||
yearlyLabel.classList.toggle('active', isYearly);
|
byId[input.value] = input;
|
||||||
|
});
|
||||||
|
|
||||||
if (isYearly) {
|
function formatMoney(n) {
|
||||||
prices[0].textContent = '100';
|
return '$' + n.toLocaleString('en-US');
|
||||||
periods[0].textContent = 'per year';
|
}
|
||||||
prices[1].textContent = '150';
|
|
||||||
periods[1].textContent = 'per year';
|
function trackTianji(name, data) {
|
||||||
} else {
|
if (typeof window.aimlTrackWhenReady === 'function') {
|
||||||
prices[0].textContent = '10';
|
window.aimlTrackWhenReady(name, data);
|
||||||
periods[0].textContent = 'per month';
|
|
||||||
prices[1].textContent = '15';
|
|
||||||
periods[1].textContent = 'per month';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pricingToggle.addEventListener('change', function () {
|
function getSelectionState() {
|
||||||
updatePricing(this.checked);
|
var build = 0;
|
||||||
|
var monthly = 0;
|
||||||
|
var selected = [];
|
||||||
|
|
||||||
|
checks.forEach(function (input) {
|
||||||
|
if (!input.checked) return;
|
||||||
|
build += Number(input.getAttribute('data-build')) || 0;
|
||||||
|
monthly += Number(input.getAttribute('data-monthly')) || 0;
|
||||||
|
selected.push(input.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
monthlyLabel.addEventListener('click', function () {
|
return {
|
||||||
pricingToggle.checked = false;
|
build: build,
|
||||||
updatePricing(false);
|
monthly: monthly,
|
||||||
|
selected: selected,
|
||||||
|
selectedCount: selected.length,
|
||||||
|
features: selected.join(','),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredBy(id) {
|
||||||
|
return checks.filter(function (input) {
|
||||||
|
var req = (input.getAttribute('data-requires') || '').split(',').filter(Boolean);
|
||||||
|
return req.indexOf(id) !== -1 && input.checked;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function enforceDependencies(changed) {
|
||||||
|
checks.forEach(function (input) {
|
||||||
|
if (!input.checked || input.disabled) return;
|
||||||
|
var req = (input.getAttribute('data-requires') || '').split(',').filter(Boolean);
|
||||||
|
req.forEach(function (depId) {
|
||||||
|
var dep = byId[depId];
|
||||||
|
if (dep && !dep.checked && !dep.disabled) {
|
||||||
|
dep.checked = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
yearlyLabel.addEventListener('click', function () {
|
if (changed && !changed.checked) {
|
||||||
pricingToggle.checked = true;
|
requiredBy(changed.value).forEach(function (dep) {
|
||||||
updatePricing(true);
|
if (!dep.disabled) dep.checked = false;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
checks.forEach(function (input) {
|
||||||
|
var note = root.querySelector('[data-note-for="' + input.value + '"]');
|
||||||
|
if (!note) return;
|
||||||
|
var req = (input.getAttribute('data-requires') || '').split(',').filter(Boolean);
|
||||||
|
note.hidden = !(input.checked && req.length);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEstimate() {
|
||||||
|
enforceDependencies();
|
||||||
|
|
||||||
|
var state = getSelectionState();
|
||||||
|
|
||||||
|
checks.forEach(function (input) {
|
||||||
|
var row = input.closest('.pricing-feature');
|
||||||
|
if (row) row.classList.toggle('is-selected', input.checked);
|
||||||
|
});
|
||||||
|
|
||||||
|
buildEl.textContent = formatMoney(state.build);
|
||||||
|
monthlyEl.textContent = formatMoney(state.monthly);
|
||||||
|
countEl.textContent = 'Selected: ' + state.selectedCount + ' feature' + (state.selectedCount === 1 ? '' : 's');
|
||||||
|
|
||||||
|
if (ctaEl) {
|
||||||
|
var subject = 'Web Design Package Estimate — ' + state.features;
|
||||||
|
ctaEl.href = '{% url "contact" %}?subject=' + encodeURIComponent(subject);
|
||||||
|
ctaEl.setAttribute('data-tianji-event-build', String(state.build));
|
||||||
|
ctaEl.setAttribute('data-tianji-event-monthly', String(state.monthly));
|
||||||
|
ctaEl.setAttribute('data-tianji-event-selected-count', String(state.selectedCount));
|
||||||
|
ctaEl.setAttribute('data-tianji-event-features', state.features);
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
checks.forEach(function (input) {
|
||||||
|
input.addEventListener('change', function () {
|
||||||
|
var before = getSelectionState();
|
||||||
|
enforceDependencies(input);
|
||||||
|
var state = updateEstimate();
|
||||||
|
|
||||||
|
trackTianji('web_design_estimate_toggle', {
|
||||||
|
page: 'web_design',
|
||||||
|
feature: input.value,
|
||||||
|
enabled: input.checked ? 'true' : 'false',
|
||||||
|
build: String(state.build),
|
||||||
|
monthly: String(state.monthly),
|
||||||
|
selected_count: String(state.selectedCount),
|
||||||
|
features: state.features,
|
||||||
|
previous_features: before.features,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
updateEstimate();
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Call to Action Section -->
|
<!-- Call to Action -->
|
||||||
<div class="section" style="text-align: center;">
|
<div class="section" style="text-align: center;">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2 class="section-title">Ready to Build Your Online Presence?</h2>
|
<h2 class="section-title">Ready to Build Your Online Presence?</h2>
|
||||||
<p class="hero-subtitle" style="margin-bottom: 2rem;">Contact us today to get started on your website project.</p>
|
<p class="hero-subtitle" style="margin-bottom: 2rem;">
|
||||||
<a href="{% url 'contact' %}" class="btn"
|
Tell us which features you need — we will turn this estimate into a tailored proposal.
|
||||||
data-tianji-event="service_cta" data-tianji-event-page="web_design" data-tianji-event-action="get_started">Get Started</a>
|
</p>
|
||||||
|
<a href="{% url 'contact' %}?subject=Web%20Design%20Inquiry" class="btn"
|
||||||
|
data-tianji-event="web_design_contact_cta"
|
||||||
|
data-tianji-event-page="web_design"
|
||||||
|
data-tianji-event-action="get_started">
|
||||||
|
Contact us
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,10 +1,89 @@
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.contrib.auth.models import User
|
||||||
from django.test import Client, TestCase, override_settings
|
from django.test import Client, TestCase, override_settings
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from .models import Contact
|
from company_site.settings.base import build_csrf_trusted_origins
|
||||||
|
|
||||||
|
from .models import Contact, EmailMessage, PageVisit
|
||||||
from .seo import SERVICE_URL_NAMES, get_service_entries
|
from .seo import SERVICE_URL_NAMES, get_service_entries
|
||||||
|
from .traffic import TrafficType, classify_user_agent
|
||||||
|
|
||||||
|
|
||||||
|
class CsrfTrustedOriginsTests(TestCase):
|
||||||
|
def test_derives_https_origins_from_public_hosts(self):
|
||||||
|
origins = build_csrf_trusted_origins(
|
||||||
|
["aimloperations.com", "www.aimloperations.com"]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
origins,
|
||||||
|
[
|
||||||
|
"https://aimloperations.com",
|
||||||
|
"https://www.aimloperations.com",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_derives_http_origins_for_local_hosts(self):
|
||||||
|
origins = build_csrf_trusted_origins(["localhost", "127.0.0.1"])
|
||||||
|
|
||||||
|
self.assertEqual(origins, ["http://localhost", "http://127.0.0.1"])
|
||||||
|
|
||||||
|
def test_explicit_origins_win(self):
|
||||||
|
origins = build_csrf_trusted_origins(
|
||||||
|
["aimloperations.com"],
|
||||||
|
["https://custom.example"],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(origins, ["https://custom.example"])
|
||||||
|
|
||||||
|
|
||||||
|
class LogoutCsrfTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = Client(enforce_csrf_checks=True)
|
||||||
|
self.user = User.objects.create_user(username="logout_user", password="pass")
|
||||||
|
|
||||||
|
def test_logout_post_with_csrf_succeeds(self):
|
||||||
|
self.client.login(username="logout_user", password="pass")
|
||||||
|
self.client.get("/")
|
||||||
|
csrf = self.client.cookies["csrftoken"].value
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("logout"),
|
||||||
|
{"csrfmiddlewaretoken": csrf},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.url, "/")
|
||||||
|
self.assertNotIn("_auth_user_id", self.client.session)
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewEmailAuthTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = Client()
|
||||||
|
self.user = User.objects.create_user(username="previewer", password="pass")
|
||||||
|
self.email = EmailMessage.objects.create(
|
||||||
|
subject="Preview subject",
|
||||||
|
body="Preview body content",
|
||||||
|
recipient="recipient@example.com",
|
||||||
|
)
|
||||||
|
self.url = reverse("preview_email", kwargs={"pk": self.email.pk})
|
||||||
|
|
||||||
|
def test_unauthenticated_user_is_redirected_to_login(self):
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertIn("/accounts/login/", response.url)
|
||||||
|
|
||||||
|
def test_authenticated_user_can_preview_email(self):
|
||||||
|
self.client.login(username="previewer", password="pass")
|
||||||
|
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Preview subject")
|
||||||
|
self.assertContains(response, "Preview body content")
|
||||||
|
|
||||||
|
|
||||||
@override_settings(
|
@override_settings(
|
||||||
@@ -145,6 +224,7 @@ class AgenticBrowsingSeoTests(TestCase):
|
|||||||
self.assertEqual(response["Content-Type"], "text/plain; charset=utf-8")
|
self.assertEqual(response["Content-Type"], "text/plain; charset=utf-8")
|
||||||
self.assertContains(response, "User-agent: *")
|
self.assertContains(response, "User-agent: *")
|
||||||
self.assertContains(response, "Sitemap:")
|
self.assertContains(response, "Sitemap:")
|
||||||
|
self.assertContains(response, "llms.txt")
|
||||||
|
|
||||||
def test_sitemap_xml_lists_public_pages(self):
|
def test_sitemap_xml_lists_public_pages(self):
|
||||||
response = self.client.get(reverse("sitemap_xml"))
|
response = self.client.get(reverse("sitemap_xml"))
|
||||||
@@ -163,6 +243,14 @@ class AgenticBrowsingSeoTests(TestCase):
|
|||||||
self.assertContains(response, "# AI ML Operations, LLC")
|
self.assertContains(response, "# AI ML Operations, LLC")
|
||||||
self.assertContains(response, "## Key pages")
|
self.assertContains(response, "## Key pages")
|
||||||
self.assertContains(response, reverse("contact"))
|
self.assertContains(response, reverse("contact"))
|
||||||
|
self.assertContains(response, "## Web design & hosting")
|
||||||
|
self.assertContains(response, "estimate_web_design_cost")
|
||||||
|
self.assertContains(response, "email_sms")
|
||||||
|
self.assertContains(response, "three instances")
|
||||||
|
self.assertContains(response, "Tailored to your brand")
|
||||||
|
self.assertContains(response, "You own the site")
|
||||||
|
self.assertContains(response, "Grafana metrics")
|
||||||
|
self.assertContains(response, "brand-tailored")
|
||||||
|
|
||||||
def test_homepage_uses_semantic_nav_controls(self):
|
def test_homepage_uses_semantic_nav_controls(self):
|
||||||
response = self.client.get(reverse("public_index"))
|
response = self.client.get(reverse("public_index"))
|
||||||
@@ -235,6 +323,7 @@ class WebMcpTests(TestCase):
|
|||||||
"get_page_content",
|
"get_page_content",
|
||||||
"navigate_to_service",
|
"navigate_to_service",
|
||||||
"open_contact_with_subject",
|
"open_contact_with_subject",
|
||||||
|
"estimate_web_design_cost",
|
||||||
"submit_contact_inquiry",
|
"submit_contact_inquiry",
|
||||||
):
|
):
|
||||||
self.assertIn("name: '" + tool_name + "'", script)
|
self.assertIn("name: '" + tool_name + "'", script)
|
||||||
@@ -242,3 +331,180 @@ class WebMcpTests(TestCase):
|
|||||||
self.assertIn("readOnlyHint: true", script)
|
self.assertIn("readOnlyHint: true", script)
|
||||||
self.assertIn("readOnlyHint: false", script)
|
self.assertIn("readOnlyHint: false", script)
|
||||||
self.assertIn("navigator.modelContext || document.modelContext", script)
|
self.assertIn("navigator.modelContext || document.modelContext", script)
|
||||||
|
self.assertIn("webDesignPricing", script)
|
||||||
|
|
||||||
|
def test_webmcp_config_includes_web_design_pricing(self):
|
||||||
|
response = self.client.get(reverse("public_index"))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "data-web-design-pricing=")
|
||||||
|
self.assertContains(response, "email_sms")
|
||||||
|
self.assertContains(response, "client_portal")
|
||||||
|
|
||||||
|
|
||||||
|
class WebDesignPricingTests(TestCase):
|
||||||
|
def test_web_design_page_renders_estimator(self):
|
||||||
|
response = self.client.get(reverse("web_design"))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Build Your Package")
|
||||||
|
self.assertContains(response, "pricingEstimator")
|
||||||
|
self.assertContains(response, "Public site")
|
||||||
|
self.assertContains(response, "Client portal + UTM")
|
||||||
|
self.assertContains(response, "three instances")
|
||||||
|
self.assertContains(response, "UTM tracking")
|
||||||
|
self.assertContains(response, "Grafana metrics & alerts")
|
||||||
|
self.assertContains(response, "You own the site")
|
||||||
|
self.assertContains(response, "Tailored to your brand")
|
||||||
|
self.assertContains(response, reverse("contact"))
|
||||||
|
self.assertContains(response, 'data-tianji-event="web_design_estimate_submit"')
|
||||||
|
self.assertContains(response, 'data-tianji-event="web_design_contact_cta"')
|
||||||
|
self.assertContains(response, "web_design_estimate_toggle")
|
||||||
|
self.assertContains(response, "aimlTrackWhenReady")
|
||||||
|
self.assertContains(response, "data-tianji-event-build=")
|
||||||
|
self.assertContains(response, "data-tianji-event-features=")
|
||||||
|
|
||||||
|
def test_base_estimate_includes_required_features_only(self):
|
||||||
|
from .web_design_pricing import estimate_web_design_cost
|
||||||
|
|
||||||
|
estimate = estimate_web_design_cost()
|
||||||
|
self.assertEqual(estimate["one_time_build"], 600)
|
||||||
|
self.assertEqual(estimate["monthly"], 40)
|
||||||
|
self.assertEqual(estimate["selected_count"], 2)
|
||||||
|
|
||||||
|
def test_payments_auto_selects_email_sms(self):
|
||||||
|
from .web_design_pricing import estimate_web_design_cost
|
||||||
|
|
||||||
|
estimate = estimate_web_design_cost(["payments"])
|
||||||
|
selected_ids = {item["id"] for item in estimate["selected"]}
|
||||||
|
self.assertIn("email_sms", selected_ids)
|
||||||
|
self.assertIn("payments", selected_ids)
|
||||||
|
self.assertEqual(estimate["one_time_build"], 1600)
|
||||||
|
self.assertEqual(estimate["monthly"], 70)
|
||||||
|
|
||||||
|
def test_ai_social_auto_selects_social(self):
|
||||||
|
from .web_design_pricing import estimate_web_design_cost
|
||||||
|
|
||||||
|
estimate = estimate_web_design_cost(["ai_social"])
|
||||||
|
selected_ids = {item["id"] for item in estimate["selected"]}
|
||||||
|
self.assertIn("social", selected_ids)
|
||||||
|
self.assertIn("ai_social", selected_ids)
|
||||||
|
self.assertEqual(estimate["one_time_build"], 1400)
|
||||||
|
self.assertEqual(estimate["monthly"], 80)
|
||||||
|
|
||||||
|
|
||||||
|
class TrafficClassificationTests(TestCase):
|
||||||
|
def test_classifies_common_agents(self):
|
||||||
|
cases = [
|
||||||
|
("Mozilla/5.0 (Macintosh) Chrome/120.0.0.0 Safari/537.36", TrafficType.HUMAN),
|
||||||
|
("Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)", TrafficType.SEARCH_INDEXER),
|
||||||
|
("Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.0)", TrafficType.AI_BOT),
|
||||||
|
("ClaudeBot/1.0", TrafficType.AI_BOT),
|
||||||
|
("facebookexternalhit/1.1", TrafficType.SOCIAL_BOT),
|
||||||
|
("UptimeRobot/2.0", TrafficType.MONITORING),
|
||||||
|
("python-requests/2.31.0", TrafficType.OTHER_BOT),
|
||||||
|
("", TrafficType.UNKNOWN),
|
||||||
|
]
|
||||||
|
for ua, expected in cases:
|
||||||
|
with self.subTest(ua=ua):
|
||||||
|
self.assertEqual(classify_user_agent(ua), expected)
|
||||||
|
|
||||||
|
|
||||||
|
class UTMTrackingTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = Client(
|
||||||
|
HTTP_USER_AGENT="Mozilla/5.0 (Macintosh) Chrome/120.0.0.0 Safari/537.36"
|
||||||
|
)
|
||||||
|
self.staff = User.objects.create_user(
|
||||||
|
username="staff_utm", password="pass", is_staff=True
|
||||||
|
)
|
||||||
|
self.regular = User.objects.create_user(username="plain_utm", password="pass")
|
||||||
|
|
||||||
|
def test_page_visit_records_utm_and_human_type(self):
|
||||||
|
response = self.client.get(
|
||||||
|
"/?utm_source=linkedin&utm_medium=social&utm_campaign=spring"
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
visit = PageVisit.objects.latest("created")
|
||||||
|
self.assertEqual(visit.path, "/")
|
||||||
|
self.assertEqual(visit.utm_source, "linkedin")
|
||||||
|
self.assertEqual(visit.utm_medium, "social")
|
||||||
|
self.assertEqual(visit.utm_campaign, "spring")
|
||||||
|
self.assertTrue(visit.is_landing)
|
||||||
|
self.assertEqual(visit.traffic_type, TrafficType.HUMAN)
|
||||||
|
|
||||||
|
def test_utm_persists_on_next_page_via_session(self):
|
||||||
|
self.client.get("/?utm_source=newsletter&utm_medium=email&utm_campaign=march")
|
||||||
|
self.client.get("/contact")
|
||||||
|
visit = PageVisit.objects.filter(path="/contact").latest("created")
|
||||||
|
self.assertEqual(visit.utm_source, "newsletter")
|
||||||
|
self.assertEqual(visit.utm_campaign, "march")
|
||||||
|
self.assertFalse(visit.is_landing)
|
||||||
|
|
||||||
|
def test_dashboard_requires_staff(self):
|
||||||
|
url = reverse("utm_dashboard")
|
||||||
|
anon = self.client.get(url)
|
||||||
|
self.assertEqual(anon.status_code, 302)
|
||||||
|
self.assertIn("login", anon.url)
|
||||||
|
|
||||||
|
self.client.login(username="plain_utm", password="pass")
|
||||||
|
denied = self.client.get(url)
|
||||||
|
self.assertEqual(denied.status_code, 302)
|
||||||
|
|
||||||
|
self.client.login(username="staff_utm", password="pass")
|
||||||
|
ok = self.client.get(url)
|
||||||
|
self.assertEqual(ok.status_code, 200)
|
||||||
|
self.assertContains(ok, "UTM & Traffic Analytics")
|
||||||
|
|
||||||
|
def test_search_bot_classified(self):
|
||||||
|
bot_client = Client(
|
||||||
|
HTTP_USER_AGENT="Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"
|
||||||
|
)
|
||||||
|
bot_client.get("/")
|
||||||
|
visit = PageVisit.objects.latest("created")
|
||||||
|
self.assertEqual(visit.traffic_type, TrafficType.SEARCH_INDEXER)
|
||||||
|
|
||||||
|
|
||||||
|
class LeadsDashboardTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = Client()
|
||||||
|
self.staff = User.objects.create_user(
|
||||||
|
username="staff_leads", password="pass", is_staff=True
|
||||||
|
)
|
||||||
|
self.regular = User.objects.create_user(username="plain_leads", password="pass")
|
||||||
|
self.lead = Contact.objects.create(
|
||||||
|
name="Ada Lovelace",
|
||||||
|
email="ada@example.com",
|
||||||
|
subject="AI help",
|
||||||
|
blurb="Need forward-deployed support.",
|
||||||
|
utm_source="linkedin",
|
||||||
|
utm_medium="social",
|
||||||
|
utm_campaign="spring",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_leads_list_requires_staff(self):
|
||||||
|
url = reverse("leads_list")
|
||||||
|
self.assertEqual(self.client.get(url).status_code, 302)
|
||||||
|
|
||||||
|
self.client.login(username="plain_leads", password="pass")
|
||||||
|
self.assertEqual(self.client.get(url).status_code, 302)
|
||||||
|
|
||||||
|
self.client.login(username="staff_leads", password="pass")
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Ada Lovelace")
|
||||||
|
self.assertContains(response, "ada@example.com")
|
||||||
|
|
||||||
|
def test_lead_detail_and_toggle(self):
|
||||||
|
self.client.login(username="staff_leads", password="pass")
|
||||||
|
detail = reverse("lead_detail", kwargs={"pk": self.lead.pk})
|
||||||
|
response = self.client.get(detail)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Need forward-deployed support.")
|
||||||
|
self.assertContains(response, "linkedin")
|
||||||
|
|
||||||
|
toggle = reverse("lead_toggle_contacted", kwargs={"pk": self.lead.pk})
|
||||||
|
response = self.client.post(toggle, {"next": detail})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.lead.refresh_from_db()
|
||||||
|
self.assertTrue(self.lead.contacted)
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
"""Classify request traffic from User-Agent and related hints."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
|
||||||
|
class TrafficType(StrEnum):
|
||||||
|
HUMAN = "human"
|
||||||
|
AI_BOT = "ai_bot"
|
||||||
|
SEARCH_INDEXER = "search_indexer"
|
||||||
|
SOCIAL_BOT = "social_bot"
|
||||||
|
MONITORING = "monitoring"
|
||||||
|
OTHER_BOT = "other_bot"
|
||||||
|
UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
TRAFFIC_TYPE_LABELS = {
|
||||||
|
TrafficType.HUMAN: "Human traffic",
|
||||||
|
TrafficType.AI_BOT: "AI bot / AI search",
|
||||||
|
TrafficType.SEARCH_INDEXER: "Search indexing",
|
||||||
|
TrafficType.SOCIAL_BOT: "Social / preview bot",
|
||||||
|
TrafficType.MONITORING: "Monitoring / uptime",
|
||||||
|
TrafficType.OTHER_BOT: "Other bot",
|
||||||
|
TrafficType.UNKNOWN: "Unknown",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Order matters: first match wins.
|
||||||
|
_AI_BOT_PATTERNS = (
|
||||||
|
r"GPTBot",
|
||||||
|
r"ChatGPT-User",
|
||||||
|
r"OAI-SearchBot",
|
||||||
|
r"ClaudeBot",
|
||||||
|
r"anthropic-ai",
|
||||||
|
r"Claude-Web",
|
||||||
|
r"Google-Extended",
|
||||||
|
r"GoogleOther",
|
||||||
|
r"Google-CloudVertexBot",
|
||||||
|
r"Bytespider",
|
||||||
|
r"CCBot",
|
||||||
|
r"Diffbot",
|
||||||
|
r"FacebookBot", # Meta AI crawler (distinct from facebookexternalhit)
|
||||||
|
r"meta-externalagent",
|
||||||
|
r"Meta-ExternalAgent",
|
||||||
|
r"PerplexityBot",
|
||||||
|
r"Perplexity-User",
|
||||||
|
r"YouBot",
|
||||||
|
r"Amazonbot",
|
||||||
|
r"Applebot-Extended",
|
||||||
|
r"cohere-ai",
|
||||||
|
r"Cohere-ai",
|
||||||
|
r"AI2Bot",
|
||||||
|
r"omgili",
|
||||||
|
r"ImagesiftBot",
|
||||||
|
r"Timpibot",
|
||||||
|
r"Webzio-Extended",
|
||||||
|
r"DuckAssistBot",
|
||||||
|
r"iAskBot",
|
||||||
|
r"MistralAI-User",
|
||||||
|
r"xAI-Bot",
|
||||||
|
r"GrokBot",
|
||||||
|
)
|
||||||
|
|
||||||
|
_SEARCH_INDEXER_PATTERNS = (
|
||||||
|
r"Googlebot",
|
||||||
|
r"Googlebot-Image",
|
||||||
|
r"Googlebot-News",
|
||||||
|
r"Googlebot-Video",
|
||||||
|
r"Storebot-Google",
|
||||||
|
r"AdsBot-Google",
|
||||||
|
r"Mediapartners-Google",
|
||||||
|
r"Bingbot",
|
||||||
|
r"bingbot",
|
||||||
|
r"BingPreview",
|
||||||
|
r"adidxbot",
|
||||||
|
r"DuckDuckBot",
|
||||||
|
r"Slurp", # Yahoo
|
||||||
|
r"YandexBot",
|
||||||
|
r"YandexImages",
|
||||||
|
r"Baiduspider",
|
||||||
|
r"Sogou",
|
||||||
|
r"Applebot",
|
||||||
|
r"SeznamBot",
|
||||||
|
r"Qwantify",
|
||||||
|
r"ecosia",
|
||||||
|
r"BraveBot",
|
||||||
|
r"PetalBot",
|
||||||
|
)
|
||||||
|
|
||||||
|
_SOCIAL_BOT_PATTERNS = (
|
||||||
|
r"facebookexternalhit",
|
||||||
|
r"Facebot",
|
||||||
|
r"Twitterbot",
|
||||||
|
r"LinkedInBot",
|
||||||
|
r"Slackbot",
|
||||||
|
r"Discordbot",
|
||||||
|
r"WhatsApp",
|
||||||
|
r"TelegramBot",
|
||||||
|
r"Pinterest",
|
||||||
|
r"vkShare",
|
||||||
|
r"SkypeUriPreview",
|
||||||
|
r"redditbot",
|
||||||
|
r"Embedly",
|
||||||
|
r"Iframely",
|
||||||
|
)
|
||||||
|
|
||||||
|
_MONITORING_PATTERNS = (
|
||||||
|
r"UptimeRobot",
|
||||||
|
r"Pingdom",
|
||||||
|
r"StatusCake",
|
||||||
|
r"Site24x7",
|
||||||
|
r"Better Uptime",
|
||||||
|
r"BetterStack",
|
||||||
|
r"Healthchecks",
|
||||||
|
r"NewRelic",
|
||||||
|
r"Datadog",
|
||||||
|
r"Synthetics",
|
||||||
|
r"GhostInspector",
|
||||||
|
r"HeadlessChrome", # often synthetic monitors
|
||||||
|
)
|
||||||
|
|
||||||
|
_GENERIC_BOT_PATTERNS = (
|
||||||
|
r"bot",
|
||||||
|
r"crawler",
|
||||||
|
r"spider",
|
||||||
|
r"scraper",
|
||||||
|
r"curl/",
|
||||||
|
r"wget/",
|
||||||
|
r"python-requests",
|
||||||
|
r"Go-http-client",
|
||||||
|
r"httpx",
|
||||||
|
r"aiohttp",
|
||||||
|
r"Java/",
|
||||||
|
r"libwww",
|
||||||
|
r"scrapy",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Browser-like tokens used to avoid over-classifying humans as bots when UA contains "bot" in odd places.
|
||||||
|
_BROWSER_HINTS = (
|
||||||
|
r"Mozilla/",
|
||||||
|
r"Chrome/",
|
||||||
|
r"Safari/",
|
||||||
|
r"Firefox/",
|
||||||
|
r"Edg/",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _compile(patterns: tuple[str, ...]) -> re.Pattern[str]:
|
||||||
|
return re.compile("|".join(f"(?:{p})" for p in patterns), re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
_AI_RE = _compile(_AI_BOT_PATTERNS)
|
||||||
|
_SEARCH_RE = _compile(_SEARCH_INDEXER_PATTERNS)
|
||||||
|
_SOCIAL_RE = _compile(_SOCIAL_BOT_PATTERNS)
|
||||||
|
_MONITOR_RE = _compile(_MONITORING_PATTERNS)
|
||||||
|
_GENERIC_BOT_RE = _compile(_GENERIC_BOT_PATTERNS)
|
||||||
|
_BROWSER_RE = _compile(_BROWSER_HINTS)
|
||||||
|
|
||||||
|
|
||||||
|
def classify_user_agent(user_agent: str | None) -> TrafficType:
|
||||||
|
ua = (user_agent or "").strip()
|
||||||
|
if not ua:
|
||||||
|
return TrafficType.UNKNOWN
|
||||||
|
|
||||||
|
if _AI_RE.search(ua):
|
||||||
|
return TrafficType.AI_BOT
|
||||||
|
if _SEARCH_RE.search(ua):
|
||||||
|
return TrafficType.SEARCH_INDEXER
|
||||||
|
if _SOCIAL_RE.search(ua):
|
||||||
|
return TrafficType.SOCIAL_BOT
|
||||||
|
if _MONITOR_RE.search(ua):
|
||||||
|
return TrafficType.MONITORING
|
||||||
|
if _GENERIC_BOT_RE.search(ua):
|
||||||
|
# Some real browsers mention "bot" rarely; prefer human if clearly a browser UA
|
||||||
|
# without other bot signals already matched above.
|
||||||
|
if _BROWSER_RE.search(ua) and not re.search(
|
||||||
|
r"(?:bot|crawler|spider|scraper)", ua, re.IGNORECASE
|
||||||
|
):
|
||||||
|
return TrafficType.HUMAN
|
||||||
|
return TrafficType.OTHER_BOT
|
||||||
|
return TrafficType.HUMAN
|
||||||
|
|
||||||
|
|
||||||
|
def traffic_type_label(value: str) -> str:
|
||||||
|
try:
|
||||||
|
return TRAFFIC_TYPE_LABELS[TrafficType(value)]
|
||||||
|
except ValueError:
|
||||||
|
return value
|
||||||
@@ -18,6 +18,14 @@ urlpatterns = [
|
|||||||
path("ml_model", views.ml_model, name="ml_model"),
|
path("ml_model", views.ml_model, name="ml_model"),
|
||||||
path("contact", views.contact, name="contact"),
|
path("contact", views.contact, name="contact"),
|
||||||
path("terms", views.terms_of_service, name="terms_of_service"),
|
path("terms", views.terms_of_service, name="terms_of_service"),
|
||||||
|
path("utm", views.utm_dashboard, name="utm_dashboard"),
|
||||||
|
path("leads", views.leads_list, name="leads_list"),
|
||||||
|
path("leads/<int:pk>/", views.lead_detail, name="lead_detail"),
|
||||||
|
path(
|
||||||
|
"leads/<int:pk>/toggle-contacted/",
|
||||||
|
views.lead_toggle_contacted,
|
||||||
|
name="lead_toggle_contacted",
|
||||||
|
),
|
||||||
path("change_password", views.change_password, name="change_password"),
|
path("change_password", views.change_password, name="change_password"),
|
||||||
path("preview_email/<int:pk>/", views.preview_email, name="preview_email")
|
path("preview_email/<int:pk>/", views.preview_email, name="preview_email")
|
||||||
]
|
]
|
||||||
|
|||||||
+224
-10
@@ -1,16 +1,23 @@
|
|||||||
from django.shortcuts import render, get_object_or_404, redirect
|
from datetime import timedelta
|
||||||
|
|
||||||
from django.http import HttpResponse
|
from django.contrib.admin.views.decorators import staff_member_required
|
||||||
from .models import Contact, EmailMessage
|
|
||||||
from django.template.loader import get_template
|
|
||||||
from django.core.mail import EmailMultiAlternatives
|
|
||||||
from django.conf import settings
|
|
||||||
from django.core.mail import send_mail
|
|
||||||
from .forms import FormWithCaptcha
|
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.contrib.auth.forms import PasswordChangeForm
|
from django.contrib.auth.forms import PasswordChangeForm
|
||||||
from django.contrib.auth import update_session_auth_hash
|
from django.contrib.auth import update_session_auth_hash
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
|
from django.core.mail import EmailMultiAlternatives
|
||||||
|
from django.db.models import Count, Q
|
||||||
|
from django.shortcuts import render, get_object_or_404, redirect
|
||||||
|
from django.template.loader import get_template
|
||||||
|
from django.conf import settings
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.views.decorators.http import require_POST
|
||||||
|
|
||||||
|
from .forms import FormWithCaptcha
|
||||||
|
from .middleware import get_session_utm
|
||||||
|
from .models import Contact, EmailMessage, PageVisit
|
||||||
|
from .traffic import TRAFFIC_TYPE_LABELS, TrafficType
|
||||||
|
|
||||||
def send_contact_email(email, subject, message):
|
def send_contact_email(email, subject, message):
|
||||||
subject = "New Contact Request for AI ML Operations, LLC"
|
subject = "New Contact Request for AI ML Operations, LLC"
|
||||||
@@ -38,7 +45,23 @@ def computers(request):
|
|||||||
return render(request, "public/computers.html", {})
|
return render(request, "public/computers.html", {})
|
||||||
|
|
||||||
def web_design(request):
|
def web_design(request):
|
||||||
return render(request, "public/web_design.html", {})
|
from .web_design_pricing import (
|
||||||
|
WEB_DESIGN_INCLUDED,
|
||||||
|
WEB_DESIGN_PRICING_DISCLAIMER,
|
||||||
|
features_for_json,
|
||||||
|
estimate_web_design_cost,
|
||||||
|
)
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"public/web_design.html",
|
||||||
|
{
|
||||||
|
"pricing_features": features_for_json(),
|
||||||
|
"pricing_included": WEB_DESIGN_INCLUDED,
|
||||||
|
"pricing_disclaimer": WEB_DESIGN_PRICING_DISCLAIMER,
|
||||||
|
"pricing_base_estimate": estimate_web_design_cost(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
def ai_sensor(request):
|
def ai_sensor(request):
|
||||||
return render(request, "public/ai_sensor.html", {})
|
return render(request, "public/ai_sensor.html", {})
|
||||||
@@ -85,7 +108,18 @@ def contact(request):
|
|||||||
|
|
||||||
|
|
||||||
# then we are good
|
# then we are good
|
||||||
c = Contact(name=name, email=email, blurb=message, subject=subject)
|
utm = get_session_utm(request.session)
|
||||||
|
c = Contact(
|
||||||
|
name=name,
|
||||||
|
email=email,
|
||||||
|
blurb=message,
|
||||||
|
subject=subject,
|
||||||
|
utm_source=utm.get("utm_source", ""),
|
||||||
|
utm_medium=utm.get("utm_medium", ""),
|
||||||
|
utm_campaign=utm.get("utm_campaign", ""),
|
||||||
|
utm_term=utm.get("utm_term", ""),
|
||||||
|
utm_content=utm.get("utm_content", ""),
|
||||||
|
)
|
||||||
c.save()
|
c.save()
|
||||||
# send the email.
|
# send the email.
|
||||||
try:
|
try:
|
||||||
@@ -128,3 +162,183 @@ def change_password(request):
|
|||||||
'form': form
|
'form': form
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@staff_member_required
|
||||||
|
def utm_dashboard(request):
|
||||||
|
try:
|
||||||
|
days = int(request.GET.get("days", "30"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
days = 30
|
||||||
|
if days not in (7, 30, 90, 365):
|
||||||
|
days = 30
|
||||||
|
|
||||||
|
traffic_filter = request.GET.get("traffic", "")
|
||||||
|
since = timezone.now() - timedelta(days=days)
|
||||||
|
visits = PageVisit.objects.filter(created__gte=since)
|
||||||
|
|
||||||
|
if traffic_filter in {t.value for t in TrafficType}:
|
||||||
|
visits = visits.filter(traffic_type=traffic_filter)
|
||||||
|
|
||||||
|
type_counts_qs = (
|
||||||
|
PageVisit.objects.filter(created__gte=since)
|
||||||
|
.values("traffic_type")
|
||||||
|
.annotate(total=Count("id"))
|
||||||
|
.order_by("-total")
|
||||||
|
)
|
||||||
|
type_counts = [
|
||||||
|
{
|
||||||
|
"key": row["traffic_type"],
|
||||||
|
"label": TRAFFIC_TYPE_LABELS.get(
|
||||||
|
TrafficType(row["traffic_type"]), row["traffic_type"]
|
||||||
|
)
|
||||||
|
if row["traffic_type"] in {t.value for t in TrafficType}
|
||||||
|
else row["traffic_type"],
|
||||||
|
"total": row["total"],
|
||||||
|
}
|
||||||
|
for row in type_counts_qs
|
||||||
|
]
|
||||||
|
|
||||||
|
total_visits = PageVisit.objects.filter(created__gte=since).count()
|
||||||
|
human_count = PageVisit.objects.filter(
|
||||||
|
created__gte=since, traffic_type=TrafficType.HUMAN
|
||||||
|
).count()
|
||||||
|
ai_count = PageVisit.objects.filter(
|
||||||
|
created__gte=since, traffic_type=TrafficType.AI_BOT
|
||||||
|
).count()
|
||||||
|
indexer_count = PageVisit.objects.filter(
|
||||||
|
created__gte=since, traffic_type=TrafficType.SEARCH_INDEXER
|
||||||
|
).count()
|
||||||
|
utm_landings = PageVisit.objects.filter(
|
||||||
|
created__gte=since, is_landing=True
|
||||||
|
).count()
|
||||||
|
attributed_visits = PageVisit.objects.filter(created__gte=since).exclude(
|
||||||
|
utm_source=""
|
||||||
|
).count()
|
||||||
|
|
||||||
|
def top_utm(field: str, limit: int = 10):
|
||||||
|
return list(
|
||||||
|
PageVisit.objects.filter(created__gte=since)
|
||||||
|
.exclude(**{field: ""})
|
||||||
|
.values(field)
|
||||||
|
.annotate(total=Count("id"))
|
||||||
|
.order_by("-total")[:limit]
|
||||||
|
)
|
||||||
|
|
||||||
|
top_pages = list(
|
||||||
|
visits.values("path")
|
||||||
|
.annotate(total=Count("id"))
|
||||||
|
.order_by("-total")[:15]
|
||||||
|
)
|
||||||
|
|
||||||
|
recent = list(visits[:100])
|
||||||
|
|
||||||
|
contacts_with_utm = (
|
||||||
|
Contact.objects.filter(created__gte=since)
|
||||||
|
.exclude(utm_source="")
|
||||||
|
.order_by("-created")[:25]
|
||||||
|
)
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"public/utm_dashboard.html",
|
||||||
|
{
|
||||||
|
"days": days,
|
||||||
|
"traffic_filter": traffic_filter,
|
||||||
|
"traffic_choices": [
|
||||||
|
(t.value, TRAFFIC_TYPE_LABELS[t]) for t in TrafficType
|
||||||
|
],
|
||||||
|
"total_visits": total_visits,
|
||||||
|
"human_count": human_count,
|
||||||
|
"ai_count": ai_count,
|
||||||
|
"indexer_count": indexer_count,
|
||||||
|
"utm_landings": utm_landings,
|
||||||
|
"attributed_visits": attributed_visits,
|
||||||
|
"type_counts": type_counts,
|
||||||
|
"top_sources": top_utm("utm_source"),
|
||||||
|
"top_mediums": top_utm("utm_medium"),
|
||||||
|
"top_campaigns": top_utm("utm_campaign"),
|
||||||
|
"top_pages": top_pages,
|
||||||
|
"recent_visits": recent,
|
||||||
|
"contacts_with_utm": contacts_with_utm,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@staff_member_required
|
||||||
|
def leads_list(request):
|
||||||
|
try:
|
||||||
|
days = int(request.GET.get("days", "90"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
days = 90
|
||||||
|
if days not in (7, 30, 90, 365, 0):
|
||||||
|
days = 90
|
||||||
|
|
||||||
|
status = request.GET.get("status", "")
|
||||||
|
q = (request.GET.get("q") or "").strip()
|
||||||
|
utm_only = request.GET.get("utm") == "1"
|
||||||
|
|
||||||
|
leads = Contact.objects.all().order_by("-created")
|
||||||
|
if days:
|
||||||
|
leads = leads.filter(created__gte=timezone.now() - timedelta(days=days))
|
||||||
|
if status == "new":
|
||||||
|
leads = leads.filter(contacted=False)
|
||||||
|
elif status == "contacted":
|
||||||
|
leads = leads.filter(contacted=True)
|
||||||
|
if utm_only:
|
||||||
|
leads = leads.exclude(utm_source="")
|
||||||
|
if q:
|
||||||
|
leads = leads.filter(
|
||||||
|
Q(name__icontains=q)
|
||||||
|
| Q(email__icontains=q)
|
||||||
|
| Q(subject__icontains=q)
|
||||||
|
| Q(blurb__icontains=q)
|
||||||
|
| Q(utm_source__icontains=q)
|
||||||
|
| Q(utm_campaign__icontains=q)
|
||||||
|
)
|
||||||
|
|
||||||
|
base = Contact.objects.all()
|
||||||
|
if days:
|
||||||
|
base = base.filter(created__gte=timezone.now() - timedelta(days=days))
|
||||||
|
|
||||||
|
total = base.count()
|
||||||
|
new_count = base.filter(contacted=False).count()
|
||||||
|
contacted_count = base.filter(contacted=True).count()
|
||||||
|
with_utm = base.exclude(utm_source="").count()
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"public/leads_list.html",
|
||||||
|
{
|
||||||
|
"leads": leads[:200],
|
||||||
|
"lead_count": leads.count(),
|
||||||
|
"days": days,
|
||||||
|
"status": status,
|
||||||
|
"q": q,
|
||||||
|
"utm_only": utm_only,
|
||||||
|
"total": total,
|
||||||
|
"new_count": new_count,
|
||||||
|
"contacted_count": contacted_count,
|
||||||
|
"with_utm": with_utm,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@staff_member_required
|
||||||
|
def lead_detail(request, pk):
|
||||||
|
lead = get_object_or_404(Contact, pk=pk)
|
||||||
|
return render(request, "public/lead_detail.html", {"lead": lead})
|
||||||
|
|
||||||
|
|
||||||
|
@staff_member_required
|
||||||
|
@require_POST
|
||||||
|
def lead_toggle_contacted(request, pk):
|
||||||
|
lead = get_object_or_404(Contact, pk=pk)
|
||||||
|
lead.contacted = not lead.contacted
|
||||||
|
lead.save(update_fields=["contacted", "last_modified"])
|
||||||
|
messages.success(
|
||||||
|
request,
|
||||||
|
f"Marked {lead.name} as {'contacted' if lead.contacted else 'new'}.",
|
||||||
|
)
|
||||||
|
next_url = request.POST.get("next") or reverse("lead_detail", kwargs={"pk": lead.pk})
|
||||||
|
return redirect(next_url)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
"""Catalog pricing for the public web design cost estimator.
|
||||||
|
|
||||||
|
Draft catalog pricing — not a formal quote. Third-party usage
|
||||||
|
(Stripe fees, SMS, postage) is billed separately.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Feature ids used by the interactive UI and WebMCP estimate_web_design_cost tool.
|
||||||
|
WEB_DESIGN_FEATURES = (
|
||||||
|
{
|
||||||
|
"id": "public_site",
|
||||||
|
"name": "Public site",
|
||||||
|
"description": "Landing, about, contact (+ your service pages).",
|
||||||
|
"build": 300,
|
||||||
|
"monthly": 10,
|
||||||
|
"required": True,
|
||||||
|
"requires": (),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "client_portal",
|
||||||
|
"name": "Client portal + UTM",
|
||||||
|
"description": "Login, dashboard, leads, UTM analytics — every client.",
|
||||||
|
"build": 300,
|
||||||
|
"monthly": 30,
|
||||||
|
"required": True,
|
||||||
|
"requires": (),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "email_sms",
|
||||||
|
"name": "Email & SMS",
|
||||||
|
"description": "Campaigns, mailing list, engagement reports.",
|
||||||
|
"build": 500,
|
||||||
|
"monthly": 10,
|
||||||
|
"required": False,
|
||||||
|
"requires": (),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "direct_mail",
|
||||||
|
"name": "Direct mail",
|
||||||
|
"description": "Postcard designer + print/send. Postage separate.",
|
||||||
|
"build": 500,
|
||||||
|
"monthly": 5,
|
||||||
|
"required": False,
|
||||||
|
"requires": (),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "blog",
|
||||||
|
"name": "Blog",
|
||||||
|
"description": "Public blog + portal post management.",
|
||||||
|
"build": 500,
|
||||||
|
"monthly": 10,
|
||||||
|
"required": False,
|
||||||
|
"requires": (),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "payments",
|
||||||
|
"name": "Payments (Stripe)",
|
||||||
|
"description": "Invoices + pay links. Requires Email & SMS.",
|
||||||
|
"build": 500,
|
||||||
|
"monthly": 20,
|
||||||
|
"required": False,
|
||||||
|
"requires": ("email_sms",),
|
||||||
|
"requires_note": "Requires Email & SMS — auto-selected.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "social",
|
||||||
|
"name": "Social consolidation",
|
||||||
|
"description": "Accounts, composer, scheduling.",
|
||||||
|
"build": 500,
|
||||||
|
"monthly": 20,
|
||||||
|
"required": False,
|
||||||
|
"requires": (),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ai_social",
|
||||||
|
"name": "AI social generator",
|
||||||
|
"description": "Ollama drafts. Requires Social consolidation.",
|
||||||
|
"build": 300,
|
||||||
|
"monthly": 20,
|
||||||
|
"required": False,
|
||||||
|
"requires": ("social",),
|
||||||
|
"requires_note": "Requires Social consolidation — auto-selected.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
WEB_DESIGN_INCLUDED = (
|
||||||
|
{
|
||||||
|
"title": "Tailored to your brand",
|
||||||
|
"description": "Custom design suited to your business — layout, visuals, and flows built around how your customers actually use the site.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "You own the site & data",
|
||||||
|
"description": "The site and your data belong to you. Leave anytime and take everything with you — no lock-in.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Three-instance hosting",
|
||||||
|
"description": "Every site runs on at least three instances for higher reliability and scale.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "UTM tracking & lead capture",
|
||||||
|
"description": "UTM tracking and lead capture/analysis ship with every client portal.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "SEO, accessibility & LLM-ready",
|
||||||
|
"description": "Optimized SEO, accessibility, performance, and LLM integration on every build.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Grafana metrics & alerts",
|
||||||
|
"description": "Live dashboards with Grafana metrics and proactive alerts on uptime and performance.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
WEB_DESIGN_PRICING_DISCLAIMER = (
|
||||||
|
"Draft catalog pricing. Not a formal quote. "
|
||||||
|
"Third-party usage (Stripe fees, SMS, postage) not included."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_feature_by_id(feature_id):
|
||||||
|
for feature in WEB_DESIGN_FEATURES:
|
||||||
|
if feature["id"] == feature_id:
|
||||||
|
return feature
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_selected_features(selected_ids):
|
||||||
|
"""Expand required bases + dependency chains; return ordered unique ids."""
|
||||||
|
catalog = {f["id"]: f for f in WEB_DESIGN_FEATURES}
|
||||||
|
selected = set(selected_ids or [])
|
||||||
|
|
||||||
|
for feature in WEB_DESIGN_FEATURES:
|
||||||
|
if feature["required"]:
|
||||||
|
selected.add(feature["id"])
|
||||||
|
|
||||||
|
changed = True
|
||||||
|
while changed:
|
||||||
|
changed = False
|
||||||
|
for feature_id in list(selected):
|
||||||
|
feature = catalog.get(feature_id)
|
||||||
|
if not feature:
|
||||||
|
continue
|
||||||
|
for dep in feature.get("requires", ()):
|
||||||
|
if dep not in selected:
|
||||||
|
selected.add(dep)
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
return [f["id"] for f in WEB_DESIGN_FEATURES if f["id"] in selected]
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_web_design_cost(selected_ids=None):
|
||||||
|
"""Return build/monthly totals for a feature selection."""
|
||||||
|
resolved = resolve_selected_features(selected_ids)
|
||||||
|
catalog = {f["id"]: f for f in WEB_DESIGN_FEATURES}
|
||||||
|
features = [catalog[fid] for fid in resolved]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"selected": [
|
||||||
|
{
|
||||||
|
"id": f["id"],
|
||||||
|
"name": f["name"],
|
||||||
|
"build": f["build"],
|
||||||
|
"monthly": f["monthly"],
|
||||||
|
}
|
||||||
|
for f in features
|
||||||
|
],
|
||||||
|
"selected_count": len(features),
|
||||||
|
"one_time_build": sum(f["build"] for f in features),
|
||||||
|
"monthly": sum(f["monthly"] for f in features),
|
||||||
|
"disclaimer": WEB_DESIGN_PRICING_DISCLAIMER,
|
||||||
|
"included_with_every_site": [
|
||||||
|
{"title": item["title"], "description": item["description"]}
|
||||||
|
for item in WEB_DESIGN_INCLUDED
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def features_for_json():
|
||||||
|
"""JSON-serializable feature list for templates and WebMCP config."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": f["id"],
|
||||||
|
"name": f["name"],
|
||||||
|
"description": f["description"],
|
||||||
|
"build": f["build"],
|
||||||
|
"monthly": f["monthly"],
|
||||||
|
"required": f["required"],
|
||||||
|
"requires": list(f.get("requires", ())),
|
||||||
|
"requires_note": f.get("requires_note", ""),
|
||||||
|
}
|
||||||
|
for f in WEB_DESIGN_FEATURES
|
||||||
|
]
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${DB_NAME}
|
||||||
|
POSTGRES_USER: ${DB_USER}
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
web:
|
||||||
|
build: .
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${WEB_PORT:-8000}:8000"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: company_site
|
||||||
|
POSTGRES_USER: company_site
|
||||||
|
POSTGRES_PASSWORD: company_site
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U company_site -d company_site"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
web:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
# No required env_file — CI has no .env. Defaults below; for local secrets:
|
||||||
|
# docker compose --env-file .env up
|
||||||
|
environment:
|
||||||
|
DJANGO_ENV: ${DJANGO_ENV:-dev}
|
||||||
|
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-dev-only-change-me}
|
||||||
|
DJANGO_DEBUG: ${DJANGO_DEBUG:-true}
|
||||||
|
DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0}
|
||||||
|
DATABASE_URL: ${DATABASE_URL:-postgres://company_site:company_site@db:5432/company_site}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
[project]
|
||||||
|
name = "company-site"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Django site for AIML Operations"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"django==5.0",
|
||||||
|
"django-enum==2.1.0",
|
||||||
|
"django-phonenumber-field==8.0.0",
|
||||||
|
"django-recaptcha==4.0.0",
|
||||||
|
"gunicorn==23.0.0",
|
||||||
|
"phonenumbers==9.0.0",
|
||||||
|
"psycopg2-binary==2.9.10",
|
||||||
|
"python-dateutil==2.9.0.post0",
|
||||||
|
"typing-extensions==4.8.0",
|
||||||
|
"whitenoise==6.9.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"pre-commit==4.1.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.uv]
|
||||||
|
package = false
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
asgiref==3.7.2
|
|
||||||
cfgv==3.4.0
|
|
||||||
distlib==0.3.9
|
|
||||||
Django==5.0
|
|
||||||
django-enum==2.1.0
|
|
||||||
django-phonenumber-field==8.0.0
|
|
||||||
django-recaptcha==4.0.0
|
|
||||||
python-dateutil==2.9.0.post0
|
|
||||||
gunicorn==23.0.0
|
|
||||||
psycopg2-binary==2.9.10
|
|
||||||
filelock==3.17.0
|
|
||||||
identify==2.6.9
|
|
||||||
nodeenv==1.9.1
|
|
||||||
phonenumbers==9.0.0
|
|
||||||
platformdirs==4.3.6
|
|
||||||
pre_commit==4.1.0
|
|
||||||
PyYAML==6.0.2
|
|
||||||
sqlparse==0.4.4
|
|
||||||
typing_extensions==4.8.0
|
|
||||||
virtualenv==20.29.3
|
|
||||||
+54
-12
@@ -2,10 +2,9 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
REPO_ROOT="/home/westfarn/Documents/django_live_sites/Company_Site"
|
REPO_ROOT="/home/westfarn/Documents/django_live_sites/Company_Site"
|
||||||
APP_DIR="$REPO_ROOT/company_site"
|
ENV_FILE="$REPO_ROOT/.env"
|
||||||
VENV="$REPO_ROOT/venv"
|
COMPOSE_FILE="$REPO_ROOT/docker-compose.prod.yml"
|
||||||
SETTINGS="$APP_DIR/company_site/settings.py"
|
VALIDATE_SCRIPT="$REPO_ROOT/scripts/validate-env.sh"
|
||||||
LOCAL_SETTINGS="$APP_DIR/company_site/local_settings.py"
|
|
||||||
|
|
||||||
if [[ $# -lt 1 || -z "${1:-}" ]]; then
|
if [[ $# -lt 1 || -z "${1:-}" ]]; then
|
||||||
echo "Usage: $0 <checkout-directory>" >&2
|
echo "Usage: $0 <checkout-directory>" >&2
|
||||||
@@ -13,24 +12,67 @@ if [[ $# -lt 1 || -z "${1:-}" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
CHECKOUT="$1"
|
CHECKOUT="$1"
|
||||||
|
DEPLOY_MODE="${DEPLOY_MODE:-docker}"
|
||||||
|
|
||||||
cp "$SETTINGS" /tmp/company_site_settings.py.bak
|
if [[ "$DEPLOY_MODE" == "docker" ]]; then
|
||||||
|
echo "Syncing application files to $REPO_ROOT (preserving server .env)..."
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude .git/ \
|
||||||
|
--exclude .venv/ \
|
||||||
|
--exclude .env \
|
||||||
|
--exclude db.sqlite3 \
|
||||||
|
--exclude staticfiles/ \
|
||||||
|
"$CHECKOUT/" "$REPO_ROOT/"
|
||||||
|
|
||||||
|
if [[ ! -f "$ENV_FILE" ]]; then
|
||||||
|
echo "Missing $ENV_FILE on the server." >&2
|
||||||
|
echo "Create it from .env.prod.example and configure production values." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
bash "$VALIDATE_SCRIPT" "$ENV_FILE"
|
||||||
|
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
echo "Building Docker images..."
|
||||||
|
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" build
|
||||||
|
|
||||||
|
echo "Starting containers..."
|
||||||
|
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up -d --remove-orphans
|
||||||
|
|
||||||
|
echo "Running database migrations..."
|
||||||
|
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" exec -T web \
|
||||||
|
uv run python manage.py migrate --noinput
|
||||||
|
|
||||||
|
echo "Deploy complete."
|
||||||
|
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" ps
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Legacy venv/systemd deploy (DEPLOY_MODE=legacy)
|
||||||
|
APP_DIR="$REPO_ROOT/company_site"
|
||||||
|
VENV="$REPO_ROOT/.venv"
|
||||||
|
SETTINGS_DIR="$APP_DIR/company_site/settings"
|
||||||
|
LOCAL_SETTINGS="$APP_DIR/company_site/local_settings.py"
|
||||||
|
|
||||||
|
cp -r "$SETTINGS_DIR" /tmp/company_site_settings.bak
|
||||||
cp "$LOCAL_SETTINGS" /tmp/company_site_local_settings.py.bak 2>/dev/null || true
|
cp "$LOCAL_SETTINGS" /tmp/company_site_local_settings.py.bak 2>/dev/null || true
|
||||||
|
|
||||||
rsync -a --delete \
|
rsync -a --delete \
|
||||||
--exclude venv/ \
|
--exclude .venv/ \
|
||||||
--exclude .git/ \
|
--exclude .git/ \
|
||||||
--exclude 'company_site/company_site/settings.py' \
|
--exclude .env \
|
||||||
|
--exclude 'company_site/company_site/settings/' \
|
||||||
--exclude 'company_site/company_site/local_settings.py' \
|
--exclude 'company_site/company_site/local_settings.py' \
|
||||||
"$CHECKOUT/" "$REPO_ROOT/"
|
"$CHECKOUT/" "$REPO_ROOT/"
|
||||||
|
|
||||||
cp /tmp/company_site_settings.py.bak "$SETTINGS"
|
rm -rf "$SETTINGS_DIR"
|
||||||
|
cp -r /tmp/company_site_settings.bak "$SETTINGS_DIR"
|
||||||
cp /tmp/company_site_local_settings.py.bak "$LOCAL_SETTINGS" 2>/dev/null || true
|
cp /tmp/company_site_local_settings.py.bak "$LOCAL_SETTINGS" 2>/dev/null || true
|
||||||
|
|
||||||
source "$VENV/bin/activate"
|
uv sync --frozen --directory "$REPO_ROOT"
|
||||||
pip install -r "$REPO_ROOT/requirements.txt"
|
|
||||||
cd "$APP_DIR"
|
cd "$APP_DIR"
|
||||||
python manage.py migrate --noinput
|
uv run python manage.py migrate --noinput
|
||||||
python manage.py collectstatic --noinput
|
uv run python manage.py collectstatic --noinput
|
||||||
|
|
||||||
sudo systemctl restart company
|
sudo systemctl restart company
|
||||||
|
|||||||
Executable
+48
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd /app/company_site
|
||||||
|
|
||||||
|
wait_for_database() {
|
||||||
|
if [[ -z "${DATABASE_URL:-}" && -z "${DB_HOST:-}" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Waiting for database..."
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
if uv run python - <<'PY'
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "company_site.settings")
|
||||||
|
|
||||||
|
import django
|
||||||
|
from django.db import connections
|
||||||
|
from django.db.utils import OperationalError
|
||||||
|
|
||||||
|
django.setup()
|
||||||
|
|
||||||
|
try:
|
||||||
|
connections["default"].ensure_connection()
|
||||||
|
except OperationalError:
|
||||||
|
sys.exit(1)
|
||||||
|
PY
|
||||||
|
then
|
||||||
|
echo "Database is ready."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Database did not become ready in time." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_database
|
||||||
|
|
||||||
|
uv run python manage.py migrate --noinput
|
||||||
|
uv run python manage.py collectstatic --noinput
|
||||||
|
|
||||||
|
exec uv run gunicorn company_site.wsgi:application \
|
||||||
|
--bind "${GUNICORN_BIND:-0.0.0.0:8000}" \
|
||||||
|
--workers "${GUNICORN_WORKERS:-2}"
|
||||||
Executable
+59
@@ -0,0 +1,59 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ENV_FILE="${1:?Usage: $0 <env-file>}"
|
||||||
|
|
||||||
|
if [[ ! -f "$ENV_FILE" ]]; then
|
||||||
|
echo "Environment file not found: $ENV_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "$ENV_FILE"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
DJANGO_ENV="${DJANGO_ENV:-prod}"
|
||||||
|
|
||||||
|
required_vars=(
|
||||||
|
DJANGO_SECRET_KEY
|
||||||
|
DJANGO_ALLOWED_HOSTS
|
||||||
|
DB_PASSWORD
|
||||||
|
DB_NAME
|
||||||
|
DB_USER
|
||||||
|
DATABASE_URL
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ "$DJANGO_ENV" == "prod" || "$DJANGO_ENV" == "beta" ]]; then
|
||||||
|
required_vars+=(
|
||||||
|
RECAPTCHA_PUBLIC_KEY
|
||||||
|
RECAPTCHA_PRIVATE_KEY
|
||||||
|
EMAIL_HOST_USER
|
||||||
|
EMAIL_HOST_PASSWORD
|
||||||
|
)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$DJANGO_ENV" == "prod" ]]; then
|
||||||
|
required_vars+=(TIANJI_WEBSITE_ID)
|
||||||
|
fi
|
||||||
|
|
||||||
|
missing=()
|
||||||
|
for var in "${required_vars[@]}"; do
|
||||||
|
if [[ -z "${!var:-}" ]]; then
|
||||||
|
missing+=("$var")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if ((${#missing[@]} > 0)); then
|
||||||
|
echo "Missing required environment variables in $ENV_FILE:" >&2
|
||||||
|
printf ' - %s\n' "${missing[@]}" >&2
|
||||||
|
echo "Copy .env.prod.example to .env on the server and set production values." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$DJANGO_ENV" == "prod" && "$DJANGO_SECRET_KEY" == change-me* ]]; then
|
||||||
|
echo "DJANGO_SECRET_KEY must be changed from the example value for production." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Environment validation passed (DJANGO_ENV=$DJANGO_ENV)."
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "asgiref"
|
||||||
|
version = "3.11.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfgv"
|
||||||
|
version = "3.5.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "company-site"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { virtual = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "django" },
|
||||||
|
{ name = "django-enum" },
|
||||||
|
{ name = "django-phonenumber-field" },
|
||||||
|
{ name = "django-recaptcha" },
|
||||||
|
{ name = "gunicorn" },
|
||||||
|
{ name = "phonenumbers" },
|
||||||
|
{ name = "psycopg2-binary" },
|
||||||
|
{ name = "python-dateutil" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
{ name = "whitenoise" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dev-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "pre-commit" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "django", specifier = "==5.0" },
|
||||||
|
{ name = "django-enum", specifier = "==2.1.0" },
|
||||||
|
{ name = "django-phonenumber-field", specifier = "==8.0.0" },
|
||||||
|
{ name = "django-recaptcha", specifier = "==4.0.0" },
|
||||||
|
{ name = "gunicorn", specifier = "==23.0.0" },
|
||||||
|
{ name = "phonenumbers", specifier = "==9.0.0" },
|
||||||
|
{ name = "psycopg2-binary", specifier = "==2.9.10" },
|
||||||
|
{ name = "python-dateutil", specifier = "==2.9.0.post0" },
|
||||||
|
{ name = "typing-extensions", specifier = "==4.8.0" },
|
||||||
|
{ name = "whitenoise", specifier = "==6.9.0" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata.requires-dev]
|
||||||
|
dev = [{ name = "pre-commit", specifier = "==4.1.0" }]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "distlib"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "django"
|
||||||
|
version = "5.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "asgiref" },
|
||||||
|
{ name = "sqlparse" },
|
||||||
|
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/be/a6/46e250737d46e955e048f6bbc2948fb22f0de3f3ab828d3803070dc1260e/Django-5.0.tar.gz", hash = "sha256:7d29e14dfbc19cb6a95a4bd669edbde11f5d4c6a71fdaa42c2d40b6846e807f7", size = 10585390, upload-time = "2023-12-04T13:12:50.251Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/c7/61b02c0ef9e129080a8c2bffefb3cb2b9ddddece4c44dc473c1c4f0647c1/Django-5.0-py3-none-any.whl", hash = "sha256:3a9fd52b8dbeae335ddf4a9dfa6c6a0853a1122f1fb071a8d5eca979f73a05c8", size = 8136382, upload-time = "2023-12-04T13:12:41.502Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "django-enum"
|
||||||
|
version = "2.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "django" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b1/75/082f9524e644ceee38162151938db09b41fa1e3e1c57645c4632c567b446/django_enum-2.1.0.tar.gz", hash = "sha256:e3b478608f1bfedde9a3da215e00419ddecd939e99a07b32ea17a310aff509c0", size = 506766, upload-time = "2025-02-25T02:28:28.631Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4c/23/960bbafd0ab867b40e162b608d3446c49c4c6324ee2d7f42a27e5f05a292/django_enum-2.1.0-py3-none-any.whl", hash = "sha256:a71f6417598e1e5dd7385a6a9b9e5b6e15dfdb8fc37fe40d09b7f928f4b72312", size = 29159, upload-time = "2025-02-25T02:28:26.164Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "django-phonenumber-field"
|
||||||
|
version = "8.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "django" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/8f/d1/0a2ba41434d98ac9a2669ea7edfc8d1bc75055da450d94894c597913e01c/django_phonenumber_field-8.0.0.tar.gz", hash = "sha256:8a560fe1b01b94c9de8cde22bc373b695f023cc6df4baba00264cb079da9f631", size = 43759, upload-time = "2024-06-24T13:49:13.649Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/ab/4738a40d6f297688ecedd08cf6dc1608ad06c08ba3c03d1fc85b19ac6717/django_phonenumber_field-8.0.0-py3-none-any.whl", hash = "sha256:196c917b70c01a98e327f482eb8a4a4a55a29891db551f99078585397370b3ba", size = 66441, upload-time = "2024-06-24T13:49:11.65Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "django-recaptcha"
|
||||||
|
version = "4.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "django" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d4/6b/6edf89da076b2d1ea042e14f116de80be18d25b17af158038d5fc14c00bb/django-recaptcha-4.0.0.tar.gz", hash = "sha256:5316438f97700c431d65351470d1255047e3f2cd9af0f2f13592b637dad9213e", size = 22907, upload-time = "2023-11-16T15:29:08.601Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/28/d7/09cefb2b4a7dc9ed8a6aabb176ea86eb904a8f73671358436e4b0aa81b93/django_recaptcha-4.0.0-py3-none-any.whl", hash = "sha256:0d912d5c7c009df4e47accd25029133d47a74342dbd2a8edc2877b6bffa971a3", size = 21915, upload-time = "2023-11-16T15:29:06.79Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "filelock"
|
||||||
|
version = "3.29.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e6/dc/be6cbe99670cd6e4ad387123647cb08e0c32975e223f82551e914c5568a6/filelock-3.29.4.tar.gz", hash = "sha256:10cdb3656fc44541cdf30652a93fb10ec6b05325620eb316bd26893e4201538a", size = 63028, upload-time = "2026-06-13T16:12:00.744Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/13/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-py3-none-any.whl", hash = "sha256:dac1648087d5115554850d113e7dd8c83ab2d38e3435dde2d4f163847e57b767", size = 42757, upload-time = "2026-06-13T16:11:59.582Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gunicorn"
|
||||||
|
version = "23.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "packaging" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029, upload-time = "2024-08-10T20:25:24.996Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "identify"
|
||||||
|
version = "2.6.19"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nodeenv"
|
||||||
|
version = "1.10.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "packaging"
|
||||||
|
version = "26.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phonenumbers"
|
||||||
|
version = "9.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/88/96/58ab3aa4f8695c85f5dce60c15bb3b113856f420d4f0575f6b6e92c1acb5/phonenumbers-9.0.0.tar.gz", hash = "sha256:094a6f728e3c2b1906df4494a480743a3c797320f721f2b53f1400fd4d8ed5f5", size = 2296775, upload-time = "2025-03-04T12:38:43.858Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/82/943ce12a9db8932be3a1baff7cbc93524262942f2c41678242f410c8f420/phonenumbers-9.0.0-py2.py3-none-any.whl", hash = "sha256:f566eddf6219d9af9b4aad454ba411a1df565d13b875a490fd33d1d202c1148d", size = 2582416, upload-time = "2025-03-04T12:38:41.974Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "platformdirs"
|
||||||
|
version = "4.10.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pre-commit"
|
||||||
|
version = "4.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "cfgv" },
|
||||||
|
{ name = "identify" },
|
||||||
|
{ name = "nodeenv" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
|
{ name = "virtualenv" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/2a/13/b62d075317d8686071eb843f0bb1f195eb332f48869d3c31a4c6f1e063ac/pre_commit-4.1.0.tar.gz", hash = "sha256:ae3f018575a588e30dfddfab9a05448bfbd6b73d78709617b5a2b853549716d4", size = 193330, upload-time = "2025-01-20T18:31:48.681Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/b3/df14c580d82b9627d173ceea305ba898dca135feb360b6d84019d0803d3b/pre_commit-4.1.0-py2.py3-none-any.whl", hash = "sha256:d29e7cb346295bcc1cc75fc3e92e343495e3ea0196c9ec6ba53f49f10ab6ae7b", size = 220560, upload-time = "2025-01-20T18:31:47.319Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "psycopg2-binary"
|
||||||
|
version = "2.9.10"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/cb/0e/bdc8274dc0585090b4e3432267d7be4dfbfd8971c0fa59167c711105a6bf/psycopg2-binary-2.9.10.tar.gz", hash = "sha256:4b3df0e6990aa98acda57d983942eff13d824135fe2250e6522edaa782a06de2", size = 385764, upload-time = "2024-10-16T11:24:58.126Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/7d/465cc9795cf76f6d329efdafca74693714556ea3891813701ac1fee87545/psycopg2_binary-2.9.10-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:880845dfe1f85d9d5f7c412efea7a08946a46894537e4e5d091732eb1d34d9a0", size = 3044771, upload-time = "2024-10-16T11:20:35.234Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/31/6d225b7b641a1a2148e3ed65e1aa74fc86ba3fee850545e27be9e1de893d/psycopg2_binary-2.9.10-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9440fa522a79356aaa482aa4ba500b65f28e5d0e63b801abf6aa152a29bd842a", size = 3275336, upload-time = "2024-10-16T11:20:38.742Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/b7/a68c2b4bff1cbb1728e3ec864b2d92327c77ad52edcd27922535a8366f68/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3923c1d9870c49a2d44f795df0c889a22380d36ef92440ff618ec315757e539", size = 2851637, upload-time = "2024-10-16T11:20:42.145Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/b1/cfedc0e0e6f9ad61f8657fd173b2f831ce261c02a08c0b09c652b127d813/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b2c956c028ea5de47ff3a8d6b3cc3330ab45cf0b7c3da35a2d6ff8420896526", size = 3082097, upload-time = "2024-10-16T11:20:46.185Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/ed/0a8e4153c9b769f59c02fb5e7914f20f0b2483a19dae7bf2db54b743d0d0/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f758ed67cab30b9a8d2833609513ce4d3bd027641673d4ebc9c067e4d208eec1", size = 3264776, upload-time = "2024-10-16T11:20:50.879Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/db/d09da68c6a0cdab41566b74e0a6068a425f077169bed0946559b7348ebe9/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cd9b4f2cfab88ed4a9106192de509464b75a906462fb846b936eabe45c2063e", size = 3020968, upload-time = "2024-10-16T11:20:56.819Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/28/4d6f8c255f0dfffb410db2b3f9ac5218d959a66c715c34cac31081e19b95/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dc08420625b5a20b53551c50deae6e231e6371194fa0651dbe0fb206452ae1f", size = 2872334, upload-time = "2024-10-16T11:21:02.411Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/f7/20d7bf796593c4fea95e12119d6cc384ff1f6141a24fbb7df5a668d29d29/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d7cd730dfa7c36dbe8724426bf5612798734bff2d3c3857f36f2733f5bfc7c00", size = 2822722, upload-time = "2024-10-16T11:21:09.01Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/e4/0c407ae919ef626dbdb32835a03b6737013c3cc7240169843965cada2bdf/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:155e69561d54d02b3c3209545fb08938e27889ff5a10c19de8d23eb5a41be8a5", size = 2920132, upload-time = "2024-10-16T11:21:16.339Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/70/aa69c9f69cf09a01da224909ff6ce8b68faeef476f00f7ec377e8f03be70/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3cc28a6fd5a4a26224007712e79b81dbaee2ffb90ff406256158ec4d7b52b47", size = 2959312, upload-time = "2024-10-16T11:21:25.584Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/bd/213e59854fafe87ba47814bf413ace0dcee33a89c8c8c814faca6bc7cf3c/psycopg2_binary-2.9.10-cp312-cp312-win32.whl", hash = "sha256:ec8a77f521a17506a24a5f626cb2aee7850f9b69a0afe704586f63a464f3cd64", size = 1025191, upload-time = "2024-10-16T11:21:29.912Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/29/06261ea000e2dc1e22907dbbc483a1093665509ea586b29b8986a0e56733/psycopg2_binary-2.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:18c5ee682b9c6dd3696dad6e54cc7ff3a1a9020df6a5c0f861ef8bfd338c3ca0", size = 1164031, upload-time = "2024-10-16T11:21:34.211Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/30/d41d3ba765609c0763505d565c4d12d8f3c79793f0d0f044ff5a28bf395b/psycopg2_binary-2.9.10-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:26540d4a9a4e2b096f1ff9cce51253d0504dca5a85872c7f7be23be5a53eb18d", size = 3044699, upload-time = "2024-10-16T11:21:42.841Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/44/257ddadec7ef04536ba71af6bc6a75ec05c5343004a7ec93006bee66c0bc/psycopg2_binary-2.9.10-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e217ce4d37667df0bc1c397fdcd8de5e81018ef305aed9415c3b093faaeb10fb", size = 3275245, upload-time = "2024-10-16T11:21:51.989Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/11/48ea1cd11de67f9efd7262085588790a95d9dfcd9b8a687d46caf7305c1a/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:245159e7ab20a71d989da00f280ca57da7641fa2cdcf71749c193cea540a74f7", size = 2851631, upload-time = "2024-10-16T11:21:57.584Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/e0/62ce5ee650e6c86719d621a761fe4bc846ab9eff8c1f12b1ed5741bf1c9b/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c4ded1a24b20021ebe677b7b08ad10bf09aac197d6943bfe6fec70ac4e4690d", size = 3082140, upload-time = "2024-10-16T11:22:02.005Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/ce/63f946c098611f7be234c0dd7cb1ad68b0b5744d34f68062bb3c5aa510c8/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3abb691ff9e57d4a93355f60d4f4c1dd2d68326c968e7db17ea96df3c023ef73", size = 3264762, upload-time = "2024-10-16T11:22:06.412Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/25/c603cd81402e69edf7daa59b1602bd41eb9859e2824b8c0855d748366ac9/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8608c078134f0b3cbd9f89b34bd60a943b23fd33cc5f065e8d5f840061bd0673", size = 3020967, upload-time = "2024-10-16T11:22:11.583Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/d6/8708d8c6fca531057fa170cdde8df870e8b6a9b136e82b361c65e42b841e/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:230eeae2d71594103cd5b93fd29d1ace6420d0b86f4778739cb1a5a32f607d1f", size = 2872326, upload-time = "2024-10-16T11:22:16.406Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/ac/5b1ea50fc08a9df82de7e1771537557f07c2632231bbab652c7e22597908/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bb89f0a835bcfc1d42ccd5f41f04870c1b936d8507c6df12b7737febc40f0909", size = 2822712, upload-time = "2024-10-16T11:22:21.366Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/fc/504d4503b2abc4570fac3ca56eb8fed5e437bf9c9ef13f36b6621db8ef00/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f0c2d907a1e102526dd2986df638343388b94c33860ff3bbe1384130828714b1", size = 2920155, upload-time = "2024-10-16T11:22:25.684Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/d1/323581e9273ad2c0dbd1902f3fb50c441da86e894b6e25a73c3fda32c57e/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8157bed2f51db683f31306aa497311b560f2265998122abe1dce6428bd86567", size = 2959356, upload-time = "2024-10-16T11:22:30.562Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/08/50/d13ea0a054189ae1bc21af1d85b6f8bb9bbc5572991055d70ad9006fe2d6/psycopg2_binary-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:27422aa5f11fbcd9b18da48373eb67081243662f9b46e6fd07c3eb46e4535142", size = 2569224, upload-time = "2025-01-04T20:09:19.234Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-dateutil"
|
||||||
|
version = "2.9.0.post0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "six" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-discovery"
|
||||||
|
version = "1.4.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "filelock" },
|
||||||
|
{ name = "platformdirs" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/0b/1a/cbbaf13b730abb0a16b964d984e19f2fe520c21a4dc664051359a3f5a9e7/python_discovery-1.4.2.tar.gz", hash = "sha256:8f3746c4b4968d22afbb97d36e1a0e5b66e6c0f297290f2e95f05b9b8bf18690", size = 70277, upload-time = "2026-06-11T16:10:42.383Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/82/a70006589557f267f15bd384c0642ad49f0d97b690c3a05b166b9dcbad3b/python_discovery-1.4.2-py3-none-any.whl", hash = "sha256:475803f53b7b2ed6e490e27373f9d8340f7d2eebf9acdaf645d7d714c97bb500", size = 33886, upload-time = "2026-06-11T16:10:41.192Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyyaml"
|
||||||
|
version = "6.0.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "six"
|
||||||
|
version = "1.17.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sqlparse"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-extensions"
|
||||||
|
version = "4.8.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1f/7a/8b94bb016069caa12fc9f587b28080ac33b4fbb8ca369b98bc0a4828543e/typing_extensions-4.8.0.tar.gz", hash = "sha256:df8e4339e9cb77357558cbdbceca33c303714cf861d1eef15e1070055ae8b7ef", size = 71456, upload-time = "2023-09-18T04:01:56.846Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/21/7d397a4b7934ff4028987914ac1044d3b7d52712f30e2ac7a2ae5bc86dd0/typing_extensions-4.8.0-py3-none-any.whl", hash = "sha256:8f92fc8806f9a6b641eaa5318da32b44d401efaac0f6678c9bc448ba3605faa0", size = 31584, upload-time = "2023-09-18T04:01:55.398Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tzdata"
|
||||||
|
version = "2026.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "virtualenv"
|
||||||
|
version = "21.5.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "distlib" },
|
||||||
|
{ name = "filelock" },
|
||||||
|
{ name = "platformdirs" },
|
||||||
|
{ name = "python-discovery" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f1/a5/81f987504738e6defeed61ec1c47e2aefab3c35d8eeb87e1b3f38cf28254/virtualenv-21.5.1.tar.gz", hash = "sha256:dca3bf98275a59c652b69d68e73433e597d977c2da9198882479d1a7188009c8", size = 4578798, upload-time = "2026-06-16T16:23:58.603Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/02/3623e6169bed617ed1e2d372f7c69f92ec28d54c4dfc997055c8578ec148/virtualenv-21.5.1-py3-none-any.whl", hash = "sha256:55aa670b67bbfb991b03fda39bd3276d92c419d702376e98c5df1c9989a26783", size = 4558820, upload-time = "2026-06-16T16:23:56.963Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "whitenoise"
|
||||||
|
version = "6.9.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b9/cf/c15c2f21aee6b22a9f6fc9be3f7e477e2442ec22848273db7f4eb73d6162/whitenoise-6.9.0.tar.gz", hash = "sha256:8c4a7c9d384694990c26f3047e118c691557481d624f069b7f7752a2f735d609", size = 25920, upload-time = "2025-02-06T22:16:34.957Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/64/b2/2ce9263149fbde9701d352bda24ea1362c154e196d2fda2201f18fc585d7/whitenoise-6.9.0-py3-none-any.whl", hash = "sha256:c8a489049b7ee9889617bb4c274a153f3d979e8f51d2efd0f5b403caf41c57df", size = 20161, upload-time = "2025-02-06T22:16:32.589Z" },
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user