routers.bundles

Endpoints for bundles.

  1"""Endpoints for bundles."""
  2
  3from datetime import datetime
  4
  5from fastapi import APIRouter, HTTPException, Response, status
  6from internal.auth.middleware import ConsumerAuthDep
  7from internal.auth.security import generate_claim_code
  8from internal.block.management import block_management
  9from internal.database.dependency import database_dependency
 10from internal.geolocation.distance import dist_safe_box, get_distance
 11from internal.geolocation.types import LocationModel
 12from internal.queries.allergens import AsyncQuerier as AllergensQuerier
 13from internal.queries.bundle import AsyncQuerier as BundleQuerier
 14from internal.queries.category import AsyncQuerier as CategoriesQuerier
 15from internal.queries.models import Bundle, Reservation
 16from internal.queries.reservations import AsyncQuerier as ReservationQuerier
 17from internal.queries.reservations import CreateReservationParams
 18from internal.queries.seller import AsyncQuerier as SellerQuerier
 19from internal.queries.seller import GetSellerByLocationParams
 20from internal.settings.env import host_settings
 21from pydantic import BaseModel, Field
 22from thefuzz.fuzz import WRatio  # type: ignore[import-untyped]
 23
 24router = APIRouter(prefix="/bundles", tags=["bundles"])
 25
 26
 27@router.get(
 28    "/",
 29    status_code=status.HTTP_200_OK,
 30    summary="Get all bundles",
 31    description="Retrieves a list of all available bundles.",
 32)
 33async def get_bundles(conn: database_dependency) -> list[Bundle]:
 34    """Get all bundles.
 35
 36    Args:
 37      conn: database connection
 38
 39    Returns:
 40      all bundles in an iterator
 41
 42    Raises:
 43      HTTPException: if failed to get bundles
 44    """
 45    bundles = [item async for item in BundleQuerier(conn).get_bundles()]
 46    if bundles is None:
 47        raise HTTPException(
 48            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
 49            detail="Failed to find bundles",
 50        )
 51    return list(bundles)
 52
 53
 54@router.get(
 55    "/{bundle_id}",
 56    status_code=status.HTTP_200_OK,
 57    summary="Get bundle by ID",
 58    description="Retrieves details of a specific bundle by its ID.",
 59)
 60async def get_bundle(bundle_id: str, conn: database_dependency) -> Bundle:
 61    """Get bundle.
 62
 63    Args:
 64      bundle_id: bundle id
 65      conn: database connection
 66
 67    Returns:
 68      found bundle
 69
 70    Raises:
 71      HTTPException: if failed to find a bundle
 72    """
 73    bundle = await BundleQuerier(conn).get_bundle(bundle_id=int(bundle_id))
 74    if not bundle:
 75        raise HTTPException(
 76            status_code=status.HTTP_404_NOT_FOUND, detail="Bundle not found"
 77        )
 78    return bundle
 79
 80
 81@router.post(
 82    "/{bundle_id}/reservations",
 83    tags=["reservations"],
 84    status_code=status.HTTP_201_CREATED,
 85    summary="Reserve a bundle",
 86    description=(
 87        "Creates a reservation for a specific bundle for the authenticated consumer."
 88    ),
 89)
 90async def reserve_bundle(
 91    bundle_id: str, conn: database_dependency, consumer: ConsumerAuthDep
 92) -> Reservation:
 93    """Create bundle reservation.
 94
 95    Args:
 96        bundle_id: bundle id
 97        conn: database connection
 98        consumer: consumer session
 99
100    Returns:
101        found reservation
102
103    Raises:
104        HTTPException: if failed to create reservation
105    """
106    bundle = await BundleQuerier(conn).get_bundle_lock(bundle_id=int(bundle_id))
107    if not bundle:
108        raise HTTPException(
109            status_code=status.HTTP_404_NOT_FOUND, detail="Bundle not found"
110        )
111    reservations_querier = ReservationQuerier(conn)
112    reservations = [
113        item
114        async for item in reservations_querier.get_bundle_reservations(
115            bundle_id=int(bundle_id)
116        )
117    ]
118    if reservations is None:
119        raise HTTPException(
120            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
121            detail="Failed to find reservations",
122        )
123    if bundle.total_qty <= len(list(reservations)):
124        raise HTTPException(
125            status_code=status.HTTP_409_CONFLICT, detail="No reservations available"
126        )
127    used_codes = [rese.claim_code for rese in reservations]
128    reservation = await reservations_querier.create_reservation(
129        CreateReservationParams(
130            bundle_id=bundle.bundle_id,
131            consumer_id=consumer.user_id,
132            claim_code=generate_claim_code(used_codes),
133        )
134    )
135    if not reservation:
136        raise HTTPException(
137            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
138            detail="Failed to create reservation",
139        )
140    return reservation
141
142
143class SearchBundlesForm(BaseModel):
144    """Consumer search form with parameters."""
145
146    lat: float
147    lon: float
148    max_dist: int = Field(gt=0)
149    max_price: float | None = Field(gt=0)
150    seller_name: str | None
151    allergens: list[int] | None
152    categories: list[int] | None
153
154
155class SearchBundlesResponse(BaseModel):
156    """Search response item with card information."""
157
158    bundle_id: int
159    sellers_name: str
160    bundle_name: str
161    bundle_description: str
162    price: float
163    discount_percentage: int
164    window_start: datetime
165    window_end: datetime
166    dist: float
167
168
169@router.post(
170    "/search",
171    status_code=status.HTTP_200_OK,
172    summary="Search bundles",
173    description=(
174        "Searches for bundles based on location, distance, price, and other filters."
175    ),
176)
177async def search_bundles(
178    form: SearchBundlesForm, conn: database_dependency
179) -> list[SearchBundlesResponse]:
180    """Bundle search with parameters endpoint.
181
182    Args:
183      form: consumer search parameters
184      conn: database connection
185
186    Returns:
187      found bundles card information
188    """
189    distance_box = dist_safe_box(
190        LocationModel(lat=form.lat, lon=form.lon), form.max_dist
191    )
192    sellers = [
193        item
194        async for item in SellerQuerier(conn).get_seller_by_location(
195            GetSellerByLocationParams(
196                lat_max=distance_box.lat_max,
197                lat_min=distance_box.lat_min,
198                lon_max=distance_box.lon_max,
199                lon_min=distance_box.lon_min,
200            )
201        )
202    ]
203    filtered_bundles: list[SearchBundlesResponse] = []
204    for seller in sellers:
205        dist = get_distance(
206            LocationModel(lat=form.lat, lon=form.lon),
207            LocationModel(lat=seller.latitude, lon=seller.longitude),
208        )
209        if dist > form.max_dist:
210            continue
211        if (
212            form.seller_name
213            and WRatio(form.seller_name, seller.seller_name)
214            < host_settings.fuzz_threshold
215        ):
216            continue
217        seller_bundles = [
218            item
219            async for item in BundleQuerier(conn).get_sellers_active_bundles(
220                seller_id=seller.user_id
221            )
222        ]
223        for bundle in seller_bundles:
224            allergens = [
225                item
226                async for item in AllergensQuerier(conn).get_bundle_allergens(
227                    bundle_id=bundle.bundle_id
228                )
229            ]
230            if (
231                allergens
232                and form.allergens
233                and not set(allergens).isdisjoint(set(form.allergens))
234            ):
235                continue
236            categories = [
237                item
238                async for item in CategoriesQuerier(conn).get_bundle_categories(
239                    bundle_id=bundle.bundle_id
240                )
241            ]
242            if (
243                categories
244                and form.categories
245                and set(categories).isdisjoint(set(form.categories))
246            ):
247                continue
248            if (
249                form.max_price
250                and (bundle.price * bundle.discount_percentage / 100) > form.max_price
251            ):
252                continue
253            reservations = [
254                item
255                async for item in ReservationQuerier(conn).get_bundle_reservations(
256                    bundle_id=int(bundle.bundle_id)
257                )
258            ]
259            if not reservations or bundle.total_qty <= len(list(reservations)):
260                continue
261            filtered_bundles.append(
262                SearchBundlesResponse(
263                    bundle_id=bundle.bundle_id,
264                    sellers_name=seller.seller_name,
265                    bundle_name=bundle.bundle_name,
266                    bundle_description=bundle.description,
267                    price=float(bundle.price),
268                    discount_percentage=bundle.discount_percentage,
269                    window_start=bundle.window_start,
270                    window_end=bundle.window_end,
271                    dist=round(dist, 2),
272                )
273            )
274    return filtered_bundles
275
276
277@router.get(
278    path="/{bundle_id}/image",
279    status_code=status.HTTP_200_OK,
280    summary="Get bundle image",
281    description="Retrieves the image for a specific bundle.",
282)
283async def get_bundle_image(bundle_id: int, conn: database_dependency) -> Response:
284    """Get bundle image.
285
286    Args:
287        bundle_id: bundle id
288        conn: database connection
289
290    Returns:
291        bundle image
292
293    Raises:
294        HTTPException: if failed to get image
295    """
296    if BundleQuerier(conn).get_bundle(bundle_id=bundle_id) is None:
297        raise HTTPException(status.HTTP_404_NOT_FOUND, "bundle not found")
298    return Response(
299        block_management.get_bundle_image(bundle_id), media_type="image/jpeg"
300    )
router = <fastapi.routing.APIRouter object>
@router.get('/', status_code=status.HTTP_200_OK, summary='Get all bundles', description='Retrieves a list of all available bundles.')
async def get_bundles( conn: Annotated[sqlalchemy.ext.asyncio.engine.AsyncConnection, Depends(dependency=<bound method DatabaseManager.get_connection of <internal.database.manager.DatabaseManager object>>, use_cache=True, scope=None)]) -> list[internal.queries.models.Bundle]:
28@router.get(
29    "/",
30    status_code=status.HTTP_200_OK,
31    summary="Get all bundles",
32    description="Retrieves a list of all available bundles.",
33)
34async def get_bundles(conn: database_dependency) -> list[Bundle]:
35    """Get all bundles.
36
37    Args:
38      conn: database connection
39
40    Returns:
41      all bundles in an iterator
42
43    Raises:
44      HTTPException: if failed to get bundles
45    """
46    bundles = [item async for item in BundleQuerier(conn).get_bundles()]
47    if bundles is None:
48        raise HTTPException(
49            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
50            detail="Failed to find bundles",
51        )
52    return list(bundles)

