From c1c98aeba4f4dfe54d27e02531b3ad9f388f2dcd Mon Sep 17 00:00:00 2001 From: jbpenrath Date: Tue, 29 Mar 2022 16:37:10 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(admin)=20add=20custom=20actions=20to?= =?UTF-8?q?=20generate=20certificates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create custom actions across all relevant models (products, course and orders) to generate certificates through the bunch of provided resources. --- CHANGELOG.md | 1 + src/backend/joanie/core/admin.py | 198 ++++++++++++++++-- .../joanie/tests/test_admin_product.py | 92 ++++++++ 3 files changed, 273 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00dac68fd..62889f730 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 5e6ef62aa..00ffb8f57 100644 --- a/src/backend/joanie/core/admin.py +++ b/src/backend/joanie/core/admin.py @@ -1,17 +1,47 @@ """ Core application admin """ -from django.contrib import admin + +from django.contrib import admin, messages from django.contrib.auth import admin as auth_admin -from django.urls import reverse +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 django.utils.translation import ngettext_lazy -from adminsortable2.admin import SortableAdminMixin, SortableInlineAdminMixin +from adminsortable2.admin import SortableAdminBase, SortableInlineAdminMixin +from django_object_actions import DjangoObjectActions, takes_instance_or_queryset from parler.admin import TranslatableAdmin +from joanie.core import helpers +from joanie.core.enums import PRODUCT_TYPE_CERTIFICATE_ALLOWED + from . import models +ACTION_NAME_GENERATE_CERTIFICATES = "generate_certificates" +ACTION_NAME_CANCEL = "cancel" + + +def summarize_certification_to_user(request, count): + """ + Display a message after generate_certificates command has been launched + """ + if count == 0: + messages.warning( + request, + _("No certificates have been generated."), + ) + else: + messages.success( + request, + ngettext_lazy( # pylint: disable=no-member + "{:d} certificate has been generated.", + "{:d} certificates have been generated.", + count, + ).format(count), + ) + @admin.register(models.CertificateDefinition) class CertificateDefinitionAdmin(TranslatableAdmin): @@ -25,12 +55,25 @@ class CertificateAdmin(admin.ModelAdmin): """Admin class for the Certificate model""" list_display = ("order", "issued_on") + readonly_fields = ("order", "issued_on", "certificate_definition") + + def certificate_definition(self, obj): # pylint: disable=no-self-use + """Retrieve the certification definition from the related order.""" + certificate_definition = obj.order.product.certificate_definition + + url = reverse( + "admin:core_certificatedefinition_change", + args=(certificate_definition.id,), + ) + return format_html(f"{certificate_definition!s}") @admin.register(models.Course) -class CourseAdmin(TranslatableAdmin): +class CourseAdmin(DjangoObjectActions, TranslatableAdmin): """Admin class for the Course model""" + actions = (ACTION_NAME_GENERATE_CERTIFICATES,) + change_actions = (ACTION_NAME_GENERATE_CERTIFICATES,) list_display = ("code", "title", "organization", "state") filter_vertical = ("products",) fieldsets = ( @@ -46,12 +89,30 @@ class CourseAdmin(TranslatableAdmin): ), ) + @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): """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,7 +137,9 @@ class ProductCourseRelationInline(SortableInlineAdminMixin, admin.TabularInline) @admin.register(models.Product) -class ProductAdmin(SortableAdminMixin, TranslatableAdmin): +class ProductAdmin( + DjangoObjectActions, SortableAdminBase, TranslatableAdmin +): # pylint: disable=too-many-ancestors """Admin class for the Product model""" list_display = ("title", "type", "price") @@ -92,35 +155,125 @@ class ProductAdmin(SortableAdminMixin, TranslatableAdmin): inlines = (ProductCourseRelationInline,) readonly_fields = ("related_courses",) + actions = (ACTION_NAME_GENERATE_CERTIFICATES,) + change_actions = (ACTION_NAME_GENERATE_CERTIFICATES,) + + 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 """ Retrieve courses related to the product """ + return self.get_related_courses_as_html(obj) + + @staticmethod + 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 = [ - ( - "
  • " - f"" - f"{course.code} | {course.title}" - "" - "
  • " + items = [] + for course in obj.courses.all(): + change_course_url = reverse( + "admin:core_course_change", + args=(course.id,), ) - for course in obj.courses.all() - ] + + 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'' # noqa pylint: disable=line-too-long + f'{_("Generate certificates")}' + "" + ) + + raw_html += "
  • " + items.append(raw_html) + return format_html(f"
      {''.join(items)}
    ") + return "-" @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"] + readonly_fields = ("state", "total", "invoice", "certificate") + change_actions = (ACTION_NAME_GENERATE_CERTIFICATES,) + actions = (ACTION_NAME_CANCEL, ACTION_NAME_GENERATE_CERTIFICATES) @admin.action(description=_("Cancel selected orders")) def cancel(self, request, queryset): # pylint: disable=no-self-use @@ -128,6 +281,15 @@ def cancel(self, request, queryset): # pylint: disable=no-self-use for order in queryset: order.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/tests/test_admin_product.py b/src/backend/joanie/tests/test_admin_product.py index 96033d5da..f7472879b 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 + displayed 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("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,)) + ) + @mock.patch.object(models.Order, "cancel") def test_admin_order_action_cancel(self, mock_cancel): """