From 6dd48c88c85f98dd78af609bdb9a3f037aa2fe82 Mon Sep 17 00:00:00 2001 From: jbpenrath Date: Tue, 29 Mar 2022 12:28:50 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(core)=20create=20a=20management=20com?= =?UTF-8?q?mand=20to=20generate=20certificates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create a management command which aims to be called by a cron task to generate certificate at a regular interval. This command accepts three options (course, product and order) to restrict review to those resources. --- CHANGELOG.md | 1 + .../commands/create_certificates.py | 223 +++++++++++ .../joanie/tests/test_admin_product.py | 2 +- .../test_commands_create_certificates.py | 362 ++++++++++++++++++ 4 files changed, 587 insertions(+), 1 deletion(-) create mode 100644 src/backend/joanie/core/management/commands/create_certificates.py create mode 100644 src/backend/joanie/tests/test_commands_create_certificates.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e865b9b217..00dac68fdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to ### Added +- 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 - Add a `get_grades` method to LMSHandler 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..a250b55d60 --- /dev/null +++ b/src/backend/joanie/core/management/commands/create_certificates.py @@ -0,0 +1,223 @@ +"""Management command to create all pending certificates.""" +import logging + +from django.core.exceptions import ValidationError +from django.core.management import BaseCommand +from django.utils import timezone + +from joanie.core import enums, models + +logger = logging.getLogger("joanie.core.create_certificates") + + +class Command(BaseCommand): + """ + A command to generate all pending certificates. + It browses all certifying products and check if related orders are eligible for + certification then generate the certificate if it is. + + Through options, you are able to restrict this command + to a list of courses (-c), products (-p) or orders (-o). + """ + + help = __doc__ + + def add_arguments(self, parser): + parser.add_argument( + "-c", + "--courses", + "--course", + help=( + "Accept a single or a list of course code to restrict review to " + "this/those course(s)." + ), + ) + parser.add_argument( + "-p", + "--products", + "--product", + help=( + "Accept a single or a list of product uuid to restrict review to " + "this/those product(s)." + ), + ) + parser.add_argument( + "-o", + "--orders", + "--order", + help=( + "Accept a single or a list of order uuid to restrict review to " + "this/those order(s)." + ), + ) + + # pylint: disable=too-many-locals + def handle(self, *args, **options): + """ + Retrieve all certifying products then for each of them check eligibility for + certification of all related orders. + If `order` option is used, this order is directly retrieve. + """ + order_uids = None + product_uids = None + course_codes = None + + if options["orders"]: + order_uids = ( + options["orders"] + if isinstance(options["orders"], list) + else [options["orders"]] + ) + + total = 0 + if order_uids: + filters = { + "is_canceled": False, + "certificate__isnull": True, + "product__type__in": enums.PRODUCT_TYPE_CERTIFICATE_ALLOWED, + "uid__in": order_uids, + } + + try: + orders = [ + order + for order in models.Order.objects.select_related( + "course__organization" + ).filter(**filters) + if order.state == enums.ORDER_STATE_VALIDATED + ] + except models.Order.DoesNotExist: + return str(total) + + for order in orders: + result = self._generate_certificate_for_order(order) + total += result + + return str(total) + + if options["courses"]: + course_codes = ( + options["courses"] + if isinstance(options["courses"], list) + else [options["courses"]] + ) + + if options["products"]: + product_uids = ( + options["products"] + if isinstance(options["products"], list) + else [options["products"]] + ) + + filters = {"type__in": enums.PRODUCT_TYPE_CERTIFICATE_ALLOWED} + if course_codes: + filters.update({"courses__code__in": course_codes}) + if product_uids: + filters.update({"uid__in": product_uids}) + + products = models.Product.objects.filter(**filters) + + for product in products: + result = self._generate_certificate_for_product(product, course_codes) + total += result + + return str(total) + + def _generate_certificate_for_product(self, product, course_codes): + """ + Retrieve orders related to the product and course code then check if + it is eligible for certification. + """ + filters = {"is_canceled": False, "certificate__isnull": True} + if course_codes: + filters.update({"course__code__in": course_codes}) + + orders = [ + order + for order in product.orders.filter(**filters).select_related( + "course__organization" + ) + if order.state == enums.ORDER_STATE_VALIDATED + ] + + certificate_counter = 0 + for order in orders: + result = self._generate_certificate_for_order(order) + certificate_counter += result + + if certificate_counter > 0: + logger.info( + '%s certificate(s) for "%s" have been generated.', + certificate_counter, + product.title, + ) + + return certificate_counter + + @staticmethod + def _generate_certificate_for_order(order): + """ + Check if order is eligible for certification then generate certificate is it is. + + Eligibility means that order contains + one passed enrollment per graded courses. + + Return: + 0 : if the order is not eligible to certification + 1: if a certificate has been generated for the current order + """ + graded_courses = order.target_courses.filter( + order_relations__is_graded=True + ).order_by("order_relations__position") + graded_courses_count = len(graded_courses) + + if graded_courses_count == 0: + return 0 + + course_runs = models.CourseRun.objects.filter( + course__in=graded_courses, + is_gradable=True, + start__lte=timezone.now(), + ) + + enrollments = order.enrollments.filter( + course_run__in=course_runs, is_active=True + ).select_related("user", "course_run") + + # Cross graded courses and enrollments to check there is an active enrollment + # for each graded courses, if not it is useless to go further + course_enrollments = [] + for course in graded_courses: + for enrollment in enrollments: + # Check if the enrollment relies on course by crossing + # all course runs implied + intersections = len( + {enrollment.course_run} & set(course.course_runs.all()) + ) + if intersections == 1: + course_enrollments.append(enrollment) + break + + # If we do not have one enrollment per graded course, there is no need to + # continue, we are sure that order is not eligible for certification. + if len(course_enrollments) != graded_courses_count: + return 0 + + # Otherwise, we now need to know if each enrollment has been passed + passed_enrollment_count = 0 + for enrollment in course_enrollments: + if enrollment.is_passed is False: + # If one enrollment has not been passed, no need to continue, + # We are sure that order is not eligible for certification. + break + passed_enrollment_count += 1 + + if passed_enrollment_count != graded_courses_count: + return 0 + + try: + order.create_certificate() + except ValidationError: + return 0 + + return 1 diff --git a/src/backend/joanie/tests/test_admin_product.py b/src/backend/joanie/tests/test_admin_product.py index 58d9b70665..78010cbea9 100644 --- a/src/backend/joanie/tests/test_admin_product.py +++ b/src/backend/joanie/tests/test_admin_product.py @@ -243,7 +243,7 @@ def test_admin_product_should_display_related_course_links(self): """ Product admin view should display a read only field "related courses" in charge of listing related courses with a link to the course admin - change view. + change view """ # Create courses diff --git a/src/backend/joanie/tests/test_commands_create_certificates.py b/src/backend/joanie/tests/test_commands_create_certificates.py new file mode 100644 index 0000000000..489adbfe7e --- /dev/null +++ b/src/backend/joanie/tests/test_commands_create_certificates.py @@ -0,0 +1,362 @@ +"""Test suite for the management command 'create_certificates'""" +import uuid +from datetime import timedelta +from unittest import mock + +from django.core.management import call_command +from django.test import TestCase +from django.utils import timezone + +from joanie.core import enums, factories, models +from joanie.lms_handler.backends.dummy import DummyLMSBackend + + +class CreateCertificatesTestCase(TestCase): + """Test case for the management command 'create_certificates'""" + + def test_commands_create_certificates_has_options( + self, + ): + """ + This command should accept three optional arguments: + - courses + - products + - orders + """ + options = { + "courses": "00000", + "orders": uuid.uuid4(), + "products": uuid.uuid4(), + } + + # TypeError: Unknown option(s) should not be raised + self.assertEqual(call_command("create_certificates", **options), "0") + + def test_commands_create_certificates(self): + """ + If a certifying product contains graded courses with gradable course runs + and a user purchased this product and passed all gradable course runs, + a certificate should be generated + """ + + # Create a certifying product with one order eligible for certification + course_run = factories.CourseRunFactory( + enrollment_end=timezone.now() + timedelta(hours=1), + enrollment_start=timezone.now() - timedelta(hours=1), + is_gradable=True, + start=timezone.now() - timedelta(hours=1), + ) + product = factories.ProductFactory( + price="0.00", + type=enums.PRODUCT_TYPE_CREDENTIAL, + target_courses=[course_run.course], + ) + course = factories.CourseFactory(products=[product]) + order = factories.OrderFactory(product=product, course=course) + certificate = models.Certificate.objects.filter(order=order) + + self.assertEqual(certificate.count(), 0) + + # DB queries should be minimized + with self.assertNumQueries(9): + self.assertEqual(call_command("create_certificates"), "1") + self.assertEqual(certificate.count(), 1) + + # But call it again, should not create a new certificate + with self.assertNumQueries(2): + self.assertEqual(call_command("create_certificates"), "0") + self.assertEqual(certificate.count(), 1) + + def test_commands_create_certificates_needs_graded_courses(self): + """ + If a certifying product does not contain graded courses, + any certificate should be generated. + """ + # Create a certifying product with one order eligible for certification + course_run = factories.CourseRunFactory( + enrollment_end=timezone.now() + timedelta(hours=1), + enrollment_start=timezone.now() - timedelta(hours=1), + is_gradable=True, + start=timezone.now() - timedelta(hours=1), + ) + product = factories.ProductFactory( + price="0.00", + type=enums.PRODUCT_TYPE_CREDENTIAL, + target_courses=[course_run.course], + ) + # Mark the only product's course as non graded + course_run.course.product_relations.update(is_graded=False) + course = factories.CourseFactory(products=[product]) + order = factories.OrderFactory(product=product, course=course) + certificate = models.Certificate.objects.filter(order=order) + self.assertEqual(certificate.count(), 0) + + self.assertEqual(call_command("create_certificates"), "0") + self.assertEqual(certificate.count(), 0) + + def test_commands_create_certificates_needs_gradable_course_runs(self): + """ + If a certifying product does not rely on gradable course runs, + any certificate should be generated. + """ + # Create a certifying product with one order eligible for certification + course_run = factories.CourseRunFactory( + enrollment_end=timezone.now() + timedelta(hours=1), + enrollment_start=timezone.now() - timedelta(hours=1), + is_gradable=False, + start=timezone.now() - timedelta(hours=1), + ) + product = factories.ProductFactory( + price="0.00", + type=enums.PRODUCT_TYPE_CREDENTIAL, + target_courses=[course_run.course], + ) + course = factories.CourseFactory(products=[product]) + order = factories.OrderFactory(product=product, course=course) + certificate = models.Certificate.objects.filter(order=order) + self.assertEqual(certificate.count(), 0) + + self.assertEqual(call_command("create_certificates"), "0") + self.assertEqual(certificate.count(), 0) + + # - Now flag the course run as gradable + course_run.is_gradable = True + course_run.save() + + self.assertEqual(call_command("create_certificates"), "1") + self.assertEqual(certificate.count(), 1) + + def test_commands_create_certificates_needs_enrollments_has_been_passed(self): + """ + Certificate is generated only if user has passed all graded courses. + """ + # Create a certifying product with one order eligible for certification + course_run = factories.CourseRunFactory( + enrollment_end=timezone.now() + timedelta(hours=1), + enrollment_start=timezone.now() - timedelta(hours=1), + is_gradable=True, + start=timezone.now() - timedelta(hours=1), + ) + product = factories.ProductFactory( + price="0.00", + type=enums.PRODUCT_TYPE_CREDENTIAL, + target_courses=[course_run.course], + ) + course = factories.CourseFactory(products=[product]) + order = factories.OrderFactory(product=product, course=course) + certificate = models.Certificate.objects.filter(order=order) + self.assertEqual(certificate.count(), 0) + + # Simulate that all enrollments are not passed + with mock.patch.object(DummyLMSBackend, "get_grades") as mock_get_grades: + mock_get_grades.return_value = {"passed": False} + self.assertEqual(call_command("create_certificates"), "0") + + self.assertEqual(certificate.count(), 0) + + # Simulate that all enrollments are passed + with mock.patch.object(DummyLMSBackend, "get_grades") as mock_get_grades: + mock_get_grades.return_value = {"passed": True} + self.assertEqual(call_command("create_certificates"), "1") + + self.assertEqual(certificate.count(), 1) + + def test_commands_create_certificates_can_be_restricted_to_order(self): + """ + If `order` option is used, the review is restricted to it. + """ + # Create a certifying product with two orders eligible for certification + course_run = factories.CourseRunFactory( + enrollment_end=timezone.now() + timedelta(hours=1), + enrollment_start=timezone.now() - timedelta(hours=1), + is_gradable=True, + start=timezone.now() - timedelta(hours=1), + ) + product = factories.ProductFactory( + price="0.00", + type=enums.PRODUCT_TYPE_CREDENTIAL, + target_courses=[course_run.course], + ) + course = factories.CourseFactory(products=[product]) + orders = factories.OrderFactory.create_batch(2, product=product, course=course) + certificate = models.Certificate.objects.filter(order__in=orders) + + self.assertEqual(certificate.count(), 0) + + # A certificate should be generated for the 1st order + with self.assertNumQueries(9): + self.assertEqual( + call_command("create_certificates", order=orders[0].uid), "1" + ) + self.assertEqual(certificate.count(), 1) + + # Then a certificate should be generated for the 2nd order + with self.assertNumQueries(7): + self.assertEqual( + call_command("create_certificates", order=orders[1].uid), "1" + ) + self.assertEqual(certificate.count(), 2) + + def test_commands_create_certificates_can_be_restricted_to_course(self): + """ + If `course` option is used, the review is restricted to it. + """ + # Create a certifying product used in two courses + # Then create one order per course + course_run = factories.CourseRunFactory( + enrollment_end=timezone.now() + timedelta(hours=1), + enrollment_start=timezone.now() - timedelta(hours=1), + is_gradable=True, + start=timezone.now() - timedelta(hours=1), + ) + product = factories.ProductFactory( + price="0.00", + type=enums.PRODUCT_TYPE_CREDENTIAL, + target_courses=[course_run.course], + ) + [course_1, course_2] = factories.CourseFactory.create_batch( + 2, products=[product] + ) + orders = [ + factories.OrderFactory(product=product, course=course_1), + factories.OrderFactory(product=product, course=course_2), + ] + certificate = models.Certificate.objects.filter(order__in=orders) + + self.assertEqual(certificate.count(), 0) + + # A certificate should be generated for the 1st course + with self.assertNumQueries(9): + self.assertEqual( + call_command("create_certificates", course=course_1.code), "1" + ) + self.assertEqual(certificate.count(), 1) + + # Then a certificate should be generated for the 2nd course + with self.assertNumQueries(8): + self.assertEqual( + call_command("create_certificates", course=course_2.code), "1" + ) + self.assertEqual(certificate.count(), 2) + + def test_commands_create_certificates_can_be_restricted_to_product(self): + """ + If `product` option is used, the review is restricted to it. + """ + # Create two certifying products with order eligible for certification. + [cr1, cr2] = factories.CourseRunFactory.create_batch( + 2, + enrollment_end=timezone.now() + timedelta(hours=1), + enrollment_start=timezone.now() - timedelta(hours=1), + is_gradable=True, + start=timezone.now() - timedelta(hours=1), + ) + product_1 = factories.ProductFactory( + price="0.00", + type=enums.PRODUCT_TYPE_CREDENTIAL, + target_courses=[cr1.course], + ) + product_2 = factories.ProductFactory( + price="0.00", + type=enums.PRODUCT_TYPE_CREDENTIAL, + target_courses=[cr2.course], + ) + course = factories.CourseFactory(products=[product_1, product_2]) + orders = [ + factories.OrderFactory(course=course, product=product_1), + factories.OrderFactory(course=course, product=product_2), + ] + certificate = models.Certificate.objects.filter(order__in=orders) + + self.assertEqual(certificate.count(), 0) + + # A certificate should be generated for the 1st product + with self.assertNumQueries(9): + self.assertEqual( + call_command("create_certificates", product=product_1.uid), "1" + ) + self.assertEqual(certificate.count(), 1) + + # Then a certificate should be generated for the 2nd product + with self.assertNumQueries(8): + self.assertEqual( + call_command("create_certificates", product=product_2.uid), "1" + ) + self.assertEqual(certificate.count(), 2) + + def test_commands_create_certificates_can_be_restricted_to_product_course(self): + """ + `product` and `course` options can be used together to restrict review to them. + """ + # Create two certifying products with order eligible for certification. + [cr1, cr2] = factories.CourseRunFactory.create_batch( + 2, + enrollment_end=timezone.now() + timedelta(hours=1), + enrollment_start=timezone.now() - timedelta(hours=1), + is_gradable=True, + start=timezone.now() - timedelta(hours=1), + ) + product_1 = factories.ProductFactory( + price="0.00", + type=enums.PRODUCT_TYPE_CREDENTIAL, + target_courses=[cr1.course], + ) + product_2 = factories.ProductFactory( + price="0.00", + type=enums.PRODUCT_TYPE_CREDENTIAL, + target_courses=[cr2.course], + ) + [course_1, course_2] = factories.CourseFactory.create_batch( + 2, products=[product_1, product_2] + ) + + # Create orders for each course product couples + orders = [ + factories.OrderFactory(course=course_1, product=product_1), + factories.OrderFactory(course=course_1, product=product_2), + factories.OrderFactory(course=course_2, product=product_1), + factories.OrderFactory(course=course_2, product=product_2), + ] + certificate = models.Certificate.objects.filter(order__in=orders) + + self.assertEqual(certificate.count(), 0) + + # A certificate should be generated for the couple course_1 - product_1 + with self.assertNumQueries(9): + self.assertEqual( + call_command( + "create_certificates", course=course_1.code, product=product_1.uid + ), + "1", + ) + self.assertEqual(certificate.count(), 1) + + # Then a certificate should be generated for the couple course_1 - product_2 + with self.assertNumQueries(8): + self.assertEqual( + call_command( + "create_certificates", course=course_1.code, product=product_2.uid + ), + "1", + ) + self.assertEqual(certificate.count(), 2) + + # Then a certificate should be generated for the couple course_2 - product_1 + with self.assertNumQueries(8): + self.assertEqual( + call_command( + "create_certificates", course=course_2.code, product=product_1.uid + ), + "1", + ) + self.assertEqual(certificate.count(), 3) + + # Finally, a certificate should be generated for the couple course_2 - product_2 + with self.assertNumQueries(7): + self.assertEqual( + call_command( + "create_certificates", course=course_2.code, product=product_2.uid + ), + "1", + ) + self.assertEqual(certificate.count(), 4)