swiftops-backend / src /app /api /v1 /ticket_completion.py
kamau1's picture
refactor: remove reconciliation system and all related code, tasks, and docs
d12a170
"""
Ticket Completion API - Runtime checklist validation with photo upload
NO DATABASE PERSISTENCE OF CHECKLISTS!
Checklist generated on-the-fly from project.activation_requirements and project.photo_requirements
"""
from fastapi import APIRouter, Depends, File, UploadFile, Form, HTTPException
from sqlalchemy.orm import Session
from typing import List, Dict, Optional
from uuid import UUID
import json
import logging
from app.api.deps import get_db, get_current_user
from app.models.user import User
from app.models.ticket import Ticket
from app.services.ticket_completion_service import TicketCompletionService
from app.schemas.ticket_completion import (
TicketCompletionChecklist,
TicketActivationDataUpdate,
TicketCompleteRequest,
TicketCompletionResponse,
ValidationErrorResponse
)
router = APIRouter()
logger = logging.getLogger(__name__)
@router.get(
"/{ticket_id}/completion-checklist",
response_model=TicketCompletionChecklist,
summary="Get Completion Checklist (Runtime Generation)",
description="""
Generate completion checklist on-the-fly from project requirements.
**NO DATABASE PERSISTENCE** - Checklist is ephemeral, generated from:
- project.photo_requirements (JSONB)
- project.activation_requirements (JSONB)
Returns current completion status including:
- Required photos vs uploaded
- Required fields vs filled
- Overall completion percentage
This is called when agent wants to view what's needed to complete ticket.
"""
)
def get_completion_checklist(
ticket_id: UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get or generate completion checklist (runtime only)"""
from sqlalchemy.orm import joinedload
from app.models.ticket_image import TicketImage
# Load ticket with images and their documents for checklist generation
ticket = db.query(Ticket).options(
joinedload(Ticket.images).joinedload(TicketImage.document)
).filter(Ticket.id == ticket_id).first()
if not ticket:
raise HTTPException(status_code=404, detail="Ticket not found")
# Generate checklist from project requirements (runtime)
checklist = TicketCompletionService.generate_checklist(ticket, db)
return checklist
@router.post(
"/{ticket_id}/upload-photos",
response_model=TicketCompletionResponse,
responses={400: {"model": ValidationErrorResponse}},
summary="Upload Photos (Routes to Cloudinary)",
description="""
Upload photos for ticket - Scoped update (photos only).
**Incremental Upload Support:**
- Agents can upload photos one at a time or in batches
- No requirement to upload all photos at once
- Validation only checks max photo limits (prevents spam)
- Completeness validation happens at ticket completion time
**Photo Storage Flow:**
1. Validate photo_type exists in project.photo_requirements
2. Check max photo limits (prevent spam)
3. Upload to Cloudinary via media_service.py
4. Create document record in documents table
5. Link to ticket via ticket_images table
**Multipart Form Data:**
- `photo_type`: Type of photo (matches project.photo_requirements[].type)
- `files`: List of image files
**Use Cases:**
- Upload "before" photos, then "after" photos later
- Upload ODU photo, then speedtest screenshot later
- Upload what you have, complete missing photos later
**Validation at Completion:**
When agent calls POST /complete-ticket, system validates all required photos are present.
"""
)
async def upload_photos(
ticket_id: UUID,
photo_type: str = Form(..., description="Type of photo (e.g., 'before_installation', 'after_installation')"),
files: List[UploadFile] = File(..., description="Photo files to upload"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Upload photos for ticket (routes to Cloudinary via media_service.py)"""
ticket = db.query(Ticket).filter(Ticket.id == ticket_id).first()
if not ticket:
raise HTTPException(status_code=404, detail="Ticket not found")
# Organize photos by type
photos = {photo_type: files}
# Upload and validate
result = await TicketCompletionService.update_photos(
ticket=ticket,
photos=photos,
uploaded_by_user_id=current_user.id,
db=db
)
return result
@router.post(
"/{ticket_id}/completion-data",
response_model=TicketCompletionResponse,
responses={400: {"model": ValidationErrorResponse}},
summary="Set Completion Data (Replace All)",
description="""
Set/replace ALL ticket completion data (activation, inventory, or any custom fields).
**Replaces entire completion_data object** - use this for initial submission or full replacement.
**Data Storage Flow:**
1. Validate against project requirements (activation_requirements + inventory_requirements)
2. Replace ticket.completion_data (JSONB column) entirely
3. Mark ticket.completion_data_verified = true
**Flexible for any project type:**
- Installation projects: ONT serial, ODU serial, router MAC, etc.
- Infrastructure projects: Equipment used, materials consumed, pole IDs, etc.
- Support projects: Replacement parts, diagnostic data, etc.
Project manager defines what fields are needed, this endpoint validates against those.
"""
)
def set_completion_data(
ticket_id: UUID,
request: TicketActivationDataUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Set/replace completion data (replaces entire object)"""
ticket = db.query(Ticket).filter(Ticket.id == ticket_id).first()
if not ticket:
raise HTTPException(status_code=404, detail="Ticket not found")
# Validate and store (replaces entire object)
result = TicketCompletionService.set_completion_data(
ticket=ticket,
completion_data=request.activation_data,
replace=True,
db=db
)
return result
@router.put(
"/{ticket_id}/completion-data",
response_model=TicketCompletionResponse,
responses={400: {"model": ValidationErrorResponse}},
summary="Update Completion Data (Merge)",
description="""
Update/merge ticket completion data (partial update).
**Merges with existing data** - only updates provided fields, keeps others intact.
**Use cases:**
- Agent fills ONT serial first, then adds ODU serial later
- Update one field without resending all fields
- Incremental data entry
**Data Storage Flow:**
1. Merge new data with existing ticket.completion_data
2. Validate merged result against project requirements
3. Store merged data back to ticket.completion_data
4. Mark ticket.completion_data_verified = true if all required fields present
**Example:**
```
Existing data: {"ont_serial": "ABC123"}
Update with: {"odu_serial": "XYZ789"}
Result: {"ont_serial": "ABC123", "odu_serial": "XYZ789"}
```
"""
)
def update_completion_data(
ticket_id: UUID,
request: TicketActivationDataUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Update completion data (merges with existing)"""
ticket = db.query(Ticket).filter(Ticket.id == ticket_id).first()
if not ticket:
raise HTTPException(status_code=404, detail="Ticket not found")
# Validate and merge with existing data
result = TicketCompletionService.set_completion_data(
ticket=ticket,
completion_data=request.activation_data,
replace=False, # Merge mode
db=db
)
return result
@router.post(
"/{ticket_id}/complete",
response_model=TicketCompletionResponse,
responses={400: {"model": ValidationErrorResponse}},
summary="Complete Ticket (Final Validation)",
description="""
Complete ticket - Final validation of both photos and activation data.
**Completion Flow:**
1. Generate checklist (runtime)
2. Validate BOTH scopes complete:
- All required photos uploaded?
- All required fields filled?
3. If validation passes:
- Mark ticket.status = 'completed'
- Installation tickets → Create subscription
- Support tickets → Update subscription equipment
4. Return success with subscription info
If either scope incomplete, returns 400 with specific errors.
Admin can force_complete=true to skip validation.
"""
)
def complete_ticket(
ticket_id: UUID,
request: TicketCompleteRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Complete ticket (final validation + subscription creation)"""
ticket = db.query(Ticket).filter(Ticket.id == ticket_id).first()
if not ticket:
raise HTTPException(status_code=404, detail="Ticket not found")
# Complete ticket with validation
result = TicketCompletionService.complete_ticket(
ticket=ticket,
work_notes=request.work_notes,
force_complete=request.force_complete,
completed_by_user_id=current_user.id,
location_lat=request.location_latitude,
location_lng=request.location_longitude,
location_accuracy=request.location_accuracy,
db=db
)
return result