Michael-Antony commited on
Commit
80b4a42
·
1 Parent(s): 9d8e0f2

feat: implement GET /trips endpoint with optimizations

Browse files

- Add GET /trips endpoint to fetch user trips from last 7 days
- Format duration as human-readable string (e.g., '8h 30m', '45m')
- Optimize query with composite index on (user_id, merchant_id, created_at)
- Remove redundant indexes to improve write performance
- Extract helper functions for duration calculation and formatting
- Fix attendance location_id type from string to UUID
- Add MS_PER_MINUTE constant for better code clarity
- Use integer division for duration calculations

app/tracker/attendance/schemas.py CHANGED
@@ -2,6 +2,7 @@
2
  Pydantic schemas for attendance module.
3
  """
4
  from typing import Optional
 
5
  from pydantic import BaseModel, Field, field_validator
6
  from datetime import datetime
7
 
@@ -10,7 +11,7 @@ class CheckInRequest(BaseModel):
10
  """Request schema for check-in"""
11
  latitude: float = Field(..., description="GPS latitude", ge=-90, le=90)
12
  longitude: float = Field(..., description="GPS longitude", ge=-180, le=180)
13
- location_id: Optional[str] = Field(None, description="Geofence location ID if inside a geofence")
14
 
15
  @field_validator('latitude')
16
  @classmethod
@@ -33,7 +34,7 @@ class CheckInRequest(BaseModel):
33
  "example": {
34
  "latitude": 19.0760,
35
  "longitude": 72.8777,
36
- "location_id": "loc_mumbai_office_001"
37
  }
38
  }
39
  }
 
2
  Pydantic schemas for attendance module.
3
  """
4
  from typing import Optional
5
+ from uuid import UUID
6
  from pydantic import BaseModel, Field, field_validator
7
  from datetime import datetime
8
 
 
11
  """Request schema for check-in"""
12
  latitude: float = Field(..., description="GPS latitude", ge=-90, le=90)
13
  longitude: float = Field(..., description="GPS longitude", ge=-180, le=180)
14
+ location_id: Optional[UUID] = Field(None, description="Geofence location UUID if inside a geofence")
15
 
16
  @field_validator('latitude')
17
  @classmethod
 
34
  "example": {
35
  "latitude": 19.0760,
36
  "longitude": 72.8777,
37
+ "location_id": "550e8400-e29b-41d4-a716-446655440000"
38
  }
39
  }
40
  }
app/tracker/trips/router.py CHANGED
@@ -10,6 +10,8 @@ from app.tracker.trips.schemas import (
10
  StartTripResponse,
11
  StopTripRequest,
12
  StopTripResponse,
 
 
13
  ErrorResponse
14
  )
15
 
@@ -422,3 +424,162 @@ async def stop_trip(
422
  "error_code": "INTERNAL_ERROR"
423
  }
424
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  StartTripResponse,
11
  StopTripRequest,
12
  StopTripResponse,
13
+ GetTripsResponse,
14
+ TripItem,
15
  ErrorResponse
16
  )
17
 
 
424
  "error_code": "INTERNAL_ERROR"
425
  }
426
  )
