Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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: 3 additions & 0 deletions backend/python/app/models/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ class BaseModel(sm.SQLModel):
default=None,
)

def __init_subclass__(cls, **kwargs: Any) -> None:
super().__init_subclass__(**kwargs)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

For context, these lines were added to fix this MyPy error:
Unexpected keyword argument "table" for "__init_subclass__" of "object"Mypy

def __init__(self, **data: Any) -> None:
if self.model_config.get("table", False) and not _ONGOING_MODEL_VALIDATE.get(
False
Expand Down
2 changes: 2 additions & 0 deletions backend/python/app/models/route.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class RouteBase(SQLModel):
default=None, sa_column=Column(DateTime(timezone=True), nullable=True)
)
expires_at: datetime | None = Field(default=None)
ends_at_warehouse: bool = Field(default=False)


class Route(RouteBase, BaseModel, table=True):
Expand Down Expand Up @@ -69,6 +70,7 @@ class RouteUpdate(SQLModel):
encoded_polyline: str | None = None
polyline_updated_at: datetime | None = None
expires_at: datetime | None = None
ends_at_warehouse: bool | None = None


class RouteWithDateRead(SQLModel):
Expand Down
111 changes: 111 additions & 0 deletions backend/python/app/utilities/routes_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
from typing import TYPE_CHECKING

from fastapi import HTTPException
from google.api_core import exceptions as google_exceptions
from google.api_core.client_options import ClientOptions
from google.maps import routing_v2

from app.config import settings

if TYPE_CHECKING:
from app.models.location import Location


async def fetch_route_polyline(
locations: list["Location"],
warehouse_lat: float,
warehouse_lon: float,
ends_at_warehouse: bool,
) -> tuple[str, float]:
"""Fetch encoded polyline from Google Maps Routes API.

Args:
locations: Ordered list of Location objects (route stops)
warehouse_lat: Warehouse latitude
warehouse_lon: Warehouse longitude
ends_at_warehouse: If True, route returns to warehouse

Returns:
Tuple with encoded polyline string and total distance in kilometers

Raises:
HTTPException: If API request fails
ValueError: If locations list is empty or API key not configured
"""
if not locations:
raise ValueError("Locations list cannot be empty")

if not settings.google_maps_api_key:
raise ValueError("Google Maps API key is not configured in settings")

# Build waypoints
origin = routing_v2.Waypoint(
location=routing_v2.Location(
lat_lng={"latitude": warehouse_lat, "longitude": warehouse_lon}
)
)

intermediates = [
routing_v2.Waypoint(
location=routing_v2.Location(
lat_lng={"latitude": loc.latitude, "longitude": loc.longitude}
)
)
for loc in locations
]

if ends_at_warehouse:
destination = origin
waypoints_to_use = intermediates
else:
if len(intermediates) > 1:
waypoints_to_use = intermediates[:-1]
destination = intermediates[-1]
else:
waypoints_to_use = None
destination = intermediates[0]

# Build request
request = routing_v2.ComputeRoutesRequest(
origin=origin,
destination=destination,
intermediates=waypoints_to_use,
travel_mode=routing_v2.RouteTravelMode.DRIVE,
routing_preference=routing_v2.RoutingPreference.TRAFFIC_AWARE,
)

try:
# Create client with API key
options = ClientOptions(api_key=settings.google_maps_api_key)
client = routing_v2.RoutesAsyncClient(client_options=options)

response = await client.compute_routes(
request=request,
metadata=[
(
"x-goog-fieldmask",
"routes.polyline.encodedPolyline,routes.distanceMeters",
)
],
)

if not response.routes:
raise HTTPException(
status_code=500,
detail="Google Maps API returned no routes",
)

route = response.routes[0]
polyline = route.polyline.encoded_polyline
distance_km = route.distance_meters / 1000.0

return polyline, distance_km

except google_exceptions.GoogleAPICallError as e:
raise HTTPException(
status_code=503, detail=f"Google Maps API error: {e!s}"
) from e
except google_exceptions.RetryError as e:
raise HTTPException(status_code=504, detail="Request timed out") from e
except Exception as e:
raise HTTPException(status_code=500, detail=f"Unexpected error: {e!s}") from e
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""added ends_at_warehouse to route model

Revision ID: eb010a6ed5ad
Revises: 7af7d4689b08
Create Date: 2025-12-01 00:31:47.827096

"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = 'eb010a6ed5ad'
down_revision = 'b1c2d3e4f5a6'
branch_labels = None
depends_on = None


def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('routes', sa.Column('ends_at_warehouse', sa.Boolean(), nullable=False, server_default='false'))
# ### end Alembic commands ###


def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('routes', 'ends_at_warehouse')
# ### end Alembic commands ###
1 change: 1 addition & 0 deletions backend/python/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ google-api-core>=2.0.0
google-cloud-core>=1.6.0
google-cloud-firestore>=2.1.0
google-cloud-storage>=1.37.1
google-maps-routing>=0.1.0
googleapis-common-protos>=1.70.0
firebase-admin>=6.0.0

Expand Down
50 changes: 50 additions & 0 deletions backend/python/tests/test_route_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from uuid import uuid4

import pytest

from app.models.location import Location
from app.utilities.routes_utils import fetch_route_polyline


@pytest.mark.asyncio
async def test_fetch_route_polyline_with_return():
"""Test fetching polyline with return to warehouse."""

# Create mock locations
loc1 = Location(
location_group_id=uuid4(),
contact_name="Test 1 Loc 1",
address="123 Test St",
phone_number="123-456-7890",
longitude=-80.50,
latitude=43.45,
halal=True,
dietary_restrictions="",
num_boxes=5,
notes="",
)

loc2 = Location(
location_group_id=uuid4(),
contact_name="Test 1 Loc 2",
address="124 Test St",
phone_number="124-456-7890",
longitude=-80.51,
latitude=43.46,
halal=True,
dietary_restrictions="",
num_boxes=5,
notes="",
)

polyline, distance_km = await fetch_route_polyline(
locations=[loc1, loc2],
warehouse_lat=43.40,
warehouse_lon=-80.46,
ends_at_warehouse=True,
)

assert isinstance(polyline, str)
assert distance_km > 0.0
assert len(polyline) > 0
print(f"Encoded Polyline: {polyline}")
Loading