Skip to content

Commit

Permalink
(PC-33346)[API] feat: add route to create and delete headline offer
Browse files Browse the repository at this point in the history
  • Loading branch information
ogeber-pass committed Jan 6, 2025
1 parent cd7f87d commit 131cd42
Show file tree
Hide file tree
Showing 13 changed files with 486 additions and 12 deletions.
10 changes: 10 additions & 0 deletions api/src/pcapi/core/offers/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,16 @@ def __init__(self) -> None:
super().__init__("headlineOffer", "This offer is already an active headline offer")


class OffererCanNotHaveHeadlineOffer(Exception):
def __init__(self) -> None:
super().__init__("headlineOffer", "This offerer can not have headline offers")


class VirtualOfferCanNotBeHeadline(Exception):
def __init__(self) -> None:
super().__init__("headlineOffer", "Digital offers can not be made headline")


class VenueHasAlreadyAnActiveHeadlineOffer(Exception):
def __init__(self) -> None:
super().__init__("headlineOffer", "This venue has already an active headline offer")
16 changes: 16 additions & 0 deletions api/src/pcapi/core/offers/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -1138,6 +1138,22 @@ def get_active_headline_offer(offer_id: int) -> models.HeadlineOffer | None:
)


def get_offerers_active_headline_offer(offerer_id: int) -> models.HeadlineOffer | None:
managed_venue_ids_subquery = (
offerers_models.Venue.query.filter(offerers_models.Venue.managingOffererId == offerer_id)
.with_entities(offerers_models.Venue.id)
.subquery()
)
return (
models.HeadlineOffer.query.join(models.Offer)
.filter(
models.HeadlineOffer.venueId.in_(managed_venue_ids_subquery),
models.HeadlineOffer.isActive == True,
)
.one_or_none()
)


