routers.sellers

Endpoints for sellers.

--- config: mirrorActors: false --- sequenceDiagram title Seller Registration actor user box ./routers participant sellers.py@{ "type" : "boundary" } end box ./internal/database participant dd as database.py end box ./internal/auth participant creation.py participant security.py end box ./internal/queries participant user.py participant sq as seller.py end participant database@{ "type" : "database" } user->>sellers.py: register seller activate sellers.py dd->>sellers.py: yield connection activate dd sellers.py->>creation.py: await create_seller() activate creation.py creation.py->>creation.py: create_user() creation.py->>security.py: hash_password() activate security.py security.py-->>creation.py: password hash deactivate security.py creation.py->>user.py: Queries.create_user() activate user.py user.py->>database: insert user activate database database-->>user.py: created user deactivate database user.py-->>creation.py: created user deactivate user.py creation.py-->>creation.py: created user creation.py->>sq: Queries.create_seller() activate sq sq->>database: insert seller activate database database-->>sq: created seller deactivate database sq-->>creation.py: created seller deactivate sq creation.py-->>sellers.py: created seller deactivate creation.py sellers.py-->>user: 201 OK sellers.py-->>dd: return connection deactivate dd deactivate sellers.py
  1"""Endpoints for sellers.
  2
  3```mermaid
  4---
  5config:
  6  mirrorActors: false
  7---
  8sequenceDiagram
  9    title Seller Registration
 10    actor user
 11    box ./routers
 12    participant sellers.py@{ "type" : "boundary" }
 13    end
 14    box ./internal/database
 15    participant dd as database.py
 16    end
 17    box ./internal/auth
 18    participant creation.py
 19    participant security.py
 20    end
 21    box ./internal/queries
 22    participant user.py
 23    participant sq as seller.py
 24    end
 25    participant database@{ "type" : "database" }
 26
 27    user->>sellers.py: register seller
 28    activate sellers.py
 29    dd->>sellers.py: yield connection
 30    activate dd
 31    sellers.py->>creation.py: await create_seller()
 32    activate creation.py
 33    creation.py->>creation.py: create_user()
 34    creation.py->>security.py: hash_password()
 35    activate security.py
 36    security.py-->>creation.py: password hash
 37    deactivate security.py
 38    creation.py->>user.py: Queries.create_user()
 39    activate user.py
 40    user.py->>database: insert user
 41    activate database
 42    database-->>user.py: created user
 43    deactivate database
 44    user.py-->>creation.py: created user
 45    deactivate user.py
 46    creation.py-->>creation.py: created user
 47    creation.py->>sq: Queries.create_seller()
 48    activate sq
 49    sq->>database: insert seller
 50    activate database
 51    database-->>sq: created seller
 52    deactivate database
 53    sq-->>creation.py: created seller
 54    deactivate sq
 55    creation.py-->>sellers.py: created seller
 56    deactivate creation.py
 57    sellers.py-->>user: 201 OK
 58    sellers.py-->>dd: return connection
 59    deactivate dd
 60    deactivate sellers.py
 61```
 62"""
 63
 64from datetime import datetime
 65from decimal import Decimal
 66from typing import Annotated
 67
 68from fastapi import APIRouter, Depends, HTTPException, Response, UploadFile, status
 69from internal.analytics.co2_estimator import estimate_carbon_doixide_saved
 70from internal.analytics.processing import AnalyticsProcesser
 71from internal.auth.creation import CreateSellerForm, create_seller
 72from internal.auth.middleware import SellerAuthDep
 73from internal.badges.engine import BadgeEngine
 74from internal.block.management import block_management
 75from internal.database.dependency import database_dependency
 76from internal.queries.allergens import (
 77    AddBundlesAllergenParams,
 78    DeleteBundleAllergenParams,
 79)
 80from internal.queries.allergens import AsyncQuerier as AllergenQuerier
 81from internal.queries.analytics import AsyncQuerier as AnalyticsQuerier
 82from internal.queries.analytics import GetGraphParams
 83from internal.queries.bundle import AsyncQuerier as BundleQuerier
 84from internal.queries.bundle import (
 85    CreateBundleParams,
 86    GetSellersBundleParams,
 87    UpdateBundleParams,
 88)
 89from internal.queries.category import (
 90    AddBundlesCategoryParams,
 91    DeleteBundleCategoryParams,
 92)
 93from internal.queries.category import AsyncQuerier as CategoryQuerier
 94from internal.queries.forecast import AsyncQuerier as ForecastQuerier
 95from internal.queries.models import (
 96    AnalyticsGraph,
 97    AnalyticsGraphsType,
 98    AnalyticsPoint,
 99    AnalyticsSeries,
100    Bundle,
101    ForecastOutput,
102    Reservation,
103)
104from internal.queries.reservations import AsyncQuerier as ReservationsQuerier
105from internal.queries.reservations import GetReservationCollectionParams
106from internal.queries.seller import AsyncQuerier as SellerQuerier
107from internal.queries.seller import GetSellerRow, GetSellersRow, UpdateSellerParams
108from pydantic import BaseModel, Field
109
110router = APIRouter(prefix="/sellers", tags=["sellers"])
111
112
113@router.get(
114    "",
115    status_code=status.HTTP_200_OK,
116    summary="Get all sellers",
117    description="Retrieves a list of all registered sellers.",
118)
119async def get_sellers(conn: database_dependency) -> list[GetSellersRow]:
120    """Get all sellers.
121
122    Args:
123      conn: database connection
124
125    Returns:
126      list of all sellers
127    """
128    return [seller async for seller in SellerQuerier(conn).get_sellers()]
129
130
131@router.get(
132    "/me",
133    status_code=status.HTTP_200_OK,
134    summary="Get authenticated seller",
135    description="Retrieves the profile of the authenticated seller.",
136)
137async def get_seller_me(
138    conn: database_dependency, seller: SellerAuthDep
139) -> GetSellerRow:
140    """Get authenticated seller profile.
141
142    Args:
143      conn: database connection
144      seller: sellers session
145
146    Returns:
147      seller profile
148
149    Raises:
150      HTTPException: if seller not found
151    """
152    seller_profile = await SellerQuerier(conn).get_seller(user_id=seller.user_id)
153    if not seller_profile:
154        raise HTTPException(
155            status_code=status.HTTP_404_NOT_FOUND, detail="Seller profile not found"
156        )
157    return seller_profile
158
159
160@router.get(
161    "/{seller_id}",
162    status_code=status.HTTP_200_OK,
163    summary="Get seller by ID",
164    description="Retrieves the profile of a seller by their unique ID.",
165)
166async def get_seller_by_id(seller_id: int, conn: database_dependency) -> GetSellerRow:
167    """Get seller profile by ID.
168
169    Args:
170      seller_id: unique identifier of the seller
171      conn: database connection
172
173    Returns:
174      seller profile
175
176    Raises:
177      HTTPException: if seller not found
178    """
179    seller_profile = await SellerQuerier(conn).get_seller(user_id=seller_id)
180    if not seller_profile:
181        raise HTTPException(
182            status_code=status.HTTP_404_NOT_FOUND, detail="Seller not found"
183        )
184    return seller_profile
185
186
187@router.post(
188    "",
189    status_code=status.HTTP_201_CREATED,
190    summary="Register seller",
191    description="Creates a new seller and their corresponding user entity.",
192)
193async def register_seller(form: CreateSellerForm, conn: database_dependency) -> None:
194    """Creates seller and coressponding user.
195
196    Args:
197      form: signup form from user
198      conn: database connection
199    """
200    _ = await create_seller(form, conn)
201
202
203class UpdateSellerForm(BaseModel):
204    """Form for updating seller profile."""
205
206    address_line1: str
207    address_line2: str | None = None
208    city: str
209    post_code: str
210    region: str | None = None
211    country: str
212    latitude: float | None = None
213    longitude: float | None = None
214
215
216@router.patch(
217    "/me",
218    status_code=status.HTTP_200_OK,
219    summary="Update seller profile",
220    description="Updates the profile information for the authenticated seller.",
221)
222async def update_seller(
223    form: UpdateSellerForm, conn: database_dependency, seller: SellerAuthDep
224) -> None:
225    """Update seller profile.
226
227    Args:
228        form: seller update form
229        conn: database connection
230        seller: seller session
231
232    Raises:
233        HTTPException: if failed to update seller
234    """
235    seller_querier = SellerQuerier(conn)
236    seller_record = await seller_querier.get_seller(user_id=seller.user_id)
237    if seller_record is None:
238        raise HTTPException(
239            status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to get seller"
240        )
241    updated_seller = await seller_querier.update_seller(
242        UpdateSellerParams(
243            user_id=seller.user_id,
244            seller_name=seller_record.seller_name,
245            address_line1=form.address_line1,
246            address_line2=form.address_line2,
247            city=form.city,
248            post_code=form.post_code,
249            region=form.region,
250            country=form.country,
251            latitude=form.latitude,
252            longitude=form.longitude,
253        )
254    )
255    if not updated_seller:
256        raise HTTPException(
257            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
258            detail="Failed to update seller",
259        )
260
261
262class BundleForm(BaseModel):
263    """User form for bundles."""
264
265    bundle_name: str
266    description: str
267    total_qty: int
268    price: Decimal = Field(decimal_places=2, gt=0)
269    discount_percentage: int = Field(lt=100, gt=0)
270    weight: int
271    categories: list[int] = Field(min_length=1)
272    allergens: list[int]
273    window_start: datetime
274    window_end: datetime
275
276
277@router.post(
278    "/me/bundles",
279    tags=["bundles"],
280    status_code=status.HTTP_201_CREATED,
281    summary="Create bundle",
282    description="Creates a new bundle for the authenticated seller.",
283)
284async def create_bundle(
285    form: BundleForm, conn: database_dependency, seller: SellerAuthDep
286) -> Bundle:
287    """Create bundle.
288
289    Args:
290      form: bundle info form
291      conn: database connection
292      seller: sellers session
293
294    Returns:
295      created bundle
296
297    Raises:
298      HTTPException: if failed to create bundle
299    """
300    category_querier = CategoryQuerier(conn)
301    allergen_querier = AllergenQuerier(conn)
302    coefficients: list[float] = []
303    for category in form.categories:
304        if (
305            category_record := await category_querier.get_category(category_id=category)
306        ) is None:
307            raise HTTPException(
308                status.HTTP_500_INTERNAL_SERVER_ERROR,
309                "Failed to get category coefficient",
310            )
311        coefficients.append(category_record.category_coefficient)
312    carbon_dioxide = estimate_carbon_doixide_saved(coefficients, weight_g=form.weight)
313    bundle = await BundleQuerier(conn).create_bundle(
314        CreateBundleParams(
315            seller_id=seller.user_id,
316            bundle_name=form.bundle_name,
317            description=form.description,
318            total_qty=form.total_qty,
319            price=form.price,
320            carbon_dioxide=carbon_dioxide,
321            discount_percentage=form.discount_percentage,
322            window_start=form.window_start,
323            window_end=form.window_end,
324        )
325    )
326    if not bundle:
327        raise HTTPException(
328            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
329            detail="Failed to create bundle",
330        )
331    for category in form.categories:
332        bundle_category = await category_querier.add_bundles_category(
333            AddBundlesCategoryParams(bundle_id=bundle.bundle_id, category_id=category)
334        )
335        if bundle_category is None:
336            raise HTTPException(
337                status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to add bundle category"
338            )
339    for allergen in form.allergens:
340        bundle_allergen = await allergen_querier.add_bundles_allergen(
341            AddBundlesAllergenParams(bundle_id=bundle.bundle_id, allergen_id=allergen)
342        )
343        if bundle_allergen is None:
344            raise HTTPException(
345                status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to add bundle allergen"
346            )
347    return bundle
348
349
350async def update_bundle_categories(
351    bundle_id: int, category_querier: CategoryQuerier, form: BundleForm
352) -> None:
353    """Update bundle categories to a new once.
354
355    Args:
356        bundle_id: bundle id
357        category_querier: categories async querier
358        form: update bundle form
359
360    Raises:
361        HTTPException: if failed to update categories
362    """
363    bundle_categories = [
364        bundle_category
365        async for bundle_category in category_querier.get_bundle_categories(
366            bundle_id=bundle_id
367        )
368    ]
369    for category in form.categories:
370        if category not in bundle_categories:
371            added_category = await category_querier.add_bundles_category(
372                AddBundlesCategoryParams(bundle_id=bundle_id, category_id=category)
373            )
374            if added_category is None:
375                raise HTTPException(
376                    status.HTTP_500_INTERNAL_SERVER_ERROR,
377                    "Failed to add bundle category",
378                )
379    for bundle_category in bundle_categories:
380        if bundle_category not in form.categories:
381            deleted_category = await category_querier.delete_bundle_category(
382                DeleteBundleCategoryParams(
383                    bundle_id=bundle_id, category_id=bundle_category
384                )
385            )
386            if deleted_category is None:
387                raise HTTPException(
388                    status.HTTP_500_INTERNAL_SERVER_ERROR,
389                    "failed to delete bundle category",
390                )
391
392
393async def update_bundle_allergens(
394    bundle_id: int, allergen_querier: AllergenQuerier, form: BundleForm
395) -> None:
396    """Update bandle allergens to a new once.
397
398    Args:
399        bundle_id: bundle id
400        allergen_querier: allergen async querier
401        form: bundle update form
402
403    Raises:
404        HTTPException: if failed to update allergens
405    """
406    bundle_allergens = [
407        bundle_allergen
408        async for bundle_allergen in allergen_querier.get_bundle_allergens(
409            bundle_id=bundle_id
410        )
411    ]
412    for allergen in form.allergens:
413        if allergen not in bundle_allergens:
414            added_allergen = await allergen_querier.add_bundles_allergen(
415                AddBundlesAllergenParams(bundle_id=bundle_id, allergen_id=allergen)
416            )
417            if added_allergen is None:
418                raise HTTPException(
419                    status.HTTP_500_INTERNAL_SERVER_ERROR,
420                    "Failed to add bundle allergen",
421                )
422    for bundle_allergen in bundle_allergens:
423        if bundle_allergen not in form.allergens:
424            deleted_allergen = await allergen_querier.delete_bundle_allergen(
425                DeleteBundleAllergenParams(
426                    bundle_id=bundle_id, allergen_id=bundle_allergen
427                )
428            )
429            if deleted_allergen is None:
430                raise HTTPException(
431                    status.HTTP_500_INTERNAL_SERVER_ERROR,
432                    "failed to delete bundle allergen",
433                )
434
435
436@router.patch(
437    "/me/bundles/{bundle_id}",
438    tags=["bundles"],
439    status_code=status.HTTP_200_OK,
440    summary="Update bundle",
441    description="Updates an existing bundle for the authenticated seller.",
442)
443async def update_bundle(
444    bundle_id: int, form: BundleForm, conn: database_dependency, seller: SellerAuthDep
445) -> Bundle:
446    """Update bundle.
447
448    Args:
449      bundle_id: bundle id
450      form: updated bundle info form
451      conn: database connection
452      seller: sellers session
453
454    Returns:
455      updated bundle
456
457    Raises:
458      HTTPException: if failed to update bundle
459    """
460    category_querier = CategoryQuerier(conn)
461    allergen_querier = AllergenQuerier(conn)
462    coefficients: list[float] = []
463    for category in form.categories:
464        if (
465            category_record := await category_querier.get_category(category_id=category)
466        ) is None:
467            raise HTTPException(
468                status.HTTP_500_INTERNAL_SERVER_ERROR,
469                "Failed to get category coefficient",
470            )
471        coefficients.append(category_record.category_coefficient)
472    carbon_dioxide = estimate_carbon_doixide_saved(coefficients, weight_g=form.weight)
473    bundle = await BundleQuerier(conn).update_bundle(
474        UpdateBundleParams(
475            bundle_id=bundle_id,
476            seller_id=seller.user_id,
477            bundle_name=form.bundle_name,
478            description=form.description,
479            total_qty=form.total_qty,
480            price=form.price,
481            discount_percentage=form.discount_percentage,
482            window_start=form.window_start,
483            window_end=form.window_end,
484            carbon_dioxide=carbon_dioxide,
485        )
486    )
487    if not bundle:
488        raise HTTPException(
489            status_code=status.HTTP_404_NOT_FOUND,
490            detail="Bundle not found or not owned by seller",
491        )
492    await update_bundle_categories(bundle_id, category_querier, form)
493    await update_bundle_allergens(bundle_id, allergen_querier, form)
494    return bundle
495
496
497@router.get(
498    "/me/bundles",
499    status_code=status.HTTP_200_OK,
500    summary="Get seller bundles",
501    description="Retrieves all bundles created by the authenticated seller.",
502)
503async def get_bundles(conn: database_dependency, seller: SellerAuthDep) -> list[Bundle]:
504    """Get sellers bundles.
505
506    Args:
507      conn: database connection
508      seller: sellers connection
509
510    Returns:
511      list of sellers bundles
512
513    Raises:
514      HTTPException: if failed to get bundles
515    """
516    bundles = [
517        item
518        async for item in BundleQuerier(conn).get_sellers_bundles(
519            seller_id=seller.user_id
520        )
521    ]
522    if bundles is None:
523        raise HTTPException(
524            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
525            detail="Failed to get bundles",
526        )
527    return list(bundles)
528
529
530@router.get(
531    "/me/bundles/{bundle_id}/reservations",
532    tags=["reservations"],
533    status_code=status.HTTP_200_OK,
534    summary="Get bundle reservations",
535    description=(
536        "Retrieves all reservations for a specific bundle owned "
537        "by the authenticated seller."
538    ),
539)
540async def get_reservations(
541    bundle_id: int, conn: database_dependency, seller: SellerAuthDep
542) -> list[Reservation]:
543    """Get reservations for sellers bundle.
544
545    Args:
546      bundle_id: bundle id
547      conn: database connection
548      seller: sellers session
549
550    Returns:
551        list of reservations for specific bundle
552
553    Raises:
554        HTTPException: if failed to get reservations
555    """
556    bundle = await BundleQuerier(conn).get_sellers_bundle(
557        GetSellersBundleParams(bundle_id=bundle_id, seller_id=seller.user_id)
558    )
559    if not bundle:
560        raise HTTPException(
561            status_code=status.HTTP_404_NOT_FOUND, detail="Bundle not found"
562        )
563    reservations = [
564        item
565        async for item in ReservationsQuerier(conn).get_bundle_reservations(
566            bundle_id=bundle.bundle_id
567        )
568    ]
569    if reservations is None:
570        raise HTTPException(
571            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
572            detail="Failed to find bundle reservations",
573        )
574    return list(reservations)
575
576
577@router.patch(
578    "/me/bundles/{bundle_id}/reservations/collect",
579    tags=["reservations"],
580    status_code=status.HTTP_200_OK,
581    summary="Collect reservation",
582    description="Confirms the collection of a reservation using a claim code.",
583)
584async def reservation_collection(
585    bundle_id: int,
586    claim_code: str,
587    conn: database_dependency,
588    seller: SellerAuthDep,
589    badge_engine: Annotated[BadgeEngine, Depends(BadgeEngine)],
590) -> Reservation:
591    """Confirm reservation collection.
592
593    Args:
594        bundle_id: bundle_id
595        claim_code: claim code
596        conn: database connection
597        seller: sellers session
598        badge_engine: badge acquiry engine
599
600    Returns:
601      confirmed claimed reservation
602
603    Raises:
604      HTTPException: if failed to collect reservation
605    """
606    reservation_querier = ReservationsQuerier(conn)
607    reservation = await reservation_querier.get_reservation_collection(
608        GetReservationCollectionParams(bundle_id=bundle_id, claim_code=claim_code)
609    )
610    if not reservation:
611        raise HTTPException(
612            status_code=status.HTTP_404_NOT_FOUND, detail="Reservation not found"
613        )
614    bundle = await BundleQuerier(conn).get_bundle(bundle_id=reservation.bundle_id)
615    if not bundle:
616        raise HTTPException(
617            status_code=status.HTTP_404_NOT_FOUND, detail="Bundle not found"
618        )
619    if bundle.seller_id != seller.user_id:
620        raise HTTPException(
621            status_code=status.HTTP_403_FORBIDDEN,
622            detail="Reservation does not belong to your bundle",
623        )
624    claimed_reservation = await reservation_querier.collect_reservation(
625        reservation_id=reservation.reservation_id
626    )
627    if not claimed_reservation:
628        raise HTTPException(
629            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
630            detail="Failed to update reservation status",
631        )
632    await conn.commit()
633    badge_engine.run(claimed_reservation.consumer_id, bundle.window_start)
634    return claimed_reservation
635
636
637@router.post(
638    "/me/analytics",
639    tags=["analytics"],
640    summary="Refresh analytics",
641    description="Triggers a refresh of analytics graphs for the authenticated seller.",
642)
643async def refresh_analytics(
644    seller: SellerAuthDep,
645    analytics_processer: Annotated[AnalyticsProcesser, Depends(AnalyticsProcesser)],
646) -> None:
647    """Refresh analytics graphs for the authenticated seller.
648
649    Args:
650        seller: authenticated seller session
651        analytics_processer: analytics processing engine
652    """
653    analytics_processer.run(seller.user_id)
654
655
656@router.patch(
657    path="/me/bundles/{bundle_id}/image",
658    status_code=status.HTTP_202_ACCEPTED,
659    summary="Change bundle image",
660    description="Updates the image for a bundle owned by the authenticated seller.",
661)
662async def change_bundle_image(
663    bundle_id: int, file: UploadFile, conn: database_dependency, seller: SellerAuthDep
664) -> None:
665    """Change bundle image.
666
667    Args:
668        bundle_id: bundle id
669        file: bundle image
670        conn: database connection
671        seller: seller session
672
673    Raises:
674        HTTPException: if failed to change image
675    """
676    if (
677        BundleQuerier(conn).get_sellers_bundle(
678            GetSellersBundleParams(seller_id=seller.user_id, bundle_id=bundle_id)
679        )
680        is None
681    ):
682        raise HTTPException(status.HTTP_404_NOT_FOUND, "bundle not found")
683    await block_management.upload_bundle_image(bundle_id, file)
684
685
686@router.get(
687    path="/me/bundles/{bundle_id}/image",
688    status_code=status.HTTP_200_OK,
689    summary="Get bundle image",
690    description="Retrieves the image for a bundle owned by the authenticated seller.",
691)
692async def get_bundle_image(
693    bundle_id: int, conn: database_dependency, seller: SellerAuthDep
694) -> Response:
695    """Get bundle image.
696
697    Args:
698        bundle_id: bundle id
699        conn: database connection
700        seller: seller session
701
702    Returns:
703        bundle image
704
705    Raises:
706        HTTPException: if failed to get bundle image
707    """
708    if (
709        BundleQuerier(conn).get_sellers_bundle(
710            GetSellersBundleParams(seller_id=seller.user_id, bundle_id=bundle_id)
711        )
712        is None
713    ):
714        raise HTTPException(status.HTTP_404_NOT_FOUND, "bundle not found")
715    return Response(
716        block_management.get_bundle_image(bundle_id), media_type="image/jpeg"
717    )
718
719
720@router.get(
721    "/me/analytics",
722    status_code=status.HTTP_200_OK,
723    tags=["analytics"],
724    summary="Get analytics graph types",
725    description="Retrieves all available analytics graph types for the seller.",
726)
727async def get_analytics_graph_types(
728    conn: database_dependency, _: SellerAuthDep
729) -> list[AnalyticsGraphsType]:
730    """Get all graph types.
731
732    Args:
733        conn: database connection
734
735    Returns:
736        list of all graph types
737    """
738    return [
739        graph_type async for graph_type in AnalyticsQuerier(conn).get_graphs_types()
740    ]
741
742
743@router.get(
744    "/me/analytics/{graph_type_id}",
745    status_code=status.HTTP_200_OK,
746    tags=["analytics"],
747    summary="Get analytics graph",
748    description="Retrieves an analytics graph with series and points for the seller.",
749)
750async def get_analytics_graph(
751    graph_type_id: int, conn: database_dependency, seller: SellerAuthDep
752) -> tuple[AnalyticsGraph, list[tuple[AnalyticsSeries, list[AnalyticsPoint]]]]:
753    """Get analytics graph.
754
755    Args:
756        graph_type_id: graph type id
757        conn: database connection
758        seller: seller session
759
760    Returns:
761        graph, series and points
762
763    Raises:
764        HTTPException: if failed to get graph
765    """
766    analytics_querier = AnalyticsQuerier(conn)
767    if (await analytics_querier.get_graph_type(graph_type_id=graph_type_id)) is None:
768        raise HTTPException(status.HTTP_404_NOT_FOUND, "failed to find graph type")
769    if (
770        graph := await analytics_querier.get_graph(
771            GetGraphParams(seller_id=seller.user_id, graph_type=graph_type_id)
772        )
773    ) is None:
774        raise HTTPException(
775            status.HTTP_500_INTERNAL_SERVER_ERROR, "failed to get graph"
776        )
777    series = [
778        series
779        async for series in analytics_querier.get_graph_series(graph_id=graph.graph_id)
780    ]
781    if len(series) == 0:
782        return (graph, [])
783    series_and_points: list[tuple[AnalyticsSeries, list[AnalyticsPoint]]] = []
784    for single_series in series:
785        single_series_points: list[AnalyticsPoint] = [
786            point
787            async for point in analytics_querier.get_graph_points(
788                series_id=single_series.series_id
789            )
790        ]
791        series_and_points.append((single_series, single_series_points))
792    return (graph, series_and_points)
793
794
795@router.get(
796    "/me/bundles/{bundle_id}/forecasting",
797    status_code=status.HTTP_200_OK,
798    tags=["forecasts"],
799    summary="Get bundle forecast",
800    description="Retrieves a forecast for a specific bundle.",
801)
802async def get_bundle_forecast(
803    bundle_id: int, conn: database_dependency, seller: SellerAuthDep
804) -> ForecastOutput:
805    """Get forecast for a specific bundle.
806
807    Args:
808        bundle_id: bundle id
809        conn: database connection
810        seller: seller session
811
812    Returns:
813        forecast for the bundle
814
815    Raises:
816        HTTPException: if forecast not found
817    """
818    forecast = await ForecastQuerier(conn).get_forecast_output_by_bundle(
819        bundle_id=bundle_id
820    )
821    if forecast is None:
822        raise HTTPException(status.HTTP_404_NOT_FOUND, "forecast not found")
823    if forecast.seller_id != seller.user_id:
824        raise HTTPException(status.HTTP_403_FORBIDDEN, "not your forecast")
825    return forecast
router = <fastapi.routing.APIRouter object>
@router.get('', status_code=status.HTTP_200_OK, summary='Get all sellers', description='Retrieves a list of all registered sellers.')
async def get_sellers( 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.seller.GetSellersRow]:
114@router.get(
115    "",
116    status_code=status.HTTP_200_OK,
117    summary="Get all sellers",
118    description="Retrieves a list of all registered sellers.",
119)
120async def get_sellers(conn: database_dependency) -> list[GetSellersRow]:
121    """Get all sellers.
122
123    Args:
124      conn: database connection
125
126    Returns:
127      list of all sellers
128    """
129    return [seller async for seller in SellerQuerier(conn).get_sellers()]

