def update_view(self): url = get_redirect_target() or self.get_url(".index_view") if request.method != "POST": return redirect(url) change_form = OfferChangeForm(request.form) if change_form.validate(): offer_ids: List[str] = change_form.ids.data.split(",") criteria: List[OfferCriterion] = change_form.data["tags"] remove_other_tags = change_form.data["remove_other_tags"] if remove_other_tags: OfferCriterion.query.filter( OfferCriterion.offerId.in_(offer_ids)).delete( synchronize_session=False) offer_criteria: List[OfferCriterion] = [] for criterion in criteria: offer_criteria.extend( OfferCriterion(offerId=offer_id, criterionId=criterion.id) for offer_id in offer_ids if OfferCriterion.query.filter( OfferCriterion.offerId == offer_id, OfferCriterion. criterionId == criterion.id).first() is None) db.session.bulk_save_objects(offer_criteria) db.session.commit() # synchronize with external apis that generate playlists based on tags search.async_index_offer_ids(offer_ids) return redirect(url) # Form didn't validate flash("Le formulaire est invalide: %s" % (change_form.errors), "error") return redirect(url, code=307)
def update_pending_offer_validation( offer: Offer, validation_status: OfferValidationStatus) -> bool: offer = offer_queries.get_offer_by_id(offer.id) if offer.validation != OfferValidationStatus.PENDING: logger.info( "Offer validation status cannot be updated, initial validation status is not PENDING. %s", extra={"offer": offer.id}, ) return False offer.validation = validation_status if validation_status == OfferValidationStatus.APPROVED: offer.isActive = True try: db.session.commit() except Exception as exception: # pylint: disable=broad-except logger.exception( "Could not update offer validation status: %s", extra={ "offer": offer.id, "validation_status": validation_status, "exc": str(exception) }, ) return False search.async_index_offer_ids([offer.id]) logger.info("Offer validation status updated", extra={"offer": offer.id}) return True
def _cancel_booking(booking: Booking, reason: BookingCancellationReasons) -> None: """Cancel booking and update a user's credit information on Batch""" with transaction(): stock = offers_repository.get_and_lock_stock(stock_id=booking.stockId) db.session.refresh(booking) try: booking.cancel_booking() except (BookingIsAlreadyUsed, BookingIsAlreadyCancelled) as e: logger.info( str(e), extra={ "booking": booking.id, "reason": str(reason), }, ) return booking.cancellationReason = reason stock.dnBookedQuantity -= booking.quantity repository.save(booking, stock) logger.info( "Booking has been cancelled", extra={ "booking": booking.id, "reason": str(reason), }, ) if booking.individualBooking is not None: update_external_user(booking.individualBooking.user) search.async_index_offer_ids([booking.stock.offerId])
def after_model_change(self, form: wtforms.Form, offer: Offer, is_created: bool = False) -> None: if hasattr(form, "validation"): previous_validation = form._fields["validation"].object_data new_validation = offer.validation if previous_validation != new_validation: offer.lastValidationDate = datetime.utcnow() if new_validation == OfferValidationStatus.APPROVED: offer.isActive = True if new_validation == OfferValidationStatus.REJECTED: offer.isActive = False cancelled_bookings = cancel_bookings_from_rejected_offer( offer) if cancelled_bookings: send_cancel_booking_notification.delay( [booking.id for booking in cancelled_bookings]) repository.save(offer) recipients = ([ offer.venue.bookingEmail ] if offer.venue.bookingEmail else [ recipient.user.email for recipient in offer.venue.managingOfferer.UserOfferers ]) send_offer_validation_status_update_email( offer, new_validation, recipients) send_offer_validation_notification_to_administration( new_validation, offer) flash("Le statut de l'offre a bien été modifié", "success") search.async_index_offer_ids([offer.id])
def _reindex_offers(created_or_updated_objects): 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) search.async_index_offer_ids(offer_ids)
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) search.async_index_offer_ids({offer.id for offer in offers})
def create_mediation( 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) 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: create_thumb(mediation, image_as_bytes, image_index=0, crop_params=crop_params) except Exception as exc: 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 previous_mediations = (Mediation.query.filter( Mediation.offerId == offer.id).filter( Mediation.id != mediation.id).all()) for previous_mediation in previous_mediations: try: for thumb_index in range(0, previous_mediation.thumbCount): remove_thumb(previous_mediation, image_index=thumb_index) except Exception as exc: # pylint: disable=broad-except logger.exception( "An unexpected error was encountered during the thumbnails deletion for %s: %s", mediation, exc, ) else: repository.delete(previous_mediation) search.async_index_offer_ids([offer.id]) return mediation
def refuse_educational_booking( educational_booking_id: int) -> EducationalBooking: educational_booking = educational_repository.find_educational_booking_by_id( educational_booking_id) if educational_booking is None: raise exceptions.EducationalBookingNotFound() if educational_booking.status == EducationalBookingStatus.REFUSED: return educational_booking with transaction(): stock = offers_repository.get_and_lock_stock( stock_id=educational_booking.booking.stockId) booking = educational_booking.booking db.session.refresh(educational_booking.booking) try: educational_booking.mark_as_refused() except ( exceptions.EducationalBookingNotRefusable, exceptions.EducationalBookingAlreadyCancelled, ) as exception: logger.error( "User from adage trying to refuse educational booking that cannot be refused", extra={ "educational_booking_id": educational_booking_id, "exception_type": exception.__class__.__name__, }, ) raise exception stock.dnBookedQuantity -= booking.quantity repository.save(booking, educational_booking) logger.info( "Booking has been cancelled", extra={ "booking": booking.id, "reason": str(booking.cancellationReason), }, ) search.async_index_offer_ids([stock.offerId]) return educational_booking
def batch_update_offers(query, update_fields): offer_ids_tuples = query.filter( Offer.validation == OfferValidationStatus.APPROVED).with_entities( Offer.id) offer_ids = [offer_id for offer_id, in offer_ids_tuples] number_of_offers_to_update = len(offer_ids) batch_size = 1000 for current_start_index in range(0, number_of_offers_to_update, batch_size): offer_ids_batch = offer_ids[ current_start_index:min(current_start_index + batch_size, number_of_offers_to_update)] query_to_update = Offer.query.filter(Offer.id.in_(offer_ids_batch)) query_to_update.update(update_fields, synchronize_session=False) db.session.commit() search.async_index_offer_ids(offer_ids_batch)
def test_offer_indexation_on_booking_cycle(app): beneficiary = users_factories.BeneficiaryGrant18Factory() stock = offers_factories.StockFactory(quantity=1) offer = stock.offer assert search_testing.search_store["offers"] == {} search.async_index_offer_ids([offer.id]) assert search_testing.search_store["offers"] == {} search.index_offers_in_queue() assert offer.id in search_testing.search_store["offers"] booking = bookings_api.book_offer(beneficiary, stock.id, quantity=1) search.index_offers_in_queue() assert offer.id not in search_testing.search_store["offers"] bookings_api.cancel_booking_by_beneficiary(beneficiary, booking) search.index_offers_in_queue() assert offer.id in search_testing.search_store["offers"]
def deactivate_inappropriate_products(isbn: str) -> bool: products = Product.query.filter( Product.extraData["isbn"].astext == isbn).all() if not products: return False for product in products: product.isGcuCompatible = False db.session.add(product) offers = Offer.query.filter(Offer.productId.in_( p.id for p in products)).filter(Offer.isActive.is_(True)) offer_ids = [ offer_id for offer_id, in offers.with_entities(Offer.id).all() ] offers.update(values={"isActive": False}, synchronize_session="fetch") try: db.session.commit() except Exception as exception: # pylint: disable=broad-except logger.exception( "Could not mark product and offers as inappropriate: %s", extra={ "isbn": isbn, "products": [p.id for p in products], "exc": str(exception) }, ) return False logger.info( "Deactivated inappropriate products", extra={ "isbn": isbn, "products": [p.id for p in products], "offers": offer_ids }, ) search.async_index_offer_ids(offer_ids) return True
def add_criteria_to_offers(criteria: list[Criterion], isbn: Optional[str] = None, visa: Optional[str] = None) -> bool: if not isbn and not visa: return False query = Product.query if isbn: isbn = isbn.replace("-", "").replace(" ", "") query = query.filter(Product.extraData["isbn"].astext == isbn) if visa: query = query.filter(Product.extraData["visa"].astext == visa) products = query.all() if not products: return False offer_ids_query = Offer.query.filter( Offer.productId.in_(p.id for p in products), Offer.isActive.is_(True)).with_entities(Offer.id) offer_ids = [offer_id for offer_id, in offer_ids_query.all()] if not offer_ids: return False offer_criteria: list[OfferCriterion] = [] for criterion in criteria: logger.info("Adding criterion %s to %d offers", criterion, len(offer_ids)) offer_criteria.extend( OfferCriterion(offerId=offer_id, criterionId=criterion.id) for offer_id in offer_ids) db.session.bulk_save_objects(offer_criteria) db.session.commit() search.async_index_offer_ids(offer_ids) return True
def upsert_stocks(offer_id: int, stock_data_list: list[Union[StockCreationBodyModel, StockEditionBodyModel]], user: User) -> list[Stock]: activation_codes = [] 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).options(joinedload( Stock.activationCodes)).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: activation_codes_exist = stock_data.activation_codes is not None and len( stock_data.activation_codes) > 0 # type: ignore[arg-type] if activation_codes_exist: validation.check_offer_is_digital(offer) validation.check_activation_codes_expiration_datetime( stock_data.activation_codes_expiration_datetime, stock_data.booking_limit_datetime, ) quantity = len( stock_data.activation_codes ) if activation_codes_exist else stock_data.quantity # type: ignore[arg-type] created_stock = _create_stock( offer=offer, price=stock_data.price, quantity=quantity, beginning=stock_data.beginning_datetime, booking_limit_datetime=stock_data.booking_limit_datetime, ) if activation_codes_exist: for activation_code in stock_data.activation_codes: # type: ignore[union-attr] activation_codes.append( ActivationCode( code=activation_code, expirationDate=stock_data. activation_codes_expiration_datetime, stock=created_stock, )) stocks.append(created_stock) repository.save(*stocks, *activation_codes) logger.info("Stock has been created or updated", extra={"offer": offer_id}) if offer.validation == OfferValidationStatus.DRAFT: offer.validation = set_offer_status_based_on_fraud_criteria(offer) offer.author = user offer.lastValidationDate = datetime.datetime.utcnow() if offer.validation == OfferValidationStatus.PENDING or offer.validation == OfferValidationStatus.REJECTED: offer.isActive = False repository.save(offer) if offer.validation == OfferValidationStatus.APPROVED: admin_emails.send_offer_creation_notification_to_administration( offer) for stock in edited_stocks: previous_beginning = edited_stocks_previous_beginnings[stock.id] if stock.beginningDatetime != previous_beginning and not stock.offer.isEducational: _notify_beneficiaries_upon_stock_edit(stock) search.async_index_offer_ids([offer.id]) return stocks
def cancel_bookings_when_offerer_deletes_stock(stock: Stock) -> list[Booking]: cancelled_bookings = _cancel_bookings_from_stock(stock, BookingCancellationReasons.OFFERER) search.async_index_offer_ids([stock.offerId]) return cancelled_bookings
def book_educational_offer(redactor_informations: RedactorInformation, stock_id: int) -> EducationalBooking: redactor = educational_repository.find_redactor_by_email( redactor_informations.email) if not redactor: redactor = _create_redactor(redactor_informations) educational_institution = educational_repository.find_educational_institution_by_uai_code( redactor_informations.uai) validation.check_institution_exists(educational_institution) # The call to transaction here ensures we free the FOR UPDATE lock # on the stock if validation issues an exception with transaction(): stock = offers_repository.get_and_lock_stock(stock_id=stock_id) validation.check_stock_is_bookable(stock) educational_year = educational_repository.find_educational_year_by_date( stock.beginningDatetime) validation.check_educational_year_exists(educational_year) educational_booking = EducationalBooking( educationalInstitution=educational_institution, educationalYear=educational_year, educationalRedactor=redactor, confirmationLimitDate=stock.bookingLimitDatetime, ) booking = bookings_models.Booking( educationalBooking=educational_booking, stockId=stock.id, amount=stock.price, token=bookings_repository.generate_booking_token(), venueId=stock.offer.venueId, offererId=stock.offer.venue.managingOffererId, status=bookings_models.BookingStatus.PENDING, ) booking.dateCreated = datetime.utcnow() booking.cancellationLimitDate = compute_cancellation_limit_date( stock.beginningDatetime, booking.dateCreated) stock.dnBookedQuantity += EAC_DEFAULT_BOOKED_QUANTITY repository.save(booking) logger.info( "Redactor booked an educational offer", extra={ "redactor": redactor_informations.email, "offerId": stock.offerId, "stockId": stock.id, "bookingId": booking.id, }, ) if stock.offer.bookingEmail: mails.send(recipients=[stock.offer.bookingEmail], data=_build_prebooking_mail_data(booking)) search.async_index_offer_ids([stock.offerId]) return booking
def book_offer( beneficiary: User, stock_id: int, quantity: int, ) -> Booking: """ Return a booking or raise an exception if it's not possible. Update a user's credit information on Batch. """ # The call to transaction here ensures we free the FOR UPDATE lock # on the stock if validation issues an exception with transaction(): stock = offers_repository.get_and_lock_stock(stock_id=stock_id) validation.check_offer_is_not_educational(stock) 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, quantity) total_amount = quantity * stock.price validation.check_expenses_limits(beneficiary, total_amount, stock.offer) from pcapi.core.offers.api import is_activation_code_applicable # To avoid import loops if is_activation_code_applicable(stock): validation.check_activation_code_available(stock) # 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(), venueId=stock.offer.venueId, offererId=stock.offer.venue.managingOffererId, ) booking.dateCreated = datetime.datetime.utcnow() booking.cancellationLimitDate = compute_cancellation_limit_date(stock.beginningDatetime, booking.dateCreated) if is_activation_code_applicable(stock): booking.activationCode = offers_repository.get_available_activation_code(stock) if FeatureToggle.AUTO_ACTIVATE_DIGITAL_BOOKINGS.is_active(): booking.mark_as_used() individual_booking = IndividualBooking( booking=booking, depositId=beneficiary.deposit.id if beneficiary.has_active_deposit else None, userId=beneficiary.id, ) stock.dnBookedQuantity += booking.quantity repository.save(individual_booking, stock) logger.info( "Beneficiary booked an offer", extra={ "actor": beneficiary.id, "offer": stock.offerId, "stock": stock.id, "booking": booking.id, "used": booking.isUsed, }, ) try: user_emails.send_individual_booking_confirmation_email_to_offerer(individual_booking) except MailServiceException as error: logger.exception("Could not send booking=%s confirmation email to offerer: %s", booking.id, error) try: user_emails.send_individual_booking_confirmation_email_to_beneficiary(individual_booking) except MailServiceException as error: logger.exception("Could not send booking=%s confirmation email to beneficiary: %s", booking.id, error) search.async_index_offer_ids([stock.offerId]) update_external_user(individual_booking.user) return individual_booking.booking
def update_offer( offer: Offer, bookingEmail: str = UNCHANGED, description: str = UNCHANGED, isNational: bool = UNCHANGED, name: str = UNCHANGED, extraData: dict = UNCHANGED, externalTicketOfficeUrl: 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, audioDisabilityCompliant: bool = UNCHANGED, mentalDisabilityCompliant: bool = UNCHANGED, motorDisabilityCompliant: bool = UNCHANGED, visualDisabilityCompliant: bool = UNCHANGED, ) -> Offer: validation.check_validation_status(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 if offer.isFromProvider: validation.check_update_only_allowed_fields_for_offer_from_provider( set(modifications), offer.lastProvider) 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) logger.info("Offer has been updated", extra={"offer": offer.id}) if product_has_been_updated: repository.save(offer.product) logger.info("Product has been updated", extra={"product": offer.product.id}) search.async_index_offer_ids([offer.id]) return offer