Skip to content

Commit

Permalink
✨(models) add is_passed cached property to enrollment model
Browse files Browse the repository at this point in the history
This property is a shortcut which retrieve the enrollment grade from its related
LMS then check if it is passed. To prevent to spam LMS, the setting
`JOANIE_ENROLLMENT_GRADE_CACHE_TTL` is used to store grade state in cache.
By default, for 10 minutes.
  • Loading branch information
jbpenrath committed Apr 1, 2022
1 parent c3f3d63 commit f048cb0
Show file tree
Hide file tree
Showing 6 changed files with 78 additions and 30 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 `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
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
61 changes: 50 additions & 11 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 @@ -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):
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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(
_(
(
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down
18 changes: 9 additions & 9 deletions src/backend/joanie/lms_handler/backends/dummy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
],
Expand Down
16 changes: 8 additions & 8 deletions src/backend/joanie/lms_handler/tests/test_backend_dummy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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%",
}
],
)
Expand All @@ -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",
}
],
Expand Down
7 changes: 5 additions & 2 deletions src/backend/joanie/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
Expand Down Expand Up @@ -357,15 +358,17 @@ class Test(Base):
"API_TOKEN": "FakeEdXAPIKey",
"BACKEND": "joanie.lms_handler.backends.dummy.DummyLMSBackend",
"BASE_URL": "http://localhost:8073",
"SELECTOR_REGEX": r"^.*/courses/(?P<course_id>.*)/course/?",
"COURSE_REGEX": r"^.*/courses/(?P<course_id>.*)/course/?$",
"SELECTOR_REGEX": r"^(?P<course_id>.*)$",
"COURSE_REGEX": r"^(?P<course_id>.*)$",
}
]

JOANIE_PAYMENT_BACKEND = {
"backend": "joanie.payment.backends.dummy.DummyPaymentBackend",
}

JOANIE_ENROLLMENT_GRADE_CACHE_TTL = 0


class ContinuousIntegration(Test):
"""
Expand Down

0 comments on commit f048cb0

Please sign in to comment.