Get all sellers.

Arguments:
  • conn: database connection
Returns:

list of all sellers

@router.get('/me', status_code=status.HTTP_200_OK, summary='Get authenticated seller', description='Retrieves the profile of the authenticated seller.')
async def get_seller_me( 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)], seller: Annotated[internal.queries.token.GetSessionByTokenRow, Security(dependency=<function seller_auth>, use_cache=True, scope=None, scopes=None)]) -> internal.queries.seller.GetSellerRow:
132@router.get(
133    "/me",
134    status_code=status.HTTP_200_OK,
135    summary="Get authenticated seller",
136    description="Retrieves the profile of the authenticated seller.",
137)
138async def get_seller_me(
139    conn: database_dependency, seller: SellerAuthDep
140) -> GetSellerRow:
141    """Get authenticated seller profile.
142
143    Args:
144      conn: database connection
145      seller: sellers session
146
147    Returns:
148      seller profile
149
150    Raises:
151      HTTPException: if seller not found
152    """
153    seller_profile = await SellerQuerier(conn).get_seller(user_id=seller.user_id)
154    if not seller_profile:
155        raise HTTPException(
156            status_code=status.HTTP_404_NOT_FOUND, detail="Seller profile not found"
157        )
158    return seller_profile

Get authenticated seller profile.

