Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

(PC-33423)[API] feat: add status isActive to headline_offer #15647

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion api/alembic_version_conflict_detection.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
f8588023c126 (pre) (head)
b18478ab2ea8 (post) (head)
4c3be4ff5274 (post) (head)
Original file line number Diff line number Diff line change
@@ -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,
)
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
6 changes: 2 additions & 4 deletions api/src/pcapi/core/offerers/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
)

Expand Down
40 changes: 40 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,44 @@ 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()

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.commit()


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
6 changes: 6 additions & 0 deletions api/src/pcapi/core/offers/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
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")
9 changes: 4 additions & 5 deletions api/src/pcapi/core/offers/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -268,6 +263,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
49 changes: 40 additions & 9 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 @@ -517,17 +518,47 @@ 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)
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 @@ -614,8 +645,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")
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
Loading
Loading