diff --git a/CHANGELOG.md b/CHANGELOG.md index c5c46620a..3193a2178 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ and this project adheres to to `Product`model - Add admin api endpoints to CRUD `Teacher` and `Skill` resources. - Add certification section in back office product detail view +- Add order export to CSV in back office ### Changed diff --git a/Makefile b/Makefile index e465a3fa7..7d5325ac0 100644 --- a/Makefile +++ b/Makefile @@ -347,7 +347,6 @@ clean: ## restore repository state as it was freshly cloned .PHONY: clean tunnel: ## Run a proxy through localtunnel - @$(MAKE) run @echo npx localtunnel -s $(LOCALTUNNEL_SUBDOMAIN) -h $(LOCALTUNNEL_HOST) --port $(LOCALTUNNEL_PORT) --print-requests .PHONY: tunnel diff --git a/src/backend/joanie/core/api/admin/__init__.py b/src/backend/joanie/core/api/admin/__init__.py index b50e3bd22..6af1f759a 100755 --- a/src/backend/joanie/core/api/admin/__init__.py +++ b/src/backend/joanie/core/api/admin/__init__.py @@ -6,7 +6,8 @@ from django.core.cache import cache from django.core.exceptions import ValidationError -from django.http import JsonResponse +from django.http import JsonResponse, StreamingHttpResponse +from django.utils import timezone from django_filters.rest_framework import DjangoFilterBackend from drf_spectacular.types import OpenApiTypes @@ -614,7 +615,9 @@ class OrderViewSet( permission_classes = [permissions.IsAdminUser & permissions.DjangoModelPermissions] serializer_classes = { "list": serializers.AdminOrderLightSerializer, + "export": serializers.AdminOrderExportSerializer, } + serializer_class = serializers.AdminOrderSerializer default_serializer_class = serializers.AdminOrderSerializer filterset_class = filters.OrderAdminFilterSet queryset = models.Order.objects.all().select_related( @@ -632,7 +635,6 @@ class OrderViewSet( "credit_card", ) filter_backends = [DjangoFilterBackend, OrderingFilter] - ordering_fields = ["created_on"] def destroy(self, request, *args, **kwargs): """Cancels an order.""" @@ -689,6 +691,29 @@ def refund(self, request, pk=None): # pylint:disable=unused-argument return Response(status=HTTPStatus.ACCEPTED) + @extend_schema( + request=None, + responses={ + (200, "text/csv"): OpenApiTypes.OBJECT, + 404: serializers.ErrorResponseSerializer, + }, + ) + @action(methods=["GET"], detail=False) + def export(self, request): + """ + Export orders to a CSV file. + """ + queryset = self.filter_queryset(self.get_queryset()) + serializer = serializers.AdminOrderListExportSerializer( + queryset.iterator(), child=self.get_serializer() + ) + now = timezone.now().strftime("%d-%m-%Y_%H-%M-%S") + return StreamingHttpResponse( + serializer.csv_stream(), + content_type="text/csv", + headers={"Content-Disposition": f'attachment; filename="orders_{now}.csv"'}, + ) + class OrganizationAddressViewSet( mixins.CreateModelMixin, diff --git a/src/backend/joanie/core/factories.py b/src/backend/joanie/core/factories.py index b9b599d49..395fc7257 100644 --- a/src/backend/joanie/core/factories.py +++ b/src/backend/joanie/core/factories.py @@ -89,6 +89,8 @@ class Meta: model = settings.AUTH_USER_MODEL django_get_or_create = ("username",) + # In our database, first_name is set by authtoken with the user's full name + first_name = factory.Faker("name") username = factory.Sequence(lambda n: f"user{n!s}") email = factory.Faker("email") language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES]) diff --git a/src/backend/joanie/core/models/accounts.py b/src/backend/joanie/core/models/accounts.py index 127723cb4..42426d43e 100644 --- a/src/backend/joanie/core/models/accounts.py +++ b/src/backend/joanie/core/models/accounts.py @@ -67,6 +67,13 @@ def __init__(self, *args, **kwargs): def __str__(self): return self.username + @property + def name(self): + """ + Return the full name of the user if available, otherwise the username. + """ + return self.get_full_name() or self.username + def clean(self): """ Normalize the `phone_number` value for consistency in database. diff --git a/src/backend/joanie/core/models/certifications.py b/src/backend/joanie/core/models/certifications.py index d14a59172..b0922d940 100644 --- a/src/backend/joanie/core/models/certifications.py +++ b/src/backend/joanie/core/models/certifications.py @@ -246,7 +246,7 @@ def get_document_context(self, language_code=None): "delivery_stamp": timezone.now(), "verification_link": self.verification_uri, "student": { - "name": self.owner.get_full_name() or self.owner.username, + "name": self.owner.name, }, "site": { "name": settings.JOANIE_CATALOG_NAME, diff --git a/src/backend/joanie/core/serializers/admin.py b/src/backend/joanie/core/serializers/admin.py index 871cf870c..2743ce2f2 100755 --- a/src/backend/joanie/core/serializers/admin.py +++ b/src/backend/joanie/core/serializers/admin.py @@ -2,9 +2,11 @@ # pylint: disable=too-many-lines """Admin serializers for Joanie Core app.""" +import csv from decimal import Decimal as D from django.conf import settings +from django.utils.translation import gettext_lazy as _ from drf_spectacular.utils import extend_schema_serializer from rest_framework import serializers @@ -16,6 +18,7 @@ ISO8601DurationField, ThumbnailDetailField, ) +from joanie.core.utils import Echo from joanie.payment import models as payment_models @@ -1266,7 +1269,320 @@ def get_owner_name(self, instance) -> str: Return the full name of the order's owner if available, otherwise fallback to the username """ - return instance.owner.get_full_name() or instance.owner.username + return instance.owner.name + + +class AdminOrderExportSerializer(serializers.ModelSerializer): # pylint: disable=too-many-public-methods + """ + Read only light serializer for Order export. + """ + + class Meta: + model = models.Order + fields_labels = [ + ("id", _("Order reference")), + ("product", _("Product")), + ("owner_name", _("Owner")), + ("owner_email", _("Email")), + ("organization", _("Organization")), + ("state", _("Order state")), + ("created_on", _("Creation date")), + ("updated_on", _("Last modification date")), + ("product_type", _("Product type")), + ("enrollment_course_run_title", _("Enrollment session")), + ("enrollment_course_run_state", _("Session status")), + ("enrollment_created_on", _("Enrolled on")), + ("total", _("Price")), + ("total_currency", _("Currency")), + ("has_waived_withdrawal_right", _("Waived withdrawal right")), + ("certificate", _("Certificate generated for this order")), + ("contract", _("Contract")), + ("contract_submitted_for_signature_on", _("Submitted for signature")), + ("contract_student_signed_on", _("Student signature date")), + ("contract_organization_signed_on", _("Organization signature date")), + ("main_invoice_type", _("Type")), + ("main_invoice_total", _("Total (on invoice)")), + ("main_invoice_balance", _("Balance (on invoice)")), + ("main_invoice_state", _("Billing state")), + ("credit_card_brand", _("Card type")), + ("credit_card_last_numbers", _("Last card digits")), + ("credit_card_expiration_date", _("Card expiration date")), + ] + for i in range(1, 5): + fields_labels.append( + (f"installment_due_date_{i}", _("Installment date %d") % i) + ) + fields_labels.append( + (f"installment_amount_{i}", _("Installment amount %d") % i) + ) + fields_labels.append( + (f"installment_state_{i}", _("Installment state %d") % i) + ) + fields = [field for field, label in fields_labels] + read_only_fields = fields + + @property + def headers(self): + """ + Return the headers of the CSV file. + """ + return [label for field, label in self.Meta.fields_labels] + + product = serializers.SlugRelatedField(read_only=True, slug_field="title") + owner_name = serializers.SerializerMethodField(read_only=True) + owner_email = serializers.SlugRelatedField( + read_only=True, slug_field="email", source="owner" + ) + organization = serializers.SlugRelatedField(read_only=True, slug_field="title") + created_on = serializers.DateTimeField(format="%d/%m/%Y %H:%M:%S") + updated_on = serializers.DateTimeField(format="%d/%m/%Y %H:%M:%S") + product_type = serializers.SlugRelatedField( + read_only=True, slug_field="type", source="product" + ) + enrollment_course_run_title = serializers.SlugRelatedField( + read_only=True, slug_field="course_run__title", source="enrollment" + ) + enrollment_course_run_state = serializers.SlugRelatedField( + read_only=True, slug_field="course_run__state", source="enrollment" + ) + enrollment_created_on = serializers.SerializerMethodField(read_only=True) + total_currency = serializers.SerializerMethodField(read_only=True) + has_waived_withdrawal_right = serializers.SerializerMethodField(read_only=True) + certificate = serializers.SerializerMethodField(read_only=True) + + contract = serializers.SlugRelatedField( + read_only=True, slug_field="definition__title" + ) + contract_submitted_for_signature_on = serializers.SerializerMethodField( + read_only=True + ) + contract_student_signed_on = serializers.SerializerMethodField(read_only=True) + contract_organization_signed_on = serializers.SerializerMethodField(read_only=True) + main_invoice_type = serializers.SlugRelatedField( + read_only=True, slug_field="type", source="main_invoice" + ) + main_invoice_total = serializers.SlugRelatedField( + read_only=True, slug_field="total", source="main_invoice" + ) + main_invoice_balance = serializers.SlugRelatedField( + read_only=True, slug_field="balance", source="main_invoice" + ) + main_invoice_state = serializers.SlugRelatedField( + read_only=True, slug_field="state", source="main_invoice" + ) + credit_card_brand = serializers.SlugRelatedField( + read_only=True, slug_field="brand", source="credit_card" + ) + credit_card_last_numbers = serializers.SlugRelatedField( + read_only=True, slug_field="last_numbers", source="credit_card" + ) + credit_card_expiration_date = serializers.SerializerMethodField(read_only=True) + + installment_due_date_1 = serializers.SerializerMethodField(read_only=True) + installment_amount_1 = serializers.SerializerMethodField(read_only=True) + installment_state_1 = serializers.SerializerMethodField(read_only=True) + installment_due_date_2 = serializers.SerializerMethodField(read_only=True) + installment_amount_2 = serializers.SerializerMethodField(read_only=True) + installment_state_2 = serializers.SerializerMethodField(read_only=True) + installment_due_date_3 = serializers.SerializerMethodField(read_only=True) + installment_amount_3 = serializers.SerializerMethodField(read_only=True) + installment_state_3 = serializers.SerializerMethodField(read_only=True) + installment_due_date_4 = serializers.SerializerMethodField(read_only=True) + installment_amount_4 = serializers.SerializerMethodField(read_only=True) + installment_state_4 = serializers.SerializerMethodField(read_only=True) + + def get_owner_name(self, instance) -> str: + """ + Return the full name of the order's owner if available, + otherwise fallback to the username + """ + return instance.owner.name + + def get_enrollment_created_on(self, instance) -> str: + """ + Return the creation date of the enrollment if available, + otherwise an empty string. + """ + if not instance.enrollment: + return "" + return instance.enrollment.created_on.strftime("%d/%m/%Y %H:%M:%S") + + def get_total_currency(self, *args, **kwargs) -> str: + """Return the code of currency used by the instance""" + return settings.DEFAULT_CURRENCY + + def get_has_waived_withdrawal_right(self, instance) -> str: + """ + Return "Yes" if the order has waived the withdrawal right, otherwise "No". + """ + return "Yes" if instance.has_waived_withdrawal_right else "No" + + def get_certificate(self, instance) -> str: + """ + Return "Yes" if a certificate has been generated for the order, otherwise "No". + """ + return "Yes" if hasattr(instance, "certificate") else "No" + + def get_contract_date(self, instance, date_field: str) -> str: + """ + Return the date of the specified contract field if available, + otherwise an empty string. + """ + try: + return getattr(instance.contract, date_field).strftime("%d/%m/%Y %H:%M:%S") + except (models.Contract.DoesNotExist, AttributeError): + return "" + + def get_contract_submitted_for_signature_on(self, instance) -> str: + """ + Return the date the contract was submitted for signature if available, + otherwise an empty string. + """ + return self.get_contract_date(instance, "submitted_for_signature_on") + + def get_contract_student_signed_on(self, instance) -> str: + """ + Return the date the student signed the contract if available, + otherwise an empty string. + """ + return self.get_contract_date(instance, "student_signed_on") + + def get_contract_organization_signed_on(self, instance) -> str: + """ + Return the date the organization signed the contract if available, + otherwise an empty string. + """ + return self.get_contract_date(instance, "organization_signed_on") + + def get_credit_card_expiration_date(self, instance) -> str: + """ + Return the expiration date of the credit card if available, + otherwise an empty string. + """ + if not instance.credit_card: + return "" + month = instance.credit_card.expiration_month + year = instance.credit_card.expiration_year + return f"{month}/{year}" + + def get_installment_value(self, instance, index, field) -> str: + """ + Return the value of the specified field for the specified installment if available, + otherwise an empty string. + """ + index -= 1 + try: + value = instance.payment_schedule[index][field] + if field == "due_date": + return value.strftime("%d/%m/%Y %H:%M:%S") + return value + except (IndexError, KeyError, TypeError): + return "" + + def get_installment_due_date_1(self, instance) -> str: + """ + Return the due date of the first installment if available, + otherwise an empty string. + """ + return self.get_installment_value(instance, 1, "due_date") + + def get_installment_amount_1(self, instance) -> str: + """ + Return the amount of the first installment if available, + otherwise an empty string. + """ + return self.get_installment_value(instance, 1, "amount") + + def get_installment_state_1(self, instance) -> str: + """ + Return the state of the first installment if available, + otherwise an empty string. + """ + return self.get_installment_value(instance, 1, "state") + + def get_installment_due_date_2(self, instance) -> str: + """ + Return the due date of the second installment if available, + otherwise an empty string. + """ + return self.get_installment_value(instance, 2, "due_date") + + def get_installment_amount_2(self, instance) -> str: + """ + Return the amount of the second installment if available, + otherwise an empty string. + """ + return self.get_installment_value(instance, 2, "amount") + + def get_installment_state_2(self, instance) -> str: + """ + Return the state of the second installment if available, + otherwise an empty string. + """ + return self.get_installment_value(instance, 2, "state") + + def get_installment_due_date_3(self, instance) -> str: + """ + Return the due date of the third installment if available, + otherwise an empty string. + """ + return self.get_installment_value(instance, 3, "due_date") + + def get_installment_amount_3(self, instance) -> str: + """ + Return the amount of the third installment if available, + otherwise an empty string. + """ + return self.get_installment_value(instance, 3, "amount") + + def get_installment_state_3(self, instance) -> str: + """ + Return the state of the third installment if available, + otherwise an empty string. + """ + return self.get_installment_value(instance, 3, "state") + + def get_installment_due_date_4(self, instance) -> str: + """ + Return the due date of the fourth installment if available, + otherwise an empty string. + """ + return self.get_installment_value(instance, 4, "due_date") + + def get_installment_amount_4(self, instance) -> str: + """ + Return the amount of the fourth installment if available, + otherwise an empty string. + """ + return self.get_installment_value(instance, 4, "amount") + + def get_installment_state_4(self, instance) -> str: + """ + Return the state of the fourth installment if available, + otherwise an empty string. + """ + return self.get_installment_value(instance, 4, "state") + + +class AdminOrderListExportSerializer(serializers.ListSerializer): + """ + Serializer for exporting a list of orders to a CSV stream. + """ + + def update(self, instance, validated_data): + """ + Only there to avoid a NotImplementedError. + """ + + def csv_stream(self): + """ + Return a CSV stream of the serialized data. + """ + pseudo_buffer = Echo() + writer = csv.writer(pseudo_buffer) + yield writer.writerow(self.child.headers) + for row in self.data: + yield writer.writerow(row.values()) class AdminEnrollmentLightSerializer(serializers.ModelSerializer): @@ -1293,7 +1609,7 @@ def get_user_name(self, instance) -> str: Return the full name of the enrollment's user if available, otherwise fallback to the username """ - return instance.user.get_full_name() or instance.user.username + return instance.user.name class AdminEnrollmentSerializer(serializers.ModelSerializer): diff --git a/src/backend/joanie/core/serializers/client.py b/src/backend/joanie/core/serializers/client.py index 104b48bc9..848378350 100644 --- a/src/backend/joanie/core/serializers/client.py +++ b/src/backend/joanie/core/serializers/client.py @@ -389,7 +389,7 @@ def get_owner_name(self, instance) -> str: """ Return the name full name of the order's owner or fallback to username """ - return instance.owner.get_full_name() or instance.owner.username + return instance.owner.name class CertificationDefinitionSerializer(serializers.ModelSerializer): diff --git a/src/backend/joanie/core/utils/__init__.py b/src/backend/joanie/core/utils/__init__.py index 302b1f0b6..1be8d445a 100755 --- a/src/backend/joanie/core/utils/__init__.py +++ b/src/backend/joanie/core/utils/__init__.py @@ -116,3 +116,14 @@ def to_python(self, value): Return the python representation of the JSON string. """ return json.loads(value) + + +class Echo: + """An object that implements just the write method of the file-like + interface. + Used for data streaming. + """ + + def write(self, value): + """Write the value by returning it, instead of storing in a buffer.""" + return value diff --git a/src/backend/joanie/core/utils/contract_definition.py b/src/backend/joanie/core/utils/contract_definition.py index 1a2263219..7b0bdfce3 100644 --- a/src/backend/joanie/core/utils/contract_definition.py +++ b/src/backend/joanie/core/utils/contract_definition.py @@ -121,7 +121,7 @@ def generate_document_context(contract_definition=None, user=None, order=None): contract_description = contract_definition.description if user: - user_name = user.get_full_name() or user.username + user_name = user.name user_email = user.email user_phone_number = user.phone_number diff --git a/src/backend/joanie/core/utils/emails.py b/src/backend/joanie/core/utils/emails.py index ac17635e6..828c79157 100644 --- a/src/backend/joanie/core/utils/emails.py +++ b/src/backend/joanie/core/utils/emails.py @@ -26,7 +26,7 @@ def prepare_context_data( or refused. """ context_data = { - "fullname": order.owner.get_full_name() or order.owner.username, + "fullname": order.owner.name, "email": order.owner.email, "product_title": product_title, "installment_amount": Money(installment_amount), diff --git a/src/backend/joanie/debug/views.py b/src/backend/joanie/debug/views.py index 4cf1c8d60..aeee48ad9 100644 --- a/src/backend/joanie/debug/views.py +++ b/src/backend/joanie/debug/views.py @@ -60,7 +60,7 @@ def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["title"] = "👨‍💻Development email preview" context["email"] = order.owner.email - context["fullname"] = order.owner.get_full_name() or order.owner.username + context["fullname"] = order.owner.name context["product"] = order.product context["site"] = { "name": settings.JOANIE_CATALOG_NAME, @@ -122,7 +122,7 @@ def get_context_data(self, **kwargs): targeted_installment_index=order.get_installment_index( state=PAYMENT_STATE_PAID ), - fullname=order.owner.get_full_name() or order.owner.username, + fullname=order.owner.name, email=order.owner.email, dashboard_order_link=settings.JOANIE_DASHBOARD_ORDER_LINK, site={ diff --git a/src/backend/joanie/demo/management/commands/create_dev_demo.py b/src/backend/joanie/demo/management/commands/create_dev_demo.py index 0d91526ea..5c31fb552 100644 --- a/src/backend/joanie/demo/management/commands/create_dev_demo.py +++ b/src/backend/joanie/demo/management/commands/create_dev_demo.py @@ -1,22 +1,9 @@ # ruff: noqa: S311, PLR0913, PLR0915 """Management command to initialize some fake data (products, courses and course runs)""" -import random - -from django.conf import settings -from django.core.exceptions import ValidationError from django.core.management.base import BaseCommand -from django.utils import timezone as django_timezone -from django.utils import translation - -from joanie.core import enums, factories, models -from joanie.core.models import CourseState -from joanie.demo.defaults import NB_DEV_OBJECTS -from joanie.payment import factories as payment_factories -OPENEDX_COURSE_RUN_URI = ( - "http://openedx.test/courses/course-v1:edx+{course:s}+{course_run:s}/course" -) +from joanie.tests.testing_utils import Demo class Command(BaseCommand): @@ -24,632 +11,9 @@ class Command(BaseCommand): help = "Create some fake credential products, courses and course runs" - def get_random_languages(self): - """ - Return a set of random languages. - global_settings.languages is not consistent between django version so we do - want to use ALL_LANGUAGES to set course run languages to prevent synchronization - issues between Joanie & Richie. - """ - return random.sample(["de", "en", "fr", "pt"], random.randint(1, 4)) - - def create_course(self, user, organization, batch_size=1, with_course_runs=False): - """Create courses for given user and organization.""" - - if batch_size == 1: - course = factories.CourseFactory( - organizations=[organization], - users=[[user, enums.OWNER]], - ) - if with_course_runs: - factories.CourseRunFactory.create_batch( - 2, - is_listed=True, - state=CourseState.ONGOING_OPEN, - languages=self.get_random_languages(), - course=course, - ) - - return course - - courses = factories.CourseFactory.create_batch( - batch_size, organizations=[organization], users=[[user, enums.OWNER]] - ) - - if with_course_runs: - for course in courses: - factories.CourseRunFactory.create_batch( - 2, - course=course, - is_listed=True, - state=CourseState.ONGOING_OPEN, - languages=self.get_random_languages(), - ) - return courses - - def create_product_credential( - self, user, organization, contract_definition=None, batch_size=1 - ): - """Create batch or products for given user and organization.""" - if batch_size == 1: - course = factories.CourseFactory( - organizations=[organization], - users=[[user, enums.OWNER]], - ) - product = factories.ProductFactory( - type=enums.PRODUCT_TYPE_CREDENTIAL, - courses=[course], - contract_definition=contract_definition, - ) - target_course_list = factories.CourseFactory.create_batch( - 2, - organizations=[organization], - users=[[user, enums.OWNER]], - ) - - for target_course in target_course_list: - factories.CourseRunFactory( - course=target_course, - is_listed=True, - state=CourseState.ONGOING_OPEN, - languages=self.get_random_languages(), - resource_link=OPENEDX_COURSE_RUN_URI.format( - course=target_course.code, course_run="{course.title}_run1" - ), - ) - factories.CourseRunFactory( - course=target_course, - is_listed=True, - state=CourseState.ONGOING_OPEN, - languages=self.get_random_languages(), - resource_link=OPENEDX_COURSE_RUN_URI.format( - course=target_course.code, course_run="{course.title}_run2" - ), - ) - factories.ProductTargetCourseRelationFactory( - course=target_course, product=product - ) - self.stdout.write( - self.style.SUCCESS( - f"Successfully create product credential on course {course.code}" - ) - ) - return product - return [ - self.create_product_credential(user, organization) - for i in range(batch_size) - ] - - def create_product_certificate(self, user, organization, batch_size=1): - """Create batch or products certificate for given user and organization.""" - if batch_size == 1: - course = factories.CourseFactory( - organizations=[organization], - users=[[user, enums.OWNER]], - ) - factories.CourseRunFactory( - course=course, - is_listed=True, - state=CourseState.ONGOING_OPEN, - languages=self.get_random_languages(), - resource_link=OPENEDX_COURSE_RUN_URI.format( - course=course.code, course_run="{course.title}_run1" - ), - ) - factories.CourseRunFactory( - course=course, - is_listed=True, - state=CourseState.ONGOING_OPEN, - languages=self.get_random_languages(), - resource_link=OPENEDX_COURSE_RUN_URI.format( - course=course.code, course_run="{course.title}_run2" - ), - ) - product = factories.ProductFactory( - type=enums.PRODUCT_TYPE_CERTIFICATE, - courses=[course], - contract_definition=None, - ) - self.stdout.write( - self.style.SUCCESS( - f"Successfully create product certificate on course {course.code}" - ) - ) - return product - return [ - self.create_product_certificate(user, organization) - for i in range(batch_size) - ] - - def create_product_certificate_enrollment(self, user, course_user, organization): - """Create a product certificate and it's enrollment.""" - product = self.create_product_certificate(course_user, organization) - course = product.courses.first() - return factories.EnrollmentFactory( - user=user, - course_run=course.course_runs.first(), - is_active=True, - state=enums.ENROLLMENT_STATE_SET, - ) - - def create_product_purchased( - self, - user, - course_user, - organization, - product_type=enums.PRODUCT_TYPE_CERTIFICATE, - order_status=enums.ORDER_STATE_COMPLETED, - contract_definition=None, - product=None, - ): # pylint: disable=too-many-arguments, too-many-positional-arguments - """Create a product, it's enrollment and it's order.""" - if not product: - if product_type == enums.PRODUCT_TYPE_CERTIFICATE: - product = self.create_product_certificate(course_user, organization) - elif product_type == enums.PRODUCT_TYPE_CREDENTIAL: - product = self.create_product_credential( - course_user, organization, contract_definition - ) - else: - raise ValueError(f"Given product_type ({product_type}) is not allowed.") - - course = product.courses.first() - - order = factories.OrderFactory( - course=None if product_type == enums.PRODUCT_TYPE_CERTIFICATE else course, - enrollment=factories.EnrollmentFactory( - user=user, - course_run=course.course_runs.first(), - is_active=True, - state=enums.ENROLLMENT_STATE_SET, - ) - if product_type == enums.PRODUCT_TYPE_CERTIFICATE - else None, - owner=user, - product=product, - state=order_status, - ) - - return order - - def create_product_purchased_with_certificate( - self, user, course_user, organization, options - ): - """ - Create a product, it's enrollment and it's order. - Also create the order's linked certificate. - """ - order = self.create_product_purchased( - user, - course_user, - organization, - options["product_type"], - enums.ORDER_STATE_COMPLETED, - options["contract_definition"] - if "contract_definition" in options - else None, - ) - return factories.OrderCertificateFactory(order=order) - - def create_order_with_installment_payment_failed( - self, user, course_user, organization - ): - """ - Create an order with an installment payment failed. - """ - - order = self.create_product_purchased( - user, - course_user, - organization, - enums.PRODUCT_TYPE_CREDENTIAL, - enums.ORDER_STATE_PENDING, - factories.ContractDefinitionFactory(), - ) - - factories.ContractFactory( - order=order, - definition=order.product.contract_definition, - submitted_for_signature_on=django_timezone.now(), - student_signed_on=django_timezone.now(), - ) - - order.generate_schedule() - installment = order.payment_schedule[0] - order.set_installment_refused(installment["id"]) - order.save() - - def create_enrollment_certificate(self, user, course_user, organization): - """create an enrollment and it's linked certificate.""" - course = self.create_course(course_user, organization, 1, True) - factories.EnrollmentCertificateFactory( - enrollment__user=user, - enrollment__course_run=course.course_runs.first(), - enrollment__is_active=True, - enrollment__state=enums.ENROLLMENT_STATE_SET, - organization=organization, - ) - - def handle(self, *args, **options): # pylint: disable=too-many-locals,too-many-statements - translation.activate("en-us") - - # Create an organization - other_owners = factories.UserFactory.create_batch( - 5, - first_name="Other", - last_name="Owner", - ) - email = settings.DEVELOPER_EMAIL - email_user, email_domain = email.split("@") - - organization_owner = factories.UserFactory( - username="organization_owner", - email=email_user + "+organization_owner@" + email_domain, - first_name="Orga", - last_name="Owner", - ) - organization = factories.OrganizationFactory( - title="The school of glory", - # Give access to admin user - users=[[organization_owner, enums.OWNER]] - + [[owner, enums.OWNER] for owner in other_owners], - ) - - # Add one credit card to student user - student_user = factories.UserFactory( - username="student_user", - email=email_user + "+student_user@" + email_domain, - first_name="Étudiant", - ) - payment_factories.CreditCardFactory(owner=student_user) - factories.UserAddressFactory(owner=student_user) - - second_student_user = factories.UserFactory( - username="second_student_user", - email=email_user + "+second_student_user@" + email_domain, - first_name="Étudiant 002", - ) - payment_factories.CreditCardFactory(owner=second_student_user) - factories.UserAddressFactory(owner=second_student_user) - - # First create a course product to learn how to become a botanist - # 1/ some course runs are required to become a botanist - bases_of_botany_run1 = factories.CourseRunFactory( - title="Bases of botany", - resource_link=OPENEDX_COURSE_RUN_URI.format( - course="00001", course_run="BasesOfBotany_run1" - ), - # Give access to organization owner user - course__users=[[organization_owner, enums.OWNER]], - course__organizations=[organization], - languages=self.get_random_languages(), - state=CourseState.ONGOING_OPEN, - ) - factories.CourseRunFactory( - title="Bases of botany", - course=bases_of_botany_run1.course, - languages=self.get_random_languages(), - resource_link=OPENEDX_COURSE_RUN_URI.format( - course="00001", course_run="BasesOfBotany_run2" - ), - state=CourseState.ONGOING_OPEN, - ) - how_to_make_a_herbarium_run1 = factories.CourseRunFactory( - title="How to make a herbarium", - resource_link=OPENEDX_COURSE_RUN_URI.format( - course="00002", course_run="HowToMakeHerbarium_run1" - ), - # Give access to organization owner user - course__users=[[organization_owner, enums.OWNER]], - course__organizations=[organization], - languages=self.get_random_languages(), - state=CourseState.ONGOING_OPEN, - ) - factories.CourseRunFactory( - title="How to make a herbarium", - course=how_to_make_a_herbarium_run1.course, - languages=self.get_random_languages(), - resource_link=OPENEDX_COURSE_RUN_URI.format( - course="00002", course_run="HowToMakeHerbarium_run2" - ), - state=CourseState.ONGOING_OPEN, - ) - scientific_publication_analysis_run1 = factories.CourseRunFactory( - title="Scientific publication analysis", - languages=self.get_random_languages(), - resource_link=OPENEDX_COURSE_RUN_URI.format( - course="00003", course_run="ScientificPublicationAnalysis_run1" - ), - state=CourseState.ONGOING_OPEN, - ) - factories.CourseRunFactory( - title="Scientific publication analysis", - course=scientific_publication_analysis_run1.course, - languages=self.get_random_languages(), - resource_link=OPENEDX_COURSE_RUN_URI.format( - course="00003", course_run="ScientificPublicationAnalysis_run2" - ), - state=CourseState.ONGOING_OPEN, - ) - - # Give courses access to admin user - - credential_courses = [ - bases_of_botany_run1.course, - how_to_make_a_herbarium_run1.course, - scientific_publication_analysis_run1.course, - ] - - # Now create a course product to learn how to become a botanist and get a certificate - # 1/ Create the credential Product linked to the botany Course - product = factories.ProductFactory( - type=enums.PRODUCT_TYPE_CREDENTIAL, - # organization=[organization], - title="Become a certified botanist", - courses=[factories.CourseFactory(organizations=[organization])], - target_courses=credential_courses, - certificate_definition=factories.CertificateDefinitionFactory( - title="Botanist Certification", - name="Become a certified botanist certificate", - ), - ) - self.stdout.write( - self.style.SUCCESS(f'Successfully create "{product.title}" product') - ) - - # We need some pagination going on, let's create few more courses and products - self.create_course( - organization_owner, - organization, - batch_size=NB_DEV_OBJECTS["course"], - with_course_runs=True, - ) - self.stdout.write( - self.style.SUCCESS( - f"Successfully create {NB_DEV_OBJECTS['course']} fake courses" - ) - ) - - self.create_product_credential( - organization_owner, - organization, - batch_size=NB_DEV_OBJECTS["product_credential"], - ) - self.stdout.write( - self.style.SUCCESS( - f"Successfully create {NB_DEV_OBJECTS['product_credential']} \ - fake PRODUCT_CREDENTIAL" - ) - ) - - self.create_product_certificate( - organization_owner, - organization, - batch_size=NB_DEV_OBJECTS["product_certificate"], - ) - self.stdout.write( - self.style.SUCCESS( - f"Successfully create {NB_DEV_OBJECTS['product_certificate']} \ - fake PRODUCT_CERTIFICATE" - ) - ) - - # Enrollments and orders - self.create_product_certificate_enrollment( - student_user, organization_owner, organization - ) - self.stdout.write( - self.style.SUCCESS( - "Successfully create an enrollment for a course with a PRODUCT_CERTIFICATE" - ) - ) - - # Order for a PRODUCT_CERTIFICATE - self.create_product_purchased( - student_user, - organization_owner, - organization, - enums.PRODUCT_TYPE_CERTIFICATE, - ) - self.stdout.write( - self.style.SUCCESS("Successfully create an order for a PRODUCT_CERTIFICATE") - ) - - # Order for a PRODUCT_CERTIFICATE with a generated certificate - self.create_product_purchased_with_certificate( - student_user, - organization_owner, - organization, - options={ - "product_type": enums.PRODUCT_TYPE_CERTIFICATE, - }, - ) - self.stdout.write( - self.style.SUCCESS( - "Successfully create an order for a PRODUCT_CERTIFICATE \ - with a generated certificate" - ) - ) - - # Order for a PRODUCT_CREDENTIAL with a generated certificate - self.create_product_purchased_with_certificate( - student_user, - organization_owner, - organization, - options={ - "product_type": enums.PRODUCT_TYPE_CREDENTIAL, - }, - ) - self.stdout.write( - self.style.SUCCESS( - "Successfully create an order for a PRODUCT_CREDENTIAL with a generated certificate" - ) - ) - - # Order for a PRODUCT_CREDENTIAL with an installment payment failed - self.create_order_with_installment_payment_failed( - student_user, - organization_owner, - organization, - ) - self.stdout.write( - self.style.SUCCESS( - "Successfully create an order for a PRODUCT_CREDENTIAL " - "with an installment payment failed" - ) - ) - - # Order for a PRODUCT_CREDENTIAL with a unsigned contract - order = self.create_product_purchased( - student_user, - organization_owner, - organization, - enums.PRODUCT_TYPE_CREDENTIAL, - enums.ORDER_STATE_COMPLETED, - factories.ContractDefinitionFactory(), - ) - factories.ContractFactory( - order=order, - definition=order.product.contract_definition, - student_signed_on=None, - ) - self.stdout.write( - self.style.SUCCESS( - "Successfully create an order for a PRODUCT_CREDENTIAL with an unsigned contract" - ) - ) - - # Order for a PRODUCT_CREDENTIAL with a learner signed contract - learner_signed_order = self.create_product_purchased( - student_user, - organization_owner, - organization, - enums.PRODUCT_TYPE_CREDENTIAL, - enums.ORDER_STATE_COMPLETED, - factories.ContractDefinitionFactory(), - ) - - factories.ContractFactory( - order=learner_signed_order, - definition=learner_signed_order.product.contract_definition, - submitted_for_signature_on=django_timezone.now(), - student_signed_on=django_timezone.now(), - ) - - # create a second purchase with a learner signed contract for the same PRODUCT_CREDENTIAL - order = self.create_product_purchased( - second_student_user, - organization_owner, - organization, - enums.PRODUCT_TYPE_CREDENTIAL, - enums.ORDER_STATE_COMPLETED, - factories.ContractDefinitionFactory(), - product=learner_signed_order.product, - ) - factories.ContractFactory( - order=order, - definition=order.product.contract_definition, - submitted_for_signature_on=django_timezone.now(), - student_signed_on=django_timezone.now(), - ) - - self.stdout.write( - self.style.SUCCESS( - f"Successfully create an order for a PRODUCT_CREDENTIAL \ - with a contract signed by a learner, organization.uuid: {organization.id}", - ) - ) - - # Order for a PRODUCT_CREDENTIAL with a fully signed contract - order = self.create_product_purchased( - student_user, - organization_owner, - organization, - enums.PRODUCT_TYPE_CREDENTIAL, - enums.ORDER_STATE_COMPLETED, - factories.ContractDefinitionFactory(), - ) - - factories.ContractFactory( - order=order, - definition=order.product.contract_definition, - student_signed_on=django_timezone.now(), - organization_signed_on=django_timezone.now(), - organization_signatory=organization_owner, - ) - - self.stdout.write( - self.style.SUCCESS( - f"Successfully create an order for a PRODUCT_CREDENTIAL \ - with a fully signed contract, organization.uuid: {organization.id}", - ) - ) - - # Enrollment with a certificate - self.create_enrollment_certificate( - student_user, organization_owner, organization - ) - self.stdout.write( - self.style.SUCCESS( - "Successfully create an enrollment with a generated certificate" - ) - ) - - # Order for all existing status on PRODUCT_CREDENTIAL - for order_status, _ in enums.ORDER_STATE_CHOICES: - self.create_product_purchased( - student_user, - organization_owner, - organization, - enums.PRODUCT_TYPE_CREDENTIAL, - order_status, - ) - - # Set organization owner for each organization - for organization in models.Organization.objects.all(): - models.OrganizationAccess.objects.get_or_create( - user=organization_owner, - organization=organization, - role=enums.OWNER, - ) - for other_owner in other_owners: - models.OrganizationAccess.objects.get_or_create( - user=other_owner, - organization=organization, - role=enums.OWNER, - ) - self.stdout.write( - self.style.SUCCESS( - "Successfully set organization owner access for each organization" - ) - ) - - self.stdout.write(self.style.SUCCESS("Successfully fake data creation")) - - for order in models.Order.objects.all(): - try: - order.generate_schedule() - except ValidationError: - continue - - if order.state == enums.ORDER_STATE_COMPLETED: - for installment in order.payment_schedule: - order.set_installment_paid(installment["id"]) - - if order.state == enums.ORDER_STATE_PENDING_PAYMENT: - order.set_installment_paid(order.payment_schedule[0]["id"]) - - if order.state == enums.ORDER_STATE_FAILED_PAYMENT: - order.set_installment_refused(order.payment_schedule[0]["id"]) - - if order.state == enums.ORDER_STATE_CANCELED: - order.cancel_remaining_installments() - - if order.state == enums.ORDER_STATE_REFUNDING: - order.set_installment_paid(order.payment_schedule[0]["id"]) - order.cancel_remaining_installments() + def handle(self, *args, **options): + def log(message): + """Log message""" + self.stdout.write(self.style.SUCCESS(message)) - if order.state == enums.ORDER_STATE_REFUNDED: - order.set_installment_paid(order.payment_schedule[0]["id"]) - order.set_installment_refunded(order.payment_schedule[0]["id"]) - order.cancel_remaining_installments() + Demo(log=log).generate() diff --git a/src/backend/joanie/payment/backends/base.py b/src/backend/joanie/payment/backends/base.py index 760f9c6b1..73ef90b85 100644 --- a/src/backend/joanie/payment/backends/base.py +++ b/src/backend/joanie/payment/backends/base.py @@ -83,7 +83,7 @@ def _send_mail_subscription_success(cls, order): template_vars={ "title": _("Subscription confirmed!"), "email": order.owner.email, - "fullname": order.owner.get_full_name() or order.owner.username, + "fullname": order.owner.name, "product": order.product, "site": { "name": settings.JOANIE_CATALOG_NAME, diff --git a/src/backend/joanie/settings.py b/src/backend/joanie/settings.py index 3c7517e61..8aa2dae4a 100755 --- a/src/backend/joanie/settings.py +++ b/src/backend/joanie/settings.py @@ -715,11 +715,11 @@ class Development(Base): "loggers": { "joanie": { "handlers": ["console"], - "level": "DEBUG", + "level": "WARNING", }, "request.summary": { "handlers": ["console"], - "level": "DEBUG", + "level": "WARNING", }, }, } @@ -786,11 +786,11 @@ class Test(Base): "loggers": { "joanie": { "handlers": ["console"], - "level": "DEBUG", + "level": "WARNING", }, "request.summary": { "handlers": ["console"], - "level": "DEBUG", + "level": "WARNING", }, }, } diff --git a/src/backend/joanie/tests/__init__.py b/src/backend/joanie/tests/__init__.py index cb63e942e..0f7b99801 100644 --- a/src/backend/joanie/tests/__init__.py +++ b/src/backend/joanie/tests/__init__.py @@ -9,3 +9,11 @@ def format_date(value: datetime) -> str | None: return value.isoformat().replace("+00:00", "Z") except AttributeError: return None + + +def format_date_export(value: datetime) -> str: + """Format a datetime to be used in a csv export""" + try: + return value.strftime("%d/%m/%Y %H:%M:%S") + except AttributeError: + return "" diff --git a/src/backend/joanie/tests/base.py b/src/backend/joanie/tests/base.py index fe4ff8b5c..020abe9ae 100644 --- a/src/backend/joanie/tests/base.py +++ b/src/backend/joanie/tests/base.py @@ -5,8 +5,9 @@ from datetime import datetime, timedelta from django.conf import settings -from django.test import TestCase +from django.test import TestCase, override_settings from django.utils import translation +from django.utils.log import configure_logging from rest_framework_simplejwt.tokens import AccessToken @@ -69,11 +70,27 @@ def generate_token_from_user(user, expires_at=None): return generate_jwt_token_from_user(user, expires_at) -class BaseLogMixinTestCase: - """Mixin for logging testing""" +class LoggingTestCase(TestCase): + """Base test case for logging tests""" maxDiff = None + @classmethod + def setUpClass(cls): + logging_settings = settings.LOGGING + logging_settings["loggers"]["joanie"]["level"] = "DEBUG" + with override_settings(LOGGING=logging_settings): + configure_logging( + settings.LOGGING_CONFIG, + logging_settings, + ) + super().setUpClass() + + @classmethod + def tearDownClass(cls): + configure_logging(settings.LOGGING_CONFIG, settings.LOGGING) + super().tearDownClass() + def assertLogsEquals(self, records, expected_records): """Check that the logs are as expected diff --git a/src/backend/joanie/tests/core/api/admin/orders/test_export.py b/src/backend/joanie/tests/core/api/admin/orders/test_export.py new file mode 100644 index 000000000..aa63d9540 --- /dev/null +++ b/src/backend/joanie/tests/core/api/admin/orders/test_export.py @@ -0,0 +1,174 @@ +"""Test suite for the admin orders API export endpoint.""" + +from http import HTTPStatus +from unittest import mock + +from django.conf import settings +from django.test import TestCase +from django.utils import timezone + +from joanie.core import enums, factories +from joanie.core.models import Course, Order +from joanie.tests import format_date_export +from joanie.tests.testing_utils import Demo + + +def yes_no(value): + """Return "Yes" if value is True, "No" otherwise.""" + return "Yes" if value else "No" + + +def expected_csv_content(order): + """Return the expected CSV content for an order.""" + content = { + "Order reference": str(order.id), + "Product": order.product.title, + "Owner": order.owner.get_full_name(), + "Email": order.owner.email, + "Organization": order.organization.title, + "Order state": order.state, + "Creation date": format_date_export(order.created_on), + "Last modification date": format_date_export(order.updated_on), + "Product type": order.product.type, + "Enrollment session": "", + "Session status": "", + "Enrolled on": "", + "Price": str(order.total), + "Currency": settings.DEFAULT_CURRENCY, + "Waived withdrawal right": yes_no(order.has_waived_withdrawal_right), + "Certificate generated for this order": yes_no(hasattr(order, "certificate")), + "Contract": "", + "Submitted for signature": "", + "Student signature date": "", + "Organization signature date": "", + "Type": "", + "Total (on invoice)": "", + "Balance (on invoice)": "", + "Billing state": "", + "Card type": order.credit_card.brand, + "Last card digits": order.credit_card.last_numbers, + "Card expiration date": ( + f"{order.credit_card.expiration_month}/{order.credit_card.expiration_year}" + ), + } + + for i in range(1, 5): + content[f"Installment date {i}"] = "" + content[f"Installment amount {i}"] = "" + content[f"Installment state {i}"] = "" + + if order.enrollment: + content["Enrollment session"] = order.enrollment.course_run.title + content["Session status"] = str(order.enrollment.course_run.state) + content["Enrolled on"] = format_date_export(order.enrollment.created_on) + + if hasattr(order, "contract"): + content["Contract"] = order.contract.definition.title + content["Submitted for signature"] = format_date_export( + order.contract.submitted_for_signature_on + ) + content["Student signature date"] = format_date_export( + order.contract.student_signed_on + ) + content["Organization signature date"] = format_date_export( + order.contract.organization_signed_on + ) + + if order.main_invoice: + content["Type"] = order.main_invoice.type + content["Total (on invoice)"] = str(order.main_invoice.total) + content["Balance (on invoice)"] = str(order.main_invoice.balance) + content["Billing state"] = order.main_invoice.state + + for i, installment in enumerate(order.payment_schedule, start=1): + content[f"Installment date {i}"] = format_date_export( + installment.get("due_date") + ) + content[f"Installment amount {i}"] = str(installment.get("amount")) + content[f"Installment state {i}"] = installment.get("state") + + return content + + +class OrdersAdminApiExportTestCase(TestCase): + """Test suite for the admin orders API export endpoint.""" + + maxDiff = None + + def test_api_admin_orders_export_csv_anonymous_user(self): + """ + Anonymous users should not be able to export orders as CSV. + """ + response = self.client.get("/api/v1.0/admin/orders/export/") + + self.assertEqual(response.status_code, HTTPStatus.UNAUTHORIZED) + + def test_api_admin_orders_export_csv_lambda_user(self): + """ + Lambda users should not be able to export orders as CSV. + """ + admin = factories.UserFactory(is_staff=False, is_superuser=False) + self.client.login(username=admin.username, password="password") + + response = self.client.get("/api/v1.0/admin/orders/export/") + + self.assertEqual(response.status_code, HTTPStatus.FORBIDDEN) + + def test_api_admin_orders_export_csv(self): + """ + Admin users should be able to export orders as CSV. + """ + Demo().generate() + + orders = Order.objects.all() + + admin = factories.UserFactory(is_staff=True, is_superuser=True) + self.client.login(username=admin.username, password="password") + + now = timezone.now() + with mock.patch("django.utils.timezone.now", return_value=now): + response = self.client.get("/api/v1.0/admin/orders/export/") + + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertEqual(response["Content-Type"], "text/csv") + self.assertEqual( + response["Content-Disposition"], + f'attachment; filename="orders_{now.strftime("%d-%m-%Y_%H-%M-%S")}.csv"', + ) + csv_content = response.getvalue().decode().splitlines() + csv_header = csv_content.pop(0) + expected_headers = expected_csv_content(orders[0]).keys() + self.assertEqual(csv_header.split(","), list(expected_headers)) + + for order, csv_line in zip(orders, csv_content, strict=False): + self.assertEqual( + csv_line.split(","), list(expected_csv_content(order).values()) + ) + + def test_api_admin_orders_export_csv_filter(self): + """ + State filter should be applied when exporting orders as CSV. + """ + Demo().generate() + course_ids = Course.objects.filter( + order__state=enums.ORDER_STATE_COMPLETED + ).values_list("id", flat=True)[:2] + + admin = factories.UserFactory(is_staff=True, is_superuser=True) + self.client.login(username=admin.username, password="password") + + response = self.client.get( + f"/api/v1.0/admin/orders/export/?state={enums.ORDER_STATE_TO_SIGN}" + f"&course_ids={course_ids[0]},{course_ids[1]}" + ) + + csv_content = response.getvalue().decode().splitlines() + csv_content.pop(0) + + orders = Order.objects.filter( + state=enums.ORDER_STATE_COMPLETED, course_id__in=course_ids + ) + for order, csv_line in zip(orders, csv_content, strict=False): + self.assertEqual( + csv_line.split(","), list(expected_csv_content(order).values()) + ) diff --git a/src/backend/joanie/tests/core/api/admin/orders/test_list.py b/src/backend/joanie/tests/core/api/admin/orders/test_list.py index c5e160266..8bbb8a6d3 100644 --- a/src/backend/joanie/tests/core/api/admin/orders/test_list.py +++ b/src/backend/joanie/tests/core/api/admin/orders/test_list.py @@ -95,7 +95,7 @@ def test_api_admin_orders_list(self): else None, "id": str(order.id), "organization_title": order.organization.title, - "owner_name": order.owner.username, + "owner_name": order.owner.get_full_name(), "product_title": order.product.title, "state": order.state, "total": float(order.total), diff --git a/src/backend/joanie/tests/core/api/admin/orders/test_refund.py b/src/backend/joanie/tests/core/api/admin/orders/test_refund.py index bab3fb4c1..6a4327803 100644 --- a/src/backend/joanie/tests/core/api/admin/orders/test_refund.py +++ b/src/backend/joanie/tests/core/api/admin/orders/test_refund.py @@ -355,7 +355,7 @@ def test_api_admin_orders_refund_an_order(self): ) text_lines = [ - f"Hello {order.owner.username},", + f"Hello {order.owner.get_full_name()},", f"For the course {order.product.title}, " "the order has been refunded.", "We have refunded the following installments on the credit card " f"•••• •••• •••• {order.credit_card.last_numbers}.", diff --git a/src/backend/joanie/tests/core/api/organizations/test_api_organizations_contract.py b/src/backend/joanie/tests/core/api/organizations/test_api_organizations_contract.py index 6f61d271a..292720708 100644 --- a/src/backend/joanie/tests/core/api/organizations/test_api_organizations_contract.py +++ b/src/backend/joanie/tests/core/api/organizations/test_api_organizations_contract.py @@ -187,7 +187,7 @@ def test_api_organizations_contracts_list_with_accesses(self, _): "contact_phone": contract.order.organization.contact_phone, "dpo_email": contract.order.organization.dpo_email, }, - "owner_name": contract.order.owner.username, + "owner_name": contract.order.owner.get_full_name(), "product_title": contract.order.product.title, }, } @@ -574,7 +574,7 @@ def test_api_organizations_contracts_retrieve_with_accesses(self, _): "contact_phone": contract.order.organization.contact_phone, "dpo_email": contract.order.organization.dpo_email, }, - "owner_name": contract.order.owner.username, + "owner_name": contract.order.owner.get_full_name(), "product_title": contract.order.product.title, }, } diff --git a/src/backend/joanie/tests/core/models/order/test_schedule.py b/src/backend/joanie/tests/core/models/order/test_schedule.py index 7afa57469..6f9b472dc 100644 --- a/src/backend/joanie/tests/core/models/order/test_schedule.py +++ b/src/backend/joanie/tests/core/models/order/test_schedule.py @@ -10,7 +10,6 @@ from django.conf import settings from django.core.exceptions import ValidationError -from django.test import TestCase from django.test.utils import override_settings from django.utils import timezone @@ -32,7 +31,7 @@ ) from joanie.core.models import CourseState, Order from joanie.core.utils import payment_schedule -from joanie.tests.base import ActivityLogMixingTestCase, BaseLogMixinTestCase +from joanie.tests.base import ActivityLogMixingTestCase, LoggingTestCase # pylint: disable=too-many-public-methods @@ -44,7 +43,7 @@ }, DEFAULT_CURRENCY="EUR", ) -class OrderModelsTestCase(TestCase, BaseLogMixinTestCase, ActivityLogMixingTestCase): +class OrderModelsTestCase(LoggingTestCase, ActivityLogMixingTestCase): """ Test suite for order payment schedule """ diff --git a/src/backend/joanie/tests/core/tasks/test_payment_schedule.py b/src/backend/joanie/tests/core/tasks/test_payment_schedule.py index 6b9fab86e..05d1e8f4a 100644 --- a/src/backend/joanie/tests/core/tasks/test_payment_schedule.py +++ b/src/backend/joanie/tests/core/tasks/test_payment_schedule.py @@ -11,7 +11,6 @@ from django.core import mail from django.core.management import call_command -from django.test import TestCase from django.test.utils import override_settings from django.urls import reverse @@ -39,10 +38,10 @@ from joanie.payment import get_payment_backend from joanie.payment.backends.dummy import DummyPaymentBackend from joanie.payment.factories import InvoiceFactory -from joanie.tests.base import BaseLogMixinTestCase +from joanie.tests.base import LoggingTestCase -class PaymentScheduleTasksTestCase(TestCase, BaseLogMixinTestCase): +class PaymentScheduleTasksTestCase(LoggingTestCase): """ Test suite for payment schedule tasks """ diff --git a/src/backend/joanie/tests/core/test_api_admin_enrollments.py b/src/backend/joanie/tests/core/test_api_admin_enrollments.py index d28c9e810..d8f4a2e99 100644 --- a/src/backend/joanie/tests/core/test_api_admin_enrollments.py +++ b/src/backend/joanie/tests/core/test_api_admin_enrollments.py @@ -125,7 +125,7 @@ def test_api_admin_enrollments_list(self): "text": enrollment.course_run.state.get("text"), }, }, - "user_name": enrollment.user.username, + "user_name": enrollment.user.get_full_name(), "id": str(enrollment.id), "state": enrollment.state, "is_active": enrollment.is_active, diff --git a/src/backend/joanie/tests/core/test_api_certificate.py b/src/backend/joanie/tests/core/test_api_certificate.py index 6c1c29b10..192ff4bca 100644 --- a/src/backend/joanie/tests/core/test_api_certificate.py +++ b/src/backend/joanie/tests/core/test_api_certificate.py @@ -247,7 +247,7 @@ def test_api_certificate_read_list_authenticated(self, _mock_thumbnail): "contact_phone": other_order.organization.contact_phone, "dpo_email": other_order.organization.dpo_email, }, - "owner_name": other_certificate.order.owner.username, + "owner_name": other_certificate.order.owner.get_full_name(), "product_title": other_certificate.order.product.title, }, }, @@ -333,7 +333,7 @@ def test_api_certificate_read_list_authenticated(self, _mock_thumbnail): "contact_phone": order.organization.contact_phone, "dpo_email": order.organization.dpo_email, }, - "owner_name": certificate.order.owner.username, + "owner_name": certificate.order.owner.get_full_name(), "product_title": certificate.order.product.title, }, }, @@ -538,7 +538,7 @@ def test_api_certificate_read_list_filtered_by_order_type(self, _mock_thumbnail) "contact_phone": cert.order.organization.contact_phone, "dpo_email": cert.order.organization.dpo_email, }, - "owner_name": cert.order.owner.username, + "owner_name": cert.order.owner.get_full_name(), "product_title": cert.order.product.title, } if cert.order @@ -913,7 +913,7 @@ def test_api_certificate_read_authenticated_from_an_order(self, _mock_thumbnail) "contact_phone": certificate.order.organization.contact_phone, "dpo_email": certificate.order.organization.dpo_email, }, - "owner_name": certificate.order.owner.username, + "owner_name": certificate.order.owner.get_full_name(), "product_title": certificate.order.product.title, }, }, diff --git a/src/backend/joanie/tests/core/test_api_contract.py b/src/backend/joanie/tests/core/test_api_contract.py index aa3bbd71b..53dcb24ae 100644 --- a/src/backend/joanie/tests/core/test_api_contract.py +++ b/src/backend/joanie/tests/core/test_api_contract.py @@ -167,7 +167,7 @@ def test_api_contracts_list_with_owner(self, _): "contact_phone": contract.order.organization.contact_phone, "dpo_email": contract.order.organization.dpo_email, }, - "owner_name": contract.order.owner.username, + "owner_name": contract.order.owner.get_full_name(), "product_title": contract.order.product.title, }, } @@ -795,7 +795,7 @@ def test_api_contracts_retrieve_with_owner(self, _): "contact_phone": contract.order.organization.contact_phone, "dpo_email": contract.order.organization.dpo_email, }, - "owner_name": contract.order.owner.username, + "owner_name": contract.order.owner.get_full_name(), "product_title": contract.order.product.title, }, } diff --git a/src/backend/joanie/tests/core/test_api_courses_contract.py b/src/backend/joanie/tests/core/test_api_courses_contract.py index 4fc67f24a..7444dddc0 100644 --- a/src/backend/joanie/tests/core/test_api_courses_contract.py +++ b/src/backend/joanie/tests/core/test_api_courses_contract.py @@ -174,7 +174,7 @@ def test_api_courses_contracts_list_with_accesses(self, _): "contact_phone": contract.order.organization.contact_phone, "dpo_email": contract.order.organization.dpo_email, }, - "owner_name": contract.order.owner.username, + "owner_name": contract.order.owner.get_full_name(), "product_title": contract.order.product.title, }, } @@ -543,7 +543,7 @@ def test_api_courses_contracts_retrieve_with_accesses(self, _): "contact_phone": contract.order.organization.contact_phone, "dpo_email": contract.order.organization.dpo_email, }, - "owner_name": contract.order.owner.username, + "owner_name": contract.order.owner.get_full_name(), "product_title": contract.order.product.title, }, } diff --git a/src/backend/joanie/tests/core/test_commands_synchronize_brevo_subscriptions.py b/src/backend/joanie/tests/core/test_commands_synchronize_brevo_subscriptions.py index 3d09d381f..2203ac1a7 100644 --- a/src/backend/joanie/tests/core/test_commands_synchronize_brevo_subscriptions.py +++ b/src/backend/joanie/tests/core/test_commands_synchronize_brevo_subscriptions.py @@ -3,12 +3,11 @@ from unittest.mock import patch from django.core.management import call_command -from django.test import TestCase -from joanie.tests.base import BaseLogMixinTestCase +from joanie.tests.base import LoggingTestCase -class SynchronizeBrevoSubscriptionsCommandTestCase(TestCase, BaseLogMixinTestCase): +class SynchronizeBrevoSubscriptionsCommandTestCase(LoggingTestCase): """ Test case for the synchronize_brevo_subscriptions command. """ diff --git a/src/backend/joanie/tests/core/test_flows_order.py b/src/backend/joanie/tests/core/test_flows_order.py index 38137c528..49adf0465 100644 --- a/src/backend/joanie/tests/core/test_flows_order.py +++ b/src/backend/joanie/tests/core/test_flows_order.py @@ -10,7 +10,6 @@ from django.core import mail from django.core.exceptions import ValidationError -from django.test import TestCase from django.test.utils import override_settings import responses @@ -28,10 +27,10 @@ ) from joanie.payment.backends.dummy import DummyPaymentBackend from joanie.payment.factories import BillingAddressDictFactory, CreditCardFactory -from joanie.tests.base import BaseLogMixinTestCase +from joanie.tests.base import LoggingTestCase -class OrderFlowsTestCase(TestCase, BaseLogMixinTestCase): +class OrderFlowsTestCase(LoggingTestCase): """Test suite for the Order flow.""" maxDiff = None diff --git a/src/backend/joanie/tests/core/test_models_certificate.py b/src/backend/joanie/tests/core/test_models_certificate.py index a7b3665a1..2f129e8f4 100644 --- a/src/backend/joanie/tests/core/test_models_certificate.py +++ b/src/backend/joanie/tests/core/test_models_certificate.py @@ -1,14 +1,14 @@ """Test suite for Certificate Model""" from django.conf import settings -from django.test import TestCase, override_settings +from django.test import override_settings from joanie.core import enums, factories from joanie.core.models import Certificate, DocumentImage -from joanie.tests.base import BaseLogMixinTestCase +from joanie.tests.base import LoggingTestCase -class CertificateModelTestCase(TestCase, BaseLogMixinTestCase): +class CertificateModelTestCase(LoggingTestCase): """Certificate model test case.""" maxDiff = None diff --git a/src/backend/joanie/tests/core/test_models_order.py b/src/backend/joanie/tests/core/test_models_order.py index eb015bfdf..54dc14f00 100644 --- a/src/backend/joanie/tests/core/test_models_order.py +++ b/src/backend/joanie/tests/core/test_models_order.py @@ -11,7 +11,7 @@ from django.contrib.sites.models import Site from django.core.exceptions import PermissionDenied, ValidationError from django.core.serializers.json import DjangoJSONEncoder -from django.test import TestCase, override_settings +from django.test import override_settings from django.utils import timezone as django_timezone from joanie.core import enums, factories @@ -25,10 +25,10 @@ InvoiceFactory, ) from joanie.signature.backends import get_signature_backend -from joanie.tests.base import BaseLogMixinTestCase +from joanie.tests.base import LoggingTestCase -class OrderModelsTestCase(TestCase, BaseLogMixinTestCase): +class OrderModelsTestCase(LoggingTestCase): """Test suite for the Order model.""" maxDiff = None diff --git a/src/backend/joanie/tests/core/test_utils_payment_schedule.py b/src/backend/joanie/tests/core/test_utils_payment_schedule.py index c740106e7..a645b43c6 100644 --- a/src/backend/joanie/tests/core/test_utils_payment_schedule.py +++ b/src/backend/joanie/tests/core/test_utils_payment_schedule.py @@ -12,7 +12,6 @@ from django.conf import settings from django.core import mail -from django.test import TestCase from django.test.utils import override_settings from django.utils import timezone @@ -32,7 +31,7 @@ from joanie.core.utils import payment_schedule from joanie.payment.factories import InvoiceFactory, TransactionFactory from joanie.payment.models import Invoice, Transaction -from joanie.tests.base import BaseLogMixinTestCase +from joanie.tests.base import LoggingTestCase # pylint: disable=protected-access, too-many-public-methods, too-many-lines @@ -47,7 +46,7 @@ DEFAULT_CURRENCY="EUR", JOANIE_INSTALLMENT_REMINDER_PERIOD_DAYS=2, ) -class PaymentScheduleUtilsTestCase(TestCase, BaseLogMixinTestCase): +class PaymentScheduleUtilsTestCase(LoggingTestCase): """ Test suite for payment schedule util """ diff --git a/src/backend/joanie/tests/core/utils/newsletter/test_brevo.py b/src/backend/joanie/tests/core/utils/newsletter/test_brevo.py index 858aaf8a1..2124c1e4b 100644 --- a/src/backend/joanie/tests/core/utils/newsletter/test_brevo.py +++ b/src/backend/joanie/tests/core/utils/newsletter/test_brevo.py @@ -6,13 +6,13 @@ from urllib.parse import quote_plus from django.conf import settings -from django.test import TestCase, override_settings +from django.test import override_settings import responses from joanie.core.factories import UserFactory from joanie.core.utils.newsletter.brevo import Brevo -from joanie.tests.base import BaseLogMixinTestCase +from joanie.tests.base import LoggingTestCase BREVO_CONTACTS_LIST = { "contacts": [ @@ -68,7 +68,7 @@ @override_settings( BREVO_API_KEY="api-key", BREVO_COMMERCIAL_NEWSLETTER_LIST_ID="list-id" ) -class BrevoTestCase(TestCase, BaseLogMixinTestCase): +class BrevoTestCase(LoggingTestCase): """ Brevo API client test case. """ diff --git a/src/backend/joanie/tests/core/utils/newsletter/test_subscription.py b/src/backend/joanie/tests/core/utils/newsletter/test_subscription.py index a64e7c721..6673ebf75 100644 --- a/src/backend/joanie/tests/core/utils/newsletter/test_subscription.py +++ b/src/backend/joanie/tests/core/utils/newsletter/test_subscription.py @@ -3,7 +3,6 @@ from unittest.mock import patch from django.conf import settings -from django.test import TestCase from joanie.core.factories import UserFactory from joanie.core.models import User @@ -12,10 +11,10 @@ set_commercial_newsletter_subscription, synchronize_brevo_subscriptions, ) -from joanie.tests.base import BaseLogMixinTestCase +from joanie.tests.base import LoggingTestCase -class UtilsNewsletterSubscriptionTestCase(TestCase, BaseLogMixinTestCase): +class UtilsNewsletterSubscriptionTestCase(LoggingTestCase): """ Test suite for newsletter subscription utilities. """ diff --git a/src/backend/joanie/tests/edx_imports/base_test_commands_migrate.py b/src/backend/joanie/tests/edx_imports/base_test_commands_migrate.py index e33eb61d6..6b31272b6 100644 --- a/src/backend/joanie/tests/edx_imports/base_test_commands_migrate.py +++ b/src/backend/joanie/tests/edx_imports/base_test_commands_migrate.py @@ -1,9 +1,9 @@ """Base test case for the migrate command.""" -from django.test import TestCase, override_settings +from django.test import override_settings from joanie.edx_imports.edx_factories import session -from joanie.tests.base import BaseLogMixinTestCase +from joanie.tests.base import LoggingTestCase @override_settings( @@ -28,7 +28,7 @@ EDX_TIME_ZONE="UTC", TIME_ZONE="UTC", ) -class MigrateOpenEdxBaseTestCase(TestCase, BaseLogMixinTestCase): +class MigrateOpenEdxBaseTestCase(LoggingTestCase): """Base test case for the migrate command.""" maxDiff = None diff --git a/src/backend/joanie/tests/edx_imports/test_import_enrollments.py b/src/backend/joanie/tests/edx_imports/test_import_enrollments.py index 4b32be40a..adabab8ae 100644 --- a/src/backend/joanie/tests/edx_imports/test_import_enrollments.py +++ b/src/backend/joanie/tests/edx_imports/test_import_enrollments.py @@ -4,7 +4,7 @@ from os.path import dirname, join, realpath from unittest.mock import patch -from django.test import TestCase, override_settings +from django.test import override_settings from joanie.core import factories, models from joanie.core.enums import ENROLLMENT_STATE_SET @@ -12,7 +12,7 @@ from joanie.edx_imports.tasks.enrollments import import_enrollments from joanie.edx_imports.utils import extract_course_number, make_date_aware from joanie.lms_handler.api import detect_lms_from_resource_link -from joanie.tests.base import BaseLogMixinTestCase +from joanie.tests.base import LoggingTestCase LOGO_NAME = "creative_common.jpeg" with open(join(dirname(realpath(__file__)), f"images/{LOGO_NAME}"), "rb") as logo: @@ -40,7 +40,7 @@ EDX_TIME_ZONE="UTC", TIME_ZONE="UTC", ) -class EdxImportEnrollmentsTestCase(TestCase, BaseLogMixinTestCase): +class EdxImportEnrollmentsTestCase(LoggingTestCase): """Tests for the import_enrollments task.""" maxDiff = None diff --git a/src/backend/joanie/tests/edx_imports/test_populate_signatory_certificates.py b/src/backend/joanie/tests/edx_imports/test_populate_signatory_certificates.py index ab91b0608..8fd5d6ce1 100644 --- a/src/backend/joanie/tests/edx_imports/test_populate_signatory_certificates.py +++ b/src/backend/joanie/tests/edx_imports/test_populate_signatory_certificates.py @@ -8,7 +8,7 @@ from django.conf import settings from django.core.files.base import ContentFile -from django.test import TestCase, override_settings +from django.test import override_settings import responses @@ -16,7 +16,7 @@ from joanie.core.utils import file_checksum from joanie.edx_imports import edx_factories from joanie.edx_imports.tasks import populate_signatory_certificates_task -from joanie.tests.base import BaseLogMixinTestCase +from joanie.tests.base import LoggingTestCase SIGNATURE_NAME = "creative_common.jpeg" SIGNATURE_PATH = join(dirname(realpath(__file__)), f"images/{SIGNATURE_NAME}") @@ -40,7 +40,7 @@ } }, ) -class PopulateSignatoryCertificatesTestCase(TestCase, BaseLogMixinTestCase): +class PopulateSignatoryCertificatesTestCase(LoggingTestCase): """Test case for the populate_signatory_certificates task""" def test_populate_signatory_certificates_task_empty_queryset(self): diff --git a/src/backend/joanie/tests/payment/test_backend_lyra.py b/src/backend/joanie/tests/payment/test_backend_lyra.py index d4a9a20d4..27acd808b 100644 --- a/src/backend/joanie/tests/payment/test_backend_lyra.py +++ b/src/backend/joanie/tests/payment/test_backend_lyra.py @@ -45,7 +45,7 @@ TransactionFactory, ) from joanie.payment.models import CreditCard, Transaction -from joanie.tests.base import BaseLogMixinTestCase +from joanie.tests.base import LoggingTestCase from joanie.tests.payment.base_payment import BasePaymentTestCase @@ -53,7 +53,7 @@ JOANIE_CATALOG_NAME="Test Catalog", JOANIE_CATALOG_BASE_URL="https://richie.education", ) -class LyraBackendTestCase(BasePaymentTestCase, BaseLogMixinTestCase): +class LyraBackendTestCase(BasePaymentTestCase, LoggingTestCase): """Test case of the Lyra backend""" def setUp(self): diff --git a/src/backend/joanie/tests/signature/backends/lex_persona/test_handle_notification.py b/src/backend/joanie/tests/signature/backends/lex_persona/test_handle_notification.py index 180e55e30..ef46163e4 100644 --- a/src/backend/joanie/tests/signature/backends/lex_persona/test_handle_notification.py +++ b/src/backend/joanie/tests/signature/backends/lex_persona/test_handle_notification.py @@ -6,7 +6,6 @@ from django.core.exceptions import ValidationError from django.http import HttpRequest -from django.test import TestCase from django.test.utils import override_settings from django.utils import timezone as django_timezone @@ -14,7 +13,7 @@ from joanie.core import factories from joanie.signature.backends import get_signature_backend -from joanie.tests.base import BaseLogMixinTestCase +from joanie.tests.base import LoggingTestCase @override_settings( @@ -27,7 +26,7 @@ JOANIE_SIGNATURE_VALIDITY_PERIOD_IN_SECONDS=60 * 60 * 24 * 15, JOANIE_SIGNATURE_TIMEOUT=3, ) -class LexPersonaBackendHandleNotificationTestCase(TestCase, BaseLogMixinTestCase): +class LexPersonaBackendHandleNotificationTestCase(LoggingTestCase): """Test suite for Lex Persona Signature provider Backend handle_notification.""" @responses.activate diff --git a/src/backend/joanie/tests/swagger/admin-swagger.json b/src/backend/joanie/tests/swagger/admin-swagger.json index 4af1a458d..0febfb310 100644 --- a/src/backend/joanie/tests/swagger/admin-swagger.json +++ b/src/backend/joanie/tests/swagger/admin-swagger.json @@ -3192,6 +3192,43 @@ } } }, + "/api/v1.0/admin/orders/export/": { + "get": { + "operationId": "orders_export_retrieve", + "description": "Export orders to a CSV file.", + "tags": [ + "orders" + ], + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "200": { + "content": { + "text/csv": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "" + } + } + } + }, "/api/v1.0/admin/organizations/": { "get": { "operationId": "organizations_list", diff --git a/src/backend/joanie/tests/testing_utils.py b/src/backend/joanie/tests/testing_utils.py index defd8d146..2dad68b08 100644 --- a/src/backend/joanie/tests/testing_utils.py +++ b/src/backend/joanie/tests/testing_utils.py @@ -1,10 +1,24 @@ +# ruff: noqa: S311, PLR0913, PLR0915 """Test utils module.""" +import random import sys from importlib import reload from django.conf import settings +from django.core.exceptions import ValidationError from django.urls import clear_url_caches +from django.utils import timezone as django_timezone +from django.utils import translation + +from joanie.core import enums, factories, models +from joanie.core.models import CourseState +from joanie.demo.defaults import NB_DEV_OBJECTS +from joanie.payment import factories as payment_factories + +OPENEDX_COURSE_RUN_URI = ( + "http://openedx.test/courses/course-v1:edx+{course:s}+{course_run:s}/course" +) def reload_urlconf(): @@ -20,3 +34,600 @@ def reload_urlconf(): reload(sys.modules[settings.ROOT_URLCONF]) clear_url_caches() # Otherwise, the module will be loaded normally by Django + + +class Demo: + """Create some fake data (products, courses and course runs)""" + + def __init__(self, log=lambda x: None): + """Initialize the demo object.""" + self.log = log + + def get_random_languages(self): + """ + Return a set of random languages. + global_settings.languages is not consistent between django version so we do + want to use ALL_LANGUAGES to set course run languages to prevent synchronization + issues between Joanie & Richie. + """ + return random.sample(["de", "en", "fr", "pt"], random.randint(1, 4)) + + def create_course(self, user, organization, batch_size=1, with_course_runs=False): + """Create courses for given user and organization.""" + + if batch_size == 1: + course = factories.CourseFactory( + organizations=[organization], + users=[[user, enums.OWNER]], + ) + if with_course_runs: + factories.CourseRunFactory.create_batch( + 2, + is_listed=True, + state=CourseState.ONGOING_OPEN, + languages=self.get_random_languages(), + course=course, + ) + + return course + + courses = factories.CourseFactory.create_batch( + batch_size, organizations=[organization], users=[[user, enums.OWNER]] + ) + + if with_course_runs: + for course in courses: + factories.CourseRunFactory.create_batch( + 2, + course=course, + is_listed=True, + state=CourseState.ONGOING_OPEN, + languages=self.get_random_languages(), + ) + return courses + + def create_product_credential( + self, user, organization, contract_definition=None, batch_size=1 + ): + """Create batch or products for given user and organization.""" + if batch_size == 1: + course = factories.CourseFactory( + organizations=[organization], + users=[[user, enums.OWNER]], + ) + product = factories.ProductFactory( + type=enums.PRODUCT_TYPE_CREDENTIAL, + courses=[course], + contract_definition=contract_definition, + ) + target_course_list = factories.CourseFactory.create_batch( + 2, + organizations=[organization], + users=[[user, enums.OWNER]], + ) + + for target_course in target_course_list: + factories.CourseRunFactory( + course=target_course, + is_listed=True, + state=CourseState.ONGOING_OPEN, + languages=self.get_random_languages(), + resource_link=OPENEDX_COURSE_RUN_URI.format( + course=target_course.code, course_run="{course.title}_run1" + ), + ) + factories.CourseRunFactory( + course=target_course, + is_listed=True, + state=CourseState.ONGOING_OPEN, + languages=self.get_random_languages(), + resource_link=OPENEDX_COURSE_RUN_URI.format( + course=target_course.code, course_run="{course.title}_run2" + ), + ) + factories.ProductTargetCourseRelationFactory( + course=target_course, product=product + ) + self.log(f"Successfully create product credential on course {course.code}") + return product + return [ + self.create_product_credential(user, organization) + for i in range(batch_size) + ] + + def create_product_certificate(self, user, organization, batch_size=1): + """Create batch or products certificate for given user and organization.""" + if batch_size == 1: + course = factories.CourseFactory( + organizations=[organization], + users=[[user, enums.OWNER]], + ) + factories.CourseRunFactory( + course=course, + is_listed=True, + state=CourseState.ONGOING_OPEN, + languages=self.get_random_languages(), + resource_link=OPENEDX_COURSE_RUN_URI.format( + course=course.code, course_run="{course.title}_run1" + ), + ) + factories.CourseRunFactory( + course=course, + is_listed=True, + state=CourseState.ONGOING_OPEN, + languages=self.get_random_languages(), + resource_link=OPENEDX_COURSE_RUN_URI.format( + course=course.code, course_run="{course.title}_run2" + ), + ) + product = factories.ProductFactory( + type=enums.PRODUCT_TYPE_CERTIFICATE, + courses=[course], + contract_definition=None, + ) + self.log(f"Successfully create product certificate on course {course.code}") + return product + return [ + self.create_product_certificate(user, organization) + for i in range(batch_size) + ] + + def create_product_certificate_enrollment(self, user, course_user, organization): + """Create a product certificate and it's enrollment.""" + product = self.create_product_certificate(course_user, organization) + course = product.courses.first() + return factories.EnrollmentFactory( + user=user, + course_run=course.course_runs.first(), + is_active=True, + state=enums.ENROLLMENT_STATE_SET, + ) + + def create_product_purchased( + self, + user, + course_user, + organization, + product_type=enums.PRODUCT_TYPE_CERTIFICATE, + order_status=enums.ORDER_STATE_COMPLETED, + contract_definition=None, + product=None, + ): # pylint: disable=too-many-arguments, too-many-positional-arguments + """Create a product, it's enrollment and it's order.""" + if not product: + if product_type == enums.PRODUCT_TYPE_CERTIFICATE: + product = self.create_product_certificate(course_user, organization) + elif product_type == enums.PRODUCT_TYPE_CREDENTIAL: + product = self.create_product_credential( + course_user, organization, contract_definition + ) + else: + raise ValueError(f"Given product_type ({product_type}) is not allowed.") + + course = product.courses.first() + + order = factories.OrderFactory( + course=None if product_type == enums.PRODUCT_TYPE_CERTIFICATE else course, + enrollment=factories.EnrollmentFactory( + user=user, + course_run=course.course_runs.first(), + is_active=True, + state=enums.ENROLLMENT_STATE_SET, + ) + if product_type == enums.PRODUCT_TYPE_CERTIFICATE + else None, + owner=user, + product=product, + state=order_status, + ) + + return order + + def create_product_purchased_with_certificate( + self, user, course_user, organization, options + ): + """ + Create a product, it's enrollment and it's order. + Also create the order's linked certificate. + """ + order = self.create_product_purchased( + user, + course_user, + organization, + options["product_type"], + enums.ORDER_STATE_COMPLETED, + options["contract_definition"] + if "contract_definition" in options + else None, + ) + return factories.OrderCertificateFactory(order=order) + + def create_order_with_installment_payment_failed( + self, user, course_user, organization + ): + """ + Create an order with an installment payment failed. + """ + + order = self.create_product_purchased( + user, + course_user, + organization, + enums.PRODUCT_TYPE_CREDENTIAL, + enums.ORDER_STATE_PENDING, + factories.ContractDefinitionFactory(), + ) + + factories.ContractFactory( + order=order, + definition=order.product.contract_definition, + submitted_for_signature_on=django_timezone.now(), + student_signed_on=django_timezone.now(), + ) + + order.generate_schedule() + installment = order.payment_schedule[0] + order.set_installment_refused(installment["id"]) + order.save() + + def create_enrollment_certificate(self, user, course_user, organization): + """create an enrollment and it's linked certificate.""" + course = self.create_course(course_user, organization, 1, True) + factories.EnrollmentCertificateFactory( + enrollment__user=user, + enrollment__course_run=course.course_runs.first(), + enrollment__is_active=True, + enrollment__state=enums.ENROLLMENT_STATE_SET, + organization=organization, + ) + + def generate(self): # pylint: disable=too-many-locals,too-many-statements + """Generate fake data.""" + translation.activate("en-us") + + # Create an organization + other_owners = factories.UserFactory.create_batch( + 5, + first_name="Other", + last_name="Owner", + ) + email = settings.DEVELOPER_EMAIL + email_user, email_domain = email.split("@") + + organization_owner = factories.UserFactory( + username="organization_owner", + email=email_user + "+organization_owner@" + email_domain, + first_name="Orga", + last_name="Owner", + ) + organization = factories.OrganizationFactory( + title="The school of glory", + # Give access to admin user + users=[[organization_owner, enums.OWNER]] + + [[owner, enums.OWNER] for owner in other_owners], + ) + + # Add one credit card to student user + student_user = factories.UserFactory( + username="student_user", + email=email_user + "+student_user@" + email_domain, + first_name="Étudiant", + ) + payment_factories.CreditCardFactory(owner=student_user) + factories.UserAddressFactory(owner=student_user) + + second_student_user = factories.UserFactory( + username="second_student_user", + email=email_user + "+second_student_user@" + email_domain, + first_name="Étudiant 002", + ) + payment_factories.CreditCardFactory(owner=second_student_user) + factories.UserAddressFactory(owner=second_student_user) + + # First create a course product to learn how to become a botanist + # 1/ some course runs are required to become a botanist + bases_of_botany_run1 = factories.CourseRunFactory( + title="Bases of botany", + resource_link=OPENEDX_COURSE_RUN_URI.format( + course="00001", course_run="BasesOfBotany_run1" + ), + # Give access to organization owner user + course__users=[[organization_owner, enums.OWNER]], + course__organizations=[organization], + languages=self.get_random_languages(), + state=CourseState.ONGOING_OPEN, + ) + factories.CourseRunFactory( + title="Bases of botany", + course=bases_of_botany_run1.course, + languages=self.get_random_languages(), + resource_link=OPENEDX_COURSE_RUN_URI.format( + course="00001", course_run="BasesOfBotany_run2" + ), + state=CourseState.ONGOING_OPEN, + ) + how_to_make_a_herbarium_run1 = factories.CourseRunFactory( + title="How to make a herbarium", + resource_link=OPENEDX_COURSE_RUN_URI.format( + course="00002", course_run="HowToMakeHerbarium_run1" + ), + # Give access to organization owner user + course__users=[[organization_owner, enums.OWNER]], + course__organizations=[organization], + languages=self.get_random_languages(), + state=CourseState.ONGOING_OPEN, + ) + factories.CourseRunFactory( + title="How to make a herbarium", + course=how_to_make_a_herbarium_run1.course, + languages=self.get_random_languages(), + resource_link=OPENEDX_COURSE_RUN_URI.format( + course="00002", course_run="HowToMakeHerbarium_run2" + ), + state=CourseState.ONGOING_OPEN, + ) + scientific_publication_analysis_run1 = factories.CourseRunFactory( + title="Scientific publication analysis", + languages=self.get_random_languages(), + resource_link=OPENEDX_COURSE_RUN_URI.format( + course="00003", course_run="ScientificPublicationAnalysis_run1" + ), + state=CourseState.ONGOING_OPEN, + ) + factories.CourseRunFactory( + title="Scientific publication analysis", + course=scientific_publication_analysis_run1.course, + languages=self.get_random_languages(), + resource_link=OPENEDX_COURSE_RUN_URI.format( + course="00003", course_run="ScientificPublicationAnalysis_run2" + ), + state=CourseState.ONGOING_OPEN, + ) + + # Give courses access to admin user + + credential_courses = [ + bases_of_botany_run1.course, + how_to_make_a_herbarium_run1.course, + scientific_publication_analysis_run1.course, + ] + + # Now create a course product to learn how to become a botanist and get a certificate + # 1/ Create the credential Product linked to the botany Course + product = factories.ProductFactory( + type=enums.PRODUCT_TYPE_CREDENTIAL, + # organization=[organization], + title="Become a certified botanist", + courses=[factories.CourseFactory(organizations=[organization])], + target_courses=credential_courses, + certificate_definition=factories.CertificateDefinitionFactory( + title="Botanist Certification", + name="Become a certified botanist certificate", + ), + ) + self.log(f'Successfully create "{product.title}" product') + + # We need some pagination going on, let's create few more courses and products + self.create_course( + organization_owner, + organization, + batch_size=NB_DEV_OBJECTS["course"], + with_course_runs=True, + ) + self.log(f"Successfully create {NB_DEV_OBJECTS['course']} fake courses") + + self.create_product_credential( + organization_owner, + organization, + batch_size=NB_DEV_OBJECTS["product_credential"], + ) + self.log( + f"Successfully create {NB_DEV_OBJECTS['product_credential']} \ + fake PRODUCT_CREDENTIAL" + ) + + self.create_product_certificate( + organization_owner, + organization, + batch_size=NB_DEV_OBJECTS["product_certificate"], + ) + self.log( + f"Successfully create {NB_DEV_OBJECTS['product_certificate']} \ + fake PRODUCT_CERTIFICATE" + ) + + # Enrollments and orders + self.create_product_certificate_enrollment( + student_user, organization_owner, organization + ) + self.log( + "Successfully create an enrollment for a course with a PRODUCT_CERTIFICATE" + ) + + # Order for a PRODUCT_CERTIFICATE + self.create_product_purchased( + student_user, + organization_owner, + organization, + enums.PRODUCT_TYPE_CERTIFICATE, + ) + self.log("Successfully create an order for a PRODUCT_CERTIFICATE") + + # Order for a PRODUCT_CERTIFICATE with a generated certificate + self.create_product_purchased_with_certificate( + student_user, + organization_owner, + organization, + options={ + "product_type": enums.PRODUCT_TYPE_CERTIFICATE, + }, + ) + self.log( + "Successfully create an order for a PRODUCT_CERTIFICATE \ + with a generated certificate" + ) + + # Order for a PRODUCT_CREDENTIAL with a generated certificate + self.create_product_purchased_with_certificate( + student_user, + organization_owner, + organization, + options={ + "product_type": enums.PRODUCT_TYPE_CREDENTIAL, + }, + ) + self.log( + "Successfully create an order for a PRODUCT_CREDENTIAL with a generated certificate" + ) + + # Order for a PRODUCT_CREDENTIAL with an installment payment failed + self.create_order_with_installment_payment_failed( + student_user, + organization_owner, + organization, + ) + self.log( + "Successfully create an order for a PRODUCT_CREDENTIAL " + "with an installment payment failed" + ) + + # Order for a PRODUCT_CREDENTIAL with a unsigned contract + order = self.create_product_purchased( + student_user, + organization_owner, + organization, + enums.PRODUCT_TYPE_CREDENTIAL, + enums.ORDER_STATE_COMPLETED, + factories.ContractDefinitionFactory(), + ) + factories.ContractFactory( + order=order, + definition=order.product.contract_definition, + student_signed_on=None, + ) + self.log( + "Successfully create an order for a PRODUCT_CREDENTIAL with an unsigned contract" + ) + + # Order for a PRODUCT_CREDENTIAL with a learner signed contract + learner_signed_order = self.create_product_purchased( + student_user, + organization_owner, + organization, + enums.PRODUCT_TYPE_CREDENTIAL, + enums.ORDER_STATE_COMPLETED, + factories.ContractDefinitionFactory(), + ) + + factories.ContractFactory( + order=learner_signed_order, + definition=learner_signed_order.product.contract_definition, + submitted_for_signature_on=django_timezone.now(), + student_signed_on=django_timezone.now(), + ) + + # create a second purchase with a learner signed contract for the same PRODUCT_CREDENTIAL + order = self.create_product_purchased( + second_student_user, + organization_owner, + organization, + enums.PRODUCT_TYPE_CREDENTIAL, + enums.ORDER_STATE_COMPLETED, + factories.ContractDefinitionFactory(), + product=learner_signed_order.product, + ) + factories.ContractFactory( + order=order, + definition=order.product.contract_definition, + submitted_for_signature_on=django_timezone.now(), + student_signed_on=django_timezone.now(), + ) + + self.log( + f"Successfully create an order for a PRODUCT_CREDENTIAL \ + with a contract signed by a learner, organization.uuid: {organization.id}", + ) + + # Order for a PRODUCT_CREDENTIAL with a fully signed contract + order = self.create_product_purchased( + student_user, + organization_owner, + organization, + enums.PRODUCT_TYPE_CREDENTIAL, + enums.ORDER_STATE_COMPLETED, + factories.ContractDefinitionFactory(), + ) + + factories.ContractFactory( + order=order, + definition=order.product.contract_definition, + student_signed_on=django_timezone.now(), + organization_signed_on=django_timezone.now(), + organization_signatory=organization_owner, + ) + + self.log( + f"Successfully create an order for a PRODUCT_CREDENTIAL \ + with a fully signed contract, organization.uuid: {organization.id}", + ) + + # Enrollment with a certificate + self.create_enrollment_certificate( + student_user, organization_owner, organization + ) + self.log("Successfully create an enrollment with a generated certificate") + + # Order for all existing status on PRODUCT_CREDENTIAL + for order_status, _ in enums.ORDER_STATE_CHOICES: + self.create_product_purchased( + student_user, + organization_owner, + organization, + enums.PRODUCT_TYPE_CREDENTIAL, + order_status, + ) + + # Set organization owner for each organization + for organization in models.Organization.objects.all(): + models.OrganizationAccess.objects.get_or_create( + user=organization_owner, + organization=organization, + role=enums.OWNER, + ) + for other_owner in other_owners: + models.OrganizationAccess.objects.get_or_create( + user=other_owner, + organization=organization, + role=enums.OWNER, + ) + self.log("Successfully set organization owner access for each organization") + + self.log("Successfully fake data creation") + + for order in models.Order.objects.all(): + try: + order.generate_schedule() + except ValidationError: + continue + + if order.state == enums.ORDER_STATE_COMPLETED: + for installment in order.payment_schedule: + order.set_installment_paid(installment["id"]) + + if order.state == enums.ORDER_STATE_PENDING_PAYMENT: + order.set_installment_paid(order.payment_schedule[0]["id"]) + + if order.state == enums.ORDER_STATE_FAILED_PAYMENT: + order.set_installment_refused(order.payment_schedule[0]["id"]) + + if order.state == enums.ORDER_STATE_CANCELED: + order.cancel_remaining_installments() + + if order.state == enums.ORDER_STATE_REFUNDING: + order.set_installment_paid(order.payment_schedule[0]["id"]) + order.cancel_remaining_installments() + + if order.state == enums.ORDER_STATE_REFUNDED: + order.set_installment_paid(order.payment_schedule[0]["id"]) + order.set_installment_refunded(order.payment_schedule[0]["id"]) + order.cancel_remaining_installments() diff --git a/src/frontend/admin/playwright-ct.config.ts b/src/frontend/admin/playwright-ct.config.ts index 45372a226..735b582ed 100644 --- a/src/frontend/admin/playwright-ct.config.ts +++ b/src/frontend/admin/playwright-ct.config.ts @@ -19,7 +19,7 @@ export default defineConfig({ /* Opt out of parallel tests on CI. */ workers: process.env.CI ? 1 : undefined, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: "html", + reporter: "list", testMatch: "**/*.spec.e2e.?(c|m)[jt]s?(x)", /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ diff --git a/src/frontend/admin/playwright.config.ts b/src/frontend/admin/playwright.config.ts index 906744ba6..e463c3cc8 100644 --- a/src/frontend/admin/playwright.config.ts +++ b/src/frontend/admin/playwright.config.ts @@ -21,11 +21,11 @@ export default defineConfig({ /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, /* Retry on CI only */ - retries: process.env.CI ? 2 : 0, + retries: process.env.CI ? 4 : 2, /* Opt out of parallel tests on CI. */ workers: process.env.CI ? 1 : undefined, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: "html", + reporter: "list", testMatch: "**/*.test.e2e.?(c|m)[jt]s?(x)", /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { diff --git a/src/frontend/admin/src/components/presentational/filters/SearchFilters.tsx b/src/frontend/admin/src/components/presentational/filters/SearchFilters.tsx index 4dc72cfb9..b169c6e41 100644 --- a/src/frontend/admin/src/components/presentational/filters/SearchFilters.tsx +++ b/src/frontend/admin/src/components/presentational/filters/SearchFilters.tsx @@ -12,7 +12,7 @@ import CircularProgress from "@mui/material/CircularProgress"; import SearchOutlined from "@mui/icons-material/SearchOutlined"; import TextField from "@mui/material/TextField"; import { useDebouncedCallback } from "use-debounce"; -import { FilterList } from "@mui/icons-material"; +import { FileDownload, FilterList } from "@mui/icons-material"; import Chip from "@mui/material/Chip"; import Stack from "@mui/material/Stack"; import Box from "@mui/material/Box"; @@ -32,6 +32,11 @@ const messages = defineMessages({ defaultMessage: "Filters", description: "Label for the filters button", }, + exportLabelButton: { + id: "components.presentational.filters.searchFilters.exportLabelButton", + defaultMessage: "Export", + description: "Label for the export button", + }, clear: { id: "components.presentational.filters.searchFilters.clear", defaultMessage: "Clear", @@ -74,6 +79,7 @@ export type SearchFilterProps = MandatorySearchFilterProps & { addChip: (chip: FilterChip) => void, removeChip: (chipName: string) => void, ) => ReactNode; + export?: () => void; }; export function SearchFilters(props: PropsWithChildren) { @@ -181,6 +187,19 @@ export function SearchFilters(props: PropsWithChildren) { )} + {props.export && ( + + )} {props.renderContent && ( diff --git a/src/frontend/admin/src/components/templates/orders/filters/OrderFilters.tsx b/src/frontend/admin/src/components/templates/orders/filters/OrderFilters.tsx index de98baed7..677b6df34 100644 --- a/src/frontend/admin/src/components/templates/orders/filters/OrderFilters.tsx +++ b/src/frontend/admin/src/components/templates/orders/filters/OrderFilters.tsx @@ -20,7 +20,7 @@ import { OrganizationSearch } from "@/components/templates/organizations/inputs/ import { UserSearch } from "@/components/templates/users/inputs/search/UserSearch"; import { RHFOrderState } from "@/components/templates/orders/inputs/RHFOrderState"; import { entitiesInputLabel } from "@/translations/common/entitiesInputLabel"; -import { OrderListQuery } from "@/hooks/useOrders/useOrders"; +import { OrderListQuery, useOrders } from "@/hooks/useOrders/useOrders"; const messages = defineMessages({ searchPlaceholder: { @@ -49,6 +49,7 @@ type Props = MandatorySearchFilterProps & { export function OrderFilters({ onFilter, ...searchFilterProps }: Props) { const intl = useIntl(); + const ordersQuery = useOrders({}, { enabled: false }); const getDefaultValues = () => { return { @@ -162,6 +163,11 @@ export function OrderFilters({ onFilter, ...searchFilterProps }: Props) { )} + export={() => { + ordersQuery.methods.export({ + currentFilters: formValuesToFilterValues(methods.getValues()), + }); + }} /> ); } diff --git a/src/frontend/admin/src/hooks/useOrders/useOrders.tsx b/src/frontend/admin/src/hooks/useOrders/useOrders.tsx index 762a2d618..3bf04f88a 100644 --- a/src/frontend/admin/src/hooks/useOrders/useOrders.tsx +++ b/src/frontend/admin/src/hooks/useOrders/useOrders.tsx @@ -57,6 +57,13 @@ export const useOrdersMessages = defineMessages({ description: "Error message shown to the user when no order matches.", defaultMessage: "Cannot find the order", }, + errorExport: { + id: "hooks.useOrders.errorExport", + description: + "Error message shown to the user when order export request fails.", + defaultMessage: + "An error occurred while exporting orders. Please retry later.", + }, }); export type OrderListQuery = ResourcesQuery & { @@ -97,6 +104,9 @@ const orderProps: UseResourcesProps = { refund: async (id: string) => { return OrderRepository.refund(id); }, + export: async (filters) => { + return OrderRepository.export(filters); + }, }), session: true, messages: useOrdersMessages, @@ -148,6 +158,17 @@ export const useOrders = ( ); }, }).mutate, + export: mutation({ + mutationFn: async (data: { currentFilters: OrderListQuery }) => { + return OrderRepository.export(data.currentFilters); + }, + onError: (error: HttpError) => { + custom.methods.setError( + error.data?.details ?? + intl.formatMessage(useOrdersMessages.errorExport), + ); + }, + }).mutate, }, }; }; diff --git a/src/frontend/admin/src/hooks/useResources/types.ts b/src/frontend/admin/src/hooks/useResources/types.ts index 1eeef82f1..ab72f6dbb 100644 --- a/src/frontend/admin/src/hooks/useResources/types.ts +++ b/src/frontend/admin/src/hooks/useResources/types.ts @@ -25,6 +25,7 @@ export interface ApiResourceInterface< create?: (payload: any) => Promise; update?: (payload: any) => Promise; delete?: (id: TData["id"]) => Promise; + export?: (filters?: TResourceQuery) => Promise; } export const useLocalizedQueryKey = (queryKey: QueryKey) => queryKey; diff --git a/src/frontend/admin/src/services/repositories/orders/OrderRepository.ts b/src/frontend/admin/src/services/repositories/orders/OrderRepository.ts index ca8d5aecb..e04ab6850 100644 --- a/src/frontend/admin/src/services/repositories/orders/OrderRepository.ts +++ b/src/frontend/admin/src/services/repositories/orders/OrderRepository.ts @@ -2,7 +2,11 @@ import queryString from "query-string"; import { Order, OrderQuery } from "@/services/api/models/Order"; import { Maybe } from "@/types/utils"; import { ResourcesQuery } from "@/hooks/useResources/types"; -import { checkStatus, fetchApi } from "@/services/http/HttpService"; +import { + buildApiUrl, + checkStatus, + fetchApi, +} from "@/services/http/HttpService"; import { PaginatedResponse } from "@/types/api"; export const orderRoutes = { @@ -11,6 +15,7 @@ export const orderRoutes = { delete: (id: string) => `/orders/${id}/`, generateCertificate: (id: string) => `/orders/${id}/generate_certificate/`, refund: (id: string) => `/orders/${id}/refund/`, + export: (params: string = "") => `/orders/export/${params}`, }; export class OrderRepository { @@ -45,4 +50,11 @@ export class OrderRepository { const url = orderRoutes.refund(id); return fetchApi(url, { method: "POST" }).then(checkStatus); } + + static export(filters: Maybe): void { + const url = orderRoutes.export( + filters ? `?${queryString.stringify(filters)}` : "", + ); + window.open(buildApiUrl(url)); + } } diff --git a/src/frontend/admin/src/tests/orders/orders-filters.test.e2e.ts b/src/frontend/admin/src/tests/orders/orders-filters.test.e2e.ts index b3c560039..7e64a3916 100644 --- a/src/frontend/admin/src/tests/orders/orders-filters.test.e2e.ts +++ b/src/frontend/admin/src/tests/orders/orders-filters.test.e2e.ts @@ -31,6 +31,19 @@ test.describe("Order filters", () => { } }); + const context = page.context(); + const exportUrl = "http://localhost:8071/api/v1.0/admin/orders/export/"; + const exportQueryParamsRegex = getUrlCatchSearchParamsRegex(exportUrl); + await context.unroute(exportQueryParamsRegex); + await context.route(exportQueryParamsRegex, async (route, request) => { + if (request.method() === "GET") { + await route.fulfill({ + contentType: "application/csv", + body: "data", + }); + } + }); + await mockPlaywrightCrud({ data: store.products, routeUrl: "http://localhost:8071/api/v1.0/admin/products/", @@ -124,4 +137,25 @@ test.describe("Order filters", () => { page.getByRole("button", { name: `Owner: ${store.users[0].username}` }), ).toBeVisible(); }); + + test("Test export with filters", async ({ page }) => { + await page.goto(PATH_ADMIN.orders.list); + + await page.getByRole("button", { name: "Filters" }).click(); + await page + .getByTestId("select-order-state-filter") + .getByLabel("State") + .click(); + await page.getByRole("option", { name: "Completed" }).click(); + await page.getByLabel("close").click(); + + await page.getByRole("button", { name: "Export" }).click(); + + page.on("popup", async (popup) => { + await popup.waitForLoadState(); + expect(popup.url()).toBe( + "http://localhost:8071/api/v1.0/admin/orders/export/?state=completed", + ); + }); + }); }); diff --git a/src/frontend/admin/tsconfig.json b/src/frontend/admin/tsconfig.json index b68b48147..f9f734a96 100644 --- a/src/frontend/admin/tsconfig.json +++ b/src/frontend/admin/tsconfig.json @@ -16,9 +16,9 @@ "incremental": true, "baseUrl": ".", "paths": { - "@/*": ["./src/*"], - }, + "@/*": ["./src/*"] + } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], - "exclude": ["node_modules"], + "exclude": ["node_modules"] }