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
1 change: 1 addition & 0 deletions src/backend/joanie/client_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
router.register("course-runs", api_client.CourseRunViewSet, basename="course-runs")
router.register("enrollments", api_client.EnrollmentViewSet, basename="enrollments")
router.register("orders", api_client.OrderViewSet, basename="orders")
router.register("batch-orders", api_client.BatchOrderViewSet, basename="batch-orders")
router.register(
"organizations", api_client.OrganizationViewSet, basename="organizations"
)
Expand Down
136 changes: 136 additions & 0 deletions src/backend/joanie/core/api/client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,142 @@ def payment_method(self, request, *args, **kwargs):
return Response(status=HTTPStatus.CREATED)


class BatchOrderViewSet(
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.CreateModelMixin,
viewsets.GenericViewSet,
):
"""
BatchOrder Viewset. Allows to create, retrieve and submit batch order for payment.

GET /api/batch-orders/
Return list of all orders for a user with pagination

GET /api/batch-orders/:batch_order_id/
Return information about a batch order

POST /api/batch-orders/ with expected data:
- relation id (course product relation)
- company required data (name, identification number, address, postcode, city, country)
- number of seats
- exhaustive list of trainees (should match the number of seats)
Return new batch_order just created

POST /api/batch-orders/:batch_order_id/submit-for-signature/
Return an invitation link to pay the batch order

POST /api/batch-orders/:batch_order_id/submit-for-payment/
Returns the info to pay the batch order
"""

lookup_field = "pk"
permission_classes = [permissions.IsAuthenticated]
serializer_class = serializers.BatchOrderSerializer
ordering = ["-created_on"]

def get_queryset(self):
"""Custom queryset to limit to batch orders owned by the logged-in user."""
username = (
self.request.auth["username"]
if self.request.auth
else self.request.user.username
)

return models.BatchOrder.objects.filter(
owner__username=username
).select_related(
"contract",
"relation",
"organization",
)

def perform_create(self, serializer):
"""Force the order's "owner" field to the logged-in user."""
serializer.save(owner=self.request.user)

@transaction.atomic
def create(self, request, *args, **kwargs):
"""Create the batch order and start the state of flows"""
serializer = self.get_serializer(data=request.data)

relation_id = request.data.get("relation_id")
try:
relation = CourseProductRelation.objects.get(pk=relation_id)
except CourseProductRelation.DoesNotExist:
return Response(
f"The course product relation does not exist: {relation_id}",
status=HTTPStatus.BAD_REQUEST,
)
organization = get_least_active_organization(relation.product, relation.course)
serializer.initial_data["organization_id"] = organization.id

if not serializer.is_valid():
return Response(serializer.errors, status=HTTPStatus.BAD_REQUEST)

self.perform_create(serializer)
serializer.instance.init_flow()

return Response(serializer.data, status=HTTPStatus.CREATED)

@extend_schema(
request=None,
responses={
(200, "application/json"): OpenApiTypes.OBJECT,
},
)
@action(detail=True, methods=["POST"], url_path="submit-for-signature")
def submit_for_signature(self, request, pk=None): # pylint: disable=unused-argument
"""
Create the contract from the product's contract definition and get the invitation
link to sign it.
"""
batch_order = self.get_object()

invitation_link = batch_order.submit_for_signature(request.user)

return JsonResponse({"invitation_link": invitation_link}, status=HTTPStatus.OK)

@extend_schema(
request=None,
responses={
(200, "application/json"): OpenApiTypes.OBJECT,
404: serializers.ErrorResponseSerializer,
422: serializers.ErrorResponseSerializer,
},
)
@action(detail=True, methods=["POST"], url_path="submit-for-payment")
def submit_for_payment(self, request, pk=None): # pylint: disable=unused-argument
"""
Submit the batch order for payment.
"""
batch_order = self.get_object()

if batch_order.state not in [
enums.BATCH_ORDER_STATE_SIGNING,
enums.BATCH_ORDER_STATE_FAILED_PAYMENT,
]:
return Response(
{
"detail": (
f"The batch order is not ready to submit for payment: {batch_order.state}."
)
},
status=HTTPStatus.UNPROCESSABLE_ENTITY,
)

batch_order.flow.update()

payment_backend = get_payment_backend()
payment_infos = payment_backend.create_payment(
order=batch_order,
billing_address=batch_order.create_billing_address(),
installment=None,
)

return Response(payment_infos, status=HTTPStatus.OK)


