feat: add Demo Requests module
- 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
This commit is contained in:
@@ -5,11 +5,12 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.database import engine, Base
|
||||
# Import all models so Alembic/SQLAlchemy picks them up
|
||||
from app.models import user, school, license, sms, billing, ticket, audit, announcement, email_log, report # noqa
|
||||
from app.models import user, school, license, sms, billing, ticket, audit, announcement, email_log, report, demo_request # noqa
|
||||
|
||||
from app.routers import auth, schools, licenses, sms as sms_router, billing as billing_router
|
||||
from app.routers import tickets, users, dashboard, school_portal, announcements, sync, email as email_router
|
||||
from app.routers import search, audit
|
||||
from app.routers import demo_requests as demo_requests_router
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
@@ -48,6 +49,7 @@ app.include_router(sync.router)
|
||||
app.include_router(email_router.router)
|
||||
app.include_router(search.router)
|
||||
app.include_router(audit.router)
|
||||
app.include_router(demo_requests_router.router)
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
|
||||
25
backend/app/models/demo_request.py
Normal file
25
backend/app/models/demo_request.py
Normal file
@@ -0,0 +1,25 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import String, DateTime, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class DemoRequest(Base):
|
||||
__tablename__ = "demo_requests"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
school_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
contact_person: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
phone_or_email: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(50), default="new", nullable=False)
|
||||
# "new" | "contacted" | "converted" | "dismissed"
|
||||
submitted_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
112
backend/app/routers/demo_requests.py
Normal file
112
backend/app/routers/demo_requests.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user