def get_inactive_headline_offers() -> list[models.HeadlineOffer]:
return (
models.HeadlineOffer.query.join(models.Offer, models.HeadlineOffer.offerId == models.Offer.id)
Expand Down
26 changes: 26 additions & 0 deletions api/src/pcapi/core/offers/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -877,3 +877,29 @@ def validate_national_program(
if not np_api.get_national_program(nationalProgramId):
raise UnknownNationalProgram()
raise IllegalNationalProgram()


def check_offerer_is_eligible_for_headline_offers(offerer_id: int) -> None:
# FIXME: ogeber 03.01.2025 - when venue regularisation is done, we can change this validation by
# raising the OffererCanNotHaveHeadlineOffer only if
# offerers_models.Venue.query.filter(
# offerers_models.Venue.managingOffererId == offerer_id
# offerers_models.Venue.isPermanent.is_(True)
# ).count()
# is superior to 1 (as permanent & virtual venues won't exist anymore)

venues = offerers_models.Venue.query.filter(offerers_models.Venue.managingOffererId == offerer_id).all()

permanent_venues = [v for v in venues if v.isPermanent and not v.isVirtual]
non_permanent_venues = [v for v in venues if not v.isPermanent and not v.isVirtual]

if len(permanent_venues) != 1 or len(non_permanent_venues) > 0:
raise exceptions.OffererCanNotHaveHeadlineOffer()


def check_offer_is_eligible_to_be_headline(offer: models.Offer) -> None:
# FIXME: ogeber 03.01.2025 - when venue regularisation is done, this
# validation can be removed and virtual offers can be made headline
subcategory = subcategories.ALL_SUBCATEGORIES_DICT[offer.subcategoryId]
if subcategory.is_online_only:
raise exceptions.VirtualOfferCanNotBeHeadline()
1 change: 1 addition & 0 deletions api/src/pcapi/routes/pro/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ def install_routes(app: Flask) -> None:
from . import collective_stocks
from . import features
from . import finance
from . import headline_offer
from . import national_programs
from . import offerers
from . import offers
Expand Down
83 changes: 83 additions & 0 deletions api/src/pcapi/routes/pro/headline_offer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import logging

from flask_login import current_user
from flask_login import login_required

from pcapi.core.offers import exceptions
import pcapi.core.offers.api as offers_api
import pcapi.core.offers.repository as offers_repository
import pcapi.core.offers.validation as offers_validation
from pcapi.models import api_errors
from pcapi.repository import atomic
from pcapi.routes.apis import private_api
from pcapi.routes.serialization import headline_offer_serialize
from pcapi.serialization.decorator import spectree_serialize
from pcapi.utils import rest

from . import blueprint


logger = logging.getLogger(__name__)


@private_api.route("/offers/headline", methods=["POST"])
@login_required
@spectree_serialize(
on_success_status=204,
api=blueprint.pro_private_schema,
)
@atomic()
def make_offer_headline_from_offers(body: headline_offer_serialize.HeadlineOfferCreationBodyModel) -> None:

offer = offers_repository.get_offer_by_id(body.offer_id, load_options=["headline_offer"])

if not offer:
raise api_errors.ResourceNotFoundError
offerer_id = offer.venue.managingOffererId

rest.check_user_has_access_to_offerer(current_user, offerer_id)
try:
offers_validation.check_offerer_is_eligible_for_headline_offers(offerer_id)
offers_validation.check_offer_is_eligible_to_be_headline(offer)
except (
exceptions.OffererCanNotHaveHeadlineOffer,
exceptions.VirtualOfferCanNotBeHeadline,
) as error:
messages = {
exceptions.OffererCanNotHaveHeadlineOffer: "Vous ne pouvez pas créer d'offre à la une sur une entité juridique possédant plusieurs structures",
exceptions.VirtualOfferCanNotBeHeadline: "Une offre virtuelle ne peut pas être mise à la une",
}
raise api_errors.ApiErrors(
errors={"global": [messages[type(error)]]},
status_code=400,
)

try:
offers_api.make_offer_headline(offer)
except (
exceptions.OfferHasAlreadyAnActiveHeadlineOffer,
exceptions.VenueHasAlreadyAnActiveHeadlineOffer,
exceptions.InactiveOfferCanNotBeHeadline,
) as error:
messages = {
exceptions.OfferHasAlreadyAnActiveHeadlineOffer: "Cette offre est déjà mise à la une",
exceptions.VenueHasAlreadyAnActiveHeadlineOffer: "Cette structure possède déjà une offre à la une",
exceptions.InactiveOfferCanNotBeHeadline: "Cette offre est inactive et ne peut pas être mise à la une",
}
raise api_errors.ApiErrors(
errors={"global": [messages[type(error)]]},
status_code=400,
)


@private_api.route("/offers/delete_headline", methods=["POST"])
@login_required
@spectree_serialize(
on_success_status=204,
api=blueprint.pro_private_schema,
)
@atomic()
def delete_headline_offer(body: headline_offer_serialize.HeadlineOfferDeleteBodyModel) -> None:
rest.check_user_has_access_to_offerer(current_user, body.offerer_id)
if active_headline_offer := offers_repository.get_offerers_active_headline_offer(body.offerer_id):
offers_api.remove_headline_offer(active_headline_offer)
18 changes: 18 additions & 0 deletions api/src/pcapi/routes/serialization/headline_offer_serialize.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from pcapi.routes.serialization import BaseModel
from pcapi.serialization.utils import to_camel


class HeadlineOfferCreationBodyModel(BaseModel):
offer_id: int

class Config:
alias_generator = to_camel
extra = "forbid"


class HeadlineOfferDeleteBodyModel(BaseModel):
offerer_id: int

class Config:
alias_generator = to_camel
extra = "forbid"
50 changes: 38 additions & 12 deletions api/tests/core/offers/test_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@

IMAGES_DIR = pathlib.Path(tests.__path__[0]) / "files"

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


@pytest.mark.usefixtures("db_session")
class CheckProviderCanEditStockTest:
def test_allocine_offer(self):
provider = providers_factories.AllocineProviderFactory(localClass="AllocineStocks")
Expand All @@ -48,7 +49,6 @@ def test_allowed_provider(self):
validation.check_provider_can_edit_stock(provider_offer, provider)


@pytest.mark.usefixtures("db_session")
class CheckCanInputIdAtProviderTest:
def test_without_id_at_provider(self):
validation.check_can_input_id_at_provider(None, None)
Expand All @@ -67,7 +67,6 @@ def test_raise_when_id_at_provider_given_without_a_provider(self):
]


@pytest.mark.usefixtures("db_session")
class CheckCanInputIdAtProviderForThisVenueTest:
def test_without_id_at_provider(self):
venue = offerers_factories.VenueFactory()
Expand Down Expand Up @@ -114,7 +113,6 @@ def test_raise_when_id_at_provider_already_taken_by_other_offer(self):
assert error.value.errors["idAtProvider"] == ["`rolalala` is already taken by another venue offer"]


@pytest.mark.usefixtures("db_session")
class CheckPricesForStockTest:
def test_event_prices(self):
offer = offers_factories.EventOfferFactory()
Expand Down Expand Up @@ -205,7 +203,6 @@ def test_price_limitation_rule_with_no_last_validation_price(self):
]


