- New demo_requests table (SQLAlchemy model + Alembic-ready) - Public POST /api/demo-requests endpoint for the TapTrack website form - Super-admin GET/PUT/DELETE endpoints to manage leads - DemoRequestsPage.vue with stats row, filterable table, status updates, notes modal - Sidebar nav item and route registered
113 lines
3.5 KiB
Python
113 lines
3.5 KiB
Python
"""Demo Requests — public POST from TapTrack Website, super admin manages."""
|
|
from datetime import datetime, timezone
|
|
from typing import Optional
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from pydantic import BaseModel
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select, desc
|
|
|
|
from app.auth.dependencies import require_super_admin
|
|
from app.database import get_db
|
|
from app.models.user import HubUser
|
|
from app.models.demo_request import DemoRequest
|
|
|
|
router = APIRouter(prefix="/api/demo-requests", tags=["demo-requests"])
|
|
|
|
|
|
class DemoRequestSubmit(BaseModel):
|
|
school: str
|
|
contact: str
|
|
phone: str
|
|
|
|
|
|
class DemoRequestUpdate(BaseModel):
|
|
status: Optional[str] = None # "new" | "contacted" | "converted" | "dismissed"
|
|
notes: Optional[str] = None
|
|
|
|
|
|
@router.post("", status_code=201)
|
|
async def submit_demo_request(
|
|
body: DemoRequestSubmit,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Public endpoint — called by TapTrack Website form."""
|
|
req = DemoRequest(
|
|
school_name=body.school,
|
|
contact_person=body.contact,
|
|
phone_or_email=body.phone,
|
|
)
|
|
db.add(req)
|
|
await db.commit()
|
|
return {"ok": True, "id": req.id}
|
|
|
|
|
|
@router.get("")
|
|
async def list_demo_requests(
|
|
status: Optional[str] = None,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
_admin: HubUser = Depends(require_super_admin),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Super admin only — list all demo requests."""
|
|
stmt = select(DemoRequest).order_by(desc(DemoRequest.submitted_at))
|
|
if status:
|
|
stmt = stmt.where(DemoRequest.status == status)
|
|
stmt = stmt.limit(limit).offset(offset)
|
|
items = (await db.execute(stmt)).scalars().all()
|
|
return [
|
|
{
|
|
"id": r.id,
|
|
"school_name": r.school_name,
|
|
"contact_person": r.contact_person,
|
|
"phone_or_email": r.phone_or_email,
|
|
"status": r.status,
|
|
"notes": r.notes,
|
|
"submitted_at": r.submitted_at.isoformat(),
|
|
"updated_at": r.updated_at.isoformat(),
|
|
}
|
|
for r in items
|
|
]
|
|
|
|
|
|
@router.put("/{req_id}")
|
|
async def update_demo_request(
|
|
req_id: str,
|
|
body: DemoRequestUpdate,
|
|
_admin: HubUser = Depends(require_super_admin),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Super admin only — update status/notes on a demo request."""
|
|
req = (await db.execute(select(DemoRequest).where(DemoRequest.id == req_id))).scalar_one_or_none()
|
|
if not req:
|
|
raise HTTPException(404, detail="Demo request not found")
|
|
if body.status is not None:
|
|
valid_statuses = {"new", "contacted", "converted", "dismissed"}
|
|
if body.status not in valid_statuses:
|
|
raise HTTPException(400, detail=f"status must be one of: {', '.join(valid_statuses)}")
|
|
req.status = body.status
|
|
if body.notes is not None:
|
|
req.notes = body.notes
|
|
req.updated_at = datetime.now(timezone.utc)
|
|
await db.commit()
|
|
return {
|
|
"id": req.id,
|
|
"status": req.status,
|
|
"notes": req.notes,
|
|
"updated_at": req.updated_at.isoformat(),
|
|
}
|
|
|
|
|
|
@router.delete("/{req_id}", status_code=204)
|
|
async def delete_demo_request(
|
|
req_id: str,
|
|
_admin: HubUser = Depends(require_super_admin),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Super admin only — permanently delete a demo request."""
|
|
req = (await db.execute(select(DemoRequest).where(DemoRequest.id == req_id))).scalar_one_or_none()
|
|
if not req:
|
|
raise HTTPException(404, detail="Demo request not found")
|
|
await db.delete(req)
|
|
await db.commit()
|