def create_mediation( user: User, offer: Offer, credit: str, image_as_bytes: bytes, crop_params=None, ) -> Mediation: validation.check_mediation_thumb_quality(image_as_bytes) mediation = Mediation( author=user, offer=offer, credit=credit, ) # `create_thumb()` requires the object to have an id, so we must save now. repository.save(mediation) create_thumb(mediation, image_as_bytes, image_index=0, crop_params=crop_params) mediation.thumbCount = 1 repository.save(mediation) if feature_queries.is_active(FeatureToggle.SYNCHRONIZE_ALGOLIA): redis.add_offer_id(client=app.redis_client, offer_id=offer.id) return mediation
def add_criterion_to_offers(criterion_name: str, isbns: Iterable[str], batch_size: int = 100) -> None: isbns = [isbn.replace("-", "").replace(" ", "") for isbn in isbns] criterion = Criterion.query.filter_by(name=criterion_name).one() offers = Offer.query.filter( Offer.extraData["isbn"].astext.in_(isbns), Offer.isActive.is_(True), ).all() if not offers: logger.info("Did not match any offer: double-check the ISBN's") return logger.info("Adding criterion %s to %d offers", criterion, len(offers)) offer_criteria = [] for offer in offers: offer_criteria.append(OfferCriterion(offer=offer, criterion=criterion)) if len(offer_criteria) > batch_size: repository.save(*offer_criteria) offer_criteria = [] repository.save(*offer_criteria) logger.info("Reindexing %d offers after addition of criterion %s", len(offers), criterion_name) for offer in offers: redis.add_offer_id(client=app.redis_client, offer_id=offer.id)
def cancel_booking_by_beneficiary(user: User, booking: Booking) -> None: if not user.isBeneficiary: raise RuntimeError( "Unexpected call to cancel_booking_by_beneficiary with non-beneficiary user %s" % user) validation.check_beneficiary_can_cancel_booking(user, booking) booking.isCancelled = True booking.cancellationReason = BookingCancellationReasons.BENEFICIARY repository.save(booking) notifier = MailjetNotificationService() notifier.send_booking_cancellation_emails_to_user_and_offerer( booking=booking, reason=booking.cancellationReason, # FIXME: we should not have to pass this argument. # Notification-related code should be reorganized. send_email=send_raw_email, ) # FIXME: why do we do that when the booking is cancelled by the # *beneficiary*, but not when it's cancelled by the *offerer* (see # cancel_booking_by_offerer)? if feature_queries.is_active(FeatureToggle.SYNCHRONIZE_ALGOLIA): redis.add_offer_id(client=app.redis_client, offer_id=booking.stock.offerId)
def create_stock( offer: models.Offer, price: float, quantity: int = None, beginning: datetime.datetime = None, booking_limit_datetime: datetime.datetime = None, ) -> Stock: """Return the new stock or raise an exception if it's not possible.""" validation.check_required_dates_for_stock(offer, beginning, booking_limit_datetime) validation.check_offer_is_editable(offer) validation.check_stocks_are_editable_for_offer(offer) stock = models.Stock( offer=offer, price=price, quantity=quantity, beginningDatetime=beginning, bookingLimitDatetime=booking_limit_datetime, ) repository.save(stock) if feature_queries.is_active(FeatureToggle.SYNCHRONIZE_ALGOLIA): redis.add_offer_id(client=app.redis_client, offer_id=offer.id) return stock
def delete_stock(stock: Stock) -> None: validation.check_stock_is_deletable(stock) stock.isSoftDeleted = True cancelled_bookings = [] for booking in stock.bookings: if not booking.isCancelled and not booking.isUsed: booking.isCancelled = True cancelled_bookings.append(booking) repository.save(stock, *cancelled_bookings) if cancelled_bookings: try: user_emails.send_batch_cancellation_emails_to_users(cancelled_bookings, mailing.send_raw_email) except mailing.MailServiceException as exc: app.logger.exception("Could not notify beneficiaries about deletion of stock=%s: %s", stock.id, exc) try: user_emails.send_offerer_bookings_recap_email_after_offerer_cancellation( cancelled_bookings, mailing.send_raw_email ) except mailing.MailServiceException as exc: app.logger.exception("Could not notify offerer about deletion of stock=%s: %s", stock.id, exc) if feature_queries.is_active(FeatureToggle.SYNCHRONIZE_ALGOLIA): redis.add_offer_id(client=app.redis_client, offer_id=stock.offerId)
def deactivate_inappropriate_offers(offer_ids: List[int]): offers = Offer.query.filter(Offer.id.in_(offer_ids)).all() for o in offers: o.isActive = False o.product.isGcuCompatible = False repository.save(*offers) for o in offers: redis.add_offer_id(client=app.redis_client, offer_id=o.id)
def update_mediation(mediation: Mediation, is_active: bool) -> Mediation: mediation.isActive = is_active repository.save(mediation) if feature_queries.is_active(FeatureToggle.SYNCHRONIZE_ALGOLIA): redis.add_offer_id(client=app.redis_client, offer_id=mediation.offerId) return mediation
def move_all_offers_from_venue_to_other_venue(origin_venue_id: str, destination_venue_id: str) -> None: origin_venue = Venue.query.filter_by(id=origin_venue_id).one() offers = origin_venue.offers for o in offers: o.venueId = destination_venue_id repository.save(*offers) for o in offers: redis.add_offer_id(client=app.redis_client, offer_id=o.id)
def create_mediation_v2( user: User, offer: Offer, credit: str, image_as_bytes: bytes, crop_params: tuple = None, ) -> Mediation: # checks image type, min dimensions validation.check_image(image_as_bytes) existing_mediations = mediation_queries.get_mediations_for_offers( [offer.id]) mediation = Mediation( author=user, offer=offer, credit=credit, ) # `create_thumb()` requires the object to have an id, so we must save now. repository.save(mediation) try: # TODO(fseguin): cleanup after image upload v2 launch create_thumb(mediation, image_as_bytes, image_index=0, crop_params=crop_params, use_v2=True) except Exception as exc: app.logger.exception( "An unexpected error was encountered during the thumbnail creation: %s", exc) # I could not use savepoints and rollbacks with SQLA repository.delete(mediation) raise ThumbnailStorageError else: mediation.thumbCount = 1 repository.save(mediation) # cleanup former thumbnails and mediations for previous_mediation in existing_mediations: try: for thumb_index in range(0, mediation.thumbCount): remove_thumb(previous_mediation, image_index=thumb_index) except Exception as exc: # pylint: disable=broad-except app.logger.exception( "An unexpected error was encountered during the thumbnails deletion for %s: %s", mediation, exc, ) else: repository.delete(previous_mediation) if feature_queries.is_active(FeatureToggle.SYNCHRONIZE_ALGOLIA): redis.add_offer_id(client=app.redis_client, offer_id=offer.id) return mediation
def cancel_booking_by_offerer(booking: Booking) -> None: validation.check_offerer_can_cancel_booking(booking) booking.isCancelled = True booking.cancellationReason = BookingCancellationReasons.OFFERER repository.save(booking) if feature_queries.is_active(FeatureToggle.SYNCHRONIZE_ALGOLIA): redis.add_offer_id(client=app.redis_client, offer_id=booking.stock.offerId)
def test_should_add_offer_id(self): # Given client = MagicMock() client.rpush = MagicMock() # When add_offer_id(client=client, offer_id=1) # Then client.rpush.assert_called_once_with("offer_ids", 1)
def update_offer( # pylint: disable=redefined-builtin offer: Offer, bookingEmail: str = UNCHANGED, description: str = UNCHANGED, isNational: bool = UNCHANGED, name: str = UNCHANGED, extraData: dict = UNCHANGED, type: str = UNCHANGED, url: str = UNCHANGED, withdrawalDetails: str = UNCHANGED, isActive: bool = UNCHANGED, isDuo: bool = UNCHANGED, durationMinutes: int = UNCHANGED, mediaUrls: List[str] = UNCHANGED, ageMin: int = UNCHANGED, ageMax: int = UNCHANGED, conditions: str = UNCHANGED, venueId: str = UNCHANGED, productId: str = UNCHANGED, ) -> Offer: # fmt: off modifications = { field: new_value for field, new_value in locals().items() if field != 'offer' and new_value is not UNCHANGED # has the user provided a value for this field and getattr(offer, field) != new_value # is the value different from what we have on database? } # fmt: on if not modifications: return offer validation.check_offer_is_editable(offer) if offer.isFromAllocine: validation.check_update_only_allowed_offer_fields_for_allocine_offer(set(modifications)) offer.populate_from_dict(modifications) if offer.product.owningOfferer and offer.product.owningOfferer == offer.venue.managingOfferer: offer.product.populate_from_dict(modifications) product_has_been_updated = True else: product_has_been_updated = False if offer.isFromAllocine: offer.fieldsUpdated = list(set(offer.fieldsUpdated) | set(modifications)) repository.save(offer) if product_has_been_updated: repository.save(offer.product) if feature_queries.is_active(FeatureToggle.SYNCHRONIZE_ALGOLIA): redis.add_offer_id(client=app.redis_client, offer_id=offer.id) return offer
def book_offer( beneficiary: User, stock: Stock, quantity: int, ) -> Booking: """Return a booking or raise an exception if it's not possible.""" validation.check_can_book_free_offer(beneficiary, stock) validation.check_offer_already_booked(beneficiary, stock.offer) validation.check_quantity(stock.offer, quantity) validation.check_stock_is_bookable(stock) total_amount = quantity * stock.price validation.check_expenses_limits(beneficiary, total_amount, stock.offer) # FIXME (dbaty, 2020-10-20): if we directly set relations (for # example with `booking.user = beneficiary`) instead of foreign keys, # the session tries to add the object when `get_user_expenses()` # is called because autoflush is enabled. As such, the PostgreSQL # exceptions (tooManyBookings and insufficientFunds) may raise at # this point and will bubble up. If we want them to be caught, we # have to set foreign keys, so that the session is NOT autoflushed # in `get_user_expenses` and is only committed in `repository.save()` # where exceptions are caught. Since we are using flask-sqlalchemy, # I don't think that we should use autoflush, nor should we use # the `pcapi.repository.repository` module. booking = Booking( userId=beneficiary.id, stockId=stock.id, amount=stock.price, quantity=quantity, token=generate_booking_token(), ) booking.dateCreated = datetime.datetime.utcnow() booking.confirmationDate = compute_confirmation_date( stock.beginningDatetime, booking.dateCreated) repository.save(booking) try: user_emails.send_booking_recap_emails(booking) except MailServiceException as error: logger.exception( "Could not send booking=%s confirmation email to offerer: %s", booking.id, error) try: user_emails.send_booking_confirmation_email_to_beneficiary(booking) except MailServiceException as error: logger.exception( "Could not send booking=%s confirmation email to beneficiary: %s", booking.id, error) if feature_queries.is_active(FeatureToggle.SYNCHRONIZE_ALGOLIA): redis.add_offer_id(client=app.redis_client, offer_id=stock.offerId) return booking
def update_offers_active_status(query, is_active): # We cannot just call `query.update()` because `distinct()` may # already have been called on `query`. query_to_update = Offer.query.filter(Offer.id.in_(query.with_entities(Offer.id))) query_to_update.update({"isActive": is_active}, synchronize_session=False) db.session.commit() if feature_queries.is_active(FeatureToggle.SYNCHRONIZE_ALGOLIA): offer_ids = {offer_id for offer_id, in query.with_entities(Offer.id)} for offer_id in offer_ids: redis.add_offer_id(client=app.redis_client, offer_id=offer_id)
def _reindex_offers(created_or_updated_objects): if not feature_queries.is_active(FeatureToggle.SYNCHRONIZE_ALGOLIA): return offer_ids = set() for obj in created_or_updated_objects: if isinstance(obj, Stock): offer_ids.add(obj.offerId) elif isinstance(obj, Offer): offer_ids.add(obj.id) for offer_id in offer_ids: redis.add_offer_id(client=app.redis_client, offer_id=offer_id)
def upsert_stocks( offer_id: int, stock_data_list: List[Union[StockCreationBodyModel, StockEditionBodyModel]] ) -> List[Stock]: stocks = [] edited_stocks = [] edited_stocks_previous_beginnings = {} offer = offer_queries.get_offer_by_id(offer_id) for stock_data in stock_data_list: if isinstance(stock_data, StockEditionBodyModel): stock = Stock.queryNotSoftDeleted().filter_by( id=stock_data.id).first_or_404() if stock.offerId != offer_id: errors = ApiErrors() errors.add_error( "global", "Vous n'avez pas les droits d'accès suffisant pour accéder à cette information." ) errors.status_code = 403 raise errors edited_stocks_previous_beginnings[ stock.id] = stock.beginningDatetime edited_stock = _edit_stock( stock, price=stock_data.price, quantity=stock_data.quantity, beginning=stock_data.beginning_datetime, booking_limit_datetime=stock_data.booking_limit_datetime, ) edited_stocks.append(edited_stock) stocks.append(edited_stock) else: created_stock = _create_stock( offer=offer, price=stock_data.price, quantity=stock_data.quantity, beginning=stock_data.beginning_datetime, booking_limit_datetime=stock_data.booking_limit_datetime, ) stocks.append(created_stock) repository.save(*stocks) for stock in edited_stocks: previous_beginning = edited_stocks_previous_beginnings[stock.id] if stock.beginningDatetime != previous_beginning: _notify_beneficiaries_upon_stock_edit(stock) if feature_queries.is_active(FeatureToggle.SYNCHRONIZE_ALGOLIA): redis.add_offer_id(client=app.redis_client, offer_id=offer.id) return stocks
def cancel_booking_by_beneficiary(user: User, booking: Booking) -> None: if not user.isBeneficiary: raise RuntimeError( "Unexpected call to cancel_booking_by_beneficiary with non-beneficiary user %s" % user) validation.check_beneficiary_can_cancel_booking(user, booking) booking.isCancelled = True booking.cancellationReason = BookingCancellationReasons.BENEFICIARY repository.save(booking) try: user_emails.send_booking_cancellation_emails_to_user_and_offerer( booking, booking.cancellationReason) except MailServiceException as error: logger.exception( "Could not send booking=%s cancellation emails to user and offerer: %s", booking.id, error) if feature_queries.is_active(FeatureToggle.SYNCHRONIZE_ALGOLIA): redis.add_offer_id(client=app.redis_client, offer_id=booking.stock.offerId)
def create_booking_for_user_on_specific_stock_bypassing_capping_limits( user_id: int, stock_id: int) -> None: stock = Stock.query.get(stock_id) user = User.query.get(user_id) quantity = 1 validation.check_offer_already_booked(user, stock.offer) validation.check_quantity(stock.offer, quantity) validation.check_can_book_free_offer(user, stock) validation.check_stock_is_bookable(stock) booking = models.Booking() # FIXME: this is not right. PcObject's constructor should allow # `Booking(stock=stock, ...)` booking.userId = user.id booking.stockId = stock.id booking.amount = stock.price booking.quantity = quantity booking.token = random_token() repository.save(booking) redis.add_offer_id(client=app.redis_client, offer_id=stock.offerId)
def on_model_change(self, form: Form, offer: Offer, is_created: bool = False) -> None: redis.add_offer_id(client=app.redis_client, offer_id=offer.id)
def edit_stock( stock: Stock, price: int = None, quantity: int = None, beginning: datetime.datetime = None, booking_limit_datetime: datetime.datetime = None, ) -> Stock: validation.check_stock_is_updatable(stock) validation.check_required_dates_for_stock(stock.offer, beginning, booking_limit_datetime) # FIXME (dbaty, 2020-11-25): We need this ugly workaround because # the frontend sends us datetimes like "2020-12-03T14:00:00Z" # (note the "Z" suffix). Pydantic deserializes it as a datetime # *with* a timezone. However, datetimes are stored in the database # as UTC datetimes *without* any timezone. Thus, we wrongly detect # a change for the "beginningDatetime" field for Allocine stocks: # because we do not allow it to be changed, we raise an error when # we should not. def as_utc_without_timezone(d: datetime.datetime) -> datetime.datetime: return d.astimezone(pytz.utc).replace(tzinfo=None) if beginning: beginning = as_utc_without_timezone(beginning) if booking_limit_datetime: booking_limit_datetime = as_utc_without_timezone(booking_limit_datetime) updates = { "price": price, "quantity": quantity, "beginningDatetime": beginning, "bookingLimitDatetime": booking_limit_datetime, } if stock.offer.isFromAllocine: # fmt: off updated_fields = { attr for attr, new_value in updates.items() if new_value != getattr(stock, attr) } # fmt: on validation.check_update_only_allowed_stock_fields_for_allocine_offer(updated_fields) stock.fieldsUpdated = list(updated_fields) previous_beginning = stock.beginningDatetime for model_attr, value in updates.items(): setattr(stock, model_attr, value) repository.save(stock) if beginning != previous_beginning: bookings = bookings_repository.find_not_cancelled_bookings_by_stock(stock) if bookings: bookings = update_confirmation_dates(bookings, beginning) date_in_two_days = datetime.datetime.utcnow() + datetime.timedelta(days=2) check_event_is_in_more_than_48_hours = beginning > date_in_two_days if check_event_is_in_more_than_48_hours: bookings = _invalidate_bookings(bookings) try: user_emails.send_batch_stock_postponement_emails_to_users(bookings, send_email=mailing.send_raw_email) except mailing.MailServiceException as exc: # fmt: off app.logger.exception( "Could not notify beneficiaries about update of stock=%s: %s", stock.id, exc, ) # fmt: on if feature_queries.is_active(FeatureToggle.SYNCHRONIZE_ALGOLIA): redis.add_offer_id(client=app.redis_client, offer_id=stock.offerId) return stock