Spaces:
Running
feat(appointments): Add projection support and request schema for list appointments endpoint
Browse files- Add ListAppointmentsRequest schema following SCM standard pattern with filters, skip, limit, and projection_list fields
- Refactor list_appointments_endpoint to accept request payload instead of individual query parameters
- Implement projection support in list_appointments service to selectively return appointment fields
- Convert merchant_id to UUID in service layer while maintaining string representation in responses
- Update appointment response conversion to handle merchant_id as string for API consistency
- Add backward compatibility for filter parameter extraction from request payload
- Return raw dict responses when projection is used, otherwise convert to AppointmentResponse models
- Remove unused Query import from router
- Align appointments list endpoint with existing SCM patterns used in staff and services endpoints
|
@@ -5,7 +5,7 @@ import logging
|
|
| 5 |
from uuid import UUID
|
| 6 |
from typing import Optional
|
| 7 |
from datetime import datetime
|
| 8 |
-
from fastapi import APIRouter, HTTPException,
|
| 9 |
|
| 10 |
from app.dependencies.auth import TokenUser
|
| 11 |
from app.dependencies.pos_permissions import require_pos_permission
|
|
@@ -15,6 +15,7 @@ from app.appointments.schemas.schema import (
|
|
| 15 |
UpdateStatusRequest,
|
| 16 |
AppointmentResponse,
|
| 17 |
AppointmentServiceResponse,
|
|
|
|
| 18 |
ListAppointmentsResponse,
|
| 19 |
CheckoutResponse,
|
| 20 |
)
|
|
@@ -51,7 +52,7 @@ async def create_appointment_endpoint(
|
|
| 51 |
merchant_id = current_user.merchant_id
|
| 52 |
|
| 53 |
aid = await create_appointment(
|
| 54 |
-
merchant_id=merchant_id,
|
| 55 |
source=req.source,
|
| 56 |
booking_channel=req.booking_channel,
|
| 57 |
customer_id=req.customer_id,
|
|
@@ -70,25 +71,32 @@ async def create_appointment_endpoint(
|
|
| 70 |
|
| 71 |
@router.post("/list", response_model=ListAppointmentsResponse)
|
| 72 |
async def list_appointments_endpoint(
|
| 73 |
-
|
| 74 |
-
start_from: Optional[datetime] = Query(None),
|
| 75 |
-
start_to: Optional[datetime] = Query(None),
|
| 76 |
-
staff_id: Optional[UUID] = Query(None),
|
| 77 |
-
status: Optional[str] = Query(None),
|
| 78 |
-
source: Optional[str] = Query(None),
|
| 79 |
-
skip: int = Query(0, ge=0),
|
| 80 |
-
limit: int = Query(100, ge=1, le=1000),
|
| 81 |
current_user: TokenUser = Depends(require_pos_permission("retail_appointments", "view"))
|
| 82 |
):
|
| 83 |
-
#
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
|
| 90 |
items, total = await list_appointments(
|
| 91 |
-
merchant_id=merchant_id,
|
| 92 |
start_from=start_from,
|
| 93 |
start_to=start_to,
|
| 94 |
staff_id=staff_id,
|
|
@@ -96,9 +104,15 @@ async def list_appointments_endpoint(
|
|
| 96 |
source=source,
|
| 97 |
skip=skip,
|
| 98 |
limit=limit,
|
|
|
|
| 99 |
)
|
| 100 |
-
|
| 101 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
|
| 103 |
@router.get("/{appointment_id}", response_model=AppointmentResponse)
|
| 104 |
async def get_appointment_endpoint(
|
|
@@ -168,7 +182,7 @@ async def _to_appt_response(appt: dict) -> AppointmentResponse:
|
|
| 168 |
) for s in appt.get("services", [])]
|
| 169 |
return AppointmentResponse(
|
| 170 |
appointment_id=UUID(appt["appointment_id"]) if isinstance(appt["appointment_id"], str) else appt["appointment_id"],
|
| 171 |
-
merchant_id=appt["merchant_id"],
|
| 172 |
source=appt["source"],
|
| 173 |
booking_channel=appt.get("booking_channel"),
|
| 174 |
customer_id=UUID(appt["customer_id"]) if appt.get("customer_id") and isinstance(appt["customer_id"], str) else appt.get("customer_id"),
|
|
|
|
| 5 |
from uuid import UUID
|
| 6 |
from typing import Optional
|
| 7 |
from datetime import datetime
|
| 8 |
+
from fastapi import APIRouter, HTTPException, status, Depends
|
| 9 |
|
| 10 |
from app.dependencies.auth import TokenUser
|
| 11 |
from app.dependencies.pos_permissions import require_pos_permission
|
|
|
|
| 15 |
UpdateStatusRequest,
|
| 16 |
AppointmentResponse,
|
| 17 |
AppointmentServiceResponse,
|
| 18 |
+
ListAppointmentsRequest,
|
| 19 |
ListAppointmentsResponse,
|
| 20 |
CheckoutResponse,
|
| 21 |
)
|
|
|
|
| 52 |
merchant_id = current_user.merchant_id
|
| 53 |
|
| 54 |
aid = await create_appointment(
|
| 55 |
+
merchant_id=UUID(merchant_id),
|
| 56 |
source=req.source,
|
| 57 |
booking_channel=req.booking_channel,
|
| 58 |
customer_id=req.customer_id,
|
|
|
|
| 71 |
|
| 72 |
@router.post("/list", response_model=ListAppointmentsResponse)
|
| 73 |
async def list_appointments_endpoint(
|
| 74 |
+
payload: ListAppointmentsRequest,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
current_user: TokenUser = Depends(require_pos_permission("retail_appointments", "view"))
|
| 76 |
):
|
| 77 |
+
# Extract filters and parameters from payload
|
| 78 |
+
filters = payload.filters or {}
|
| 79 |
+
skip = payload.skip or 0
|
| 80 |
+
limit = payload.limit or 100
|
| 81 |
+
projection_list = payload.projection_list
|
| 82 |
+
|
| 83 |
+
# Use merchant_id from token (following SCM pattern)
|
| 84 |
+
if not current_user.merchant_id:
|
| 85 |
+
raise HTTPException(status_code=400, detail="merchant_id must be available in token")
|
| 86 |
+
|
| 87 |
+
# Extract individual filter parameters for backward compatibility
|
| 88 |
+
start_from = None
|
| 89 |
+
start_to = None
|
| 90 |
+
if filters.get("start_from"):
|
| 91 |
+
start_from = filters["start_from"] if isinstance(filters["start_from"], datetime) else datetime.fromisoformat(filters["start_from"])
|
| 92 |
+
if filters.get("start_to"):
|
| 93 |
+
start_to = filters["start_to"] if isinstance(filters["start_to"], datetime) else datetime.fromisoformat(filters["start_to"])
|
| 94 |
+
staff_id = UUID(filters["staff_id"]) if filters.get("staff_id") else None
|
| 95 |
+
status = filters.get("status")
|
| 96 |
+
source = filters.get("source")
|
| 97 |
|
| 98 |
items, total = await list_appointments(
|
| 99 |
+
merchant_id=UUID(current_user.merchant_id),
|
| 100 |
start_from=start_from,
|
| 101 |
start_to=start_to,
|
| 102 |
staff_id=staff_id,
|
|
|
|
| 104 |
source=source,
|
| 105 |
skip=skip,
|
| 106 |
limit=limit,
|
| 107 |
+
projection_list=projection_list,
|
| 108 |
)
|
| 109 |
+
|
| 110 |
+
# Return raw dict if projection used, otherwise convert to response models
|
| 111 |
+
if projection_list:
|
| 112 |
+
return {"items": items, "total": total}
|
| 113 |
+
else:
|
| 114 |
+
responses = [await _to_appt_response(i) for i in items]
|
| 115 |
+
return ListAppointmentsResponse(items=responses, total=total)
|
| 116 |
|
| 117 |
@router.get("/{appointment_id}", response_model=AppointmentResponse)
|
| 118 |
async def get_appointment_endpoint(
|
|
|
|
| 182 |
) for s in appt.get("services", [])]
|
| 183 |
return AppointmentResponse(
|
| 184 |
appointment_id=UUID(appt["appointment_id"]) if isinstance(appt["appointment_id"], str) else appt["appointment_id"],
|
| 185 |
+
merchant_id=str(appt["merchant_id"]) if appt["merchant_id"] else None,
|
| 186 |
source=appt["source"],
|
| 187 |
booking_channel=appt.get("booking_channel"),
|
| 188 |
customer_id=UUID(appt["customer_id"]) if appt.get("customer_id") and isinstance(appt["customer_id"], str) else appt.get("customer_id"),
|
|
@@ -1,7 +1,7 @@
|
|
| 1 |
"""
|
| 2 |
Pydantic schemas for Appointment APIs.
|
| 3 |
"""
|
| 4 |
-
from typing import List, Optional
|
| 5 |
from pydantic import BaseModel, Field
|
| 6 |
from uuid import UUID
|
| 7 |
from datetime import datetime
|
|
@@ -58,6 +58,28 @@ class AppointmentResponse(BaseModel):
|
|
| 58 |
created_by: Optional[UUID]
|
| 59 |
services: List[AppointmentServiceResponse] = []
|
| 60 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
class ListAppointmentsResponse(BaseModel):
|
| 62 |
items: List[AppointmentResponse]
|
| 63 |
total: int
|
|
|
|
| 1 |
"""
|
| 2 |
Pydantic schemas for Appointment APIs.
|
| 3 |
"""
|
| 4 |
+
from typing import List, Optional, Dict, Any
|
| 5 |
from pydantic import BaseModel, Field
|
| 6 |
from uuid import UUID
|
| 7 |
from datetime import datetime
|
|
|
|
| 58 |
created_by: Optional[UUID]
|
| 59 |
services: List[AppointmentServiceResponse] = []
|
| 60 |
|
| 61 |
+
class ListAppointmentsRequest(BaseModel):
|
| 62 |
+
"""List appointments request following SCM standard pattern"""
|
| 63 |
+
filters: Optional[Dict[str, Any]] = Field(
|
| 64 |
+
None,
|
| 65 |
+
description="Filter criteria for appointments"
|
| 66 |
+
)
|
| 67 |
+
skip: Optional[int] = Field(
|
| 68 |
+
0,
|
| 69 |
+
ge=0,
|
| 70 |
+
description="Number of records to skip"
|
| 71 |
+
)
|
| 72 |
+
limit: Optional[int] = Field(
|
| 73 |
+
100,
|
| 74 |
+
ge=1,
|
| 75 |
+
le=1000,
|
| 76 |
+
description="Maximum records to return (1-1000)"
|
| 77 |
+
)
|
| 78 |
+
projection_list: Optional[List[str]] = Field(
|
| 79 |
+
None,
|
| 80 |
+
description="List of fields to include in response"
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
class ListAppointmentsResponse(BaseModel):
|
| 84 |
items: List[AppointmentResponse]
|
| 85 |
total: int
|
|
@@ -137,6 +137,7 @@ async def list_appointments(
|
|
| 137 |
source: Optional[str] = None,
|
| 138 |
skip: int = 0,
|
| 139 |
limit: int = 100,
|
|
|
|
| 140 |
) -> Tuple[List[dict], int]:
|
| 141 |
async with get_postgres_session() as session:
|
| 142 |
if session is None:
|
|
@@ -158,9 +159,38 @@ async def list_appointments(
|
|
| 158 |
if staff_id:
|
| 159 |
where.append("EXISTS (SELECT 1 FROM trans.pos_appointment_service s WHERE s.appointment_id = pa.appointment_id AND s.staff_id = :sid)")
|
| 160 |
params["sid"] = str(staff_id)
|
| 161 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
rs = await session.execute(text(sql), params)
|
| 163 |
items = [dict(r._mapping) for r in rs.fetchall()]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
# total
|
| 165 |
rsc = await session.execute(text(f"SELECT COUNT(*) FROM trans.pos_appointment pa WHERE {' AND '.join(where)}"), {k: v for k, v in params.items() if k not in ("skip", "limit")})
|
| 166 |
total = int(rsc.scalar() or 0)
|
|
|
|
| 137 |
source: Optional[str] = None,
|
| 138 |
skip: int = 0,
|
| 139 |
limit: int = 100,
|
| 140 |
+
projection_list: Optional[List[str]] = None,
|
| 141 |
) -> Tuple[List[dict], int]:
|
| 142 |
async with get_postgres_session() as session:
|
| 143 |
if session is None:
|
|
|
|
| 159 |
if staff_id:
|
| 160 |
where.append("EXISTS (SELECT 1 FROM trans.pos_appointment_service s WHERE s.appointment_id = pa.appointment_id AND s.staff_id = :sid)")
|
| 161 |
params["sid"] = str(staff_id)
|
| 162 |
+
|
| 163 |
+
# Build SELECT clause based on projection_list
|
| 164 |
+
if projection_list:
|
| 165 |
+
# For PostgreSQL, we need to map projection fields to actual columns
|
| 166 |
+
select_fields = []
|
| 167 |
+
for field in projection_list:
|
| 168 |
+
if field in ["appointment_id", "merchant_id", "source", "booking_channel",
|
| 169 |
+
"customer_id", "customer_name", "customer_phone", "status",
|
| 170 |
+
"start_time", "end_time", "notes", "created_by"]:
|
| 171 |
+
select_fields.append(f"pa.{field}")
|
| 172 |
+
if not select_fields:
|
| 173 |
+
select_fields = ["pa.*"] # fallback
|
| 174 |
+
select_clause = ", ".join(select_fields)
|
| 175 |
+
else:
|
| 176 |
+
select_clause = "pa.*"
|
| 177 |
+
|
| 178 |
+
sql = f"SELECT {select_clause} FROM trans.pos_appointment pa WHERE {' AND '.join(where)} ORDER BY pa.start_time DESC OFFSET :skip LIMIT :limit"
|
| 179 |
rs = await session.execute(text(sql), params)
|
| 180 |
items = [dict(r._mapping) for r in rs.fetchall()]
|
| 181 |
+
|
| 182 |
+
# If projection_list is used, return raw dict without services
|
| 183 |
+
if projection_list:
|
| 184 |
+
# total
|
| 185 |
+
rsc = await session.execute(text(f"SELECT COUNT(*) FROM trans.pos_appointment pa WHERE {' AND '.join(where)}"), {k: v for k, v in params.items() if k not in ("skip", "limit")})
|
| 186 |
+
total = int(rsc.scalar() or 0)
|
| 187 |
+
return items, total
|
| 188 |
+
|
| 189 |
+
# For full response, add services to each appointment
|
| 190 |
+
for item in items:
|
| 191 |
+
svc_rs = await session.execute(text("SELECT * FROM trans.pos_appointment_service WHERE appointment_id=:aid"), {"aid": item["appointment_id"]})
|
| 192 |
+
item["services"] = [dict(r._mapping) for r in svc_rs.fetchall()]
|
| 193 |
+
|
| 194 |
# total
|
| 195 |
rsc = await session.execute(text(f"SELECT COUNT(*) FROM trans.pos_appointment pa WHERE {' AND '.join(where)}"), {k: v for k, v in params.items() if k not in ("skip", "limit")})
|
| 196 |
total = int(rsc.scalar() or 0)
|
|
@@ -19,7 +19,8 @@ load_dotenv()
|
|
| 19 |
logging.basicConfig(level=logging.INFO)
|
| 20 |
logger = logging.getLogger(__name__)
|
| 21 |
|
| 22 |
-
|
|
|
|
| 23 |
|
| 24 |
# MongoDB connection from .env
|
| 25 |
MONGODB_URI = os.getenv("MONGODB_URI", "mongodb://localhost:27017")
|
|
|
|
| 19 |
logging.basicConfig(level=logging.INFO)
|
| 20 |
logger = logging.getLogger(__name__)
|
| 21 |
|
| 22 |
+
# Use standard UUID for consistency across services
|
| 23 |
+
MERCHANT_ID = "01234567-89ab-cdef-0123-456789abcdef" # Cuatro Beauty Ltd UUID
|
| 24 |
|
| 25 |
# MongoDB connection from .env
|
| 26 |
MONGODB_URI = os.getenv("MONGODB_URI", "mongodb://localhost:27017")
|
|
@@ -21,7 +21,8 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
| 21 |
from app.nosql import connect_to_mongo, close_mongo_connection, get_database
|
| 22 |
from app.core.config import settings
|
| 23 |
|
| 24 |
-
|
|
|
|
| 25 |
|
| 26 |
# Sample Services
|
| 27 |
SAMPLE_SERVICES = [
|
|
|
|
| 21 |
from app.nosql import connect_to_mongo, close_mongo_connection, get_database
|
| 22 |
from app.core.config import settings
|
| 23 |
|
| 24 |
+
# Use standard UUID for consistency across services
|
| 25 |
+
MERCHANT_ID = "01234567-89ab-cdef-0123-456789abcdef" # Cuatro Beauty Ltd UUID
|
| 26 |
|
| 27 |
# Sample Services
|
| 28 |
SAMPLE_SERVICES = [
|