From 95b2fa5baa372e5d83a861ebe009607442e62efd Mon Sep 17 00:00:00 2001 From: ogeber <144006742+ogeber@users.noreply.github.com> Date: Fri, 20 Dec 2024 10:27:25 +0100 Subject: [PATCH 1/5] (PC-33423)[API] feat: migration remove unicity constraints --- api/alembic_version_conflict_detection.txt | 2 +- ...drop_headline_offer_unicity_constraints.py | 77 +++++++++++++++++++ api/src/pcapi/core/offers/models.py | 10 +-- 3 files changed, 83 insertions(+), 6 deletions(-) create mode 100644 api/src/pcapi/alembic/versions/20241220T091708_4c53718279e7_drop_headline_offer_unicity_constraints.py diff --git a/api/alembic_version_conflict_detection.txt b/api/alembic_version_conflict_detection.txt index 79870a1dfb6..281d4bd2091 100644 --- a/api/alembic_version_conflict_detection.txt +++ b/api/alembic_version_conflict_detection.txt @@ -1,2 +1,2 @@ f8588023c126 (pre) (head) -b18478ab2ea8 (post) (head) +4c53718279e7 (post) (head) diff --git a/api/src/pcapi/alembic/versions/20241220T091708_4c53718279e7_drop_headline_offer_unicity_constraints.py b/api/src/pcapi/alembic/versions/20241220T091708_4c53718279e7_drop_headline_offer_unicity_constraints.py new file mode 100644 index 00000000000..ef984a9f1ab --- /dev/null +++ b/api/src/pcapi/alembic/versions/20241220T091708_4c53718279e7_drop_headline_offer_unicity_constraints.py @@ -0,0 +1,77 @@ +""" +Drop headline offer unicity constraints on offer and venue +""" + +from alembic import op + + +# pre/post deployment: post +# revision identifiers, used by Alembic. +revision = "4c53718279e7" +down_revision = "b18478ab2ea8" +branch_labels: tuple[str] | None = None +depends_on: list[str] | None = None + + +def upgrade() -> None: + with op.get_context().autocommit_block(): + op.drop_index( + "ix_headline_offer_offerId", + table_name="headline_offer", + postgresql_concurrently=True, + if_exists=True, + ) + op.create_index( + op.f("ix_headline_offer_offerId"), + "headline_offer", + ["offerId"], + unique=False, + postgresql_concurrently=True, + if_not_exists=True, + ) + op.drop_index( + "ix_headline_offer_venueId", + table_name="headline_offer", + postgresql_concurrently=True, + if_exists=True, + ) + op.create_index( + op.f("ix_headline_offer_venueId"), + "headline_offer", + ["venueId"], + unique=False, + postgresql_concurrently=True, + if_not_exists=True, + ) + + +def downgrade() -> None: + with op.get_context().autocommit_block(): + op.drop_index( + op.f("ix_headline_offer_venueId"), + table_name="headline_offer", + postgresql_concurrently=True, + if_exists=True, + ) + op.create_index( + "ix_headline_offer_venueId", + "headline_offer", + ["venueId"], + unique=True, + postgresql_concurrently=True, + if_not_exists=True, + ) + op.drop_index( + op.f("ix_headline_offer_offerId"), + table_name="headline_offer", + postgresql_concurrently=True, + if_exists=True, + ) + op.create_index( + "ix_headline_offer_offerId", + "headline_offer", + ["offerId"], + unique=True, + postgresql_concurrently=True, + if_not_exists=True, + ) diff --git a/api/src/pcapi/core/offers/models.py b/api/src/pcapi/core/offers/models.py index 1d1409f7d7a..4afcf50d1e7 100644 --- a/api/src/pcapi/core/offers/models.py +++ b/api/src/pcapi/core/offers/models.py @@ -517,10 +517,10 @@ class HeadlineOffer(PcObject, Base, Model): __tablename__ = "headline_offer" offerId: int = sa.Column( - sa.BigInteger, sa.ForeignKey("offer.id", ondelete="CASCADE"), nullable=False, index=True, unique=True + sa.BigInteger, sa.ForeignKey("offer.id", ondelete="CASCADE"), nullable=False, index=True, unique=False ) - offer: sa_orm.Mapped["Offer"] = sa_orm.relationship("Offer", back_populates="headlineOffer") - venueId: int = sa.Column(sa.BigInteger, sa.ForeignKey("venue.id"), nullable=False, index=True, unique=True) + offer: sa_orm.Mapped["Offer"] = sa_orm.relationship("Offer", back_populates="headlineOffers") + venueId: int = sa.Column(sa.BigInteger, sa.ForeignKey("venue.id"), nullable=False, index=True, unique=False) venue: sa_orm.Mapped["Venue"] = sa_orm.relationship("Venue", back_populates="headlineOffers") dateCreated: datetime.datetime = sa.Column(sa.DateTime, nullable=False, default=datetime.datetime.utcnow) @@ -614,8 +614,8 @@ def __table_args__(self): reactions: list["Reaction"] = sa.orm.relationship( "Reaction", back_populates="offer", uselist=True, cascade="all, delete-orphan", passive_deletes=True ) - headlineOffer: sa_orm.Mapped["HeadlineOffer"] = sa_orm.relationship( - "HeadlineOffer", back_populates="offer", uselist=False + headlineOffers: sa_orm.Mapped[list["HeadlineOffer"]] = sa_orm.relationship( + "HeadlineOffer", back_populates="offer", uselist=True, cascade="all, delete-orphan", passive_deletes=True ) sa.Index("idx_offer_trgm_name", name, postgresql_using="gin") From ba8dadcccd9905fd0f44ed3cd0a07446a96676b0 Mon Sep 17 00:00:00 2001 From: ogeber <144006742+ogeber@users.noreply.github.com> Date: Mon, 23 Dec 2024 16:36:06 +0100 Subject: [PATCH 2/5] (PC-33423)[API] feat: add timespan to headline_offer --- api/alembic_version_conflict_detection.txt | 2 +- ...e4ff5274_add_timespan_on_headline_offer.py | 42 ++++++ api/src/pcapi/core/offerers/models.py | 2 +- api/src/pcapi/core/offers/api.py | 32 ++++ api/src/pcapi/core/offers/exceptions.py | 15 ++ api/src/pcapi/core/offers/factories.py | 4 + api/src/pcapi/core/offers/models.py | 39 ++++- api/src/pcapi/core/offers/repository.py | 14 +- api/tests/core/offers/test_api.py | 137 ++++++++++++++++++ api/tests/core/offers/test_models.py | 54 +++++++ .../routes/pro/get_offerer_stats_test.py | 10 +- 11 files changed, 340 insertions(+), 11 deletions(-) create mode 100644 api/src/pcapi/alembic/versions/20241220T095131_4c3be4ff5274_add_timespan_on_headline_offer.py diff --git a/api/alembic_version_conflict_detection.txt b/api/alembic_version_conflict_detection.txt index 281d4bd2091..cc28230cf50 100644 --- a/api/alembic_version_conflict_detection.txt +++ b/api/alembic_version_conflict_detection.txt @@ -1,2 +1,2 @@ f8588023c126 (pre) (head) -4c53718279e7 (post) (head) +4c3be4ff5274 (post) (head) diff --git a/api/src/pcapi/alembic/versions/20241220T095131_4c3be4ff5274_add_timespan_on_headline_offer.py b/api/src/pcapi/alembic/versions/20241220T095131_4c3be4ff5274_add_timespan_on_headline_offer.py new file mode 100644 index 00000000000..f4685195364 --- /dev/null +++ b/api/src/pcapi/alembic/versions/20241220T095131_4c3be4ff5274_add_timespan_on_headline_offer.py @@ -0,0 +1,42 @@ +""" +add timespan on headline_offer table +""" + +from alembic import op + + +# pre/post deployment: post +# revision identifiers, used by Alembic. +revision = "4c3be4ff5274" +down_revision = "4c53718279e7" +branch_labels: tuple[str] | None = None +depends_on: list[str] | None = None + + +def upgrade() -> None: + op.execute('ALTER TABLE headline_offer DROP COLUMN IF EXISTS "dateUpdated"') + op.execute('ALTER TABLE headline_offer DROP COLUMN IF EXISTS "dateCreated"') + + op.execute('ALTER TABLE headline_offer ADD COLUMN IF NOT EXISTS "timespan" TSRANGE NOT NULL') + + op.execute("ALTER TABLE headline_offer DROP CONSTRAINT IF EXISTS exclude_offer_timespan") + op.execute( + 'ALTER TABLE headline_offer ADD CONSTRAINT exclude_offer_timespan EXCLUDE USING gist ("offerId" WITH =, timespan WITH &&)' + ) + + op.execute("ALTER TABLE headline_offer DROP CONSTRAINT IF EXISTS exclude_venue_timespan") + op.execute( + 'ALTER TABLE headline_offer ADD CONSTRAINT exclude_venue_timespan EXCLUDE USING gist ("venueId" WITH =, timespan WITH &&)' + ) + + +def downgrade() -> None: + op.execute("ALTER TABLE headline_offer DROP CONSTRAINT IF EXISTS exclude_offer_timespan") + op.execute("ALTER TABLE headline_offer DROP CONSTRAINT IF EXISTS exclude_venue_timespan") + op.execute('ALTER TABLE headline_offer DROP COLUMN IF EXISTS "timespan"') + op.execute( + 'ALTER TABLE headline_offer ADD COLUMN IF NOT EXISTS "dateCreated" TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT now()' + ) + op.execute( + 'ALTER TABLE headline_offer ADD COLUMN IF NOT EXISTS "dateUpdated" TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT now()' + ) diff --git a/api/src/pcapi/core/offerers/models.py b/api/src/pcapi/core/offerers/models.py index 9841b5780c3..8d92f1803fd 100644 --- a/api/src/pcapi/core/offerers/models.py +++ b/api/src/pcapi/core/offerers/models.py @@ -726,7 +726,7 @@ def is_caledonian(self) -> bool: @property def has_headline_offer(self) -> bool: - return bool(self.headlineOffers) + return any(headline_offer.isActive for headline_offer in self.headlineOffers) class GooglePlacesInfo(PcObject, Base, Model): diff --git a/api/src/pcapi/core/offers/api.py b/api/src/pcapi/core/offers/api.py index a03a1023a5a..bf8f6d6f21f 100644 --- a/api/src/pcapi/core/offers/api.py +++ b/api/src/pcapi/core/offers/api.py @@ -64,12 +64,14 @@ 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 from pcapi.repository import on_commit from pcapi.repository import repository from pcapi.repository import transaction +from pcapi.utils import db as db_utils from pcapi.utils import image_conversion import pcapi.utils.cinema_providers as cinema_providers_utils from pcapi.utils.custom_keys import get_field @@ -698,6 +700,36 @@ def activate_future_offers(publication_date: datetime.datetime | None = None) -> batch_update_offers(query, {"isActive": True}) +def make_offer_headline(offer: models.Offer) -> models.HeadlineOffer: + if offer.status != OfferStatus.ACTIVE: + raise exceptions.InactiveOfferCanNotBeHeadline() + + try: + headline_offer = models.HeadlineOffer(offer=offer, venue=offer.venue, timespan=(datetime.datetime.utcnow(),)) + db.session.add(headline_offer) + # Note: We use flush and not commit to be compliant with atomic. At this moment, + # the timespan is a str because the __init__ overloaded method of HeadlineOffer calls + # make_timerange which transforms timespan into a str using .isoformat. Thus, you will get + # a TypeError if you try to access the isActive property of this headline_offer object + # before any session commit. To fix this error, you need to commit your session + # as the TSRANGE object saves the timespan as a datetime in the database + db.session.flush() + except sqla_exc.IntegrityError as error: + db.session.rollback() + if "exclude_offer_timespan" in str(error.orig): + raise exceptions.OfferHasAlreadyAnActiveHeadlineOffer + if "exclude_venue_timespan" in str(error.orig): + raise exceptions.VenueHasAlreadyAnActiveHeadlineOffer + raise error + + return headline_offer + + +def remove_headline_offer(headline_offer: models.HeadlineOffer) -> None: + headline_offer.timespan = db_utils.make_timerange(headline_offer.timespan.lower, datetime.datetime.utcnow()) + db.session.flush() + + def _notify_pro_upon_stock_edit_for_event_offer(stock: models.Stock, bookings: list[bookings_models.Booking]) -> None: if stock.offer.isEvent: transactional_mails.send_event_offer_postponement_confirmation_email_to_pro(stock, len(bookings)) diff --git a/api/src/pcapi/core/offers/exceptions.py b/api/src/pcapi/core/offers/exceptions.py index 669b5dbb41f..fca0aa09095 100644 --- a/api/src/pcapi/core/offers/exceptions.py +++ b/api/src/pcapi/core/offers/exceptions.py @@ -378,3 +378,18 @@ class AllNullContactRequestDataError(CollectiveOfferContactRequestError): class UrlandFormBothSetError(CollectiveOfferContactRequestError): msg = "Url and form can not both be used" fields = "url,form" + + +class InactiveOfferCanNotBeHeadline(Exception): + def __init__(self) -> None: + super().__init__("headlineOffer", "This offer is inactive and can not be made headline") + + +class OfferHasAlreadyAnActiveHeadlineOffer(Exception): + def __init__(self) -> None: + super().__init__("headlineOffer", "This offer is already an active headline offer") + + +class VenueHasAlreadyAnActiveHeadlineOffer(Exception): + def __init__(self) -> None: + super().__init__("headlineOffer", "This venue has already an active headline offer") diff --git a/api/src/pcapi/core/offers/factories.py b/api/src/pcapi/core/offers/factories.py index 0e8928b6d57..b033b39efa2 100644 --- a/api/src/pcapi/core/offers/factories.py +++ b/api/src/pcapi/core/offers/factories.py @@ -268,6 +268,10 @@ class HeadlineOfferFactory(BaseFactory): class Meta: model = models.HeadlineOffer + offer = factory.SubFactory(OfferFactory) + venue = factory.SelfAttribute("offer.venue") + timespan = (datetime.datetime.utcnow(),) + class PriceCategoryLabelFactory(BaseFactory): class Meta: diff --git a/api/src/pcapi/core/offers/models.py b/api/src/pcapi/core/offers/models.py index 4afcf50d1e7..1e85e4fa1df 100644 --- a/api/src/pcapi/core/offers/models.py +++ b/api/src/pcapi/core/offers/models.py @@ -6,6 +6,7 @@ import typing from flask_sqlalchemy import BaseQuery +import psycopg2.extras import sqlalchemy as sa from sqlalchemy.dialects import postgresql import sqlalchemy.exc as sa_exc @@ -523,11 +524,41 @@ class HeadlineOffer(PcObject, Base, Model): venueId: int = sa.Column(sa.BigInteger, sa.ForeignKey("venue.id"), nullable=False, index=True, unique=False) venue: sa_orm.Mapped["Venue"] = sa_orm.relationship("Venue", back_populates="headlineOffers") - dateCreated: datetime.datetime = sa.Column(sa.DateTime, nullable=False, default=datetime.datetime.utcnow) - dateUpdated: datetime.datetime = sa.Column( - sa.DateTime, nullable=False, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow + timespan: psycopg2.extras.DateTimeRange = sa.Column(postgresql.TSRANGE, nullable=False) + + __table_args__ = ( + # One Offer can have only one active Headline Offer at a time + # To do so, we check that there are no overlaping HeadlineOffer for one Offer + # If a timespan has no upper limit, it is the active headline offer for this offer (see property below) + postgresql.ExcludeConstraint((offerId, "="), (timespan, "&&"), name="exclude_offer_timespan"), + # Likewise, for now one venue can only have one active headline offer + postgresql.ExcludeConstraint((venueId, "="), (timespan, "&&"), name="exclude_venue_timespan"), ) + def __init__(self, **kwargs: typing.Any) -> None: + kwargs["timespan"] = db_utils.make_timerange(*kwargs["timespan"]) + super().__init__(**kwargs) + + @hybrid_property + def isActive(self) -> bool: + now = datetime.datetime.utcnow() + return ( + (self.timespan.upper is None or self.timespan.upper > now) + and self.timespan.lower <= now + and self.offer.status == OfferStatus.ACTIVE + ) + + @isActive.expression # type: ignore[no-redef] + def isActive(cls) -> bool: # pylint: disable=no-self-argument + now = datetime.datetime.utcnow() + offer_alias = sa_orm.aliased(Offer) # avoids cartesian product + return sa.and_( + sa.or_(sa.func.upper(cls.timespan) == None, (sa.func.upper(cls.timespan) > now)), + sa.func.lower(cls.timespan) <= now, + offer_alias.id == cls.offerId, + offer_alias.status == OfferStatus.ACTIVE, + ) + class Offer(PcObject, Base, Model, DeactivableMixin, ValidationMixin, AccessibilityMixin): __tablename__ = "offer" @@ -988,7 +1019,7 @@ def fullAddress(self) -> str | None: @property def is_headline_offer(self) -> bool: - return bool(self.headlineOffer) + return any(headline_offer.isActive for headline_offer in self.headlineOffers) class ActivationCode(PcObject, Base, Model): diff --git a/api/src/pcapi/core/offers/repository.py b/api/src/pcapi/core/offers/repository.py index 336c34e3a77..e41250173a3 100644 --- a/api/src/pcapi/core/offers/repository.py +++ b/api/src/pcapi/core/offers/repository.py @@ -108,7 +108,7 @@ def get_capped_offers_for_filters( models.Offer.extraData, models.Offer.lastProviderId, models.Offer.offererAddressId, - ).joinedload(models.Offer.headlineOffer) + ).joinedload(models.Offer.headlineOffers) ) .options( sa_orm.joinedload(models.Offer.venue) @@ -209,6 +209,16 @@ def get_offers_data_from_top_offers(top_offers: list[dict]) -> list[dict]: models.Mediation.credit, ) ) + .options(sa_orm.joinedload(models.Offer.headlineOffers)) + .options( + sa_orm.joinedload(models.Offer.stocks).load_only( + models.Stock.quantity, + models.Stock.isSoftDeleted, + models.Stock.beginningDatetime, + models.Stock.dnBookedQuantity, + models.Stock.bookingLimitDatetime, + ) + ) .options( sa_orm.joinedload(models.Offer.product) .load_only( @@ -1140,7 +1150,7 @@ def get_offer_by_id(offer_id: int, load_options: OFFER_LOAD_OPTIONS = ()) -> mod if "product" in load_options: query = query.options(sa_orm.joinedload(models.Offer.product).joinedload(models.Product.productMediations)) if "headline_offer" in load_options: - query = query.options(sa_orm.joinedload(models.Offer.headlineOffer)) + query = query.options(sa_orm.joinedload(models.Offer.headlineOffers)) if "price_category" in load_options: query = query.options( sa_orm.joinedload(models.Offer.priceCategories).joinedload(models.PriceCategory.priceCategoryLabel) diff --git a/api/tests/core/offers/test_api.py b/api/tests/core/offers/test_api.py index 42f6ac9deee..1308d2cf975 100644 --- a/api/tests/core/offers/test_api.py +++ b/api/tests/core/offers/test_api.py @@ -2103,6 +2103,143 @@ def test_activate_future_offers(self, mocked_async_index_offer_ids): assert set(mocked_async_index_offer_ids.call_args[0][0]) == set([offer.id]) +@pytest.mark.usefixtures("db_session") +class HeadlineOfferTest: + def test_make_new_offer_headline(self): + offer = factories.OfferFactory(isActive=True) + factories.StockFactory(offer=offer) + headline_offer = api.make_offer_headline(offer=offer) + db.session.commit() # see comment in make_offer_headline() + + assert offer.is_headline_offer + assert headline_offer.isActive + assert headline_offer.timespan.lower + assert not headline_offer.timespan.upper + + def test_create_offer_headline_when_another_is_still_active_should_fail(self): + offer = factories.OfferFactory(isActive=True) + 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() + offer_1 = factories.OfferFactory(isActive=True, venue=venue) + factories.StockFactory(offer=offer_1) + offer_2 = factories.OfferFactory(isActive=True, venue=venue) + factories.StockFactory(offer=offer_2) + + api.make_offer_headline(offer=offer_1) + assert venue.has_headline_offer + + with pytest.raises(exceptions.VenueHasAlreadyAnActiveHeadlineOffer) as error: + api.make_offer_headline(offer=offer_2) + assert error.value.errors["headlineOffer"] == ["This venue has already an active headline offer"] + + assert offer_1.is_headline_offer + assert not offer_2.is_headline_offer + assert venue.has_headline_offer + + def test_create_offer_headline_when_another_is_still_active_should_fail(self): + offer = factories.OfferFactory(isActive=True) + 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) + factories.StockFactory(offer=offer) + headline_offer = factories.HeadlineOfferFactory(offer=offer) + + api.remove_headline_offer(headline_offer) + db.session.commit() # see comment in make_offer_headline() + + assert headline_offer.timespan.upper + assert not headline_offer.isActive + assert not offer.is_headline_offer + + @time_machine.travel("2024-12-13 15:44:00") + def test_make_offer_headline_again(self): + offer = factories.OfferFactory(isActive=True) + factories.StockFactory(offer=offer) + creation_time = datetime.utcnow() + finished_timespan = (creation_time, creation_time + timedelta(days=10)) + old_headline_offer = factories.HeadlineOfferFactory(offer=offer, timespan=finished_timespan) + + one_eternity_later = creation_time + timedelta(days=1000) + with time_machine.travel(one_eternity_later): + new_headline_offer = api.make_offer_headline(offer=offer) + db.session.commit() # see comment in make_offer_headline() + assert offer.is_headline_offer + assert not old_headline_offer.isActive + assert new_headline_offer.isActive + assert new_headline_offer.timespan.lower.date() != creation_time.date() + assert new_headline_offer.timespan.upper == None + + @time_machine.travel("2024-12-13 15:44:00") + def test_make_another_offer_headline_on_same_venue(self): + venue = offerers_factories.VenueFactory() + offer_1 = factories.OfferFactory(isActive=True, venue=venue) + factories.StockFactory(offer=offer_1) + offer_2 = factories.OfferFactory(isActive=True, venue=venue) + factories.StockFactory(offer=offer_2) + + ten_days_ago = datetime.utcnow() - timedelta(days=10) + finished_timespan = (ten_days_ago, ten_days_ago + timedelta(days=1)) + old_headline_offer = factories.HeadlineOfferFactory(offer=offer_1, timespan=finished_timespan) + new_headline_offer = api.make_offer_headline(offer=offer_2) + db.session.commit() # see comment in make_offer_headline() + assert not old_headline_offer.isActive + assert new_headline_offer.isActive + assert not offer_1.is_headline_offer + assert offer_2.is_headline_offer + assert venue.has_headline_offer + + def test_headline_offer_on_offer_turned_inactive_is_inactive(self): + active_offer = factories.OfferFactory(isActive=True) + factories.StockFactory(offer=active_offer) + + api.make_offer_headline(offer=active_offer) + + active_offer.isActive = False + assert not active_offer.is_headline_offer + + def test_headline_offer_on_sold_out_offer_is_inactive(self): + stock = factories.StockFactory(quantity=10) + offer = factories.OfferFactory(isActive=True, stocks=[stock]) + 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): + tomorrow = date.today() + timedelta(days=1) + stock = factories.StockFactory(bookingLimitDatetime=tomorrow) + offer = factories.OfferFactory( + validation=models.OfferValidationStatus.APPROVED, + isActive=True, + stocks=[ + stock, + ], + ) + 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) + factories.StockFactory(offer=offer) + + api.make_offer_headline(offer=offer) + offer.validation = models.OfferValidationStatus.REJECTED + assert not offer.is_headline_offer + @pytest.mark.usefixtures("db_session") class OfferExpenseDomainsTest: def test_offer_expense_domains(self): diff --git a/api/tests/core/offers/test_models.py b/api/tests/core/offers/test_models.py index 2597c15a2d6..252fdfade95 100644 --- a/api/tests/core/offers/test_models.py +++ b/api/tests/core/offers/test_models.py @@ -1,6 +1,8 @@ import datetime import pytest +from sqlalchemy import exc +import time_machine import pcapi.core.bookings.constants as bookings_constants import pcapi.core.bookings.factories as bookings_factories @@ -695,3 +697,55 @@ def test_full_address(self, label, street, expected_full_address): ) offer = factories.OfferFactory(offererAddress=oa) assert offer.fullAddress == expected_full_address + + +class HeadlineOfferTest: + today = datetime.datetime.utcnow() + tomorrow = today + datetime.timedelta(days=1) + day_after_tomorrow = today + datetime.timedelta(days=2) + next_month = today + datetime.timedelta(days=30) + + def test_headline_offer_is_active(self): + offer = factories.OfferFactory(isActive=True) + factories.StockFactory(offer=offer) + headline_offer = factories.HeadlineOfferFactory(offer=offer, timespan=(self.today, None)) + assert headline_offer.isActive + + def test_headline_offer_with_ending_time_in_the_future_is_active(self): + offer = factories.OfferFactory(isActive=True) + factories.StockFactory(offer=offer) + headline_offer = factories.HeadlineOfferFactory(offer=offer, timespan=(self.today, self.day_after_tomorrow)) + assert headline_offer.isActive + + def test_headline_offer_is_not_active(self): + offer = factories.OfferFactory(isActive=True) + factories.StockFactory(offer=offer) + headline_offer = factories.HeadlineOfferFactory(offer=offer, timespan=(self.today, self.day_after_tomorrow)) + with time_machine.travel(self.next_month): + assert not headline_offer.isActive + + @pytest.mark.parametrize( + "timespan,overlaping_timespan", + [ + ((today, None), (tomorrow, None)), + ((today, None), (tomorrow, next_month)), + ((today, day_after_tomorrow), (tomorrow, None)), + ((today, day_after_tomorrow), (tomorrow, next_month)), + ], + ) + def test_unicity_headline_offer(self, timespan, overlaping_timespan): + offer = factories.OfferFactory(isActive=True) + factories.StockFactory(offer=offer) + factories.HeadlineOfferFactory(offer=offer, timespan=timespan) + with pytest.raises(exc.IntegrityError): + factories.HeadlineOfferFactory(offer=offer, timespan=overlaping_timespan) + + def test_unicity_headline_offer_by_venue(self): + venue = offerers_factories.VenueFactory() + offer = factories.OfferFactory(isActive=True, venue=venue) + another_offer_on_the_same_venue = factories.OfferFactory(isActive=True, venue=venue) + factories.StockFactory(offer=offer) + factories.StockFactory(offer=another_offer_on_the_same_venue) + factories.HeadlineOfferFactory(offer=offer) + with pytest.raises(exc.IntegrityError): + factories.HeadlineOfferFactory(offer=another_offer_on_the_same_venue) diff --git a/api/tests/routes/pro/get_offerer_stats_test.py b/api/tests/routes/pro/get_offerer_stats_test.py index 8851c3029ed..93bda277aa7 100644 --- a/api/tests/routes/pro/get_offerer_stats_test.py +++ b/api/tests/routes/pro/get_offerer_stats_test.py @@ -1,3 +1,5 @@ +import datetime + import pytest import time_machine @@ -25,7 +27,10 @@ def test_get_offerer_stats(self, client): offer_2 = offers_factories.OfferFactory(venue__managingOffererId=offerer.id) mediation = offers_factories.MediationFactory(offer=offer_2) offer_3 = offers_factories.OfferFactory(venue__managingOffererId=offerer.id) - offers_factories.HeadlineOfferFactory(offer=offer_2, venue=offer_2.venue) + offers_factories.StockFactory(offer=offer_2) + offers_factories.HeadlineOfferFactory( + offer=offer_2, venue=offer_2.venue, timespan=(datetime.datetime.utcnow(),) + ) offerers_factories.OffererStatsFactory( offerer=offerer, @@ -73,8 +78,7 @@ def test_get_offerer_stats(self, client): queries = testing.AUTHENTICATION_QUERIES queries += 1 # check user_offerer exists queries += 1 # select offerer_stats - queries += 1 # select offers with images - queries += 3 # determine if headline offers + queries += 1 # select offers with images, join on headline offer & stocks with testing.assert_num_queries(queries): response = client.get(f"/offerers/{offerer_id}/stats") assert response.status_code == 200 From 70115466a109f99d27d7e95bcb9db5f1ba2696bc Mon Sep 17 00:00:00 2001 From: ogeber <144006742+ogeber@users.noreply.github.com> Date: Mon, 23 Dec 2024 16:37:16 +0100 Subject: [PATCH 3/5] (PC-33423)[API] feat: add command to update inactive headline offer upper timespan --- api/src/pcapi/core/offers/api.py | 8 +++ api/src/pcapi/core/offers/commands.py | 6 ++ api/src/pcapi/core/offers/repository.py | 26 ++++++++ api/tests/core/offers/test_api.py | 43 +++++++++++++ api/tests/core/offers/test_repository.py | 80 ++++++++++++++++++++++++ 5 files changed, 163 insertions(+) diff --git a/api/src/pcapi/core/offers/api.py b/api/src/pcapi/core/offers/api.py index bf8f6d6f21f..fc44315bff9 100644 --- a/api/src/pcapi/core/offers/api.py +++ b/api/src/pcapi/core/offers/api.py @@ -700,6 +700,14 @@ def activate_future_offers(publication_date: datetime.datetime | None = None) -> batch_update_offers(query, {"isActive": True}) +def set_upper_timespan_of_inactive_headline_offers() -> None: + inactive_headline_offers = offers_repository.get_inactive_headline_offers() + for headline_offer in inactive_headline_offers: + headline_offer.timespan = db_utils.make_timerange(headline_offer.timespan.lower, datetime.datetime.utcnow()) + + db.session.commit() + + def make_offer_headline(offer: models.Offer) -> models.HeadlineOffer: if offer.status != OfferStatus.ACTIVE: raise exceptions.InactiveOfferCanNotBeHeadline() diff --git a/api/src/pcapi/core/offers/commands.py b/api/src/pcapi/core/offers/commands.py index de79db94a4b..370bddcb246 100644 --- a/api/src/pcapi/core/offers/commands.py +++ b/api/src/pcapi/core/offers/commands.py @@ -10,3 +10,9 @@ @log_cron_with_transaction def activate_future_offers() -> None: offers_api.activate_future_offers() + + +@blueprint.cli.command("set_upper_timespan_of_inactive_headline_offers") +@log_cron_with_transaction +def set_upper_timespan_of_inactive_headline_offers() -> None: + offers_api.set_upper_timespan_of_inactive_headline_offers() diff --git a/api/src/pcapi/core/offers/repository.py b/api/src/pcapi/core/offers/repository.py index e41250173a3..9f4ce6a2a46 100644 --- a/api/src/pcapi/core/offers/repository.py +++ b/api/src/pcapi/core/offers/repository.py @@ -1127,6 +1127,32 @@ def get_offer_reaction_count_subquery() -> sa.sql.selectable.ScalarSelect: ) +def get_active_headline_offer(offer_id: int) -> models.HeadlineOffer | None: + return ( + models.HeadlineOffer.query.join(models.Offer) + .filter( + models.HeadlineOffer.offerId == offer_id, + 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) + .filter(models.Offer.status != offer_mixin.OfferStatus.ACTIVE) + .filter( + # We don't want to fetch HeadlineOffers that have already been marked as finished + sa.or_( + sa.func.upper(models.HeadlineOffer.timespan).is_(None), + sa.func.upper(models.HeadlineOffer.timespan) > datetime.datetime.utcnow(), + ), + ) + .all() + ) + + def get_product_reaction_count_subquery() -> sa.sql.selectable.ScalarSelect: return ( sa.select(sa.func.count(reactions_models.Reaction.id)) diff --git a/api/tests/core/offers/test_api.py b/api/tests/core/offers/test_api.py index 1308d2cf975..a60e2b77898 100644 --- a/api/tests/core/offers/test_api.py +++ b/api/tests/core/offers/test_api.py @@ -2240,6 +2240,49 @@ def test_headline_offer_on_rejected_offer_is_inactive(self): 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() + offer_1 = factories.OfferFactory(isActive=True, venue=venue_1) + factories.StockFactory(offer=offer_1) + venue_2 = offerers_factories.VenueFactory() + stock = factories.StockFactory(quantity=1) + offer_2 = factories.OfferFactory(isActive=True, venue=venue_2, stocks=[stock]) + venue_3 = offerers_factories.VenueFactory() + offer_3 = factories.OfferFactory(isActive=True, venue=venue_3) + factories.StockFactory(offer=offer_3) + + headline_offer_1 = factories.HeadlineOfferFactory(offer=offer_1) + headline_offer_2 = factories.HeadlineOfferFactory(offer=offer_2) + headline_offer_3 = factories.HeadlineOfferFactory(offer=offer_3) + + assert headline_offer_1.isActive + assert headline_offer_1.timespan.upper is None + assert headline_offer_2.isActive + assert headline_offer_2.timespan.upper is None + + offer_1.validation = models.OfferValidationStatus.REJECTED + stock.quantity = 0 + + api.set_upper_timespan_of_inactive_headline_offers() + assert not headline_offer_1.isActive + assert not headline_offer_1.timespan.upper is None + assert not headline_offer_2.isActive + assert not headline_offer_2.timespan.upper is None + + assert headline_offer_3.isActive + 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) + factories.StockFactory(offer=offer) + creation_time = datetime.utcnow() - timedelta(days=20) + finished_timespan = (creation_time, creation_time + timedelta(days=10)) + old_headline_offer = factories.HeadlineOfferFactory(offer=offer, timespan=finished_timespan) + api.set_upper_timespan_of_inactive_headline_offers() + assert old_headline_offer.timespan.lower.date() == creation_time.date() + assert old_headline_offer.timespan.upper.date() == (creation_time + timedelta(days=10)).date() + + @pytest.mark.usefixtures("db_session") class OfferExpenseDomainsTest: def test_offer_expense_domains(self): diff --git a/api/tests/core/offers/test_repository.py b/api/tests/core/offers/test_repository.py index 1392c53608a..4e1c2677de6 100644 --- a/api/tests/core/offers/test_repository.py +++ b/api/tests/core/offers/test_repository.py @@ -2588,3 +2588,83 @@ def test_should_return_no_result(self): # Then assert len(price_categories.all()) == 0 + + +@pytest.mark.usefixtures("db_session") +class GetHeadlineOfferFiltersTest: + def test_get_headline_offer_basic(self): + offer = factories.OfferFactory(isActive=True) + factories.StockFactory(offer=offer) + headline_offer = factories.HeadlineOfferFactory(offer=offer) + + headline_offer_query_result = repository.get_active_headline_offer(offer.id) + assert headline_offer_query_result == headline_offer + + def test_get_only_active_headline_offer(self): + offer = factories.OfferFactory(isActive=True) + factories.StockFactory(offer=offer) + creation_time = datetime.datetime.utcnow() - datetime.timedelta(days=20) + finished_timespan = (creation_time, creation_time + datetime.timedelta(days=10)) + headline_offer = factories.HeadlineOfferFactory(offer=offer) + factories.HeadlineOfferFactory(offer=offer, timespan=finished_timespan) + + headline_offer_query_result = repository.get_active_headline_offer(offer.id) + assert headline_offer_query_result == headline_offer + + def test_get_specific_offer_active_headline_offer(self): + offer = factories.OfferFactory(isActive=True) + offer_on_another_venue = factories.OfferFactory(isActive=True) + factories.StockFactory(offer=offer) + factories.StockFactory(offer=offer_on_another_venue) + factories.HeadlineOfferFactory(offer=offer) + factories.HeadlineOfferFactory(offer=offer_on_another_venue) + + headline_offer_query_result = repository.get_active_headline_offer(offer.id) + assert headline_offer_query_result.offer == offer + + def test_should_return_no_inactive_headline_offer(self): + offer = factories.OfferFactory(isActive=False) + factories.StockFactory(offer=offer) + factories.HeadlineOfferFactory(offer=offer) + creation_time = datetime.datetime.utcnow() - datetime.timedelta(days=20) + finished_timespan = (creation_time, creation_time + datetime.timedelta(days=10)) + factories.HeadlineOfferFactory(offer=offer, timespan=finished_timespan) + + headline_offer_query_result = repository.get_active_headline_offer(offer.id) + assert headline_offer_query_result == None + + def test_get_inactive_headline_offers_basic(self): + inactive_offer = factories.OfferFactory(isActive=False) + inactive_offer_headline_offer = factories.HeadlineOfferFactory(offer=inactive_offer) + + active_offer = factories.OfferFactory(isActive=True) + factories.StockFactory(offer=active_offer) + active_offer_headline_offer = factories.HeadlineOfferFactory(offer=active_offer) + + finished_timespan = ( + datetime.datetime.utcnow() - datetime.timedelta(days=20), + datetime.datetime.utcnow() - datetime.timedelta(days=10), + ) + already_inactive_offer_headline_offer = factories.HeadlineOfferFactory( + offer=active_offer, timespan=finished_timespan + ) + + another_active_offer = factories.OfferFactory(isActive=True) + timespan_finishing_in_the_future = ( + datetime.datetime.utcnow() - datetime.timedelta(days=3), + datetime.datetime.utcnow() + datetime.timedelta(days=3), + ) + soon_to_be_inactive_timespan = factories.HeadlineOfferFactory( + offer=another_active_offer, timespan=timespan_finishing_in_the_future + ) + + headline_offer_query_result = repository.get_inactive_headline_offers() + assert headline_offer_query_result == [inactive_offer_headline_offer] + + def test_get_inactive_headline_offers_empty_result(self): + offer = factories.OfferFactory(isActive=True) + factories.StockFactory(offer=offer) + factories.HeadlineOfferFactory(offer=offer) + + headline_offer_query_result = repository.get_inactive_headline_offers() + assert headline_offer_query_result == [] From 39ef7407120f0702ca3c066fb787ba530ddd04f2 Mon Sep 17 00:00:00 2001 From: ogeber <144006742+ogeber@users.noreply.github.com> Date: Thu, 2 Jan 2025 15:39:47 +0100 Subject: [PATCH 4/5] (PC-33423)[API] feat: change headline offer creation in the sandbox --- api/src/pcapi/core/offers/factories.py | 5 --- .../scripts/creators/industrial/__init__.py | 5 +++ .../create_industrial_event_offers.py | 4 -- .../create_industrial_headline_offers.py | 37 +++++++++++++++++++ .../create_industrial_thing_offers.py | 5 --- 5 files changed, 42 insertions(+), 14 deletions(-) create mode 100644 api/src/pcapi/sandboxes/scripts/creators/industrial/create_industrial_headline_offers.py diff --git a/api/src/pcapi/core/offers/factories.py b/api/src/pcapi/core/offers/factories.py index b033b39efa2..051489aac11 100644 --- a/api/src/pcapi/core/offers/factories.py +++ b/api/src/pcapi/core/offers/factories.py @@ -217,11 +217,6 @@ def _create( return super()._create(model_class, *args, **kwargs) - @factory.post_generation - def is_headline_offer(self, create: bool, is_headline_offer: bool = False, **kwargs: typing.Any) -> None: - if is_headline_offer: - HeadlineOfferFactory(offer=self, venue=self.venue) - class ArtistProductLinkFactory(BaseFactory): class Meta: diff --git a/api/src/pcapi/sandboxes/scripts/creators/industrial/__init__.py b/api/src/pcapi/sandboxes/scripts/creators/industrial/__init__.py index 2549ccf7f46..db7500e562a 100644 --- a/api/src/pcapi/sandboxes/scripts/creators/industrial/__init__.py +++ b/api/src/pcapi/sandboxes/scripts/creators/industrial/__init__.py @@ -23,6 +23,9 @@ from pcapi.sandboxes.scripts.creators.industrial.create_industrial_event_occurrences import * from pcapi.sandboxes.scripts.creators.industrial.create_industrial_event_offers import * from pcapi.sandboxes.scripts.creators.industrial.create_industrial_event_stocks import * +from pcapi.sandboxes.scripts.creators.industrial.create_industrial_headline_offers import ( + create_industrial_headline_offers, +) from pcapi.sandboxes.scripts.creators.industrial.create_industrial_incidents import create_industrial_incidents from pcapi.sandboxes.scripts.creators.industrial.create_industrial_individual_offerers import ( create_industrial_individual_offerers, @@ -102,6 +105,8 @@ def save_industrial_sandbox() -> None: create_industrial_mediations(offers_by_name) + create_industrial_headline_offers(offers_by_name) + criteria_by_name = create_industrial_criteria() associate_criterion_to_one_offer_with_mediation(offers_by_name, criteria_by_name) diff --git a/api/src/pcapi/sandboxes/scripts/creators/industrial/create_industrial_event_offers.py b/api/src/pcapi/sandboxes/scripts/creators/industrial/create_industrial_event_offers.py index 73cc7b8b74f..bc0c8cd6972 100644 --- a/api/src/pcapi/sandboxes/scripts/creators/industrial/create_industrial_event_offers.py +++ b/api/src/pcapi/sandboxes/scripts/creators/industrial/create_industrial_event_offers.py @@ -34,7 +34,6 @@ def create_industrial_event_offers( continue event_venue = event_venues[0] - headline_offer_limit_per_offerer = 1 for venue_event_index in range(0, EVENTS_PER_OFFERER_WITH_PHYSICAL_VENUE): event_subcategory_index = (venue_event_index + event_index) % len(event_subcategories) @@ -59,11 +58,8 @@ def create_industrial_event_offers( ), isActive=is_active, isDuo=is_duo, - is_headline_offer=bool(headline_offer_limit_per_offerer and not event_venue.has_headline_offer), ) offer_index += 1 - # FIXME : 6.12.2024 ogeber : decrement headline_offer_limit_per_offerer (limit 0) if original limit is > 1 - headline_offer_limit_per_offerer = 0 event_index += EVENTS_PER_OFFERER_WITH_PHYSICAL_VENUE diff --git a/api/src/pcapi/sandboxes/scripts/creators/industrial/create_industrial_headline_offers.py b/api/src/pcapi/sandboxes/scripts/creators/industrial/create_industrial_headline_offers.py new file mode 100644 index 00000000000..ac273d9f565 --- /dev/null +++ b/api/src/pcapi/sandboxes/scripts/creators/industrial/create_industrial_headline_offers.py @@ -0,0 +1,37 @@ +import logging + +import pcapi.core.offers.factories as offers_factories +from pcapi.core.offers.models import Offer +from pcapi.models.offer_mixin import OfferStatus + + +logger = logging.getLogger(__name__) + +HEADLINE_OFFER_LIMIT_PER_OFFERER = 1 + + +def create_industrial_headline_offers(offers_by_name: dict[str, Offer]) -> None: + logger.info("create_industrial_headline_offers") + + headline_offer_limit_per_offerer = {} + offerers = {offer.venue.managingOfferer.name: offer.venue.managingOfferer for offer in offers_by_name.values()} + for offerer_name in offerers.keys(): + headline_offer_limit_per_offerer[offerer_name] = HEADLINE_OFFER_LIMIT_PER_OFFERER + + headline_offers_by_name = {} + for offer_name, offer in offers_by_name.items(): + offerer_name = offer.venue.managingOfferer.name + if ( + headline_offer_limit_per_offerer[offerer_name] + and offer.status == OfferStatus.ACTIVE + and not offer.venue.has_headline_offer + ): + headline_offers_by_name[offer_name] = offers_factories.HeadlineOfferFactory(offer=offer, venue=offer.venue) + + headline_offer_limit_per_offerer[offerer_name] = ( + headline_offer_limit_per_offerer[offerer_name] - 1 + if headline_offer_limit_per_offerer[offerer_name] > 0 + else headline_offer_limit_per_offerer[offerer_name] + ) + + logger.info("created %d headline offers", len(headline_offers_by_name)) diff --git a/api/src/pcapi/sandboxes/scripts/creators/industrial/create_industrial_thing_offers.py b/api/src/pcapi/sandboxes/scripts/creators/industrial/create_industrial_thing_offers.py index 8d99d38df1d..a59f2c08fbb 100644 --- a/api/src/pcapi/sandboxes/scripts/creators/industrial/create_industrial_thing_offers.py +++ b/api/src/pcapi/sandboxes/scripts/creators/industrial/create_industrial_thing_offers.py @@ -34,8 +34,6 @@ def create_industrial_thing_offers( physical_venue_name = virtual_venue.name.replace(" (Offre numérique)", "") physical_venue = venues_by_name.get(physical_venue_name) - headline_offer_limit_per_offerer = 1 - for venue_thing_index in range(0, THINGS_PER_OFFERER): thing_venue = None subcategory_index = (venue_thing_index + thing_index) % len(thing_subcategories) @@ -65,12 +63,9 @@ def create_industrial_thing_offers( url="http://example.com" if subcategory.is_online_only else None, idAtProvider=str(id_at_provider), extraData=offers_factories.build_extra_data_from_subcategory(subcategory.id, set_all_fields=False), - is_headline_offer=bool(headline_offer_limit_per_offerer and not thing_venue.has_headline_offer), ) offer_index += 1 id_at_provider += 1 - # FIXME : 6.12.2024 ogeber : decrement headline_offer_limit_per_offerer (limit 0) if original limit is > 1 - headline_offer_limit_per_offerer = 0 thing_index += THINGS_PER_OFFERER From 0b97ef68b6eb4ab0a3d6a6dbbd0dde8c5a253496 Mon Sep 17 00:00:00 2001 From: ogeber <144006742+ogeber@users.noreply.github.com> Date: Tue, 24 Dec 2024 16:08:13 +0100 Subject: [PATCH 5/5] (PC-33423)[API] feat: filter on status in GET offerer headline_offer routes --- api/src/pcapi/core/offerers/repository.py | 6 ++---- api/tests/core/offerers/test_repository.py | 3 +++ api/tests/routes/native/v1/offerers_test.py | 1 + api/tests/routes/pro/get_offerer_headline_offer_test.py | 4 ++++ 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/api/src/pcapi/core/offerers/repository.py b/api/src/pcapi/core/offerers/repository.py index fe7d29a44b2..ecabc1ecf93 100644 --- a/api/src/pcapi/core/offerers/repository.py +++ b/api/src/pcapi/core/offerers/repository.py @@ -886,18 +886,16 @@ def get_offerer_address_of_offerer(offerer_id: int, offerer_address_id: int) -> def get_offerer_headline_offer(offerer_id: int) -> offers_models.Offer | None: try: - # FIXME: ogeber: when offers will be able to have several headline offers, and unicity of the headline - # offer will be on its active status, change this query and add a filter on active headline offer only offer = ( offers_models.Offer.query.join(models.Venue, offers_models.Offer.venueId == models.Venue.id) .join(models.Offerer, models.Venue.managingOffererId == models.Offerer.id) .join(offers_models.HeadlineOffer, offers_models.HeadlineOffer.offerId == offers_models.Offer.id) .options( - sqla_orm.contains_eager(offers_models.Offer.headlineOffer), + sqla_orm.contains_eager(offers_models.Offer.headlineOffers), sqla_orm.joinedload(offers_models.Offer.mediations), sqla_orm.joinedload(offers_models.Offer.product).joinedload(offers_models.Product.productMediations), ) - .filter(models.Offerer.id == offerer_id) + .filter(models.Offerer.id == offerer_id, offers_models.HeadlineOffer.isActive == True) .one_or_none() ) diff --git a/api/tests/core/offerers/test_repository.py b/api/tests/core/offerers/test_repository.py index 4ae99092a4f..1e187107b4a 100644 --- a/api/tests/core/offerers/test_repository.py +++ b/api/tests/core/offerers/test_repository.py @@ -276,6 +276,7 @@ def test_get_offerer_addresses(self): class GetOffererHeadlineOfferTest: def test_return_headline_offer(self): offer = offers_factories.OfferFactory() + offers_factories.StockFactory(offer=offer) offers_factories.HeadlineOfferFactory(offer=offer, venue=offer.venue) headline_offer = repository.get_offerer_headline_offer(offer.venue.managingOffererId) @@ -287,7 +288,9 @@ def test_shoud_not_return_several_headline_offer(self): venue = offerers_factories.VenueFactory(managingOfferer=offerer) other_venue = offerers_factories.VenueFactory(managingOfferer=offerer) offer = offers_factories.OfferFactory(venue=venue) + offers_factories.StockFactory(offer=offer) other_offer = offers_factories.OfferFactory(venue=other_venue) + offers_factories.StockFactory(offer=other_offer) offers_factories.HeadlineOfferFactory(offer=offer, venue=venue) offers_factories.HeadlineOfferFactory(offer=other_offer, venue=other_venue) diff --git a/api/tests/routes/native/v1/offerers_test.py b/api/tests/routes/native/v1/offerers_test.py index 1cdee3cf6b0..0e10eb67ae0 100644 --- a/api/tests/routes/native/v1/offerers_test.py +++ b/api/tests/routes/native/v1/offerers_test.py @@ -211,6 +211,7 @@ def test_get_offerer_headline_offer_success(self, client): offerer = user_offerer.offerer venue = offerers_factories.VenueFactory(managingOfferer=offerer) offer = offers_factories.OfferFactory(venue=venue) + offers_factories.StockFactory(offer=offer) offers_factories.HeadlineOfferFactory(offer=offer, venue=venue) client = client.with_session_auth(email=pro.email) diff --git a/api/tests/routes/pro/get_offerer_headline_offer_test.py b/api/tests/routes/pro/get_offerer_headline_offer_test.py index b43cc9cdd9b..92930ef3252 100644 --- a/api/tests/routes/pro/get_offerer_headline_offer_test.py +++ b/api/tests/routes/pro/get_offerer_headline_offer_test.py @@ -24,6 +24,7 @@ def test_get_offerer_headline_offer_success(self, client): offerer = user_offerer.offerer venue = offerers_factories.VenueFactory(managingOfferer=offerer) offer = offers_factories.OfferFactory(venue=venue) + offers_factories.StockFactory(offer=offer) offers_factories.HeadlineOfferFactory(offer=offer, venue=venue) client = client.with_session_auth(email=pro.email) offerer_id = offerer.id @@ -52,6 +53,7 @@ def test_get_offerer_headline_offer_with_product_mediations(self, client): offers_factories.ProductMediationFactory(product=product, imageType=TiteliveImageType.VERSO) offer = offers_factories.OfferFactory(venue=venue, product=product) + offers_factories.StockFactory(offer=offer) offers_factories.HeadlineOfferFactory(offer=offer, venue=venue) client = client.with_session_auth(email=pro.email) offerer_id = offerer.id @@ -91,9 +93,11 @@ def test_with_multiple_headline_offer_on_one_offerer_should_fail(self, client): offerer = user_offerer.offerer venue = offerers_factories.VenueFactory(managingOfferer=offerer) offer = offers_factories.OfferFactory(venue=venue) + offers_factories.StockFactory(offer=offer) offers_factories.HeadlineOfferFactory(offer=offer, venue=venue) other_venue = offerers_factories.VenueFactory(managingOfferer=offerer) other_offer = offers_factories.OfferFactory(venue=other_venue) + offers_factories.StockFactory(offer=other_offer) offers_factories.HeadlineOfferFactory(offer=other_offer, venue=other_venue) client = client.with_session_auth(email=pro.email) offerer_id = offerer.id