Commit adb88e21 by Nimisha Asthagiri

Bulk-reads and Request caching in Course Grade Report

This reverts commit 5388d5d1.
parent 544d5d59
...@@ -9,8 +9,11 @@ from config_models.models import ConfigurationModel ...@@ -9,8 +9,11 @@ from config_models.models import ConfigurationModel
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.db.models import Q from django.db.models import Q
from django.dispatch import receiver
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from openedx.core.djangoapps.xmodule_django.models import CourseKeyField from openedx.core.djangoapps.xmodule_django.models import CourseKeyField
from request_cache.middleware import ns_request_cached, RequestCache
Mode = namedtuple('Mode', Mode = namedtuple('Mode',
[ [
...@@ -141,6 +144,8 @@ class CourseMode(models.Model): ...@@ -141,6 +144,8 @@ class CourseMode(models.Model):
DEFAULT_SHOPPINGCART_MODE_SLUG = HONOR DEFAULT_SHOPPINGCART_MODE_SLUG = HONOR
DEFAULT_SHOPPINGCART_MODE = Mode(HONOR, _('Honor'), 0, '', 'usd', None, None, None, None) DEFAULT_SHOPPINGCART_MODE = Mode(HONOR, _('Honor'), 0, '', 'usd', None, None, None, None)
CACHE_NAMESPACE = u"course_modes.CourseMode.cache."
class Meta(object): class Meta(object):
unique_together = ('course_id', 'mode_slug', 'currency') unique_together = ('course_id', 'mode_slug', 'currency')
...@@ -265,6 +270,7 @@ class CourseMode(models.Model): ...@@ -265,6 +270,7 @@ class CourseMode(models.Model):
return [mode.to_tuple() for mode in found_course_modes] return [mode.to_tuple() for mode in found_course_modes]
@classmethod @classmethod
@ns_request_cached(CACHE_NAMESPACE)
def modes_for_course(cls, course_id, include_expired=False, only_selectable=True): def modes_for_course(cls, course_id, include_expired=False, only_selectable=True):
""" """
Returns a list of the non-expired modes for a given course id Returns a list of the non-expired modes for a given course id
...@@ -666,6 +672,13 @@ class CourseMode(models.Model): ...@@ -666,6 +672,13 @@ class CourseMode(models.Model):
) )
@receiver(models.signals.post_save, sender=CourseMode)
@receiver(models.signals.post_delete, sender=CourseMode)
def invalidate_course_mode_cache(sender, **kwargs): # pylint: disable=unused-argument
"""Invalidate the cache of course modes. """
RequestCache.clear_request_cache(name=CourseMode.CACHE_NAMESPACE)
class CourseModesArchive(models.Model): class CourseModesArchive(models.Model):
""" """
Store the past values of course_mode that a course had in the past. We decided on having Store the past values of course_mode that a course had in the past. We decided on having
......
...@@ -16,7 +16,7 @@ from opaque_keys.edx.locator import CourseLocator ...@@ -16,7 +16,7 @@ from opaque_keys.edx.locator import CourseLocator
import pytz import pytz
from course_modes.helpers import enrollment_mode_display from course_modes.helpers import enrollment_mode_display
from course_modes.models import CourseMode, Mode from course_modes.models import CourseMode, Mode, invalidate_course_mode_cache
from course_modes.tests.factories import CourseModeFactory from course_modes.tests.factories import CourseModeFactory
...@@ -31,6 +31,9 @@ class CourseModeModelTest(TestCase): ...@@ -31,6 +31,9 @@ class CourseModeModelTest(TestCase):
self.course_key = SlashSeparatedCourseKey('Test', 'TestCourse', 'TestCourseRun') self.course_key = SlashSeparatedCourseKey('Test', 'TestCourse', 'TestCourseRun')
CourseMode.objects.all().delete() CourseMode.objects.all().delete()
def tearDown(self):
invalidate_course_mode_cache(sender=None)
def create_mode( def create_mode(
self, self,
mode_slug, mode_slug,
......
...@@ -41,6 +41,16 @@ def get_cache(name): ...@@ -41,6 +41,16 @@ def get_cache(name):
return middleware.RequestCache.get_request_cache(name) return middleware.RequestCache.get_request_cache(name)
def clear_cache(name):
"""
Clears the request cache named ``name``.
Arguments:
name (str): The name of the request cache to clear
"""
return middleware.RequestCache.clear_request_cache(name)
def get_request(): def get_request():
""" """
Return the current request. Return the current request.
......
...@@ -39,11 +39,14 @@ class RequestCache(object): ...@@ -39,11 +39,14 @@ class RequestCache(object):
return crum.get_current_request() return crum.get_current_request()
@classmethod @classmethod
def clear_request_cache(cls): def clear_request_cache(cls, name=None):
""" """
Empty the request cache. Empty the request cache.
""" """
REQUEST_CACHE.data = {} if name is None:
REQUEST_CACHE.data = {}
elif REQUEST_CACHE.data.get(name):
REQUEST_CACHE.data[name] = {}
def process_request(self, request): def process_request(self, request):
self.clear_request_cache() self.clear_request_cache()
...@@ -82,25 +85,43 @@ def request_cached(f): ...@@ -82,25 +85,43 @@ def request_cached(f):
cache the value it returns, and return that cached value for subsequent calls with the cache the value it returns, and return that cached value for subsequent calls with the
same args/kwargs within a single request same args/kwargs within a single request
""" """
def wrapper(*args, **kwargs): return ns_request_cached()(f)
"""
Wrapper function to decorate with.
"""
# Check to see if we have a result in cache. If not, invoke our wrapped
# function. Cache and return the result to the caller.
rcache = RequestCache.get_request_cache()
cache_key = func_call_cache_key(f, *args, **kwargs)
if cache_key in rcache.data:
return rcache.data.get(cache_key)
else:
result = f(*args, **kwargs)
rcache.data[cache_key] = result
return result def ns_request_cached(namespace=None):
"""
Same as request_cached above, except an optional namespace can be passed in to compartmentalize the cache.
Arguments:
namespace (string): An optional namespace to use for the cache. Useful if the caller wants to manage
their own sub-cache by, for example, calling RequestCache.clear_request_cache for their own namespace.
"""
def outer_wrapper(f):
"""
Outer wrapper that decorates the given function
wrapper.request_cached_contained_func = f Arguments:
return wrapper f (func): the function to wrap
"""
def inner_wrapper(*args, **kwargs):
"""
Wrapper function to decorate with.
"""
# Check to see if we have a result in cache. If not, invoke our wrapped
# function. Cache and return the result to the caller.
rcache = RequestCache.get_request_cache(namespace)
rcache = rcache.data if namespace is None else rcache
cache_key = func_call_cache_key(f, *args, **kwargs)
if cache_key in rcache:
return rcache.get(cache_key)
else:
result = f(*args, **kwargs)
rcache[cache_key] = result
return result
return inner_wrapper
return outer_wrapper
def func_call_cache_key(func, *args, **kwargs): def func_call_cache_key(func, *args, **kwargs):
......
...@@ -998,7 +998,9 @@ class CourseEnrollment(models.Model): ...@@ -998,7 +998,9 @@ class CourseEnrollment(models.Model):
history = HistoricalRecords() history = HistoricalRecords()
# cache key format e.g enrollment.<username>.<course_key>.mode = 'honor' # cache key format e.g enrollment.<username>.<course_key>.mode = 'honor'
COURSE_ENROLLMENT_CACHE_KEY = u"enrollment.{}.{}.mode" COURSE_ENROLLMENT_CACHE_KEY = u"enrollment.{}.{}.mode" # TODO Can this be removed? It doesn't seem to be used.
MODE_CACHE_NAMESPACE = u'CourseEnrollment.mode_and_active'
class Meta(object): class Meta(object):
unique_together = (('user', 'course_id'),) unique_together = (('user', 'course_id'),)
...@@ -1698,11 +1700,27 @@ class CourseEnrollment(models.Model): ...@@ -1698,11 +1700,27 @@ class CourseEnrollment(models.Model):
return enrollment_state return enrollment_state
@classmethod @classmethod
def bulk_fetch_enrollment_states(cls, users, course_key):
"""
Bulk pre-fetches the enrollment states for the given users
for the given course.
"""
# before populating the cache with another bulk set of data,
# remove previously cached entries to keep memory usage low.
request_cache.clear_cache(cls.MODE_CACHE_NAMESPACE)
records = cls.objects.filter(user__in=users, course_id=course_key).select_related('user__id')
cache = cls._get_mode_active_request_cache()
for record in records:
enrollment_state = CourseEnrollmentState(record.mode, record.is_active)
cls._update_enrollment(cache, record.user.id, course_key, enrollment_state)
@classmethod
def _get_mode_active_request_cache(cls): def _get_mode_active_request_cache(cls):
""" """
Returns the request-specific cache for CourseEnrollment Returns the request-specific cache for CourseEnrollment
""" """
return request_cache.get_cache('CourseEnrollment.mode_and_active') return request_cache.get_cache(cls.MODE_CACHE_NAMESPACE)
@classmethod @classmethod
def _get_enrollment_in_request_cache(cls, user, course_key): def _get_enrollment_in_request_cache(cls, user, course_key):
...@@ -1718,7 +1736,15 @@ class CourseEnrollment(models.Model): ...@@ -1718,7 +1736,15 @@ class CourseEnrollment(models.Model):
Updates the cached value for the user's enrollment in the Updates the cached value for the user's enrollment in the
request cache. request cache.
""" """
cls._get_mode_active_request_cache()[(user.id, course_key)] = enrollment_state cls._update_enrollment(cls._get_mode_active_request_cache(), user.id, course_key, enrollment_state)
@classmethod
def _update_enrollment(cls, cache, user_id, course_key, enrollment_state):
"""
Updates the cached value for the user's enrollment in the
given cache.
"""
cache[(user_id, course_key)] = enrollment_state
@receiver(models.signals.post_save, sender=CourseEnrollment) @receiver(models.signals.post_save, sender=CourseEnrollment)
......
...@@ -4,10 +4,12 @@ adding users, removing users, and listing members ...@@ -4,10 +4,12 @@ adding users, removing users, and listing members
""" """
from abc import ABCMeta, abstractmethod from abc import ABCMeta, abstractmethod
from collections import defaultdict
from django.contrib.auth.models import User from django.contrib.auth.models import User
import logging import logging
from request_cache import get_cache
from student.models import CourseAccessRole from student.models import CourseAccessRole
from openedx.core.djangoapps.xmodule_django.models import CourseKeyField from openedx.core.djangoapps.xmodule_django.models import CourseKeyField
...@@ -34,14 +36,38 @@ def register_access_role(cls): ...@@ -34,14 +36,38 @@ def register_access_role(cls):
return cls return cls
class BulkRoleCache(object):
CACHE_NAMESPACE = u"student.roles.BulkRoleCache"
CACHE_KEY = u'roles_by_user'
@classmethod
def prefetch(cls, users):
roles_by_user = defaultdict(set)
get_cache(cls.CACHE_NAMESPACE)[cls.CACHE_KEY] = roles_by_user
for role in CourseAccessRole.objects.filter(user__in=users).select_related('user__id'):
roles_by_user[role.user.id].add(role)
users_without_roles = filter(lambda u: u.id not in roles_by_user, users)
for user in users_without_roles:
roles_by_user[user.id] = set()
@classmethod
def get_user_roles(cls, user):
return get_cache(cls.CACHE_NAMESPACE)[cls.CACHE_KEY][user.id]
class RoleCache(object): class RoleCache(object):
""" """
A cache of the CourseAccessRoles held by a particular user A cache of the CourseAccessRoles held by a particular user
""" """
def __init__(self, user): def __init__(self, user):
self._roles = set( try:
CourseAccessRole.objects.filter(user=user).all() self._roles = BulkRoleCache.get_user_roles(user)
) except KeyError:
self._roles = set(
CourseAccessRole.objects.filter(user=user).all()
)
def has_role(self, role, course_id, org): def has_role(self, role, course_id, org):
""" """
......
...@@ -493,6 +493,18 @@ def handle_course_cert_awarded(sender, user, course_key, **kwargs): # pylint: d ...@@ -493,6 +493,18 @@ def handle_course_cert_awarded(sender, user, course_key, **kwargs): # pylint: d
def certificate_status_for_student(student, course_id): def certificate_status_for_student(student, course_id):
"""
This returns a dictionary with a key for status, and other information.
See certificate_status for more information.
"""
try:
generated_certificate = GeneratedCertificate.objects.get(user=student, course_id=course_id)
except GeneratedCertificate.DoesNotExist:
generated_certificate = None
return certificate_status(generated_certificate)
def certificate_status(generated_certificate):
''' '''
This returns a dictionary with a key for status, and other information. This returns a dictionary with a key for status, and other information.
The status is one of the following: The status is one of the following:
...@@ -527,9 +539,7 @@ def certificate_status_for_student(student, course_id): ...@@ -527,9 +539,7 @@ def certificate_status_for_student(student, course_id):
# the course_modes app is loaded, resulting in a Django deprecation warning. # the course_modes app is loaded, resulting in a Django deprecation warning.
from course_modes.models import CourseMode from course_modes.models import CourseMode
try: if generated_certificate:
generated_certificate = GeneratedCertificate.objects.get( # pylint: disable=no-member
user=student, course_id=course_id)
cert_status = { cert_status = {
'status': generated_certificate.status, 'status': generated_certificate.status,
'mode': generated_certificate.mode, 'mode': generated_certificate.mode,
...@@ -539,7 +549,7 @@ def certificate_status_for_student(student, course_id): ...@@ -539,7 +549,7 @@ def certificate_status_for_student(student, course_id):
cert_status['grade'] = generated_certificate.grade cert_status['grade'] = generated_certificate.grade
if generated_certificate.mode == 'audit': if generated_certificate.mode == 'audit':
course_mode_slugs = [mode.slug for mode in CourseMode.modes_for_course(course_id)] course_mode_slugs = [mode.slug for mode in CourseMode.modes_for_course(generated_certificate.course_id)]
# Short term fix to make sure old audit users with certs still see their certs # Short term fix to make sure old audit users with certs still see their certs
# only do this if there if no honor mode # only do this if there if no honor mode
if 'honor' not in course_mode_slugs: if 'honor' not in course_mode_slugs:
...@@ -550,31 +560,24 @@ def certificate_status_for_student(student, course_id): ...@@ -550,31 +560,24 @@ def certificate_status_for_student(student, course_id):
cert_status['download_url'] = generated_certificate.download_url cert_status['download_url'] = generated_certificate.download_url
return cert_status return cert_status
else:
except GeneratedCertificate.DoesNotExist: return {'status': CertificateStatuses.unavailable, 'mode': GeneratedCertificate.MODES.honor, 'uuid': None}
pass
return {'status': CertificateStatuses.unavailable, 'mode': GeneratedCertificate.MODES.honor, 'uuid': None}
def certificate_info_for_user(user, course_id, grade, user_is_whitelisted=None): def certificate_info_for_user(user, grade, user_is_whitelisted, user_certificate):
""" """
Returns the certificate info for a user for grade report. Returns the certificate info for a user for grade report.
""" """
if user_is_whitelisted is None:
user_is_whitelisted = CertificateWhitelist.objects.filter(
user=user, course_id=course_id, whitelist=True
).exists()
certificate_is_delivered = 'N' certificate_is_delivered = 'N'
certificate_type = 'N/A' certificate_type = 'N/A'
eligible_for_certificate = 'Y' if (user_is_whitelisted or grade is not None) and user.profile.allow_certificate \ eligible_for_certificate = 'Y' if (user_is_whitelisted or grade is not None) and user.profile.allow_certificate \
else 'N' else 'N'
certificate_status = certificate_status_for_student(user, course_id) status = certificate_status(user_certificate)
certificate_generated = certificate_status['status'] == CertificateStatuses.downloadable certificate_generated = status['status'] == CertificateStatuses.downloadable
if certificate_generated: if certificate_generated:
certificate_is_delivered = 'Y' certificate_is_delivered = 'Y'
certificate_type = certificate_status['mode'] certificate_type = status['mode']
return [eligible_for_certificate, certificate_is_delivered, certificate_type] return [eligible_for_certificate, certificate_is_delivered, certificate_type]
......
...@@ -54,11 +54,11 @@ class CertificatesModelTest(ModuleStoreTestCase, MilestonesTestCaseMixin): ...@@ -54,11 +54,11 @@ class CertificatesModelTest(ModuleStoreTestCase, MilestonesTestCaseMixin):
Verify that certificate_info_for_user works. Verify that certificate_info_for_user works.
""" """
student = UserFactory() student = UserFactory()
course = CourseFactory.create(org='edx', number='verified', display_name='Verified Course') _ = CourseFactory.create(org='edx', number='verified', display_name='Verified Course')
student.profile.allow_certificate = allow_certificate student.profile.allow_certificate = allow_certificate
student.profile.save() student.profile.save()
certificate_info = certificate_info_for_user(student, course.id, grade, whitelisted) certificate_info = certificate_info_for_user(student, grade, whitelisted, user_certificate=None)
self.assertEqual(certificate_info, output) self.assertEqual(certificate_info, output)
@unpack @unpack
...@@ -81,14 +81,13 @@ class CertificatesModelTest(ModuleStoreTestCase, MilestonesTestCaseMixin): ...@@ -81,14 +81,13 @@ class CertificatesModelTest(ModuleStoreTestCase, MilestonesTestCaseMixin):
student.profile.allow_certificate = allow_certificate student.profile.allow_certificate = allow_certificate
student.profile.save() student.profile.save()
GeneratedCertificateFactory.create( certificate = GeneratedCertificateFactory.create(
user=student, user=student,
course_id=course.id, course_id=course.id,
status=CertificateStatuses.downloadable, status=CertificateStatuses.downloadable,
mode='honor' mode='honor'
) )
certificate_info = certificate_info_for_user(student, grade, whitelisted, certificate)
certificate_info = certificate_info_for_user(student, course.id, grade, whitelisted)
self.assertEqual(certificate_info, output) self.assertEqual(certificate_info, output)
def test_course_ids_with_certs_for_user(self): def test_course_ids_with_certs_for_user(self):
......
...@@ -25,6 +25,7 @@ from track.event_transaction_utils import get_event_transaction_id, get_event_tr ...@@ -25,6 +25,7 @@ from track.event_transaction_utils import get_event_transaction_id, get_event_tr
from coursewarehistoryextended.fields import UnsignedBigIntAutoField from coursewarehistoryextended.fields import UnsignedBigIntAutoField
from opaque_keys.edx.keys import CourseKey, UsageKey from opaque_keys.edx.keys import CourseKey, UsageKey
from openedx.core.djangoapps.xmodule_django.models import CourseKeyField, UsageKeyField from openedx.core.djangoapps.xmodule_django.models import CourseKeyField, UsageKeyField
from request_cache import get_cache
from .config import waffle from .config import waffle
...@@ -522,6 +523,8 @@ class PersistentCourseGrade(DeleteGradesMixin, TimeStampedModel): ...@@ -522,6 +523,8 @@ class PersistentCourseGrade(DeleteGradesMixin, TimeStampedModel):
# Information related to course completion # Information related to course completion
passed_timestamp = models.DateTimeField(u'Date learner earned a passing grade', blank=True, null=True) passed_timestamp = models.DateTimeField(u'Date learner earned a passing grade', blank=True, null=True)
CACHE_NAMESPACE = u"grades.models.PersistentCourseGrade"
def __unicode__(self): def __unicode__(self):
""" """
Returns a string representation of this model. Returns a string representation of this model.
...@@ -536,6 +539,21 @@ class PersistentCourseGrade(DeleteGradesMixin, TimeStampedModel): ...@@ -536,6 +539,21 @@ class PersistentCourseGrade(DeleteGradesMixin, TimeStampedModel):
]) ])
@classmethod @classmethod
def _cache_key(cls, course_id):
return u"grades_cache.{}".format(course_id)
@classmethod
def prefetch(cls, course_id, users):
"""
Prefetches grades for the given users for the given course.
"""
get_cache(cls.CACHE_NAMESPACE)[cls._cache_key(course_id)] = {
grade.user_id: grade
for grade in
cls.objects.filter(user_id__in=[user.id for user in users], course_id=course_id)
}
@classmethod
def read(cls, user_id, course_id): def read(cls, user_id, course_id):
""" """
Reads a grade from database Reads a grade from database
...@@ -546,7 +564,17 @@ class PersistentCourseGrade(DeleteGradesMixin, TimeStampedModel): ...@@ -546,7 +564,17 @@ class PersistentCourseGrade(DeleteGradesMixin, TimeStampedModel):
Raises PersistentCourseGrade.DoesNotExist if applicable Raises PersistentCourseGrade.DoesNotExist if applicable
""" """
return cls.objects.get(user_id=user_id, course_id=course_id) try:
prefetched_grades = get_cache(cls.CACHE_NAMESPACE)[cls._cache_key(course_id)]
try:
return prefetched_grades[user_id]
except KeyError:
# user's grade is not in the prefetched list, so
# assume they have no grade
raise cls.DoesNotExist
except KeyError:
# grades were not prefetched for the course, so fetch it
return cls.objects.get(user_id=user_id, course_id=course_id)
@classmethod @classmethod
def update_or_create(cls, user_id, course_id, **kwargs): def update_or_create(cls, user_id, course_id, **kwargs):
......
...@@ -81,7 +81,6 @@ class CourseGradeFactory(object): ...@@ -81,7 +81,6 @@ class CourseGradeFactory(object):
users, users,
course=None, course=None,
collected_block_structure=None, collected_block_structure=None,
course_structure=None,
course_key=None, course_key=None,
force_update=False, force_update=False,
): ):
...@@ -99,7 +98,9 @@ class CourseGradeFactory(object): ...@@ -99,7 +98,9 @@ class CourseGradeFactory(object):
# compute the grade for all students. # compute the grade for all students.
# 2. Optimization: the collected course_structure is not # 2. Optimization: the collected course_structure is not
# retrieved from the data store multiple times. # retrieved from the data store multiple times.
course_data = CourseData(None, course, collected_block_structure, course_structure, course_key) course_data = CourseData(
user=None, course=course, collected_block_structure=collected_block_structure, course_key=course_key,
)
for user in users: for user in users:
with dog_stats_api.timer( with dog_stats_api.timer(
'lms.grades.CourseGradeFactory.iter', 'lms.grades.CourseGradeFactory.iter',
...@@ -107,7 +108,9 @@ class CourseGradeFactory(object): ...@@ -107,7 +108,9 @@ class CourseGradeFactory(object):
): ):
try: try:
method = CourseGradeFactory().update if force_update else CourseGradeFactory().create method = CourseGradeFactory().update if force_update else CourseGradeFactory().create
course_grade = method(user, course, course_data.collected_structure, course_structure, course_key) course_grade = method(
user, course_data.course, course_data.collected_structure, course_key=course_key,
)
yield self.GradeResult(user, course_grade, None) yield self.GradeResult(user, course_grade, None)
except Exception as exc: # pylint: disable=broad-except except Exception as exc: # pylint: disable=broad-except
......
...@@ -178,10 +178,10 @@ class TestCourseGradeFactory(GradeTestBase): ...@@ -178,10 +178,10 @@ class TestCourseGradeFactory(GradeTestBase):
self.assertEqual(course_grade.letter_grade, u'Pass' if expected_pass else None) self.assertEqual(course_grade.letter_grade, u'Pass' if expected_pass else None)
self.assertEqual(course_grade.percent, 0.5) self.assertEqual(course_grade.percent, 0.5)
with self.assertNumQueries(12), mock_get_score(1, 2): with self.assertNumQueries(11), mock_get_score(1, 2):
_assert_create(expected_pass=True) _assert_create(expected_pass=True)
with self.assertNumQueries(15), mock_get_score(1, 2): with self.assertNumQueries(13), mock_get_score(1, 2):
grade_factory.update(self.request.user, self.course) grade_factory.update(self.request.user, self.course)
with self.assertNumQueries(1): with self.assertNumQueries(1):
...@@ -189,7 +189,7 @@ class TestCourseGradeFactory(GradeTestBase): ...@@ -189,7 +189,7 @@ class TestCourseGradeFactory(GradeTestBase):
self._update_grading_policy(passing=0.9) self._update_grading_policy(passing=0.9)
with self.assertNumQueries(8): with self.assertNumQueries(6):
_assert_create(expected_pass=False) _assert_create(expected_pass=False)
@ddt.data(True, False) @ddt.data(True, False)
......
...@@ -409,8 +409,8 @@ class ComputeGradesForCourseTest(HasCourseWithProblemsMixin, ModuleStoreTestCase ...@@ -409,8 +409,8 @@ class ComputeGradesForCourseTest(HasCourseWithProblemsMixin, ModuleStoreTestCase
@ddt.data(*xrange(1, 12, 3)) @ddt.data(*xrange(1, 12, 3))
def test_database_calls(self, batch_size): def test_database_calls(self, batch_size):
per_user_queries = 17 * min(batch_size, 6) # No more than 6 due to offset per_user_queries = 15 * min(batch_size, 6) # No more than 6 due to offset
with self.assertNumQueries(5 + per_user_queries): with self.assertNumQueries(6 + per_user_queries):
with check_mongo_calls(1): with check_mongo_calls(1):
compute_grades_for_course_v2.delay( compute_grades_for_course_v2.delay(
course_key=six.text_type(self.course.id), course_key=six.text_type(self.course.id),
......
...@@ -34,9 +34,11 @@ from lms.djangoapps.teams.tests.factories import CourseTeamFactory, CourseTeamMe ...@@ -34,9 +34,11 @@ from lms.djangoapps.teams.tests.factories import CourseTeamFactory, CourseTeamMe
from lms.djangoapps.verify_student.tests.factories import SoftwareSecurePhotoVerificationFactory from lms.djangoapps.verify_student.tests.factories import SoftwareSecurePhotoVerificationFactory
from openedx.core.djangoapps.course_groups.models import CourseUserGroupPartitionGroup, CohortMembership from openedx.core.djangoapps.course_groups.models import CourseUserGroupPartitionGroup, CohortMembership
from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory
from openedx.core.djangoapps.credit.tests.factories import CreditCourseFactory
import openedx.core.djangoapps.user_api.course_tag.api as course_tag_api import openedx.core.djangoapps.user_api.course_tag.api as course_tag_api
from openedx.core.djangoapps.user_api.partition_schemes import RandomUserPartitionScheme from openedx.core.djangoapps.user_api.partition_schemes import RandomUserPartitionScheme
from openedx.core.djangoapps.util.testing import ContentGroupTestCase, TestConditionalContent from openedx.core.djangoapps.util.testing import ContentGroupTestCase, TestConditionalContent
from request_cache.middleware import RequestCache
from shoppingcart.models import ( from shoppingcart.models import (
Order, PaidCourseRegistration, CourseRegistrationCode, Invoice, Order, PaidCourseRegistration, CourseRegistrationCode, Invoice,
CourseRegistrationCodeInvoiceItem, InvoiceTransaction, Coupon CourseRegistrationCodeInvoiceItem, InvoiceTransaction, Coupon
...@@ -44,8 +46,9 @@ from shoppingcart.models import ( ...@@ -44,8 +46,9 @@ from shoppingcart.models import (
from student.models import CourseEnrollment, CourseEnrollmentAllowed, ManualEnrollmentAudit, ALLOWEDTOENROLL_TO_ENROLLED from student.models import CourseEnrollment, CourseEnrollmentAllowed, ManualEnrollmentAudit, ALLOWEDTOENROLL_TO_ENROLLED
from student.tests.factories import CourseEnrollmentFactory, CourseModeFactory, UserFactory from student.tests.factories import CourseEnrollmentFactory, CourseModeFactory, UserFactory
from survey.models import SurveyForm, SurveyAnswer from survey.models import SurveyForm, SurveyAnswer
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, check_mongo_calls
from xmodule.partitions.partitions import Group, UserPartition from xmodule.partitions.partitions import Group, UserPartition
from ..models import ReportStore from ..models import ReportStore
...@@ -321,6 +324,44 @@ class TestInstructorGradeReport(InstructorGradeReportTestCase): ...@@ -321,6 +324,44 @@ class TestInstructorGradeReport(InstructorGradeReportTestCase):
result = CourseGradeReport.generate(None, None, self.course.id, None, 'graded') result = CourseGradeReport.generate(None, None, self.course.id, None, 'graded')
self.assertDictContainsSubset({'attempted': 1, 'succeeded': 1, 'failed': 0}, result) self.assertDictContainsSubset({'attempted': 1, 'succeeded': 1, 'failed': 0}, result)
@ddt.data(
(ModuleStoreEnum.Type.mongo, 4),
(ModuleStoreEnum.Type.split, 3),
)
@ddt.unpack
def test_query_counts(self, store_type, mongo_count):
with self.store.default_store(store_type):
experiment_group_a = Group(2, u'Expériment Group A')
experiment_group_b = Group(3, u'Expériment Group B')
experiment_partition = UserPartition(
1,
u'Content Expériment Configuration',
u'Group Configuration for Content Expériments',
[experiment_group_a, experiment_group_b],
scheme_id='random'
)
course = CourseFactory.create(
cohort_config={'cohorted': True, 'auto_cohort': True, 'auto_cohort_groups': ['cohort 1', 'cohort 2']},
user_partitions=[experiment_partition],
teams_configuration={
'max_size': 2, 'topics': [{'topic-id': 'topic', 'name': 'Topic', 'description': 'A Topic'}]
},
)
_ = CreditCourseFactory(course_key=course.id)
num_users = 5
for _ in range(num_users):
user = UserFactory.create()
CourseEnrollment.enroll(user, course.id, mode='verified')
SoftwareSecurePhotoVerificationFactory.create(user=user, status='approved')
RequestCache.clear_request_cache()
with patch('lms.djangoapps.instructor_task.tasks_helper.runner._get_current_task'):
with check_mongo_calls(mongo_count):
with self.assertNumQueries(41):
CourseGradeReport.generate(None, None, course.id, None, 'graded')
class TestTeamGradeReport(InstructorGradeReportTestCase): class TestTeamGradeReport(InstructorGradeReportTestCase):
""" Test that teams appear correctly in the grade report when it is enabled for the course. """ """ Test that teams appear correctly in the grade report when it is enabled for the course. """
...@@ -1783,7 +1824,7 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase): ...@@ -1783,7 +1824,7 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase):
'failed': 3, 'failed': 3,
'skipped': 2 'skipped': 2
} }
with self.assertNumQueries(186): with self.assertNumQueries(171):
self.assertCertificatesGenerated(task_input, expected_results) self.assertCertificatesGenerated(task_input, expected_results)
expected_results = { expected_results = {
......
...@@ -210,12 +210,19 @@ class PhotoVerification(StatusModel): ...@@ -210,12 +210,19 @@ class PhotoVerification(StatusModel):
This will check for the user's *initial* verification. This will check for the user's *initial* verification.
""" """
return cls.verified_query(earliest_allowed_date).filter(user=user).exists()
@classmethod
def verified_query(cls, earliest_allowed_date=None):
"""
Return a query set for all records with 'approved' state
that are still valid according to the earliest_allowed_date
value or policy settings.
"""
return cls.objects.filter( return cls.objects.filter(
user=user,
status="approved", status="approved",
created_at__gte=(earliest_allowed_date created_at__gte=(earliest_allowed_date or cls._earliest_allowed_date()),
or cls._earliest_allowed_date()) )
).exists()
@classmethod @classmethod
def verification_valid_or_pending(cls, user, earliest_allowed_date=None, queryset=None): def verification_valid_or_pending(cls, user, earliest_allowed_date=None, queryset=None):
...@@ -951,14 +958,15 @@ class SoftwareSecurePhotoVerification(PhotoVerification): ...@@ -951,14 +958,15 @@ class SoftwareSecurePhotoVerification(PhotoVerification):
return response return response
@classmethod @classmethod
def verification_status_for_user(cls, user, course_id, user_enrollment_mode): def verification_status_for_user(cls, user, course_id, user_enrollment_mode, user_is_verified=None):
""" """
Returns the verification status for use in grade report. Returns the verification status for use in grade report.
""" """
if user_enrollment_mode not in CourseMode.VERIFIED_MODES: if user_enrollment_mode not in CourseMode.VERIFIED_MODES:
return 'N/A' return 'N/A'
user_is_verified = cls.user_is_verified(user) if user_is_verified is None:
user_is_verified = cls.user_is_verified(user)
if not user_is_verified: if not user_is_verified:
return 'Not ID Verified' return 'Not ID Verified'
......
...@@ -14,7 +14,8 @@ from django.utils.translation import ugettext as _ ...@@ -14,7 +14,8 @@ from django.utils.translation import ugettext as _
from courseware import courses from courseware import courses
from eventtracking import tracker from eventtracking import tracker
from request_cache.middleware import RequestCache, request_cached import request_cache
from request_cache.middleware import request_cached
from student.models import get_user_by_username_or_email from student.models import get_user_by_username_or_email
from .models import ( from .models import (
...@@ -146,8 +147,45 @@ def get_cohorted_commentables(course_key): ...@@ -146,8 +147,45 @@ def get_cohorted_commentables(course_key):
return ans return ans
COHORT_CACHE_NAMESPACE = u"cohorts.get_cohort"
def _cohort_cache_key(user_id, course_key):
"""
Returns the cache key for the given user_id and course_key.
"""
return u"{}.{}".format(user_id, course_key)
def bulk_cache_cohorts(course_key, users):
"""
Pre-fetches and caches the cohort assignments for the
given users, for later fast retrieval by get_cohort.
"""
# before populating the cache with another bulk set of data,
# remove previously cached entries to keep memory usage low.
request_cache.clear_cache(COHORT_CACHE_NAMESPACE)
cache = request_cache.get_cache(COHORT_CACHE_NAMESPACE)
if is_course_cohorted(course_key):
cohorts_by_user = {
membership.user: membership
for membership in
CohortMembership.objects.filter(user__in=users, course_id=course_key).select_related('user__id')
}
for user, membership in cohorts_by_user.iteritems():
cache[_cohort_cache_key(user.id, course_key)] = membership.course_user_group
uncohorted_users = filter(lambda u: u not in cohorts_by_user, users)
else:
uncohorted_users = users
for user in uncohorted_users:
cache[_cohort_cache_key(user.id, course_key)] = None
def get_cohort(user, course_key, assign=True, use_cached=False): def get_cohort(user, course_key, assign=True, use_cached=False):
"""Returns the user's cohort for the specified course. """
Returns the user's cohort for the specified course.
The cohort for the user is cached for the duration of a request. Pass The cohort for the user is cached for the duration of a request. Pass
use_cached=True to use the cached value instead of fetching from the use_cached=True to use the cached value instead of fetching from the
...@@ -166,19 +204,19 @@ def get_cohort(user, course_key, assign=True, use_cached=False): ...@@ -166,19 +204,19 @@ def get_cohort(user, course_key, assign=True, use_cached=False):
Raises: Raises:
ValueError if the CourseKey doesn't exist. ValueError if the CourseKey doesn't exist.
""" """
request_cache = RequestCache.get_request_cache() cache = request_cache.get_cache(COHORT_CACHE_NAMESPACE)
cache_key = u"cohorts.get_cohort.{}.{}".format(user.id, course_key) cache_key = _cohort_cache_key(user.id, course_key)
if use_cached and cache_key in request_cache.data: if use_cached and cache_key in cache:
return request_cache.data[cache_key] return cache[cache_key]
request_cache.data.pop(cache_key, None) cache.pop(cache_key, None)
# First check whether the course is cohorted (users shouldn't be in a cohort # First check whether the course is cohorted (users shouldn't be in a cohort
# in non-cohorted courses, but settings can change after course starts) # in non-cohorted courses, but settings can change after course starts)
course_cohort_settings = get_course_cohort_settings(course_key) course_cohort_settings = get_course_cohort_settings(course_key)
if not course_cohort_settings.is_cohorted: if not course_cohort_settings.is_cohorted:
return request_cache.data.setdefault(cache_key, None) return cache.setdefault(cache_key, None)
# If course is cohorted, check if the user already has a cohort. # If course is cohorted, check if the user already has a cohort.
try: try:
...@@ -186,7 +224,7 @@ def get_cohort(user, course_key, assign=True, use_cached=False): ...@@ -186,7 +224,7 @@ def get_cohort(user, course_key, assign=True, use_cached=False):
course_id=course_key, course_id=course_key,
user_id=user.id, user_id=user.id,
) )
return request_cache.data.setdefault(cache_key, membership.course_user_group) return cache.setdefault(cache_key, membership.course_user_group)
except CohortMembership.DoesNotExist: except CohortMembership.DoesNotExist:
# Didn't find the group. If we do not want to assign, return here. # Didn't find the group. If we do not want to assign, return here.
if not assign: if not assign:
...@@ -201,7 +239,7 @@ def get_cohort(user, course_key, assign=True, use_cached=False): ...@@ -201,7 +239,7 @@ def get_cohort(user, course_key, assign=True, use_cached=False):
user=user, user=user,
course_user_group=get_random_cohort(course_key) course_user_group=get_random_cohort(course_key)
) )
return request_cache.data.setdefault(cache_key, membership.course_user_group) return cache.setdefault(cache_key, membership.course_user_group)
except IntegrityError as integrity_error: except IntegrityError as integrity_error:
# An IntegrityError is raised when multiple workers attempt to # An IntegrityError is raised when multiple workers attempt to
# create the same row in one of the cohort model entries: # create the same row in one of the cohort model entries:
...@@ -419,21 +457,21 @@ def get_group_info_for_cohort(cohort, use_cached=False): ...@@ -419,21 +457,21 @@ def get_group_info_for_cohort(cohort, use_cached=False):
use_cached=True to use the cached value instead of fetching from the use_cached=True to use the cached value instead of fetching from the
database. database.
""" """
request_cache = RequestCache.get_request_cache() cache = request_cache.get_cache(u"cohorts.get_group_info_for_cohort")
cache_key = u"cohorts.get_group_info_for_cohort.{}".format(cohort.id) cache_key = unicode(cohort.id)
if use_cached and cache_key in request_cache.data: if use_cached and cache_key in cache:
return request_cache.data[cache_key] return cache[cache_key]
request_cache.data.pop(cache_key, None) cache.pop(cache_key, None)
try: try:
partition_group = CourseUserGroupPartitionGroup.objects.get(course_user_group=cohort) partition_group = CourseUserGroupPartitionGroup.objects.get(course_user_group=cohort)
return request_cache.data.setdefault(cache_key, (partition_group.group_id, partition_group.partition_id)) return cache.setdefault(cache_key, (partition_group.group_id, partition_group.partition_id))
except CourseUserGroupPartitionGroup.DoesNotExist: except CourseUserGroupPartitionGroup.DoesNotExist:
pass pass
return request_cache.data.setdefault(cache_key, (None, None)) return cache.setdefault(cache_key, (None, None))
def set_assignment_type(user_group, assignment_type): def set_assignment_type(user_group, assignment_type):
......
...@@ -22,6 +22,7 @@ from model_utils.models import TimeStampedModel ...@@ -22,6 +22,7 @@ from model_utils.models import TimeStampedModel
import pytz import pytz
from simple_history.models import HistoricalRecords from simple_history.models import HistoricalRecords
from openedx.core.djangoapps.xmodule_django.models import CourseKeyField from openedx.core.djangoapps.xmodule_django.models import CourseKeyField
from request_cache.middleware import ns_request_cached, RequestCache
CREDIT_PROVIDER_ID_REGEX = r"[a-z,A-Z,0-9,\-]+" CREDIT_PROVIDER_ID_REGEX = r"[a-z,A-Z,0-9,\-]+"
...@@ -290,6 +291,8 @@ class CreditRequirement(TimeStampedModel): ...@@ -290,6 +291,8 @@ class CreditRequirement(TimeStampedModel):
criteria = JSONField() criteria = JSONField()
active = models.BooleanField(default=True) active = models.BooleanField(default=True)
CACHE_NAMESPACE = u"credit.CreditRequirement.cache."
class Meta(object): class Meta(object):
unique_together = ('namespace', 'name', 'course') unique_together = ('namespace', 'name', 'course')
ordering = ["order"] ordering = ["order"]
...@@ -331,6 +334,7 @@ class CreditRequirement(TimeStampedModel): ...@@ -331,6 +334,7 @@ class CreditRequirement(TimeStampedModel):
return credit_requirement, created return credit_requirement, created
@classmethod @classmethod
@ns_request_cached(CACHE_NAMESPACE)
def get_course_requirements(cls, course_key, namespace=None, name=None): def get_course_requirements(cls, course_key, namespace=None, name=None):
""" """
Get credit requirements of a given course. Get credit requirements of a given course.
...@@ -392,6 +396,13 @@ class CreditRequirement(TimeStampedModel): ...@@ -392,6 +396,13 @@ class CreditRequirement(TimeStampedModel):
return None return None
@receiver(models.signals.post_save, sender=CreditRequirement)
@receiver(models.signals.post_delete, sender=CreditRequirement)
def invalidate_credit_requirement_cache(sender, **kwargs): # pylint: disable=unused-argument
"""Invalidate the cache of credit requirements. """
RequestCache.clear_request_cache(name=CreditRequirement.CACHE_NAMESPACE)
class CreditRequirementStatus(TimeStampedModel): class CreditRequirementStatus(TimeStampedModel):
""" """
This model represents the status of each requirement. This model represents the status of each requirement.
......
...@@ -664,7 +664,7 @@ class CreditRequirementApiTests(CreditApiTestBase): ...@@ -664,7 +664,7 @@ class CreditRequirementApiTests(CreditApiTestBase):
self.assertFalse(api.is_user_eligible_for_credit(user.username, self.course_key)) self.assertFalse(api.is_user_eligible_for_credit(user.username, self.course_key))
# Satisfy the other requirement # Satisfy the other requirement
with self.assertNumQueries(25): with self.assertNumQueries(24):
api.set_credit_requirement_status( api.set_credit_requirement_status(
user, user,
self.course_key, self.course_key,
...@@ -718,7 +718,7 @@ class CreditRequirementApiTests(CreditApiTestBase): ...@@ -718,7 +718,7 @@ class CreditRequirementApiTests(CreditApiTestBase):
# Delete the eligibility entries and satisfy the user's eligibility # Delete the eligibility entries and satisfy the user's eligibility
# requirement again to trigger eligibility notification # requirement again to trigger eligibility notification
CreditEligibility.objects.all().delete() CreditEligibility.objects.all().delete()
with self.assertNumQueries(17): with self.assertNumQueries(16):
api.set_credit_requirement_status( api.set_credit_requirement_status(
user, user,
self.course_key, self.course_key,
......
...@@ -7,6 +7,8 @@ Stores global metadata using the UserPreference model, and per-course metadata u ...@@ -7,6 +7,8 @@ Stores global metadata using the UserPreference model, and per-course metadata u
UserCourseTag model. UserCourseTag model.
""" """
from collections import defaultdict
from request_cache import get_cache
from ..models import UserCourseTag from ..models import UserCourseTag
# Scopes # Scopes
...@@ -15,6 +17,42 @@ from ..models import UserCourseTag ...@@ -15,6 +17,42 @@ from ..models import UserCourseTag
COURSE_SCOPE = 'course' COURSE_SCOPE = 'course'
class BulkCourseTags(object):
CACHE_NAMESPACE = u'user_api.course_tag.api'
@classmethod
def prefetch(cls, course_id, users):
"""
Prefetches the value of the course tags for the specified users
for the specified course_id.
Args:
users: iterator of User objects
course_id: course identifier (CourseKey)
Returns:
course_tags: a dict of dicts,
where the primary key is the user's id
and the secondary key is the course tag's key
"""
course_tags = defaultdict(dict)
for tag in UserCourseTag.objects.filter(user__in=users, course_id=course_id).select_related('user__id'):
course_tags[tag.user.id][tag.key] = tag.value
get_cache(cls.CACHE_NAMESPACE)[cls._cache_key(course_id)] = course_tags
@classmethod
def get_course_tag(cls, user_id, course_id, key):
return get_cache(cls.CACHE_NAMESPACE)[cls._cache_key(course_id)][user_id][key]
@classmethod
def is_prefetched(cls, course_id):
return cls._cache_key(course_id) in get_cache(cls.CACHE_NAMESPACE)
@classmethod
def _cache_key(cls, course_id):
return u'course_tag.{}'.format(course_id)
def get_course_tag(user, course_id, key): def get_course_tag(user, course_id, key):
""" """
Gets the value of the user's course tag for the specified key in the specified Gets the value of the user's course tag for the specified key in the specified
...@@ -28,6 +66,11 @@ def get_course_tag(user, course_id, key): ...@@ -28,6 +66,11 @@ def get_course_tag(user, course_id, key):
Returns: Returns:
string value, or None if there is no value saved string value, or None if there is no value saved
""" """
if BulkCourseTags.is_prefetched(course_id):
try:
return BulkCourseTags.get_course_tag(user.id, course_id, key)
except KeyError:
return None
try: try:
record = UserCourseTag.objects.get( record = UserCourseTag.objects.get(
user=user, user=user,
......
...@@ -70,7 +70,7 @@ class RandomUserPartitionScheme(object): ...@@ -70,7 +70,7 @@ class RandomUserPartitionScheme(object):
exc_info=True exc_info=True
) )
if group is None and assign: if group is None and assign and not course_tag_api.BulkCourseTags.is_prefetched(course_key):
if not user_partition.groups: if not user_partition.groups:
raise UserPartitionError('Cannot assign user to an empty user partition') raise UserPartitionError('Cannot assign user to an empty user partition')
......
...@@ -26,6 +26,11 @@ class MemoryCourseTagAPI(object): ...@@ -26,6 +26,11 @@ class MemoryCourseTagAPI(object):
"""Gets the value of ``key``""" """Gets the value of ``key``"""
self._tags[course_id][key] = value self._tags[course_id][key] = value
class BulkCourseTags(object):
@classmethod
def is_prefetched(self, course_id):
return False
class TestRandomUserPartitionScheme(PartitionTestCase): class TestRandomUserPartitionScheme(PartitionTestCase):
""" """
......
...@@ -5,17 +5,17 @@ from django.db import models ...@@ -5,17 +5,17 @@ from django.db import models
from django.utils.translation import ugettext_lazy from django.utils.translation import ugettext_lazy
from django.dispatch import receiver from django.dispatch import receiver
from django.db.models.signals import post_save, pre_save from django.db.models.signals import post_save, pre_save
import logging
from openedx.core.djangoapps.xmodule_django.models import CourseKeyField
from student.models import CourseEnrollment
from lms.djangoapps.courseware.courses import get_course_by_id from lms.djangoapps.courseware.courses import get_course_by_id
from openedx.core.djangoapps.xmodule_django.models import CourseKeyField
from openedx.core.djangoapps.verified_track_content.tasks import sync_cohort_with_mode from openedx.core.djangoapps.verified_track_content.tasks import sync_cohort_with_mode
from openedx.core.djangoapps.course_groups.cohorts import ( from openedx.core.djangoapps.course_groups.cohorts import (
get_course_cohorts, CourseCohort, is_course_cohorted, get_random_cohort get_course_cohorts, CourseCohort, is_course_cohorted, get_random_cohort
) )
from request_cache.middleware import ns_request_cached, RequestCache
from student.models import CourseEnrollment
import logging
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -97,6 +97,8 @@ class VerifiedTrackCohortedCourse(models.Model): ...@@ -97,6 +97,8 @@ class VerifiedTrackCohortedCourse(models.Model):
enabled = models.BooleanField() enabled = models.BooleanField()
CACHE_NAMESPACE = u"verified_track_content.VerifiedTrackCohortedCourse.cache."
def __unicode__(self): def __unicode__(self):
return u"Course: {}, enabled: {}".format(unicode(self.course_key), self.enabled) return u"Course: {}, enabled: {}".format(unicode(self.course_key), self.enabled)
...@@ -119,6 +121,7 @@ class VerifiedTrackCohortedCourse(models.Model): ...@@ -119,6 +121,7 @@ class VerifiedTrackCohortedCourse(models.Model):
return None return None
@classmethod @classmethod
@ns_request_cached(CACHE_NAMESPACE)
def is_verified_track_cohort_enabled(cls, course_key): def is_verified_track_cohort_enabled(cls, course_key):
""" """
Checks whether or not verified track cohort is enabled for the given course. Checks whether or not verified track cohort is enabled for the given course.
...@@ -134,3 +137,10 @@ class VerifiedTrackCohortedCourse(models.Model): ...@@ -134,3 +137,10 @@ class VerifiedTrackCohortedCourse(models.Model):
return cls.objects.get(course_key=course_key).enabled return cls.objects.get(course_key=course_key).enabled
except cls.DoesNotExist: except cls.DoesNotExist:
return False return False
@receiver(models.signals.post_save, sender=VerifiedTrackCohortedCourse)
@receiver(models.signals.post_delete, sender=VerifiedTrackCohortedCourse)
def invalidate_verified_track_cache(sender, **kwargs): # pylint: disable=unused-argument
"""Invalidate the cache of VerifiedTrackCohortedCourse. """
RequestCache.clear_request_cache(name=VerifiedTrackCohortedCourse.CACHE_NAMESPACE)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment