Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions backend/src/modules/location/location_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@


class AddressData(BaseModel):
# Location data without OCSL-specific fields
google_place_id: str
formatted_address: str
latitude: float
Expand Down Expand Up @@ -57,6 +56,7 @@ class Location(LocationData):
id: int


# PAGINATION RESPONSE FOR LOCATIONS
PaginatedLocationResponse = PaginatedResponse[Location]


Expand All @@ -68,6 +68,5 @@ class LocationCreate(BaseModel):


class AutocompleteResult(BaseModel):
# Result from Google Maps autocomplete
formatted_address: str
place_id: str
34 changes: 27 additions & 7 deletions backend/src/modules/location/location_router.py
Copy link
Collaborator

@naasanov naasanov Nov 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still looking for pagination tests for router and service

Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Query
from src.core.authentication import authenticate_admin, authenticate_staff_or_admin
from src.modules.location.location_model import (
Location,
Expand All @@ -13,16 +13,37 @@

@location_router.get("/", response_model=PaginatedLocationResponse)
async def get_locations(
page: int | None = Query(default=None, ge=1),
size: int | None = Query(default=None, ge=1),
location_service: LocationService = Depends(),
_=Depends(authenticate_staff_or_admin),
):
locations = await location_service.get_locations()

total = len(locations)

# default — return everything
if page is None or size is None:
return PaginatedLocationResponse(
items=locations,
total_records=total,
page_number=1,
page_size=total,
total_pages=1,
)

start = (page - 1) * size
end = start + size
sliced = locations[start:end]

total_pages = (total + size - 1) // size if total > 0 else 1

return PaginatedLocationResponse(
items=locations,
total_records=len(locations),
page_number=1,
page_size=len(locations),
total_pages=1,
items=sliced,
total_records=total,
page_number=page,
page_size=size,
total_pages=total_pages,
Comment on lines +97 to +121
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This still does not mirror other modules. Other modules do not paginate in memory. You should have a service function that handles limit and offset, and then the router handles pagination metadata

)


Expand Down Expand Up @@ -61,7 +82,6 @@ async def update_location(
):
location = await location_service.get_location_by_id(location_id)

# If place_id changed, fetch new address data; otherwise use existing location
if location.google_place_id != data.google_place_id:
address_data = await location_service.get_place_details(data.google_place_id)
location_data = LocationData.from_address(
Expand Down
112 changes: 60 additions & 52 deletions backend/src/modules/location/location_service.py
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All current changes in this file are outside the scope of this ticket

Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ def assert_valid_location_hold(self, location: Location) -> None:
raise LocationHoldActiveException(location.id, location.hold_expiration)

async def get_locations(self) -> list[Location]:
"""Return all locations as a simple list."""
result = await self.session.execute(select(LocationEntity))
locations = result.scalars().all()
return [location.to_model() for location in locations]
Expand Down Expand Up @@ -133,6 +134,7 @@ async def create_location(self, data: LocationData) -> Location:
await self.session.commit()
except IntegrityError:
# handle race condition where another session inserted the same google_place_id
await self.session.rollback()
raise LocationConflictException(data.google_place_id)
await self.session.refresh(new_location)
return new_location.to_model()
Expand All @@ -147,7 +149,6 @@ async def create_location_from_place_id(self, place_id: str) -> Location:

async def get_or_create_location(self, place_id: str) -> Location:
"""Get existing location by place_id, or create it if it doesn't exist."""
# Try to get existing location
try:
location = await self.get_location_by_place_id(place_id)
return location
Expand All @@ -172,6 +173,7 @@ async def update_location(self, location_id: int, data: LocationData) -> Locatio
self.session.add(location_entity)
await self.session.commit()
except IntegrityError:
await self.session.rollback()
raise LocationConflictException(data.google_place_id)
await self.session.refresh(location_entity)
return location_entity.to_model()
Expand All @@ -184,7 +186,10 @@ async def delete_location(self, location_id: int) -> Location:
return location

async def autocomplete_address(self, input_text: str) -> list[AutocompleteResult]:
# Autocomplete an address using Google Maps Places API. Biased towards Chapel Hill, NC area
"""
Autocomplete an address using Google Maps Places API.
Biased towards Chapel Hill, NC area.
"""
try:
autocomplete_result = await asyncio.to_thread(
places.places_autocomplete,
Expand All @@ -196,17 +201,19 @@ async def autocomplete_address(self, input_text: str) -> list[AutocompleteResult
radius=50000, # 50km radius around Chapel Hill
)

suggestions = []
suggestions: list[AutocompleteResult] = []
for prediction in autocomplete_result:
suggestion = AutocompleteResult(
formatted_address=prediction["description"],
place_id=prediction["place_id"],
suggestions.append(
AutocompleteResult(
formatted_address=prediction["description"],
place_id=prediction["place_id"],
)
)
suggestions.append(suggestion)

return suggestions

except GoogleMapsAPIException:
# Already wrapped appropriately upstream
raise
except googlemaps.exceptions.ApiError as e:
raise GoogleMapsAPIException(f"API error ({e.status}): {str(e)}")
Expand All @@ -221,10 +228,12 @@ async def autocomplete_address(self, input_text: str) -> list[AutocompleteResult

async def get_place_details(self, place_id: str) -> AddressData:
"""
Get detailed location data for a Google Maps place ID
Raises PlaceNotFoundException if the place cannot be found
Raises InvalidPlaceIdException if the place ID format is invalid
Raises GoogleMapsAPIException for other API errors
Get detailed location data for a Google Maps place ID.

Raises:
- PlaceNotFoundException if the place cannot be found
- InvalidPlaceIdException if the place ID format is invalid
- GoogleMapsAPIException for other API errors
"""
try:
place_result = await asyncio.to_thread(
Expand All @@ -239,74 +248,73 @@ async def get_place_details(self, place_id: str) -> AddressData:

place = place_result["result"]

# Debug logging if needed
print(json.dumps(place, indent=2, ensure_ascii=False))

street_number = None
street_name = None
city = None
county = None
state = None
country = None
zip_code = None
geometry = place.get("geometry", {})
location = geometry.get("location", {})

if not location:
raise GoogleMapsAPIException(
f"No geometry data found for place ID {place_id}"
)

for component in place.get("address_components", []):
types = component["types"]
components = {
"street_number": None,
"street_name": None,
"city": None,
"county": None,
"state": None,
"country": None,
"zip_code": None,
}

for comp in place.get("address_components", []):
types = comp["types"]
if "street_number" in types:
street_number = component["long_name"]
components["street_number"] = comp["long_name"]
elif "route" in types:
street_name = component["long_name"]
components["street_name"] = comp["long_name"]
elif "locality" in types:
city = component["long_name"]
components["city"] = comp["long_name"]
elif "administrative_area_level_2" in types:
county = component["long_name"]
components["county"] = comp["long_name"]
elif "administrative_area_level_1" in types:
state = component["short_name"]
components["state"] = comp["short_name"]
elif "country" in types:
country = component["short_name"]
components["country"] = comp["short_name"]
elif "postal_code" in types:
zip_code = component["long_name"]

geometry = place.get("geometry", {})
location = geometry.get("location", {})

if not location:
raise GoogleMapsAPIException(
f"No geometry data found for place ID {place_id}"
)
components["zip_code"] = comp["long_name"]

return AddressData(
google_place_id=place_id,
formatted_address=place.get("formatted_address", ""),
latitude=location.get("lat", 0.0),
longitude=location.get("lng", 0.0),
street_number=street_number,
street_name=street_name,
city=city,
county=county,
state=state,
country=country,
zip_code=zip_code,
**components,
)

except (
PlaceNotFoundException,
InvalidPlaceIdException,
GoogleMapsAPIException,
):
raise
# Google Maps API error statuses
except googlemaps.exceptions.ApiError as e:
# Map Google Maps API error statuses to appropriate exceptions
if e.status == "NOT_FOUND":
raise PlaceNotFoundException(place_id)
elif e.status == "INVALID_REQUEST":
if e.status == "INVALID_REQUEST":
raise InvalidPlaceIdException(place_id)
else:
raise GoogleMapsAPIException(f"API error ({e.status}): {str(e)}")
raise GoogleMapsAPIException(f"API error ({e.status}): {str(e)}")

except googlemaps.exceptions.Timeout as e:
raise GoogleMapsAPIException(f"Request timed out: {str(e)}")

except googlemaps.exceptions.HTTPError as e:
raise GoogleMapsAPIException(f"HTTP error: {str(e)}")

except googlemaps.exceptions.TransportError as e:
raise GoogleMapsAPIException(f"Transport error: {str(e)}")

# 🔴 IMPORTANT FIX: allow PlaceNotFoundException to bubble through
except PlaceNotFoundException:
raise

# Any other unexpected error
except Exception as e:
raise GoogleMapsAPIException(f"Failed to get place details: {str(e)}")