Synced files: - Session logs updated - Latest context and credentials - Command/directive updates Machine: Mikes-MacBook-Air.local Timestamp: 2026-03-09 08:14:13 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
520 lines
14 KiB
Python
520 lines
14 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,
|
|
"notes": "Looking for complete managed services package"
|
|
}
|
|
```
|
|
|
|
**Example Response:**
|
|
```json
|
|
{
|
|
"id": "123e4567-e89b-12d3-a456-426614174000",
|
|
"access_token": "xYz123abc456def789ghi012jkl345mno678pqr901stu",
|
|
"status": "draft",
|
|
"message": "Quote created successfully. Use the access_token to access your quote."
|
|
}
|
|
```
|
|
"""
|
|
ip_address = get_client_ip(request)
|
|
user_agent = get_user_agent(request)
|
|
|
|
quote = quote_service.create_quote(
|
|
db=db,
|
|
quote_data=quote_data,
|
|
ip_address=ip_address,
|
|
user_agent=user_agent
|
|
)
|
|
|
|
return QuoteCreatedResponse(
|
|
id=quote.id,
|
|
access_token=quote.access_token,
|
|
status=quote.status,
|
|
message="Quote created successfully. Use the access_token to access your quote."
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/{access_token}",
|
|
response_model=QuoteResponse,
|
|
summary="Get quote by access token",
|
|
description="Retrieve a quote by its access token",
|
|
status_code=status.HTTP_200_OK,
|
|
responses={
|
|
200: {
|
|
"description": "Quote found and returned",
|
|
"model": QuoteResponse,
|
|
},
|
|
404: {
|
|
"description": "Quote not found",
|
|
"content": {
|
|
"application/json": {
|
|
"example": {"detail": "Quote not found"}
|
|
}
|
|
},
|
|
},
|
|
},
|
|
)
|
|
def get_quote(
|
|
access_token: str,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Get a quote by its access token.
|
|
|
|
Returns the quote with all its items. This is the public endpoint
|
|
for viewing a quote.
|
|
|
|
**Example Request:**
|
|
```
|
|
GET /api/quotes/xYz123abc456def789ghi012jkl345mno678pqr901stu
|
|
```
|
|
|
|
**Example Response:**
|
|
```json
|
|
{
|
|
"id": "123e4567-e89b-12d3-a456-426614174000",
|
|
"access_token": "xYz123abc456def789ghi012jkl345mno678pqr901stu",
|
|
"status": "draft",
|
|
"company_name": null,
|
|
"contact_name": null,
|
|
"contact_email": null,
|
|
"employee_count": 25,
|
|
"monthly_total": "450.00",
|
|
"setup_total": "500.00",
|
|
"annual_total": "5900.00",
|
|
"items": [
|
|
{
|
|
"id": "456e7890-e89b-12d3-a456-426614174001",
|
|
"service_name": "Managed Endpoint Protection",
|
|
"category": "security",
|
|
"unit_price": "15.00",
|
|
"quantity": 25,
|
|
"billing_frequency": "monthly",
|
|
"line_total": "375.00",
|
|
"monthly_amount": "375.00"
|
|
}
|
|
],
|
|
"created_at": "2024-01-15T10:30:00Z",
|
|
"updated_at": "2024-01-15T10:30:00Z"
|
|
}
|
|
```
|
|
"""
|
|
quote = quote_service.get_quote_by_token(db, access_token)
|
|
|
|
# Build response with calculated fields for items
|
|
items_response = []
|
|
for item in quote.items:
|
|
item_dict = QuoteItemResponse(
|
|
id=item.id,
|
|
quote_id=item.quote_id,
|
|
service_name=item.service_name,
|
|
service_description=item.service_description,
|
|
category=item.category,
|
|
billing_frequency=item.billing_frequency,
|
|
unit_price=item.unit_price,
|
|
quantity=item.quantity,
|
|
setup_fee=item.setup_fee,
|
|
is_required=item.is_required,
|
|
sort_order=item.sort_order,
|
|
line_total=item.line_total,
|
|
monthly_amount=item.monthly_amount,
|
|
created_at=item.created_at,
|
|
updated_at=item.updated_at
|
|
)
|
|
items_response.append(item_dict)
|
|
|
|
return QuoteResponse(
|
|
id=quote.id,
|
|
access_token=quote.access_token,
|
|
status=quote.status,
|
|
company_name=quote.company_name,
|
|
contact_name=quote.contact_name,
|
|
contact_email=quote.contact_email,
|
|
contact_phone=quote.contact_phone,
|
|
employee_count=quote.employee_count,
|
|
notes=quote.notes,
|
|
monthly_total=quote.monthly_total,
|
|
setup_total=quote.setup_total,
|
|
annual_total=quote.annual_total,
|
|
expires_at=quote.expires_at,
|
|
submitted_at=quote.submitted_at,
|
|
created_at=quote.created_at,
|
|
updated_at=quote.updated_at,
|
|
items=items_response
|
|
)
|
|
|
|
|
|
@router.put(
|
|
"/{access_token}",
|
|
response_model=QuoteResponse,
|
|
summary="Update quote",
|
|
description="Update a quote's details and/or items",
|
|
status_code=status.HTTP_200_OK,
|
|
responses={
|
|
200: {
|
|
"description": "Quote updated successfully",
|
|
"model": QuoteResponse,
|
|
},
|
|
400: {
|
|
"description": "Quote cannot be modified (not a draft)",
|
|
"content": {
|
|
"application/json": {
|
|
"example": {"detail": "Cannot update quote with status 'submitted'. Only drafts can be modified."}
|
|
}
|
|
},
|
|
},
|
|
404: {
|
|
"description": "Quote not found",
|
|
},
|
|
},
|
|
)
|
|
def update_quote(
|
|
access_token: str,
|
|
quote_data: QuoteUpdate,
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Update a quote.
|
|
|
|
Updates quote details and/or replaces all items. Only draft quotes
|
|
can be modified.
|
|
|
|
**Example Request:**
|
|
```json
|
|
PUT /api/quotes/xYz123abc456def789ghi012jkl345mno678pqr901stu
|
|
Content-Type: application/json
|
|
|
|
{
|
|
"employee_count": 30,
|
|
"items": [
|
|
{
|
|
"service_name": "Managed Endpoint Protection",
|
|
"category": "security",
|
|
"unit_price": "15.00",
|
|
"quantity": 30,
|
|
"billing_frequency": "monthly"
|
|
},
|
|
{
|
|
"service_name": "Cloud Backup",
|
|
"category": "backup",
|
|
"unit_price": "5.00",
|
|
"quantity": 30,
|
|
"billing_frequency": "monthly",
|
|
"setup_fee": "250.00"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
"""
|
|
ip_address = get_client_ip(request)
|
|
|
|
quote = quote_service.update_quote(
|
|
db=db,
|
|
access_token=access_token,
|
|
quote_data=quote_data,
|
|
ip_address=ip_address
|
|
)
|
|
|
|
return get_quote(access_token, db)
|
|
|
|
|
|
@router.post(
|
|
"/{access_token}/items",
|
|
response_model=QuoteResponse,
|
|
summary="Add item to quote",
|
|
description="Add a single item to the quote",
|
|
status_code=status.HTTP_201_CREATED,
|
|
responses={
|
|
201: {
|
|
"description": "Item added successfully",
|
|
"model": QuoteResponse,
|
|
},
|
|
400: {
|
|
"description": "Quote cannot be modified",
|
|
},
|
|
404: {
|
|
"description": "Quote not found",
|
|
},
|
|
},
|
|
)
|
|
def add_item(
|
|
access_token: str,
|
|
item_data: QuoteItemCreate,
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Add a single item to a quote.
|
|
|
|
**Example Request:**
|
|
```json
|
|
POST /api/quotes/xYz123abc456def789ghi012jkl345mno678pqr901stu/items
|
|
Content-Type: application/json
|
|
|
|
{
|
|
"service_name": "24/7 Help Desk Support",
|
|
"category": "support",
|
|
"unit_price": "50.00",
|
|
"quantity": 1,
|
|
"billing_frequency": "monthly"
|
|
}
|
|
```
|
|
"""
|
|
ip_address = get_client_ip(request)
|
|
|
|
quote_service.add_item_to_quote(
|
|
db=db,
|
|
access_token=access_token,
|
|
item_data=item_data,
|
|
ip_address=ip_address
|
|
)
|
|
|
|
return get_quote(access_token, db)
|
|
|
|
|
|
@router.delete(
|
|
"/{access_token}/items/{item_id}",
|
|
response_model=QuoteResponse,
|
|
summary="Remove item from quote",
|
|
description="Remove an item from the quote",
|
|
status_code=status.HTTP_200_OK,
|
|
responses={
|
|
200: {
|
|
"description": "Item removed successfully",
|
|
"model": QuoteResponse,
|
|
},
|
|
400: {
|
|
"description": "Quote cannot be modified or item is required",
|
|
},
|
|
404: {
|
|
"description": "Quote or item not found",
|
|
},
|
|
},
|
|
)
|
|
def remove_item(
|
|
access_token: str,
|
|
item_id: UUID,
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Remove an item from a quote.
|
|
|
|
Required items cannot be removed.
|
|
|
|
**Example Request:**
|
|
```
|
|
DELETE /api/quotes/xYz123abc456def789ghi012jkl345mno678pqr901stu/items/456e7890-e89b-12d3-a456-426614174001
|
|
```
|
|
"""
|
|
ip_address = get_client_ip(request)
|
|
|
|
quote_service.remove_item_from_quote(
|
|
db=db,
|
|
access_token=access_token,
|
|
item_id=item_id,
|
|
ip_address=ip_address
|
|
)
|
|
|
|
return get_quote(access_token, db)
|
|
|
|
|
|
@router.post(
|
|
"/{access_token}/submit",
|
|
response_model=QuoteResponse,
|
|
summary="Submit quote",
|
|
description="Submit the quote with contact information",
|
|
status_code=status.HTTP_200_OK,
|
|
responses={
|
|
200: {
|
|
"description": "Quote submitted successfully",
|
|
"model": QuoteResponse,
|
|
},
|
|
400: {
|
|
"description": "Quote cannot be submitted (not a draft or no items)",
|
|
"content": {
|
|
"application/json": {
|
|
"examples": {
|
|
"not_draft": {
|
|
"value": {"detail": "Cannot submit quote with status 'submitted'. Only drafts can be submitted."}
|
|
},
|
|
"no_items": {
|
|
"value": {"detail": "Cannot submit quote without any items. Please add at least one service."}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
},
|
|
404: {
|
|
"description": "Quote not found",
|
|
},
|
|
422: {
|
|
"description": "Validation error - missing required fields",
|
|
},
|
|
},
|
|
)
|
|
def submit_quote(
|
|
access_token: str,
|
|
submit_data: QuoteSubmit,
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Submit a quote with contact information.
|
|
|
|
This finalizes the quote and sends it for review. Contact information
|
|
is required at this stage.
|
|
|
|
**Example Request:**
|
|
```json
|
|
POST /api/quotes/xYz123abc456def789ghi012jkl345mno678pqr901stu/submit
|
|
Content-Type: application/json
|
|
|
|
{
|
|
"company_name": "Acme Corporation",
|
|
"contact_name": "John Doe",
|
|
"contact_email": "john.doe@acme.com",
|
|
"contact_phone": "555-123-4567",
|
|
"notes": "Please contact me to discuss implementation timeline."
|
|
}
|
|
```
|
|
|
|
**Example Response:**
|
|
```json
|
|
{
|
|
"id": "123e4567-e89b-12d3-a456-426614174000",
|
|
"access_token": "xYz123abc456def789ghi012jkl345mno678pqr901stu",
|
|
"status": "submitted",
|
|
"company_name": "Acme Corporation",
|
|
"contact_name": "John Doe",
|
|
"contact_email": "john.doe@acme.com",
|
|
"submitted_at": "2024-01-15T14:30:00Z",
|
|
...
|
|
}
|
|
```
|
|
"""
|
|
ip_address = get_client_ip(request)
|
|
|
|
quote_service.submit_quote(
|
|
db=db,
|
|
access_token=access_token,
|
|
submit_data=submit_data,
|
|
ip_address=ip_address
|
|
)
|
|
|
|
return get_quote(access_token, db)
|
|
|
|
|
|
@router.get(
|
|
"/{access_token}/pdf",
|
|
summary="Get quote PDF (placeholder)",
|
|
description="Generate and return a PDF version of the quote",
|
|
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
|
responses={
|
|
501: {
|
|
"description": "PDF generation not yet implemented",
|
|
"content": {
|
|
"application/json": {
|
|
"example": {"detail": "PDF generation is not yet implemented"}
|
|
}
|
|
},
|
|
},
|
|
},
|
|
)
|
|
def get_quote_pdf(
|
|
access_token: str,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Generate a PDF version of the quote.
|
|
|
|
**Note:** This endpoint is a placeholder. PDF generation will be
|
|
implemented in a future update.
|
|
"""
|
|
# Verify quote exists
|
|
quote_service.get_quote_by_token(db, access_token)
|
|
|
|
raise HTTPException(
|
|
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
|
detail="PDF generation is not yet implemented"
|
|
)
|