diff --git a/Dockerfile b/Dockerfile index 33649ae3b..78726f92a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -49,7 +49,8 @@ RUN --mount=type=cache,target=/root/.cache/pip \ python -m pip install -r requirements.txt # Switch to the non-privileged user to run the application. -USER appuser +# this is commented out because running as root allows to upload files locally +# USER appuser # Copy the source code into the container. COPY . . diff --git a/api/serializers/declaration.py b/api/serializers/declaration.py index 6dd9a067f..efe09b7ce 100644 --- a/api/serializers/declaration.py +++ b/api/serializers/declaration.py @@ -90,23 +90,34 @@ def update(self, instance, validated_data): ADDABLE_ELEMENT_FIELDS = ( + "new", + "new_name", + "new_description", "authorization_mode", "fr_reason", "fr_details", "eu_reference_country", "eu_legal_source", "eu_details", - "new_description", - "new", - "request_private_notes", "request_status", + "request_private_notes", ) +DECLARED_ELEMENT_SHARED_FIELDS = ADDABLE_ELEMENT_FIELDS + ("type",) + class DeclaredElementNestedField: + # DRF ne gère pas automatiquement la création des nested-fields : + # https://www.django-rest-framework.org/api-guide/serializers/#writable-nested-representations def create(self, validated_data): - # DRF ne gère pas automatiquement la création des nested-fields : - # https://www.django-rest-framework.org/api-guide/serializers/#writable-nested-representations + self._set_element(validated_data) + return super().create(validated_data) + + def update(self, instance, validated_data): + self._set_element(validated_data) + return super().update(instance, validated_data) + + def _set_element(self, validated_data): element = validated_data.pop(self.nested_field_name, None) if element: id = element.get("id") @@ -119,8 +130,6 @@ def create(self, validated_data): } ) - return super().create(validated_data) - class DeclaredIngredientCommonSerializer(DeclaredElementNestedField, PrivateFieldsSerializer): private_fields = ("request_private_notes", "request_status") @@ -137,17 +146,15 @@ class DeclaredPlantSerializer(DeclaredIngredientCommonSerializer): class Meta: model = DeclaredPlant - fields = ADDABLE_ELEMENT_FIELDS + ( + fields = DECLARED_ELEMENT_SHARED_FIELDS + ( "id", "declaration", "element", - "new_name", "active", "used_part", - "unit", "quantity", + "unit", "preparation", - "type", ) @@ -160,18 +167,16 @@ class DeclaredMicroorganismSerializer(DeclaredIngredientCommonSerializer): class Meta: model = DeclaredMicroorganism - fields = ADDABLE_ELEMENT_FIELDS + ( + fields = DECLARED_ELEMENT_SHARED_FIELDS + ( "id", "declaration", "element", - "new_name", - "new_species", - "new_genre", "active", "activated", + "new_species", + "new_genre", "strain", "quantity", - "type", ) @@ -185,16 +190,14 @@ class DeclaredIngredientSerializer(DeclaredIngredientCommonSerializer): class Meta: model = DeclaredIngredient - fields = ADDABLE_ELEMENT_FIELDS + ( + fields = DECLARED_ELEMENT_SHARED_FIELDS + ( "id", "declaration", "element", - "new_name", - "new_type", "active", + "new_type", "quantity", "unit", - "type", ) @@ -207,15 +210,13 @@ class DeclaredSubstanceSerializer(DeclaredIngredientCommonSerializer): class Meta: model = DeclaredSubstance - fields = ADDABLE_ELEMENT_FIELDS + ( + fields = DECLARED_ELEMENT_SHARED_FIELDS + ( "id", "declaration", "element", - "new_name", "active", "quantity", "unit", - "type", ) @@ -295,6 +296,14 @@ class Meta: read_only_fields = fields +def add_enum_or_personnalized_value(item, custom_value): + if item: + if "(à préciser)" not in str(item).lower(): + return item.name + else: + return "Autre : " + str(custom_value) + + class OpenDataDeclarationSerializer(serializers.ModelSerializer): id = serializers.SerializerMethodField() decision = serializers.SerializerMethodField() @@ -386,17 +395,17 @@ def get_article_procedure(self, obj): Unify all types of Articles 15 categories. If not part of Article 15, then return display name """ - article = Declaration.Article(obj.article).label - if "Article 15" in article: + if obj.article in [ + Declaration.Article.ARTICLE_15, + Declaration.Article.ARTICLE_15_WARNING, + Declaration.Article.ARTICLE_15_HIGH_RISK_POPULATION, + ]: return "Article 15" - else: - return article + elif obj.article: + return obj.article.label def get_forme_galenique(self, obj): - if obj.galenic_formulation: - return obj.galenic_formulation.name - else: - return None + return add_enum_or_personnalized_value(obj.galenic_formulation, obj.other_galenic_formulation) def get_dose_journaliere(self, obj): return obj.daily_recommended_dose @@ -408,13 +417,19 @@ def get_mises_en_garde(self, obj): return obj.warning def get_objectif_effet(self, obj): - return [effect.name for effect in obj.effects.all()] + effects = [] + for effect in obj.effects.all(): + effects.append(add_enum_or_personnalized_value(effect, obj.other_effects)) + return effects def get_aromes(self, obj): return obj.flavor def get_facteurs_risques(self, obj): - return [condition.name for condition in obj.conditions_not_recommended.all()] + risk_factors = [] + for risk_factor in obj.conditions_not_recommended.all(): + risk_factors.append(add_enum_or_personnalized_value(risk_factor, obj.other_effects)) + return risk_factors def get_populations_cibles(self, obj): return [population.name for population in obj.populations.all()] diff --git a/api/tests/test_declaration.py b/api/tests/test_declaration.py index 2c8b008d7..9338680f9 100644 --- a/api/tests/test_declaration.py +++ b/api/tests/test_declaration.py @@ -38,7 +38,15 @@ VisaRoleFactory, PlantSynonymFactory, ) -from data.models import Attachment, Declaration, DeclaredMicroorganism, DeclaredPlant, Snapshot +from data.models import ( + Attachment, + Declaration, + DeclaredMicroorganism, + DeclaredPlant, + DeclaredSubstance, + Snapshot, + IngredientType, +) from .utils import authenticate @@ -1335,9 +1343,9 @@ def test_filter_by_article(self): Les déclarations peuvent être filtrées par article """ InstructionRoleFactory(user=authenticate.user) - art_15 = AwaitingInstructionDeclarationFactory(overriden_article=Declaration.Article.ARTICLE_15) - AwaitingInstructionDeclarationFactory(overriden_article=Declaration.Article.ARTICLE_16) - AwaitingInstructionDeclarationFactory(overriden_article=Declaration.Article.ANSES_REFERAL) + art_15 = AwaitingInstructionDeclarationFactory(overridden_article=Declaration.Article.ARTICLE_15) + AwaitingInstructionDeclarationFactory(overridden_article=Declaration.Article.ARTICLE_16) + AwaitingInstructionDeclarationFactory(overridden_article=Declaration.Article.ANSES_REFERAL) # Filtrage pour obtenir les déclarations en article 15 url = f"{reverse('api:list_all_declarations')}?article=ART_15" @@ -1579,7 +1587,7 @@ def test_update_article(self): art_15.refresh_from_db() self.assertEqual(art_15.article, Declaration.Article.ARTICLE_16) self.assertEqual(art_15.calculated_article, Declaration.Article.ARTICLE_15) - self.assertEqual(art_15.overriden_article, Declaration.Article.ARTICLE_16) + self.assertEqual(art_15.overridden_article, Declaration.Article.ARTICLE_16) @authenticate def test_update_article_unauthorized(self): @@ -1924,43 +1932,201 @@ def test_replace_declared_plant(self): ) self.assertEqual(response.status_code, status.HTTP_200_OK, response.json()) declared_plant.refresh_from_db() + self.assertEqual(declared_plant.plant, plant) self.assertEqual(declared_plant.request_status, DeclaredPlant.AddableStatus.REPLACED) self.assertFalse(declared_plant.new) - self.assertEqual(declared_plant.plant, plant) @authenticate - def test_cannot_replace_element_different_type(self): + def test_replace_inactive_ingredient_with_active(self): """ - Pour reduire le scope de changements, temporairement bloque le remplacement d'une demande - avec un element d'un type different + Vérifier que c'est possible de remplacer un ingrédient par un autre + Le front est responsable d'envoyer les bonnes valeurs pour `active`, `quantity`, `unit` + selon la logique gérée là-bas """ InstructionRoleFactory(user=authenticate.user) declaration = DeclarationFactory() - declared_plant = DeclaredPlantFactory(declaration=declaration) + declared_ingredient = DeclaredIngredientFactory( + declaration=declaration, + new=True, + new_type=IngredientType.NON_ACTIVE_INGREDIENT.label, + active=False, + quantity=None, + unit=None, + ingredient=None, + ) + self.assertNotEqual(declared_ingredient.request_status, DeclaredPlant.AddableStatus.REPLACED) + active_ingredient = IngredientFactory(ingredient_type=IngredientType.ACTIVE_INGREDIENT) + unit = SubstanceUnitFactory() + + response = self.client.post( + reverse("api:declared_element_replace", kwargs={"pk": declared_ingredient.id, "type": "other-ingredient"}), + { + "element": {"id": active_ingredient.id, "type": "other-ingredient"}, + "additional_fields": {"active": True, "quantity": 20, "unit": unit.id}, + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK, response.json()) + declared_ingredient.refresh_from_db() + self.assertEqual(declared_ingredient.ingredient, active_ingredient) + self.assertEqual(declared_ingredient.new_type, IngredientType.NON_ACTIVE_INGREDIENT.label) # pas changé + self.assertTrue(declared_ingredient.active) + self.assertEqual(declared_ingredient.quantity, 20) + self.assertEqual(declared_ingredient.unit, unit) + self.assertEqual(declared_ingredient.request_status, DeclaredPlant.AddableStatus.REPLACED) + self.assertFalse(declared_ingredient.new) + + @authenticate + def test_can_replace_plant_request_with_microorganism(self): + """ + Test de remplacement cross-type : plante vers microorganisme + Verifier que les données sont copiées, et les nouvelles données sont sauvegardées. + """ + InstructionRoleFactory(user=authenticate.user) + + declaration = DeclarationFactory() + declared_plant = DeclaredPlantFactory( + declaration=declaration, new_name="Test plant", new_description="Test description", new=True, quantity=10 + ) self.assertEqual(declared_plant.request_status, DeclaredPlant.AddableStatus.REQUESTED) microorganism = MicroorganismFactory() response = self.client.post( reverse("api:declared_element_replace", kwargs={"pk": declared_plant.id, "type": "plant"}), - {"element": {"id": microorganism.id, "type": "microorganism"}}, + { + "element": {"id": microorganism.id, "type": "microorganism"}, + "additional_fields": { + "strain": "Test strain", + "activated": False, + "quantity": 90, + }, + }, format="json", ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST, response.json()) - declared_plant.refresh_from_db() - self.assertEqual(declared_plant.request_status, DeclaredPlant.AddableStatus.REQUESTED) - self.assertNotEqual(declared_plant.plant, microorganism) + self.assertEqual(response.status_code, status.HTTP_200_OK, response.json()) + with self.assertRaises(DeclaredPlant.DoesNotExist): + DeclaredPlant.objects.get(id=declared_plant.id) + + self.assertEqual(DeclaredMicroorganism.objects.count(), 1) + declared_microorganism = DeclaredMicroorganism.objects.get(declaration=declaration) + + self.assertEqual(declared_microorganism.microorganism, microorganism) + self.assertEqual(declared_microorganism.request_status, DeclaredMicroorganism.AddableStatus.REPLACED) + self.assertFalse(declared_microorganism.new) + + # est-ce que le nom est copié dans le champ espèce ? + self.assertEqual(declared_microorganism.new_species, "Test plant") + self.assertEqual(declared_microorganism.new_genre, "") + # est-ce que les nouveaux champs sont sauvegardés ? + self.assertEqual(declared_microorganism.strain, "Test strain") + self.assertEqual(declared_microorganism.activated, False) + self.assertEqual(declared_microorganism.quantity, 90) + # est-ce que les anciens champs sont sauvegardés ? + self.assertEqual(declared_microorganism.new_description, "Test description") + + @authenticate + def test_can_replace_microorganism_request_with_plant(self): + """ + Test de remplacement cross-type : microoganisme vers plante + Verifier que les données sont copiées, et les nouvelles données sont sauvegardées. + """ + InstructionRoleFactory(user=authenticate.user) + + declaration = DeclarationFactory() + declared_microorganism = DeclaredMicroorganismFactory( + declaration=declaration, + new_species="test", + new_genre="testing", + new_description="Test description", + new=True, + quantity=10, + ) + self.assertEqual(declared_microorganism.request_status, DeclaredMicroorganism.AddableStatus.REQUESTED) + plant = PlantFactory() + used_part = PlantPartFactory() + unit = SubstanceUnitFactory() + + response = self.client.post( + reverse("api:declared_element_replace", kwargs={"pk": declared_microorganism.id, "type": "microorganism"}), + { + "element": {"id": plant.id, "type": "plant"}, + "additional_fields": { + "used_part": used_part.id, + "unit": unit.id, + "quantity": 90, + }, + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK, response.json()) + with self.assertRaises(DeclaredMicroorganism.DoesNotExist): + DeclaredMicroorganism.objects.get(id=declared_microorganism.id) + + self.assertEqual(DeclaredPlant.objects.count(), 1) + declared_plant = DeclaredPlant.objects.get(declaration=declaration) + + self.assertEqual(declared_plant.plant, plant) + self.assertEqual(declared_plant.request_status, DeclaredPlant.AddableStatus.REPLACED) + self.assertFalse(declared_plant.new) + + # est-ce que l'espèce + genre sont copiés dans le champ nom ? + self.assertEqual(declared_plant.new_name, "test testing") + # est-ce que les nouveaux champs sont sauvegardés ? + self.assertEqual(declared_plant.used_part, used_part) + self.assertEqual(declared_plant.unit, unit) + self.assertEqual(declared_plant.quantity, 90) + # est-ce que les anciens champs sont sauvegardés ? + self.assertEqual(declared_plant.new_description, "Test description") + + @authenticate + def test_can_replace_substance_request_with_plant(self): + """ + Test de remplacement cross-type : microoganisme vers plante + Verifier que les données complexes, comme unit, sont bien gardées si elles ne sont pas spécifiées. + """ + InstructionRoleFactory(user=authenticate.user) + + declaration = DeclarationFactory() + unit = SubstanceUnitFactory() + declared_substance = DeclaredSubstanceFactory( + declaration=declaration, new_description="Test description", new=True, unit=unit + ) + self.assertEqual(declared_substance.request_status, DeclaredSubstance.AddableStatus.REQUESTED) + plant = PlantFactory() + + response = self.client.post( + reverse("api:declared_element_replace", kwargs={"pk": declared_substance.id, "type": "substance"}), + { + "element": {"id": plant.id, "type": "plant"}, + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK, response.json()) + with self.assertRaises(DeclaredSubstance.DoesNotExist): + DeclaredSubstance.objects.get(id=declared_substance.id) + + self.assertEqual(DeclaredPlant.objects.count(), 1) + declared_plant = DeclaredPlant.objects.get(declaration=declaration) + + self.assertEqual(declared_plant.plant, plant) + self.assertEqual(declared_plant.request_status, DeclaredPlant.AddableStatus.REPLACED) + self.assertFalse(declared_plant.new) + + # est-ce que les anciens champs sont sauvegardés ? + self.assertEqual(declared_plant.new_description, "Test description") + self.assertEqual(declared_plant.unit, unit) @authenticate def test_can_add_synonym_on_replace(self): """ - C'est possible d'envoyer une liste avec un nouvel element pour - ajouter un synonyme et laisser des synonymes existantes non-modifiées + C'est possible d'envoyer une liste avec un element pour ajouter un synonyme + et laisser des synonymes existantes non-modifiées """ InstructionRoleFactory(user=authenticate.user) declaration = DeclarationFactory() - declared_plant = DeclaredPlantFactory(declaration=declaration, new=True) + declared_plant = DeclaredPlantFactory(declaration=declaration) plant = PlantFactory() synonym = PlantSynonymFactory.create(name="Eucalyptus Plant", standard_name=plant) @@ -1982,6 +2148,10 @@ def test_can_add_synonym_on_replace(self): @authenticate def test_cannot_provide_synonym_with_no_name(self): + """ + Si on donne un nom vide, ignore-le. + Si on ne donne pas de nom, envoie un 400. + """ InstructionRoleFactory(user=authenticate.user) declaration = DeclarationFactory() @@ -2016,13 +2186,12 @@ def test_cannot_provide_synonym_with_no_name(self): @authenticate def test_cannot_add_duplicate_synonyms(self): """ - C'est possible d'envoyer une liste avec un nouvel element pour - ajouter un synonyme et laisser des synonymes existantes non-modifiées + Ignorer les synonymes qui matchent des synonymes existantes """ InstructionRoleFactory(user=authenticate.user) declaration = DeclarationFactory() - declared_plant = DeclaredPlantFactory(declaration=declaration, new=True) + declared_plant = DeclaredPlantFactory(declaration=declaration) plant = PlantFactory() synonym = PlantSynonymFactory.create(name="Eucalyptus Plant", standard_name=plant) @@ -2039,3 +2208,93 @@ def test_cannot_add_duplicate_synonyms(self): self.assertEqual(plant.plantsynonym_set.count(), 2) self.assertIsNotNone(plant.plantsynonym_set.get(name="New synonym")) self.assertEqual(plant.plantsynonym_set.get(id=synonym.id).name, synonym.name) + + @authenticate + def test_elements_unchanged_on_replace_fail(self): + """ + Si on donne des mauvaises données à sauvegarder, annule tout l'action, y compris la MAJ des synonymes + """ + InstructionRoleFactory(user=authenticate.user) + + declaration = DeclarationFactory() + declared_microorganism = DeclaredMicroorganismFactory(declaration=declaration) + plant = PlantFactory() + + response = self.client.post( + reverse("api:declared_element_replace", kwargs={"pk": declared_microorganism.id, "type": "microorganism"}), + { + "element": {"id": plant.id, "type": "plant"}, + "additional_fields": { + "used_part": 99, # fail: id unrecognised + }, + "synonyms": [{"name": "New synonym"}], + }, + format="json", + ) + body = response.json() + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST, body) + self.assertEqual( + body["fieldErrors"]["usedPart"][0], "Clé primaire «\xa099\xa0» non valide - l'objet n'existe pas.", body + ) + # assertion implicite - l'objet existe tjs + DeclaredMicroorganism.objects.get(id=declared_microorganism.id) + self.assertFalse(plant.plantsynonym_set.filter(name="New synonym").exists()) + self.assertEqual(DeclaredPlant.objects.count(), 0) + + @authenticate + def test_elements_unchanged_on_synonym_fail(self): + """ + Si l'ajout de synonyme ne passe pas, on annule le remplacement complètement + """ + InstructionRoleFactory(user=authenticate.user) + + declaration = DeclarationFactory() + declared_microorganism = DeclaredMicroorganismFactory(declaration=declaration, quantity=10) + plant = PlantFactory() + + response = self.client.post( + reverse("api:declared_element_replace", kwargs={"pk": declared_microorganism.id, "type": "microorganism"}), + { + "element": {"id": plant.id, "type": "plant"}, + "additional_fields": { + "quantity": 99, + }, + "synonyms": [{}], + }, + format="json", + ) + body = response.json() + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST, body) + still_existing_declared_microorganism = DeclaredMicroorganism.objects.get(id=declared_microorganism.id) + self.assertEqual(still_existing_declared_microorganism.quantity, 10) + self.assertEqual(DeclaredPlant.objects.count(), 0) + + @authenticate + def test_id_ignored_in_replace(self): + """ + Vérifier que l'id d'un nouvel ingrédient déclaré est généré automatiquement, et non pas avec les données passées + """ + InstructionRoleFactory(user=authenticate.user) + + declaration = DeclarationFactory() + declared_microorganism = DeclaredMicroorganismFactory(id=66, declaration=declaration, new_species="test") + self.assertEqual(declared_microorganism.id, 66) + plant = PlantFactory() + unit = SubstanceUnitFactory() + + response = self.client.post( + reverse("api:declared_element_replace", kwargs={"pk": declared_microorganism.id, "type": "microorganism"}), + { + "element": {"id": plant.id, "type": "plant"}, + "additional_fields": { + "id": 99, + "unit": unit.id, + }, + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK, response.json()) + new_declared_plant = DeclaredPlant.objects.first() + self.assertEqual(new_declared_plant.unit, unit) + self.assertNotEqual(new_declared_plant.id, 66) + self.assertNotEqual(new_declared_plant.id, 99) diff --git a/api/tests/test_declaration_flow.py b/api/tests/test_declaration_flow.py index 134213970..66d48c165 100644 --- a/api/tests/test_declaration_flow.py +++ b/api/tests/test_declaration_flow.py @@ -718,8 +718,8 @@ def test_visor_cant_modify_on_refuse(self): declaration.refresh_from_db() latest_snapshot = declaration.snapshots.latest("creation_date") self.assertEqual(declaration.status, Declaration.DeclarationStatus.AWAITING_INSTRUCTION) - self.assertEqual(latest_snapshot.comment, "À authoriser") self.assertEqual(latest_snapshot.status, Declaration.DeclarationStatus.AWAITING_INSTRUCTION) + self.assertEqual(latest_snapshot.comment, "") self.assertEqual(latest_snapshot.expiration_days, None) self.assertEqual(latest_snapshot.blocking_reasons, None) diff --git a/api/views/declaration/declaration.py b/api/views/declaration/declaration.py index c6630f5d1..824d94bac 100644 --- a/api/views/declaration/declaration.py +++ b/api/views/declaration/declaration.py @@ -406,7 +406,7 @@ def post(self, request, pk): if new_article not in Declaration.Article: raise ProjectAPIException(global_error="Merci de spécifier un article valide") - declaration.overriden_article = Declaration.Article(new_article) + declaration.overridden_article = Declaration.Article(new_article) declaration.save() declaration.refresh_from_db() serializer = self.get_serializer(declaration) @@ -637,7 +637,6 @@ def perform_snapshot_creation(self, request, declaration): declaration.create_snapshot( user=request.user, action=self.get_snapshot_action(request, declaration), - comment=declaration.post_validation_producer_message, post_validation_status=self.get_snapshot_post_validation_status(request, declaration), ) diff --git a/api/views/declaration/declared_element.py b/api/views/declaration/declared_element.py index 0317b87be..3a8533cd1 100644 --- a/api/views/declaration/declared_element.py +++ b/api/views/declaration/declared_element.py @@ -1,4 +1,5 @@ import abc +from django.db import transaction from django.shortcuts import get_object_or_404 from rest_framework.exceptions import ParseError from rest_framework.generics import ListAPIView, RetrieveAPIView @@ -11,10 +12,6 @@ DeclaredIngredient, DeclaredMicroorganism, Declaration, - Plant, - Microorganism, - Substance, - Ingredient, PlantSynonym, MicroorganismSynonym, SubstanceSynonym, @@ -58,45 +55,43 @@ def get_queryset(self): ) -class ElementMappingMixin: - type_mapping = { - "plant": { - "model": DeclaredPlant, - "element_model": Plant, - "synonym_model": PlantSynonym, - "serializer": DeclaredPlantSerializer, - }, - "microorganism": { - "model": DeclaredMicroorganism, - "element_model": Microorganism, - "synonym_model": MicroorganismSynonym, - "serializer": DeclaredMicroorganismSerializer, - }, - "substance": { - "model": DeclaredSubstance, - "element_model": Substance, - "synonym_model": SubstanceSynonym, - "serializer": DeclaredSubstanceSerializer, - }, - "other-ingredient": { - "model": DeclaredIngredient, - "element_model": Ingredient, - "synonym_model": IngredientSynonym, - "serializer": DeclaredIngredientSerializer, - }, - } +TYPE_MAPPING = { + "plant": { + "model": DeclaredPlant, + "synonym_model": PlantSynonym, + "serializer": DeclaredPlantSerializer, + }, + "microorganism": { + "model": DeclaredMicroorganism, + "synonym_model": MicroorganismSynonym, + "serializer": DeclaredMicroorganismSerializer, + }, + "substance": { + "model": DeclaredSubstance, + "synonym_model": SubstanceSynonym, + "serializer": DeclaredSubstanceSerializer, + }, + "other-ingredient": { + "model": DeclaredIngredient, + "synonym_model": IngredientSynonym, + "serializer": DeclaredIngredientSerializer, + "attribute": "ingredient", + }, +} + +class ElementMappingMixin: @property def element_type(self): return self.kwargs["type"] @property def type_info(self): - if self.element_type not in self.type_mapping: - valid_type_list = list(self.type_mapping.keys()) + if self.element_type not in TYPE_MAPPING: + valid_type_list = list(TYPE_MAPPING.keys()) raise ParseError(detail=f"Unknown type: '{self.element_type}' not in {valid_type_list}") - return self.type_mapping[self.element_type] + return TYPE_MAPPING[self.element_type] @property def type_model(self): @@ -106,10 +101,6 @@ def type_model(self): def type_serializer(self): return self.type_info["serializer"] - @property - def element_model(self): - return self.type_info["element_model"] - @property def synonym_model(self): return self.type_info["synonym_model"] @@ -129,13 +120,14 @@ class DeclaredElementActionAbstractView(APIView, ElementMappingMixin): permission_classes = [(IsInstructor | IsVisor)] __metaclass__ = abc.ABCMeta + @transaction.atomic def post(self, request, pk, type): element = get_object_or_404(self.type_model, pk=pk) - self._update_element(element, request) - element.save() + element_to_save = self._update_element(element, request) + element_to_save.save() - return Response(self.type_serializer(element, context={"request": request}).data) + return Response(self.type_serializer(element_to_save, context={"request": request}).data) @abc.abstractmethod def _update_element(self, element, request): @@ -146,40 +138,67 @@ class DeclaredElementRequestInfoView(DeclaredElementActionAbstractView): def _update_element(self, element, request): element.request_status = self.type_model.AddableStatus.INFORMATION element.request_private_notes = request.data.get("request_private_notes") + return element class DeclaredElementRejectView(DeclaredElementActionAbstractView): def _update_element(self, element, request): element.request_status = self.type_model.AddableStatus.REJECTED element.request_private_notes = request.data.get("request_private_notes") + return element class DeclaredElementReplaceView(DeclaredElementActionAbstractView): - def _update_element(self, element, request): + def _update_element(self, declared_element, request): try: - existing_element_id = request.data["element"]["id"] - existing_element_type = request.data["element"]["type"] + replacement_element_id = request.data["element"]["id"] + replacement_type = request.data["element"]["type"] except KeyError: raise ParseError(detail="Must provide a dict 'element' with id and type") - if existing_element_type != self.element_type: - raise ParseError(detail="Cannot replace element request with existing element of a different type") + # utiliser les valeurs serialisées pour MAJ ou créer l'element déclaré + element_data = self.type_serializer(declared_element).data + additional_fields = request.data.get("additional_fields", {}) + element_data.update(additional_fields) + element_data.update( + { + "element": {"id": replacement_element_id}, + "request_status": self.type_model.AddableStatus.REPLACED, + "new": False, + } + ) - try: - existing_element = self.element_model.objects.get(pk=existing_element_id) - except self.element_model.DoesNotExist: - raise ParseError(detail=f"No {self.element_type} exists with id {existing_element_id}") + new_type = TYPE_MAPPING[replacement_type] + if replacement_type != self.element_type: + # gérer le difference en nom entre microorganismes et les autres types + if replacement_type == "microorganism": + element_data["new_species"] = declared_element.new_name + elif self.element_type == "microorganism": + element_data["new_name"] = declared_element.new_name - setattr(element, self.element_type, existing_element) - element.request_status = self.type_model.AddableStatus.REPLACED - element.new = False + serializer = new_type["serializer"](data=element_data) + serializer.is_valid(raise_exception=True) - synonyms = request.data.get("synonyms", []) + serializer.validated_data["declaration"] = declared_element.declaration + new_declared_element = serializer.create(serializer.validated_data) + declared_element.delete() + declared_element = new_declared_element + else: + serializer = self.type_serializer(declared_element, data=element_data, partial=True) + serializer.is_valid(raise_exception=True) + declared_element = serializer.save() + + replacement_synonym_model = new_type["synonym_model"] + synonyms = request.data.get("synonyms", []) + replacement_type_attribute = new_type.get("attribute", replacement_type) + replacement_element = getattr(declared_element, replacement_type_attribute) for synonym in synonyms: try: name = synonym["name"] - if name and not self.synonym_model.objects.filter(name=name).exists(): - self.synonym_model.objects.create(standard_name=existing_element, name=name) + if name and not replacement_synonym_model.objects.filter(name=name).exists(): + replacement_synonym_model.objects.create(standard_name=replacement_element, name=name) except KeyError: raise ParseError(detail="Must provide 'name' to create new synonym") + + return declared_element diff --git a/config/tests/test_automatic_approval.py b/config/tests/test_automatic_approval.py index 347e6c6e4..94535982e 100644 --- a/config/tests/test_automatic_approval.py +++ b/config/tests/test_automatic_approval.py @@ -49,11 +49,11 @@ def test_awaiting_declaration_approved_art_15(self, _): * aucune action d'instruction n'a été effectuée dessus * son snapshot de soumission date de plus de trente jours. """ - declaration_15 = AwaitingInstructionDeclarationFactory(overriden_article=Declaration.Article.ARTICLE_15) + declaration_15 = AwaitingInstructionDeclarationFactory(overridden_article=Declaration.Article.ARTICLE_15) TestAutomaticApproval._create_submission_snapshot(declaration_15) declaration_high_risk_population = AwaitingInstructionDeclarationFactory( - overriden_article=Declaration.Article.ARTICLE_15_HIGH_RISK_POPULATION + overridden_article=Declaration.Article.ARTICLE_15_HIGH_RISK_POPULATION ) TestAutomaticApproval._create_submission_snapshot(declaration_high_risk_population) @@ -76,7 +76,7 @@ def test_double_submission_declaration_approved_art_15(self, _): deux snapshots type SUBMIT sont créés. Le bot doit quand même approuver ces déclarations. Plus d'infos : https://github.com/betagouv/complements-alimentaires/issues/1395 """ - declaration_15 = AwaitingInstructionDeclarationFactory(overriden_article=Declaration.Article.ARTICLE_15) + declaration_15 = AwaitingInstructionDeclarationFactory(overridden_article=Declaration.Article.ARTICLE_15) # Double soumission TestAutomaticApproval._create_submission_snapshot(declaration_15) @@ -94,7 +94,7 @@ def test_non_submission_snapshots_not_approved_art_15(self, _): Si au moins un snapshot est présent avec un type différent de "SUBMIT" la déclaration ne devra pas être autorisée """ - declaration_15 = AwaitingInstructionDeclarationFactory(overriden_article=Declaration.Article.ARTICLE_15) + declaration_15 = AwaitingInstructionDeclarationFactory(overridden_article=Declaration.Article.ARTICLE_15) # Double soumission TestAutomaticApproval._create_submission_snapshot(declaration_15) @@ -117,7 +117,7 @@ def test_email_sent_declaration_approved(self, mocked_brevo): """ L'email d'approbation doit être envoyé lors d'une approbation automatique """ - declaration = AwaitingInstructionDeclarationFactory(overriden_article=Declaration.Article.ARTICLE_15) + declaration = AwaitingInstructionDeclarationFactory(overridden_article=Declaration.Article.ARTICLE_15) TestAutomaticApproval._create_submission_snapshot(declaration) approve_declarations() @@ -137,7 +137,7 @@ def test_awaiting_declaration_not_approved_without_setting(self, mocked_brevo): Le bot ne doit pas approuver des déclarations si le setting ENABLE_AUTO_VALIDATION n'est pas True """ - declaration = AwaitingInstructionDeclarationFactory(overriden_article=Declaration.Article.ARTICLE_15) + declaration = AwaitingInstructionDeclarationFactory(overridden_article=Declaration.Article.ARTICLE_15) TestAutomaticApproval._create_submission_snapshot(declaration) approve_declarations() @@ -150,7 +150,7 @@ def test_awaiting_declaration_not_approved_art_15_vig(self, mocked_brevo): Une déclaration en attente d'instruction de doit pas se valider si elle a l'article 15 vigilance """ - declaration = AwaitingInstructionDeclarationFactory(overriden_article=Declaration.Article.ARTICLE_15_WARNING) + declaration = AwaitingInstructionDeclarationFactory(overridden_article=Declaration.Article.ARTICLE_15_WARNING) TestAutomaticApproval._create_submission_snapshot(declaration) approve_declarations() @@ -162,7 +162,7 @@ def test_awaiting_declaration_not_approved_art_16(self, mocked_brevo): """ Une déclaration en attente d'instruction de doit pas se valider si elle a l'article 16 """ - declaration = AwaitingInstructionDeclarationFactory(overriden_article=Declaration.Article.ARTICLE_16) + declaration = AwaitingInstructionDeclarationFactory(overridden_article=Declaration.Article.ARTICLE_16) TestAutomaticApproval._create_submission_snapshot(declaration) approve_declarations() @@ -174,7 +174,7 @@ def test_awaiting_declaration_not_approved_art_anses(self, mocked_brevo): """ Une déclaration en attente d'instruction de doit pas se valider si elle a l'article ANSES """ - declaration = AwaitingInstructionDeclarationFactory(overriden_article=Declaration.Article.ANSES_REFERAL) + declaration = AwaitingInstructionDeclarationFactory(overridden_article=Declaration.Article.ANSES_REFERAL) TestAutomaticApproval._create_submission_snapshot(declaration) approve_declarations() @@ -200,7 +200,7 @@ def test_awaiting_declaration_not_approved_instructed(self, mocked_brevo): actions d'instruction ont été effectuées dessus, par exemple des observations, objections, requêtes de visa, etc. """ - declaration = AwaitingInstructionDeclarationFactory(overriden_article=Declaration.Article.ARTICLE_15) + declaration = AwaitingInstructionDeclarationFactory(overridden_article=Declaration.Article.ARTICLE_15) TestAutomaticApproval._create_submission_snapshot(declaration) # On crée un autre snapshot indiquant que la déclaration a subi des actions autres @@ -224,7 +224,7 @@ def test_ongoing_declaration_not_approved(self, mocked_brevo): Une déclaration dont l'instruction est en cours ne doit pas se valider toute seule """ - declaration = OngoingInstructionDeclarationFactory(overriden_article=Declaration.Article.ARTICLE_15) + declaration = OngoingInstructionDeclarationFactory(overridden_article=Declaration.Article.ARTICLE_15) TestAutomaticApproval._create_submission_snapshot(declaration) approve_declarations() @@ -236,7 +236,7 @@ def test_observed_declaration_not_approved(self, mocked_brevo): """ Une déclaration en observation ne doit pas se valider toute seule """ - declaration = ObservationDeclarationFactory(overriden_article=Declaration.Article.ARTICLE_15) + declaration = ObservationDeclarationFactory(overridden_article=Declaration.Article.ARTICLE_15) TestAutomaticApproval._create_submission_snapshot(declaration) approve_declarations() @@ -248,7 +248,7 @@ def test_objected_declaration_not_approved(self, mocked_brevo): """ Une déclaration en objection ne doit pas se valider toute seule """ - declaration = ObjectionDeclarationFactory(overriden_article=Declaration.Article.ARTICLE_15) + declaration = ObjectionDeclarationFactory(overridden_article=Declaration.Article.ARTICLE_15) TestAutomaticApproval._create_submission_snapshot(declaration) approve_declarations() @@ -261,7 +261,7 @@ def test_abandoned_declaration_not_approved(self, mocked_brevo): Une déclaration en abandon ne doit pas se valider toute seule """ declaration = InstructionReadyDeclarationFactory( - status=Declaration.DeclarationStatus.ABANDONED, overriden_article=Declaration.Article.ARTICLE_15 + status=Declaration.DeclarationStatus.ABANDONED, overridden_article=Declaration.Article.ARTICLE_15 ) TestAutomaticApproval._create_submission_snapshot(declaration) @@ -275,7 +275,7 @@ def test_refused_declaration_not_approved(self, mocked_brevo): Une déclaration refusée ne doit pas se valider toute seule """ declaration = InstructionReadyDeclarationFactory( - status=Declaration.DeclarationStatus.REJECTED, overriden_article=Declaration.Article.ARTICLE_15 + status=Declaration.DeclarationStatus.REJECTED, overridden_article=Declaration.Article.ARTICLE_15 ) TestAutomaticApproval._create_submission_snapshot(declaration) @@ -289,7 +289,7 @@ def test_withdrawn_declaration_not_approved(self, mocked_brevo): Une déclaration retirée du marché ne doit pas se valider toute seule """ declaration = InstructionReadyDeclarationFactory( - status=Declaration.DeclarationStatus.WITHDRAWN, overriden_article=Declaration.Article.ARTICLE_15 + status=Declaration.DeclarationStatus.WITHDRAWN, overridden_article=Declaration.Article.ARTICLE_15 ) TestAutomaticApproval._create_submission_snapshot(declaration) diff --git a/data/README.md b/data/README.md index 7c2ec3706..a54200fc4 100644 --- a/data/README.md +++ b/data/README.md @@ -44,7 +44,7 @@ REF_ICA_FONCTION_INGREDIENT | | | | | | |ICA_POPULATION_RISQUE_DECLAREE | | | |ICA_SUBSTANCE_DECLAREE | | | |ICA_USAGER | | 🕵️anonymisée (contient Foreign Key vers USR, ADM, ETAB) | -|REF_ICA_TYPE_DECLARATION | |Enum ? ou obsolète ? (Art 1(, Art 1-, Simplifiée)) | +|REF_ICA_TYPE_DECLARATION | |Enum ? ou obsolète ? (Art 15, Art 16, Simplifiée)) | |REF_ICA_TYPE_HERITAGE | | Enum ? (Simplifié ou Nouvelle formule)| |REF_ICA_TYPE_VERSION_DECLARATION | | | |REF_ICA_FORME_GALENIQUE | | | diff --git a/data/admin/declaration.py b/data/admin/declaration.py index fe96a0091..3f87f037f 100644 --- a/data/admin/declaration.py +++ b/data/admin/declaration.py @@ -264,7 +264,7 @@ class DeclarationAdmin(SimpleHistoryAdmin): "gamme", "flavor", "calculated_article", - "overriden_article", + "overridden_article", ) }, ), diff --git a/data/admin/ingredient.py b/data/admin/ingredient.py index 178d86fe6..7da71d20d 100644 --- a/data/admin/ingredient.py +++ b/data/admin/ingredient.py @@ -38,8 +38,8 @@ class IngredientAdmin(ElementAdminWithChangeReason): SubstanceInlineAdmin, IngredientSynonymInline, ) - list_display = ("name", "is_obsolete", "status", "is_risky", "novel_food") - list_filter = ("is_obsolete", "status", "is_risky", "novel_food") + list_display = ("name", "is_obsolete", "status", "is_risky", "novel_food", "has_linked_substances") + list_filter = ("is_obsolete", "status", "is_risky", "novel_food", "ingredient_type") show_facets = admin.ShowFacets.NEVER readonly_fields = ( "name", @@ -49,3 +49,6 @@ class IngredientAdmin(ElementAdminWithChangeReason): "siccrf_private_comments", ) search_fields = ["id", "name"] + + def has_linked_substances(self, obj): + return "Oui" if obj.substances.exists() else "Non" diff --git a/data/etl/extractor.py b/data/etl/extractor.py index e001c3e90..2f6006561 100644 --- a/data/etl/extractor.py +++ b/data/etl/extractor.py @@ -60,7 +60,7 @@ def clean_dataset(self): # --------------------------------------------------- self.df = self.df[columns_to_keep] self.filter_dataframe_with_schema_cols() - self.df = self.df.replace({"\n": " ", "\r": " "}, regex=True) + self.df = self.df.replace({"\n": " ", "\r": " ", ";": ""}, regex=True) def is_valid(self) -> bool: files = prepare_file_validata_post_request(self.df) @@ -99,7 +99,7 @@ class DECLARATIONS(EXTRACTOR): def __init__(self): super().__init__() self.dataset_name = "declarations" - self.schema = json.load(open("data/schemas/schema_declarations.json"))["schema"] + self.schema = json.load(open("data/schemas/schema_declarations.json")) self.schema_url = ( "https://github.com/betagouv/complements-alimentaires/blob/staging/data/schemas/schema_declarations.json" ) diff --git a/data/etl/teleicare_history/extractor.py b/data/etl/teleicare_history/extractor.py index 9ca33f281..a2d917c7f 100644 --- a/data/etl/teleicare_history/extractor.py +++ b/data/etl/teleicare_history/extractor.py @@ -4,18 +4,46 @@ from datetime import date, datetime, timezone from django.core.exceptions import MultipleObjectsReturned, ValidationError -from django.db import IntegrityError +from django.db import IntegrityError, transaction from phonenumber_field.phonenumber import PhoneNumber -from data.models import GalenicFormulation, SubstanceUnit +from data.models import ( + Condition, + Effect, + GalenicFormulation, + Ingredient, + Microorganism, + Plant, + PlantPart, + Population, + Preparation, + Substance, + SubstanceUnit, +) from data.models.company import ActivityChoices, Company -from data.models.declaration import Declaration +from data.models.declaration import ( + ComputedSubstance, + Declaration, + DeclaredIngredient, + DeclaredMicroorganism, + DeclaredPlant, +) from data.models.teleicare_history.ica_declaration import ( IcaComplementAlimentaire, IcaDeclaration, + IcaEffetDeclare, + IcaPopulationCibleDeclaree, + IcaPopulationRisqueDeclaree, IcaVersionDeclaration, ) +from data.models.teleicare_history.ica_declaration_composition import ( + IcaIngredient, + IcaIngredientAutre, + IcaMicroOrganisme, + IcaPreparation, + IcaSubstanceDeclaree, +) from data.models.teleicare_history.ica_etablissement import IcaEtablissement logger = logging.getLogger(__name__) @@ -177,6 +205,144 @@ def convert_str_date(value, aware=False): 8: Declaration.DeclarationStatus.ABANDONED, # 'abandonné' } +DECLARATION_TYPE_TO_ARTICLE_MAPPING = { + 1: Declaration.Article.ARTICLE_15, + 2: Declaration.Article.ARTICLE_16, + 3: None, # Type "simplifié" dans Teleicare, normalement liées à des modifications de déclarations déjà instruites +} + + +MANY_TO_MANY_PRODUCT_MODELS_MATCHING = { + "effects": {"teleIcare_model": IcaEffetDeclare, "teleIcare_pk": "objeff_ident", "CA_model": Effect}, + "conditions_not_recommended": { + "teleIcare_model": IcaPopulationRisqueDeclaree, + "teleIcare_pk": "poprs_ident", + "CA_model": Condition, + }, + "populations": { + "teleIcare_model": IcaPopulationCibleDeclaree, + "teleIcare_pk": "popcbl_ident", + "CA_model": Population, + }, +} + + +def create_declared_plant(declaration, teleIcare_plant, active): + declared_plant = DeclaredPlant( + declaration=declaration, + plant=Plant.objects.get(siccrf_id=teleIcare_plant.plte_ident), + active=active, + quantity=teleIcare_plant.prepa_qte if active else None, + unit=SubstanceUnit.objects.get(siccrf_id=teleIcare_plant.unt_ident) if active else None, + preparation=Preparation.objects.get(siccrf_id=teleIcare_plant.typprep_ident) if active else None, + used_part=PlantPart.objects.get(siccrf_id=teleIcare_plant.pplan_ident) if active else None, + ) + return declared_plant + + +def create_declared_microorganism(declaration, teleIcare_microorganism, active): + declared_microorganism = DeclaredMicroorganism( + declaration=declaration, + microorganism=Microorganism.objects.get(siccrf_id=teleIcare_microorganism.morg_ident), + active=active, + activated=True, # dans TeleIcare, le champ `activated` n'existait pas, les MO inactivés étaient des `ingrédients autres` + strain=teleIcare_microorganism.ingmorg_souche, + quantity=teleIcare_microorganism.ingmorg_quantite_par_djr, + ) + return declared_microorganism + + +def create_declared_ingredient(declaration, teleIcare_ingredient, active): + declared_ingredient = DeclaredIngredient( + declaration=declaration, + ingredient=Ingredient.objects.get(siccrf_id=teleIcare_ingredient.inga_ident), + active=active, + quantity=None, # dans TeleIcare, les ingrédients n'avaient pas de quantité associée + ) + return declared_ingredient + + +def create_computed_substance(declaration, teleIcare_substance): + computed_substance = ComputedSubstance( + declaration=declaration, + substance=Substance.objects.get(siccrf_id=teleIcare_substance.sbsact_ident), + quantity=teleIcare_substance.sbsactdecl_quantite_par_djr, + # le champ de TeleIcare 'sbsact_commentaires' n'est pas repris + ) + return computed_substance + + +@transaction.atomic +def add_product_info_from_teleicare_history(declaration, vrsdecl_ident): + """ + Cette function importe les champs ManyToMany des déclarations, relatifs à l'onglet "Produit" + Il est nécessaire que les objets soient enregistrés en base (et aient obtenu un id) grâce à la fonction + `create_declaration_from_teleicare_history` pour updater leurs champs ManyToMany. + """ + # TODO: other_conditions=conditions_not_recommended, + # TODO: other_effects= + for CA_field_name, struct in MANY_TO_MANY_PRODUCT_MODELS_MATCHING.items(): + # par exemple Declaration.populations + CA_field = getattr(declaration, CA_field_name) + # TODO: utiliser les dataclass ici + pk_field = struct["teleIcare_pk"] + CA_model = struct["CA_model"] + teleIcare_model = struct["teleIcare_model"] + CA_field.set( + [ + CA_model.objects.get(siccrf_id=getattr(TI_object, pk_field)) + for TI_object in (teleIcare_model.objects.filter(vrsdecl_ident=vrsdecl_ident)) + ] + ) + + +@transaction.atomic +def add_composition_from_teleicare_history(declaration, vrsdecl_ident): + """ + Cette function importe les champs ManyToMany des déclarations, relatifs à l'onglet "Composition" + Il est nécessaire que les objets soient enregistrés en base (et aient obtenu un id) grâce à la fonction + `create_declaration_from_teleicare_history` pour updater leurs champs ManyToMany. + """ + bulk_ingredients = {DeclaredPlant: [], DeclaredMicroorganism: [], DeclaredIngredient: [], ComputedSubstance: []} + for ingredient in IcaIngredient.objects.filter(vrsdecl_ident=vrsdecl_ident): + if ingredient.tying_ident == 1: + declared_plant = create_declared_plant( + declaration=declaration, + teleIcare_plant=IcaPreparation.objects.get( + ingr_ident=ingredient.ingr_ident, + ), + active=ingredient.fctingr_ident == 1, + ) + bulk_ingredients[DeclaredPlant].append(declared_plant) + elif ingredient.tying_ident == 2: + declared_microorganism = create_declared_microorganism( + declaration=declaration, + teleIcare_microorganism=IcaMicroOrganisme.objects.get( + ingr_ident=ingredient.ingr_ident, + ), + active=ingredient.fctingr_ident == 1, + ) + bulk_ingredients[DeclaredMicroorganism].append(declared_microorganism) + elif ingredient.tying_ident == 3: + declared_ingredient = create_declared_ingredient( + declaration=declaration, + teleIcare_ingredient=IcaIngredientAutre.objects.get( + ingr_ident=ingredient.ingr_ident, + ), + active=ingredient.fctingr_ident == 1, + ) + bulk_ingredients[DeclaredIngredient].append(declared_ingredient) + # dans TeleIcare les `declared_substances` étaient des ingrédients + # donc on ne rempli pas le champ declaration.declared_substances grâce à l'historique + for teleIcare_substance in IcaSubstanceDeclaree.objects.filter(vrsdecl_ident=vrsdecl_ident): + computed_substance = create_computed_substance( + declaration=declaration, teleIcare_substance=teleIcare_substance + ) + bulk_ingredients[ComputedSubstance].append(computed_substance) + + for model, bulk_of_objects in bulk_ingredients.items(): + model.objects.bulk_create(bulk_of_objects) + def create_declaration_from_teleicare_history(): """ @@ -248,12 +414,8 @@ def create_declaration_from_teleicare_history(): minimum_duration=latest_ica_version_declaration.vrsdecl_durabilite, instructions=latest_ica_version_declaration.vrsdecl_mode_emploi or "", warning=latest_ica_version_declaration.vrsdecl_mise_en_garde or "", - # TODO: ces champs proviennent de tables pas encore importées - # populations= - # conditions_not_recommended= - # other_conditions= - # effects= - # other_effects= + calculated_article=DECLARATION_TYPE_TO_ARTICLE_MAPPING[latest_ica_declaration.tydcl_ident], + # TODO: ces champs sont à importer # address= # postal_code= # city= @@ -266,6 +428,12 @@ def create_declaration_from_teleicare_history(): try: with suppress_autotime(declaration, ["creation_date", "modification_date"]): declaration.save() + add_product_info_from_teleicare_history( + declaration, latest_ica_version_declaration.vrsdecl_ident + ) + add_composition_from_teleicare_history( + declaration, latest_ica_version_declaration.vrsdecl_ident + ) nb_created_declarations += 1 except IntegrityError: # cette Déclaration a déjà été créée diff --git a/data/etl/teleicare_history/sql/company_table_creation.sql b/data/etl/teleicare_history/sql/company_table_creation.sql new file mode 100644 index 000000000..4c7548087 --- /dev/null +++ b/data/etl/teleicare_history/sql/company_table_creation.sql @@ -0,0 +1,43 @@ +-- Le but de ce fichier est de permettre la recréation des tables Teleicare correspondant +-- au modèle de Declaration Compl'Alim + + +-- int int smallint -> integer +-- vachar -> text +-- bit -> boolean +-- datetime -> text (pour une conversion ensuite) + +CREATE TABLE ICA_ETABLISSEMENT ( + ETAB_IDENT integer PRIMARY KEY, + COG_IDENT integer NULL, + ETAB_IDENT_PARENT integer NULL, + PAYS_IDENT integer NOT NULL, + ETAB_SIRET text NULL, + ETAB_NUMERO_TVA_INTRA text NULL, + ETAB_RAISON_SOCIALE text NOT NULL, + ETAB_ENSEIGNE text NULL, + ETAB_ADRE_VILLE text NULL, + ETAB_ADRE_CP text NULL, + ETAB_ADRE_VOIE text NULL, + ETAB_ADRE_COMP text NULL, + ETAB_ADRE_COMP2 text NULL, + ETAB_ADRE_DIST text NULL, + ETAB_TELEPHONE text NULL, + ETAB_FAX text NULL, + ETAB_COURRIEL text NULL, + ETAB_SITE_INTERNET text NULL, + ETAB_ICA_FACONNIER boolean NULL, + ETAB_ICA_FABRICANT boolean NULL, + ETAB_ICA_CONSEIL boolean NULL, + ETAB_ICA_IMPORTATEUR boolean NULL, + ETAB_ICA_INTRODUCTEUR boolean NULL, + ETAB_ICA_DISTRIBUTEUR boolean NULL, + ETAB_ICA_ENSEIGNE text NULL, + ETAB_ADRE_REGION text NULL, + ETAB_DT_AJOUT_IDENT_PARENT text NULL, + ETAB_NUM_ADH_TELE_PROC text NULL, + ETAB_COMMENTAIRE_IDENT_PARENT text NULL, + ETAB_NOM_DOMAINE text NULL, + ETAB_DATE_ADHESION text NULL, + ETAB_NB_COMPTE_AUTORISE integer NOT NULL +); diff --git a/data/etl/teleicare_history/sql/declaration_composition_table_creation.sql b/data/etl/teleicare_history/sql/declaration_composition_table_creation.sql new file mode 100644 index 000000000..8ebb619e3 --- /dev/null +++ b/data/etl/teleicare_history/sql/declaration_composition_table_creation.sql @@ -0,0 +1,44 @@ +CREATE TABLE ICA_INGREDIENT ( + INGR_IDENT integer PRIMARY KEY, + VRSDECL_IDENT integer NOT NULL, + FCTINGR_IDENT integer NOT NULL, + TYING_IDENT integer NOT NULL, + INGR_COMMENTAIRES text NULL +); + + + + +CREATE TABLE ICA_MICRO_ORGANISME ( + INGR_IDENT integer PRIMARY KEY, + MORG_IDENT integer NOT NULL, + INGMORG_SOUCHE text NULL, + INGMORG_QUANTITE_PAR_DJR bigint NULL +); + + + +CREATE TABLE ICA_INGREDIENT_AUTRE ( + INGR_IDENT integer PRIMARY KEY, + INGA_IDENT integer NOT NULL +); + + + +CREATE TABLE ICA_PREPARATION ( + INGR_IDENT integer PRIMARY KEY, + PLTE_IDENT integer NOT NULL, + PPLAN_IDENT integer NULL, + UNT_IDENT integer NULL, + TYPPREP_IDENT integer NULL, + PREPA_QTE float NULL +); + + +CREATE TABLE ICA_SUBSTANCE_DECLAREE( + VRSDECL_IDENT integer NOT NULL, + SBSACT_IDENT integer NOT NULL, + SBSACT_COMMENTAIRES text NULL, + SBSACTDECL_QUANTITE_PAR_DJR float NULL, + PRIMARY KEY (VRSDECL_IDENT, SBSACT_IDENT) +); diff --git a/data/etl/teleicare_history/sql/company_declaration_table_creation.sql b/data/etl/teleicare_history/sql/declaration_table_creation.sql similarity index 71% rename from data/etl/teleicare_history/sql/company_declaration_table_creation.sql rename to data/etl/teleicare_history/sql/declaration_table_creation.sql index 9e5e6bdec..1dfb63054 100644 --- a/data/etl/teleicare_history/sql/company_declaration_table_creation.sql +++ b/data/etl/teleicare_history/sql/declaration_table_creation.sql @@ -1,48 +1,3 @@ --- Le but de ce fichier est de permettre la recréation des tables Teleicare correspondant --- au modèle de Declaration Compl'Alim - - --- int int smallint -> integer --- vachar -> text --- bit -> boolean --- datetime -> text (pour une conversion ensuite) - -CREATE TABLE ICA_ETABLISSEMENT ( - ETAB_IDENT integer PRIMARY KEY, - COG_IDENT integer NULL, - ETAB_IDENT_PARENT integer NULL, - PAYS_IDENT integer NOT NULL, - ETAB_SIRET text NULL, - ETAB_NUMERO_TVA_INTRA text NULL, - ETAB_RAISON_SOCIALE text NOT NULL, - ETAB_ENSEIGNE text NULL, - ETAB_ADRE_VILLE text NULL, - ETAB_ADRE_CP text NULL, - ETAB_ADRE_VOIE text NULL, - ETAB_ADRE_COMP text NULL, - ETAB_ADRE_COMP2 text NULL, - ETAB_ADRE_DIST text NULL, - ETAB_TELEPHONE text NULL, - ETAB_FAX text NULL, - ETAB_COURRIEL text NULL, - ETAB_SITE_INTERNET text NULL, - ETAB_ICA_FACONNIER boolean NULL, - ETAB_ICA_FABRICANT boolean NULL, - ETAB_ICA_CONSEIL boolean NULL, - ETAB_ICA_IMPORTATEUR boolean NULL, - ETAB_ICA_INTRODUCTEUR boolean NULL, - ETAB_ICA_DISTRIBUTEUR boolean NULL, - ETAB_ICA_ENSEIGNE text NULL, - ETAB_ADRE_REGION text NULL, - ETAB_DT_AJOUT_IDENT_PARENT text NULL, - ETAB_NUM_ADH_TELE_PROC text NULL, - ETAB_COMMENTAIRE_IDENT_PARENT text NULL, - ETAB_NOM_DOMAINE text NULL, - ETAB_DATE_ADHESION text NULL, - ETAB_NB_COMPTE_AUTORISE integer NOT NULL -); - - CREATE TABLE ICA_COMPLEMENTALIMENTAIRE ( CPLALIM_IDENT integer PRIMARY KEY, FRMGAL_IDENT integer NULL, @@ -144,3 +99,30 @@ CREATE TABLE ICA_VERSIONDECLARATION ( -- when 1 then 'nouvelle' -- when 2 then 'compléments d'information' -- when 3 then 'observation' + + + +CREATE TABLE ICA_POPULATION_CIBLE_DECLAREE ( + VRSDECL_IDENT integer NOT NULL, + POPCBL_IDENT integer NOT NULL, + VRSPCB_POPCIBLE_AUTRE text NULL, + PRIMARY KEY (VRSDECL_IDENT, POPCBL_IDENT) +); + + + + +CREATE TABLE ICA_POPULATION_RISQUE_DECLAREE ( + VRSDECL_IDENT integer NOT NULL, + POPRS_IDENT integer NOT NULL, + VRSPRS_POPRISQUE_AUTRE text NULL, + PRIMARY KEY (VRSDECL_IDENT, POPRS_IDENT) +); + + +CREATE TABLE ICA_EFFET_DECLARE ( + VRSDECL_IDENT integer NOT NULL, + OBJEFF_IDENT integer NOT NULL, + VRS_AUTRE_OBJECTIF text NULL, + PRIMARY KEY (VRSDECL_IDENT, OBJEFF_IDENT) +); diff --git a/data/etl/transformer_loader.py b/data/etl/transformer_loader.py index 407186c7c..8e5f3f309 100644 --- a/data/etl/transformer_loader.py +++ b/data/etl/transformer_loader.py @@ -22,7 +22,7 @@ def _load_data_csv(self, filename): index=False, na_rep="", encoding="utf_8_sig", - quoting=csv.QUOTE_NONE, + quoting=csv.QUOTE_NONNUMERIC, escapechar="\\", ) diff --git a/data/factories/teleicare_history/__init__.py b/data/factories/teleicare_history/__init__.py index a446b86cd..932a8a042 100644 --- a/data/factories/teleicare_history/__init__.py +++ b/data/factories/teleicare_history/__init__.py @@ -68,7 +68,8 @@ class Meta: dcl_ident = factory.Sequence(lambda n: n + 1) cplalim = factory.SubFactory(ComplementAlimentaireFactory) - tydcl_ident = factory.Faker("pyint", min_value=0, max_value=20) + # 3 valeurs possibles dans TeleIcare {1: "Article 15", 2: "Article 16", 3: "Simplifiée"} + tydcl_ident = factory.Faker("pyint", min_value=1, max_value=3) etab = factory.SubFactory(EtablissementFactory) etab_ident_rmm_declarant = factory.Faker("pyint", min_value=0, max_value=20) dcl_date = datetime.strftime(random_date(start=datetime(2016, 1, 1)), "%m/%d/%Y %H:%M:%S %p") diff --git a/data/migrations/0118_icaeffetdeclare_icapopulationcibledeclaree_and_more.py b/data/migrations/0118_icaeffetdeclare_icapopulationcibledeclaree_and_more.py new file mode 100644 index 000000000..792d87e52 --- /dev/null +++ b/data/migrations/0118_icaeffetdeclare_icapopulationcibledeclaree_and_more.py @@ -0,0 +1,49 @@ +# Generated by Django 5.1.5 on 2025-01-24 09:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('data', '0117_remove_blogpost_body'), + ] + + operations = [ + migrations.CreateModel( + name='IcaEffetDeclare', + fields=[ + ('vrsdecl_ident', models.IntegerField(primary_key=True, serialize=False)), + ('objeff_ident', models.IntegerField()), + ('vrs_autre_objectif', models.TextField(blank=True, null=True)), + ], + options={ + 'db_table': 'ica_effet_declare', + 'managed': False, + }, + ), + migrations.CreateModel( + name='IcaPopulationCibleDeclaree', + fields=[ + ('vrsdecl_ident', models.IntegerField(primary_key=True, serialize=False)), + ('popcbl_ident', models.IntegerField()), + ('vrspcb_popcible_autre', models.TextField(blank=True, null=True)), + ], + options={ + 'db_table': 'ica_population_cible_declaree', + 'managed': False, + }, + ), + migrations.CreateModel( + name='IcaPopulationRisqueDeclaree', + fields=[ + ('vrsdecl_ident', models.IntegerField(primary_key=True, serialize=False)), + ('poprs_ident', models.IntegerField()), + ('vrsprs_poprisque_autre', models.TextField(blank=True, null=True)), + ], + options={ + 'db_table': 'ica_population_risque_declaree', + 'managed': False, + }, + ), + ] diff --git a/data/migrations/0118_rename_overriden_article_declaration_overridden_article_and_more.py b/data/migrations/0118_rename_overriden_article_declaration_overridden_article_and_more.py new file mode 100644 index 000000000..6aa199e7b --- /dev/null +++ b/data/migrations/0118_rename_overriden_article_declaration_overridden_article_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 5.1.5 on 2025-01-24 16:59 + +import django.db.models.functions.comparison +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('data', '0117_remove_blogpost_body'), + ] + + operations = [ + migrations.RenameField( + model_name='declaration', + old_name='overriden_article', + new_name='overridden_article', + ), + migrations.RenameField( + model_name='historicaldeclaration', + old_name='overriden_article', + new_name='overridden_article', + ), + # ValueError: Modifying GeneratedFields is not supported - the field data.Declaration.article must be removed and re-added with the new definition. + migrations.RemoveField( + model_name="declaration", + name="article", + ), + migrations.RemoveField( + model_name="historicaldeclaration", + name="article", + ), + migrations.AddField( + model_name='declaration', + name='article', + field=models.GeneratedField(db_persist=True, expression=django.db.models.functions.comparison.Coalesce(models.Case(models.When(overridden_article='', then=models.Value(None)), default='overridden_article'), models.Case(models.When(calculated_article='', then=models.Value(None)), default='calculated_article'), models.Value(None)), output_field=models.TextField(null=True, verbose_name='article')), + ), + migrations.AddField( + model_name='historicaldeclaration', + name='article', + field=models.GeneratedField(db_persist=True, expression=django.db.models.functions.comparison.Coalesce(models.Case(models.When(overridden_article='', then=models.Value(None)), default='overridden_article'), models.Case(models.When(calculated_article='', then=models.Value(None)), default='calculated_article'), models.Value(None)), output_field=models.TextField(null=True, verbose_name='article')), + ), + ] diff --git a/data/migrations/0119_merge_20250128_1101.py b/data/migrations/0119_merge_20250128_1101.py new file mode 100644 index 000000000..897e484c3 --- /dev/null +++ b/data/migrations/0119_merge_20250128_1101.py @@ -0,0 +1,14 @@ +# Generated by Django 5.1.5 on 2025-01-28 10:01 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('data', '0118_icaeffetdeclare_icapopulationcibledeclaree_and_more'), + ('data', '0118_rename_overriden_article_declaration_overridden_article_and_more'), + ] + + operations = [ + ] diff --git a/data/models/declaration.py b/data/models/declaration.py index fae619db6..513227123 100644 --- a/data/models/declaration.py +++ b/data/models/declaration.py @@ -179,11 +179,11 @@ class Article(models.TextChoices): other_effects = models.TextField(blank=True, verbose_name="autres objectifs ou effets non listés") calculated_article = models.TextField("article calculé automatiquement", blank=True, choices=Article) - # TODO: les Article.choice pour overriden_article ne devraient pas inclure les choices calculés automatiquement - overriden_article = models.TextField("article manuellement spécifié", blank=True, choices=Article) + # TODO: les Article.choice pour overridden_article ne devraient pas inclure les choices calculés automatiquement + overridden_article = models.TextField("article manuellement spécifié", blank=True, choices=Article) article = models.GeneratedField( expression=Coalesce( - Case(When(overriden_article="", then=Value(None)), default="overriden_article"), + Case(When(overridden_article="", then=Value(None)), default="overridden_article"), Case(When(calculated_article="", then=Value(None)), default="calculated_article"), Value(None), ), diff --git a/data/models/teleicare_history/ica_declaration.py b/data/models/teleicare_history/ica_declaration.py index f235b3099..b6bc813bf 100644 --- a/data/models/teleicare_history/ica_declaration.py +++ b/data/models/teleicare_history/ica_declaration.py @@ -35,13 +35,14 @@ class IcaDeclaration(models.Model): # dcl_ident et cplalim_ident ne sont pas égaux dcl_ident = models.IntegerField(primary_key=True) cplalim = models.ForeignKey(IcaComplementAlimentaire, on_delete=models.CASCADE, db_column="cplalim_ident") - tydcl_ident = models.IntegerField() + tydcl_ident = models.IntegerField() # Article 15 ou 16 etab = models.ForeignKey( IcaEtablissement, on_delete=models.CASCADE, db_column="etab_ident" ) # correspond à l'entreprise gestionnaire de la déclaration etab_ident_rmm_declarant = models.IntegerField() dcl_date = models.TextField() dcl_saisie_administration = models.BooleanField(null=True) # rendu nullable pour simplifier les Factories + # l'identifiant Teleicare est constitué ainsi {dcl_annee}-{dcl_mois}-{dcl_numero} dcl_annee = models.IntegerField(null=True) # rendu nullable pour simplifier les Factories dcl_mois = models.IntegerField(null=True) # rendu nullable pour simplifier les Factories dcl_numero = models.IntegerField(null=True) # rendu nullable pour simplifier les Factories @@ -55,7 +56,8 @@ class Meta: class IcaVersionDeclaration(models.Model): vrsdecl_ident = models.IntegerField(primary_key=True) ag_ident = models.IntegerField(blank=True, null=True) - typvrs_ident = models.IntegerField(null=True) # rendu nullable pour simplifier les Factories + typvrs_ident = models.IntegerField(null=True) # 1: Nouvelle 2: Complément d'information 3: Observations + # rendu nullable pour simplifier les Factories unt_ident = models.IntegerField(blank=True, null=True) pays_ident_adre = models.IntegerField(blank=True, null=True) etab = models.ForeignKey( @@ -95,3 +97,36 @@ class IcaVersionDeclaration(models.Model): class Meta: managed = False db_table = "ica_versiondeclaration" + + +class IcaPopulationCibleDeclaree(models.Model): + vrsdecl_ident = models.IntegerField() + popcbl_ident = models.IntegerField() + vrspcb_popcible_autre = models.TextField(blank=True, null=True) + + class Meta: + managed = False + db_table = "ica_population_cible_declaree" + unique_together = (("vrsdecl_ident", "popcbl_ident"),) + + +class IcaPopulationRisqueDeclaree(models.Model): + vrsdecl_ident = models.IntegerField() + poprs_ident = models.IntegerField() + vrsprs_poprisque_autre = models.TextField(blank=True, null=True) + + class Meta: + managed = False + db_table = "ica_population_risque_declaree" + unique_together = (("vrsdecl_ident", "poprs_ident"),) + + +class IcaEffetDeclare(models.Model): + vrsdecl_ident = models.IntegerField() + objeff_ident = models.IntegerField() + vrs_autre_objectif = models.TextField(blank=True, null=True) + + class Meta: + managed = False + db_table = "ica_effet_declare" + unique_together = (("vrsdecl_ident", "objeff_ident"),) diff --git a/data/models/teleicare_history/ica_declaration_composition.py b/data/models/teleicare_history/ica_declaration_composition.py new file mode 100644 index 000000000..1fbd4ec0d --- /dev/null +++ b/data/models/teleicare_history/ica_declaration_composition.py @@ -0,0 +1,64 @@ +# This is an auto-generated Django model module. +# Feel free to rename the models, but don't rename db_table values or field names. +# Pour plus de simplicité, le nom de ces modèles suivent le nom de la table de données provenant du SICCRF, +# initialement stockée dans un système MSSQL, +# et non la nomenclature habituellement utilisée par Compl'Alim + +from django.db import models + + +class IcaIngredient(models.Model): + ingr_ident = models.IntegerField(primary_key=True) + vrsdecl_ident = models.IntegerField() + fctingr_ident = models.IntegerField() + tying_ident = models.IntegerField() + ingr_commentaires = models.TextField(blank=True, null=True) + + class Meta: + managed = False + db_table = "ica_ingredient" + + +class IcaIngredientAutre(models.Model): + ingr_ident = models.IntegerField(primary_key=True) + inga_ident = models.IntegerField() + + class Meta: + managed = False + db_table = "ica_ingredient_autre" + + +class IcaMicroOrganisme(models.Model): + ingr_ident = models.IntegerField(primary_key=True) + morg_ident = models.IntegerField() + ingmorg_souche = models.TextField(blank=True, null=True) + ingmorg_quantite_par_djr = models.BigIntegerField(blank=True, null=True) + + class Meta: + managed = False + db_table = "ica_micro_organisme" + + +class IcaPreparation(models.Model): + ingr_ident = models.IntegerField(primary_key=True) + plte_ident = models.IntegerField() + pplan_ident = models.IntegerField(blank=True, null=True) + unt_ident = models.IntegerField(blank=True, null=True) + typprep_ident = models.IntegerField(blank=True, null=True) + prepa_qte = models.FloatField(blank=True, null=True) + + class Meta: + managed = False + db_table = "ica_preparation" + + +class IcaSubstanceDeclaree(models.Model): + vrsdecl_ident = models.IntegerField() + sbsact_ident = models.IntegerField() + sbsact_commentaires = models.TextField(blank=True, null=True) + sbsactdecl_quantite_par_djr = models.FloatField(blank=True, null=True) + + class Meta: + managed = False + db_table = "ica_substance_declaree" + unique_together = (("vrsdecl_ident", "sbsact_ident"),) diff --git a/data/schemas/CHANGELOG_declarations.md b/data/schemas/CHANGELOG_declarations.md index e4b5a7c3b..9a4ce57e5 100644 --- a/data/schemas/CHANGELOG_declarations.md +++ b/data/schemas/CHANGELOG_declarations.md @@ -2,6 +2,12 @@ Tous les changements notables apportés aux jeux de données exposés sur data.gouv.fr vont être documentés ici. +## 2025-01-23 + +## Modification + - Remplacer dans la colonne `Facteurs Risques` les valeurs 'Autres (à préciser)' par les valeurs renseignées par l'utilisateur + - Remplacer dans la colonne `Objectif Effets` les valeurs 'Autres (à préciser)' par les valeurs renseignées par l'utilisateur + ## 2025-01-16 ## Ajout diff --git a/data/schemas/schema_declarations.json b/data/schemas/schema_declarations.json index 310598270..bf7b2c478 100644 --- a/data/schemas/schema_declarations.json +++ b/data/schemas/schema_declarations.json @@ -1,221 +1,222 @@ { + "$schema": "https://frictionlessdata.io/schemas/table-schema.json", "dialect": { "csv": { "delimiter": ";" } }, "encoding": "utf-8-sig", + "fields": [ + { + "constraints": { + "required": true + }, + "description": "Identifiant unique permettant de référencer une déclaration d'un complément alimentaire", + "example": "3211", + "name": "id", + "title": "Identifiant", + "type": "integer" + }, + { + "constraints": { + "required": true + }, + "description": "Décision prise suite à la déclaration du complément alimentaire. Indique s'il est autorisé ou refusé à la vente", + "example": "autorisé", + "name": "decision", + "title": "Décision", + "type": "string" + }, + { + "constraints": { + "required": true + }, + "description": "Date de décision sur la déclaration du complément alimentaire.", + "example": "2024-01-27", + "name": "date_decision", + "title": "Date décision", + "type": "date" + }, + { + "constraints": { + "required": true + }, + "description": "Nom commercial du produit (fourni par le fabricant)", + "example": "Compl'Alimentaires", + "name": "nom_commercial", + "title": "Nom commercial", + "type": "string" + }, + { + "description": "Marque du fabricant sous laquelle est vendue le complément alimentaire (fournie par le fabricant)", + "example": "Nutra Power", + "name": "marque", + "title": "Marque", + "type": "string" + }, + { + "description": "Gamme de la marque du fabricant dans laquelle le produit est vendu (fournie par le fabricant)", + "example": "Premium", + "name": "gamme", + "title": "Gamme", + "type": "string" + }, + { + "constraints": { + "required": true + }, + "description": "Nom de l'entreprise responsable de la mise sur le marché du complément alimentaire", + "example": "Compl Corp", + "name": "responsable_mise_sur_marche", + "title": "Responsable de la mise sur le marché", + "type": "string" + }, + { + "constraints": { + "pattern": "^[0-9]{14}$", + "required": true + }, + "description": "Siret de l'entreprise responsable de la mise sur le marché du complément alimentaire", + "example": "11007001800012", + "name": "siret_responsable_mise_sur_marche", + "title": "Siret responsable de la mise sur le marché", + "type": "integer" + }, + { + "constraints": { + "enum": [ + "15", + "16", + "17", + "18" + ], + "required": true + }, + "description": "Réference de l'article juridique qui encadre la déclaration du produit. La déclaration du complément ne suit pas la même procédure en fonction de l'article dans lequel il tombe. Voir sur : https://www.legifrance.gouv.fr/loda/id/JORFTEXT000000638341", + "example": "15", + "name": "article_procedure", + "title": "Article de procédure", + "type": "integer" + }, + { + "constraints": { + "required": true + }, + "description": "La forme galénique correspond à la forme sous laquelle le complément alimentaire se présente (comprimé, gélule, sirop...). Elle est spécialement conçue pour la voie d’administration à laquelle le complément est destiné. (source : sante.gouv.fr)", + "example": "Sirop", + "name": "forme_galenique", + "title": "Forme galénique", + "type": "string" + }, + { + "constraints": { + "required": true + }, + "description": "Dose journalière recommandée (DJR) (fournie par le fabricant). Les quantités des composants sont renseignées par DJR.", + "example": "16 gouttes", + "name": "dose_journaliere", + "title": "Dose journalière", + "type": "string" + }, + { + "description": "Recommandation d'emploi (fournies par le fabricant)", + "example": "4 gouttes 4 fois par jour dans un verre d'eau", + "name": "mode_emploi", + "title": "Mode Emploi", + "type": "string" + }, + { + "description": "Mise en garde et avertissement (fournies par le fabricant). Certains avertissements doivent obligatoirement être notés sur l'étiquettage.", + "example": "Ne pas dépasser la dose journalière recommandée. Tenir hors de portée des jeunes enfants. Ne peut se substituer à un régime alimentaire varié.", + "name": "mises_en_garde", + "title": "Mises en garde", + "type": "string" + }, + { + "description": "Objectifs du complément alimentaire (fourni par le fabricant)", + "name": "objectif_effet", + "type": "array" + }, + { + "description": "Arômes (fourni par le fabricant)", + "example": "Orange", + "name": "aromes", + "title": "Arômes", + "type": "string" + }, + { + "description": "La consommation du complément alimentaire est déconseillée pour les populations présentant ce ou ces facteurs", + "example": "Hypertension, Maladie rénale", + "name": "facteurs_risques", + "title": "facteurs_risques", + "type": "array" + }, + { + "description": "Les populations ciblées par le complément alimentaire déclaré. Le fabricant choisi parmi une liste fournie par Compl'Alim", + "example": "Femmes enceintes, Femmes allaitantes", + "name": "populations_cibles", + "title": "Population cibles", + "type": "array" + }, + { + "description": "Liste des plantes actives composants le complément alimentaire. Pour chaque plante nous retrouvons son nom en latin, sa famille, la partie utilisée ainsi que la quantité par DJR.", + "example": "{'nom': 'Juglans regia L.', 'partie': 'Fleur', 'preparation': 'Alcoolat', 'quantité_par_djr': '2', 'unité': 'ml'}", + "name": "plantes", + "title": "Plantes", + "type": "array" + }, + { + "description": "Liste des plantes/autres ingrédients inactifs composants le complément alimentaire. Pour chaque plante nous retrouvons son nom en latin, sa famille et la partie utilisée", + "example": "carotte, miel, fibre", + "name": "ingredients_inactifs", + "title": "Ingrédients inactifs", + "type": "array" + }, + { + "description": "Micro organismes composants le complément alimentaire. Si le micro-organisme a été inactivé, il n'est pas nécessaire de renseigner la quantité.", + "example": "{'genre': 'Lactobacillus', 'espèce': 'lactis', 'souche': '', 'quantité_par_djr': '2', 'unité': 'cfu', 'inactivé': 'True'}", + "name": "micro_organismes", + "type": "array" + }, + { + "description": "Additifs", + "example": "E416", + "name": "additifs", + "type": "array" + }, + { + "description": "Nutriments (ou forme d'apports)", + "example": "Cyanocobalamine", + "name": "nutriments", + "type": "array" + }, + { + "description": "Autres ingrédients actifs", + "example": "dextrine", + "name": "autres_ingredients_actifs", + "title": "Autres ingredients actifs", + "type": "array" + }, + { + "description": "Substances (composant actif)", + "example": "['nom': 'cafféine', 'quantité_par_djr': '2', 'unité': 'ml']", + "name": "substances", + "title": "Substances", + "type": "array" + }, + { + "description": "Lien vers une image de l'étiquette (limité à 4Mb)", + "example": "www.lienverslimage.com", + "name": "etiquette", + "title": "Etiquette", + "type": "string" + } + ], "format": "csv", + "homepage": "https://github.com/betagouv/complements-alimentaires", "mediatype": "text/csv", "name": "liste-des-complements-alimentaires-declares", - "schema": { - "fields": [ - { - "constraints": { - "required": "True" - }, - "description": "Identifiant unique permettant de référencer une déclaration d'un complément alimentaire", - "example": "3211", - "name": "id", - "title": "Identifiant", - "type": "integer" - }, - { - "constraints": { - "required": "True" - }, - "description": "Décision prise suite à la déclaration du complément alimentaire. Indique s'il est autorisé ou refusé à la vente", - "example": "autorisé", - "name": "decision", - "title": "Décision", - "type": "string" - }, - { - "constraints": { - "required": "True" - }, - "description": "Date de décision sur la déclaration du complément alimentaire.", - "example": "2024-01-27", - "name": "date_decision", - "title": "Date décision", - "type": "date" - }, - { - "constraints": { - "required": "True" - }, - "description": "Nom commercial du produit (fourni par le fabricant)", - "example": "Compl'Alimentaires", - "name": "nom_commercial", - "title": "Nom commercial", - "type": "string" - }, - { - "description": "Marque du fabricant sous laquelle est vendue le complément alimentaire (fournie par le fabricant)", - "example": "Nutra Power", - "name": "marque", - "title": "Marque", - "type": "string" - }, - { - "description": "Gamme de la marque du fabricant dans laquelle le produit est vendu (fournie par le fabricant)", - "example": "Premium", - "name": "gamme", - "title": "Gamme", - "type": "string" - }, - { - "constraints": { - "required": "True" - }, - "description": "Nom de l'entreprise responsable de la mise sur le marché du complément alimentaire", - "example": "Compl Corp", - "name": "responsable_mise_sur_marche", - "title": "Responsable de la mise sur le marché", - "type": "string" - }, - { - "constraints": { - "pattern": "^[0-9]{14}$", - "required": true - }, - "description": "Siret de l'entreprise responsable de la mise sur le marché du complément alimentaire", - "example": "11007001800012", - "name": "siret_responsable_mise_sur_marche", - "title": "Siret responsable de la mise sur le marché", - "type": "integer" - }, - { - "constraints": { - "enum": [ - "15", - "16", - "17", - "18" - ], - "required": "True" - }, - "description": "Réference de l'article juridique qui encadre la déclaration du produit. La déclaration du complément ne suit pas la même procédure en fonction de l'article dans lequel il tombe. Voir sur : https://www.legifrance.gouv.fr/loda/id/JORFTEXT000000638341", - "example": "15", - "name": "article_procedure", - "title": "Article de procédure", - "type": "integer" - }, - { - "constraints": { - "required": "True" - }, - "description": "La forme galénique correspond à la forme sous laquelle le complément alimentaire se présente (comprimé, gélule, sirop...). Elle est spécialement conçue pour la voie d’administration à laquelle le complément est destiné. (source : sante.gouv.fr)", - "example": "Sirop", - "name": "forme_galenique", - "title": "Forme galénique", - "type": "string" - }, - { - "constraints": { - "required": "True" - }, - "description": "Dose journalière recommandée (DJR) (fournie par le fabricant). Les quantités des composants sont renseignées par DJR.", - "example": "16 gouttes", - "name": "dose_journaliere", - "title": "Dose journalière", - "type": "string" - }, - { - "description": "Recommandation d'emploi (fournies par le fabricant)", - "example": "4 gouttes 4 fois par jour dans un verre d'eau", - "name": "mode_emploi", - "title": "Mode Emploi", - "type": "string" - }, - { - "description": "Mise en garde et avertissement (fournies par le fabricant). Certains avertissements doivent obligatoirement être notés sur l'étiquettage.", - "example": "Ne pas dépasser la dose journalière recommandée. Tenir hors de portée des jeunes enfants. Ne peut se substituer à un régime alimentaire varié.", - "name": "mises_en_garde", - "title": "Mises en garde", - "type": "string" - }, - { - "description": "Objectifs du complément alimentaire (fourni par le fabricant)", - "name": "objectif_effet", - "type": "array" - }, - { - "description": "Arômes (fourni par le fabricant)", - "example": "Orange", - "name": "aromes", - "title": "Arômes", - "type": "string" - }, - { - "description": "La consommation du complément alimentaire est déconseillée pour les populations présentant ce ou ces facteurs", - "example": "Hypertension, Maladie rénale", - "name": "facteurs_risques", - "title": "facteurs_risques", - "type": "array" - }, - { - "description": "Les populations ciblées par le complément alimentaire déclaré. Le fabricant choisi parmi une liste fournie par Compl'Alim", - "example": "Femmes enceintes, Femmes allaitantes", - "name": "populations_cibles", - "title": "Population cibles", - "type": "array" - }, - { - "description": "Liste des plantes actives composants le complément alimentaire. Pour chaque plante nous retrouvons son nom en latin, sa famille, la partie utilisée ainsi que la quantité par DJR.", - "example": "{'nom': 'Juglans regia L.', 'partie': 'Fleur', 'preparation': 'Alcoolat', 'quantité_par_djr': '2', 'unité': 'ml'}", - "name": "plantes", - "title": "Plantes", - "type": "array" - }, - { - "description": "Liste des plantes/autres ingrédients inactifs composants le complément alimentaire. Pour chaque plante nous retrouvons son nom en latin, sa famille et la partie utilisée", - "example": "carotte, miel, fibre", - "name": "ingredients_inactifs", - "title": "Ingrédients inactifs", - "type": "array" - }, - { - "description": "Micro organismes composants le complément alimentaire. Si le micro-organisme a été inactivé, il n'est pas nécessaire de renseigner la quantité.", - "example": "{'genre': 'Lactobacillus', 'espèce': 'lactis', 'souche': '', 'quantité_par_djr': '2', 'unité': 'cfu', 'inactivé': 'True'}", - "name": "micro_organismes", - "type": "array" - }, - { - "description": "Additifs", - "example": "E416", - "name": "additifs", - "type": "array" - }, - { - "description": "Nutriments (ou forme d'apports)", - "example": "Cyanocobalamine", - "name": "nutriments", - "type": "array" - }, - { - "description": "Autres ingrédients actifs", - "example": "dextrine", - "name": "autres_ingredients_actifs", - "title": "Autres ingredients actifs", - "type": "array" - }, - { - "description": "Substances (composant actif)", - "example": "['nom': 'cafféine', 'quantité_par_djr': '2', 'unité': 'ml']", - "name": "substances", - "title": "Substances", - "type": "array" - }, - { - "description": "Lien vers une image de l'étiquette (limité à 4Mb)", - "example": "www.lienverslimage.com", - "name": "etiquette", - "title": "Etiquette", - "type": "string" - } - ] - }, + "path": "https://raw.githubusercontent.com/betagouv/complements-alimentaires/refs/heads/staging/data/schemas/schema_declarations.json", "scheme": "file", "type": "table" } diff --git a/data/tests/test_declaration.py b/data/tests/test_declaration.py index 5d87cc1d5..91e25be4e 100644 --- a/data/tests/test_declaration.py +++ b/data/tests/test_declaration.py @@ -135,7 +135,7 @@ def test_article_empty(self): self.assertIsNone(declaration.article) self.assertEqual(declaration.calculated_article, "") - self.assertEqual(declaration.overriden_article, "") + self.assertEqual(declaration.overridden_article, "") def test_article_15(self): declaration = InstructionReadyDeclarationFactory( @@ -152,7 +152,7 @@ def test_article_15(self): self.assertEqual(declaration.article, Declaration.Article.ARTICLE_15) self.assertEqual(declaration.calculated_article, Declaration.Article.ARTICLE_15) - self.assertEqual(declaration.overriden_article, "") + self.assertEqual(declaration.overridden_article, "") def test_article_15_warning(self): """ @@ -173,7 +173,7 @@ def test_article_15_warning(self): declaration_with_risky_substance.refresh_from_db() self.assertEqual(declaration_with_risky_substance.article, Declaration.Article.ARTICLE_15_WARNING) self.assertEqual(declaration_with_risky_substance.calculated_article, Declaration.Article.ARTICLE_15_WARNING) - self.assertEqual(declaration_with_risky_substance.overriden_article, "") + self.assertEqual(declaration_with_risky_substance.overridden_article, "") declaration_with_risky_prepared_plant = InstructionReadyDeclarationFactory( declared_plants=[], @@ -188,7 +188,7 @@ def test_article_15_warning(self): self.assertEqual( declaration_with_risky_prepared_plant.calculated_article, Declaration.Article.ARTICLE_15_WARNING ) - self.assertEqual(declaration_with_risky_prepared_plant.overriden_article, "") + self.assertEqual(declaration_with_risky_prepared_plant.overridden_article, "") risky_galenic_formulation = GalenicFormulationFactory(is_risky=True) declaration_with_risky_galenic_formulation = InstructionReadyDeclarationFactory( @@ -202,7 +202,7 @@ def test_article_15_warning(self): self.assertEqual( declaration_with_risky_galenic_formulation.calculated_article, Declaration.Article.ARTICLE_15_WARNING ) - self.assertEqual(declaration_with_risky_galenic_formulation.overriden_article, "") + self.assertEqual(declaration_with_risky_galenic_formulation.overridden_article, "") risky_target_population = PopulationFactory(is_defined_by_anses=True) declaration_with_risky_population = InstructionReadyDeclarationFactory( @@ -218,7 +218,7 @@ def test_article_15_warning(self): self.assertEqual( declaration_with_risky_population.calculated_article, Declaration.Article.ARTICLE_15_HIGH_RISK_POPULATION ) - self.assertEqual(declaration_with_risky_population.overriden_article, "") + self.assertEqual(declaration_with_risky_population.overridden_article, "") def test_article_15_override(self): declaration = InstructionReadyDeclarationFactory( @@ -230,14 +230,14 @@ def test_article_15_override(self): ) # La PlantFactory utilisée dans DeclaredPlantFactory a par défaut un status = AUTHORIZED DeclaredPlantFactory(new=False, declaration=declaration) - declaration.overriden_article = Declaration.Article.ARTICLE_16 + declaration.overridden_article = Declaration.Article.ARTICLE_16 declaration.assign_calculated_article() declaration.save() declaration.refresh_from_db() self.assertEqual(declaration.article, Declaration.Article.ARTICLE_16) self.assertEqual(declaration.calculated_article, Declaration.Article.ARTICLE_15) - self.assertEqual(declaration.overriden_article, Declaration.Article.ARTICLE_16) + self.assertEqual(declaration.overridden_article, Declaration.Article.ARTICLE_16) def test_article_16(self): """ @@ -259,7 +259,7 @@ def test_article_16(self): self.assertEqual(declaration_new.article, Declaration.Article.ARTICLE_16) self.assertEqual(declaration_new.calculated_article, Declaration.Article.ARTICLE_16) - self.assertEqual(declaration_new.overriden_article, "") + self.assertEqual(declaration_new.overridden_article, "") declaration_not_autorized = InstructionReadyDeclarationFactory( declared_plants=[], @@ -277,7 +277,7 @@ def test_article_16(self): self.assertEqual(declaration_not_autorized.article, Declaration.Article.ARTICLE_16) self.assertEqual(declaration_not_autorized.calculated_article, Declaration.Article.ARTICLE_16) - self.assertEqual(declaration_not_autorized.overriden_article, "") + self.assertEqual(declaration_not_autorized.overridden_article, "") def test_article_anses_referal(self): SUBSTANCE_MAX_QUANTITY = 1.0 @@ -298,7 +298,7 @@ def test_article_anses_referal(self): self.assertEqual( declaration_with_computed_substance_max_exceeded.calculated_article, Declaration.Article.ANSES_REFERAL ) - self.assertEqual(declaration_with_computed_substance_max_exceeded.overriden_article, "") + self.assertEqual(declaration_with_computed_substance_max_exceeded.overridden_article, "") # La déclaration ne doit pas passer en saisine ANSES si la dose est exactement égale à la dose maximale declaration_with_computed_substance_equals_max = InstructionReadyDeclarationFactory( @@ -317,7 +317,7 @@ def test_article_anses_referal(self): self.assertEqual( declaration_with_computed_substance_equals_max.calculated_article, Declaration.Article.ARTICLE_15 ) - self.assertEqual(declaration_with_computed_substance_equals_max.overriden_article, "") + self.assertEqual(declaration_with_computed_substance_equals_max.overridden_article, "") declaration_with_declared_substance_max_exceeded = InstructionReadyDeclarationFactory( computed_substances=[], @@ -335,7 +335,7 @@ def test_article_anses_referal(self): self.assertEqual( declaration_with_declared_substance_max_exceeded.calculated_article, Declaration.Article.ANSES_REFERAL ) - self.assertEqual(declaration_with_declared_substance_max_exceeded.overriden_article, "") + self.assertEqual(declaration_with_declared_substance_max_exceeded.overridden_article, "") def test_visa_refused(self): """ diff --git a/data/tests/test_teleicare_history_importer.py b/data/tests/test_teleicare_history_importer.py index e3d0e4909..81a17121d 100644 --- a/data/tests/test_teleicare_history_importer.py +++ b/data/tests/test_teleicare_history_importer.py @@ -123,7 +123,9 @@ def test_create_new_companies(self): self.assertEqual(created_company.postal_code, etablissement_to_create_as_company.etab_adre_cp) self.assertEqual(created_company.city, etablissement_to_create_as_company.etab_adre_ville) - def test_create_declaration_from_history(self): + @patch("data.etl.teleicare_history.extractor.add_composition_from_teleicare_history") + @patch("data.etl.teleicare_history.extractor.add_product_info_from_teleicare_history") + def test_create_declaration_from_history(self, mocked_add_composition_function, mocked_add_product_function): """ Les déclarations sont créées à partir d'object historiques des modèles Ica_ """ @@ -136,7 +138,7 @@ def test_create_declaration_from_history(self): CA_to_create_as_declaration = ComplementAlimentaireFactory( etab=etablissement_to_create_as_company, frmgal_ident=galenic_formulation_id ) - declaration_to_create_as_declaration = DeclarationFactory(cplalim=CA_to_create_as_declaration) + declaration_to_create_as_declaration = DeclarationFactory(cplalim=CA_to_create_as_declaration, tydcl_ident=1) version_declaration_to_create_as_declaration = VersionDeclarationFactory( dcl=declaration_to_create_as_declaration, stadcl_ident=8, @@ -157,6 +159,7 @@ def test_create_declaration_from_history(self): self.assertEqual(created_declaration.galenic_formulation, galenic_formulation) self.assertEqual(created_declaration.unit_quantity, 32) self.assertEqual(created_declaration.unit_measurement, unit) + self.assertEqual(created_declaration.article, Declaration.Article.ARTICLE_15) self.assertEqual( created_declaration.conditioning, version_declaration_to_create_as_declaration.vrsdecl_conditionnement ) @@ -165,5 +168,6 @@ def test_create_declaration_from_history(self): str(version_declaration_to_create_as_declaration.vrsdecl_poids_uc), ) self.assertEqual( - created_declaration.minimum_duration, str(version_declaration_to_create_as_declaration.vrsdecl_durabilite) + created_declaration.minimum_duration, + str(version_declaration_to_create_as_declaration.vrsdecl_durabilite), ) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b5d0054da..2376e1009 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -5,20 +5,20 @@ "packages": { "": { "dependencies": { - "@gouvminint/vue-dsfr": "^8.1.0", + "@gouvminint/vue-dsfr": "^8.1.1", "@vue/cli": "^5.0.8", "@vuelidate/core": "^2.0.3", "@vuelidate/validators": "^2.0.4", - "@vueuse/core": "^12.4.0", + "@vueuse/core": "^12.5.0", "core-js": "^3.40.0", - "pinia": "^2.3.0", + "pinia": "^2.3.1", "vue": "^3.5.13", "vue-matomo": "^4.2.0", "vue-router": "^4.5.0", "webpack-bundle-tracker": "^3.1.1" }, "devDependencies": { - "@babel/core": "^7.26.0", + "@babel/core": "^7.26.7", "@babel/eslint-parser": "^7.26.5", "@vue/cli-plugin-babel": "~5.0.0", "@vue/cli-plugin-eslint": "~5.0.0", @@ -244,9 +244,10 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.0.tgz", - "integrity": "sha512-INCKxTtbXtcNbUZ3YXutwMpEleqttcswhAdee7dhuoVrD2cnuc3PqtERBtxkX5nziX9vnBL8WXmSGwv8CuPV6g==", + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", + "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.25.9", "js-tokens": "^4.0.0", @@ -257,28 +258,30 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.0.tgz", - "integrity": "sha512-qETICbZSLe7uXv9VE8T/RWOdIE5qqyTucOt4zLYMafj2MRO271VGgLd4RACJMeBO37UPWhXiKMBk7YlJ0fOzQA==", + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.5.tgz", + "integrity": "sha512-XvcZi1KWf88RVbF9wn8MN6tYFloU5qX8KjuF3E1PVBmJ9eypXfs4GRiJwLuTZL0iSnJUKn1BFPa5BPZZJyFzPg==", + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.0.tgz", - "integrity": "sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==", + "version": "7.26.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.7.tgz", + "integrity": "sha512-SRijHmF0PSPgLIBYlWnG0hyeJLwXE2CgpsXaMOrtt2yp9/86ALw6oUlj9KYuZ0JN07T4eBMVIW4li/9S1j2BGA==", + "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.26.0", - "@babel/generator": "^7.26.0", - "@babel/helper-compilation-targets": "^7.25.9", + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.26.5", + "@babel/helper-compilation-targets": "^7.26.5", "@babel/helper-module-transforms": "^7.26.0", - "@babel/helpers": "^7.26.0", - "@babel/parser": "^7.26.0", + "@babel/helpers": "^7.26.7", + "@babel/parser": "^7.26.7", "@babel/template": "^7.25.9", - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.26.0", + "@babel/traverse": "^7.26.7", + "@babel/types": "^7.26.7", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -329,12 +332,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.0.tgz", - "integrity": "sha512-/AIkAmInnWwgEAJGQr9vY0c66Mj6kjkE2ZPB1PurTRaRAh3U+J45sAQMjQDJdh4WbR3l0x5xkimXBKyBXXAu2w==", + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.5.tgz", + "integrity": "sha512-2caSP6fN9I7HOe6nqhtft7V4g7/V/gfDsC3Ag4W7kEzzvRGKqiv0pu0HogPiZ3KaVSoNDhUws6IJjDjpfmYIXw==", + "license": "MIT", "dependencies": { - "@babel/parser": "^7.26.0", - "@babel/types": "^7.26.0", + "@babel/parser": "^7.26.5", + "@babel/types": "^7.26.5", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" @@ -366,11 +370,12 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.9.tgz", - "integrity": "sha512-j9Db8Suy6yV/VHa4qzrj9yZfZxhLWQdVnRlXxmKLYlhWUVB1sB2G5sxuWYXk/whHD9iW76PmNzxZ4UCnTQTVEQ==", + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.26.5.tgz", + "integrity": "sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA==", + "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.25.9", + "@babel/compat-data": "^7.26.5", "@babel/helper-validator-option": "^7.25.9", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", @@ -666,23 +671,25 @@ } }, "node_modules/@babel/helpers": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.0.tgz", - "integrity": "sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==", + "version": "7.26.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.7.tgz", + "integrity": "sha512-8NHiL98vsi0mbPQmYAGWwfcFaOy4j2HY49fXJCfuDcdE7fMIsH9a7GdaeXpIBsbT7307WU8KCMp5pUVDNL4f9A==", + "license": "MIT", "dependencies": { "@babel/template": "^7.25.9", - "@babel/types": "^7.26.0" + "@babel/types": "^7.26.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.26.1", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.1.tgz", - "integrity": "sha512-reoQYNiAJreZNsJzyrDNzFQ+IQ5JFiIzAHJg9bn94S3l+4++J7RsIhNMoB+lgP/9tpmiAQqspv+xfdxTSzREOw==", + "version": "7.26.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.7.tgz", + "integrity": "sha512-kEvgGGgEjRUutvdVvZhbn/BxVt+5VSpwXz1j3WYXQbXDo8KzFOPNG2GQbdAiNq8g6wn1yKk7C/qrke03a84V+w==", + "license": "MIT", "dependencies": { - "@babel/types": "^7.26.0" + "@babel/types": "^7.26.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -2083,15 +2090,16 @@ } }, "node_modules/@babel/traverse": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.9.tgz", - "integrity": "sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw==", + "version": "7.26.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.7.tgz", + "integrity": "sha512-1x1sgeyRLC3r5fQOM0/xtQKsYjyxmFjaOrLJNtZ81inNjyJHGIolTULPiSc/2qe1/qfpFLisLQYFnnZl7QoedA==", + "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.25.9", - "@babel/generator": "^7.25.9", - "@babel/parser": "^7.25.9", + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.26.5", + "@babel/parser": "^7.26.7", "@babel/template": "^7.25.9", - "@babel/types": "^7.25.9", + "@babel/types": "^7.26.7", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -2100,9 +2108,10 @@ } }, "node_modules/@babel/types": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.0.tgz", - "integrity": "sha512-Z/yiTPj+lDVnF7lWeKCIJzaIkI0vYO87dMpZ4bg4TDrFe4XXLFWL1TbXU27gBP3QccxV9mZICCrnjnYlJjXHOA==", + "version": "7.26.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.7.tgz", + "integrity": "sha512-t8kDRGrKXyp6+tjUh7hw2RLyclsW4TRoRvRHtSyAX9Bb5ldlFh+90YAYY6awRXrlB4G5G2izNeGySpATlFzmOg==", + "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9" @@ -2215,9 +2224,10 @@ } }, "node_modules/@gouvminint/vue-dsfr": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@gouvminint/vue-dsfr/-/vue-dsfr-8.1.0.tgz", - "integrity": "sha512-eF7lVhZjsKg8qSJsCxySfSAxjYS4DZU2wtjcnPUMDPxL7PFbGSvYFHqyJZ3PVMMYh5DE+9zkleqnqAJmJbhRyg==", + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@gouvminint/vue-dsfr/-/vue-dsfr-8.1.1.tgz", + "integrity": "sha512-L4FRp4mex7DGKRIn+ljsHynW/kvj2/dbOPkRWnxTGFrppBj3otn5jh55tRL4hK3Kn3LjEs62/6ldOcWb5Zn1kQ==", + "license": "MIT", "dependencies": { "@gouvfr/dsfr": "~1.12.1", "focus-trap": "^7.6.2", @@ -3897,13 +3907,14 @@ } }, "node_modules/@vueuse/core": { - "version": "12.4.0", - "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.4.0.tgz", - "integrity": "sha512-XnjQYcJwCsyXyIafyA6SvyN/OBtfPnjvJmbxNxQjCcyWD198urwm5TYvIUUyAxEAN0K7HJggOgT15cOlWFyLeA==", + "version": "12.5.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.5.0.tgz", + "integrity": "sha512-GVyH1iYqNANwcahAx8JBm6awaNgvR/SwZ1fjr10b8l1HIgDp82ngNbfzJUgOgWEoxjL+URAggnlilAEXwCOZtg==", + "license": "MIT", "dependencies": { "@types/web-bluetooth": "^0.0.20", - "@vueuse/metadata": "12.4.0", - "@vueuse/shared": "12.4.0", + "@vueuse/metadata": "12.5.0", + "@vueuse/shared": "12.5.0", "vue": "^3.5.13" }, "funding": { @@ -3911,17 +3922,19 @@ } }, "node_modules/@vueuse/metadata": { - "version": "12.4.0", - "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.4.0.tgz", - "integrity": "sha512-AhPuHs/qtYrKHUlEoNO6zCXufu8OgbR8S/n2oMw1OQuBQJ3+HOLQ+EpvXs+feOlZMa0p8QVvDWNlmcJJY8rW2g==", + "version": "12.5.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.5.0.tgz", + "integrity": "sha512-Ui7Lo2a7AxrMAXRF+fAp9QsXuwTeeZ8fIB9wsLHqzq9MQk+2gMYE2IGJW48VMJ8ecvCB3z3GsGLKLbSasQ5Qlg==", + "license": "MIT", "funding": { "url": "https://github.com/sponsors/antfu" } }, "node_modules/@vueuse/shared": { - "version": "12.4.0", - "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.4.0.tgz", - "integrity": "sha512-9yLgbHVIF12OSCojnjTIoZL1+UA10+O4E1aD6Hpfo/DKVm5o3SZIwz6CupqGy3+IcKI8d6Jnl26EQj/YucnW0Q==", + "version": "12.5.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.5.0.tgz", + "integrity": "sha512-vMpcL1lStUU6O+kdj6YdHDixh0odjPAUM15uJ9f7MY781jcYkIwFA4iv2EfoIPO6vBmvutI1HxxAwmf0cx5ISQ==", + "license": "MIT", "dependencies": { "vue": "^3.5.13" }, @@ -12224,9 +12237,10 @@ } }, "node_modules/pinia": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.0.tgz", - "integrity": "sha512-ohZj3jla0LL0OH5PlLTDMzqKiVw2XARmC1XYLdLWIPBMdhDW/123ZWr4zVAhtJm+aoSkFa13pYXskAvAscIkhQ==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", "dependencies": { "@vue/devtools-api": "^6.6.3", "vue-demi": "^0.14.10" diff --git a/frontend/package.json b/frontend/package.json index 3f60cf6c9..df28f5881 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,20 +8,20 @@ "lint": "vue-cli-service lint" }, "dependencies": { - "@gouvminint/vue-dsfr": "^8.1.0", + "@gouvminint/vue-dsfr": "^8.1.1", "@vue/cli": "^5.0.8", "@vuelidate/core": "^2.0.3", "@vuelidate/validators": "^2.0.4", - "@vueuse/core": "^12.4.0", + "@vueuse/core": "^12.5.0", "core-js": "^3.40.0", - "pinia": "^2.3.0", + "pinia": "^2.3.1", "vue": "^3.5.13", "vue-matomo": "^4.2.0", "vue-router": "^4.5.0", "webpack-bundle-tracker": "^3.1.1" }, "devDependencies": { - "@babel/core": "^7.26.0", + "@babel/core": "^7.26.7", "@babel/eslint-parser": "^7.26.5", "@vue/cli-plugin-babel": "~5.0.0", "@vue/cli-plugin-eslint": "~5.0.0", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 35ca57b09..558594103 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,22 +1,24 @@ diff --git a/frontend/src/views/VisaPage/VisaValidationTab.vue b/frontend/src/views/VisaPage/VisaValidationTab.vue index 59da17ce5..725aefaf5 100644 --- a/frontend/src/views/VisaPage/VisaValidationTab.vue +++ b/frontend/src/views/VisaPage/VisaValidationTab.vue @@ -79,8 +79,6 @@ const hasOverriddenOriginalDecision = computed( () => overriddenDecision.value && Object.keys(overriddenDecision.value).length > 0 ) -const producerMessage = ref(declaration.value.postValidationProducerMessage) - const instructorName = computed(() => { if (!declaration.value?.instructor) return "-" return `${declaration.value.instructor.firstName || ""} ${declaration.value.instructor.lastName || ""}` diff --git a/frontend/src/views/VisaPage/index.vue b/frontend/src/views/VisaPage/index.vue index 3144f279b..9ebfbf95e 100644 --- a/frontend/src/views/VisaPage/index.vue +++ b/frontend/src/views/VisaPage/index.vue @@ -30,7 +30,7 @@ />
{ + Object.assign(definitions[0], { + __VUE_OPTIONS_API__: "true", + __VUE_PROD_DEVTOOLS__: "false", + __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: "false", + }) + return definitions + }) + config.devServer .host("0.0.0.0") .port(8080) diff --git a/requirements.txt b/requirements.txt index eafe375b5..33aa2630f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ boto3==1.35.97 ; python_version >= "3.11" and python_version < "4" botocore==1.35.97 ; python_version >= "3.11" and python_version < "4" bs4==0.0.2 ; python_version >= "3.11" and python_version < "4" celery==5.4.0 ; python_version >= "3.11" and python_version < "4" -certifi==2024.12.14 ; python_version >= "3.11" and python_version < "4" +certifi==2024.8.30 ; python_version >= "3.11" and python_version < "4" cffi==1.17.1 ; python_version >= "3.11" and python_version < "4" and platform_python_implementation != "PyPy" cfgv==3.4.0 ; python_version >= "3.11" and python_version < "4" chardet==5.2.0 ; python_version >= "3.11" and python_version < "4" @@ -61,7 +61,7 @@ kombu==5.4.2 ; python_version >= "3.11" and python_version < "4" lxml==5.3.0 ; python_version >= "3.11" and python_version < "4" matplotlib-inline==0.1.7 ; python_version >= "3.11" and python_version < "4" nodeenv==1.9.1 ; python_version >= "3.11" and python_version < "4" -numpy==2.2.2 ; python_version >= "3.12" and python_version < "4" or python_version == "3.11" +numpy==2.2.1 ; python_version >= "3.12" and python_version < "4" or python_version == "3.11" oscrypto==1.3.0 ; python_version >= "3.11" and python_version < "4" pandas==2.2.3 ; python_version >= "3.11" and python_version < "4" parso==0.8.4 ; python_version >= "3.11" and python_version < "4" @@ -76,7 +76,7 @@ ptyprocess==0.7.0 ; python_version >= "3.11" and python_version < "4" and (sys_p pure-eval==0.2.3 ; python_version >= "3.11" and python_version < "4" pycodestyle==2.12.1 ; python_version >= "3.11" and python_version < "4" pycparser==2.22 ; python_version >= "3.11" and python_version < "4" and platform_python_implementation != "PyPy" -pygments==2.19.1 ; python_version >= "3.11" and python_version < "4" +pygments==2.18.0 ; python_version >= "3.11" and python_version < "4" pyhanko-certvalidator==0.26.5 ; python_version >= "3.11" and python_version < "4" pyhanko==0.25.3 ; python_version >= "3.11" and python_version < "4" pypdf==5.1.0 ; python_version >= "3.11" and python_version < "4"