diff --git a/api/alembic_version_conflict_detection.txt b/api/alembic_version_conflict_detection.txt index ce0e56384d0..a4d10de85bc 100644 --- a/api/alembic_version_conflict_detection.txt +++ b/api/alembic_version_conflict_detection.txt @@ -1,2 +1,2 @@ 8523f3e2d7d6 (pre) (head) -404b3075d1a4 (post) (head) +da2896f47266 (post) (head) diff --git a/api/src/pcapi/alembic/versions/20240828T115933_01a23d1b5687_add_reason_in_enum_collectivebookingcancellationreasons.py b/api/src/pcapi/alembic/versions/20240828T115933_01a23d1b5687_add_reason_in_enum_collectivebookingcancellationreasons.py new file mode 100644 index 00000000000..d7fb7249f6e --- /dev/null +++ b/api/src/pcapi/alembic/versions/20240828T115933_01a23d1b5687_add_reason_in_enum_collectivebookingcancellationreasons.py @@ -0,0 +1,28 @@ +""" +add reason in enum CollectiveBookingCancellationReasons +""" + +from alembic import op + + +# pre/post deployment: post +# revision identifiers, used by Alembic. +revision = "01a23d1b5687" +down_revision = "404b3075d1a4" +branch_labels: tuple[str] | None = None +depends_on: list[str] | None = None + + +def upgrade() -> None: + op.execute("ALTER TYPE \"bookingcancellationreasons\" ADD VALUE IF NOT EXISTS 'BACKOFFICE_EVENT_CANCELLED' ") + op.execute("ALTER TYPE \"bookingcancellationreasons\" ADD VALUE IF NOT EXISTS 'BACKOFFICE_OVERBOOKING' ") + op.execute("ALTER TYPE \"bookingcancellationreasons\" ADD VALUE IF NOT EXISTS 'BACKOFFICE_BENEFICIARY_REQUEST' ") + op.execute("ALTER TYPE \"bookingcancellationreasons\" ADD VALUE IF NOT EXISTS 'BACKOFFICE_OFFER_MODIFIED' ") + op.execute( + "ALTER TYPE \"bookingcancellationreasons\" ADD VALUE IF NOT EXISTS 'BACKOFFICE_OFFER_WITH_WRONG_INFORMATION' " + ) + op.execute("ALTER TYPE \"bookingcancellationreasons\" ADD VALUE IF NOT EXISTS 'OFFERER_CONNECT_AS' ") + + +def downgrade() -> None: + pass diff --git a/api/src/pcapi/alembic/versions/20240828T120442_da2896f47266_add_reason_in_enum_bookingcancellationreasons.py b/api/src/pcapi/alembic/versions/20240828T120442_da2896f47266_add_reason_in_enum_bookingcancellationreasons.py new file mode 100644 index 00000000000..701c23fb154 --- /dev/null +++ b/api/src/pcapi/alembic/versions/20240828T120442_da2896f47266_add_reason_in_enum_bookingcancellationreasons.py @@ -0,0 +1,26 @@ +""" +add reason in enum BookingCancellationReasons +""" + +from alembic import op + + +# pre/post deployment: post +# revision identifiers, used by Alembic. +revision = "da2896f47266" +down_revision = "01a23d1b5687" +branch_labels: tuple[str] | None = None +depends_on: list[str] | None = None + + +def upgrade() -> None: + op.execute("ALTER TYPE \"cancellation_reason\" ADD VALUE IF NOT EXISTS 'BACKOFFICE_EVENT_CANCELLED' ") + op.execute("ALTER TYPE \"cancellation_reason\" ADD VALUE IF NOT EXISTS 'BACKOFFICE_OVERBOOKING' ") + op.execute("ALTER TYPE \"cancellation_reason\" ADD VALUE IF NOT EXISTS 'BACKOFFICE_BENEFICIARY_REQUEST' ") + op.execute("ALTER TYPE \"cancellation_reason\" ADD VALUE IF NOT EXISTS 'BACKOFFICE_OFFER_MODIFIED' ") + op.execute("ALTER TYPE \"cancellation_reason\" ADD VALUE IF NOT EXISTS 'BACKOFFICE_OFFER_WITH_WRONG_INFORMATION' ") + op.execute("ALTER TYPE \"cancellation_reason\" ADD VALUE IF NOT EXISTS 'OFFERER_CONNECT_AS' ") + + +def downgrade() -> None: + pass diff --git a/api/src/pcapi/core/bookings/api.py b/api/src/pcapi/core/bookings/api.py index 65ea4d3e2d5..debbb7a3130 100644 --- a/api/src/pcapi/core/bookings/api.py +++ b/api/src/pcapi/core/bookings/api.py @@ -625,7 +625,10 @@ def _cancel_external_booking(booking: Booking, stock: Stock) -> None: def _cancel_bookings_from_stock( - stock: offers_models.Stock, reason: BookingCancellationReasons, one_side_cancellation: bool = False + stock: offers_models.Stock, + reason: BookingCancellationReasons, + one_side_cancellation: bool = False, + author_id: int | None = None, ) -> list[Booking]: """ Cancel multiple bookings and update the users' credit information on Batch. @@ -638,6 +641,7 @@ def _cancel_bookings_from_stock( reason, cancel_even_if_used=typing.cast(bool, stock.offer.isEvent), one_side_cancellation=one_side_cancellation, + author_id=author_id, ): deleted_bookings.append(booking) @@ -659,8 +663,14 @@ def cancel_booking_by_offerer(booking: Booking) -> None: user_emails_job.send_booking_cancellation_emails_to_user_and_offerer_job.delay(booking.id) -def cancel_bookings_from_stock_by_offerer(stock: offers_models.Stock) -> list[Booking]: - return _cancel_bookings_from_stock(stock, BookingCancellationReasons.OFFERER, one_side_cancellation=True) +def cancel_bookings_from_stock_by_offerer( + stock: offers_models.Stock, author_id: int | None = None, user_connect_as: bool | None = None +) -> list[Booking]: + if user_connect_as: + cancellation_reason = BookingCancellationReasons.OFFERER_CONNECT_AS + else: + cancellation_reason = BookingCancellationReasons.OFFERER + return _cancel_bookings_from_stock(stock, cancellation_reason, one_side_cancellation=True, author_id=author_id) def cancel_bookings_from_rejected_offer(offer: offers_models.Offer) -> list[Booking]: diff --git a/api/src/pcapi/core/bookings/models.py b/api/src/pcapi/core/bookings/models.py index 0d419f55e22..0d7d991cb10 100644 --- a/api/src/pcapi/core/bookings/models.py +++ b/api/src/pcapi/core/bookings/models.py @@ -56,6 +56,12 @@ class BookingCancellationReasons(enum.Enum): REFUSED_BY_INSTITUTE = "REFUSED_BY_INSTITUTE" FINANCE_INCIDENT = "FINANCE_INCIDENT" BACKOFFICE = "BACKOFFICE" + BACKOFFICE_EVENT_CANCELLED = "BACKOFFICE_EVENT_CANCELLED" + BACKOFFICE_OVERBOOKING = "BACKOFFICE_OVERBOOKING" + BACKOFFICE_BENEFICIARY_REQUEST = "BACKOFFICE_BENEFICIARY_REQUEST" + BACKOFFICE_OFFER_MODIFIED = "BACKOFFICE_OFFER_MODIFIED" + BACKOFFICE_OFFER_WITH_WRONG_INFORMATION = "BACKOFFICE_OFFER_WITH_WRONG_INFORMATION" + OFFERER_CONNECT_AS = "OFFERER_CONNECT_AS" class BookingStatus(enum.Enum): diff --git a/api/src/pcapi/core/educational/api/booking.py b/api/src/pcapi/core/educational/api/booking.py index fe3d47f04c7..8a619c40c89 100644 --- a/api/src/pcapi/core/educational/api/booking.py +++ b/api/src/pcapi/core/educational/api/booking.py @@ -285,7 +285,7 @@ def get_collective_booking_by_id(booking_id: int) -> educational_models.Collecti return collective_booking -def cancel_collective_offer_booking(offer_id: int) -> None: +def cancel_collective_offer_booking(offer_id: int, author_id: int, user_connect_as: bool) -> None: collective_offer: educational_models.CollectiveOffer | None = ( educational_models.CollectiveOffer.query.filter(educational_models.CollectiveOffer.id == offer_id) .options( @@ -305,7 +305,7 @@ def cancel_collective_offer_booking(offer_id: int) -> None: collective_stock = collective_offer.collectiveStock # Offer is reindexed in the end of this function - cancelled_booking = _cancel_collective_booking_by_offerer(collective_stock) + cancelled_booking = _cancel_collective_booking_by_offerer(collective_stock, author_id, user_connect_as) logger.info( "Cancelled collective booking from offer", @@ -337,6 +337,7 @@ def notify_pro_pending_booking_confirmation_limit_in_3_days() -> None: def _cancel_collective_booking( collective_booking: educational_models.CollectiveBooking, reason: educational_models.CollectiveBookingCancellationReasons, + author_id: int, ) -> None: with transaction(): educational_repository.get_and_lock_collective_stock(stock_id=collective_booking.collectiveStock.id) @@ -346,7 +347,7 @@ def _cancel_collective_booking( # The booking cannot be used nor reimbursed yet, otherwise # `cancel_booking` will fail. Thus, there is no finance # event to cancel here. - collective_booking.cancel_booking(reason) + collective_booking.cancel_booking(reason, author_id=author_id) except exceptions.CollectiveBookingAlreadyCancelled: return @@ -363,6 +364,8 @@ def _cancel_collective_booking( def _cancel_collective_booking_by_offerer( collective_stock: educational_models.CollectiveStock, + author_id: int, + user_connect_as: bool, ) -> educational_models.CollectiveBooking: """ Cancel booking. @@ -380,9 +383,14 @@ def _cancel_collective_booking_by_offerer( if booking_to_cancel is None: raise exceptions.NoCollectiveBookingToCancel() + if user_connect_as: + cancellation_reason = educational_models.CollectiveBookingCancellationReasons.OFFERER_CONNECT_AS + else: + cancellation_reason = educational_models.CollectiveBookingCancellationReasons.OFFERER _cancel_collective_booking( booking_to_cancel, - educational_models.CollectiveBookingCancellationReasons.OFFERER, + cancellation_reason, + author_id, ) return booking_to_cancel diff --git a/api/src/pcapi/core/educational/models.py b/api/src/pcapi/core/educational/models.py index bcac6566420..8872568cbd6 100644 --- a/api/src/pcapi/core/educational/models.py +++ b/api/src/pcapi/core/educational/models.py @@ -92,6 +92,12 @@ class CollectiveBookingCancellationReasons(enum.Enum): PUBLIC_API = "PUBLIC_API" FINANCE_INCIDENT = "FINANCE_INCIDENT" BACKOFFICE = "BACKOFFICE" + BACKOFFICE_EVENT_CANCELLED = "BACKOFFICE_EVENT_CANCELLED" + BACKOFFICE_OVERBOOKING = "BACKOFFICE_OVERBOOKING" + BACKOFFICE_BENEFICIARY_REQUEST = "BACKOFFICE_BENEFICIARY_REQUEST" + BACKOFFICE_OFFER_MODIFIED = "BACKOFFICE_OFFER_MODIFIED" + BACKOFFICE_OFFER_WITH_WRONG_INFORMATION = "BACKOFFICE_OFFER_WITH_WRONG_INFORMATION" + OFFERER_CONNECT_AS = "OFFERER_CONNECT_AS" class Ministry(enum.Enum): diff --git a/api/src/pcapi/core/offers/api.py b/api/src/pcapi/core/offers/api.py index 1370bf03040..31e56742af1 100644 --- a/api/src/pcapi/core/offers/api.py +++ b/api/src/pcapi/core/offers/api.py @@ -885,12 +885,12 @@ def _invalidate_bookings(bookings: list[bookings_models.Booking]) -> list[bookin return bookings -def _delete_stock(stock: models.Stock) -> None: +def _delete_stock(stock: models.Stock, author_id: int | None = None, user_connect_as: bool | None = None) -> None: stock.isSoftDeleted = True repository.save(stock) # the algolia sync for the stock will happen within this function - cancelled_bookings = bookings_api.cancel_bookings_from_stock_by_offerer(stock) + cancelled_bookings = bookings_api.cancel_bookings_from_stock_by_offerer(stock, author_id, user_connect_as) logger.info( "Deleted stock and cancelled its bookings", @@ -908,9 +908,9 @@ def _delete_stock(stock: models.Stock) -> None: ) -def delete_stock(stock: models.Stock) -> None: +def delete_stock(stock: models.Stock, author_id: int | None = None, user_connect_as: bool | None = None) -> None: validation.check_stock_is_deletable(stock) - _delete_stock(stock) + _delete_stock(stock, author_id, user_connect_as) def create_mediation( @@ -1542,13 +1542,15 @@ def batch_delete_draft_offers(query: BaseQuery) -> None: db.session.commit() -def batch_delete_stocks(stocks_to_delete: list[models.Stock]) -> None: +def batch_delete_stocks( + stocks_to_delete: list[models.Stock], author_id: int | None, user_connect_as: bool | None +) -> None: # We want to check that all stocks can be deleted first for stock in stocks_to_delete: validation.check_stock_is_deletable(stock) for stock in stocks_to_delete: - _delete_stock(stock) + _delete_stock(stock, author_id, user_connect_as) def get_or_create_label(label: str, venue: offerers_models.Venue) -> models.PriceCategoryLabel: diff --git a/api/src/pcapi/core/users/models.py b/api/src/pcapi/core/users/models.py index fe07fd56ae8..9133002f0c2 100644 --- a/api/src/pcapi/core/users/models.py +++ b/api/src/pcapi/core/users/models.py @@ -712,6 +712,10 @@ def has_partner_page(self) -> bool: ).scalar() return has_partner_page + @property + def is_impersonated(self) -> bool: + return bool(self.impersonator) + class DiscordUser(PcObject, Base, Model): __tablename__ = "discord_user" diff --git a/api/src/pcapi/routes/backoffice/bookings/forms.py b/api/src/pcapi/routes/backoffice/bookings/forms.py index 0b069b01411..52d7c3e0a06 100644 --- a/api/src/pcapi/routes/backoffice/bookings/forms.py +++ b/api/src/pcapi/routes/backoffice/bookings/forms.py @@ -216,6 +216,11 @@ class CancelCollectiveBookingForm(FlaskForm): choices=utils.choices_from_enum( educational_models.CollectiveBookingCancellationReasons, formatter=filters.format_booking_cancellation, + exclude_opts=( + educational_models.CollectiveBookingCancellationReasons.OFFERER, + educational_models.CollectiveBookingCancellationReasons.OFFERER_CONNECT_AS, + educational_models.CollectiveBookingCancellationReasons.PUBLIC_API, + ), ), ) @@ -224,7 +229,12 @@ class CancelIndividualBookingForm(FlaskForm): reason = fields.PCSelectWithPlaceholderValueField( "Raison", choices=utils.choices_from_enum( - bookings_models.BookingCancellationReasons, formatter=filters.format_booking_cancellation + bookings_models.BookingCancellationReasons, + formatter=filters.format_booking_cancellation, + exclude_opts=( + bookings_models.BookingCancellationReasons.OFFERER, + bookings_models.BookingCancellationReasons.OFFERER_CONNECT_AS, + ), ), ) diff --git a/api/src/pcapi/routes/backoffice/filters.py b/api/src/pcapi/routes/backoffice/filters.py index b36e86fe3ec..fc2876d327f 100644 --- a/api/src/pcapi/routes/backoffice/filters.py +++ b/api/src/pcapi/routes/backoffice/filters.py @@ -235,7 +235,26 @@ def format_booking_cancellation( bookings_models.BookingCancellationReasons.OFFERER | educational_models.CollectiveBookingCancellationReasons.OFFERER ): + if author: + return Markup( + 'Annulée par l\'acteur culturel ({email})' + ).format( + url=url_for("backoffice_web.pro_user.get", user_id=author.id), + email=author.email, + ) return "Annulée par l'acteur culturel" + case ( + bookings_models.BookingCancellationReasons.OFFERER_CONNECT_AS + | educational_models.CollectiveBookingCancellationReasons.OFFERER_CONNECT_AS + ): + if author: + return Markup( + 'Annulée pour l\'acteur culturel par {full_name} via Connect As' + ).format( + url=url_for("backoffice_web.bo_users.get_bo_user", user_id=author.id), + full_name=author.full_name, + ) + return "Annulée pour l'acteur culturel via Connect As" case ( bookings_models.BookingCancellationReasons.BENEFICIARY | educational_models.CollectiveBookingCancellationReasons.BENEFICIARY @@ -261,11 +280,74 @@ def format_booking_cancellation( | bookings_models.BookingCancellationReasons.BACKOFFICE ): if author: - return Markup('Annulée sur le backoffice par {full_name}').format( + return Markup( + 'Annulée depuis le backoffice par {full_name}' + ).format( + url=url_for("backoffice_web.bo_users.get_bo_user", user_id=author.id), + full_name=author.full_name, + ) + return "Annulée depuis le backoffice" + case ( + educational_models.CollectiveBookingCancellationReasons.BACKOFFICE_EVENT_CANCELLED + | bookings_models.BookingCancellationReasons.BACKOFFICE_EVENT_CANCELLED + ): + if author: + return Markup( + 'Annulée depuis le backoffice par {full_name} pour annulation d’évènement' + ).format( + url=url_for("backoffice_web.bo_users.get_bo_user", user_id=author.id), + full_name=author.full_name, + ) + return "Annulée depuis le backoffice pour annulation d’évènement" + case ( + educational_models.CollectiveBookingCancellationReasons.BACKOFFICE_OVERBOOKING + | bookings_models.BookingCancellationReasons.BACKOFFICE_OVERBOOKING + ): + if author: + return Markup( + 'Annulée depuis le backoffice par {full_name} pour surbooking' + ).format( url=url_for("backoffice_web.bo_users.get_bo_user", user_id=author.id), full_name=author.full_name, ) - return "Annulée sur le backoffice" + return "Annulée depuis le backoffice pour surbooking" + case ( + educational_models.CollectiveBookingCancellationReasons.BACKOFFICE_BENEFICIARY_REQUEST + | bookings_models.BookingCancellationReasons.BACKOFFICE_BENEFICIARY_REQUEST + ): + if author: + return Markup( + 'Annulée depuis le backoffice par {full_name} sur demande du bénéficiaire' + ).format( + url=url_for("backoffice_web.bo_users.get_bo_user", user_id=author.id), + full_name=author.full_name, + ) + return "Annulée depuis le backoffice sur demande du bénéficiaire" + case ( + educational_models.CollectiveBookingCancellationReasons.BACKOFFICE_OFFER_MODIFIED + | bookings_models.BookingCancellationReasons.BACKOFFICE_OFFER_MODIFIED + ): + if author: + return Markup( + 'Annulée depuis le backoffice par {full_name} pour modification des informations de l\'offre' + ).format( + url=url_for("backoffice_web.bo_users.get_bo_user", user_id=author.id), + full_name=author.full_name, + ) + return "Annulée depuis le backoffice pour modification des informations de l'offre" + case ( + educational_models.CollectiveBookingCancellationReasons.BACKOFFICE_OFFER_WITH_WRONG_INFORMATION + | bookings_models.BookingCancellationReasons.BACKOFFICE_OFFER_WITH_WRONG_INFORMATION + ): + if author: + return Markup( + "Annulée depuis le backoffice par {full_name} pour erreur d'information dans l'offre" + ).format( + url=url_for("backoffice_web.bo_users.get_bo_user", user_id=author.id), + full_name=author.full_name, + ) + return "Annulée depuis le backoffice pour erreur d'information dans l'offre" + case ( bookings_models.BookingCancellationReasons.REFUSED_BY_INSTITUTE | educational_models.CollectiveBookingCancellationReasons.REFUSED_BY_INSTITUTE diff --git a/api/src/pcapi/routes/pro/collective_bookings.py b/api/src/pcapi/routes/pro/collective_bookings.py index 62ea54b159f..f417cbceefb 100644 --- a/api/src/pcapi/routes/pro/collective_bookings.py +++ b/api/src/pcapi/routes/pro/collective_bookings.py @@ -163,7 +163,9 @@ def cancel_collective_offer_booking(offer_id: int) -> None: check_user_has_access_to_offerer(current_user, offerer.id) try: - educational_api_booking.cancel_collective_offer_booking(offer_id) + educational_api_booking.cancel_collective_offer_booking( + offer_id, current_user.real_user.id, current_user.is_impersonated + ) except collective_exceptions.CollectiveStockNotFound: raise ApiErrors( {"code": "NO_ACTIVE_STOCK_FOUND", "message": "No active stock has been found with this id"}, 404 diff --git a/api/src/pcapi/routes/pro/offers.py b/api/src/pcapi/routes/pro/offers.py index 3f4cd56b58c..80827442a53 100644 --- a/api/src/pcapi/routes/pro/offers.py +++ b/api/src/pcapi/routes/pro/offers.py @@ -155,7 +155,7 @@ def delete_stocks(offer_id: int, body: offers_serialize.DeleteStockListBody) -> rest.check_user_has_access_to_offerer(current_user, offer.venue.managingOffererId) stocks_to_delete = [stock for stock in offer.stocks if stock.id in body.ids_to_delete] - offers_api.batch_delete_stocks(stocks_to_delete) + offers_api.batch_delete_stocks(stocks_to_delete, current_user.real_user.id, current_user.is_impersonated) @private_api.route("/offers//stocks/all-delete", methods=["POST"]) diff --git a/api/src/pcapi/routes/pro/stocks.py b/api/src/pcapi/routes/pro/stocks.py index a33ae5f32ec..7a29b0aa57f 100644 --- a/api/src/pcapi/routes/pro/stocks.py +++ b/api/src/pcapi/routes/pro/stocks.py @@ -222,7 +222,7 @@ def delete_stock(stock_id: int) -> serialization.StockIdResponseModel: offerer_id = stock.offer.venue.managingOffererId check_user_has_access_to_offerer(current_user, offerer_id) try: - offers_api.delete_stock(stock) + offers_api.delete_stock(stock, current_user.real_user.id, current_user.is_impersonated) except offers_exceptions.OfferEditionBaseException as error: raise ApiErrors(error.errors, status_code=400) return serialization.StockIdResponseModel.from_orm(stock) diff --git a/api/src/pcapi/routes/public/individual_offers/v1/events.py b/api/src/pcapi/routes/public/individual_offers/v1/events.py index 0e69f8ae322..3d433bf70ba 100644 --- a/api/src/pcapi/routes/public/individual_offers/v1/events.py +++ b/api/src/pcapi/routes/public/individual_offers/v1/events.py @@ -1,5 +1,6 @@ import copy +from flask_login import current_user import sqlalchemy as sqla from pcapi import repository @@ -505,7 +506,7 @@ def delete_event_stock(event_id: int, stock_id: int) -> None: if not stock_to_delete: raise api_errors.ApiErrors({"stock_id": ["No stock could be found"]}, status_code=404) try: - offers_api.delete_stock(stock_to_delete) + offers_api.delete_stock(stock_to_delete, current_user) except offers_exceptions.OfferEditionBaseException as error: raise api_errors.ApiErrors(error.errors, status_code=400) diff --git a/api/tests/core/offers/test_api.py b/api/tests/core/offers/test_api.py index d21da837b6f..f5b359fdbf9 100644 --- a/api/tests/core/offers/test_api.py +++ b/api/tests/core/offers/test_api.py @@ -2745,7 +2745,7 @@ def test_delete_draft_with_mediation_offer_criterion_activation_code_and_stocks( class DeleteStocksTest: def test_delete_batch_stocks(self, client): stocks = factories.StockFactory.create_batch(3) - api.batch_delete_stocks(stocks) + api.batch_delete_stocks(stocks, author_id=None, user_connect_as=None) assert all(stock.isSoftDeleted for stock in stocks) @time_machine.travel("2020-10-15 00:00:00") @@ -2761,8 +2761,8 @@ def test_delete_batch_stocks_filtered_by_date(self): offer_id=offer.id, date=beginning_datetime.date(), venue=offer.venue, - ) - api.batch_delete_stocks(stocks) + ).all() + api.batch_delete_stocks(stocks, author_id=None, user_connect_as=None) # Then assert stock_1.isSoftDeleted @@ -2781,8 +2781,8 @@ def test_delete_batch_stocks_filtered_by_time(self): offer_id=offer.id, time=beginning_datetime.time(), venue=offer.venue, - ) - api.batch_delete_stocks(stocks) + ).all() + api.batch_delete_stocks(stocks, author_id=None, user_connect_as=None) # Then assert stock_1.isSoftDeleted @@ -2803,8 +2803,8 @@ def test_delete_batch_stocks_filtered_by_price_cat(self): offer_id=offer.id, price_category_id=stock_1.priceCategoryId, venue=offer.venue, - ) - api.batch_delete_stocks(stocks) + ).all() + api.batch_delete_stocks(stocks, author_id=None, user_connect_as=None) # Then assert stock_1.isSoftDeleted diff --git a/api/tests/routes/backoffice/collective_bookings_test.py b/api/tests/routes/backoffice/collective_bookings_test.py index 617bb18ef4e..cddb9afa9e7 100644 --- a/api/tests/routes/backoffice/collective_bookings_test.py +++ b/api/tests/routes/backoffice/collective_bookings_test.py @@ -178,8 +178,36 @@ def test_list_bookings_by_id(self, authenticated_client, collective_bookings): @pytest.mark.parametrize( "cancellation_reason, expected_text", [ - (educational_models.CollectiveBookingCancellationReasons.BACKOFFICE, "Annulée sur le backoffice par"), + (educational_models.CollectiveBookingCancellationReasons.OFFERER, "Annulée par l'acteur culturel"), + ( + educational_models.CollectiveBookingCancellationReasons.OFFERER_CONNECT_AS, + "Annulée pour l'acteur culturel par Hercule Poirot via Connect As", + ), + (educational_models.CollectiveBookingCancellationReasons.BENEFICIARY, "Annulée par le bénéficiaire"), + (educational_models.CollectiveBookingCancellationReasons.EXPIRED, "Expirée"), (educational_models.CollectiveBookingCancellationReasons.FRAUD, "Fraude"), + (educational_models.CollectiveBookingCancellationReasons.REFUSED_BY_INSTITUTE, "Refusée par l'institution"), + (educational_models.CollectiveBookingCancellationReasons.FINANCE_INCIDENT, "Incident finance"), + ( + educational_models.CollectiveBookingCancellationReasons.BACKOFFICE_EVENT_CANCELLED, + "Annulée depuis le backoffice par Hercule Poirot pour annulation d’évènement", + ), + ( + educational_models.CollectiveBookingCancellationReasons.BACKOFFICE_BENEFICIARY_REQUEST, + "Annulée depuis le backoffice par Hercule Poirot sur demande du bénéficiaire", + ), + ( + educational_models.CollectiveBookingCancellationReasons.BACKOFFICE_OVERBOOKING, + "Annulée depuis le backoffice par Hercule Poirot pour surbooking", + ), + ( + educational_models.CollectiveBookingCancellationReasons.BACKOFFICE_OFFER_MODIFIED, + "Annulée depuis le backoffice par Hercule Poirot pour modification des informations de l'offre", + ), + ( + educational_models.CollectiveBookingCancellationReasons.BACKOFFICE_OFFER_WITH_WRONG_INFORMATION, + "Annulée depuis le backoffice par Hercule Poirot pour erreur d'information dans l'offre", + ), ], ) def test_list_cancelled_collective_booking_information( @@ -192,9 +220,7 @@ def test_list_cancelled_collective_booking_information( booking_id = booking.id with assert_num_queries(self.expected_num_queries): - response = authenticated_client.get( - url_for(self.endpoint, q=booking_id, cancellation_reason=["OFFERER", "FRAUD", "BACKOFFICE"]) - ) + response = authenticated_client.get(url_for(self.endpoint, q=booking_id)) assert response.status_code == 200 extra_data = html_parser.extract(response.data, tag="tr", class_="collapse accordion-collapse")[0] @@ -204,10 +230,7 @@ def test_list_cancelled_collective_booking_information( assert "Date limite de réservation" in extra_data assert "Date d'annulation" in extra_data assert "Raison de l'annulation" in extra_data - if expected_text == "Fraude": - assert expected_text in extra_data - else: - assert f"{expected_text} {legit_user.full_name}" in extra_data + assert expected_text in extra_data @pytest.mark.parametrize( "query_args", diff --git a/api/tests/routes/backoffice/individual_bookings_test.py b/api/tests/routes/backoffice/individual_bookings_test.py index 1e63d911b7c..325a9f6c590 100644 --- a/api/tests/routes/backoffice/individual_bookings_test.py +++ b/api/tests/routes/backoffice/individual_bookings_test.py @@ -566,8 +566,39 @@ def test_sort_bookings_by_date(self, authenticated_client, bookings): @pytest.mark.parametrize( "cancellation_reason, expected_text", [ - (bookings_models.BookingCancellationReasons.BACKOFFICE, "Annulée sur le backoffice par"), + (bookings_models.BookingCancellationReasons.OFFERER, "Annulée par l'acteur culturel"), + ( + bookings_models.BookingCancellationReasons.OFFERER_CONNECT_AS, + "Annulée pour l'acteur culturel par Hercule Poirot via Connect As", + ), + (bookings_models.BookingCancellationReasons.BENEFICIARY, "Annulée par le bénéficiaire"), + (bookings_models.BookingCancellationReasons.EXPIRED, "Expirée"), (bookings_models.BookingCancellationReasons.FRAUD, "Fraude"), + ( + bookings_models.BookingCancellationReasons.REFUSED_BY_INSTITUTE, + "Refusée par l'institution", + ), + (bookings_models.BookingCancellationReasons.FINANCE_INCIDENT, "Incident finance"), + ( + bookings_models.BookingCancellationReasons.BACKOFFICE_EVENT_CANCELLED, + "Annulée depuis le backoffice par Hercule Poirot pour annulation d’évènement", + ), + ( + bookings_models.BookingCancellationReasons.BACKOFFICE_BENEFICIARY_REQUEST, + "Annulée depuis le backoffice par Hercule Poirot sur demande du bénéficiaire", + ), + ( + bookings_models.BookingCancellationReasons.BACKOFFICE_OVERBOOKING, + "Annulée depuis le backoffice par Hercule Poirot pour surbooking", + ), + ( + bookings_models.BookingCancellationReasons.BACKOFFICE_OFFER_MODIFIED, + "Annulée depuis le backoffice par Hercule Poirot pour modification des informations de l'offre", + ), + ( + bookings_models.BookingCancellationReasons.BACKOFFICE_OFFER_WITH_WRONG_INFORMATION, + "Annulée depuis le backoffice par Hercule Poirot pour erreur d'information dans l'offre", + ), ], ) def test_list_cancelled_booking_information( @@ -579,9 +610,7 @@ def test_list_cancelled_booking_information( ) with assert_num_queries(self.expected_num_queries): - response = authenticated_client.get( - url_for(self.endpoint, cancellation_reason=["OFFERER", "FRAUD", "BACKOFFICE"]) - ) + response = authenticated_client.get(url_for(self.endpoint, cancellation_reason=cancellation_reason.name)) assert response.status_code == 200 extra_data = html_parser.extract(response.data, tag="tr", class_="collapse accordion-collapse")[0] @@ -590,12 +619,7 @@ def test_list_cancelled_booking_information( assert "Sous-catégorie" in extra_data assert "Date d'annulation" in extra_data assert "Raison de l'annulation" in extra_data - if expected_text == "Fraude": - assert expected_text in extra_data - else: - assert f"{expected_text} {legit_user.full_name}" in extra_data - link = '' + legit_user.full_name + "" - assert link in str(html_parser.get_soup(response.data)) + assert expected_text in extra_data class MarkBookingAsUsedTest(PostEndpointHelper): diff --git a/api/tests/routes/native/openapi_test.py b/api/tests/routes/native/openapi_test.py index cd4758d9720..4f2b7a4fddc 100644 --- a/api/tests/routes/native/openapi_test.py +++ b/api/tests/routes/native/openapi_test.py @@ -204,6 +204,12 @@ def test_public_api(client): "REFUSED_BY_INSTITUTE", "FINANCE_INCIDENT", "BACKOFFICE", + "BACKOFFICE_EVENT_CANCELLED", + "BACKOFFICE_OVERBOOKING", + "BACKOFFICE_BENEFICIARY_REQUEST", + "BACKOFFICE_OFFER_MODIFIED", + "BACKOFFICE_OFFER_WITH_WRONG_INFORMATION", + "OFFERER_CONNECT_AS", ], "title": "BookingCancellationReasons", }, diff --git a/api/tests/routes/pro/delete_stock_test.py b/api/tests/routes/pro/delete_stock_test.py index 1e61a40336a..bc42f127b13 100644 --- a/api/tests/routes/pro/delete_stock_test.py +++ b/api/tests/routes/pro/delete_stock_test.py @@ -1,7 +1,10 @@ +from pcapi.core.bookings import factories as bookings_factory from pcapi.core.bookings.factories import BookingFactory import pcapi.core.offerers.factories as offerers_factories import pcapi.core.offers.factories as offers_factories from pcapi.core.offers.models import OfferValidationStatus +from pcapi.core.token import SecureToken +from pcapi.core.token.serialization import ConnectAsInternalModel import pcapi.core.users.factories as users_factories from pcapi.notifications.push import testing as push_testing @@ -10,7 +13,7 @@ class Returns200Test: def when_current_user_has_rights_on_offer(self, client, db_session): # given offer = offers_factories.OfferFactory() - offerers_factories.UserOffererFactory( + user_offerer = offerers_factories.UserOffererFactory( user__email="pro@example.com", offerer=offer.venue.managingOfferer, ) @@ -24,6 +27,7 @@ def when_current_user_has_rights_on_offer(self, client, db_session): assert response.status_code == 200 assert response.json == {"id": stock.id} assert stock.isSoftDeleted + assert stock.bookings[0].cancellationUser == user_offerer.user assert push_testing.requests[-1] == { "group_id": "Cancel_booking", "message": { @@ -34,6 +38,37 @@ def when_current_user_has_rights_on_offer(self, client, db_session): "can_be_asynchronously_retried": False, } + def when_current_user_is_connect_as(self, client, db_session): + # given + offer = offers_factories.OfferFactory() + user_offerer = offerers_factories.UserOffererFactory( + user__email="pro@example.com", + offerer=offer.venue.managingOfferer, + ) + stock = offers_factories.StockFactory(offer=offer, bookings=[bookings_factory.BookingFactory()]) + expected_redirect_link = "https://example.com" + admin = users_factories.AdminFactory(email="admin@example.com") + secure_token = SecureToken( + data=ConnectAsInternalModel( + redirect_link=expected_redirect_link, + user_id=user_offerer.user.id, + internal_admin_email=admin.email, + internal_admin_id=admin.id, + ).dict(), + ) + client = client.with_session_auth(admin.email) + response_token = client.get(f"/users/connect-as/{secure_token.token}") + assert response_token.status_code == 302 + + # when + response = client.delete(f"/stocks/{stock.id}") + + # then + assert response.status_code == 200 + assert response.json == {"id": stock.id} + assert stock.isSoftDeleted + assert stock.bookings[0].cancellationUser == admin + class Returns400Test: def test_delete_non_approved_offer_fails(self, client, db_session): diff --git a/api/tests/routes/pro/delete_stocks_test.py b/api/tests/routes/pro/delete_stocks_test.py index 61db36139e0..8248ebaa2c3 100644 --- a/api/tests/routes/pro/delete_stocks_test.py +++ b/api/tests/routes/pro/delete_stocks_test.py @@ -1,8 +1,11 @@ import pytest +from pcapi.core.bookings import factories as booking_factory import pcapi.core.offerers.factories as offerers_factories import pcapi.core.offers.factories as offers_factories import pcapi.core.offers.models as offer_models +from pcapi.core.token import SecureToken +from pcapi.core.token.serialization import ConnectAsInternalModel import pcapi.core.users.factories as users_factories @@ -15,6 +18,8 @@ def test_delete_multiple_stocks_by_offer_id(self, client): offerers_factories.UserOffererFactory(user=user, offerer=offer.venue.managingOfferer) batch_stocks = offers_factories.StockFactory.create_batch(3, offer=offer) + booking_1 = booking_factory.BookingFactory(stock=batch_stocks[0]) + booking_2 = booking_factory.BookingFactory(stock=batch_stocks[1]) data = {"ids_to_delete": [stock.id for stock in batch_stocks]} # When @@ -22,8 +27,41 @@ def test_delete_multiple_stocks_by_offer_id(self, client): # Then assert response.status_code == 204 + assert all(stock.isSoftDeleted for stock in offer_models.Stock.query.all()) + assert booking_1.cancellationUser == user + assert booking_2.cancellationUser == user + + def test_delete_multiple_stocks_by_offer_id_with_connect_as(self, client): + # Given + offer = offers_factories.OfferFactory() + user = users_factories.ProFactory() + user_offerer = offerers_factories.UserOffererFactory(user=user, offerer=offer.venue.managingOfferer) + batch_stocks = offers_factories.StockFactory.create_batch(3, offer=offer) + booking_1 = booking_factory.BookingFactory(stock=batch_stocks[0]) + booking_2 = booking_factory.BookingFactory(stock=batch_stocks[1]) + data = {"ids_to_delete": [stock.id for stock in batch_stocks]} + admin = users_factories.AdminFactory(email="admin@example.com") + secure_token = SecureToken( + data=ConnectAsInternalModel( + redirect_link="https://example.com", + user_id=user_offerer.user.id, + internal_admin_email=admin.email, + internal_admin_id=admin.id, + ).dict(), + ) + client = client.with_session_auth(admin.email) + response_token = client.get(f"/users/connect-as/{secure_token.token}") + assert response_token.status_code == 302 + + # When + response = client.with_session_auth(user.email).post(f"/offers/{offer.id}/stocks/delete", json=data) + + # Then + assert response.status_code == 204 assert all(stock.isSoftDeleted for stock in offer_models.Stock.query.all()) + assert booking_1.cancellationUser == admin + assert booking_2.cancellationUser == admin def test_delete_unaccessible_stocks(self, client): # Given diff --git a/api/tests/routes/pro/patch_cancel_collective_offer_booking_test.py b/api/tests/routes/pro/patch_cancel_collective_offer_booking_test.py index d5b67be046d..6f5140d8a7e 100644 --- a/api/tests/routes/pro/patch_cancel_collective_offer_booking_test.py +++ b/api/tests/routes/pro/patch_cancel_collective_offer_booking_test.py @@ -6,6 +6,8 @@ import pcapi.core.educational.testing as adage_api_testing from pcapi.core.offerers import factories as offerers_factories from pcapi.core.testing import override_settings +from pcapi.core.token import SecureToken +from pcapi.core.token.serialization import ConnectAsInternalModel from pcapi.core.users import factories as user_factories from pcapi.routes.adage.v1.serialization.prebooking import serialize_collective_booking @@ -37,7 +39,7 @@ def test_cancel_pending_booking(self, client): assert adage_api_testing.adage_requests[0]["url"] == "https://adage_base_url/v1/prereservation-annule" def test_cancel_confirmed_booking(self, client): - user = user_factories.UserFactory() + user = user_factories.ProFactory() offerer = offerers_factories.OffererFactory() offerers_factories.UserOffererFactory(user=user, offerer=offerer) collective_booking = CollectiveBookingFactory( @@ -50,12 +52,41 @@ def test_cancel_confirmed_booking(self, client): assert response.status_code == 204 assert collective_booking.status == CollectiveBookingStatus.CANCELLED + assert collective_booking.cancellationUser == user expected_payload = serialize_collective_booking(collective_booking) assert len(adage_api_testing.adage_requests) == 1 assert adage_api_testing.adage_requests[0]["sent_data"] == expected_payload assert adage_api_testing.adage_requests[0]["url"] == "https://adage_base_url/v1/prereservation-annule" + def test_cancel_confirmed_booking_user_connect_as(self, client): + user = user_factories.ProFactory() + admin = user_factories.AdminFactory(email="admin@example.com") + offerer = offerers_factories.OffererFactory() + offerers_factories.UserOffererFactory(user=user, offerer=offerer) + collective_booking = CollectiveBookingFactory( + status=CollectiveBookingStatus.CONFIRMED, collectiveStock__collectiveOffer__venue__managingOfferer=offerer + ) + expected_redirect_link = "https://example.com" + secure_token = SecureToken( + data=ConnectAsInternalModel( + redirect_link=expected_redirect_link, + user_id=user.id, + internal_admin_email=admin.email, + internal_admin_id=admin.id, + ).dict(), + ) + client = client.with_session_auth(admin.email) + response_token = client.get(f"/users/connect-as/{secure_token.token}") + assert response_token.status_code == 302 + + offer_id = collective_booking.collectiveStock.collectiveOffer.id + response = client.patch(f"/collective/offers/{offer_id}/cancel_booking") + + assert response.status_code == 204 + assert collective_booking.status == CollectiveBookingStatus.CANCELLED + assert collective_booking.cancellationUser == admin + class Returns404Test: def test_no_collective_offer_found(self, client): diff --git a/pro/src/apiClient/v1/models/CollectiveBookingCancellationReasons.ts b/pro/src/apiClient/v1/models/CollectiveBookingCancellationReasons.ts index e35f5e85677..32b793c3414 100644 --- a/pro/src/apiClient/v1/models/CollectiveBookingCancellationReasons.ts +++ b/pro/src/apiClient/v1/models/CollectiveBookingCancellationReasons.ts @@ -15,4 +15,10 @@ export enum CollectiveBookingCancellationReasons { PUBLIC_API = 'PUBLIC_API', FINANCE_INCIDENT = 'FINANCE_INCIDENT', BACKOFFICE = 'BACKOFFICE', + BACKOFFICE_EVENT_CANCELLED = 'BACKOFFICE_EVENT_CANCELLED', + BACKOFFICE_OVERBOOKING = 'BACKOFFICE_OVERBOOKING', + BACKOFFICE_BENEFICIARY_REQUEST = 'BACKOFFICE_BENEFICIARY_REQUEST', + BACKOFFICE_OFFER_MODIFIED = 'BACKOFFICE_OFFER_MODIFIED', + BACKOFFICE_OFFER_WITH_WRONG_INFORMATION = 'BACKOFFICE_OFFER_WITH_WRONG_INFORMATION', + OFFERER_CONNECT_AS = 'OFFERER_CONNECT_AS', }