Get all bundles.

Arguments:
  • conn: database connection
Returns:

all bundles in an iterator

Raises:
  • HTTPException: if failed to get bundles
@router.get('/{bundle_id}', status_code=status.HTTP_200_OK, summary='Get bundle by ID', description='Retrieves details of a specific bundle by its ID.')
async def get_bundle( bundle_id: str, conn: Annotated[sqlalchemy.ext.asyncio.engine.AsyncConnection, Depends(dependency=<bound method DatabaseManager.get_connection of <internal.database.manager.DatabaseManager object>>, use_cache=True, scope=None)]) -> internal.queries.models.Bundle:
55@router.get(
56    "/{bundle_id}",
57    status_code=status.HTTP_200_OK,
58    summary="Get bundle by ID",
59    description="Retrieves details of a specific bundle by its ID.",
60)
61async def get_bundle(bundle_id: str, conn: database_dependency) -> Bundle:
62    """Get bundle.
63
64    Args:
65      bundle_id: bundle id
66      conn: database connection
67
68    Returns:
69      found bundle
70
71    Raises:
72      HTTPException: if failed to find a bundle
73    """
74    bundle = await BundleQuerier(conn).get_bundle(bundle_id=int(bundle_id))
75    if not bundle:
76        raise HTTPException(
77            status_code=status.HTTP_404_NOT_FOUND, detail="Bundle not found"
78        )
79    return bundle

