Compare commits
19
Commits
master
..
0ba6710faa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ba6710faa | ||
|
|
70181ceda1 | ||
|
|
ea382cf4ba | ||
|
|
2332caca08 | ||
|
|
5596a87cc7 | ||
|
|
3fc73cbf56 | ||
|
|
8630eb7755 | ||
|
|
cd41c75920 | ||
|
|
67acda869a | ||
|
|
c66bfa39f0 | ||
|
|
25bf9c6193 | ||
|
|
16eac8a9e8 | ||
|
|
8b33a179cb | ||
|
|
1033d28e09 | ||
|
|
e70ea3fb96 | ||
|
|
88ee85dda3 | ||
|
|
7ca7721511 | ||
|
|
358231af2e | ||
|
|
eb84d01c03 |
No files matched your search
+1
-4
@@ -33,10 +33,7 @@ EMAIL_HOST_USER=
|
||||
EMAIL_HOST_PASSWORD=
|
||||
EMAIL_PORT=2525
|
||||
EMAIL_USE_TLS=true
|
||||
# Display name has a comma — must be quoted (RFC 5322)
|
||||
# DEFAULT_FROM_EMAIL="AI ML Operations, LLC" <info@aimloperations.com>
|
||||
# Absolute origin for email logo / footer links
|
||||
PUBLIC_SITE_URL=https://aimloperations.com
|
||||
# DEFAULT_FROM_EMAIL=AI ML Operations, LLC <info@aimloperations.com>
|
||||
|
||||
# Stripe (test keys from https://dashboard.stripe.com/test/apikeys)
|
||||
STRIPE_SECRET_KEY=
|
||||
|
||||
+1
-3
@@ -34,9 +34,7 @@ EMAIL_HOST_USER=replace-with-smtp-user
|
||||
EMAIL_HOST_PASSWORD=replace-with-smtp-password
|
||||
EMAIL_PORT=2525
|
||||
EMAIL_USE_TLS=true
|
||||
# Display name has a comma — must be quoted (RFC 5322)
|
||||
# DEFAULT_FROM_EMAIL="AI ML Operations, LLC" <info@aimloperations.com>
|
||||
PUBLIC_SITE_URL=https://aimloperations.com
|
||||
# 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_
|
||||
|
||||
+1
-2
@@ -9,8 +9,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates libpq5 \
|
||||
&& update-ca-certificates \
|
||||
&& apt-get install -y --no-install-recommends libpq5 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||
|
||||
@@ -202,10 +202,8 @@ EMAIL_PORT = int(env("EMAIL_PORT", "2525"))
|
||||
EMAIL_USE_TLS = env_bool("EMAIL_USE_TLS", True)
|
||||
DEFAULT_FROM_EMAIL = env(
|
||||
"DEFAULT_FROM_EMAIL",
|
||||
'"AI ML Operations, LLC" <info@aimloperations.com>',
|
||||
"AI ML Operations, LLC <info@aimloperations.com>",
|
||||
)
|
||||
# Absolute site origin for email logo / footer links (no trailing slash).
|
||||
PUBLIC_SITE_URL = env("PUBLIC_SITE_URL", "https://aimloperations.com").rstrip("/")
|
||||
|
||||
# Stripe (invoices / subscriptions — customers pay us)
|
||||
STRIPE_SECRET_KEY = env("STRIPE_SECRET_KEY", "")
|
||||
|
||||
@@ -25,16 +25,6 @@ from .models import BillingCustomer, Invoice, RecurringSubscription
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _optional_pk(value) -> int | None:
|
||||
"""Local row ids are integers. Another site's metadata must not crash the webhook."""
|
||||
if value is None or value == "":
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
STRIPE_API_BASE = "https://api.stripe.com/v1"
|
||||
|
||||
|
||||
@@ -156,23 +146,25 @@ def ensure_stripe_customer(customer: BillingCustomer) -> str:
|
||||
|
||||
|
||||
def create_one_off_invoice(invoice: Invoice) -> Invoice:
|
||||
"""Create + finalize a Stripe Invoice; store hosted pay URL.
|
||||
|
||||
The line item is attached to the draft invoice. Stripe's current
|
||||
Invoices API defaults ``pending_invoice_items_behavior`` to
|
||||
``exclude``, so a pending item created beforehand is left off the
|
||||
invoice. Finalizing that empty invoice yields a $0 total, and Stripe
|
||||
marks any $0 invoice paid.
|
||||
"""
|
||||
"""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",
|
||||
# Finalize ourselves after the line item is attached. auto_advance
|
||||
# would let Stripe finalize the still-empty draft on its own.
|
||||
"auto_advance": False,
|
||||
"pending_invoice_items_behavior": "exclude",
|
||||
"auto_advance": True,
|
||||
"metadata": {"local_invoice_id": str(invoice.pk)},
|
||||
}
|
||||
if invoice.due_date:
|
||||
due_dt = datetime.combine(invoice.due_date, datetime.min.time())
|
||||
@@ -183,40 +175,11 @@ def create_one_off_invoice(invoice: Invoice) -> Invoice:
|
||||
create_data["days_until_due"] = 30
|
||||
|
||||
stripe_invoice = _stripe_request("POST", "/invoices", create_data)
|
||||
invoice_id = stripe_invoice["id"]
|
||||
|
||||
_stripe_request(
|
||||
"POST",
|
||||
"/invoiceitems",
|
||||
{
|
||||
"customer": stripe_customer_id,
|
||||
"invoice": invoice_id,
|
||||
"amount": invoice.amount_cents,
|
||||
"currency": invoice.currency,
|
||||
"description": invoice.description,
|
||||
},
|
||||
)
|
||||
|
||||
stripe_invoice = _stripe_request(
|
||||
"POST", f"/invoices/{invoice_id}/finalize", {}
|
||||
"POST", f"/invoices/{stripe_invoice['id']}/finalize", {}
|
||||
)
|
||||
|
||||
total = stripe_invoice.get("total")
|
||||
if total != invoice.amount_cents:
|
||||
_void_mismatched_invoice(invoice_id)
|
||||
raise StripeAPIError(
|
||||
f"Stripe invoice total was {total} cents, expected {invoice.amount_cents} cents."
|
||||
)
|
||||
|
||||
# Metadata is set only after the total checks out, so an invoice.paid
|
||||
# webhook for a $0 finalize cannot match this local row.
|
||||
_stripe_request(
|
||||
"POST",
|
||||
f"/invoices/{invoice_id}",
|
||||
{"metadata": {"local_invoice_id": str(invoice.pk)}},
|
||||
)
|
||||
|
||||
invoice.stripe_invoice_id = invoice_id
|
||||
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
|
||||
@@ -235,16 +198,6 @@ def create_one_off_invoice(invoice: Invoice) -> Invoice:
|
||||
return invoice
|
||||
|
||||
|
||||
def _void_mismatched_invoice(invoice_id: str) -> None:
|
||||
"""Void a finalized invoice whose total does not match the local amount."""
|
||||
try:
|
||||
_stripe_request("POST", f"/invoices/{invoice_id}/void", {})
|
||||
except StripeAPIError:
|
||||
logger.exception(
|
||||
"Failed to void Stripe invoice %s after amount mismatch", invoice_id
|
||||
)
|
||||
|
||||
|
||||
def create_subscription_checkout(
|
||||
subscription: RecurringSubscription,
|
||||
*,
|
||||
@@ -329,18 +282,16 @@ def send_pay_link_email(
|
||||
from_email = getattr(
|
||||
settings,
|
||||
"DEFAULT_FROM_EMAIL",
|
||||
'"AI ML Operations, LLC" <info@aimloperations.com>',
|
||||
)
|
||||
from public.email_branding import email_brand_context
|
||||
|
||||
context = email_brand_context(
|
||||
customer_name=customer_name,
|
||||
description=description,
|
||||
amount_dollars=f"{amount_dollars:.2f}",
|
||||
pay_url=pay_url,
|
||||
kind=kind,
|
||||
subject=subject,
|
||||
"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])
|
||||
@@ -361,19 +312,7 @@ def apply_stripe_invoice_event(stripe_invoice: dict) -> Invoice | None:
|
||||
return None
|
||||
|
||||
status = stripe_invoice.get("status") or invoice.status
|
||||
# A $0 Stripe invoice is auto-marked paid on finalize. Never copy that
|
||||
# onto a local invoice that was supposed to charge money.
|
||||
if (
|
||||
status == Invoice.Status.PAID
|
||||
and invoice.amount_cents > 0
|
||||
and stripe_invoice.get("total") == 0
|
||||
):
|
||||
logger.error(
|
||||
"Refusing paid status for invoice %s: Stripe total is 0, local amount is %s cents",
|
||||
invoice.pk,
|
||||
invoice.amount_cents,
|
||||
)
|
||||
elif status in dict(Invoice.Status.choices):
|
||||
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
|
||||
@@ -421,22 +360,18 @@ def apply_subscription_event(stripe_sub: dict) -> RecurringSubscription | None:
|
||||
|
||||
|
||||
def apply_checkout_session_completed(session: dict) -> RecurringSubscription | None:
|
||||
"""Attach subscription id after Checkout completes. Unknown sessions are ignored."""
|
||||
session_id = session.get("id") or ""
|
||||
"""Attach subscription id after Checkout completes."""
|
||||
if session.get("mode") != "subscription":
|
||||
logger.info("Ignoring checkout session %s with mode %s", session_id, session.get("mode") or "")
|
||||
return None
|
||||
local_id = (session.get("metadata") or {}).get("local_subscription_id")
|
||||
sub = None
|
||||
pk = _optional_pk(local_id)
|
||||
if pk is not None:
|
||||
sub = RecurringSubscription.objects.filter(pk=pk).first()
|
||||
if sub is None and session_id:
|
||||
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
|
||||
checkout_session_id=session["id"]
|
||||
).first()
|
||||
if sub is None:
|
||||
logger.info("Ignoring checkout session %s with no local subscription", session_id)
|
||||
return None
|
||||
|
||||
stripe_sub_id = session.get("subscription")
|
||||
|
||||
@@ -1,26 +1,48 @@
|
||||
{% extends "emails/base_email.html" %}
|
||||
|
||||
{% block title %}{{ subject }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p style="margin:0 0 16px;color:#e0e0e0;">Hello {{ customer_name }},</p>
|
||||
{% if kind == "subscription" %}
|
||||
<p style="margin:0 0 16px;color:#e0e0e0;">Please complete checkout to start your recurring payment:</p>
|
||||
{% else %}
|
||||
<p style="margin:0 0 16px;color:#e0e0e0;">You have a new invoice ready for payment:</p>
|
||||
{% endif %}
|
||||
<p style="margin:0 0 8px;color:#e0e0e0;"><strong>{{ description }}</strong></p>
|
||||
<p class="amount" style="font-size:22px;font-weight:700;color:#00f3ff;margin:12px 0;">${{ amount_dollars }} USD</p>
|
||||
<p style="margin:24px 0;">
|
||||
<a class="email-btn" href="{{ pay_url }}" style="display:inline-block;padding:14px 28px;background-color:#00f3ff;color:#0a0a0a !important;text-decoration:none;border-radius:30px;font-weight:700;font-size:14px;letter-spacing:0.5px;text-transform:uppercase;">
|
||||
{% if kind == "subscription" %}Complete checkout{% else %}Pay invoice{% endif %}
|
||||
</a>
|
||||
</p>
|
||||
<p class="muted" style="color:#a0a0a0;font-size:13px;line-height:1.5;margin:0 0 16px;">
|
||||
Or open this link:<br>
|
||||
<a href="{{ pay_url }}" style="color:#00f3ff;word-break:break-all;">{{ pay_url }}</a>
|
||||
</p>
|
||||
<p style="margin:0;color:#e0e0e0;">Thank you,<br>{{ brand_legal|default:"AI ML Operations, LLC" }}</p>
|
||||
{% endblock %}
|
||||
|
||||
{% block footer_note %}This is an automated message. Please do not reply to this email.{% endblock %}
|
||||
<!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>
|
||||
@@ -9,8 +9,4 @@ Amount: ${{ amount_dollars }} USD
|
||||
{{ pay_url }}
|
||||
|
||||
Thank you,
|
||||
{{ brand_legal|default:"AI ML Operations, LLC" }}
|
||||
|
||||
—
|
||||
{{ site_url|default:"https://aimloperations.com" }}
|
||||
Forward-deployed AI engineering
|
||||
AI ML Operations, LLC
|
||||
@@ -1,7 +1,5 @@
|
||||
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
|
||||
from django.urls import reverse
|
||||
|
||||
from financial.models import (
|
||||
@@ -218,168 +216,3 @@ class InvoiceAccessTests(TestCase):
|
||||
self.assertEqual(invoice.status, Invoice.Status.PAID)
|
||||
self.assertEqual(invoice.hosted_invoice_url, "https://pay.stripe.com/test")
|
||||
|
||||
def test_webhook_ignores_checkout_from_other_site(self):
|
||||
from django.test import override_settings
|
||||
|
||||
from financial.models import RecurringSubscription
|
||||
from financial.stripe_billing import apply_checkout_session_completed
|
||||
|
||||
session = {
|
||||
"id": "cs_other_site",
|
||||
"mode": "subscription",
|
||||
"customer": "cus_other",
|
||||
"subscription": "sub_other",
|
||||
"metadata": {
|
||||
"user_id": "999999",
|
||||
"purpose": "personal",
|
||||
"local_subscription_id": "not-ours",
|
||||
},
|
||||
}
|
||||
before = RecurringSubscription.objects.count()
|
||||
self.assertIsNone(apply_checkout_session_completed(session))
|
||||
payload = (
|
||||
'{"id":"evt_other_1","type":"checkout.session.completed",'
|
||||
'"data":{"object":{"id":"cs_other_site","mode":"subscription",'
|
||||
'"metadata":{"user_id":"999999","purpose":"personal",'
|
||||
'"local_subscription_id":"not-ours"}}}}'
|
||||
)
|
||||
with override_settings(STRIPE_WEBHOOK_SECRET=""):
|
||||
response = self.client_http.post(
|
||||
reverse("stripe_webhook"),
|
||||
data=payload,
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(RecurringSubscription.objects.count(), before)
|
||||
|
||||
def test_apply_invoice_webhook_ignores_zero_dollar_paid(self):
|
||||
from financial.models import BillingCustomer, Invoice
|
||||
from financial.stripe_billing import apply_stripe_invoice_event
|
||||
|
||||
customer = BillingCustomer.objects.create(
|
||||
name="Acme", email="acme-zero@example.com"
|
||||
)
|
||||
invoice = Invoice.objects.create(
|
||||
customer=customer,
|
||||
description="Work",
|
||||
amount_cents=204000,
|
||||
stripe_invoice_id="in_zero",
|
||||
status=Invoice.Status.DRAFT,
|
||||
)
|
||||
apply_stripe_invoice_event(
|
||||
{
|
||||
"id": "in_zero",
|
||||
"status": "paid",
|
||||
"total": 0,
|
||||
"metadata": {"local_invoice_id": str(invoice.pk)},
|
||||
}
|
||||
)
|
||||
invoice.refresh_from_db()
|
||||
self.assertEqual(invoice.status, Invoice.Status.DRAFT)
|
||||
|
||||
|
||||
class CreateOneOffInvoiceTests(TestCase):
|
||||
def setUp(self):
|
||||
from financial.models import BillingCustomer, Invoice
|
||||
|
||||
self.customer = BillingCustomer.objects.create(
|
||||
name="Acme", email="billing@example.com"
|
||||
)
|
||||
self.invoice = Invoice.objects.create(
|
||||
customer=self.customer,
|
||||
description="Site build",
|
||||
amount_cents=204000,
|
||||
status=Invoice.Status.DRAFT,
|
||||
)
|
||||
|
||||
@override_settings(STRIPE_SECRET_KEY="sk_test_x")
|
||||
@patch("financial.stripe_billing._stripe_request")
|
||||
def test_line_item_attached_to_draft_before_finalize(self, stripe_request):
|
||||
from financial.models import Invoice
|
||||
from financial.stripe_billing import create_one_off_invoice
|
||||
|
||||
def side_effect(method, path, data=None):
|
||||
if path == "/customers":
|
||||
return {"id": "cus_123"}
|
||||
if path == "/invoices":
|
||||
return {"id": "in_123", "status": "draft"}
|
||||
if path == "/invoiceitems":
|
||||
return {"id": "ii_123"}
|
||||
if path == "/invoices/in_123/finalize":
|
||||
return {
|
||||
"id": "in_123",
|
||||
"status": "open",
|
||||
"total": 204000,
|
||||
"amount_due": 204000,
|
||||
"hosted_invoice_url": "https://pay.stripe.com/i/in_123",
|
||||
"invoice_pdf": "https://pay.stripe.com/i/in_123/pdf",
|
||||
}
|
||||
if path == "/invoices/in_123":
|
||||
return {"id": "in_123"}
|
||||
raise AssertionError(f"unexpected {method} {path}")
|
||||
|
||||
stripe_request.side_effect = side_effect
|
||||
create_one_off_invoice(self.invoice)
|
||||
|
||||
paths = [call.args[1] for call in stripe_request.call_args_list]
|
||||
self.assertEqual(
|
||||
paths,
|
||||
[
|
||||
"/customers",
|
||||
"/invoices",
|
||||
"/invoiceitems",
|
||||
"/invoices/in_123/finalize",
|
||||
"/invoices/in_123",
|
||||
],
|
||||
)
|
||||
invoice_create = stripe_request.call_args_list[1].args[2]
|
||||
self.assertEqual(invoice_create["pending_invoice_items_behavior"], "exclude")
|
||||
self.assertNotIn("metadata", invoice_create)
|
||||
item = stripe_request.call_args_list[2].args[2]
|
||||
self.assertEqual(item["invoice"], "in_123")
|
||||
self.assertEqual(item["amount"], 204000)
|
||||
|
||||
self.invoice.refresh_from_db()
|
||||
self.assertEqual(self.invoice.status, Invoice.Status.OPEN)
|
||||
self.assertEqual(self.invoice.amount_cents, 204000)
|
||||
self.assertEqual(self.invoice.stripe_invoice_id, "in_123")
|
||||
self.assertEqual(
|
||||
self.invoice.hosted_invoice_url, "https://pay.stripe.com/i/in_123"
|
||||
)
|
||||
|
||||
@override_settings(STRIPE_SECRET_KEY="sk_test_x")
|
||||
@patch("financial.stripe_billing._stripe_request")
|
||||
def test_zero_total_finalize_does_not_mark_paid(self, stripe_request):
|
||||
from financial.models import Invoice
|
||||
from financial.stripe_billing import StripeAPIError, create_one_off_invoice
|
||||
|
||||
def side_effect(method, path, data=None):
|
||||
if path == "/customers":
|
||||
return {"id": "cus_123"}
|
||||
if path == "/invoices":
|
||||
return {"id": "in_zero", "status": "draft"}
|
||||
if path == "/invoiceitems":
|
||||
return {"id": "ii_123"}
|
||||
if path == "/invoices/in_zero/finalize":
|
||||
return {
|
||||
"id": "in_zero",
|
||||
"status": "paid",
|
||||
"total": 0,
|
||||
"amount_due": 0,
|
||||
}
|
||||
if path == "/invoices/in_zero/void":
|
||||
return {"id": "in_zero", "status": "void"}
|
||||
raise AssertionError(f"unexpected {method} {path}")
|
||||
|
||||
stripe_request.side_effect = side_effect
|
||||
with self.assertRaises(StripeAPIError):
|
||||
create_one_off_invoice(self.invoice)
|
||||
|
||||
paths = [call.args[1] for call in stripe_request.call_args_list]
|
||||
self.assertIn("/invoices/in_zero/void", paths)
|
||||
self.assertNotIn("/invoices/in_zero", paths)
|
||||
self.invoice.refresh_from_db()
|
||||
self.assertEqual(self.invoice.status, Invoice.Status.DRAFT)
|
||||
self.assertEqual(self.invoice.stripe_invoice_id, "")
|
||||
self.assertEqual(self.invoice.amount_cents, 204000)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
from django.shortcuts import get_object_or_404
|
||||
@@ -6,7 +5,6 @@ from django.template.loader import get_template
|
||||
from django.template.response import TemplateResponse
|
||||
from django.urls import path
|
||||
|
||||
from .email_branding import email_brand_context
|
||||
from .models import Contact, EmailMessage, PageVisit
|
||||
|
||||
|
||||
@@ -38,12 +36,8 @@ def send_emails(modeladmin, request, queryset):
|
||||
for email in queryset:
|
||||
success_count: int = 0
|
||||
try:
|
||||
from_email = getattr(
|
||||
settings,
|
||||
"DEFAULT_FROM_EMAIL",
|
||||
'"AI ML Operations, LLC" <info@aimloperations.com>',
|
||||
)
|
||||
d = email_brand_context(title=email.subject, content=email.body)
|
||||
from_email = "AI ML Operations, LLC <info@aimloperations.com>"
|
||||
d = {"title": email.subject, "content": email.body}
|
||||
|
||||
html_content = get_template("emails/marketing_email.html").render(d)
|
||||
text_content = get_template("emails/marketing_email.txt").render(d)
|
||||
@@ -83,11 +77,8 @@ class EmailMessageAdmin(admin.ModelAdmin):
|
||||
|
||||
def preview_email(self, request, pk):
|
||||
email_instance = get_object_or_404(EmailMessage, pk=pk)
|
||||
context = email_brand_context(
|
||||
title=email_instance.subject,
|
||||
content=email_instance.body,
|
||||
)
|
||||
return TemplateResponse(request, "emails/marketing_email.html", context)
|
||||
context = {"title": email_instance.subject, "content": email_instance.body}
|
||||
return TemplateResponse(request, "public/preview_email.html", context)
|
||||
|
||||
|
||||
@admin.register(PageVisit)
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
"""Shared context for branded HTML/text emails."""
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.staticfiles.storage import staticfiles_storage
|
||||
|
||||
|
||||
def email_brand_context(**extra):
|
||||
site_url = getattr(settings, "PUBLIC_SITE_URL", "https://aimloperations.com").rstrip(
|
||||
"/"
|
||||
)
|
||||
logo_path = staticfiles_storage.url("public/img/logo.png")
|
||||
if logo_path.startswith("http://") or logo_path.startswith("https://"):
|
||||
logo_url = logo_path
|
||||
else:
|
||||
logo_url = f"{site_url}{logo_path}"
|
||||
return {
|
||||
"site_url": site_url,
|
||||
"logo_url": logo_url,
|
||||
"brand_name": "AI ML Operations",
|
||||
"brand_legal": "AI ML Operations, LLC",
|
||||
**extra,
|
||||
}
|
||||
@@ -361,10 +361,8 @@
|
||||
'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, AI social, e-commerce, POS sync, event ticketing, or shipping. ' +
|
||||
'Dependencies (payments→email_sms, ai_social→social, ecommerce→email_sms+payments, ' +
|
||||
'pos_sync/shipping→ecommerce, event_ticketing→email_sms+payments) are auto-selected. ' +
|
||||
'Call with an empty features array ' +
|
||||
'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',
|
||||
@@ -373,8 +371,7 @@
|
||||
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, ' +
|
||||
'ecommerce, pos_sync, event_ticketing, shipping.',
|
||||
'public_site, client_portal, email_sms, direct_mail, blog, payments, social, ai_social.',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="supported-color-schemes" content="dark">
|
||||
<title>{% block title %}{{ brand_name|default:"AI ML Operations" }}{% endblock %}</title>
|
||||
<!--[if mso]>
|
||||
<style type="text/css">
|
||||
body, table, td { font-family: Arial, Helvetica, sans-serif !important; }
|
||||
</style>
|
||||
<![endif]-->
|
||||
<style type="text/css">
|
||||
body, table, td, a {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
table, td {
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
img {
|
||||
border: 0;
|
||||
height: auto;
|
||||
line-height: 100%;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
body {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
background-color: #0a0a0a;
|
||||
color: #e0e0e0;
|
||||
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
a { color: #00f3ff; }
|
||||
.email-btn {
|
||||
display: inline-block;
|
||||
padding: 14px 28px;
|
||||
background-color: #00f3ff;
|
||||
color: #0a0a0a !important;
|
||||
text-decoration: none;
|
||||
border-radius: 30px;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.muted { color: #a0a0a0; font-size: 13px; line-height: 1.5; }
|
||||
.amount { font-size: 22px; font-weight: 700; color: #00f3ff; margin: 12px 0; }
|
||||
.field-label { color: #a0a0a0; font-size: 12px; text-transform: uppercase; letter-spacing: 0.6px; margin: 0 0 4px; }
|
||||
.field-value { color: #e0e0e0; font-size: 15px; margin: 0 0 16px; line-height: 1.5; }
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background-color:#0a0a0a;">
|
||||
{% block preheader %}{% endblock %}
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color:#0a0a0a;">
|
||||
<tr>
|
||||
<td align="center" style="padding:32px 16px;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="max-width:600px;background-color:#1a1a1a;border:1px solid rgba(255,255,255,0.1);">
|
||||
<tr>
|
||||
<td align="center" style="padding:28px 24px 20px;border-bottom:1px solid rgba(0,243,255,0.35);">
|
||||
{% if logo_url %}
|
||||
<a href="{{ site_url|default:'https://aimloperations.com' }}" style="text-decoration:none;">
|
||||
<img src="{{ logo_url }}" alt="{{ brand_name|default:'AI ML Operations' }}" width="220" style="display:block;width:220px;max-width:80%;height:auto;">
|
||||
</a>
|
||||
{% else %}
|
||||
<p style="margin:0;font-size:18px;font-weight:700;letter-spacing:1px;color:#e0e0e0;text-transform:uppercase;">
|
||||
{{ brand_name|default:"AI ML Operations" }}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% block header_extra %}{% endblock %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="height:3px;line-height:3px;font-size:0;background-color:#bc13fe;"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:28px 28px 8px;color:#e0e0e0;font-size:15px;line-height:1.6;font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
|
||||
{% block content %}{% endblock %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 28px 28px;color:#a0a0a0;font-size:12px;line-height:1.5;text-align:center;border-top:1px solid rgba(255,255,255,0.08);font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
|
||||
{% block footer %}
|
||||
<p style="margin:16px 0 8px;color:#a0a0a0;">
|
||||
{% block footer_note %}{% endblock %}
|
||||
</p>
|
||||
<p style="margin:0 0 4px;color:#a0a0a0;">
|
||||
© {% now "Y" %} {{ brand_legal|default:"AI ML Operations, LLC" }}. All rights reserved.
|
||||
</p>
|
||||
<p style="margin:0;color:#a0a0a0;">
|
||||
<a href="{{ site_url|default:'https://aimloperations.com' }}" style="color:#00f3ff;text-decoration:none;">aimloperations.com</a>
|
||||
· Forward-deployed AI engineering
|
||||
</p>
|
||||
{% endblock %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,21 +1,110 @@
|
||||
{% extends "emails/base_email.html" %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>New Feedback Submission</title>
|
||||
<style>
|
||||
/* Basic reset for email clients */
|
||||
body, table, td, a {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
table, td {
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
img {
|
||||
border: 0;
|
||||
height: auto;
|
||||
line-height: 100%;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: Arial, sans-serif;
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
.email-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #dddddd;
|
||||
}
|
||||
.header {
|
||||
background-color: #007BFF;
|
||||
color: #ffffff;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.content {
|
||||
padding: 20px;
|
||||
color: #333333;
|
||||
}
|
||||
.footer {
|
||||
background-color: #f4f4f4;
|
||||
color: #777777;
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.feedback-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.feedback-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" align="center">
|
||||
<tr>
|
||||
<td>
|
||||
<!-- Email Container -->
|
||||
<div class="email-container">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<h1>New Contact Request</h1>
|
||||
</div>
|
||||
|
||||
{% block title %}New Contact Request{% endblock %}
|
||||
<!-- Content -->
|
||||
<div class="content">
|
||||
<p>Hello,</p>
|
||||
<p>A new feedback item has been submitted. Here are the details:</p>
|
||||
|
||||
{% block content %}
|
||||
<p style="margin:0 0 16px;color:#e0e0e0;">Hello,</p>
|
||||
<p style="margin:0 0 24px;color:#e0e0e0;">A new contact request was submitted on the site.</p>
|
||||
<!-- Feedback Title -->
|
||||
<div class="feedback-title">
|
||||
Subject: <strong>{{ subject }}</strong>
|
||||
</div>
|
||||
|
||||
<p class="field-label" style="color:#a0a0a0;font-size:12px;text-transform:uppercase;letter-spacing:0.6px;margin:0 0 4px;">Subject</p>
|
||||
<p class="field-value" style="color:#e0e0e0;font-size:15px;margin:0 0 16px;"><strong>{{ subject }}</strong></p>
|
||||
<!-- Email -->
|
||||
<div class="feedback-title">
|
||||
Email: <strong>{{ email }}</strong>
|
||||
</div>
|
||||
|
||||
<p class="field-label" style="color:#a0a0a0;font-size:12px;text-transform:uppercase;letter-spacing:0.6px;margin:0 0 4px;">From</p>
|
||||
<p class="field-value" style="color:#e0e0e0;font-size:15px;margin:0 0 16px;">
|
||||
<a href="mailto:{{ email }}" style="color:#00f3ff;text-decoration:none;">{{ email }}</a>
|
||||
</p>
|
||||
<!-- Feedback Text -->
|
||||
<div class="feedback-text">
|
||||
<strong>Message:</strong><br>
|
||||
{{ message }}
|
||||
</div>
|
||||
|
||||
<p class="field-label" style="color:#a0a0a0;font-size:12px;text-transform:uppercase;letter-spacing:0.6px;margin:0 0 4px;">Message</p>
|
||||
<p class="field-value" style="color:#e0e0e0;font-size:15px;margin:0 0 16px;white-space:pre-wrap;">{{ message }}</p>
|
||||
{% endblock %}
|
||||
<p>Thank you for your attention.</p>
|
||||
</div>
|
||||
|
||||
{% block footer_note %}This is an automated message. Please do not reply to this email.{% endblock %}
|
||||
<!-- Footer -->
|
||||
<div class="footer">
|
||||
<p>This is an automated message. Please do not reply to this email.</p>
|
||||
<p>© 2025 AI ML Operations, LLC. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,12 +1,3 @@
|
||||
New Contact Request — {{ brand_legal|default:"AI ML Operations, LLC" }}
|
||||
New Contact Request for AI ML Operations, LLC
|
||||
|
||||
Subject: {{ subject }}
|
||||
From: {{ email }}
|
||||
|
||||
Message:
|
||||
{{ message }}
|
||||
|
||||
—
|
||||
{{ brand_legal|default:"AI ML Operations, LLC" }}
|
||||
{{ site_url|default:"https://aimloperations.com" }}
|
||||
Forward-deployed AI engineering
|
||||
"New Contact. {{ subject }} from {{ email }}. {{ message }}"
|
||||
@@ -1,15 +1,61 @@
|
||||
{% extends "emails/base_email.html" %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Email Template</title>
|
||||
<!-- Materialize CSS -->
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
background-color: #f4f4f4;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.email-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.header {
|
||||
background-color: #37474f;
|
||||
color: #ffffff;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.content {
|
||||
padding: 20px;
|
||||
color: #333333;
|
||||
}
|
||||
.footer {
|
||||
background-color: #333;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<h4>{{ title | safe }}</h4>
|
||||
</div>
|
||||
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
<!-- Content -->
|
||||
<div class="content">
|
||||
{{ content | safe }}
|
||||
</div>
|
||||
|
||||
{% block header_extra %}
|
||||
{% if title %}
|
||||
<p style="margin:16px 0 0;font-size:18px;font-weight:600;color:#e0e0e0;line-height:1.4;">{{ title|safe }}</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ content|safe }}
|
||||
{% endblock %}
|
||||
|
||||
{% block footer_note %}{% endblock %}
|
||||
<!-- Footer -->
|
||||
<div class="footer">
|
||||
<p>© 2025 AI ML Operations, LLC. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,8 +1,3 @@
|
||||
{{ title }}
|
||||
{{ subject }}
|
||||
|
||||
{{ content }}
|
||||
|
||||
—
|
||||
{{ brand_legal|default:"AI ML Operations, LLC" }}
|
||||
{{ site_url|default:"https://aimloperations.com" }}
|
||||
Forward-deployed AI engineering
|
||||
@@ -1 +1,61 @@
|
||||
{% extends "emails/marketing_email.html" %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Email Template</title>
|
||||
<!-- Materialize CSS -->
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
background-color: #f4f4f4;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.email-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.header {
|
||||
background-color: #37474f;
|
||||
color: #ffffff;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.content {
|
||||
padding: 20px;
|
||||
color: #333333;
|
||||
}
|
||||
.footer {
|
||||
background-color: #333;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<h4>{{ title | safe }}</h4>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="content">
|
||||
{{ content | safe }}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="footer">
|
||||
<p>© 2025 AI ML Operations, LLC. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -2,7 +2,7 @@
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Web Design & Hosting - AI ML Operations, LLC{% endblock %}
|
||||
{% 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, e-commerce, POS sync, event tickets, shipping, social, and more. Three-instance hosting, SEO, accessibility, LLM integration, and Grafana alerts on every build.{% endblock %}
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
@@ -78,7 +78,7 @@
|
||||
<img src="{% static 'public/img/web_design/card-5.jpg' %}" alt="Growth Add-ons"
|
||||
style="width: 100%; border-radius: 8px; margin-bottom: 1rem;">
|
||||
<span class="card-title">Growth Add-ons</span>
|
||||
<p class="card-text">Email/SMS, direct mail, blog, Stripe, e-commerce, POS sync, event tickets, shipping, social, and AI social drafts.</p>
|
||||
<p class="card-text">Email/SMS, direct mail, blog, Stripe payments, social, and AI social drafts.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -90,7 +90,7 @@
|
||||
<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 / EasyPost usage billed separately.
|
||||
build and monthly totals. Stripe / SMS / postage usage billed separately.
|
||||
</p>
|
||||
|
||||
<div class="pricing-estimator" id="pricingEstimator"
|
||||
|
||||
@@ -246,10 +246,6 @@ class AgenticBrowsingSeoTests(TestCase):
|
||||
self.assertContains(response, "## Web design & hosting")
|
||||
self.assertContains(response, "estimate_web_design_cost")
|
||||
self.assertContains(response, "email_sms")
|
||||
self.assertContains(response, "ecommerce")
|
||||
self.assertContains(response, "pos_sync")
|
||||
self.assertContains(response, "event_ticketing")
|
||||
self.assertContains(response, "shipping")
|
||||
self.assertContains(response, "three instances")
|
||||
self.assertContains(response, "Tailored to your brand")
|
||||
self.assertContains(response, "You own the site")
|
||||
@@ -344,8 +340,6 @@ class WebMcpTests(TestCase):
|
||||
self.assertContains(response, "data-web-design-pricing=")
|
||||
self.assertContains(response, "email_sms")
|
||||
self.assertContains(response, "client_portal")
|
||||
self.assertContains(response, "ecommerce")
|
||||
self.assertContains(response, "event_ticketing")
|
||||
|
||||
|
||||
class WebDesignPricingTests(TestCase):
|
||||
@@ -369,10 +363,6 @@ class WebDesignPricingTests(TestCase):
|
||||
self.assertContains(response, "aimlTrackWhenReady")
|
||||
self.assertContains(response, "data-tianji-event-build=")
|
||||
self.assertContains(response, "data-tianji-event-features=")
|
||||
self.assertContains(response, "E-commerce storefront")
|
||||
self.assertContains(response, "POS API sync")
|
||||
self.assertContains(response, "Event ticketing")
|
||||
self.assertContains(response, "Shipping automation")
|
||||
|
||||
def test_base_estimate_includes_required_features_only(self):
|
||||
from .web_design_pricing import estimate_web_design_cost
|
||||
@@ -402,74 +392,6 @@ class WebDesignPricingTests(TestCase):
|
||||
self.assertEqual(estimate["one_time_build"], 1400)
|
||||
self.assertEqual(estimate["monthly"], 80)
|
||||
|
||||
def test_ecommerce_auto_selects_email_sms_and_payments(self):
|
||||
from .web_design_pricing import estimate_web_design_cost
|
||||
|
||||
estimate = estimate_web_design_cost(["ecommerce"])
|
||||
selected_ids = {item["id"] for item in estimate["selected"]}
|
||||
self.assertEqual(
|
||||
selected_ids,
|
||||
{"public_site", "client_portal", "email_sms", "payments", "ecommerce"},
|
||||
)
|
||||
self.assertEqual(estimate["one_time_build"], 2300)
|
||||
self.assertEqual(estimate["monthly"], 100)
|
||||
|
||||
def test_pos_sync_pulls_ecommerce_chain(self):
|
||||
from .web_design_pricing import estimate_web_design_cost
|
||||
|
||||
estimate = estimate_web_design_cost(["pos_sync"])
|
||||
selected_ids = {item["id"] for item in estimate["selected"]}
|
||||
self.assertEqual(
|
||||
selected_ids,
|
||||
{
|
||||
"public_site",
|
||||
"client_portal",
|
||||
"email_sms",
|
||||
"payments",
|
||||
"ecommerce",
|
||||
"pos_sync",
|
||||
},
|
||||
)
|
||||
self.assertEqual(estimate["one_time_build"], 2800)
|
||||
self.assertEqual(estimate["monthly"], 120)
|
||||
|
||||
def test_event_ticketing_auto_selects_email_sms_and_payments(self):
|
||||
from .web_design_pricing import estimate_web_design_cost
|
||||
|
||||
estimate = estimate_web_design_cost(["event_ticketing"])
|
||||
selected_ids = {item["id"] for item in estimate["selected"]}
|
||||
self.assertEqual(
|
||||
selected_ids,
|
||||
{
|
||||
"public_site",
|
||||
"client_portal",
|
||||
"email_sms",
|
||||
"payments",
|
||||
"event_ticketing",
|
||||
},
|
||||
)
|
||||
self.assertEqual(estimate["one_time_build"], 2000)
|
||||
self.assertEqual(estimate["monthly"], 80)
|
||||
|
||||
def test_shipping_pulls_ecommerce_chain(self):
|
||||
from .web_design_pricing import estimate_web_design_cost
|
||||
|
||||
estimate = estimate_web_design_cost(["shipping"])
|
||||
selected_ids = {item["id"] for item in estimate["selected"]}
|
||||
self.assertEqual(
|
||||
selected_ids,
|
||||
{
|
||||
"public_site",
|
||||
"client_portal",
|
||||
"email_sms",
|
||||
"payments",
|
||||
"ecommerce",
|
||||
"shipping",
|
||||
},
|
||||
)
|
||||
self.assertEqual(estimate["one_time_build"], 2700)
|
||||
self.assertEqual(estimate["monthly"], 115)
|
||||
|
||||
|
||||
class TrafficClassificationTests(TestCase):
|
||||
def test_classifies_common_agents(self):
|
||||
|
||||
@@ -14,25 +14,20 @@ from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
from .email_branding import email_brand_context
|
||||
from .forms import FormWithCaptcha
|
||||
from .middleware import get_session_utm
|
||||
from .models import Contact, EmailMessage, PageVisit
|
||||
from .traffic import TRAFFIC_TYPE_LABELS, TrafficType
|
||||
|
||||
def send_contact_email(email, subject, message):
|
||||
mail_subject = "New Contact Request for AI ML Operations, LLC"
|
||||
from_email = getattr(
|
||||
settings,
|
||||
"DEFAULT_FROM_EMAIL",
|
||||
'"AI ML Operations, LLC" <info@aimloperations.com>',
|
||||
)
|
||||
to = "ryan@aimloperations.com"
|
||||
d = email_brand_context(subject=subject, message=message, email=email)
|
||||
html_content = get_template("emails/contact_email.html").render(d)
|
||||
text_content = get_template("emails/contact_email.txt").render(d)
|
||||
subject = "New Contact Request for AI ML Operations, LLC"
|
||||
from_email = "ryan@aimloperations.com"
|
||||
to="ryan@aimloperations.com"
|
||||
d = {"subject": subject, "message": message, "email": email}
|
||||
html_content = get_template(r'emails/contact_email.html').render(d)
|
||||
text_content = get_template(r'emails/contact_email.txt').render(d)
|
||||
|
||||
msg = EmailMultiAlternatives(mail_subject, text_content, from_email, [to])
|
||||
msg = EmailMultiAlternatives(subject, text_content, from_email, [to])
|
||||
msg.attach_alternative(html_content, "text/html")
|
||||
msg.send(fail_silently=True)
|
||||
|
||||
@@ -86,11 +81,13 @@ def terms_of_service(request):
|
||||
@login_required
|
||||
def preview_email(request, pk):
|
||||
email_instance = get_object_or_404(EmailMessage, pk=pk)
|
||||
context = email_brand_context(
|
||||
title=email_instance.subject,
|
||||
content=email_instance.body,
|
||||
context = {
|
||||
"title":email_instance.subject,
|
||||
"content":email_instance.body
|
||||
}
|
||||
return render(
|
||||
request, 'public/preview_email.html', context
|
||||
)
|
||||
return render(request, "emails/marketing_email.html", context)
|
||||
|
||||
def contact(request):
|
||||
errors = ''
|
||||
|
||||
@@ -80,46 +80,6 @@ WEB_DESIGN_FEATURES = (
|
||||
"requires": ("social",),
|
||||
"requires_note": "Requires Social consolidation — auto-selected.",
|
||||
},
|
||||
{
|
||||
"id": "ecommerce",
|
||||
"name": "E-commerce storefront",
|
||||
"description": "Catalog, cart, inventory dashboard, Stripe checkout. Requires Email & SMS and Payments.",
|
||||
"build": 700,
|
||||
"monthly": 30,
|
||||
"required": False,
|
||||
"requires": ("email_sms", "payments"),
|
||||
"requires_note": "Requires Email & SMS and Payments (Stripe) — auto-selected.",
|
||||
},
|
||||
{
|
||||
"id": "pos_sync",
|
||||
"name": "POS API sync",
|
||||
"description": "Keep in-store POS inventory in sync with the online catalog.",
|
||||
"build": 500,
|
||||
"monthly": 20,
|
||||
"required": False,
|
||||
"requires": ("ecommerce",),
|
||||
"requires_note": "Requires E-commerce storefront — auto-selected.",
|
||||
},
|
||||
{
|
||||
"id": "event_ticketing",
|
||||
"name": "Event ticketing",
|
||||
"description": "Events, capacity limits, digital tickets. Requires Email & SMS and Payments.",
|
||||
"build": 400,
|
||||
"monthly": 10,
|
||||
"required": False,
|
||||
"requires": ("email_sms", "payments"),
|
||||
"requires_note": "Requires Email & SMS and Payments (Stripe) — auto-selected.",
|
||||
},
|
||||
{
|
||||
"id": "shipping",
|
||||
"name": "Shipping automation",
|
||||
"description": "EasyPost labels + Pirate Ship CSV export. Requires E-commerce storefront.",
|
||||
"build": 400,
|
||||
"monthly": 15,
|
||||
"required": False,
|
||||
"requires": ("ecommerce",),
|
||||
"requires_note": "Requires E-commerce storefront — auto-selected.",
|
||||
},
|
||||
)
|
||||
|
||||
WEB_DESIGN_INCLUDED = (
|
||||
@@ -151,7 +111,7 @@ WEB_DESIGN_INCLUDED = (
|
||||
|
||||
WEB_DESIGN_PRICING_DISCLAIMER = (
|
||||
"Draft catalog pricing. Not a formal quote. "
|
||||
"Third-party usage (Stripe fees, SMS, postage, EasyPost labels) not included."
|
||||
"Third-party usage (Stripe fees, SMS, postage) not included."
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
# Email previews
|
||||
|
||||
Static renders of branded HTML emails (dark neon site UX: `#0a0a0a` / `#1a1a1a` / `#00f3ff` / `#bc13fe`).
|
||||
|
||||
| Template | Screenshot |
|
||||
|----------|------------|
|
||||
| Contact notify | [contact.png](contact.png) |
|
||||
| Marketing | [marketing.png](marketing.png) |
|
||||
| Invoice / pay link | [invoice.png](invoice.png) |
|
||||
|
||||
Shared base: `public/templates/emails/base_email.html`.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 46 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 48 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 38 KiB |
Reference in new issue
Block a user