diff --git a/CHANGELOG.md b/CHANGELOG.md index 9685c3ae56..0fb831b7ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to ### Added +- Signature backend can now retrieve the signing progression of a document - Debit installment on pending order transition if due date is on current day - Display order credit card detail in the back office - Send an email reminder to the user when an installment diff --git a/src/backend/joanie/signature/backends/base.py b/src/backend/joanie/signature/backends/base.py index c90f062723..5c646521e9 100644 --- a/src/backend/joanie/signature/backends/base.py +++ b/src/backend/joanie/signature/backends/base.py @@ -151,3 +151,12 @@ def update_signatories(self, reference_id: str, all_signatories: bool): "subclasses of BaseSignatureBackend must provide a " "update_signatories() method." ) + + def get_signature_state(self, reference_id: str): + """ + Get the signature state of a contract to know who has signed yet the document. + """ + raise NotImplementedError( + "subclasses of BaseSignatureBackend must provide a " + "get_signature_state() method." + ) diff --git a/src/backend/joanie/signature/backends/dummy.py b/src/backend/joanie/signature/backends/dummy.py index c7abeecaa9..6385909f0d 100644 --- a/src/backend/joanie/signature/backends/dummy.py +++ b/src/backend/joanie/signature/backends/dummy.py @@ -147,3 +147,22 @@ def update_signatories(self, reference_id: str, all_signatories: bool) -> str: raise ValidationError(f"The contract {contract.id} is already fully signed") return contract.signature_backend_reference + + def get_signature_state(self, reference_id: str) -> int: + """ + Dummy method that returns the state of document in signing process. + It returns whether the student and the organization have signed. + """ + if not reference_id.startswith(self.prefix_workflow): + raise ValidationError(f"The reference does not exist: {reference_id}.") + try: + contract = Contract.objects.get(signature_backend_reference=reference_id) + except Contract.DoesNotExist as exception: + raise ValidationError( + f"Contract with reference id {reference_id} does not exist." + ) from exception + + return { + "student": bool(contract.student_signed_on), + "organization": bool(contract.organization_signed_on), + } diff --git a/src/backend/joanie/signature/backends/lex_persona.py b/src/backend/joanie/signature/backends/lex_persona.py index f4bbae81e8..7ef2558ce8 100644 --- a/src/backend/joanie/signature/backends/lex_persona.py +++ b/src/backend/joanie/signature/backends/lex_persona.py @@ -667,3 +667,37 @@ def update_signatories(self, reference_id: str, all_signatories: bool) -> str: ) return response.json()["id"] + + def get_signature_state(self, reference_id: str) -> dict: + """ + Get the signature state progress on a signing procedure. + It returns a dictionary whether the student and the organization have signed. + """ + timeout = settings.JOANIE_SIGNATURE_TIMEOUT + base_url = self.get_setting("BASE_URL") + token = self.get_setting("TOKEN") + url = f"{base_url}/api/workflows/{reference_id}/" + headers = {"Authorization": f"Bearer {token}"} + + response = requests.get(url, headers=headers, timeout=timeout) + + if not response.ok: + logger.error( + "Lex Persona: Unable to retrieve the signature procedure" + " the reference does not exist %s, reason: %s", + reference_id, + response.json(), + extra={ + "url": url, + "response": response.json(), + }, + ) + raise exceptions.SignatureProcedureNotFound( + "Lex Persona: Unable to retrieve the signature procedure" + f" the reference does not exist {reference_id}" + ) + + return { + "student": response.json().get("steps")[0].get("isFinished"), + "organization": response.json().get("steps")[-1].get("isFinished"), + } diff --git a/src/backend/joanie/signature/exceptions.py b/src/backend/joanie/signature/exceptions.py index 1f325af679..a06d2b88b5 100644 --- a/src/backend/joanie/signature/exceptions.py +++ b/src/backend/joanie/signature/exceptions.py @@ -67,3 +67,14 @@ class DeleteSignatureProcedureFailed(APIException): status_code = HTTPStatus.BAD_REQUEST default_detail = _("Cannot delete the signature procedure.") default_code = "delete_signature_procedure_failed" + + +class SignatureProcedureNotFound(APIException): + """ + Exception triggered when retrieving the signing procedure from the signature + provider fails + """ + + status_code = HTTPStatus.NOT_FOUND + default_detail = _("The reference of signing procedure does not exist.") + default_code = "signature_procedure_not_found" diff --git a/src/backend/joanie/tests/signature/backends/lex_persona/test_get_signature_state.py b/src/backend/joanie/tests/signature/backends/lex_persona/test_get_signature_state.py new file mode 100644 index 0000000000..4c6b6f69e2 --- /dev/null +++ b/src/backend/joanie/tests/signature/backends/lex_persona/test_get_signature_state.py @@ -0,0 +1,441 @@ +"""Test suite for the Lex Persona Signature Backend `get_signature_state`""" + +from http import HTTPStatus + +from django.test import TestCase +from django.test.utils import override_settings + +import responses + +from joanie.signature import exceptions +from joanie.signature.backends import get_signature_backend + + +@override_settings( + JOANIE_SIGNATURE_BACKEND="joanie.signature.backends.lex_persona.LexPersonaBackend", + JOANIE_SIGNATURE_LEXPERSONA_BASE_URL="https://lex_persona.test01.com", + JOANIE_SIGNATURE_LEXPERSONA_CONSENT_PAGE_ID="cop_id_fake", + JOANIE_SIGNATURE_LEXPERSONA_SESSION_USER_ID="usr_id_fake", + JOANIE_SIGNATURE_LEXPERSONA_PROFILE_ID="sip_profile_id_fake", + JOANIE_SIGNATURE_LEXPERSONA_TOKEN="token_id_fake", + JOANIE_SIGNATURE_VALIDITY_PERIOD_IN_SECONDS=60 * 60 * 24 * 15, + JOANIE_SIGNATURE_TIMEOUT=3, +) +class LexPersonaBackendGetSignatureState(TestCase): + """ + Test suite for `get_signature_state` + """ + + @responses.activate + def test_backend_lex_persona_get_signature_state_when_nobody_has_signed_yet( + self, + ): + """ + Test that the method `get_signature_state` return that nobody has signed the document. + It should return the value False for the student and the organization in the dictionnary. + """ + backend = get_signature_backend() + workflow_id = "wfl_fake_id" + api_url = f"https://lex_persona.test01.com/api/workflows/{workflow_id}/" + response = { + "allowConsolidation": True, + "allowedCoManagerUsers": [], + "coManagerNotifiedEvents": [], + "created": 1726242320013, + "currentRecipientEmails": ["johndoe@acme.fr"], + "currentRecipientUsers": [], + "description": "1 rue de l'exemple, 75000 Paris", + "email": "johndoes@acme.fr", + "firstName": "John", + "groupId": "grp_fake_id", + "id": workflow_id, + "lastName": "Does", + "logs": [], + "name": "Test workflow signature", + "notifiedEvents": [ + "recipientFinished", + "workflowStopped", + "workflowFinished", + ], + "progress": 0, + "started": 1726242331317, + "steps": [ + { + "allowComments": False, + "hideAttachments": False, + "hideWorkflowRecipients": False, + "id": "stp_fake_id", + "invitePeriod": 86400000, + "isFinished": False, + "isStarted": True, + "logs": [ + {"created": 1726242331317, "operation": "start"}, + { + "created": 1726242331317, + "operation": "notifyWorkflowStarted", + }, + ], + "maxInvites": 0, + "recipients": [ + { + "consentPageId": "cop_fake_id", + "country": "FR", + "email": "johndoe@acme.org", + "firstName": "John Doe", + "lastName": ".", + "phoneNumber": "", + "preferredLocale": "fr", + } + ], + "requiredRecipients": 1, + "sendDownloadLink": True, + "stepType": "signature", + "validityPeriod": 86400000, + }, + { + "allowComments": False, + "hideAttachments": False, + "hideWorkflowRecipients": False, + "id": "stp_fake_id", + "invitePeriod": 86400000, + "isFinished": False, + "isStarted": False, + "logs": [], + "maxInvites": 0, + "recipients": [ + { + "consentPageId": "cop_fake_id", + "country": "FR", + "email": "janedoe@acme.org", + "firstName": "Jane Doe", + "lastName": ".", + "phoneNumber": "", + "preferredLocale": "fr", + } + ], + "requiredRecipients": 1, + "sendDownloadLink": True, + "stepType": "signature", + "validityPeriod": 86400000, + }, + ], + "tenantId": "ten_fake_id", + "updated": 1726242331422, + "userId": "usr_fake_id", + "viewAuthorizedGroups": ["grp_fake_id"], + "viewAuthorizedUsers": [], + "watchers": [], + "workflowStatus": "started", + } + responses.add( + responses.GET, + api_url, + json=response, + status=HTTPStatus.OK, + ) + + signature_state = backend.get_signature_state(reference_id=workflow_id) + + self.assertEqual(signature_state, {"student": False, "organization": False}) + + @responses.activate + def test_backend_lex_persona_get_signature_state_when_one_person_has_signed( + self, + ): + """ + Test that the method `get_signature_state` that the student has signed the document. + It should return the value True for the student and False for the organization + in the dictionary. + """ + backend = get_signature_backend() + workflow_id = "wfl_fake_id" + api_url = f"https://lex_persona.test01.com/api/workflows/{workflow_id}/" + + response = { + "allowConsolidation": True, + "allowedCoManagerUsers": [], + "coManagerNotifiedEvents": [], + "created": 1726235653238, + "currentRecipientEmails": [], + "currentRecipientUsers": [], + "description": "1 rue de l'exemple, 75000 Paris", + "email": "johndoe@acme.org", + "firstName": "John", + "groupId": "grp_fake_id", + "id": workflow_id, + "lastName": "Doe", + "logs": [], + "name": "Test Workflow Signature", + "notifiedEvents": [ + "recipientFinished", + "workflowStopped", + "workflowFinished", + ], + "progress": 50, + "started": 1726235671708, + "steps": [ + { + "allowComments": False, + "hideAttachments": False, + "hideWorkflowRecipients": False, + "id": "stp_fake_id", + "invitePeriod": 86400000, + "isFinished": True, + "isStarted": True, + "logs": [ + {"created": 1726235671708, "operation": "start"}, + { + "created": 1726235671708, + "operation": "notifyWorkflowStarted", + }, + { + "created": 1726235727060, + "evidenceId": "evi_serversealingiframe_fake_id", + "operation": "sign", + "recipientEmail": "johndoes@acme.org", + }, + { + "created": 1726235727060, + "operation": "notifyRecipientFinished", + "recipientEmail": "johndoes@acme.org", + }, + ], + "maxInvites": 0, + "recipients": [ + { + "consentPageId": "cop_fake_id", + "country": "FR", + "email": "johndoes@acme.org", + "firstName": "John Doe", + "lastName": ".", + "phoneNumber": "", + "preferredLocale": "fr", + } + ], + "requiredRecipients": 1, + "sendDownloadLink": True, + "stepType": "signature", + "validityPeriod": 86400000, + }, + { + "allowComments": False, + "hideAttachments": False, + "hideWorkflowRecipients": False, + "id": "stp_fake_id", + "invitePeriod": 86400000, + "isFinished": False, + "isStarted": True, + "logs": [], + "maxInvites": 0, + "recipients": [ + { + "consentPageId": "cop_fake_id", + "country": "FR", + "email": "janedoe@acme.fr", + "firstName": "Jane Doe", + "lastName": ".", + "phoneNumber": "", + "preferredLocale": "fr", + } + ], + "requiredRecipients": 1, + "sendDownloadLink": True, + "stepType": "signature", + "validityPeriod": 86400000, + }, + ], + "tenantId": "ten_fake_id", + "updated": 1726237384491, + "userId": "usr_fake_id", + "viewAuthorizedGroups": ["grp_fake_id"], + "viewAuthorizedUsers": [], + "watchers": [], + "workflowStatus": "started", + } + + responses.add( + responses.GET, + api_url, + json=response, + status=HTTPStatus.OK, + ) + + signature_state = backend.get_signature_state(reference_id=workflow_id) + + self.assertEqual(signature_state, {"student": True, "organization": False}) + + @responses.activate + def test_backend_lex_persona_get_signature_state_all_signatories_have_signed( + self, + ): + """ + Test that the method `get_signature_state` that both have signed the document. + It should return the value True for the student and the organization in the dictionary. + """ + backend = get_signature_backend() + workflow_id = "wfl_fake_id" + api_url = f"https://lex_persona.test01.com/api/workflows/{workflow_id}/" + response = { + "allowConsolidation": True, + "allowedCoManagerUsers": [], + "coManagerNotifiedEvents": [], + "created": 1726235653238, + "currentRecipientEmails": [], + "currentRecipientUsers": [], + "description": "1 rue de l'exemple, 75000 Paris", + "email": "johndoes@acme.org", + "firstName": "John", + "groupId": "grp_fake_id", + "id": "wfl_fake_id", + "lastName": "Does", + "logs": [], + "name": "Test workflow signature", + "notifiedEvents": [ + "recipientFinished", + "workflowStopped", + "workflowFinished", + ], + "progress": 100, + "started": 1726235671708, + "steps": [ + { + "allowComments": False, + "hideAttachments": False, + "hideWorkflowRecipients": False, + "id": "stp_fake_id", + "invitePeriod": 86400000, + "isFinished": True, + "isStarted": True, + "logs": [ + {"created": 1726235671708, "operation": "start"}, + { + "created": 1726235671708, + "operation": "notifyWorkflowStarted", + }, + { + "created": 1726235727060, + "evidenceId": "evi_serversealingiframe_fake_id", + "operation": "sign", + "recipientEmail": "johndoe@acme.org", + }, + { + "created": 1726235727060, + "operation": "notifyRecipientFinished", + "recipientEmail": "johndoe@acme.org", + }, + ], + "maxInvites": 0, + "recipients": [ + { + "consentPageId": "cop_fake_id", + "country": "FR", + "email": "johndoe@acme.org", + "firstName": "John Doe", + "lastName": ".", + "phoneNumber": "", + "preferredLocale": "fr", + } + ], + "requiredRecipients": 1, + "sendDownloadLink": True, + "stepType": "signature", + "validityPeriod": 86400000, + }, + { + "allowComments": False, + "hideAttachments": False, + "hideWorkflowRecipients": False, + "id": "stp_fake_id", + "invitePeriod": 86400000, + "isFinished": True, + "isStarted": True, + "logs": [ + {"created": 1726235727082, "operation": "start"}, + { + "created": 1726237384315, + "evidenceId": "evi_serversealingiframe_fake_id", + "operation": "sign", + "recipientEmail": "janedoe@acme.org", + }, + { + "created": 1726237384315, + "operation": "notifyRecipientFinished", + "recipientEmail": "janedoe@acme.org", + }, + { + "created": 1726237384315, + "operation": "notifyWorkflowFinished", + }, + ], + "maxInvites": 0, + "recipients": [ + { + "consentPageId": "cop_fake_id", + "country": "FR", + "email": "janedoe@acme.org", + "firstName": "Jane Doe", + "lastName": ".", + "phoneNumber": "", + "preferredLocale": "fr", + } + ], + "requiredRecipients": 1, + "sendDownloadLink": True, + "stepType": "signature", + "validityPeriod": 86400000, + }, + ], + "tenantId": "ten_fake_id", + "updated": 1726237384491, + "userId": "usr_fake_id", + "viewAuthorizedGroups": ["grp_fake_id"], + "viewAuthorizedUsers": [], + "watchers": [], + "workflowStatus": "finished", + } + + responses.add( + responses.GET, + api_url, + json=response, + status=HTTPStatus.OK, + ) + + signature_state = backend.get_signature_state(reference_id=workflow_id) + + self.assertEqual(signature_state, {"student": True, "organization": True}) + + @responses.activate + def test_backend_lex_persona_get_signature_state_returns_not_found( + self, + ): + """ + Test that the method `get_signature_state` should return a status code + NOT_FOUND (404) because the reference does not exist at the signature provider. + """ + backend = get_signature_backend() + workflow_id = "wfl_fake_id_not_exist" + api_url = f"https://lex_persona.test01.com/api/workflows/{workflow_id}/" + response = { + "status": 404, + "error": "Not Found", + "message": "The specified workflow can not be found.", + "requestId": "2a72", + "code": "WorkflowNotFound", + } + + responses.add( + responses.GET, + api_url, + json=response, + status=HTTPStatus.NOT_FOUND, + ) + + with self.assertRaises(exceptions.SignatureProcedureNotFound) as context: + backend.get_signature_state(reference_id=workflow_id) + + self.assertEqual( + str(context.exception), + "Lex Persona: Unable to retrieve the signature procedure the reference " + "does not exist wfl_fake_id_not_exist", + ) diff --git a/src/backend/joanie/tests/signature/test_backend_signature_base.py b/src/backend/joanie/tests/signature/test_backend_signature_base.py index 87d6c2562f..7675762218 100644 --- a/src/backend/joanie/tests/signature/test_backend_signature_base.py +++ b/src/backend/joanie/tests/signature/test_backend_signature_base.py @@ -167,3 +167,24 @@ def test_backend_signature_base_backend_reset_contract(self): self.assertIsNone(contract.signature_backend_reference) order.refresh_from_db() self.assertEqual(order.state, enums.ORDER_STATE_TO_SIGN) + + @override_settings( + JOANIE_SIGNATURE_BACKEND="joanie.signature.backends.base.BaseSignatureBackend", + ) + def test_backend_signature_base_raise_not_implemented_error_get_signature_state( + self, + ): + """ + Base backend signature provider should raise NotImplementedError for the method + `get_signature_state`. + """ + backend = get_signature_backend() + + with self.assertRaises(NotImplementedError) as context: + backend.get_signature_state(reference_id="123") + + self.assertEqual( + str(context.exception), + "subclasses of BaseSignatureBackend must provide a " + "get_signature_state() method.", + ) diff --git a/src/backend/joanie/tests/signature/test_backend_signature_dummy.py b/src/backend/joanie/tests/signature/test_backend_signature_dummy.py index a916182309..a72942c023 100644 --- a/src/backend/joanie/tests/signature/test_backend_signature_dummy.py +++ b/src/backend/joanie/tests/signature/test_backend_signature_dummy.py @@ -496,3 +496,84 @@ def test_backend_dummy_update_organization_signatories_order_without_contract(se str(context.exception.message), "The reference fake_signature_reference does not exist.", ) + + def test_backend_dummy_get_signature_state(self): + """ + Dummy backend instance should return the value of how many people have signed the + document. It returns a dictionary with boolean value that gives us the information + if the student has signed and the organization. + """ + backend = DummySignatureBackend() + order = factories.OrderFactory( + state=enums.ORDER_STATE_TO_SIGN, + product__contract_definition=factories.ContractDefinitionFactory(), + ) + contract = factories.ContractFactory( + order=order, + definition=order.product.contract_definition, + signature_backend_reference="wfl_fake_dummy_id", + definition_checksum="1234", + context="context", + submitted_for_signature_on=django_timezone.now(), + student_signed_on=None, + organization_signed_on=None, + ) + + signature_state = backend.get_signature_state( + contract.signature_backend_reference + ) + + self.assertEqual(signature_state, {"student": False, "organization": False}) + + contract.student_signed_on = django_timezone.now() + contract.save() + + signature_state = backend.get_signature_state( + contract.signature_backend_reference + ) + + self.assertEqual(signature_state, {"student": True, "organization": False}) + + contract.organization_signed_on = django_timezone.now() + contract.submitted_for_signature_on = None + contract.save() + + signature_state = backend.get_signature_state( + contract.signature_backend_reference + ) + + self.assertEqual(signature_state, {"student": True, "organization": True}) + + def test_backend_dummy_get_signature_state_with_non_existing_reference_id( + self, + ): + """ + Dummy backend instance should not return a dictionary if the passed `reference_id` + is not attached to any contract and raise a `ValidationError`. + """ + backend = DummySignatureBackend() + + with self.assertRaises(ValidationError) as context: + backend.get_signature_state(reference_id="wfl_fake_dummy_id_does_not_exist") + + self.assertEqual( + str(context.exception.message), + "Contract with reference id wfl_fake_dummy_id_does_not_exist does not exist.", + ) + + def test_backend_dummy_get_signature_state_with_wrong_format_reference_id( + self, + ): + """ + Dummy backend instance should raise a `ValidationError` if the reference_id + has the wrong format for the Dummy Backend. + """ + backend = DummySignatureBackend() + + with self.assertRaises(ValidationError) as context: + backend.get_signature_state(reference_id="fake_dummy_id_does_not_exist") + + self.assertEqual( + str(context.exception.message), + "The reference does not exist: fake_dummy_id_does_not_exist.", + )