@pytest.mark.usefixtures("db_session")
class CheckRequiredDatesForStockTest:
def test_thing_offer_must_not_have_beginning(self):
offer = offers_factories.ThingOfferFactory()
Expand Down Expand Up @@ -271,7 +268,6 @@ def test_event_offer_ok_with_beginning_and_booking_limit_datetime(self):
)


@pytest.mark.usefixtures("db_session")
class CheckStockCanBeCreatedForOfferTest:
def test_offer_from_provider(self, app):
provider = providers_factories.AllocineProviderFactory()
Expand All @@ -289,7 +285,6 @@ def test_allowed_provider(self, app):
validation.check_provider_can_create_stock(offer, provider)


@pytest.mark.usefixtures("db_session")
class CheckStockIsDeletableTest:
def test_non_approved_offer(self):
offer = offers_factories.OfferFactory(validation=OfferValidationStatus.PENDING)
Expand Down Expand Up @@ -320,7 +315,6 @@ def test_long_begun_event_stock(self):
]


@pytest.mark.usefixtures("db_session")
class CheckStockIsUpdatableTest:
def test_approved_offer(self):
offer = offers_factories.OfferFactory()
Expand Down Expand Up @@ -468,7 +462,6 @@ def test_wrong_format(self):
)


@pytest.mark.usefixtures("db_session")
class CheckValidationStatusTest:
def test_approved_offer(self):
approved_offer = offers_factories.OfferFactory()
Expand Down Expand Up @@ -501,7 +494,6 @@ def test_rejected_offer(self):
]


@pytest.mark.usefixtures("db_session")
class CheckOfferWithdrawalTest:
def test_offer_can_have_no_withdrawal_informations(self):
assert not validation.check_offer_withdrawal(
Expand Down Expand Up @@ -653,7 +645,6 @@ def test_withdrawable_event_offer_with_provider_without_a_ticketing_service_impl
)


@pytest.mark.usefixtures("db_session")
class CheckOfferExtraDataTest:
def test_invalid_ean_extra_data(self):
with pytest.raises(ApiErrors) as error:
Expand Down Expand Up @@ -738,7 +729,6 @@ def test_allow_creation_with_inactive_ean(self):
)


