Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions enterprise_data/api/v1/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class EnterpriseLearnerEnrollmentSerializer(serializers.ModelSerializer):
total_learning_time_hours = serializers.SerializerMethodField()
enterprise_flex_group_name = serializers.SerializerMethodField()
enterprise_flex_group_uuid = serializers.SerializerMethodField()
course_passing_grade = serializers.SerializerMethodField()
course_progress = serializers.SerializerMethodField()

class Meta:
Expand All @@ -51,6 +52,7 @@ class Meta:
'enterprise_customer_uuid', 'enterprise_sso_uid', 'created', 'course_api_url',
'total_learning_time_hours', 'is_subsidy', 'course_product_line', 'budget_id',
'enterprise_flex_group_name', 'enterprise_flex_group_uuid',
'course_passing_grade',
'course_progress',
)

Expand All @@ -72,6 +74,10 @@ def get_course_progress(self, obj):
"""Returns learner course progress from selected report data."""
return getattr(obj, 'course_progress', None)

def get_course_passing_grade(self, obj):
"""Returns required passing grade for the course, if available."""
return getattr(obj, 'course_passing_grade', None)

@cache_it()
def _get_flex_groups(self, obj):
"""
Expand Down
108 changes: 100 additions & 8 deletions enterprise_data/api/v1/views/enterprise_learner.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
from logging import getLogger
from uuid import UUID

try:
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
except ImportError:
CourseOverview = None

from rest_framework import filters, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import NotFound
Expand All @@ -14,12 +19,14 @@

from django.conf import settings
from django.core.paginator import Paginator
from django.db import connection
from django.db.models import Count, Exists, Max, OuterRef, Prefetch, Q, Subquery, Value
from django.db.models.fields import IntegerField
from django.db.models.functions import Coalesce
from django.http import StreamingHttpResponse
from django.utils import timezone

from enterprise_data import cache
from enterprise_data.api.v1 import serializers
from enterprise_data.clients import EnterpriseApiClient
from enterprise_data.exceptions import EnterpriseApiClientException
Expand All @@ -35,6 +42,7 @@


DEFAULT_LEARNER_CACHE_TIMEOUT = 60 * 10
DEFAULT_COURSE_PASSING_GRADE_CACHE_TIMEOUT = 60 * 60 * 6


class EnterpriseLearnerEnrollmentViewSet(EnterpriseViewSetMixin, viewsets.ReadOnlyModelViewSet):
Expand Down Expand Up @@ -70,6 +78,7 @@ class EnterpriseLearnerEnrollmentViewSet(EnterpriseViewSetMixin, viewsets.ReadOn
'enterprise_customer_uuid', 'enterprise_sso_uid', 'created', 'course_api_url', 'total_learning_time_hours',
'is_subsidy', 'course_product_line', 'budget_id',
'enterprise_flex_group_name', 'enterprise_flex_group_uuid',
'course_passing_grade',
'course_progress',
]

Expand All @@ -96,15 +105,27 @@ def get_queryset(self):

# TODO: Created a ticket ENT0-9531 to add the cache on this viewset

# Add a synthetic placeholder column so the serialized response shape
# always includes `course_progress`; real values are merged in later.
enrollments = EnterpriseLearnerEnrollment.objects.filter(
enterprise_customer_uuid=enterprise_customer_uuid
).extra(select={'course_progress': 'NULL'})
# Add synthetic placeholder columns so the serialized response shape
# always includes `course_progress` and `course_passing_grade`.
enrollments = self._get_enrollments_with_course_metadata(enterprise_customer_uuid)

enrollments = self.apply_filters(enrollments)

return enrollments

def _get_enrollments_with_course_metadata(self, enterprise_customer_uuid):
"""
Build the enrollments queryset and include synthetic metadata fields.

