diff --git a/src/backend/joanie/core/admin.py b/src/backend/joanie/core/admin.py index 4870bc0767..30d640e568 100644 --- a/src/backend/joanie/core/admin.py +++ b/src/backend/joanie/core/admin.py @@ -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 @@ -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) @@ -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 diff --git a/src/backend/joanie/core/enums.py b/src/backend/joanie/core/enums.py index 2c3cc42428..a2df485e53 100644 --- a/src/backend/joanie/core/enums.py +++ b/src/backend/joanie/core/enums.py @@ -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 diff --git a/src/backend/joanie/core/management/commands/create_certificates.py b/src/backend/joanie/core/management/commands/create_certificates.py new file mode 100644 index 0000000000..823fa95e3b --- /dev/null +++ b/src/backend/joanie/core/management/commands/create_certificates.py @@ -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.' + ) diff --git a/src/backend/joanie/core/models/products.py b/src/backend/joanie/core/models/products.py index 8789fca1b6..6039593aee 100644 --- a/src/backend/joanie/core/models/products.py +++ b/src/backend/joanie/core/models/products.py @@ -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 @@ -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 @@ -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): @@ -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() @@ -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( _( ( @@ -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