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:
2026-03-09 08:14:13 -07:00
parent f81872784b
commit a1a19f8c00
59 changed files with 14435 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

View 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)}"
)

View 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