Adds placeholders for fields enriched after serialization. Keeping the
base queryset ORM-only avoids a per-request CourseOverview join and
lets the enrichment path use a reusable cache.
"""
enrollments = EnterpriseLearnerEnrollment.objects.filter(
enterprise_customer_uuid=enterprise_customer_uuid
).extra(select={'course_progress': 'NULL'})
return enrollments.extra(select={'course_passing_grade': 'NULL'})

def list(self, request, *args, **kwargs):
"""
Override the list method to handle streaming CSV download and enrich
Expand All @@ -123,21 +144,21 @@ def list(self, request, *args, **kwargs):

def _enrich_course_progress_rows(self, rows):
"""
Enrich serialized enrollment rows with ``course_progress`` fetched from
Snowflake's internal LPR table.
Enrich serialized enrollment rows with cached course metadata.

Accepts a list-like collection of serialized row dicts and mutates each
matching row in place. Silently skips enrichment on any error so the
ORM-backed response is always returned intact.
"""
self._enrich_course_passing_grade_rows(rows)
try:
if not rows:
return rows
enterprise_uuid = self.kwargs['enterprise_id']
progress_map = SnowflakeCourseProgressSource().get_course_progress_map(enterprise_uuid, rows)
for row in rows:
key = (
row.get('user_email', '').strip(),
row.get('user_email', '').strip().lower(),
row.get('courserun_key', '').strip(),
)
if key in progress_map:
Expand All @@ -147,6 +168,77 @@ def _enrich_course_progress_rows(self, rows):
LOGGER.warning('Could not enrich course_progress from Snowflake', exc_info=True)
return rows

@staticmethod
def _course_passing_grade_cache_timeout():
"""Return the configurable TTL for CourseOverview passing-grade cache entries."""
return getattr(
settings,
'LPR_COURSE_PASSING_GRADE_CACHE_TIMEOUT',
DEFAULT_COURSE_PASSING_GRADE_CACHE_TIMEOUT,
)

@staticmethod
def _course_passing_grade_cache_key(courserun_key):
"""Return the cache key for a CourseOverview passing grade."""
return cache.get_key('lpr_course_passing_grade', courserun_key)

@staticmethod
def _course_overview_table_exists():
"""Return whether CourseOverview can be queried in this runtime."""
if CourseOverview is None:
return False
return CourseOverview._meta.db_table in connection.introspection.table_names()

def _enrich_course_passing_grade_rows(self, rows):
"""
Enrich serialized enrollment rows with cached CourseOverview passing grades.
"""
try:
if not rows or not self._course_overview_table_exists():
return rows

courserun_keys = sorted({
row.get('courserun_key', '').strip()
for row in rows
if row.get('courserun_key')
})
if not courserun_keys:
return rows

passing_grade_map = {}
missing_courserun_keys = []
for courserun_key in courserun_keys:
cached_response = cache.get(self._course_passing_grade_cache_key(courserun_key))
if cached_response.is_found:
passing_grade_map[courserun_key] = cached_response.value
else:
missing_courserun_keys.append(courserun_key)

if missing_courserun_keys:
fetched_grades = dict(
CourseOverview.objects.filter(id__in=missing_courserun_keys).values_list(
'id',
'lowest_passing_grade',
)
)
for courserun_key in missing_courserun_keys:
passing_grade = fetched_grades.get(courserun_key)
passing_grade_map[courserun_key] = passing_grade
cache.set(
self._course_passing_grade_cache_key(courserun_key),
passing_grade,
timeout=self._course_passing_grade_cache_timeout(),
)

for row in rows:
courserun_key = row.get('courserun_key', '').strip()
if courserun_key in passing_grade_map:
row['course_passing_grade'] = passing_grade_map[courserun_key]
return rows
except Exception: # pylint: disable=broad-exception-caught
LOGGER.warning('Could not enrich course_passing_grade from CourseOverview', exc_info=True)
return rows

