Synced files: - Session logs updated - Latest context and credentials - Command/directive updates Machine: Mikes-MacBook-Air.local Timestamp: 2026-03-09 08:14:13 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
565 lines
16 KiB
Python
565 lines
16 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,
|
|
)
|
|
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"
|
|
REVIEWING = "reviewing"
|
|
APPROVED = "approved"
|
|
REJECTED = "rejected"
|
|
EXPIRED = "expired"
|
|
|
|
|
|
class ServiceCategory(str, PyEnum):
|
|
"""Service category options for quote items."""
|
|
MANAGED_SERVICES = "managed_services"
|
|
SECURITY = "security"
|
|
BACKUP = "backup"
|
|
CLOUD = "cloud"
|
|
HARDWARE = "hardware"
|
|
SOFTWARE = "software"
|
|
CONSULTING = "consulting"
|
|
SUPPORT = "support"
|
|
|
|
|
|
class BillingFrequency(str, PyEnum):
|
|
"""Billing frequency options for quote items."""
|
|
MONTHLY = "monthly"
|
|
QUARTERLY = "quarterly"
|
|
ANNUAL = "annual"
|
|
ONE_TIME = "one_time"
|
|
|
|
|
|
class NotificationType(str, PyEnum):
|
|
"""Notification types for quote events."""
|
|
EMAIL_SENT = "email_sent"
|
|
SMS_SENT = "sms_sent"
|
|
ADMIN_ALERT = "admin_alert"
|
|
REMINDER_SENT = "reminder_sent"
|
|
|
|
|
|
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, reviewing, 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
|
|
notes: Customer notes or special requirements
|
|
admin_notes: Internal admin notes (not visible to customer)
|
|
monthly_total: Calculated monthly recurring total
|
|
setup_total: Calculated one-time setup total
|
|
annual_total: Calculated annual 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, reviewing, approved, rejected, expired"
|
|
)
|
|
|
|
# Contact information (optional until submission)
|
|
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"
|
|
)
|
|
|
|
# Notes
|
|
notes: Mapped[Optional[str]] = mapped_column(
|
|
Text,
|
|
doc="Customer notes or special requirements"
|
|
)
|
|
|
|
admin_notes: Mapped[Optional[str]] = mapped_column(
|
|
Text,
|
|
doc="Internal admin notes (not visible to customer)"
|
|
)
|
|
|
|
# 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"
|
|
)
|
|
|
|
annual_total: Mapped[Decimal] = mapped_column(
|
|
Numeric(10, 2),
|
|
nullable=False,
|
|
default=Decimal("0.00"),
|
|
server_default="0.00",
|
|
doc="Calculated annual 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(
|
|
String(500),
|
|
doc="Browser user agent string"
|
|
)
|
|
|
|
# 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', 'reviewing', 'approved', 'rejected', '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, TimestampMixin):
|
|
"""
|
|
Quote item model representing a single line item in a quote.
|
|
|
|
Stores service details, pricing, and quantity information.
|
|
|
|
Attributes:
|
|
quote_id: Reference to the parent quote
|
|
service_name: Name of the service
|
|
service_description: Detailed description of the service
|
|
category: Service category (managed_services, security, etc.)
|
|
billing_frequency: Billing frequency (monthly, annual, one_time)
|
|
unit_price: Price per unit
|
|
quantity: Number of units
|
|
setup_fee: One-time setup fee
|
|
is_required: Whether this item is required (cannot be removed)
|
|
sort_order: Display order within the quote
|
|
"""
|
|
|
|
__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"
|
|
)
|
|
|
|
# Service identification
|
|
service_name: Mapped[str] = mapped_column(
|
|
String(255),
|
|
nullable=False,
|
|
doc="Name of the service"
|
|
)
|
|
|
|
service_description: Mapped[Optional[str]] = mapped_column(
|
|
Text,
|
|
doc="Detailed description of the service"
|
|
)
|
|
|
|
# Category
|
|
category: Mapped[str] = mapped_column(
|
|
String(50),
|
|
nullable=False,
|
|
default=ServiceCategory.MANAGED_SERVICES.value,
|
|
doc="Service category: managed_services, security, backup, cloud, etc."
|
|
)
|
|
|
|
# Billing
|
|
billing_frequency: Mapped[str] = mapped_column(
|
|
String(20),
|
|
nullable=False,
|
|
default=BillingFrequency.MONTHLY.value,
|
|
doc="Billing frequency: monthly, quarterly, annual, one_time"
|
|
)
|
|
|
|
# Pricing
|
|
unit_price: Mapped[Decimal] = mapped_column(
|
|
Numeric(10, 2),
|
|
nullable=False,
|
|
default=Decimal("0.00"),
|
|
doc="Price per unit"
|
|
)
|
|
|
|
quantity: Mapped[int] = mapped_column(
|
|
Integer,
|
|
nullable=False,
|
|
default=1,
|
|
doc="Number of units"
|
|
)
|
|
|
|
setup_fee: Mapped[Decimal] = mapped_column(
|
|
Numeric(10, 2),
|
|
nullable=False,
|
|
default=Decimal("0.00"),
|
|
server_default="0.00",
|
|
doc="One-time setup fee"
|
|
)
|
|
|
|
# Configuration
|
|
is_required: Mapped[bool] = mapped_column(
|
|
Boolean,
|
|
nullable=False,
|
|
default=False,
|
|
server_default="0",
|
|
doc="Whether this item is required (cannot be removed)"
|
|
)
|
|
|
|
sort_order: Mapped[int] = mapped_column(
|
|
Integer,
|
|
nullable=False,
|
|
default=0,
|
|
server_default="0",
|
|
doc="Display order within the quote"
|
|
)
|
|
|
|
# Relationships
|
|
quote: Mapped["Quote"] = relationship(
|
|
"Quote",
|
|
back_populates="items"
|
|
)
|
|
|
|
# Constraints and indexes
|
|
__table_args__ = (
|
|
CheckConstraint(
|
|
"category IN ('managed_services', 'security', 'backup', 'cloud', 'hardware', 'software', 'consulting', 'support')",
|
|
name="ck_quote_items_category"
|
|
),
|
|
CheckConstraint(
|
|
"billing_frequency IN ('monthly', 'quarterly', 'annual', 'one_time')",
|
|
name="ck_quote_items_billing_frequency"
|
|
),
|
|
CheckConstraint(
|
|
"quantity >= 1",
|
|
name="ck_quote_items_quantity_positive"
|
|
),
|
|
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(service='{self.service_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.QUARTERLY.value:
|
|
return self.line_total / Decimal("3")
|
|
elif self.billing_frequency == BillingFrequency.ANNUAL.value:
|
|
return self.line_total / Decimal("12")
|
|
else: # one_time
|
|
return Decimal("0.00")
|
|
|
|
|
|
class QuoteActivity(Base, UUIDMixin, TimestampMixin):
|
|
"""
|
|
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.)
|
|
description: Detailed description of the action
|
|
actor: Who performed the action (email, 'system', 'admin')
|
|
ip_address: IP address of the actor
|
|
metadata: JSON metadata about the action
|
|
"""
|
|
|
|
__tablename__ = "quote_activities"
|
|
|
|
# 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."
|
|
)
|
|
|
|
description: Mapped[Optional[str]] = mapped_column(
|
|
Text,
|
|
doc="Detailed description of the action"
|
|
)
|
|
|
|
actor: Mapped[Optional[str]] = mapped_column(
|
|
String(255),
|
|
doc="Who performed the action (email, 'system', 'admin')"
|
|
)
|
|
|
|
ip_address: Mapped[Optional[str]] = mapped_column(
|
|
String(45),
|
|
doc="IP address of the actor"
|
|
)
|
|
|
|
metadata: Mapped[Optional[str]] = mapped_column(
|
|
Text,
|
|
doc="JSON metadata about the action"
|
|
)
|
|
|
|
# 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, TimestampMixin):
|
|
"""
|
|
Quote notification model for tracking notifications sent.
|
|
|
|
Records all notifications (emails, SMS, alerts) sent for a quote.
|
|
|
|
Attributes:
|
|
quote_id: Reference to the parent quote
|
|
notification_type: Type of notification (email_sent, sms_sent, etc.)
|
|
recipient: Notification recipient (email, phone, etc.)
|
|
subject: Notification subject
|
|
content: Notification content/body
|
|
status: Delivery status (pending, sent, delivered, failed)
|
|
sent_at: Timestamp when notification was sent
|
|
error_message: Error message if delivery failed
|
|
"""
|
|
|
|
__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_sent, sms_sent, admin_alert, reminder_sent"
|
|
)
|
|
|
|
recipient: Mapped[str] = mapped_column(
|
|
String(255),
|
|
nullable=False,
|
|
doc="Notification recipient (email, phone, etc.)"
|
|
)
|
|
|
|
subject: Mapped[Optional[str]] = mapped_column(
|
|
String(500),
|
|
doc="Notification subject"
|
|
)
|
|
|
|
content: Mapped[Optional[str]] = mapped_column(
|
|
Text,
|
|
doc="Notification content/body"
|
|
)
|
|
|
|
# Status tracking
|
|
status: Mapped[str] = mapped_column(
|
|
String(20),
|
|
nullable=False,
|
|
default="pending",
|
|
server_default="pending",
|
|
doc="Delivery status: pending, sent, delivered, failed"
|
|
)
|
|
|
|
sent_at: Mapped[Optional[datetime]] = mapped_column(
|
|
DateTime,
|
|
doc="Timestamp when notification was sent"
|
|
)
|
|
|
|
error_message: Mapped[Optional[str]] = mapped_column(
|
|
Text,
|
|
doc="Error message if delivery failed"
|
|
)
|
|
|
|
# Relationships
|
|
quote: Mapped["Quote"] = relationship(
|
|
"Quote",
|
|
back_populates="notifications"
|
|
)
|
|
|
|
# Constraints and indexes
|
|
__table_args__ = (
|
|
CheckConstraint(
|
|
"notification_type IN ('email_sent', 'sms_sent', 'admin_alert', 'reminder_sent')",
|
|
name="ck_quote_notifications_type"
|
|
),
|
|
CheckConstraint(
|
|
"status IN ('pending', 'sent', 'delivered', '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}')>"
|