class AddressViewSet(
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
Expand Down
28 changes: 28 additions & 0 deletions src/backend/joanie/core/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@

ORDER_STATE_DRAFT = "draft" # order has been created
ORDER_STATE_ASSIGNED = "assigned" # order has been assigned to an organization
ORDER_STATE_TO_OWN = (
"to_own" # order is paid with batch order and doesn't have owner yet
)
ORDER_STATE_TO_SAVE_PAYMENT_METHOD = (
"to_save_payment_method" # order needs a payment method
)
Expand Down Expand Up @@ -105,6 +108,12 @@
ORDER_STATE_REFUNDED,
pgettext_lazy("As in: the order payments are refunded", "Refunded"),
),
(
ORDER_STATE_TO_OWN,
pgettext_lazy(
"As in: the order is paid through batch order but without owner", "To own"
),
),
)
ORDER_STATE_ALLOW_ENROLLMENT = (
ORDER_STATE_COMPLETED,
Expand Down Expand Up @@ -234,3 +243,22 @@
# Course offers
COURSE_OFFER_PAID = "paid"
COURSE_OFFER_FREE = "free"


BATCH_ORDER_STATE_DRAFT = "draft"
BATCH_ORDER_STATE_ASSIGNED = "assigned"
BATCH_ORDER_STATE_TO_SIGN = "to_sign"
BATCH_ORDER_STATE_SIGNING = "signing"
BATCH_ORDER_STATE_PENDING = "pending"
BATCH_ORDER_STATE_FAILED_PAYMENT = "failed_payment"
BATCH_ORDER_STATE_COMPLETED = "completed"

BATCH_ORDER_STATE_CHOICES = (
(BATCH_ORDER_STATE_DRAFT, _("Draft")),
(BATCH_ORDER_STATE_ASSIGNED, _("Assigned")),
(BATCH_ORDER_STATE_TO_SIGN, _("To sign")),
(BATCH_ORDER_STATE_SIGNING, _("Signing")),
(BATCH_ORDER_STATE_PENDING, _("Pending")),
(BATCH_ORDER_STATE_FAILED_PAYMENT, _("Failed payment")),
(BATCH_ORDER_STATE_COMPLETED, _("Completed")),
)
70 changes: 69 additions & 1 deletion src/backend/joanie/core/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -835,12 +835,18 @@ class Meta:
total = factory.LazyAttribute(lambda o: o.product.price)
enrollment = None
state = enums.ORDER_STATE_DRAFT
batch_order = None
voucher = None

@factory.lazy_attribute
def owner(self):
"""Retrieve the user from the enrollment when available or create a new one."""
if self.enrollment:
return self.enrollment.user

if self.state == enums.ORDER_STATE_TO_OWN:
return None

return UserFactory(language="en-us")

@factory.lazy_attribute
Expand Down Expand Up @@ -875,7 +881,7 @@ def main_invoice(self, create, extracted, **kwargs):
extracted.save()
return extracted

if self.state != enums.ORDER_STATE_DRAFT:
if self.state not in [enums.ORDER_STATE_DRAFT, enums.ORDER_STATE_TO_OWN]:
from joanie.payment.factories import ( # pylint: disable=import-outside-toplevel, cyclic-import
InvoiceFactory,
)
Expand Down Expand Up @@ -1012,6 +1018,7 @@ def billing_address(self, create, extracted, **kwargs):
if self.state not in [
enums.ORDER_STATE_DRAFT,
enums.ORDER_STATE_ASSIGNED,
enums.ORDER_STATE_TO_OWN,
]:
self.state = enums.ORDER_STATE_DRAFT

Expand Down Expand Up @@ -1148,6 +1155,11 @@ def billing_address(self, create, extracted, **kwargs):
self.save()
self.flow.refunded()

if self.state == enums.ORDER_STATE_TO_OWN:
self.batch_order = BatchOrderFactory()
self.voucher = VoucherFactory(discount=DiscountFactory(rate=1))
self.save()

@factory.post_generation
# pylint: disable=method-hidden
def payment_schedule(self, create, extracted, **kwargs):
Expand Down Expand Up @@ -1178,6 +1190,62 @@ class Meta:
position = factory.fuzzy.FuzzyInteger(0, 1000)


class TraineeFactory(factory.DictFactory):
"""Factory to create trainees for batch orders"""

first_name = factory.Faker("first_name")
last_name = factory.Faker("last_name")


class BatchOrderFactory(DebugModelFactory, factory.django.DjangoModelFactory):
"""Factory for the Batch Order model"""

class Meta:
model = models.BatchOrder

relation = factory.SubFactory(
CourseProductRelationFactory,
product__type=enums.PRODUCT_TYPE_CREDENTIAL,
product__contract_definition=factory.SubFactory(ContractDefinitionFactory),
)
owner = factory.SubFactory(UserFactory)
identification_number = factory.Faker("random_number", digits=14, fix_len=True)
company_name = factory.Faker("word")
address = factory.Faker("street_address")
postcode = factory.Faker("postcode")
city = factory.Faker("city")
country = factory.Faker("country_code")
nb_seats = factory.fuzzy.FuzzyInteger(1, 20)

@factory.lazy_attribute
def organization(self):
"""Retrieve the organization from the product/course relation."""
course_relations = self.relation.product.course_relations
return course_relations.first().organizations.order_by("?").first()

@factory.lazy_attribute
def total(self):
"""Generate the total of the batch order from the product price and the number of seats"""
return self.nb_seats * self.relation.product.price

@factory.lazy_attribute
def trainees(self):
"""
Prepare a list of dictionary with first name and last name of students.
We ensure that the length of trainees matches the number of seats.
"""
return TraineeFactory.create_batch(self.nb_seats)

@factory.post_generation
# pylint: disable=unused-argument
def order_groups(self, create, extracted, **kwargs):
"""
Set order groups if any
"""
if extracted:
self.order_groups.set(extracted)


class AddressFactory(DebugModelFactory, factory.django.DjangoModelFactory):
"""A factory to create an address"""

Expand Down
Loading