def _enrich_course_progress(self, response):
"""
Enrich each row in the paginated response with ``course_progress`` fetched
Expand Down
1 change: 1 addition & 0 deletions enterprise_data/api/v1/views/lpr_data_source_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ class LPRSerializerShapeMixin:
'enterprise_customer_uuid', 'enterprise_sso_uid', 'created', 'course_api_url',
'total_learning_time_hours', 'is_subsidy', 'course_product_line', 'budget_id',
'enterprise_flex_group_name', 'enterprise_flex_group_uuid',
'course_passing_grade',
'course_progress',
)

Expand Down
85 changes: 75 additions & 10 deletions enterprise_data/api/v1/views/lpr_data_source_snowflake.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,20 @@
LPR_SNOWFLAKE_INTERNAL_TABLE = 'LEARNER_PROGRESS_REPORT_INTERNAL'
LPR_SNOWFLAKE_WAREHOUSE = None (omitted from connection if not set)
LPR_SNOWFLAKE_ROLE = None (omitted from connection if not set)
LPR_COURSE_PROGRESS_CACHE_TIMEOUT = 300 (5 minutes)
"""

from logging import getLogger
from types import SimpleNamespace

from django.conf import settings

from enterprise_data import cache

LOGGER = getLogger(__name__)

DEFAULT_COURSE_PROGRESS_CACHE_TIMEOUT = 60 * 5

try:
import snowflake.connector as snowflake_connector
except ImportError: # pragma: no cover - depends on runtime extras
Expand Down Expand Up @@ -98,6 +103,33 @@ def _internal_table():
table = getattr(settings, 'LPR_SNOWFLAKE_INTERNAL_TABLE', 'LEARNER_PROGRESS_REPORT_INTERNAL')
return f'{database}.{schema}.{table}'

@staticmethod
def _normalized_enterprise_uuid(enterprise_customer_uuid):
"""Normalize enterprise UUIDs to the Snowflake comparison format."""
return str(enterprise_customer_uuid).replace('-', '').lower()

@staticmethod
def _normalized_row_key(user_email, courserun_key):
"""Return a stable in-memory key for matching ORM rows to Snowflake rows."""
return (str(user_email).strip().lower(), str(courserun_key).strip())

def _cache_key(self, enterprise_customer_uuid):
"""Return the enterprise-scoped cache key for Snowflake course progress."""
return cache.get_key(
'lpr_course_progress',
self._internal_table(),
self._normalized_enterprise_uuid(enterprise_customer_uuid),
)

@staticmethod
def _cache_timeout():
"""Return the configurable TTL for Snowflake course progress cache entries."""
return getattr(
settings,
'LPR_COURSE_PROGRESS_CACHE_TIMEOUT',
DEFAULT_COURSE_PROGRESS_CACHE_TIMEOUT,
)

# ------------------------------------------------------------------
# Public interface
# ------------------------------------------------------------------
Expand All @@ -107,6 +139,11 @@ def get_course_progress_map(self, enterprise_customer_uuid, enrollments):
Return a ``{(user_email, courserun_key): course_progress}`` mapping for
all rows on the current page.

The Snowflake data refreshes roughly daily, so this method keeps an
enterprise-scoped cache of the full Snowflake result set and returns
only the rows requested by the current page. This avoids repeated
Snowflake queries while keeping the public API unchanged.