Arguments:
  • conn: database connection
  • seller: sellers session
Returns:

seller profile

Raises:
  • HTTPException: if seller not found
@router.get('/{seller_id}', status_code=status.HTTP_200_OK, summary='Get seller by ID', description='Retrieves the profile of a seller by their unique ID.')
async def get_seller_by_id( seller_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)]) -> internal.queries.seller.GetSellerRow:
161@router.get(
162    "/{seller_id}",
163    status_code=status.HTTP_200_OK,
164    summary="Get seller by ID",
165    description="Retrieves the profile of a seller by their unique ID.",
166)
167async def get_seller_by_id(seller_id: int, conn: database_dependency) -> GetSellerRow:
168    """Get seller profile by ID.
169
170    Args:
171      seller_id: unique identifier of the seller
172      conn: database connection
173
174    Returns:
175      seller profile
176
177    Raises:
178      HTTPException: if seller not found
179    """
180    seller_profile = await SellerQuerier(conn).get_seller(user_id=seller_id)
181    if not seller_profile:
182        raise HTTPException(
183            status_code=status.HTTP_404_NOT_FOUND, detail="Seller not found"
184        )
185    return seller_profile

Get seller profile by ID.

Arguments:
  • seller_id: unique identifier of the seller
  • conn: database connection
