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