Skip to content

Commit

Permalink
(PC-31307)[API] feat: simulate adage actions: confirm booking
Browse files Browse the repository at this point in the history
Add a new route (others are coming) to simulate a collective booking
confirmation. It will not be available from production environment as
it should only exist to provide a way to do some actions that cannot be
because there is integration environment for Adage.

It remains available from testing and staging for testing/validation.
  • Loading branch information
jeremieb-pass committed Sep 2, 2024
1 parent f825aa6 commit c7b3024
Show file tree
Hide file tree
Showing 11 changed files with 375 additions and 0 deletions.
7 changes: 7 additions & 0 deletions api/src/pcapi/routes/public/collective/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from flask import Flask

from pcapi import settings


def install_routes(app: Flask) -> None:
# pylint: disable=unused-import
Expand All @@ -12,3 +14,8 @@ def install_routes(app: Flask) -> None:
from .endpoints import offers
from .endpoints import students_levels
from .endpoints import venues

if not settings.IS_PROD:
# do not import this route when inside production environment.
# it should not be exposed by automatic documentation tools.
from .endpoints.simulate_adage_steps import bookings as simulate_adage_bookings
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
from pcapi.core.bookings import exceptions as bookings_exceptions
from pcapi.core.educational import exceptions
from pcapi.core.educational.api import booking as booking_api
from pcapi.models.api_errors import ForbiddenError
from pcapi.models.api_errors import ResourceNotFoundError
from pcapi.routes.adage.v1.serialization import constants
from pcapi.routes.public import blueprints
from pcapi.routes.public import spectree_schemas
from pcapi.routes.public.collective.endpoints.simulate_adage_steps import utils
from pcapi.routes.public.documentation_constants import http_responses
from pcapi.routes.public.documentation_constants import tags
from pcapi.serialization.decorator import spectree_serialize
from pcapi.serialization.spec_tree import ExtendResponse as SpectreeResponse
from pcapi.validation.routes.users_authentifications import api_key_required


@blueprints.public_api.route("/v2/collective/adage_mock/bookings/<int:booking_id>/confirm", methods=["POST"])
@utils.exclude_prod_environment
@api_key_required
@spectree_serialize(
api=spectree_schemas.public_api_schema,
on_success_status=204,
tags=[tags.COLLECTIVE_ADAGE_MOCK],
resp=SpectreeResponse(
**(
http_responses.HTTP_204_COLLECTIVE_BOOKING_STATUS_UPDATE
| http_responses.HTTP_40X_SHARED_BY_API_ENDPOINTS
| http_responses.HTTP_403_COLLECTIVE_BOOKING_STATUS_UPDATE_REFUSED
| http_responses.HTTP_404_COLLECTIVE_OFFER_NOT_FOUND
)
),
)
def confirm_collective_booking(booking_id: int) -> None:
"""
Mock collective booking confirmation
Like this could happen within the Adage platform.
Warning: not available for production nor integration environments
"""
try:
booking_api.confirm_collective_booking(booking_id)
except exceptions.InsufficientFund:
raise ForbiddenError({"code": "INSUFFICIENT_FUND"})
except exceptions.InsufficientMinistryFund:
raise ForbiddenError({"code": "INSUFFICIENT_MINISTRY_FUND"})
except exceptions.InsufficientTemporaryFund:
raise ForbiddenError({"code": "INSUFFICIENT_FUND_DEPOSIT_NOT_FINAL"})
except exceptions.BookingIsCancelled:
raise ForbiddenError({"code": "EDUCATIONAL_BOOKING_IS_CANCELLED"})
except bookings_exceptions.ConfirmationLimitDateHasPassed:
raise ForbiddenError({"code": "CONFIRMATION_LIMIT_DATE_HAS_PASSED"})
except exceptions.EducationalBookingNotFound:
raise ResourceNotFoundError({"code": constants.EDUCATIONAL_BOOKING_NOT_FOUND})
except exceptions.EducationalDepositNotFound:
raise ResourceNotFoundError({"code": "DEPOSIT_NOT_FOUND"})
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from flask import current_app as app

from pcapi import settings
from pcapi.models.api_errors import ApiErrors
from pcapi.routes.error_handlers.generic_error_handlers import ApiErrorResponse


class UnauthorizedEnvironment(Exception):
pass


@app.errorhandler(UnauthorizedEnvironment)
def handle_unauthorized_env(_: UnauthorizedEnvironment) -> ApiErrorResponse:
msg = f"unauthorized action from {settings.ENV}"
error = ApiErrors(status_code=403, errors={"msg": msg})

return app.generate_error_response(error.errors), error.status_code
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from functools import wraps
import typing

from pcapi import settings

from .errors import UnauthorizedEnvironment


def exclude_prod_environment(func: typing.Callable) -> typing.Callable:
"""Ensure a route it not executed from production environment.
The only goal is to ensure that a critical route cannot be used
from the production environment. It wont prevent it from being
documented by automatic tools, to avoid this, ensure that the whole
route or module is not imported.
"""