Returns:

seller profile

Raises:
  • HTTPException: if seller not found
@router.post('', status_code=status.HTTP_201_CREATED, summary='Register seller', description='Creates a new seller and their corresponding user entity.')
async def register_seller( form: internal.auth.creation.CreateSellerForm, 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)]) -> None:
188@router.post(
189    "",
190    status_code=status.HTTP_201_CREATED,
191    summary="Register seller",
192    description="Creates a new seller and their corresponding user entity.",
193)
194async def register_seller(form: CreateSellerForm, conn: database_dependency) -> None:
195    """Creates seller and coressponding user.
196
197    Args:
198      form: signup form from user
199      conn: database connection
200    """
201    _ = await create_seller(form, conn)

Creates seller and coressponding user.

Arguments:
  • form: signup form from user
  • conn: database connection
class UpdateSellerForm(pydantic.main.BaseModel):
204class UpdateSellerForm(BaseModel):
205    """Form for updating seller profile."""
206
207    address_line1: str
208    address_line2: str | None = None
209    city: str
210    post_code: str
211    region: str | None = None
212    country: str
213    latitude: float | None = None
214    longitude: float | None = None

Form for updating seller profile.

address_line1: str = PydanticUndefined
address_line2: str | None = None
city: str = PydanticUndefined
post_code: str = PydanticUndefined
region: str | None = None
country: str = PydanticUndefined
latitude: float | None = None
longitude: float | None = None
@router.patch('/me', status_code=status.HTTP_200_OK, summary='Update seller profile', description='Updates the profile information for the authenticated seller.')
async def update_seller( form: UpdateSellerForm, 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)], seller: Annotated[internal.queries.token.GetSessionByTokenRow, Security(dependency=<function seller_auth>, use_cache=True, scope=None, scopes=None)]) -> None:
217@router.patch(
218    "/me",
219    status_code=status.HTTP_200_OK,
220    summary="Update seller profile",
221    description="Updates the profile information for the authenticated seller.",
222)
223async def update_seller(
224    form: UpdateSellerForm, conn: database_dependency, seller: SellerAuthDep
225) -> None:
226    """Update seller profile.
227
228    Args:
229        form: seller update form
230        conn: database connection
231        seller: seller session
232
233    Raises:
234        HTTPException: if failed to update seller
235    """
236    seller_querier = SellerQuerier(conn)
237    seller_record = await seller_querier.get_seller(user_id=seller.user_id)
238    if seller_record is None:
239        raise HTTPException(
240            status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to get seller"
241        )
242    updated_seller = await seller_querier.update_seller(
243        UpdateSellerParams(
244            user_id=seller.user_id,
245            seller_name=seller_record.seller_name,
246            address_line1=form.address_line1,
247            address_line2=form.address_line2,
248            city=form.city,
249            post_code=form.post_code,
250            region=form.region,
251            country=form.country,
252            latitude=form.latitude,
253            longitude=form.longitude,
254        )
255    )
256    if not updated_seller:
257        raise HTTPException(
258            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
259            detail="Failed to update seller",
260        )

