Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03bb01664f | ||
|
|
b98f504afc | ||
|
|
05aa0b96b1 | ||
|
|
5fa61d02e8 | ||
|
|
139f375f73 | ||
|
|
c97bd16445 | ||
|
|
8ccf17655d | ||
|
|
04d842d799 | ||
|
|
ae0f8a8bc5 | ||
|
|
16dfb3faae | ||
|
|
a5fa08d4a0 | ||
|
|
9a383c0ee9 | ||
|
|
6f97f6084d | ||
|
|
426cc82f04 | ||
|
|
7dd5ec3be1 | ||
|
|
2fb5204614 | ||
|
|
bb5aa6de82 | ||
|
|
7c9aba7e6c | ||
|
|
b5ab8eb512 | ||
|
|
846f1b8a37 |
@@ -0,0 +1,11 @@
|
|||||||
|
.git
|
||||||
|
.venv
|
||||||
|
**/__pycache__
|
||||||
|
*.py[cod]
|
||||||
|
db.sqlite3
|
||||||
|
.env
|
||||||
|
htmlcov/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
*.log
|
||||||
|
staticfiles/
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# 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>
|
||||||
|
# Absolute origin for email logo / footer links
|
||||||
|
PUBLIC_SITE_URL=https://aimloperations.com
|
||||||
|
# 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,56 @@
|
|||||||
|
# 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>
|
||||||
|
PUBLIC_SITE_URL=https://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,33 +1,45 @@
|
|||||||
name: Deploy Company Site
|
name: Deploy Company Site
|
||||||
|
|
||||||
|
# Runs after Unit Tests completes on master. Direct pushes only (not PRs).
|
||||||
on:
|
on:
|
||||||
push:
|
workflow_run:
|
||||||
|
workflows: [Unit Tests]
|
||||||
|
types: [completed]
|
||||||
branches: [master]
|
branches: [master]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
docker:
|
||||||
|
if: gitea.event.workflow_run.conclusion == 'success' && gitea.event.workflow_run.event == 'push'
|
||||||
runs-on: self-hosted
|
runs-on: self-hosted
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ gitea.event.workflow_run.head_sha }}
|
||||||
|
|
||||||
- name: Set up Python environment
|
- name: Build Docker image
|
||||||
run: |
|
run: docker compose build
|
||||||
python3 -m venv .venv
|
|
||||||
.venv/bin/pip install --upgrade pip
|
|
||||||
.venv/bin/pip install -r requirements.txt
|
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run containerized tests
|
||||||
run: |
|
run: |
|
||||||
cd company_site
|
docker compose up -d db
|
||||||
../.venv/bin/python manage.py test
|
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:
|
deploy:
|
||||||
needs: test
|
if: gitea.event.workflow_run.conclusion == 'success' && gitea.event.workflow_run.event == 'push'
|
||||||
runs-on: self-hosted
|
runs-on: self-hosted
|
||||||
|
needs: docker
|
||||||
|
env:
|
||||||
|
SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Deploy company_site prod
|
||||||
uses: actions/checkout@v4
|
run: |
|
||||||
|
"${SERVER_INFRA_ROOT}/scripts/deploy.sh" \
|
||||||
- name: Deploy to live site
|
--app company_site \
|
||||||
run: bash scripts/deploy.sh "${{ gitea.workspace }}"
|
--env prod \
|
||||||
|
--ref "${{ gitea.event.workflow_run.head_sha }}"
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
name: Unit Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
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
|
||||||
@@ -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,161 +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
|
|
||||||
|
|
||||||
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',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
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 only after user consent)
|
|
||||||
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,222 @@
|
|||||||
|
"""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>",
|
||||||
|
)
|
||||||
|
# Absolute site origin for email logo / footer links (no trailing slash).
|
||||||
|
PUBLIC_SITE_URL = env("PUBLIC_SITE_URL", "https://aimloperations.com").rstrip("/")
|
||||||
|
|
||||||
|
# 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
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Agentic Browsing Readiness
|
||||||
|
|
||||||
|
This site targets Google's experimental **Agentic Browsing** Lighthouse category (v13.3+), which checks whether AI agents can read, navigate, and act on public pages.
|
||||||
|
|
||||||
|
PageSpeed Insights does not yet expose this category. Run audits locally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx lighthouse@latest https://aimloperations.com \
|
||||||
|
--only-categories=agentic-browsing \
|
||||||
|
--chrome-flags="--enable-experimental-web-platform-features" \
|
||||||
|
--output=html --output-path=agentic-browsing-report.html
|
||||||
|
```
|
||||||
|
|
||||||
|
Test at minimum:
|
||||||
|
|
||||||
|
- `/` (homepage)
|
||||||
|
- `/contact` (primary conversion page)
|
||||||
|
|
||||||
|
## What we ship
|
||||||
|
|
||||||
|
| Check | Implementation |
|
||||||
|
|-------|----------------|
|
||||||
|
| **llms.txt** | `GET /llms.txt` — machine-readable site summary |
|
||||||
|
| **robots.txt** | `GET /robots.txt` — crawl rules + sitemap reference |
|
||||||
|
| **sitemap.xml** | `GET /sitemap.xml` — public marketing URLs |
|
||||||
|
| **Accessibility tree** | Form labels, semantic nav controls, ARIA on dialogs |
|
||||||
|
| **Layout stability** | Fixed cookie banner, reserved hero/marquee space, font fallbacks |
|
||||||
|
| **WebMCP tools** | `navigator.modelContext` tools behind `WEBMCP_ENABLED` (see [webmcp.md](webmcp.md)) |
|
||||||
|
|
||||||
|
## WebMCP (issue #9)
|
||||||
|
|
||||||
|
When `WEBMCP_ENABLED=True`:
|
||||||
|
|
||||||
|
- 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
|
||||||
|
- Default is **disabled** (`WEBMCP_ENABLED=False`) until deliberately enabled per environment
|
||||||
|
|
||||||
|
Full tool catalog, Chrome flag setup, and reCAPTCHA notes: **[docs/webmcp.md](webmcp.md)**
|
||||||
|
|
||||||
|
## Regression checklist
|
||||||
|
|
||||||
|
Before merging public-facing template or CSS changes:
|
||||||
|
|
||||||
|
1. Contact form fields have associated `<label>` elements (not placeholder-only).
|
||||||
|
2. Nav dropdowns use `<button>` triggers with `aria-expanded` / `aria-haspopup`.
|
||||||
|
3. Cookie consent banner stays `position: fixed` (no document flow shift).
|
||||||
|
4. `GET /robots.txt`, `/sitemap.xml`, `/llms.txt` return 200.
|
||||||
|
5. With `WEBMCP_ENABLED=True`, public pages include `webmcp-config` and `webmcp-tools.js`; contact form always has `toolname`, `tooldescription`, and `toolparamdescription` on fields.
|
||||||
|
6. Re-run Lighthouse agentic-browsing on homepage and contact page (Chrome experimental flag on).
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Lighthouse Agentic Browsing audit overview](https://locomotive.agency/blog/lighthouse-agentic-browsing-audit/)
|
||||||
|
- [llms.txt proposal](https://llmstxt.org/)
|
||||||
|
- [WebMCP tool catalog](webmcp.md)
|
||||||
|
- Gitea issue #5 (foundational), issue #9 (WebMCP)
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# WebMCP Tool Catalog
|
||||||
|
|
||||||
|
WebMCP exposes public marketing actions as named, callable tools via `navigator.modelContext` (or `document.modelContext` in newer builds). Tools register only when `WEBMCP_ENABLED=True` in Django settings.
|
||||||
|
|
||||||
|
## Enablement
|
||||||
|
|
||||||
|
### 1. Django setting
|
||||||
|
|
||||||
|
```python
|
||||||
|
# company_site/settings.py (or environment-specific settings)
|
||||||
|
WEBMCP_ENABLED = True
|
||||||
|
```
|
||||||
|
|
||||||
|
Default is `False` so production stays opt-in until you deliberately enable agent tooling.
|
||||||
|
|
||||||
|
### 2. Chrome experimental flag
|
||||||
|
|
||||||
|
WebMCP requires the experimental web platform features flag:
|
||||||
|
|
||||||
|
1. Open `chrome://flags/#enable-experimental-web-platform-features`
|
||||||
|
2. Set **Enable experimental web platform features** to **Enabled**
|
||||||
|
3. Restart Chrome
|
||||||
|
|
||||||
|
Serve the site over HTTPS (or `localhost`) — WebMCP requires a secure context.
|
||||||
|
|
||||||
|
### 3. Lighthouse audit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx lighthouse@latest https://aimloperations.com/contact \
|
||||||
|
--only-categories=agentic-browsing \
|
||||||
|
--chrome-flags="--enable-experimental-web-platform-features" \
|
||||||
|
--output=html --output-path=agentic-browsing-contact.html
|
||||||
|
|
||||||
|
npx lighthouse@latest https://aimloperations.com/ \
|
||||||
|
--only-categories=agentic-browsing \
|
||||||
|
--chrome-flags="--enable-experimental-web-platform-features" \
|
||||||
|
--output=html --output-path=agentic-browsing-home.html
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `https://` URLs to avoid redirect warnings. Run with `WEBMCP_ENABLED=True` on the target environment.
|
||||||
|
|
||||||
|
## Registered tools
|
||||||
|
|
||||||
|
| Tool | Pages | readOnly | Description |
|
||||||
|
|------|-------|----------|-------------|
|
||||||
|
| `list_services` | All public pages | Yes | Returns service name, slug, URL, and summary |
|
||||||
|
| `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 |
|
||||||
|
| `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 |
|
||||||
|
|
||||||
|
### `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`
|
||||||
|
|
||||||
|
**Input schema:**
|
||||||
|
|
||||||
|
| Field | Type | Required |
|
||||||
|
|-------|------|----------|
|
||||||
|
| `name` | string | Yes |
|
||||||
|
| `email` | string | Yes |
|
||||||
|
| `subject` | string | Yes |
|
||||||
|
| `message` | string | No |
|
||||||
|
|
||||||
|
**Output:** JSON with `success: true` and a confirmation message, or `success: false` with an `error` string.
|
||||||
|
|
||||||
|
**reCAPTCHA limitation:** In production (`DEBUG=False`), the contact endpoint requires reCAPTCHA v3. Automated agents cannot complete captcha without a real browser session and the reCAPTCHA widget. For Lighthouse demos, run with `DEBUG=True` or use the declarative HTML form (agent fills fields; user submits manually).
|
||||||
|
|
||||||
|
### Declarative form annotation
|
||||||
|
|
||||||
|
The contact `<form>` declares WebMCP coverage via HTML attributes (always rendered, independent of `WEBMCP_ENABLED`):
|
||||||
|
|
||||||
|
```html
|
||||||
|
<form id="contact-form" toolname="submit_contact_inquiry"
|
||||||
|
tooldescription="Submit a contact inquiry to AI ML Operations">
|
||||||
|
<input name="name" toolparamdescription="Full name of the person submitting the inquiry." required>
|
||||||
|
...
|
||||||
|
</form>
|
||||||
|
<!-- reCAPTCHA renders outside the annotated form and links via form="contact-form" -->
|
||||||
|
```
|
||||||
|
|
||||||
|
The imperative `registerTool` on `/contact` adds structured `execute` behavior with success/error responses when `WEBMCP_ENABLED=True`.
|
||||||
|
|
||||||
|
## Data source
|
||||||
|
|
||||||
|
Service metadata is sourced from `PUBLIC_PAGE_ENTRIES` in `public/seo.py` — the same config that powers `sitemap.xml` and `llms.txt`. Navigation tools always stay in sync with SEO endpoints.
|
||||||
|
|
||||||
|
## Security scope
|
||||||
|
|
||||||
|
- Tools run in the visitor's browser session (no API keys)
|
||||||
|
- Only unauthenticated marketing flows are exposed
|
||||||
|
- Planning, financial, and admin actions are **not** registered
|
||||||
|
|
||||||
|
## Manual verification
|
||||||
|
|
||||||
|
Use [Chrome Labs Awesome WebMCP](https://github.com/chrome-labs/awesome-webmcp) or DevTools console:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Feature-detect (Chrome with experimental flag)
|
||||||
|
'modelContext' in navigator && typeof navigator.modelContext.registerTool === 'function'
|
||||||
|
```
|
||||||
|
|
||||||
|
With a WebMCP-capable browser on `/`, call `list_services` from the agent UI. On `/contact`, call `submit_contact_inquiry` with test data (DEBUG mode).
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [WebMCP specification overview](https://specification.website/spec/agent-readiness/webmcp/)
|
||||||
|
- [Google Chrome modern-web-guidance — agentic JavaScript tools](https://github.com/GoogleChrome/modern-web-guidance/blob/main/skills/modern-web-guidance/guides/webmcp/agentic-javascript-tools.md)
|
||||||
|
- [Awesome WebMCP demos](https://github.com/chrome-labs/awesome-webmcp)
|
||||||
|
- Gitea issue #9
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from .models import Contract, Employee, ChargeNumber, TimeCard, TimeCardCell
|
from .models import (
|
||||||
# Register your models here.
|
Contract,
|
||||||
|
Employee,
|
||||||
|
ChargeNumber,
|
||||||
|
TimeCard,
|
||||||
|
TimeCardCell,
|
||||||
|
UserProfile,
|
||||||
|
BillingCustomer,
|
||||||
|
Invoice,
|
||||||
|
RecurringSubscription,
|
||||||
|
)
|
||||||
|
|
||||||
class ContractAdmin(admin.ModelAdmin):
|
class ContractAdmin(admin.ModelAdmin):
|
||||||
pass
|
pass
|
||||||
@@ -8,6 +17,10 @@ class ContractAdmin(admin.ModelAdmin):
|
|||||||
class EmployeeAdmin(admin.ModelAdmin):
|
class EmployeeAdmin(admin.ModelAdmin):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
class UserProfileAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("user", "user_type")
|
||||||
|
list_filter = ("user_type",)
|
||||||
|
|
||||||
class ChargeNumberAdmin(admin.ModelAdmin):
|
class ChargeNumberAdmin(admin.ModelAdmin):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -17,8 +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(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)
|
||||||
|
|||||||
@@ -1,7 +1,20 @@
|
|||||||
import datetime
|
import datetime
|
||||||
from django import forms
|
from django import forms
|
||||||
|
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
|
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")
|
||||||
@@ -37,6 +50,7 @@ class NewEmployeeForm(ModelForm):
|
|||||||
employee.workAddress = address
|
employee.workAddress = address
|
||||||
if commit:
|
if commit:
|
||||||
employee.save()
|
employee.save()
|
||||||
|
set_user_type(employee.user, UserProfile.UserType.EMPLOYEE)
|
||||||
return employee
|
return employee
|
||||||
|
|
||||||
class EmployeeForm(ModelForm):
|
class EmployeeForm(ModelForm):
|
||||||
@@ -44,6 +58,15 @@ class EmployeeForm(ModelForm):
|
|||||||
model = Employee
|
model = Employee
|
||||||
fields = ["user", "manager", "primaryAddress", "workAddress", "phoneNumber", "slary"]
|
fields = ["user", "manager", "primaryAddress", "workAddress", "phoneNumber", "slary"]
|
||||||
|
|
||||||
|
class UserProfileForm(ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = UserProfile
|
||||||
|
fields = ["user_type"]
|
||||||
|
|
||||||
|
class AdminUserTypeForm(forms.Form):
|
||||||
|
user = forms.ModelChoiceField(queryset=User.objects.order_by("username"))
|
||||||
|
user_type = forms.ChoiceField(choices=UserProfile.UserType.choices)
|
||||||
|
|
||||||
class ContractForm(ModelForm):
|
class ContractForm(ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Contract
|
model = Contract
|
||||||
@@ -88,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,79 @@
|
|||||||
|
# Generated manually for issue #14
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_user_profiles(apps, schema_editor):
|
||||||
|
User = apps.get_model("auth", "User")
|
||||||
|
UserProfile = apps.get_model("financial", "UserProfile")
|
||||||
|
Employee = apps.get_model("financial", "Employee")
|
||||||
|
TimeCardCell = apps.get_model("financial", "TimeCardCell")
|
||||||
|
|
||||||
|
employee_user_ids = set(
|
||||||
|
TimeCardCell.objects.values_list("timeCard__employee__user_id", flat=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
for user in User.objects.all():
|
||||||
|
if user.id in employee_user_ids:
|
||||||
|
UserProfile.objects.update_or_create(
|
||||||
|
user_id=user.id,
|
||||||
|
defaults={"user_type": "employee"},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
UserProfile.objects.update_or_create(
|
||||||
|
user_id=user.id,
|
||||||
|
defaults={"user_type": "client"},
|
||||||
|
)
|
||||||
|
Employee.objects.filter(user_id=user.id).delete()
|
||||||
|
|
||||||
|
|
||||||
|
def reverse_migrate_user_profiles(apps, schema_editor):
|
||||||
|
User = apps.get_model("auth", "User")
|
||||||
|
Employee = apps.get_model("financial", "Employee")
|
||||||
|
|
||||||
|
for user in User.objects.all():
|
||||||
|
Employee.objects.get_or_create(user_id=user.id)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
("financial", "0014_chargenumber_name"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="UserProfile",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user_type",
|
||||||
|
models.CharField(
|
||||||
|
choices=[("employee", "Employee"), ("client", "Client")],
|
||||||
|
default="client",
|
||||||
|
max_length=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.OneToOneField(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="profile",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.RunPython(migrate_user_profiles, reverse_migrate_user_profiles),
|
||||||
|
]
|
||||||
@@ -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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -243,6 +243,29 @@ class AddressModel(models.Model):
|
|||||||
state = models.CharField(max_length=2)
|
state = models.CharField(max_length=2)
|
||||||
zip_code = models.CharField(max_length=5)
|
zip_code = models.CharField(max_length=5)
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfile(models.Model):
|
||||||
|
class UserType(models.TextChoices):
|
||||||
|
EMPLOYEE = "employee", "Employee"
|
||||||
|
CLIENT = "client", "Client"
|
||||||
|
|
||||||
|
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
|
||||||
|
user_type = models.CharField(
|
||||||
|
max_length=10,
|
||||||
|
choices=UserType.choices,
|
||||||
|
default=UserType.CLIENT,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user} ({self.get_user_type_display()})"
|
||||||
|
|
||||||
|
def is_employee(self):
|
||||||
|
return self.user_type == self.UserType.EMPLOYEE
|
||||||
|
|
||||||
|
def is_client(self):
|
||||||
|
return self.user_type == self.UserType.CLIENT
|
||||||
|
|
||||||
|
|
||||||
class Employee(IdMixin, TimeMixin):
|
class Employee(IdMixin, TimeMixin):
|
||||||
manager = models.ForeignKey("self", on_delete=models.CASCADE, related_name="manager_employee", null=True, blank=True)
|
manager = models.ForeignKey("self", on_delete=models.CASCADE, related_name="manager_employee", null=True, blank=True)
|
||||||
user = models.OneToOneField(User, on_delete=models.CASCADE)
|
user = models.OneToOneField(User, on_delete=models.CASCADE)
|
||||||
@@ -279,6 +302,132 @@ 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):
|
||||||
|
"""Set user type and sync the Employee record (mutually exclusive types)."""
|
||||||
|
user.__dict__.pop("profile", None)
|
||||||
|
profile, _ = UserProfile.objects.get_or_create(
|
||||||
|
user=user,
|
||||||
|
defaults={"user_type": user_type},
|
||||||
|
)
|
||||||
|
if profile.user_type == user_type:
|
||||||
|
if user_type == UserProfile.UserType.EMPLOYEE:
|
||||||
|
Employee.objects.get_or_create(user=user)
|
||||||
|
return profile
|
||||||
|
|
||||||
|
if user_type == UserProfile.UserType.CLIENT:
|
||||||
|
employee = Employee.objects.filter(user=user).first()
|
||||||
|
if employee and TimeCardCell.objects.filter(timeCard__employee=employee).exists():
|
||||||
|
raise ValueError("Cannot set Client: user has time log entries.")
|
||||||
|
|
||||||
|
profile.user_type = user_type
|
||||||
|
profile.save()
|
||||||
|
user.__dict__.pop("profile", None)
|
||||||
|
|
||||||
|
if user_type == UserProfile.UserType.EMPLOYEE:
|
||||||
|
Employee.objects.get_or_create(user=user)
|
||||||
|
else:
|
||||||
|
Employee.objects.filter(user=user).delete()
|
||||||
|
|
||||||
|
return profile
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from django.contrib.auth.decorators import login_required, user_passes_test
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_profile(user):
|
||||||
|
if not user.is_authenticated:
|
||||||
|
return None
|
||||||
|
from .models import UserProfile
|
||||||
|
|
||||||
|
return UserProfile.objects.filter(user_id=user.pk).first()
|
||||||
|
|
||||||
|
|
||||||
|
def is_financial_admin(user):
|
||||||
|
return user.is_active and user.is_superuser
|
||||||
|
|
||||||
|
|
||||||
|
def is_employee_user(user):
|
||||||
|
profile = get_user_profile(user)
|
||||||
|
return bool(profile and profile.is_employee())
|
||||||
|
|
||||||
|
|
||||||
|
def is_client_user(user):
|
||||||
|
profile = get_user_profile(user)
|
||||||
|
return bool(profile and profile.is_client())
|
||||||
|
|
||||||
|
|
||||||
|
def has_financial_access(user):
|
||||||
|
return is_financial_admin(user) or is_employee_user(user) or is_client_user(user)
|
||||||
|
|
||||||
|
|
||||||
|
def can_write_financials(user):
|
||||||
|
return is_financial_admin(user) or is_employee_user(user)
|
||||||
|
|
||||||
|
|
||||||
|
def get_employees():
|
||||||
|
from .models import Employee, UserProfile
|
||||||
|
|
||||||
|
return Employee.objects.filter(
|
||||||
|
user__profile__user_type=UserProfile.UserType.EMPLOYEE
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_employee(user):
|
||||||
|
from .models import Employee
|
||||||
|
|
||||||
|
if not is_employee_user(user):
|
||||||
|
return None
|
||||||
|
return Employee.objects.filter(user=user).first()
|
||||||
|
|
||||||
|
|
||||||
|
def financial_admin_required(view_func):
|
||||||
|
return user_passes_test(is_financial_admin)(view_func)
|
||||||
|
|
||||||
|
|
||||||
|
def financial_access_required(view_func):
|
||||||
|
@login_required
|
||||||
|
@wraps(view_func)
|
||||||
|
def _wrapped(request, *args, **kwargs):
|
||||||
|
if has_financial_access(request.user):
|
||||||
|
return view_func(request, *args, **kwargs)
|
||||||
|
raise PermissionDenied
|
||||||
|
|
||||||
|
return _wrapped
|
||||||
|
|
||||||
|
|
||||||
|
def financial_write_required(view_func):
|
||||||
|
@login_required
|
||||||
|
@wraps(view_func)
|
||||||
|
def _wrapped(request, *args, **kwargs):
|
||||||
|
if can_write_financials(request.user):
|
||||||
|
return view_func(request, *args, **kwargs)
|
||||||
|
raise PermissionDenied
|
||||||
|
|
||||||
|
return _wrapped
|
||||||
@@ -2,10 +2,14 @@ from django.db.models.signals import post_save
|
|||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
|
||||||
|
from .models import UserProfile
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=User)
|
@receiver(post_save, sender=User)
|
||||||
def create_employee_for_user(sender, instance, created, **kwargs):
|
def create_profile_for_user(sender, instance, created, **kwargs):
|
||||||
"""Auto-create an Employee record whenever a User is created."""
|
"""Auto-create a UserProfile (default Client) whenever a User is created."""
|
||||||
from financial.models import Employee
|
|
||||||
if created:
|
if created:
|
||||||
Employee.objects.get_or_create(user=instance)
|
UserProfile.objects.get_or_create(
|
||||||
|
user=instance,
|
||||||
|
defaults={"user_type": UserProfile.UserType.CLIENT},
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,385 @@
|
|||||||
|
"""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>",
|
||||||
|
)
|
||||||
|
from public.email_branding import email_brand_context
|
||||||
|
|
||||||
|
context = email_brand_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,26 @@
|
|||||||
|
{% extends "emails/base_email.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ subject }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<p style="margin:0 0 16px;color:#e0e0e0;">Hello {{ customer_name }},</p>
|
||||||
|
{% if kind == "subscription" %}
|
||||||
|
<p style="margin:0 0 16px;color:#e0e0e0;">Please complete checkout to start your recurring payment:</p>
|
||||||
|
{% else %}
|
||||||
|
<p style="margin:0 0 16px;color:#e0e0e0;">You have a new invoice ready for payment:</p>
|
||||||
|
{% endif %}
|
||||||
|
<p style="margin:0 0 8px;color:#e0e0e0;"><strong>{{ description }}</strong></p>
|
||||||
|
<p class="amount" style="font-size:22px;font-weight:700;color:#00f3ff;margin:12px 0;">${{ amount_dollars }} USD</p>
|
||||||
|
<p style="margin:24px 0;">
|
||||||
|
<a class="email-btn" href="{{ pay_url }}" style="display:inline-block;padding:14px 28px;background-color:#00f3ff;color:#0a0a0a !important;text-decoration:none;border-radius:30px;font-weight:700;font-size:14px;letter-spacing:0.5px;text-transform:uppercase;">
|
||||||
|
{% if kind == "subscription" %}Complete checkout{% else %}Pay invoice{% endif %}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p class="muted" style="color:#a0a0a0;font-size:13px;line-height:1.5;margin:0 0 16px;">
|
||||||
|
Or open this link:<br>
|
||||||
|
<a href="{{ pay_url }}" style="color:#00f3ff;word-break:break-all;">{{ pay_url }}</a>
|
||||||
|
</p>
|
||||||
|
<p style="margin:0;color:#e0e0e0;">Thank you,<br>{{ brand_legal|default:"AI ML Operations, LLC" }}</p>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block footer_note %}This is an automated message. Please do not reply to this email.{% endblock %}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
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,
|
||||||
|
{{ brand_legal|default:"AI ML Operations, LLC" }}
|
||||||
|
|
||||||
|
—
|
||||||
|
{{ site_url|default:"https://aimloperations.com" }}
|
||||||
|
Forward-deployed AI engineering
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
<h1 class="section-title">Dashboard</h1>
|
<h1 class="section-title">Dashboard</h1>
|
||||||
|
|
||||||
<div class="card-grid" style="margin-bottom: 3rem;">
|
<div class="card-grid" style="margin-bottom: 3rem;">
|
||||||
|
{% if is_financial_admin %}
|
||||||
<a href="{% url 'contracts' %}" class="card"
|
<a href="{% url 'contracts' %}" class="card"
|
||||||
data-tianji-event="financial_nav" data-tianji-event-destination="contracts">
|
data-tianji-event="financial_nav" data-tianji-event-destination="contracts">
|
||||||
<span class="card-title">View Contracts</span>
|
<span class="card-title">View Contracts</span>
|
||||||
@@ -24,15 +25,27 @@
|
|||||||
<span class="card-title">New Employee</span>
|
<span class="card-title">New Employee</span>
|
||||||
<p class="card-text">Add a new personnel member to your organization.</p>
|
<p class="card-text">Add a new personnel member to your organization.</p>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="{% url 'manage_users' %}" class="card">
|
||||||
|
<span class="card-title">Manage Users</span>
|
||||||
|
<p class="card-text">Set Employee or Client type for user accounts.</p>
|
||||||
|
</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 %}
|
||||||
|
{% if can_write_financials %}
|
||||||
<a href="{% url 'Timekeeping' %}" class="card"
|
<a href="{% url 'Timekeeping' %}" class="card"
|
||||||
data-tianji-event="financial_nav" data-tianji-event-destination="timekeeping">
|
data-tianji-event="financial_nav" data-tianji-event-destination="timekeeping">
|
||||||
<span class="card-title">Log Time</span>
|
<span class="card-title">Log Time</span>
|
||||||
<p class="card-text">Record work hours against specific contracts.</p>
|
<p class="card-text">Record work hours against specific contracts.</p>
|
||||||
</a>
|
</a>
|
||||||
|
{% endif %}
|
||||||
<a href="{% url 'time_logs' %}" class="card"
|
<a href="{% url 'time_logs' %}" class="card"
|
||||||
data-tianji-event="financial_nav" data-tianji-event-destination="time_logs">
|
data-tianji-event="financial_nav" data-tianji-event-destination="time_logs">
|
||||||
<span class="card-title">Manage Time Logs</span>
|
<span class="card-title">{% if can_write_financials %}Manage{% else %}View{% endif %} Time Logs</span>
|
||||||
<p class="card-text">Review and edit submitted time entries.</p>
|
<p class="card-text">Review{% if can_write_financials %} and edit{% endif %} submitted time entries.</p>
|
||||||
</a>
|
</a>
|
||||||
<a href="{% url 'client_reports' %}" class="card">
|
<a href="{% url 'client_reports' %}" class="card">
|
||||||
<span class="card-title">Client Reports</span>
|
<span class="card-title">Client Reports</span>
|
||||||
|
|||||||
@@ -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,50 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Manage Users - AI ML Operations{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="section">
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="section-title" style="text-align: left;">Manage Users</h1>
|
||||||
|
|
||||||
|
{% if messages %}
|
||||||
|
{% for message in messages %}
|
||||||
|
<p style="margin-bottom: 1rem; color: {% if message.tags == 'error' %}#ff6666{% else %}var(--primary-color){% endif %};">
|
||||||
|
{{ message }}
|
||||||
|
</p>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card" style="max-width: 600px; margin-bottom: 2rem;">
|
||||||
|
<h2 style="font-size: 1.1rem; margin-bottom: 1rem;">Set User Type</h2>
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form.as_p }}
|
||||||
|
<button type="submit" class="btn" style="margin-top: 1rem;">Update User Type</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Type</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for u in users %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ u.username }}</td>
|
||||||
|
<td>{{ u.get_full_name|default:"—" }}</td>
|
||||||
|
<td>{{ u.profile.get_user_type_display|default:"Client" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -7,13 +7,36 @@
|
|||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1 class="section-title" style="text-align: left;">Profile</h1>
|
<h1 class="section-title" style="text-align: left;">Profile</h1>
|
||||||
<div class="card" style="max-width: 600px;">
|
|
||||||
|
{% if messages %}
|
||||||
|
{% for message in messages %}
|
||||||
|
<p style="margin-bottom: 1rem; color: {% if message.tags == 'error' %}#ff6666{% else %}var(--primary-color){% endif %};">
|
||||||
|
{{ message }}
|
||||||
|
</p>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card" style="max-width: 600px; margin-bottom: 2rem;">
|
||||||
|
<h2 style="font-size: 1.1rem; margin-bottom: 1rem;">Account Type</h2>
|
||||||
|
<p style="color: var(--text-muted); margin-bottom: 1rem;">
|
||||||
|
Your account is currently: <strong>{{ profile.get_user_type_display }}</strong>
|
||||||
|
</p>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form.as_p }}
|
{{ form.as_p }}
|
||||||
|
{% if can_edit_type %}
|
||||||
<button type="submit" class="btn" style="margin-top: 1rem;">Save Profile</button>
|
<button type="submit" class="btn" style="margin-top: 1rem;">Save Profile</button>
|
||||||
|
{% endif %}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if employee_form %}
|
||||||
|
<div class="card" style="max-width: 600px;">
|
||||||
|
<h2 style="font-size: 1.1rem; margin-bottom: 1rem;">Employee Details</h2>
|
||||||
|
{{ employee_form.as_p }}
|
||||||
|
<p style="color: var(--text-muted); font-size: 0.9rem;">Contact an admin to update employee details.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% 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 %}
|
||||||
@@ -9,11 +9,13 @@
|
|||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
|
||||||
<h1 class="section-title" style="margin-bottom: 0;">All Time Logs</h1>
|
<h1 class="section-title" style="margin-bottom: 0;">All Time Logs</h1>
|
||||||
<div>
|
<div>
|
||||||
<a href="{% url 'financial_index' %}" class="btn"
|
<a href="{% url 'financial_home' %}" class="btn"
|
||||||
style="padding: 0.5rem 1.5rem; font-size: 0.9rem; margin-right: 1rem; background: var(--surface-color); color: var(--text-color); border: 1px solid rgba(255,255,255,0.1);">Back
|
style="padding: 0.5rem 1.5rem; font-size: 0.9rem; margin-right: 1rem; background: var(--surface-color); color: var(--text-color); border: 1px solid rgba(255,255,255,0.1);">Back
|
||||||
to Dashboard</a>
|
to Dashboard</a>
|
||||||
|
{% if can_edit_logs %}
|
||||||
<a href="{% url 'Timekeeping' %}" class="btn" style="padding: 0.5rem 1.5rem; font-size: 0.9rem;">Log New
|
<a href="{% url 'Timekeeping' %}" class="btn" style="padding: 0.5rem 1.5rem; font-size: 0.9rem;">Log New
|
||||||
Time</a>
|
Time</a>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -144,6 +146,7 @@
|
|||||||
<td>{{ log.end_time|default_if_none:"" }}</td>
|
<td>{{ log.end_time|default_if_none:"" }}</td>
|
||||||
<td>{{ log.hour }}</td>
|
<td>{{ log.hour }}</td>
|
||||||
<td>
|
<td>
|
||||||
|
{% if can_edit_logs %}
|
||||||
<a href="{% url 'edit_time_log' log.id %}" class="text-cyber-cyan"
|
<a href="{% url 'edit_time_log' log.id %}" class="text-cyber-cyan"
|
||||||
style="margin-right: 10px;">Edit</a>
|
style="margin-right: 10px;">Edit</a>
|
||||||
<form action="{% url 'delete_time_log' log.id %}" method="POST" style="display:inline;"
|
<form action="{% url 'delete_time_log' log.id %}" method="POST" style="display:inline;"
|
||||||
@@ -152,6 +155,9 @@
|
|||||||
<button type="submit"
|
<button type="submit"
|
||||||
style="background:none; border:none; color: #ff4444; cursor:pointer; font-size: 0.95rem; font-family: var(--font-main);">Delete</button>
|
style="background:none; border:none; color: #ff4444; cursor:pointer; font-size: 0.95rem; font-family: var(--font-main);">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<span style="color: var(--text-muted);">Read only</span>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
|
|||||||
@@ -1,3 +1,218 @@
|
|||||||
from django.test import TestCase
|
from django.contrib.auth.models import User
|
||||||
|
from django.test import Client, TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from financial.models import (
|
||||||
|
AddressModel,
|
||||||
|
ChargeNumber,
|
||||||
|
Contract,
|
||||||
|
Employee,
|
||||||
|
TimeCard,
|
||||||
|
TimeCardCell,
|
||||||
|
UserProfile,
|
||||||
|
set_user_type,
|
||||||
|
)
|
||||||
|
from financial.permissions import get_employees, is_client_user, is_employee_user
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfileSignalTests(TestCase):
|
||||||
|
def test_new_user_gets_client_profile_not_employee(self):
|
||||||
|
user = User.objects.create_user(username="newbie", password="pass")
|
||||||
|
self.assertTrue(UserProfile.objects.filter(user=user, user_type=UserProfile.UserType.CLIENT).exists())
|
||||||
|
self.assertFalse(Employee.objects.filter(user=user).exists())
|
||||||
|
|
||||||
|
|
||||||
|
class SetUserTypeTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(username="worker", password="pass")
|
||||||
|
UserProfile.objects.filter(user=self.user).delete()
|
||||||
|
|
||||||
|
def test_set_employee_creates_employee_record(self):
|
||||||
|
set_user_type(self.user, UserProfile.UserType.EMPLOYEE)
|
||||||
|
self.assertTrue(Employee.objects.filter(user=self.user).exists())
|
||||||
|
self.assertEqual(self.user.profile.user_type, UserProfile.UserType.EMPLOYEE)
|
||||||
|
|
||||||
|
def test_set_client_removes_employee_without_time_entries(self):
|
||||||
|
set_user_type(self.user, UserProfile.UserType.EMPLOYEE)
|
||||||
|
set_user_type(self.user, UserProfile.UserType.CLIENT)
|
||||||
|
self.assertFalse(Employee.objects.filter(user=self.user).exists())
|
||||||
|
self.assertEqual(
|
||||||
|
UserProfile.objects.get(user=self.user).user_type,
|
||||||
|
UserProfile.UserType.CLIENT,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_cannot_set_client_with_time_entries(self):
|
||||||
|
set_user_type(self.user, UserProfile.UserType.EMPLOYEE)
|
||||||
|
employee = Employee.objects.get(user=self.user)
|
||||||
|
contract = Contract.objects.create(
|
||||||
|
contract_type=Contract.ContractTypeEnum.FIRM_FIX_PRICED,
|
||||||
|
name="Test Contract",
|
||||||
|
)
|
||||||
|
charge = ChargeNumber.objects.create(
|
||||||
|
charge_number_type=ChargeNumber.ChargeNumberTypeEnum.LEVEL_OF_EFFORT,
|
||||||
|
contract=contract,
|
||||||
|
)
|
||||||
|
time_card = TimeCard.objects.create(employee=employee)
|
||||||
|
TimeCardCell.objects.create(timeCard=time_card, charge_number=charge, hour=2.0)
|
||||||
|
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
set_user_type(self.user, UserProfile.UserType.CLIENT)
|
||||||
|
|
||||||
|
|
||||||
|
class EmployeeFilterTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.employee_user = User.objects.create_user(username="emp", password="pass")
|
||||||
|
self.client_user = User.objects.create_user(username="cli", password="pass")
|
||||||
|
set_user_type(self.employee_user, UserProfile.UserType.EMPLOYEE)
|
||||||
|
set_user_type(self.client_user, UserProfile.UserType.CLIENT)
|
||||||
|
|
||||||
|
def test_get_employees_excludes_clients(self):
|
||||||
|
employees = list(get_employees())
|
||||||
|
self.assertEqual(len(employees), 1)
|
||||||
|
self.assertEqual(employees[0].user, self.employee_user)
|
||||||
|
|
||||||
|
def test_permission_helpers(self):
|
||||||
|
self.assertTrue(is_employee_user(self.employee_user))
|
||||||
|
self.assertFalse(is_employee_user(self.client_user))
|
||||||
|
self.assertTrue(is_client_user(self.client_user))
|
||||||
|
self.assertFalse(is_client_user(self.employee_user))
|
||||||
|
|
||||||
|
|
||||||
|
class FinancialAccessTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = Client()
|
||||||
|
self.admin = User.objects.create_superuser(username="admin", password="pass", email="a@test.com")
|
||||||
|
self.employee = User.objects.create_user(username="employee", password="pass")
|
||||||
|
self.client_user = User.objects.create_user(username="client", password="pass")
|
||||||
|
set_user_type(self.employee, UserProfile.UserType.EMPLOYEE)
|
||||||
|
set_user_type(self.client_user, UserProfile.UserType.CLIENT)
|
||||||
|
|
||||||
|
contract = Contract.objects.create(
|
||||||
|
contract_type=Contract.ContractTypeEnum.FIRM_FIX_PRICED,
|
||||||
|
name="C1",
|
||||||
|
budget_hours=100,
|
||||||
|
)
|
||||||
|
charge = ChargeNumber.objects.create(
|
||||||
|
charge_number_type=ChargeNumber.ChargeNumberTypeEnum.LEVEL_OF_EFFORT,
|
||||||
|
contract=contract,
|
||||||
|
)
|
||||||
|
emp_record = Employee.objects.get(user=self.employee)
|
||||||
|
time_card = TimeCard.objects.create(employee=emp_record)
|
||||||
|
TimeCardCell.objects.create(timeCard=time_card, charge_number=charge, hour=4.0)
|
||||||
|
|
||||||
|
def test_client_can_view_reports_readonly(self):
|
||||||
|
self.client.login(username="client", password="pass")
|
||||||
|
response = self.client.get(reverse("client_reports"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "C1")
|
||||||
|
|
||||||
|
def test_client_cannot_log_time(self):
|
||||||
|
self.client.login(username="client", password="pass")
|
||||||
|
response = self.client.get(reverse("Timekeeping"))
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_client_can_view_time_logs_without_edit(self):
|
||||||
|
self.client.login(username="client", password="pass")
|
||||||
|
response = self.client.get(reverse("time_logs"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Read only")
|
||||||
|
self.assertNotContains(response, 'href="/financial/time_logs/')
|
||||||
|
|
||||||
|
def test_employee_can_access_timekeeping(self):
|
||||||
|
self.client.login(username="employee", password="pass")
|
||||||
|
response = self.client.get(reverse("Timekeeping"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_client_redirected_from_financial_home_to_reports(self):
|
||||||
|
self.client.login(username="client", password="pass")
|
||||||
|
response = self.client.get(reverse("financial_home"))
|
||||||
|
self.assertRedirects(response, reverse("client_reports"))
|
||||||
|
|
||||||
|
def test_admin_can_manage_users(self):
|
||||||
|
self.client.login(username="admin", password="pass")
|
||||||
|
response = self.client.get(reverse("manage_users"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
response = self.client.post(reverse("manage_users"), {
|
||||||
|
"user": self.client_user.id,
|
||||||
|
"user_type": UserProfile.UserType.EMPLOYEE,
|
||||||
|
})
|
||||||
|
self.assertRedirects(response, reverse("manage_users"))
|
||||||
|
self.client_user.refresh_from_db()
|
||||||
|
self.assertEqual(self.client_user.profile.user_type, UserProfile.UserType.EMPLOYEE)
|
||||||
|
|
||||||
|
def test_time_logs_only_lists_employees(self):
|
||||||
|
self.client.login(username="admin", password="pass")
|
||||||
|
response = self.client.get(reverse("time_logs"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "employee")
|
||||||
|
employees = response.context["employees"]
|
||||||
|
self.assertEqual(employees.count(), 1)
|
||||||
|
|
||||||
|
def test_new_user_not_in_employee_dropdown(self):
|
||||||
|
extra = User.objects.create_user(username="extra", password="pass")
|
||||||
|
set_user_type(extra, UserProfile.UserType.CLIENT)
|
||||||
|
self.client.login(username="admin", password="pass")
|
||||||
|
response = self.client.get(reverse("time_logs"))
|
||||||
|
employees = response.context["employees"]
|
||||||
|
usernames = [e.user.username for e in employees]
|
||||||
|
self.assertIn("employee", 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")
|
||||||
|
|
||||||
# Create your tests here.
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from . import views
|
from . import views
|
||||||
|
from . import invoice_views
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", views.index, name="financial_index"),
|
path("", views.financial_home, name="financial_home"),
|
||||||
|
path("dashboard", views.index, name="financial_index"),
|
||||||
path("timekeeping", views.timekeeping, name="Timekeeping"),
|
path("timekeeping", views.timekeeping, name="Timekeeping"),
|
||||||
path("time_logs", views.time_logs, name="time_logs"),
|
path("time_logs", views.time_logs, name="time_logs"),
|
||||||
path("time_logs/<int:log_id>/edit", views.edit_time_log, name="edit_time_log"),
|
path("time_logs/<int:log_id>/edit", views.edit_time_log, name="edit_time_log"),
|
||||||
@@ -18,5 +20,26 @@ urlpatterns = [
|
|||||||
#path("contracts/<int:contract_id>/", views.contract_detail, name="contract"),
|
#path("contracts/<int:contract_id>/", views.contract_detail, name="contract"),
|
||||||
path("procurements", views.procurement, name="procurements"),
|
path("procurements", views.procurement, name="procurements"),
|
||||||
path("profile", views.profile, name="profile"),
|
path("profile", views.profile, name="profile"),
|
||||||
|
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,23 +1,41 @@
|
|||||||
from django.shortcuts import render, redirect
|
from django.shortcuts import render, redirect
|
||||||
from django.contrib.auth.decorators import user_passes_test
|
from django.contrib.auth.models import User
|
||||||
from .forms import EmployeeForm, ContractForm, ChargeNumberForm, TimeLogForm, NewEmployeeForm
|
from django.contrib import messages
|
||||||
from .models import Contract, ChargeNumber, TimeCard, TimeCardCell, Employee
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.db.models import Sum
|
from django.db.models import Sum
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
import json
|
import json
|
||||||
|
|
||||||
def is_admin(user):
|
from .forms import (
|
||||||
return user.is_active and user.is_superuser
|
EmployeeForm,
|
||||||
|
ContractForm,
|
||||||
|
ChargeNumberForm,
|
||||||
|
TimeLogForm,
|
||||||
|
NewEmployeeForm,
|
||||||
|
UserProfileForm,
|
||||||
|
AdminUserTypeForm,
|
||||||
|
)
|
||||||
|
from .models import Contract, ChargeNumber, TimeCard, TimeCardCell, Employee, UserProfile
|
||||||
|
from .permissions import (
|
||||||
|
financial_admin_required,
|
||||||
|
financial_access_required,
|
||||||
|
financial_write_required,
|
||||||
|
get_employees,
|
||||||
|
get_user_employee,
|
||||||
|
is_client_user,
|
||||||
|
is_financial_admin,
|
||||||
|
can_write_financials,
|
||||||
|
)
|
||||||
|
|
||||||
@user_passes_test(is_admin)
|
|
||||||
|
@financial_admin_required
|
||||||
def index(request):
|
def index(request):
|
||||||
contracts = Contract.objects.all()
|
contracts = Contract.objects.all()
|
||||||
for c in contracts:
|
for c in contracts:
|
||||||
total = TimeCardCell.objects.filter(charge_number__contract=c).aggregate(Sum('hour'))['hour__sum']
|
total = TimeCardCell.objects.filter(charge_number__contract=c).aggregate(Sum('hour'))['hour__sum']
|
||||||
c.total_logged = total if total else 0.0
|
c.total_logged = total if total else 0.0
|
||||||
|
|
||||||
employees = Employee.objects.all()
|
employees = get_employees()
|
||||||
employee_data = []
|
employee_data = []
|
||||||
for e in employees:
|
for e in employees:
|
||||||
contract_hours = []
|
contract_hours = []
|
||||||
@@ -28,10 +46,20 @@ def index(request):
|
|||||||
|
|
||||||
return render(request, "financial/index.html", {
|
return render(request, "financial/index.html", {
|
||||||
'contracts': contracts,
|
'contracts': contracts,
|
||||||
'employee_data': employee_data
|
'employee_data': employee_data,
|
||||||
})
|
})
|
||||||
|
|
||||||
@user_passes_test(is_admin)
|
|
||||||
|
@financial_access_required
|
||||||
|
def financial_home(request):
|
||||||
|
if is_financial_admin(request.user):
|
||||||
|
return redirect('financial_index')
|
||||||
|
if is_client_user(request.user):
|
||||||
|
return redirect('client_reports')
|
||||||
|
return redirect('Timekeeping')
|
||||||
|
|
||||||
|
|
||||||
|
@financial_admin_required
|
||||||
def new_employee(request):
|
def new_employee(request):
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
form = NewEmployeeForm(request.POST)
|
form = NewEmployeeForm(request.POST)
|
||||||
@@ -42,7 +70,8 @@ def new_employee(request):
|
|||||||
form = NewEmployeeForm()
|
form = NewEmployeeForm()
|
||||||
return render(request, 'financial/new_employee.html', {"form": form})
|
return render(request, 'financial/new_employee.html', {"form": form})
|
||||||
|
|
||||||
@user_passes_test(is_admin)
|
|
||||||
|
@financial_admin_required
|
||||||
def contracts(request):
|
def contracts(request):
|
||||||
contracts_list = Contract.objects.all()
|
contracts_list = Contract.objects.all()
|
||||||
today = timezone.now().date()
|
today = timezone.now().date()
|
||||||
@@ -102,7 +131,8 @@ def contracts(request):
|
|||||||
'chart_data_json': json.dumps(chart_data_list)
|
'chart_data_json': json.dumps(chart_data_list)
|
||||||
})
|
})
|
||||||
|
|
||||||
@user_passes_test(is_admin)
|
|
||||||
|
@financial_admin_required
|
||||||
def contract_detail(request, contract_slug):
|
def contract_detail(request, contract_slug):
|
||||||
contract = Contract.objects.filter(slug=contract_slug).first()
|
contract = Contract.objects.filter(slug=contract_slug).first()
|
||||||
|
|
||||||
@@ -122,7 +152,6 @@ def contract_detail(request, contract_slug):
|
|||||||
lines_str = "\n".join(mermaid_gantt_lines)
|
lines_str = "\n".join(mermaid_gantt_lines)
|
||||||
mermaid_gantt = f"gantt\n title {contract.name} Charge Numbers Timeline\n dateFormat YYYY-MM-DD\n section Charge Numbers\n{lines_str}"
|
mermaid_gantt = f"gantt\n title {contract.name} Charge Numbers Timeline\n dateFormat YYYY-MM-DD\n section Charge Numbers\n{lines_str}"
|
||||||
|
|
||||||
# --- EVM Data ---
|
|
||||||
evm = contract.get_evm_data() if contract else {}
|
evm = contract.get_evm_data() if contract else {}
|
||||||
evm_chart_json = json.dumps({
|
evm_chart_json = json.dumps({
|
||||||
'time_series': evm.get('time_series', []),
|
'time_series': evm.get('time_series', []),
|
||||||
@@ -149,7 +178,8 @@ def contract_detail(request, contract_slug):
|
|||||||
'evm_chart_json': evm_chart_json,
|
'evm_chart_json': evm_chart_json,
|
||||||
})
|
})
|
||||||
|
|
||||||
@user_passes_test(is_admin)
|
|
||||||
|
@financial_admin_required
|
||||||
def new_contract(request):
|
def new_contract(request):
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
form = ContractForm(request.POST)
|
form = ContractForm(request.POST)
|
||||||
@@ -160,27 +190,42 @@ def new_contract(request):
|
|||||||
form = ContractForm()
|
form = ContractForm()
|
||||||
return render(request, 'financial/contract_detail.html', {"form": form, 'is_new': True})
|
return render(request, 'financial/contract_detail.html', {"form": form, 'is_new': True})
|
||||||
|
|
||||||
@user_passes_test(is_admin)
|
|
||||||
|
@financial_write_required
|
||||||
def timekeeping(request):
|
def timekeeping(request):
|
||||||
|
employee = get_user_employee(request.user)
|
||||||
|
if not employee and not is_financial_admin(request.user):
|
||||||
|
messages.error(request, "Only employees can log time.")
|
||||||
|
return redirect('financial_home')
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
form = TimeLogForm(request.POST)
|
form = TimeLogForm(request.POST)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
employee, _ = Employee.objects.get_or_create(user=request.user)
|
if is_financial_admin(request.user) and not employee:
|
||||||
|
messages.error(request, "Admin must have an Employee profile to log time here.")
|
||||||
|
return redirect('time_logs')
|
||||||
|
|
||||||
time_card, _ = TimeCard.objects.get_or_create(employee=employee, startDate=timezone.now().date(), endDate=timezone.now().date())
|
time_card, _ = TimeCard.objects.get_or_create(
|
||||||
|
employee=employee,
|
||||||
|
startDate=timezone.now().date(),
|
||||||
|
endDate=timezone.now().date(),
|
||||||
|
)
|
||||||
cell = form.save(commit=False)
|
cell = form.save(commit=False)
|
||||||
cell.timeCard = time_card
|
cell.timeCard = time_card
|
||||||
cell.save()
|
cell.save()
|
||||||
return redirect('financial_index')
|
return redirect('financial_home')
|
||||||
else:
|
else:
|
||||||
form = TimeLogForm()
|
form = TimeLogForm()
|
||||||
return render(request, 'financial/timekeeping.html', {'form': form})
|
return render(request, 'financial/timekeeping.html', {'form': form})
|
||||||
|
|
||||||
@user_passes_test(is_admin)
|
|
||||||
|
@financial_access_required
|
||||||
def time_logs(request):
|
def time_logs(request):
|
||||||
logs = TimeCardCell.objects.select_related(
|
logs = TimeCardCell.objects.select_related(
|
||||||
'timeCard__employee__user',
|
'timeCard__employee__user',
|
||||||
'charge_number__contract',
|
'charge_number__contract',
|
||||||
|
).filter(
|
||||||
|
timeCard__employee__user__profile__user_type=UserProfile.UserType.EMPLOYEE,
|
||||||
).order_by('-date', '-created')
|
).order_by('-date', '-created')
|
||||||
|
|
||||||
employee_ids = []
|
employee_ids = []
|
||||||
@@ -231,12 +276,13 @@ def time_logs(request):
|
|||||||
|
|
||||||
return render(request, 'financial/time_logs.html', {
|
return render(request, 'financial/time_logs.html', {
|
||||||
'logs': logs,
|
'logs': logs,
|
||||||
'employees': Employee.objects.select_related('user').order_by('user__last_name', 'user__first_name'),
|
'employees': get_employees().select_related('user').order_by('user__last_name', 'user__first_name'),
|
||||||
'contracts': Contract.objects.order_by('name'),
|
'contracts': Contract.objects.order_by('name'),
|
||||||
'charge_numbers': charge_numbers,
|
'charge_numbers': charge_numbers,
|
||||||
'contract_totals': contract_totals,
|
'contract_totals': contract_totals,
|
||||||
'charge_number_totals': charge_number_totals,
|
'charge_number_totals': charge_number_totals,
|
||||||
'grand_total': grand_total,
|
'grand_total': grand_total,
|
||||||
|
'can_edit_logs': can_write_financials(request.user),
|
||||||
'filters': {
|
'filters': {
|
||||||
'employees': [str(eid) for eid in employee_ids],
|
'employees': [str(eid) for eid in employee_ids],
|
||||||
'month': month or '',
|
'month': month or '',
|
||||||
@@ -245,7 +291,8 @@ def time_logs(request):
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@user_passes_test(is_admin)
|
|
||||||
|
@financial_write_required
|
||||||
def edit_time_log(request, log_id):
|
def edit_time_log(request, log_id):
|
||||||
log_entry = TimeCardCell.objects.filter(id=log_id).first()
|
log_entry = TimeCardCell.objects.filter(id=log_id).first()
|
||||||
if not log_entry:
|
if not log_entry:
|
||||||
@@ -261,7 +308,8 @@ def edit_time_log(request, log_id):
|
|||||||
|
|
||||||
return render(request, 'financial/edit_time_log.html', {'form': form, 'log': log_entry})
|
return render(request, 'financial/edit_time_log.html', {'form': form, 'log': log_entry})
|
||||||
|
|
||||||
@user_passes_test(is_admin)
|
|
||||||
|
@financial_write_required
|
||||||
def delete_time_log(request, log_id):
|
def delete_time_log(request, log_id):
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
log_entry = TimeCardCell.objects.filter(id=log_id).first()
|
log_entry = TimeCardCell.objects.filter(id=log_id).first()
|
||||||
@@ -269,16 +317,24 @@ def delete_time_log(request, log_id):
|
|||||||
log_entry.delete()
|
log_entry.delete()
|
||||||
return redirect('time_logs')
|
return redirect('time_logs')
|
||||||
|
|
||||||
@user_passes_test(is_admin)
|
|
||||||
|
@financial_access_required
|
||||||
def client_reports(request):
|
def client_reports(request):
|
||||||
contracts = Contract.objects.all()
|
contracts = Contract.objects.all()
|
||||||
for c in contracts:
|
for c in contracts:
|
||||||
total = TimeCardCell.objects.filter(charge_number__contract=c).aggregate(Sum('hour'))['hour__sum']
|
total = TimeCardCell.objects.filter(
|
||||||
|
charge_number__contract=c,
|
||||||
|
timeCard__employee__user__profile__user_type=UserProfile.UserType.EMPLOYEE,
|
||||||
|
).aggregate(Sum('hour'))['hour__sum']
|
||||||
c.total_logged = total if total else 0.0
|
c.total_logged = total if total else 0.0
|
||||||
c.remaining_budget = c.budget_hours - c.total_logged
|
c.remaining_budget = c.budget_hours - c.total_logged
|
||||||
return render(request, 'financial/reports.html', {'contracts': contracts})
|
return render(request, 'financial/reports.html', {
|
||||||
|
'contracts': contracts,
|
||||||
|
'read_only': is_client_user(request.user),
|
||||||
|
})
|
||||||
|
|
||||||
@user_passes_test(is_admin)
|
|
||||||
|
@financial_admin_required
|
||||||
def update_charge_number(request, charge_number_slug):
|
def update_charge_number(request, charge_number_slug):
|
||||||
charge_number = ChargeNumber.objects.filter(slug=charge_number_slug).first()
|
charge_number = ChargeNumber.objects.filter(slug=charge_number_slug).first()
|
||||||
if not charge_number:
|
if not charge_number:
|
||||||
@@ -297,7 +353,8 @@ def update_charge_number(request, charge_number_slug):
|
|||||||
'charge_number': charge_number,
|
'charge_number': charge_number,
|
||||||
})
|
})
|
||||||
|
|
||||||
@user_passes_test(is_admin)
|
|
||||||
|
@financial_admin_required
|
||||||
def new_charge_number(request, contract_slug):
|
def new_charge_number(request, contract_slug):
|
||||||
contract = Contract.objects.filter(slug=contract_slug).first()
|
contract = Contract.objects.filter(slug=contract_slug).first()
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
@@ -309,19 +366,72 @@ def new_charge_number(request, contract_slug):
|
|||||||
return redirect('contract_detail', contract_slug=contract.slug)
|
return redirect('contract_detail', contract_slug=contract.slug)
|
||||||
return redirect('contract_detail', contract_slug=contract_slug)
|
return redirect('contract_detail', contract_slug=contract_slug)
|
||||||
|
|
||||||
@user_passes_test(is_admin)
|
|
||||||
|
@financial_admin_required
|
||||||
def timeapproval(request):
|
def timeapproval(request):
|
||||||
return render(request, 'financial/not_created.html', {})
|
return render(request, 'financial/not_created.html', {})
|
||||||
|
|
||||||
@user_passes_test(is_admin)
|
|
||||||
|
@financial_admin_required
|
||||||
def chargenumber(request):
|
def chargenumber(request):
|
||||||
return render(request, 'financial/not_created.html', {})
|
return render(request, 'financial/not_created.html', {})
|
||||||
|
|
||||||
@user_passes_test(is_admin)
|
|
||||||
|
@financial_admin_required
|
||||||
def procurement(request):
|
def procurement(request):
|
||||||
return render(request, 'financial/procurement.html', {})
|
return render(request, 'financial/procurement.html', {})
|
||||||
|
|
||||||
@user_passes_test(is_admin)
|
|
||||||
|
@financial_access_required
|
||||||
def profile(request):
|
def profile(request):
|
||||||
form = EmployeeForm()
|
profile_obj, _ = UserProfile.objects.get_or_create(user=request.user)
|
||||||
return render(request, 'financial/profile.html', {'form': form})
|
can_edit_type = is_financial_admin(request.user)
|
||||||
|
|
||||||
|
if request.method == "POST" and can_edit_type:
|
||||||
|
form = UserProfileForm(request.POST, instance=profile_obj)
|
||||||
|
if form.is_valid():
|
||||||
|
from .models import set_user_type
|
||||||
|
try:
|
||||||
|
set_user_type(request.user, form.cleaned_data['user_type'])
|
||||||
|
messages.success(request, "Profile updated.")
|
||||||
|
except ValueError as exc:
|
||||||
|
messages.error(request, str(exc))
|
||||||
|
return redirect('profile')
|
||||||
|
else:
|
||||||
|
form = UserProfileForm(instance=profile_obj)
|
||||||
|
if not can_edit_type:
|
||||||
|
form.fields['user_type'].disabled = True
|
||||||
|
|
||||||
|
employee = Employee.objects.filter(user=request.user).first()
|
||||||
|
employee_form = None
|
||||||
|
if employee and profile_obj.is_employee():
|
||||||
|
employee_form = EmployeeForm(instance=employee)
|
||||||
|
|
||||||
|
return render(request, 'financial/profile.html', {
|
||||||
|
'form': form,
|
||||||
|
'employee_form': employee_form,
|
||||||
|
'profile': profile_obj,
|
||||||
|
'can_edit_type': can_edit_type,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@financial_admin_required
|
||||||
|
def manage_users(request):
|
||||||
|
if request.method == "POST":
|
||||||
|
form = AdminUserTypeForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
from .models import set_user_type
|
||||||
|
try:
|
||||||
|
set_user_type(form.cleaned_data['user'], form.cleaned_data['user_type'])
|
||||||
|
messages.success(request, "User type updated.")
|
||||||
|
return redirect('manage_users')
|
||||||
|
except ValueError as exc:
|
||||||
|
messages.error(request, str(exc))
|
||||||
|
else:
|
||||||
|
form = AdminUserTypeForm()
|
||||||
|
|
||||||
|
users = User.objects.select_related('profile').order_by('username')
|
||||||
|
return render(request, 'financial/manage_users.html', {
|
||||||
|
'form': form,
|
||||||
|
'users': users,
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,20 +1,36 @@
|
|||||||
|
from django.conf import settings
|
||||||
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 .email_branding import email_brand_context
|
||||||
|
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")
|
||||||
@@ -22,11 +38,15 @@ def send_emails(modeladmin, request, queryset):
|
|||||||
for email in queryset:
|
for email in queryset:
|
||||||
success_count: int = 0
|
success_count: int = 0
|
||||||
try:
|
try:
|
||||||
from_email = "AI ML Operations, LLC <info@aimloperations.com>"
|
from_email = getattr(
|
||||||
d = {"title": email.subject, "content": email.body}
|
settings,
|
||||||
|
"DEFAULT_FROM_EMAIL",
|
||||||
|
"AI ML Operations, LLC <info@aimloperations.com>",
|
||||||
|
)
|
||||||
|
d = email_brand_context(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]
|
||||||
@@ -63,5 +83,45 @@ class EmailMessageAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
def preview_email(self, request, pk):
|
def preview_email(self, request, pk):
|
||||||
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 = email_brand_context(
|
||||||
return TemplateResponse(request, "public/preview_email.html", context)
|
title=email_instance.subject,
|
||||||
|
content=email_instance.body,
|
||||||
|
)
|
||||||
|
return TemplateResponse(request, "emails/marketing_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"
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
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):
|
||||||
@@ -16,3 +26,70 @@ def tianji_tracking(request):
|
|||||||
),
|
),
|
||||||
'page_name': getattr(getattr(request, 'resolver_match', None), 'url_name', ''),
|
'page_name': getattr(getattr(request, 'resolver_match', None), 'url_name', ''),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def webmcp_context(request):
|
||||||
|
page_name = getattr(getattr(request, 'resolver_match', None), 'url_name', '')
|
||||||
|
services = [
|
||||||
|
{
|
||||||
|
**entry,
|
||||||
|
"url": request.build_absolute_uri(reverse(entry["slug"])),
|
||||||
|
}
|
||||||
|
for entry in get_service_entries()
|
||||||
|
]
|
||||||
|
page_lookup = {
|
||||||
|
url_name: {
|
||||||
|
"slug": url_name,
|
||||||
|
"name": title,
|
||||||
|
"summary": summary,
|
||||||
|
"url": request.build_absolute_uri(reverse(url_name)),
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
'webmcp_enabled': getattr(settings, 'WEBMCP_ENABLED', False),
|
||||||
|
'webmcp_page_name': page_name,
|
||||||
|
'webmcp_contact_url': request.build_absolute_uri(reverse('contact')),
|
||||||
|
'webmcp_recaptcha_required': not settings.DEBUG,
|
||||||
|
'webmcp_services_json': json.dumps(services),
|
||||||
|
'webmcp_pages_json': json.dumps(page_lookup),
|
||||||
|
'webmcp_web_design_pricing_json': json.dumps(web_design_pricing),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def financial_access(request):
|
||||||
|
user = request.user
|
||||||
|
if not user.is_authenticated:
|
||||||
|
return {
|
||||||
|
'is_financial_admin': False,
|
||||||
|
'is_employee_user': False,
|
||||||
|
'is_client_user': False,
|
||||||
|
'can_write_financials': False,
|
||||||
|
'has_financial_access': False,
|
||||||
|
}
|
||||||
|
|
||||||
|
from financial.permissions import (
|
||||||
|
can_write_financials,
|
||||||
|
has_financial_access,
|
||||||
|
is_client_user,
|
||||||
|
is_employee_user,
|
||||||
|
is_financial_admin,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_financial_admin': is_financial_admin(user),
|
||||||
|
'is_employee_user': is_employee_user(user),
|
||||||
|
'is_client_user': is_client_user(user),
|
||||||
|
'can_write_financials': can_write_financials(user),
|
||||||
|
'has_financial_access': has_financial_access(user),
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""Shared context for branded HTML/text emails."""
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.staticfiles.storage import staticfiles_storage
|
||||||
|
|
||||||
|
|
||||||
|
def email_brand_context(**extra):
|
||||||
|
site_url = getattr(settings, "PUBLIC_SITE_URL", "https://aimloperations.com").rstrip(
|
||||||
|
"/"
|
||||||
|
)
|
||||||
|
logo_path = staticfiles_storage.url("public/img/logo.png")
|
||||||
|
if logo_path.startswith("http://") or logo_path.startswith("https://"):
|
||||||
|
logo_url = logo_path
|
||||||
|
else:
|
||||||
|
logo_url = f"{site_url}{logo_path}"
|
||||||
|
return {
|
||||||
|
"site_url": site_url,
|
||||||
|
"logo_url": logo_url,
|
||||||
|
"brand_name": "AI ML Operations",
|
||||||
|
"brand_legal": "AI ML Operations, LLC",
|
||||||
|
**extra,
|
||||||
|
}
|
||||||
@@ -7,8 +7,9 @@ class FormWithCaptcha(forms.Form):
|
|||||||
captcha = ReCaptchaField(
|
captcha = ReCaptchaField(
|
||||||
widget=ReCaptchaV3(
|
widget=ReCaptchaV3(
|
||||||
attrs={
|
attrs={
|
||||||
'required_score':0.85,
|
'required_score': 0.85,
|
||||||
}
|
'form': 'contact-form',
|
||||||
|
}
|
||||||
),
|
),
|
||||||
public_key=settings.RECAPTCHA_PUBLIC_KEY,
|
public_key=settings.RECAPTCHA_PUBLIC_KEY,
|
||||||
private_key=settings.RECAPTCHA_PRIVATE_KEY,
|
private_key=settings.RECAPTCHA_PRIVATE_KEY,
|
||||||
|
|||||||
@@ -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}"
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""Machine-readable site discovery endpoints for crawlers and AI agents."""
|
||||||
|
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
# Public marketing pages included in sitemap and llms.txt.
|
||||||
|
# Tuple: (url_name, title, changefreq, priority, summary)
|
||||||
|
PUBLIC_PAGE_ENTRIES = (
|
||||||
|
(
|
||||||
|
"public_index",
|
||||||
|
"Home",
|
||||||
|
"weekly",
|
||||||
|
"1.0",
|
||||||
|
"Company homepage with an overview of AI ML Operations services.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"forward_deployed",
|
||||||
|
"Forward-Deployed AI",
|
||||||
|
"monthly",
|
||||||
|
"0.9",
|
||||||
|
"Embedded AI engineering — we work inside your environment to build production systems.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"bot",
|
||||||
|
"AI Agents",
|
||||||
|
"monthly",
|
||||||
|
"0.9",
|
||||||
|
"Custom AI agents and agentic workflows tailored to your operational bottlenecks.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ml_model",
|
||||||
|
"ML Models",
|
||||||
|
"monthly",
|
||||||
|
"0.8",
|
||||||
|
"Machine learning model development, training, and deployment for production use.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"chat",
|
||||||
|
"Secure AI Chat",
|
||||||
|
"monthly",
|
||||||
|
"0.8",
|
||||||
|
"Private, hosted AI chat deployments with enterprise-grade security.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ai_sensor",
|
||||||
|
"AI Sensor Algorithms",
|
||||||
|
"monthly",
|
||||||
|
"0.7",
|
||||||
|
"Computer vision and sensor-fusion algorithms for real-world sensing applications.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ai_education",
|
||||||
|
"AI Education",
|
||||||
|
"monthly",
|
||||||
|
"0.7",
|
||||||
|
"Hands-on AI training and workshops for teams adopting agentic workflows.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"computers",
|
||||||
|
"Computer Builds",
|
||||||
|
"monthly",
|
||||||
|
"0.7",
|
||||||
|
"Custom workstation and server builds optimized for AI and ML workloads.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"web_design",
|
||||||
|
"Web Design and Hosting",
|
||||||
|
"monthly",
|
||||||
|
"0.8",
|
||||||
|
"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",
|
||||||
|
"monthly",
|
||||||
|
"0.9",
|
||||||
|
"Contact form to inquire about forward-deployed AI engineering services.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"terms_of_service",
|
||||||
|
"Terms of Service and Privacy",
|
||||||
|
"yearly",
|
||||||
|
"0.3",
|
||||||
|
"Terms of service and privacy policy for AI ML Operations, LLC.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
SERVICE_URL_NAMES = frozenset({
|
||||||
|
"forward_deployed",
|
||||||
|
"bot",
|
||||||
|
"ml_model",
|
||||||
|
"chat",
|
||||||
|
"ai_sensor",
|
||||||
|
"ai_education",
|
||||||
|
"computers",
|
||||||
|
"web_design",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def get_service_entries():
|
||||||
|
"""Return service page metadata for WebMCP navigation tools."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"slug": url_name,
|
||||||
|
"name": title,
|
||||||
|
"summary": summary,
|
||||||
|
}
|
||||||
|
for url_name, title, _changefreq, _priority, summary in PUBLIC_PAGE_ENTRIES
|
||||||
|
if url_name in SERVICE_URL_NAMES
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _absolute_url(request, url_name):
|
||||||
|
return request.build_absolute_uri(reverse(url_name))
|
||||||
|
|
||||||
|
|
||||||
|
def robots_txt(request):
|
||||||
|
sitemap_url = _absolute_url(request, "sitemap_xml")
|
||||||
|
content = render_to_string(
|
||||||
|
"public/robots.txt",
|
||||||
|
{
|
||||||
|
"sitemap_url": sitemap_url,
|
||||||
|
"llms_url": _absolute_url(request, "llms_txt"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return HttpResponse(content, content_type="text/plain; charset=utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def sitemap_xml(request):
|
||||||
|
pages = [
|
||||||
|
{
|
||||||
|
"loc": _absolute_url(request, url_name),
|
||||||
|
"changefreq": changefreq,
|
||||||
|
"priority": priority,
|
||||||
|
}
|
||||||
|
for url_name, _title, changefreq, priority, _summary in PUBLIC_PAGE_ENTRIES
|
||||||
|
]
|
||||||
|
content = render_to_string("public/sitemap.xml", {"pages": pages})
|
||||||
|
return HttpResponse(content, content_type="application/xml; charset=utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def llms_txt(request):
|
||||||
|
from .web_design_pricing import (
|
||||||
|
WEB_DESIGN_INCLUDED,
|
||||||
|
features_for_json,
|
||||||
|
)
|
||||||
|
|
||||||
|
pages = [
|
||||||
|
{
|
||||||
|
"title": title,
|
||||||
|
"url": _absolute_url(request, url_name),
|
||||||
|
"summary": summary,
|
||||||
|
}
|
||||||
|
for url_name, title, _changefreq, _priority, summary in PUBLIC_PAGE_ENTRIES
|
||||||
|
]
|
||||||
|
content = render_to_string(
|
||||||
|
"public/llms.txt",
|
||||||
|
{
|
||||||
|
"site_url": request.build_absolute_uri("/"),
|
||||||
|
"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,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return HttpResponse(content, content_type="text/plain; charset=utf-8")
|
||||||
@@ -23,6 +23,7 @@ body {
|
|||||||
font-family: var(--font-main);
|
font-family: var(--font-main);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
@@ -81,6 +82,7 @@ nav {
|
|||||||
.hero-section {
|
.hero-section {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
min-height: 520px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -88,6 +90,11 @@ nav {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero-section--compact {
|
||||||
|
height: 40vh;
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
#hero-canvas {
|
#hero-canvas {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -95,6 +102,7 @@ nav {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-content {
|
.hero-content {
|
||||||
@@ -102,11 +110,14 @@ nav {
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
|
min-height: 12rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-title {
|
.hero-title {
|
||||||
font-size: 4rem;
|
font-size: clamp(2.5rem, 8vw, 4rem);
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
|
line-height: 1.1;
|
||||||
|
min-height: 1.1em;
|
||||||
background: linear-gradient(45deg, var(--primary-color), var(--secondary-color));
|
background: linear-gradient(45deg, var(--primary-color), var(--secondary-color));
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
@@ -282,6 +293,41 @@ nav {
|
|||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: var(--text-color);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info-list {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info-item {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info-icon {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.form-control {
|
.form-control {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
@@ -376,7 +422,29 @@ nav {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown>a::after {
|
.dropdown.dropdown-open .dropdown-content {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dropdown-trigger {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--text-color);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dropdown-trigger:hover,
|
||||||
|
.nav-dropdown-trigger.active {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown .nav-dropdown-trigger::after {
|
||||||
content: ' ▼';
|
content: ' ▼';
|
||||||
font-size: 0.7em;
|
font-size: 0.7em;
|
||||||
margin-left: 5px;
|
margin-left: 5px;
|
||||||
@@ -387,12 +455,72 @@ nav {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0.25rem;
|
padding: 0.25rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-icon-link::after {
|
.profile-icon-link::after {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Trusted-by marquee */
|
||||||
|
.trusted-by-section {
|
||||||
|
padding: 2.5rem 0;
|
||||||
|
background: var(--bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trusted-by-label {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trusted-by-marquee {
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trusted-by-marquee-row {
|
||||||
|
display: flex;
|
||||||
|
width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trusted-by-track {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3rem;
|
||||||
|
padding-right: 3rem;
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trusted-by-logo img {
|
||||||
|
display: block;
|
||||||
|
height: 48px;
|
||||||
|
width: auto;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.trusted-by-marquee-row {
|
||||||
|
animation: trusted-by-scroll 30s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes trusted-by-scroll {
|
||||||
|
from {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.profile-icon {
|
.profile-icon {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
@@ -599,6 +727,21 @@ input:focus, select:focus, textarea:focus {
|
|||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footer-link-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link-btn:hover {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
.footer-separator {
|
.footer-separator {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin: 0 0.5rem;
|
margin: 0 0.5rem;
|
||||||
@@ -637,6 +780,14 @@ input:focus, select:focus, textarea:focus {
|
|||||||
margin: 0 0 1rem 1.5rem;
|
margin: 0 0 1rem 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.contact-captcha-group {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-captcha-group + .btn {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Cookie consent banner */
|
/* Cookie consent banner */
|
||||||
.cookie-consent-banner {
|
.cookie-consent-banner {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -727,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 843 KiB |
|
Before Width: | Height: | Size: 3.4 MiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 99 KiB |
@@ -1,4 +1,7 @@
|
|||||||
const canvas = document.getElementById('hero-canvas');
|
const canvas = document.getElementById('hero-canvas');
|
||||||
|
if (!canvas) {
|
||||||
|
// Hero animation only runs on the homepage.
|
||||||
|
} else {
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
let width, height;
|
let width, height;
|
||||||
@@ -109,3 +112,4 @@ function animate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
animate();
|
animate();
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
var CONSENT_KEY = 'aiml_analytics_consent';
|
var NOTICE_KEY = 'aiml_analytics_notice';
|
||||||
|
var LEGACY_CONSENT_KEY = 'aiml_analytics_consent';
|
||||||
|
var LEGACY_DISABLED_KEY = 'tianji.disabled';
|
||||||
var configEl = document.getElementById('tianji-config');
|
var configEl = document.getElementById('tianji-config');
|
||||||
if (!configEl) {
|
if (!configEl) {
|
||||||
return;
|
return;
|
||||||
@@ -10,19 +12,26 @@
|
|||||||
var trackerUrl = configEl.dataset.trackerUrl;
|
var trackerUrl = configEl.dataset.trackerUrl;
|
||||||
var websiteId = configEl.dataset.websiteId;
|
var websiteId = configEl.dataset.websiteId;
|
||||||
var userId = configEl.dataset.userId || '';
|
var userId = configEl.dataset.userId || '';
|
||||||
var pendingConsentEvent = false;
|
|
||||||
|
|
||||||
function getConsent() {
|
function getStorageItem(key) {
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem(CONSENT_KEY);
|
return localStorage.getItem(key);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setConsent(value) {
|
function setStorageItem(key, value) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(CONSENT_KEY, value);
|
localStorage.setItem(key, value);
|
||||||
|
} catch (e) {
|
||||||
|
/* ignore storage errors */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeStorageItem(key) {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(key);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
/* ignore storage errors */
|
/* ignore storage errors */
|
||||||
}
|
}
|
||||||
@@ -71,51 +80,44 @@
|
|||||||
|
|
||||||
script.onload = function () {
|
script.onload = function () {
|
||||||
identifyUser();
|
identifyUser();
|
||||||
if (pendingConsentEvent) {
|
|
||||||
trackEvent('consent_accepted');
|
|
||||||
pendingConsentEvent = false;
|
|
||||||
}
|
|
||||||
document.dispatchEvent(new CustomEvent('tianji:ready'));
|
document.dispatchEvent(new CustomEvent('tianji:ready'));
|
||||||
};
|
};
|
||||||
|
|
||||||
document.head.appendChild(script);
|
document.head.appendChild(script);
|
||||||
}
|
}
|
||||||
|
|
||||||
function acceptTracking() {
|
function migrateLegacyConsent() {
|
||||||
setConsent('accepted');
|
var legacyConsent = getStorageItem(LEGACY_CONSENT_KEY);
|
||||||
hideBanner();
|
var legacyDisabled = getStorageItem(LEGACY_DISABLED_KEY);
|
||||||
try {
|
|
||||||
localStorage.removeItem('tianji.disabled');
|
if (legacyConsent === 'accepted') {
|
||||||
} catch (e) {
|
setStorageItem(NOTICE_KEY, 'acknowledged');
|
||||||
/* ignore storage errors */
|
removeStorageItem(LEGACY_CONSENT_KEY);
|
||||||
|
removeStorageItem(LEGACY_DISABLED_KEY);
|
||||||
|
return 'migrated_acknowledged';
|
||||||
}
|
}
|
||||||
pendingConsentEvent = true;
|
|
||||||
loadTracker();
|
if (legacyConsent === 'declined' || legacyDisabled === '1') {
|
||||||
|
removeStorageItem(LEGACY_CONSENT_KEY);
|
||||||
|
removeStorageItem(LEGACY_DISABLED_KEY);
|
||||||
|
return 'migrated_declined';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function declineTracking() {
|
function acknowledgeNotice() {
|
||||||
setConsent('declined');
|
setStorageItem(NOTICE_KEY, 'acknowledged');
|
||||||
hideBanner();
|
hideBanner();
|
||||||
try {
|
window.aimlTrackWhenReady('notice_acknowledged');
|
||||||
localStorage.setItem('tianji.disabled', '1');
|
|
||||||
} catch (e) {
|
|
||||||
/* ignore storage errors */
|
|
||||||
}
|
|
||||||
if (window.aimlTrack) {
|
|
||||||
window.aimlTrack('consent_declined');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindBannerControls() {
|
function bindBannerControls() {
|
||||||
var acceptBtn = document.getElementById('cookie-consent-accept');
|
var acknowledgeBtn = document.getElementById('cookie-consent-acknowledge');
|
||||||
var declineBtn = document.getElementById('cookie-consent-decline');
|
|
||||||
var manageLinks = document.querySelectorAll('[data-open-cookie-preferences]');
|
var manageLinks = document.querySelectorAll('[data-open-cookie-preferences]');
|
||||||
|
|
||||||
if (acceptBtn) {
|
if (acknowledgeBtn) {
|
||||||
acceptBtn.addEventListener('click', acceptTracking);
|
acknowledgeBtn.addEventListener('click', acknowledgeNotice);
|
||||||
}
|
|
||||||
if (declineBtn) {
|
|
||||||
declineBtn.addEventListener('click', declineTracking);
|
|
||||||
}
|
}
|
||||||
manageLinks.forEach(function (link) {
|
manageLinks.forEach(function (link) {
|
||||||
link.addEventListener('click', function (event) {
|
link.addEventListener('click', function (event) {
|
||||||
@@ -140,16 +142,14 @@
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
function initConsent() {
|
function initNotice() {
|
||||||
bindBannerControls();
|
bindBannerControls();
|
||||||
|
loadTracker();
|
||||||
|
|
||||||
var consent = getConsent();
|
var migration = migrateLegacyConsent();
|
||||||
if (consent === 'accepted') {
|
var notice = getStorageItem(NOTICE_KEY);
|
||||||
hideBanner();
|
|
||||||
loadTracker();
|
if (notice === 'acknowledged' || migration === 'migrated_acknowledged') {
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (consent === 'declined') {
|
|
||||||
hideBanner();
|
hideBanner();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -157,5 +157,5 @@
|
|||||||
showBanner();
|
showBanner();
|
||||||
}
|
}
|
||||||
|
|
||||||
initConsent();
|
initNotice();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -0,0 +1,404 @@
|
|||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var configEl = document.getElementById('webmcp-config');
|
||||||
|
if (!configEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var modelContext = navigator.modelContext || document.modelContext;
|
||||||
|
if (!modelContext || typeof modelContext.registerTool !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var contactUrl = configEl.dataset.contactUrl || '/contact';
|
||||||
|
var pageName = configEl.dataset.pageName || '';
|
||||||
|
var recaptchaRequired = configEl.dataset.recaptchaRequired === 'true';
|
||||||
|
var services = [];
|
||||||
|
var pages = {};
|
||||||
|
var webDesignPricing = { features: [], included_with_every_site: [], disclaimer: '' };
|
||||||
|
|
||||||
|
try {
|
||||||
|
services = JSON.parse(configEl.dataset.services || '[]');
|
||||||
|
} catch (e) {
|
||||||
|
services = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
pages = JSON.parse(configEl.dataset.pages || '{}');
|
||||||
|
} catch (e) {
|
||||||
|
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) {
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCsrfToken() {
|
||||||
|
var match = document.cookie.match(/(?:^|;\s*)csrftoken=([^;]+)/);
|
||||||
|
return match ? decodeURIComponent(match[1]) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePageUrl(slug) {
|
||||||
|
var page = pages[slug];
|
||||||
|
if (page && page.url) {
|
||||||
|
return page.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
var service = services.find(function (entry) {
|
||||||
|
return entry.slug === slug;
|
||||||
|
});
|
||||||
|
return service ? service.url : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findPage(query) {
|
||||||
|
var normalized = String(query || '').trim().toLowerCase();
|
||||||
|
if (!normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var keys = Object.keys(pages);
|
||||||
|
for (var i = 0; i < keys.length; i += 1) {
|
||||||
|
var slug = keys[i];
|
||||||
|
var page = pages[slug];
|
||||||
|
if (
|
||||||
|
slug.toLowerCase() === normalized ||
|
||||||
|
page.name.toLowerCase() === normalized ||
|
||||||
|
page.name.toLowerCase().indexOf(normalized) !== -1
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
slug: slug,
|
||||||
|
name: page.name,
|
||||||
|
summary: page.summary,
|
||||||
|
url: page.url || resolvePageUrl(slug),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
if (!recaptchaRequired) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.grecaptcha || typeof window.grecaptcha.execute !== 'function') {
|
||||||
|
throw new Error(
|
||||||
|
'reCAPTCHA is required in production. Use the HTML contact form in a browser session, ' +
|
||||||
|
'or enable DEBUG for audit demos without captcha.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
var widget = document.querySelector('.g-recaptcha');
|
||||||
|
var siteKey = widget ? widget.getAttribute('data-sitekey') : null;
|
||||||
|
if (!siteKey) {
|
||||||
|
var script = document.querySelector('script[src*="recaptcha"]');
|
||||||
|
if (script && script.src) {
|
||||||
|
var keyMatch = script.src.match(/[?&]render=([^&]+)/);
|
||||||
|
siteKey = keyMatch ? decodeURIComponent(keyMatch[1]) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!siteKey) {
|
||||||
|
throw new Error('reCAPTCHA site key not found on the contact page.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.grecaptcha.execute(siteKey, { action: 'contact' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitContactInquiry(input) {
|
||||||
|
var formData = new FormData();
|
||||||
|
formData.append('name', input.name);
|
||||||
|
formData.append('email', input.email);
|
||||||
|
formData.append('subject', input.subject);
|
||||||
|
formData.append('message', input.message || '');
|
||||||
|
formData.append('csrfmiddlewaretoken', getCsrfToken());
|
||||||
|
|
||||||
|
if (recaptchaRequired) {
|
||||||
|
var token = await getRecaptchaToken();
|
||||||
|
formData.append('g-recaptcha-response', token);
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = await fetch(contactUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: {
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
var html = await response.text();
|
||||||
|
if (html.indexOf("We'll be in contact shortly!") !== -1) {
|
||||||
|
return textResult({
|
||||||
|
success: true,
|
||||||
|
message: 'Contact inquiry submitted successfully.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (html.indexOf('There was an error submitting') !== -1) {
|
||||||
|
return textResult({
|
||||||
|
success: false,
|
||||||
|
error: 'Submission failed validation (often reCAPTCHA in production).',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (html.indexOf('is required') !== -1) {
|
||||||
|
return textResult({
|
||||||
|
success: false,
|
||||||
|
error: 'Missing required fields: name, email, and subject are required.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return textResult({
|
||||||
|
success: false,
|
||||||
|
error: 'Unexpected response from contact endpoint.',
|
||||||
|
status: response.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
modelContext.registerTool({
|
||||||
|
name: 'list_services',
|
||||||
|
description: 'List AI ML Operations service pages with name, URL, and summary.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
|
annotations: { readOnlyHint: true },
|
||||||
|
execute: function () {
|
||||||
|
var results = services.map(function (service) {
|
||||||
|
return {
|
||||||
|
name: service.name,
|
||||||
|
slug: service.slug,
|
||||||
|
url: service.url || resolvePageUrl(service.slug),
|
||||||
|
summary: service.summary,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return textResult({ services: results });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
modelContext.registerTool({
|
||||||
|
name: 'get_page_content',
|
||||||
|
description: 'Look up a public page by slug or service name and return its URL and summary.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
query: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Page slug (e.g. forward_deployed) or service name (e.g. AI Agents).',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['query'],
|
||||||
|
},
|
||||||
|
annotations: { readOnlyHint: true },
|
||||||
|
execute: function (input) {
|
||||||
|
var page = findPage(input.query);
|
||||||
|
if (!page) {
|
||||||
|
return textResult({
|
||||||
|
success: false,
|
||||||
|
error: 'No page found for query: ' + input.query,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return textResult({ success: true, page: page });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
modelContext.registerTool({
|
||||||
|
name: 'navigate_to_service',
|
||||||
|
description: 'Resolve a service slug or name to its canonical marketing page URL.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
service: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Service slug or display name.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['service'],
|
||||||
|
},
|
||||||
|
annotations: { readOnlyHint: true },
|
||||||
|
execute: function (input) {
|
||||||
|
var page = findPage(input.service);
|
||||||
|
if (!page) {
|
||||||
|
return textResult({
|
||||||
|
success: false,
|
||||||
|
error: 'Unknown service: ' + input.service,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return textResult({
|
||||||
|
success: true,
|
||||||
|
url: page.url,
|
||||||
|
name: page.name,
|
||||||
|
summary: page.summary,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
modelContext.registerTool({
|
||||||
|
name: 'open_contact_with_subject',
|
||||||
|
description: 'Build a contact page URL with a pre-filled subject for hosting, pricing, or service inquiries.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
subject: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Subject line to pre-fill on the contact form.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['subject'],
|
||||||
|
},
|
||||||
|
annotations: { readOnlyHint: true },
|
||||||
|
execute: function (input) {
|
||||||
|
var url = new URL(contactUrl, window.location.origin);
|
||||||
|
url.searchParams.set('subject', input.subject);
|
||||||
|
return textResult({
|
||||||
|
success: true,
|
||||||
|
url: url.toString(),
|
||||||
|
subject: input.subject,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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') {
|
||||||
|
modelContext.registerTool({
|
||||||
|
name: 'submit_contact_inquiry',
|
||||||
|
description: 'Submit a contact inquiry to AI ML Operations. Requires name, email, and subject.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
name: { type: 'string', description: 'Sender full name.' },
|
||||||
|
email: { type: 'string', description: 'Sender email address.' },
|
||||||
|
subject: { type: 'string', description: 'Inquiry subject line.' },
|
||||||
|
message: { type: 'string', description: 'Optional message body.' },
|
||||||
|
},
|
||||||
|
required: ['name', 'email', 'subject'],
|
||||||
|
},
|
||||||
|
annotations: { readOnlyHint: false },
|
||||||
|
execute: submitContactInquiry,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -44,23 +44,34 @@
|
|||||||
{% if user.is_authenticated %}data-user-id="{{ user.pk }}"{% endif %}
|
{% if user.is_authenticated %}data-user-id="{{ user.pk }}"{% endif %}
|
||||||
hidden></div>
|
hidden></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if webmcp_enabled %}
|
||||||
|
<div id="webmcp-config"
|
||||||
|
data-page-name="{{ webmcp_page_name }}"
|
||||||
|
data-contact-url="{{ webmcp_contact_url }}"
|
||||||
|
data-recaptcha-required="{{ webmcp_recaptcha_required|yesno:'true,false' }}"
|
||||||
|
data-services='{{ webmcp_services_json|escapejs }}'
|
||||||
|
data-pages='{{ webmcp_pages_json|escapejs }}'
|
||||||
|
data-web-design-pricing='{{ webmcp_web_design_pricing_json|escapejs }}'
|
||||||
|
hidden></div>
|
||||||
|
{% endif %}
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<!-- Navigation Bar -->
|
<!-- Navigation Bar -->
|
||||||
<nav>
|
<nav aria-label="Main navigation">
|
||||||
<a href="{% url 'public_index' %}" class="brand-logo">
|
<a href="{% url 'public_index' %}" class="brand-logo">
|
||||||
<img src="{% static 'public/img/logo.png' %}" alt="AI ML Operations" class="brand-logo-img" width="243" height="28">
|
<img src="{% static 'public/img/logo.png' %}" alt="AI ML Operations" class="brand-logo-img" width="243" height="28">
|
||||||
</a>
|
</a>
|
||||||
<button class="mobile-menu-btn" aria-label="Menu">☰</button>
|
<button type="button" class="mobile-menu-btn" aria-label="Open menu" aria-expanded="false" aria-controls="main-nav-links">☰</button>
|
||||||
<ul class="nav-links">
|
<ul class="nav-links" id="main-nav-links">
|
||||||
<li><a href="{% url 'public_index' %}"
|
<li><a href="{% url 'public_index' %}"
|
||||||
class="{% if request.resolver_match.url_name == 'public_index' %}active{% endif %}">Home</a></li>
|
class="{% if request.resolver_match.url_name == 'public_index' %}active{% endif %}">Home</a></li>
|
||||||
<li class="dropdown">
|
<li class="dropdown">
|
||||||
<a href="#"
|
<button type="button" class="nav-dropdown-trigger{% if request.resolver_match.url_name in 'forward_deployed,ai_education,ai_sensor,bot,chat,computers,ml_model,web_design' %} active{% endif %}"
|
||||||
class="{% if request.resolver_match.url_name in 'forward_deployed,ai_education,ai_sensor,bot,chat,computers,file_hosting,ml_model,web_design' %}active{% endif %}">Services</a>
|
id="services-menu-button" aria-expanded="false" aria-haspopup="true" aria-controls="services-menu">Services</button>
|
||||||
<ul class="dropdown-content">
|
<ul class="dropdown-content" id="services-menu" role="menu" aria-labelledby="services-menu-button">
|
||||||
<li><a href="{% url 'forward_deployed' %}">Forward-Deployed AI</a></li>
|
<li><a href="{% url 'forward_deployed' %}">Forward-Deployed AI</a></li>
|
||||||
<li><a href="{% url 'bot' %}">AI Agents</a></li>
|
<li><a href="{% url 'bot' %}">AI Agents</a></li>
|
||||||
<li><a href="{% url 'ml_model' %}">ML Models</a></li>
|
<li><a href="{% url 'ml_model' %}">ML Models</a></li>
|
||||||
@@ -68,7 +79,6 @@
|
|||||||
<li><a href="{% url 'ai_sensor' %}">Sensors</a></li>
|
<li><a href="{% url 'ai_sensor' %}">Sensors</a></li>
|
||||||
<li><a href="{% url 'ai_education' %}">Education</a></li>
|
<li><a href="{% url 'ai_education' %}">Education</a></li>
|
||||||
<li><a href="{% url 'computers' %}">Hardware</a></li>
|
<li><a href="{% url 'computers' %}">Hardware</a></li>
|
||||||
<li><a href="{% url 'file_hosting' %}">Hosting</a></li>
|
|
||||||
<li><a href="{% url 'web_design' %}">Web Design</a></li>
|
<li><a href="{% url 'web_design' %}">Web Design</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
@@ -79,19 +89,33 @@
|
|||||||
<li><a href="{% url 'planning:board_view' %}"
|
<li><a href="{% url 'planning:board_view' %}"
|
||||||
class="{% if 'planning' in request.path %}active{% endif %}"
|
class="{% if 'planning' in request.path %}active{% endif %}"
|
||||||
data-tianji-event="nav_planning">Planning</a></li>
|
data-tianji-event="nav_planning">Planning</a></li>
|
||||||
<li><a href="{% url 'financial_index' %}"
|
{% if has_financial_access %}
|
||||||
|
<li><a href="{% url 'financial_home' %}"
|
||||||
class="{% if 'financial' in request.path %}active{% endif %}"
|
class="{% if 'financial' in request.path %}active{% endif %}"
|
||||||
data-tianji-event="nav_financials">Financials</a></li>
|
data-tianji-event="nav_financials">Financials</a></li>
|
||||||
|
{% endif %}
|
||||||
<li class="dropdown" id="user-profile-dropdown">
|
<li class="dropdown" id="user-profile-dropdown">
|
||||||
<a href="#" class="profile-icon-link" title="{{ user.get_full_name|default:user.username }}">
|
<button type="button" class="profile-icon-link" aria-label="Account menu for {{ user.get_full_name|default:user.username }}"
|
||||||
|
aria-expanded="false" aria-haspopup="true" aria-controls="profile-menu">
|
||||||
<svg class="profile-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
|
<svg class="profile-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
|
||||||
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
<circle cx="12" cy="8" r="4" />
|
<circle cx="12" cy="8" r="4" />
|
||||||
<path d="M4 21v-1a6 6 0 0 1 12 0v1" />
|
<path d="M4 21v-1a6 6 0 0 1 12 0v1" />
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</button>
|
||||||
<ul class="dropdown-content profile-dropdown-content">
|
<ul class="dropdown-content profile-dropdown-content" id="profile-menu" role="menu">
|
||||||
<li class="profile-name-item">{{ user.get_full_name|default:user.username }}</li>
|
<li class="profile-name-item">{{ user.get_full_name|default:user.username }}</li>
|
||||||
|
{% if has_financial_access %}
|
||||||
|
<li><a href="{% url 'profile' %}">Profile</a></li>
|
||||||
|
{% endif %}
|
||||||
|
{% 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>
|
||||||
|
{% elif user.is_staff %}
|
||||||
|
<li><a href="{% url 'utm_dashboard' %}">UTM Analytics</a></li>
|
||||||
|
<li><a href="{% url 'leads_list' %}">Leads</a></li>
|
||||||
|
{% endif %}
|
||||||
<li><a href="{% url 'change_password' %}">Change Password</a></li>
|
<li><a href="{% url 'change_password' %}">Change Password</a></li>
|
||||||
<li>
|
<li>
|
||||||
<form action="{% url 'logout' %}" method="post" style="margin: 0;">
|
<form action="{% url 'logout' %}" method="post" style="margin: 0;">
|
||||||
@@ -123,7 +147,7 @@
|
|||||||
<a href="{% url 'terms_of_service' %}" data-tianji-event="footer_terms">Terms & Privacy</a>
|
<a href="{% url 'terms_of_service' %}" data-tianji-event="footer_terms">Terms & Privacy</a>
|
||||||
{% if tianji_enabled %}
|
{% if tianji_enabled %}
|
||||||
<span class="footer-separator">|</span>
|
<span class="footer-separator">|</span>
|
||||||
<a href="#" data-open-cookie-preferences data-tianji-event="footer_cookie_preferences">Cookie Preferences</a>
|
<button type="button" class="footer-link-btn" data-open-cookie-preferences data-tianji-event="footer_cookie_preferences">Cookie Preferences</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
<p class="footer-text footer-text--muted">© 2023 -
|
<p class="footer-text footer-text--muted">© 2023 -
|
||||||
@@ -133,23 +157,27 @@
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
{% if tianji_enabled %}
|
{% if tianji_enabled %}
|
||||||
<div id="cookie-consent-banner" class="cookie-consent-banner" hidden role="dialog" aria-live="polite"
|
<div id="cookie-consent-banner" class="cookie-consent-banner" hidden role="dialog" aria-modal="true"
|
||||||
aria-label="Cookie consent">
|
aria-labelledby="cookie-consent-title" aria-describedby="cookie-consent-description">
|
||||||
<div class="cookie-consent-content">
|
<div class="cookie-consent-content">
|
||||||
<p class="cookie-consent-text">
|
<h2 id="cookie-consent-title" class="visually-hidden">Analytics notice</h2>
|
||||||
<span class="cookie-consent-text-full">We use analytics tracking to understand how visitors use our site. Tracking runs only if you accept.
|
<p class="cookie-consent-text" id="cookie-consent-description">
|
||||||
|
<span class="cookie-consent-text-full">We use analytics to understand how visitors use our site. This helps us improve performance and content.
|
||||||
See our <a href="{% url 'terms_of_service' %}">Terms of Service & Privacy Policy</a> for details.</span>
|
See our <a href="{% url 'terms_of_service' %}">Terms of Service & Privacy Policy</a> for details.</span>
|
||||||
<span class="cookie-consent-text-short">We use analytics if you accept. <a href="{% url 'terms_of_service' %}">Privacy Policy</a></span>
|
<span class="cookie-consent-text-short">We use analytics on this site. <a href="{% url 'terms_of_service' %}">Privacy Policy</a></span>
|
||||||
</p>
|
</p>
|
||||||
<div class="cookie-consent-actions">
|
<div class="cookie-consent-actions">
|
||||||
<button type="button" id="cookie-consent-decline" class="btn btn-outline">Decline</button>
|
<button type="button" id="cookie-consent-acknowledge" class="btn">Acknowledge</button>
|
||||||
<button type="button" id="cookie-consent-accept" class="btn">Accept Analytics</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="{% static 'public/js/tianji-consent.js' %}"></script>
|
<script src="{% static 'public/js/tianji-consent.js' %}"></script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if webmcp_enabled %}
|
||||||
|
<script src="{% static 'public/js/webmcp-tools.js' %}"></script>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
const mobileBtn = document.querySelector('.mobile-menu-btn');
|
const mobileBtn = document.querySelector('.mobile-menu-btn');
|
||||||
@@ -157,9 +185,41 @@
|
|||||||
|
|
||||||
if (mobileBtn && navLinks) {
|
if (mobileBtn && navLinks) {
|
||||||
mobileBtn.addEventListener('click', function () {
|
mobileBtn.addEventListener('click', function () {
|
||||||
navLinks.classList.toggle('active');
|
const isOpen = navLinks.classList.toggle('active');
|
||||||
|
mobileBtn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||||
|
mobileBtn.setAttribute('aria-label', isOpen ? 'Close menu' : 'Open menu');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeDropdown(dropdown) {
|
||||||
|
dropdown.classList.remove('dropdown-open');
|
||||||
|
const trigger = dropdown.querySelector('.nav-dropdown-trigger, .profile-icon-link');
|
||||||
|
if (trigger) {
|
||||||
|
trigger.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.dropdown').forEach(function (dropdown) {
|
||||||
|
const trigger = dropdown.querySelector('.nav-dropdown-trigger, .profile-icon-link');
|
||||||
|
if (!trigger) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
trigger.addEventListener('click', function (event) {
|
||||||
|
if (window.matchMedia('(min-width: 769px)').matches && dropdown.querySelector('.nav-dropdown-trigger')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
const isOpen = dropdown.classList.toggle('dropdown-open');
|
||||||
|
trigger.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', function (event) {
|
||||||
|
if (!event.target.closest('.dropdown')) {
|
||||||
|
document.querySelectorAll('.dropdown.dropdown-open').forEach(closeDropdown);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% block tracking_events %}{% endblock %}
|
{% block tracking_events %}{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{% include "django_recaptcha/includes/js_v3.html" %}
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="{{ widget.name }}"
|
||||||
|
class="g-recaptcha"
|
||||||
|
form="contact-form"
|
||||||
|
{% for name, value in widget.attrs.items %}{% if value is not False %} {{ name }}{% if value is not True %}="{{ value|stringformat:'s' }}"{% endif %}{% endif %}{% endfor %}
|
||||||
|
>
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="color-scheme" content="dark">
|
||||||
|
<meta name="supported-color-schemes" content="dark">
|
||||||
|
<title>{% block title %}{{ brand_name|default:"AI ML Operations" }}{% endblock %}</title>
|
||||||
|
<!--[if mso]>
|
||||||
|
<style type="text/css">
|
||||||
|
body, table, td { font-family: Arial, Helvetica, sans-serif !important; }
|
||||||
|
</style>
|
||||||
|
<![endif]-->
|
||||||
|
<style type="text/css">
|
||||||
|
body, table, td, a {
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-ms-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
table, td {
|
||||||
|
mso-table-lspace: 0pt;
|
||||||
|
mso-table-rspace: 0pt;
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
border: 0;
|
||||||
|
height: auto;
|
||||||
|
line-height: 100%;
|
||||||
|
outline: none;
|
||||||
|
text-decoration: none;
|
||||||
|
-ms-interpolation-mode: bicubic;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
width: 100% !important;
|
||||||
|
background-color: #0a0a0a;
|
||||||
|
color: #e0e0e0;
|
||||||
|
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
a { color: #00f3ff; }
|
||||||
|
.email-btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 14px 28px;
|
||||||
|
background-color: #00f3ff;
|
||||||
|
color: #0a0a0a !important;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 30px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 14px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.muted { color: #a0a0a0; font-size: 13px; line-height: 1.5; }
|
||||||
|
.amount { font-size: 22px; font-weight: 700; color: #00f3ff; margin: 12px 0; }
|
||||||
|
.field-label { color: #a0a0a0; font-size: 12px; text-transform: uppercase; letter-spacing: 0.6px; margin: 0 0 4px; }
|
||||||
|
.field-value { color: #e0e0e0; font-size: 15px; margin: 0 0 16px; line-height: 1.5; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;padding:0;background-color:#0a0a0a;">
|
||||||
|
{% block preheader %}{% endblock %}
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color:#0a0a0a;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding:32px 16px;">
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="max-width:600px;background-color:#1a1a1a;border:1px solid rgba(255,255,255,0.1);">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding:28px 24px 20px;border-bottom:1px solid rgba(0,243,255,0.35);">
|
||||||
|
{% if logo_url %}
|
||||||
|
<a href="{{ site_url|default:'https://aimloperations.com' }}" style="text-decoration:none;">
|
||||||
|
<img src="{{ logo_url }}" alt="{{ brand_name|default:'AI ML Operations' }}" width="220" style="display:block;width:220px;max-width:80%;height:auto;">
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<p style="margin:0;font-size:18px;font-weight:700;letter-spacing:1px;color:#e0e0e0;text-transform:uppercase;">
|
||||||
|
{{ brand_name|default:"AI ML Operations" }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
{% block header_extra %}{% endblock %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="height:3px;line-height:3px;font-size:0;background-color:#bc13fe;"> </td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:28px 28px 8px;color:#e0e0e0;font-size:15px;line-height:1.6;font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:8px 28px 28px;color:#a0a0a0;font-size:12px;line-height:1.5;text-align:center;border-top:1px solid rgba(255,255,255,0.08);font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
|
||||||
|
{% block footer %}
|
||||||
|
<p style="margin:16px 0 8px;color:#a0a0a0;">
|
||||||
|
{% block footer_note %}{% endblock %}
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 4px;color:#a0a0a0;">
|
||||||
|
© {% now "Y" %} {{ brand_legal|default:"AI ML Operations, LLC" }}. All rights reserved.
|
||||||
|
</p>
|
||||||
|
<p style="margin:0;color:#a0a0a0;">
|
||||||
|
<a href="{{ site_url|default:'https://aimloperations.com' }}" style="color:#00f3ff;text-decoration:none;">aimloperations.com</a>
|
||||||
|
· Forward-deployed AI engineering
|
||||||
|
</p>
|
||||||
|
{% endblock %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,110 +1,21 @@
|
|||||||
<!DOCTYPE html>
|
{% extends "emails/base_email.html" %}
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>New Feedback Submission</title>
|
|
||||||
<style>
|
|
||||||
/* Basic reset for email clients */
|
|
||||||
body, table, td, a {
|
|
||||||
-webkit-text-size-adjust: 100%;
|
|
||||||
-ms-text-size-adjust: 100%;
|
|
||||||
}
|
|
||||||
table, td {
|
|
||||||
mso-table-lspace: 0pt;
|
|
||||||
mso-table-rspace: 0pt;
|
|
||||||
}
|
|
||||||
img {
|
|
||||||
border: 0;
|
|
||||||
height: auto;
|
|
||||||
line-height: 100%;
|
|
||||||
outline: none;
|
|
||||||
text-decoration: none;
|
|
||||||
-ms-interpolation-mode: bicubic;
|
|
||||||
}
|
|
||||||
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: #007BFF;
|
|
||||||
color: #ffffff;
|
|
||||||
padding: 20px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.content {
|
|
||||||
padding: 20px;
|
|
||||||
color: #333333;
|
|
||||||
}
|
|
||||||
.footer {
|
|
||||||
background-color: #f4f4f4;
|
|
||||||
color: #777777;
|
|
||||||
text-align: center;
|
|
||||||
padding: 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.feedback-title {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
.feedback-text {
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" align="center">
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<!-- Email Container -->
|
|
||||||
<div class="email-container">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="header">
|
|
||||||
<h1>New Contact Request</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Content -->
|
{% block title %}New Contact Request{% endblock %}
|
||||||
<div class="content">
|
|
||||||
<p>Hello,</p>
|
|
||||||
<p>A new feedback item has been submitted. Here are the details:</p>
|
|
||||||
|
|
||||||
<!-- Feedback Title -->
|
{% block content %}
|
||||||
<div class="feedback-title">
|
<p style="margin:0 0 16px;color:#e0e0e0;">Hello,</p>
|
||||||
Subject: <strong>{{ subject }}</strong>
|
<p style="margin:0 0 24px;color:#e0e0e0;">A new contact request was submitted on the site.</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Email -->
|
<p class="field-label" style="color:#a0a0a0;font-size:12px;text-transform:uppercase;letter-spacing:0.6px;margin:0 0 4px;">Subject</p>
|
||||||
<div class="feedback-title">
|
<p class="field-value" style="color:#e0e0e0;font-size:15px;margin:0 0 16px;"><strong>{{ subject }}</strong></p>
|
||||||
Email: <strong>{{ email }}</strong>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Feedback Text -->
|
<p class="field-label" style="color:#a0a0a0;font-size:12px;text-transform:uppercase;letter-spacing:0.6px;margin:0 0 4px;">From</p>
|
||||||
<div class="feedback-text">
|
<p class="field-value" style="color:#e0e0e0;font-size:15px;margin:0 0 16px;">
|
||||||
<strong>Message:</strong><br>
|
<a href="mailto:{{ email }}" style="color:#00f3ff;text-decoration:none;">{{ email }}</a>
|
||||||
{{ message }}
|
</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<p>Thank you for your attention.</p>
|
<p class="field-label" style="color:#a0a0a0;font-size:12px;text-transform:uppercase;letter-spacing:0.6px;margin:0 0 4px;">Message</p>
|
||||||
</div>
|
<p class="field-value" style="color:#e0e0e0;font-size:15px;margin:0 0 16px;white-space:pre-wrap;">{{ message }}</p>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
<!-- Footer -->
|
{% block footer_note %}This is an automated message. Please do not reply to this email.{% endblock %}
|
||||||
<div class="footer">
|
|
||||||
<p>This is an automated message. Please do not reply to this email.</p>
|
|
||||||
<p>© 2025 AI ML Operations, LLC. All rights reserved.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|||||||
@@ -1,3 +1,12 @@
|
|||||||
New Contact Request for AI ML Operations, LLC
|
New Contact Request — {{ brand_legal|default:"AI ML Operations, LLC" }}
|
||||||
|
|
||||||
"New Contact. {{ subject }} from {{ email }}. {{ message }}"
|
Subject: {{ subject }}
|
||||||
|
From: {{ email }}
|
||||||
|
|
||||||
|
Message:
|
||||||
|
{{ message }}
|
||||||
|
|
||||||
|
—
|
||||||
|
{{ brand_legal|default:"AI ML Operations, LLC" }}
|
||||||
|
{{ site_url|default:"https://aimloperations.com" }}
|
||||||
|
Forward-deployed AI engineering
|
||||||
|
|||||||
@@ -1,61 +1,15 @@
|
|||||||
<!DOCTYPE html>
|
{% extends "emails/base_email.html" %}
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Email Template</title>
|
|
||||||
<!-- Materialize CSS -->
|
|
||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
background-color: #f4f4f4;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.email-container {
|
|
||||||
max-width: 600px;
|
|
||||||
margin: 0 auto;
|
|
||||||
background-color: #ffffff;
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
.header {
|
|
||||||
background-color: #37474f;
|
|
||||||
color: #ffffff;
|
|
||||||
padding: 20px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.content {
|
|
||||||
padding: 20px;
|
|
||||||
color: #333333;
|
|
||||||
}
|
|
||||||
.footer {
|
|
||||||
background-color: #333;
|
|
||||||
padding: 10px;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 12px;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="email-container">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="header">
|
|
||||||
<h4>{{ title | safe }}</h4>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Content -->
|
{% block title %}{{ title }}{% endblock %}
|
||||||
<div class="content">
|
|
||||||
{{ content | safe }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Footer -->
|
{% block header_extra %}
|
||||||
<div class="footer">
|
{% if title %}
|
||||||
<p>© 2025 AI ML Operations, LLC. All rights reserved.</p>
|
<p style="margin:16px 0 0;font-size:18px;font-weight:600;color:#e0e0e0;line-height:1.4;">{{ title|safe }}</p>
|
||||||
</div>
|
{% endif %}
|
||||||
</div>
|
{% endblock %}
|
||||||
</body>
|
|
||||||
</html>
|
{% block content %}
|
||||||
|
{{ content|safe }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block footer_note %}{% endblock %}
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
{{ subject }}
|
{{ title }}
|
||||||
|
|
||||||
{{ content }}
|
{{ content }}
|
||||||
|
|
||||||
|
—
|
||||||
|
{{ brand_legal|default:"AI ML Operations, LLC" }}
|
||||||
|
{{ site_url|default:"https://aimloperations.com" }}
|
||||||
|
Forward-deployed AI engineering
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<!-- Hero Section -->
|
<!-- Hero Section -->
|
||||||
<div class="hero-section" style="height: 40vh; min-height: 300px;">
|
<div class="hero-section hero-section--compact">
|
||||||
<div class="hero-content">
|
<div class="hero-content">
|
||||||
<h1 class="hero-title">Get in Touch</h1>
|
<h1 class="hero-title">Get in Touch</h1>
|
||||||
<p class="hero-subtitle hero-subtitle--wide">Describe the workflow or bottleneck you want to automate—we will help you scope a forward-deployed solution.</p>
|
<p class="hero-subtitle hero-subtitle--wide">Describe the workflow or bottleneck you want to automate—we will help you scope a forward-deployed solution.</p>
|
||||||
@@ -32,52 +32,69 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h4 class="card-title" style="margin-bottom: 2rem;">Send Us a Message</h4>
|
<h2 class="card-title contact-form-heading" id="contact-form-heading" style="margin-bottom: 2rem;">Send Us a Message</h2>
|
||||||
<form action="{% url 'contact' %}" method="POST">
|
<form id="contact-form" action="{% url 'contact' %}" method="POST" aria-labelledby="contact-form-heading"
|
||||||
|
toolname="submit_contact_inquiry"
|
||||||
|
tooldescription="Submit a contact inquiry to AI ML Operations">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<input type="text" class="form-control" name="name" placeholder="Your Name">
|
<label class="form-label" for="contact-name">Your Name</label>
|
||||||
|
<input type="text" class="form-control" name="name" id="contact-name" autocomplete="name" required
|
||||||
|
toolparamdescription="Full name of the person submitting the inquiry.">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<input type="email" class="form-control" name="email" placeholder="Your Email">
|
<label class="form-label" for="contact-email">Your Email</label>
|
||||||
|
<input type="email" class="form-control" name="email" id="contact-email" autocomplete="email" required
|
||||||
|
toolparamdescription="Email address where AI ML Operations can reply.">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<input type="text" class="form-control" name="subject" id="contact-subject" placeholder="Subject" value="{{ request.GET.subject|default:'' }}">
|
<label class="form-label" for="contact-subject">Subject</label>
|
||||||
|
<input type="text" class="form-control" name="subject" id="contact-subject" value="{{ request.GET.subject|default:'' }}" required
|
||||||
|
toolparamdescription="Short summary of the inquiry topic or service of interest.">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<textarea name="message" class="form-control" rows="5" placeholder="What workflow is costing you the most time? What systems does it touch?"></textarea>
|
<label class="form-label" for="contact-message">Message</label>
|
||||||
</div>
|
<textarea name="message" class="form-control" id="contact-message" rows="5"
|
||||||
|
placeholder="What workflow is costing you the most time? What systems does it touch?"
|
||||||
<div class="form-group">
|
toolparamdescription="Optional details about the workflow, bottleneck, or systems involved."></textarea>
|
||||||
{% if capchaForm %}
|
|
||||||
{{ capchaForm }}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if not capchaForm %}
|
||||||
<button class="btn" type="submit"
|
<button class="btn" type="submit"
|
||||||
data-tianji-event="contact_form_submit">
|
data-tianji-event="contact_form_submit">
|
||||||
Send Message
|
Send Message
|
||||||
</button>
|
</button>
|
||||||
|
{% endif %}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{% if capchaForm %}
|
||||||
|
<div class="form-group contact-captcha-group">
|
||||||
|
{{ capchaForm }}
|
||||||
|
</div>
|
||||||
|
<button class="btn" type="submit" form="contact-form"
|
||||||
|
data-tianji-event="contact_form_submit">
|
||||||
|
Send Message
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Info Column -->
|
<!-- Info Column -->
|
||||||
<div>
|
<div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h5 class="card-title">Contact Information</h5>
|
<h3 class="card-title">Contact Information</h3>
|
||||||
<ul style="list-style: none;">
|
<ul class="contact-info-list">
|
||||||
<li style="margin-bottom: 1rem; display: flex; align-items: center; gap: 1rem;">
|
<li class="contact-info-item">
|
||||||
<span style="color: var(--primary-color);">📞</span>
|
<span class="contact-info-icon" aria-hidden="true">📞</span>
|
||||||
<p style="color: var(--text-muted);">+1 (330) 402-2675</p>
|
<a href="tel:+13304022675">+1 (330) 402-2675</a>
|
||||||
</li>
|
</li>
|
||||||
<li style="margin-bottom: 1rem; display: flex; align-items: center; gap: 1rem;">
|
<li class="contact-info-item">
|
||||||
<span style="color: var(--primary-color);">✉️</span>
|
<span class="contact-info-icon" aria-hidden="true">✉️</span>
|
||||||
<p style="color: var(--text-muted);">ryan@aimloperations.com</p>
|
<a href="mailto:ryan@aimloperations.com">ryan@aimloperations.com</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% load static %}
|
|
||||||
|
|
||||||
{% block title %}File Hosting - AI ML Operations, LLC{% endblock %}
|
|
||||||
{% block meta_description %}Secure, scalable, and reliable file hosting with weekly backups by AI ML Operations, LLC.
|
|
||||||
Protect your data with our advanced storage solutions.{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
|
|
||||||
<!-- Hero Section -->
|
|
||||||
<div class="hero-section" style="height: 40vh; min-height: 300px;">
|
|
||||||
<div class="hero-content">
|
|
||||||
<h1 class="hero-title">File Hosting</h1>
|
|
||||||
<p class="hero-subtitle">Secure, Scalable, and Reliable File Storage with Weekly Backups</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- About File Hosting Section -->
|
|
||||||
<div class="section">
|
|
||||||
<div class="container">
|
|
||||||
<h2 class="section-title">About Our File Hosting Service</h2>
|
|
||||||
<p style="text-align: center; max-width: 800px; margin: 0 auto; color: var(--text-muted); font-size: 1.1rem;">
|
|
||||||
At AI ML Operations, we provide secure and scalable file hosting solutions tailored to your business needs. Our
|
|
||||||
platform ensures your data is always accessible, protected, and backed up with weekly backups for added peace of
|
|
||||||
mind. Whether you're storing critical business documents, media files, or large datasets, our file hosting service
|
|
||||||
is designed to meet your requirements with reliability and efficiency.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Features Section -->
|
|
||||||
<div class="section" style="background: var(--surface-color);">
|
|
||||||
<div class="container">
|
|
||||||
<h2 class="section-title">What We Offer</h2>
|
|
||||||
<div class="card-grid">
|
|
||||||
<div class="card" style="text-align: center;">
|
|
||||||
<h5 class="card-title">Secure Storage</h5>
|
|
||||||
<p class="card-text">Your files are stored securely with advanced encryption protocols.</p>
|
|
||||||
</div>
|
|
||||||
<div class="card" style="text-align: center;">
|
|
||||||
<h5 class="card-title">Weekly Backups</h5>
|
|
||||||
<p class="card-text">Automatic weekly backups to ensure your data is always safe.</p>
|
|
||||||
</div>
|
|
||||||
<div class="card" style="text-align: center;">
|
|
||||||
<h5 class="card-title">Scalable Solutions</h5>
|
|
||||||
<p class="card-text">Easily scale your storage as your business grows.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Benefits Section -->
|
|
||||||
<div class="section">
|
|
||||||
<div class="container">
|
|
||||||
<h2 class="section-title">Why Choose Us?</h2>
|
|
||||||
<div class="card-grid">
|
|
||||||
<div class="card">
|
|
||||||
<img src="{% static 'public/img/file_hosting/card-1.jpg' %}" alt="Data Security"
|
|
||||||
style="width: 100%; border-radius: 8px; margin-bottom: 1rem;">
|
|
||||||
<span class="card-title">Data Security</span>
|
|
||||||
<p class="card-text">Advanced encryption and access controls to protect your files.</p>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<img src="{% static 'public/img/file_hosting/card-2.jpg' %}" alt="Reliability"
|
|
||||||
style="width: 100%; border-radius: 8px; margin-bottom: 1rem;">
|
|
||||||
<span class="card-title">Reliability</span>
|
|
||||||
<p class="card-text">99.9% uptime guarantee for uninterrupted access to your files.</p>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<img src="{% static 'public/img/file_hosting/card-3.jpg' %}" alt="Easy Management"
|
|
||||||
style="width: 100%; border-radius: 8px; margin-bottom: 1rem;">
|
|
||||||
<span class="card-title">Easy Management</span>
|
|
||||||
<p class="card-text">User-friendly interface for seamless file management.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Call to Action Section -->
|
|
||||||
<div class="section" style="text-align: center;">
|
|
||||||
<div class="container">
|
|
||||||
<h2 class="section-title">Ready to Secure Your Files?</h2>
|
|
||||||
<p class="hero-subtitle" style="margin-bottom: 2rem;">Contact us today to get started with our file hosting service.
|
|
||||||
</p>
|
|
||||||
<a href="{% url 'contact' %}" class="btn"
|
|
||||||
data-tianji-event="service_cta" data-tianji-event-page="file_hosting" data-tianji-event-action="get_started">Get Started</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
@@ -53,10 +53,6 @@
|
|||||||
<h3>Web Design</h3>
|
<h3>Web Design</h3>
|
||||||
<p>Elevate your online presence with captivating web design that blends creativity with functionality. Our expert designers craft visually stunning websites that engage your audience and drive conversions, ensuring a seamless user experience across all devices.</p>
|
<p>Elevate your online presence with captivating web design that blends creativity with functionality. Our expert designers craft visually stunning websites that engage your audience and drive conversions, ensuring a seamless user experience across all devices.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-4">
|
|
||||||
<h3>File Hosting</h3>
|
|
||||||
<p>Securely store and share your files with ease using our reliable file hosting platform. With robust encryption and flexible access controls, you can confidently manage your data, collaborate seamlessly, and streamline your workflow.</p>
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
<div class="col-4">
|
||||||
<h3>App Virtualization</h3>
|
<h3>App Virtualization</h3>
|
||||||
<p>Transform your software delivery with our app virtualization solutions, enabling seamless access to applications from any device, anywhere. Experience enhanced flexibility, scalability, and security as we optimize your IT infrastructure for the digital age.</p>
|
<p>Transform your software delivery with our app virtualization solutions, enabling seamless access to applications from any device, anywhere. Experience enhanced flexibility, scalability, and security as we optimize your IT infrastructure for the digital age.</p>
|
||||||
@@ -106,13 +102,16 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<div class="form-group col-6" style="padding-left:1rem">
|
<div class="form-group col-6" style="padding-left:1rem">
|
||||||
<input type="email" class="form-control" name="email" placeholder="Your Email">
|
<label for="legacy-contact-email">Your Email</label>
|
||||||
|
<input type="email" class="form-control" name="email" id="legacy-contact-email" placeholder="Your Email">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group col-6" style="padding-right:1rem">
|
<div class="form-group col-6" style="padding-right:1rem">
|
||||||
<input type="text" class="form-control" name="name" placeholder="Your Name">
|
<label for="legacy-contact-name">Your Name</label>
|
||||||
|
<input type="text" class="form-control" name="name" id="legacy-contact-name" placeholder="Your Name">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group col-12" style="padding:1rem">
|
<div class="form-group col-12" style="padding:1rem">
|
||||||
<textarea name="message" type="text" class="form-control" rows="5" placeholder="Your message"></textarea>
|
<label for="legacy-contact-message">Your Message</label>
|
||||||
|
<textarea name="message" id="legacy-contact-message" class="form-control" rows="5" placeholder="Your message"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group col-12">
|
<div class="form-group col-12">
|
||||||
{% if capchaForm %}
|
{% if capchaForm %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# AI ML Operations, LLC
|
||||||
|
|
||||||
|
> 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 design/hosting.
|
||||||
|
|
||||||
|
## Key pages
|
||||||
|
|
||||||
|
{% for page in pages %}- [{{ page.title }}]({{ page.url }}){% if page.summary %} — {{ page.summary }}{% endif %}
|
||||||
|
{% 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 form]({{ contact_url }})
|
||||||
|
- Phone: +1 (330) 402-2675
|
||||||
|
- Email: ryan@aimloperations.com
|
||||||
|
|
||||||
|
## Site
|
||||||
|
|
||||||
|
{{ site_url }}
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<!-- Hero Section -->
|
<!-- Hero Section -->
|
||||||
<div class="hero-section">
|
<div class="hero-section">
|
||||||
<canvas id="hero-canvas"></canvas>
|
<canvas id="hero-canvas" aria-hidden="true"></canvas>
|
||||||
<div class="hero-content">
|
<div class="hero-content">
|
||||||
<h1 class="hero-title">AI ML Operations</h1>
|
<h1 class="hero-title">AI ML Operations</h1>
|
||||||
<p class="hero-subtitle hero-subtitle--wide">
|
<p class="hero-subtitle hero-subtitle--wide">
|
||||||
@@ -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"
|
||||||
@@ -219,12 +219,6 @@
|
|||||||
<p class="card-text">Servers and workstations for compute, storage, and local inference workloads.</p>
|
<p class="card-text">Servers and workstations for compute, storage, and local inference workloads.</p>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a href="{% url 'file_hosting' %}" class="card"
|
|
||||||
data-tianji-event="service_click" data-tianji-event-service="File Hosting">
|
|
||||||
<span class="card-title">File Hosting</span>
|
|
||||||
<p class="card-text">Secure, scalable file hosting to store and share your data.</p>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,61 +1 @@
|
|||||||
<!DOCTYPE html>
|
{% extends "emails/marketing_email.html" %}
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Email Template</title>
|
|
||||||
<!-- Materialize CSS -->
|
|
||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
background-color: #f4f4f4;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.email-container {
|
|
||||||
max-width: 600px;
|
|
||||||
margin: 0 auto;
|
|
||||||
background-color: #ffffff;
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
.header {
|
|
||||||
background-color: #37474f;
|
|
||||||
color: #ffffff;
|
|
||||||
padding: 20px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.content {
|
|
||||||
padding: 20px;
|
|
||||||
color: #333333;
|
|
||||||
}
|
|
||||||
.footer {
|
|
||||||
background-color: #333;
|
|
||||||
padding: 10px;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 12px;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="email-container">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="header">
|
|
||||||
<h4>{{ title | safe }}</h4>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Content -->
|
|
||||||
<div class="content">
|
|
||||||
{{ content | safe }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Footer -->
|
|
||||||
<div class="footer">
|
|
||||||
<p>© 2025 AI ML Operations, LLC. All rights reserved.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
Disallow: /admin/
|
||||||
|
Disallow: /accounts/
|
||||||
|
Disallow: /financial/
|
||||||
|
Disallow: /planning/
|
||||||
|
|
||||||
|
User-agent: GPTBot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: ClaudeBot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: Google-Extended
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: PerplexityBot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: {{ sitemap_url }}
|
||||||
|
# llms.txt: {{ llms_url }}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
{% for page in pages %}
|
||||||
|
<url>
|
||||||
|
<loc>{{ page.loc }}</loc>
|
||||||
|
<changefreq>{{ page.changefreq }}</changefreq>
|
||||||
|
<priority>{{ page.priority }}</priority>
|
||||||
|
</url>
|
||||||
|
{% endfor %}
|
||||||
|
</urlset>
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
<ul>
|
<ul>
|
||||||
<li><strong>Information you provide:</strong> name, email address, message content, and other details submitted through contact forms or account-related requests.</li>
|
<li><strong>Information you provide:</strong> name, email address, message content, and other details submitted through contact forms or account-related requests.</li>
|
||||||
<li><strong>Account information:</strong> username and authentication data for authorized users of internal tools.</li>
|
<li><strong>Account information:</strong> username and authentication data for authorized users of internal tools.</li>
|
||||||
<li><strong>Usage and analytics data:</strong> if you accept analytics in our cookie banner, we collect information about how you interact with the site, as described in Section 4.2.</li>
|
<li><strong>Usage and analytics data:</strong> information about how you interact with the site, as described in Section 4.2.</li>
|
||||||
<li><strong>Technical information:</strong> browser type, device type, operating system, language, referring URLs, and similar data collected automatically when you use the site.</li>
|
<li><strong>Technical information:</strong> browser type, device type, operating system, language, referring URLs, and similar data collected automatically when you use the site.</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>
|
<p>
|
||||||
@@ -63,9 +63,9 @@
|
|||||||
<h3>4.2 Analytics and Usage Tracking</h3>
|
<h3>4.2 Analytics and Usage Tracking</h3>
|
||||||
<p>
|
<p>
|
||||||
We use Tianji, a self-hosted analytics platform, to understand how visitors use our website and internal
|
We use Tianji, a self-hosted analytics platform, to understand how visitors use our website and internal
|
||||||
tools. Tracking is enabled only after you provide consent through our cookie banner.
|
tools. Analytics run when you use the site so we can measure usage and improve the experience.
|
||||||
</p>
|
</p>
|
||||||
<p>When you accept analytics, we may collect information such as:</p>
|
<p>We may collect information such as:</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Pages viewed and navigation paths</li>
|
<li>Pages viewed and navigation paths</li>
|
||||||
<li>Approximate geographic location derived from truncated IP addresses</li>
|
<li>Approximate geographic location derived from truncated IP addresses</li>
|
||||||
@@ -76,16 +76,16 @@
|
|||||||
</ul>
|
</ul>
|
||||||
<p>
|
<p>
|
||||||
We do not use Tianji to sell your personal information. Analytics data helps us improve site performance,
|
We do not use Tianji to sell your personal information. Analytics data helps us improve site performance,
|
||||||
content, and product usability. You may decline analytics at any time using the cookie preferences link
|
content, and product usability. We show an analytics notice when you first visit the site. You can review it
|
||||||
in the site footer. If you decline, the analytics script will not load.
|
again at any time using the cookie preferences link in the site footer.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
We store your consent choice locally in your browser so we can remember your preference on future visits.
|
We store your acknowledgement locally in your browser so we do not show the notice on every visit.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3>4.3 Cookies and Local Storage</h3>
|
<h3>4.3 Cookies and Local Storage</h3>
|
||||||
<p>
|
<p>
|
||||||
In addition to analytics consent storage, our site uses essential cookies and session storage required for
|
In addition to analytics notice storage, our site uses essential cookies and session storage required for
|
||||||
authentication, security (including CSRF protection), and basic site functionality. These essential
|
authentication, security (including CSRF protection), and basic site functionality. These essential
|
||||||
technologies are necessary for the site to operate and are not used for marketing analytics.
|
technologies are necessary for the site to operate and are not used for marketing analytics.
|
||||||
</p>
|
</p>
|
||||||
@@ -126,7 +126,7 @@
|
|||||||
<h3>4.7 Your Choices and Rights</h3>
|
<h3>4.7 Your Choices and Rights</h3>
|
||||||
<p>You can:</p>
|
<p>You can:</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Accept or decline analytics tracking through our cookie banner or the cookie preferences link in the footer</li>
|
<li>Acknowledge our analytics notice through the banner or review it again using the cookie preferences link in the footer</li>
|
||||||
<li>Contact us to request access to, correction of, or deletion of personal information you have provided, subject to applicable law</li>
|
<li>Contact us to request access to, correction of, or deletion of personal information you have provided, subject to applicable law</li>
|
||||||
<li>Disable non-essential browser storage or cookies through your browser settings, though essential site features may not function properly</li>
|
<li>Disable non-essential browser storage or cookies through your browser settings, though essential site features may not function properly</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -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" role="switch" aria-labelledby="monthlyLabel yearlyLabel">
|
{% for feature in pricing_features %}
|
||||||
<span class="pricing-switch-slider"></span>
|
<li class="pricing-feature{% if feature.required %} is-required{% endif %}"
|
||||||
</label>
|
data-feature-id="{{ feature.id }}">
|
||||||
<span class="pricing-toggle-label" id="yearlyLabel">Yearly</span>
|
<label class="pricing-feature-label">
|
||||||
</div>
|
<input type="checkbox"
|
||||||
</div>
|
class="pricing-feature-check"
|
||||||
|
value="{{ feature.id }}"
|
||||||
<!-- Pricing cards -->
|
data-build="{{ feature.build }}"
|
||||||
<div class="card-grid" style="grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));">
|
data-monthly="{{ feature.monthly }}"
|
||||||
<!-- Monthly Card -->
|
data-requires="{{ feature.requires|join:',' }}"
|
||||||
<div class="card" style="text-align: center;">
|
{% if feature.required %}checked disabled{% endif %}>
|
||||||
<span class="card-title" style="font-size: 1.5rem;">Standard Plan</span>
|
<span class="pricing-feature-body">
|
||||||
<h4 style="font-size: 3rem; color: var(--primary-color); margin: 1rem 0;">$<span class="price">10</span></h4>
|
<span class="pricing-feature-name">{{ feature.name }}</span>
|
||||||
<p class="card-text" style="margin-bottom: 2rem;"><span class="billing-period">per month</span></p>
|
<span class="pricing-feature-desc">{{ feature.description }}</span>
|
||||||
<ul style="text-align: left; margin-bottom: 2rem; list-style: none;">
|
{% if feature.requires_note %}
|
||||||
<li style="margin-bottom: 0.5rem;">✓ Web Hosting</li>
|
<span class="pricing-feature-note" hidden data-note-for="{{ feature.id }}">{{ feature.requires_note }}</span>
|
||||||
<li style="margin-bottom: 0.5rem;">✓ Weekly Backups</li>
|
{% endif %}
|
||||||
<li style="margin-bottom: 0.5rem;">✓ SSL Certificate</li>
|
</span>
|
||||||
<li style="margin-bottom: 0.5rem;">✓ CAPTCHA Protection</li>
|
<span class="pricing-feature-costs">
|
||||||
<li style="margin-bottom: 0.5rem;">✓ Email Notifications</li>
|
<strong>${{ feature.build }}</strong> build
|
||||||
<li style="margin-bottom: 0.5rem;">✓ Backend Admin Access</li>
|
<span class="pricing-feature-monthly"><strong>${{ feature.monthly }}</strong> /mo</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
<a href="#" class="btn">Get Started</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Yearly Card -->
|
<aside class="pricing-estimate-panel" aria-live="polite">
|
||||||
<div class="card" style="text-align: center; border-color: var(--secondary-color);">
|
<h3 class="pricing-panel-title">Estimate</h3>
|
||||||
<span class="card-title" style="font-size: 1.5rem; color: var(--secondary-color);">Premium Plan</span>
|
<div class="pricing-estimate-row">
|
||||||
<h4 style="font-size: 3rem; color: var(--secondary-color); margin: 1rem 0;">$<span class="price">15</span></h4>
|
<span>One-time build</span>
|
||||||
<p class="card-text" style="margin-bottom: 2rem;"><span class="billing-period">per month</span></p>
|
<strong id="estimateBuild">${{ pricing_base_estimate.one_time_build }}</strong>
|
||||||
<ul style="text-align: left; margin-bottom: 2rem; list-style: none;">
|
</div>
|
||||||
<li style="margin-bottom: 0.5rem;">✓ All Standard Features</li>
|
<div class="pricing-estimate-row">
|
||||||
<li style="margin-bottom: 0.5rem;">✓ Monthly Analytics Reports</li>
|
<span>Monthly</span>
|
||||||
<li style="margin-bottom: 0.5rem;">✓ Site Optimization Reports</li>
|
<strong id="estimateMonthly">${{ pricing_base_estimate.monthly }}</strong>
|
||||||
<li style="margin-bottom: 0.5rem;">✓ HTML Marketing Emails</li>
|
</div>
|
||||||
<li style="margin-bottom: 0.5rem;">✓ 2 Months Free (Yearly)</li>
|
<p class="pricing-estimate-count" id="estimateCount">
|
||||||
<li style="margin-bottom: 0.5rem;">✓ Priority Support</li>
|
Selected: {{ pricing_base_estimate.selected_count }} features
|
||||||
</ul>
|
</p>
|
||||||
<a href="#" class="btn" style="background: var(--secondary-color); color: white;">Save 20%</a>
|
<p class="pricing-estimate-disclaimer">{{ pricing_disclaimer }}</p>
|
||||||
</div>
|
<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);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
build: build,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (changed && !changed.checked) {
|
||||||
|
requiredBy(changed.value).forEach(function (dep) {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
monthlyLabel.addEventListener('click', function () {
|
updateEstimate();
|
||||||
pricingToggle.checked = false;
|
})();
|
||||||
updatePricing(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
yearlyLabel.addEventListener('click', function () {
|
|
||||||
pricingToggle.checked = true;
|
|
||||||
updatePricing(true);
|
|
||||||
});
|
|
||||||
</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,9 +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 .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(
|
||||||
@@ -26,9 +106,14 @@ class ContactViewTests(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, "Send Us a Message")
|
self.assertContains(response, "Send Us a Message")
|
||||||
self.assertContains(response, 'name="name"')
|
self.assertContains(response, 'id="contact-name"')
|
||||||
self.assertContains(response, 'name="email"')
|
self.assertContains(response, 'for="contact-name"')
|
||||||
self.assertContains(response, 'name="subject"')
|
self.assertContains(response, 'id="contact-email"')
|
||||||
|
self.assertContains(response, 'for="contact-email"')
|
||||||
|
self.assertContains(response, 'id="contact-subject"')
|
||||||
|
self.assertContains(response, 'for="contact-subject"')
|
||||||
|
self.assertContains(response, 'id="contact-message"')
|
||||||
|
self.assertContains(response, 'for="contact-message"')
|
||||||
|
|
||||||
@patch("public.views.send_contact_email")
|
@patch("public.views.send_contact_email")
|
||||||
def test_contact_post_success_saves_contact_and_sends_email(self, mock_send_email):
|
def test_contact_post_success_saves_contact_and_sends_email(self, mock_send_email):
|
||||||
@@ -110,18 +195,316 @@ class ContactViewTests(TestCase):
|
|||||||
|
|
||||||
@override_settings(DEBUG=True, TIANJI_ENABLED=True)
|
@override_settings(DEBUG=True, TIANJI_ENABLED=True)
|
||||||
class TianjiTrackingTests(TestCase):
|
class TianjiTrackingTests(TestCase):
|
||||||
def test_homepage_includes_consent_banner_when_enabled(self):
|
def test_homepage_includes_analytics_notice_when_enabled(self):
|
||||||
response = self.client.get(reverse("public_index"))
|
response = self.client.get(reverse("public_index"))
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, "cookie-consent-banner")
|
self.assertContains(response, "cookie-consent-banner")
|
||||||
|
self.assertContains(response, "cookie-consent-acknowledge")
|
||||||
|
self.assertContains(response, "Acknowledge")
|
||||||
|
self.assertNotContains(response, "cookie-consent-decline")
|
||||||
|
self.assertNotContains(response, "Accept Analytics")
|
||||||
self.assertContains(response, "tianji-config")
|
self.assertContains(response, "tianji-config")
|
||||||
self.assertContains(response, "tianji-consent.js")
|
self.assertContains(response, "tianji-consent.js")
|
||||||
|
|
||||||
@override_settings(TIANJI_ENABLED=False)
|
@override_settings(TIANJI_ENABLED=False)
|
||||||
def test_homepage_omits_consent_banner_when_disabled(self):
|
def test_homepage_omits_analytics_notice_when_disabled(self):
|
||||||
response = self.client.get(reverse("public_index"))
|
response = self.client.get(reverse("public_index"))
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertNotContains(response, "cookie-consent-banner")
|
self.assertNotContains(response, "cookie-consent-banner")
|
||||||
self.assertNotContains(response, "tianji-config")
|
self.assertNotContains(response, "tianji-config")
|
||||||
|
|
||||||
|
|
||||||
|
class AgenticBrowsingSeoTests(TestCase):
|
||||||
|
def test_robots_txt_is_available(self):
|
||||||
|
response = self.client.get(reverse("robots_txt"))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response["Content-Type"], "text/plain; charset=utf-8")
|
||||||
|
self.assertContains(response, "User-agent: *")
|
||||||
|
self.assertContains(response, "Sitemap:")
|
||||||
|
self.assertContains(response, "llms.txt")
|
||||||
|
|
||||||
|
def test_sitemap_xml_lists_public_pages(self):
|
||||||
|
response = self.client.get(reverse("sitemap_xml"))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response["Content-Type"], "application/xml; charset=utf-8")
|
||||||
|
self.assertContains(response, "<urlset")
|
||||||
|
self.assertContains(response, reverse("contact"))
|
||||||
|
self.assertContains(response, reverse("forward_deployed"))
|
||||||
|
|
||||||
|
def test_llms_txt_is_available(self):
|
||||||
|
response = self.client.get(reverse("llms_txt"))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response["Content-Type"], "text/plain; charset=utf-8")
|
||||||
|
self.assertContains(response, "# AI ML Operations, LLC")
|
||||||
|
self.assertContains(response, "## Key pages")
|
||||||
|
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):
|
||||||
|
response = self.client.get(reverse("public_index"))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, 'id="services-menu-button"')
|
||||||
|
self.assertContains(response, 'aria-haspopup="true"')
|
||||||
|
self.assertContains(response, 'aria-label="Main navigation"')
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(DEBUG=True, WEBMCP_ENABLED=True)
|
||||||
|
class WebMcpTests(TestCase):
|
||||||
|
def test_homepage_includes_webmcp_when_enabled(self):
|
||||||
|
response = self.client.get(reverse("public_index"))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "webmcp-config")
|
||||||
|
self.assertContains(response, "webmcp-tools.js")
|
||||||
|
self.assertContains(response, 'data-page-name="public_index"')
|
||||||
|
self.assertContains(response, "forward_deployed")
|
||||||
|
|
||||||
|
@override_settings(WEBMCP_ENABLED=False)
|
||||||
|
def test_homepage_omits_webmcp_when_disabled(self):
|
||||||
|
response = self.client.get(reverse("public_index"))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertNotContains(response, "webmcp-config")
|
||||||
|
self.assertNotContains(response, "webmcp-tools.js")
|
||||||
|
|
||||||
|
def test_contact_page_includes_webmcp_form_annotations(self):
|
||||||
|
response = self.client.get(reverse("contact"))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, 'id="contact-form"')
|
||||||
|
self.assertContains(response, 'toolname="submit_contact_inquiry"')
|
||||||
|
self.assertContains(response, "tooldescription=")
|
||||||
|
self.assertContains(response, "toolparamdescription=")
|
||||||
|
self.assertContains(response, "submit_contact_inquiry")
|
||||||
|
|
||||||
|
@override_settings(DEBUG=False)
|
||||||
|
def test_contact_page_renders_captcha_outside_annotated_form(self):
|
||||||
|
response = self.client.get(reverse("contact"))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
form_end = response.content.index(b"</form>")
|
||||||
|
captcha_index = response.content.index(b"g-recaptcha")
|
||||||
|
self.assertGreater(captcha_index, form_end)
|
||||||
|
self.assertContains(response, b'form="contact-form"')
|
||||||
|
|
||||||
|
def test_webmcp_services_json_includes_all_service_pages(self):
|
||||||
|
response = self.client.get(reverse("public_index"))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
for slug in SERVICE_URL_NAMES:
|
||||||
|
self.assertContains(response, slug)
|
||||||
|
|
||||||
|
def test_service_entries_match_public_seo_config(self):
|
||||||
|
entries = get_service_entries()
|
||||||
|
self.assertEqual(len(entries), len(SERVICE_URL_NAMES))
|
||||||
|
self.assertEqual({entry["slug"] for entry in entries}, SERVICE_URL_NAMES)
|
||||||
|
|
||||||
|
def test_webmcp_static_script_defines_expected_tools(self):
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
script_path = Path(__file__).resolve().parent / "static/public/js/webmcp-tools.js"
|
||||||
|
script = script_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
for tool_name in (
|
||||||
|
"list_services",
|
||||||
|
"get_page_content",
|
||||||
|
"navigate_to_service",
|
||||||
|
"open_contact_with_subject",
|
||||||
|
"estimate_web_design_cost",
|
||||||
|
"submit_contact_inquiry",
|
||||||
|
):
|
||||||
|
self.assertIn("name: '" + tool_name + "'", script)
|
||||||
|
|
||||||
|
self.assertIn("readOnlyHint: true", script)
|
||||||
|
self.assertIn("readOnlyHint: false", 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
|
||||||
@@ -1,20 +1,36 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
from django.views.generic import RedirectView
|
||||||
|
|
||||||
from . import views
|
from . import seo, views
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
path("robots.txt", seo.robots_txt, name="robots_txt"),
|
||||||
|
path("sitemap.xml", seo.sitemap_xml, name="sitemap_xml"),
|
||||||
|
path("llms.txt", seo.llms_txt, name="llms_txt"),
|
||||||
path("", views.index, name="public_index"),
|
path("", views.index, name="public_index"),
|
||||||
path("chat", views.chat, name="chat"),
|
path("chat", views.chat, name="chat"),
|
||||||
path("ai_education", views.ai_education, name="ai_education"),
|
path("ai_education", views.ai_education, name="ai_education"),
|
||||||
path("computer", views.computers, name="computers"),
|
path("computer", views.computers, name="computers"),
|
||||||
path("web_design", views.web_design, name="web_design"),
|
path("web_design", views.web_design, name="web_design"),
|
||||||
path("ai_sensor", views.ai_sensor, name="ai_sensor"),
|
path("ai_sensor", views.ai_sensor, name="ai_sensor"),
|
||||||
path("file_hosting", views.file_hosting, name="file_hosting"),
|
# Permanent redirect: product retired; keep URL for inbound links/search results.
|
||||||
|
path(
|
||||||
|
"file_hosting",
|
||||||
|
RedirectView.as_view(pattern_name="public_index", permanent=True),
|
||||||
|
),
|
||||||
path("bot_creation", views.bot, name="bot"),
|
path("bot_creation", views.bot, name="bot"),
|
||||||
path("forward-deployed-ai", views.forward_deployed, name="forward_deployed"),
|
path("forward-deployed-ai", views.forward_deployed, name="forward_deployed"),
|
||||||
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")
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,26 +1,38 @@
|
|||||||
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 .email_branding import email_brand_context
|
||||||
|
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"
|
mail_subject = "New Contact Request for AI ML Operations, LLC"
|
||||||
from_email = "ryan@aimloperations.com"
|
from_email = getattr(
|
||||||
to="ryan@aimloperations.com"
|
settings,
|
||||||
d = {"subject": subject, "message": message, "email": email}
|
"DEFAULT_FROM_EMAIL",
|
||||||
html_content = get_template(r'emails/contact_email.html').render(d)
|
"AI ML Operations, LLC <info@aimloperations.com>",
|
||||||
text_content = get_template(r'emails/contact_email.txt').render(d)
|
)
|
||||||
|
to = "ryan@aimloperations.com"
|
||||||
|
d = email_brand_context(subject=subject, message=message, email=email)
|
||||||
|
html_content = get_template("emails/contact_email.html").render(d)
|
||||||
|
text_content = get_template("emails/contact_email.txt").render(d)
|
||||||
|
|
||||||
msg = EmailMultiAlternatives(subject, text_content, from_email, [to])
|
msg = EmailMultiAlternatives(mail_subject, text_content, from_email, [to])
|
||||||
msg.attach_alternative(html_content, "text/html")
|
msg.attach_alternative(html_content, "text/html")
|
||||||
msg.send(fail_silently=True)
|
msg.send(fail_silently=True)
|
||||||
|
|
||||||
@@ -38,14 +50,27 @@ 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", {})
|
||||||
|
|
||||||
def file_hosting(request):
|
|
||||||
return render(request, "public/file_hosting.html", {})
|
|
||||||
|
|
||||||
def bot(request):
|
def bot(request):
|
||||||
return render(request, "public/bot.html", {})
|
return render(request, "public/bot.html", {})
|
||||||
|
|
||||||
@@ -61,13 +86,11 @@ def terms_of_service(request):
|
|||||||
@login_required
|
@login_required
|
||||||
def preview_email(request, pk):
|
def preview_email(request, pk):
|
||||||
email_instance = get_object_or_404(EmailMessage, pk=pk)
|
email_instance = get_object_or_404(EmailMessage, pk=pk)
|
||||||
context = {
|
context = email_brand_context(
|
||||||
"title":email_instance.subject,
|
title=email_instance.subject,
|
||||||
"content":email_instance.body
|
content=email_instance.body,
|
||||||
}
|
|
||||||
return render(
|
|
||||||
request, 'public/preview_email.html', context
|
|
||||||
)
|
)
|
||||||
|
return render(request, "emails/marketing_email.html", context)
|
||||||
|
|
||||||
def contact(request):
|
def contact(request):
|
||||||
errors = ''
|
errors = ''
|
||||||
@@ -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,11 @@
|
|||||||
|
# Email previews
|
||||||
|
|
||||||
|
Static renders of branded HTML emails (dark neon site UX: `#0a0a0a` / `#1a1a1a` / `#00f3ff` / `#bc13fe`).
|
||||||
|
|
||||||
|
| Template | Screenshot |
|
||||||
|
|----------|------------|
|
||||||
|
| Contact notify | [contact.png](contact.png) |
|
||||||
|
| Marketing | [marketing.png](marketing.png) |
|
||||||
|
| Invoice / pay link | [invoice.png](invoice.png) |
|
||||||
|
|
||||||
|
Shared base: `public/templates/emails/base_email.html`.
|
||||||
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 38 KiB |
@@ -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
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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}"
|
||||||
@@ -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" },
|
||||||
|
]
|
||||||