Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions lms/djangoapps/course_api/blocks/api.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""
API function for retrieving course blocks data
"""

from edx_django_utils.cache import RequestCache

import lms.djangoapps.course_blocks.api as course_blocks_api
from lms.djangoapps.course_blocks.transformers.access_denied_filter import AccessDeniedMessageFilterTransformer
Expand Down Expand Up @@ -29,6 +29,7 @@ def get_blocks(
block_types_filter=None,
hide_access_denials=False,
allow_start_dates_in_future=False,
for_blocks_view=False,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name of this argument doesn't really express what it does -- to me at least. I also question the API design:

  1. Why wouldn't caching blocks be the default? Wouldn't all callers benefit?
  2. Could the caching code be abstracted from the body of the function so it's not inline with the business logic?
  3. I realize you are working around old code, but the future start date stuff feels like it could be the callers responsibility and that complexity could be removed from the getter.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As for the naming, I guess we could come up something better: cache_with_future_dates seems to reflect the intent more clearly.

As for the other questions:

  1. Making this caching the default behavior would not benefit the other callers, because they either:
  • only call get_blocks() once and never need to reuse the collected course structure; or

  • do call get_blocks() more than once, but they either pass the same arguments (benefiting from @request_cached) or they pass a different user (the cached structure has to be recollected from scratch).

  1. Yes, this abstraction seems reasonable, and I think it can be nicely paired with the other refactoring you suggest - creating variables for cache key names.

  2. The logic here was indeed influenced by the existing codebase in a lot of ways, that's for sure. And while some of the previous design choices might seem questionable, this particular decision seems to make sense: we want to get_blocks and we specify a filtering criteria - whether to include future start dates or not. Filtering after the fact in each caller would basically mean trying to reproduce what the existing transformers are already designed to do. This doesn't seem like a very clean approach, and the only reason we resort to it here is because of the constraints (in terms of scope and performance) of this particular api view.

@cmltaWt0 cmltaWt0 Oct 16, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name of this argument doesn't really express what it does -- to me at least.

Not only for you - I've also had this question.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Renamed the flag to cache_with_future_dates
  • Moved the logic for getting blocks from cache to utils.py

):
"""
Return a serialized representation of the course blocks.
Expand Down Expand Up @@ -61,6 +62,7 @@ def get_blocks(
allow_start_dates_in_future (bool): When True, will allow blocks to be
returned that can bypass the StartDateTransformer's filter to show
blocks with start dates in the future.
for_blocks_view (bool): When True, will use the block caching logic using RequestCache
"""

if HIDE_ACCESS_DENIALS_FLAG.is_enabled():
Expand Down Expand Up @@ -118,6 +120,10 @@ def get_blocks(
),
]

if for_blocks_view:
# Include future dates such that get_course_assignments can reuse the block structure from RequestCache
allow_start_dates_in_future = True

# transform
blocks = course_blocks_api.get_course_blocks(
user,
Expand All @@ -128,6 +134,19 @@ def get_blocks(
include_has_scheduled_content=include_has_scheduled_content
)

if for_blocks_view:
# Store a copy of the transformed, but still unfiltered, course blocks in RequestCache to be reused
# wherever possible for optimization. Copying is required to make sure the cached structure is not mutated
# by the filtering below.
request_cache = RequestCache("unfiltered_course_structure")
request_cache.set("reusable_transformed_blocks", blocks.copy())

# Since we included blocks with future start dates in our block structure,
# we need to include the 'start' field to filter out such blocks before returning the response.
# If 'start' field is not requested, it will be removed from the response.
requested_fields = set(requested_fields)
requested_fields.add('start')

# filter blocks by types
if block_types_filter:
block_keys_to_remove = []
Expand All @@ -142,7 +161,7 @@ def get_blocks(
serializer_context = {
'request': request,
'block_structure': blocks,
'requested_fields': requested_fields or [],
'requested_fields': requested_fields,
}

if return_type == 'dict':
Expand Down
44 changes: 44 additions & 0 deletions lms/djangoapps/course_api/blocks/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ def list(self, request, usage_key_string, hide_access_denials=False): # pylint:
params.cleaned_data['return_type'],
params.cleaned_data.get('block_types_filter', None),
hide_access_denials=hide_access_denials,
for_blocks_view=True
)
)
# If the username is an empty string, and not None, then we are requesting
Expand Down Expand Up @@ -339,9 +340,52 @@ def list(self, request, hide_access_denials=False): # pylint: disable=arguments
if not root:
raise ValidationError(f"Unable to find course block in '{course_key_string}'")

# Earlier we included blocks with future start dates in the collected/cached block structure.
# Now we need to emulate allow_start_dates_in_future=False by removing any such blocks.
include_start = "start" in request.query_params['requested_fields']
self.remove_future_blocks(course_blocks, include_start)

recurse_mark_complete(root, course_blocks)
return response

@staticmethod
def remove_future_blocks(course_blocks, include_start: bool):
"""
Mutates course_blocks in place:
- removes blocks whose 'start' is in the future
- also removes references to them from parents' 'children' lists
- removes 'start' key from all blocks if it wasn't requested
"""
from datetime import datetime, timezone

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no need for this to be a function-local import, is there?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There isn't indeed, moved the import to module level


if not course_blocks:
return course_blocks

now = datetime.now(timezone.utc)

# 1. Collect IDs of blocks to remove
to_remove = set()
for block_id, block in course_blocks.items():
get_field = block.get if include_start else block.pop
start = get_field("start")
if start and start > now:
to_remove.add(block_id)
Comment on lines +370 to +371

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like this function is ignoring days_early_for_beta for content beta testers. Is that accounted for somewhere else?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At this point, we are dealing with the start dates that have been computed by StartDateTransformer - which is where the logic concerning days_early_for_beta lives.


if not to_remove:
return course_blocks

# 2. Remove the blocks themselves
for block_id in to_remove:
course_blocks.pop(block_id, None)

# 3. Clean up children lists
for block in course_blocks.values():
children = block.get("children")
if children:
block["children"] = [cid for cid in children if cid not in to_remove]
Comment on lines +381 to +384

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to check my understanding: Is it the case that it's okay to do this simple child removal (and not go down into further descendants) because all the inheritance has already been pre-computed, and the start attribute has been denormalized and put on all the nodes?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this is correct: by this point, StartDateTransformer has traversed the structure and resolved the start dates, and BlockSerializer has put the start key on all the returned blocks.


return course_blocks


@method_decorator(transaction.non_atomic_requests, name='dispatch')
@view_auth_classes(is_authenticated=False)
Expand Down
9 changes: 8 additions & 1 deletion lms/djangoapps/courseware/courses.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from django.http import Http404, QueryDict
from django.urls import reverse
from django.utils.translation import gettext as _
from edx_django_utils.cache import RequestCache
from edx_django_utils.monitoring import function_trace, set_custom_attribute
from fs.errors import ResourceNotFound
from opaque_keys.edx.keys import UsageKey
Expand Down Expand Up @@ -632,7 +633,13 @@ def get_course_assignments(course_key, user, include_access=False, include_witho

store = modulestore()
course_usage_key = store.make_course_usage_key(course_key)
block_data = get_course_blocks(user, course_usage_key, allow_start_dates_in_future=True, include_completion=True)

request_cache = RequestCache("unfiltered_course_structure")
cached_response = request_cache.get_cached_response("reusable_transformed_blocks")
reusable_transformed_blocks = cached_response.value if cached_response.is_found else None
block_data = reusable_transformed_blocks or get_course_blocks(
user, course_usage_key, allow_start_dates_in_future=True, include_completion=True
)

now = datetime.now(pytz.UTC)
assignments = []
Expand Down
12 changes: 10 additions & 2 deletions lms/djangoapps/grades/course_data.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""
Code used to get and cache the requested course-data
"""

from edx_django_utils.cache import RequestCache

from lms.djangoapps.course_blocks.api import get_course_blocks
from openedx.core.djangoapps.content.block_structure.api import get_block_structure_manager
Expand Down Expand Up @@ -56,7 +56,15 @@ def location(self): # lint-amnesty, pylint: disable=missing-function-docstring
@property
def structure(self): # lint-amnesty, pylint: disable=missing-function-docstring
if self._structure is None:
self._structure = get_course_blocks(
# The get_course_blocks function proved to be a major time sink during a request at "blocks/".
# This caching logic helps improve the response time by getting a copy of the already transformed, but still
# unfiltered, course blocks from RequestCache and thus reducing the number of times that
# the get_course_blocks function is called.

request_cache = RequestCache("unfiltered_course_structure")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As the names are used multiple time, let's create a variable at the appropriate scope for these.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, constants added to utils.py

cached_response = request_cache.get_cached_response("reusable_transformed_blocks")
reusable_transformed_blocks = cached_response.value if cached_response.is_found else None
self._structure = reusable_transformed_blocks or get_course_blocks(
self.user,
self.location,
collected_block_structure=self._collected_block_structure,
Expand Down
72 changes: 72 additions & 0 deletions lms/djangoapps/mobile_api/tests/test_course_info_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,78 @@ def test_extend_sequential_info_with_assignment_progress_for_other_types(self, b
for block_info in response.data['blocks'].values():
self.assertNotEqual('assignment_progress', block_info)

def test_response_keys(self):
response = self.verify_response(url=self.url)
data = response.data

expected_top_level_keys = {
'blocks',
'certificate',
'course_about',
'course_access_details',
'course_handouts',
'course_modes',
'course_progress',
'course_sharing_utm_parameters',
'course_updates',
'deprecate_youtube',
'discussion_url',
'end',
'enrollment_details',
'id',
'is_self_paced',
'media',
'name',
'number',
'org',
'org_logo',
'root',
'start',
'start_display',
'start_type'
}
expected_course_access_keys = {
"has_unmet_prerequisites",
"is_too_early",
"is_staff",
"audit_access_expires",
"courseware_access"
}
expected_courseware_access_keys = {
"has_access",
"error_code",
"developer_message",
"user_message",
"additional_context_user_message",
"user_fragment"
}
expected_enrollment_details_keys = {"created", "mode", "is_active", "upgrade_deadline"}
expected_media_keys = {"image"}
expected_image_keys = {"raw", "small", "large"}
expected_course_sharing_keys = {"facebook", "twitter"}
expected_course_modes_keys = {"slug", "sku", "android_sku", "ios_sku", "min_price"}
expected_course_progress_keys = {"total_assignments_count", "assignments_completed"}

self.assertSetEqual(set(data), expected_top_level_keys)
self.assertSetEqual(set(data["course_access_details"]), expected_course_access_keys)
self.assertSetEqual(set(data["course_access_details"]["courseware_access"]), expected_courseware_access_keys)
self.assertSetEqual(set(data["enrollment_details"]), expected_enrollment_details_keys)
self.assertSetEqual(set(data["media"]), expected_media_keys)
self.assertSetEqual(set(data["media"]["image"]), expected_image_keys)
self.assertSetEqual(set(data["course_sharing_utm_parameters"]), expected_course_sharing_keys)
self.assertSetEqual(set(data["course_modes"][0]), expected_course_modes_keys)
self.assertSetEqual(set(data["course_progress"]), expected_course_progress_keys)

def test_block_count_depends_on_depth_in_request_params(self):
response_depth_zero = self.verify_response(url=self.url, params={'depth': 0})
response_depth_one = self.verify_response(url=self.url, params={'depth': 1})
blocks_depth_zero = [block for block in self.store.get_items(self.course_key) if block.category == "course"]
blocks_depth_one = [
block for block in self.store.get_items(self.course_key) if block.category in ("course", "chapter")
]
self.assertEqual(len(response_depth_zero.data["blocks"]), len(blocks_depth_zero))
self.assertEqual(len(response_depth_one.data["blocks"]), len(blocks_depth_one))


class TestCourseEnrollmentDetailsView(MobileAPITestCase, MilestonesTestCaseMixin): # lint-amnesty, pylint: disable=test-inherits-tests
"""
Expand Down
Loading