Update seller profile.

Arguments:
  • form: seller update form
  • conn: database connection
  • seller: seller session
Raises:
  • HTTPException: if failed to update seller
class BundleForm(pydantic.main.BaseModel):
263class BundleForm(BaseModel):
264    """User form for bundles."""
265
266    bundle_name: str
267    description: str
268    total_qty: int
269    price: Decimal = Field(decimal_places=2, gt=0)
270    discount_percentage: int = Field(lt=100, gt=0)
271    weight: int
272    categories: list[int] = Field(min_length=1)
273    allergens: list[int]
274    window_start: datetime
275    window_end: datetime

User form for bundles.

bundle_name: str = PydanticUndefined
description: str = PydanticUndefined
total_qty: int = PydanticUndefined
price: decimal.Decimal = PydanticUndefined
discount_percentage: int = PydanticUndefined
weight: int = PydanticUndefined
categories: list[int] = PydanticUndefined
allergens: list[int] = PydanticUndefined
window_start: datetime.datetime = PydanticUndefined
window_end: datetime.datetime = PydanticUndefined
@router.post('/me/bundles', tags=['bundles'], status_code=status.HTTP_201_CREATED, summary='Create bundle', description='Creates a new bundle for the authenticated seller.')
async def create_bundle( form: BundleForm, 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)], seller: Annotated[internal.queries.token.GetSessionByTokenRow, Security(dependency=<function seller_auth>, use_cache=True, scope=None, scopes=None)]) -> internal.queries.models.Bundle:
278@router.post(
279    "/me/bundles",
280    tags=["bundles"],
281    status_code=status.HTTP_201_CREATED,
282    summary="Create bundle",
283    description="Creates a new bundle for the authenticated seller.",
284)
285async def create_bundle(
286    form: BundleForm, conn: database_dependency, seller: SellerAuthDep
287) -> Bundle:
288    """Create bundle.
289
290    Args:
291      form: bundle info form
292      conn: database connection
293      seller: sellers session
294
295    Returns:
296      created bundle
297
298    Raises:
299      HTTPException: if failed to create bundle
300    """
301    category_querier = CategoryQuerier(conn)
302    allergen_querier = AllergenQuerier(conn)
303    coefficients: list[float] = []
304    for category in form.categories:
305        if (
306            category_record := await category_querier.get_category(category_id=category)
307        ) is None:
308            raise HTTPException(
309                status.HTTP_500_INTERNAL_SERVER_ERROR,
310                "Failed to get category coefficient",
311            )
312        coefficients.append(category_record.category_coefficient)
313    carbon_dioxide = estimate_carbon_doixide_saved(coefficients, weight_g=form.weight)
314    bundle = await BundleQuerier(conn).create_bundle(
315        CreateBundleParams(
316            seller_id=seller.user_id,
317            bundle_name=form.bundle_name,
318            description=form.description,
319            total_qty=form.total_qty,
320            price=form.price,
321            carbon_dioxide=carbon_dioxide,
322            discount_percentage=form.discount_percentage,
323            window_start=form.window_start,
324            window_end=form.window_end,
325        )
326    )
327    if not bundle:
328        raise HTTPException(
329            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
330            detail="Failed to create bundle",
331        )
332    for category in form.categories:
333        bundle_category = await category_querier.add_bundles_category(
334            AddBundlesCategoryParams(bundle_id=bundle.bundle_id, category_id=category)
335        )
336        if bundle_category is None:
337            raise HTTPException(
338                status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to add bundle category"
339            )
340    for allergen in form.allergens:
341        bundle_allergen = await allergen_querier.add_bundles_allergen(
342            AddBundlesAllergenParams(bundle_id=bundle.bundle_id, allergen_id=allergen)
343        )
344        if bundle_allergen is None:
345            raise HTTPException(
346                status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to add bundle allergen"
347            )
348    return bundle

Create bundle.

Arguments:
  • form: bundle info form
  • conn: database connection
  • seller: sellers session
Returns:

created bundle

Raises:
  • HTTPException: if failed to create bundle
