diff --git a/CHANGELOG.md b/CHANGELOG.md index 74526f0ec..8ed794b7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,9 +14,14 @@ and this project adheres to ### Changed +- Remove `generate_certificates` action in django admin views - Remove `owner` and `is_main` in CreditCard model permanently - Remove `is_active` on order group client serializer +## Fixed + +- BO: generate certificates for orders with product of type certificate + ## [2.16.0] - 2025-02-13 ### Added diff --git a/src/backend/joanie/core/admin.py b/src/backend/joanie/core/admin.py index b965bfb25..33985a99d 100644 --- a/src/backend/joanie/core/admin.py +++ b/src/backend/joanie/core/admin.py @@ -6,21 +6,18 @@ from django.contrib.admin.options import csrf_protect_m from django.contrib.auth import admin as auth_admin from django.contrib.sites.models import Site -from django.http import HttpResponseRedirect -from django.urls import re_path, reverse +from django.urls import reverse from django.utils.html import format_html from django.utils.translation import gettext_lazy as _ from django.utils.translation import ngettext_lazy from admin_auto_filters.filters import AutocompleteFilter from adminsortable2.admin import SortableAdminBase, SortableInlineAdminMixin -from django_object_actions import DjangoObjectActions, takes_instance_or_queryset +from django_object_actions import DjangoObjectActions from parler.admin import TranslatableAdmin, TranslatableStackedInline -from joanie.core import forms, helpers, models -from joanie.core.enums import PRODUCT_TYPE_CERTIFICATE_ALLOWED +from joanie.core import forms, models -ACTION_NAME_GENERATE_CERTIFICATES = "generate_certificates" ACTION_NAME_CANCEL = "cancel" @@ -275,9 +272,7 @@ class CourseCourseRunsInline(admin.TabularInline): class CourseAdmin(DjangoObjectActions, TranslatableAdmin): """Admin class for the Course model""" - actions = (ACTION_NAME_GENERATE_CERTIFICATES,) autocomplete_fields = ["organizations"] - change_actions = (ACTION_NAME_GENERATE_CERTIFICATES,) change_form_template = "joanie/admin/translatable_change_form_with_actions.html" list_display = ("code", "title", "state") readonly_fields = ("course_runs",) @@ -305,18 +300,6 @@ class CourseAdmin(DjangoObjectActions, TranslatableAdmin): ) search_fields = ["code", "translations__title"] - @takes_instance_or_queryset - def generate_certificates(self, request, queryset): # pylint: disable no-self-use - """ - Custom action to generate certificates for a collection of courses - passed as a queryset - """ - certificate_generated_count = helpers.generate_certificates_for_orders( - models.Order.objects.filter(course__in=queryset) - ) - - summarize_certification_to_user(request, certificate_generated_count) - @admin.register(models.CourseRun) class CourseRunAdmin(TranslatableAdmin): @@ -479,67 +462,8 @@ class ProductAdmin( "id", "related_courses", ) - actions = (ACTION_NAME_GENERATE_CERTIFICATES,) - change_actions = (ACTION_NAME_GENERATE_CERTIFICATES,) search_fields = ["translations__title"] - def get_change_actions(self, request, object_id, form_url): - """ - Remove the generate_certificates action from list of actions - if the product instance is not certifying - """ - actions = super().get_change_actions(request, object_id, form_url) - actions = list(actions) - - if not self.model.objects.filter( - pk=object_id, type__in=PRODUCT_TYPE_CERTIFICATE_ALLOWED - ).exists(): - actions.remove(ACTION_NAME_GENERATE_CERTIFICATES) - - return actions - - def get_urls(self): - """ - Add url to trigger certificate generation for a course - product couple. - """ - url_patterns = super().get_urls() - - return [ - re_path( - r"^(?P.+)/generate-certificates/(?P.+)/$", - self.admin_site.admin_view(self.generate_certificates_for_course), - name=ACTION_NAME_GENERATE_CERTIFICATES, - ) - ] + url_patterns - - @takes_instance_or_queryset - def generate_certificates(self, request, queryset): # pylint: disable=no-self-use - """ - Custom action to generate certificates for a collection of products - passed as a queryset - """ - certificate_generated_count = helpers.generate_certificates_for_orders( - models.Order.objects.filter(product__in=queryset) - ) - - summarize_certification_to_user(request, certificate_generated_count) - - def generate_certificates_for_course(self, request, product_id, course_code): # pylint: disable=no-self-use - """ - A custom action to generate certificates for a course - product couple. - """ - certificate_generated_count = helpers.generate_certificates_for_orders( - models.Order.objects.filter( - product__id=product_id, course__code=course_code - ) - ) - - summarize_certification_to_user(request, certificate_generated_count) - - return HttpResponseRedirect( - reverse("admin:core_product_change", args=(product_id,)) - ) - @admin.display(description="Related courses") def related_courses(self, obj): # pylint: disable=no-self-use """ @@ -553,7 +477,6 @@ def get_related_courses_as_html(obj): # pylint: disable=no-self-use Get the html representation of the product's related courses """ related_courses = obj.courses.all() - is_certifying = obj.type in PRODUCT_TYPE_CERTIFICATE_ALLOWED if related_courses: items = [] @@ -562,26 +485,11 @@ def get_related_courses_as_html(obj): # pylint: disable=no-self-use "admin:core_course_change", args=(course.id,), ) - raw_html = ( '
  • ' f"{course.code} | {course.title}" + "
  • " ) - - if is_certifying: - # Add a button to generate certificate - generate_certificates_url = reverse( - f"admin:{ACTION_NAME_GENERATE_CERTIFICATES}", - kwargs={"product_id": obj.id, "course_code": course.code}, - ) - - raw_html += ( - f'' # pylint: disable=line-too-long - f"{_('Generate certificates')}" - "" - ) - - raw_html += "" items.append(raw_html) return format_html(f"") @@ -601,9 +509,8 @@ class DiscountAdmin(admin.ModelAdmin): class OrderAdmin(DjangoObjectActions, admin.ModelAdmin): """Admin class for the Order model""" - actions = (ACTION_NAME_CANCEL, ACTION_NAME_GENERATE_CERTIFICATES) + actions = (ACTION_NAME_CANCEL,) autocomplete_fields = ["course", "enrollment", "organization", "owner", "product"] - change_actions = (ACTION_NAME_GENERATE_CERTIFICATES,) list_display = ("id", "created_on", "organization", "owner", "product", "state") list_filter = [OwnerFilter, OrganizationFilter, ProductFilter, "state"] readonly_fields = ( @@ -621,15 +528,6 @@ def cancel(self, request, queryset): # pylint: disable=no-self-use for order in queryset: order.flow.cancel() - @takes_instance_or_queryset - def generate_certificates(self, request, queryset): # pylint: disable=no-self-use - """ - Custom action to launch generate_certificates management commands - over the order selected - """ - certificate_generated_count = helpers.generate_certificates_for_orders(queryset) - summarize_certification_to_user(request, certificate_generated_count) - def invoice(self, obj): # pylint: disable=no-self-use """Retrieve the root invoice related to the order.""" invoice = obj.invoices.get(parent__isnull=True) diff --git a/src/backend/joanie/core/utils/course_product_relation.py b/src/backend/joanie/core/utils/course_product_relation.py index aa5cb5513..b75596243 100644 --- a/src/backend/joanie/core/utils/course_product_relation.py +++ b/src/backend/joanie/core/utils/course_product_relation.py @@ -1,17 +1,23 @@ """Utility methods to get all orders and/or certificates from a course product relation.""" +from django.db.models import Q + from joanie.core.enums import ORDER_STATE_COMPLETED, PRODUCT_TYPE_CERTIFICATE_ALLOWED from joanie.core.models import Certificate, Order def get_orders(course_product_relation): """ - Returns a list of all validated orders ids for a course product relation. + Returns a list of all completed orders ids for a course product relation. """ return [ str(order_id) for order_id in Order.objects.filter( - course=course_product_relation.course, + Q(course=course_product_relation.course, enrollment__isnull=True) + | Q( + course__isnull=True, + enrollment__course_run__course=course_product_relation.course, + ), product=course_product_relation.product, product__type__in=PRODUCT_TYPE_CERTIFICATE_ALLOWED, state=ORDER_STATE_COMPLETED, @@ -27,8 +33,12 @@ def get_generated_certificates(course_product_relation): Return certificates that were published for a course product relation. """ return Certificate.objects.filter( + Q(order__course=course_product_relation.course, order__enrollment__isnull=True) + | Q( + order__course__isnull=True, + order__enrollment__course_run__course=course_product_relation.course, + ), order__product=course_product_relation.product, - order__course=course_product_relation.course, order__certificate__isnull=False, order__state=ORDER_STATE_COMPLETED, ) diff --git a/src/backend/joanie/tests/core/api/admin/course_product_relations/test_generate_certificates.py b/src/backend/joanie/tests/core/api/admin/course_product_relations/test_generate_certificates.py index 238425453..3c5fb2084 100644 --- a/src/backend/joanie/tests/core/api/admin/course_product_relations/test_generate_certificates.py +++ b/src/backend/joanie/tests/core/api/admin/course_product_relations/test_generate_certificates.py @@ -11,7 +11,7 @@ from django.utils import timezone from joanie.core import enums, factories -from joanie.core.models import Certificate, CourseProductRelation +from joanie.core.models import Certificate, CourseProductRelation, CourseState from joanie.lms_handler.backends.dummy import DummyLMSBackend @@ -683,3 +683,75 @@ def test_api_admin_course_product_relation_check_certificates_generation_complet # Verify that certificates were generated for order in orders: self.assertTrue(Certificate.objects.filter(order=order).exists()) + + @mock.patch("joanie.core.api.admin.generate_certificates_task") + @mock.patch( + "joanie.lms_handler.backends.dummy.DummyLMSBackend.get_grades", + return_value={"passed": True}, + ) + def test_api_admin_course_product_relation_generate_certificate_product_certificate( + self, _mock_get_grades, mock_generate_certificates_task + ): + """ + Authenticated admin user should be able to generate certificate for order with products + of type certificate. The task should be called with the orders that are eligible to get + their certificate generated. + """ + admin = factories.UserFactory(is_staff=True, is_superuser=True) + self.client.login(username=admin.username, password="password") + + course_run = factories.CourseRunFactory( + is_listed=True, state=CourseState.ONGOING_OPEN + ) + enrollments = factories.EnrollmentFactory.create_batch(7, course_run=course_run) + product = factories.ProductFactory( + price=0, + type=enums.PRODUCT_TYPE_CERTIFICATE, + ) + relation = factories.CourseProductRelationFactory( + product=product, course=enrollments[0].course_run.course + ) + + # Create 3 orders where the certificate will be generated + order_ids = [] + for enrollment in enrollments[:3]: + order_ids.append( + factories.OrderFactory( + product=relation.product, + enrollment=enrollment, + course=None, + state=enums.ORDER_STATE_COMPLETED, + ).id + ) + # Create 4 orders where the certificate has been generated + for enrollment in enrollments[3:]: + factories.OrderCertificateFactory( + order=factories.OrderFactory( + product=relation.product, + course=None, + enrollment=enrollment, + state=enums.ORDER_STATE_COMPLETED, + ) + ) + + response = self.client.post( + f"/api/v1.0/admin/course-product-relations/{relation.id}/generate_certificates/", + content_type="application/json", + ) + + self.assertEqual(response.status_code, HTTPStatus.CREATED) + self.assertDictEqual( + response.json(), + { + "course_product_relation_id": str(relation.id), + "count_certificate_to_generate": 3, + "count_exist_before_generation": 4, + }, + ) + self.assertTrue(mock_generate_certificates_task.delay.called) + self.assertTrue( + mock_generate_certificates_task.delay.called_with( + order_ids=order_ids, + cache_key=f"celery_certificate_generation_{relation.id}", + ) + ) diff --git a/src/backend/joanie/tests/core/test_admin_course.py b/src/backend/joanie/tests/core/test_admin_course.py index aac6d36e5..6968d53ab 100644 --- a/src/backend/joanie/tests/core/test_admin_course.py +++ b/src/backend/joanie/tests/core/test_admin_course.py @@ -45,10 +45,3 @@ def test_admin_course_use_translatable_change_form_with_actions_template(self): # Django parler tabs should be displayed parler_tabs = html.cssselect(".parler-language-tabs span") self.assertEqual(len(parler_tabs), len(settings.LANGUAGES)) - - # Django object actions should be displayed - object_actions = html.cssselect(".objectaction-item") - self.assertEqual(len(object_actions), 1) - self.assertEqual( - object_actions[0].attrib["data-tool-name"], "generate_certificates" - ) diff --git a/src/backend/joanie/tests/core/test_admin_product.py b/src/backend/joanie/tests/core/test_admin_product.py index cf4d41866..b6768c59b 100644 --- a/src/backend/joanie/tests/core/test_admin_product.py +++ b/src/backend/joanie/tests/core/test_admin_product.py @@ -5,10 +5,8 @@ import random import uuid from http import HTTPStatus -from unittest import mock from django.conf import settings -from django.contrib.messages import get_messages from django.urls import reverse import lxml.html @@ -350,9 +348,9 @@ def test_admin_product_should_allow_to_generate_certificate_for_related_course( # - The related course should be displayed related_course = related_courses_field.cssselect("li") self.assertEqual(len(related_course), 1) - # - And it should contain two links + # - And it should contain one link links = related_course[0].cssselect("a") - self.assertEqual(len(links), 2) + self.assertEqual(len(links), 1) # - 1st a link to go to the related course change view self.assertEqual(links[0].text_content(), f"{course.code} | {course.title}") self.assertEqual( @@ -360,50 +358,6 @@ def test_admin_product_should_allow_to_generate_certificate_for_related_course( reverse("admin:core_course_change", args=(course.pk,)), ) - # - 2nd a link to generate certificate for the course - product couple - self.assertEqual(links[1].text_content(), "Generate certificates") - self.assertEqual( - links[1].attrib["href"], - reverse( - "admin:generate_certificates", - kwargs={"product_id": product.id, "course_code": course.code}, - ), - ) - - @mock.patch("joanie.core.helpers.generate_certificates_for_orders", return_value=0) - def test_admin_product_generate_certificate_for_course( - self, mock_generate_certificates - ): - """ - Product Admin should contain an endpoint which triggers the - `create_certificates` management command with product and course as options. - """ - user = factories.UserFactory(is_staff=True, is_superuser=True) - self.client.login(username=user.username, password="password") - - course = factories.CourseFactory() - product = factories.ProductFactory(courses=[course]) - - response = self.client.get( - reverse( - "admin:generate_certificates", - kwargs={"course_code": course.code, "product_id": product.id}, - ), - ) - - # - Generate certificates command should have been called - mock_generate_certificates.assert_called_once() - - # Check the presence of a confirmation message - messages = list(get_messages(response.wsgi_request)) - self.assertEqual(len(messages), 1) - self.assertEqual(str(messages[0]), "No certificates have been generated.") - - # - User should be redirected to the product change view - self.assertRedirects( - response, reverse("admin:core_product_change", args=(product.id,)) - ) - def test_admin_product_use_translatable_change_form_with_actions_template(self): """ The product admin change view should use a custom change form template diff --git a/src/backend/joanie/tests/core/test_utils_course_product_relation.py b/src/backend/joanie/tests/core/test_utils_course_product_relation.py index f4147e13e..7d6e68b3e 100644 --- a/src/backend/joanie/tests/core/test_utils_course_product_relation.py +++ b/src/backend/joanie/tests/core/test_utils_course_product_relation.py @@ -1,12 +1,9 @@ """Test suite utility methods for course product relation to get orders and certificates""" -import datetime - from django.test import TestCase -from django.utils import timezone from joanie.core import enums, factories -from joanie.core.models import CourseProductRelation +from joanie.core.models import CourseProductRelation, CourseState from joanie.core.utils.course_product_relation import ( get_generated_certificates, get_orders, @@ -16,69 +13,93 @@ class UtilsCourseProductRelationTestCase(TestCase): """Test suite utility methods for course product relation to get orders and certificates""" - def test_utils_course_product_relation_get_orders_made(self): + def test_utils_course_product_relation_get_orders_for_product_type_credential(self): """ - It should return the amount of orders that are validated for this course product - relation. + It should return the list of orders ids that are completed for this course product relation + with a product of type credential where the certificate has not been published yet. """ - course = factories.CourseFactory(products=None) - product = factories.ProductFactory( - price="0.00", - type=enums.PRODUCT_TYPE_CREDENTIAL, - certificate_definition=factories.CertificateDefinitionFactory(), - courses=[course], - ) + course = factories.CourseFactory() factories.CourseRunFactory( course=course, - enrollment_end=timezone.now() + datetime.timedelta(hours=1), - enrollment_start=timezone.now() - datetime.timedelta(hours=1), + state=CourseState.ONGOING_OPEN, + is_listed=True, is_gradable=True, - start=timezone.now() - datetime.timedelta(hours=1), ) - factories.ProductTargetCourseRelationFactory( - product=product, - course=course, - is_graded=True, + product = factories.ProductFactory( + price=0, + type=enums.PRODUCT_TYPE_CREDENTIAL, + courses=[course], ) course_product_relation = CourseProductRelation.objects.get( product=product, course=course ) - # Generate orders for the course product relation - orders = factories.OrderFactory.create_batch( + # Generate orders for the course product relation with the course + factories.OrderFactory.create_batch( 10, product=course_product_relation.product, course=course_product_relation.course, + enrollment=None, + state=enums.ORDER_STATE_COMPLETED, ) - for order in orders: - order.init_flow() result = get_orders(course_product_relation=course_product_relation) self.assertEqual(len(result), 10) - def test_utils_course_product_relation_get_generated_certificates(self): + def test_utils_course_product_relation_get_orders_for_product_type_certificate( + self, + ): + """ + It should return the list of orders ids that are completed for the course product relation + with a product of type certificate where the certificate has not been published yet. + """ + course_run = factories.CourseRunFactory( + is_gradable=True, is_listed=True, state=CourseState.ONGOING_OPEN + ) + enrollments = factories.EnrollmentFactory.create_batch(5, course_run=course_run) + product = factories.ProductFactory( + price=0, + type=enums.PRODUCT_TYPE_CERTIFICATE, + ) + relation = factories.CourseProductRelationFactory( + product=product, course=enrollments[0].course_run.course + ) + + orders = get_orders(course_product_relation=relation) + + self.assertEqual(len(orders), 0) + + # Generate orders for the course product relation with the enrollments + for enrollment in enrollments: + factories.OrderFactory( + product=relation.product, + enrollment=enrollment, + course=None, + state=enums.ORDER_STATE_COMPLETED, + ) + + orders = get_orders(course_product_relation=relation) + + self.assertEqual(len(orders), 5) + + def test_utils_course_product_relation_get_generated_certificates_for_product_type_credential( + self, + ): """ It should return the amount of certificates that were published for this course product - relation. + relation with a product of type credential. """ - course = factories.CourseFactory(products=None) + course = factories.CourseFactory() product = factories.ProductFactory( - price="0.00", + price=0, type=enums.PRODUCT_TYPE_CREDENTIAL, - certificate_definition=factories.CertificateDefinitionFactory(), courses=[course], ) factories.CourseRunFactory( course=course, - enrollment_end=timezone.now() + datetime.timedelta(hours=1), - enrollment_start=timezone.now() - datetime.timedelta(hours=1), + state=CourseState.ONGOING_OPEN, + is_listed=True, is_gradable=True, - start=timezone.now() - datetime.timedelta(hours=1), - ) - factories.ProductTargetCourseRelationFactory( - product=product, - course=course, - is_graded=True, ) course_product_relation = CourseProductRelation.objects.get( product=product, course=course @@ -87,6 +108,7 @@ def test_utils_course_product_relation_get_generated_certificates(self): generated_certificates_queryset = get_generated_certificates( course_product_relation=course_product_relation ) + self.assertEqual(generated_certificates_queryset.count(), 0) # Generate certificates for the course product relation @@ -94,9 +116,10 @@ def test_utils_course_product_relation_get_generated_certificates(self): 5, product=course_product_relation.product, course=course_product_relation.course, + enrollment=None, + state=enums.ORDER_STATE_COMPLETED, ) for order in orders: - order.init_flow() factories.OrderCertificateFactory(order=order) generated_certificates_queryset = get_generated_certificates( @@ -104,3 +127,44 @@ def test_utils_course_product_relation_get_generated_certificates(self): ) self.assertEqual(generated_certificates_queryset.count(), 5) + + def test_utils_course_product_relation_get_generated_certificated_for_product_type_certificate( + self, + ): + """ + It should return the amount of certificates that were published for this course product + relation with a product of type certificate. + """ + course_run = factories.CourseRunFactory( + is_gradable=True, is_listed=True, state=CourseState.ONGOING_OPEN + ) + enrollments = factories.EnrollmentFactory.create_batch( + 10, course_run=course_run + ) + product = factories.ProductFactory(price=0, type=enums.PRODUCT_TYPE_CERTIFICATE) + relation = factories.CourseProductRelationFactory( + product=product, course=enrollments[0].course_run.course + ) + + generated_certificates_queryset = get_generated_certificates( + course_product_relation=relation + ) + + self.assertEqual(generated_certificates_queryset.count(), 0) + + # Generate certificates for the course product relation + for enrollment in enrollments: + factories.OrderCertificateFactory( + order=factories.OrderFactory( + product=relation.product, + enrollment=enrollment, + course=None, + state=enums.ORDER_STATE_COMPLETED, + ) + ) + + generated_certificates_queryset = get_generated_certificates( + course_product_relation=relation + ) + + self.assertEqual(generated_certificates_queryset.count(), 10)