Skip to content

Commit

Permalink
✨(core) create a management command to generate certificates
Browse files Browse the repository at this point in the history
Create a management command which aims to be called by a cron task to generate
certificate at a regular interval.
  • Loading branch information
jbpenrath committed Mar 18, 2022
1 parent 78ca002 commit b263e8f
Show file tree
Hide file tree
Showing 4 changed files with 201 additions and 10 deletions.
68 changes: 66 additions & 2 deletions src/backend/joanie/core/admin.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
"""
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.utils import timezone
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _

from adminsortable2.admin import SortableInlineAdminMixin
from parler.admin import TranslatableAdmin

from joanie.core.enums import ORDER_STATE_VALIDATED, PRODUCT_TYPE_CERTIFICATE_ALLOWED

from . import models


Expand Down Expand Up @@ -51,7 +54,13 @@ class CourseAdmin(TranslatableAdmin):
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 Down Expand Up @@ -92,6 +101,61 @@ class ProductAdmin(TranslatableAdmin):

inlines = (ProductCourseRelationInline,)
readonly_fields = ("related_courses",)
actions = ("generate_certificate",)

@admin.action(description=_("Generate certificate"))
def generate_certificate(self, request, queryset):
"""
Generate certificate for each order related to the selected products
"""
for product in queryset:
# Only certifying products are concerned
if product.type not in PRODUCT_TYPE_CERTIFICATE_ALLOWED:
self.message_user(
request,
_(f'"{product.title}" is not certifying.'),
messages.ERROR,
)
return

# Retrieve all active orders related to the product
orders = [
order
for order in models.Order.objects.filter(
product=product, is_canceled=False, certificate__isnull=True
).prefetch_related("enrollments")
if order.state == ORDER_STATE_VALIDATED
]

graded_courses = product.target_courses.filter(
product_relations__is_graded=True
)

graded_course_runs = models.CourseRun.objects.filter(
course__in=graded_courses, start__lt=timezone.now()
)
certificate_generated = 0
if len(graded_course_runs) > 0:
for order in orders:
# Retrieve all related enrollments
enrollments = order.enrollments.filter(
course_run__in=graded_course_runs,
)

passed_enrollments = 0

for enrollment in enrollments:
if enrollment.is_passed is True:
passed_enrollments += 1

if passed_enrollments == len(graded_courses):
order.create_certificate()
certificate_generated += 1

self.message_user(
request,
f'{certificate_generated} certificate(s) for "{product.title}" has been generated.',
)

@admin.display(description="Related courses")
def related_courses(self, obj): # pylint: disable=no-self-use
Expand Down
5 changes: 5 additions & 0 deletions src/backend/joanie/core/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@
(PRODUCT_TYPE_CERTIFICATE, _("Certificate")),
)

PRODUCT_TYPE_CERTIFICATE_ALLOWED = [
PRODUCT_TYPE_CERTIFICATE,
PRODUCT_TYPE_CREDENTIAL,
]

ORDER_STATE_PENDING = "pending" # waiting for payment
ORDER_STATE_CANCELED = "canceled" #  has been canceled
ORDER_STATE_FAILED = "failed" #  payment failed
Expand Down
90 changes: 90 additions & 0 deletions src/backend/joanie/core/management/commands/create_certificates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""Management command to create all available certificates that are not yet existing."""
import logging

from django.core.management import BaseCommand

from joanie.core import enums, models

logger = logging.getLogger("joanie.core.create_certificates")


class Command(BaseCommand):
"""Browse all courses and their products then create certificate if needed."""

help = "Browse all courses and their products then create certificate if needed."

def add_arguments(self, parser):
parser.add_argument(
"-c",
"--course",
help="Provide a course code to restrict check to this course",
)
parser.add_argument(
"-p",
"--product",
help="Provide a product uuid to restrict check to this product",
)

def handle(self, *args, **options):
"""
Retrieve all courses with certifying products then for each product check state
of order and their enrollments, if all enrollments has been passed with success
we are able to create the related certificate.
"""
# TODO: Improve db queries by using select_related and prefetch_related methods
# 1. First retrieve all courses with certifying products
course = options["course"]
product = options["product"]
filters = {"type__in": enums.PRODUCT_TYPE_CERTIFICATE_ALLOWED}

if course:
filters.update({"courses__code": course})