async def update_bundle_categories( bundle_id: int, category_querier: internal.queries.category.AsyncQuerier, form: BundleForm) -> None:
351async def update_bundle_categories(
352    bundle_id: int, category_querier: CategoryQuerier, form: BundleForm
353) -> None:
354    """Update bundle categories to a new once.
355
356    Args:
357        bundle_id: bundle id
358        category_querier: categories async querier
359        form: update bundle form
360
361    Raises:
362        HTTPException: if failed to update categories
363    """
364    bundle_categories = [
365        bundle_category
366        async for bundle_category in category_querier.get_bundle_categories(
367            bundle_id=bundle_id
368        )
369    ]
370    for category in form.categories:
371        if category not in bundle_categories:
372            added_category = await category_querier.add_bundles_category(
373                AddBundlesCategoryParams(bundle_id=bundle_id, category_id=category)
374            )
375            if added_category is None:
376                raise HTTPException(
377                    status.HTTP_500_INTERNAL_SERVER_ERROR,
378                    "Failed to add bundle category",
379                )
380    for bundle_category in bundle_categories:
381        if bundle_category not in form.categories:
382            deleted_category = await category_querier.delete_bundle_category(
383                DeleteBundleCategoryParams(
384                    bundle_id=bundle_id, category_id=bundle_category
385                )
386            )
387            if deleted_category is None:
388                raise HTTPException(
389                    status.HTTP_500_INTERNAL_SERVER_ERROR,
390                    "failed to delete bundle category",
391                )

Update bundle categories to a new once.

Arguments:
  • bundle_id: bundle id
  • category_querier: categories async querier
  • form: update bundle form
Raises:
  • HTTPException: if failed to update categories
async def update_bundle_allergens( bundle_id: int, allergen_querier: internal.queries.allergens.AsyncQuerier, form: BundleForm) -> None:
394async def update_bundle_allergens(
395    bundle_id: int, allergen_querier: AllergenQuerier, form: BundleForm
396) -> None:
397    """Update bandle allergens to a new once.
398
399    Args:
400        bundle_id: bundle id
401        allergen_querier: allergen async querier
402        form: bundle update form
403
404    Raises:
405        HTTPException: if failed to update allergens
406    """
407    bundle_allergens = [
408        bundle_allergen
409        async for bundle_allergen in allergen_querier.get_bundle_allergens(
410            bundle_id=bundle_id
411        )
412    ]
413    for allergen in form.allergens:
414        if allergen not in bundle_allergens:
415            added_allergen = await allergen_querier.add_bundles_allergen(
416                AddBundlesAllergenParams(bundle_id=bundle_id, allergen_id=allergen)
417            )
418            if added_allergen is None:
419                raise HTTPException(
420                    status.HTTP_500_INTERNAL_SERVER_ERROR,
421                    "Failed to add bundle allergen",
422                )
423    for bundle_allergen in bundle_allergens:
424        if bundle_allergen not in form.allergens:
425            deleted_allergen = await allergen_querier.delete_bundle_allergen(
426                DeleteBundleAllergenParams(
427                    bundle_id=bundle_id, allergen_id=bundle_allergen
428                )
429            )
430            if deleted_allergen is None:
431                raise HTTPException(
432                    status.HTTP_500_INTERNAL_SERVER_ERROR,
433                    "failed to delete bundle allergen",
434                )

Update bandle allergens to a new once.

Arguments:
  • bundle_id: bundle id
  • allergen_querier: allergen async querier
  • form: bundle update form
Raises:
  • HTTPException: if failed to update allergens
@router.patch('/me/bundles/{bundle_id}', tags=['bundles'], status_code=status.HTTP_200_OK, summary='Update bundle', description='Updates an existing bundle for the authenticated seller.')
async def update_bundle( bundle_id: int, form: BundleForm, 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)], seller: Annotated[internal.queries.token.GetSessionByTokenRow, Security(dependency=<function seller_auth>, use_cache=True, scope=None, scopes=None)]) -> internal.queries.models.Bundle:
437@router.patch(
438    "/me/bundles/{bundle_id}",
439    tags=["bundles"],
440    status_code=status.HTTP_200_OK,
441    summary="Update bundle",
442    description="Updates an existing bundle for the authenticated seller.",
443)
444async def update_bundle(
445    bundle_id: int, form: BundleForm, conn: database_dependency, seller: SellerAuthDep
446) -> Bundle:
447    """Update bundle.
448
449    Args:
450      bundle_id: bundle id
451      form: updated bundle info form
452      conn: database connection
453      seller: sellers session
454
455    Returns:
456      updated bundle
457
458    Raises:
459      HTTPException: if failed to update bundle
460    """
461    category_querier = CategoryQuerier(conn)
462    allergen_querier = AllergenQuerier(conn)
463    coefficients: list[float] = []
464    for category in form.categories:
465        if (
466            category_record := await category_querier.get_category(category_id=category)
467        ) is None:
468            raise HTTPException(
469                status.HTTP_500_INTERNAL_SERVER_ERROR,
470                "Failed to get category coefficient",
471            )
472        coefficients.append(category_record.category_coefficient)
473    carbon_dioxide = estimate_carbon_doixide_saved(coefficients, weight_g=form.weight)
474    bundle = await BundleQuerier(conn).update_bundle(
475        UpdateBundleParams(
476            bundle_id=bundle_id,
477            seller_id=seller.user_id,
478            bundle_name=form.bundle_name,
479            description=form.description,
480            total_qty=form.total_qty,
481            price=form.price,
482            discount_percentage=form.discount_percentage,
483            window_start=form.window_start,
484            window_end=form.window_end,
485            carbon_dioxide=carbon_dioxide,
486        )
487    )
488    if not bundle:
489        raise HTTPException(
490            status_code=status.HTTP_404_NOT_FOUND,
491            detail="Bundle not found or not owned by seller",
492        )
493    await update_bundle_categories(bundle_id, category_querier, form)
494    await update_bundle_allergens(bundle_id, allergen_querier, form)
495    return bundle

Update bundle.

Arguments:
  • bundle_id: bundle id
  • form: updated bundle info form
  • conn: database connection
  • seller: sellers session
Returns:

updated bundle

Raises:
  • HTTPException: if failed to update bundle
@router.get('/me/bundles', status_code=status.HTTP_200_OK, summary='Get seller bundles', description='Retrieves all bundles created by the authenticated seller.')
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)], seller: Annotated[internal.queries.token.GetSessionByTokenRow, Security(dependency=<function seller_auth>, use_cache=True, scope=None, scopes=None)]) -> list[internal.queries.models.Bundle]:
498@router.get(
499    "/me/bundles",
500    status_code=status.HTTP_200_OK,
501    summary="Get seller bundles",
502    description="Retrieves all bundles created by the authenticated seller.",
503)
504async def get_bundles(conn: database_dependency, seller: SellerAuthDep) -> list[Bundle]:
505    """Get sellers bundles.
506
507    Args:
508      conn: database connection
509      seller: sellers connection
510
511    Returns:
512      list of sellers bundles
513
514    Raises:
515      HTTPException: if failed to get bundles
516    """
517    bundles = [
518        item
519        async for item in BundleQuerier(conn).get_sellers_bundles(
520            seller_id=seller.user_id
521        )
522    ]
523    if bundles is None:
524        raise HTTPException(
525            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
526            detail="Failed to get bundles",
527        )
528    return list(bundles)

Get sellers bundles.

Arguments:
  • conn: database connection
  • seller: sellers connection
