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 7, 2025
1 parent 4e33ce5 commit ebcd1a0
Show file tree
Hide file tree
Showing 15 changed files with 513 additions and 30 deletions.
7 changes: 3 additions & 4 deletions api/src/pcapi/core/offers/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
import pcapi.core.offerers.models as offerers_models
import pcapi.core.offerers.schemas as offerers_schemas
from pcapi.core.offers import models as offers_models
import pcapi.core.offers.validation as offers_validation
from pcapi.core.providers.allocine import get_allocine_products_provider
from pcapi.core.providers.constants import GTL_IDS_BY_MUSIC_GENRE_CODE
from pcapi.core.providers.constants import MUSIC_SLUG_BY_GTL_ID
Expand All @@ -64,7 +65,6 @@
from pcapi.models import pc_object
from pcapi.models.api_errors import ApiErrors
from pcapi.models.feature import FeatureToggle
from pcapi.models.offer_mixin import OfferStatus
from pcapi.models.offer_mixin import OfferValidationType
from pcapi.repository import is_managed_transaction
from pcapi.repository import mark_transaction_as_invalid
Expand Down Expand Up @@ -709,9 +709,8 @@ def set_upper_timespan_of_inactive_headline_offers() -> None:


def make_offer_headline(offer: models.Offer) -> models.HeadlineOffer:
if offer.status != OfferStatus.ACTIVE:
raise exceptions.InactiveOfferCanNotBeHeadline()

offers_validation.check_offerer_is_eligible_for_headline_offers(offer.venue.managingOffererId)
offers_validation.check_offer_is_eligible_to_be_headline(offer)
try:
headline_offer = models.HeadlineOffer(offer=offer, venue=offer.venue, timespan=(datetime.datetime.utcnow(),))
db.session.add(headline_offer)
Expand Down
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
29 changes: 29 additions & 0 deletions api/src/pcapi/core/offers/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from pcapi.domain import show_types
from pcapi.models import api_errors
from pcapi.models.feature import FeatureToggle
from pcapi.models.offer_mixin import OfferStatus
from pcapi.models.offer_mixin import OfferValidationStatus
from pcapi.routes.serialization import stock_serialize as serialization
from pcapi.utils import date
Expand Down Expand Up @@ -877,3 +878,31 @@ 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:
if offer.status != OfferStatus.ACTIVE:
raise exceptions.InactiveOfferCanNotBeHeadline()
# 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
76 changes: 76 additions & 0 deletions api/src/pcapi/routes/pro/headline_offer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
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
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_api.make_offer_headline(offer)
except exceptions.OffererCanNotHaveHeadlineOffer:
raise api_errors.ApiErrors(
errors={
"global": [
"Vous ne pouvez pas créer d'offre à la une sur une entité juridique possédant plusieurs structures"
]
},
)
except exceptions.VirtualOfferCanNotBeHeadline:
raise api_errors.ApiErrors(
errors={"global": ["Une offre virtuelle ne peut pas être mise à la une"]},
)
except exceptions.OfferHasAlreadyAnActiveHeadlineOffer:
raise api_errors.ApiErrors(
errors={"global": ["Cette offre est déjà mise à la une"]},
)
except exceptions.VenueHasAlreadyAnActiveHeadlineOffer:
raise api_errors.ApiErrors(
errors={"global": ["Cette structure possède déjà une offre à la une"]},
)
except exceptions.InactiveOfferCanNotBeHeadline:
raise api_errors.ApiErrors(
errors={"global": ["Cette offre est inactive et ne peut pas être mise à la une"]},
)


