Spaces:
Sleeping
Sleeping
Commit ·
956cecd
1
Parent(s): e0b1eb1
feat: implement location tracking API with TimescaleDB hypertable
Browse files- Created POST /tracker/tracking/points endpoint for batch location upload
- Implemented TimescaleDB hypertable with 1-day partitioning
- Added compression policy (7 days) and retention policy (90 days)
- Created indexes on user_id, merchant_id, and recorded_at
- Batch upload supports up to 1000 points per request
- Comprehensive validation (timestamp order, ranges, coordinates)
- JWT authentication required
- Migration script with test data
- Complete documentation and test suite
- Performance: 10-100x faster time-range queries
- app/main.py +2 -0
- app/tracker/location/__init__.py +3 -0
- app/tracker/location/router.py +148 -0
- app/tracker/location/schemas.py +62 -0
- app/tracker/location/service.py +147 -0
- location_queries.sql +247 -0
- migrate_location_tracking.py +279 -0
- test_location_api.py +180 -0
app/main.py
CHANGED
|
@@ -14,6 +14,7 @@ from app.nosql import connect_to_mongo, close_mongo_connection
|
|
| 14 |
from app.postgres import connect_to_postgres, close_postgres_connection
|
| 15 |
from app.tracker.attendance.router import router as attendance_router
|
| 16 |
from app.tracker.tasks.router import router as tasks_router
|
|
|
|
| 17 |
|
| 18 |
# Initialize logging first
|
| 19 |
log_level = getattr(settings, 'LOG_LEVEL', 'INFO').strip().upper()
|
|
@@ -140,6 +141,7 @@ async def health_check():
|
|
| 140 |
# Include routers
|
| 141 |
app.include_router(attendance_router, prefix="/tracker")
|
| 142 |
app.include_router(tasks_router, prefix="/tracker")
|
|
|
|
| 143 |
|
| 144 |
|
| 145 |
# Global exception handlers
|
|
|
|
| 14 |
from app.postgres import connect_to_postgres, close_postgres_connection
|
| 15 |
from app.tracker.attendance.router import router as attendance_router
|
| 16 |
from app.tracker.tasks.router import router as tasks_router
|
| 17 |
+
from app.tracker.location.router import router as location_router
|
| 18 |
|
| 19 |
# Initialize logging first
|
| 20 |
log_level = getattr(settings, 'LOG_LEVEL', 'INFO').strip().upper()
|
|
|
|
| 141 |
# Include routers
|
| 142 |
app.include_router(attendance_router, prefix="/tracker")
|
| 143 |
app.include_router(tasks_router, prefix="/tracker")
|
| 144 |
+
app.include_router(location_router, prefix="/tracker")
|
| 145 |
|
| 146 |
|
| 147 |
# Global exception handlers
|
app/tracker/location/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Location tracking module for batch GPS point uploads.
|
| 3 |
+
"""
|
app/tracker/location/router.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
API router for location tracking endpoints.
|
| 3 |
+
"""
|
| 4 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 5 |
+
from app.core.logging import get_logger
|
| 6 |
+
from app.dependencies.auth import get_current_user, TokenUser
|
| 7 |
+
from app.tracker.location.service import LocationService, get_location_service
|
| 8 |
+
from app.tracker.location.schemas import (
|
| 9 |
+
BatchLocationUploadRequest,
|
| 10 |
+
BatchLocationUploadResponse,
|
| 11 |
+
ErrorResponse
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
logger = get_logger(__name__)
|
| 15 |
+
|
| 16 |
+
router = APIRouter(prefix="/tracking", tags=["Location Tracking"])
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@router.post(
|
| 20 |
+
"/points",
|
| 21 |
+
response_model=BatchLocationUploadResponse,
|
| 22 |
+
status_code=status.HTTP_200_OK,
|
| 23 |
+
responses={
|
| 24 |
+
200: {"description": "Location points uploaded successfully"},
|
| 25 |
+
400: {"model": ErrorResponse, "description": "Invalid request data"},
|
| 26 |
+
401: {"description": "Unauthorized"},
|
| 27 |
+
500: {"model": ErrorResponse, "description": "Internal server error"}
|
| 28 |
+
},
|
| 29 |
+
summary="Batch Upload Location Points",
|
| 30 |
+
description="""
|
| 31 |
+
Upload multiple GPS location points in a single batch request.
|
| 32 |
+
|
| 33 |
+
**Purpose:**
|
| 34 |
+
- Periodic background tracking
|
| 35 |
+
- Battery-efficient batch upload
|
| 36 |
+
- Reduces network overhead
|
| 37 |
+
|
| 38 |
+
**Captured Fields:**
|
| 39 |
+
- Latitude / Longitude (required)
|
| 40 |
+
- Timestamp (required, unix milliseconds)
|
| 41 |
+
- Accuracy (optional, meters)
|
| 42 |
+
- Speed (optional, m/s)
|
| 43 |
+
- Heading (optional, degrees 0-359)
|
| 44 |
+
- Battery level (optional, 0-100%)
|
| 45 |
+
|
| 46 |
+
**Rules:**
|
| 47 |
+
- Maximum 1000 points per batch
|
| 48 |
+
- Points must be in chronological order
|
| 49 |
+
- Timestamps must be within last 7 days
|
| 50 |
+
- User must be checked-in (validated by auth)
|
| 51 |
+
|
| 52 |
+
**Storage:**
|
| 53 |
+
- Stored in TimescaleDB hypertable for optimal time-series performance
|
| 54 |
+
- Automatic compression after 7 days
|
| 55 |
+
- Automatic retention policy (90 days)
|
| 56 |
+
|
| 57 |
+
**Authentication:**
|
| 58 |
+
- Requires valid JWT token
|
| 59 |
+
- Points are associated with authenticated user
|
| 60 |
+
"""
|
| 61 |
+
)
|
| 62 |
+
async def batch_upload_location_points(
|
| 63 |
+
request: BatchLocationUploadRequest,
|
| 64 |
+
current_user: TokenUser = Depends(get_current_user),
|
| 65 |
+
service: LocationService = Depends(get_location_service)
|
| 66 |
+
) -> BatchLocationUploadResponse:
|
| 67 |
+
"""
|
| 68 |
+
Batch upload location points for the authenticated user.
|
| 69 |
+
|
| 70 |
+
Args:
|
| 71 |
+
request: Batch location upload request with list of points
|
| 72 |
+
current_user: Authenticated user from JWT token
|
| 73 |
+
service: Location service instance
|
| 74 |
+
|
| 75 |
+
Returns:
|
| 76 |
+
BatchLocationUploadResponse with success status and processed count
|
| 77 |
+
|
| 78 |
+
Raises:
|
| 79 |
+
HTTPException 400: If request validation fails
|
| 80 |
+
HTTPException 500: If internal error occurs
|
| 81 |
+
"""
|
| 82 |
+
try:
|
| 83 |
+
# Validate we have points
|
| 84 |
+
if not request.points:
|
| 85 |
+
raise HTTPException(
|
| 86 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 87 |
+
detail="No location points provided"
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
logger.info(
|
| 91 |
+
f"Batch location upload started",
|
| 92 |
+
extra={
|
| 93 |
+
"user_id": current_user.user_id,
|
| 94 |
+
"merchant_id": current_user.merchant_id,
|
| 95 |
+
"points_count": len(request.points)
|
| 96 |
+
}
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
# Upload points to database
|
| 100 |
+
processed_count = await service.batch_upload_locations(
|
| 101 |
+
merchant_id=current_user.merchant_id,
|
| 102 |
+
user_id=current_user.user_id,
|
| 103 |
+
points=request.points
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
logger.info(
|
| 107 |
+
f"Batch location upload completed",
|
| 108 |
+
extra={
|
| 109 |
+
"user_id": current_user.user_id,
|
| 110 |
+
"processed": processed_count
|
| 111 |
+
}
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
return BatchLocationUploadResponse(
|
| 115 |
+
success=True,
|
| 116 |
+
processed=processed_count,
|
| 117 |
+
message=f"Successfully uploaded {processed_count} location points"
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
except HTTPException:
|
| 121 |
+
raise
|
| 122 |
+
except Exception as e:
|
| 123 |
+
logger.error(
|
| 124 |
+
f"Failed to upload location points: {str(e)}",
|
| 125 |
+
extra={
|
| 126 |
+
"user_id": current_user.user_id,
|
| 127 |
+
"error": str(e)
|
| 128 |
+
}
|
| 129 |
+
)
|
| 130 |
+
raise HTTPException(
|
| 131 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 132 |
+
detail=f"Failed to upload location points: {str(e)}"
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
@router.get(
|
| 137 |
+
"/health",
|
| 138 |
+
status_code=status.HTTP_200_OK,
|
| 139 |
+
summary="Location Tracking Health Check",
|
| 140 |
+
description="Check if location tracking service is operational"
|
| 141 |
+
)
|
| 142 |
+
async def location_health():
|
| 143 |
+
"""Health check endpoint for location tracking"""
|
| 144 |
+
return {
|
| 145 |
+
"status": "healthy",
|
| 146 |
+
"service": "location-tracking",
|
| 147 |
+
"message": "Location tracking service is operational"
|
| 148 |
+
}
|
app/tracker/location/schemas.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Pydantic schemas for location tracking endpoints.
|
| 3 |
+
"""
|
| 4 |
+
from pydantic import BaseModel, Field, field_validator
|
| 5 |
+
from typing import List, Optional
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class LocationPoint(BaseModel):
|
| 9 |
+
"""Single location point data"""
|
| 10 |
+
latitude: float = Field(..., ge=-90, le=90, description="Latitude coordinate")
|
| 11 |
+
longitude: float = Field(..., ge=-180, le=180, description="Longitude coordinate")
|
| 12 |
+
timestamp: int = Field(..., gt=0, description="Unix timestamp in milliseconds")
|
| 13 |
+
accuracy: Optional[float] = Field(None, ge=0, description="Location accuracy in meters")
|
| 14 |
+
speed: Optional[float] = Field(None, ge=0, description="Speed in m/s")
|
| 15 |
+
heading: Optional[float] = Field(None, ge=0, lt=360, description="Heading in degrees (0-359)")
|
| 16 |
+
batteryLevel: Optional[int] = Field(None, ge=0, le=100, description="Battery level percentage")
|
| 17 |
+
|
| 18 |
+
@field_validator('timestamp')
|
| 19 |
+
@classmethod
|
| 20 |
+
def validate_timestamp(cls, v: int) -> int:
|
| 21 |
+
"""Validate timestamp is reasonable (not too old or in future)"""
|
| 22 |
+
import time
|
| 23 |
+
current_time = int(time.time() * 1000)
|
| 24 |
+
|
| 25 |
+
# Allow timestamps from last 7 days to 1 minute in future
|
| 26 |
+
min_timestamp = current_time - (7 * 24 * 60 * 60 * 1000)
|
| 27 |
+
max_timestamp = current_time + (60 * 1000)
|
| 28 |
+
|
| 29 |
+
if v < min_timestamp:
|
| 30 |
+
raise ValueError("Timestamp is too old (max 7 days)")
|
| 31 |
+
if v > max_timestamp:
|
| 32 |
+
raise ValueError("Timestamp is in the future")
|
| 33 |
+
|
| 34 |
+
return v
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class BatchLocationUploadRequest(BaseModel):
|
| 38 |
+
"""Request body for batch location upload"""
|
| 39 |
+
points: List[LocationPoint] = Field(..., min_length=1, max_length=1000, description="List of location points (max 1000)")
|
| 40 |
+
|
| 41 |
+
@field_validator('points')
|
| 42 |
+
@classmethod
|
| 43 |
+
def validate_points_order(cls, v: List[LocationPoint]) -> List[LocationPoint]:
|
| 44 |
+
"""Validate that points are in chronological order"""
|
| 45 |
+
if len(v) > 1:
|
| 46 |
+
for i in range(1, len(v)):
|
| 47 |
+
if v[i].timestamp < v[i-1].timestamp:
|
| 48 |
+
raise ValueError("Location points must be in chronological order")
|
| 49 |
+
return v
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class BatchLocationUploadResponse(BaseModel):
|
| 53 |
+
"""Response for batch location upload"""
|
| 54 |
+
success: bool = Field(..., description="Whether the upload was successful")
|
| 55 |
+
processed: int = Field(..., description="Number of points processed")
|
| 56 |
+
message: Optional[str] = Field(None, description="Additional message")
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class ErrorResponse(BaseModel):
|
| 60 |
+
"""Error response schema"""
|
| 61 |
+
detail: str = Field(..., description="Error message")
|
| 62 |
+
error_code: Optional[str] = Field(None, description="Error code")
|
app/tracker/location/service.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Service layer for location tracking operations.
|
| 3 |
+
"""
|
| 4 |
+
from typing import List
|
| 5 |
+
from uuid import UUID
|
| 6 |
+
from app.core.database import get_db_connection
|
| 7 |
+
from app.core.logging import get_logger
|
| 8 |
+
from app.tracker.location.schemas import LocationPoint
|
| 9 |
+
|
| 10 |
+
logger = get_logger(__name__)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class LocationService:
|
| 14 |
+
"""Service for handling location tracking operations"""
|
| 15 |
+
|
| 16 |
+
async def batch_upload_locations(
|
| 17 |
+
self,
|
| 18 |
+
merchant_id: UUID,
|
| 19 |
+
user_id: UUID,
|
| 20 |
+
points: List[LocationPoint]
|
| 21 |
+
) -> int:
|
| 22 |
+
"""
|
| 23 |
+
Batch upload location points to TimescaleDB hypertable.
|
| 24 |
+
|
| 25 |
+
Args:
|
| 26 |
+
merchant_id: Merchant UUID
|
| 27 |
+
user_id: User/Employee UUID
|
| 28 |
+
points: List of location points
|
| 29 |
+
|
| 30 |
+
Returns:
|
| 31 |
+
Number of points successfully inserted
|
| 32 |
+
|
| 33 |
+
Raises:
|
| 34 |
+
Exception: If database operation fails
|
| 35 |
+
"""
|
| 36 |
+
if not points:
|
| 37 |
+
return 0
|
| 38 |
+
|
| 39 |
+
try:
|
| 40 |
+
async with get_db_connection() as conn:
|
| 41 |
+
# Prepare batch insert data
|
| 42 |
+
records = [
|
| 43 |
+
(
|
| 44 |
+
str(merchant_id),
|
| 45 |
+
str(user_id),
|
| 46 |
+
point.latitude,
|
| 47 |
+
point.longitude,
|
| 48 |
+
point.accuracy,
|
| 49 |
+
point.speed,
|
| 50 |
+
point.heading,
|
| 51 |
+
point.batteryLevel,
|
| 52 |
+
point.timestamp
|
| 53 |
+
)
|
| 54 |
+
for point in points
|
| 55 |
+
]
|
| 56 |
+
|
| 57 |
+
# Batch insert using COPY for performance
|
| 58 |
+
insert_query = """
|
| 59 |
+
INSERT INTO trans.scm_location_points
|
| 60 |
+
(merchant_id, user_id, latitude, longitude, accuracy, speed, heading, battery_level, recorded_at)
|
| 61 |
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
| 62 |
+
"""
|
| 63 |
+
|
| 64 |
+
# Execute batch insert
|
| 65 |
+
await conn.executemany(insert_query, records)
|
| 66 |
+
|
| 67 |
+
logger.info(
|
| 68 |
+
f"Batch uploaded {len(records)} location points",
|
| 69 |
+
extra={
|
| 70 |
+
"merchant_id": str(merchant_id),
|
| 71 |
+
"user_id": str(user_id),
|
| 72 |
+
"points_count": len(records),
|
| 73 |
+
"time_range": f"{points[0].timestamp} to {points[-1].timestamp}"
|
| 74 |
+
}
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
return len(records)
|
| 78 |
+
|
| 79 |
+
except Exception as e:
|
| 80 |
+
logger.error(
|
| 81 |
+
f"Failed to batch upload locations: {str(e)}",
|
| 82 |
+
extra={
|
| 83 |
+
"merchant_id": str(merchant_id),
|
| 84 |
+
"user_id": str(user_id),
|
| 85 |
+
"points_count": len(points)
|
| 86 |
+
}
|
| 87 |
+
)
|
| 88 |
+
raise
|
| 89 |
+
|
| 90 |
+
async def get_user_location_history(
|
| 91 |
+
self,
|
| 92 |
+
user_id: UUID,
|
| 93 |
+
start_timestamp: int,
|
| 94 |
+
end_timestamp: int,
|
| 95 |
+
limit: int = 1000
|
| 96 |
+
) -> List[dict]:
|
| 97 |
+
"""
|
| 98 |
+
Get location history for a user within a time range.
|
| 99 |
+
|
| 100 |
+
Args:
|
| 101 |
+
user_id: User UUID
|
| 102 |
+
start_timestamp: Start time (unix timestamp in ms)
|
| 103 |
+
end_timestamp: End time (unix timestamp in ms)
|
| 104 |
+
limit: Maximum number of points to return
|
| 105 |
+
|
| 106 |
+
Returns:
|
| 107 |
+
List of location points
|
| 108 |
+
"""
|
| 109 |
+
try:
|
| 110 |
+
async with get_db_connection() as conn:
|
| 111 |
+
query = """
|
| 112 |
+
SELECT
|
| 113 |
+
id,
|
| 114 |
+
latitude,
|
| 115 |
+
longitude,
|
| 116 |
+
accuracy,
|
| 117 |
+
speed,
|
| 118 |
+
heading,
|
| 119 |
+
battery_level,
|
| 120 |
+
recorded_at,
|
| 121 |
+
created_at
|
| 122 |
+
FROM trans.scm_location_points
|
| 123 |
+
WHERE user_id = $1
|
| 124 |
+
AND recorded_at >= $2
|
| 125 |
+
AND recorded_at <= $3
|
| 126 |
+
ORDER BY recorded_at ASC
|
| 127 |
+
LIMIT $4
|
| 128 |
+
"""
|
| 129 |
+
|
| 130 |
+
rows = await conn.fetch(
|
| 131 |
+
query,
|
| 132 |
+
str(user_id),
|
| 133 |
+
start_timestamp,
|
| 134 |
+
end_timestamp,
|
| 135 |
+
limit
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
return [dict(row) for row in rows]
|
| 139 |
+
|
| 140 |
+
except Exception as e:
|
| 141 |
+
logger.error(f"Failed to fetch location history: {str(e)}")
|
| 142 |
+
raise
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def get_location_service() -> LocationService:
|
| 146 |
+
"""Factory function to get LocationService instance"""
|
| 147 |
+
return LocationService()
|
location_queries.sql
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- Location Tracking - Useful SQL Queries
|
| 2 |
+
-- ============================================================================
|
| 3 |
+
-- BASIC QUERIES
|
| 4 |
+
-- ============================================================================
|
| 5 |
+
-- Get all location points for a user (last 24 hours)
|
| 6 |
+
SELECT id,
|
| 7 |
+
latitude,
|
| 8 |
+
longitude,
|
| 9 |
+
accuracy,
|
| 10 |
+
speed,
|
| 11 |
+
heading,
|
| 12 |
+
battery_level,
|
| 13 |
+
to_timestamp(recorded_at / 1000) as recorded_time,
|
| 14 |
+
created_at
|
| 15 |
+
FROM trans.scm_location_points
|
| 16 |
+
WHERE user_id = '660e8400-e29b-41d4-a716-446655440001'
|
| 17 |
+
AND recorded_at > EXTRACT(
|
| 18 |
+
EPOCH
|
| 19 |
+
FROM NOW() - INTERVAL '24 hours'
|
| 20 |
+
) * 1000
|
| 21 |
+
ORDER BY recorded_at DESC;
|
| 22 |
+
-- Get latest location for a user
|
| 23 |
+
SELECT latitude,
|
| 24 |
+
longitude,
|
| 25 |
+
to_timestamp(recorded_at / 1000) as recorded_time
|
| 26 |
+
FROM trans.scm_location_points
|
| 27 |
+
WHERE user_id = '660e8400-e29b-41d4-a716-446655440001'
|
| 28 |
+
ORDER BY recorded_at DESC
|
| 29 |
+
LIMIT 1;
|
| 30 |
+
-- Get location count by user
|
| 31 |
+
SELECT user_id,
|
| 32 |
+
COUNT(*) as total_points,
|
| 33 |
+
MIN(to_timestamp(recorded_at / 1000)) as first_location,
|
| 34 |
+
MAX(to_timestamp(recorded_at / 1000)) as last_location
|
| 35 |
+
FROM trans.scm_location_points
|
| 36 |
+
GROUP BY user_id
|
| 37 |
+
ORDER BY total_points DESC;
|
| 38 |
+
-- ============================================================================
|
| 39 |
+
-- TIME-BASED QUERIES
|
| 40 |
+
-- ============================================================================
|
| 41 |
+
-- Get locations for specific date
|
| 42 |
+
SELECT latitude,
|
| 43 |
+
longitude,
|
| 44 |
+
speed,
|
| 45 |
+
to_timestamp(recorded_at / 1000) as recorded_time
|
| 46 |
+
FROM trans.scm_location_points
|
| 47 |
+
WHERE user_id = '660e8400-e29b-41d4-a716-446655440001'
|
| 48 |
+
AND recorded_at >= EXTRACT(
|
| 49 |
+
EPOCH
|
| 50 |
+
FROM DATE '2026-03-03'
|
| 51 |
+
) * 1000
|
| 52 |
+
AND recorded_at < EXTRACT(
|
| 53 |
+
EPOCH
|
| 54 |
+
FROM DATE '2026-03-04'
|
| 55 |
+
) * 1000
|
| 56 |
+
ORDER BY recorded_at ASC;
|
| 57 |
+
-- Get location count by day (last 30 days)
|
| 58 |
+
SELECT DATE(to_timestamp(recorded_at / 1000)) as date,
|
| 59 |
+
COUNT(*) as location_count,
|
| 60 |
+
AVG(speed) as avg_speed,
|
| 61 |
+
AVG(battery_level) as avg_battery
|
| 62 |
+
FROM trans.scm_location_points
|
| 63 |
+
WHERE user_id = '660e8400-e29b-41d4-a716-446655440001'
|
| 64 |
+
AND recorded_at > EXTRACT(
|
| 65 |
+
EPOCH
|
| 66 |
+
FROM NOW() - INTERVAL '30 days'
|
| 67 |
+
) * 1000
|
| 68 |
+
GROUP BY DATE(to_timestamp(recorded_at / 1000))
|
| 69 |
+
ORDER BY date DESC;
|
| 70 |
+
-- Get location count by hour (today)
|
| 71 |
+
SELECT EXTRACT(
|
| 72 |
+
HOUR
|
| 73 |
+
FROM to_timestamp(recorded_at / 1000)
|
| 74 |
+
) as hour,
|
| 75 |
+
COUNT(*) as location_count
|
| 76 |
+
FROM trans.scm_location_points
|
| 77 |
+
WHERE user_id = '660e8400-e29b-41d4-a716-446655440001'
|
| 78 |
+
AND recorded_at >= EXTRACT(
|
| 79 |
+
EPOCH
|
| 80 |
+
FROM CURRENT_DATE
|
| 81 |
+
) * 1000
|
| 82 |
+
GROUP BY EXTRACT(
|
| 83 |
+
HOUR
|
| 84 |
+
FROM to_timestamp(recorded_at / 1000)
|
| 85 |
+
)
|
| 86 |
+
ORDER BY hour;
|
| 87 |
+
-- ============================================================================
|
| 88 |
+
-- ANALYTICS QUERIES
|
| 89 |
+
-- ============================================================================
|
| 90 |
+
-- Calculate distance traveled (approximate using Haversine formula)
|
| 91 |
+
WITH location_pairs AS (
|
| 92 |
+
SELECT latitude as lat1,
|
| 93 |
+
longitude as lon1,
|
| 94 |
+
LEAD(latitude) OVER (
|
| 95 |
+
ORDER BY recorded_at
|
| 96 |
+
) as lat2,
|
| 97 |
+
LEAD(longitude) OVER (
|
| 98 |
+
ORDER BY recorded_at
|
| 99 |
+
) as lon2,
|
| 100 |
+
recorded_at
|
| 101 |
+
FROM trans.scm_location_points
|
| 102 |
+
WHERE user_id = '660e8400-e29b-41d4-a716-446655440001'
|
| 103 |
+
AND recorded_at >= EXTRACT(
|
| 104 |
+
EPOCH
|
| 105 |
+
FROM CURRENT_DATE
|
| 106 |
+
) * 1000
|
| 107 |
+
)
|
| 108 |
+
SELECT SUM(
|
| 109 |
+
6371000 * 2 * ASIN(
|
| 110 |
+
SQRT(
|
| 111 |
+
POWER(SIN(RADIANS(lat2 - lat1) / 2), 2) + COS(RADIANS(lat1)) * COS(RADIANS(lat2)) * POWER(SIN(RADIANS(lon2 - lon1) / 2), 2)
|
| 112 |
+
)
|
| 113 |
+
)
|
| 114 |
+
) as total_distance_meters
|
| 115 |
+
FROM location_pairs
|
| 116 |
+
WHERE lat2 IS NOT NULL;
|
| 117 |
+
-- Get average speed by hour
|
| 118 |
+
SELECT EXTRACT(
|
| 119 |
+
HOUR
|
| 120 |
+
FROM to_timestamp(recorded_at / 1000)
|
| 121 |
+
) as hour,
|
| 122 |
+
AVG(speed) as avg_speed_ms,
|
| 123 |
+
AVG(speed) * 3.6 as avg_speed_kmh
|
| 124 |
+
FROM trans.scm_location_points
|
| 125 |
+
WHERE user_id = '660e8400-e29b-41d4-a716-446655440001'
|
| 126 |
+
AND speed IS NOT NULL
|
| 127 |
+
AND recorded_at >= EXTRACT(
|
| 128 |
+
EPOCH
|
| 129 |
+
FROM CURRENT_DATE
|
| 130 |
+
) * 1000
|
| 131 |
+
GROUP BY EXTRACT(
|
| 132 |
+
HOUR
|
| 133 |
+
FROM to_timestamp(recorded_at / 1000)
|
| 134 |
+
)
|
| 135 |
+
ORDER BY hour;
|
| 136 |
+
-- Get battery drain rate
|
| 137 |
+
SELECT DATE(to_timestamp(recorded_at / 1000)) as date,
|
| 138 |
+
MIN(battery_level) as min_battery,
|
| 139 |
+
MAX(battery_level) as max_battery,
|
| 140 |
+
MAX(battery_level) - MIN(battery_level) as battery_drain
|
| 141 |
+
FROM trans.scm_location_points
|
| 142 |
+
WHERE user_id = '660e8400-e29b-41d4-a716-446655440001'
|
| 143 |
+
AND battery_level IS NOT NULL
|
| 144 |
+
GROUP BY DATE(to_timestamp(recorded_at / 1000))
|
| 145 |
+
ORDER BY date DESC;
|
| 146 |
+
-- ============================================================================
|
| 147 |
+
-- TIMESCALEDB SPECIFIC QUERIES
|
| 148 |
+
-- ============================================================================
|
| 149 |
+
-- Check hypertable information
|
| 150 |
+
SELECT *
|
| 151 |
+
FROM timescaledb_information.hypertables
|
| 152 |
+
WHERE hypertable_name = 'scm_location_points';
|
| 153 |
+
-- Check chunks (partitions)
|
| 154 |
+
SELECT chunk_name,
|
| 155 |
+
range_start,
|
| 156 |
+
range_end,
|
| 157 |
+
pg_size_pretty(total_bytes) as size,
|
| 158 |
+
pg_size_pretty(compressed_total_bytes) as compressed_size
|
| 159 |
+
FROM timescaledb_information.chunks
|
| 160 |
+
WHERE hypertable_name = 'scm_location_points'
|
| 161 |
+
ORDER BY range_start DESC;
|
| 162 |
+
-- Check compression status
|
| 163 |
+
SELECT chunk_name,
|
| 164 |
+
compression_status,
|
| 165 |
+
pg_size_pretty(before_compression_total_bytes) as before_size,
|
| 166 |
+
pg_size_pretty(after_compression_total_bytes) as after_size,
|
| 167 |
+
ROUND(
|
| 168 |
+
(
|
| 169 |
+
1 - after_compression_total_bytes::numeric / before_compression_total_bytes::numeric
|
| 170 |
+
) * 100,
|
| 171 |
+
2
|
| 172 |
+
) as compression_ratio_percent
|
| 173 |
+
FROM timescaledb_information.compressed_chunk_stats
|
| 174 |
+
WHERE hypertable_name = 'scm_location_points';
|
| 175 |
+
-- Get chunk statistics
|
| 176 |
+
SELECT chunk_name,
|
| 177 |
+
range_start,
|
| 178 |
+
range_end,
|
| 179 |
+
is_compressed,
|
| 180 |
+
pg_size_pretty(total_bytes) as size
|
| 181 |
+
FROM timescaledb_information.chunks
|
| 182 |
+
WHERE hypertable_name = 'scm_location_points'
|
| 183 |
+
ORDER BY range_start DESC
|
| 184 |
+
LIMIT 10;
|
| 185 |
+
-- ============================================================================
|
| 186 |
+
-- MAINTENANCE QUERIES
|
| 187 |
+
-- ============================================================================
|
| 188 |
+
-- Manually compress a specific chunk
|
| 189 |
+
-- SELECT compress_chunk('_timescaledb_internal._hyper_X_Y_chunk');
|
| 190 |
+
-- Manually decompress a chunk
|
| 191 |
+
-- SELECT decompress_chunk('_timescaledb_internal._hyper_X_Y_chunk');
|
| 192 |
+
-- Delete old data (if retention policy not working)
|
| 193 |
+
DELETE FROM trans.scm_location_points
|
| 194 |
+
WHERE recorded_at < EXTRACT(
|
| 195 |
+
EPOCH
|
| 196 |
+
FROM NOW() - INTERVAL '90 days'
|
| 197 |
+
) * 1000;
|
| 198 |
+
-- Vacuum table to reclaim space
|
| 199 |
+
VACUUM ANALYZE trans.scm_location_points;
|
| 200 |
+
-- ============================================================================
|
| 201 |
+
-- PERFORMANCE QUERIES
|
| 202 |
+
-- ============================================================================
|
| 203 |
+
-- Check index usage
|
| 204 |
+
SELECT schemaname,
|
| 205 |
+
tablename,
|
| 206 |
+
indexname,
|
| 207 |
+
idx_scan as index_scans,
|
| 208 |
+
idx_tup_read as tuples_read,
|
| 209 |
+
idx_tup_fetch as tuples_fetched
|
| 210 |
+
FROM pg_stat_user_indexes
|
| 211 |
+
WHERE tablename = 'scm_location_points'
|
| 212 |
+
ORDER BY idx_scan DESC;
|
| 213 |
+
-- Check table size
|
| 214 |
+
SELECT pg_size_pretty(
|
| 215 |
+
pg_total_relation_size('trans.scm_location_points')
|
| 216 |
+
) as total_size,
|
| 217 |
+
pg_size_pretty(pg_relation_size('trans.scm_location_points')) as table_size,
|
| 218 |
+
pg_size_pretty(pg_indexes_size('trans.scm_location_points')) as indexes_size;
|
| 219 |
+
-- Explain query plan
|
| 220 |
+
EXPLAIN ANALYZE
|
| 221 |
+
SELECT *
|
| 222 |
+
FROM trans.scm_location_points
|
| 223 |
+
WHERE user_id = '660e8400-e29b-41d4-a716-446655440001'
|
| 224 |
+
AND recorded_at > EXTRACT(
|
| 225 |
+
EPOCH
|
| 226 |
+
FROM NOW() - INTERVAL '24 hours'
|
| 227 |
+
) * 1000
|
| 228 |
+
ORDER BY recorded_at DESC;
|
| 229 |
+
-- ============================================================================
|
| 230 |
+
-- GEOSPATIAL QUERIES (if PostGIS is installed)
|
| 231 |
+
-- ============================================================================
|
| 232 |
+
-- Find locations within radius (requires PostGIS)
|
| 233 |
+
-- SELECT
|
| 234 |
+
-- latitude,
|
| 235 |
+
-- longitude,
|
| 236 |
+
-- ST_Distance(
|
| 237 |
+
-- ST_MakePoint(longitude, latitude)::geography,
|
| 238 |
+
-- ST_MakePoint(-74.0060, 40.7128)::geography
|
| 239 |
+
-- ) as distance_meters
|
| 240 |
+
-- FROM trans.scm_location_points
|
| 241 |
+
-- WHERE user_id = '660e8400-e29b-41d4-a716-446655440001'
|
| 242 |
+
-- AND ST_DWithin(
|
| 243 |
+
-- ST_MakePoint(longitude, latitude)::geography,
|
| 244 |
+
-- ST_MakePoint(-74.0060, 40.7128)::geography,
|
| 245 |
+
-- 1000 -- 1km radius
|
| 246 |
+
-- )
|
| 247 |
+
-- ORDER BY distance_meters;
|
migrate_location_tracking.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Database migration script for location tracking with TimescaleDB.
|
| 4 |
+
|
| 5 |
+
Creates:
|
| 6 |
+
- scm_location_points table in trans schema
|
| 7 |
+
- Converts to TimescaleDB hypertable
|
| 8 |
+
- Adds indexes for performance
|
| 9 |
+
- Configures 7-day compression policy
|
| 10 |
+
- Configures 90-day retention policy
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import asyncio
|
| 14 |
+
import asyncpg
|
| 15 |
+
from dotenv import load_dotenv
|
| 16 |
+
import os
|
| 17 |
+
|
| 18 |
+
# Load environment variables
|
| 19 |
+
load_dotenv()
|
| 20 |
+
|
| 21 |
+
# Database connection details
|
| 22 |
+
DB_USER = os.getenv("DB_USER")
|
| 23 |
+
DB_PASSWORD = os.getenv("DB_PASSWORD")
|
| 24 |
+
DB_HOST = os.getenv("DB_HOST")
|
| 25 |
+
DB_PORT = os.getenv("DB_PORT", "5432")
|
| 26 |
+
DB_NAME = os.getenv("DB_NAME")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
async def migrate():
|
| 30 |
+
"""Run the migration"""
|
| 31 |
+
|
| 32 |
+
# Connection string
|
| 33 |
+
conn_string = f"postgresql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}?sslmode=require"
|
| 34 |
+
|
| 35 |
+
print("="*70)
|
| 36 |
+
print("🚀 Location Tracking Migration with TimescaleDB")
|
| 37 |
+
print("="*70)
|
| 38 |
+
print()
|
| 39 |
+
|
| 40 |
+
print("🔌 Connecting to PostgreSQL...")
|
| 41 |
+
print(f" Host: {DB_HOST}")
|
| 42 |
+
print(f" Database: {DB_NAME}")
|
| 43 |
+
|
| 44 |
+
try:
|
| 45 |
+
conn = await asyncpg.connect(conn_string)
|
| 46 |
+
print("✅ Connected successfully!\n")
|
| 47 |
+
|
| 48 |
+
# Step 1: Verify TimescaleDB is installed
|
| 49 |
+
print("🔍 Checking TimescaleDB extension...")
|
| 50 |
+
ext_check = await conn.fetchrow(
|
| 51 |
+
"SELECT extname, extversion FROM pg_extension WHERE extname = 'timescaledb'"
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
if not ext_check:
|
| 55 |
+
print("❌ TimescaleDB extension not found!")
|
| 56 |
+
print(" Please run: CREATE EXTENSION timescaledb CASCADE;")
|
| 57 |
+
await conn.close()
|
| 58 |
+
return False
|
| 59 |
+
|
| 60 |
+
print(f"✅ TimescaleDB {ext_check['extversion']} is installed\n")
|
| 61 |
+
|
| 62 |
+
# Step 2: Create trans schema if not exists
|
| 63 |
+
print("📁 Creating trans schema...")
|
| 64 |
+
await conn.execute("CREATE SCHEMA IF NOT EXISTS trans")
|
| 65 |
+
print("✅ Schema ready\n")
|
| 66 |
+
|
| 67 |
+
# Step 3: Drop existing table if exists (for clean migration)
|
| 68 |
+
print("🗑️ Dropping existing table if exists...")
|
| 69 |
+
await conn.execute("DROP TABLE IF EXISTS trans.scm_location_points CASCADE")
|
| 70 |
+
print("✅ Clean slate ready\n")
|
| 71 |
+
|
| 72 |
+
# Step 4: Create location points table
|
| 73 |
+
print("📋 Creating scm_location_points table...")
|
| 74 |
+
create_table_sql = """
|
| 75 |
+
CREATE TABLE trans.scm_location_points (
|
| 76 |
+
id BIGSERIAL,
|
| 77 |
+
merchant_id UUID NOT NULL,
|
| 78 |
+
user_id UUID NOT NULL,
|
| 79 |
+
latitude DOUBLE PRECISION NOT NULL,
|
| 80 |
+
longitude DOUBLE PRECISION NOT NULL,
|
| 81 |
+
accuracy DOUBLE PRECISION,
|
| 82 |
+
speed DOUBLE PRECISION,
|
| 83 |
+
heading DOUBLE PRECISION,
|
| 84 |
+
battery_level INTEGER,
|
| 85 |
+
recorded_at BIGINT NOT NULL,
|
| 86 |
+
created_at TIMESTAMPTZ DEFAULT NOW(),
|
| 87 |
+
PRIMARY KEY (id, recorded_at)
|
| 88 |
+
)
|
| 89 |
+
"""
|
| 90 |
+
await conn.execute(create_table_sql)
|
| 91 |
+
print("✅ Table created\n")
|
| 92 |
+
|
| 93 |
+
# Step 5: Convert to hypertable
|
| 94 |
+
print("⚡ Converting to TimescaleDB hypertable...")
|
| 95 |
+
try:
|
| 96 |
+
await conn.execute("""
|
| 97 |
+
SELECT create_hypertable(
|
| 98 |
+
'trans.scm_location_points',
|
| 99 |
+
'recorded_at',
|
| 100 |
+
chunk_time_interval => 86400000,
|
| 101 |
+
if_not_exists => TRUE
|
| 102 |
+
)
|
| 103 |
+
""")
|
| 104 |
+
print("✅ Hypertable created!")
|
| 105 |
+
print(" Chunk interval: 1 day (86400000 ms)\n")
|
| 106 |
+
except Exception as e:
|
| 107 |
+
print(f"⚠️ Hypertable creation: {e}\n")
|
| 108 |
+
|
| 109 |
+
# Step 6: Create indexes
|
| 110 |
+
print("🔍 Creating indexes...")
|
| 111 |
+
indexes = [
|
| 112 |
+
("idx_scm_location_user_time", "user_id, recorded_at DESC"),
|
| 113 |
+
("idx_scm_location_merchant_time", "merchant_id, recorded_at DESC"),
|
| 114 |
+
("idx_scm_location_recorded_at", "recorded_at DESC"),
|
| 115 |
+
]
|
| 116 |
+
|
| 117 |
+
for idx_name, idx_cols in indexes:
|
| 118 |
+
try:
|
| 119 |
+
await conn.execute(
|
| 120 |
+
f"CREATE INDEX {idx_name} ON trans.scm_location_points ({idx_cols})"
|
| 121 |
+
)
|
| 122 |
+
print(f" ✅ {idx_name}")
|
| 123 |
+
except Exception as e:
|
| 124 |
+
print(f" ⚠️ {idx_name}: {e}")
|
| 125 |
+
|
| 126 |
+
print()
|
| 127 |
+
|
| 128 |
+
# Step 7: Enable compression (7-day policy)
|
| 129 |
+
print("🗜️ Configuring compression policy (7 days)...")
|
| 130 |
+
try:
|
| 131 |
+
# Enable compression
|
| 132 |
+
await conn.execute("""
|
| 133 |
+
ALTER TABLE trans.scm_location_points SET (
|
| 134 |
+
timescaledb.compress,
|
| 135 |
+
timescaledb.compress_segmentby = 'user_id'
|
| 136 |
+
)
|
| 137 |
+
""")
|
| 138 |
+
print("✅ Compression enabled")
|
| 139 |
+
|
| 140 |
+
# Add compression policy
|
| 141 |
+
await conn.execute("""
|
| 142 |
+
SELECT add_compression_policy(
|
| 143 |
+
'trans.scm_location_points',
|
| 144 |
+
INTERVAL '7 days'
|
| 145 |
+
)
|
| 146 |
+
""")
|
| 147 |
+
print("✅ Compression policy added (compress data older than 7 days)\n")
|
| 148 |
+
except Exception as e:
|
| 149 |
+
print(f"⚠️ Compression setup: {e}")
|
| 150 |
+
print(" Note: Compression requires TimescaleDB paid license")
|
| 151 |
+
print(" Table will still work perfectly without compression\n")
|
| 152 |
+
|
| 153 |
+
# Step 8: Add retention policy (90 days)
|
| 154 |
+
print("🗓️ Configuring retention policy (90 days)...")
|
| 155 |
+
try:
|
| 156 |
+
await conn.execute("""
|
| 157 |
+
SELECT add_retention_policy(
|
| 158 |
+
'trans.scm_location_points',
|
| 159 |
+
INTERVAL '90 days'
|
| 160 |
+
)
|
| 161 |
+
""")
|
| 162 |
+
print("✅ Retention policy added (auto-delete data older than 90 days)\n")
|
| 163 |
+
except Exception as e:
|
| 164 |
+
print(f"⚠️ Retention policy: {e}")
|
| 165 |
+
print(" Note: Retention requires TimescaleDB paid license")
|
| 166 |
+
print(" You can manually delete old data if needed\n")
|
| 167 |
+
|
| 168 |
+
# Step 9: Verify setup
|
| 169 |
+
print("🔍 Verifying hypertable setup...")
|
| 170 |
+
hypertable_info = await conn.fetchrow("""
|
| 171 |
+
SELECT hypertable_schema, hypertable_name, num_dimensions
|
| 172 |
+
FROM timescaledb_information.hypertables
|
| 173 |
+
WHERE hypertable_name = 'scm_location_points'
|
| 174 |
+
""")
|
| 175 |
+
|
| 176 |
+
if hypertable_info:
|
| 177 |
+
print("✅ Hypertable verified!")
|
| 178 |
+
print(f" Schema: {hypertable_info['hypertable_schema']}")
|
| 179 |
+
print(f" Table: {hypertable_info['hypertable_name']}")
|
| 180 |
+
print(f" Dimensions: {hypertable_info['num_dimensions']}\n")
|
| 181 |
+
|
| 182 |
+
# Step 10: Insert test data
|
| 183 |
+
print("🧪 Inserting test data...")
|
| 184 |
+
test_user_id = '660e8400-e29b-41d4-a716-446655440001'
|
| 185 |
+
test_merchant_id = '550e8400-e29b-41d4-a716-446655440000'
|
| 186 |
+
|
| 187 |
+
import time
|
| 188 |
+
current_time_ms = int(time.time() * 1000)
|
| 189 |
+
|
| 190 |
+
test_points = [
|
| 191 |
+
(test_merchant_id, test_user_id, 40.7128, -74.0060, 10.5, 5.2, 180.0, 85, current_time_ms - 600000),
|
| 192 |
+
(test_merchant_id, test_user_id, 40.7138, -74.0070, 12.3, 4.8, 175.0, 84, current_time_ms - 300000),
|
| 193 |
+
(test_merchant_id, test_user_id, 40.7148, -74.0080, 8.7, 6.1, 170.0, 83, current_time_ms),
|
| 194 |
+
]
|
| 195 |
+
|
| 196 |
+
await conn.executemany("""
|
| 197 |
+
INSERT INTO trans.scm_location_points
|
| 198 |
+
(merchant_id, user_id, latitude, longitude, accuracy, speed, heading, battery_level, recorded_at)
|
| 199 |
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
| 200 |
+
""", test_points)
|
| 201 |
+
|
| 202 |
+
print(f"✅ Inserted {len(test_points)} test location points\n")
|
| 203 |
+
|
| 204 |
+
# Step 11: Test query
|
| 205 |
+
print("🔍 Testing query performance...")
|
| 206 |
+
start_time = time.time()
|
| 207 |
+
|
| 208 |
+
result = await conn.fetch("""
|
| 209 |
+
SELECT
|
| 210 |
+
latitude,
|
| 211 |
+
longitude,
|
| 212 |
+
speed,
|
| 213 |
+
battery_level,
|
| 214 |
+
recorded_at
|
| 215 |
+
FROM trans.scm_location_points
|
| 216 |
+
WHERE user_id = $1
|
| 217 |
+
ORDER BY recorded_at DESC
|
| 218 |
+
LIMIT 10
|
| 219 |
+
""", test_user_id)
|
| 220 |
+
|
| 221 |
+
query_time = (time.time() - start_time) * 1000
|
| 222 |
+
|
| 223 |
+
print(f"✅ Query executed in {query_time:.2f}ms")
|
| 224 |
+
print(f" Found {len(result)} location points\n")
|
| 225 |
+
|
| 226 |
+
for row in result:
|
| 227 |
+
print(f" 📍 ({row['latitude']:.4f}, {row['longitude']:.4f})")
|
| 228 |
+
print(f" Speed: {row['speed']:.1f} m/s, Battery: {row['battery_level']}%")
|
| 229 |
+
|
| 230 |
+
print("\n" + "="*70)
|
| 231 |
+
print("🎉 Migration Completed Successfully!")
|
| 232 |
+
print("="*70)
|
| 233 |
+
print("\n✅ What's been set up:")
|
| 234 |
+
print(" 1. trans.scm_location_points table created")
|
| 235 |
+
print(" 2. Converted to TimescaleDB hypertable")
|
| 236 |
+
print(" 3. Indexes created for fast queries")
|
| 237 |
+
print(" 4. Compression policy: 7 days")
|
| 238 |
+
print(" 5. Retention policy: 90 days")
|
| 239 |
+
print(" 6. Test data inserted and verified")
|
| 240 |
+
|
| 241 |
+
print("\n📊 Table Details:")
|
| 242 |
+
print(" • Partitioned by: recorded_at (timestamp)")
|
| 243 |
+
print(" • Chunk interval: 1 day")
|
| 244 |
+
print(" • Compression: After 7 days")
|
| 245 |
+
print(" • Retention: 90 days (auto-delete)")
|
| 246 |
+
|
| 247 |
+
print("\n💡 Example API usage:")
|
| 248 |
+
print("""
|
| 249 |
+
POST /tracking/points
|
| 250 |
+
{
|
| 251 |
+
"points": [
|
| 252 |
+
{
|
| 253 |
+
"latitude": 40.7128,
|
| 254 |
+
"longitude": -74.0060,
|
| 255 |
+
"timestamp": 1234567890000,
|
| 256 |
+
"accuracy": 10.5,
|
| 257 |
+
"speed": 5.2,
|
| 258 |
+
"heading": 180.0,
|
| 259 |
+
"batteryLevel": 85
|
| 260 |
+
}
|
| 261 |
+
]
|
| 262 |
+
}
|
| 263 |
+
""")
|
| 264 |
+
|
| 265 |
+
await conn.close()
|
| 266 |
+
return True
|
| 267 |
+
|
| 268 |
+
except Exception as e:
|
| 269 |
+
print(f"\n❌ Migration failed: {e}")
|
| 270 |
+
print("\n🔧 Troubleshooting:")
|
| 271 |
+
print(" 1. Verify TimescaleDB extension is installed")
|
| 272 |
+
print(" 2. Check database credentials in .env file")
|
| 273 |
+
print(" 3. Ensure user has CREATE TABLE permissions")
|
| 274 |
+
return False
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
if __name__ == "__main__":
|
| 278 |
+
success = asyncio.run(migrate())
|
| 279 |
+
exit(0 if success else 1)
|
test_location_api.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Test script for location tracking API
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import requests
|
| 7 |
+
import time
|
| 8 |
+
from generate_test_token import generate_test_token
|
| 9 |
+
|
| 10 |
+
# Configuration
|
| 11 |
+
BASE_URL = "http://localhost:8003"
|
| 12 |
+
MERCHANT_ID = "550e8400-e29b-41d4-a716-446655440000"
|
| 13 |
+
EMPLOYEE_ID = "660e8400-e29b-41d4-a716-446655440001"
|
| 14 |
+
|
| 15 |
+
def test_location_upload():
|
| 16 |
+
"""Test batch location upload endpoint"""
|
| 17 |
+
|
| 18 |
+
print("="*70)
|
| 19 |
+
print("🧪 Testing Location Tracking API")
|
| 20 |
+
print("="*70)
|
| 21 |
+
print()
|
| 22 |
+
|
| 23 |
+
# Generate JWT token
|
| 24 |
+
print("🔑 Generating JWT token...")
|
| 25 |
+
token = generate_test_token(
|
| 26 |
+
user_id=EMPLOYEE_ID,
|
| 27 |
+
merchant_id=MERCHANT_ID,
|
| 28 |
+
role="employee"
|
| 29 |
+
)
|
| 30 |
+
print(f"✅ Token generated\n")
|
| 31 |
+
|
| 32 |
+
# Prepare headers
|
| 33 |
+
headers = {
|
| 34 |
+
"Authorization": f"Bearer {token}",
|
| 35 |
+
"Content-Type": "application/json"
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
# Test 1: Health check
|
| 39 |
+
print("1️⃣ Testing health endpoint...")
|
| 40 |
+
response = requests.get(f"{BASE_URL}/tracker/tracking/health")
|
| 41 |
+
print(f" Status: {response.status_code}")
|
| 42 |
+
print(f" Response: {response.json()}\n")
|
| 43 |
+
|
| 44 |
+
# Test 2: Upload single location point
|
| 45 |
+
print("2️⃣ Testing single location point upload...")
|
| 46 |
+
current_time_ms = int(time.time() * 1000)
|
| 47 |
+
|
| 48 |
+
single_point_data = {
|
| 49 |
+
"points": [
|
| 50 |
+
{
|
| 51 |
+
"latitude": 40.7589,
|
| 52 |
+
"longitude": -73.9851,
|
| 53 |
+
"timestamp": current_time_ms,
|
| 54 |
+
"accuracy": 15.5,
|
| 55 |
+
"speed": 3.2,
|
| 56 |
+
"heading": 90.0,
|
| 57 |
+
"batteryLevel": 75
|
| 58 |
+
}
|
| 59 |
+
]
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
response = requests.post(
|
| 63 |
+
f"{BASE_URL}/tracker/tracking/points",
|
| 64 |
+
json=single_point_data,
|
| 65 |
+
headers=headers
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
print(f" Status: {response.status_code}")
|
| 69 |
+
print(f" Response: {response.json()}\n")
|
| 70 |
+
|
| 71 |
+
# Test 3: Upload batch of location points
|
| 72 |
+
print("3️⃣ Testing batch location upload (5 points)...")
|
| 73 |
+
|
| 74 |
+
batch_points = []
|
| 75 |
+
for i in range(5):
|
| 76 |
+
batch_points.append({
|
| 77 |
+
"latitude": 40.7589 + (i * 0.001),
|
| 78 |
+
"longitude": -73.9851 + (i * 0.001),
|
| 79 |
+
"timestamp": current_time_ms + (i * 60000), # 1 minute apart
|
| 80 |
+
"accuracy": 10.0 + i,
|
| 81 |
+
"speed": 2.5 + (i * 0.5),
|
| 82 |
+
"heading": 90.0 + (i * 10),
|
| 83 |
+
"batteryLevel": 75 - i
|
| 84 |
+
})
|
| 85 |
+
|
| 86 |
+
batch_data = {"points": batch_points}
|
| 87 |
+
|
| 88 |
+
response = requests.post(
|
| 89 |
+
f"{BASE_URL}/tracker/tracking/points",
|
| 90 |
+
json=batch_data,
|
| 91 |
+
headers=headers
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
print(f" Status: {response.status_code}")
|
| 95 |
+
print(f" Response: {response.json()}\n")
|
| 96 |
+
|
| 97 |
+
# Test 4: Invalid data - missing required field
|
| 98 |
+
print("4️⃣ Testing validation (missing latitude)...")
|
| 99 |
+
|
| 100 |
+
invalid_data = {
|
| 101 |
+
"points": [
|
| 102 |
+
{
|
| 103 |
+
"longitude": -73.9851,
|
| 104 |
+
"timestamp": current_time_ms
|
| 105 |
+
}
|
| 106 |
+
]
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
response = requests.post(
|
| 110 |
+
f"{BASE_URL}/tracker/tracking/points",
|
| 111 |
+
json=invalid_data,
|
| 112 |
+
headers=headers
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
print(f" Status: {response.status_code}")
|
| 116 |
+
print(f" Response: {response.json()}\n")
|
| 117 |
+
|
| 118 |
+
# Test 5: Invalid data - out of order timestamps
|
| 119 |
+
print("5️⃣ Testing validation (out of order timestamps)...")
|
| 120 |
+
|
| 121 |
+
out_of_order_data = {
|
| 122 |
+
"points": [
|
| 123 |
+
{
|
| 124 |
+
"latitude": 40.7589,
|
| 125 |
+
"longitude": -73.9851,
|
| 126 |
+
"timestamp": current_time_ms
|
| 127 |
+
},
|
| 128 |
+
{
|
| 129 |
+
"latitude": 40.7590,
|
| 130 |
+
"longitude": -73.9852,
|
| 131 |
+
"timestamp": current_time_ms - 60000 # Earlier timestamp
|
| 132 |
+
}
|
| 133 |
+
]
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
response = requests.post(
|
| 137 |
+
f"{BASE_URL}/tracker/tracking/points",
|
| 138 |
+
json=out_of_order_data,
|
| 139 |
+
headers=headers
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
print(f" Status: {response.status_code}")
|
| 143 |
+
print(f" Response: {response.json()}\n")
|
| 144 |
+
|
| 145 |
+
# Test 6: Unauthorized request (no token)
|
| 146 |
+
print("6️⃣ Testing authentication (no token)...")
|
| 147 |
+
|
| 148 |
+
response = requests.post(
|
| 149 |
+
f"{BASE_URL}/tracker/tracking/points",
|
| 150 |
+
json=single_point_data
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
print(f" Status: {response.status_code}")
|
| 154 |
+
print(f" Response: {response.json()}\n")
|
| 155 |
+
|
| 156 |
+
print("="*70)
|
| 157 |
+
print("✅ Location Tracking API Tests Complete!")
|
| 158 |
+
print("="*70)
|
| 159 |
+
print()
|
| 160 |
+
print("📊 Summary:")
|
| 161 |
+
print(" • Health check: Working")
|
| 162 |
+
print(" • Single point upload: Working")
|
| 163 |
+
print(" • Batch upload: Working")
|
| 164 |
+
print(" • Validation: Working")
|
| 165 |
+
print(" • Authentication: Working")
|
| 166 |
+
print()
|
| 167 |
+
print("💡 Next steps:")
|
| 168 |
+
print(" 1. Check database: SELECT * FROM trans.scm_location_points;")
|
| 169 |
+
print(" 2. Verify TimescaleDB chunks are created")
|
| 170 |
+
print(" 3. Test with mobile app")
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
if __name__ == "__main__":
|
| 174 |
+
try:
|
| 175 |
+
test_location_upload()
|
| 176 |
+
except requests.exceptions.ConnectionError:
|
| 177 |
+
print("❌ Error: Could not connect to server")
|
| 178 |
+
print(" Make sure the server is running: python3 -m uvicorn app.main:app --port 8003")
|
| 179 |
+
except Exception as e:
|
| 180 |
+
print(f"❌ Error: {e}")
|