Returns:

list of sellers bundles

Raises:
  • HTTPException: if failed to get bundles
@router.get('/me/bundles/{bundle_id}/reservations', tags=['reservations'], status_code=status.HTTP_200_OK, summary='Get bundle reservations', description='Retrieves all reservations for a specific bundle owned by the authenticated seller.')
async def get_reservations( 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)], seller: Annotated[internal.queries.token.GetSessionByTokenRow, Security(dependency=<function seller_auth>, use_cache=True, scope=None, scopes=None)]) -> list[internal.queries.models.Reservation]:
531@router.get(
532    "/me/bundles/{bundle_id}/reservations",
533    tags=["reservations"],
534    status_code=status.HTTP_200_OK,
535    summary="Get bundle reservations",
536    description=(
537        "Retrieves all reservations for a specific bundle owned "
538        "by the authenticated seller."
539    ),
540)
541async def get_reservations(
542    bundle_id: int, conn: database_dependency, seller: SellerAuthDep
543) -> list[Reservation]:
544    """Get reservations for sellers bundle.
545
546    Args:
547      bundle_id: bundle id
548      conn: database connection
549      seller: sellers session
550
551    Returns:
552        list of reservations for specific bundle
553
554    Raises:
555        HTTPException: if failed to get reservations
556    """
557    bundle = await BundleQuerier(conn).get_sellers_bundle(
558        GetSellersBundleParams(bundle_id=bundle_id, seller_id=seller.user_id)
559    )
560    if not bundle:
561        raise HTTPException(
562            status_code=status.HTTP_404_NOT_FOUND, detail="Bundle not found"
563        )
564    reservations = [
565        item
566        async for item in ReservationsQuerier(conn).get_bundle_reservations(
567            bundle_id=bundle.bundle_id
568        )
569    ]
570    if reservations is None:
571        raise HTTPException(
572            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
573            detail="Failed to find bundle reservations",
574        )
575    return list(reservations)

Get reservations for sellers bundle.

Arguments:
  • bundle_id: bundle id
  • conn: database connection
  • seller: sellers session
Returns:

list of reservations for specific bundle

Raises:
  • HTTPException: if failed to get reservations
@router.patch('/me/bundles/{bundle_id}/reservations/collect', tags=['reservations'], status_code=status.HTTP_200_OK, summary='Collect reservation', description='Confirms the collection of a reservation using a claim code.')
async def reservation_collection( bundle_id: int, claim_code: 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)], seller: Annotated[internal.queries.token.GetSessionByTokenRow, Security(dependency=<function seller_auth>, use_cache=True, scope=None, scopes=None)], badge_engine: Annotated[internal.badges.engine.BadgeEngine, Depends(dependency=<class 'internal.badges.engine.BadgeEngine'>, use_cache=True, scope=None)]) -> internal.queries.models.Reservation:
578@router.patch(
579    "/me/bundles/{bundle_id}/reservations/collect",
580    tags=["reservations"],
581    status_code=status.HTTP_200_OK,
582    summary="Collect reservation",
583    description="Confirms the collection of a reservation using a claim code.",
584)
585async def reservation_collection(
586    bundle_id: int,
587    claim_code: str,
588    conn: database_dependency,
589    seller: SellerAuthDep,
590    badge_engine: Annotated[BadgeEngine, Depends(BadgeEngine)],
591) -> Reservation:
592    """Confirm reservation collection.
593
594    Args:
595        bundle_id: bundle_id
596        claim_code: claim code
597        conn: database connection
598        seller: sellers session
599        badge_engine: badge acquiry engine
600
601    Returns:
602      confirmed claimed reservation
603
604    Raises:
605      HTTPException: if failed to collect reservation
606    """
607    reservation_querier = ReservationsQuerier(conn)
608    reservation = await reservation_querier.get_reservation_collection(
609        GetReservationCollectionParams(bundle_id=bundle_id, claim_code=claim_code)
610    )
611    if not reservation:
612        raise HTTPException(
613            status_code=status.HTTP_404_NOT_FOUND, detail="Reservation not found"
614        )
615    bundle = await BundleQuerier(conn).get_bundle(bundle_id=reservation.bundle_id)
616    if not bundle:
617        raise HTTPException(
618            status_code=status.HTTP_404_NOT_FOUND, detail="Bundle not found"
619        )
620    if bundle.seller_id != seller.user_id:
621        raise HTTPException(
622            status_code=status.HTTP_403_FORBIDDEN,
623            detail="Reservation does not belong to your bundle",
624        )
625    claimed_reservation = await reservation_querier.collect_reservation(
626        reservation_id=reservation.reservation_id
627    )
628    if not claimed_reservation:
629        raise HTTPException(
630            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
631            detail="Failed to update reservation status",
632        )
633    await conn.commit()
634    badge_engine.run(claimed_reservation.consumer_id, bundle.window_start)
635    return claimed_reservation

Confirm reservation collection.

Arguments:
  • bundle_id: bundle_id
  • claim_code: claim code
  • conn: database connection
  • seller: sellers session
  • badge_engine: badge acquiry engine
Returns:

confirmed claimed reservation

Raises:
  • HTTPException: if failed to collect reservation
@router.post('/me/analytics', tags=['analytics'], summary='Refresh analytics', description='Triggers a refresh of analytics graphs for the authenticated seller.')
async def refresh_analytics( seller: Annotated[internal.queries.token.GetSessionByTokenRow, Security(dependency=<function seller_auth>, use_cache=True, scope=None, scopes=None)], analytics_processer: Annotated[internal.analytics.processing.AnalyticsProcesser, Depends(dependency=<class 'internal.analytics.processing.AnalyticsProcesser'>, use_cache=True, scope=None)]) -> None:
638@router.post(
639    "/me/analytics",
640    tags=["analytics"],
641    summary="Refresh analytics",
642    description="Triggers a refresh of analytics graphs for the authenticated seller.",
643)
644async def refresh_analytics(
645    seller: SellerAuthDep,
646    analytics_processer: Annotated[AnalyticsProcesser, Depends(AnalyticsProcesser)],
647) -> None:
648    """Refresh analytics graphs for the authenticated seller.
649
650    Args:
651        seller: authenticated seller session
652        analytics_processer: analytics processing engine
653    """
654    analytics_processer.run(seller.user_id)

Refresh analytics graphs for the authenticated seller.

Arguments:
  • seller: authenticated seller session
  • analytics_processer: analytics processing engine
@router.patch(path='/me/bundles/{bundle_id}/image', status_code=status.HTTP_202_ACCEPTED, summary='Change bundle image', description='Updates the image for a bundle owned by the authenticated seller.')
async def change_bundle_image( bundle_id: int, file: fastapi.datastructures.UploadFile, 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)], seller: Annotated[internal.queries.token.GetSessionByTokenRow, Security(dependency=<function seller_auth>, use_cache=True, scope=None, scopes=None)]) -> None:
657@router.patch(
658    path="/me/bundles/{bundle_id}/image",
659    status_code=status.HTTP_202_ACCEPTED,
660    summary="Change bundle image",
661    description="Updates the image for a bundle owned by the authenticated seller.",
662)
663async def change_bundle_image(
664    bundle_id: int, file: UploadFile, conn: database_dependency, seller: SellerAuthDep
665) -> None:
666    """Change bundle image.
667
668    Args:
669        bundle_id: bundle id
670        file: bundle image
671        conn: database connection
672        seller: seller session
673
674    Raises:
675        HTTPException: if failed to change image
676    """
677    if (
678        BundleQuerier(conn).get_sellers_bundle(
679            GetSellersBundleParams(seller_id=seller.user_id, bundle_id=bundle_id)
680        )
681        is None
682    ):
683        raise HTTPException(status.HTTP_404_NOT_FOUND, "bundle not found")
684    await block_management.upload_bundle_image(bundle_id, file)