@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"
40 changes: 26 additions & 14 deletions api/tests/core/offers/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import pcapi.core.mails.testing as mails_testing
import pcapi.core.offerers.factories as offerers_factories
import pcapi.core.offerers.models as offerers_models
from pcapi.core.offerers.schemas import VenueTypeCode
from pcapi.core.offers import api
from pcapi.core.offers import exceptions
from pcapi.core.offers import factories
Expand Down Expand Up @@ -2106,7 +2107,8 @@ def test_activate_future_offers(self, mocked_async_index_offer_ids):
@pytest.mark.usefixtures("db_session")
class HeadlineOfferTest:
def test_make_new_offer_headline(self):
offer = factories.OfferFactory(isActive=True)
venue = offerers_factories.VenueFactory(venueTypeCode=VenueTypeCode.LIBRARY)
offer = factories.OfferFactory(isActive=True, venue=venue)
factories.StockFactory(offer=offer)
headline_offer = api.make_offer_headline(offer=offer)
db.session.commit() # see comment in make_offer_headline()
Expand All @@ -2117,15 +2119,16 @@ def test_make_new_offer_headline(self):
assert not headline_offer.timespan.upper

def test_create_offer_headline_when_another_is_still_active_should_fail(self):
offer = factories.OfferFactory(isActive=True)
venue = offerers_factories.VenueFactory(venueTypeCode=VenueTypeCode.LIBRARY)
offer = factories.OfferFactory(isActive=True, venue=venue)
factories.StockFactory(offer=offer)
api.make_offer_headline(offer=offer)
with pytest.raises(exceptions.OfferHasAlreadyAnActiveHeadlineOffer) as error:
api.make_offer_headline(offer=offer)
assert error.value.errors["headlineOffer"] == ["This offer is already an active headline offer"]

def test_make_another_offer_headline_on_the_same_venue_should_fail(self):
venue = offerers_factories.VenueFactory()
venue = offerers_factories.VenueFactory(venueTypeCode=VenueTypeCode.LIBRARY)
offer_1 = factories.OfferFactory(isActive=True, venue=venue)
factories.StockFactory(offer=offer_1)
offer_2 = factories.OfferFactory(isActive=True, venue=venue)
Expand All @@ -2143,15 +2146,17 @@ def test_make_another_offer_headline_on_the_same_venue_should_fail(self):
assert venue.has_headline_offer

def test_create_offer_headline_when_another_is_still_active_should_fail(self):
offer = factories.OfferFactory(isActive=True)
venue = offerers_factories.VenueFactory(venueTypeCode=VenueTypeCode.LIBRARY)
offer = factories.OfferFactory(isActive=True, venue=venue)
factories.StockFactory(offer=offer)
api.make_offer_headline(offer=offer)
with pytest.raises(exceptions.OfferHasAlreadyAnActiveHeadlineOffer) as error:
api.make_offer_headline(offer=offer)
assert error.value.errors["headlineOffer"] == ["This offer is already an active headline offer"]

def test_remove_headline_offer(self):
offer = factories.OfferFactory(isActive=True)
venue = offerers_factories.VenueFactory(venueTypeCode=VenueTypeCode.LIBRARY)
offer = factories.OfferFactory(isActive=True, venue=venue)
factories.StockFactory(offer=offer)
headline_offer = factories.HeadlineOfferFactory(offer=offer)

Expand All @@ -2164,7 +2169,8 @@ def test_remove_headline_offer(self):

@time_machine.travel("2024-12-13 15:44:00")
def test_make_offer_headline_again(self):
offer = factories.OfferFactory(isActive=True)
venue = offerers_factories.VenueFactory(venueTypeCode=VenueTypeCode.LIBRARY)
offer = factories.OfferFactory(isActive=True, venue=venue)
factories.StockFactory(offer=offer)
creation_time = datetime.utcnow()
finished_timespan = (creation_time, creation_time + timedelta(days=10))
Expand All @@ -2182,7 +2188,7 @@ def test_make_offer_headline_again(self):

@time_machine.travel("2024-12-13 15:44:00")
def test_make_another_offer_headline_on_same_venue(self):
venue = offerers_factories.VenueFactory()
venue = offerers_factories.VenueFactory(venueTypeCode=VenueTypeCode.LIBRARY)
offer_1 = factories.OfferFactory(isActive=True, venue=venue)
factories.StockFactory(offer=offer_1)
offer_2 = factories.OfferFactory(isActive=True, venue=venue)
Expand All @@ -2200,7 +2206,8 @@ def test_make_another_offer_headline_on_same_venue(self):
assert venue.has_headline_offer

