Skip to content
This repository has been archived by the owner on Nov 22, 2023. It is now read-only.

Commit

Permalink
(PC-9393) Create educational booking endpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
Gautier Vanneste authored and VictorEnaud committed Aug 9, 2021
1 parent fc2c5f9 commit 69c4dc1
Show file tree
Hide file tree
Showing 13 changed files with 618 additions and 33 deletions.
73 changes: 66 additions & 7 deletions src/pcapi/core/educational/api.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,81 @@
from pcapi import repository
from pcapi.core.bookings import models as booking_models
import logging

from pcapi.core import search
from pcapi.core.bookings import models as bookings_models
from pcapi.core.bookings import repository as bookings_repository
from pcapi.core.educational import repository as educational_repository
from pcapi.core.educational import validation
from pcapi.core.educational.exceptions import EducationalBookingNotFound
from pcapi.core.educational.models import EducationalBooking
from pcapi.core.offers import repository as offers_repository
from pcapi.repository import repository
from pcapi.repository import transaction


logger = logging.getLogger(__name__)

EAC_DEFAULT_BOOKED_QUANTITY = 1


def book_educational_offer(redactor_email: str, uai_code: str, stock_id: int) -> EducationalBooking:
# The call to transaction here ensures we free the FOR UPDATE lock
# on the stock if validation issues an exception
with transaction():
stock = offers_repository.get_and_lock_stock(stock_id=stock_id)
validation.check_stock_is_bookable(stock, EAC_DEFAULT_BOOKED_QUANTITY)

educational_institution = educational_repository.find_educational_institution_by_uai_code(uai_code)
validation.check_institution_exists(educational_institution)

educational_year = educational_repository.find_educational_year_by_date(stock.beginningDatetime)
validation.check_educational_year_exists(educational_year)

educational_booking = EducationalBooking(
educationalInstitution=educational_institution,
educationalYear=educational_year,
)

booking = bookings_models.Booking(
educationalBooking=educational_booking,
stockId=stock.id,
amount=stock.price,
token=bookings_repository.generate_booking_token(),
venueId=stock.offer.venueId,
offererId=stock.offer.venue.managingOffererId,
status=bookings_models.BookingStatus.PENDING,
)

stock.dnBookedQuantity += EAC_DEFAULT_BOOKED_QUANTITY

repository.save(booking)

logger.info(
"Redactor booked an educational offer",
extra={
"redactor": redactor_email,
"offerId": stock.offerId,
"stockId": stock.id,
"bookingId": booking.id,
},
)

search.async_index_offer_ids([stock.offerId])

return booking


def confirm_educational_booking(educational_booking_id: int) -> booking_models.Booking:
def confirm_educational_booking(educational_booking_id: int) -> bookings_models.Booking:
educational_booking = educational_repository.find_educational_booking_by_id(educational_booking_id)
if educational_booking is None:
raise EducationalBookingNotFound()

booking: booking_models.Booking = educational_booking.booking
if booking.status == booking_models.BookingStatus.CONFIRMED:
booking: bookings_models.Booking = educational_booking.booking
if booking.status == bookings_models.BookingStatus.CONFIRMED:
return booking

educational_institution_id = educational_booking.educationalInstitutionId
educational_year_id = educational_booking.educationalYearId
with repository.transaction():
with transaction():
deposit = educational_repository.get_and_lock_educational_deposit(
educational_institution_id, educational_year_id
)
Expand All @@ -27,5 +86,5 @@ def confirm_educational_booking(educational_booking_id: int) -> booking_models.B
deposit,
)
booking.mark_as_confirmed()
repository.repository.save(booking)
repository.save(booking)
return booking
30 changes: 30 additions & 0 deletions src/pcapi/core/educational/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,33 @@
from pcapi.domain.client_exceptions import ClientError


class EducationalInstitutionUnknown(ClientError):
def __init__(self) -> None:
super().__init__("educationalInstitution", "Cette institution est inconnue")


class NoStockLeftError(ClientError):
def __init__(self, stock_id) -> None:
super().__init__("stock", f"Le stock {stock_id} n'est plus disponible")


class EducationalYearNotFound(ClientError):
def __init__(self) -> None:
super().__init__(
"educationalYear", "Aucune année scolaire correspondant à la réservation demandée n'a été trouvée"
)


class OfferIsNotEducational(ClientError):
def __init__(self, offer_id) -> None:
super().__init__("offer", f"L'offre {offer_id} n'est pas une offre éducationnelle")


class OfferIsNotEvent(ClientError):
def __init__(self, offer_id) -> None:
super().__init__("offer", f"L'offre {offer_id} n'est pas une offre évènementielle")


class InsufficientFund(Exception):
pass

Expand Down
19 changes: 19 additions & 0 deletions src/pcapi/core/educational/repository.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from datetime import datetime
from decimal import Decimal
from operator import and_
from typing import Optional
Expand All @@ -9,6 +10,9 @@
from pcapi.core.educational.exceptions import EducationalDepositNotFound
from pcapi.core.educational.models import EducationalBooking
from pcapi.core.educational.models import EducationalDeposit
from pcapi.core.educational.models import EducationalInstitution
from pcapi.core.educational.models import EducationalYear
from pcapi.core.offers.models import Stock


