Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

(PC-30861)[BO] feat: add reason cancellation with author for booking … #13860

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion api/alembic_version_conflict_detection.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
8523f3e2d7d6 (pre) (head)
404b3075d1a4 (post) (head)
da2896f47266 (post) (head)
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
16 changes: 13 additions & 3 deletions api/src/pcapi/core/bookings/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)

Expand All @@ -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]:
Expand Down
6 changes: 6 additions & 0 deletions api/src/pcapi/core/bookings/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
16 changes: 12 additions & 4 deletions api/src/pcapi/core/educational/api/booking.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
blbpro marked this conversation as resolved.
Show resolved Hide resolved
collective_offer: educational_models.CollectiveOffer | None = (
educational_models.CollectiveOffer.query.filter(educational_models.CollectiveOffer.id == offer_id)
.options(
Expand All @@ -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",
Expand Down Expand Up @@ -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)
Expand All @@ -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

Expand All @@ -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.
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions api/src/pcapi/core/educational/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
14 changes: 8 additions & 6 deletions api/src/pcapi/core/offers/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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(
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions api/src/pcapi/core/users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
12 changes: 11 additions & 1 deletion api/src/pcapi/routes/backoffice/bookings/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
),
)

Expand All @@ -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,
),
),
)

Expand Down
86 changes: 84 additions & 2 deletions api/src/pcapi/routes/backoffice/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (<a class="link-primary" href="{url}">{email}</a>)'
).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 <a class="link-primary" href="{url}">{full_name}</a> 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
Expand All @@ -261,11 +280,74 @@ def format_booking_cancellation(
| bookings_models.BookingCancellationReasons.BACKOFFICE
):
if author:
return Markup('Annulée sur le backoffice par <a href="{url}">{full_name}</a>').format(
return Markup(
'Annulée depuis le backoffice par <a class="link-primary" href="{url}">{full_name}</a>'
).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 <a class="link-primary" href="{url}">{full_name}</a> 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 <a class="link-primary" href="{url}">{full_name}</a> 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 <a class="link-primary" href="{url}">{full_name}</a> 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 <a class="link-primary" href="{url}">{full_name}</a> 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 <a class='link-primary' href=\"{url}\">{full_name}</a> 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
Expand Down
4 changes: 3 additions & 1 deletion api/src/pcapi/routes/pro/collective_bookings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion api/src/pcapi/routes/pro/offers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<int:offer_id>/stocks/all-delete", methods=["POST"])
Expand Down
2 changes: 1 addition & 1 deletion api/src/pcapi/routes/pro/stocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading
Loading