427
+
428
+
429
+ @router.get(
430
+ "",
431
+ response_model=GetTripsResponse,
432
+ status_code=status.HTTP_200_OK,
433
+ responses={
434
+ 200: {
435
+ "description": "Trips retrieved successfully",
436
+ "model": GetTripsResponse
437
+ },
438
+ 401: {
439
+ "description": "Unauthorized - invalid or missing JWT token",
440
+ "model": ErrorResponse
441
+ },
442
+ 500: {
443
+ "description": "Internal server error",
444
+ "model": ErrorResponse
445
+ }
446
+ },
447
+ summary="Get User Trips",
448
+ description="""
449
+ Retrieve list of trips for the authenticated user from the last 7 days.
450
+
451
+ **Use Cases:**
452
+ - View trip history
453
+ - Track business travel
454
+ - Generate trip reports
455
+ - Monitor active trips
456
+ - Analyze travel patterns
457
+
458
+ **Business Rules:**
459
+ - ✅ Returns trips from last 7 days only
460
+ - ✅ Ordered by creation date (newest first)
461
+ - ✅ Includes both active and completed trips
462
+ - ✅ User can only see their own trips
463
+ - ✅ Trips are merchant-scoped (multi-tenant isolation)
464
+
465
+ **Response Fields:**
466
+ - tripId: Unique trip identifier (UUID)
467
+ - tripDate: Trip creation date (YYYY-MM-DD)
468
+ - status: Trip status (active/completed)
469
+ - distanceKm: Distance traveled in kilometers
470
+ - duration: Human-readable duration (e.g., "8h 30m" or "45m")
471
+ - startedAt: Start timestamp in milliseconds
472
+ - stoppedAt: Stop timestamp in milliseconds (null for active trips)
473
+
474
+ **Duration Format:**
475
+ - >= 1 hour: "Xh Ym" (e.g., "8h 30m", "2h 15m")
476
+ - Exact hours: "Xh" (e.g., "3h")
477
+ - < 1 hour: "Xm" (e.g., "45m", "5m")
478
+
479
+ **Duration Calculation:**
480
+ - Completed trips: stoppedAt - startedAt
481
+ - Active trips: currentTime - startedAt
482
+
483
+ **Distance Calculation:**
484
+ - Converted from meters to kilometers
485
+ - Rounded to 2 decimal places
486
+ - 0.0 for trips without distance data
487
+
488
+ **Performance:**
489
+ - Indexed by user_id and created_at
490
+ - Limited to 7 days for optimal performance
491
+ - Efficient query with proper filtering
492
+
493
+ **Error Handling:**
494
+ - 401 Unauthorized: If JWT invalid
495
+ - 500 Internal Error: If database operation fails
496
+ """
497
+ )
498
+ async def get_trips(
499
+ current_user: TokenUser = Depends(get_current_user),
500
+ service: TripService = Depends(get_trip_service)
501
+ ) -> GetTripsResponse:
502
+ """
503
+ Get list of trips for the authenticated user from the last 7 days.
504
+
505
+ Args:
506
+ current_user: Authenticated user from JWT token
507
+ service: Trip service instance
508
+
509
+ Returns:
510
+ GetTripsResponse with list of trips
511
+
512
+ Raises:
513
+ HTTPException 401: If authentication fails
514
+ HTTPException 500: If internal error occurs
515
+ """
516
+ try:
517
+ logger.info(
518
+ "Fetching trips",
519
+ extra={
520
+ "user_id": current_user.user_id,
521
+ "merchant_id": current_user.merchant_id
522
+ }
523
+ )
524
+
525
+ # Get trips from service
526
+ # Security: user_id and merchant_id come from validated JWT token
527
+ trips_data = await service.get_trips(
528
+ user_id=str(current_user.user_id),
529
+ merchant_id=str(current_user.merchant_id),
530
+ days=7
531
+ )
532
+
533
+ # Convert to response format
534
+ trips = [
535
+ TripItem(
536
+ tripId=trip["trip_id"],
537
+ tripDate=trip["trip_date"],
538
+ status=trip["status"],
539
+ distanceKm=trip["distance_km"],
540
+ duration=trip["duration"],
541
+ startedAt=trip["started_at"],
542
+ stoppedAt=trip["stopped_at"]
543
+ )
544
+ for trip in trips_data
545
+ ]
546
+
547
+ logger.info(
548
+ f"Retrieved {len(trips)} trips successfully",
549
+ extra={
550
+ "user_id": current_user.user_id,
551
+ "merchant_id": current_user.merchant_id,
552
+ "trip_count": len(trips)
553
+ }
554
+ )
555
+
556
+ return GetTripsResponse(
557
+ success=True,
558
+ trips=trips,
559
+ count=len(trips)
560
+ )
561
+
562
+ except HTTPException:
563
+ # Re-raise HTTP exceptions (already formatted)
564
+ raise
565
+
566
+ except Exception as e:
567
+ # Unexpected errors
568
+ logger.error(
569
+ f"Failed to fetch trips: {str(e)}",
570
+ extra={
571
+ "user_id": current_user.user_id,
572
+ "merchant_id": current_user.merchant_id,
573
+ "error": str(e)
574
+ },
575
+ exc_info=True
576
+ )
577
+ raise HTTPException(
578
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
579
+ detail={
580
+ "success": False,
581
+ "error": "Internal Server Error",
582
+ "detail": "Failed to fetch trips. Please try again later.",
583
+ "error_code": "INTERNAL_ERROR"
584
+ }
585
+ )
app/tracker/trips/schemas.py CHANGED
@@ -198,3 +198,62 @@ class StopTripResponse(BaseModel):
198
  "durationMinutes": 45
