Author SHA1 Message Date
westfarn 04226abc19 Update company_site/financial/templates/financial/subscription_new.html for Stripe invoices (#23)
CI / test (pull_request) Successful in 13s
Unit Tests / test (pull_request) Successful in 12s
2026-07-31 04:35:33 -07:00
westfarn a590fb8979 Update company_site/financial/templates/financial/subscription_detail.html for Stripe invoices (#23) 2026-07-31 04:35:27 -07:00
westfarn f759efc2aa Update company_site/financial/templates/financial/invoices.html for Stripe invoices (#23) 2026-07-31 04:35:20 -07:00
westfarn 9ca0cf95be Update company_site/financial/templates/financial/invoice_new.html for Stripe invoices (#23) 2026-07-31 04:35:13 -07:00
westfarn 975d846678 Update company_site/financial/templates/financial/invoice_detail.html for Stripe invoices (#23) 2026-07-31 04:35:07 -07:00
westfarn 33859fa28b Update company_site/financial/templates/emails/invoice_pay_link.txt for Stripe invoices (#23) 2026-07-31 04:35:00 -07:00
westfarn fe4fcfdd13 Update company_site/financial/templates/emails/invoice_pay_link.html for Stripe invoices (#23) 2026-07-31 04:34:54 -07:00
westfarn 11de91faf7 Update company_site/financial/stripe_billing.py for Stripe invoices (#23) 2026-07-31 04:34:47 -07:00
westfarn 109aea2242 Update company_site/financial/migrations/0016_billing_invoices_subscriptions.py for Stripe invoices (#23) 2026-07-31 04:34:41 -07:00
westfarn cb7b39bf43 Update company_site/financial/invoice_views.py for Stripe invoices (#23) 2026-07-31 04:34:34 -07:00
westfarn cbdeb93810 Update company_site/financial/urls.py for Stripe invoices (#23) 2026-07-31 04:34:27 -07:00
westfarn 73448806cc Update company_site/financial/tests.py for Stripe invoices (#23) 2026-07-31 04:34:21 -07:00
westfarn 2fdd322640 Update company_site/financial/templates/financial/index.html for Stripe invoices (#23) 2026-07-31 04:34:14 -07:00
westfarn eefa59abad Update company_site/financial/models.py for Stripe invoices (#23) 2026-07-31 04:34:08 -07:00
westfarn d63c6af374 Update company_site/financial/forms.py for Stripe invoices (#23) 2026-07-31 04:34:01 -07:00
westfarn 71e64cfc45 Update company_site/financial/admin.py for Stripe invoices (#23) 2026-07-31 04:33:55 -07:00
westfarn db95cabe9c Update company_site/company_site/settings/base.py for Stripe invoices (#23) 2026-07-31 04:33:48 -07:00
westfarn f932fcbdba Update .env.prod.example for Stripe invoices (#23) 2026-07-31 04:33:42 -07:00
westfarn bccab2b6e9 Add admin Stripe invoices and recurring customer subscriptions.
Closes #23: one-off hosted invoices, monthly Checkout subscriptions, pay-link email, and webhook status sync for admins.
2026-07-31 04:33:35 -07:00
westfarn 5fa61d02e8 added pricing, utm tracking, and leads (#20)
Unit Tests / test (push) Successful in 11s
Reviewed-on: #20
2026-07-24 18:29:19 -07:00
westfarn 139f375f73 Fix logout 403 CSRF verification failed (#18) (#19)
Unit Tests / test (push) Successful in 11s
## Summary
- Closes #18
- Logout POST was failing CSRF Origin checks on HTTPS because `CSRF_TRUSTED_ORIGINS` was unset (Django 5)
- Derive trusted origins from `DJANGO_ALLOWED_HOSTS` (override via `DJANGO_CSRF_TRUSTED_ORIGINS`)
- Trust reverse-proxy TLS headers in prod/beta (`SECURE_PROXY_SSL_HEADER`, secure cookies)

## Test plan
- [x] `python manage.py test public.tests.CsrfTrustedOriginsTests public.tests.LogoutCsrfTests`
- [ ] Deploy, log in on aimloperations.com, click Log Out → redirect home, no 403
- [ ] Confirm login still works after deploy

Reviewed-on: #19
2026-07-10 17:39:29 -07:00
westfarn c97bd16445 Require auth for /preview_email/ (#16) (#17)
Unit Tests / test (push) Successful in 10s
## Summary
- Closes #16
- Confirms `/preview_email/<pk>/` requires an authenticated user (`@login_required` already on the view)
- Adds unit tests for unauthenticated redirect and authenticated preview access

## Test plan
- [x] `python manage.py test public.tests.PreviewEmailAuthTests`
- [ ] Manually hit `/preview_email/1/` logged out → redirect to `/accounts/login/`
- [ ] Log in and hit same URL → email preview renders

Reviewed-on: #17
2026-07-10 10:57:48 -07:00
westfarn 8ccf17655d updateing deploy for westfarn user
Unit Tests / test (push) Successful in 11s
2026-07-08 12:26:07 -05:00
westfarn 04d842d799 Test who runs the job
Unit Tests / test (push) Successful in 10s
2026-07-08 12:21:50 -05:00
westfarn ae0f8a8bc5 Updated deploy to use act_runner keys
Deploy Company Site / test (push) Successful in 10s
Unit Tests / test (push) Successful in 10s
Deploy Company Site / docker (push) Successful in 16s
Deploy Company Site / deploy (push) Failing after 0s
2026-07-08 11:51:33 -05:00
westfarn 16dfb3faae Updated deploy to use act_runner keys
Unit Tests / test (push) Successful in 10s
2026-07-08 11:46:43 -05:00
westfarn a5fa08d4a0 update docker unittests
Unit Tests / test (push) Successful in 10s
2026-07-08 06:12:21 -05:00
westfarn 9a383c0ee9 Update workflow to use server-infra
Unit Tests / test (push) Successful in 10s
2026-07-08 06:05:11 -05:00
westfarn 6f97f6084d Adding images
Unit Tests / test (push) Successful in 11s
2026-07-07 15:28:45 -05:00
westfarn 426cc82f04 Dockerize Django app with dev/beta/prod env config and uv (#6)
Unit Tests / test (push) Successful in 10s
## Summary

- Containerize the Django app with Docker and docker-compose (dev + production)
- Refactor settings into `dev` / `beta` / `prod` environments driven by environment variables
- Connect to PostgreSQL via `DATABASE_URL` or `DB_*` vars
- Migrate package management from pip to uv (`pyproject.toml`, `uv.lock`)
- Split Gitea workflows: PRs run unit tests only; pushes to `master` run tests, Docker validation, and deploy
- Update deploy script to rsync code, preserve server `.env`, validate config, and run Docker compose

Closes #4

## Test plan

- [x] `uv run python manage.py test` passes locally (10/10)
- [x] `DJANGO_ENV=beta` and `DJANGO_ENV=prod` load with correct logging levels
- [x] `scripts/validate-env.sh` rejects missing production variables
- [ ] `docker compose up --build` starts app + Postgres locally
- [ ] Containerized unit tests pass in CI Docker job
- [ ] Server `.env` created from `.env.prod.example` before first production deploy
- [ ] CI workflow runs on this PR (tests only, no deploy)

Reviewed-on: #6
2026-07-07 11:23:50 -07:00
49 changed files with 3740 additions and 176 deletions
+9
View File
@@ -4,6 +4,8 @@ DJANGO_ENV=dev
DJANGO_DEBUG=true DJANGO_DEBUG=true
DJANGO_SECRET_KEY=change-me-for-local-development DJANGO_SECRET_KEY=change-me-for-local-development
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0 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 (docker-compose sets DATABASE_URL for the web service)
DATABASE_URL=postgres://company_site:company_site@db:5432/company_site DATABASE_URL=postgres://company_site:company_site@db:5432/company_site
@@ -31,6 +33,13 @@ EMAIL_HOST_USER=
EMAIL_HOST_PASSWORD= EMAIL_HOST_PASSWORD=
EMAIL_PORT=2525 EMAIL_PORT=2525
EMAIL_USE_TLS=true EMAIL_USE_TLS=true
# DEFAULT_FROM_EMAIL=AI ML Operations, LLC <info@aimloperations.com>
# Stripe (test keys from https://dashboard.stripe.com/test/apikeys)
STRIPE_SECRET_KEY=
STRIPE_PUBLISHABLE_KEY=
# From `stripe listen` or Dashboard → Developers → Webhooks
STRIPE_WEBHOOK_SECRET=
# Gunicorn # Gunicorn
GUNICORN_WORKERS=2 GUNICORN_WORKERS=2
+14
View File
@@ -7,6 +7,8 @@ DJANGO_ENV=prod
DJANGO_DEBUG=false DJANGO_DEBUG=false
DJANGO_SECRET_KEY=replace-with-a-long-random-secret DJANGO_SECRET_KEY=replace-with-a-long-random-secret
DJANGO_ALLOWED_HOSTS=aimloperations.com,www.aimloperations.com 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) # Logging (optional override; defaults: dev=DEBUG, beta=INFO, prod=WARNING)
# DJANGO_LOG_LEVEL=WARNING # DJANGO_LOG_LEVEL=WARNING
@@ -32,6 +34,18 @@ EMAIL_HOST_USER=replace-with-smtp-user
EMAIL_HOST_PASSWORD=replace-with-smtp-password EMAIL_HOST_PASSWORD=replace-with-smtp-password
EMAIL_PORT=2525 EMAIL_PORT=2525
EMAIL_USE_TLS=true EMAIL_USE_TLS=true
# DEFAULT_FROM_EMAIL=AI ML Operations, LLC <info@aimloperations.com>
# Stripe (live keys from https://dashboard.stripe.com/apikeys)
# Secret key starts with sk_live_; publishable with pk_live_
STRIPE_SECRET_KEY=replace-with-stripe-secret-key
STRIPE_PUBLISHABLE_KEY=replace-with-stripe-publishable-key
# Dashboard → Developers → Webhooks → endpoint
# URL: https://aimloperations.com/financial/stripe/webhook/
# Events: invoice.paid, invoice.payment_failed, invoice.finalized,
# customer.subscription.updated, customer.subscription.deleted,
# checkout.session.completed
STRIPE_WEBHOOK_SECRET=replace-with-stripe-webhook-signing-secret
# Gunicorn # Gunicorn
GUNICORN_WORKERS=2 GUNICORN_WORKERS=2
+5 -25
View File
@@ -1,6 +1,6 @@
name: Deploy Company Site name: Deploy Company Site
# Deploy pipeline runs only on pushes to master (never on pull requests). # Runs after Unit Tests completes on master. Direct pushes only (not PRs).
on: on:
workflow_run: workflow_run:
workflows: [Unit Tests] workflows: [Unit Tests]
@@ -8,34 +8,14 @@ on:
branches: [master] branches: [master]
jobs: 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
docker: docker:
if: gitea.event.workflow_run.conclusion == 'success' && gitea.event.workflow_run.event == 'push'
runs-on: self-hosted runs-on: self-hosted
needs: test
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ gitea.event.workflow_run.head_sha }}
- name: Build Docker image - name: Build Docker image
run: docker compose build run: docker compose build
@@ -53,7 +33,7 @@ jobs:
deploy: deploy:
if: gitea.event.workflow_run.conclusion == 'success' && gitea.event.workflow_run.event == 'push' if: gitea.event.workflow_run.conclusion == 'success' && gitea.event.workflow_run.event == 'push'
runs-on: self-hosted runs-on: self-hosted
needs: [test, docker] needs: docker
env: env:
SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra
steps: steps:
@@ -82,6 +82,34 @@ WEBMCP_ENABLED = env_bool("WEBMCP_ENABLED", False)
allowed_hosts = env_list("DJANGO_ALLOWED_HOSTS", "*") allowed_hosts = env_list("DJANGO_ALLOWED_HOSTS", "*")
ALLOWED_HOSTS = allowed_hosts if allowed_hosts else ["*"] 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 = [ INSTALLED_APPS = [
"public.apps.PublicConfig", "public.apps.PublicConfig",
"financial.apps.FinancialConfig", "financial.apps.FinancialConfig",
@@ -106,6 +134,7 @@ MIDDLEWARE = [
"django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
"public.middleware.UTMTrackingMiddleware",
] ]
ROOT_URLCONF = "company_site.urls" ROOT_URLCONF = "company_site.urls"
@@ -171,6 +200,15 @@ EMAIL_HOST_USER = env("EMAIL_HOST_USER", "")
EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD", "") EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD", "")
EMAIL_PORT = int(env("EMAIL_PORT", "2525")) EMAIL_PORT = int(env("EMAIL_PORT", "2525"))
EMAIL_USE_TLS = env_bool("EMAIL_USE_TLS", True) EMAIL_USE_TLS = env_bool("EMAIL_USE_TLS", True)
DEFAULT_FROM_EMAIL = env(
"DEFAULT_FROM_EMAIL",
"AI ML Operations, LLC <info@aimloperations.com>",
)
# Stripe (invoices / subscriptions — customers pay us)
STRIPE_SECRET_KEY = env("STRIPE_SECRET_KEY", "")
STRIPE_PUBLISHABLE_KEY = env("STRIPE_PUBLISHABLE_KEY", "")
STRIPE_WEBHOOK_SECRET = env("STRIPE_WEBHOOK_SECRET", "")
LOGIN_REDIRECT_URL = "/" LOGIN_REDIRECT_URL = "/"
LOGOUT_REDIRECT_URL = "/" LOGOUT_REDIRECT_URL = "/"
@@ -11,4 +11,10 @@ if DEBUG:
warnings.warn("DEBUG is enabled in beta environment.", stacklevel=1) 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") LOGGING = build_logging_config(logging_level_for_env("beta"), "beta")
@@ -9,4 +9,10 @@ TIANJI_ENABLED = env_bool("TIANJI_ENABLED", True) # noqa: F405
if not env("DJANGO_SECRET_KEY"): # noqa: F405 if not env("DJANGO_SECRET_KEY"): # noqa: F405
raise ValueError("DJANGO_SECRET_KEY must be set in production.") 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") LOGGING = build_logging_config(logging_level_for_env("prod"), "prod")
+1 -1
View File
@@ -31,7 +31,7 @@ Test at minimum:
When `WEBMCP_ENABLED=True`: When `WEBMCP_ENABLED=True`:
- Navigation tools (`list_services`, `get_page_content`, `navigate_to_service`, `open_contact_with_subject`) load on all public pages - Navigation tools (`list_services`, `get_page_content`, `navigate_to_service`, `open_contact_with_subject`, `estimate_web_design_cost`) load on all public pages
- Contact page registers `submit_contact_inquiry` via declarative form annotations (`toolname`, `tooldescription`, `toolparamdescription`); reCAPTCHA renders outside the annotated form - Contact page registers `submit_contact_inquiry` via declarative form annotations (`toolname`, `tooldescription`, `toolparamdescription`); reCAPTCHA renders outside the annotated form
- Default is **disabled** (`WEBMCP_ENABLED=False`) until deliberately enabled per environment - Default is **disabled** (`WEBMCP_ENABLED=False`) until deliberately enabled per environment
+22
View File
@@ -47,8 +47,30 @@ Use `https://` URLs to avoid redirect warnings. Run with `WEBMCP_ENABLED=True` o
| `get_page_content` | All public pages | Yes | Look up a page by slug or display name | | `get_page_content` | All public pages | Yes | Look up a page by slug or display name |
| `navigate_to_service` | All public pages | Yes | Resolve a service to its canonical URL | | `navigate_to_service` | All public pages | Yes | Resolve a service to its canonical URL |
| `open_contact_with_subject` | All public pages | Yes | Build a contact URL with `?subject=` pre-filled | | `open_contact_with_subject` | All public pages | Yes | Build a contact URL with `?subject=` pre-filled |
| `estimate_web_design_cost` | All public pages | Yes | Estimate web design build + monthly package pricing |
| `submit_contact_inquiry` | `/contact` only | No | POST a contact inquiry to the Django contact endpoint | | `submit_contact_inquiry` | `/contact` only | No | POST a contact inquiry to the Django contact endpoint |
### `estimate_web_design_cost`
**Input schema:**
| Field | Type | Required |
|-------|------|----------|
| `features` | string[] | No — feature ids to add beyond the required base |
**Valid feature ids:** `public_site`, `client_portal`, `email_sms`, `direct_mail`, `blog`, `payments`, `social`, `ai_social`
**Behavior:**
- `public_site` and `client_portal` are always included
- Selecting `payments` auto-selects `email_sms`
- Selecting `ai_social` auto-selects `social`
- Returns one-time build total, monthly total, selected features, included-with-every-site notes, and the full catalog
- Included-with-every-site covers brand-tailored design, client ownership of site/data, three-instance hosting, UTM/leads, SEO/accessibility/LLM readiness, and Grafana metrics/alerts
**Example:** `{ "features": ["email_sms", "payments"] }` → base + Email/SMS + Payments
Pricing catalog and included benefits are sourced from `public/web_design_pricing.py` (same data as `/web_design` and `/llms.txt`).
### `submit_contact_inquiry` ### `submit_contact_inquiry`
**Input schema:** **Input schema:**
+27 -1
View File
@@ -1,5 +1,15 @@
from django.contrib import admin from django.contrib import admin
from .models import Contract, Employee, ChargeNumber, TimeCard, TimeCardCell, UserProfile from .models import (
Contract,
Employee,
ChargeNumber,
TimeCard,
TimeCardCell,
UserProfile,
BillingCustomer,
Invoice,
RecurringSubscription,
)
class ContractAdmin(admin.ModelAdmin): class ContractAdmin(admin.ModelAdmin):
pass pass
@@ -20,9 +30,25 @@ class TimeCardAdmin(admin.ModelAdmin):
class TimeCardCellAdmin(admin.ModelAdmin): class TimeCardCellAdmin(admin.ModelAdmin):
pass pass
class BillingCustomerAdmin(admin.ModelAdmin):
list_display = ("name", "email", "company", "stripe_customer_id")
search_fields = ("name", "email", "company")
class InvoiceAdmin(admin.ModelAdmin):
list_display = ("id", "customer", "description", "amount_cents", "status", "created")
list_filter = ("status",)
search_fields = ("description", "stripe_invoice_id", "customer__email")
class RecurringSubscriptionAdmin(admin.ModelAdmin):
list_display = ("id", "customer", "description", "amount_cents", "interval", "status")
list_filter = ("status", "interval")
admin.site.register(Contract, ContractAdmin) admin.site.register(Contract, ContractAdmin)
admin.site.register(Employee, EmployeeAdmin) admin.site.register(Employee, EmployeeAdmin)
admin.site.register(UserProfile, UserProfileAdmin) admin.site.register(UserProfile, UserProfileAdmin)
admin.site.register(ChargeNumber, ChargeNumberAdmin) admin.site.register(ChargeNumber, ChargeNumberAdmin)
admin.site.register(TimeCard, TimeCardAdmin) admin.site.register(TimeCard, TimeCardAdmin)
admin.site.register(TimeCardCell, TimeCardCellAdmin) admin.site.register(TimeCardCell, TimeCardCellAdmin)
admin.site.register(BillingCustomer, BillingCustomerAdmin)
admin.site.register(Invoice, InvoiceAdmin)
admin.site.register(RecurringSubscription, RecurringSubscriptionAdmin)
+154 -1
View File
@@ -2,7 +2,19 @@ import datetime
from django import forms from django import forms
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.forms import ModelForm from django.forms import ModelForm
from .models import Employee, Contract, ChargeNumber, TimeCardCell, AddressModel, UserProfile, set_user_type from .models import (
Employee,
Contract,
ChargeNumber,
TimeCardCell,
AddressModel,
UserProfile,
set_user_type,
BillingCustomer,
Invoice,
RecurringSubscription,
)
from .stripe_billing import dollars_to_cents
class NewEmployeeForm(ModelForm): class NewEmployeeForm(ModelForm):
first_name = forms.CharField(max_length=30, required=False, label="First Name") first_name = forms.CharField(max_length=30, required=False, label="First Name")
@@ -99,3 +111,144 @@ class TimeLogForm(ModelForm):
cleaned_data['hour'] = duration cleaned_data['hour'] = duration
return cleaned_data return cleaned_data
class BillingCustomerForm(ModelForm):
class Meta:
model = BillingCustomer
fields = ["name", "email", "company", "notes"]
class OneOffInvoiceForm(forms.Form):
customer = forms.ModelChoiceField(
queryset=BillingCustomer.objects.all(),
required=False,
help_text="Pick an existing customer, or fill New customer fields below.",
)
new_customer_name = forms.CharField(max_length=200, required=False, label="New customer name")
new_customer_email = forms.EmailField(required=False, label="New customer email")
new_customer_company = forms.CharField(
max_length=200, required=False, label="New customer company"
)
description = forms.CharField(max_length=500)
amount = forms.DecimalField(
max_digits=10,
decimal_places=2,
min_value=0.50,
label="Amount (USD)",
help_text="Minimum $0.50",
)
due_date = forms.DateField(
required=False,
widget=forms.DateInput(attrs={"type": "date"}),
)
notes = forms.CharField(required=False, widget=forms.Textarea(attrs={"rows": 3}))
send_email = forms.BooleanField(
required=False,
initial=True,
label="Email payment link to customer",
)
def clean(self):
cleaned = super().clean()
customer = cleaned.get("customer")
name = (cleaned.get("new_customer_name") or "").strip()
email = (cleaned.get("new_customer_email") or "").strip()
if not customer and not (name and email):
raise forms.ValidationError(
"Select an existing customer or provide new customer name and email."
)
return cleaned
def resolve_customer(self, user):
customer = self.cleaned_data.get("customer")
if customer:
return customer
customer = BillingCustomer(
name=self.cleaned_data["new_customer_name"].strip(),
email=self.cleaned_data["new_customer_email"].strip(),
company=(self.cleaned_data.get("new_customer_company") or "").strip(),
created_by=user,
last_modified_BY=user,
)
customer.save()
return customer
def build_invoice(self, user) -> Invoice:
customer = self.resolve_customer(user)
return Invoice(
customer=customer,
description=self.cleaned_data["description"],
amount_cents=dollars_to_cents(self.cleaned_data["amount"]),
due_date=self.cleaned_data.get("due_date"),
notes=self.cleaned_data.get("notes") or "",
created_by=user,
last_modified_BY=user,
)
class RecurringSubscriptionForm(forms.Form):
customer = forms.ModelChoiceField(
queryset=BillingCustomer.objects.all(),
required=False,
help_text="Pick an existing customer, or fill New customer fields below.",
)
new_customer_name = forms.CharField(max_length=200, required=False, label="New customer name")
new_customer_email = forms.EmailField(required=False, label="New customer email")
new_customer_company = forms.CharField(
max_length=200, required=False, label="New customer company"
)
description = forms.CharField(max_length=500)
amount = forms.DecimalField(
max_digits=10,
decimal_places=2,
min_value=0.50,
label="Amount per period (USD)",
)
interval = forms.ChoiceField(
choices=RecurringSubscription.Interval.choices,
initial=RecurringSubscription.Interval.MONTH,
)
notes = forms.CharField(required=False, widget=forms.Textarea(attrs={"rows": 3}))
send_email = forms.BooleanField(
required=False,
initial=True,
label="Email checkout / payment link to customer",
)
def clean(self):
cleaned = super().clean()
customer = cleaned.get("customer")
name = (cleaned.get("new_customer_name") or "").strip()
email = (cleaned.get("new_customer_email") or "").strip()
if not customer and not (name and email):
raise forms.ValidationError(
"Select an existing customer or provide new customer name and email."
)
return cleaned
def resolve_customer(self, user):
customer = self.cleaned_data.get("customer")
if customer:
return customer
customer = BillingCustomer(
name=self.cleaned_data["new_customer_name"].strip(),
email=self.cleaned_data["new_customer_email"].strip(),
company=(self.cleaned_data.get("new_customer_company") or "").strip(),
created_by=user,
last_modified_BY=user,
)
customer.save()
return customer
def build_subscription(self, user) -> RecurringSubscription:
customer = self.resolve_customer(user)
return RecurringSubscription(
customer=customer,
description=self.cleaned_data["description"],
amount_cents=dollars_to_cents(self.cleaned_data["amount"]),
interval=self.cleaned_data["interval"],
notes=self.cleaned_data.get("notes") or "",
created_by=user,
last_modified_BY=user,
)
+262
View File
@@ -0,0 +1,262 @@
"""Admin invoice / subscription views and Stripe webhook."""
import json
import logging
from django.conf import settings
from django.contrib import messages
from django.http import HttpResponse, HttpResponseBadRequest
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from .forms import OneOffInvoiceForm, RecurringSubscriptionForm
from .models import BillingCustomer, Invoice, RecurringSubscription
from .permissions import financial_admin_required
from .stripe_billing import (
StripeAPIError,
StripeNotConfigured,
apply_checkout_session_completed,
apply_stripe_invoice_event,
apply_subscription_event,
construct_webhook_event,
create_one_off_invoice,
create_subscription_checkout,
send_pay_link_email,
)
logger = logging.getLogger(__name__)
@financial_admin_required
def invoice_list(request):
invoices = Invoice.objects.select_related("customer").all()[:100]
subscriptions = RecurringSubscription.objects.select_related("customer").all()[:100]
customers = BillingCustomer.objects.all()[:50]
return render(
request,
"financial/invoices.html",
{
"invoices": invoices,
"subscriptions": subscriptions,
"customers": customers,
"stripe_configured": bool(getattr(settings, "STRIPE_SECRET_KEY", "")),
},
)
@financial_admin_required
def invoice_new(request):
if request.method == "POST":
form = OneOffInvoiceForm(request.POST)
if form.is_valid():
invoice = form.build_invoice(request.user)
invoice.save()
try:
create_one_off_invoice(invoice)
if form.cleaned_data.get("send_email") and invoice.hosted_invoice_url:
send_pay_link_email(
to_email=invoice.customer.email,
customer_name=invoice.customer.name,
description=invoice.description,
amount_dollars=invoice.amount_dollars,
pay_url=invoice.hosted_invoice_url,
kind="invoice",
)
invoice.pay_link_emailed_at = timezone.now()
invoice.save(update_fields=["pay_link_emailed_at"])
messages.success(
request,
f"Invoice created and payment link emailed to {invoice.customer.email}.",
)
else:
messages.success(
request,
"Invoice created in Stripe. Payment link saved on the detail page.",
)
return redirect("invoice_detail", invoice_id=invoice.pk)
except StripeNotConfigured as exc:
messages.error(request, str(exc))
except StripeAPIError as exc:
messages.error(request, f"Stripe error: {exc.user_message}")
else:
form = OneOffInvoiceForm()
return render(request, "financial/invoice_new.html", {"form": form})
@financial_admin_required
def invoice_detail(request, invoice_id):
invoice = get_object_or_404(
Invoice.objects.select_related("customer"), pk=invoice_id
)
return render(request, "financial/invoice_detail.html", {"invoice": invoice})
@financial_admin_required
def invoice_resend_email(request, invoice_id):
invoice = get_object_or_404(
Invoice.objects.select_related("customer"), pk=invoice_id
)
if request.method != "POST":
return redirect("invoice_detail", invoice_id=invoice.pk)
if not invoice.hosted_invoice_url:
messages.error(request, "No payment link available for this invoice.")
return redirect("invoice_detail", invoice_id=invoice.pk)
try:
send_pay_link_email(
to_email=invoice.customer.email,
customer_name=invoice.customer.name,
description=invoice.description,
amount_dollars=invoice.amount_dollars,
pay_url=invoice.hosted_invoice_url,
kind="invoice",
)
invoice.pay_link_emailed_at = timezone.now()
invoice.save(update_fields=["pay_link_emailed_at"])
messages.success(request, f"Payment link resent to {invoice.customer.email}.")
except Exception as exc: # noqa: BLE001 — surface mail errors to admin UI
messages.error(request, f"Email failed: {exc}")
return redirect("invoice_detail", invoice_id=invoice.pk)
@financial_admin_required
def subscription_new(request):
if request.method == "POST":
form = RecurringSubscriptionForm(request.POST)
if form.is_valid():
subscription = form.build_subscription(request.user)
subscription.save()
success_url = (
request.build_absolute_uri(
reverse("subscription_detail", args=[subscription.pk])
)
+ "?checkout=success"
)
cancel_url = (
request.build_absolute_uri(
reverse("subscription_detail", args=[subscription.pk])
)
+ "?checkout=cancel"
)
try:
create_subscription_checkout(
subscription,
success_url=success_url,
cancel_url=cancel_url,
)
if form.cleaned_data.get("send_email") and subscription.checkout_url:
send_pay_link_email(
to_email=subscription.customer.email,
customer_name=subscription.customer.name,
description=subscription.description,
amount_dollars=subscription.amount_dollars,
pay_url=subscription.checkout_url,
kind="subscription",
)
subscription.pay_link_emailed_at = timezone.now()
subscription.save(update_fields=["pay_link_emailed_at"])
messages.success(
request,
f"Subscription checkout created and emailed to {subscription.customer.email}.",
)
else:
messages.success(
request,
"Subscription checkout created. Share the link from the detail page.",
)
return redirect("subscription_detail", subscription_id=subscription.pk)
except StripeNotConfigured as exc:
messages.error(request, str(exc))
except StripeAPIError as exc:
messages.error(request, f"Stripe error: {exc.user_message}")
else:
form = RecurringSubscriptionForm()
return render(request, "financial/subscription_new.html", {"form": form})
@financial_admin_required
def subscription_detail(request, subscription_id):
subscription = get_object_or_404(
RecurringSubscription.objects.select_related("customer"),
pk=subscription_id,
)
return render(
request,
"financial/subscription_detail.html",
{"subscription": subscription},
)
@financial_admin_required
def subscription_resend_email(request, subscription_id):
subscription = get_object_or_404(
RecurringSubscription.objects.select_related("customer"),
pk=subscription_id,
)
if request.method != "POST":
return redirect("subscription_detail", subscription_id=subscription.pk)
if not subscription.checkout_url:
messages.error(request, "No checkout link available for this subscription.")
return redirect("subscription_detail", subscription_id=subscription.pk)
try:
send_pay_link_email(
to_email=subscription.customer.email,
customer_name=subscription.customer.name,
description=subscription.description,
amount_dollars=subscription.amount_dollars,
pay_url=subscription.checkout_url,
kind="subscription",
)
subscription.pay_link_emailed_at = timezone.now()
subscription.save(update_fields=["pay_link_emailed_at"])
messages.success(
request, f"Checkout link resent to {subscription.customer.email}."
)
except Exception as exc: # noqa: BLE001
messages.error(request, f"Email failed: {exc}")
return redirect("subscription_detail", subscription_id=subscription.pk)
@csrf_exempt
@require_POST
def stripe_webhook(request):
"""Receive Stripe webhooks; verify signature when secret is configured."""
payload = request.body
sig_header = request.META.get("HTTP_STRIPE_SIGNATURE", "")
webhook_secret = getattr(settings, "STRIPE_WEBHOOK_SECRET", "") or ""
try:
if webhook_secret:
event = construct_webhook_event(payload, sig_header, webhook_secret)
else:
logger.warning(
"STRIPE_WEBHOOK_SECRET unset — accepting unsigned webhook (dev only)"
)
event = json.loads(payload.decode("utf-8"))
except (ValueError, json.JSONDecodeError) as exc:
return HttpResponseBadRequest(str(exc))
event_type = event.get("type")
data_object = event.get("data", {}).get("object", {})
if event_type in {
"invoice.paid",
"invoice.payment_failed",
"invoice.finalized",
"invoice.voided",
"invoice.marked_uncollectible",
}:
apply_stripe_invoice_event(data_object)
elif event_type in {
"customer.subscription.updated",
"customer.subscription.deleted",
}:
apply_subscription_event(data_object)
elif event_type == "checkout.session.completed":
apply_checkout_session_completed(data_object)
return HttpResponse(status=200)
@@ -0,0 +1,88 @@
# Generated by Django 5.0 on 2026-07-31 11:31
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('financial', '0015_userprofile'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='BillingCustomer',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateTimeField(default=django.utils.timezone.now)),
('last_modified', models.DateTimeField(default=django.utils.timezone.now)),
('slug', models.SlugField(blank=True, null=True, unique=True)),
('name', models.CharField(max_length=200)),
('email', models.EmailField(max_length=254)),
('company', models.CharField(blank=True, max_length=200)),
('stripe_customer_id', models.CharField(blank=True, default='', max_length=255)),
('notes', models.TextField(blank=True)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('last_modified_BY', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Invoice',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateTimeField(default=django.utils.timezone.now)),
('last_modified', models.DateTimeField(default=django.utils.timezone.now)),
('slug', models.SlugField(blank=True, null=True, unique=True)),
('description', models.CharField(max_length=500)),
('amount_cents', models.PositiveIntegerField(help_text='Amount in cents (USD)')),
('currency', models.CharField(default='usd', max_length=3)),
('status', models.CharField(choices=[('draft', 'Draft'), ('open', 'Open'), ('paid', 'Paid'), ('void', 'Void'), ('uncollectible', 'Uncollectible'), ('failed', 'Failed')], default='draft', max_length=20)),
('due_date', models.DateField(blank=True, null=True)),
('notes', models.TextField(blank=True)),
('stripe_invoice_id', models.CharField(blank=True, default='', max_length=255)),
('hosted_invoice_url', models.URLField(blank=True, default='', max_length=500)),
('invoice_pdf_url', models.URLField(blank=True, default='', max_length=500)),
('pay_link_emailed_at', models.DateTimeField(blank=True, null=True)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='invoices', to='financial.billingcustomer')),
('last_modified_BY', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created'],
},
),
migrations.CreateModel(
name='RecurringSubscription',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateTimeField(default=django.utils.timezone.now)),
('last_modified', models.DateTimeField(default=django.utils.timezone.now)),
('slug', models.SlugField(blank=True, null=True, unique=True)),
('description', models.CharField(max_length=500)),
('amount_cents', models.PositiveIntegerField(help_text='Amount per period in cents (USD)')),
('currency', models.CharField(default='usd', max_length=3)),
('interval', models.CharField(choices=[('month', 'Monthly'), ('year', 'Yearly'), ('week', 'Weekly')], default='month', max_length=10)),
('status', models.CharField(choices=[('incomplete', 'Incomplete'), ('active', 'Active'), ('past_due', 'Past due'), ('canceled', 'Canceled'), ('unpaid', 'Unpaid'), ('trialing', 'Trialing'), ('paused', 'Paused')], default='incomplete', max_length=20)),
('notes', models.TextField(blank=True)),
('stripe_product_id', models.CharField(blank=True, default='', max_length=255)),
('stripe_price_id', models.CharField(blank=True, default='', max_length=255)),
('stripe_subscription_id', models.CharField(blank=True, default='', max_length=255)),
('checkout_session_id', models.CharField(blank=True, default='', max_length=255)),
('checkout_url', models.URLField(blank=True, default='', max_length=500)),
('pay_link_emailed_at', models.DateTimeField(blank=True, null=True)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='subscriptions', to='financial.billingcustomer')),
('last_modified_BY', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created'],
},
),
]
+102
View File
@@ -302,6 +302,108 @@ class TimeCardCell(IdMixin, TimeMixin):
charge_number = models.ForeignKey(ChargeNumber, on_delete=models.CASCADE, null=True, blank=True) charge_number = models.ForeignKey(ChargeNumber, on_delete=models.CASCADE, null=True, blank=True)
class BillingCustomer(IdMixin, TimeMixin):
"""Customer who pays us (one-off invoices or subscriptions)."""
name = models.CharField(max_length=200)
email = models.EmailField()
company = models.CharField(max_length=200, blank=True)
stripe_customer_id = models.CharField(max_length=255, blank=True, default="")
notes = models.TextField(blank=True)
class Meta:
ordering = ["name"]
def __str__(self):
return f"{self.name} <{self.email}>"
class Invoice(IdMixin, TimeMixin):
"""One-off invoice — customer pays us via Stripe hosted invoice URL."""
class Status(models.TextChoices):
DRAFT = "draft", "Draft"
OPEN = "open", "Open"
PAID = "paid", "Paid"
VOID = "void", "Void"
UNCOLLECTIBLE = "uncollectible", "Uncollectible"
FAILED = "failed", "Failed"
customer = models.ForeignKey(
BillingCustomer, on_delete=models.PROTECT, related_name="invoices"
)
description = models.CharField(max_length=500)
amount_cents = models.PositiveIntegerField(help_text="Amount in cents (USD)")
currency = models.CharField(max_length=3, default="usd")
status = models.CharField(
max_length=20, choices=Status.choices, default=Status.DRAFT
)
due_date = models.DateField(null=True, blank=True)
notes = models.TextField(blank=True)
stripe_invoice_id = models.CharField(max_length=255, blank=True, default="")
hosted_invoice_url = models.URLField(max_length=500, blank=True, default="")
invoice_pdf_url = models.URLField(max_length=500, blank=True, default="")
pay_link_emailed_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ["-created"]
def __str__(self):
return f"Invoice {self.pk}{self.customer}{self.status}"
@property
def amount_dollars(self):
return self.amount_cents / 100.0
class RecurringSubscription(IdMixin, TimeMixin):
"""Monthly (or custom) recurring charge — customer pays us via Stripe Subscription."""
class Status(models.TextChoices):
INCOMPLETE = "incomplete", "Incomplete"
ACTIVE = "active", "Active"
PAST_DUE = "past_due", "Past due"
CANCELED = "canceled", "Canceled"
UNPAID = "unpaid", "Unpaid"
TRIALING = "trialing", "Trialing"
PAUSED = "paused", "Paused"
class Interval(models.TextChoices):
MONTH = "month", "Monthly"
YEAR = "year", "Yearly"
WEEK = "week", "Weekly"
customer = models.ForeignKey(
BillingCustomer, on_delete=models.PROTECT, related_name="subscriptions"
)
description = models.CharField(max_length=500)
amount_cents = models.PositiveIntegerField(help_text="Amount per period in cents (USD)")
currency = models.CharField(max_length=3, default="usd")
interval = models.CharField(
max_length=10, choices=Interval.choices, default=Interval.MONTH
)
status = models.CharField(
max_length=20, choices=Status.choices, default=Status.INCOMPLETE
)
notes = models.TextField(blank=True)
stripe_product_id = models.CharField(max_length=255, blank=True, default="")
stripe_price_id = models.CharField(max_length=255, blank=True, default="")
stripe_subscription_id = models.CharField(max_length=255, blank=True, default="")
checkout_session_id = models.CharField(max_length=255, blank=True, default="")
checkout_url = models.URLField(max_length=500, blank=True, default="")
pay_link_emailed_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ["-created"]
def __str__(self):
return f"Subscription {self.pk}{self.customer}{self.status}"
@property
def amount_dollars(self):
return self.amount_cents / 100.0
def set_user_type(user, user_type): def set_user_type(user, user_type):
"""Set user type and sync the Employee record (mutually exclusive types).""" """Set user type and sync the Employee record (mutually exclusive types)."""
user.__dict__.pop("profile", None) user.__dict__.pop("profile", None)
+383
View File
@@ -0,0 +1,383 @@
"""Stripe REST helpers (stdlib HTTP) for invoices and subscriptions.
Customers pay us — one-off hosted invoices and Checkout subscription sessions.
"""
from __future__ import annotations
import hashlib
import hmac
import json
import logging
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime
from decimal import Decimal
from django.conf import settings
from django.core.mail import EmailMultiAlternatives
from django.template.loader import get_template
from django.utils import timezone
from .models import BillingCustomer, Invoice, RecurringSubscription
logger = logging.getLogger(__name__)
STRIPE_API_BASE = "https://api.stripe.com/v1"
class StripeNotConfigured(Exception):
"""Raised when Stripe secret key is missing."""
class StripeAPIError(Exception):
"""Raised when Stripe API returns an error."""
def __init__(self, message: str, status: int | None = None):
super().__init__(message)
self.status = status
self.user_message = message
def _secret_key() -> str:
key = getattr(settings, "STRIPE_SECRET_KEY", "") or ""
if not key:
raise StripeNotConfigured(
"STRIPE_SECRET_KEY is not set. Add it to the environment."
)
return key
def _stripe_request(method: str, path: str, data: dict | None = None) -> dict:
"""Call Stripe API with form-encoded body (Stripe default)."""
url = f"{STRIPE_API_BASE}{path}"
body = None
headers = {
"Authorization": f"Bearer {_secret_key()}",
}
if data is not None:
body = urllib.parse.urlencode(_flatten_params(data)).encode("utf-8")
headers["Content-Type"] = "application/x-www-form-urlencoded"
request = urllib.request.Request(url, data=body, headers=headers, method=method)
try:
with urllib.request.urlopen(request, timeout=30) as response:
return json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
raw = exc.read().decode("utf-8", errors="replace")
try:
payload = json.loads(raw)
message = payload.get("error", {}).get("message") or raw
except json.JSONDecodeError:
message = raw or str(exc)
raise StripeAPIError(message, status=exc.code) from exc
except urllib.error.URLError as exc:
raise StripeAPIError(f"Could not reach Stripe: {exc.reason}") from exc
def _flatten_params(data: dict, prefix: str = "") -> list[tuple[str, str]]:
"""Flatten nested dicts/lists into Stripe form keys (a[b]=1, items[0][x]=y)."""
items: list[tuple[str, str]] = []
for key, value in data.items():
full_key = f"{prefix}[{key}]" if prefix else str(key)
if value is None:
continue
if isinstance(value, dict):
items.extend(_flatten_params(value, full_key))
elif isinstance(value, (list, tuple)):
for index, entry in enumerate(value):
indexed = f"{full_key}[{index}]"
if isinstance(entry, dict):
items.extend(_flatten_params(entry, indexed))
else:
items.append((indexed, str(entry)))
elif isinstance(value, bool):
items.append((full_key, "true" if value else "false"))
else:
items.append((full_key, str(value)))
return items
def construct_webhook_event(payload: bytes, sig_header: str, secret: str) -> dict:
"""Verify Stripe-Signature and return event dict."""
if not sig_header:
raise ValueError("Missing Stripe-Signature header")
elements = {part.split("=", 1)[0]: part.split("=", 1)[1] for part in sig_header.split(",") if "=" in part}
timestamp = elements.get("t")
signatures = [part.split("=", 1)[1] for part in sig_header.split(",") if part.startswith("v1=")]
if not timestamp or not signatures:
raise ValueError("Invalid Stripe-Signature header")
signed_payload = f"{timestamp}.{payload.decode('utf-8')}".encode("utf-8")
expected = hmac.new(secret.encode("utf-8"), signed_payload, hashlib.sha256).hexdigest()
if not any(hmac.compare_digest(expected, candidate) for candidate in signatures):
raise ValueError("Webhook signature verification failed")
# Reject stale timestamps (>5 minutes)
if abs(time.time() - int(timestamp)) > 300:
raise ValueError("Webhook timestamp outside tolerance")
return json.loads(payload.decode("utf-8"))
def ensure_stripe_customer(customer: BillingCustomer) -> str:
"""Create Stripe Customer if needed; return stripe_customer_id."""
if customer.stripe_customer_id:
return customer.stripe_customer_id
stripe_customer = _stripe_request(
"POST",
"/customers",
{
"email": customer.email,
"name": customer.name,
"metadata": {
"local_customer_id": str(customer.pk),
"company": customer.company or "",
},
},
)
customer.stripe_customer_id = stripe_customer["id"]
customer.save(update_fields=["stripe_customer_id", "last_modified"])
return customer.stripe_customer_id
def create_one_off_invoice(invoice: Invoice) -> Invoice:
"""Create + finalize a Stripe Invoice; store hosted pay URL."""
stripe_customer_id = ensure_stripe_customer(invoice.customer)
_stripe_request(
"POST",
"/invoiceitems",
{
"customer": stripe_customer_id,
"amount": invoice.amount_cents,
"currency": invoice.currency,
"description": invoice.description,
},
)
create_data: dict = {
"customer": stripe_customer_id,
"collection_method": "send_invoice",
"auto_advance": True,
"metadata": {"local_invoice_id": str(invoice.pk)},
}
if invoice.due_date:
due_dt = datetime.combine(invoice.due_date, datetime.min.time())
if timezone.is_naive(due_dt):
due_dt = timezone.make_aware(due_dt, timezone.get_current_timezone())
create_data["due_date"] = int(due_dt.timestamp())
else:
create_data["days_until_due"] = 30
stripe_invoice = _stripe_request("POST", "/invoices", create_data)
stripe_invoice = _stripe_request(
"POST", f"/invoices/{stripe_invoice['id']}/finalize", {}
)
invoice.stripe_invoice_id = stripe_invoice["id"]
invoice.hosted_invoice_url = stripe_invoice.get("hosted_invoice_url") or ""
invoice.invoice_pdf_url = stripe_invoice.get("invoice_pdf") or ""
status = stripe_invoice.get("status") or Invoice.Status.OPEN
if status in dict(Invoice.Status.choices):
invoice.status = status
invoice.last_modified = timezone.now()
invoice.save(
update_fields=[
"stripe_invoice_id",
"hosted_invoice_url",
"invoice_pdf_url",
"status",
"last_modified",
]
)
return invoice
def create_subscription_checkout(
subscription: RecurringSubscription,
*,
success_url: str,
cancel_url: str,
) -> RecurringSubscription:
"""Create Stripe Product/Price + Checkout Session (subscription mode)."""
stripe_customer_id = ensure_stripe_customer(subscription.customer)
product = _stripe_request(
"POST",
"/products",
{
"name": subscription.description,
"metadata": {"local_subscription_id": str(subscription.pk)},
},
)
price = _stripe_request(
"POST",
"/prices",
{
"product": product["id"],
"unit_amount": subscription.amount_cents,
"currency": subscription.currency,
"recurring": {"interval": subscription.interval},
},
)
session = _stripe_request(
"POST",
"/checkout/sessions",
{
"mode": "subscription",
"customer": stripe_customer_id,
"line_items": [{"price": price["id"], "quantity": 1}],
"success_url": success_url,
"cancel_url": cancel_url,
"metadata": {"local_subscription_id": str(subscription.pk)},
"subscription_data": {
"metadata": {"local_subscription_id": str(subscription.pk)},
},
},
)
subscription.stripe_product_id = product["id"]
subscription.stripe_price_id = price["id"]
subscription.checkout_session_id = session["id"]
subscription.checkout_url = session.get("url") or ""
subscription.status = RecurringSubscription.Status.INCOMPLETE
subscription.last_modified = timezone.now()
subscription.save(
update_fields=[
"stripe_product_id",
"stripe_price_id",
"checkout_session_id",
"checkout_url",
"status",
"last_modified",
]
)
return subscription
def dollars_to_cents(amount: Decimal | float | str) -> int:
return int(round(Decimal(str(amount)) * 100))
def send_pay_link_email(
*,
to_email: str,
customer_name: str,
description: str,
amount_dollars: float,
pay_url: str,
kind: str = "invoice",
) -> None:
"""Email customer a Stripe payment / checkout link."""
subject = (
f"Payment request from AI ML Operations — {description}"
if kind == "invoice"
else f"Subscription signup — {description}"
)
from_email = getattr(
settings,
"DEFAULT_FROM_EMAIL",
"AI ML Operations, LLC <info@aimloperations.com>",
)
context = {
"customer_name": customer_name,
"description": description,
"amount_dollars": f"{amount_dollars:.2f}",
"pay_url": pay_url,
"kind": kind,
"subject": subject,
}
html_content = get_template("emails/invoice_pay_link.html").render(context)
text_content = get_template("emails/invoice_pay_link.txt").render(context)
msg = EmailMultiAlternatives(subject, text_content, from_email, [to_email])
msg.attach_alternative(html_content, "text/html")
msg.send(fail_silently=False)
def apply_stripe_invoice_event(stripe_invoice: dict) -> Invoice | None:
"""Sync local Invoice from Stripe invoice object dict."""
local_id = (stripe_invoice.get("metadata") or {}).get("local_invoice_id")
stripe_id = stripe_invoice.get("id")
invoice = None
if local_id:
invoice = Invoice.objects.filter(pk=local_id).first()
if invoice is None and stripe_id:
invoice = Invoice.objects.filter(stripe_invoice_id=stripe_id).first()
if invoice is None:
return None
status = stripe_invoice.get("status") or invoice.status
if status in dict(Invoice.Status.choices):
invoice.status = status
invoice.hosted_invoice_url = (
stripe_invoice.get("hosted_invoice_url") or invoice.hosted_invoice_url
)
invoice.invoice_pdf_url = (
stripe_invoice.get("invoice_pdf") or invoice.invoice_pdf_url
)
if stripe_id:
invoice.stripe_invoice_id = stripe_id
invoice.last_modified = timezone.now()
invoice.save(
update_fields=[
"status",
"hosted_invoice_url",
"invoice_pdf_url",
"stripe_invoice_id",
"last_modified",
]
)
return invoice
def apply_subscription_event(stripe_sub: dict) -> RecurringSubscription | None:
"""Sync local RecurringSubscription from Stripe subscription object."""
local_id = (stripe_sub.get("metadata") or {}).get("local_subscription_id")
stripe_id = stripe_sub.get("id")
sub = None
if local_id:
sub = RecurringSubscription.objects.filter(pk=local_id).first()
if sub is None and stripe_id:
sub = RecurringSubscription.objects.filter(
stripe_subscription_id=stripe_id
).first()
if sub is None:
return None
status = stripe_sub.get("status") or sub.status
if status in dict(RecurringSubscription.Status.choices):
sub.status = status
if stripe_id:
sub.stripe_subscription_id = stripe_id
sub.last_modified = timezone.now()
sub.save(update_fields=["status", "stripe_subscription_id", "last_modified"])
return sub
def apply_checkout_session_completed(session: dict) -> RecurringSubscription | None:
"""Attach subscription id after Checkout completes."""
if session.get("mode") != "subscription":
return None
local_id = (session.get("metadata") or {}).get("local_subscription_id")
sub = None
if local_id:
sub = RecurringSubscription.objects.filter(pk=local_id).first()
if sub is None and session.get("id"):
sub = RecurringSubscription.objects.filter(
checkout_session_id=session["id"]
).first()
if sub is None:
return None
stripe_sub_id = session.get("subscription")
if stripe_sub_id:
sub.stripe_subscription_id = stripe_sub_id
sub.status = RecurringSubscription.Status.ACTIVE
sub.last_modified = timezone.now()
sub.save(update_fields=["stripe_subscription_id", "status", "last_modified"])
return sub
@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ subject }}</title>
<style>
body { margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f4f4; }
.email-container { max-width: 600px; margin: 0 auto; background-color: #ffffff; border: 1px solid #dddddd; }
.header { background-color: #0b3d5c; color: #ffffff; padding: 20px; text-align: center; }
.content { padding: 24px; color: #333333; font-size: 15px; line-height: 1.5; }
.btn {
display: inline-block; margin: 20px 0; padding: 12px 24px;
background-color: #0b3d5c; color: #ffffff !important; text-decoration: none;
border-radius: 4px; font-weight: bold;
}
.footer { background-color: #f4f4f4; color: #777777; text-align: center; padding: 12px; font-size: 12px; }
.amount { font-size: 22px; font-weight: bold; margin: 12px 0; }
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<h1>AI ML Operations</h1>
</div>
<div class="content">
<p>Hello {{ customer_name }},</p>
{% if kind == "subscription" %}
<p>Please complete checkout to start your recurring payment:</p>
{% else %}
<p>You have a new invoice ready for payment:</p>
{% endif %}
<p><strong>{{ description }}</strong></p>
<p class="amount">${{ amount_dollars }} USD</p>
<p>
<a class="btn" href="{{ pay_url }}">
{% if kind == "subscription" %}Complete checkout{% else %}Pay invoice{% endif %}
</a>
</p>
<p style="font-size: 13px; color: #666;">Or open this link:<br>{{ pay_url }}</p>
<p>Thank you,<br>AI ML Operations, LLC</p>
</div>
<div class="footer">
<p>This is an automated message. Please do not reply to this email.</p>
</div>
</div>
</body>
</html>
@@ -0,0 +1,12 @@
Hello {{ customer_name }},
{% if kind == "subscription" %}Please complete checkout to start your recurring payment:{% else %}You have a new invoice ready for payment:{% endif %}
{{ description }}
Amount: ${{ amount_dollars }} USD
{% if kind == "subscription" %}Checkout link:{% else %}Pay invoice:{% endif %}
{{ pay_url }}
Thank you,
AI ML Operations, LLC
@@ -29,6 +29,11 @@
<span class="card-title">Manage Users</span> <span class="card-title">Manage Users</span>
<p class="card-text">Set Employee or Client type for user accounts.</p> <p class="card-text">Set Employee or Client type for user accounts.</p>
</a> </a>
<a href="{% url 'invoice_list' %}" class="card"
data-tianji-event="financial_nav" data-tianji-event-destination="invoices">
<span class="card-title">Invoices</span>
<p class="card-text">Create one-off invoices and monthly subscriptions (Stripe).</p>
</a>
{% endif %} {% endif %}
{% if can_write_financials %} {% if can_write_financials %}
<a href="{% url 'Timekeeping' %}" class="card" <a href="{% url 'Timekeeping' %}" class="card"
@@ -0,0 +1,38 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Invoice {{ invoice.pk }} - AI ML Operations{% endblock %}
{% block content %}
<div class="section">
<div class="container">
<div style="margin-bottom: 2rem;">
<a href="{% url 'invoice_list' %}" class="btn">Back to Invoices</a>
</div>
<h1 class="section-title" style="text-align: left;">Invoice #{{ invoice.pk }}</h1>
<div class="card" style="max-width: 720px;">
<p><strong>Customer:</strong> {{ invoice.customer.name }} &lt;{{ invoice.customer.email }}&gt;</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 &amp; 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 &amp; Subscriptions</h1>
{% if not stripe_configured %}
<p style="color: #c0392b; margin-bottom: 1.5rem;">
Stripe is not configured. Set <code>STRIPE_SECRET_KEY</code> (and webhook secret) in the environment before creating invoices.
</p>
{% endif %}
<div class="card-grid" style="margin-bottom: 2rem;">
<a href="{% url 'invoice_new' %}" class="card">
<span class="card-title">New one-off invoice</span>
<p class="card-text">Create a Stripe invoice and email the customer a pay link.</p>
</a>
<a href="{% url 'subscription_new' %}" class="card">
<span class="card-title">New monthly subscription</span>
<p class="card-text">Customer pays us on a recurring schedule via Checkout.</p>
</a>
</div>
<h2 class="section-title" style="font-size: 1.5rem; margin-bottom: 1rem;">One-off invoices</h2>
<div class="table-responsive" style="margin-bottom: 3rem;">
{% if invoices %}
<table class="table">
<thead>
<tr>
<th>Customer</th>
<th>Description</th>
<th>Amount</th>
<th>Status</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{% for inv in invoices %}
<tr>
<td><a href="{% url 'invoice_detail' inv.pk %}">{{ inv.customer.name }}</a></td>
<td>{{ inv.description }}</td>
<td>${{ inv.amount_dollars|floatformat:2 }}</td>
<td>{{ inv.get_status_display }}</td>
<td>{{ inv.created|date:"Y-m-d" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="color: var(--text-muted);">No invoices yet.</p>
{% endif %}
</div>
<h2 class="section-title" style="font-size: 1.5rem; margin-bottom: 1rem;">Recurring subscriptions</h2>
<div class="table-responsive">
{% if subscriptions %}
<table class="table">
<thead>
<tr>
<th>Customer</th>
<th>Description</th>
<th>Amount</th>
<th>Interval</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for sub in subscriptions %}
<tr>
<td><a href="{% url 'subscription_detail' sub.pk %}">{{ sub.customer.name }}</a></td>
<td>{{ sub.description }}</td>
<td>${{ sub.amount_dollars|floatformat:2 }}</td>
<td>{{ sub.get_interval_display }}</td>
<td>{{ sub.get_status_display }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="color: var(--text-muted);">No subscriptions yet.</p>
{% endif %}
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,34 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Subscription {{ subscription.pk }} - AI ML Operations{% endblock %}
{% block content %}
<div class="section">
<div class="container">
<div style="margin-bottom: 2rem;">
<a href="{% url 'invoice_list' %}" class="btn">Back to Invoices</a>
</div>
<h1 class="section-title" style="text-align: left;">Subscription #{{ subscription.pk }}</h1>
<div class="card" style="max-width: 720px;">
<p><strong>Customer:</strong> {{ subscription.customer.name }} &lt;{{ subscription.customer.email }}&gt;</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 &amp; send</button>
</form>
</div>
</div>
</div>
{% endblock %}
+59
View File
@@ -157,3 +157,62 @@ class FinancialAccessTests(TestCase):
usernames = [e.user.username for e in employees] usernames = [e.user.username for e in employees]
self.assertIn("employee", usernames) self.assertIn("employee", usernames)
self.assertNotIn("extra", usernames) self.assertNotIn("extra", usernames)
class InvoiceAccessTests(TestCase):
def setUp(self):
self.admin = User.objects.create_superuser(
username="billing_admin", password="pass", email="admin@example.com"
)
self.employee_user = User.objects.create_user(username="emp2", password="pass")
set_user_type(self.employee_user, UserProfile.UserType.EMPLOYEE)
self.client_http = Client()
def test_non_admin_cannot_view_invoices(self):
self.client_http.login(username="emp2", password="pass")
response = self.client_http.get(reverse("invoice_list"))
self.assertIn(response.status_code, (302, 403))
def test_admin_can_view_invoices(self):
self.client_http.login(username="billing_admin", password="pass")
response = self.client_http.get(reverse("invoice_list"))
self.assertEqual(response.status_code, 200)
def test_webhook_rejects_bad_signature_when_secret_set(self):
from django.test import override_settings
with override_settings(STRIPE_WEBHOOK_SECRET="whsec_test"):
response = self.client_http.post(
reverse("stripe_webhook"),
data=b'{"type":"invoice.paid","data":{"object":{}}}',
content_type="application/json",
HTTP_STRIPE_SIGNATURE="t=1,v1=bad",
)
self.assertEqual(response.status_code, 400)
def test_apply_invoice_webhook_updates_status(self):
from financial.models import BillingCustomer, Invoice
from financial.stripe_billing import apply_stripe_invoice_event
customer = BillingCustomer.objects.create(
name="Acme", email="acme@example.com"
)
invoice = Invoice.objects.create(
customer=customer,
description="Work",
amount_cents=5000,
stripe_invoice_id="in_test_123",
status=Invoice.Status.OPEN,
)
apply_stripe_invoice_event(
{
"id": "in_test_123",
"status": "paid",
"metadata": {"local_invoice_id": str(invoice.pk)},
"hosted_invoice_url": "https://pay.stripe.com/test",
}
)
invoice.refresh_from_db()
self.assertEqual(invoice.status, Invoice.Status.PAID)
self.assertEqual(invoice.hosted_invoice_url, "https://pay.stripe.com/test")
+21
View File
@@ -1,6 +1,7 @@
from django.urls import path from django.urls import path
from . import views from . import views
from . import invoice_views
urlpatterns = [ urlpatterns = [
path("", views.financial_home, name="financial_home"), path("", views.financial_home, name="financial_home"),
@@ -21,4 +22,24 @@ urlpatterns = [
path("profile", views.profile, name="profile"), path("profile", views.profile, name="profile"),
path("manage_users", views.manage_users, name="manage_users"), path("manage_users", views.manage_users, name="manage_users"),
path("client_reports", views.client_reports, name="client_reports"), path("client_reports", views.client_reports, name="client_reports"),
path("invoices", invoice_views.invoice_list, name="invoice_list"),
path("invoices/new", invoice_views.invoice_new, name="invoice_new"),
path("invoices/<int:invoice_id>", invoice_views.invoice_detail, name="invoice_detail"),
path(
"invoices/<int:invoice_id>/resend",
invoice_views.invoice_resend_email,
name="invoice_resend_email",
),
path("subscriptions/new", invoice_views.subscription_new, name="subscription_new"),
path(
"subscriptions/<int:subscription_id>",
invoice_views.subscription_detail,
name="subscription_detail",
),
path(
"subscriptions/<int:subscription_id>/resend",
invoice_views.subscription_resend_email,
name="subscription_resend_email",
),
path("stripe/webhook/", invoice_views.stripe_webhook, name="stripe_webhook"),
] ]
+62 -11
View File
@@ -1,20 +1,34 @@
from django.contrib import admin from django.contrib import admin
from .models import Contact, EmailMessage
from .views import preview_email
from django.shortcuts import render, get_object_or_404
from django.urls import path
from django.template.loader import get_template
from django.core.mail import EmailMultiAlternatives from django.core.mail import EmailMultiAlternatives
from django.shortcuts import get_object_or_404
from django.template.loader import get_template
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.urls import path
# Register your models here. from .models import Contact, EmailMessage, PageVisit
@admin.register(Contact, site=admin.site) @admin.register(Contact, site=admin.site)
class ContactAdmin(admin.ModelAdmin): class ContactAdmin(admin.ModelAdmin):
list_display = ("email", "name", "contacted") list_display = (
list_filter = ("email", "name", "contacted") "email",
search_fields = ("email", "name") "name",
"contacted",
"utm_source",
"utm_campaign",
"created",
)
list_filter = ("contacted", "utm_source", "utm_medium", "utm_campaign")
search_fields = ("email", "name", "utm_source", "utm_campaign")
readonly_fields = (
"utm_source",
"utm_medium",
"utm_campaign",
"utm_term",
"utm_content",
"created",
"last_modified",
)
@admin.action(description="Send seelcted emails") @admin.action(description="Send seelcted emails")
@@ -25,8 +39,8 @@ def send_emails(modeladmin, request, queryset):
from_email = "AI ML Operations, LLC <info@aimloperations.com>" from_email = "AI ML Operations, LLC <info@aimloperations.com>"
d = {"title": email.subject, "content": email.body} d = {"title": email.subject, "content": email.body}
html_content = get_template(f"emails/marketing_email.html").render(d) html_content = get_template("emails/marketing_email.html").render(d)
text_content = get_template(f"emails/marketing_email.txt").render(d) text_content = get_template("emails/marketing_email.txt").render(d)
msg = EmailMultiAlternatives( msg = EmailMultiAlternatives(
email.subject, text_content, from_email, [email.recipient] email.subject, text_content, from_email, [email.recipient]
@@ -65,3 +79,40 @@ class EmailMessageAdmin(admin.ModelAdmin):
email_instance = get_object_or_404(EmailMessage, pk=pk) email_instance = get_object_or_404(EmailMessage, pk=pk)
context = {"title": email_instance.subject, "content": email_instance.body} context = {"title": email_instance.subject, "content": email_instance.body}
return TemplateResponse(request, "public/preview_email.html", context) return TemplateResponse(request, "public/preview_email.html", context)
@admin.register(PageVisit)
class PageVisitAdmin(admin.ModelAdmin):
list_display = (
"created",
"path",
"traffic_type",
"utm_source",
"utm_medium",
"utm_campaign",
"is_landing",
)
list_filter = (
"traffic_type",
"is_landing",
"utm_source",
"utm_medium",
"utm_campaign",
)
search_fields = ("path", "utm_source", "utm_campaign", "user_agent", "referrer")
readonly_fields = (
"created",
"path",
"query_string",
"referrer",
"user_agent",
"traffic_type",
"utm_source",
"utm_medium",
"utm_campaign",
"utm_term",
"utm_content",
"session_key",
"is_landing",
)
date_hierarchy = "created"
+15
View File
@@ -4,6 +4,11 @@ from django.conf import settings
from django.urls import reverse from django.urls import reverse
from .seo import PUBLIC_PAGE_ENTRIES, get_service_entries from .seo import PUBLIC_PAGE_ENTRIES, get_service_entries
from .web_design_pricing import (
WEB_DESIGN_INCLUDED,
WEB_DESIGN_PRICING_DISCLAIMER,
features_for_json,
)
def tianji_tracking(request): def tianji_tracking(request):
@@ -41,6 +46,15 @@ def webmcp_context(request):
} }
for url_name, title, _changefreq, _priority, summary in PUBLIC_PAGE_ENTRIES for url_name, title, _changefreq, _priority, summary in PUBLIC_PAGE_ENTRIES
} }
web_design_pricing = {
"features": features_for_json(),
"included_with_every_site": [
{"title": item["title"], "description": item["description"]}
for item in WEB_DESIGN_INCLUDED
],
"disclaimer": WEB_DESIGN_PRICING_DISCLAIMER,
"page_url": request.build_absolute_uri(reverse("web_design")),
}
return { return {
'webmcp_enabled': getattr(settings, 'WEBMCP_ENABLED', False), 'webmcp_enabled': getattr(settings, 'WEBMCP_ENABLED', False),
@@ -49,6 +63,7 @@ def webmcp_context(request):
'webmcp_recaptcha_required': not settings.DEBUG, 'webmcp_recaptcha_required': not settings.DEBUG,
'webmcp_services_json': json.dumps(services), 'webmcp_services_json': json.dumps(services),
'webmcp_pages_json': json.dumps(page_lookup), 'webmcp_pages_json': json.dumps(page_lookup),
'webmcp_web_design_pricing_json': json.dumps(web_design_pricing),
} }
+141
View File
@@ -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),
),
]
+59 -1
View File
@@ -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}"
+18 -3
View File
@@ -75,7 +75,10 @@ PUBLIC_PAGE_ENTRIES = (
"Web Design and Hosting", "Web Design and Hosting",
"monthly", "monthly",
"0.8", "0.8",
"Web design, development, and managed hosting for business sites and apps.", "Custom brand-tailored sites with interactive package pricing. You own the site and data. "
"Public site + client portal (UTM/leads) always included; add-ons for Email/SMS, direct mail, "
"blog, Stripe payments, social, and AI social. Every build includes three-instance hosting, "
"SEO/accessibility/LLM readiness, and Grafana metrics and alerts.",
), ),
( (
"contact", "contact",
@@ -127,7 +130,10 @@ def robots_txt(request):
sitemap_url = _absolute_url(request, "sitemap_xml") sitemap_url = _absolute_url(request, "sitemap_xml")
content = render_to_string( content = render_to_string(
"public/robots.txt", "public/robots.txt",
{"sitemap_url": sitemap_url}, {
"sitemap_url": sitemap_url,
"llms_url": _absolute_url(request, "llms_txt"),
},
) )
return HttpResponse(content, content_type="text/plain; charset=utf-8") return HttpResponse(content, content_type="text/plain; charset=utf-8")
@@ -146,18 +152,27 @@ def sitemap_xml(request):
def llms_txt(request): def llms_txt(request):
from .web_design_pricing import (
WEB_DESIGN_INCLUDED,
features_for_json,
)
pages = [ pages = [
{ {
"title": title, "title": title,
"url": _absolute_url(request, url_name), "url": _absolute_url(request, url_name),
"summary": summary,
} }
for url_name, title, _changefreq, _priority, _summary in PUBLIC_PAGE_ENTRIES for url_name, title, _changefreq, _priority, summary in PUBLIC_PAGE_ENTRIES
] ]
content = render_to_string( content = render_to_string(
"public/llms.txt", "public/llms.txt",
{ {
"site_url": request.build_absolute_uri("/"), "site_url": request.build_absolute_uri("/"),
"contact_url": _absolute_url(request, "contact"), "contact_url": _absolute_url(request, "contact"),
"web_design_url": _absolute_url(request, "web_design"),
"web_design_features": features_for_json(),
"web_design_included": WEB_DESIGN_INCLUDED,
"pages": pages, "pages": pages,
}, },
) )
@@ -878,3 +878,214 @@ input:focus, select:focus, textarea:focus {
text-transform: none; text-transform: none;
} }
} }
/* Web design interactive pricing estimator */
.pricing-intro {
text-align: center;
max-width: 720px;
margin: 0 auto 2.5rem;
color: var(--text-muted);
font-size: 1.05rem;
}
.pricing-estimator {
display: grid;
grid-template-columns: minmax(0, 1.6fr) minmax(260px, 0.9fr);
gap: 1.5rem;
align-items: start;
}
.pricing-features-panel,
.pricing-estimate-panel {
background: var(--bg-color);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
padding: 1.5rem;
}
.pricing-panel-title {
font-size: 1.35rem;
font-weight: 600;
color: #fff;
margin: 0 0 1.25rem;
}
.pricing-feature-list {
list-style: none;
margin: 0;
padding: 0;
}
.pricing-feature {
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.pricing-feature:first-child {
border-top: none;
}
.pricing-feature-label {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 0.85rem 1rem;
align-items: start;
padding: 1rem 0.25rem;
cursor: pointer;
}
.pricing-feature.is-required .pricing-feature-label {
cursor: default;
}
.pricing-feature-check {
appearance: none;
width: 1.15rem;
height: 1.15rem;
margin-top: 0.2rem;
border: 1.5px solid rgba(255, 255, 255, 0.35);
border-radius: 4px;
background: transparent;
display: grid;
place-content: center;
flex-shrink: 0;
cursor: pointer;
}
.pricing-feature-check::before {
content: "";
width: 0.65rem;
height: 0.65rem;
transform: scale(0);
transition: transform 0.12s ease-in-out;
box-shadow: inset 1em 1em var(--bg-color);
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
}
.pricing-feature-check:checked {
background: var(--primary-color);
border-color: var(--primary-color);
}
.pricing-feature-check:checked::before {
transform: scale(1);
}
.pricing-feature-check:disabled {
background: rgba(255, 255, 255, 0.18);
border-color: rgba(255, 255, 255, 0.25);
cursor: not-allowed;
}
.pricing-feature-check:disabled:checked::before {
box-shadow: inset 1em 1em rgba(10, 10, 10, 0.75);
}
.pricing-feature-check:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
.pricing-feature.is-selected:not(.is-required) {
background: rgba(0, 243, 255, 0.04);
}
.pricing-feature-name {
display: block;
color: #fff;
font-weight: 600;
margin-bottom: 0.2rem;
}
.pricing-feature-desc {
display: block;
color: var(--text-muted);
font-size: 0.92rem;
line-height: 1.45;
}
.pricing-feature-note {
display: block;
margin-top: 0.4rem;
color: #ffb347;
font-size: 0.85rem;
}
.pricing-feature-costs {
text-align: right;
color: var(--text-muted);
font-size: 0.9rem;
white-space: nowrap;
line-height: 1.45;
}
.pricing-feature-costs strong {
color: var(--primary-color);
font-weight: 700;
}
.pricing-feature-monthly {
display: block;
}
.pricing-estimate-panel {
position: sticky;
top: 5.5rem;
}
.pricing-estimate-row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 1rem;
margin-bottom: 0.85rem;
color: var(--text-muted);
}
.pricing-estimate-row strong {
color: var(--primary-color);
font-size: 1.5rem;
font-weight: 700;
}
.pricing-estimate-count {
margin: 1.25rem 0 0.75rem;
color: #fff;
font-size: 0.95rem;
}
.pricing-estimate-disclaimer {
color: var(--text-muted);
font-size: 0.85rem;
line-height: 1.45;
margin-bottom: 1.5rem;
}
.pricing-estimate-cta {
display: inline-block;
width: 100%;
text-align: center;
}
@media (max-width: 900px) {
.pricing-estimator {
grid-template-columns: 1fr;
}
.pricing-estimate-panel {
position: static;
}
.pricing-feature-label {
grid-template-columns: auto minmax(0, 1fr);
}
.pricing-feature-costs {
grid-column: 2;
text-align: left;
}
.pricing-feature-monthly {
display: inline;
margin-left: 0.5rem;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

@@ -16,6 +16,7 @@
var recaptchaRequired = configEl.dataset.recaptchaRequired === 'true'; var recaptchaRequired = configEl.dataset.recaptchaRequired === 'true';
var services = []; var services = [];
var pages = {}; var pages = {};
var webDesignPricing = { features: [], included_with_every_site: [], disclaimer: '' };
try { try {
services = JSON.parse(configEl.dataset.services || '[]'); services = JSON.parse(configEl.dataset.services || '[]');
@@ -29,6 +30,15 @@
pages = {}; pages = {};
} }
try {
webDesignPricing = JSON.parse(configEl.dataset.webDesignPricing || '{}');
if (!webDesignPricing.features) {
webDesignPricing.features = [];
}
} catch (e) {
webDesignPricing = { features: [], included_with_every_site: [], disclaimer: '' };
}
function textResult(payload) { function textResult(payload) {
return { return {
content: [{ content: [{
@@ -82,6 +92,85 @@
return null; return null;
} }
function estimateWebDesignCost(selectedIds) {
var catalog = {};
var features = webDesignPricing.features || [];
features.forEach(function (feature) {
catalog[feature.id] = feature;
});
var selected = {};
(selectedIds || []).forEach(function (id) {
if (catalog[id]) {
selected[id] = true;
}
});
features.forEach(function (feature) {
if (feature.required) {
selected[feature.id] = true;
}
});
var changed = true;
while (changed) {
changed = false;
Object.keys(selected).forEach(function (featureId) {
var feature = catalog[featureId];
if (!feature || !feature.requires) {
return;
}
feature.requires.forEach(function (dep) {
if (!selected[dep] && catalog[dep]) {
selected[dep] = true;
changed = true;
}
});
});
}
var resolved = features.filter(function (feature) {
return selected[feature.id];
});
var unknown = (selectedIds || []).filter(function (id) {
return !catalog[id];
});
return {
selected: resolved.map(function (feature) {
return {
id: feature.id,
name: feature.name,
build: feature.build,
monthly: feature.monthly,
};
}),
selected_count: resolved.length,
one_time_build: resolved.reduce(function (sum, feature) {
return sum + feature.build;
}, 0),
monthly: resolved.reduce(function (sum, feature) {
return sum + feature.monthly;
}, 0),
included_with_every_site: webDesignPricing.included_with_every_site || [],
disclaimer: webDesignPricing.disclaimer || '',
page_url: webDesignPricing.page_url || resolvePageUrl('web_design'),
available_features: features.map(function (feature) {
return {
id: feature.id,
name: feature.name,
description: feature.description,
build: feature.build,
monthly: feature.monthly,
required: !!feature.required,
requires: feature.requires || [],
};
}),
unknown_feature_ids: unknown,
};
}
async function getRecaptchaToken() { async function getRecaptchaToken() {
if (!recaptchaRequired) { if (!recaptchaRequired) {
return null; return null;
@@ -265,6 +354,35 @@
}, },
}); });
modelContext.registerTool({
name: 'estimate_web_design_cost',
description:
'Estimate one-time build and monthly cost for an AI ML Operations web design package. ' +
'Sites are brand-tailored; clients own the site and data. Public site and client portal are ' +
'always included, along with three-instance hosting, UTM/leads, SEO/accessibility/LLM readiness, ' +
'and Grafana metrics/alerts. Pass optional feature ids to add Email/SMS, direct mail, blog, ' +
'Stripe payments, social consolidation, or AI social generator. Dependencies ' +
'(payments→email_sms, ai_social→social) are auto-selected. Call with an empty features array ' +
'to list catalog pricing and included-with-every-site benefits.',
inputSchema: {
type: 'object',
properties: {
features: {
type: 'array',
description:
'Optional feature ids to include beyond the required base. Valid ids: ' +
'public_site, client_portal, email_sms, direct_mail, blog, payments, social, ai_social.',
items: { type: 'string' },
},
},
},
annotations: { readOnlyHint: true },
execute: function (input) {
var selected = (input && input.features) || [];
return textResult(estimateWebDesignCost(selected));
},
});
if (pageName === 'contact') { if (pageName === 'contact') {
modelContext.registerTool({ modelContext.registerTool({
name: 'submit_contact_inquiry', name: 'submit_contact_inquiry',
+6
View File
@@ -52,6 +52,7 @@
data-recaptcha-required="{{ webmcp_recaptcha_required|yesno:'true,false' }}" data-recaptcha-required="{{ webmcp_recaptcha_required|yesno:'true,false' }}"
data-services='{{ webmcp_services_json|escapejs }}' data-services='{{ webmcp_services_json|escapejs }}'
data-pages='{{ webmcp_pages_json|escapejs }}' data-pages='{{ webmcp_pages_json|escapejs }}'
data-web-design-pricing='{{ webmcp_web_design_pricing_json|escapejs }}'
hidden></div> hidden></div>
{% endif %} {% endif %}
</head> </head>
@@ -109,7 +110,12 @@
<li><a href="{% url 'profile' %}">Profile</a></li> <li><a href="{% url 'profile' %}">Profile</a></li>
{% endif %} {% endif %}
{% if is_financial_admin %} {% if is_financial_admin %}
<li><a href="{% url 'utm_dashboard' %}">UTM Analytics</a></li>
<li><a href="{% url 'leads_list' %}">Leads</a></li>
<li><a href="{% url 'manage_users' %}">Manage Users</a></li> <li><a href="{% url 'manage_users' %}">Manage Users</a></li>
{% elif user.is_staff %}
<li><a href="{% url 'utm_dashboard' %}">UTM Analytics</a></li>
<li><a href="{% url 'leads_list' %}">Leads</a></li>
{% endif %} {% endif %}
<li><a href="{% url 'change_password' %}">Change Password</a></li> <li><a href="{% url 'change_password' %}">Change Password</a></li>
<li> <li>
@@ -0,0 +1,80 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Lead: {{ lead.name }}{% endblock %}
{% block content %}
<div class="section">
<div class="container" style="max-width: 48rem;">
<p style="margin-bottom: 1rem;">
<a href="{% url 'leads_list' %}" style="color: var(--text-muted);">← All leads</a>
</p>
<div style="display: flex; flex-wrap: wrap; justify-content: space-between; gap: 1rem; align-items: start; margin-bottom: 1.5rem;">
<div>
<h1 class="section-title" style="margin-bottom: 0.35rem;">{{ lead.name }}</h1>
<p style="color: var(--text-muted); margin: 0;">
{{ lead.created|date:"F j, Y g:i A" }}
·
{% if lead.contacted %}
<span>Contacted</span>
{% else %}
<span style="color: var(--primary-color);">New</span>
{% endif %}
</p>
</div>
<form action="{% url 'lead_toggle_contacted' lead.pk %}" method="post">
{% csrf_token %}
<input type="hidden" name="next" value="{% url 'lead_detail' lead.pk %}">
<button type="submit" class="btn" style="border: 1px solid var(--primary-color); padding: 0.5rem 1.25rem; border-radius: 4px; color: var(--primary-color); background: transparent; cursor: pointer;">
{% if lead.contacted %}Mark as new{% else %}Mark as contacted{% endif %}
</button>
</form>
</div>
{% if messages %}
<ul style="list-style: none; margin-bottom: 1.5rem; padding: 0;">
{% for message in messages %}
<li style="color: var(--primary-color); margin-bottom: 0.35rem;">{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
<div class="card" style="margin-bottom: 1.5rem; cursor: default;">
<span class="card-title" style="font-size: 1.1rem;">Contact</span>
<p class="card-text" style="margin-top: 0.75rem;">
<strong>Email:</strong> <a href="mailto:{{ lead.email }}">{{ lead.email }}</a><br>
<strong>Subject:</strong> {{ lead.subject }}
</p>
</div>
<div class="card" style="margin-bottom: 1.5rem; cursor: default;">
<span class="card-title" style="font-size: 1.1rem;">Message</span>
<p class="card-text" style="margin-top: 0.75rem; white-space: pre-wrap;">{% if lead.blurb %}{{ lead.blurb }}{% else %}<span style="color: var(--text-muted);">(No message body)</span>{% endif %}</p>
</div>
<div class="card" style="margin-bottom: 1.5rem; cursor: default;">
<span class="card-title" style="font-size: 1.1rem;">UTM attribution</span>
{% if lead.utm_source or lead.utm_medium or lead.utm_campaign or lead.utm_term or lead.utm_content %}
<div class="table-responsive" style="margin-top: 0.75rem;">
<table class="table">
<tbody>
<tr><th style="width: 8rem;">Source</th><td>{{ lead.utm_source|default:"—" }}</td></tr>
<tr><th>Medium</th><td>{{ lead.utm_medium|default:"—" }}</td></tr>
<tr><th>Campaign</th><td>{{ lead.utm_campaign|default:"—" }}</td></tr>
<tr><th>Term</th><td>{{ lead.utm_term|default:"—" }}</td></tr>
<tr><th>Content</th><td>{{ lead.utm_content|default:"—" }}</td></tr>
</tbody>
</table>
</div>
{% else %}
<p class="card-text" style="margin-top: 0.75rem; color: var(--text-muted);">No UTM params on this leads 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 %}
+18 -2
View File
@@ -2,12 +2,28 @@
> Forward-deployed AI engineering. We embed with your team to architect, build, and deploy custom agentic workflows, integrations, and production AI systems. > Forward-deployed AI engineering. We embed with your team to architect, build, and deploy custom agentic workflows, integrations, and production AI systems.
AI ML Operations helps organizations move from AI pilots to production systems. Core services include forward-deployed AI engineering, custom AI agents, ML model development, secure hosted chat, sensor algorithms, education, hardware builds, and web hosting. AI ML Operations helps organizations move from AI pilots to production systems. Core services include forward-deployed AI engineering, custom AI agents, ML model development, secure hosted chat, sensor algorithms, education, hardware builds, and web design/hosting.
## Key pages ## Key pages
{% for page in pages %}- [{{ page.title }}]({{ page.url }}) {% for page in pages %}- [{{ page.title }}]({{ page.url }}){% if page.summary %} — {{ page.summary }}{% endif %}
{% endfor %} {% endfor %}
## Web design & hosting
Interactive package estimator: {{ web_design_url }}
Custom brand-tailored sites. Clients own the site and data and can leave anytime with everything. Use the estimator to toggle catalog add-ons and see one-time build + monthly totals (draft pricing; not a formal quote; Stripe/SMS/postage usage billed separately).
### Included with every site
{% for item in web_design_included %}- **{{ item.title }}**: {{ item.description }}
{% endfor %}
### Catalog features
{% for feature in web_design_features %}- **{{ feature.name }}** (`{{ feature.id }}`): ${{ feature.build }} build, ${{ feature.monthly }}/mo{% if feature.required %} — always included{% endif %}{% if feature.requires %} — requires {{ feature.requires|join:", " }}{% endif %} — {{ feature.description }}
{% endfor %}
Agents can call the WebMCP tool `estimate_web_design_cost` with optional feature ids to compute build + monthly totals and return the included-with-every-site list.
## Contact ## Contact
- [Contact form]({{ contact_url }}) - [Contact form]({{ contact_url }})
@@ -186,7 +186,7 @@
data-tianji-event="service_click" data-tianji-event-service="Web Design"> data-tianji-event="service_click" data-tianji-event-service="Web Design">
<span class="card-badge">Primary</span> <span class="card-badge">Primary</span>
<span class="card-title">Web Design and Hosting</span> <span class="card-title">Web Design and Hosting</span>
<p class="card-text">Modern websites with reliable hosting for your online presence.</p> <p class="card-text">Brand-tailored sites you own, with multi-instance hosting, UTM/leads, Grafana alerts, and an interactive package estimator.</p>
</a> </a>
<a href="{% url 'ml_model' %}" class="card" <a href="{% url 'ml_model' %}" class="card"
@@ -18,3 +18,4 @@ User-agent: PerplexityBot
Allow: / Allow: /
Sitemap: {{ sitemap_url }} Sitemap: {{ sitemap_url }}
# llms.txt: {{ llms_url }}
@@ -0,0 +1,236 @@
{% extends "base.html" %}
{% load static %}
{% block title %}UTM & Traffic Analytics{% endblock %}
{% block content %}
<div class="section">
<div class="container">
<h1 class="section-title">UTM &amp; 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&amp;utm_medium=social&amp;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, weve 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 &amp; Performance</span>
<p class="card-text">Improve your website's visibility and ranking on search engines.</p> <p class="card-text">Search-ready, accessible, and fast — plus LLM-friendly site structure.</p>
</div> </div>
<div class="card"> <div class="card">
<img src="{% static 'public/img/web_design/card-5.jpg' %}" alt="Maintenance & Support" <img src="{% static 'public/img/web_design/card-5.jpg' %}" alt="Growth Add-ons"
style="width: 100%; border-radius: 8px; margin-bottom: 1rem;"> style="width: 100%; border-radius: 8px; margin-bottom: 1rem;">
<span class="card-title">Maintenance & Support</span> <span class="card-title">Growth Add-ons</span>
<p class="card-text">Regular updates, backups, and troubleshooting to keep your site running smoothly.</p> <p class="card-text">Email/SMS, direct mail, blog, Stripe payments, social, and AI social drafts.</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div> -->
<!-- Pricing Section --> <!-- Interactive pricing -->
<div class="section" style="background: var(--surface-color);"> <div class="section" style="background: var(--surface-color);">
<div class="container"> <div class="container">
<h2 class="section-title">Web Hosting Plans</h2> <h2 class="section-title">Build Your Package</h2>
<p class="pricing-intro">
Public site and client portal (with UTM) are always included. Toggle add-ons to see one-time
build and monthly totals. Stripe / SMS / postage usage billed separately.
</p>
<!-- Pricing toggle --> <div class="pricing-estimator" id="pricingEstimator"
<div class="pricing-toggle-wrap"> data-disclaimer="{{ pricing_disclaimer|escape }}">
<div class="pricing-toggle" role="group" aria-label="Billing period"> <div class="pricing-features-panel">
<span class="pricing-toggle-label active" id="monthlyLabel">Monthly</span> <h3 class="pricing-panel-title">Features</h3>
<label class="pricing-switch" for="pricingToggle"> <ul class="pricing-feature-list" role="list">
<input type="checkbox" id="pricingToggle" aria-label="Toggle between monthly and yearly billing"> {% for feature in pricing_features %}
<span class="pricing-switch-slider"></span> <li class="pricing-feature{% if feature.required %} is-required{% endif %}"
</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="{% url 'contact' %}?subject=Web%20Hosting%20Standard%20Plan" 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="{% url 'contact' %}?subject=Web%20Hosting%20Premium%20Plan" 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 %}
+267 -1
View File
@@ -1,10 +1,89 @@
from unittest.mock import patch from unittest.mock import patch
from django.contrib.auth.models import User
from django.test import Client, TestCase, override_settings from django.test import Client, TestCase, override_settings
from django.urls import reverse from django.urls import reverse
from .models import Contact from company_site.settings.base import build_csrf_trusted_origins
from .models import Contact, EmailMessage, PageVisit
from .seo import SERVICE_URL_NAMES, get_service_entries from .seo import SERVICE_URL_NAMES, get_service_entries
from .traffic import TrafficType, classify_user_agent
class CsrfTrustedOriginsTests(TestCase):
def test_derives_https_origins_from_public_hosts(self):
origins = build_csrf_trusted_origins(
["aimloperations.com", "www.aimloperations.com"]
)
self.assertEqual(
origins,
[
"https://aimloperations.com",
"https://www.aimloperations.com",
],
)
def test_derives_http_origins_for_local_hosts(self):
origins = build_csrf_trusted_origins(["localhost", "127.0.0.1"])
self.assertEqual(origins, ["http://localhost", "http://127.0.0.1"])
def test_explicit_origins_win(self):
origins = build_csrf_trusted_origins(
["aimloperations.com"],
["https://custom.example"],
)
self.assertEqual(origins, ["https://custom.example"])
class LogoutCsrfTests(TestCase):
def setUp(self):
self.client = Client(enforce_csrf_checks=True)
self.user = User.objects.create_user(username="logout_user", password="pass")
def test_logout_post_with_csrf_succeeds(self):
self.client.login(username="logout_user", password="pass")
self.client.get("/")
csrf = self.client.cookies["csrftoken"].value
response = self.client.post(
reverse("logout"),
{"csrfmiddlewaretoken": csrf},
)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, "/")
self.assertNotIn("_auth_user_id", self.client.session)
class PreviewEmailAuthTests(TestCase):
def setUp(self):
self.client = Client()
self.user = User.objects.create_user(username="previewer", password="pass")
self.email = EmailMessage.objects.create(
subject="Preview subject",
body="Preview body content",
recipient="recipient@example.com",
)
self.url = reverse("preview_email", kwargs={"pk": self.email.pk})
def test_unauthenticated_user_is_redirected_to_login(self):
response = self.client.get(self.url)
self.assertEqual(response.status_code, 302)
self.assertIn("/accounts/login/", response.url)
def test_authenticated_user_can_preview_email(self):
self.client.login(username="previewer", password="pass")
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Preview subject")
self.assertContains(response, "Preview body content")
@override_settings( @override_settings(
@@ -145,6 +224,7 @@ class AgenticBrowsingSeoTests(TestCase):
self.assertEqual(response["Content-Type"], "text/plain; charset=utf-8") self.assertEqual(response["Content-Type"], "text/plain; charset=utf-8")
self.assertContains(response, "User-agent: *") self.assertContains(response, "User-agent: *")
self.assertContains(response, "Sitemap:") self.assertContains(response, "Sitemap:")
self.assertContains(response, "llms.txt")
def test_sitemap_xml_lists_public_pages(self): def test_sitemap_xml_lists_public_pages(self):
response = self.client.get(reverse("sitemap_xml")) response = self.client.get(reverse("sitemap_xml"))
@@ -163,6 +243,14 @@ class AgenticBrowsingSeoTests(TestCase):
self.assertContains(response, "# AI ML Operations, LLC") self.assertContains(response, "# AI ML Operations, LLC")
self.assertContains(response, "## Key pages") self.assertContains(response, "## Key pages")
self.assertContains(response, reverse("contact")) self.assertContains(response, reverse("contact"))
self.assertContains(response, "## Web design & hosting")
self.assertContains(response, "estimate_web_design_cost")
self.assertContains(response, "email_sms")
self.assertContains(response, "three instances")
self.assertContains(response, "Tailored to your brand")
self.assertContains(response, "You own the site")
self.assertContains(response, "Grafana metrics")
self.assertContains(response, "brand-tailored")
def test_homepage_uses_semantic_nav_controls(self): def test_homepage_uses_semantic_nav_controls(self):
response = self.client.get(reverse("public_index")) response = self.client.get(reverse("public_index"))
@@ -235,6 +323,7 @@ class WebMcpTests(TestCase):
"get_page_content", "get_page_content",
"navigate_to_service", "navigate_to_service",
"open_contact_with_subject", "open_contact_with_subject",
"estimate_web_design_cost",
"submit_contact_inquiry", "submit_contact_inquiry",
): ):
self.assertIn("name: '" + tool_name + "'", script) self.assertIn("name: '" + tool_name + "'", script)
@@ -242,3 +331,180 @@ class WebMcpTests(TestCase):
self.assertIn("readOnlyHint: true", script) self.assertIn("readOnlyHint: true", script)
self.assertIn("readOnlyHint: false", script) self.assertIn("readOnlyHint: false", script)
self.assertIn("navigator.modelContext || document.modelContext", script) self.assertIn("navigator.modelContext || document.modelContext", script)
self.assertIn("webDesignPricing", script)
def test_webmcp_config_includes_web_design_pricing(self):
response = self.client.get(reverse("public_index"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "data-web-design-pricing=")
self.assertContains(response, "email_sms")
self.assertContains(response, "client_portal")
class WebDesignPricingTests(TestCase):
def test_web_design_page_renders_estimator(self):
response = self.client.get(reverse("web_design"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Build Your Package")
self.assertContains(response, "pricingEstimator")
self.assertContains(response, "Public site")
self.assertContains(response, "Client portal + UTM")
self.assertContains(response, "three instances")
self.assertContains(response, "UTM tracking")
self.assertContains(response, "Grafana metrics &amp; 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 &amp; 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)
+189
View File
@@ -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
+8
View File
@@ -18,6 +18,14 @@ urlpatterns = [
path("ml_model", views.ml_model, name="ml_model"), path("ml_model", views.ml_model, name="ml_model"),
path("contact", views.contact, name="contact"), path("contact", views.contact, name="contact"),
path("terms", views.terms_of_service, name="terms_of_service"), path("terms", views.terms_of_service, name="terms_of_service"),
path("utm", views.utm_dashboard, name="utm_dashboard"),
path("leads", views.leads_list, name="leads_list"),
path("leads/<int:pk>/", views.lead_detail, name="lead_detail"),
path(
"leads/<int:pk>/toggle-contacted/",
views.lead_toggle_contacted,
name="lead_toggle_contacted",
),
path("change_password", views.change_password, name="change_password"), path("change_password", views.change_password, name="change_password"),
path("preview_email/<int:pk>/", views.preview_email, name="preview_email") path("preview_email/<int:pk>/", views.preview_email, name="preview_email")
] ]
+224 -10
View File
@@ -1,16 +1,23 @@
from django.shortcuts import render, get_object_or_404, redirect from datetime import timedelta
from django.http import HttpResponse from django.contrib.admin.views.decorators import staff_member_required
from .models import Contact, EmailMessage
from django.template.loader import get_template
from django.core.mail import EmailMultiAlternatives
from django.conf import settings
from django.core.mail import send_mail
from .forms import FormWithCaptcha
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib.auth.forms import PasswordChangeForm from django.contrib.auth.forms import PasswordChangeForm
from django.contrib.auth import update_session_auth_hash from django.contrib.auth import update_session_auth_hash
from django.contrib import messages from django.contrib import messages
from django.core.mail import EmailMultiAlternatives
from django.db.models import Count, Q
from django.shortcuts import render, get_object_or_404, redirect
from django.template.loader import get_template
from django.conf import settings
from django.urls import reverse
from django.utils import timezone
from django.views.decorators.http import require_POST
from .forms import FormWithCaptcha
from .middleware import get_session_utm
from .models import Contact, EmailMessage, PageVisit
from .traffic import TRAFFIC_TYPE_LABELS, TrafficType
def send_contact_email(email, subject, message): def send_contact_email(email, subject, message):
subject = "New Contact Request for AI ML Operations, LLC" subject = "New Contact Request for AI ML Operations, LLC"
@@ -38,7 +45,23 @@ def computers(request):
return render(request, "public/computers.html", {}) return render(request, "public/computers.html", {})
def web_design(request): def web_design(request):
return render(request, "public/web_design.html", {}) from .web_design_pricing import (
WEB_DESIGN_INCLUDED,
WEB_DESIGN_PRICING_DISCLAIMER,
features_for_json,
estimate_web_design_cost,
)
return render(
request,
"public/web_design.html",
{
"pricing_features": features_for_json(),
"pricing_included": WEB_DESIGN_INCLUDED,
"pricing_disclaimer": WEB_DESIGN_PRICING_DISCLAIMER,
"pricing_base_estimate": estimate_web_design_cost(),
},
)
def ai_sensor(request): def ai_sensor(request):
return render(request, "public/ai_sensor.html", {}) return render(request, "public/ai_sensor.html", {})
@@ -85,7 +108,18 @@ def contact(request):
# then we are good # then we are good
c = Contact(name=name, email=email, blurb=message, subject=subject) utm = get_session_utm(request.session)
c = Contact(
name=name,
email=email,
blurb=message,
subject=subject,
utm_source=utm.get("utm_source", ""),
utm_medium=utm.get("utm_medium", ""),
utm_campaign=utm.get("utm_campaign", ""),
utm_term=utm.get("utm_term", ""),
utm_content=utm.get("utm_content", ""),
)
c.save() c.save()
# send the email. # send the email.
try: try:
@@ -128,3 +162,183 @@ def change_password(request):
'form': form 'form': form
}) })
@staff_member_required
def utm_dashboard(request):
try:
days = int(request.GET.get("days", "30"))
except (TypeError, ValueError):
days = 30
if days not in (7, 30, 90, 365):
days = 30
traffic_filter = request.GET.get("traffic", "")
since = timezone.now() - timedelta(days=days)
visits = PageVisit.objects.filter(created__gte=since)
if traffic_filter in {t.value for t in TrafficType}:
visits = visits.filter(traffic_type=traffic_filter)
type_counts_qs = (
PageVisit.objects.filter(created__gte=since)
.values("traffic_type")
.annotate(total=Count("id"))
.order_by("-total")
)
type_counts = [
{
"key": row["traffic_type"],
"label": TRAFFIC_TYPE_LABELS.get(
TrafficType(row["traffic_type"]), row["traffic_type"]
)
if row["traffic_type"] in {t.value for t in TrafficType}
else row["traffic_type"],
"total": row["total"],
}
for row in type_counts_qs
]
total_visits = PageVisit.objects.filter(created__gte=since).count()
human_count = PageVisit.objects.filter(
created__gte=since, traffic_type=TrafficType.HUMAN
).count()
ai_count = PageVisit.objects.filter(
created__gte=since, traffic_type=TrafficType.AI_BOT
).count()
indexer_count = PageVisit.objects.filter(
created__gte=since, traffic_type=TrafficType.SEARCH_INDEXER
).count()
utm_landings = PageVisit.objects.filter(
created__gte=since, is_landing=True
).count()
attributed_visits = PageVisit.objects.filter(created__gte=since).exclude(
utm_source=""
).count()
def top_utm(field: str, limit: int = 10):
return list(
PageVisit.objects.filter(created__gte=since)
.exclude(**{field: ""})
.values(field)
.annotate(total=Count("id"))
.order_by("-total")[:limit]
)
top_pages = list(
visits.values("path")
.annotate(total=Count("id"))
.order_by("-total")[:15]
)
recent = list(visits[:100])
contacts_with_utm = (
Contact.objects.filter(created__gte=since)
.exclude(utm_source="")
.order_by("-created")[:25]
)
return render(
request,
"public/utm_dashboard.html",
{
"days": days,
"traffic_filter": traffic_filter,
"traffic_choices": [
(t.value, TRAFFIC_TYPE_LABELS[t]) for t in TrafficType
],
"total_visits": total_visits,
"human_count": human_count,
"ai_count": ai_count,
"indexer_count": indexer_count,
"utm_landings": utm_landings,
"attributed_visits": attributed_visits,
"type_counts": type_counts,
"top_sources": top_utm("utm_source"),
"top_mediums": top_utm("utm_medium"),
"top_campaigns": top_utm("utm_campaign"),
"top_pages": top_pages,
"recent_visits": recent,
"contacts_with_utm": contacts_with_utm,
},
)
@staff_member_required
def leads_list(request):
try:
days = int(request.GET.get("days", "90"))
except (TypeError, ValueError):
days = 90
if days not in (7, 30, 90, 365, 0):
days = 90
status = request.GET.get("status", "")
q = (request.GET.get("q") or "").strip()
utm_only = request.GET.get("utm") == "1"
leads = Contact.objects.all().order_by("-created")
if days:
leads = leads.filter(created__gte=timezone.now() - timedelta(days=days))
if status == "new":
leads = leads.filter(contacted=False)
elif status == "contacted":
leads = leads.filter(contacted=True)
if utm_only:
leads = leads.exclude(utm_source="")
if q:
leads = leads.filter(
Q(name__icontains=q)
| Q(email__icontains=q)
| Q(subject__icontains=q)
| Q(blurb__icontains=q)
| Q(utm_source__icontains=q)
| Q(utm_campaign__icontains=q)
)
base = Contact.objects.all()
if days:
base = base.filter(created__gte=timezone.now() - timedelta(days=days))
total = base.count()
new_count = base.filter(contacted=False).count()
contacted_count = base.filter(contacted=True).count()
with_utm = base.exclude(utm_source="").count()
return render(
request,
"public/leads_list.html",
{
"leads": leads[:200],
"lead_count": leads.count(),
"days": days,
"status": status,
"q": q,
"utm_only": utm_only,
"total": total,
"new_count": new_count,
"contacted_count": contacted_count,
"with_utm": with_utm,
},
)
@staff_member_required
def lead_detail(request, pk):
lead = get_object_or_404(Contact, pk=pk)
return render(request, "public/lead_detail.html", {"lead": lead})
@staff_member_required
@require_POST
def lead_toggle_contacted(request, pk):
lead = get_object_or_404(Contact, pk=pk)
lead.contacted = not lead.contacted
lead.save(update_fields=["contacted", "last_modified"])
messages.success(
request,
f"Marked {lead.name} as {'contacted' if lead.contacted else 'new'}.",
)
next_url = request.POST.get("next") or reverse("lead_detail", kwargs={"pk": lead.pk})
return redirect(next_url)
+190
View File
@@ -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
]
+7 -4
View File
@@ -18,11 +18,14 @@ services:
build: . build: .
ports: ports:
- "8000:8000" - "8000:8000"
env_file: # No required env_file — CI has no .env. Defaults below; for local secrets:
- .env # docker compose --env-file .env up
environment: environment:
DJANGO_ENV: dev DJANGO_ENV: ${DJANGO_ENV:-dev}
DATABASE_URL: postgres://company_site:company_site@db:5432/company_site 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: depends_on:
db: db:
condition: service_healthy condition: service_healthy