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',
}