diff --git a/api/tests/test_declaration_flow.py b/api/tests/test_declaration_flow.py
index c13a117b4..134213970 100644
--- a/api/tests/test_declaration_flow.py
+++ b/api/tests/test_declaration_flow.py
@@ -658,42 +658,70 @@ def test_accept_visa(self):
self.assertEqual(latest_snapshot.expiration_days, 23)
@authenticate
- def test_visor_can_modify_comment(self):
+ def test_visor_can_modify_decision(self):
"""
- Une personne avec le rôle de visa peut modifier le commentaire à destination du pro
+ La viseuse peut modifier la décision de l'instructrice
"""
VisaRoleFactory(user=authenticate.user)
- # Visa acceptée
+ # L'instructrice à marqué cette déclaration comme « autorisée », mais la viseuse la fera
+ # passer en observation
declaration = OngoingVisaDeclarationFactory(
post_validation_status=Declaration.DeclarationStatus.AUTHORIZED,
post_validation_producer_message="À authoriser",
- post_validation_expiration_days=12,
)
- response = self.client.post(
- reverse("api:accept_visa", kwargs={"pk": declaration.id}), {"comment": "Overriden comment"}, format="json"
- )
+ body = {
+ "comment": "overridden comment",
+ "proposal": "OBSERVATION",
+ "delayDays": 6,
+ "reasons": [
+ "a",
+ "b",
+ ],
+ }
+ response = self.client.post(reverse("api:accept_visa", kwargs={"pk": declaration.id}), body, format="json")
self.assertEqual(response.status_code, status.HTTP_200_OK)
declaration.refresh_from_db()
- self.assertEqual(declaration.last_administration_comment, "Overriden comment")
+ latest_snapshot = declaration.snapshots.latest("creation_date")
+ self.assertEqual(declaration.status, Declaration.DeclarationStatus.OBSERVATION)
+ self.assertEqual(latest_snapshot.comment, "overridden comment")
+ self.assertEqual(latest_snapshot.status, Declaration.DeclarationStatus.OBSERVATION)
+ self.assertEqual(latest_snapshot.expiration_days, 6)
+ self.assertEqual(latest_snapshot.blocking_reasons, ["a", "b"])
+
+ @authenticate
+ def test_visor_cant_modify_on_refuse(self):
+ """
+ Refuser un visa n'applique pas les modifications
+ """
+ VisaRoleFactory(user=authenticate.user)
- # Visa refusée
declaration = OngoingVisaDeclarationFactory(
- post_validation_status=Declaration.DeclarationStatus.REJECTED,
- post_validation_producer_message="À refuser",
- post_validation_expiration_days=20,
+ post_validation_status=Declaration.DeclarationStatus.AUTHORIZED,
+ post_validation_producer_message="À authoriser",
)
- response = self.client.post(
- reverse("api:refuse_visa", kwargs={"pk": declaration.id}),
- {"comment": "Overriden comment 2"},
- format="json",
- )
+ body = {
+ "comment": "overridden comment",
+ "proposal": "OBSERVATION",
+ "delayDays": 6,
+ "reasons": [
+ "a",
+ "b",
+ ],
+ }
+ response = self.client.post(reverse("api:refuse_visa", kwargs={"pk": declaration.id}), body, format="json")
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
declaration.refresh_from_db()
- self.assertEqual(declaration.last_administration_comment, "Overriden comment 2")
+ latest_snapshot = declaration.snapshots.latest("creation_date")
+ self.assertEqual(declaration.status, Declaration.DeclarationStatus.AWAITING_INSTRUCTION)
+ self.assertEqual(latest_snapshot.comment, "À authoriser")
+ self.assertEqual(latest_snapshot.status, Declaration.DeclarationStatus.AWAITING_INSTRUCTION)
+ self.assertEqual(latest_snapshot.expiration_days, None)
+ self.assertEqual(latest_snapshot.blocking_reasons, None)
@authenticate
def accept_visa_unauthorized(self):
diff --git a/api/views/declaration/declaration.py b/api/views/declaration/declaration.py
index 639f68070..c6630f5d1 100644
--- a/api/views/declaration/declaration.py
+++ b/api/views/declaration/declaration.py
@@ -637,7 +637,7 @@ def perform_snapshot_creation(self, request, declaration):
declaration.create_snapshot(
user=request.user,
action=self.get_snapshot_action(request, declaration),
- comment=request.data.get("comment", declaration.post_validation_producer_message),
+ comment=declaration.post_validation_producer_message,
post_validation_status=self.get_snapshot_post_validation_status(request, declaration),
)
@@ -645,24 +645,42 @@ def perform_snapshot_creation(self, request, declaration):
class DeclarationAcceptVisaView(VisaDecisionView):
"""
ONGOING_VISA -> { AUTHORIZED | REJECTED | OBJECTION | OBSERVATION }
+ Le ou la viseuse peut surcharger la décision de l'instructrice en envoyant
+ un objet dans le payload de cette forme :
+ {
+ comment: "overridden comment",
+ proposal: "OBSERVATION", // (ou un autre statut)
+ delayDays: 10,
+ reasons: ["a", "b"],
+ }
"""
snapshot_action = Snapshot.SnapshotActions.ACCEPT_VISA
- def get_snapshot_post_validation_status(self, request, declaration):
+ def get_validation_status(self, request, declaration):
+ overridden_status = request.data.get("proposal")
+ if overridden_status:
+ return Declaration.DeclarationStatus(overridden_status)
return declaration.post_validation_status
+ def get_snapshot_post_validation_status(self, request, declaration):
+ return self.get_validation_status(request, declaration)
+
def perform_snapshot_creation(self, request, declaration):
"""
Possible de le surcharger si la création du snapshot nécessite un
traitement spécial
"""
- declaration.create_snapshot(
+ overridden = request.data.get("proposal")
+ data = request.data
+ d = declaration
+ d.create_snapshot(
user=request.user,
- comment=request.data.get("comment", declaration.post_validation_producer_message),
- expiration_days=declaration.post_validation_expiration_days,
- action=self.get_snapshot_action(request, declaration),
- post_validation_status=self.get_snapshot_post_validation_status(request, declaration),
+ comment=data.get("comment") if overridden else d.post_validation_producer_message,
+ expiration_days=data.get("delay_days") if overridden else d.post_validation_expiration_days,
+ action=self.get_snapshot_action(request, d),
+ post_validation_status=self.get_snapshot_post_validation_status(request, d),
+ blocking_reasons=data.get("reasons") if overridden else None,
)
def get_transition(self, request, declaration):
@@ -672,7 +690,7 @@ def get_transition(self, request, declaration):
Declaration.DeclarationStatus.OBJECTION: "accept_visa_object",
Declaration.DeclarationStatus.OBSERVATION: "accept_visa_observe",
}
- return transition_map.get(declaration.post_validation_status)
+ return transition_map.get(self.get_validation_status(request, declaration))
def get_brevo_template_id(self, request, declaration):
template_map = {
diff --git a/frontend/src/utils/mappings.js b/frontend/src/utils/mappings.js
index 4d9ea11ac..f2c92e19b 100644
--- a/frontend/src/utils/mappings.js
+++ b/frontend/src/utils/mappings.js
@@ -275,3 +275,59 @@ export const populationCategoriesMapping = {
export const getAuthorizationModeInFrench = (type) => {
return authorizationModesMapping[type] || null
}
+
+export const blockingReasons = [
+ {
+ title: "Le produit ne répond pas à la définition du complément alimentaire",
+ items: [
+ "Forme assimilable à un aliment courant",
+ "Recommandations d'emploi incompatibles",
+ "Composition (source concentrée, ...)",
+ "Autre raison pour laquelle le produit ne répond pas à la définition du complément alimentaire",
+ ],
+ },
+ {
+ title: "Le produit répond à la définition du médicament",
+ items: ["Médicament par fonction", "Médicament par présentation", "Sevrage tabagique"],
+ },
+ {
+ title: "Les procédures ne sont pas respectées",
+ items: [
+ "Présence d'un Novel Food",
+ "Présence d'une forme d'apport en nutriments non autorisée",
+ "Demande en article 17 attendue",
+ "Demande en article 18 attendue",
+ ],
+ },
+ {
+ title: "Le dossier n'est pas recevable",
+ items: [
+ "Incohérences entre le dossier et l'étiquetage",
+ "Informations manquantes",
+ "Absence de preuve de reconnaissance mutuelle",
+ "Absence ou non conformité de l'étiquetage",
+ "Autre motif d'irrecevabilité",
+ ],
+ },
+ {
+ title: "Le complément alimentaire n'est pas acceptable",
+ items: ["Existence d'un risque"],
+ },
+]
+
+export const decisionCategories = [
+ {
+ value: "approve",
+ title: "J’envoie l’attestation de déclaration",
+ icon: "ri-checkbox-circle-fill",
+ description: "La déclaration est conforme et peut être transmise.",
+ color: "green",
+ },
+ {
+ value: "modify",
+ title: "Des changements sont nécessaires",
+ icon: "ri-close-circle-fill",
+ description: "La déclaration ne peut pas être transmise en l'état.",
+ color: "red",
+ },
+]
diff --git a/frontend/src/views/InstructionPage/DecisionTab.vue b/frontend/src/views/InstructionPage/DecisionTab.vue
index e0923d40e..061a62edb 100644
--- a/frontend/src/views/InstructionPage/DecisionTab.vue
+++ b/frontend/src/views/InstructionPage/DecisionTab.vue
@@ -105,6 +105,7 @@ import { headers } from "@/utils/data-fetching"
import useToaster from "@/composables/use-toaster"
import { handleError } from "@/utils/error-handling"
import ArticleInfoRow from "@/components/DeclarationSummary/ArticleInfoRow"
+import { blockingReasons, decisionCategories } from "@/utils/mappings"
const decisionCategory = ref(null)
watch(decisionCategory, () => (proposal.value = decisionCategory.value === "approve" ? "autorisation" : null))
@@ -120,7 +121,7 @@ const rules = computed(() => {
})
const declaration = defineModel()
const proposal = ref(null)
-const delayDays = ref(15)
+const delayDays = ref()
const comment = ref(declaration.value?.lastAdministrationComment || "")
const reasons = ref([])
@@ -136,68 +137,12 @@ const disableDelayDays = computed(() => proposal.value === "rejection")
watch(proposal, (newProposal) => {
if (mandatoryVisaProposals.indexOf(newProposal) > -1) needsVisa.value = true
if (newProposal === "objection") delayDays.value = 30
- else if (newProposal === "rejection") delayDays.value = null
- else delayDays.value = 15
+ else if (newProposal === "observation") delayDays.value = 15
+ else delayDays.value = null
})
const needsAnsesReferal = computed(() => declaration.value?.article === "ANSES_REFERAL")
-const decisionCategories = [
- {
- value: "approve",
- title: "J’envoie l’attestation de déclaration",
- icon: "ri-checkbox-circle-fill",
- description: "La déclaration est conforme et peut être transmise.",
- color: "green",
- },
- {
- value: "modify",
- title: "Des changements sont nécessaires",
- icon: "ri-close-circle-fill",
- description: "La déclaration ne peut pas être transmise en l'état.",
- color: "red",
- },
-]
-
-const blockingReasons = [
- {
- title: "Le produit ne répond pas à la définition du complément alimentaire",
- items: [
- "Forme assimilable à un aliment courant",
- "Recommandations d'emploi incompatibles",
- "Composition (source concentrée, ...)",
- "Autre raison pour laquelle le produit ne répond pas à la définition du complément alimentaire",
- ],
- },
- {
- title: "Le produit répond à la définition du médicament",
- items: ["Médicament par fonction", "Médicament par présentation", "Sevrage tabagique"],
- },
- {
- title: "Les procédures ne sont pas respectées",
- items: [
- "Présence d'un Novel Food",
- "Présence d'une forme d'apport en nutriments non autorisée",
- "Demande en article 17 attendue",
- "Demande en article 18 attendue",
- ],
- },
- {
- title: "Le dossier n'est pas recevable",
- items: [
- "Incohérences entre le dossier et l'étiquetage",
- "Informations manquantes",
- "Absence de preuve de reconnaissance mutuelle",
- "Absence ou non conformité de l'étiquetage",
- "Autre motif d'irrecevabilité",
- ],
- },
- {
- title: "Le complément alimentaire n'est pas acceptable",
- items: ["Existence d'un risque"],
- },
-]
-
const proposalOptions = computed(() => {
if (decisionCategory.value === "approve") return [{ text: "Autorisation", value: "autorisation" }]
diff --git a/frontend/src/views/VisaPage/DecisionModificationModal.vue b/frontend/src/views/VisaPage/DecisionModificationModal.vue
new file mode 100644
index 000000000..542c97ff5
--- /dev/null
+++ b/frontend/src/views/VisaPage/DecisionModificationModal.vue
@@ -0,0 +1,154 @@
+
+
+
{{ decision.description }}
+
+{{ strikethroughText }}+{{ text }}