199
  }
200
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  "durationMinutes": 45
199
  }
200
  }
201
+
202
+
203
+ class TripItem(BaseModel):
204
+ """Individual trip item in list"""
205
+ tripId: str = Field(..., description="UUID of the trip")
206
+ tripDate: str = Field(..., description="Trip date in YYYY-MM-DD format")
207
+ status: str = Field(..., description="Trip status (active/completed)")
208
+ distanceKm: float = Field(..., description="Distance traveled in kilometers")
209
+ duration: str = Field(..., description="Trip duration as human-readable string (e.g., '8h 30m' or '45m')")
210
+ startedAt: int = Field(..., description="Trip start timestamp in milliseconds")
211
+ stoppedAt: Optional[int] = Field(None, description="Trip stop timestamp in milliseconds (null if active)")
212
+
213
+ class Config:
214
+ json_schema_extra = {
215
+ "example": {
216
+ "tripId": "550e8400-e29b-41d4-a716-446655440000",
217
+ "tripDate": "2026-03-13",
218
+ "status": "completed",
219
+ "distanceKm": 15.5,
220
+ "duration": "8h 30m",
221
+ "startedAt": 1678901234567,
222
+ "stoppedAt": 1678905234567
223
+ }
224
+ }
225
+
226
+
227
+ class GetTripsResponse(BaseModel):
228
+ """Response for get trips list"""
229
+ success: bool = Field(True, description="Whether the request was successful")
230
+ trips: list[TripItem] = Field(..., description="List of trips")
231
+ count: int = Field(..., description="Total number of trips returned")
232
+
233
+ class Config:
234
+ json_schema_extra = {
235
+ "example": {
236
+ "success": True,
237
+ "count": 2,
238
+ "trips": [
239
+ {
240
+ "tripId": "550e8400-e29b-41d4-a716-446655440000",
241
+ "tripDate": "2026-03-13",
242
+ "status": "completed",
243
+ "distanceKm": 15.5,
244
+ "duration": "8h 30m",
245
+ "startedAt": 1678901234567,
246
+ "stoppedAt": 1678905234567
247
+ },
248
+ {
249
+ "tripId": "550e8400-e29b-41d4-a716-446655440001",
250
+ "tripDate": "2026-03-12",
251
+ "status": "active",
252
+ "distanceKm": 0.0,
253
+ "duration": "45m",
254
+ "startedAt": 1678801234567,
255
+ "stoppedAt": None
256
+ }
257
+ ]
258
+ }
259
+ }
app/tracker/trips/service.py CHANGED
@@ -2,6 +2,7 @@
2
  Service layer for trip operations.
3
  Handles business logic and database interactions for trips.
