""" 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"" 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"" @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"" 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""