diff --git a/Dockerfile b/Dockerfile index 7aea1e61d..33649ae3b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,6 +48,9 @@ RUN --mount=type=cache,target=/root/.cache/pip \ --mount=type=bind,source=requirements.txt,target=requirements.txt \ python -m pip install -r requirements.txt +# Switch to the non-privileged user to run the application. +USER appuser + # Copy the source code into the container. COPY . . diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..de043bf89 --- /dev/null +++ b/Makefile @@ -0,0 +1,12 @@ +build: + docker compose build + +run: + docker compose up + +bash: + docker compose run --user root server bash -rm + +clean: + docker image prune + docker container prune diff --git a/README.md b/README.md index 086878db0..2c832e770 100644 --- a/README.md +++ b/README.md @@ -82,9 +82,9 @@ avec `git commit -m 'my message' --no-verify`. ### Installation avec docker (_méthode 3_) ``` -docker compose build -docker compose up -docker compose run -rm server bash +make build +make run +make bash python manage.py migrate ``` diff --git a/api/serializers/declaration.py b/api/serializers/declaration.py index 24945017a..087e41f63 100644 --- a/api/serializers/declaration.py +++ b/api/serializers/declaration.py @@ -244,6 +244,7 @@ class Meta: "id", "file", "type", + "type_display", "name", ) read_only_fields = ("file",) diff --git a/api/tests/test_company.py b/api/tests/test_company.py index eee17e7b0..ff7b6c5ed 100644 --- a/api/tests/test_company.py +++ b/api/tests/test_company.py @@ -178,7 +178,7 @@ def test_update_company_ko_unauthenticated(self): def test_update_company_ko_bad_data(self): self.login(self.supervisor) - self.payload["email"] = "" + self.payload["phone_number"] = "" response = self.put(self.url(pk=self.company.id), self.payload) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/api/tests/test_declaration.py b/api/tests/test_declaration.py index 7c7df26e7..6eb04f2b8 100644 --- a/api/tests/test_declaration.py +++ b/api/tests/test_declaration.py @@ -38,7 +38,7 @@ VisaRoleFactory, PlantSynonymFactory, ) -from data.models import Attachment, Declaration, Snapshot, DeclaredMicroorganism, DeclaredPlant +from data.models import Attachment, Declaration, DeclaredMicroorganism, DeclaredPlant, Snapshot from .utils import authenticate @@ -1497,6 +1497,56 @@ def test_sort_declarations_by_instruction_limit(self): self.assertEqual(results[1]["id"], declaration_middle.id) self.assertEqual(results[2]["id"], declaration_last.id) + @authenticate + def test_sort_declarations_by_instruction_limit_with_visa(self): + """ + Le refus d'un visa ne doit pas compter pour le triage par date limite de réponse + """ + InstructionRoleFactory(user=authenticate.user) + + today = timezone.now() + + declaration_less_urgent = AwaitingInstructionDeclarationFactory() + snapshot_less_urgent = SnapshotFactory( + declaration=declaration_less_urgent, status=declaration_less_urgent.status + ) + snapshot_less_urgent.creation_date = today - timedelta(days=1) + snapshot_less_urgent.save() + + declaration_more_urgent = AwaitingInstructionDeclarationFactory() + snapshot_more_urgent = SnapshotFactory( + declaration=declaration_more_urgent, status=declaration_more_urgent.status + ) + snapshot_more_urgent.creation_date = today - timedelta(days=10) + snapshot_more_urgent.save() + + # Le snapshot du refus de visa ne doit pas affecter la date limite de réponse + snapshot_visa_refusal = SnapshotFactory( + declaration=declaration_more_urgent, + status=declaration_more_urgent.status, + action=Snapshot.SnapshotActions.REFUSE_VISA, + ) + snapshot_visa_refusal.creation_date = today - timedelta(days=1) + snapshot_visa_refusal.save() + + # Triage par date limite d'instruction + sort_url = f"{reverse('api:list_all_declarations')}?ordering=responseLimitDate" + response = self.client.get(sort_url, format="json") + results = response.json()["results"] + self.assertEqual(len(results), 2) + + self.assertEqual(results[0]["id"], declaration_more_urgent.id) + self.assertEqual(results[1]["id"], declaration_less_urgent.id) + + # Triage par date limite d'instruction inversé + reverse_sort_url = f"{reverse('api:list_all_declarations')}?ordering=-responseLimitDate" + response = self.client.get(reverse_sort_url, format="json") + results = response.json()["results"] + self.assertEqual(len(results), 2) + + self.assertEqual(results[0]["id"], declaration_less_urgent.id) + self.assertEqual(results[1]["id"], declaration_more_urgent.id) + @authenticate def test_update_article(self): """ @@ -1682,7 +1732,7 @@ def test_get_single_declared_ingredient(self): ingredient = DeclaredIngredientFactory(declaration=declaration) response = self.client.get( - reverse("api:declared_element", kwargs={"pk": ingredient.id, "type": "ingredient"}), format="json" + reverse("api:declared_element", kwargs={"pk": ingredient.id, "type": "other-ingredient"}), format="json" ) body = response.json() self.assertEqual(body["id"], ingredient.id) @@ -1700,7 +1750,7 @@ def test_cannot_get_declared_element_unknown_type(self): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual( response.json()["globalError"], - "Unknown type: 'unknown' not in ['plant', 'microorganism', 'substance', 'ingredient']", + "Unknown type: 'unknown' not in ['plant', 'microorganism', 'substance', 'other-ingredient']", ) def test_get_declared_element_not_allowed_not_authenticated(self): @@ -1718,7 +1768,7 @@ def test_get_declared_element_not_allowed_not_authenticated(self): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) response = self.client.get( - reverse("api:declared_element", kwargs={"pk": 1, "type": "ingredient"}), format="json" + reverse("api:declared_element", kwargs={"pk": 1, "type": "other-ingredient"}), format="json" ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) @@ -1738,7 +1788,7 @@ def test_get_declared_element_not_allowed_not_instructor(self): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) response = self.client.get( - reverse("api:declared_element", kwargs={"pk": 1, "type": "ingredient"}), format="json" + reverse("api:declared_element", kwargs={"pk": 1, "type": "other-ingredient"}), format="json" ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) @@ -1779,7 +1829,7 @@ def test_request_info_declared_element_not_allowed_not_instructor(self): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) response = self.client.get( - reverse("api:declared_element_request_info", kwargs={"pk": 1, "type": "ingredient"}), format="json" + reverse("api:declared_element_request_info", kwargs={"pk": 1, "type": "other-ingredient"}), format="json" ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) @@ -1819,7 +1869,7 @@ def test_reject_declared_element_not_allowed_not_instructor(self): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) response = self.client.get( - reverse("api:declared_element_reject", kwargs={"pk": 1, "type": "ingredient"}), format="json" + reverse("api:declared_element_reject", kwargs={"pk": 1, "type": "other-ingredient"}), format="json" ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) diff --git a/api/views/declaration/declaration.py b/api/views/declaration/declaration.py index 613eb55b9..639f68070 100644 --- a/api/views/declaration/declaration.py +++ b/api/views/declaration/declaration.py @@ -291,6 +291,9 @@ def filter_queryset(self, request, queryset, view): """ Cette fonction vise à réproduire la property "response_limit_date" du modèle Declaration mais dans la couche DB (avec des querysets) afin de pouvoir filtrer dessus. + + ⚠️ Attention : Tout changement effectué dans cette fonction doit aussi être reflété dans + data/models/declaration.py > response_limit_date """ order_by_response_limit, desc = self.order_by_response_limit(request) @@ -302,6 +305,7 @@ def filter_queryset(self, request, queryset, view): Snapshot.objects.filter( declaration=OuterRef("pk"), status=Declaration.DeclarationStatus.AWAITING_INSTRUCTION ) + .exclude(action=Snapshot.SnapshotActions.REFUSE_VISA) .order_by("-creation_date" if order_by_response_limit else "creation_date") .values("creation_date")[:1] ) diff --git a/api/views/declaration/declared_element.py b/api/views/declaration/declared_element.py index 6f3d9744c..c721c1081 100644 --- a/api/views/declaration/declared_element.py +++ b/api/views/declaration/declared_element.py @@ -78,7 +78,7 @@ class ElementMappingMixin: "synonym_model": SubstanceSynonym, "serializer": DeclaredSubstanceSerializer, }, - "ingredient": { + "other-ingredient": { "model": DeclaredIngredient, "element_model": Ingredient, "synonym_model": IngredientSynonym, diff --git a/data/behaviours/auto_validable.py b/data/behaviours/auto_validable.py index 4da0efe2f..f0ea6e16c 100644 --- a/data/behaviours/auto_validable.py +++ b/data/behaviours/auto_validable.py @@ -10,5 +10,15 @@ class Meta: abstract = True def save(self, *args, **kwargs): - self.full_clean() + """ + * fields_with_no_validation est un set + """ + fields_with_no_validation = kwargs.pop("fields_with_no_validation", ()) + if fields_with_no_validation: + self.clean_fields(exclude=fields_with_no_validation) + self.clean() + self.validate_unique() + self.validate_constraints() + else: + self.full_clean() super().save(*args, **kwargs) diff --git a/data/etl/teleicare_history/extractor.py b/data/etl/teleicare_history/extractor.py index 605646f75..acdec9f03 100644 --- a/data/etl/teleicare_history/extractor.py +++ b/data/etl/teleicare_history/extractor.py @@ -1,11 +1,51 @@ import logging +import re +from datetime import date, datetime -from data.models import Company, IcaEtablissement +from django.core.exceptions import ValidationError +from django.db import IntegrityError + +from phonenumber_field.phonenumber import PhoneNumber + +from data.models import GalenicFormulation, SubstanceUnit +from data.models.company import ActivityChoices, Company +from data.models.declaration import Declaration +from data.models.teleicare_history.ica_declaration import ( + IcaComplementAlimentaire, + IcaDeclaration, + IcaVersionDeclaration, +) +from data.models.teleicare_history.ica_etablissement import IcaEtablissement logger = logging.getLogger(__name__) -def match_companies_on_siret_or_vat(): +def convert_phone_number(phone_number_to_parse): + if phone_number_to_parse: + phone_number = PhoneNumber.from_string(phone_number_to_parse, region="FR") + return phone_number + return "" + + +def convert_activities(etab): + activities = [] + if etab.etab_ica_faconnier: + activities.append(ActivityChoices.FAÇONNIER) + if etab.etab_ica_fabricant: + activities.append(ActivityChoices.FABRICANT) + if etab.etab_ica_importateur: + activities.append(ActivityChoices.IMPORTATEUR) + if etab.etab_ica_introducteur: + activities.append(ActivityChoices.INTRODUCTEUR) + if etab.etab_ica_conseil: + activities.append(ActivityChoices.CONSEIL) + if etab.etab_ica_distributeur: + activities.append(ActivityChoices.DISTRIBUTEUR) + + return activities + + +def match_companies_on_siret_or_vat(create_if_not_exist=False): """ Le matching pourrait aussi être fait sur * Q(social_name__icontains=etab.etab_raison_sociale) @@ -13,10 +53,14 @@ def match_companies_on_siret_or_vat(): * Q(email__icontains=etab.etab_courriel) * Q(phone_number__icontains=etab.etab_telephone) Mais il serait moins précis. + Cette méthode créé les entreprises non matchées pour avoir toutes les données intégrées dans le nouveau système. """ nb_vat_match = 0 nb_siret_match = 0 + nb_created_companies = 0 for etab in IcaEtablissement.objects.all(): + matched = False + # recherche de l'etablissement dans les Company déjà enregistrées if etab.etab_siret is not None: siret_matching = Company.objects.filter(siret=etab.etab_siret) # seulement 2 options possible pour len(siret_matching) sont 0 et 1 car il y a une contrainte d'unicité sur le champ Company.siret @@ -27,7 +71,9 @@ def match_companies_on_siret_or_vat(): ) else: nb_siret_match += 1 + matched = True siret_matching[0].siccrf_id = etab.etab_ident + siret_matching[0].matched = True siret_matching[0].save() elif etab.etab_numero_tva_intra is not None: @@ -40,9 +86,142 @@ def match_companies_on_siret_or_vat(): ) else: nb_vat_match += 1 + matched = True vat_matching[0].siccrf_id = etab.etab_ident + vat_matching[0].matched = True vat_matching[0].save() + # creation de la company + if not matched and create_if_not_exist: + new_company = Company( + siccrf_id=etab.etab_ident, + address=etab.etab_adre_voie, + postal_code=etab.etab_adre_cp, + city=etab.etab_adre_ville, + phone_number=convert_phone_number(etab.etab_telephone), + email=etab.etab_courriel or "", + social_name=etab.etab_raison_sociale, + commercial_name=etab.etab_enseigne or "", + siret=etab.etab_siret, + vat=etab.etab_numero_tva_intra, + activities=convert_activities(etab), + # la etab_date_adhesion n'est pas conservée + ) + try: + new_company.save(fields_with_no_validation=("phone_number")) + nb_created_companies += 1 + except ValidationError as e: + logger.error(f"Impossible de créer la Company à partir du siccrf_id = {etab.etab_ident}: {e}") logger.info( - f"{nb_vat_match} + {nb_siret_match} entreprises réconcilliées sur {len(IcaEtablissement.objects.all())}" + f"Sur {len(IcaEtablissement.objects.all())} : {nb_siret_match} entreprises réconcilliées par le siret." + ) + logger.info( + f"Sur {len(IcaEtablissement.objects.all())} : {nb_vat_match} entreprises réconcilliées par le n°TVA intracom." ) + logger.info(f"Sur {len(IcaEtablissement.objects.all())} : {nb_created_companies} entreprises créées.") + + +def get_most_recent(list_of_declarations): + def convert_str_date(value): + return datetime.strptime(value, "%m/%d/%Y %H:%M:%S %p").date() + + most_recent_date = date.min + for ica_declaration in list_of_declarations: + current_date = convert_str_date(ica_declaration.dcl_date) + if most_recent_date < current_date: + most_recent_date = current_date + most_recente_dcl_date = ica_declaration.dcl_date + + return list_of_declarations.get(dcl_date=most_recente_dcl_date) + + +# Pour les déclarations TeleIcare, le status correspond au champ IcaVersionDeclaration.stattdcl_ident +DECLARATION_STATUS_MAPPING = { + 1: Declaration.DeclarationStatus.ONGOING_INSTRUCTION, # 'en cours' + 2: Declaration.DeclarationStatus.AUTHORIZED, # 'autorisé temporaire' + 3: Declaration.DeclarationStatus.AUTHORIZED, # 'autorisé prolongé' aucune occurence + 4: Declaration.DeclarationStatus.AUTHORIZED, # 'autorisé définitif' aucune occurence + 5: Declaration.DeclarationStatus.REJECTED, # 'refusé' + 6: Declaration.DeclarationStatus.WITHDRAWN, # 'arrêt commercialisation' + 7: Declaration.DeclarationStatus.WITHDRAWN, # 'retiré du marché' aucune occurence + 8: Declaration.DeclarationStatus.ABANDONED, # 'abandonné' +} + + +def create_declaration_from_teleicare_history(): + """ + Dans Teleicare une entreprise peut-être relié à une déclaration par 3 relations différentes : + * responsable de l'étiquetage (équivalent Declaration.mandated_company) + * gestionnaire de la déclaration (équivalent Declaration.company) + * télédéclarante de la déclaration (cette relation n'est pour le moment pas conservée, car le BEPIAS ne sait pas ce qu'elle signifie) + """ + nb_created_declarations = 0 + + for ica_complement_alimentaire in IcaComplementAlimentaire.objects.all(): + # retrouve la déclaration la plus à jour correspondant à ce complément alimentaire + all_ica_declarations = IcaDeclaration.objects.filter(cplalim_id=ica_complement_alimentaire.cplalim_ident) + # le champ date est stocké en text, il faut donc faire la conversion en python + if all_ica_declarations.exists(): + latest_ica_declaration = get_most_recent(all_ica_declarations) + # retrouve la version de déclaration la plus à jour correspondant à cette déclaration + latest_ica_version_declaration = IcaVersionDeclaration.objects.filter( + dcl_id=latest_ica_declaration.dcl_ident, + stattdcl_ident__in=[ + 2, + 5, + 6, + 8, + ], # status 'autorisé', 'refusé', 'arrêt commercialisation', 'abandonné' + stadcl_ident=8, # état 'clos' + ).first() + # la déclaration a une version finalisée + if latest_ica_version_declaration: + try: + company = Company.objects.get(siccrf_id=ica_complement_alimentaire.etab_id) + except Company.DoesNotExist: + logger.error( + f"Cette entreprise avec siccrf_id={ica_complement_alimentaire.etab_id} n'existe pas déjà en base" + ) + continue + declaration = Declaration( + siccrf_id=ica_complement_alimentaire.cplalim_ident, + galenic_formulation=GalenicFormulation.objects.get( + siccrf_id=ica_complement_alimentaire.frmgal_ident + ), + company=company, # resp étiquetage, resp commercialisation + brand=ica_complement_alimentaire.cplalim_marque or "", + gamme=ica_complement_alimentaire.cplalim_gamme or "", + name=ica_complement_alimentaire.cplalim_nom, + flavor=ica_complement_alimentaire.dclencours_gout_arome_parfum or "", + other_galenic_formulation=ica_complement_alimentaire.cplalim_forme_galenique_autre or "", + # extraction d'un nombre depuis une chaîne de caractères + unit_quantity=re.findall(r"\d+", latest_ica_version_declaration.vrsdecl_djr)[0], + unit_measurement=SubstanceUnit.objects.get(siccrf_id=latest_ica_version_declaration.unt_ident), + conditioning=latest_ica_version_declaration.vrsdecl_conditionnement or "", + daily_recommended_dose=latest_ica_version_declaration.vrsdecl_poids_uc, + 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= + # address= + # postal_code= + # city= + # country= + status=Declaration.DeclarationStatus.WITHDRAWN + if latest_ica_declaration.dcl_date_fin_commercialisation + else DECLARATION_STATUS_MAPPING[latest_ica_version_declaration.stattdcl_ident], + ) + + try: + declaration.save() + nb_created_declarations += 1 + except IntegrityError: + # cette Déclaration a déjà été créée + pass + + logger.info(f"Sur {len(IcaComplementAlimentaire.objects.all())} : {nb_created_declarations} déclarations créées.") diff --git a/data/etl/teleicare_history/sql/company_declaration_table_creation.sql b/data/etl/teleicare_history/sql/company_declaration_table_creation.sql index a5abf4192..9e5e6bdec 100644 --- a/data/etl/teleicare_history/sql/company_declaration_table_creation.sql +++ b/data/etl/teleicare_history/sql/company_declaration_table_creation.sql @@ -46,6 +46,7 @@ CREATE TABLE ICA_ETABLISSEMENT ( CREATE TABLE ICA_COMPLEMENTALIMENTAIRE ( CPLALIM_IDENT integer PRIMARY KEY, FRMGAL_IDENT integer NULL, + -- toujours le même que celui indiqué dans ICA_VERSIONDECLARATION ETAB_IDENT integer NOT NULL, CPLALIM_MARQUE text NULL, CPLALIM_GAMME text NULL, @@ -57,9 +58,9 @@ CREATE TABLE ICA_COMPLEMENTALIMENTAIRE ( CREATE TABLE ICA_DECLARATION ( DCL_IDENT integer PRIMARY KEY, - CPLALIM_IDENT integer NOT NULL, + CPLALIM_IDENT integer NOT NULL, -- dcl_ident et cplalim_ident sont égaux TYDCL_IDENT integer NOT NULL, - ETAB_IDENT integer NULL, + ETAB_IDENT integer NULL, -- si différent de NULL c'est parce qu'il est différent de celui dans ICA_COMPLEMENTALIMENTAIRE et ICA_VERSIONDECLARATION, c'est l'id de l'entp mandatée (qui déclare pour une autre) ETAB_IDENT_RMM_DECLARANT integer NOT NULL, DCL_DATE text NOT NULL, DCL_SAISIE_ADMINISTRATION boolean NOT NULL, @@ -73,16 +74,17 @@ CREATE TABLE ICA_DECLARATION ( CREATE TABLE ICA_VERSIONDECLARATION ( VRSDECL_IDENT integer PRIMARY KEY, AG_IDENT integer NULL, - TYPVRS_IDENT integer NOT NULL, + TYPVRS_IDENT integer NOT NULL, -- le type de version de déclaration UNT_IDENT integer NULL, PAYS_IDENT_ADRE integer NULL, + -- toujours le même que celui indiqué dans ICA_COMPLEMENTALIMENTAIRE ETAB_IDENT integer NULL, - EX_IDENT integer NOT NULL, + EX_IDENT integer NOT NULL, -- le stade d'examen de la déclaration PAYS_IDENT_PAYS_DE_REFERENCE integer NULL, DCL_IDENT integer NOT NULL, - STATTDCL_IDENT integer NULL, - STADCL_IDENT integer NULL, - VRSDECL_NUMERO integer NOT NULL, + STATTDCL_IDENT integer NULL, -- le status de la déclaration + STADCL_IDENT integer NULL, -- le stade de la déclaration + VRSDECL_NUMERO integer NOT NULL, -- veut dire quoi ? VRSDECL_COMMENTAIRES text NULL, VRSDECL_MISE_EN_GARDE text NULL, VRSDECL_DURABILITE integer NULL, @@ -105,4 +107,40 @@ CREATE TABLE ICA_VERSIONDECLARATION ( VRSDECL_ADRE_DIST text NULL, VRSDECL_ADRE_REGION text NULL, VRSDECL_ADRE_RAISON_SOCIALE text NULL -) +); + + +-- EX_IDENT +-- when 1 then 'en attente' +-- when 2 then 'examen' +-- when 3 then 'validation' +-- when 4 then 'signature' +-- when 5 then 'envoi' +-- when 6 then 'reexamen' +-- when 7 then 'terminé' + +-- STATTDCL_IDENT +-- when 1 then 'en cours' +-- when 2 then 'autorisé temporaire' +-- when 3 then 'autorisé prolongé' +-- when 4 then 'autorisé définitif' +-- when 5 then 'refusé' +-- when 6 then 'arrêt commercialisation' +-- when 7 then 'retiré du marché' +-- when 8 then 'abandonné' + +-- STADCL_IDENT +-- when 1 then 'saisie par administration' +-- when 2 then 'en préparation' +-- when 3 then 'en cours' +-- when 4 then 'en attente compléments' +-- when 5 then 'en attente observations' +-- when 6 then 'compléments hors délais' +-- when 7 then 'non autorisé (HD)' +-- when 8 then 'clos' + + +-- TYPVRS_IDENT +-- when 1 then 'nouvelle' +-- when 2 then 'compléments d'information' +-- when 3 then 'observation' diff --git a/data/factories/teleicare_history/__init__.py b/data/factories/teleicare_history/__init__.py index af6b7b336..a446b86cd 100644 --- a/data/factories/teleicare_history/__init__.py +++ b/data/factories/teleicare_history/__init__.py @@ -3,14 +3,32 @@ import factory import faker + from phonenumber_field.phonenumber import PhoneNumber +from datetime import datetime, timedelta +from random import randrange from data.choices import CountryChoices from data.models.teleicare_history.ica_etablissement import IcaEtablissement +from data.models.teleicare_history.ica_declaration import ( + IcaComplementAlimentaire, + IcaDeclaration, + IcaVersionDeclaration, +) from data.utils.string_utils import make_random_str from data.factories.company import _make_siret, _make_vat, _make_phone_number +def random_date(start, end=datetime.now()): + """ + Retourne une date random entre une date de début et une date de fin + """ + delta = end - start + int_delta = (delta.days * 24 * 60 * 60) + delta.seconds + random_second = randrange(int_delta) + return start + timedelta(seconds=random_second) + + class EtablissementFactory(factory.django.DjangoModelFactory): class Meta: model = IcaEtablissement @@ -19,9 +37,80 @@ class Meta: etab_raison_sociale = factory.Faker("company", locale="FR") etab_enseigne = factory.Faker("company", locale="FR") etab_siret = factory.LazyFunction(_make_siret) - etab_numero_tva_intra = factory.LazyFunction(_make_siret) + etab_numero_tva_intra = factory.LazyFunction(_make_vat) pays_ident = factory.Faker("pyint", min_value=0, max_value=200) etab_nb_compte_autorise = factory.Faker("pyint", min_value=0, max_value=5) # contact etab_telephone = factory.LazyFunction(_make_phone_number) etab_courriel = factory.Faker("email", locale="FR") + etab_adre_ville = factory.Faker("city", locale="FR") + etab_adre_cp = factory.Faker("postcode", locale="FR") + etab_adre_voie = factory.Faker("street_address", locale="FR") + + +class ComplementAlimentaireFactory(factory.django.DjangoModelFactory): + class Meta: + model = IcaComplementAlimentaire + + cplalim_ident = factory.Sequence(lambda n: n + 1) + frmgal_ident = factory.Faker("pyint", min_value=0, max_value=20) + etab = factory.SubFactory(EtablissementFactory) + cplalim_marque = factory.Faker("text", max_nb_chars=20) + cplalim_gamme = factory.Faker("text", max_nb_chars=20) + cplalim_nom = factory.Faker("text", max_nb_chars=20) + dclencours_gout_arome_parfum = factory.Faker("text", max_nb_chars=20) + cplalim_forme_galenique_autre = factory.Faker("text", max_nb_chars=20) + + +class DeclarationFactory(factory.django.DjangoModelFactory): + class Meta: + model = IcaDeclaration + + dcl_ident = factory.Sequence(lambda n: n + 1) + cplalim = factory.SubFactory(ComplementAlimentaireFactory) + tydcl_ident = factory.Faker("pyint", min_value=0, max_value=20) + 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") + dcl_date_fin_commercialisation = factory.LazyFunction( + lambda: datetime.strftime(random_date(start=datetime(2016, 1, 1)), "%m/%d/%Y %H:%M:%S %p") + if random.random() > 0.3 + else None + ) + + +class VersionDeclarationFactory(factory.django.DjangoModelFactory): + class Meta: + model = IcaVersionDeclaration + + vrsdecl_ident = factory.Sequence(lambda n: n + 1) + ag_ident = factory.Faker("pyint", min_value=0, max_value=20) + typvrs_ident = factory.Faker("pyint", min_value=0, max_value=20) + unt_ident = factory.Faker("pyint", min_value=0, max_value=20) + pays_ident_adre = factory.Faker("pyint", min_value=0, max_value=8) + etab = factory.SubFactory(EtablissementFactory) + pays_ident_pays_de_reference = factory.Faker("pyint", min_value=0, max_value=8) + dcl = factory.SubFactory(DeclarationFactory) + stattdcl_ident = factory.Faker("pyint", min_value=0, max_value=8) + stadcl_ident = factory.Faker("pyint", min_value=0, max_value=8) + vrsdecl_numero = factory.Faker("pyint", min_value=0, max_value=20) + vrsdecl_commentaires = factory.Faker("text", max_nb_chars=20) + vrsdecl_mise_en_garde = factory.Faker("text", max_nb_chars=20) + vrsdecl_durabilite = factory.Faker("pyint", min_value=0, max_value=8) + vrsdecl_mode_emploi = factory.Faker("text", max_nb_chars=20) + vrsdecl_djr = factory.fuzzy.FuzzyText(length=4, chars=string.ascii_uppercase + string.digits) + vrsdecl_conditionnement = factory.Faker("text", max_nb_chars=20) + vrsdecl_poids_uc = factory.Faker("pyfloat") + vrsdecl_forme_galenique_autre = factory.Faker("text", max_nb_chars=20) + vrsdecl_date_limite_reponse_pro = factory.Faker("text", max_nb_chars=20) + vrsdecl_observations_ac = factory.Faker("text", max_nb_chars=20) + vrsdecl_observations_pro = factory.Faker("text", max_nb_chars=20) + vrsdecl_numero_dossiel = factory.Faker("text", max_nb_chars=20) + vrsdecl_adre_ville = factory.Faker("text", max_nb_chars=20) + vrsdecl_adre_cp = factory.Faker("text", max_nb_chars=20) + vrsdecl_adre_voie = factory.Faker("text", max_nb_chars=20) + vrsdecl_adre_comp = factory.Faker("text", max_nb_chars=20) + vrsdecl_adre_comp2 = factory.Faker("text", max_nb_chars=20) + vrsdecl_adre_dist = factory.Faker("text", max_nb_chars=20) + vrsdecl_adre_region = factory.Faker("text", max_nb_chars=20) + vrsdecl_adre_raison_sociale = factory.Faker("text", max_nb_chars=20) diff --git a/data/fields.py b/data/fields.py index f2c2c550f..d87c541fe 100644 --- a/data/fields.py +++ b/data/fields.py @@ -1,6 +1,9 @@ from django import forms from django.contrib.postgres.fields import ArrayField +from prose.fields import RichTextField +from prose.widgets import RichTextEditor + class MultipleChoiceField(ArrayField): """ @@ -16,3 +19,20 @@ def formfield(self, **kwargs): **kwargs, } return super(ArrayField, self).formfield(**defaults) + + +# L'editeur de Prose a seulement h1 comme entête. Ces éditeurs enrichis ajoutent les +# autres niveaux d'entête. +class EnrichedRichTextEditor(RichTextEditor): + class Media: + js = ("extend-buttons.js",) + + +class EnrichedRichTextField(RichTextField): + def formfield(self, **kwargs): + kwargs["widget"] = EnrichedRichTextEditor + + # On a besoin d'appeller directement la superclass de RichTextField car sinon notre + # kwargs["widget"] est ecrassé par la superclass + parent_return_value = super(RichTextField, self).formfield(**kwargs) + return parent_return_value diff --git a/data/migrations/0110_declaration_siccrf_id_and_more.py b/data/migrations/0110_declaration_siccrf_id_and_more.py new file mode 100644 index 000000000..a4a37cc8e --- /dev/null +++ b/data/migrations/0110_declaration_siccrf_id_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.1.4 on 2024-12-27 17:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('data', '0109_icacomplementalimentaire_icadeclaration_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='declaration', + name='siccrf_id', + field=models.IntegerField(blank=True, db_index=True, editable=False, null=True, unique=True, verbose_name='cplalim_ident dans le modèle IcaComplementAlimentaire si la déclaration provient de Teleicare'), + ), + migrations.AddField( + model_name='historicaldeclaration', + name='siccrf_id', + field=models.IntegerField(blank=True, db_index=True, editable=False, null=True, verbose_name='cplalim_ident dans le modèle IcaComplementAlimentaire si la déclaration provient de Teleicare'), + ), + migrations.AlterField( + model_name='company', + name='siccrf_id', + field=models.IntegerField(blank=True, db_index=True, editable=False, null=True, unique=True, verbose_name='etab_ident dans le modèle IcaEtablissement SICCRF'), + ), + ] diff --git a/data/migrations/0111_company_matched_alter_company_commercial_name_and_more.py b/data/migrations/0111_company_matched_alter_company_commercial_name_and_more.py new file mode 100644 index 000000000..635014a34 --- /dev/null +++ b/data/migrations/0111_company_matched_alter_company_commercial_name_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.1.4 on 2025-01-07 14:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('data', '0110_declaration_siccrf_id_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='company', + name='matched', + field=models.BooleanField(default=False, verbose_name="La Company Compl'Alim a été matchée avec un Etablissement TeleIcare"), + ), + migrations.AlterField( + model_name='company', + name='commercial_name', + field=models.CharField(blank=True, help_text='nom commercial', verbose_name='enseigne'), + ), + migrations.AlterField( + model_name='company', + name='email', + field=models.EmailField(blank=True, max_length=254, verbose_name='adresse e-mail de contact'), + ), + ] diff --git a/data/migrations/0113_merge_20250109_1112.py b/data/migrations/0113_merge_20250109_1112.py new file mode 100644 index 000000000..2920dee92 --- /dev/null +++ b/data/migrations/0113_merge_20250109_1112.py @@ -0,0 +1,14 @@ +# Generated by Django 5.1.4 on 2025-01-09 10:12 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('data', '0111_company_matched_alter_company_commercial_name_and_more'), + ('data', '0112_blogpost_content_alter_blogpost_body'), + ] + + operations = [ + ] diff --git a/data/migrations/0114_alter_company_activities_alter_company_phone_number.py b/data/migrations/0114_alter_company_activities_alter_company_phone_number.py new file mode 100644 index 000000000..feb5a9ccb --- /dev/null +++ b/data/migrations/0114_alter_company_activities_alter_company_phone_number.py @@ -0,0 +1,25 @@ +# Generated by Django 5.1.4 on 2025-01-10 09:28 + +import data.fields +import phonenumber_field.modelfields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('data', '0113_merge_20250109_1112'), + ] + + operations = [ + migrations.AlterField( + model_name='company', + name='activities', + field=data.fields.MultipleChoiceField(base_field=models.CharField(choices=[('FABRICANT', 'Fabricant'), ('FAÇONNIER', 'Façonnier'), ('IMPORTATEUR', 'Importateur'), ('INTRODUCTEUR', 'Introducteur'), ('CONSEIL', 'Conseil'), ('DISTRIBUTEUR', 'Distributeur')]), blank=True, default=list, size=None, verbose_name='activités'), + ), + migrations.AlterField( + model_name='company', + name='phone_number', + field=phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, region=None, verbose_name='numéro de téléphone de contact'), + ), + ] diff --git a/data/migrations/0115_alter_blogpost_content.py b/data/migrations/0115_alter_blogpost_content.py new file mode 100644 index 000000000..98c2ea206 --- /dev/null +++ b/data/migrations/0115_alter_blogpost_content.py @@ -0,0 +1,19 @@ +# Generated by Django 5.1.4 on 2025-01-10 15:59 + +import data.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('data', '0114_alter_company_activities_alter_company_phone_number'), + ] + + operations = [ + migrations.AlterField( + model_name='blogpost', + name='content', + field=data.fields.EnrichedRichTextField(blank=True, null=True, verbose_name='contenu'), + ), + ] diff --git a/data/models/blogpost.py b/data/models/blogpost.py index 8c83925d9..4da5e7c54 100644 --- a/data/models/blogpost.py +++ b/data/models/blogpost.py @@ -3,7 +3,8 @@ from django.utils import timezone from django_ckeditor_5.fields import CKEditor5Field -from prose.fields import RichTextField + +from data.fields import EnrichedRichTextField class BlogPost(models.Model): @@ -19,7 +20,7 @@ class Meta: tagline = models.TextField(null=True, blank=True, verbose_name="description courte") display_date = models.DateField(default=timezone.now, verbose_name="date affichée") body = CKEditor5Field(null=True, blank=True, verbose_name="contenu (legacy)") - content = RichTextField(null=True, blank=True, verbose_name="contenu") + content = EnrichedRichTextField(null=True, blank=True, verbose_name="contenu") published = models.BooleanField(default=False, verbose_name="publié") author = models.ForeignKey( settings.AUTH_USER_MODEL, diff --git a/data/models/company.py b/data/models/company.py index dbf73e153..9c0a56b4d 100644 --- a/data/models/company.py +++ b/data/models/company.py @@ -45,8 +45,8 @@ class CompanyContact(models.Model): class Meta: abstract = True - phone_number = PhoneNumberField("numéro de téléphone de contact") - email = models.EmailField("adresse e-mail de contact") + phone_number = PhoneNumberField("numéro de téléphone de contact", blank=True) + email = models.EmailField("adresse e-mail de contact", blank=True) website = models.CharField("site web de l'entreprise", blank=True) @@ -74,7 +74,10 @@ class Meta: editable=False, db_index=True, unique=True, - verbose_name="id dans les tables et tables relationnelles SICCRF", + verbose_name="etab_ident dans le modèle IcaEtablissement SICCRF", + ) + matched = models.BooleanField( + default=False, verbose_name="La Company Compl'Alim a été matchée avec un Etablissement TeleIcare" ) @@ -83,7 +86,7 @@ class Meta: verbose_name = "entreprise" social_name = models.CharField("dénomination sociale") - commercial_name = models.CharField("enseigne", help_text="nom commercial") + commercial_name = models.CharField("enseigne", blank=True, help_text="nom commercial") # null=True permet de gérer en parralèle le unique=True siret = models.CharField( "n° SIRET", @@ -94,7 +97,9 @@ class Meta: validators=[validate_siret], ) vat = models.CharField("n° TVA intracommunautaire", unique=True, blank=True, null=True, validators=[validate_vat]) - activities = MultipleChoiceField(models.CharField(choices=ActivityChoices), verbose_name="activités", default=list) + activities = MultipleChoiceField( + models.CharField(choices=ActivityChoices), verbose_name="activités", default=list, blank=True + ) supervisors = models.ManyToManyField( settings.AUTH_USER_MODEL, @@ -124,7 +129,9 @@ def clean(self): raise ValidationError( "Une entreprise doit avoir un n° de SIRET ou un n°de TVA intracommunautaire (ou les deux)." ) - + # Au minimum un point de contact nécessaire (hors None ou "") + if not ((self.phone_number and self.phone_number.is_valid()) or self.email): + raise ValidationError("Une entreprise doit avoir un n° de téléphone ou un e-mail (ou les deux).") # Pas de duplication possible des activités if len(self.activities) != len(set(self.activities)): raise ValidationError("Une entreprise ne peut avoir plusieurs fois la même activité") diff --git a/data/models/declaration.py b/data/models/declaration.py index 91ee7597c..2ab15ac98 100644 --- a/data/models/declaration.py +++ b/data/models/declaration.py @@ -190,6 +190,14 @@ class Article(models.TextChoices): output_field=models.TextField(verbose_name="article", null=True), db_persist=True, ) + siccrf_id = models.IntegerField( + blank=True, + null=True, + editable=False, + db_index=True, + unique=True, + verbose_name="cplalim_ident dans le modèle IcaComplementAlimentaire si la déclaration provient de Teleicare", + ) def create_snapshot( self, @@ -357,7 +365,11 @@ def response_limit_date(self): """ La date limite d'instruction est fixée à deux mois à partir du dernier statut - "en attente d'instruction" sauf dans le cas d'un refus de visa + "en attente d'instruction" sauf dans le cas d'un refus de visa. + + ⚠️ Attention : Le filtre par date de réponse dans api/views/declaration/declaration.py + refait la même logique dans la couche DB. Tout changement effectué dans cette fonction + doit aussi être reflété dans InstructionDateOrderingFilter > filter_queryset. """ concerned_statuses = [ Declaration.DeclarationStatus.AWAITING_INSTRUCTION, @@ -770,3 +782,11 @@ class AttachmentType(models.TextChoices): null=True, blank=True, upload_to="declaration-attachments/%Y/%m/%d/", verbose_name="pièce jointe" ) name = models.TextField("nom du fichier", blank=True) + + @property + def has_pdf_extension(self): + return self.file and self.file.url.endswith(".pdf") + + @property + def type_display(self): + return self.get_type_display() or "Type inconnu" diff --git a/data/models/teleicare_history/ica_declaration.py b/data/models/teleicare_history/ica_declaration.py index 2bd735f0e..f235b3099 100644 --- a/data/models/teleicare_history/ica_declaration.py +++ b/data/models/teleicare_history/ica_declaration.py @@ -8,11 +8,18 @@ from .ica_etablissement import IcaEtablissement +# Les etablissements en lien avec ces 3 modèles peuvent être tous différents (cas rare) ou tous les mêmes +# * entreprise responsable de l'étiquetage du modèle IcaComplementAlimentaire +# * entreprise télédéclarante du modèle IcaVersionDeclaration +# * entreprise gestionnaire du modèle IcaDeclaration + class IcaComplementAlimentaire(models.Model): cplalim_ident = models.IntegerField(primary_key=True) frmgal_ident = models.IntegerField(blank=True, null=True) - etab = models.ForeignKey(IcaEtablissement, on_delete=models.CASCADE, db_column="etab_ident") + etab = models.ForeignKey( + IcaEtablissement, on_delete=models.CASCADE, db_column="etab_ident" + ) # correspond à l'entreprise responsable de l'étiquetage cplalim_marque = models.TextField(blank=True, null=True) cplalim_gamme = models.TextField(blank=True, null=True) cplalim_nom = models.TextField() @@ -25,18 +32,19 @@ class Meta: 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() etab = models.ForeignKey( IcaEtablissement, on_delete=models.CASCADE, db_column="etab_ident" - ) # duplique la foreign key vers l'établissement présente dans le CA + ) # correspond à l'entreprise gestionnaire de la déclaration etab_ident_rmm_declarant = models.IntegerField() dcl_date = models.TextField() - dcl_saisie_administration = models.BooleanField() - dcl_annee = models.IntegerField() - dcl_mois = models.IntegerField() - dcl_numero = models.IntegerField() + dcl_saisie_administration = models.BooleanField(null=True) # rendu nullable pour simplifier les Factories + 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 dcl_date_fin_commercialisation = models.TextField(blank=True, null=True) class Meta: @@ -47,20 +55,20 @@ 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() + typvrs_ident = models.IntegerField(null=True) # 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( IcaEtablissement, on_delete=models.CASCADE, db_column="etab_ident" - ) # duplique la foreign key vers l'établissement présente dans le CA - ex_ident = models.IntegerField() + ) # correspond à l'entreprise télédéclarante + ex_ident = models.IntegerField(null=True) # rendu nullable pour simplifier les Factories pays_ident_pays_de_reference = models.IntegerField(blank=True, null=True) dcl = models.ForeignKey( IcaDeclaration, on_delete=models.CASCADE, db_column="dcl_ident" - ) # duplique la foreign key vers l'établissement présente dans la decla + ) # dcl_ident est aussi une foreign_key vers IcaComplementAlimentaire stattdcl_ident = models.IntegerField(blank=True, null=True) stadcl_ident = models.IntegerField(blank=True, null=True) - vrsdecl_numero = models.IntegerField() + vrsdecl_numero = models.IntegerField(null=True) vrsdecl_commentaires = models.TextField(blank=True, null=True) vrsdecl_mise_en_garde = models.TextField(blank=True, null=True) vrsdecl_durabilite = models.IntegerField(blank=True, null=True) @@ -72,9 +80,9 @@ class IcaVersionDeclaration(models.Model): vrsdecl_date_limite_reponse_pro = models.TextField(blank=True, null=True) vrsdecl_observations_ac = models.TextField(blank=True, null=True) vrsdecl_observations_pro = models.TextField(blank=True, null=True) - vrsdecl_mode_json = models.BooleanField() + vrsdecl_mode_json = models.BooleanField(null=True) # rendu nullable pour simplifier les Factories vrsdecl_numero_dossiel = models.TextField(blank=True, null=True) - vrsdecl_mode_sans_verif = models.BooleanField() + vrsdecl_mode_sans_verif = models.BooleanField(null=True) # rendu nullable pour simplifier les Factories vrsdecl_adre_ville = models.TextField(blank=True, null=True) vrsdecl_adre_cp = models.TextField(blank=True, null=True) vrsdecl_adre_voie = models.TextField(blank=True, null=True) diff --git a/data/static/extend-buttons.js b/data/static/extend-buttons.js new file mode 100644 index 000000000..37386e7c7 --- /dev/null +++ b/data/static/extend-buttons.js @@ -0,0 +1,55 @@ +/* eslint-disable no-undef, semi */ +Trix.config.blockAttributes.subHeadingh2 = { tagName: "h2" } +Trix.config.blockAttributes.subHeadingh3 = { tagName: "h3" } +Trix.config.blockAttributes.subHeadingh4 = { tagName: "h4" } +Trix.config.blockAttributes.subHeadingh5 = { tagName: "h5" } +Trix.config.blockAttributes.subHeadingh6 = { tagName: "h6" } +Trix.config.blockAttributes.p = { tagName: "p" } +/* eslint-enable no-undef */ + +const h2ButtonHTML = + '' +const h3ButtonHTML = + '' +const h4ButtonHTML = + '' +const h5ButtonHTML = + '' +const h6ButtonHTML = + '' +const pButtonHTML = + '' + +document.addEventListener("trix-before-initialize", (event) => { + const { toolbarElement } = event.target + + const trixTitleButton = toolbarElement.querySelector( + "[data-trix-attribute=heading1]", + ) + trixTitleButton.insertAdjacentHTML("afterend", pButtonHTML) + trixTitleButton.remove() + + const p = toolbarElement.querySelector("[data-trix-attribute=p]") + p.insertAdjacentHTML("afterend", h2ButtonHTML) + + const h2Button = toolbarElement.querySelector( + "[data-trix-attribute=subHeadingh2]", + ) + h2Button.insertAdjacentHTML("afterend", h3ButtonHTML) + + const h3Button = toolbarElement.querySelector( + "[data-trix-attribute=subHeadingh3]", + ) + h3Button.insertAdjacentHTML("afterend", h4ButtonHTML) + + const h4Button = toolbarElement.querySelector( + "[data-trix-attribute=subHeadingh4]", + ) + h4Button.insertAdjacentHTML("afterend", h5ButtonHTML) + + const h5Button = toolbarElement.querySelector( + "[data-trix-attribute=subHeadingh5]", + ) + h5Button.insertAdjacentHTML("afterend", h6ButtonHTML) +}) +/* eslint-enable semi */ diff --git a/data/tests/test_teleicare_history_importer.py b/data/tests/test_teleicare_history_importer.py index 1ebb18cc3..e3d0e4909 100644 --- a/data/tests/test_teleicare_history_importer.py +++ b/data/tests/test_teleicare_history_importer.py @@ -3,9 +3,26 @@ from django.db import connection from django.test import TestCase -from data.etl.teleicare_history.extractor import match_companies_on_siret_or_vat +from data.etl.teleicare_history.extractor import ( + create_declaration_from_teleicare_history, + match_companies_on_siret_or_vat, +) from data.factories.company import CompanyFactory, _make_siret, _make_vat -from data.factories.teleicare_history import EtablissementFactory +from data.factories.galenic_formulation import GalenicFormulationFactory +from data.factories.teleicare_history import ( + ComplementAlimentaireFactory, + DeclarationFactory, + EtablissementFactory, + VersionDeclarationFactory, +) +from data.factories.unit import SubstanceUnitFactory +from data.models.company import Company +from data.models.declaration import Declaration +from data.models.teleicare_history.ica_declaration import ( + IcaComplementAlimentaire, + IcaDeclaration, + IcaVersionDeclaration, +) from data.models.teleicare_history.ica_etablissement import IcaEtablissement @@ -19,21 +36,24 @@ def setUp(self): Adapted from: https://stackoverflow.com/a/49800437 """ super().setUp() - with connection.schema_editor() as schema_editor: - schema_editor.create_model(IcaEtablissement) + for table in [IcaEtablissement, IcaComplementAlimentaire, IcaDeclaration, IcaVersionDeclaration]: + with connection.schema_editor() as schema_editor: + schema_editor.create_model(table) - if IcaEtablissement._meta.db_table not in connection.introspection.table_names(): - raise ValueError( - "Table `{table_name}` is missing in test database.".format( - table_name=IcaEtablissement._meta.db_table + if table._meta.db_table not in connection.introspection.table_names(): + raise ValueError( + "Table `{table_name}` is missing in test database.".format(table_name=table._meta.db_table) ) - ) def tearDown(self): super().tearDown() - - with connection.schema_editor() as schema_editor: - schema_editor.delete_model(IcaEtablissement) + for table in [IcaVersionDeclaration, IcaComplementAlimentaire, IcaDeclaration, IcaEtablissement]: + table.objects.all().delete() + # la suppression des modèles fail avec l'erreur + # django.db.utils.OperationalError: cannot DROP TABLE "ica_versiondeclaration" because it has pending trigger events + # même avec un sleep(15) + # with connection.schema_editor() as schema_editor: + # schema_editor.delete_model(table) def test_match_companies_on_siret_or_vat(self): """ @@ -49,7 +69,9 @@ def test_match_companies_on_siret_or_vat(self): company_with_vat = CompanyFactory(vat=vat) random_company = CompanyFactory() - random_etablissement = EtablissementFactory() + random_etablissement = EtablissementFactory( + etab_ica_fabricant=True, + ) match_companies_on_siret_or_vat() company_with_siret.refresh_from_db() @@ -80,3 +102,68 @@ def test_match_companies_on_vat_used_twice(self, mocked_logger): mocked_logger.error.assert_called_with( "Plusieurs Etablissement provenant de Teleicare ont le même n° TVA, ce qui rend le matching avec une Company Compl'Alim incertain." ) + + def test_create_new_companies(self): + """ + Si une entreprise enregistrée dans TeleIcare n'existe pas encore dans Compl'Alim, elle est créée + """ + + etablissement_to_create_as_company = EtablissementFactory(etab_siret=None, etab_ica_importateur=True) + # devrait être créée malgré le numéro de téléphone mal formaté + _ = EtablissementFactory(etab_siret=None, etab_ica_importateur=True, etab_telephone="0345") + self.assertEqual(Company.objects.filter(siccrf_id=etablissement_to_create_as_company.etab_ident).count(), 0) + + match_companies_on_siret_or_vat(create_if_not_exist=True) + self.assertTrue(Company.objects.filter(siccrf_id=etablissement_to_create_as_company.etab_ident).exists()) + self.assertEqual(Company.objects.exclude(siccrf_id=None).count(), 2) + + created_company = Company.objects.get(siccrf_id=etablissement_to_create_as_company.etab_ident) + self.assertEqual(created_company.siccrf_id, etablissement_to_create_as_company.etab_ident) + self.assertEqual(created_company.address, etablissement_to_create_as_company.etab_adre_voie) + 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): + """ + Les déclarations sont créées à partir d'object historiques des modèles Ica_ + """ + galenic_formulation_id = 1 + galenic_formulation = GalenicFormulationFactory(siccrf_id=galenic_formulation_id) + unit_id = 1 + unit = SubstanceUnitFactory(siccrf_id=unit_id) + etablissement_to_create_as_company = EtablissementFactory(etab_siret=None, etab_ica_importateur=True) + + 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) + version_declaration_to_create_as_declaration = VersionDeclarationFactory( + dcl=declaration_to_create_as_declaration, + stadcl_ident=8, + stattdcl_ident=2, + unt_ident=unit_id, + vrsdecl_djr="32 kg of ppo", + ) + + match_companies_on_siret_or_vat(create_if_not_exist=True) + create_declaration_from_teleicare_history() + + version_declaration_to_create_as_declaration.refresh_from_db() + created_declaration = Declaration.objects.get(siccrf_id=CA_to_create_as_declaration.cplalim_ident) + self.assertEqual(created_declaration.name, CA_to_create_as_declaration.cplalim_nom) + self.assertEqual(created_declaration.brand, CA_to_create_as_declaration.cplalim_marque) + self.assertEqual(created_declaration.gamme, CA_to_create_as_declaration.cplalim_gamme) + self.assertEqual(created_declaration.flavor, CA_to_create_as_declaration.dclencours_gout_arome_parfum) + 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.conditioning, version_declaration_to_create_as_declaration.vrsdecl_conditionnement + ) + self.assertEqual( + created_declaration.daily_recommended_dose, + 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) + ) diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index 5ca0b8884..79650c95e 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -20,6 +20,7 @@ module.exports = { "vue/no-multiple-template-root": "off", // Change some errors to warning only, to not prevent development "no-unused-vars": 1, + "prettier/prettier": ["error", { semi: false }], }, plugins: ["prettier"], diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 06705fd5b..4041842fe 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -5,12 +5,12 @@ "packages": { "": { "dependencies": { - "@gouvminint/vue-dsfr": "^7.0.2", + "@gouvminint/vue-dsfr": "^8.1.0", "@vue/cli": "^5.0.8", "@vuelidate/core": "^2.0.3", "@vuelidate/validators": "^2.0.4", - "@vueuse/core": "^12.3.0", - "core-js": "^3.39.0", + "@vueuse/core": "^12.4.0", + "core-js": "^3.40.0", "pinia": "^2.3.0", "vue": "^3.5.13", "vue-matomo": "^4.2.0", @@ -19,7 +19,7 @@ }, "devDependencies": { "@babel/core": "^7.26.0", - "@babel/eslint-parser": "^7.25.9", + "@babel/eslint-parser": "^7.26.5", "@vue/cli-plugin-babel": "~5.0.0", "@vue/cli-plugin-eslint": "~5.0.0", "@vue/cli-plugin-router": "~5.0.0", @@ -302,9 +302,9 @@ } }, "node_modules/@babel/eslint-parser": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.25.9.tgz", - "integrity": "sha512-5UXfgpK0j0Xr/xIdgdLEhOFxaDZ0bRPWJJchRpqOSur/3rZoPbqqki5mm0p4NE2cs28krBEiSM2MB7//afRSQQ==", + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.26.5.tgz", + "integrity": "sha512-Kkm8C8uxI842AwQADxl0GbcG1rupELYLShazYEZO/2DYjhyWXJIOUVOE3tBYm6JXzUCNJOZEzqc4rCW/jsEQYQ==", "dev": true, "dependencies": { "@nicolo-ribaudo/eslint-scope-5-internals": "5.1.1-v1", @@ -2215,25 +2215,27 @@ } }, "node_modules/@gouvminint/vue-dsfr": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@gouvminint/vue-dsfr/-/vue-dsfr-7.0.2.tgz", - "integrity": "sha512-5OJGMdp8JQ+s5UOfR3oiELYfC/7Q+56gxAItE9jRWErLUfgcq/V+Xrieuem+qP+aZWaxGXZClXFp9ygfYNicMg==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@gouvminint/vue-dsfr/-/vue-dsfr-8.1.0.tgz", + "integrity": "sha512-eF7lVhZjsKg8qSJsCxySfSAxjYS4DZU2wtjcnPUMDPxL7PFbGSvYFHqyJZ3PVMMYh5DE+9zkleqnqAJmJbhRyg==", "dependencies": { "@gouvfr/dsfr": "~1.12.1", - "focus-trap": "^7.5.4", + "focus-trap": "^7.6.2", "focus-trap-vue": "^4.0.3", - "pnpm": "^9.10.0", - "vue": "^3.5.4", - "vue-router": "^4.4.4" + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "bin": { + "vue-dsfr-icons": "meta/custom-icon-collections-creator-bin.js" }, "engines": { "node": ">=20.x.x", "npm": ">=10.x.x" }, "peerDependencies": { - "@iconify/vue": "^4.1.2", - "vue": "^3.4.38", - "vue-router": "^4.4.3" + "@iconify/vue": "^4.2.0", + "vue": "^3.5.13", + "vue-router": "^4.5.0" } }, "node_modules/@graphql-tools/merge": { @@ -2396,9 +2398,9 @@ "peer": true }, "node_modules/@iconify/vue": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@iconify/vue/-/vue-4.1.2.tgz", - "integrity": "sha512-CQnYqLiQD5LOAaXhBrmj1mdL2/NCJvwcC4jtW2Z8ukhThiFkLDkutarTOV2trfc9EXqUqRs0KqXOL9pZ/IyysA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@iconify/vue/-/vue-4.3.0.tgz", + "integrity": "sha512-Xq0h6zMrHBbrW8jXJ9fISi+x8oDQllg5hTDkDuxnWiskJ63rpJu9CvJshj8VniHVTbsxCg9fVoPAaNp3RQI5OQ==", "peer": true, "dependencies": { "@iconify/types": "^2.0.0" @@ -3895,13 +3897,13 @@ } }, "node_modules/@vueuse/core": { - "version": "12.3.0", - "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.3.0.tgz", - "integrity": "sha512-cnV8QDKZrsyKC7tWjPbeEUz2cD9sa9faxF2YkR8QqNwfofgbOhmfIgvSYmkp+ttSvfOw4E6hLcQx15mRPr0yBA==", + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.4.0.tgz", + "integrity": "sha512-XnjQYcJwCsyXyIafyA6SvyN/OBtfPnjvJmbxNxQjCcyWD198urwm5TYvIUUyAxEAN0K7HJggOgT15cOlWFyLeA==", "dependencies": { "@types/web-bluetooth": "^0.0.20", - "@vueuse/metadata": "12.3.0", - "@vueuse/shared": "12.3.0", + "@vueuse/metadata": "12.4.0", + "@vueuse/shared": "12.4.0", "vue": "^3.5.13" }, "funding": { @@ -3909,17 +3911,17 @@ } }, "node_modules/@vueuse/metadata": { - "version": "12.3.0", - "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.3.0.tgz", - "integrity": "sha512-M/iQHHjMffOv2npsw2ihlUx1CTiBwPEgb7DzByLq7zpg1+Ke8r7s9p5ybUWc5OIeGewtpY4Xy0R2cKqFqM8hFg==", + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.4.0.tgz", + "integrity": "sha512-AhPuHs/qtYrKHUlEoNO6zCXufu8OgbR8S/n2oMw1OQuBQJ3+HOLQ+EpvXs+feOlZMa0p8QVvDWNlmcJJY8rW2g==", "funding": { "url": "https://github.com/sponsors/antfu" } }, "node_modules/@vueuse/shared": { - "version": "12.3.0", - "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.3.0.tgz", - "integrity": "sha512-X3YD35GUeW0d5Gajcwv9jdLAJTV2Jdb/Ll6Ii2JIYcKLYZqv5wxyLeKtiQkqWmHg3v0J0ZWjDUMVOw2E7RCXfA==", + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.4.0.tgz", + "integrity": "sha512-9yLgbHVIF12OSCojnjTIoZL1+UA10+O4E1aD6Hpfo/DKVm5o3SZIwz6CupqGy3+IcKI8d6Jnl26EQj/YucnW0Q==", "dependencies": { "vue": "^3.5.13" }, @@ -6231,9 +6233,9 @@ } }, "node_modules/core-js": { - "version": "3.39.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.39.0.tgz", - "integrity": "sha512-raM0ew0/jJUqkJ0E6e8UDtl+y/7ktFivgWvqw8dNSQeNWoSDLvQ1H/RN3aPXB9tBd4/FhyR4RDPGhsNIMsAn7g==", + "version": "3.40.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.40.0.tgz", + "integrity": "sha512-7vsMc/Lty6AGnn7uFpYT56QesI5D2Y/UkgKounk87OP9Z2H9Z8kj6jzcSGAxFmUtDOS0ntK6lbQz+Nsa0Jj6mQ==", "hasInstallScript": true, "funding": { "type": "opencollective", @@ -8759,9 +8761,9 @@ } }, "node_modules/focus-trap": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.5.4.tgz", - "integrity": "sha512-N7kHdlgsO/v+iD/dMoJKtsSqs5Dz/dXZVebRgJw23LDk+jMi/974zyiOYDziY2JPp8xivq9BmUGwIJMiuSBi7w==", + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.6.4.tgz", + "integrity": "sha512-xx560wGBk7seZ6y933idtjJQc1l+ck+pI3sKvhKozdBV1dRZoKhkW5xoCaFv9tQiX5RH1xfSxjuNu6g+lmN/gw==", "dependencies": { "tabbable": "^6.2.0" } @@ -12305,21 +12307,6 @@ "node": ">=10" } }, - "node_modules/pnpm": { - "version": "9.15.0", - "resolved": "https://registry.npmjs.org/pnpm/-/pnpm-9.15.0.tgz", - "integrity": "sha512-duI3l2CkMo7EQVgVvNZije5yevN3mqpMkU45RBVsQpmSGon5djge4QfUHxLPpLZmgcqccY8GaPoIMe1MbYulbA==", - "bin": { - "pnpm": "bin/pnpm.cjs", - "pnpx": "bin/pnpx.cjs" - }, - "engines": { - "node": ">=18.12" - }, - "funding": { - "url": "https://opencollective.com/pnpm" - } - }, "node_modules/portfinder": { "version": "1.0.32", "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.32.tgz", diff --git a/frontend/package.json b/frontend/package.json index b11792638..451319f45 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,12 +8,12 @@ "lint": "vue-cli-service lint" }, "dependencies": { - "@gouvminint/vue-dsfr": "^7.0.2", + "@gouvminint/vue-dsfr": "^8.1.0", "@vue/cli": "^5.0.8", "@vuelidate/core": "^2.0.3", "@vuelidate/validators": "^2.0.4", - "@vueuse/core": "^12.3.0", - "core-js": "^3.39.0", + "@vueuse/core": "^12.4.0", + "core-js": "^3.40.0", "pinia": "^2.3.0", "vue": "^3.5.13", "vue-matomo": "^4.2.0", @@ -22,7 +22,7 @@ }, "devDependencies": { "@babel/core": "^7.26.0", - "@babel/eslint-parser": "^7.25.9", + "@babel/eslint-parser": "^7.26.5", "@vue/cli-plugin-babel": "~5.0.0", "@vue/cli-plugin-eslint": "~5.0.0", "@vue/cli-plugin-router": "~5.0.0", diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue index 4441e0741..2309f3d2c 100644 --- a/frontend/src/components/AppHeader.vue +++ b/frontend/src/components/AppHeader.vue @@ -40,6 +40,10 @@ const navItems = [ to: "/blog", text: "Ressources", }, + { + to: "/faq", + text: "FAQ", + }, ] const loggedOnlyNavItems = [ { diff --git a/frontend/src/components/DeclarationSummary/index.vue b/frontend/src/components/DeclarationSummary/index.vue index f319b0b7c..3ec80eab0 100644 --- a/frontend/src/components/DeclarationSummary/index.vue +++ b/frontend/src/components/DeclarationSummary/index.vue @@ -12,13 +12,13 @@ + - diff --git a/frontend/src/components/FilePreview.vue b/frontend/src/components/FilePreview.vue index 89fe6d12b..7c28a33df 100644 --- a/frontend/src/components/FilePreview.vue +++ b/frontend/src/components/FilePreview.vue @@ -14,7 +14,7 @@ :required="true" /> -
{{ documentTypes.find((x) => x.value === file.type)?.text }}
+
{{ file.typeDisplay }}
-

{{ blogPost.title }}

-

{{ author }}

-

{{ date }}

+
+

{{ blogPost.title }}

+

{{ author }}

+

{{ date }}

+
diff --git a/frontend/src/views/FaqPage.vue b/frontend/src/views/FaqPage.vue new file mode 100644 index 000000000..9b3748fb5 --- /dev/null +++ b/frontend/src/views/FaqPage.vue @@ -0,0 +1,441 @@ + + + + diff --git a/frontend/src/views/ProducerFormPage/SummaryTab.vue b/frontend/src/views/ProducerFormPage/SummaryTab.vue index cf9f83c51..fba719a40 100644 --- a/frontend/src/views/ProducerFormPage/SummaryTab.vue +++ b/frontend/src/views/ProducerFormPage/SummaryTab.vue @@ -1,5 +1,11 @@