@wraps(func)
def decorated_function(*args: typing.Sequence, **kwargs: typing.Mapping) -> typing.Any:
if settings.IS_PROD:
raise UnauthorizedEnvironment()
return func(*args, **kwargs)

return decorated_function
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
"HTTP_204": (None, "This collective booking has been successfully cancelled")
}

HTTP_204_COLLECTIVE_BOOKING_STATUS_UPDATE = {
"HTTP_204": (None, "This collective booking's status has been successfully updated")
}

# Client errors
HTTP_400_BAD_REQUEST = {
"HTTP_400": (None, "The request is invalid. The response body contains a list of errors."),
Expand Down Expand Up @@ -73,6 +77,10 @@
"HTTP_403": (None, "You don't have enough rights to access or edit the collective offer"),
}

HTTP_403_COLLECTIVE_BOOKING_STATUS_UPDATE_REFUSED = {
"HTTP_403": (None, "Collective booking status updated has been refused"),
}

# Specific 410
HTTP_410_BOOKING_CANCELED_OR_VALIDATED = {
"HTTP_410": (None, "You cannot perform this action because the booking has either been validated or canceled.")
Expand Down
3 changes: 3 additions & 0 deletions api/src/pcapi/routes/public/documentation_constants/tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@
COLLECTIVE_BOOKINGS = Tag(name="Collective bookings")
COLLECTIVE_OFFER_ATTRIBUTES = Tag(name="Collective offer attributes")

# COLLECTIVE BOOKINGS ADAGE MOCK
COLLECTIVE_ADAGE_MOCK = Tag(name="Collective bookings Adage mock")

# COLLECTIVE OFFERS --- Deprecated
COLLECTIVE_CATEGORIES = Tag(name="[DEPRECATED] Collective categories")
COLLECTIVE_VENUES = Tag(name="[DEPRECATED] Collective venues")
Expand Down
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import contextlib
import datetime

import pytest

from pcapi.core.educational import factories
from pcapi.core.educational import models
from pcapi.core.testing import override_features
from pcapi.models import db
from pcapi.routes.adage.v1.serialization import constants

from tests.routes.public.helpers import PublicAPIRestrictedEnvEndpointHelper


pytestmark = pytest.mark.usefixtures("db_session")


@pytest.fixture(name="current_year")
def current_year_fixture():
return factories.EducationalYearFactory(
beginningDate=datetime.datetime(factories._get_current_educational_year(), 9, 1),
expirationDate=datetime.datetime(factories._get_current_educational_year() + 1, 8, 31, 23, 59),
)


@pytest.fixture(name="institution")
def institution_fixture(current_year):
return factories.EducationalDepositFactory(educationalYear=current_year).educationalInstitution


@pytest.fixture(name="pending_booking")
def pending_booking_fixture(current_year, institution):
return factories.PendingCollectiveBookingFactory(educationalYear=current_year, educationalInstitution=institution)


@pytest.fixture(name="confirmed_booking")
def confirmed_booking_fixture(current_year, institution):
return factories.ConfirmedCollectiveBookingFactory(educationalYear=current_year, educationalInstitution=institution)


@pytest.fixture(name="used_booking")
def used_booking_fixture(current_year, institution):
return factories.UsedCollectiveBookingFactory(
educationalYear=current_year,
educationalInstitution=institution,
)


@pytest.fixture(name="reimbursed_booking")
def reimbursed_booking_fixture(current_year, institution):
return factories.ReimbursedCollectiveBookingFactory(
educationalYear=current_year, educationalInstitution=institution
)


@pytest.fixture(name="cancelled_booking")
def cancelled_booking_fixture(current_year, institution):
return factories.CancelledCollectiveBookingFactory(educationalYear=current_year, educationalInstitution=institution)


@contextlib.contextmanager
def assert_status_changes_to(booking, expected_status):
previous_status = booking.status

yield

db.session.refresh(booking)
assert booking.status != previous_status
assert booking.status == expected_status


@contextlib.contextmanager
def assert_status_does_not_change(booking):
previous_status = booking.status

yield

db.session.refresh(booking)
assert booking.status == previous_status


class ConfirmCollectiveBookingTest(PublicAPIRestrictedEnvEndpointHelper):
endpoint_url = "/v2/collective/adage_mock/bookings/{booking_id}/confirm"
endpoint_method = "post"
default_path_params = {"booking_id": 1}

def setup_method(self):
self.plain_api_key, _ = self.setup_provider()

def get_authenticated_client(self, client):
if not hasattr(self, "_authenticated_client"):
self._authenticated_client = client.with_explicit_token(self.plain_api_key)
return self._authenticated_client

def test_confirm_pending_booking(self, client, pending_booking):
with assert_status_changes_to(pending_booking, models.CollectiveBookingStatus.CONFIRMED):
self.confirm_booking(client, pending_booking.id, status_code=204)

def test_confirm_confirmed_booking(self, client, confirmed_booking):
with assert_status_does_not_change(confirmed_booking):
self.confirm_booking(client, confirmed_booking.id, status_code=204)

def test_confirm_used_booking(self, client, used_booking):
error = {"code": "CONFIRMATION_LIMIT_DATE_HAS_PASSED"}

with assert_status_does_not_change(used_booking):
self.confirm_booking(client, used_booking.id, status_code=403, json_error=error)

def test_confirm_reimbursed_booking(self, client, reimbursed_booking):
error = {"code": "CONFIRMATION_LIMIT_DATE_HAS_PASSED"}

with assert_status_does_not_change(reimbursed_booking):
self.confirm_booking(client, reimbursed_booking.id, status_code=403, json_error=error)

def test_confirm_cancelled_booking(self, client, cancelled_booking):
error = {"code": "EDUCATIONAL_BOOKING_IS_CANCELLED"}

with assert_status_does_not_change(cancelled_booking):
self.confirm_booking(client, cancelled_booking.id, status_code=403, json_error=error)

def test_confirm_when_insufficient_fund(self, client, institution, pending_booking):
for deposit in institution.deposits:
deposit.amount = 0

error = {"code": "INSUFFICIENT_FUND"}
with assert_status_does_not_change(pending_booking):
self.confirm_booking(client, pending_booking.id, status_code=403, json_error=error)

@override_features(ENABLE_EAC_FINANCIAL_PROTECTION=True)
def test_confirm_when_insufficient_ministry_fund(self, client, used_booking, pending_booking):
# ensure offer's stock start between september and december
# because this validation is not ran after and before that.
start = pending_booking.collectiveStock.startDatetime.replace(month=10)
pending_booking.collectiveStock.startDatetime = start
pending_booking.collectiveStock.beginningDatetime = start

# pending booking price is within the the institution's budget
# but some special rules apply at the end of the year: the
# overall used budget must be at most 1/3 of the total.
institution = used_booking.educationalInstitution
deposit_amount = sum(deposit.amount for deposit in institution.deposits)
used_booking.collectiveStock.price = deposit_amount / 3

error = {"code": "INSUFFICIENT_MINISTRY_FUND"}
with assert_status_does_not_change(pending_booking):
self.confirm_booking(client, pending_booking.id, status_code=403, json_error=error)

def test_confirm_when_insufficient_temporary_fund(self, client, institution, pending_booking):
for deposit in institution.deposits:
deposit.amount = 0
deposit.isFinal = False

error = {"code": "INSUFFICIENT_FUND_DEPOSIT_NOT_FINAL"}
with assert_status_does_not_change(pending_booking):
self.confirm_booking(client, pending_booking.id, status_code=403, json_error=error)

def test_confirm_unknown_booking(self, client):
error = {"code": constants.EDUCATIONAL_BOOKING_NOT_FOUND}
self.confirm_booking(client, 0, status_code=404, json_error=error)

def test_confirm_unknown_deposit(self, client, institution, pending_booking):
for deposit in institution.deposits:
deposit.educationalYear = factories.EducationalYearFactory()

error = {"code": "DEPOSIT_NOT_FOUND"}
with assert_status_does_not_change(pending_booking):
self.confirm_booking(client, pending_booking.id, status_code=404, json_error=error)

def confirm_booking(self, client, booking_id, status_code, json_error=None):
response = self.send_request(self.get_authenticated_client(client), url_params={"booking_id": booking_id})

assert response.status_code == status_code
if json_error:
for key, msg in json_error.items():
assert response.json.get(key) == msg
54 changes: 54 additions & 0 deletions api/tests/routes/public/expected_openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -10791,6 +10791,60 @@
]
}
},
"/v2/collective/adage_mock/bookings/{booking_id}/confirm": {
"post": {
"description": "Like this could happen within the Adage platform.\n\nWarning: not available for production nor integration environments",
"operationId": "ConfirmCollectiveBooking",
"parameters": [
{
"description": "",
"in": "path",
"name": "booking_id",
"required": true,
"schema": {
"format": "int32",
"type": "integer"
}
}
],
"responses": {
"204": {
"description": "This collective booking's status has been successfully updated"
},
"401": {
"description": "Authentication is necessary to use this API."
},
"403": {
"description": "Collective booking status updated has been refused"
},
"404": {
"description": "The collective offer could not be found."
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidationError"
}
}
},
"description": "Unprocessable Entity"
},
"429": {
"description": "You have made too many requests. (**rate limit: 200 requests/minute**)"
}
},
"security": [
{
"ApiKeyAuth": []
}
],
"summary": "Mock collective booking confirmation",
"tags": [
"Collective bookings Adage mock"
]
}
},
"/v2/collective/bookings/{booking_id}": {
"patch": {
"description": "Cancel an collective event booking.",
Expand Down
Loading

0 comments on commit c7b3024

Please sign in to comment.