diff --git a/CHANGELOG.md b/CHANGELOG.md index 00dac68fdc..62889f7305 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to ### Added +- Add custom actions into admin to generate certificates - Add a management command to generate certificate for eligible orders - Add `is_passed` cached property to enrollment model - Add an api endpoint to retrieve and download certificates diff --git a/src/backend/joanie/core/admin.py b/src/backend/joanie/core/admin.py index 4870bc0767..0e9775d2c6 100644 --- a/src/backend/joanie/core/admin.py +++ b/src/backend/joanie/core/admin.py @@ -1,18 +1,41 @@ """ Core application admin """ -from django.contrib import admin +from operator import concat + +from django.contrib import admin, messages from django.contrib.auth import admin as auth_admin -from django.urls import reverse +from django.core import management +from django.http import HttpResponseRedirect +from django.urls import re_path, reverse from django.utils.html import format_html from django.utils.translation import gettext_lazy as _ from adminsortable2.admin import SortableInlineAdminMixin +from django_object_actions import DjangoObjectActions, takes_instance_or_queryset from parler.admin import TranslatableAdmin +from joanie.core.enums import PRODUCT_TYPE_CERTIFICATE_ALLOWED + from . import models +def resume_certification_to_user(request, count): + """ + Display a message after create_certificates command has been launched + """ + if count == "0": + messages.warning( + request, + _("No certificate has been generated.").format(count), + ) + else: + messages.success( + request, + _("{:s} certificate(s) has been generated.").format(count), + ) + + @admin.register(models.CertificateDefinition) class CertificateDefinitionAdmin(TranslatableAdmin): """Admin class for the CertificateDefinition model""" @@ -28,9 +51,11 @@ class CertificateAdmin(admin.ModelAdmin): @admin.register(models.Course) -class CourseAdmin(TranslatableAdmin): +class CourseAdmin(DjangoObjectActions, TranslatableAdmin): """Admin class for the Course model""" + actions = ("generate_certificates",) + change_actions = ("generate_certificates",) list_display = ("code", "title", "organization", "state") filter_vertical = ("products",) fieldsets = ( @@ -46,12 +71,31 @@ class CourseAdmin(TranslatableAdmin): ), ) + @takes_instance_or_queryset + def generate_certificates(self, request, queryset): # pylint: disable=no-self-use + """ + Custom action to launch create_certificates management command + over the selected courses + """ + codes = [course.code for course in queryset] + certificate_generated_count = management.call_command( + "create_certificates", courses=codes + ) + + resume_certification_to_user(request, certificate_generated_count) + @admin.register(models.CourseRun) class CourseRunAdmin(TranslatableAdmin): """Admin class for the CourseRun model""" - list_display = ("title", "resource_link", "start", "end", "state") + list_display = ("title", "resource_link", "start", "end", "state", "is_gradable") + actions = ("mark_as_gradable",) + + @admin.action(description=_("Mark course run as gradable")) + def mark_as_gradable(self, request, queryset): # pylint: disable=no-self-use + """Mark selected course runs as gradable""" + queryset.update(is_gradable=True) @admin.register(models.Organization) @@ -76,8 +120,10 @@ class ProductCourseRelationInline(SortableInlineAdminMixin, admin.TabularInline) @admin.register(models.Product) -class ProductAdmin(TranslatableAdmin): - """Admin class for the Product model""" +class ProductAdmin(DjangoObjectActions, TranslatableAdmin): + """ + Admin class for the Product model + """ list_display = ("title", "type", "price") fields = ( @@ -92,6 +138,65 @@ class ProductAdmin(TranslatableAdmin): inlines = (ProductCourseRelationInline,) readonly_fields = ("related_courses",) + actions = ("generate_certificates",) + change_actions = ("generate_certificates",) + + def get_change_actions(self, request, object_id, form_url): + """ + Remove the generate_certificates action to the action list + if the product instance is not certifying + """ + actions = super().get_change_actions(request, object_id, form_url) + actions = list(actions) + + obj = self.model.objects.get(pk=object_id) + if obj.type not in PRODUCT_TYPE_CERTIFICATE_ALLOWED: + actions.remove("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="generate_certificates", + ) + ] + url_patterns + + @takes_instance_or_queryset + def generate_certificates(self, request, queryset): # pylint: disable=no-self-use + """ + Custom action to launch create_certificates management command + over the selected products + """ + uids = [product.uid for product in queryset] + certificate_generated_count = management.call_command( + "create_certificates", products=uids + ) + + resume_certification_to_user(request, certificate_generated_count) + + def generate_certificates_for_course(self, request, product_id, course_code): + """ + A custom action to generate certificates for a course - product couple. + """ + product = self.model.objects.get(id=product_id) + + certificate_generated_count = management.call_command( + "create_certificates", courses=course_code, products=product.uid + ) + + resume_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 @@ -99,14 +204,26 @@ def related_courses(self, obj): # pylint: disable=no-self-use Retrieve courses related to the product """ related_courses = obj.courses.all() + is_certifying = obj.type in PRODUCT_TYPE_CERTIFICATE_ALLOWED + if related_courses: items = [ - ( - "
  • " - f"" - f"{course.code} | {course.title}" - "" - "
  • " + concat( + ( + '
  • ' + f"" + f"{course.code} | {course.title}" + "" + ), + ( + f'' # noqa pylint: disable=line-too-long + f'{_("Generate certificates")}' + "" + "
  • " + ) + if is_certifying + else "", ) for course in obj.courses.all() ] @@ -115,12 +232,13 @@ def related_courses(self, obj): # pylint: disable=no-self-use @admin.register(models.Order) -class OrderAdmin(admin.ModelAdmin): +class OrderAdmin(DjangoObjectActions, admin.ModelAdmin): """Admin class for the Order model""" list_display = ("uid", "owner", "product", "state") readonly_fields = ("total", "invoice") - actions = ["cancel"] + change_actions = ("generate_certificate",) + actions = ("cancel", "generate_certificate") @admin.action(description=_("Cancel selected orders")) def cancel(self, request, queryset): # pylint: disable=no-self-use @@ -128,6 +246,18 @@ def cancel(self, request, queryset): # pylint: disable=no-self-use for order in queryset: order.cancel() + @takes_instance_or_queryset + def generate_certificate(self, request, queryset): # pylint: disable=no-self-use + """ + Custom action to launch create_certificates management commands + over the order selected + """ + ids = [order.uid for order in queryset] + certificate_generated_count = management.call_command( + "create_certificates", orders=ids + ) + resume_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/tests/test_admin_product.py b/src/backend/joanie/tests/test_admin_product.py index 78010cbea9..c153e85b23 100644 --- a/src/backend/joanie/tests/test_admin_product.py +++ b/src/backend/joanie/tests/test_admin_product.py @@ -5,12 +5,14 @@ import uuid from unittest import mock +from django.contrib.messages import get_messages from django.urls import reverse import lxml.html from joanie.core import factories, models +from ..core import enums from .base import BaseAPITestCase @@ -284,6 +286,96 @@ def test_admin_product_should_display_related_course_links(self): reverse("admin:core_course_change", args=(course_1.pk,)), ) + def test_admin_product_should_allow_to_generate_certificate_for_related_course( + self, + ): + """ + Product admin view should display a link to generate certificates for + the couple course - product next to each related course item. This link is + display only for certifying products. + """ + + # Create a course + course = factories.CourseFactory() + + # Create a product + product = factories.ProductFactory( + courses=[course], type=enums.PRODUCT_TYPE_CREDENTIAL + ) + + # Login a user with all permission to manage products in django admin + user = factories.UserFactory(is_staff=True, is_superuser=True) + self.client.login(username=user.username, password="password") + + # Now we go to the product admin change view + response = self.client.get( + reverse("admin:core_product_change", args=(product.pk,)), + ) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, product.title) + + # - Check there are links to go to related courses admin change view + html = lxml.html.fromstring(response.content) + related_courses_field = html.cssselect(".field-related_courses")[0] + + # - 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 + links = related_course[0].cssselect("a") + self.assertEqual(len(links), 2) + # - 1st a link to go to the related course change view + self.assertEqual(links[0].text_content(), f"{course.code} | {course.title}") + self.assertEqual( + links[0].attrib["href"], + 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("django.core.management.call_command", return_value="0") + def test_admin_product_generate_certificate_for_course(self, mock_call_command): + """ + Product Admin should contain an endpoint which trigger 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}, + ), + ) + + # - Create certificates command should have been called + mock_call_command.assert_called_once_with( + "create_certificates", courses=course.code, products=product.uid + ) + + # 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 certificate has been generated.") + + # - User should be redirected to the product change view + self.assertRedirects( + response, reverse("admin:core_product_change", args=(product.id,)) + ) + @mock.patch.object(models.Order, "cancel") def test_admin_order_action_cancel(self, mock_cancel): """