def get_and_lock_educational_deposit(educational_institution_id: int, educational_year_id: str) -> EducationalDeposit:
Expand Down Expand Up @@ -49,3 +53,18 @@ def get_confirmed_educational_bookings_amount(

def find_educational_booking_by_id(educational_booking_id: int) -> Optional[EducationalBooking]:
return EducationalBooking.query.filter(EducationalBooking.id == educational_booking_id).join(Booking).one_or_none()


def find_educational_year_by_date(date: datetime) -> EducationalYear:
return EducationalYear.query.filter(
date >= EducationalYear.beginningDate,
date <= EducationalYear.expirationDate,
).first()


def find_educational_institution_by_uai_code(uai_code: str) -> EducationalInstitution:
return EducationalInstitution.query.filter_by(institutionId=uai_code).first()


def find_stock_by_id(stock_id: int) -> Stock:
return Stock.query.filter_by(id=stock_id).first()
23 changes: 23 additions & 0 deletions src/pcapi/core/educational/validation.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
from decimal import Decimal

from pcapi.core.educational import exceptions
from pcapi.core.educational.models import EducationalDeposit
from pcapi.core.educational.models import EducationalInstitution
from pcapi.core.educational.models import EducationalYear
from pcapi.core.educational.repository import get_confirmed_educational_bookings_amount
from pcapi.core.offers.models import Stock


def check_institution_fund(
Expand All @@ -14,3 +18,22 @@ def check_institution_fund(
total_amount = booking_amount + spent_amount

deposit.check_has_enough_fund(total_amount)


def check_institution_exists(educational_institution: EducationalInstitution) -> None:
if not educational_institution:
raise exceptions.EducationalInstitutionUnknown()


def check_educational_year_exists(educational_year: EducationalYear) -> None:
if not educational_year:
raise exceptions.EducationalYearNotFound()


def check_stock_is_bookable(stock: Stock, booked_quantity: int) -> None:
if (stock.quantity - stock.dnBookedQuantity) < booked_quantity:
raise exceptions.NoStockLeftError(stock.id)
if not stock.offer.isEducational:
raise exceptions.OfferIsNotEducational(stock.offer.id)
if not stock.offer.isEvent:
raise exceptions.OfferIsNotEvent(stock.offer.id)
2 changes: 1 addition & 1 deletion src/pcapi/routes/adage_iframe/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@

def install_routes(app: Flask) -> None:
# pylint: disable=unused-import
pass
from . import bookings
52 changes: 52 additions & 0 deletions src/pcapi/routes/adage_iframe/bookings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import logging

from pcapi.core.educational import api as educational_api
from pcapi.core.educational import exceptions
from pcapi.core.offers import exceptions as offers_exceptions
from pcapi.models.api_errors import ApiErrors
from pcapi.models.api_errors import ForbiddenError
from pcapi.routes.adage_iframe import blueprint
from pcapi.routes.adage_iframe.security import adage_jwt_required
from pcapi.routes.adage_iframe.serialization.educational_booking import BookEducationalOfferRequest
from pcapi.routes.adage_iframe.serialization.educational_booking import BookEducationalOfferResponse
from pcapi.serialization.decorator import spectree_serialize


logger = logging.getLogger(__name__)


@blueprint.adage_iframe.route("/bookings", methods=["POST"])
@spectree_serialize(api=blueprint.api, response_model=BookEducationalOfferResponse, on_error_statuses=[400])
@adage_jwt_required
def book_educational_offer(authenticated_email: str, body: BookEducationalOfferRequest) -> BookEducationalOfferResponse:
if body.redactorEmail != authenticated_email:
logger.info(
"Authenticated email and redactor email do not match",
extra={"email_token": authenticated_email, "email_body": (body.redactorEmail)},
)
raise ForbiddenError({"Authorization": "Authenticated email and redactor email do not match"})

try:
booking = educational_api.book_educational_offer(
redactor_email=(body.redactorEmail), uai_code=body.UAICode, stock_id=body.stockId
)
except offers_exceptions.StockDoesNotExist:
logger.info("Could not book offer: stock does not exist", extra={"stock_id": body.stockId})
raise ApiErrors({"stock": "stock introuvable"}, status_code=400)
except exceptions.NoStockLeftError:
logger.info("Could not book offer: no stock left", extra={"stock_id": body.stockId})
raise ApiErrors({"stock": "Il n'y a plus de stock disponible à la réservation sur cette offre"})
except exceptions.OfferIsNotEvent:
logger.info("Could not book offer: offer is not an event", extra={"stock_id": body.stockId})
raise ApiErrors({"offer": "L'offre n'est pas un évènement"})
except exceptions.OfferIsNotEducational:
logger.info("Could not book offer: offer is not educational", extra={"stock_id": body.stockId})
raise ApiErrors({"offer": "L'offre n'est pas une offre éducationnelle"})
except exceptions.EducationalInstitutionUnknown:
logger.info("Could not book offer: educational institution not found", extra={"uai_code": body.UAICode})
raise ApiErrors({"educationalInstitution": "L'établissement n'existe pas"})
except exceptions.EducationalYearNotFound:
logger.info("Could not book offer: associated educational year not found", extra={"stock_id": body.stockId})
raise ApiErrors({"educationalYear": "Aucune année scolaire ne correspond à cet évènement"})

return BookEducationalOfferResponse(bookingId=booking.id)
2 changes: 1 addition & 1 deletion src/pcapi/routes/adage_iframe/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ def wrapper(*args, **kwargs):
logger.warning("Token does not contain an expiration date")
raise InvalidTokenError("No expiration date provided")

kwargs["user_email"] = adage_jwt_decoded.get("email")
kwargs["authenticated_email"] = adage_jwt_decoded.get("email")
return route_function(*args, **kwargs)

raise ForbiddenError({"Authorization": ["Unrecognized token"]})
Expand Down
11 changes: 11 additions & 0 deletions src/pcapi/routes/adage_iframe/serialization/educational_booking.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from pydantic import BaseModel


class BookEducationalOfferRequest(BaseModel):
redactorEmail: str
UAICode: str
stockId: int


class BookEducationalOfferResponse(BaseModel):
bookingId: int
Empty file.
Loading

0 comments on commit 69c4dc1

Please sign in to comment.