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.
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.
@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