Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/backend/joanie/client_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,11 @@
api_client.NestedOrganizationContractViewSet,
basename="organization_contracts",
)
organization_related_router.register(
"agreements",
api_client.NestedOrganizationAgreementViewSet,
basename="organization_agreements",
)
organization_related_router.register(
"offerings",
api_client.OfferingViewSet,
Expand Down
98 changes: 98 additions & 0 deletions src/backend/joanie/core/api/client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1100,6 +1100,12 @@ def get_queryset(self):
type=OpenApiTypes.UUID,
many=True,
),
OpenApiParameter(
name="from_batch_order",
description="Retrieve contracts links for batch orders",
required=False,
type=OpenApiTypes.BOOL,
),
],
)
@action(
Expand All @@ -1115,12 +1121,14 @@ def contracts_signature_link(self, request, *args, **kwargs):
organization = self.get_object()
contract_ids = request.query_params.getlist("contract_ids")
offering_ids = request.query_params.getlist("offering_ids")
from_batch_order = request.query_params.get("from_batch_order", False)

try:
(signature_link, ids) = organization.contracts_signature_link(
request.user,
contract_ids=contract_ids,
offering_ids=offering_ids,
from_batch_order=from_batch_order,
)
except NoContractToSignError as error:
return Response({"detail": f"{error}"}, status=HTTPStatus.BAD_REQUEST)
Expand Down Expand Up @@ -1636,6 +1644,96 @@ def get_me(self, request):
return Response(self.serializer_class(request.user, context=context).data)


class GenericAgreementViewSet(
mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
"""
Agreement ViewSet that returns information on Contracts related to Batch Orders.
Only authenticated users that have access to organization can get information.

GET /.*/agreements/
GET /.*/agreements/<uuid:contract_id>
"""

lookup_field = "pk"
permission_classes = [permissions.IsAuthenticated]
serializer_class = serializers.AgreementBatchOrderSerializer
filterset_class = filters.AgreementFilter
ordering = ["-organization_signed_on", "-created_on"]
queryset = (
models.Contract.objects.exclude(
batch_order__state=enums.BATCH_ORDER_STATE_CANCELED
)
.select_related(
"definition",
"organization_signatory",
)
.prefetch_related(
"batch_order__organization",
"batch_order__offering__course",
"batch_order__owner",
"batch_order__offering__product",
)
)


class NestedOrganizationAgreementViewSet(NestedGenericViewSet, GenericAgreementViewSet):
"""
Nested Organization and Agreements (contracts) related to batch orders inside organization
route.

It allows to list & retrieve organization's agreements (contracts) if the user is
an administrator or an owner of the organization.

GET /api/organizations/<organization_id|organization_code>/agreements/
Return list of all organization's contracts

GET /api/organizations/<organization_id|organization_code>/agreements/<contract_id>/
Return an organization's contract if one matches the provided id

You can use query params to filter by signature state when retrieving the list, or by
offering id.
"""

lookup_fields = ["batch_order__organization__pk", "pk"]
lookup_url_kwargs = ["organization_id", "pk"]

def _lookup_by_organization_code_or_pk(self):
"""
Override `lookup_fields` to lookup by organization code or pk according to
the `organization_id` kwarg is a valid UUID or not.
"""
try:
uuid.UUID(self.kwargs["organization_id"])
except ValueError:
self.lookup_fields[0] = "batch_order__organization__code__iexact"

def initial(self, request, *args, **kwargs):
"""
Runs anything that needs to occur prior to calling method handler.
"""
super().initial(request, *args, **kwargs)
self._lookup_by_organization_code_or_pk()

def get_queryset(self):
"""
Customize the queryset to get only user's agreements.
"""
queryset = super().get_queryset()

username = (
self.request.auth["username"]
if self.request.auth
else self.request.user.username
)

additional_filter = {
"batch_order__organization__accesses__user__username": username,
}

return queryset.filter(**additional_filter)


class GenericContractViewSet(
mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
Expand Down
54 changes: 54 additions & 0 deletions src/backend/joanie/core/filters/client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,60 @@ def filter_offering_id(self, queryset, _name, value):
)


class AgreementFilter(filters.FilterSet):
"""
AgreementFilter allows to filter this resource with signature state, organization id,
or offering id.
"""

id = filters.AllValuesMultipleFilter()
signature_state = filters.ChoiceFilter(
method="filter_signature_state",
choices=enums.CONTRACT_SIGNATURE_STATE_FILTER_CHOICES,
)
organization_id = filters.UUIDFilter(field_name="batch_order__organization__id")
offering_id = filters.UUIDFilter(method="filter_offering_id")

class Meta:
model = models.Contract
fields: List[str] = ["id", "signature_state", "offering_id"]

def filter_signature_state(self, queryset, _name, value):
"""
Filter Contracts by signature state
"""
is_unsigned = value == enums.CONTRACT_SIGNATURE_STATE_UNSIGNED
is_half_signed = value == enums.CONTRACT_SIGNATURE_STATE_HALF_SIGNED

return queryset.filter(
student_signed_on__isnull=is_unsigned,
organization_signed_on__isnull=is_unsigned | is_half_signed,
)

def filter_offering_id(self, queryset, _name, value):
"""
Try to retrieve an offering from the given id and filter contracts accordingly.
"""
url_kwargs = self.request.parser_context.get("kwargs", {})
queryset_filters = {"id": value}

if organization_id := url_kwargs.get("organization_id"):
queryset_filters["organizations__in"] = [organization_id]

try:
offering = models.CourseProductRelation.objects.get(**queryset_filters)
except models.CourseProductRelation.DoesNotExist:
return queryset.none()

return queryset.filter(
batch_order__relation__course_id=offering.course_id,
batch_order__relation__product_id=offering.product_id,
batch_order__organization__in=offering.organizations.only("pk").values_list(
"pk", flat=True
),
)


class NestedOrderCourseViewSetFilter(filters.FilterSet):
"""
OrderCourseFilter that allows to filter this resource with a product's 'id', an
Expand Down
6 changes: 5 additions & 1 deletion src/backend/joanie/core/models/contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,11 @@ def get_abilities(self, user):
can_sign = False

if user.is_authenticated:
abilities = self.order.organization.get_abilities(user=user)
abilities = (
self.order.organization.get_abilities(user=user)
if self.order
else self.batch_order.organization.get_abilities(user=user)
)
can_sign = abilities.get("sign_contracts", False)

return {
Expand Down
20 changes: 16 additions & 4 deletions src/backend/joanie/core/models/courses.py
Original file line number Diff line number Diff line change
Expand Up @@ -341,20 +341,32 @@ def signature_backend_references_to_sign(self, **kwargs):
Return the list of references that should be signed by the organization.
"""
filters = Q()
from_batch_order = kwargs.get("from_batch_order")

if contract_ids := kwargs.get("contract_ids"):
filters &= Q(id__in=contract_ids)
if relation_ids := kwargs.get("offering_ids"):
filters &= Q(order__product__offerings__id__in=relation_ids)

if offering_ids := kwargs.get("offering_ids"):
if from_batch_order:
filters &= Q(batch_order__relation__id__in=offering_ids)
else:
filters &= Q(order__product__offerings__id__in=offering_ids)

if from_batch_order:
filters &= Q(batch_order__organization=self)
exclude_filter = Q(batch_order__state=enums.BATCH_ORDER_STATE_CANCELED)
else:
filters &= Q(order__organization=self)
exclude_filter = Q(order__state=enums.ORDER_STATE_CANCELED)

contracts_to_sign = list(
Contract.objects.filter(
filters,
signature_backend_reference__isnull=False,
submitted_for_signature_on__isnull=False,
student_signed_on__isnull=False,
order__organization=self,
)
.exclude(order__state=enums.ORDER_STATE_CANCELED)
.exclude(exclude_filter)
.values_list("id", "signature_backend_reference")
)

Expand Down
14 changes: 14 additions & 0 deletions src/backend/joanie/core/serializers/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,20 @@ def get_total_currency(self, *args, **kwargs) -> str:
return settings.DEFAULT_CURRENCY


class AgreementBatchOrderSerializer(AbilitiesModelSerializer):
"""Small serializer for Contracts models related to Batch Orders (agreements)"""

batch_order = BatchOrderLightSerializer(read_only=True)

class Meta:
model = models.Contract
fields = [
"id",
"batch_order",
]
read_only_fields = fields


class QuoteDefinitionSerializer(serializers.ModelSerializer):
"""Read only serializer for QuoteDefinition model."""

Expand Down
Loading