Files
claudetools/api/models/quote.py
Mike Swanson fa15b03180 sync: Auto-sync from ACG-M-L5090 at 2026-03-10 19:11:00
Synced files:
- Quote wizard frontend (all components, hooks, types, config)
- API updates (config, models, routers, schemas, services)
- Client work (bg-builders, gurushow)
- Scripts (BGB Lesley termination, CIPP, Datto, migration)
- Temp files (Bardach contacts, VWP investigation, misc)
- Credentials and session logs
- Email service, PHP API, session logs

Machine: ACG-M-L5090
Timestamp: 2026-03-10 19:11:00

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 19:59:08 -07:00

605 lines
17 KiB
Python

"""
Quote models for MSP Quote Wizard.
Models for managing quotes, quote items, activity tracking, and notifications
for the public-facing MSP service quote wizard.
"""
import secrets
from datetime import datetime
from decimal import Decimal
from enum import Enum as PyEnum
from typing import TYPE_CHECKING, Optional
from sqlalchemy import (
CHAR,
Boolean,
CheckConstraint,
DateTime,
ForeignKey,
Index,
Integer,
Numeric,
String,
Text,
func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .base import Base, TimestampMixin, UUIDMixin
class QuoteStatus(str, PyEnum):
"""Status options for quotes."""
DRAFT = "draft"
SUBMITTED = "submitted"
VIEWED = "viewed"
FOLLOWED_UP = "followed_up"
CONVERTED = "converted"
EXPIRED = "expired"
class ServiceCategory(str, PyEnum):
"""Service category options for quote items."""
GPS_MONITORING = "gps_monitoring"
SUPPORT_PLAN = "support_plan"
VOIP = "voip"
WEB_HOSTING = "web_hosting"
EMAIL = "email"
HARDWARE = "hardware"
ADDON = "addon"
class BillingFrequency(str, PyEnum):
"""Billing frequency options for quote items."""
MONTHLY = "monthly"
YEARLY = "yearly"
ONE_TIME = "one_time"
class NotificationType(str, PyEnum):
"""Notification types for quote events."""
EMAIL = "email"
WEBHOOK = "webhook"
class Quote(Base, UUIDMixin, TimestampMixin):
"""
Quote model representing a service quote request.
Stores quote details including contact information, selections,
and calculated totals. Uses an access token for public URL access.
Attributes:
access_token: Unique token for public access (URL-safe, 43 chars)
status: Current quote status (draft, submitted, viewed, etc.)
company_name: Prospect company name
contact_name: Primary contact name
contact_email: Contact email address
contact_phone: Contact phone number
employee_count: Number of employees/users
monthly_total: Calculated monthly recurring total
setup_total: Calculated one-time setup total
expires_at: Quote expiration date
submitted_at: Timestamp when quote was submitted
ip_address: IP address of the requester
user_agent: Browser user agent string
"""
__tablename__ = "quotes"
# Access control
access_token: Mapped[str] = mapped_column(
String(64),
unique=True,
nullable=False,
default=lambda: secrets.token_urlsafe(32),
doc="Unique access token for public URL (URL-safe, 43 chars)"
)
# Status
status: Mapped[str] = mapped_column(
String(20),
nullable=False,
default=QuoteStatus.DRAFT.value,
server_default=QuoteStatus.DRAFT.value,
doc="Quote status: draft, submitted, viewed, followed_up, converted, expired"
)
# Contact information
company_name: Mapped[Optional[str]] = mapped_column(
String(255),
doc="Prospect company name"
)
contact_name: Mapped[Optional[str]] = mapped_column(
String(255),
doc="Primary contact name"
)
contact_email: Mapped[Optional[str]] = mapped_column(
String(255),
doc="Contact email address"
)
contact_phone: Mapped[Optional[str]] = mapped_column(
String(50),
doc="Contact phone number"
)
# Business information
employee_count: Mapped[Optional[int]] = mapped_column(
Integer,
doc="Number of employees/users"
)
industry: Mapped[Optional[str]] = mapped_column(
String(100),
doc="Industry/vertical of the prospect"
)
current_it_situation: Mapped[Optional[str]] = mapped_column(
Text,
doc="Description of the prospect's current IT setup"
)
# Calculated totals
monthly_total: Mapped[Decimal] = mapped_column(
Numeric(10, 2),
nullable=False,
default=Decimal("0.00"),
server_default="0.00",
doc="Calculated monthly recurring total"
)
setup_total: Mapped[Decimal] = mapped_column(
Numeric(10, 2),
nullable=False,
default=Decimal("0.00"),
server_default="0.00",
doc="Calculated one-time setup total"
)
# Timestamps
expires_at: Mapped[Optional[datetime]] = mapped_column(
DateTime,
doc="Quote expiration date"
)
submitted_at: Mapped[Optional[datetime]] = mapped_column(
DateTime,
doc="Timestamp when quote was submitted"
)
# Tracking
ip_address: Mapped[Optional[str]] = mapped_column(
String(45),
doc="IP address of the requester (IPv4 or IPv6)"
)
user_agent: Mapped[Optional[str]] = mapped_column(
Text,
doc="Browser user agent string"
)
# Marketing attribution
source: Mapped[Optional[str]] = mapped_column(
String(50),
server_default="website",
doc="Lead source (e.g., website, referral)"
)
utm_source: Mapped[Optional[str]] = mapped_column(
String(100),
doc="UTM source parameter"
)
utm_medium: Mapped[Optional[str]] = mapped_column(
String(100),
doc="UTM medium parameter"
)
utm_campaign: Mapped[Optional[str]] = mapped_column(
String(100),
doc="UTM campaign parameter"
)
# Syncro RMM Integration
syncro_lead_id: Mapped[Optional[str]] = mapped_column(
String(100),
doc="Lead ID in SyncroRMM if synced"
)
syncro_synced_at: Mapped[Optional[datetime]] = mapped_column(
DateTime,
doc="Timestamp when quote was synced to Syncro"
)
is_existing_customer: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
server_default="0",
doc="Whether this is an existing Syncro customer"
)
# Relationships
items: Mapped[list["QuoteItem"]] = relationship(
"QuoteItem",
back_populates="quote",
cascade="all, delete-orphan",
doc="Line items in this quote"
)
activities: Mapped[list["QuoteActivity"]] = relationship(
"QuoteActivity",
back_populates="quote",
cascade="all, delete-orphan",
order_by="QuoteActivity.created_at.desc()",
doc="Activity log for this quote"
)
notifications: Mapped[list["QuoteNotification"]] = relationship(
"QuoteNotification",
back_populates="quote",
cascade="all, delete-orphan",
doc="Notifications sent for this quote"
)
# Constraints and indexes
__table_args__ = (
CheckConstraint(
"status IN ('draft', 'submitted', 'viewed', 'followed_up', 'converted', 'expired')",
name="ck_quotes_status"
),
Index("idx_quotes_access_token", "access_token"),
Index("idx_quotes_status", "status"),
Index("idx_quotes_contact_email", "contact_email"),
Index("idx_quotes_created_at", "created_at"),
)
def __repr__(self) -> str:
"""String representation of the quote."""
return f"<Quote(id='{self.id}', status='{self.status}', company='{self.company_name}')>"
class QuoteItem(Base, UUIDMixin):
"""
Quote item model representing a single line item in a quote.
Stores product details, pricing, and quantity information.
Attributes:
quote_id: Reference to the parent quote
category: Service category (gps_monitoring, support_plan, etc.)
product_code: Product code identifier
product_name: Name of the product/service
description: Detailed description of the product/service
quantity: Number of units
unit_price: Price per unit
setup_price: One-time setup price
billing_frequency: Billing frequency (monthly, yearly, one_time)
tier: Pricing tier
is_recommended: Whether this item is recommended
created_at: Timestamp when item was created
"""
__tablename__ = "quote_items"
# Foreign keys
quote_id: Mapped[str] = mapped_column(
CHAR(36),
ForeignKey("quotes.id", ondelete="CASCADE"),
nullable=False,
doc="Reference to the parent quote"
)
# Category
category: Mapped[str] = mapped_column(
String(50),
nullable=False,
doc="Service category: gps_monitoring, support_plan, voip, web_hosting, email, hardware, addon"
)
# Product identification
product_code: Mapped[str] = mapped_column(
String(50),
nullable=False,
doc="Product code identifier"
)
product_name: Mapped[str] = mapped_column(
String(255),
nullable=False,
doc="Name of the product/service"
)
description: Mapped[Optional[str]] = mapped_column(
Text,
doc="Detailed description of the product/service"
)
# Quantity and pricing
quantity: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=1,
doc="Number of units"
)
unit_price: Mapped[Decimal] = mapped_column(
Numeric(10, 2),
nullable=False,
doc="Price per unit"
)
setup_price: Mapped[Decimal] = mapped_column(
Numeric(10, 2),
nullable=False,
default=Decimal("0.00"),
server_default="0.00",
doc="One-time setup price"
)
# Billing
billing_frequency: Mapped[str] = mapped_column(
String(20),
nullable=False,
default=BillingFrequency.MONTHLY.value,
server_default="monthly",
doc="Billing frequency: monthly, yearly, one_time"
)
# Configuration
tier: Mapped[Optional[str]] = mapped_column(
String(50),
doc="Pricing tier"
)
is_recommended: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
server_default="0",
doc="Whether this item is recommended"
)
# Timestamp (no updated_at in DB)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
doc="Timestamp when the item was created"
)
# Relationships
quote: Mapped["Quote"] = relationship(
"Quote",
back_populates="items"
)
# Constraints and indexes
__table_args__ = (
CheckConstraint(
"category IN ('gps_monitoring', 'support_plan', 'voip', 'web_hosting', 'email', 'hardware', 'addon')",
name="ck_quote_items_category"
),
CheckConstraint(
"billing_frequency IN ('monthly', 'yearly', 'one_time')",
name="ck_quote_items_billing_frequency"
),
Index("idx_quote_items_quote_id", "quote_id"),
Index("idx_quote_items_category", "category"),
)
def __repr__(self) -> str:
"""String representation of the quote item."""
return f"<QuoteItem(product='{self.product_name}', qty={self.quantity}, price={self.unit_price})>"
@property
def line_total(self) -> Decimal:
"""Calculate the line total (unit_price * quantity)."""
return self.unit_price * self.quantity
@property
def monthly_amount(self) -> Decimal:
"""Calculate the monthly amount based on billing frequency."""
if self.billing_frequency == BillingFrequency.MONTHLY.value:
return self.line_total
elif self.billing_frequency == BillingFrequency.YEARLY.value:
return self.line_total / Decimal("12")
else: # one_time
return Decimal("0.00")
class QuoteActivity(Base, UUIDMixin):
"""
Quote activity model for tracking quote history and changes.
Logs all actions taken on a quote for audit and tracking purposes.
Attributes:
quote_id: Reference to the parent quote
action: Action performed (created, updated, submitted, etc.)
step_name: Name of the wizard step associated with the action
details: Additional details about the action (longtext)
ip_address: IP address of the actor
created_at: Timestamp when the activity was recorded
"""
__tablename__ = "quote_activity"
# Foreign keys
quote_id: Mapped[str] = mapped_column(
CHAR(36),
ForeignKey("quotes.id", ondelete="CASCADE"),
nullable=False,
doc="Reference to the parent quote"
)
# Activity details
action: Mapped[str] = mapped_column(
String(50),
nullable=False,
doc="Action performed: created, updated, item_added, item_removed, submitted, status_changed, etc."
)
step_name: Mapped[Optional[str]] = mapped_column(
String(50),
doc="Name of the wizard step associated with the action"
)
details: Mapped[Optional[str]] = mapped_column(
Text,
doc="Additional details about the action"
)
ip_address: Mapped[Optional[str]] = mapped_column(
String(45),
doc="IP address of the actor"
)
# Timestamp (no updated_at in DB)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
doc="Timestamp when the activity was recorded"
)
# Relationships
quote: Mapped["Quote"] = relationship(
"Quote",
back_populates="activities"
)
# Indexes
__table_args__ = (
Index("idx_quote_activities_quote_id", "quote_id"),
Index("idx_quote_activities_action", "action"),
Index("idx_quote_activities_created_at", "created_at"),
)
def __repr__(self) -> str:
"""String representation of the quote activity."""
return f"<QuoteActivity(quote_id='{self.quote_id}', action='{self.action}')>"
class QuoteNotification(Base, UUIDMixin):
"""
Quote notification model for tracking notifications sent.
Records all notifications (emails, webhooks) sent for a quote.
Attributes:
quote_id: Reference to the parent quote
notification_type: Type of notification (email, webhook)
recipient: Notification recipient (email address, webhook URL, etc.)
subject: Notification subject
body: Notification body content
status: Delivery status (pending, sent, failed)
attempts: Number of delivery attempts
last_attempt_at: Timestamp of last delivery attempt
sent_at: Timestamp when notification was successfully sent
error_message: Error message if delivery failed
created_at: Timestamp when notification was created
"""
__tablename__ = "quote_notifications"
# Foreign keys
quote_id: Mapped[str] = mapped_column(
CHAR(36),
ForeignKey("quotes.id", ondelete="CASCADE"),
nullable=False,
doc="Reference to the parent quote"
)
# Notification details
notification_type: Mapped[str] = mapped_column(
String(30),
nullable=False,
doc="Type of notification: email, webhook"
)
recipient: Mapped[str] = mapped_column(
String(255),
nullable=False,
doc="Notification recipient (email address, webhook URL, etc.)"
)
subject: Mapped[Optional[str]] = mapped_column(
String(255),
doc="Notification subject"
)
body: Mapped[Optional[str]] = mapped_column(
Text,
doc="Notification body content"
)
# Status tracking
status: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="pending",
server_default="pending",
doc="Delivery status: pending, sent, failed"
)
attempts: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
server_default="0",
doc="Number of delivery attempts"
)
last_attempt_at: Mapped[Optional[datetime]] = mapped_column(
DateTime,
doc="Timestamp of last delivery attempt"
)
sent_at: Mapped[Optional[datetime]] = mapped_column(
DateTime,
doc="Timestamp when notification was successfully sent"
)
error_message: Mapped[Optional[str]] = mapped_column(
Text,
doc="Error message if delivery failed"
)
# Timestamp (no updated_at in DB)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
doc="Timestamp when the notification was created"
)
# Relationships
quote: Mapped["Quote"] = relationship(
"Quote",
back_populates="notifications"
)
# Constraints and indexes
__table_args__ = (
CheckConstraint(
"notification_type IN ('email', 'webhook')",
name="ck_quote_notifications_type"
),
CheckConstraint(
"status IN ('pending', 'sent', 'failed')",
name="ck_quote_notifications_status"
),
Index("idx_quote_notifications_quote_id", "quote_id"),
Index("idx_quote_notifications_type", "notification_type"),
Index("idx_quote_notifications_status", "status"),
)
def __repr__(self) -> str:
"""String representation of the quote notification."""
return f"<QuoteNotification(type='{self.notification_type}', recipient='{self.recipient}', status='{self.status}')>"