Args:
enterprise_customer_uuid (str): The enterprise UUID to scope the query.
enrollments (list[dict]): Serialized enrollment rows from the ORM page.
Expand All @@ -117,31 +154,59 @@ def get_course_progress_map(self, enterprise_customer_uuid, enrollments):
try/except so Snowflake unavailability never breaks the LPR response.
"""
pairs = [
(row.get('user_email', ''), row.get('courserun_key', ''))
self._normalized_row_key(row.get('user_email', ''), row.get('courserun_key', ''))
for row in enrollments
if row.get('user_email') and row.get('courserun_key')
]
if not pairs:
return {}

table = self._internal_table()
normalized_uuid = str(enterprise_customer_uuid).replace('-', '').lower()
enterprise_progress_map = self._get_enterprise_course_progress_map(enterprise_customer_uuid)
return {
pair: enterprise_progress_map[pair]
for pair in pairs
if pair in enterprise_progress_map
}

def _get_enterprise_course_progress_map(self, enterprise_customer_uuid):
"""
Return the full enterprise course progress map from cache or Snowflake.
"""
cache_key = self._cache_key(enterprise_customer_uuid)
cached_response = cache.get(cache_key)
if cached_response.is_found:
LOGGER.info(
'[course_progress] Cache hit for enterprise_uuid=%s',
enterprise_customer_uuid,
)
return cached_response.value

# Build parameterised IN list for (user_email, courserun_key) pairs.
placeholders = ', '.join(['(%s, %s)'] * len(pairs))
flat_params = [param for pair in pairs for param in pair]
LOGGER.info(
'[course_progress] Cache miss for enterprise_uuid=%s; fetching from Snowflake',
enterprise_customer_uuid,
)
progress_map = self._fetch_enterprise_course_progress_map(enterprise_customer_uuid)
cache.set(cache_key, progress_map, timeout=self._cache_timeout())
return progress_map

def _fetch_enterprise_course_progress_map(self, enterprise_customer_uuid):
"""
Fetch all course progress rows for an enterprise from Snowflake.
"""

table = self._internal_table()
normalized_uuid = self._normalized_enterprise_uuid(enterprise_customer_uuid)

sql = (
f"SELECT USER_EMAIL, COURSERUN_KEY, COURSE_PROGRESS "
f"FROM {table} "
f"WHERE LOWER(REPLACE(TO_VARCHAR(ENTERPRISE_CUSTOMER_UUID), '-', '')) = %s "
f" AND (USER_EMAIL, COURSERUN_KEY) IN ({placeholders})"
f"WHERE LOWER(REPLACE(TO_VARCHAR(ENTERPRISE_CUSTOMER_UUID), '-', '')) = %s"
)

ctx = self._get_connection()
cs = ctx.cursor()
try:
cs.execute(sql, [normalized_uuid] + flat_params)
cs.execute(sql, [normalized_uuid])
rows = cs.fetchall()
if not rows:
LOGGER.warning(
Expand All @@ -150,7 +215,7 @@ def get_course_progress_map(self, enterprise_customer_uuid, enrollments):
enterprise_customer_uuid,
)
return {
(row[0], row[1]): row[2]
self._normalized_row_key(row[0], row[1]): row[2]
for row in rows
}
finally:
Expand Down
1 change: 1 addition & 0 deletions enterprise_data/renderers.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class EnrollmentsCSVRenderer(CSVStreamingRenderer):
'user_country_code', 'user_username', 'user_first_name', 'user_last_name', 'enterprise_name',
'enterprise_customer_uuid', 'enterprise_sso_uid', 'created', 'course_api_url', 'total_learning_time_hours',
'is_subsidy', 'course_product_line', 'budget_id', 'enterprise_flex_group_name', 'enterprise_flex_group_uuid',
'course_passing_grade',
'course_progress',
]

Expand Down
9 changes: 9 additions & 0 deletions enterprise_data/tests/api/v1/test_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@ def test_course_progress_field_present(self):
serializer = EnterpriseLearnerEnrollmentSerializer(self.enrollment)
assert serializer.data['course_progress'] == 0.42

def test_course_passing_grade_field_present(self):
self.enrollment.course_passing_grade = 0.7
serializer = EnterpriseLearnerEnrollmentSerializer(self.enrollment)
assert serializer.data['course_passing_grade'] == 0.7

def test_course_passing_grade_field_defaults_to_none(self):
serializer = EnterpriseLearnerEnrollmentSerializer(self.enrollment)
assert serializer.data['course_passing_grade'] is None

def test_csv_renderer_header_matches_serializer_field_order(self):
"""CSV header must exactly match serializer field order."""
serializer_fields = list(EnterpriseLearnerEnrollmentSerializer.Meta.fields)
Expand Down
Loading