4
  """
 
5
  from typing import Optional
6
  from uuid import UUID, uuid4
7
  from app.postgres import get_postgres_connection, release_postgres_connection
@@ -9,6 +10,57 @@ from app.core.logging import get_logger
9
 
10
  logger = get_logger(__name__)
11
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
  class TripService:
14
  """Service for handling trip operations"""
@@ -313,7 +365,7 @@ class TripService:
313
 
314
  # Calculate duration in minutes
315
  duration_ms = stopped_at - trip['started_at']
316
- duration_minutes = int(duration_ms / 60000)
317
 
318
  # Update trip
319
  update_query = """
@@ -375,6 +427,107 @@ class TripService:
375
  finally:
376
  if conn:
377
  await release_postgres_connection(conn)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
378
 
379
 
380
  def get_trip_service() -> TripService:
 
2
  Service layer for trip operations.
3
  Handles business logic and database interactions for trips.
4
  """
5
+ import time
6
  from typing import Optional
7
  from uuid import UUID, uuid4
8
  from app.postgres import get_postgres_connection, release_postgres_connection
 
10
 
11
  logger = get_logger(__name__)
12
 
13
+ # Constants
14
+ MS_PER_MINUTE = 60000 # Milliseconds in one minute
15
+
16
+
17
+ def _format_duration(duration_minutes: int) -> str:
18
+ """
19
+ Format duration in minutes to human-readable string.
20
+
21
+ Args:
22
+ duration_minutes: Duration in minutes
23
+
24
+ Returns:
25
+ Formatted string (e.g., "8h 30m", "3h", "45m")
26
+ """
27
+ if duration_minutes >= 60:
28
+ hours = duration_minutes // 60
29
+ minutes = duration_minutes % 60
30
+ if minutes > 0:
31
+ return f"{hours}h {minutes}m"
32
+ else:
33
+ return f"{hours}h"
34
+ else:
35
+ return f"{duration_minutes}m"
36
+
37
+
38
+ def _calculate_duration_minutes(
39
+ status: str,
40
+ started_at: Optional[int],
41
+ stopped_at: Optional[int],
42
+ current_time_ms: int
43
+ ) -> int:
44
+ """
45
+ Calculate trip duration in minutes.
46
+
47
+ Args:
48
+ status: Trip status (active/completed)
49
+ started_at: Start timestamp in milliseconds
50
+ stopped_at: Stop timestamp in milliseconds (None for active trips)
51
+ current_time_ms: Current time in milliseconds
52
+
53
+ Returns:
54
+ Duration in minutes
55
+ """
56
+ if status == 'completed' and stopped_at and started_at:
57
+ duration_ms = stopped_at - started_at
58
+ return duration_ms // MS_PER_MINUTE
59
+ elif status == 'active' and started_at:
60
+ duration_ms = current_time_ms - started_at
61
+ return duration_ms // MS_PER_MINUTE
62
+ return 0
63
+
64
 
65
  class TripService:
66
  """Service for handling trip operations"""
 
365
 
366
  # Calculate duration in minutes
367
  duration_ms = stopped_at - trip['started_at']
368
+ duration_minutes = duration_ms // MS_PER_MINUTE
369
 
370
  # Update trip
371
  update_query = """
 
427
  finally:
428
  if conn:
429
  await release_postgres_connection(conn)
