-
Notifications
You must be signed in to change notification settings - Fork 38
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
(PC-31307)[API] feat: simulate adage actions: confirm booking
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
1 parent
f825aa6
commit c7b3024
Showing
11 changed files
with
375 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
56 changes: 56 additions & 0 deletions
56
api/src/pcapi/routes/public/collective/endpoints/simulate_adage_steps/bookings.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"}) |
17 changes: 17 additions & 0 deletions
17
api/src/pcapi/routes/public/collective/endpoints/simulate_adage_steps/errors.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
24 changes: 24 additions & 0 deletions
24
api/src/pcapi/routes/public/collective/endpoints/simulate_adage_steps/utils.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
175 changes: 175 additions & 0 deletions
175
api/tests/routes/public/collective/endpoints/simulate_adage_steps/test_bookings.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.