Skip to content

Commit

Permalink
(PC-33423)[API] feat: add timespan to headline_offer
Browse files Browse the repository at this point in the history
  • Loading branch information
ogeber-pass committed Jan 6, 2025
1 parent fda8427 commit d9870eb
Show file tree
Hide file tree
Showing 11 changed files with 340 additions and 11 deletions.
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 @@
f8588023c126 (pre) (head)
4c53718279e7 (post) (head)
4c3be4ff5274 (post) (head)
Original file line number Diff line number Diff line change
@@ -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()'
)
2 changes: 1 addition & 1 deletion api/src/pcapi/core/offerers/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
32 changes: 32 additions & 0 deletions api/src/pcapi/core/offers/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down
15 changes: 15 additions & 0 deletions api/src/pcapi/core/offers/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
4 changes: 4 additions & 0 deletions api/src/pcapi/core/offers/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
39 changes: 35 additions & 4 deletions api/src/pcapi/core/offers/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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):
Expand Down
14 changes: 12 additions & 2 deletions api/src/pcapi/core/offers/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand Down
137 changes: 137 additions & 0 deletions api/tests/core/offers/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading

0 comments on commit d9870eb

Please sign in to comment.