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 @@
{{ author }}
-{{ date }}
+{{ author }}
+{{ date }}
+Documentation pour vous guider dans la prise en main de Compl'Alim.
++ Le moteur de recherche "Ingrédients" vous permet de vérifier rapidement les recommandations d'usage et la + conformité des ingrédients que vous envisagez d’utiliser dans vos produits. +
++ En consultant les informations sur les doses autorisées, les usages permis et les restrictions + réglementaires, vous vous assurez que la composition de vos nouveaux produits respecte les normes en vigueur + dès la phase de conception. +
++ Cela vous aide à anticiper les exigences réglementaires et à sécuriser la mise sur le marché de vos + produits. +
++ Seuls les gestionnaires ont le pouvoir de gérer les accès des collaborateurs. Pour ajouter un collaborateur, + voici la marche à suivre : +
++ Depuis cette même page, vous pouvez modifier les droits d’un collaborateur (ex. passer de gestionnaire à + déclarant) ou révoquer ses accès. +
+Le gestionnaire et le déclarant jouent des rôles distincts au sein de la plateforme.
++ Le gestionnaire est responsable de la gestion des informations de l'entreprise ainsi que des collaborateurs + qui ont les droits de gestion et/ou de déclaration pour cette entreprise. +
++ Le déclarant a uniquement le pouvoir de réaliser des déclarations pour le compte de l'entreprise à laquelle + il est rattaché. +
++ + ⚠️ Attention, le gestionnaire n'est pas automatiquement déclarant (vous ne pourrez pas déclarer un nouveau + produit) + + . Pour cumuler les fonctions de gestion et de déclaration, le gestionnaire doit s'attribuer les deux rôles. +
++ Lors du lancement de la plateforme, il n’était pas possible d’inviter directement une société mandataire. Le + gestionnaire devait inviter individuellement les consultants ou collaborateurs de la société mandatée pour + leur permettre de réaliser des déclarations au nom son entreprise. +
++ Désormais, tous les comptes associés à une société mandataire auront automatiquement l’autorisation de + déclarer pour les clients ayant confié un mandat à cette entité. +
++ Pour cela, un nouvel onglet + Entreprises mandatées + est disponible dans votre tableau de bord. +
+Voici les étapes à suivre pour rattacher une entreprise :
++ ⚠️ L’entreprise mandatée doit disposer d’un compte sur la plateforme. Si ce n’est pas le cas, elle devra en + créer un au préalable. +
++ Si vous souhaitez ajuster les rôles des déclarants de votre entreprise, n’hésitez pas à nous contacter à + l'adresse suivante : + contact@compl-alim.beta.gouv.fr +
+Les champs obligatoires à remplir sont indiqués par une astérix *.
++ 💡 Vous pouvez naviguer librement entre ces onglets sans avoir à remplir tous les champs d'un onglet avant + de passer au suivant. +
++ Toutes vos informations s’enregistrent automatiquement à chaque changement d'étape, en cliquant sur les + boutons en bas de page "Revenir à l'onglet " ou "Passer à l'onglet ". Si le dossier n’est pas soumis, il + sera automatiquement sauvegardé en tant que brouillon. +
++ L'onglet "Produit" vous permet de renseigner les informations principales relatives à votre complément + alimentaire, telles que son nom, sa marque, son format, les populations cibles, ainsi que les effets + recherchés. Plus précisément, vous devez renseigner : +
++ Si une définition n'est pas claire, n'hésitez pas à nous contacter pour demander des informations + complémentaires. +
++ Cet onglet vous permet de renseigner les ingrédients et les substances actives de votre complément + alimentaire. +
++ + La section Ingrédients sert à ajouter les éléments constitutifs du produit, tels que les plantes ou autres + matières premières. + + Pour ajouter un ingrédient, tapez son nom dans le moteur de recherche puis sélectionnez l’ingrédient + correspondant dans la liste déroulante qui s’affiche. +
++ 💡 Dans la section "Ingrédients", vous avez désormais la possibilité de préciser si l’ingrédient ajouté joue + un rôle actif ou non actif dans le produit, afin d'éviter la création de doublons dans la base de données et + de mieux structurer les informations déclarées. +
++ + La section "Substances" affiche automatiquement les composés actifs présents dans les ingrédients actifs + + . Une vigilance particulière est accordée à certaines de ces substances, notamment sur la quantité présente + qui peut conduire à des restrictions d’usage. Il est donc essentiel de les identifier clairement et de + préciser leurs quantités pour assurer la sécurité et la conformité réglementaire du complément alimentaire. +
++ Dans la section "Substances", contrairement à l'ancien outil de déclaration Teleicare, il vous est pour le + moment demandé de renseigner les quantités présentes dans le produit pour toutes les substances identifiées. + Cependant, dans les prochains mois, nous allons distinguer les substances pour lesquelles il est obligatoire + de déclarer le dosage (en raison de restrictions réglementaires, par exemple) de celles pour lesquelles cela + ne sera pas nécessaire. Nous vous tiendrons informés dès que cette modification sera effectuée. + + En attendant, si une substance ne nécessite pas de dosage, vous pouvez simplement indiquer zéro. + +
++ 💡 L'icône "commentaire" en couleur bleue vous permet d'accéder aux informations disponibles sur chaque + substance, telles que la quantité maximale autorisée ou les recommandations spécifiques pour certaines + populations. +
++ 🚀 Dans les prochains mois, nous allons enrichir nos bases de données afin de vous fournir encore plus + d’informations, pour mieux vous accompagner dans la déclaration de vos produits. +
++ Si un ingrédient de votre complément alimentaire n’existe pas dans Compl’Alim, voici la démarche à suivre : +
+Vous pouvez créer :
++ Vous n’avez désormais plus besoin de déterminer vous-mêmes si le complément alimentaire relève de l'article + 15 ou 16 du décret n°2006-352. L'algorithme de Compl'Alim se charge automatiquement de cette classification + en fonction de la composition que vous renseignez. Cela simplifie la procédure et garantit une attribution + conforme aux dispositions réglementaires. +
++ Dès qu'il y a une évolution ou une actualité concernant votre dossier, vous serez automatiquement averti par + email. Cela vous permet de rester informé.e en temps réel de toute mise à jour importante. +
++ Vous pouvez également accéder à une vue récapitulative cliquant sur la carte "Toutes mes déclarations" + depuis votre tableau de bord. Vous accédez alors un tableau répertoriant l'ensemble de vos déclarations. Ce + tableau est partagé avec tous les collaborateurs rattachés à votre entreprise sur Compl’Alim, ce qui vous + permet de suivre collectivement l’avancement de chaque dossier. +
++ accéder aux détails d'un dossier, cliquez sur le nom du produit dans le tableau + . +
++ 💡 Le filtre "Types de déclaration affichés" vous permet de sélectionner les dossiers que vous souhaitez + voir à l’écran, en choisissant les types de déclarations qui vous intéressent : Brouillon, Instruction, + Observation, Objection, Abandon, Déclaration finalisée, Refus, Retiré du marché. +
++ Une fois votre dossier soumis, vous pouvez télécharger l'accusé d'enregistrement au format PDF. Pour cela : +
++ Ce PDF contient l’accusé d’enregistrement de déclaration de votre complément alimentaire ainsi que votre + engagement de conformité. +
++ Actuellement, il n'est pas encore possible de télécharger directement le résumé de votre dossier via la + plateforme. +
+En attendant cette fonctionnalité, vous pouvez créer une version PDF en suivant cette méthode :
+Et voilà, le tour est joué !
++ Lorsqu’une objection a été émise sur la déclaration de votre produit, vous pouvez consulter les détails en + vous rendant sur la page dédiée à ce complément alimentaire. Pour cela, cliquez sur le nom du produit qui + vous intéresse dans le tableau récapitulatif ("Toutes mes déclarations"). +
++ Les commentaires de l’instructeur, expliquant les raisons de cette observation/ objection, seront visibles + dans l'onglet Historique. Après avoir pris connaissance des remarques, il vous sera nécessaire de soumettre + une nouvelle version de votre dossier, en tenant compte des éléments supplémentaires ou des modifications + demandées par l'administration. +
++ Une fois votre dossier instruit, vous pouvez télécharger l’attestation de déclaration au format PDF. Pour + cela : +
+Pour rappel, vous serez informé.e par mail lorsque votre dossier aura été instruit.
+Oui, vous devez recréer une fiche pour votre entreprise sur Compl'Alim.
++ II ne sera pas nécessaire de déclarer à nouveau sur Compl'Alim les produits que vous avez déjà déclarés sur + Teleicare. +
++ L’historique de vos déclarations antérieures sera importé sur Compl'Alim d’ici la fin de l’année, ce qui + vous permettra de retrouver toutes vos informations sans avoir à refaire les démarches. Nous ferons le lien + grâce au SIRET de l'entreprise (après qu'elle ait été créée sur Compl'Alim). +
++ En attendant que cela soit fait, vous conserverez l'accès à la rubrique " + Je consulte les produits + " de Teleicare et pourrez donc toujours accéder à vos attestations de déclaration. +
++ Les dossiers transmis sur Teleicare seront instruits sur Teleicare. Vous conservez l’accès à la rubrique “Je + suis mes déclarations” de Teleicare. +
++ Une fois votre dossier instruit, l’attestation de déclaration sera importée sur Compl’Alim au moment de + l’import de l’historique prévu d’ici la fin de l’année (voir question ci-dessus). +
++ + | ++ + | +
Environnement : {{ environment }}
+ {% endif %} + +Soumise le {{ submission_date|date:"l j F Y" }}
+ {% endif %} + + ++ {{ row.0 }} + | +{{ row.1 }} | +
---|
+ {% firstof declared_plant.plant.name declared_plant.new_name %} + {% if declared_plant.new %} + (nouvel ajout) + {% endif %} +
+ {% endif %} + + {% if declared_plant.active %} +Partie utilisée + | {% firstof declared_plant.used_part.name "Non spécifiée" %} | +|
---|---|---|
Qté par DJR + {% if declared_plant.quantity %} + | {{ declared_plant.quantity }} + + {% firstof declared_plant.unit.name "(unité non spécifiée)" %} + + | + {% else %} +Non spécifiée | + {% endif %} +
Préparation + | {% firstof declared_plant.preparation.name 'Non spécifiée' %} | +
Souche + | {% firstof declared_microorganism.strain 'Non spécifiée' %} | +
---|---|
Qté par DJR (en UFC) + | {% firstof declared_microorganism.quantity 'Non spécifiée' %} | +
Qté par DJR + {% if declared_substance.quantity %} + | + {{ declared_substance.quantity }} + + {% firstof declared_substance.unit.name declared_substance.substance.unit.name "(unité non spécifiée)" %} + + | + {% else %} +Non spécifiée | + {% endif %} +
---|
Qté par DJR + {% if declared_ingredient.quantity %} + | {{ declared_ingredient.quantity }} + + {% firstof declared_ingredient.unit.name "(unité non spécifiée)" %} + + | + {% else %} +Non spécifiée | + {% endif %} +
---|
Qté par DJR + {% if computed_substance.quantity %} + | {{ computed_substance.quantity }} + + {% firstof computed_substance.unit.name computed_substance.substance.unit.name "(unité non spécifiée)" %} + + | + {% else %} +Non spécifiée | + {% endif %} +
---|
Imprimée le {{ now|date:"l j F Y H:m" }}
+ {% endif %} + + diff --git a/web/tests.py b/web/tests.py index 733890642..3af0075e7 100644 --- a/web/tests.py +++ b/web/tests.py @@ -1,6 +1,12 @@ from http import HTTPStatus from django.test import TestCase +from django.urls import reverse + +from rest_framework import status +from rest_framework.test import APITestCase + +from data.factories import AwaitingInstructionDeclarationFactory, UserFactory class RobotsTxtTests(TestCase): @@ -15,3 +21,39 @@ def test_post_disallowed(self): response = self.client.post("/robots.txt") assert response.status_code == HTTPStatus.METHOD_NOT_ALLOWED + + +class DeclarationPdfViewTests: + """ + Cette class agit comme parente de `CertificateViewTests` et `SummaryViewTests`. Elle ne peut pas + directement hériter d'APITestCase car on ne veut pas que le test-runner la prenne en compte. + """ + + view_name = None + + def setUp(self): + self.user = UserFactory() + self.declaration = AwaitingInstructionDeclarationFactory(author=self.user) + + def test_get_certificate(self): + self.client.force_login(self.user) + response = self.client.get(reverse(self.view_name, kwargs={"pk": self.declaration.id})) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_unauthenticated_forbidden(self): + response = self.client.get(reverse(self.view_name, kwargs={"pk": self.declaration.id})) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_unauthorized_forbidden(self): + other_user = UserFactory() + self.client.force_login(other_user) + response = self.client.get(reverse(self.view_name, kwargs={"pk": self.declaration.id})) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + +class CertificateViewTests(DeclarationPdfViewTests, APITestCase): + view_name = "web:certificate" + + +class SummaryViewTests(DeclarationPdfViewTests, APITestCase): + view_name = "web:summary" diff --git a/web/urls.py b/web/urls.py index 6ecfc14d0..70bb7a18d 100644 --- a/web/urls.py +++ b/web/urls.py @@ -1,7 +1,7 @@ from django.urls import path from django.views.generic.base import TemplateView -from web.views import CertificateView, FileUploadView, VueAppDisplayView +from web.views import CertificateView, FileUploadView, SummaryView, VueAppDisplayView urlpatterns = [ path("", VueAppDisplayView.as_view(), name="app"), @@ -9,4 +9,7 @@ # https://docs.djangoproject.com/en/5.0/topics/auth/default/#django.contrib.auth.views.LoginView path("envoyer-un-fichier", FileUploadView.as_view(), name="file_upload"), path("declarations/