sync: Auto-sync from Mikes-MacBook-Air.local at 2026-03-09 08:14:13
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>
This commit is contained in:
@@ -33,6 +33,8 @@ from api.routers import (
|
||||
security_incidents,
|
||||
bulk_import,
|
||||
version,
|
||||
quotes,
|
||||
admin_quotes,
|
||||
)
|
||||
|
||||
# Import middleware
|
||||
@@ -124,6 +126,10 @@ app.include_router(credential_audit_logs.router, prefix="/api/credential-audit-l
|
||||
app.include_router(security_incidents.router, prefix="/api/security-incidents", tags=["Security Incidents"])
|
||||
app.include_router(bulk_import.router, prefix="/api/bulk-import", tags=["Bulk Import"])
|
||||
|
||||
# Quote Wizard endpoints (public and admin)
|
||||
app.include_router(quotes.router, prefix="/api/quotes", tags=["Quotes"])
|
||||
app.include_router(admin_quotes.router, prefix="/api/admin/quotes", tags=["Admin Quotes"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
@@ -31,6 +31,7 @@ from api.models.operation_failure import OperationFailure
|
||||
from api.models.pending_task import PendingTask
|
||||
from api.models.problem_solution import ProblemSolution
|
||||
from api.models.project import Project
|
||||
from api.models.quote import Quote, QuoteActivity, QuoteItem, QuoteNotification
|
||||
from api.models.schema_migration import SchemaMigration
|
||||
from api.models.security_incident import SecurityIncident
|
||||
from api.models.service import Service
|
||||
@@ -72,6 +73,10 @@ __all__ = [
|
||||
"PendingTask",
|
||||
"ProblemSolution",
|
||||
"Project",
|
||||
"Quote",
|
||||
"QuoteActivity",
|
||||
"QuoteItem",
|
||||
"QuoteNotification",
|
||||
"SchemaMigration",
|
||||
"SecurityIncident",
|
||||
"Service",
|
||||
|
||||
564
api/models/quote.py
Normal file
564
api/models/quote.py
Normal file
@@ -0,0 +1,564 @@
|
||||
"""
|
||||
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}')>"
|
||||
384
api/routers/admin_quotes.py
Normal file
384
api/routers/admin_quotes.py
Normal file
@@ -0,0 +1,384 @@
|
||||
"""
|
||||
Admin Quote API router for ClaudeTools.
|
||||
|
||||
This module defines all admin REST API endpoints for managing quotes,
|
||||
requiring JWT authentication for access.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.database import get_db
|
||||
from api.middleware.auth import get_current_user
|
||||
from api.schemas.quote import (
|
||||
QuoteAdminResponse,
|
||||
QuoteAdminUpdate,
|
||||
QuoteActivityResponse,
|
||||
QuoteItemResponse,
|
||||
QuoteListItem,
|
||||
QuoteListResponse,
|
||||
QuoteNotificationResponse,
|
||||
QuoteStatsResponse,
|
||||
QuoteStatus,
|
||||
)
|
||||
from api.services import quote_service
|
||||
|
||||
# Create router with authentication required
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=QuoteListResponse,
|
||||
summary="List all quotes",
|
||||
description="Retrieve a paginated list of all quotes with optional filtering",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def list_quotes(
|
||||
skip: int = Query(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Number of records to skip for pagination"
|
||||
),
|
||||
limit: int = Query(
|
||||
default=100,
|
||||
ge=1,
|
||||
le=1000,
|
||||
description="Maximum number of records to return (max 1000)"
|
||||
),
|
||||
status_filter: Optional[str] = Query(
|
||||
default=None,
|
||||
alias="status",
|
||||
description="Filter by status (draft, submitted, reviewing, approved, rejected, expired)"
|
||||
),
|
||||
search: Optional[str] = Query(
|
||||
default=None,
|
||||
description="Search in company_name, contact_name, contact_email"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
List all quotes with pagination and filtering.
|
||||
|
||||
- **skip**: Number of quotes to skip (default: 0)
|
||||
- **limit**: Maximum number of quotes to return (default: 100, max: 1000)
|
||||
- **status**: Filter by quote status
|
||||
- **search**: Search in company name, contact name, or email
|
||||
|
||||
Returns a list of quotes with pagination metadata.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
GET /api/admin/quotes?skip=0&limit=50&status=submitted
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"total": 25,
|
||||
"skip": 0,
|
||||
"limit": 50,
|
||||
"quotes": [
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"access_token": "xYz123...",
|
||||
"status": "submitted",
|
||||
"company_name": "Acme Corporation",
|
||||
"contact_name": "John Doe",
|
||||
"contact_email": "john@acme.com",
|
||||
"employee_count": 25,
|
||||
"monthly_total": "450.00",
|
||||
"setup_total": "500.00",
|
||||
"item_count": 3,
|
||||
"submitted_at": "2024-01-15T14:30:00Z",
|
||||
"created_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
"""
|
||||
quotes, total = quote_service.list_quotes(
|
||||
db=db,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
status_filter=status_filter,
|
||||
search=search
|
||||
)
|
||||
|
||||
# Build list items with item counts
|
||||
quote_items = []
|
||||
for quote in quotes:
|
||||
quote_items.append(QuoteListItem(
|
||||
id=quote.id,
|
||||
access_token=quote.access_token,
|
||||
status=quote.status,
|
||||
company_name=quote.company_name,
|
||||
contact_name=quote.contact_name,
|
||||
contact_email=quote.contact_email,
|
||||
employee_count=quote.employee_count,
|
||||
monthly_total=quote.monthly_total,
|
||||
setup_total=quote.setup_total,
|
||||
item_count=len(quote.items),
|
||||
submitted_at=quote.submitted_at,
|
||||
created_at=quote.created_at
|
||||
))
|
||||
|
||||
return QuoteListResponse(
|
||||
total=total,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
quotes=quote_items
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/stats",
|
||||
response_model=QuoteStatsResponse,
|
||||
summary="Get quote statistics",
|
||||
description="Get dashboard statistics for quotes",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def get_stats(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get quote statistics for the admin dashboard.
|
||||
|
||||
Returns aggregate statistics including totals, counts by status,
|
||||
and conversion rates.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
GET /api/admin/quotes/stats
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"total_quotes": 150,
|
||||
"quotes_by_status": {
|
||||
"draft": 45,
|
||||
"submitted": 60,
|
||||
"reviewing": 15,
|
||||
"approved": 25,
|
||||
"rejected": 3,
|
||||
"expired": 2
|
||||
},
|
||||
"total_monthly_value": "12500.00",
|
||||
"total_setup_value": "8500.00",
|
||||
"quotes_this_month": 28,
|
||||
"quotes_submitted_this_month": 18,
|
||||
"average_monthly_value": "125.00",
|
||||
"conversion_rate": "66.67"
|
||||
}
|
||||
```
|
||||
"""
|
||||
return quote_service.get_quote_stats(db)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{quote_id}",
|
||||
response_model=QuoteAdminResponse,
|
||||
summary="Get quote by ID",
|
||||
description="Retrieve a single quote by its ID with full details",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Quote found and returned",
|
||||
"model": QuoteAdminResponse,
|
||||
},
|
||||
404: {
|
||||
"description": "Quote not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Quote with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def get_quote(
|
||||
quote_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get a specific quote by ID with full admin details.
|
||||
|
||||
Returns the quote with all items, activities, and notifications.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
GET /api/admin/quotes/123e4567-e89b-12d3-a456-426614174000
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"access_token": "xYz123...",
|
||||
"status": "submitted",
|
||||
"company_name": "Acme Corporation",
|
||||
"contact_name": "John Doe",
|
||||
"contact_email": "john@acme.com",
|
||||
"admin_notes": "Follow up scheduled for next week",
|
||||
"ip_address": "192.168.1.100",
|
||||
"user_agent": "Mozilla/5.0...",
|
||||
"items": [...],
|
||||
"activities": [
|
||||
{
|
||||
"id": "789...",
|
||||
"action": "submitted",
|
||||
"description": "Quote submitted by John Doe (john@acme.com)",
|
||||
"actor": "john@acme.com",
|
||||
"created_at": "2024-01-15T14:30:00Z"
|
||||
}
|
||||
],
|
||||
"notifications": [...]
|
||||
}
|
||||
```
|
||||
"""
|
||||
quote = quote_service.get_quote_by_id(db, quote_id)
|
||||
|
||||
# Build response with all related data
|
||||
items_response = []
|
||||
for item in quote.items:
|
||||
items_response.append(QuoteItemResponse(
|
||||
id=item.id,
|
||||
quote_id=item.quote_id,
|
||||
service_name=item.service_name,
|
||||
service_description=item.service_description,
|
||||
category=item.category,
|
||||
billing_frequency=item.billing_frequency,
|
||||
unit_price=item.unit_price,
|
||||
quantity=item.quantity,
|
||||
setup_fee=item.setup_fee,
|
||||
is_required=item.is_required,
|
||||
sort_order=item.sort_order,
|
||||
line_total=item.line_total,
|
||||
monthly_amount=item.monthly_amount,
|
||||
created_at=item.created_at,
|
||||
updated_at=item.updated_at
|
||||
))
|
||||
|
||||
activities_response = []
|
||||
for activity in quote.activities:
|
||||
activities_response.append(QuoteActivityResponse(
|
||||
id=activity.id,
|
||||
quote_id=activity.quote_id,
|
||||
action=activity.action,
|
||||
description=activity.description,
|
||||
actor=activity.actor,
|
||||
ip_address=activity.ip_address,
|
||||
created_at=activity.created_at
|
||||
))
|
||||
|
||||
notifications_response = []
|
||||
for notification in quote.notifications:
|
||||
notifications_response.append(QuoteNotificationResponse(
|
||||
id=notification.id,
|
||||
quote_id=notification.quote_id,
|
||||
notification_type=notification.notification_type,
|
||||
recipient=notification.recipient,
|
||||
subject=notification.subject,
|
||||
status=notification.status,
|
||||
sent_at=notification.sent_at,
|
||||
error_message=notification.error_message,
|
||||
created_at=notification.created_at
|
||||
))
|
||||
|
||||
return QuoteAdminResponse(
|
||||
id=quote.id,
|
||||
access_token=quote.access_token,
|
||||
status=quote.status,
|
||||
company_name=quote.company_name,
|
||||
contact_name=quote.contact_name,
|
||||
contact_email=quote.contact_email,
|
||||
contact_phone=quote.contact_phone,
|
||||
employee_count=quote.employee_count,
|
||||
notes=quote.notes,
|
||||
admin_notes=quote.admin_notes,
|
||||
monthly_total=quote.monthly_total,
|
||||
setup_total=quote.setup_total,
|
||||
annual_total=quote.annual_total,
|
||||
expires_at=quote.expires_at,
|
||||
submitted_at=quote.submitted_at,
|
||||
ip_address=quote.ip_address,
|
||||
user_agent=quote.user_agent,
|
||||
created_at=quote.created_at,
|
||||
updated_at=quote.updated_at,
|
||||
items=items_response,
|
||||
activities=activities_response,
|
||||
notifications=notifications_response
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{quote_id}",
|
||||
response_model=QuoteAdminResponse,
|
||||
summary="Update quote status/notes",
|
||||
description="Update a quote's status or admin notes",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Quote updated successfully",
|
||||
"model": QuoteAdminResponse,
|
||||
},
|
||||
404: {
|
||||
"description": "Quote not found",
|
||||
},
|
||||
},
|
||||
)
|
||||
def update_quote(
|
||||
quote_id: UUID,
|
||||
update_data: QuoteAdminUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Update a quote's status or admin notes.
|
||||
|
||||
Admins can change the quote status (e.g., from submitted to reviewing
|
||||
or approved) and add internal notes.
|
||||
|
||||
**Example Request:**
|
||||
```json
|
||||
PUT /api/admin/quotes/123e4567-e89b-12d3-a456-426614174000
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"status": "reviewing",
|
||||
"admin_notes": "Assigned to sales team. Follow up scheduled for Monday."
|
||||
}
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"status": "reviewing",
|
||||
"admin_notes": "Assigned to sales team. Follow up scheduled for Monday.",
|
||||
...
|
||||
}
|
||||
```
|
||||
"""
|
||||
# Get admin username from token
|
||||
admin_user = current_user.get("sub", "admin")
|
||||
|
||||
quote_service.update_quote_status(
|
||||
db=db,
|
||||
quote_id=quote_id,
|
||||
update_data=update_data,
|
||||
admin_user=admin_user
|
||||
)
|
||||
|
||||
return get_quote(quote_id, db, current_user)
|
||||
519
api/routers/quotes.py
Normal file
519
api/routers/quotes.py
Normal file
@@ -0,0 +1,519 @@
|
||||
"""
|
||||
Public Quote API router for ClaudeTools.
|
||||
|
||||
This module defines all public REST API endpoints for the MSP Quote Wizard,
|
||||
allowing prospects to create, view, and submit quotes without authentication.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.database import get_db
|
||||
from api.schemas.quote import (
|
||||
QuoteCreate,
|
||||
QuoteCreatedResponse,
|
||||
QuoteItemCreate,
|
||||
QuoteResponse,
|
||||
QuoteItemResponse,
|
||||
QuoteSubmit,
|
||||
QuoteUpdate,
|
||||
)
|
||||
from api.services import quote_service
|
||||
|
||||
# Create router (no authentication required for public endpoints)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_client_ip(request: Request) -> Optional[str]:
|
||||
"""Extract client IP from request, handling proxies."""
|
||||
forwarded = request.headers.get("X-Forwarded-For")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.client.host if request.client else None
|
||||
|
||||
|
||||
def get_user_agent(request: Request) -> Optional[str]:
|
||||
"""Extract user agent from request."""
|
||||
return request.headers.get("User-Agent")
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=QuoteCreatedResponse,
|
||||
summary="Create new quote draft",
|
||||
description="Create a new quote draft. Returns an access token for future access.",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
responses={
|
||||
201: {
|
||||
"description": "Quote created successfully",
|
||||
"model": QuoteCreatedResponse,
|
||||
},
|
||||
500: {
|
||||
"description": "Server error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Failed to create quote"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def create_quote(
|
||||
quote_data: QuoteCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Create a new quote draft.
|
||||
|
||||
This endpoint does not require authentication. A unique access token is
|
||||
generated for the quote which can be used to access it later.
|
||||
|
||||
**Example Request:**
|
||||
```json
|
||||
POST /api/quotes
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"employee_count": 25,
|
||||
"notes": "Looking for complete managed services package"
|
||||
}
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"access_token": "xYz123abc456def789ghi012jkl345mno678pqr901stu",
|
||||
"status": "draft",
|
||||
"message": "Quote created successfully. Use the access_token to access your quote."
|
||||
}
|
||||
```
|
||||
"""
|
||||
ip_address = get_client_ip(request)
|
||||
user_agent = get_user_agent(request)
|
||||
|
||||
quote = quote_service.create_quote(
|
||||
db=db,
|
||||
quote_data=quote_data,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent
|
||||
)
|
||||
|
||||
return QuoteCreatedResponse(
|
||||
id=quote.id,
|
||||
access_token=quote.access_token,
|
||||
status=quote.status,
|
||||
message="Quote created successfully. Use the access_token to access your quote."
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{access_token}",
|
||||
response_model=QuoteResponse,
|
||||
summary="Get quote by access token",
|
||||
description="Retrieve a quote by its access token",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Quote found and returned",
|
||||
"model": QuoteResponse,
|
||||
},
|
||||
404: {
|
||||
"description": "Quote not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Quote not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def get_quote(
|
||||
access_token: str,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get a quote by its access token.
|
||||
|
||||
Returns the quote with all its items. This is the public endpoint
|
||||
for viewing a quote.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
GET /api/quotes/xYz123abc456def789ghi012jkl345mno678pqr901stu
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"access_token": "xYz123abc456def789ghi012jkl345mno678pqr901stu",
|
||||
"status": "draft",
|
||||
"company_name": null,
|
||||
"contact_name": null,
|
||||
"contact_email": null,
|
||||
"employee_count": 25,
|
||||
"monthly_total": "450.00",
|
||||
"setup_total": "500.00",
|
||||
"annual_total": "5900.00",
|
||||
"items": [
|
||||
{
|
||||
"id": "456e7890-e89b-12d3-a456-426614174001",
|
||||
"service_name": "Managed Endpoint Protection",
|
||||
"category": "security",
|
||||
"unit_price": "15.00",
|
||||
"quantity": 25,
|
||||
"billing_frequency": "monthly",
|
||||
"line_total": "375.00",
|
||||
"monthly_amount": "375.00"
|
||||
}
|
||||
],
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
"""
|
||||
quote = quote_service.get_quote_by_token(db, access_token)
|
||||
|
||||
# Build response with calculated fields for items
|
||||
items_response = []
|
||||
for item in quote.items:
|
||||
item_dict = QuoteItemResponse(
|
||||
id=item.id,
|
||||
quote_id=item.quote_id,
|
||||
service_name=item.service_name,
|
||||
service_description=item.service_description,
|
||||
category=item.category,
|
||||
billing_frequency=item.billing_frequency,
|
||||
unit_price=item.unit_price,
|
||||
quantity=item.quantity,
|
||||
setup_fee=item.setup_fee,
|
||||
is_required=item.is_required,
|
||||
sort_order=item.sort_order,
|
||||
line_total=item.line_total,
|
||||
monthly_amount=item.monthly_amount,
|
||||
created_at=item.created_at,
|
||||
updated_at=item.updated_at
|
||||
)
|
||||
items_response.append(item_dict)
|
||||
|
||||
return QuoteResponse(
|
||||
id=quote.id,
|
||||
access_token=quote.access_token,
|
||||
status=quote.status,
|
||||
company_name=quote.company_name,
|
||||
contact_name=quote.contact_name,
|
||||
contact_email=quote.contact_email,
|
||||
contact_phone=quote.contact_phone,
|
||||
employee_count=quote.employee_count,
|
||||
notes=quote.notes,
|
||||
monthly_total=quote.monthly_total,
|
||||
setup_total=quote.setup_total,
|
||||
annual_total=quote.annual_total,
|
||||
expires_at=quote.expires_at,
|
||||
submitted_at=quote.submitted_at,
|
||||
created_at=quote.created_at,
|
||||
updated_at=quote.updated_at,
|
||||
items=items_response
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{access_token}",
|
||||
response_model=QuoteResponse,
|
||||
summary="Update quote",
|
||||
description="Update a quote's details and/or items",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Quote updated successfully",
|
||||
"model": QuoteResponse,
|
||||
},
|
||||
400: {
|
||||
"description": "Quote cannot be modified (not a draft)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Cannot update quote with status 'submitted'. Only drafts can be modified."}
|
||||
}
|
||||
},
|
||||
},
|
||||
404: {
|
||||
"description": "Quote not found",
|
||||
},
|
||||
},
|
||||
)
|
||||
def update_quote(
|
||||
access_token: str,
|
||||
quote_data: QuoteUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Update a quote.
|
||||
|
||||
Updates quote details and/or replaces all items. Only draft quotes
|
||||
can be modified.
|
||||
|
||||
**Example Request:**
|
||||
```json
|
||||
PUT /api/quotes/xYz123abc456def789ghi012jkl345mno678pqr901stu
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"employee_count": 30,
|
||||
"items": [
|
||||
{
|
||||
"service_name": "Managed Endpoint Protection",
|
||||
"category": "security",
|
||||
"unit_price": "15.00",
|
||||
"quantity": 30,
|
||||
"billing_frequency": "monthly"
|
||||
},
|
||||
{
|
||||
"service_name": "Cloud Backup",
|
||||
"category": "backup",
|
||||
"unit_price": "5.00",
|
||||
"quantity": 30,
|
||||
"billing_frequency": "monthly",
|
||||
"setup_fee": "250.00"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
"""
|
||||
ip_address = get_client_ip(request)
|
||||
|
||||
quote = quote_service.update_quote(
|
||||
db=db,
|
||||
access_token=access_token,
|
||||
quote_data=quote_data,
|
||||
ip_address=ip_address
|
||||
)
|
||||
|
||||
return get_quote(access_token, db)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{access_token}/items",
|
||||
response_model=QuoteResponse,
|
||||
summary="Add item to quote",
|
||||
description="Add a single item to the quote",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
responses={
|
||||
201: {
|
||||
"description": "Item added successfully",
|
||||
"model": QuoteResponse,
|
||||
},
|
||||
400: {
|
||||
"description": "Quote cannot be modified",
|
||||
},
|
||||
404: {
|
||||
"description": "Quote not found",
|
||||
},
|
||||
},
|
||||
)
|
||||
def add_item(
|
||||
access_token: str,
|
||||
item_data: QuoteItemCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Add a single item to a quote.
|
||||
|
||||
**Example Request:**
|
||||
```json
|
||||
POST /api/quotes/xYz123abc456def789ghi012jkl345mno678pqr901stu/items
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"service_name": "24/7 Help Desk Support",
|
||||
"category": "support",
|
||||
"unit_price": "50.00",
|
||||
"quantity": 1,
|
||||
"billing_frequency": "monthly"
|
||||
}
|
||||
```
|
||||
"""
|
||||
ip_address = get_client_ip(request)
|
||||
|
||||
quote_service.add_item_to_quote(
|
||||
db=db,
|
||||
access_token=access_token,
|
||||
item_data=item_data,
|
||||
ip_address=ip_address
|
||||
)
|
||||
|
||||
return get_quote(access_token, db)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{access_token}/items/{item_id}",
|
||||
response_model=QuoteResponse,
|
||||
summary="Remove item from quote",
|
||||
description="Remove an item from the quote",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Item removed successfully",
|
||||
"model": QuoteResponse,
|
||||
},
|
||||
400: {
|
||||
"description": "Quote cannot be modified or item is required",
|
||||
},
|
||||
404: {
|
||||
"description": "Quote or item not found",
|
||||
},
|
||||
},
|
||||
)
|
||||
def remove_item(
|
||||
access_token: str,
|
||||
item_id: UUID,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Remove an item from a quote.
|
||||
|
||||
Required items cannot be removed.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
DELETE /api/quotes/xYz123abc456def789ghi012jkl345mno678pqr901stu/items/456e7890-e89b-12d3-a456-426614174001
|
||||
```
|
||||
"""
|
||||
ip_address = get_client_ip(request)
|
||||
|
||||
quote_service.remove_item_from_quote(
|
||||
db=db,
|
||||
access_token=access_token,
|
||||
item_id=item_id,
|
||||
ip_address=ip_address
|
||||
)
|
||||
|
||||
return get_quote(access_token, db)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{access_token}/submit",
|
||||
response_model=QuoteResponse,
|
||||
summary="Submit quote",
|
||||
description="Submit the quote with contact information",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Quote submitted successfully",
|
||||
"model": QuoteResponse,
|
||||
},
|
||||
400: {
|
||||
"description": "Quote cannot be submitted (not a draft or no items)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"examples": {
|
||||
"not_draft": {
|
||||
"value": {"detail": "Cannot submit quote with status 'submitted'. Only drafts can be submitted."}
|
||||
},
|
||||
"no_items": {
|
||||
"value": {"detail": "Cannot submit quote without any items. Please add at least one service."}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
404: {
|
||||
"description": "Quote not found",
|
||||
},
|
||||
422: {
|
||||
"description": "Validation error - missing required fields",
|
||||
},
|
||||
},
|
||||
)
|
||||
def submit_quote(
|
||||
access_token: str,
|
||||
submit_data: QuoteSubmit,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Submit a quote with contact information.
|
||||
|
||||
This finalizes the quote and sends it for review. Contact information
|
||||
is required at this stage.
|
||||
|
||||
**Example Request:**
|
||||
```json
|
||||
POST /api/quotes/xYz123abc456def789ghi012jkl345mno678pqr901stu/submit
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"company_name": "Acme Corporation",
|
||||
"contact_name": "John Doe",
|
||||
"contact_email": "john.doe@acme.com",
|
||||
"contact_phone": "555-123-4567",
|
||||
"notes": "Please contact me to discuss implementation timeline."
|
||||
}
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"access_token": "xYz123abc456def789ghi012jkl345mno678pqr901stu",
|
||||
"status": "submitted",
|
||||
"company_name": "Acme Corporation",
|
||||
"contact_name": "John Doe",
|
||||
"contact_email": "john.doe@acme.com",
|
||||
"submitted_at": "2024-01-15T14:30:00Z",
|
||||
...
|
||||
}
|
||||
```
|
||||
"""
|
||||
ip_address = get_client_ip(request)
|
||||
|
||||
quote_service.submit_quote(
|
||||
db=db,
|
||||
access_token=access_token,
|
||||
submit_data=submit_data,
|
||||
ip_address=ip_address
|
||||
)
|
||||
|
||||
return get_quote(access_token, db)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{access_token}/pdf",
|
||||
summary="Get quote PDF (placeholder)",
|
||||
description="Generate and return a PDF version of the quote",
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
responses={
|
||||
501: {
|
||||
"description": "PDF generation not yet implemented",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "PDF generation is not yet implemented"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def get_quote_pdf(
|
||||
access_token: str,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Generate a PDF version of the quote.
|
||||
|
||||
**Note:** This endpoint is a placeholder. PDF generation will be
|
||||
implemented in a future update.
|
||||
"""
|
||||
# Verify quote exists
|
||||
quote_service.get_quote_by_token(db, access_token)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
detail="PDF generation is not yet implemented"
|
||||
)
|
||||
@@ -15,6 +15,20 @@ from .m365_tenant import M365TenantBase, M365TenantCreate, M365TenantResponse, M
|
||||
from .machine import MachineBase, MachineCreate, MachineResponse, MachineUpdate
|
||||
from .network import NetworkBase, NetworkCreate, NetworkResponse, NetworkUpdate
|
||||
from .project import ProjectBase, ProjectCreate, ProjectResponse, ProjectUpdate
|
||||
from .quote import (
|
||||
QuoteCreate,
|
||||
QuoteCreatedResponse,
|
||||
QuoteItemCreate,
|
||||
QuoteItemResponse,
|
||||
QuoteItemUpdate,
|
||||
QuoteListResponse,
|
||||
QuoteResponse,
|
||||
QuoteAdminResponse,
|
||||
QuoteAdminUpdate,
|
||||
QuoteStatsResponse,
|
||||
QuoteSubmit,
|
||||
QuoteUpdate,
|
||||
)
|
||||
from .security_incident import SecurityIncidentBase, SecurityIncidentCreate, SecurityIncidentResponse, SecurityIncidentUpdate
|
||||
from .service import ServiceBase, ServiceCreate, ServiceResponse, ServiceUpdate
|
||||
from .session import SessionBase, SessionCreate, SessionResponse, SessionUpdate
|
||||
@@ -109,4 +123,17 @@ __all__ = [
|
||||
"SecurityIncidentCreate",
|
||||
"SecurityIncidentUpdate",
|
||||
"SecurityIncidentResponse",
|
||||
# Quote schemas
|
||||
"QuoteCreate",
|
||||
"QuoteCreatedResponse",
|
||||
"QuoteItemCreate",
|
||||
"QuoteItemResponse",
|
||||
"QuoteItemUpdate",
|
||||
"QuoteListResponse",
|
||||
"QuoteResponse",
|
||||
"QuoteAdminResponse",
|
||||
"QuoteAdminUpdate",
|
||||
"QuoteStatsResponse",
|
||||
"QuoteSubmit",
|
||||
"QuoteUpdate",
|
||||
]
|
||||
|
||||
303
api/schemas/quote.py
Normal file
303
api/schemas/quote.py
Normal file
@@ -0,0 +1,303 @@
|
||||
"""
|
||||
Pydantic schemas for Quote models.
|
||||
|
||||
Request and response schemas for the MSP Quote Wizard including
|
||||
public and admin-facing operations.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field, EmailStr, field_validator
|
||||
|
||||
|
||||
class QuoteStatus(str, Enum):
|
||||
"""Status options for quotes."""
|
||||
DRAFT = "draft"
|
||||
SUBMITTED = "submitted"
|
||||
REVIEWING = "reviewing"
|
||||
APPROVED = "approved"
|
||||
REJECTED = "rejected"
|
||||
EXPIRED = "expired"
|
||||
|
||||
|
||||
class ServiceCategory(str, Enum):
|
||||
"""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, Enum):
|
||||
"""Billing frequency options for quote items."""
|
||||
MONTHLY = "monthly"
|
||||
QUARTERLY = "quarterly"
|
||||
ANNUAL = "annual"
|
||||
ONE_TIME = "one_time"
|
||||
|
||||
|
||||
class NotificationType(str, Enum):
|
||||
"""Notification types for quote events."""
|
||||
EMAIL_SENT = "email_sent"
|
||||
SMS_SENT = "sms_sent"
|
||||
ADMIN_ALERT = "admin_alert"
|
||||
REMINDER_SENT = "reminder_sent"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Quote Item Schemas
|
||||
# ============================================================================
|
||||
|
||||
class QuoteItemBase(BaseModel):
|
||||
"""Base schema with shared QuoteItem fields."""
|
||||
|
||||
service_name: str = Field(..., description="Name of the service", min_length=1, max_length=255)
|
||||
service_description: Optional[str] = Field(None, description="Detailed description of the service")
|
||||
category: ServiceCategory = Field(
|
||||
ServiceCategory.MANAGED_SERVICES,
|
||||
description="Service category"
|
||||
)
|
||||
billing_frequency: BillingFrequency = Field(
|
||||
BillingFrequency.MONTHLY,
|
||||
description="Billing frequency"
|
||||
)
|
||||
unit_price: Decimal = Field(..., description="Price per unit", ge=0)
|
||||
quantity: int = Field(1, description="Number of units", ge=1)
|
||||
setup_fee: Decimal = Field(Decimal("0.00"), description="One-time setup fee", ge=0)
|
||||
is_required: bool = Field(False, description="Whether this item is required")
|
||||
sort_order: int = Field(0, description="Display order within the quote")
|
||||
|
||||
|
||||
class QuoteItemCreate(QuoteItemBase):
|
||||
"""Schema for creating a new QuoteItem."""
|
||||
pass
|
||||
|
||||
|
||||
class QuoteItemUpdate(BaseModel):
|
||||
"""Schema for updating an existing QuoteItem. All fields optional."""
|
||||
|
||||
service_name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
service_description: Optional[str] = None
|
||||
category: Optional[ServiceCategory] = None
|
||||
billing_frequency: Optional[BillingFrequency] = None
|
||||
unit_price: Optional[Decimal] = Field(None, ge=0)
|
||||
quantity: Optional[int] = Field(None, ge=1)
|
||||
setup_fee: Optional[Decimal] = Field(None, ge=0)
|
||||
is_required: Optional[bool] = None
|
||||
sort_order: Optional[int] = None
|
||||
|
||||
|
||||
class QuoteItemResponse(QuoteItemBase):
|
||||
"""Schema for QuoteItem responses with ID and computed fields."""
|
||||
|
||||
id: UUID = Field(..., description="Unique identifier for the quote item")
|
||||
quote_id: UUID = Field(..., description="Reference to the parent quote")
|
||||
line_total: Decimal = Field(..., description="Calculated line total (unit_price * quantity)")
|
||||
monthly_amount: Decimal = Field(..., description="Calculated monthly amount")
|
||||
created_at: datetime = Field(..., description="Timestamp when item was created")
|
||||
updated_at: datetime = Field(..., description="Timestamp when item was last updated")
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Quote Schemas
|
||||
# ============================================================================
|
||||
|
||||
class QuoteBase(BaseModel):
|
||||
"""Base schema with shared Quote fields."""
|
||||
|
||||
company_name: Optional[str] = Field(None, description="Prospect company name", max_length=255)
|
||||
contact_name: Optional[str] = Field(None, description="Primary contact name", max_length=255)
|
||||
contact_email: Optional[EmailStr] = Field(None, description="Contact email address")
|
||||
contact_phone: Optional[str] = Field(None, description="Contact phone number", max_length=50)
|
||||
employee_count: Optional[int] = Field(None, description="Number of employees/users", ge=1)
|
||||
notes: Optional[str] = Field(None, description="Customer notes or special requirements")
|
||||
|
||||
|
||||
class QuoteCreate(BaseModel):
|
||||
"""Schema for creating a new Quote draft."""
|
||||
|
||||
employee_count: Optional[int] = Field(None, description="Number of employees/users", ge=1)
|
||||
notes: Optional[str] = Field(None, description="Initial notes")
|
||||
# Items can optionally be provided at creation
|
||||
items: Optional[list[QuoteItemCreate]] = Field(None, description="Initial quote items")
|
||||
|
||||
|
||||
class QuoteUpdate(BaseModel):
|
||||
"""Schema for updating a Quote during wizard flow."""
|
||||
|
||||
company_name: Optional[str] = Field(None, max_length=255)
|
||||
contact_name: Optional[str] = Field(None, max_length=255)
|
||||
contact_email: Optional[EmailStr] = None
|
||||
contact_phone: Optional[str] = Field(None, max_length=50)
|
||||
employee_count: Optional[int] = Field(None, ge=1)
|
||||
notes: Optional[str] = None
|
||||
# Items to add/update
|
||||
items: Optional[list[QuoteItemCreate]] = Field(None, description="Items to set (replaces existing)")
|
||||
|
||||
|
||||
class QuoteSubmit(BaseModel):
|
||||
"""Schema for final quote submission with required contact info."""
|
||||
|
||||
company_name: str = Field(..., description="Company name (required for submission)", min_length=1, max_length=255)
|
||||
contact_name: str = Field(..., description="Contact name (required for submission)", min_length=1, max_length=255)
|
||||
contact_email: EmailStr = Field(..., description="Email address (required for submission)")
|
||||
contact_phone: Optional[str] = Field(None, description="Phone number", max_length=50)
|
||||
notes: Optional[str] = Field(None, description="Additional notes")
|
||||
|
||||
@field_validator("company_name", "contact_name")
|
||||
@classmethod
|
||||
def strip_whitespace(cls, v: str) -> str:
|
||||
"""Strip whitespace from string fields."""
|
||||
return v.strip() if v else v
|
||||
|
||||
|
||||
class QuoteResponse(QuoteBase):
|
||||
"""Schema for public Quote responses with items."""
|
||||
|
||||
id: UUID = Field(..., description="Unique identifier for the quote")
|
||||
access_token: str = Field(..., description="Access token for public URL")
|
||||
status: QuoteStatus = Field(..., description="Current quote status")
|
||||
monthly_total: Decimal = Field(..., description="Calculated monthly recurring total")
|
||||
setup_total: Decimal = Field(..., description="Calculated one-time setup total")
|
||||
annual_total: Decimal = Field(..., description="Calculated annual total")
|
||||
expires_at: Optional[datetime] = Field(None, description="Quote expiration date")
|
||||
submitted_at: Optional[datetime] = Field(None, description="When quote was submitted")
|
||||
created_at: datetime = Field(..., description="Timestamp when quote was created")
|
||||
updated_at: datetime = Field(..., description="Timestamp when quote was last updated")
|
||||
items: list[QuoteItemResponse] = Field(default_factory=list, description="Quote line items")
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class QuoteCreatedResponse(BaseModel):
|
||||
"""Schema for quote creation response with access URL info."""
|
||||
|
||||
id: UUID = Field(..., description="Unique identifier for the quote")
|
||||
access_token: str = Field(..., description="Access token for public URL")
|
||||
status: QuoteStatus = Field(..., description="Current quote status")
|
||||
message: str = Field(..., description="Success message")
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Quote Activity Schemas
|
||||
# ============================================================================
|
||||
|
||||
class QuoteActivityResponse(BaseModel):
|
||||
"""Schema for QuoteActivity responses."""
|
||||
|
||||
id: UUID = Field(..., description="Unique identifier for the activity")
|
||||
quote_id: UUID = Field(..., description="Reference to the parent quote")
|
||||
action: str = Field(..., description="Action performed")
|
||||
description: Optional[str] = Field(None, description="Detailed description")
|
||||
actor: Optional[str] = Field(None, description="Who performed the action")
|
||||
ip_address: Optional[str] = Field(None, description="IP address of the actor")
|
||||
created_at: datetime = Field(..., description="Timestamp of the action")
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Quote Notification Schemas
|
||||
# ============================================================================
|
||||
|
||||
class QuoteNotificationResponse(BaseModel):
|
||||
"""Schema for QuoteNotification responses."""
|
||||
|
||||
id: UUID = Field(..., description="Unique identifier for the notification")
|
||||
quote_id: UUID = Field(..., description="Reference to the parent quote")
|
||||
notification_type: NotificationType = Field(..., description="Type of notification")
|
||||
recipient: str = Field(..., description="Notification recipient")
|
||||
subject: Optional[str] = Field(None, description="Notification subject")
|
||||
status: str = Field(..., description="Delivery status")
|
||||
sent_at: Optional[datetime] = Field(None, description="When notification was sent")
|
||||
error_message: Optional[str] = Field(None, description="Error message if failed")
|
||||
created_at: datetime = Field(..., description="Timestamp when created")
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Admin Schemas
|
||||
# ============================================================================
|
||||
|
||||
class QuoteAdminUpdate(BaseModel):
|
||||
"""Schema for admin updates to a quote."""
|
||||
|
||||
status: Optional[QuoteStatus] = Field(None, description="New status")
|
||||
admin_notes: Optional[str] = Field(None, description="Internal admin notes")
|
||||
expires_at: Optional[datetime] = Field(None, description="Quote expiration date")
|
||||
|
||||
|
||||
class QuoteAdminResponse(QuoteResponse):
|
||||
"""Schema for admin Quote responses with additional fields."""
|
||||
|
||||
admin_notes: Optional[str] = Field(None, description="Internal admin notes")
|
||||
ip_address: Optional[str] = Field(None, description="IP address of the requester")
|
||||
user_agent: Optional[str] = Field(None, description="Browser user agent")
|
||||
activities: list[QuoteActivityResponse] = Field(
|
||||
default_factory=list,
|
||||
description="Activity log for this quote"
|
||||
)
|
||||
notifications: list[QuoteNotificationResponse] = Field(
|
||||
default_factory=list,
|
||||
description="Notifications sent for this quote"
|
||||
)
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# List and Stats Schemas
|
||||
# ============================================================================
|
||||
|
||||
class QuoteListItem(BaseModel):
|
||||
"""Schema for quote list items (summary view)."""
|
||||
|
||||
id: UUID = Field(..., description="Unique identifier")
|
||||
access_token: str = Field(..., description="Access token")
|
||||
status: QuoteStatus = Field(..., description="Current status")
|
||||
company_name: Optional[str] = Field(None, description="Company name")
|
||||
contact_name: Optional[str] = Field(None, description="Contact name")
|
||||
contact_email: Optional[str] = Field(None, description="Contact email")
|
||||
employee_count: Optional[int] = Field(None, description="Employee count")
|
||||
monthly_total: Decimal = Field(..., description="Monthly total")
|
||||
setup_total: Decimal = Field(..., description="Setup total")
|
||||
item_count: int = Field(..., description="Number of line items")
|
||||
submitted_at: Optional[datetime] = Field(None, description="Submission timestamp")
|
||||
created_at: datetime = Field(..., description="Creation timestamp")
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class QuoteListResponse(BaseModel):
|
||||
"""Schema for paginated quote list responses."""
|
||||
|
||||
total: int = Field(..., description="Total number of quotes matching filters")
|
||||
skip: int = Field(..., description="Number of records skipped")
|
||||
limit: int = Field(..., description="Maximum number of records returned")
|
||||
quotes: list[QuoteListItem] = Field(..., description="List of quotes")
|
||||
|
||||
|
||||
class QuoteStatsResponse(BaseModel):
|
||||
"""Schema for admin dashboard statistics."""
|
||||
|
||||
total_quotes: int = Field(..., description="Total number of quotes")
|
||||
quotes_by_status: dict[str, int] = Field(..., description="Quote count by status")
|
||||
total_monthly_value: Decimal = Field(..., description="Total monthly value of all submitted quotes")
|
||||
total_setup_value: Decimal = Field(..., description="Total setup value of all submitted quotes")
|
||||
quotes_this_month: int = Field(..., description="Quotes created this month")
|
||||
quotes_submitted_this_month: int = Field(..., description="Quotes submitted this month")
|
||||
average_monthly_value: Decimal = Field(..., description="Average monthly value per submitted quote")
|
||||
conversion_rate: Decimal = Field(..., description="Percentage of drafts that get submitted")
|
||||
@@ -11,6 +11,8 @@ from . import (
|
||||
credential_service,
|
||||
credential_audit_log_service,
|
||||
security_incident_service,
|
||||
quote_service,
|
||||
syncro_service,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -24,4 +26,6 @@ __all__ = [
|
||||
"credential_service",
|
||||
"credential_audit_log_service",
|
||||
"security_incident_service",
|
||||
"quote_service",
|
||||
"syncro_service",
|
||||
]
|
||||
|
||||
985
api/services/quote_service.py
Normal file
985
api/services/quote_service.py
Normal file
@@ -0,0 +1,985 @@
|
||||
"""
|
||||
Quote service layer for business logic and database operations.
|
||||
|
||||
This module handles all database operations for quotes, providing a clean
|
||||
separation between the API routes and data access layer.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from api.models.quote import (
|
||||
Quote,
|
||||
QuoteActivity,
|
||||
QuoteItem,
|
||||
QuoteNotification,
|
||||
QuoteStatus,
|
||||
BillingFrequency,
|
||||
)
|
||||
from api.schemas.quote import (
|
||||
QuoteCreate,
|
||||
QuoteUpdate,
|
||||
QuoteSubmit,
|
||||
QuoteItemCreate,
|
||||
QuoteAdminUpdate,
|
||||
QuoteListItem,
|
||||
QuoteStatsResponse,
|
||||
)
|
||||
from api.services.syncro_service import get_syncro_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def generate_access_token() -> str:
|
||||
"""
|
||||
Generate a secure, URL-safe access token for quote access.
|
||||
|
||||
Returns:
|
||||
str: A 43-character URL-safe token
|
||||
"""
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def calculate_totals(items: list[QuoteItem]) -> tuple[Decimal, Decimal, Decimal]:
|
||||
"""
|
||||
Calculate monthly, setup, and annual totals from quote items.
|
||||
|
||||
Args:
|
||||
items: List of QuoteItem objects
|
||||
|
||||
Returns:
|
||||
tuple: (monthly_total, setup_total, annual_total)
|
||||
"""
|
||||
monthly_total = Decimal("0.00")
|
||||
setup_total = Decimal("0.00")
|
||||
|
||||
for item in items:
|
||||
# Calculate line total
|
||||
line_total = item.unit_price * item.quantity
|
||||
|
||||
# Add to appropriate total based on billing frequency
|
||||
if item.billing_frequency == BillingFrequency.MONTHLY.value:
|
||||
monthly_total += line_total
|
||||
elif item.billing_frequency == BillingFrequency.QUARTERLY.value:
|
||||
monthly_total += line_total / Decimal("3")
|
||||
elif item.billing_frequency == BillingFrequency.ANNUAL.value:
|
||||
monthly_total += line_total / Decimal("12")
|
||||
# one_time items don't add to monthly
|
||||
|
||||
# Setup fees are always one-time
|
||||
setup_total += item.setup_fee
|
||||
|
||||
# Annual total is monthly * 12 + setup
|
||||
annual_total = (monthly_total * Decimal("12")) + setup_total
|
||||
|
||||
return monthly_total, setup_total, annual_total
|
||||
|
||||
|
||||
def log_activity(
|
||||
db: Session,
|
||||
quote_id: str,
|
||||
action: str,
|
||||
description: Optional[str] = None,
|
||||
actor: Optional[str] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
metadata: Optional[dict] = None
|
||||
) -> QuoteActivity:
|
||||
"""
|
||||
Log an activity for a quote.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
quote_id: UUID of the quote
|
||||
action: Action being performed
|
||||
description: Detailed description
|
||||
actor: Who performed the action
|
||||
ip_address: IP address of the actor
|
||||
metadata: Additional metadata as dict
|
||||
|
||||
Returns:
|
||||
QuoteActivity: The created activity record
|
||||
"""
|
||||
activity = QuoteActivity(
|
||||
quote_id=quote_id,
|
||||
action=action,
|
||||
description=description,
|
||||
actor=actor,
|
||||
ip_address=ip_address,
|
||||
metadata=json.dumps(metadata) if metadata else None
|
||||
)
|
||||
|
||||
db.add(activity)
|
||||
db.flush()
|
||||
|
||||
return activity
|
||||
|
||||
|
||||
def create_quote(
|
||||
db: Session,
|
||||
quote_data: QuoteCreate,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None
|
||||
) -> Quote:
|
||||
"""
|
||||
Create a new quote draft with access token.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
quote_data: Quote creation data
|
||||
ip_address: IP address of the requester
|
||||
user_agent: Browser user agent
|
||||
|
||||
Returns:
|
||||
Quote: The created quote object
|
||||
|
||||
Example:
|
||||
```python
|
||||
quote_data = QuoteCreate(employee_count=25)
|
||||
quote = create_quote(db, quote_data, ip_address="192.168.1.1")
|
||||
print(f"Quote created: {quote.access_token}")
|
||||
```
|
||||
"""
|
||||
try:
|
||||
# Create quote with unique access token
|
||||
quote = Quote(
|
||||
access_token=generate_access_token(),
|
||||
status=QuoteStatus.DRAFT.value,
|
||||
employee_count=quote_data.employee_count,
|
||||
notes=quote_data.notes,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
# Set expiration to 30 days from now
|
||||
expires_at=datetime.utcnow() + timedelta(days=30)
|
||||
)
|
||||
|
||||
db.add(quote)
|
||||
db.flush() # Get the quote ID
|
||||
|
||||
# Add initial items if provided
|
||||
if quote_data.items:
|
||||
for idx, item_data in enumerate(quote_data.items):
|
||||
item = QuoteItem(
|
||||
quote_id=quote.id,
|
||||
service_name=item_data.service_name,
|
||||
service_description=item_data.service_description,
|
||||
category=item_data.category.value,
|
||||
billing_frequency=item_data.billing_frequency.value,
|
||||
unit_price=item_data.unit_price,
|
||||
quantity=item_data.quantity,
|
||||
setup_fee=item_data.setup_fee,
|
||||
is_required=item_data.is_required,
|
||||
sort_order=item_data.sort_order if item_data.sort_order else idx
|
||||
)
|
||||
db.add(item)
|
||||
|
||||
db.flush()
|
||||
|
||||
# Calculate and update totals
|
||||
monthly, setup, annual = calculate_totals(quote.items)
|
||||
quote.monthly_total = monthly
|
||||
quote.setup_total = setup
|
||||
quote.annual_total = annual
|
||||
|
||||
# Log activity
|
||||
log_activity(
|
||||
db=db,
|
||||
quote_id=quote.id,
|
||||
action="created",
|
||||
description="Quote draft created",
|
||||
ip_address=ip_address,
|
||||
metadata={"employee_count": quote_data.employee_count}
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(quote)
|
||||
|
||||
return quote
|
||||
|
||||
except IntegrityError as e:
|
||||
db.rollback()
|
||||
# If token collision (extremely rare), retry once
|
||||
if "access_token" in str(e.orig):
|
||||
return create_quote(db, quote_data, ip_address, user_agent)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Database error: {str(e)}"
|
||||
)
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to create quote: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
def get_quote_by_token(db: Session, access_token: str) -> Quote:
|
||||
"""
|
||||
Retrieve a quote by its access token (public access).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
access_token: The quote's access token
|
||||
|
||||
Returns:
|
||||
Quote: The quote object with items loaded
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if quote not found
|
||||
"""
|
||||
quote = (
|
||||
db.query(Quote)
|
||||
.options(joinedload(Quote.items))
|
||||
.filter(Quote.access_token == access_token)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not quote:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Quote not found"
|
||||
)
|
||||
|
||||
# Check if expired
|
||||
if quote.expires_at and quote.expires_at < datetime.utcnow():
|
||||
if quote.status == QuoteStatus.DRAFT.value:
|
||||
quote.status = QuoteStatus.EXPIRED.value
|
||||
db.commit()
|
||||
|
||||
return quote
|
||||
|
||||
|
||||
def get_quote_by_id(db: Session, quote_id: UUID) -> Quote:
|
||||
"""
|
||||
Retrieve a quote by its ID (admin access).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
quote_id: UUID of the quote
|
||||
|
||||
Returns:
|
||||
Quote: The quote object with all related data loaded
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if quote not found
|
||||
"""
|
||||
quote = (
|
||||
db.query(Quote)
|
||||
.options(
|
||||
joinedload(Quote.items),
|
||||
joinedload(Quote.activities),
|
||||
joinedload(Quote.notifications)
|
||||
)
|
||||
.filter(Quote.id == str(quote_id))
|
||||
.first()
|
||||
)
|
||||
|
||||
if not quote:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Quote with ID {quote_id} not found"
|
||||
)
|
||||
|
||||
return quote
|
||||
|
||||
|
||||
def update_quote(
|
||||
db: Session,
|
||||
access_token: str,
|
||||
quote_data: QuoteUpdate,
|
||||
ip_address: Optional[str] = None
|
||||
) -> Quote:
|
||||
"""
|
||||
Update a quote (add/remove items, update details).
|
||||
|
||||
Only drafts can be updated. Replaces all items if items are provided.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
access_token: The quote's access token
|
||||
quote_data: Update data
|
||||
ip_address: IP address of the requester
|
||||
|
||||
Returns:
|
||||
Quote: The updated quote object
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if not found, 400 if not a draft
|
||||
"""
|
||||
quote = get_quote_by_token(db, access_token)
|
||||
|
||||
# Only drafts can be updated
|
||||
if quote.status != QuoteStatus.DRAFT.value:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Cannot update quote with status '{quote.status}'. Only drafts can be modified."
|
||||
)
|
||||
|
||||
try:
|
||||
# Update basic fields if provided
|
||||
update_data = quote_data.model_dump(exclude_unset=True, exclude={"items"})
|
||||
changes = []
|
||||
|
||||
for field, value in update_data.items():
|
||||
old_value = getattr(quote, field)
|
||||
if old_value != value:
|
||||
setattr(quote, field, value)
|
||||
changes.append(f"{field}: {old_value} -> {value}")
|
||||
|
||||
# Replace items if provided
|
||||
if quote_data.items is not None:
|
||||
# Remove existing items
|
||||
db.query(QuoteItem).filter(QuoteItem.quote_id == quote.id).delete()
|
||||
|
||||
# Add new items
|
||||
for idx, item_data in enumerate(quote_data.items):
|
||||
item = QuoteItem(
|
||||
quote_id=quote.id,
|
||||
service_name=item_data.service_name,
|
||||
service_description=item_data.service_description,
|
||||
category=item_data.category.value,
|
||||
billing_frequency=item_data.billing_frequency.value,
|
||||
unit_price=item_data.unit_price,
|
||||
quantity=item_data.quantity,
|
||||
setup_fee=item_data.setup_fee,
|
||||
is_required=item_data.is_required,
|
||||
sort_order=item_data.sort_order if item_data.sort_order else idx
|
||||
)
|
||||
db.add(item)
|
||||
|
||||
changes.append(f"items: replaced with {len(quote_data.items)} items")
|
||||
|
||||
db.flush()
|
||||
|
||||
# Recalculate totals
|
||||
db.refresh(quote)
|
||||
monthly, setup, annual = calculate_totals(quote.items)
|
||||
quote.monthly_total = monthly
|
||||
quote.setup_total = setup
|
||||
quote.annual_total = annual
|
||||
|
||||
# Log activity
|
||||
if changes:
|
||||
log_activity(
|
||||
db=db,
|
||||
quote_id=quote.id,
|
||||
action="updated",
|
||||
description=f"Quote updated: {', '.join(changes)}",
|
||||
ip_address=ip_address
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(quote)
|
||||
|
||||
return quote
|
||||
|
||||
except HTTPException:
|
||||
db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to update quote: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
def submit_quote(
|
||||
db: Session,
|
||||
access_token: str,
|
||||
submit_data: QuoteSubmit,
|
||||
ip_address: Optional[str] = None
|
||||
) -> Quote:
|
||||
"""
|
||||
Submit a quote with contact information.
|
||||
|
||||
Transitions quote from draft to submitted status.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
access_token: The quote's access token
|
||||
submit_data: Submission data with required contact info
|
||||
ip_address: IP address of the requester
|
||||
|
||||
Returns:
|
||||
Quote: The submitted quote object
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if not found, 400 if not a draft or no items
|
||||
"""
|
||||
quote = get_quote_by_token(db, access_token)
|
||||
|
||||
# Only drafts can be submitted
|
||||
if quote.status != QuoteStatus.DRAFT.value:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Cannot submit quote with status '{quote.status}'. Only drafts can be submitted."
|
||||
)
|
||||
|
||||
# Must have at least one item
|
||||
if not quote.items:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot submit quote without any items. Please add at least one service."
|
||||
)
|
||||
|
||||
try:
|
||||
# Update contact information
|
||||
quote.company_name = submit_data.company_name
|
||||
quote.contact_name = submit_data.contact_name
|
||||
quote.contact_email = submit_data.contact_email
|
||||
quote.contact_phone = submit_data.contact_phone
|
||||
if submit_data.notes:
|
||||
quote.notes = submit_data.notes
|
||||
|
||||
# Update status and timestamp
|
||||
quote.status = QuoteStatus.SUBMITTED.value
|
||||
quote.submitted_at = datetime.utcnow()
|
||||
|
||||
# Extend expiration to 90 days from submission
|
||||
quote.expires_at = datetime.utcnow() + timedelta(days=90)
|
||||
|
||||
# Log activity
|
||||
log_activity(
|
||||
db=db,
|
||||
quote_id=quote.id,
|
||||
action="submitted",
|
||||
description=f"Quote submitted by {submit_data.contact_name} ({submit_data.contact_email})",
|
||||
actor=submit_data.contact_email,
|
||||
ip_address=ip_address,
|
||||
metadata={
|
||||
"company_name": submit_data.company_name,
|
||||
"contact_email": submit_data.contact_email,
|
||||
"monthly_total": str(quote.monthly_total),
|
||||
"setup_total": str(quote.setup_total)
|
||||
}
|
||||
)
|
||||
|
||||
# Create admin notification record (actual sending would be handled elsewhere)
|
||||
notification = QuoteNotification(
|
||||
quote_id=quote.id,
|
||||
notification_type="admin_alert",
|
||||
recipient="admin@example.com", # Would come from config in production
|
||||
subject=f"New Quote Submission: {submit_data.company_name}",
|
||||
content=f"Quote submitted by {submit_data.contact_name}. Monthly: ${quote.monthly_total}",
|
||||
status="pending"
|
||||
)
|
||||
db.add(notification)
|
||||
|
||||
db.commit()
|
||||
db.refresh(quote)
|
||||
|
||||
return quote
|
||||
|
||||
except HTTPException:
|
||||
db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to submit quote: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
async def sync_quote_to_syncro(db: Session, quote: Quote) -> dict:
|
||||
"""
|
||||
Sync a submitted quote to SyncroRMM.
|
||||
|
||||
Checks for existing customer and creates a lead in Syncro. Updates the
|
||||
quote with sync status and existing customer flag.
|
||||
|
||||
This function is designed to be called after submit_quote() completes,
|
||||
typically as a background task or in the API endpoint. It handles all
|
||||
Syncro API errors gracefully to avoid blocking the quote submission.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
quote: The submitted quote object (must have contact_email)
|
||||
|
||||
Returns:
|
||||
dict: Sync result with keys:
|
||||
- synced: bool - Whether lead was created successfully
|
||||
- is_existing_customer: bool - Whether customer already exists
|
||||
- syncro_lead_id: str|None - Lead ID if created
|
||||
- error: str|None - Error message if sync failed
|
||||
|
||||
Example:
|
||||
```python
|
||||
quote = submit_quote(db, access_token, submit_data, ip_address)
|
||||
sync_result = await sync_quote_to_syncro(db, quote)
|
||||
if sync_result["synced"]:
|
||||
print(f"Lead created: {sync_result['syncro_lead_id']}")
|
||||
```
|
||||
"""
|
||||
result = {
|
||||
"synced": False,
|
||||
"is_existing_customer": False,
|
||||
"syncro_lead_id": None,
|
||||
"error": None
|
||||
}
|
||||
|
||||
if not quote.contact_email:
|
||||
result["error"] = "Quote has no contact email"
|
||||
return result
|
||||
|
||||
try:
|
||||
syncro = get_syncro_service()
|
||||
|
||||
# Check for existing customer
|
||||
customer_check = await syncro.check_existing_customer(
|
||||
email=quote.contact_email,
|
||||
business_name=quote.company_name
|
||||
)
|
||||
|
||||
if customer_check.exists:
|
||||
quote.is_existing_customer = True
|
||||
result["is_existing_customer"] = True
|
||||
|
||||
logger.info(
|
||||
f"Quote {quote.id} is from existing customer: "
|
||||
f"{customer_check.customer_name} (ID: {customer_check.customer_id}, "
|
||||
f"match: {customer_check.match_type})"
|
||||
)
|
||||
|
||||
# Log activity for existing customer
|
||||
log_activity(
|
||||
db=db,
|
||||
quote_id=quote.id,
|
||||
action="syncro_customer_found",
|
||||
description=f"Existing Syncro customer found: {customer_check.customer_name}",
|
||||
metadata={
|
||||
"syncro_customer_id": customer_check.customer_id,
|
||||
"match_type": customer_check.match_type
|
||||
}
|
||||
)
|
||||
|
||||
# Create lead in Syncro
|
||||
lead_result = await syncro.create_lead(quote)
|
||||
|
||||
if lead_result.success:
|
||||
quote.syncro_lead_id = lead_result.lead_id
|
||||
quote.syncro_synced_at = datetime.utcnow()
|
||||
result["synced"] = True
|
||||
result["syncro_lead_id"] = lead_result.lead_id
|
||||
|
||||
# Log activity for successful sync
|
||||
log_activity(
|
||||
db=db,
|
||||
quote_id=quote.id,
|
||||
action="syncro_lead_created",
|
||||
description=f"Lead created in Syncro: {lead_result.lead_id}",
|
||||
metadata={
|
||||
"syncro_lead_id": lead_result.lead_id,
|
||||
"is_existing_customer": customer_check.exists
|
||||
}
|
||||
)
|
||||
else:
|
||||
result["error"] = lead_result.error
|
||||
logger.warning(
|
||||
f"Failed to create Syncro lead for quote {quote.id}: {lead_result.error}"
|
||||
)
|
||||
|
||||
# Log activity for failed sync
|
||||
log_activity(
|
||||
db=db,
|
||||
quote_id=quote.id,
|
||||
action="syncro_sync_failed",
|
||||
description=f"Failed to sync to Syncro: {lead_result.error}",
|
||||
metadata={"error": lead_result.error}
|
||||
)
|
||||
|
||||
# Commit the updates to quote
|
||||
db.commit()
|
||||
db.refresh(quote)
|
||||
|
||||
except Exception as e:
|
||||
# Log error but don't fail the overall operation
|
||||
error_msg = str(e)
|
||||
result["error"] = error_msg
|
||||
logger.error(
|
||||
f"Unexpected error syncing quote {quote.id} to Syncro: {error_msg}",
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
try:
|
||||
log_activity(
|
||||
db=db,
|
||||
quote_id=quote.id,
|
||||
action="syncro_sync_error",
|
||||
description=f"Syncro sync error: {error_msg}",
|
||||
metadata={"error": error_msg}
|
||||
)
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def list_quotes(
|
||||
db: Session,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
status_filter: Optional[str] = None,
|
||||
search: Optional[str] = None
|
||||
) -> tuple[list[Quote], int]:
|
||||
"""
|
||||
List quotes with pagination and optional filters (admin).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
skip: Number of records to skip
|
||||
limit: Maximum number of records to return
|
||||
status_filter: Filter by status
|
||||
search: Search in company_name, contact_name, contact_email
|
||||
|
||||
Returns:
|
||||
tuple: (list of quotes, total count)
|
||||
"""
|
||||
query = db.query(Quote).options(joinedload(Quote.items))
|
||||
|
||||
# Apply filters
|
||||
if status_filter:
|
||||
query = query.filter(Quote.status == status_filter)
|
||||
|
||||
if search:
|
||||
search_term = f"%{search}%"
|
||||
query = query.filter(
|
||||
(Quote.company_name.ilike(search_term)) |
|
||||
(Quote.contact_name.ilike(search_term)) |
|
||||
(Quote.contact_email.ilike(search_term))
|
||||
)
|
||||
|
||||
# Get total count before pagination
|
||||
total = query.count()
|
||||
|
||||
# Apply pagination and ordering
|
||||
quotes = (
|
||||
query
|
||||
.order_by(Quote.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
return quotes, total
|
||||
|
||||
|
||||
def update_quote_status(
|
||||
db: Session,
|
||||
quote_id: UUID,
|
||||
update_data: QuoteAdminUpdate,
|
||||
admin_user: str
|
||||
) -> Quote:
|
||||
"""
|
||||
Update quote status and admin notes (admin).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
quote_id: UUID of the quote
|
||||
update_data: Admin update data
|
||||
admin_user: Username of the admin making the change
|
||||
|
||||
Returns:
|
||||
Quote: The updated quote object
|
||||
"""
|
||||
quote = get_quote_by_id(db, quote_id)
|
||||
|
||||
try:
|
||||
changes = []
|
||||
|
||||
if update_data.status is not None and update_data.status.value != quote.status:
|
||||
old_status = quote.status
|
||||
quote.status = update_data.status.value
|
||||
changes.append(f"status: {old_status} -> {update_data.status.value}")
|
||||
|
||||
if update_data.admin_notes is not None:
|
||||
quote.admin_notes = update_data.admin_notes
|
||||
changes.append("admin_notes updated")
|
||||
|
||||
if update_data.expires_at is not None:
|
||||
quote.expires_at = update_data.expires_at
|
||||
changes.append(f"expires_at: {update_data.expires_at}")
|
||||
|
||||
# Log activity
|
||||
if changes:
|
||||
log_activity(
|
||||
db=db,
|
||||
quote_id=quote.id,
|
||||
action="admin_update",
|
||||
description=f"Admin update: {', '.join(changes)}",
|
||||
actor=admin_user
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(quote)
|
||||
|
||||
return quote
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to update quote status: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
def get_quote_stats(db: Session) -> QuoteStatsResponse:
|
||||
"""
|
||||
Get dashboard statistics for quotes (admin).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
QuoteStatsResponse: Statistics about quotes
|
||||
"""
|
||||
# Total quotes
|
||||
total_quotes = db.query(Quote).count()
|
||||
|
||||
# Quotes by status
|
||||
status_counts = (
|
||||
db.query(Quote.status, func.count(Quote.id))
|
||||
.group_by(Quote.status)
|
||||
.all()
|
||||
)
|
||||
quotes_by_status = {status: count for status, count in status_counts}
|
||||
|
||||
# Total values for submitted quotes
|
||||
submitted_statuses = [
|
||||
QuoteStatus.SUBMITTED.value,
|
||||
QuoteStatus.REVIEWING.value,
|
||||
QuoteStatus.APPROVED.value
|
||||
]
|
||||
value_query = (
|
||||
db.query(
|
||||
func.sum(Quote.monthly_total),
|
||||
func.sum(Quote.setup_total)
|
||||
)
|
||||
.filter(Quote.status.in_(submitted_statuses))
|
||||
.first()
|
||||
)
|
||||
total_monthly_value = value_query[0] or Decimal("0.00")
|
||||
total_setup_value = value_query[1] or Decimal("0.00")
|
||||
|
||||
# Quotes this month
|
||||
month_start = datetime.utcnow().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
quotes_this_month = (
|
||||
db.query(Quote)
|
||||
.filter(Quote.created_at >= month_start)
|
||||
.count()
|
||||
)
|
||||
|
||||
# Quotes submitted this month
|
||||
quotes_submitted_this_month = (
|
||||
db.query(Quote)
|
||||
.filter(
|
||||
Quote.submitted_at >= month_start,
|
||||
Quote.submitted_at.isnot(None)
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
# Calculate averages and conversion rate
|
||||
submitted_count = sum(
|
||||
quotes_by_status.get(s, 0)
|
||||
for s in submitted_statuses
|
||||
)
|
||||
|
||||
average_monthly_value = (
|
||||
total_monthly_value / submitted_count
|
||||
if submitted_count > 0
|
||||
else Decimal("0.00")
|
||||
)
|
||||
|
||||
draft_count = quotes_by_status.get(QuoteStatus.DRAFT.value, 0)
|
||||
total_started = draft_count + submitted_count
|
||||
conversion_rate = (
|
||||
(Decimal(submitted_count) / Decimal(total_started) * Decimal("100"))
|
||||
if total_started > 0
|
||||
else Decimal("0.00")
|
||||
)
|
||||
|
||||
return QuoteStatsResponse(
|
||||
total_quotes=total_quotes,
|
||||
quotes_by_status=quotes_by_status,
|
||||
total_monthly_value=total_monthly_value,
|
||||
total_setup_value=total_setup_value,
|
||||
quotes_this_month=quotes_this_month,
|
||||
quotes_submitted_this_month=quotes_submitted_this_month,
|
||||
average_monthly_value=round(average_monthly_value, 2),
|
||||
conversion_rate=round(conversion_rate, 2)
|
||||
)
|
||||
|
||||
|
||||
def add_item_to_quote(
|
||||
db: Session,
|
||||
access_token: str,
|
||||
item_data: QuoteItemCreate,
|
||||
ip_address: Optional[str] = None
|
||||
) -> Quote:
|
||||
"""
|
||||
Add a single item to a quote.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
access_token: The quote's access token
|
||||
item_data: Item data to add
|
||||
ip_address: IP address of the requester
|
||||
|
||||
Returns:
|
||||
Quote: The updated quote object
|
||||
"""
|
||||
quote = get_quote_by_token(db, access_token)
|
||||
|
||||
if quote.status != QuoteStatus.DRAFT.value:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot add items to a non-draft quote"
|
||||
)
|
||||
|
||||
try:
|
||||
# Get next sort order
|
||||
max_order = (
|
||||
db.query(func.max(QuoteItem.sort_order))
|
||||
.filter(QuoteItem.quote_id == quote.id)
|
||||
.scalar()
|
||||
) or 0
|
||||
|
||||
item = QuoteItem(
|
||||
quote_id=quote.id,
|
||||
service_name=item_data.service_name,
|
||||
service_description=item_data.service_description,
|
||||
category=item_data.category.value,
|
||||
billing_frequency=item_data.billing_frequency.value,
|
||||
unit_price=item_data.unit_price,
|
||||
quantity=item_data.quantity,
|
||||
setup_fee=item_data.setup_fee,
|
||||
is_required=item_data.is_required,
|
||||
sort_order=item_data.sort_order if item_data.sort_order else max_order + 1
|
||||
)
|
||||
db.add(item)
|
||||
db.flush()
|
||||
|
||||
# Recalculate totals
|
||||
db.refresh(quote)
|
||||
monthly, setup, annual = calculate_totals(quote.items)
|
||||
quote.monthly_total = monthly
|
||||
quote.setup_total = setup
|
||||
quote.annual_total = annual
|
||||
|
||||
# Log activity
|
||||
log_activity(
|
||||
db=db,
|
||||
quote_id=quote.id,
|
||||
action="item_added",
|
||||
description=f"Added item: {item_data.service_name}",
|
||||
ip_address=ip_address
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(quote)
|
||||
|
||||
return quote
|
||||
|
||||
except HTTPException:
|
||||
db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to add item: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
def remove_item_from_quote(
|
||||
db: Session,
|
||||
access_token: str,
|
||||
item_id: UUID,
|
||||
ip_address: Optional[str] = None
|
||||
) -> Quote:
|
||||
"""
|
||||
Remove an item from a quote.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
access_token: The quote's access token
|
||||
item_id: UUID of the item to remove
|
||||
ip_address: IP address of the requester
|
||||
|
||||
Returns:
|
||||
Quote: The updated quote object
|
||||
"""
|
||||
quote = get_quote_by_token(db, access_token)
|
||||
|
||||
if quote.status != QuoteStatus.DRAFT.value:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot remove items from a non-draft quote"
|
||||
)
|
||||
|
||||
# Find the item
|
||||
item = (
|
||||
db.query(QuoteItem)
|
||||
.filter(QuoteItem.id == str(item_id), QuoteItem.quote_id == quote.id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not item:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Item with ID {item_id} not found in this quote"
|
||||
)
|
||||
|
||||
if item.is_required:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot remove required items from the quote"
|
||||
)
|
||||
|
||||
try:
|
||||
item_name = item.service_name
|
||||
db.delete(item)
|
||||
db.flush()
|
||||
|
||||
# Recalculate totals
|
||||
db.refresh(quote)
|
||||
monthly, setup, annual = calculate_totals(quote.items)
|
||||
quote.monthly_total = monthly
|
||||
quote.setup_total = setup
|
||||
quote.annual_total = annual
|
||||
|
||||
# Log activity
|
||||
log_activity(
|
||||
db=db,
|
||||
quote_id=quote.id,
|
||||
action="item_removed",
|
||||
description=f"Removed item: {item_name}",
|
||||
ip_address=ip_address
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(quote)
|
||||
|
||||
return quote
|
||||
|
||||
except HTTPException:
|
||||
db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to remove item: {str(e)}"
|
||||
)
|
||||
445
api/services/syncro_service.py
Normal file
445
api/services/syncro_service.py
Normal file
@@ -0,0 +1,445 @@
|
||||
"""
|
||||
SyncroRMM integration service for Quote Wizard.
|
||||
|
||||
This module handles all interactions with the SyncroRMM API for lead creation
|
||||
and customer duplicate detection when quotes are submitted.
|
||||
|
||||
API Documentation: https://api-docs.syncromsp.com/
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from api.models.quote import Quote
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# TODO: Move to environment variables or secure configuration for production
|
||||
SYNCRO_API_BASE_URL = "https://computerguru.syncromsp.com/api/v1"
|
||||
SYNCRO_API_KEY = "T259810e5c9917386b-52c2aeea7cdb5ff41c6685a73cebbeb3"
|
||||
|
||||
# HTTP client configuration
|
||||
SYNCRO_TIMEOUT_SECONDS = 30.0
|
||||
SYNCRO_CONNECT_TIMEOUT_SECONDS = 10.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class CustomerCheckResult:
|
||||
"""Result of checking for an existing customer in Syncro."""
|
||||
exists: bool
|
||||
customer_id: Optional[str] = None
|
||||
customer_name: Optional[str] = None
|
||||
match_type: Optional[str] = None # 'email' or 'business_name'
|
||||
|
||||
|
||||
@dataclass
|
||||
class LeadCreationResult:
|
||||
"""Result of creating a lead in Syncro."""
|
||||
success: bool
|
||||
lead_id: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class SyncroService:
|
||||
"""
|
||||
Service for interacting with the SyncroRMM API.
|
||||
|
||||
Handles customer duplicate checking and lead creation for the Quote Wizard.
|
||||
All API calls are made asynchronously to avoid blocking quote submission.
|
||||
|
||||
Example:
|
||||
```python
|
||||
syncro = SyncroService()
|
||||
|
||||
# Check for existing customer
|
||||
result = await syncro.check_existing_customer(
|
||||
email="contact@company.com",
|
||||
business_name="Company Inc"
|
||||
)
|
||||
if result.exists:
|
||||
print(f"Customer exists: {result.customer_name}")
|
||||
|
||||
# Create lead from quote
|
||||
lead_result = await syncro.create_lead(quote)
|
||||
if lead_result.success:
|
||||
print(f"Lead created: {lead_result.lead_id}")
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_base_url: str = SYNCRO_API_BASE_URL,
|
||||
api_key: str = SYNCRO_API_KEY,
|
||||
timeout: float = SYNCRO_TIMEOUT_SECONDS,
|
||||
connect_timeout: float = SYNCRO_CONNECT_TIMEOUT_SECONDS
|
||||
):
|
||||
"""
|
||||
Initialize the SyncroService.
|
||||
|
||||
Args:
|
||||
api_base_url: Base URL for the Syncro API
|
||||
api_key: API key for authentication
|
||||
timeout: Total request timeout in seconds
|
||||
connect_timeout: Connection timeout in seconds
|
||||
"""
|
||||
self.api_base_url = api_base_url.rstrip('/')
|
||||
self.api_key = api_key
|
||||
self.timeout = httpx.Timeout(timeout, connect=connect_timeout)
|
||||
|
||||
def _get_client(self) -> httpx.AsyncClient:
|
||||
"""
|
||||
Create an async HTTP client with configured settings.
|
||||
|
||||
Returns:
|
||||
httpx.AsyncClient: Configured HTTP client
|
||||
"""
|
||||
return httpx.AsyncClient(
|
||||
timeout=self.timeout,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json"
|
||||
}
|
||||
)
|
||||
|
||||
def _build_url(self, endpoint: str, **params) -> str:
|
||||
"""
|
||||
Build a full API URL with the api_key parameter.
|
||||
|
||||
Args:
|
||||
endpoint: API endpoint path (e.g., '/customers')
|
||||
**params: Additional query parameters
|
||||
|
||||
Returns:
|
||||
str: Full URL with query parameters
|
||||
"""
|
||||
url = f"{self.api_base_url}{endpoint}"
|
||||
query_params = {"api_key": self.api_key, **params}
|
||||
|
||||
# Build query string
|
||||
query_string = "&".join(
|
||||
f"{key}={httpx.URL('').copy_with(params={key: str(value)}).params[key]}"
|
||||
for key, value in query_params.items()
|
||||
if value is not None
|
||||
)
|
||||
|
||||
return f"{url}?{query_string}"
|
||||
|
||||
async def check_existing_customer(
|
||||
self,
|
||||
email: str,
|
||||
business_name: Optional[str] = None
|
||||
) -> CustomerCheckResult:
|
||||
"""
|
||||
Check if a customer already exists in Syncro.
|
||||
|
||||
Performs a two-stage check:
|
||||
1. Search by email address (primary)
|
||||
2. Search by business name if no email match (secondary)
|
||||
|
||||
Args:
|
||||
email: Contact email address to search for
|
||||
business_name: Optional business name for secondary search
|
||||
|
||||
Returns:
|
||||
CustomerCheckResult: Object containing match status and details
|
||||
|
||||
Note:
|
||||
This method handles errors gracefully and returns a non-match
|
||||
result if the API is unavailable, to avoid blocking quote submission.
|
||||
"""
|
||||
async with self._get_client() as client:
|
||||
# First, check by email
|
||||
try:
|
||||
email_result = await self._search_customers_by_email(client, email)
|
||||
if email_result.exists:
|
||||
return email_result
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Syncro email search failed for {email}: {e}",
|
||||
exc_info=True
|
||||
)
|
||||
# Continue to business name search if email search fails
|
||||
|
||||
# If no email match, try business name
|
||||
if business_name:
|
||||
try:
|
||||
name_result = await self._search_customers_by_business_name(
|
||||
client, business_name
|
||||
)
|
||||
if name_result.exists:
|
||||
return name_result
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Syncro business name search failed for {business_name}: {e}",
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
# No matches found
|
||||
return CustomerCheckResult(exists=False)
|
||||
|
||||
async def _search_customers_by_email(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
email: str
|
||||
) -> CustomerCheckResult:
|
||||
"""
|
||||
Search for customers by email address.
|
||||
|
||||
Args:
|
||||
client: HTTP client instance
|
||||
email: Email address to search for
|
||||
|
||||
Returns:
|
||||
CustomerCheckResult: Match result
|
||||
|
||||
Raises:
|
||||
httpx.HTTPError: If the API request fails
|
||||
"""
|
||||
url = self._build_url("/customers", email=email)
|
||||
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
customers = data.get("customers", [])
|
||||
|
||||
if customers:
|
||||
customer = customers[0]
|
||||
return CustomerCheckResult(
|
||||
exists=True,
|
||||
customer_id=str(customer.get("id")),
|
||||
customer_name=customer.get("business_name") or customer.get("fullname"),
|
||||
match_type="email"
|
||||
)
|
||||
|
||||
return CustomerCheckResult(exists=False)
|
||||
|
||||
async def _search_customers_by_business_name(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
business_name: str
|
||||
) -> CustomerCheckResult:
|
||||
"""
|
||||
Search for customers by business name.
|
||||
|
||||
Args:
|
||||
client: HTTP client instance
|
||||
business_name: Business name to search for
|
||||
|
||||
Returns:
|
||||
CustomerCheckResult: Match result
|
||||
|
||||
Raises:
|
||||
httpx.HTTPError: If the API request fails
|
||||
"""
|
||||
url = self._build_url("/customers", business_name=business_name)
|
||||
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
customers = data.get("customers", [])
|
||||
|
||||
if customers:
|
||||
# Look for exact match or very close match
|
||||
normalized_search = business_name.lower().strip()
|
||||
for customer in customers:
|
||||
customer_name = customer.get("business_name", "").lower().strip()
|
||||
if customer_name == normalized_search:
|
||||
return CustomerCheckResult(
|
||||
exists=True,
|
||||
customer_id=str(customer.get("id")),
|
||||
customer_name=customer.get("business_name"),
|
||||
match_type="business_name"
|
||||
)
|
||||
|
||||
return CustomerCheckResult(exists=False)
|
||||
|
||||
async def create_lead(self, quote: "Quote") -> LeadCreationResult:
|
||||
"""
|
||||
Create a lead in Syncro from a submitted quote.
|
||||
|
||||
Builds a formatted lead with quote details in the notes field.
|
||||
|
||||
Args:
|
||||
quote: Quote object with contact info and items
|
||||
|
||||
Returns:
|
||||
LeadCreationResult: Object containing success status and lead ID
|
||||
|
||||
Note:
|
||||
This method handles errors gracefully to avoid blocking quote
|
||||
submission. Errors are logged but not raised.
|
||||
"""
|
||||
if not quote.contact_email:
|
||||
return LeadCreationResult(
|
||||
success=False,
|
||||
error="Quote has no contact email"
|
||||
)
|
||||
|
||||
# Parse contact name into first/last
|
||||
first_name, last_name = self._parse_contact_name(quote.contact_name or "")
|
||||
|
||||
# Build formatted notes with quote summary
|
||||
notes = self._build_quote_summary(quote)
|
||||
|
||||
lead_data = {
|
||||
"business_name": quote.company_name or "",
|
||||
"first_name": first_name,
|
||||
"last_name": last_name,
|
||||
"email": quote.contact_email,
|
||||
"phone": quote.contact_phone or "",
|
||||
"address": "",
|
||||
"referred_by": "Website Quote Tool",
|
||||
"status": "New",
|
||||
"notes": notes
|
||||
}
|
||||
|
||||
try:
|
||||
async with self._get_client() as client:
|
||||
url = self._build_url("/leads")
|
||||
|
||||
response = await client.post(url, json=lead_data)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
lead_id = str(data.get("lead", {}).get("id", ""))
|
||||
|
||||
if lead_id:
|
||||
logger.info(
|
||||
f"Created Syncro lead {lead_id} for quote {quote.id}"
|
||||
)
|
||||
return LeadCreationResult(success=True, lead_id=lead_id)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Syncro lead creation returned no ID for quote {quote.id}"
|
||||
)
|
||||
return LeadCreationResult(
|
||||
success=False,
|
||||
error="No lead ID returned from Syncro"
|
||||
)
|
||||
|
||||
except httpx.TimeoutException as e:
|
||||
error_msg = f"Syncro API timeout: {e}"
|
||||
logger.error(f"{error_msg} for quote {quote.id}")
|
||||
return LeadCreationResult(success=False, error=error_msg)
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
error_msg = f"Syncro API error {e.response.status_code}: {e.response.text}"
|
||||
logger.error(f"{error_msg} for quote {quote.id}")
|
||||
return LeadCreationResult(success=False, error=error_msg)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Unexpected error: {str(e)}"
|
||||
logger.error(f"{error_msg} for quote {quote.id}", exc_info=True)
|
||||
return LeadCreationResult(success=False, error=error_msg)
|
||||
|
||||
def _parse_contact_name(self, full_name: str) -> tuple[str, str]:
|
||||
"""
|
||||
Parse a full name into first and last name components.
|
||||
|
||||
Args:
|
||||
full_name: Full contact name
|
||||
|
||||
Returns:
|
||||
tuple: (first_name, last_name)
|
||||
"""
|
||||
parts = full_name.strip().split(maxsplit=1)
|
||||
|
||||
if len(parts) == 0:
|
||||
return ("", "")
|
||||
elif len(parts) == 1:
|
||||
return (parts[0], "")
|
||||
else:
|
||||
return (parts[0], parts[1])
|
||||
|
||||
def _build_quote_summary(self, quote: "Quote") -> str:
|
||||
"""
|
||||
Build formatted notes from quote items for Syncro lead.
|
||||
|
||||
Creates a human-readable summary of the quote including:
|
||||
- Quote reference number
|
||||
- Monthly and setup totals
|
||||
- List of selected services with pricing
|
||||
- Customer notes if provided
|
||||
|
||||
Args:
|
||||
quote: Quote object with items
|
||||
|
||||
Returns:
|
||||
str: Formatted notes string for Syncro lead
|
||||
"""
|
||||
lines = []
|
||||
|
||||
# Quote reference
|
||||
access_token_short = quote.access_token[:8] if quote.access_token else "N/A"
|
||||
lines.append(f"Quote #{access_token_short}")
|
||||
lines.append("")
|
||||
|
||||
# Totals
|
||||
monthly_total = quote.monthly_total or Decimal("0.00")
|
||||
setup_total = quote.setup_total or Decimal("0.00")
|
||||
|
||||
lines.append(f"Monthly: ${monthly_total:,.2f}")
|
||||
if setup_total > 0:
|
||||
lines.append(f"Setup: ${setup_total:,.2f}")
|
||||
lines.append("")
|
||||
|
||||
# Services
|
||||
lines.append("Services:")
|
||||
|
||||
if hasattr(quote, 'items') and quote.items:
|
||||
for item in sorted(quote.items, key=lambda x: x.sort_order or 0):
|
||||
quantity = item.quantity or 1
|
||||
unit_price = item.unit_price or Decimal("0.00")
|
||||
line_total = quantity * unit_price
|
||||
|
||||
if quantity > 1:
|
||||
lines.append(
|
||||
f"- {item.service_name} ({quantity} x ${unit_price:,.2f}): "
|
||||
f"${line_total:,.2f}/mo"
|
||||
)
|
||||
else:
|
||||
lines.append(f"- {item.service_name}: ${unit_price:,.2f}/mo")
|
||||
|
||||
# Add setup fee if present
|
||||
if item.setup_fee and item.setup_fee > 0:
|
||||
lines.append(f" Setup: ${item.setup_fee:,.2f}")
|
||||
else:
|
||||
lines.append("- No items")
|
||||
|
||||
# Employee count
|
||||
if quote.employee_count:
|
||||
lines.append("")
|
||||
lines.append(f"Employees/Users: {quote.employee_count}")
|
||||
|
||||
# Customer notes
|
||||
if quote.notes:
|
||||
lines.append("")
|
||||
lines.append("Customer Notes:")
|
||||
lines.append(quote.notes)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# Singleton instance for convenience
|
||||
_syncro_service: Optional[SyncroService] = None
|
||||
|
||||
|
||||
def get_syncro_service() -> SyncroService:
|
||||
"""
|
||||
Get or create the singleton SyncroService instance.
|
||||
|
||||
Returns:
|
||||
SyncroService: The service instance
|
||||
"""
|
||||
global _syncro_service
|
||||
if _syncro_service is None:
|
||||
_syncro_service = SyncroService()
|
||||
return _syncro_service
|
||||
Reference in New Issue
Block a user