From fa60bac7cf7e887fae9c8be14ea03f71f525cd09 Mon Sep 17 00:00:00 2001 From: Thibault Coudray Date: Tue, 15 Oct 2024 17:43:33 +0200 Subject: [PATCH] (PC-31915)[API] feat: add `AddressLocation` option in `OfferCreation` serializer --- api/documentation/static/openapi.json | 80 ++++++- api/src/pcapi/core/offers/api.py | 4 +- .../documentation_constants/descriptions.py | 10 + .../public/documentation_constants/fields.py | 3 + .../public/individual_offers/v1/events.py | 17 +- .../public/individual_offers/v1/products.py | 77 ++++++- .../individual_offers/v1/serialization.py | 68 +++++- .../public/individual_offers/v1/utils.py | 33 ++- .../v1/patch_product_test.py | 3 +- .../individual_offers/v1/post_event_test.py | 99 +++++++++ .../v1/post_product_by_ean_test.py | 115 +++++++--- .../individual_offers/v1/post_product_test.py | 205 ++++++++---------- 12 files changed, 525 insertions(+), 189 deletions(-) diff --git a/api/documentation/static/openapi.json b/api/documentation/static/openapi.json index 0a44dd3dad7..2561340da40 100644 --- a/api/documentation/static/openapi.json +++ b/api/documentation/static/openapi.json @@ -865,6 +865,46 @@ "title": "AccessibilityResponse", "type": "object" }, + "AddressLocation": { + "description": "If your offer location is different from the venue location", + "properties": { + "addressId": { + "description": "Address id in the pass Culture DB", + "example": 1, + "title": "Addressid", + "type": "integer" + }, + "addressLabel": { + "description": "Address label", + "example": "Z\u00e9nith Paris", + "maxLength": 200, + "minLength": 1, + "nullable": true, + "title": "Addresslabel", + "type": "string" + }, + "type": { + "default": "address", + "enum": [ + "address" + ], + "title": "Type", + "type": "string" + }, + "venueId": { + "description": "Venue Id. The venues list is available on [**this endpoint (`Get offerer venues`)**](#tag/Venues/operation/GetOffererVenues)", + "example": 535, + "title": "Venueid", + "type": "integer" + } + }, + "required": [ + "venueId", + "addressId" + ], + "title": "AddressLocation", + "type": "object" + }, "AddressModel": { "properties": { "city": { @@ -2132,6 +2172,7 @@ "type": "object" }, "DigitalLocation": { + "description": "If your offer has no physical location as it is a digital product", "properties": { "type": { "default": "digital", @@ -2151,8 +2192,8 @@ "type": "string" }, "venueId": { - "description": "List of venues is available at GET /offerer_venues", - "example": 1, + "description": "Venue Id. The venues list is available on [**this endpoint (`Get offerer venues`)**](#tag/Venues/operation/GetOffererVenues)", + "example": 535, "title": "Venueid", "type": "integer" } @@ -2715,9 +2756,10 @@ "type": "string" }, "location": { - "description": "Location where the offer will be available or will take place. The location type must be compatible with the category", + "description": "\nIndicates where the offer will be available or where it will take place. The location type must be compatible with the offer category.\n\nYou have **three options** for the location:\n\n- `\"digital\"`: Use this if the offer is a digital product and does not have a physical location\n- `\"physical\"`: Use this if the offer will be available at your venue\n- `\"address\"`: Use this if the offer takes place at a different location from your venue\n", "discriminator": { "mapping": { + "address": "#/components/schemas/AddressLocation", "digital": "#/components/schemas/DigitalLocation", "physical": "#/components/schemas/PhysicalLocation" }, @@ -2729,6 +2771,9 @@ }, { "$ref": "#/components/schemas/DigitalLocation" + }, + { + "$ref": "#/components/schemas/AddressLocation" } ], "title": "Location" @@ -3144,9 +3189,10 @@ "type": "string" }, "location": { - "description": "Location where the offer will be available or will take place. The location type must be compatible with the category", + "description": "\nIndicates where the offer will be available or where it will take place. The location type must be compatible with the offer category.\n\nYou have **three options** for the location:\n\n- `\"digital\"`: Use this if the offer is a digital product and does not have a physical location\n- `\"physical\"`: Use this if the offer will be available at your venue\n- `\"address\"`: Use this if the offer takes place at a different location from your venue\n", "discriminator": { "mapping": { + "address": "#/components/schemas/AddressLocation", "digital": "#/components/schemas/DigitalLocation", "physical": "#/components/schemas/PhysicalLocation" }, @@ -3158,6 +3204,9 @@ }, { "$ref": "#/components/schemas/DigitalLocation" + }, + { + "$ref": "#/components/schemas/AddressLocation" } ], "title": "Location" @@ -5946,6 +5995,7 @@ "type": "object" }, "PhysicalLocation": { + "description": "If your offer location is your venue", "properties": { "type": { "default": "physical", @@ -5956,8 +6006,8 @@ "type": "string" }, "venueId": { - "description": "List of venues is available at GET /offerer_venues", - "example": 1, + "description": "Venue Id. The venues list is available on [**this endpoint (`Get offerer venues`)**](#tag/Venues/operation/GetOffererVenues)", + "example": 535, "title": "Venueid", "type": "integer" } @@ -6567,9 +6617,10 @@ "type": "string" }, "location": { - "description": "Location where the offer will be available or will take place. The location type must be compatible with the category", + "description": "\nIndicates where the offer will be available or where it will take place. The location type must be compatible with the offer category.\n\nYou have **three options** for the location:\n\n- `\"digital\"`: Use this if the offer is a digital product and does not have a physical location\n- `\"physical\"`: Use this if the offer will be available at your venue\n- `\"address\"`: Use this if the offer takes place at a different location from your venue\n", "discriminator": { "mapping": { + "address": "#/components/schemas/AddressLocation", "digital": "#/components/schemas/DigitalLocation", "physical": "#/components/schemas/PhysicalLocation" }, @@ -6581,6 +6632,9 @@ }, { "$ref": "#/components/schemas/DigitalLocation" + }, + { + "$ref": "#/components/schemas/AddressLocation" } ], "title": "Location" @@ -7063,9 +7117,10 @@ "type": "string" }, "location": { - "description": "Location where the offer will be available or will take place. The location type must be compatible with the category", + "description": "\nIndicates where the offer will be available or where it will take place. The location type must be compatible with the offer category.\n\nYou have **three options** for the location:\n\n- `\"digital\"`: Use this if the offer is a digital product and does not have a physical location\n- `\"physical\"`: Use this if the offer will be available at your venue\n- `\"address\"`: Use this if the offer takes place at a different location from your venue\n", "discriminator": { "mapping": { + "address": "#/components/schemas/AddressLocation", "digital": "#/components/schemas/DigitalLocation", "physical": "#/components/schemas/PhysicalLocation" }, @@ -7077,6 +7132,9 @@ }, { "$ref": "#/components/schemas/DigitalLocation" + }, + { + "$ref": "#/components/schemas/AddressLocation" } ], "title": "Location" @@ -7200,9 +7258,10 @@ "additionalProperties": false, "properties": { "location": { - "description": "Location where the offer will be available or will take place. The location type must be compatible with the category", + "description": "\nIndicates where the offer will be available or where it will take place. The location type must be compatible with the offer category.\n\nYou have **three options** for the location:\n\n- `\"digital\"`: Use this if the offer is a digital product and does not have a physical location\n- `\"physical\"`: Use this if the offer will be available at your venue\n- `\"address\"`: Use this if the offer takes place at a different location from your venue\n", "discriminator": { "mapping": { + "address": "#/components/schemas/AddressLocation", "digital": "#/components/schemas/DigitalLocation", "physical": "#/components/schemas/PhysicalLocation" }, @@ -7214,6 +7273,9 @@ }, { "$ref": "#/components/schemas/DigitalLocation" + }, + { + "$ref": "#/components/schemas/AddressLocation" } ], "title": "Location" diff --git a/api/src/pcapi/core/offers/api.py b/api/src/pcapi/core/offers/api.py index 4b5bf7634b2..a95e7672c9b 100644 --- a/api/src/pcapi/core/offers/api.py +++ b/api/src/pcapi/core/offers/api.py @@ -117,8 +117,10 @@ class StocksStats: def build_new_offer_from_product( venue: offerers_models.Venue, product: models.Product, + *, id_at_provider: str | None, provider_id: int | None, + offerer_address_id: int | None = None, ) -> models.Offer: return models.Offer( bookingEmail=venue.bookingEmail, @@ -130,7 +132,7 @@ def build_new_offer_from_product( venueId=venue.id, subcategoryId=product.subcategoryId, withdrawalDetails=venue.withdrawalDetails, - offererAddressId=venue.offererAddressId, + offererAddressId=venue.offererAddressId if offerer_address_id is None else offerer_address_id, ) diff --git a/api/src/pcapi/routes/public/documentation_constants/descriptions.py b/api/src/pcapi/routes/public/documentation_constants/descriptions.py index 15e56e56fa7..4a6c31c0db6 100644 --- a/api/src/pcapi/routes/public/documentation_constants/descriptions.py +++ b/api/src/pcapi/routes/public/documentation_constants/descriptions.py @@ -31,6 +31,15 @@ - `SOLD_OUT`: offer is validated but there is no (more) stock available for booking. In the case of a collective offer, there is stock for only one booking ; if the booking is canceled, it is possible to book the collective offer again. """ +OFFER_LOCATION_DESCRIPTION = """ +Indicates where the offer will be available or where it will take place. The location type must be compatible with the offer category. + +You have **three options** for the location: + +- `"digital"`: Use this if the offer is a digital product and does not have a physical location +- `"physical"`: Use this if the offer will be available at your venue +- `"address"`: Use this if the offer takes place at a different location from your venue +""" COLLECTIVE_OFFER_STATUS_FIELD_DESCRIPTION = ( OFFER_STATUS_FIELD_DESCRIPTION + "\n\n" + "- `ARCHIVED`: offer is archived by pro." @@ -45,5 +54,6 @@ * `REIMBURSED` The booking has been reimbursed by pass Culture to the venue """ + BEGINNING_DATETIME_FIELD_DESCRIPTION = "Beginning datetime of the event. The expected format is **[ISO 8601](https://fr.wikipedia.org/wiki/ISO_8601)** (standard format for timezone aware datetime)." BOOKING_LIMIT_DATETIME_FIELD_DESCRIPTION = "Datetime after which the offer can no longer be booked. The expected format is **[ISO 8601](https://fr.wikipedia.org/wiki/ISO_8601)** (standard format for timezone aware datetime)." diff --git a/api/src/pcapi/routes/public/documentation_constants/fields.py b/api/src/pcapi/routes/public/documentation_constants/fields.py index 8ead73ebe4e..8fecf01e48d 100644 --- a/api/src/pcapi/routes/public/documentation_constants/fields.py +++ b/api/src/pcapi/routes/public/documentation_constants/fields.py @@ -122,6 +122,8 @@ class SomeOtheResponseModel(BaseModel): CITY = Field(description="City", example="Paris") POSTAL_CODE = Field(description="Postal Code", example="75001") STREET = Field(description="Street name and number", example="182 Rue Saint-Honoré") + ADDRESS_ID = Field(description="Address id in the pass Culture DB", example=1) + ADDRESS_LABEL = Field(description="Address label", example="Zénith Paris") # Offer fields OFFER_ID = Field(description="Offer id", example=12345) @@ -162,6 +164,7 @@ class SomeOtheResponseModel(BaseModel): example=True, default=True, ) + OFFER_LOCATION = Field(discriminator="type", description=descriptions.OFFER_LOCATION_DESCRIPTION) # Products fields EANS_FILTER = Field(description="EANs list (max 100)", example="3700551782888,9782895761792") diff --git a/api/src/pcapi/routes/public/individual_offers/v1/events.py b/api/src/pcapi/routes/public/individual_offers/v1/events.py index c8de4817e24..5107e31df84 100644 --- a/api/src/pcapi/routes/public/individual_offers/v1/events.py +++ b/api/src/pcapi/routes/public/individual_offers/v1/events.py @@ -7,6 +7,7 @@ from pcapi.core.bookings import exceptions as booking_exceptions from pcapi.core.categories import subcategories_v2 as subcategories from pcapi.core.finance import utils as finance_utils +from pcapi.core.offerers import api as offerers_api from pcapi.core.offers import api as offers_api from pcapi.core.offers import exceptions as offers_exceptions from pcapi.core.offers import models as offers_models @@ -77,6 +78,16 @@ def post_event_offer(body: serialization.EventOfferCreation) -> serialization.Ev withdrawal_type = _deserialize_has_ticket(body.has_ticket, body.category_related_fields.subcategory_id) try: with repository.transaction(): + offerer_address = venue.offererAddress # default offerer_address + + if body.location.type == "address": + address = utils.get_address_or_raise_404(body.location.address_id) + offerer_address = offerers_api.get_or_create_offerer_address( + offerer_id=venue.managingOffererId, + address_id=address.id, + label=body.location.address_label, + ) + offer_body = offers_schemas.CreateOffer( name=body.name, subcategoryId=body.category_related_fields.subcategory_id, @@ -97,7 +108,11 @@ def post_event_offer(body: serialization.EventOfferCreation) -> serialization.Ev withdrawalType=withdrawal_type, ) # type: ignore[call-arg] created_offer = offers_api.create_offer( - offer_body, venue=venue, venue_provider=venue_provider, provider=current_api_key.provider + offer_body, + venue=venue, + venue_provider=venue_provider, + provider=current_api_key.provider, + offerer_address=offerer_address, ) # To create the priceCategories, the offer needs to have an id db.session.flush() diff --git a/api/src/pcapi/routes/public/individual_offers/v1/products.py b/api/src/pcapi/routes/public/individual_offers/v1/products.py index 202e3417bbd..54b3621505d 100644 --- a/api/src/pcapi/routes/public/individual_offers/v1/products.py +++ b/api/src/pcapi/routes/public/individual_offers/v1/products.py @@ -31,6 +31,7 @@ from pcapi.routes.public.documentation_constants import http_responses from pcapi.routes.public.documentation_constants import tags from pcapi.routes.public.serialization import venues as venues_serialization +from pcapi.routes.public.services import authorization from pcapi.serialization.decorator import spectree_serialize from pcapi.serialization.spec_tree import ExtendResponse as SpectreeResponse from pcapi.utils import image_conversion @@ -218,7 +219,11 @@ class CreateStockDBError(CreateStockError): pass -def _create_product(venue: offerers_models.Venue, body: serialization.ProductOfferCreation) -> offers_models.Offer: +def _create_product( + venue: offerers_models.Venue, + body: serialization.ProductOfferCreation, + offerer_address: offerers_models.OffererAddress | None, +) -> offers_models.Offer: try: offer_body = offers_schemas.CreateOffer( name=body.name, @@ -237,7 +242,12 @@ def _create_product(venue: offerers_models.Venue, body: serialization.ProductOff url=body.location.url if isinstance(body.location, serialization.DigitalLocation) else None, withdrawalDetails=body.withdrawal_details, ) # type: ignore[call-arg] - created_product = offers_api.create_offer(offer_body, venue=venue, provider=current_api_key.provider) + created_product = offers_api.create_offer( + offer_body, + venue=venue, + provider=current_api_key.provider, + offerer_address=offerer_address, + ) # To create stocks or publishing the offer we need to flush # the session to get the offer id @@ -299,11 +309,22 @@ def post_product_offer(body: serialization.ProductOfferCreation) -> serializatio Create a product in authorized categories. """ - venue = utils.retrieve_venue_from_location(body.location) + venue_provider = authorization.get_venue_provider_or_raise_404(body.location.venue_id) + venue = utils.get_venue_with_offerer_address(venue_provider.venueId) try: with repository.transaction(): - product = _create_product(venue=venue, body=body) + offerer_address = venue.offererAddress # default offerer_address + + if body.location.type == "address": + address = utils.get_address_or_raise_404(body.location.address_id) + offerer_address = offerers_api.get_or_create_offerer_address( + offerer_id=venue.managingOffererId, + address_id=address.id, + label=body.location.address_label, + ) + + product = _create_product(venue=venue, body=body, offerer_address=offerer_address) if body.image: utils.save_image(body.image, product) @@ -354,15 +375,38 @@ def post_product_offer_by_ean(body: serialization.ProductsOfferByEanCreation) -> **WARNING:** As it is an asynchronous you won't be given any feedback if one or more EANs is rejected. To make sure that your EANs won't be rejected please use [**this endpoint**](/rest-api#tag/Product-offer-bulk-operations/operation/CheckEansAvailability) """ - venue = utils.retrieve_venue_from_location(body.location) + venue_provider = authorization.get_venue_provider_or_raise_404(body.location.venue_id) + venue = utils.get_venue_with_offerer_address(venue_provider.venueId) + address_id = None + address_label = None + if venue.isVirtual: raise api_errors.ApiErrors({"location": ["Cannot create product offer for virtual venues"]}) + + if body.location.type == "address": + address = utils.get_address_or_raise_404(body.location.address_id) + address_id = address.id + address_label = body.location.address_label + serialized_products_stocks = _serialize_products_from_body(body.products) - _create_or_update_ean_offers.delay(serialized_products_stocks, venue.id, current_api_key.provider.id) + _create_or_update_ean_offers.delay( + serialized_products_stocks=serialized_products_stocks, + venue_id=venue.id, + provider_id=current_api_key.provider.id, + address_id=address_id, + address_label=address_label, + ) @job(worker.low_queue) -def _create_or_update_ean_offers(serialized_products_stocks: dict, venue_id: int, provider_id: int) -> None: +def _create_or_update_ean_offers( + *, + serialized_products_stocks: dict, + venue_id: int, + provider_id: int, + address_id: int | None = None, + address_label: str | None = None, +) -> None: provider = providers_models.Provider.query.filter_by(id=provider_id).one() venue = offerers_models.Venue.query.filter_by(id=venue_id).one() @@ -379,6 +423,15 @@ def _create_or_update_ean_offers(serialized_products_stocks: dict, venue_id: int ean_list_to_create = ean_to_create_or_update - ean_list_to_update offers_to_index = [] with repository.transaction(): + offerer_address = venue.offererAddress # default offerer_address + + if address_id: + offerer_address = offerers_api.get_or_create_offerer_address( + offerer_id=venue.managingOffererId, + address_id=address_id, + label=address_label, + ) + if ean_list_to_create: created_offers = [] existing_products = _get_existing_products(ean_list_to_create) @@ -398,6 +451,7 @@ def _create_or_update_ean_offers(serialized_products_stocks: dict, venue_id: int venue, product_by_ean[ean], provider, + offererAddress=offerer_address, ) created_offers.append(created_offer) @@ -538,10 +592,17 @@ def _create_offer_from_product( venue: offerers_models.Venue, product: offers_models.Product, provider: providers_models.Provider, + offererAddress: offerers_models.OffererAddress, ) -> offers_models.Offer: ean = product.extraData.get("ean") if product.extraData else None - offer = offers_api.build_new_offer_from_product(venue, product, ean, provider.id) + offer = offers_api.build_new_offer_from_product( + venue, + product, + id_at_provider=ean, + provider_id=provider.id, + offerer_address_id=offererAddress.id, + ) offer.audioDisabilityCompliant = venue.audioDisabilityCompliant offer.mentalDisabilityCompliant = venue.mentalDisabilityCompliant diff --git a/api/src/pcapi/routes/public/individual_offers/v1/serialization.py b/api/src/pcapi/routes/public/individual_offers/v1/serialization.py index 0a67584fafe..fd94162a811 100644 --- a/api/src/pcapi/routes/public/individual_offers/v1/serialization.py +++ b/api/src/pcapi/routes/public/individual_offers/v1/serialization.py @@ -122,14 +122,53 @@ class AccessibilityResponse(serialization.ConfiguredBaseModel): visual_disability_compliant: bool | None = fields.VISUAL_DISABILITY_COMPLIANT +class AddressLabel(pydantic_v1.ConstrainedStr): + min_length = 1 + max_length = 200 + + +class AddressLocation(serialization.ConfiguredBaseModel): + """ + If your offer location is different from the venue location + """ + + type: typing.Literal["address"] = "address" + venue_id: int = fields.VENUE_ID + address_id: int = fields.ADDRESS_ID + address_label: AddressLabel | None = fields.ADDRESS_LABEL + + @classmethod + def build_from_offer(cls, offer: offers_models.Offer) -> "AddressLocation": + if not offer.offererAddress: + raise ValueError("offer.offererAddress is `None`") + + if offer.offererAddress.addressId is None: + raise ValueError("offer.offererAddress.addressId is `None`") + + return cls( + type="address", + venue_id=offer.venueId, + address_id=offer.offererAddress.addressId, + address_label=offer.offererAddress.label, # type: ignore[arg-type] + ) + + class PhysicalLocation(serialization.ConfiguredBaseModel): + """ + If your offer location is your venue + """ + type: typing.Literal["physical"] = "physical" - venue_id: int = pydantic_v1.Field(..., example=1, description="List of venues is available at GET /offerer_venues") + venue_id: int = fields.VENUE_ID class DigitalLocation(serialization.ConfiguredBaseModel): + """ + If your offer has no physical location as it is a digital product + """ + type: typing.Literal["digital"] = "digital" - venue_id: int = pydantic_v1.Field(..., example=1, description="List of venues is available at GET /offerer_venues") + venue_id: int = fields.VENUE_ID url: pydantic_v1.HttpUrl = pydantic_v1.Field( ..., description="Link users will be redirected to after booking this offer. You may include '{token}', '{email}' and/or '{offerId}' in the URL, which will be replaced respectively by the booking token (use this token to confirm the offer - see API Contremarque), the email of the user who booked the offer and the created offer id", @@ -167,11 +206,6 @@ class CategoryRelatedFields(ExtraDataModel): example="Opening hours, specific office, collection period, access code, email announcement...", alias="itemCollectionDetails", ) -LOCATION_FIELD = pydantic_v1.Field( - ..., - discriminator="type", - description="Location where the offer will be available or will take place. The location type must be compatible with the category", -) class ImageBody(serialization.ConfiguredBaseModel): @@ -432,7 +466,7 @@ class StockEdition(BaseStockEdition): class ProductOfferCreation(OfferCreationBase): category_related_fields: product_category_creation_fields stock: StockCreation | None - location: PhysicalLocation | DigitalLocation = LOCATION_FIELD + location: PhysicalLocation | DigitalLocation | AddressLocation = fields.OFFER_LOCATION class Config: extra = "forbid" @@ -453,7 +487,7 @@ class ProductsOfferByEanCreation(serialization.ConfiguredBaseModel): products: list[ProductOfferByEanCreation] = pydantic_v1.Field( description="List of product to create or update", max_items=500 ) - location: PhysicalLocation | DigitalLocation = LOCATION_FIELD + location: PhysicalLocation | DigitalLocation | AddressLocation = fields.OFFER_LOCATION class Config: extra = "forbid" @@ -511,7 +545,7 @@ class Config: class EventOfferCreation(OfferCreationBase): category_related_fields: event_category_creation_fields event_duration: int | None = fields.EVENT_DURATION - location: PhysicalLocation | DigitalLocation = LOCATION_FIELD + location: PhysicalLocation | DigitalLocation | AddressLocation = fields.OFFER_LOCATION has_ticket: bool = fields.EVENT_HAS_TICKET price_categories: list[PriceCategoryCreation] | None = fields.PRICE_CATEGORIES publication_date: datetime.datetime | None = fields.OFFER_PUBLICATION_DATE @@ -703,7 +737,7 @@ class OfferResponse(serialization.ConfiguredBaseModel): external_ticket_office_url: str | None = EXTERNAL_TICKET_OFFICE_URL_FIELD image: ImageResponse | None enable_double_bookings: bool | None = fields.OFFER_ENABLE_DOUBLE_BOOKINGS_WITH_DEFAULT - location: PhysicalLocation | DigitalLocation = LOCATION_FIELD + location: PhysicalLocation | DigitalLocation | AddressLocation = fields.OFFER_LOCATION name: str = fields.OFFER_NAME status: offer_mixin.OfferStatus = pydantic_v1.Field( ..., @@ -713,8 +747,18 @@ class OfferResponse(serialization.ConfiguredBaseModel): withdrawal_details: str | None = WITHDRAWAL_DETAILS_FIELD id_at_provider: str | None = fields.ID_AT_PROVIDER + @classmethod + def get_location(cls, offer: offers_models.Offer) -> PhysicalLocation | DigitalLocation | AddressLocation: + if offer.isDigital: + return DigitalLocation.from_orm(offer) + if offer.offererAddressId is not None and offer.offererAddressId != offer.venue.offererAddressId: + return AddressLocation.build_from_offer(offer) + + return PhysicalLocation.from_orm(offer) + @classmethod def build_offer(cls, offer: offers_models.Offer) -> "OfferResponse": + return cls( id=offer.id, booking_contact=offer.bookingContact, @@ -724,7 +768,7 @@ def build_offer(cls, offer: offers_models.Offer) -> "OfferResponse": external_ticket_office_url=offer.externalTicketOfficeUrl, image=offer.image, # type: ignore[arg-type] enable_double_bookings=offer.isDuo, - location=DigitalLocation.from_orm(offer) if offer.isDigital else PhysicalLocation.from_orm(offer), + location=cls.get_location(offer), name=offer.name, status=offer.status, withdrawal_details=offer.withdrawalDetails, diff --git a/api/src/pcapi/routes/public/individual_offers/v1/utils.py b/api/src/pcapi/routes/public/individual_offers/v1/utils.py index d8073af02e5..d82c93dad94 100644 --- a/api/src/pcapi/routes/public/individual_offers/v1/utils.py +++ b/api/src/pcapi/routes/public/individual_offers/v1/utils.py @@ -1,6 +1,7 @@ import sqlalchemy as sqla from sqlalchemy import orm as sqla_orm +from pcapi.core.geography import models as geography_models from pcapi.core.offerers import models as offerers_models from pcapi.core.offers import api as offers_api from pcapi.core.offers import exceptions as offers_exceptions @@ -17,6 +18,16 @@ from . import serialization +def get_address_or_raise_404(address_id: int) -> geography_models.Address: + address = geography_models.Address.query.filter(geography_models.Address.id == address_id).one_or_none() + + if not address: + raise api_errors.ResourceNotFoundError( + {"location.AddressLocation.addressId": [f"There is no venue with id {address_id}"]} + ) + return address + + def get_venue_with_offerer_address(venue_id: int) -> offerers_models.Venue: return ( offerers_models.Venue.query.filter(offerers_models.Venue.id == venue_id) @@ -25,26 +36,6 @@ def get_venue_with_offerer_address(venue_id: int) -> offerers_models.Venue: ) -def retrieve_venue_from_location( - location: serialization.DigitalLocation | serialization.PhysicalLocation, -) -> offerers_models.Venue: - venue = ( - offerers_models.Venue.query.join(providers_models.VenueProvider, offerers_models.Venue.venueProviders) - .filter( - offerers_models.Venue.id == location.venue_id, - providers_models.VenueProvider.provider == current_api_key.provider, - providers_models.VenueProvider.isActive, - ) - .options(sqla.orm.joinedload(offerers_models.Venue.offererAddress)) - .one_or_none() - ) - if not venue: - raise api_errors.ApiErrors( - {"venueId": ["There is no venue with this id associated to your API key"]}, status_code=404 - ) - return venue - - def retrieve_offer_relations_query(query: sqla_orm.Query) -> sqla_orm.Query: return ( query.options(sqla_orm.joinedload(offers_models.Offer.stocks)) @@ -97,6 +88,7 @@ def _retrieve_offer_tied_to_user_query() -> sqla_orm.Query: .join(providers_models.VenueProvider.provider) .filter(providers_models.VenueProvider.provider == current_api_key.provider) .filter(providers_models.VenueProvider.isActive) + .options(sqla_orm.joinedload(offers_models.Offer.venue)) ) @@ -113,6 +105,7 @@ def retrieve_offers( .filter(offers_models.Offer.id >= firstIndex) .order_by(offers_models.Offer.id) .options(sqla.orm.contains_eager(offers_models.Offer.futureOffer)) + .options(sqla_orm.joinedload(offers_models.Offer.venue)) ) if ids_at_provider: diff --git a/api/tests/routes/public/individual_offers/v1/patch_product_test.py b/api/tests/routes/public/individual_offers/v1/patch_product_test.py index a86c0f30fe4..c566146e13c 100644 --- a/api/tests/routes/public/individual_offers/v1/patch_product_test.py +++ b/api/tests/routes/public/individual_offers/v1/patch_product_test.py @@ -372,7 +372,8 @@ def test_update_name_and_description(self, client): # 5. update offer # 6. reload provider # 7. reload offer and related data (before serialization) - with assert_num_queries(7): + # 8. check venue offerer address + with assert_num_queries(8): response = client.with_explicit_token(offerers_factories.DEFAULT_CLEAR_API_KEY).patch( "/public/offers/v1/products", json={"offerId": offer_id, "name": new_name, "description": new_desc}, diff --git a/api/tests/routes/public/individual_offers/v1/post_event_test.py b/api/tests/routes/public/individual_offers/v1/post_event_test.py index ff20c5d50b6..77204ffc5b3 100644 --- a/api/tests/routes/public/individual_offers/v1/post_event_test.py +++ b/api/tests/routes/public/individual_offers/v1/post_event_test.py @@ -5,6 +5,9 @@ import pytest from pcapi import settings +from pcapi.core.geography import factories as geography_factories +from pcapi.core.offerers import factories as offerers_factories +from pcapi.core.offerers import models as offerers_models from pcapi.core.offers import factories as offers_factories from pcapi.core.offers import models as offers_models from pcapi.core.testing import override_features @@ -365,6 +368,102 @@ def test_other_music_type_serialization(self, client): "performer": None, } + def test_event_with_custom_address(self, client): + plain_api_key, venue_provider = self.setup_active_venue_provider(provider_has_ticketing_urls=False) + payload = self._get_base_payload(venue_provider.venueId) + address = geography_factories.AddressFactory() + offerer_address = offerers_factories.OffererAddressFactory( + address=address, + offerer=venue_provider.venue.managingOfferer, + label="My beautiful address no one knows about", + ) + payload["location"] = { + "type": "address", + "venueId": venue_provider.venueId, + "addressId": address.id, + "addressLabel": "My beautiful address no one knows about", + } + + response = client.with_explicit_token(plain_api_key).post(self.endpoint_url, json=payload) + assert response.status_code == 200 + assert response.json["location"]["addressId"] == address.id + assert response.json["location"]["addressLabel"] == "My beautiful address no one knows about" + created_offer = offers_models.Offer.query.one() + assert created_offer.offererAddress == offerer_address + + def test_event_with_custom_address_should_create_offerer_address(self, client): + plain_api_key, venue_provider = self.setup_active_venue_provider(provider_has_ticketing_urls=False) + payload = self._get_base_payload(venue_provider.venueId) + address = geography_factories.AddressFactory() + + assert not offerers_models.OffererAddress.query.filter( + offerers_models.OffererAddress.addressId == address.id, + offerers_models.OffererAddress.label == "My beautiful address no one knows about", + ).one_or_none() + + payload["location"] = { + "type": "address", + "venueId": venue_provider.venueId, + "addressId": address.id, + "addressLabel": "My beautiful address no one knows about", + } + response = client.with_explicit_token(plain_api_key).post(self.endpoint_url, json=payload) + assert response.status_code == 200 + created_offer = offers_models.Offer.query.one() + offerer_address = offerers_models.OffererAddress.query.filter( + offerers_models.OffererAddress.addressId == address.id, + offerers_models.OffererAddress.label == "My beautiful address no one knows about", + ).one() + assert created_offer.offererAddress == offerer_address + + def test_event_with_custom_address_should_raiser_404_because_address_does_not_exist(self, client): + plain_api_key, venue_provider = self.setup_active_venue_provider(provider_has_ticketing_urls=False) + payload = self._get_base_payload(venue_provider.venueId) + address = geography_factories.AddressFactory() + not_existing_address_id = address.id + 1 + + payload["location"] = { + "type": "address", + "venueId": venue_provider.venueId, + "addressId": not_existing_address_id, + } + response = client.with_explicit_token(plain_api_key).post(self.endpoint_url, json=payload) + assert response.status_code == 404 + assert response.json == { + "location.AddressLocation.addressId": [f"There is no venue with id {not_existing_address_id}"] + } + + @pytest.mark.parametrize( + "partial_location,expected_json", + [ + ({"addressId": "coucou"}, {"location.AddressLocation.addressId": ["value is not a valid integer"]}), + ( + {"addressLabel": ""}, + {"location.AddressLocation.addressLabel": ["ensure this value has at least 1 characters"]}, + ), + ( + {"addressLabel": "a" * 201}, + {"location.AddressLocation.addressLabel": ["ensure this value has at most 200 characters"]}, + ), + ], + ) + def test_event_with_custom_address_should_raiser_400_because_address_location_params_are_incorrect( + self, client, partial_location, expected_json + ): + plain_api_key, venue_provider = self.setup_active_venue_provider(provider_has_ticketing_urls=False) + payload = self._get_base_payload(venue_provider.venueId) + address = geography_factories.AddressFactory() + base_location_object = { + "type": "address", + "venueId": venue_provider.venueId, + "addressId": address.id, + "addressLabel": "My beautiful address no one knows about", + } + payload["location"] = dict(base_location_object, **partial_location) + response = client.with_explicit_token(plain_api_key).post(self.endpoint_url, json=payload) + assert response.status_code == 400 + assert response.json == expected_json + def test_event_without_ticket(self, client): plain_api_key, venue_provider = self.setup_active_venue_provider(provider_has_ticketing_urls=False) diff --git a/api/tests/routes/public/individual_offers/v1/post_product_by_ean_test.py b/api/tests/routes/public/individual_offers/v1/post_product_by_ean_test.py index 036b67abff3..111f8d5162f 100644 --- a/api/tests/routes/public/individual_offers/v1/post_product_by_ean_test.py +++ b/api/tests/routes/public/individual_offers/v1/post_product_by_ean_test.py @@ -8,7 +8,9 @@ from pcapi.core.bookings import factories as bookings_factories from pcapi.core.categories import subcategories_v2 as subcategories from pcapi.core.finance import factories as finance_factories +from pcapi.core.geography import factories as geography_factories from pcapi.core.offerers import factories as offerers_factories +from pcapi.core.offerers import models as offerers_models from pcapi.core.offers import factories as offers_factories from pcapi.core.offers import models as offers_models from pcapi.core.providers import factories as providers_factories @@ -24,6 +26,19 @@ class PostProductByEanTest(PublicAPIVenueEndpointHelper): endpoint_url = "/public/offers/v1/products/ean" endpoint_method = "post" + @staticmethod + def _get_base_product(ean: str | None = None) -> tuple[str, offers_models.Product]: + ean = ean or "1234567890123" + product_provider = providers_factories.ProviderFactory() + product = offers_factories.ProductFactory( + subcategoryId=subcategories.SUPPORT_PHYSIQUE_MUSIQUE_CD.id, + extraData={"ean": ean}, + lastProviderId=product_provider.id, + idAtProviders=ean, + ) + + return ean, product + def test_should_raise_404_because_has_no_access_to_venue(self, client): plain_api_key, _ = self.setup_provider() venue = self.setup_venue() @@ -440,7 +455,9 @@ def test_does_not_create_an_offer_of_non_compatible_product(self, client, gcu_co def test_400_when_quantity_is_too_big(self, client): venue, _ = utils.create_offerer_provider_linked_to_venue() - product = offers_factories.ProductFactory(extraData={"ean": "1234567890123"}) + product = offers_factories.ThingProductFactory( + subcategoryId=subcategories.SUPPORT_PHYSIQUE_MUSIQUE_CD.id, extraData={"ean": "1234567890123"} + ) response = client.with_explicit_token(offerers_factories.DEFAULT_CLEAR_API_KEY).post( "/public/offers/v1/products/ean", @@ -521,30 +538,14 @@ def test_update_offer_when_ean_already_exists(self, client): client.with_explicit_token(offerers_factories.DEFAULT_CLEAR_API_KEY).post( "/public/offers/v1/products/ean", json={ - "products": [ - { - "ean": product.extraData["ean"], - "stock": { - "price": 1234, - "quantity": 3, - }, - } - ], + "products": [{"ean": product.extraData["ean"], "stock": {"price": 1234, "quantity": 3}}], "location": {"type": "physical", "venueId": venue.id}, }, ) client.with_explicit_token(offerers_factories.DEFAULT_CLEAR_API_KEY).post( "/public/offers/v1/products/ean", json={ - "products": [ - { - "ean": product.extraData["ean"], - "stock": { - "price": 7890, - "quantity": 3, - }, - } - ], + "products": [{"ean": product.extraData["ean"], "stock": {"price": 7890, "quantity": 3}}], "location": {"type": "physical", "venueId": venue.id}, }, ) @@ -554,6 +555,73 @@ def test_update_offer_when_ean_already_exists(self, client): assert created_stock.price == decimal.Decimal("78.90") assert created_stock.quantity == 3 + def test_with_custom_address(self, client): + address = geography_factories.AddressFactory() + plain_api_key, venue_provider = self.setup_active_venue_provider() + offerer_address = offerers_factories.OffererAddressFactory( + address=address, + offerer=venue_provider.venue.managingOfferer, + label="My beautiful address no one knows about", + ) + ean, _ = self._get_base_product() + + response = client.with_explicit_token(plain_api_key).post( + self.endpoint_url, + json={ + "location": { + "type": "address", + "venueId": venue_provider.venueId, + "addressId": address.id, + "addressLabel": "My beautiful address no one knows about", + }, + "products": [{"ean": ean, "stock": {"price": 1234, "quantity": 3}}], + }, + ) + assert response.status_code == 204 + created_offer = offers_models.Offer.query.one() + assert created_offer.offererAddress == offerer_address + + def test_with_custom_address_should_create_offerer_address(self, client): + address = geography_factories.AddressFactory() + ean, _ = self._get_base_product() + plain_api_key, venue_provider = self.setup_active_venue_provider() + + response = client.with_explicit_token(plain_api_key).post( + self.endpoint_url, + json={ + "location": { + "type": "address", + "venueId": venue_provider.venueId, + "addressId": address.id, + "addressLabel": "My beautiful address no one knows about", + }, + "products": [{"ean": ean, "stock": {"price": 1234, "quantity": 3}}], + }, + ) + + assert response.status_code == 204 + created_offer = offers_models.Offer.query.one() + offerer_address = offerers_models.OffererAddress.query.filter( + offerers_models.OffererAddress.addressId == address.id, + offerers_models.OffererAddress.label == "My beautiful address no one knows about", + ).one() + assert created_offer.offererAddress == offerer_address + + def test_event_with_custom_address_should_raiser_404_because_address_does_not_exist(self, client): + ean, _ = self._get_base_product() + plain_api_key, venue_provider = self.setup_active_venue_provider() + + response = client.with_explicit_token(plain_api_key).post( + self.endpoint_url, + json={ + "location": {"type": "address", "venueId": venue_provider.venueId, "addressId": -1}, + "products": [{"ean": ean, "stock": {"price": 1234, "quantity": 3}}], + }, + ) + + assert response.status_code == 404 + assert response.json == {"location.AddressLocation.addressId": ["There is no venue with id -1"]} + @mock.patch("pcapi.core.search.async_index_offer_ids") def test_create_and_update_offer(self, async_index_offer_ids, client): product_provider = providers_factories.ProviderFactory() @@ -621,14 +689,11 @@ def test_create_and_update_offer(self, async_index_offer_ids, client): assert created_offer.activeStocks[0].price == decimal.Decimal("98.76") assert created_offer.activeStocks[0].quantity == 22 - -@pytest.mark.usefixtures("db_session") -class JsonFormatTest: def test_invalid_json_raise_syntax_error(self, client): - utils.create_offerer_provider_linked_to_venue() + plain_api_key, _ = self.setup_provider() - response = client.with_explicit_token(offerers_factories.DEFAULT_CLEAR_API_KEY).post( - "/public/offers/v1/products/ean", + response = client.with_explicit_token(plain_api_key).post( + self.endpoint_url, raw_json="""{ "location": {"type": "physical", "venueId": venue.id}, "products": [ diff --git a/api/tests/routes/public/individual_offers/v1/post_product_test.py b/api/tests/routes/public/individual_offers/v1/post_product_test.py index 8af84f8cddd..5c6cb8f0ce6 100644 --- a/api/tests/routes/public/individual_offers/v1/post_product_test.py +++ b/api/tests/routes/public/individual_offers/v1/post_product_test.py @@ -8,7 +8,9 @@ import sqlalchemy.exc as sqla_exc from pcapi import settings +from pcapi.core.geography import factories as geography_factories from pcapi.core.offerers import factories as offerers_factories +from pcapi.core.offerers import models as offerers_models from pcapi.core.offers import factories as offers_factories from pcapi.core.offers import models as offers_models from pcapi.core.testing import override_features @@ -49,8 +51,7 @@ def test_should_raise_404_because_has_no_access_to_venue(self, client): plain_api_key, _ = self.setup_provider() venue = self.setup_venue() response = client.with_explicit_token(plain_api_key).post( - self.endpoint_url, - json=self._get_base_payload(venue.id), + self.endpoint_url, json=self._get_base_payload(venue.id) ) assert response.status_code == 404 @@ -58,36 +59,26 @@ def test_should_raise_404_because_has_no_access_to_venue(self, client): def test_should_raise_404_because_venue_provider_is_inactive(self, client): plain_api_key, venue_provider = self.setup_inactive_venue_provider() response = client.with_explicit_token(plain_api_key).post( - self.endpoint_url, - json=self._get_base_payload(venue_provider.venue.id), + self.endpoint_url, json=self._get_base_payload(venue_provider.venue.id) ) assert response.status_code == 404 @pytest.mark.usefixtures("db_session") @mock.patch("pcapi.tasks.sendinblue_tasks.update_sib_pro_attributes_task") def test_physical_product_minimal_body(self, update_sib_pro_task_mock, client): - venue, _ = utils.create_offerer_provider_linked_to_venue() + plain_api_key, venue_provider = self.setup_active_venue_provider() - response = client.with_explicit_token(offerers_factories.DEFAULT_CLEAR_API_KEY).post( - "/public/offers/v1/products", - json={ - "location": {"type": "physical", "venueId": venue.id}, - "categoryRelatedFields": { - "category": "SUPPORT_PHYSIQUE_FILM", - "ean": "1234567891234", - }, - "accessibility": utils.ACCESSIBILITY_FIELDS, - "name": "Le champ des possibles", - }, + response = client.with_explicit_token(plain_api_key).post( + self.endpoint_url, json=self._get_base_payload(venue_provider.venueId) ) assert response.status_code == 200 created_offer = offers_models.Offer.query.one() assert created_offer.name == "Le champ des possibles" - assert created_offer.venue == venue + assert created_offer.venue == venue_provider.venue assert created_offer.subcategoryId == "SUPPORT_PHYSIQUE_FILM" assert created_offer.audioDisabilityCompliant is True - assert created_offer.lastProvider.name == "Technical provider" + assert created_offer.lastProvider.name == venue_provider.provider.name assert created_offer.mentalDisabilityCompliant is True assert created_offer.motorDisabilityCompliant is True assert created_offer.visualDisabilityCompliant is True @@ -95,7 +86,7 @@ def test_physical_product_minimal_body(self, update_sib_pro_task_mock, client): assert created_offer.bookingEmail is None assert created_offer.description is None assert created_offer.status == offer_mixin.OfferStatus.SOLD_OUT - assert created_offer.offererAddress.id == venue.offererAddress.id + assert created_offer.offererAddress.id == venue_provider.venue.offererAddress.id assert response.json == { "bookingContact": None, @@ -116,7 +107,7 @@ def test_physical_product_minimal_body(self, update_sib_pro_task_mock, client): "id": created_offer.id, "image": None, "itemCollectionDetails": None, - "location": {"type": "physical", "venueId": venue.id}, + "location": {"type": "physical", "venueId": venue_provider.venue.id}, "name": "Le champ des possibles", "status": "SOLD_OUT", "stock": None, @@ -263,77 +254,27 @@ def test_unlimited_quantity(self, client): assert created_stock.quantity is None assert created_stock.offer == created_offer + @pytest.mark.parametrize( + "stock,expected_json", + [ + ({"price": 12.34, "quantity": "unlimited"}, {"stock.price": ["value is not a valid integer"]}), + ( + {"price": -1200, "quantity": "unlimited"}, + {"stock.price": ["ensure this value is greater than or equal to 0"]}, + ), + ({"price": 1200, "quantity": -1}, {"stock.quantity": ["Value must be positive"]}), + ], + ) @pytest.mark.usefixtures("db_session") - def test_price_must_be_integer_strict(self, client): - venue, _ = utils.create_offerer_provider_linked_to_venue() - - response = client.with_explicit_token(offerers_factories.DEFAULT_CLEAR_API_KEY).post( - "/public/offers/v1/products", - json={ - "location": {"type": "physical", "venueId": venue.id}, - "categoryRelatedFields": { - "category": "SUPPORT_PHYSIQUE_FILM", - "ean": "1234567891234", - }, - "accessibility": utils.ACCESSIBILITY_FIELDS, - "name": "Le champ des possibles", - "stock": { - "price": 12.34, - "quantity": "unlimited", - }, - }, - ) - - assert response.status_code == 400 - assert response.json == {"stock.price": ["value is not a valid integer"]} - - @pytest.mark.usefixtures("db_session") - def test_price_must_be_positive(self, client): - venue, _ = utils.create_offerer_provider_linked_to_venue() - - response = client.with_explicit_token(offerers_factories.DEFAULT_CLEAR_API_KEY).post( - "/public/offers/v1/products", - json={ - "location": {"type": "physical", "venueId": venue.id}, - "categoryRelatedFields": { - "category": "SUPPORT_PHYSIQUE_FILM", - "ean": "1234567891234", - }, - "accessibility": utils.ACCESSIBILITY_FIELDS, - "name": "Le champ des possibles", - "stock": { - "price": -1200, - "quantity": "unlimited", - }, - }, - ) - - assert response.status_code == 400 - assert response.json == {"stock.price": ["ensure this value is greater than or equal to 0"]} - - @pytest.mark.usefixtures("db_session") - def test_quantity_must_be_positive(self, client): - venue, _ = utils.create_offerer_provider_linked_to_venue() + def test_should_raise_400_because_of_incorrect_price_value(self, client, stock, expected_json): + plain_api_key, venue_provider = self.setup_active_venue_provider() + payload = self._get_base_payload(venue_provider.venueId) + payload["stock"] = stock - response = client.with_explicit_token(offerers_factories.DEFAULT_CLEAR_API_KEY).post( - "/public/offers/v1/products", - json={ - "location": {"type": "physical", "venueId": venue.id}, - "categoryRelatedFields": { - "category": "SUPPORT_PHYSIQUE_FILM", - "ean": "1234567891234", - }, - "accessibility": utils.ACCESSIBILITY_FIELDS, - "name": "Le champ des possibles", - "stock": { - "price": 1200, - "quantity": -1, - }, - }, - ) + response = client.with_explicit_token(plain_api_key).post(self.endpoint_url, json=payload) assert response.status_code == 400 - assert response.json == {"stock.quantity": ["Value must be positive"]} + assert response.json == expected_json @pytest.mark.usefixtures("db_session") def test_is_duo_not_applicable(self, client): @@ -374,16 +315,9 @@ def test_extra_data_deserialization(self, client): ) assert response.status_code == 200 + assert response.json["categoryRelatedFields"] == {"category": "SUPPORT_PHYSIQUE_FILM", "ean": "1234567891234"} created_offer = offers_models.Offer.query.one() - - assert created_offer.extraData == { - "ean": "1234567891234", - } - - assert response.json["categoryRelatedFields"] == { - "category": "SUPPORT_PHYSIQUE_FILM", - "ean": "1234567891234", - } + assert created_offer.extraData == {"ean": "1234567891234"} @pytest.mark.usefixtures("db_session") @override_features(WIP_ENABLE_OFFER_ADDRESS=True) @@ -411,24 +345,71 @@ def test_physical_product_attached_to_digital_venue(self, client): assert offers_models.Offer.query.first() is None @pytest.mark.usefixtures("db_session") - @override_features(WIP_ENABLE_OFFER_ADDRESS=False) - def test_physical_product_without_offerer_address_legacy(self, client): - venue, _ = utils.create_offerer_provider_linked_to_venue(is_virtual=False) - - response = client.with_explicit_token(offerers_factories.DEFAULT_CLEAR_API_KEY).post( - "/public/offers/v1/products", - json={ - "location": {"type": "physical", "venueId": venue.id}, - "categoryRelatedFields": { - "category": "SUPPORT_PHYSIQUE_FILM", - "ean": "1234567891234", - }, - "accessibility": utils.ACCESSIBILITY_FIELDS, - "name": "Le champ des possibles", - }, + def test_event_with_custom_address(self, client): + plain_api_key, venue_provider = self.setup_active_venue_provider() + payload = self._get_base_payload(venue_provider.venueId) + address = geography_factories.AddressFactory() + offerer_address = offerers_factories.OffererAddressFactory( + address=address, + offerer=venue_provider.venue.managingOfferer, + label="My beautiful address no one knows about", ) + payload["location"] = { + "type": "address", + "venueId": venue_provider.venueId, + "addressId": address.id, + "addressLabel": "My beautiful address no one knows about", + } + response = client.with_explicit_token(plain_api_key).post(self.endpoint_url, json=payload) assert response.status_code == 200 + assert response.json["location"]["addressId"] == address.id + assert response.json["location"]["addressLabel"] == "My beautiful address no one knows about" + created_offer = offers_models.Offer.query.one() + assert created_offer.offererAddress == offerer_address + + @pytest.mark.usefixtures("db_session") + def test_event_with_custom_address_should_create_offerer_address(self, client): + plain_api_key, venue_provider = self.setup_active_venue_provider() + payload = self._get_base_payload(venue_provider.venueId) + address = geography_factories.AddressFactory() + + assert not offerers_models.OffererAddress.query.filter( + offerers_models.OffererAddress.addressId == address.id, + offerers_models.OffererAddress.label == "My beautiful address no one knows about", + ).one_or_none() + + payload["location"] = { + "type": "address", + "venueId": venue_provider.venueId, + "addressId": address.id, + "addressLabel": "My beautiful address no one knows about", + } + response = client.with_explicit_token(plain_api_key).post(self.endpoint_url, json=payload) + assert response.status_code == 200 + created_offer = offers_models.Offer.query.one() + offerer_address = offerers_models.OffererAddress.query.filter( + offerers_models.OffererAddress.addressId == address.id, + offerers_models.OffererAddress.label == "My beautiful address no one knows about", + ).one() + assert created_offer.offererAddress == offerer_address + + def test_event_with_custom_address_should_raiser_404_because_address_does_not_exist(self, client): + plain_api_key, venue_provider = self.setup_active_venue_provider(provider_has_ticketing_urls=False) + payload = self._get_base_payload(venue_provider.venueId) + address = geography_factories.AddressFactory() + not_existing_address_id = address.id + 1 + + payload["location"] = { + "type": "address", + "venueId": venue_provider.venueId, + "addressId": not_existing_address_id, + } + response = client.with_explicit_token(plain_api_key).post(self.endpoint_url, json=payload) + assert response.status_code == 404 + assert response.json == { + "location.AddressLocation.addressId": [f"There is no venue with id {not_existing_address_id}"] + } @pytest.mark.usefixtures("db_session") def test_event_category_not_accepted(self, client): @@ -467,7 +448,7 @@ def test_venue_allowed(self, client): ) assert response.status_code == 404 - assert response.json == {"venueId": ["There is no venue with this id associated to your API key"]} + assert response.json == {"global": "Venue cannot be found"} assert offers_models.Offer.query.first() is None @pytest.mark.usefixtures("clean_database")