diff --git a/api/src/pcapi/core/offers/api.py b/api/src/pcapi/core/offers/api.py index 28421eeee42..8bc96ba6029 100644 --- a/api/src/pcapi/core/offers/api.py +++ b/api/src/pcapi/core/offers/api.py @@ -221,6 +221,7 @@ def create_draft_offer( is_from_private_api: bool = True, ) -> models.Offer: validation.check_offer_subcategory_is_valid(body.subcategory_id) + validation.check_offer_name_does_not_contain_ean(body.name) if feature.FeatureToggle.WIP_SUGGESTED_SUBCATEGORIES.is_active(): venue = _get_coherent_venue_with_subcategory(venue, body.subcategory_id) @@ -271,6 +272,9 @@ def update_draft_offer(offer: models.Offer, body: offers_schemas.PatchDraftOffer if not updates: return offer + if body.name: + validation.check_offer_name_does_not_contain_ean(body.name) + if "extraData" in updates: formatted_extra_data = _format_extra_data(offer.subcategoryId, body.extra_data) or {} validation.check_offer_extra_data( @@ -309,6 +313,7 @@ def create_offer( validation.check_is_duo_compliance(body.is_duo, subcategory) validation.check_can_input_id_at_provider(provider, body.id_at_provider) validation.check_can_input_id_at_provider_for_this_venue(venue.id, body.id_at_provider) + validation.check_offer_name_does_not_contain_ean(body.name) fields = body.dict(by_alias=True) @@ -399,6 +404,10 @@ def update_offer( validation.check_can_input_id_at_provider(offer.lastProvider, id_at_provider) validation.check_can_input_id_at_provider_for_this_venue(offer.venueId, id_at_provider, offer.id) + if "name" in updates: + name = get_field(offer, updates, "name", aliases=aliases) + validation.check_offer_name_does_not_contain_ean(name) + if ( "withdrawalType" in updates or "withdrawalDelay" in updates diff --git a/api/src/pcapi/core/offers/exceptions.py b/api/src/pcapi/core/offers/exceptions.py index 18e86156c74..80b1bb6a534 100644 --- a/api/src/pcapi/core/offers/exceptions.py +++ b/api/src/pcapi/core/offers/exceptions.py @@ -229,6 +229,14 @@ class EanFormatException(OfferCreationBaseException): pass +class EanInOfferNameException(OfferCreationBaseException): + def __init__(self) -> None: + super().__init__( + "name", + "Le titre d'une offre ne peut contenir l'EAN", + ) + + class ProductNotFoundForOfferCreation(OfferCreationBaseException): pass diff --git a/api/src/pcapi/core/offers/validation.py b/api/src/pcapi/core/offers/validation.py index b4cc098c7b6..443a49d5580 100644 --- a/api/src/pcapi/core/offers/validation.py +++ b/api/src/pcapi/core/offers/validation.py @@ -2,6 +2,7 @@ import decimal from io import BytesIO import logging +import re import typing import warnings @@ -663,6 +664,11 @@ def check_ean_does_not_exist(ean: str | None, venue: offerers_models.Venue) -> N raise exceptions.OfferAlreadyExists("ean") +def check_offer_name_does_not_contain_ean(offer_name: str) -> None: + if re.search(r"\d{13}", offer_name): + raise exceptions.EanInOfferNameException() + + def _check_offer_has_product(offer: models.Offer | None) -> None: if FeatureToggle.WIP_EAN_CREATION.is_active() and offer and offer.product is not None: raise exceptions.OfferWithProductShouldNotUpdateExtraData() diff --git a/api/tests/core/offers/test_api.py b/api/tests/core/offers/test_api.py index 80d65db7c7b..3d11fb9969a 100644 --- a/api/tests/core/offers/test_api.py +++ b/api/tests/core/offers/test_api.py @@ -1275,6 +1275,16 @@ def test_create_draft_offer_from_scratch(self): assert not offer.product assert models.Offer.query.count() == 1 + def test_cannot_create_draft_offer_with_ean_in_name(self): + venue = offerers_factories.VenueFactory() + body = offers_schemas.PostDraftOfferBodyModel( + name="A pretty good offer 4759217254634", + subcategoryId=subcategories.SEANCE_CINE.id, + venueId=venue.id, + ) + with pytest.raises(exceptions.EanInOfferNameException): + api.create_draft_offer(body, venue=venue) + @pytest.mark.features(WIP_SUGGESTED_SUBCATEGORIES=False) def test_create_draft_digitaloffer_with_virtual_venue(self): venue = offerers_factories.VirtualVenueFactory() @@ -1458,6 +1468,25 @@ def test_basics(self): assert offer.name == "New name" assert offer.description == "New description" + def test_cannot_update_if_ean_in_name(self): + offer = factories.OfferFactory( + name="Name", + subcategoryId=subcategories.ESCAPE_GAME.id, + description="description", + ) + body = offers_schemas.PatchDraftOfferBodyModel( + name="New name 4759217254634", + description="New description", + ) + + with pytest.raises(exceptions.EanInOfferNameException): + api.update_draft_offer(offer, body) + + db.session.flush() + + assert offer.name == "Name" + assert offer.description == "description" + @pytest.mark.usefixtures("db_session") class CreateOfferTest: @@ -1597,6 +1626,21 @@ def test_cannot_create_offer_when_invalid_subcategory(self): assert error.value.errors["subcategory"] == ["La sous-catégorie de cette offre est inconnue"] + def test_cannot_create_offer_with_ean_in_name(self): + venue = offerers_factories.VenueFactory() + body = offers_schemas.CreateOffer( + name="An offer he can't refuse - 4759217254634", + subcategoryId=subcategories.SEANCE_CINE.id, + audioDisabilityCompliant=True, + mentalDisabilityCompliant=True, + motorDisabilityCompliant=True, + visualDisabilityCompliant=True, + ) + with pytest.raises(exceptions.EanInOfferNameException) as error: + api.create_offer(body, venue=venue) + + assert error.value.errors["name"] == ["Le titre d'une offre ne peut contenir l'EAN"] + def test_raise_error_if_extra_data_mandatory_fields_not_provided(self): venue = offerers_factories.VenueFactory() body = offers_schemas.CreateOffer( @@ -1713,6 +1757,15 @@ def test_cannot_update_with_name_too_long(self): assert error.value.errors == {"name": ["Vous devez saisir moins de 140 caractères"]} assert models.Offer.query.one().name == "Old name" + def test_cannot_update_with_name_containing_ean(self): + offer = factories.OfferFactory(name="Old name", extraData={"ean": "1234567890124"}) + body = offers_schemas.UpdateOffer(name="Luftballons 1234567890124") + with pytest.raises(exceptions.EanInOfferNameException) as error: + api.update_offer(offer, body) + + assert error.value.errors == {"name": ["Le titre d'une offre ne peut contenir l'EAN"]} + assert models.Offer.query.one().name == "Old name" + def test_success_on_allocine_offer(self): provider = providers_factories.AllocineProviderFactory(localClass="AllocineStocks") offer = factories.OfferFactory( diff --git a/api/tests/core/offers/test_validation.py b/api/tests/core/offers/test_validation.py index 2e995d23c24..90096c41483 100644 --- a/api/tests/core/offers/test_validation.py +++ b/api/tests/core/offers/test_validation.py @@ -959,3 +959,17 @@ def test_check_offer_is_eligible_to_be_headline(self): validation.check_offer_is_eligible_to_be_headline(offer_without_image) msg = "Offers without images can not be set to the headline" assert exc.value.errors["headlineOffer"] == [msg] + + +class CheckOfferNameDoesNotContainEanTest: + @pytest.mark.parametrize( + "offer_name", + [ + "Mon offre de filou - 3759217254634", + "4759217254634", + "[3759217254634] J'essaye de mettre mon offre en avant", + ], + ) + def test_check_offer_name_does_not_contain_ean_should_raise(self, offer_name): + with pytest.raises(exceptions.EanInOfferNameException): + validation.check_offer_name_does_not_contain_ean(offer_name) diff --git a/api/tests/routes/pro/patch_draft_offer_test.py b/api/tests/routes/pro/patch_draft_offer_test.py index 76200addf26..901772253ae 100644 --- a/api/tests/routes/pro/patch_draft_offer_test.py +++ b/api/tests/routes/pro/patch_draft_offer_test.py @@ -632,6 +632,26 @@ def when_trying_to_patch_forbidden_attributes(self, client): for key in forbidden_keys: assert key in response.json + def when_trying_to_set_offer_name_with_ean(self, client): + offer = offers_factories.OfferFactory( + subcategoryId=subcategories.CARTE_MUSEE.id, + name="New name", + url="http://example.com/offer", + description="description", + ) + offerers_factories.UserOffererFactory( + user__email="user@example.com", + offerer=offer.venue.managingOfferer, + ) + + data = { + "name": "Le Visible et l'invisible - Suivi de notes de travail - 9782070286256", + } + response = client.with_session_auth("user@example.com").patch(f"offers/draft/{offer.id}", json=data) + + assert response.status_code == 400 + assert response.json["name"] == ["Le titre d'une offre ne peut contenir l'EAN"] + @pytest.mark.features(WIP_EAN_CREATION=True) def when_trying_to_patch_offer_with_product_with_new_ean(self, client): user_offerer = offerers_factories.UserOffererFactory(user__email="user@example.com") diff --git a/api/tests/routes/pro/patch_offer_test.py b/api/tests/routes/pro/patch_offer_test.py index 9f3c55a0e88..67c59486c4d 100644 --- a/api/tests/routes/pro/patch_offer_test.py +++ b/api/tests/routes/pro/patch_offer_test.py @@ -877,6 +877,31 @@ def should_fail_when_url_has_no_scheme(self, app, client): assert response.status_code == 400 assert response.json["url"] == ['L\'URL doit commencer par "http://" ou "https://"'] + def should_fail_when_name_contains_ean(self, app, client): + # Given + virtual_venue = offerers_factories.VirtualVenueFactory() + offer = offers_factories.OfferFactory( + venue=virtual_venue, + subcategoryId=subcategories.CARTE_MUSEE.id, + name="New name", + url="test@test.com", + description="description", + ) + offerers_factories.UserOffererFactory( + user__email="user@example.com", + offerer=offer.venue.managingOfferer, + ) + + # When + data = { + "name": "Le Visible et l'invisible - Suivi de notes de travail - 9782070286256", + } + response = client.with_session_auth("user@example.com").patch(f"offers/{offer.id}", json=data) + + # Then + assert response.status_code == 400 + assert response.json["name"] == ["Le titre d'une offre ne peut contenir l'EAN"] + def should_fail_when_externalTicketOfficeUrl_has_no_scheme(self, app, client): # Given virtual_venue = offerers_factories.VirtualVenueFactory() diff --git a/api/tests/routes/pro/post_draft_offer_test.py b/api/tests/routes/pro/post_draft_offer_test.py index 94e1adbd027..ad80f27fed1 100644 --- a/api/tests/routes/pro/post_draft_offer_test.py +++ b/api/tests/routes/pro/post_draft_offer_test.py @@ -291,6 +291,21 @@ def test_fail_if_name_too_long(self, client): assert response.status_code == 400 assert response.json["name"] == ["Le titre de l’offre doit faire au maximum 90 caractères."] + def test_fail_if_name_contains_ean(self, client): + venue = offerers_factories.VenueFactory() + offerer = venue.managingOfferer + offerers_factories.UserOffererFactory(offerer=offerer, user__email="user@example.com") + + data = { + "name": "Le Visible et l'invisible - Suivi de notes de travail - 9782070286256", + "subcategoryId": subcategories.LIVRE_PAPIER.id, + "venueId": venue.id, + } + response = client.with_session_auth("user@example.com").post("/offers/draft", json=data) + + assert response.status_code == 400 + assert response.json["name"] == ["Le titre d'une offre ne peut contenir l'EAN"] + def test_fail_if_unknown_subcategory(self, client): venue = offerers_factories.VenueFactory() offerer = venue.managingOfferer diff --git a/api/tests/routes/pro/post_offer_test.py b/api/tests/routes/pro/post_offer_test.py index d58ef914e57..65f97faf5c3 100644 --- a/api/tests/routes/pro/post_offer_test.py +++ b/api/tests/routes/pro/post_offer_test.py @@ -361,6 +361,29 @@ def test_fail_if_name_too_long(self, client): assert response.status_code == 400 assert response.json["name"] == ["Le titre de l’offre doit faire au maximum 90 caractères."] + def test_fail_if_name_contains_ean(self, client): + # Given + venue = offerers_factories.VenueFactory() + offerer = venue.managingOfferer + offerers_factories.UserOffererFactory(offerer=offerer, user__email="user@example.com") + + # When + data = { + "venueId": venue.id, + "name": "Le Visible et l'invisible - Suivi de notes de travail - 9782070286256", + "subcategoryId": subcategories.LIVRE_PAPIER.id, + "withdrawalType": "no_ticket", + "mentalDisabilityCompliant": False, + "audioDisabilityCompliant": False, + "visualDisabilityCompliant": False, + "motorDisabilityCompliant": False, + } + response = client.with_session_auth("user@example.com").post("/offers", json=data) + + # Then + assert response.status_code == 400 + assert response.json["name"] == ["Le titre d'une offre ne peut contenir l'EAN"] + def test_fail_if_unknown_subcategory(self, client): # Given venue = offerers_factories.VenueFactory() 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 e7f7013b96e..f2ecc134e75 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 @@ -330,6 +330,24 @@ def test_update_subcategory_raises_error(self, client): ] } + def test_update_with_ean_in_name_raises(self, client): + plain_api_key, venue_provider = self.setup_active_venue_provider() + product_offer = offers_factories.ThingOfferFactory( + venue=venue_provider.venue, + subcategoryId=subcategories.ABO_BIBLIOTHEQUE.id, + lastProvider=venue_provider.provider, + ) + + response = client.with_explicit_token(plain_api_key).patch( + self.endpoint_url, + json={ + "offerId": product_offer.id, + "name": "Le Visible et l'invisible - Suivi de notes de travail - 9782070286256", + }, + ) + assert response.status_code == 400 + assert response.json == {"name": ["Le titre d'une offre ne peut contenir l'EAN"]} + def test_update_unallowed_subcategory_product_raises_error(self, client): venue, api_key = utils.create_offerer_provider_linked_to_venue() product_offer = offers_factories.ThingOfferFactory( 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 9953f19fb65..2232cb7969a 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 @@ -403,6 +403,23 @@ def test_event_category_not_accepted(self, client): assert "categoryRelatedFields.category" in response.json assert offers_models.Offer.query.first() is None + @pytest.mark.usefixtures("db_session") + def test_offer_with_ean_in_name_is_not_accepted(self, client): + 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": "physical", "venueId": venue_provider.venue.id}, + "categoryRelatedFields": {"category": "LIVRE_NUMERIQUE"}, + "accessibility": utils.ACCESSIBILITY_FIELDS, + "name": "Le Visible et l'invisible - Suivi de notes de travail - 9782070286256", + }, + ) + + assert response.status_code == 400 + assert response.json["name"] == ["Le titre d'une offre ne peut contenir l'EAN"] + @pytest.mark.usefixtures("db_session") def test_venue_allowed(self, client): utils.create_offerer_provider_linked_to_venue()