def test_headline_offer_on_offer_turned_inactive_is_inactive(self):
active_offer = factories.OfferFactory(isActive=True)
venue = offerers_factories.VenueFactory(venueTypeCode=VenueTypeCode.LIBRARY)
active_offer = factories.OfferFactory(isActive=True, venue=venue)
factories.StockFactory(offer=active_offer)

api.make_offer_headline(offer=active_offer)
Expand All @@ -2209,15 +2216,17 @@ def test_headline_offer_on_offer_turned_inactive_is_inactive(self):
assert not active_offer.is_headline_offer

def test_headline_offer_on_sold_out_offer_is_inactive(self):
venue = offerers_factories.VenueFactory(venueTypeCode=VenueTypeCode.LIBRARY)
stock = factories.StockFactory(quantity=10)
offer = factories.OfferFactory(isActive=True, stocks=[stock])
offer = factories.OfferFactory(isActive=True, stocks=[stock], venue=venue)
api.make_offer_headline(offer=offer)
assert offer.is_headline_offer

stock.quantity = 0
assert not offer.is_headline_offer

def test_headline_offer_on_expired_offer_is_inactive(self):
venue = offerers_factories.VenueFactory(venueTypeCode=VenueTypeCode.LIBRARY)
tomorrow = date.today() + timedelta(days=1)
stock = factories.StockFactory(bookingLimitDatetime=tomorrow)
offer = factories.OfferFactory(
Expand All @@ -2226,28 +2235,30 @@ def test_headline_offer_on_expired_offer_is_inactive(self):
stocks=[
stock,
],
venue=venue,
)
api.make_offer_headline(offer=offer)
assert offer.is_headline_offer
with time_machine.travel(tomorrow + timedelta(days=1)):
assert not offer.is_headline_offer

def test_headline_offer_on_rejected_offer_is_inactive(self):
offer = factories.OfferFactory(isActive=True)
venue = offerers_factories.VenueFactory(venueTypeCode=VenueTypeCode.LIBRARY)
offer = factories.OfferFactory(isActive=True, venue=venue)
factories.StockFactory(offer=offer)

api.make_offer_headline(offer=offer)
offer.validation = models.OfferValidationStatus.REJECTED
assert not offer.is_headline_offer

def test_set_upper_timespan_of_inactive_headline_offers(self):
venue_1 = offerers_factories.VenueFactory()
venue_1 = offerers_factories.VenueFactory(venueTypeCode=VenueTypeCode.LIBRARY)
offer_1 = factories.OfferFactory(isActive=True, venue=venue_1)
factories.StockFactory(offer=offer_1)
venue_2 = offerers_factories.VenueFactory()
venue_2 = offerers_factories.VenueFactory(venueTypeCode=VenueTypeCode.LIBRARY)
stock = factories.StockFactory(quantity=1)
offer_2 = factories.OfferFactory(isActive=True, venue=venue_2, stocks=[stock])
venue_3 = offerers_factories.VenueFactory()
venue_3 = offerers_factories.VenueFactory(venueTypeCode=VenueTypeCode.LIBRARY)
offer_3 = factories.OfferFactory(isActive=True, venue=venue_3)
factories.StockFactory(offer=offer_3)

Expand All @@ -2273,7 +2284,8 @@ def test_set_upper_timespan_of_inactive_headline_offers(self):
assert headline_offer_3.timespan.upper is None

def test_do_not_update_upper_timespan_of_already_inactive_headline_offers(self):
offer = factories.OfferFactory(isActive=True)
venue = offerers_factories.VenueFactory(venueTypeCode=VenueTypeCode.LIBRARY)
offer = factories.OfferFactory(isActive=True, venue=venue)
factories.StockFactory(offer=offer)
creation_time = datetime.utcnow() - timedelta(days=20)
finished_timespan = (creation_time, creation_time + timedelta(days=10))
Expand Down
Loading

0 comments on commit ebcd1a0

Please sign in to comment.