Change bundle image.

Arguments:
  • bundle_id: bundle id
  • file: bundle image
  • conn: database connection
  • seller: seller session
Raises:
  • HTTPException: if failed to change image
@router.get(path='/me/bundles/{bundle_id}/image', status_code=status.HTTP_200_OK, summary='Get bundle image', description='Retrieves the image for a bundle owned by the authenticated seller.')
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)], seller: Annotated[internal.queries.token.GetSessionByTokenRow, Security(dependency=<function seller_auth>, use_cache=True, scope=None, scopes=None)]) -> starlette.responses.Response:
687@router.get(
688    path="/me/bundles/{bundle_id}/image",
689    status_code=status.HTTP_200_OK,
690    summary="Get bundle image",
691    description="Retrieves the image for a bundle owned by the authenticated seller.",
692)
693async def get_bundle_image(
694    bundle_id: int, conn: database_dependency, seller: SellerAuthDep
695) -> Response:
696    """Get bundle image.
697
698    Args:
699        bundle_id: bundle id
700        conn: database connection
701        seller: seller session
702
703    Returns:
704        bundle image
705
706    Raises:
707        HTTPException: if failed to get bundle image
708    """
709    if (
710        BundleQuerier(conn).get_sellers_bundle(
711            GetSellersBundleParams(seller_id=seller.user_id, bundle_id=bundle_id)
712        )
713        is None
714    ):
715        raise HTTPException(status.HTTP_404_NOT_FOUND, "bundle not found")
716    return Response(
717        block_management.get_bundle_image(bundle_id), media_type="image/jpeg"
718    )

Get bundle image.

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

bundle image

Raises:
  • HTTPException: if failed to get bundle image
@router.get('/me/analytics', status_code=status.HTTP_200_OK, tags=['analytics'], summary='Get analytics graph types', description='Retrieves all available analytics graph types for the seller.')
async def get_analytics_graph_types( 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)], _: Annotated[internal.queries.token.GetSessionByTokenRow, Security(dependency=<function seller_auth>, use_cache=True, scope=None, scopes=None)]) -> list[internal.queries.models.AnalyticsGraphsType]:
721@router.get(
722    "/me/analytics",
723    status_code=status.HTTP_200_OK,
724    tags=["analytics"],
725    summary="Get analytics graph types",
726    description="Retrieves all available analytics graph types for the seller.",
727)
728async def get_analytics_graph_types(
729    conn: database_dependency, _: SellerAuthDep
730) -> list[AnalyticsGraphsType]:
731    """Get all graph types.
732
733    Args:
734        conn: database connection
735
736    Returns:
737        list of all graph types
738    """
739    return [
740        graph_type async for graph_type in AnalyticsQuerier(conn).get_graphs_types()
741    ]

Get all graph types.

Arguments:
  • conn: database connection
Returns:

list of all graph types

@router.get('/me/analytics/{graph_type_id}', status_code=status.HTTP_200_OK, tags=['analytics'], summary='Get analytics graph', description='Retrieves an analytics graph with series and points for the seller.')
async def get_analytics_graph( graph_type_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)], seller: Annotated[internal.queries.token.GetSessionByTokenRow, Security(dependency=<function seller_auth>, use_cache=True, scope=None, scopes=None)]) -> tuple[internal.queries.models.AnalyticsGraph, list[tuple[internal.queries.models.AnalyticsSeries, list[internal.queries.models.AnalyticsPoint]]]]:
744@router.get(
745    "/me/analytics/{graph_type_id}",
746    status_code=status.HTTP_200_OK,
747    tags=["analytics"],
748    summary="Get analytics graph",
749    description="Retrieves an analytics graph with series and points for the seller.",
750)
751async def get_analytics_graph(
752    graph_type_id: int, conn: database_dependency, seller: SellerAuthDep
753) -> tuple[AnalyticsGraph, list[tuple[AnalyticsSeries, list[AnalyticsPoint]]]]:
754    """Get analytics graph.
755
756    Args:
757        graph_type_id: graph type id
758        conn: database connection
759        seller: seller session
760
761    Returns:
762        graph, series and points
763
764    Raises:
765        HTTPException: if failed to get graph
766    """
767    analytics_querier = AnalyticsQuerier(conn)
768    if (await analytics_querier.get_graph_type(graph_type_id=graph_type_id)) is None:
769        raise HTTPException(status.HTTP_404_NOT_FOUND, "failed to find graph type")
770    if (
771        graph := await analytics_querier.get_graph(
772            GetGraphParams(seller_id=seller.user_id, graph_type=graph_type_id)
773        )
774    ) is None:
775        raise HTTPException(
776            status.HTTP_500_INTERNAL_SERVER_ERROR, "failed to get graph"
777        )
778    series = [
779        series
780        async for series in analytics_querier.get_graph_series(graph_id=graph.graph_id)
781    ]
782    if len(series) == 0:
783        return (graph, [])
784    series_and_points: list[tuple[AnalyticsSeries, list[AnalyticsPoint]]] = []
785    for single_series in series:
786        single_series_points: list[AnalyticsPoint] = [
787            point
788            async for point in analytics_querier.get_graph_points(
789                series_id=single_series.series_id
790            )
791        ]
792        series_and_points.append((single_series, single_series_points))
793    return (graph, series_and_points)

Get analytics graph.

Arguments:
  • graph_type_id: graph type id
  • conn: database connection
  • seller: seller session
Returns:

graph, series and points

Raises:
  • HTTPException: if failed to get graph
@router.get('/me/bundles/{bundle_id}/forecasting', status_code=status.HTTP_200_OK, tags=['forecasts'], summary='Get bundle forecast', description='Retrieves a forecast for a specific bundle.')
async def get_bundle_forecast( 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)], seller: Annotated[internal.queries.token.GetSessionByTokenRow, Security(dependency=<function seller_auth>, use_cache=True, scope=None, scopes=None)]) -> internal.queries.models.ForecastOutput:
796@router.get(
797    "/me/bundles/{bundle_id}/forecasting",
798    status_code=status.HTTP_200_OK,
799    tags=["forecasts"],
800    summary="Get bundle forecast",
801    description="Retrieves a forecast for a specific bundle.",
802)
803async def get_bundle_forecast(
804    bundle_id: int, conn: database_dependency, seller: SellerAuthDep
805) -> ForecastOutput:
806    """Get forecast for a specific bundle.
807
808    Args:
809        bundle_id: bundle id
810        conn: database connection
811        seller: seller session
812
813    Returns:
814        forecast for the bundle
815
816    Raises:
817        HTTPException: if forecast not found
818    """
819    forecast = await ForecastQuerier(conn).get_forecast_output_by_bundle(
820        bundle_id=bundle_id
821    )
822    if forecast is None:
823        raise HTTPException(status.HTTP_404_NOT_FOUND, "forecast not found")
824    if forecast.seller_id != seller.user_id:
825        raise HTTPException(status.HTTP_403_FORBIDDEN, "not your forecast")
826    return forecast

Get forecast for a specific bundle.

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

forecast for the bundle

Raises:
  • HTTPException: if forecast not found