Files
claudetools/api/routers/quotes.py
Mike Swanson fa15b03180 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>
2026-03-10 19:59:08 -07:00

562 lines
15 KiB
Python

"""
Public Quote API router for ClaudeTools.
This module defines all public REST API endpoints for the MSP Quote Wizard,
allowing prospects to create, view, and submit quotes without authentication.
"""
from typing import Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sqlalchemy.orm import Session
from api.database import get_db
from api.schemas.quote import (
QuoteCreate,
QuoteCreatedResponse,
QuoteItemCreate,
QuoteResponse,
QuoteItemResponse,
QuoteSubmit,
QuoteUpdate,
)
from api.services import quote_service
# Create router (no authentication required for public endpoints)
router = APIRouter()
def get_client_ip(request: Request) -> Optional[str]:
"""Extract client IP from request, handling proxies."""
forwarded = request.headers.get("X-Forwarded-For")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else None
def get_user_agent(request: Request) -> Optional[str]:
"""Extract user agent from request."""
return request.headers.get("User-Agent")
@router.post(
"",
response_model=QuoteCreatedResponse,
summary="Create new quote draft",
description="Create a new quote draft. Returns an access token for future access.",
status_code=status.HTTP_201_CREATED,
responses={
201: {
"description": "Quote created successfully",
"model": QuoteCreatedResponse,
},
500: {
"description": "Server error",
"content": {
"application/json": {
"example": {"detail": "Failed to create quote"}
}
},
},
},
)
def create_quote(
quote_data: QuoteCreate,
request: Request,
db: Session = Depends(get_db),
):
"""
Create a new quote draft.
This endpoint does not require authentication. A unique access token is
generated for the quote which can be used to access it later.
**Example Request:**
```json
POST /api/quotes
Content-Type: application/json
{
"employee_count": 25
}
```
**Example Response:**
```json
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"access_token": "xYz123abc456def789ghi012jkl345mno678pqr901stu",
"status": "draft",
"message": "Quote created successfully. Use the access_token to access your quote."
}
```
"""
ip_address = get_client_ip(request)
user_agent = get_user_agent(request)
quote = quote_service.create_quote(
db=db,
quote_data=quote_data,
ip_address=ip_address,
user_agent=user_agent
)
return QuoteCreatedResponse(
id=quote.id,
access_token=quote.access_token,
status=quote.status,
message="Quote created successfully. Use the access_token to access your quote."
)
@router.get(
"/{access_token}",
response_model=QuoteResponse,
summary="Get quote by access token",
description="Retrieve a quote by its access token",
status_code=status.HTTP_200_OK,
responses={
200: {
"description": "Quote found and returned",
"model": QuoteResponse,
},
404: {
"description": "Quote not found",
"content": {
"application/json": {
"example": {"detail": "Quote not found"}
}
},
},
},
)
def get_quote(
access_token: str,
db: Session = Depends(get_db),
):
"""
Get a quote by its access token.
Returns the quote with all its items. This is the public endpoint
for viewing a quote.
**Example Request:**
```
GET /api/quotes/xYz123abc456def789ghi012jkl345mno678pqr901stu
```
**Example Response:**
```json
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"access_token": "xYz123abc456def789ghi012jkl345mno678pqr901stu",
"status": "draft",
"company_name": null,
"contact_name": null,
"contact_email": null,
"employee_count": 25,
"monthly_total": "450.00",
"setup_total": "500.00",
"items": [
{
"id": "456e7890-e89b-12d3-a456-426614174001",
"service_name": "Managed Endpoint Protection",
"category": "security",
"unit_price": "15.00",
"quantity": 25,
"billing_frequency": "monthly",
"line_total": "375.00",
"monthly_amount": "375.00"
}
],
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:00Z"
}
```
"""
quote = quote_service.get_quote_by_token(db, access_token)
# Build response with calculated fields for items
items_response = []
for item in quote.items:
item_dict = QuoteItemResponse(
id=item.id,
quote_id=item.quote_id,
category=item.category,
product_code=item.product_code,
product_name=item.product_name,
description=item.description,
quantity=item.quantity,
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,
)
items_response.append(item_dict)
return QuoteResponse(
id=quote.id,
access_token=quote.access_token,
status=quote.status,
company_name=quote.company_name,
contact_name=quote.contact_name,
contact_email=quote.contact_email,
contact_phone=quote.contact_phone,
employee_count=quote.employee_count,
monthly_total=quote.monthly_total,
setup_total=quote.setup_total,
expires_at=quote.expires_at,
submitted_at=quote.submitted_at,
created_at=quote.created_at,
updated_at=quote.updated_at,
items=items_response
)
@router.put(
"/{access_token}",
response_model=QuoteResponse,
summary="Update quote",
description="Update a quote's details and/or items",
status_code=status.HTTP_200_OK,
responses={
200: {
"description": "Quote updated successfully",
"model": QuoteResponse,
},
400: {
"description": "Quote cannot be modified (not a draft)",
"content": {
"application/json": {
"example": {"detail": "Cannot update quote with status 'submitted'. Only drafts can be modified."}
}
},
},
404: {
"description": "Quote not found",
},
},
)
def update_quote(
access_token: str,
quote_data: QuoteUpdate,
request: Request,
db: Session = Depends(get_db),
):
"""
Update a quote.
Updates quote details and/or replaces all items. Only draft quotes
can be modified.
**Example Request:**
```json
PUT /api/quotes/xYz123abc456def789ghi012jkl345mno678pqr901stu
Content-Type: application/json
{
"employee_count": 30,
"items": [
{
"service_name": "Managed Endpoint Protection",
"category": "security",
"unit_price": "15.00",
"quantity": 30,
"billing_frequency": "monthly"
},
{
"service_name": "Cloud Backup",
"category": "backup",
"unit_price": "5.00",
"quantity": 30,
"billing_frequency": "monthly",
"setup_fee": "250.00"
}
]
}
```
"""
ip_address = get_client_ip(request)
quote = quote_service.update_quote(
db=db,
access_token=access_token,
quote_data=quote_data,
ip_address=ip_address
)
return get_quote(access_token, db)
@router.post(
"/{access_token}/items",
response_model=QuoteResponse,
summary="Add item to quote",
description="Add a single item to the quote",
status_code=status.HTTP_201_CREATED,
responses={
201: {
"description": "Item added successfully",
"model": QuoteResponse,
},
400: {
"description": "Quote cannot be modified",
},
404: {
"description": "Quote not found",
},
},
)
def add_item(
access_token: str,
item_data: QuoteItemCreate,
request: Request,
db: Session = Depends(get_db),
):
"""
Add a single item to a quote.
**Example Request:**
```json
POST /api/quotes/xYz123abc456def789ghi012jkl345mno678pqr901stu/items
Content-Type: application/json
{
"service_name": "24/7 Help Desk Support",
"category": "support",
"unit_price": "50.00",
"quantity": 1,
"billing_frequency": "monthly"
}
```
"""
ip_address = get_client_ip(request)
quote_service.add_item_to_quote(
db=db,
access_token=access_token,
item_data=item_data,
ip_address=ip_address
)
return get_quote(access_token, db)
@router.delete(
"/{access_token}/items/{item_id}",
response_model=QuoteResponse,
summary="Remove item from quote",
description="Remove an item from the quote",
status_code=status.HTTP_200_OK,
responses={
200: {
"description": "Item removed successfully",
"model": QuoteResponse,
},
400: {
"description": "Quote cannot be modified or item is required",
},
404: {
"description": "Quote or item not found",
},
},
)
def remove_item(
access_token: str,
item_id: UUID,
request: Request,
db: Session = Depends(get_db),
):
"""
Remove an item from a quote.
Required items cannot be removed.
**Example Request:**
```
DELETE /api/quotes/xYz123abc456def789ghi012jkl345mno678pqr901stu/items/456e7890-e89b-12d3-a456-426614174001
```
"""
ip_address = get_client_ip(request)
quote_service.remove_item_from_quote(
db=db,
access_token=access_token,
item_id=item_id,
ip_address=ip_address
)
return get_quote(access_token, db)
@router.post(
"/{access_token}/submit",
response_model=QuoteResponse,
summary="Submit quote",
description="Submit the quote with contact information",
status_code=status.HTTP_200_OK,
responses={
200: {
"description": "Quote submitted successfully",
"model": QuoteResponse,
},
400: {
"description": "Quote cannot be submitted (not a draft or no items)",
"content": {
"application/json": {
"examples": {
"not_draft": {
"value": {"detail": "Cannot submit quote with status 'submitted'. Only drafts can be submitted."}
},
"no_items": {
"value": {"detail": "Cannot submit quote without any items. Please add at least one service."}
}
}
}
},
},
404: {
"description": "Quote not found",
},
422: {
"description": "Validation error - missing required fields",
},
},
)
async def submit_quote_endpoint(
access_token: str,
submit_data: QuoteSubmit,
request: Request,
db: Session = Depends(get_db),
):
"""
Submit a quote with contact information.
This finalizes the quote and sends it for review. Contact information
is required at this stage. An email notification is sent to the admin.
**Example Request:**
```json
POST /api/quotes/xYz123abc456def789ghi012jkl345mno678pqr901stu/submit
Content-Type: application/json
{
"company_name": "Acme Corporation",
"contact_name": "John Doe",
"contact_email": "john.doe@acme.com",
"contact_phone": "555-123-4567"
}
```
**Example Response:**
```json
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"access_token": "xYz123abc456def789ghi012jkl345mno678pqr901stu",
"status": "submitted",
"company_name": "Acme Corporation",
"contact_name": "John Doe",
"contact_email": "john.doe@acme.com",
"submitted_at": "2024-01-15T14:30:00Z",
...
}
```
"""
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 = 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)
@router.get(
"/{access_token}/pdf",
summary="Get quote PDF (placeholder)",
description="Generate and return a PDF version of the quote",
status_code=status.HTTP_501_NOT_IMPLEMENTED,
responses={
501: {
"description": "PDF generation not yet implemented",
"content": {
"application/json": {
"example": {"detail": "PDF generation is not yet implemented"}
}
},
},
},
)
def get_quote_pdf(
access_token: str,
db: Session = Depends(get_db),
):
"""
Generate a PDF version of the quote.
**Note:** This endpoint is a placeholder. PDF generation will be
implemented in a future update.
"""
# Verify quote exists
quote_service.get_quote_by_token(db, access_token)
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="PDF generation is not yet implemented"
)