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 Mar 29, 2022
1 parent 07fead4 commit ee222fe
Show file tree
Hide file tree
Showing 3 changed files with 239 additions and 18 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
158 changes: 144 additions & 14 deletions src/backend/joanie/core/admin.py
Original file line number Diff line number Diff line change
@@ -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"""
Expand All @@ -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 = (
Expand All @@ -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)
Expand All @@ -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 = (
Expand All @@ -92,21 +138,92 @@ 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<product_id>.+)/generate-certificates/(?P<course_code>.+)/$",
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
"""
Retrieve courses related to the product
"""
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>"
concat(
(
'<li style="margin-bottom: 1rem">'
f"<a href='{reverse('admin:core_course_change', args=(course.id,),)}'>"
f"{course.code} | {course.title}"
"</a>"
),
(
f'<a style="margin-left: 1rem" class="button" '
f'href="{reverse("admin:generate_certificates", kwargs={"product_id": obj.id, "course_code": course.code}, )}">' # noqa pylint: disable=line-too-long
f'{_("Generate certificates")}'
"</a>"
"</li>"
)
if is_certifying
else "",
)
for course in obj.courses.all()
]
Expand All @@ -115,19 +232,32 @@ 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
"""Cancel orders"""
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)
Expand Down
98 changes: 94 additions & 4 deletions src/backend/joanie/tests/test_admin_product.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@
Test suite for products API
"""
import random
import uuid
from unittest import mock
import uuid

from django.contrib.messages import get_messages
from django.urls import reverse

import lxml.html

from joanie.core import factories, models
import lxml.html

from .base import BaseAPITestCase
from ..core import enums


class ProductAdminTestCase(BaseAPITestCase):
Expand Down Expand Up @@ -284,6 +284,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):
"""
Expand Down

0 comments on commit ee222fe

Please sign in to comment.