@pytest.mark.usefixtures("db_session")
class CheckBookingLimitDatetimeTest:
@pytest.mark.parametrize(
"stock_factory, offer_factory, venue_factory",
Expand Down Expand Up @@ -906,3 +896,39 @@ def test_check_publication_date(self):
offer = offers_factories.EventOfferFactory()
publication_date = datetime.datetime.utcnow().replace(minute=0) + datetime.timedelta(days=30)
assert validation.check_publication_date(offer, publication_date) is None


class CheckOffererIsEligibleForHeadlineOffersTest:
def test_check_offerer_is_eligible_for_headline_offers(self):
offerer = offerers_factories.OffererFactory()
offerers_factories.VenueFactory(isPermanent=True, isVirtual=False, managingOfferer=offerer)
offerers_factories.VirtualVenueFactory(isPermanent=False, managingOfferer=offerer)

assert validation.check_offerer_is_eligible_for_headline_offers(offerer.id) is None

another_venue = offerers_factories.VenueFactory(isPermanent=False, isVirtual=False, managingOfferer=offerer)
with pytest.raises(exceptions.OffererCanNotHaveHeadlineOffer) as exc:
validation.check_offerer_is_eligible_for_headline_offers(offerer.id)
msg = "This offerer can not have headline offers"
assert exc.value.errors["headlineOffer"] == [msg]

another_venue.isPermanent = True
with pytest.raises(exceptions.OffererCanNotHaveHeadlineOffer) as exc:
validation.check_offerer_is_eligible_for_headline_offers(offerer.id)
msg = "This offerer can not have headline offers"
assert exc.value.errors["headlineOffer"] == [msg]

def test_check_offer_is_eligible_to_be_headline(self):
offerer = offerers_factories.OffererFactory()
permanent_venue = offerers_factories.VenueFactory(isPermanent=True, isVirtual=False, managingOfferer=offerer)
virtual_venue = offerers_factories.VirtualVenueFactory(isPermanent=False, managingOfferer=offerer)
offer = offers_factories.ThingOfferFactory(venue=permanent_venue)
digital_offer = offers_factories.DigitalOfferFactory(venue=virtual_venue)

assert validation.check_offerer_is_eligible_for_headline_offers(offerer.id) is None
assert validation.check_offer_is_eligible_to_be_headline(offer) is None

with pytest.raises(exceptions.VirtualOfferCanNotBeHeadline) as exc:
validation.check_offer_is_eligible_to_be_headline(digital_offer)
msg = "Digital offers can not be made headline"
assert exc.value.errors["headlineOffer"] == [msg]
73 changes: 73 additions & 0 deletions api/tests/routes/pro/delete_headline_offer_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import datetime

import pytest

import pcapi.core.offerers.factories as offerers_factories
import pcapi.core.offers.factories as offers_factories
import pcapi.core.offers.models as offer_models
import pcapi.core.users.factories as users_factory


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


class Returns204Test:
def test_delete_headline_offer(self, client):
pro = users_factory.ProFactory()
offer = offers_factories.OfferFactory()
offerers_factories.UserOffererFactory(user=pro, offerer=offer.venue.managingOfferer)
offers_factories.StockFactory(offer=offer)
headline_offer = offers_factories.HeadlineOfferFactory(offer=offer)
ten_days_ago = datetime.datetime.utcnow() - datetime.timedelta(days=10)
yesterday = datetime.datetime.utcnow() - datetime.timedelta(days=1)
old_headline_offer = offers_factories.HeadlineOfferFactory(offer=offer, timespan=(ten_days_ago, yesterday))

data = {"offererId": offer.venue.managingOfferer.id}
client = client.with_session_auth(pro.email)
response = client.post("/offers/delete_headline", json=data)

assert response.status_code == 204

assert not headline_offer.isActive
assert headline_offer.timespan.upper.date() == datetime.datetime.utcnow().date()
assert not old_headline_offer.isActive
assert old_headline_offer.timespan.upper == yesterday

def test_delete_only_offerers_headline_offer(self, client):
pro = users_factory.ProFactory()
offer = offers_factories.OfferFactory()
offerer = offer.venue.managingOfferer
another_offer = offers_factories.OfferFactory()
another_offerer = another_offer.venue.managingOfferer
offers_factories.StockFactory(offer=offer)
offers_factories.StockFactory(offer=another_offer)
headline_offer = offers_factories.HeadlineOfferFactory(offer=offer)
another_headline_offer = offers_factories.HeadlineOfferFactory(offer=another_offer)

offerers_factories.UserOffererFactory(user=pro, offerer=offerer)
offerers_factories.UserOffererFactory(user=pro, offerer=another_offerer)
client = client.with_session_auth(pro.email)
data = {"offererId": offerer.id}
response = client.post("/offers/delete_headline", json=data)

assert response.status_code == 204

assert not headline_offer.isActive
assert another_headline_offer.isActive


class Returns401Test:
def test_delete_headline_when_current_user_has_no_rights_on_offer(self, client):
pro = users_factory.ProFactory()
offer = offers_factories.OfferFactory()
offers_factories.StockFactory(offer=offer)
offerers_factories.UserOffererFactory(user=pro, offerer=offer.venue.managingOfferer)
headline_offer = offers_factories.HeadlineOfferFactory(offer=offer)
data = {"offererId": offer.venue.managingOfferer.id}

response = client.post("/offers/delete_headline", json=data)

assert response.status_code == 401

assert offer_models.HeadlineOffer.query.count() == 1
assert headline_offer.isActive
Loading

0 comments on commit 131cd42

Please sign in to comment.