Skip to content

Commit

Permalink
✨(admin) add custom actions to generate certificates
Browse files Browse the repository at this point in the history
Create custom actions across all relevant models (products, course and orders)
to generate certificates through the bunch of provided resources.
  • Loading branch information
jbpenrath committed Apr 7, 2022
1 parent 5958c01 commit 7cf84f4
Show file tree
Hide file tree
Showing 3 changed files with 272 additions and 17 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
196 changes: 179 additions & 17 deletions src/backend/joanie/core/admin.py
Original file line number Diff line number Diff line change
@@ -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 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):
Expand All @@ -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"<a href='{url:s}'>{certificate_definition!s}</a>")


@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 = (
Expand All @@ -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)
Expand All @@ -76,7 +137,9 @@ class ProductCourseRelationInline(SortableInlineAdminMixin, admin.TabularInline)


@admin.register(models.Product)
class ProductAdmin(SortableAdminMixin, TranslatableAdmin):
class ProductAdmin(
DjangoObjectActions, SortableAdminMixin, TranslatableAdmin
): # pylint: disable=too-many-ancestors
"""Admin class for the Product model"""

list_display = ("title", "type", "price")
Expand All @@ -92,42 +155,141 @@ 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<product_id>.+)/generate-certificates/(?P<course_code>.+)/$",
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 = [
(
"<li>"
f"<a href='{reverse('admin:core_course_change', args=(course.id,),)}'>"
f"{course.code} | {course.title}"
"</a>"
"</li>"
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 = (
'<li style="margin-bottom: 1rem">'
f"<a href='{change_course_url}'>{course.code} | {course.title}</a>"
)

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'<a style="margin-left: 1rem" class="button" href="{generate_certificates_url}">' # noqa pylint: disable=line-too-long
f'{_("Generate certificates")}'
"</a>"
)

raw_html += "</li>"
items.append(raw_html)

return format_html(f"<ul style='margin: 0'>{''.join(items)}</ul>")

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
"""Cancel orders"""
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)
Expand Down
92 changes: 92 additions & 0 deletions src/backend/joanie/tests/test_admin_product.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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):
"""
Expand Down

0 comments on commit 7cf84f4

Please sign in to comment.