Get bundle.

Arguments:
  • bundle_id: bundle id
  • conn: database connection
Returns:

found bundle

Raises:
  • HTTPException: if failed to find a bundle
@router.post('/{bundle_id}/reservations', tags=['reservations'], status_code=status.HTTP_201_CREATED, summary='Reserve a bundle', description='Creates a reservation for a specific bundle for the authenticated consumer.')
async def reserve_bundle( bundle_id: str, conn: Annotated[sqlalchemy.ext.asyncio.engine.AsyncConnection, Depends(dependency=<bound method DatabaseManager.get_connection of <internal.database.manager.DatabaseManager object>>, use_cache=True, scope=None)], consumer: Annotated[internal.queries.token.GetSessionByTokenRow, Security(dependency=<function consumer_auth>, use_cache=True, scope=None, scopes=None)]) -> internal.queries.models.Reservation:
 82@router.post(
 83    "/{bundle_id}/reservations",
 84    tags=["reservations"],
 85    status_code=status.HTTP_201_CREATED,
 86    summary="Reserve a bundle",
 87    description=(
 88        "Creates a reservation for a specific bundle for the authenticated consumer."
 89    ),
 90)
 91async def reserve_bundle(
 92    bundle_id: str, conn: database_dependency, consumer: ConsumerAuthDep
 93) -> Reservation:
 94    """Create bundle reservation.
 95
 96    Args:
 97        bundle_id: bundle id
 98        conn: database connection
 99        consumer: consumer session
100
101    Returns:
102        found reservation
103
104    Raises:
105        HTTPException: if failed to create reservation
106    """
107    bundle = await BundleQuerier(conn).get_bundle_lock(bundle_id=int(bundle_id))
108    if not bundle:
109        raise HTTPException(
110            status_code=status.HTTP_404_NOT_FOUND, detail="Bundle not found"
111        )
112    reservations_querier = ReservationQuerier(conn)
113    reservations = [
114        item
115        async for item in reservations_querier.get_bundle_reservations(
116            bundle_id=int(bundle_id)
117        )
118    ]
119    if reservations is None:
120        raise HTTPException(
121            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
122            detail="Failed to find reservations",
123        )
124    if bundle.total_qty <= len(list(reservations)):
125        raise HTTPException(
126            status_code=status.HTTP_409_CONFLICT, detail="No reservations available"
127        )
128    used_codes = [rese.claim_code for rese in reservations]
129    reservation = await reservations_querier.create_reservation(
130        CreateReservationParams(
131            bundle_id=bundle.bundle_id,
132            consumer_id=consumer.user_id,
133            claim_code=generate_claim_code(used_codes),
134        )
135    )
136    if not reservation:
137        raise HTTPException(
138            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
139            detail="Failed to create reservation",
140        )
141    return reservation

