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:
@@ -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."""
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
204
api/services/email_service.py
Normal file
204
api/services/email_service.py
Normal 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>
|
||||
"""
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user