430
+
431
+ async def get_trips(
432
+ self,
433
+ user_id: str,
434
+ merchant_id: str,
435
+ days: int = 7
436
+ ) -> list[dict]:
437
+ """
438
+ Get list of trips for a user in the last N days.
439
+
440
+ Args:
441
+ user_id: User UUID
442
+ merchant_id: Merchant UUID (for security validation)
443
+ days: Number of days to look back (default: 7)
444
+
445
+ Returns:
446
+ List of trip dictionaries with:
447
+ - trip_id: Trip UUID
448
+ - trip_date: Created date (YYYY-MM-DD)
449
+ - status: Trip status (active/completed)
450
+ - distance_km: Distance in kilometers
451
+ - duration_minutes: Total trip time in minutes
452
+ - started_at: Start timestamp
453
+ - stopped_at: Stop timestamp (null if active)
454
+
455
+ Raises:
456
+ Exception: If database operation fails
457
+ """
458
+ conn = None
459
+ try:
460
+ conn = await get_postgres_connection()
461
+
462
+ query = """
463
+ SELECT
464
+ id,
465
+ started_at,
466
+ stopped_at,
467
+ distance_meters,
468
+ status,
469
+ created_at
470
+ FROM trans.scm_trips
471
+ WHERE user_id = $1
472
+ AND merchant_id = $2
473
+ AND created_at >= NOW() - INTERVAL '1 day' * $3
474
+ ORDER BY created_at DESC
475
+ """
476
+
477
+ rows = await conn.fetch(query, user_id, merchant_id, days)
478
+
479
+ # Calculate current time once for all active trips
480
+ current_time_ms = int(time.time() * 1000)
481
+
482
+ trips = []
483
+ for row in rows:
484
+ # Calculate distance in km
485
+ distance_km = round(row['distance_meters'] / 1000.0, 2) if row['distance_meters'] else 0.0
486
+
487
+ # Calculate duration
488
+ duration_minutes = _calculate_duration_minutes(
489
+ status=row['status'],
490
+ started_at=row['started_at'],
491
+ stopped_at=row['stopped_at'],
492
+ current_time_ms=current_time_ms
493
+ )
494
+
495
+ trips.append({
496
+ "trip_id": str(row['id']),
497
+ "trip_date": row['created_at'].strftime("%Y-%m-%d"),
498
+ "status": row['status'],
499
+ "distance_km": distance_km,
500
+ "duration": _format_duration(duration_minutes),
501
+ "started_at": row['started_at'],
502
+ "stopped_at": row['stopped_at']
503
+ })
504
+
505
+ logger.info(
506
+ f"Retrieved {len(trips)} trips",
507
+ extra={
508
+ "user_id": user_id,
509
+ "merchant_id": merchant_id,
510
+ "days": days,
511
+ "trip_count": len(trips)
512
+ }
513
+ )
514
+
515
+ return trips
516
+
517
+ except Exception as e:
518
+ logger.error(
519
+ f"Failed to fetch trips: {str(e)}",
520
+ extra={
521
+ "user_id": user_id,
522
+ "merchant_id": merchant_id,
523
+ "error": str(e)
524
+ },
525
+ exc_info=True
526
+ )
527
+ raise
528
+ finally:
529
+ if conn:
530
+ await release_postgres_connection(conn)
531
 
532
 
533
  def get_trip_service() -> TripService:
migrate_all_tables.py CHANGED
@@ -269,14 +269,23 @@ async def create_trips_table(conn):
269
  )
270
  """)
271
 
 
272
  await conn.execute("""
273
- CREATE INDEX IF NOT EXISTS idx_scm_trips_user_status
274
- ON trans.scm_trips (user_id, status)
275
  """)
276
 
277
  await conn.execute("""
278
- CREATE INDEX IF NOT EXISTS idx_scm_trips_merchant_date
279
- ON trans.scm_trips (merchant_id, created_at DESC)
 
 
 
 
 
 
 
 
 
280
  """)
281
 
282
  await conn.execute("""
 
269
  )
270
  """)
271
 
272
+ # Drop old redundant indexes if they exist
273
  await conn.execute("""
274
+ DROP INDEX IF EXISTS trans.idx_scm_trips_user_status
 
275
  """)
276
 
277
  await conn.execute("""
278
+ DROP INDEX IF EXISTS trans.idx_scm_trips_merchant_date
279
+ """)
280
+
281
+ await conn.execute("""
282
+ DROP INDEX IF EXISTS trans.idx_scm_trips_status
283
+ """)
284
+
285
+ # Create optimized composite index that covers all query patterns
286
+ await conn.execute("""
287
+ CREATE INDEX IF NOT EXISTS idx_scm_trips_user_merchant_created
288
+ ON trans.scm_trips (user_id, merchant_id, created_at DESC)
289
  """)
290
 
291
  await conn.execute("""