diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e392e0bcb..e865b9b217 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to ### Added +- Add `is_passed` cached property to enrollment model - Add an api endpoint to retrieve and download certificates - Add a `get_grades` method to LMSHandler - Add a computed "state" to Course and CourseRun models 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/models/products.py b/src/backend/joanie/core/models/products.py index 8789fca1b6..1b4203135a 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() @@ -138,7 +135,7 @@ class Meta: verbose_name_plural = _("Courses relations to products with a position") def __str__(self): - return f"{self.product}: {self.position}/ {self.course}]" + return f"{self.product}: {self.position} / {self.course}" class Order(models.Model): @@ -267,7 +264,10 @@ def save(self, *args, **kwargs): # - Generate order course relation for relation in ProductCourseRelation.objects.filter(product=self.product): OrderCourseRelation.objects.create( - order=self, course=relation.course, position=relation.position + order=self, + course=relation.course, + position=relation.position, + is_graded=relation.is_graded, ) self.validate() @@ -326,7 +326,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( _( ( @@ -383,7 +383,7 @@ class Meta: verbose_name_plural = _("Courses relations to orders with a position") def __str__(self): - return f"{self.order}: {self.position}/ {self.course}]" + return f"{self.order}: {self.position} / {self.course}" class Enrollment(models.Model): @@ -435,6 +435,45 @@ 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 grade then return the `passed` property value or False""" + grade = self.get_grade() + + return grade["passed"] if grade else False + + def get_grade(self): + """Retrieve the grade from the related LMS then store result in cache.""" + grade = cache.get(self.grade_cache_key) + + if grade is None: + lms = LMSHandler.select_lms(self.course_run.resource_link) + + if lms is None: + logger.error( + "Course run %s has no related lms.", + self.course_run.id, + ) + else: + try: + grade = lms.get_grades( + username=self.user.username, + resource_link=self.course_run.resource_link, + ) + except GradeError: + pass + + cache.set( + self.grade_cache_key, grade, settings.JOANIE_ENROLLMENT_GRADE_CACHE_TTL + ) + + 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 diff --git a/src/backend/joanie/lms_handler/backends/dummy.py b/src/backend/joanie/lms_handler/backends/dummy.py index 5e20c5f069..893cc5b219 100644 --- a/src/backend/joanie/lms_handler/backends/dummy.py +++ b/src/backend/joanie/lms_handler/backends/dummy.py @@ -81,28 +81,28 @@ def get_grades(self, username, resource_link): Get a fake user's grade for a course run given its resource_link. The return dict looks like a grade summary of a course run which has only one - graded exercice called "Final Exam" which have a grade of 0.0. + graded exercice called "Final Exam" which have a grade of 1.0. """ return { - "passed": False, - "grade": None, - "percent": 0.0, + "passed": True, + "grade": "A", + "percent": 1.0, "totaled_scores": { - "Final Exam": [[0.0, 1.0, True, "First section", None]], + "Final Exam": [[1.0, 1.0, True, "First section", None]], }, "grade_breakdown": [ { "category": "Final Exam", - "percent": 0.0, - "detail": "Final Exam = 0.00% of a possible 0.00%", + "percent": 1.0, + "detail": "Final Exam = 100.00% of a possible 100.00%", } ], "section_breakdown": [ { "category": "Final Exam", "prominent": True, - "percent": 0.0, - "detail": "Final Exam = 0%", + "percent": 1.0, + "detail": "Final Exam = 100%", "label": "FE", }, ], diff --git a/src/backend/joanie/lms_handler/tests/test_backend_dummy.py b/src/backend/joanie/lms_handler/tests/test_backend_dummy.py index 0ef7c87a39..62bb2e754a 100644 --- a/src/backend/joanie/lms_handler/tests/test_backend_dummy.py +++ b/src/backend/joanie/lms_handler/tests/test_backend_dummy.py @@ -114,18 +114,18 @@ def test_backend_dummy_get_grades(self): self.assertEqual(len(grade_summary), 6) # - a boolean `passed` - self.assertEqual(grade_summary["passed"], False) + self.assertEqual(grade_summary["passed"], True) # - a string `grade` - self.assertEqual(grade_summary["grade"], None) + self.assertEqual(grade_summary["grade"], "A") # - a float `percent` - self.assertEqual(grade_summary["percent"], 0.0) + self.assertEqual(grade_summary["percent"], 1.0) # - a dict `totaled_scores` self.assertEqual( grade_summary["totaled_scores"], - {"Final Exam": [[0.0, 1.0, True, "First section", None]]}, + {"Final Exam": [[1.0, 1.0, True, "First section", None]]}, ) # - a list `grade_breakdown` @@ -134,8 +134,8 @@ def test_backend_dummy_get_grades(self): [ { "category": "Final Exam", - "percent": 0.0, - "detail": "Final Exam = 0.00% of a possible 0.00%", + "percent": 1.0, + "detail": "Final Exam = 100.00% of a possible 100.00%", } ], ) @@ -147,8 +147,8 @@ def test_backend_dummy_get_grades(self): { "category": "Final Exam", "prominent": True, - "percent": 0.0, - "detail": "Final Exam = 0%", + "percent": 1.0, + "detail": "Final Exam = 100%", "label": "FE", } ], diff --git a/src/backend/joanie/settings.py b/src/backend/joanie/settings.py index 0eef686b5d..bfa9f090ec 100644 --- a/src/backend/joanie/settings.py +++ b/src/backend/joanie/settings.py @@ -202,6 +202,7 @@ class Base(Configuration): } JOANIE_ANONYMOUS_COURSE_SERIALIZER_CACHE_TTL = 3600 # 1 hour + JOANIE_ENROLLMENT_GRADE_CACHE_TTL = 600 # 10 minutes LANGUAGES = ( ("en-us", _("English")), @@ -357,8 +358,8 @@ class Test(Base): "API_TOKEN": "FakeEdXAPIKey", "BACKEND": "joanie.lms_handler.backends.dummy.DummyLMSBackend", "BASE_URL": "http://localhost:8073", - "SELECTOR_REGEX": r"^.*/courses/(?P.*)/course/?", - "COURSE_REGEX": r"^.*/courses/(?P.*)/course/?$", + "SELECTOR_REGEX": r"^(?P.*)$", + "COURSE_REGEX": r"^(?P.*)$", } ] @@ -366,6 +367,8 @@ class Test(Base): "backend": "joanie.payment.backends.dummy.DummyPaymentBackend", } + JOANIE_ENROLLMENT_GRADE_CACHE_TTL = 0 + class ContinuousIntegration(Test): """