sync: Auto-sync from ACG-M-L5090 at 2026-03-10 19:11:00

Synced files:
- Quote wizard frontend (all components, hooks, types, config)
- API updates (config, models, routers, schemas, services)
- Client work (bg-builders, gurushow)
- Scripts (BGB Lesley termination, CIPP, Datto, migration)
- Temp files (Bardach contacts, VWP investigation, misc)
- Credentials and session logs
- Email service, PHP API, session logs

Machine: ACG-M-L5090
Timestamp: 2026-03-10 19:11:00

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 19:59:08 -07:00
parent a1a19f8c00
commit fa15b03180
169 changed files with 879909 additions and 1243 deletions

View File

@@ -46,6 +46,13 @@ class Settings(BaseSettings):
# API configuration
ALLOWED_ORIGINS: str = "*"
# Microsoft Graph API (Email via M365)
GRAPH_TENANT_ID: str = ""
GRAPH_CLIENT_ID: str = ""
GRAPH_CLIENT_SECRET: str = ""
GRAPH_SENDER_EMAIL: str = "noreply@azcomputerguru.com"
ADMIN_NOTIFICATION_EMAIL: str = "mike@azcomputerguru.com"
class Config:
"""Pydantic configuration."""

View File

@@ -22,6 +22,7 @@ from sqlalchemy import (
Numeric,
String,
Text,
func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -32,38 +33,34 @@ class QuoteStatus(str, PyEnum):
"""Status options for quotes."""
DRAFT = "draft"
SUBMITTED = "submitted"
REVIEWING = "reviewing"
APPROVED = "approved"
REJECTED = "rejected"
VIEWED = "viewed"
FOLLOWED_UP = "followed_up"
CONVERTED = "converted"
EXPIRED = "expired"
class ServiceCategory(str, PyEnum):
"""Service category options for quote items."""
MANAGED_SERVICES = "managed_services"
SECURITY = "security"
BACKUP = "backup"
CLOUD = "cloud"
GPS_MONITORING = "gps_monitoring"
SUPPORT_PLAN = "support_plan"
VOIP = "voip"
WEB_HOSTING = "web_hosting"
EMAIL = "email"
HARDWARE = "hardware"
SOFTWARE = "software"
CONSULTING = "consulting"
SUPPORT = "support"
ADDON = "addon"
class BillingFrequency(str, PyEnum):
"""Billing frequency options for quote items."""
MONTHLY = "monthly"
QUARTERLY = "quarterly"
ANNUAL = "annual"
YEARLY = "yearly"
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"
EMAIL = "email"
WEBHOOK = "webhook"
class Quote(Base, UUIDMixin, TimestampMixin):
@@ -75,17 +72,14 @@ class Quote(Base, UUIDMixin, TimestampMixin):
Attributes:
access_token: Unique token for public access (URL-safe, 43 chars)
status: Current quote status (draft, submitted, reviewing, etc.)
status: Current quote status (draft, submitted, viewed, etc.)
company_name: Prospect company name
contact_name: Primary contact name
contact_email: Contact email address
contact_phone: Contact phone number
employee_count: Number of employees/users
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
@@ -109,10 +103,10 @@ class Quote(Base, UUIDMixin, TimestampMixin):
nullable=False,
default=QuoteStatus.DRAFT.value,
server_default=QuoteStatus.DRAFT.value,
doc="Quote status: draft, submitted, reviewing, approved, rejected, expired"
doc="Quote status: draft, submitted, viewed, followed_up, converted, expired"
)
# Contact information (optional until submission)
# Contact information
company_name: Mapped[Optional[str]] = mapped_column(
String(255),
doc="Prospect company name"
@@ -139,15 +133,14 @@ class Quote(Base, UUIDMixin, TimestampMixin):
doc="Number of employees/users"
)
# Notes
notes: Mapped[Optional[str]] = mapped_column(
Text,
doc="Customer notes or special requirements"
industry: Mapped[Optional[str]] = mapped_column(
String(100),
doc="Industry/vertical of the prospect"
)
admin_notes: Mapped[Optional[str]] = mapped_column(
current_it_situation: Mapped[Optional[str]] = mapped_column(
Text,
doc="Internal admin notes (not visible to customer)"
doc="Description of the prospect's current IT setup"
)
# Calculated totals
@@ -167,14 +160,6 @@ class Quote(Base, UUIDMixin, TimestampMixin):
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,
@@ -193,10 +178,32 @@ class Quote(Base, UUIDMixin, TimestampMixin):
)
user_agent: Mapped[Optional[str]] = mapped_column(
String(500),
Text,
doc="Browser user agent string"
)
# Marketing attribution
source: Mapped[Optional[str]] = mapped_column(
String(50),
server_default="website",
doc="Lead source (e.g., website, referral)"
)
utm_source: Mapped[Optional[str]] = mapped_column(
String(100),
doc="UTM source parameter"
)
utm_medium: Mapped[Optional[str]] = mapped_column(
String(100),
doc="UTM medium parameter"
)
utm_campaign: Mapped[Optional[str]] = mapped_column(
String(100),
doc="UTM campaign parameter"
)
# Syncro RMM Integration
syncro_lead_id: Mapped[Optional[str]] = mapped_column(
String(100),
@@ -242,7 +249,7 @@ class Quote(Base, UUIDMixin, TimestampMixin):
# Constraints and indexes
__table_args__ = (
CheckConstraint(
"status IN ('draft', 'submitted', 'reviewing', 'approved', 'rejected', 'expired')",
"status IN ('draft', 'submitted', 'viewed', 'followed_up', 'converted', 'expired')",
name="ck_quotes_status"
),
Index("idx_quotes_access_token", "access_token"),
@@ -256,23 +263,25 @@ class Quote(Base, UUIDMixin, TimestampMixin):
return f"<Quote(id='{self.id}', status='{self.status}', company='{self.company_name}')>"
class QuoteItem(Base, UUIDMixin, TimestampMixin):
class QuoteItem(Base, UUIDMixin):
"""
Quote item model representing a single line item in a quote.
Stores service details, pricing, and quantity information.
Stores product 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
category: Service category (gps_monitoring, support_plan, etc.)
product_code: Product code identifier
product_name: Name of the product/service
description: Detailed description of the product/service
quantity: Number of units
setup_fee: One-time setup fee
is_required: Whether this item is required (cannot be removed)
sort_order: Display order within the quote
unit_price: Price per unit
setup_price: One-time setup price
billing_frequency: Billing frequency (monthly, yearly, one_time)
tier: Pricing tier
is_recommended: Whether this item is recommended
created_at: Timestamp when item was created
"""
__tablename__ = "quote_items"
@@ -285,42 +294,32 @@ class QuoteItem(Base, UUIDMixin, TimestampMixin):
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."
doc="Service category: gps_monitoring, support_plan, voip, web_hosting, email, hardware, addon"
)
# Billing
billing_frequency: Mapped[str] = mapped_column(
String(20),
# Product identification
product_code: Mapped[str] = mapped_column(
String(50),
nullable=False,
default=BillingFrequency.MONTHLY.value,
doc="Billing frequency: monthly, quarterly, annual, one_time"
doc="Product code identifier"
)
# Pricing
unit_price: Mapped[Decimal] = mapped_column(
Numeric(10, 2),
product_name: Mapped[str] = mapped_column(
String(255),
nullable=False,
default=Decimal("0.00"),
doc="Price per unit"
doc="Name of the product/service"
)
description: Mapped[Optional[str]] = mapped_column(
Text,
doc="Detailed description of the product/service"
)
# Quantity and pricing
quantity: Mapped[int] = mapped_column(
Integer,
nullable=False,
@@ -328,29 +327,49 @@ class QuoteItem(Base, UUIDMixin, TimestampMixin):
doc="Number of units"
)
setup_fee: Mapped[Decimal] = mapped_column(
unit_price: Mapped[Decimal] = mapped_column(
Numeric(10, 2),
nullable=False,
doc="Price per unit"
)
setup_price: Mapped[Decimal] = mapped_column(
Numeric(10, 2),
nullable=False,
default=Decimal("0.00"),
server_default="0.00",
doc="One-time setup fee"
doc="One-time setup price"
)
# Billing
billing_frequency: Mapped[str] = mapped_column(
String(20),
nullable=False,
default=BillingFrequency.MONTHLY.value,
server_default="monthly",
doc="Billing frequency: monthly, yearly, one_time"
)
# Configuration
is_required: Mapped[bool] = mapped_column(
tier: Mapped[Optional[str]] = mapped_column(
String(50),
doc="Pricing tier"
)
is_recommended: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
server_default="0",
doc="Whether this item is required (cannot be removed)"
doc="Whether this item is recommended"
)
sort_order: Mapped[int] = mapped_column(
Integer,
# Timestamp (no updated_at in DB)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
default=0,
server_default="0",
doc="Display order within the quote"
server_default=func.now(),
doc="Timestamp when the item was created"
)
# Relationships
@@ -362,24 +381,20 @@ class QuoteItem(Base, UUIDMixin, TimestampMixin):
# Constraints and indexes
__table_args__ = (
CheckConstraint(
"category IN ('managed_services', 'security', 'backup', 'cloud', 'hardware', 'software', 'consulting', 'support')",
"category IN ('gps_monitoring', 'support_plan', 'voip', 'web_hosting', 'email', 'hardware', 'addon')",
name="ck_quote_items_category"
),
CheckConstraint(
"billing_frequency IN ('monthly', 'quarterly', 'annual', 'one_time')",
"billing_frequency IN ('monthly', 'yearly', '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})>"
return f"<QuoteItem(product='{self.product_name}', qty={self.quantity}, price={self.unit_price})>"
@property
def line_total(self) -> Decimal:
@@ -391,15 +406,13 @@ class QuoteItem(Base, UUIDMixin, TimestampMixin):
"""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:
elif self.billing_frequency == BillingFrequency.YEARLY.value:
return self.line_total / Decimal("12")
else: # one_time
return Decimal("0.00")
class QuoteActivity(Base, UUIDMixin, TimestampMixin):
class QuoteActivity(Base, UUIDMixin):
"""
Quote activity model for tracking quote history and changes.
@@ -408,13 +421,13 @@ class QuoteActivity(Base, UUIDMixin, TimestampMixin):
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')
step_name: Name of the wizard step associated with the action
details: Additional details about the action (longtext)
ip_address: IP address of the actor
metadata: JSON metadata about the action
created_at: Timestamp when the activity was recorded
"""
__tablename__ = "quote_activities"
__tablename__ = "quote_activity"
# Foreign keys
quote_id: Mapped[str] = mapped_column(
@@ -431,14 +444,14 @@ class QuoteActivity(Base, UUIDMixin, TimestampMixin):
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"
step_name: Mapped[Optional[str]] = mapped_column(
String(50),
doc="Name of the wizard step associated with the action"
)
actor: Mapped[Optional[str]] = mapped_column(
String(255),
doc="Who performed the action (email, 'system', 'admin')"
details: Mapped[Optional[str]] = mapped_column(
Text,
doc="Additional details about the action"
)
ip_address: Mapped[Optional[str]] = mapped_column(
@@ -446,9 +459,12 @@ class QuoteActivity(Base, UUIDMixin, TimestampMixin):
doc="IP address of the actor"
)
metadata: Mapped[Optional[str]] = mapped_column(
Text,
doc="JSON metadata about the action"
# Timestamp (no updated_at in DB)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
doc="Timestamp when the activity was recorded"
)
# Relationships
@@ -469,21 +485,24 @@ class QuoteActivity(Base, UUIDMixin, TimestampMixin):
return f"<QuoteActivity(quote_id='{self.quote_id}', action='{self.action}')>"
class QuoteNotification(Base, UUIDMixin, TimestampMixin):
class QuoteNotification(Base, UUIDMixin):
"""
Quote notification model for tracking notifications sent.
Records all notifications (emails, SMS, alerts) sent for a quote.
Records all notifications (emails, webhooks) 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.)
notification_type: Type of notification (email, webhook)
recipient: Notification recipient (email address, webhook URL, etc.)
subject: Notification subject
content: Notification content/body
status: Delivery status (pending, sent, delivered, failed)
sent_at: Timestamp when notification was sent
body: Notification body content
status: Delivery status (pending, sent, failed)
attempts: Number of delivery attempts
last_attempt_at: Timestamp of last delivery attempt
sent_at: Timestamp when notification was successfully sent
error_message: Error message if delivery failed
created_at: Timestamp when notification was created
"""
__tablename__ = "quote_notifications"
@@ -500,23 +519,23 @@ class QuoteNotification(Base, UUIDMixin, TimestampMixin):
notification_type: Mapped[str] = mapped_column(
String(30),
nullable=False,
doc="Type of notification: email_sent, sms_sent, admin_alert, reminder_sent"
doc="Type of notification: email, webhook"
)
recipient: Mapped[str] = mapped_column(
String(255),
nullable=False,
doc="Notification recipient (email, phone, etc.)"
doc="Notification recipient (email address, webhook URL, etc.)"
)
subject: Mapped[Optional[str]] = mapped_column(
String(500),
String(255),
doc="Notification subject"
)
content: Mapped[Optional[str]] = mapped_column(
body: Mapped[Optional[str]] = mapped_column(
Text,
doc="Notification content/body"
doc="Notification body content"
)
# Status tracking
@@ -525,12 +544,25 @@ class QuoteNotification(Base, UUIDMixin, TimestampMixin):
nullable=False,
default="pending",
server_default="pending",
doc="Delivery status: pending, sent, delivered, failed"
doc="Delivery status: pending, sent, failed"
)
attempts: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
server_default="0",
doc="Number of delivery attempts"
)
last_attempt_at: Mapped[Optional[datetime]] = mapped_column(
DateTime,
doc="Timestamp of last delivery attempt"
)
sent_at: Mapped[Optional[datetime]] = mapped_column(
DateTime,
doc="Timestamp when notification was sent"
doc="Timestamp when notification was successfully sent"
)
error_message: Mapped[Optional[str]] = mapped_column(
@@ -538,6 +570,14 @@ class QuoteNotification(Base, UUIDMixin, TimestampMixin):
doc="Error message if delivery failed"
)
# Timestamp (no updated_at in DB)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
doc="Timestamp when the notification was created"
)
# Relationships
quote: Mapped["Quote"] = relationship(
"Quote",
@@ -547,11 +587,11 @@ class QuoteNotification(Base, UUIDMixin, TimestampMixin):
# Constraints and indexes
__table_args__ = (
CheckConstraint(
"notification_type IN ('email_sent', 'sms_sent', 'admin_alert', 'reminder_sent')",
"notification_type IN ('email', 'webhook')",
name="ck_quote_notifications_type"
),
CheckConstraint(
"status IN ('pending', 'sent', 'delivered', 'failed')",
"status IN ('pending', 'sent', 'failed')",
name="ck_quote_notifications_status"
),
Index("idx_quote_notifications_quote_id", "quote_id"),

View File

@@ -52,7 +52,7 @@ def list_quotes(
status_filter: Optional[str] = Query(
default=None,
alias="status",
description="Filter by status (draft, submitted, reviewing, approved, rejected, expired)"
description="Filter by status (draft, submitted, viewed, followed_up, converted, expired)"
),
search: Optional[str] = Query(
default=None,
@@ -166,9 +166,9 @@ def get_stats(
"quotes_by_status": {
"draft": 45,
"submitted": 60,
"reviewing": 15,
"approved": 25,
"rejected": 3,
"viewed": 15,
"followed_up": 10,
"converted": 25,
"expired": 2
},
"total_monthly_value": "12500.00",
@@ -229,7 +229,6 @@ def get_quote(
"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": [...],
@@ -254,19 +253,19 @@ def get_quote(
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,
product_code=item.product_code,
product_name=item.product_name,
description=item.description,
quantity=item.quantity,
setup_fee=item.setup_fee,
is_required=item.is_required,
sort_order=item.sort_order,
unit_price=item.unit_price,
setup_price=item.setup_price,
billing_frequency=item.billing_frequency,
tier=item.tier,
is_recommended=item.is_recommended,
line_total=item.line_total,
monthly_amount=item.monthly_amount,
created_at=item.created_at,
updated_at=item.updated_at
))
activities_response = []
@@ -275,8 +274,8 @@ def get_quote(
id=activity.id,
quote_id=activity.quote_id,
action=activity.action,
description=activity.description,
actor=activity.actor,
step_name=activity.step_name,
details=activity.details,
ip_address=activity.ip_address,
created_at=activity.created_at
))
@@ -290,6 +289,8 @@ def get_quote(
recipient=notification.recipient,
subject=notification.subject,
status=notification.status,
attempts=notification.attempts,
last_attempt_at=notification.last_attempt_at,
sent_at=notification.sent_at,
error_message=notification.error_message,
created_at=notification.created_at
@@ -304,11 +305,8 @@ def get_quote(
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,
@@ -346,8 +344,8 @@ def update_quote(
"""
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.
Admins can change the quote status (e.g., from submitted to viewed
or converted) and update expiration.
**Example Request:**
```json
@@ -356,8 +354,7 @@ def update_quote(
Content-Type: application/json
{
"status": "reviewing",
"admin_notes": "Assigned to sales team. Follow up scheduled for Monday."
"status": "viewed"
}
```
@@ -365,8 +362,7 @@ def update_quote(
```json
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"status": "reviewing",
"admin_notes": "Assigned to sales team. Follow up scheduled for Monday.",
"status": "viewed",
...
}
```
@@ -382,3 +378,47 @@ def update_quote(
)
return get_quote(quote_id, db, current_user)
@router.post(
"/{quote_id}/sync-syncro",
summary="Sync quote to SyncroRMM",
description="Create or update a lead in SyncroRMM from a submitted quote",
status_code=status.HTTP_200_OK,
responses={
200: {
"description": "Sync result",
"content": {
"application/json": {
"example": {
"synced": True,
"is_existing_customer": False,
"syncro_lead_id": "12345",
"error": None,
}
}
},
},
404: {"description": "Quote not found"},
},
)
async def sync_quote_to_syncro(
quote_id: UUID,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""
Manually trigger a SyncroRMM sync for a quote.
Checks for an existing customer in Syncro and creates a lead with
the quote details. The quote must have a contact email to sync.
**Example Request:**
```
POST /api/admin/quotes/123e4567-e89b-12d3-a456-426614174000/sync-syncro
Authorization: Bearer <token>
```
"""
quote = quote_service.get_quote_by_id(db, quote_id)
result = await quote_service.sync_quote_to_syncro(db, quote)
return result

View File

@@ -78,8 +78,7 @@ def create_quote(
Content-Type: application/json
{
"employee_count": 25,
"notes": "Looking for complete managed services package"
"employee_count": 25
}
```
@@ -159,7 +158,6 @@ def get_quote(
"employee_count": 25,
"monthly_total": "450.00",
"setup_total": "500.00",
"annual_total": "5900.00",
"items": [
{
"id": "456e7890-e89b-12d3-a456-426614174001",
@@ -185,19 +183,19 @@ def get_quote(
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,
product_code=item.product_code,
product_name=item.product_name,
description=item.description,
quantity=item.quantity,
setup_fee=item.setup_fee,
is_required=item.is_required,
sort_order=item.sort_order,
unit_price=item.unit_price,
setup_price=item.setup_price,
billing_frequency=item.billing_frequency,
tier=item.tier,
is_recommended=item.is_recommended,
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)
@@ -210,10 +208,8 @@ def get_quote(
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,
@@ -432,7 +428,7 @@ def remove_item(
},
},
)
def submit_quote(
async def submit_quote_endpoint(
access_token: str,
submit_data: QuoteSubmit,
request: Request,
@@ -442,7 +438,7 @@ def submit_quote(
Submit a quote with contact information.
This finalizes the quote and sends it for review. Contact information
is required at this stage.
is required at this stage. An email notification is sent to the admin.
**Example Request:**
```json
@@ -453,8 +449,7 @@ def submit_quote(
"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."
"contact_phone": "555-123-4567"
}
```
@@ -472,15 +467,62 @@ def submit_quote(
}
```
"""
import logging
from api.config import get_settings
from api.services.email_service import send_email, build_quote_notification_html
logger = logging.getLogger(__name__)
ip_address = get_client_ip(request)
quote_service.submit_quote(
quote = quote_service.submit_quote(
db=db,
access_token=access_token,
submit_data=submit_data,
ip_address=ip_address
)
# Send email notification (non-blocking, don't fail the request if email fails)
try:
settings = get_settings()
items_data = [
{
"service_name": item.product_name,
"billing_frequency": item.billing_frequency,
"unit_price": str(item.unit_price),
"quantity": item.quantity,
}
for item in quote.items
]
html = build_quote_notification_html(
company_name=submit_data.company_name,
contact_name=submit_data.contact_name,
contact_email=submit_data.contact_email,
contact_phone=submit_data.contact_phone,
monthly_total=str(quote.monthly_total),
setup_total=str(quote.setup_total),
items=items_data,
notes=submit_data.notes,
)
sent = await send_email(
to_email=settings.ADMIN_NOTIFICATION_EMAIL,
subject=f"New Quote Submission: {submit_data.company_name} - ${quote.monthly_total}/mo",
body_html=html,
)
# Update notification record status
if quote.notifications:
notification = quote.notifications[-1]
notification.status = "sent" if sent else "failed"
if not sent:
notification.error_message = "Graph API send failed"
db.commit()
except Exception as e:
logger.error(f"Failed to send quote notification email: {e}")
# Don't fail the submission - email is best-effort
return get_quote(access_token, db)

View File

@@ -7,49 +7,17 @@ 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"
from api.models.quote import (
QuoteStatus,
ServiceCategory,
BillingFrequency,
NotificationType,
)
# ============================================================================
@@ -59,21 +27,19 @@ class NotificationType(str, Enum):
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"
)
category: ServiceCategory = Field(..., description="Service category")
product_code: str = Field(..., description="Product code identifier", min_length=1, max_length=50)
product_name: str = Field(..., description="Name of the product/service", min_length=1, max_length=255)
description: Optional[str] = Field(None, description="Detailed description of the product/service")
quantity: int = Field(1, description="Number of units", ge=1)
unit_price: Decimal = Field(..., description="Price per unit", ge=0)
setup_price: Decimal = Field(Decimal("0.00"), description="One-time setup price", ge=0)
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")
tier: Optional[str] = Field(None, description="Pricing tier", max_length=50)
is_recommended: bool = Field(False, description="Whether this item is recommended")
class QuoteItemCreate(QuoteItemBase):
@@ -84,15 +50,16 @@ class QuoteItemCreate(QuoteItemBase):
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)
product_code: Optional[str] = Field(None, min_length=1, max_length=50)
product_name: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = None
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
unit_price: Optional[Decimal] = Field(None, ge=0)
setup_price: Optional[Decimal] = Field(None, ge=0)
billing_frequency: Optional[BillingFrequency] = None
tier: Optional[str] = Field(None, max_length=50)
is_recommended: Optional[bool] = None
class QuoteItemResponse(QuoteItemBase):
@@ -103,7 +70,6 @@ class QuoteItemResponse(QuoteItemBase):
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}
@@ -120,14 +86,12 @@ class QuoteBase(BaseModel):
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")
@@ -140,7 +104,6 @@ class QuoteUpdate(BaseModel):
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)")
@@ -152,7 +115,7 @@ class QuoteSubmit(BaseModel):
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")
notes: Optional[str] = Field(None, description="Additional notes from the prospect", max_length=2000)
@field_validator("company_name", "contact_name")
@classmethod
@@ -169,7 +132,6 @@ class QuoteResponse(QuoteBase):
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")
@@ -200,8 +162,8 @@ class QuoteActivityResponse(BaseModel):
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")
step_name: Optional[str] = Field(None, description="Wizard step name")
details: Optional[str] = Field(None, description="Additional details")
ip_address: Optional[str] = Field(None, description="IP address of the actor")
created_at: datetime = Field(..., description="Timestamp of the action")
@@ -221,6 +183,8 @@ class QuoteNotificationResponse(BaseModel):
recipient: str = Field(..., description="Notification recipient")
subject: Optional[str] = Field(None, description="Notification subject")
status: str = Field(..., description="Delivery status")
attempts: int = Field(0, description="Number of delivery attempts")
last_attempt_at: Optional[datetime] = Field(None, description="Last delivery attempt timestamp")
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")
@@ -236,14 +200,12 @@ 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(

View File

@@ -0,0 +1,204 @@
"""
Email service using Microsoft Graph API.
Sends email via M365 Graph API using client credentials flow.
Used for quote submission notifications and other system emails.
"""
import logging
from typing import Optional
import httpx
from api.config import get_settings
logger = logging.getLogger(__name__)
# Cache the access token to avoid requesting a new one for every email
_token_cache: dict = {"access_token": None, "expires_at": 0}
async def _get_graph_token() -> str:
"""Obtain an access token from Azure AD using client credentials."""
import time
if _token_cache["access_token"] and _token_cache["expires_at"] > time.time() + 60:
return _token_cache["access_token"]
settings = get_settings()
if not settings.GRAPH_TENANT_ID or not settings.GRAPH_CLIENT_ID:
raise RuntimeError("Microsoft Graph API credentials not configured")
token_url = f"https://login.microsoftonline.com/{settings.GRAPH_TENANT_ID}/oauth2/v2.0/token"
async with httpx.AsyncClient(timeout=15) as client:
response = await client.post(
token_url,
data={
"client_id": settings.GRAPH_CLIENT_ID,
"client_secret": settings.GRAPH_CLIENT_SECRET,
"scope": "https://graph.microsoft.com/.default",
"grant_type": "client_credentials",
},
)
response.raise_for_status()
data = response.json()
_token_cache["access_token"] = data["access_token"]
_token_cache["expires_at"] = time.time() + data.get("expires_in", 3600)
return data["access_token"]
async def send_email(
to_email: str,
subject: str,
body_html: str,
cc_email: Optional[str] = None,
) -> bool:
"""
Send an email via Microsoft Graph API.
Args:
to_email: Recipient email address
subject: Email subject
body_html: HTML body content
cc_email: Optional CC recipient
Returns:
True if sent successfully, False otherwise
"""
settings = get_settings()
if not settings.GRAPH_TENANT_ID:
logger.warning("Graph API not configured - skipping email send")
return False
try:
token = await _get_graph_token()
message: dict = {
"message": {
"subject": subject,
"body": {
"contentType": "HTML",
"content": body_html,
},
"toRecipients": [
{"emailAddress": {"address": to_email}}
],
},
"saveToSentItems": "true",
}
if cc_email:
message["message"]["ccRecipients"] = [
{"emailAddress": {"address": cc_email}}
]
sender = settings.GRAPH_SENDER_EMAIL
url = f"https://graph.microsoft.com/v1.0/users/{sender}/sendMail"
async with httpx.AsyncClient(timeout=15) as client:
response = await client.post(
url,
json=message,
headers={"Authorization": f"Bearer {token}"},
)
response.raise_for_status()
logger.info(f"Email sent to {to_email}: {subject}")
return True
except Exception as e:
logger.error(f"Failed to send email to {to_email}: {e}")
return False
def build_quote_notification_html(
company_name: str,
contact_name: str,
contact_email: str,
contact_phone: Optional[str],
monthly_total: str,
setup_total: str,
items: list,
notes: Optional[str] = None,
) -> str:
"""Build HTML email body for quote submission notification."""
items_html = ""
for item in items:
freq = item.get("billing_frequency", "monthly")
freq_label = "/mo" if freq == "monthly" else " (one-time)"
qty = item.get("quantity", 1)
price = item.get("unit_price", "0.00")
line_total = float(price) * qty
items_html += f"""
<tr>
<td style="padding: 8px 12px; border-bottom: 1px solid #e5e7eb;">{item.get('service_name', '')}</td>
<td style="padding: 8px 12px; border-bottom: 1px solid #e5e7eb; text-align: center;">{qty}</td>
<td style="padding: 8px 12px; border-bottom: 1px solid #e5e7eb; text-align: right;">${price}{freq_label}</td>
<td style="padding: 8px 12px; border-bottom: 1px solid #e5e7eb; text-align: right;">${line_total:,.2f}{freq_label}</td>
</tr>"""
notes_section = ""
if notes:
notes_section = f"""
<div style="margin-top: 20px; padding: 12px 16px; background: #f8f9fb; border-radius: 8px;">
<strong style="color: #333d49;">Notes:</strong>
<p style="margin: 4px 0 0; color: #555;">{notes}</p>
</div>"""
phone_line = f"<br>Phone: {contact_phone}" if contact_phone else ""
return f"""
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: linear-gradient(135deg, #333d49, #113559); padding: 24px 32px; border-radius: 12px 12px 0 0;">
<h1 style="color: white; margin: 0; font-size: 22px;">New Quote Submission</h1>
<p style="color: rgba(255,255,255,0.7); margin: 4px 0 0; font-size: 14px;">Arizona Computer Guru - MSP Quote Wizard</p>
</div>
<div style="padding: 24px 32px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 12px 12px;">
<div style="margin-bottom: 20px;">
<h2 style="color: #333d49; font-size: 18px; margin: 0 0 8px;">Contact Information</h2>
<p style="margin: 0; color: #555; line-height: 1.6;">
<strong>{contact_name}</strong><br>
{company_name}<br>
Email: <a href="mailto:{contact_email}">{contact_email}</a>
{phone_line}
</p>
</div>
<div style="background: linear-gradient(135deg, #333d49, #113559); border-radius: 8px; padding: 16px 20px; margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center;">
<span style="color: rgba(255,255,255,0.8); font-size: 14px;">Monthly Total</span>
<span style="color: white; font-size: 24px; font-weight: bold;">${monthly_total}/mo</span>
</div>
{"<div style='background: #fff7ed; border-radius: 8px; padding: 12px 20px; margin-bottom: 20px;'><span style=\"color: #9a3412; font-size: 14px;\">One-Time Costs: <strong>$" + setup_total + "</strong></span></div>" if float(setup_total or 0) > 0 else ""}
<h3 style="color: #333d49; font-size: 16px; margin: 20px 0 8px;">Services</h3>
<table style="width: 100%; border-collapse: collapse; font-size: 14px;">
<thead>
<tr style="background: #f8f9fb;">
<th style="padding: 8px 12px; text-align: left; color: #333d49;">Service</th>
<th style="padding: 8px 12px; text-align: center; color: #333d49;">Qty</th>
<th style="padding: 8px 12px; text-align: right; color: #333d49;">Unit Price</th>
<th style="padding: 8px 12px; text-align: right; color: #333d49;">Total</th>
</tr>
</thead>
<tbody>
{items_html}
</tbody>
</table>
{notes_section}
<div style="margin-top: 24px; padding-top: 16px; border-top: 2px solid #fe7400; text-align: center;">
<p style="color: #999; font-size: 12px; margin: 0;">
Submitted via <a href="https://azcomputerguru.com/quote" style="color: #fe7400;">azcomputerguru.com/quote</a>
</p>
</div>
</div>
</div>
"""

View File

@@ -5,8 +5,8 @@ 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 os
import secrets
from datetime import datetime, timedelta
from decimal import Decimal
@@ -50,15 +50,15 @@ def generate_access_token() -> str:
return secrets.token_urlsafe(32)
def calculate_totals(items: list[QuoteItem]) -> tuple[Decimal, Decimal, Decimal]:
def calculate_totals(items: list[QuoteItem]) -> tuple[Decimal, Decimal]:
"""
Calculate monthly, setup, and annual totals from quote items.
Calculate monthly and setup totals from quote items.
Args:
items: List of QuoteItem objects
Returns:
tuple: (monthly_total, setup_total, annual_total)
tuple: (monthly_total, setup_total)
"""
monthly_total = Decimal("0.00")
setup_total = Decimal("0.00")
@@ -70,29 +70,23 @@ def calculate_totals(items: list[QuoteItem]) -> tuple[Decimal, Decimal, Decimal]
# 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:
elif item.billing_frequency == BillingFrequency.YEARLY.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
# Setup prices are always one-time
setup_total += item.setup_price
# Annual total is monthly * 12 + setup
annual_total = (monthly_total * Decimal("12")) + setup_total
return monthly_total, setup_total, annual_total
return monthly_total, setup_total
def log_activity(
db: Session,
quote_id: str,
action: str,
description: Optional[str] = None,
actor: Optional[str] = None,
details: Optional[str] = None,
step_name: Optional[str] = None,
ip_address: Optional[str] = None,
metadata: Optional[dict] = None
) -> QuoteActivity:
"""
Log an activity for a quote.
@@ -101,21 +95,23 @@ def log_activity(
db: Database session
quote_id: UUID of the quote
action: Action being performed
description: Detailed description
actor: Who performed the action
details: Additional details about the action (stored as JSON)
step_name: Wizard step name associated with the action
ip_address: IP address of the actor
metadata: Additional metadata as dict
Returns:
QuoteActivity: The created activity record
"""
import json
# DB column has CHECK (json_valid(details)), so wrap in JSON
details_json = json.dumps({"message": details}) if details else None
activity = QuoteActivity(
quote_id=quote_id,
action=action,
description=description,
actor=actor,
step_name=step_name,
details=details_json,
ip_address=ip_address,
metadata=json.dumps(metadata) if metadata else None
)
db.add(activity)
@@ -155,7 +151,6 @@ def create_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
@@ -170,34 +165,33 @@ def create_quote(
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,
product_code=item_data.product_code,
product_name=item_data.product_name,
description=item_data.description,
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
unit_price=item_data.unit_price,
setup_price=item_data.setup_price,
billing_frequency=item_data.billing_frequency.value,
tier=item_data.tier,
is_recommended=item_data.is_recommended,
)
db.add(item)
db.flush()
# Calculate and update totals
monthly, setup, annual = calculate_totals(quote.items)
monthly, setup = 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",
details=f"Quote draft created, employee_count={quote_data.employee_count}",
ip_address=ip_address,
metadata={"employee_count": quote_data.employee_count}
)
db.commit()
@@ -344,15 +338,16 @@ def update_quote(
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,
product_code=item_data.product_code,
product_name=item_data.product_name,
description=item_data.description,
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
unit_price=item_data.unit_price,
setup_price=item_data.setup_price,
billing_frequency=item_data.billing_frequency.value,
tier=item_data.tier,
is_recommended=item_data.is_recommended,
)
db.add(item)
@@ -362,10 +357,9 @@ def update_quote(
# Recalculate totals
db.refresh(quote)
monthly, setup, annual = calculate_totals(quote.items)
monthly, setup = calculate_totals(quote.items)
quote.monthly_total = monthly
quote.setup_total = setup
quote.annual_total = annual
# Log activity
if changes:
@@ -373,7 +367,7 @@ def update_quote(
db=db,
quote_id=quote.id,
action="updated",
description=f"Quote updated: {', '.join(changes)}",
details=f"Quote updated: {', '.join(changes)}",
ip_address=ip_address
)
@@ -438,8 +432,6 @@ def submit_quote(
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
@@ -453,24 +445,17 @@ def submit_quote(
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,
details=f"Quote submitted by {submit_data.contact_name} ({submit_data.contact_email}), company={submit_data.company_name}, monthly=${quote.monthly_total}, setup=${quote.setup_total}",
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
notification_type="email",
recipient=os.environ.get("ADMIN_NOTIFICATION_EMAIL", "mike@azcomputerguru.com"),
subject=f"New Quote Submission: {submit_data.company_name}",
content=f"Quote submitted by {submit_data.contact_name}. Monthly: ${quote.monthly_total}",
body=f"Quote submitted by {submit_data.contact_name}. Monthly: ${quote.monthly_total}",
status="pending"
)
db.add(notification)
@@ -478,6 +463,10 @@ def submit_quote(
db.commit()
db.refresh(quote)
# Syncro sync is handled via the admin endpoint POST /{quote_id}/sync-syncro
# or can be triggered manually after submission. Not run inline to avoid
# async/sync mixing and DB session lifecycle issues.
return quote
except HTTPException:
@@ -556,11 +545,7 @@ async def sync_quote_to_syncro(db: Session, quote: Quote) -> dict:
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
}
details=f"Existing Syncro customer found: {customer_check.customer_name} (ID: {customer_check.customer_id}, match: {customer_check.match_type})",
)
# Create lead in Syncro
@@ -577,11 +562,7 @@ async def sync_quote_to_syncro(db: Session, quote: Quote) -> dict:
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
}
details=f"Lead created in Syncro: {lead_result.lead_id}, is_existing_customer={customer_check.exists}",
)
else:
result["error"] = lead_result.error
@@ -594,8 +575,7 @@ async def sync_quote_to_syncro(db: Session, quote: Quote) -> dict:
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}
details=f"Failed to sync to Syncro: {lead_result.error}",
)
# Commit the updates to quote
@@ -616,8 +596,7 @@ async def sync_quote_to_syncro(db: Session, quote: Quote) -> dict:
db=db,
quote_id=quote.id,
action="syncro_sync_error",
description=f"Syncro sync error: {error_msg}",
metadata={"error": error_msg}
details=f"Syncro sync error: {error_msg}",
)
db.commit()
except Exception:
@@ -682,7 +661,7 @@ def update_quote_status(
admin_user: str
) -> Quote:
"""
Update quote status and admin notes (admin).
Update quote status and expiration (admin).
Args:
db: Database session
@@ -703,10 +682,6 @@ def update_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}")
@@ -717,8 +692,7 @@ def update_quote_status(
db=db,
quote_id=quote.id,
action="admin_update",
description=f"Admin update: {', '.join(changes)}",
actor=admin_user
details=f"Admin update by {admin_user}: {', '.join(changes)}",
)
db.commit()
@@ -758,8 +732,9 @@ def get_quote_stats(db: Session) -> QuoteStatsResponse:
# Total values for submitted quotes
submitted_statuses = [
QuoteStatus.SUBMITTED.value,
QuoteStatus.REVIEWING.value,
QuoteStatus.APPROVED.value
QuoteStatus.VIEWED.value,
QuoteStatus.FOLLOWED_UP.value,
QuoteStatus.CONVERTED.value,
]
value_query = (
db.query(
@@ -849,41 +824,34 @@ def add_item_to_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,
product_code=item_data.product_code,
product_name=item_data.product_name,
description=item_data.description,
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
unit_price=item_data.unit_price,
setup_price=item_data.setup_price,
billing_frequency=item_data.billing_frequency.value,
tier=item_data.tier,
is_recommended=item_data.is_recommended,
)
db.add(item)
db.flush()
# Recalculate totals
db.refresh(quote)
monthly, setup, annual = calculate_totals(quote.items)
monthly, setup = 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}",
details=f"Added item: {item_data.product_name}",
ip_address=ip_address
)
@@ -942,30 +910,23 @@ def remove_item_from_quote(
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
item_name = item.product_name
db.delete(item)
db.flush()
# Recalculate totals
db.refresh(quote)
monthly, setup, annual = calculate_totals(quote.items)
monthly, setup = 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}",
details=f"Removed item: {item_name}",
ip_address=ip_address
)

View File

@@ -8,6 +8,7 @@ API Documentation: https://api-docs.syncromsp.com/
"""
import logging
import os
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
@@ -20,9 +21,10 @@ if TYPE_CHECKING:
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"
SYNCRO_API_BASE_URL = os.environ.get(
"SYNCRO_API_BASE_URL", "https://computerguru.syncromsp.com/api/v1"
)
SYNCRO_API_KEY = os.environ.get("SYNCRO_API_KEY", "")
# HTTP client configuration
SYNCRO_TIMEOUT_SECONDS = 30.0