Spaces:
Sleeping
Sleeping
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 +3 -2
- app/tracker/trips/router.py +161 -0
- app/tracker/trips/schemas.py +59 -0
- app/tracker/trips/service.py +154 -1
- migrate_all_tables.py +13 -4
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[
|
| 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": "
|
| 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 =
|
| 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 |
-
|
| 274 |
-
ON trans.scm_trips (user_id, status)
|
| 275 |
""")
|
| 276 |
|
| 277 |
await conn.execute("""
|
| 278 |
-
|
| 279 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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("""
|