Create bundle reservation.

Arguments:
  • bundle_id: bundle id
  • conn: database connection
  • consumer: consumer session
Returns:

found reservation

Raises:
  • HTTPException: if failed to create reservation
class SearchBundlesForm(pydantic.main.BaseModel):
144class SearchBundlesForm(BaseModel):
145    """Consumer search form with parameters."""
146
147    lat: float
148    lon: float
149    max_dist: int = Field(gt=0)
150    max_price: float | None = Field(gt=0)
151    seller_name: str | None
152    allergens: list[int] | None
153    categories: list[int] | None

Consumer search form with parameters.

lat: float = PydanticUndefined
lon: float = PydanticUndefined
max_dist: int = PydanticUndefined
max_price: float | None = PydanticUndefined
seller_name: str | None = PydanticUndefined
allergens: list[int] | None = PydanticUndefined
categories: list[int] | None = PydanticUndefined
class SearchBundlesResponse(pydantic.main.BaseModel):
156class SearchBundlesResponse(BaseModel):
157    """Search response item with card information."""
158
159    bundle_id: int
160    sellers_name: str
161    bundle_name: str
162    bundle_description: str
163    price: float
164    discount_percentage: int
165    window_start: datetime
166    window_end: datetime
167    dist: float

Search response item with card information.

