New GET /api/poi/distances/bulk returns all POI distances keyed by listing ID, allowing the frontend to fetch distances separately from the listing stream and keep the stream on the cached path.
237 lines
7.1 KiB
Python
237 lines
7.1 KiB
Python
import logging
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from pydantic import BaseModel, Field
|
|
|
|
from api.auth import User, get_current_user
|
|
from database import engine
|
|
from models.listing import ListingType
|
|
from repositories.poi_repository import POIRepository
|
|
from repositories.user_repository import UserRepository
|
|
from services import poi_service, task_service
|
|
|
|
logger = logging.getLogger("uvicorn")
|
|
|
|
poi_router = APIRouter(prefix="/api/poi", tags=["poi"])
|
|
|
|
|
|
class CreatePOIRequest(BaseModel):
|
|
name: str = Field(max_length=200)
|
|
address: str = Field(max_length=500)
|
|
latitude: float = Field(ge=-90, le=90)
|
|
longitude: float = Field(ge=-180, le=180)
|
|
|
|
|
|
class UpdatePOIRequest(BaseModel):
|
|
name: str | None = Field(default=None, max_length=200)
|
|
address: str | None = Field(default=None, max_length=500)
|
|
latitude: float | None = Field(default=None, ge=-90, le=90)
|
|
longitude: float | None = Field(default=None, ge=-180, le=180)
|
|
|
|
|
|
class POIResponse(BaseModel):
|
|
id: int
|
|
name: str
|
|
address: str
|
|
latitude: float
|
|
longitude: float
|
|
created_at: str
|
|
|
|
|
|
class CalculateRequest(BaseModel):
|
|
travel_modes: list[str] # WALK, BICYCLE, TRANSIT
|
|
listing_type: ListingType = ListingType.RENT
|
|
listing_ids: list[int] | None = None
|
|
|
|
|
|
class POIDistanceResponse(BaseModel):
|
|
poi_id: int
|
|
poi_name: str
|
|
travel_mode: str
|
|
duration_seconds: int
|
|
distance_meters: int
|
|
|
|
|
|
def _get_user_id(user: User) -> int:
|
|
"""Resolve auth User to database user ID."""
|
|
user_repo = UserRepository(engine)
|
|
db_user = user_repo.get_user_by_email(user.email)
|
|
if db_user is None:
|
|
# Auto-create user on first POI interaction
|
|
db_user = user_repo.create_user(user.email)
|
|
if db_user.id is None:
|
|
raise HTTPException(status_code=500, detail="Failed to create user")
|
|
return db_user.id
|
|
|
|
|
|
def _poi_to_response(poi: "poi_service.PointOfInterest") -> POIResponse:
|
|
return POIResponse(
|
|
id=poi.id, # type: ignore[arg-type]
|
|
name=poi.name,
|
|
address=poi.address,
|
|
latitude=poi.latitude,
|
|
longitude=poi.longitude,
|
|
created_at=poi.created_at.isoformat(),
|
|
)
|
|
|
|
|
|
@poi_router.get("", response_model=list[POIResponse])
|
|
async def list_pois(
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
) -> list[POIResponse]:
|
|
"""List all POIs for the current user."""
|
|
user_id = _get_user_id(user)
|
|
repo = POIRepository(engine)
|
|
pois = poi_service.get_user_pois(repo, user_id)
|
|
return [_poi_to_response(p) for p in pois]
|
|
|
|
|
|
@poi_router.post("", response_model=POIResponse)
|
|
async def create_poi(
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
body: CreatePOIRequest,
|
|
) -> POIResponse:
|
|
"""Create a new POI."""
|
|
user_id = _get_user_id(user)
|
|
repo = POIRepository(engine)
|
|
result = poi_service.create_poi(
|
|
repo,
|
|
user_id=user_id,
|
|
name=body.name,
|
|
address=body.address,
|
|
latitude=body.latitude,
|
|
longitude=body.longitude,
|
|
)
|
|
return _poi_to_response(result.poi)
|
|
|
|
|
|
@poi_router.put("/{poi_id}", response_model=POIResponse)
|
|
async def update_poi(
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
poi_id: int,
|
|
body: UpdatePOIRequest,
|
|
) -> POIResponse:
|
|
"""Update an existing POI."""
|
|
user_id = _get_user_id(user)
|
|
repo = POIRepository(engine)
|
|
result = poi_service.update_poi(
|
|
repo,
|
|
poi_id=poi_id,
|
|
user_id=user_id,
|
|
name=body.name,
|
|
address=body.address,
|
|
latitude=body.latitude,
|
|
longitude=body.longitude,
|
|
)
|
|
if result is None:
|
|
raise HTTPException(status_code=404, detail="POI not found")
|
|
return _poi_to_response(result.poi)
|
|
|
|
|
|
@poi_router.delete("/{poi_id}")
|
|
async def delete_poi(
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
poi_id: int,
|
|
) -> dict[str, bool]:
|
|
"""Delete a POI and its associated distances."""
|
|
user_id = _get_user_id(user)
|
|
repo = POIRepository(engine)
|
|
deleted = poi_service.delete_poi(repo, poi_id, user_id)
|
|
if not deleted:
|
|
raise HTTPException(status_code=404, detail="POI not found")
|
|
return {"success": True}
|
|
|
|
|
|
@poi_router.post("/{poi_id}/calculate")
|
|
async def calculate_distances(
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
poi_id: int,
|
|
body: CalculateRequest,
|
|
) -> dict[str, str]:
|
|
"""Trigger distance calculation for a POI."""
|
|
user_id = _get_user_id(user)
|
|
repo = POIRepository(engine)
|
|
|
|
# Verify POI exists and belongs to user
|
|
poi = poi_service.get_poi(repo, poi_id)
|
|
if poi is None or poi.user_id != user_id:
|
|
raise HTTPException(status_code=404, detail="POI not found")
|
|
|
|
result = poi_service.trigger_calculation(
|
|
poi_id=poi_id,
|
|
travel_modes=body.travel_modes,
|
|
listing_type=body.listing_type,
|
|
user_email=user.email,
|
|
listing_ids=body.listing_ids,
|
|
)
|
|
|
|
if result.task_id:
|
|
task_service.add_task_for_user(user.email, result.task_id)
|
|
|
|
return {"task_id": result.task_id or "", "message": result.message}
|
|
|
|
|
|
@poi_router.get("/distances/bulk")
|
|
async def get_bulk_distances(
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
listing_type: ListingType = ListingType.RENT,
|
|
) -> dict[int, list[POIDistanceResponse]]:
|
|
"""Get all POI distances for the current user, keyed by listing ID."""
|
|
user_id = _get_user_id(user)
|
|
repo = POIRepository(engine)
|
|
pois = {p.id: p for p in poi_service.get_user_pois(repo, user_id)}
|
|
if not pois:
|
|
return {}
|
|
|
|
from repositories.listing_repository import ListingRepository
|
|
from database import engine as db_engine
|
|
listing_repo = ListingRepository(db_engine)
|
|
all_ids = list(listing_repo.get_listing_ids(listing_type))
|
|
if not all_ids:
|
|
return {}
|
|
|
|
distances = repo.get_distances_for_listings(all_ids, listing_type, user_id)
|
|
|
|
result: dict[int, list[POIDistanceResponse]] = {}
|
|
for d in distances:
|
|
poi_name = pois[d.poi_id].name if d.poi_id in pois else "Unknown"
|
|
result.setdefault(d.listing_id, []).append(
|
|
POIDistanceResponse(
|
|
poi_id=d.poi_id,
|
|
poi_name=poi_name,
|
|
travel_mode=d.travel_mode,
|
|
duration_seconds=d.duration_seconds,
|
|
distance_meters=d.distance_meters,
|
|
)
|
|
)
|
|
return result
|
|
|
|
|
|
@poi_router.get("/distances")
|
|
async def get_distances(
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
listing_id: int,
|
|
listing_type: ListingType = ListingType.RENT,
|
|
) -> list[POIDistanceResponse]:
|
|
"""Get POI distances for a specific listing."""
|
|
user_id = _get_user_id(user)
|
|
repo = POIRepository(engine)
|
|
poi_repo_pois = {
|
|
p.id: p for p in poi_service.get_user_pois(repo, user_id)
|
|
}
|
|
|
|
distances = poi_service.get_distances_for_listing(
|
|
repo, listing_id, listing_type, user_id
|
|
)
|
|
|
|
return [
|
|
POIDistanceResponse(
|
|
poi_id=d.poi_id,
|
|
poi_name=poi_repo_pois[d.poi_id].name if d.poi_id in poi_repo_pois else "Unknown",
|
|
travel_mode=d.travel_mode,
|
|
duration_seconds=d.duration_seconds,
|
|
distance_meters=d.distance_meters,
|
|
)
|
|
for d in distances
|
|
]
|