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

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