bundle_id: int = PydanticUndefined
sellers_name: str = PydanticUndefined
bundle_name: str = PydanticUndefined
bundle_description: str = PydanticUndefined
price: float = PydanticUndefined
discount_percentage: int = PydanticUndefined
window_start: datetime.datetime = PydanticUndefined
window_end: datetime.datetime = PydanticUndefined
dist: float = PydanticUndefined
@router.post('/search', status_code=status.HTTP_200_OK, summary='Search bundles', description='Searches for bundles based on location, distance, price, and other filters.')
async def search_bundles( form: SearchBundlesForm, conn: Annotated[sqlalchemy.ext.asyncio.engine.AsyncConnection, Depends(dependency=<bound method DatabaseManager.get_connection of <internal.database.manager.DatabaseManager object>>, use_cache=True, scope=None)]) -> list[SearchBundlesResponse]:
170@router.post(
171    "/search",
172    status_code=status.HTTP_200_OK,
173    summary="Search bundles",
174    description=(
175        "Searches for bundles based on location, distance, price, and other filters."
176    ),
177)
178async def search_bundles(
179    form: SearchBundlesForm, conn: database_dependency
180) -> list[SearchBundlesResponse]:
181    """Bundle search with parameters endpoint.
182
183    Args:
184      form: consumer search parameters
185      conn: database connection
186
187    Returns:
188      found bundles card information
189    """
190    distance_box = dist_safe_box(
191        LocationModel(lat=form.lat, lon=form.lon), form.max_dist
192    )
193    sellers = [
194        item
195        async for item in SellerQuerier(conn).get_seller_by_location(
196            GetSellerByLocationParams(
197                lat_max=distance_box.lat_max,
198                lat_min=distance_box.lat_min,
199                lon_max=distance_box.lon_max,
200                lon_min=distance_box.lon_min,
201            )
202        )
203    ]
204    filtered_bundles: list[SearchBundlesResponse] = []
205    for seller in sellers:
206        dist = get_distance(
207            LocationModel(lat=form.lat, lon=form.lon),
208            LocationModel(lat=seller.latitude, lon=seller.longitude),
209        )
210        if dist > form.max_dist:
211            continue
212        if (
213            form.seller_name
214            and WRatio(form.seller_name, seller.seller_name)
215            < host_settings.fuzz_threshold
216        ):
217            continue
218        seller_bundles = [
219            item
220            async for item in BundleQuerier(conn).get_sellers_active_bundles(
221                seller_id=seller.user_id
222            )
223        ]
224        for bundle in seller_bundles:
225            allergens = [
226                item
227                async for item in AllergensQuerier(conn).get_bundle_allergens(
228                    bundle_id=bundle.bundle_id
229                )
230            ]
231            if (
232                allergens
233                and form.allergens
234                and not set(allergens).isdisjoint(set(form.allergens))
235            ):
236                continue
237            categories = [
238                item
239                async for item in CategoriesQuerier(conn).get_bundle_categories(
240                    bundle_id=bundle.bundle_id
241                )
242            ]
243            if (
244                categories
245                and form.categories
246                and set(categories).isdisjoint(set(form.categories))
247            ):
248                continue
249            if (
250                form.max_price
251                and (bundle.price * bundle.discount_percentage / 100) > form.max_price
252            ):
253                continue
254            reservations = [
255                item
256                async for item in ReservationQuerier(conn).get_bundle_reservations(
257                    bundle_id=int(bundle.bundle_id)
258                )
259            ]
260            if not reservations or bundle.total_qty <= len(list(reservations)):
261                continue
262            filtered_bundles.append(
263                SearchBundlesResponse(
264                    bundle_id=bundle.bundle_id,
265                    sellers_name=seller.seller_name,
266                    bundle_name=bundle.bundle_name,
267                    bundle_description=bundle.description,
268                    price=float(bundle.price),
269                    discount_percentage=bundle.discount_percentage,
270                    window_start=bundle.window_start,
271                    window_end=bundle.window_end,
272                    dist=round(dist, 2),
273                )
274            )
275    return filtered_bundles

Bundle search with parameters endpoint.

Arguments:
  • form: consumer search parameters
  • conn: database connection
Returns:

found bundles card information

@router.get(path='/{bundle_id}/image', status_code=status.HTTP_200_OK, summary='Get bundle image', description='Retrieves the image for a specific bundle.')
async def get_bundle_image( bundle_id: int, conn: Annotated[sqlalchemy.ext.asyncio.engine.AsyncConnection, Depends(dependency=<bound method DatabaseManager.get_connection of <internal.database.manager.DatabaseManager object>>, use_cache=True, scope=None)]) -> starlette.responses.Response:
278@router.get(
279    path="/{bundle_id}/image",
280    status_code=status.HTTP_200_OK,
281    summary="Get bundle image",
282    description="Retrieves the image for a specific bundle.",
283)
284async def get_bundle_image(bundle_id: int, conn: database_dependency) -> Response:
285    """Get bundle image.
286
287    Args:
288        bundle_id: bundle id
289        conn: database connection
290
291    Returns:
292        bundle image
293
294    Raises:
295        HTTPException: if failed to get image
296    """
297    if BundleQuerier(conn).get_bundle(bundle_id=bundle_id) is None:
298        raise HTTPException(status.HTTP_404_NOT_FOUND, "bundle not found")
299    return Response(
300        block_management.get_bundle_image(bundle_id), media_type="image/jpeg"
301    )

Get bundle image.

Arguments:
  • bundle_id: bundle id
  • conn: database connection
Returns:

bundle image

Raises:
  • HTTPException: if failed to get image