if product:
filters.update({"uid": product})

products = models.Product.objects.filter(**filters)

for product in products:

# Retrieve all active orders related to the course
orders = [
order
for order in product.orders.filter(
is_canceled=False, certificate__isnull=True
)
if order.state == enums.ORDER_STATE_VALIDATED
]

certificate_generated = 0
for order in orders:
graded_courses = order.target_courses.filter(
order_relations__is_graded=True
).order_by("order_relations__position")

course_runs = models.CourseRun.objects.filter(
course__in=graded_courses, is_gradable=True
)

enrollments = order.enrollments.filter(
course_run__in=course_runs, is_active=True
)

if len(enrollments) < len(graded_courses):
break

passed_enrollments = 0
for enrollment in enrollments:
if enrollment.is_passed:
passed_enrollments += 1

if passed_enrollments == len(graded_courses):
order.create_certificate()
certificate_generated += 1

logger.info(
f'{certificate_generated} certificate(s) for "{product.title}" has been generated.'
)
self.stdout.write(
f'{certificate_generated} certificate(s) for "{product.title}" has been generated.'
)
48 changes: 40 additions & 8 deletions src/backend/joanie/core/models/products.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from decimal import Decimal as D

from django.conf import settings
from django.core.cache import cache
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db import models
from django.utils import timezone
Expand All @@ -16,7 +17,7 @@
from djmoney.models.validators import MinMoneyValidator
from parler import models as parler_models

from joanie.core.exceptions import EnrollmentError
from joanie.core.exceptions import EnrollmentError, GradeError
from joanie.core.models.certifications import Certificate
from joanie.lms_handler import LMSHandler

Expand All @@ -25,10 +26,6 @@
from . import courses as courses_models

logger = logging.getLogger(__name__)
PRODUCT_TYPE_CERTIFICATE_ALLOWED = [
enums.PRODUCT_TYPE_CERTIFICATE,
enums.PRODUCT_TYPE_CREDENTIAL,
]


class Product(parler_models.TranslatableModel):
Expand Down Expand Up @@ -94,12 +91,12 @@ def clean(self):
"""
if (
self.certificate_definition
and self.type not in PRODUCT_TYPE_CERTIFICATE_ALLOWED
and self.type not in enums.PRODUCT_TYPE_CERTIFICATE_ALLOWED
):
raise ValidationError(
_(
f"Certificate definition is only allowed for product kinds: "
f"{', '.join(PRODUCT_TYPE_CERTIFICATE_ALLOWED)}"
f"{', '.join(enums.PRODUCT_TYPE_CERTIFICATE_ALLOWED)}"
)
)
super().clean()
Expand Down Expand Up @@ -326,7 +323,7 @@ def create_certificate(self):
Create a certificate if the related product type is certifying and if one
has not been already created.
"""
if self.product.type not in PRODUCT_TYPE_CERTIFICATE_ALLOWED:
if self.product.type not in enums.PRODUCT_TYPE_CERTIFICATE_ALLOWED:
raise ValidationError(
_(
(
Expand Down Expand Up @@ -435,6 +432,41 @@ def __str__(self):
active = _("active") if self.is_active else _("inactive")
return f"[{active}][{self.state}] {self.user} for {self.course_run}"

@property
def grade_cache_key(self):
"""The cache key used to store enrollment's grade."""
return f"grade_{self.uid}"

@property
def is_passed(self):
"""Get enrollment's grade, store result in cache then return the passed state."""
grade = cache.get(self.grade_cache_key)

if grade is None:
try:
grade = self.get_grade()
except GradeError:
return False

cache.set(
self.grade_cache_key, grade, settings.JOANIE_ENROLLMENT_GRADE_CACHE_TTL
)
return grade["passed"]

def get_grade(self):
"""Retrieve the grade from the related LMS"""
lms = LMSHandler.select_lms(self.course_run.resource_link)

if lms is None:
logger.error(
f"Course run {self.course_run.id} has no related lms.", self.course_run
)
return {"passed": False}

grade = lms.get_grades(self.user.username, self.course_run.resource_link)

return grade

def clean(self):
"""Clean instance fields and raise a ValidationError in case of issue."""
# The related course run must be opened for enrollment
Expand Down

0 comments on commit b263e8f

Please sign in to comment.