Skip to content

Commit f98b2cf

Browse files
fixup! ♻️(backend) update permission to sign for contract of batch order
1 parent 7398abe commit f98b2cf

File tree

5 files changed

+353
-5
lines changed

5 files changed

+353
-5
lines changed

src/backend/joanie/core/api/client/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1100,6 +1100,12 @@ def get_queryset(self):
11001100
type=OpenApiTypes.UUID,
11011101
many=True,
11021102
),
1103+
OpenApiParameter(
1104+
name="from_batch_order",
1105+
description="Boolean value if we want contracts from batch orders or not",
1106+
required=False,
1107+
type=OpenApiTypes.BOOL,
1108+
),
11031109
],
11041110
)
11051111
@action(
@@ -1115,12 +1121,14 @@ def contracts_signature_link(self, request, *args, **kwargs):
11151121
organization = self.get_object()
11161122
contract_ids = request.query_params.getlist("contract_ids")
11171123
offering_ids = request.query_params.getlist("offering_ids")
1124+
from_batch_order = request.query_params.get("from_batch_order", False)
11181125

11191126
try:
11201127
(signature_link, ids) = organization.contracts_signature_link(
11211128
request.user,
11221129
contract_ids=contract_ids,
11231130
offering_ids=offering_ids,
1131+
from_batch_order=from_batch_order,
11241132
)
11251133
except NoContractToSignError as error:
11261134
return Response({"detail": f"{error}"}, status=HTTPStatus.BAD_REQUEST)

src/backend/joanie/core/models/contracts.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,7 @@ def get_abilities(self, user):
314314
abilities = (
315315
self.order.organization.get_abilities(user=user)
316316
if self.order
317-
else self.batch_orders.first().organization.get_abilities(user=user)
317+
else self.batch_order.organization.get_abilities(user=user)
318318
)
319319
can_sign = abilities.get("sign_contracts", False)
320320

src/backend/joanie/core/models/courses.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -341,20 +341,32 @@ def signature_backend_references_to_sign(self, **kwargs):
341341
Return the list of references that should be signed by the organization.
342342
"""
343343
filters = Q()
344+
from_batch_order = kwargs.get("from_batch_order")
345+
344346
if contract_ids := kwargs.get("contract_ids"):
345347
filters &= Q(id__in=contract_ids)
346-
if relation_ids := kwargs.get("offering_ids"):
347-
filters &= Q(order__product__offerings__id__in=relation_ids)
348+
349+
if offering_ids := kwargs.get("offering_ids"):
350+
if from_batch_order:
351+
filters &= Q(batch_order__relation__id__in=offering_ids)
352+
else:
353+
filters &= Q(order__product__offerings__id__in=offering_ids)
354+
355+
if from_batch_order:
356+
filters &= Q(batch_order__organization=self)
357+
exclude_filter = Q(batch_order__state=enums.BATCH_ORDER_STATE_CANCELED)
358+
else:
359+
filters &= Q(order__organization=self)
360+
exclude_filter = Q(order__state=enums.ORDER_STATE_CANCELED)
348361

349362
contracts_to_sign = list(
350363
Contract.objects.filter(
351364
filters,
352365
signature_backend_reference__isnull=False,
353366
submitted_for_signature_on__isnull=False,
354367
student_signed_on__isnull=False,
355-
order__organization=self,
356368
)
357-
.exclude(order__state=enums.ORDER_STATE_CANCELED)
369+
.exclude(exclude_filter)
358370
.values_list("id", "signature_backend_reference")
359371
)
360372

src/backend/joanie/tests/core/api/organizations/test_contracts_signature_link.py

Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,3 +387,323 @@ def test_api_organization_contracts_signature_link_cumulative_filters(self):
387387
response.json(),
388388
{"detail": "Some contracts are not available for this organization."},
389389
)
390+
391+
def test_api_organization_contracts_from_batch_order_signature_link_without_owner(
392+
self,
393+
):
394+
"""
395+
Authenticated users which is not an organization owner should not be able
396+
to sign contracts in bulk.
397+
"""
398+
organization_roles_not_owner = [
399+
role[0]
400+
for role in OrganizationAccess.ROLE_CHOICES
401+
if role[0] != enums.OWNER
402+
]
403+
404+
for organization_role in organization_roles_not_owner:
405+
with self.subTest(role=organization_role):
406+
user = factories.UserFactory()
407+
organization = factories.BatchOrderFactory(
408+
state=enums.BATCH_ORDER_STATE_TO_SIGN
409+
).organization
410+
factories.UserOrganizationAccessFactory(
411+
user=user,
412+
organization=organization,
413+
role=organization_role,
414+
)
415+
416+
token = self.generate_token_from_user(user)
417+
418+
response = self.client.get(
419+
f"/api/v1.0/organizations/{organization.id}/contracts-signature-link/",
420+
HTTP_AUTHORIZATION=f"Bearer {token}",
421+
)
422+
423+
self.assertContains(
424+
response,
425+
'{"detail":"You do not have permission to perform this action."}',
426+
status_code=HTTPStatus.FORBIDDEN,
427+
)
428+
429+
def test_api_organization_contracts_from_batch_order_signature_link_success(self):
430+
"""
431+
Authenticated users with the owner role should be able to sign contracts in bulk.
432+
Contracts where the batch order is canceled should not be returned.
433+
"""
434+
user = factories.UserFactory()
435+
batch_order = factories.BatchOrderFactory(state=enums.BATCH_ORDER_STATE_TO_SIGN)
436+
offering = batch_order.offering
437+
organization = batch_order.organization
438+
factories.UserOrganizationAccessFactory(
439+
user=user,
440+
organization=organization,
441+
role=enums.OWNER,
442+
)
443+
444+
# Create one batch order that is canceled
445+
batch_order_2 = factories.BatchOrderFactory(
446+
organization=organization,
447+
state=enums.BATCH_ORDER_STATE_TO_SIGN,
448+
)
449+
batch_order_2.flow.cancel()
450+
# Create simple orders that should not be retrieved
451+
factories.OrderGeneratorFactory.create_batch(
452+
3,
453+
organization=organization,
454+
product=offering.product,
455+
course=offering.course,
456+
)
457+
458+
token = self.generate_token_from_user(user)
459+
460+
response = self.client.get(
461+
f"/api/v1.0/organizations/{organization.id}/contracts-signature-link/"
462+
"?from_batch_order=true",
463+
HTTP_AUTHORIZATION=f"Bearer {token}",
464+
)
465+
466+
content = response.json()
467+
468+
self.assertStatusCodeEqual(response, HTTPStatus.OK)
469+
self.assertIn(
470+
"https://dummysignaturebackend.fr/?reference=",
471+
content["invitation_link"],
472+
)
473+
# We should not find the batch_order_2 contract since it's canceled.
474+
self.assertCountEqual(
475+
content["contract_ids"],
476+
[str(batch_order.contract.id)],
477+
)
478+
479+
def test_api_organization_contracts_from_batch_order_signature_link_with_offering_id(
480+
self,
481+
):
482+
"""
483+
Authenticated user with the owner role should be able to sign the contracts in
484+
bulk by passing offering ids.
485+
"""
486+
user = factories.UserFactory()
487+
[organization, other_organization] = factories.OrganizationFactory.create_batch(
488+
2
489+
)
490+
factories.UserOrganizationAccessFactory(
491+
user=user,
492+
organization=organization,
493+
role=enums.OWNER,
494+
)
495+
offering_1 = factories.OfferingFactory(
496+
organizations=[organization, other_organization],
497+
product__contract_definition_batch_order=factories.ContractDefinitionFactory(),
498+
product__quote_definition=factories.QuoteDefinitionFactory(),
499+
product__price=0,
500+
)
501+
offering_2 = factories.OfferingFactory(
502+
organizations=[organization],
503+
product__contract_definition_batch_order=factories.ContractDefinitionFactory(),
504+
product__quote_definition=factories.QuoteDefinitionFactory(),
505+
product__price=0,
506+
)
507+
batch_order_1 = factories.BatchOrderFactory(
508+
offering=offering_1,
509+
organization=organization,
510+
state=enums.BATCH_ORDER_STATE_TO_SIGN,
511+
)
512+
factories.BatchOrderFactory(
513+
offering=offering_1,
514+
organization=other_organization,
515+
state=enums.BATCH_ORDER_STATE_TO_SIGN,
516+
)
517+
batch_order_2 = factories.BatchOrderFactory(
518+
offering=offering_2,
519+
organization=organization,
520+
state=enums.BATCH_ORDER_STATE_TO_SIGN,
521+
)
522+
523+
token = self.generate_token_from_user(user)
524+
525+
response = self.client.get(
526+
f"/api/v1.0/organizations/{organization.id}/contracts-signature-link/"
527+
f"?from_batch_order=true&offering_ids={offering_1.id}",
528+
HTTP_AUTHORIZATION=f"Bearer {token}",
529+
)
530+
531+
content = response.json()
532+
533+
self.assertStatusCodeEqual(response, HTTPStatus.OK)
534+
self.assertIn(
535+
"https://dummysignaturebackend.fr/?reference=",
536+
content["invitation_link"],
537+
)
538+
# We should only find batch_order_1.contract in the response
539+
self.assertCountEqual(
540+
content["contract_ids"],
541+
[str(batch_order_1.contract.id)],
542+
)
543+
544+
response = self.client.get(
545+
f"/api/v1.0/organizations/{organization.id}/contracts-signature-link/"
546+
f"?from_batch_order=true&offering_ids={offering_1.id}&offering_ids={offering_2.id}",
547+
HTTP_AUTHORIZATION=f"Bearer {token}",
548+
)
549+
550+
content = response.json()
551+
self.assertStatusCodeEqual(response, HTTPStatus.OK)
552+
self.assertIn(
553+
"https://dummysignaturebackend.fr/?reference=",
554+
content["invitation_link"],
555+
)
556+
# We should only find both contracts related to organization's batch orders.
557+
self.assertCountEqual(
558+
content["contract_ids"],
559+
[str(batch_order_1.contract.id), str(batch_order_2.contract.id)],
560+
)
561+
562+
def test_api_organization_contracts_from_batch_order_signature_link_with_contract_ids(
563+
self,
564+
):
565+
"""
566+
Authenticated user with the owner role should be able to sign the contracts in
567+
bulk by passing contract ids.
568+
"""
569+
user = factories.UserFactory()
570+
organization = factories.OrganizationFactory()
571+
factories.UserOrganizationAccessFactory(
572+
user=user,
573+
organization=organization,
574+
role=enums.OWNER,
575+
)
576+
batch_order_1 = factories.BatchOrderFactory(
577+
organization=organization, state=enums.BATCH_ORDER_STATE_TO_SIGN
578+
)
579+
batch_order_2 = factories.BatchOrderFactory(
580+
organization=organization, state=enums.BATCH_ORDER_STATE_TO_SIGN
581+
)
582+
583+
token = self.generate_token_from_user(user)
584+
585+
response = self.client.get(
586+
f"/api/v1.0/organizations/{organization.id}/contracts-signature-link/"
587+
f"?from_batch_order=true&contract_ids={batch_order_1.contract.id}",
588+
HTTP_AUTHORIZATION=f"Bearer {token}",
589+
)
590+
591+
content = response.json()
592+
self.assertStatusCodeEqual(response, HTTPStatus.OK)
593+
self.assertIn(
594+
"https://dummysignaturebackend.fr/?reference=",
595+
content["invitation_link"],
596+
)
597+
self.assertCountEqual(
598+
content["contract_ids"],
599+
[str(batch_order_1.contract.id)],
600+
)
601+
602+
response = self.client.get(
603+
f"/api/v1.0/organizations/{organization.id}/contracts-signature-link/"
604+
f"?from_batch_order=true&contract_ids={batch_order_1.contract.id}"
605+
f"&contract_ids={batch_order_2.contract.id}",
606+
HTTP_AUTHORIZATION=f"Bearer {token}",
607+
)
608+
609+
content = response.json()
610+
611+
self.assertStatusCodeEqual(response, HTTPStatus.OK)
612+
self.assertIn(
613+
"https://dummysignaturebackend.fr/?reference=",
614+
content["invitation_link"],
615+
)
616+
self.assertCountEqual(
617+
content["contract_ids"],
618+
[str(batch_order_1.contract.id), str(batch_order_2.contract.id)],
619+
)
620+
621+
def test_api_organization_contracts_from_batch_order_signature_link_cumulative_filters(
622+
self,
623+
):
624+
"""
625+
When filter by both a list of offering ids and a list of contract ids,
626+
those filter should be combined.
627+
"""
628+
user = factories.UserFactory()
629+
organization = factories.OrganizationFactory()
630+
factories.UserOrganizationAccessFactory(
631+
user=user,
632+
organization=organization,
633+
role=enums.OWNER,
634+
)
635+
offering_1 = factories.OfferingFactory(
636+
organizations=[organization],
637+
product__contract_definition_batch_order=factories.ContractDefinitionFactory(),
638+
product__quote_definition=factories.QuoteDefinitionFactory(),
639+
product__price=0,
640+
)
641+
offering_2 = factories.OfferingFactory(
642+
organizations=[organization],
643+
product__contract_definition_batch_order=factories.ContractDefinitionFactory(),
644+
product__quote_definition=factories.QuoteDefinitionFactory(),
645+
product__price=0,
646+
)
647+
batch_order_1 = factories.BatchOrderFactory(
648+
offering=offering_1,
649+
organization=organization,
650+
state=enums.BATCH_ORDER_STATE_TO_SIGN,
651+
)
652+
batch_order_2 = factories.BatchOrderFactory(
653+
offering=offering_2,
654+
organization=organization,
655+
state=enums.BATCH_ORDER_STATE_TO_SIGN,
656+
)
657+
658+
token = self.generate_token_from_user(user)
659+
660+
response = self.client.get(
661+
f"/api/v1.0/organizations/{organization.id}/contracts-signature-link/"
662+
f"?from_batch_order=true&contract_ids={batch_order_1.contract.id}"
663+
f"&offering_ids={offering_1.id}",
664+
HTTP_AUTHORIZATION=f"Bearer {token}",
665+
)
666+
667+
content = response.json()
668+
self.assertStatusCodeEqual(response, HTTPStatus.OK)
669+
self.assertIn(
670+
"https://dummysignaturebackend.fr/?reference=",
671+
content["invitation_link"],
672+
)
673+
self.assertCountEqual(
674+
content["contract_ids"],
675+
[str(batch_order_1.contract.id)],
676+
)
677+
678+
response = self.client.get(
679+
f"/api/v1.0/organizations/{organization.id}/contracts-signature-link/"
680+
f"?from_batch_order=true&contract_ids={batch_order_2.contract.id}"
681+
f"&offering_ids={offering_2.id}",
682+
HTTP_AUTHORIZATION=f"Bearer {token}",
683+
)
684+
685+
content = response.json()
686+
self.assertStatusCodeEqual(response, HTTPStatus.OK)
687+
self.assertIn(
688+
"https://dummysignaturebackend.fr/?reference=",
689+
content["invitation_link"],
690+
)
691+
self.assertCountEqual(
692+
content["contract_ids"],
693+
[str(batch_order_2.contract.id)],
694+
)
695+
696+
# Requesting a wrong pair of offering and contract available
697+
response = self.client.get(
698+
f"/api/v1.0/organizations/{organization.id}/contracts-signature-link/"
699+
f"?from_batch_order=true&contract_ids={batch_order_1.contract.id}"
700+
f"&offering_ids={offering_2.id}",
701+
HTTP_AUTHORIZATION=f"Bearer {token}",
702+
)
703+
704+
content = response.json()
705+
self.assertStatusCodeEqual(response, HTTPStatus.BAD_REQUEST)
706+
self.assertEqual(
707+
response.json(),
708+
{"detail": "Some contracts are not available for this organization."},
709+
)

0 commit comments

Comments
 (0)