MukeshKapoor25 commited on
Commit
b548f31
·
1 Parent(s): 65d5f2a

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

app/appointments/controllers/router.py CHANGED
@@ -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, Query, status, Depends
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
- merchant_id: Optional[str] = None,
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
- # Use merchant_id from token if not provided in request
84
- if merchant_id is None:
85
- if not current_user.merchant_id:
86
- raise HTTPException(status_code=400, detail="merchant_id must be provided in request or token")
87
- # Use merchant_id directly as string from JWT token
88
- merchant_id = current_user.merchant_id
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- responses = [await _to_appt_response(i) for i in items]
101
- return ListAppointmentsResponse(items=responses, total=total)
 
 
 
 
 
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"),
app/appointments/schemas/schema.py CHANGED
@@ -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
app/appointments/services/service.py CHANGED
@@ -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
- sql = f"SELECT * FROM trans.pos_appointment pa WHERE {' AND '.join(where)} ORDER BY pa.start_time DESC OFFSET :skip LIMIT :limit"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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)
create_seed_data_simple.py CHANGED
@@ -19,7 +19,8 @@ load_dotenv()
19
  logging.basicConfig(level=logging.INFO)
20
  logger = logging.getLogger(__name__)
21
 
22
- MERCHANT_ID = "company_cuatro_beauty_ltd"
 
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")
scripts/seed_data.py CHANGED
@@ -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
- MERCHANT_ID = "11111111-1111-1111-1111-111111111111"
 
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 = [