Skip to content
Merged
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
97 changes: 97 additions & 0 deletions analytics_data_api/insights_snowflake/mappers/performance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""Map Snowflake performance rows into the existing API response shapes."""

from analytics_data_api.v0.models import ProblemFirstLastResponseAnswerDistribution

COURSE_PROBLEM_FIELDS = (
'module_id',
'total_submissions',
'correct_submissions',
'part_ids',
'created',
)
COURSE_PROBLEM_INTEGER_FIELDS = (
'total_submissions',
'correct_submissions',
)


def _row_value(row, name):
"""Return a row value from dictionary rows produced by the Snowflake client."""
if name in row:
return row[name]

Check failure on line 21 in analytics_data_api/insights_snowflake/mappers/performance.py

View workflow job for this annotation

GitHub Actions / Tests (ubuntu-latest, 3.11, main.test)

Missing coverage

Missing coverage on line 21
return row[name.upper()]


def _optional_row_value(row, name):
"""Return a row value when present, otherwise None."""
if name in row:
return row[name]

Check failure on line 28 in analytics_data_api/insights_snowflake/mappers/performance.py

View workflow job for this annotation

GitHub Actions / Tests (ubuntu-latest, 3.11, main.test)

Missing coverage

Missing coverage on line 28
return row.get(name.upper())


def _nullable_int(value):
"""Return an integer value while preserving nulls."""
if value is None:
return None

Check failure on line 35 in analytics_data_api/insights_snowflake/mappers/performance.py

View workflow job for this annotation

GitHub Actions / Tests (ubuntu-latest, 3.11, main.test)

Missing coverage

Missing coverage on line 35
return int(value)


def _api_value(row, name):
"""Return the API-compatible value for a Snowflake course problem field."""
value = _row_value(row, name)
if name == 'part_ids':
return value.split(',') if value else []
if name in COURSE_PROBLEM_INTEGER_FIELDS:
return int(value)
return value


def _answer_value(row):
"""Return the Snowflake answer text using the existing API field name."""
if 'answer_value_text' in row:
return row['answer_value_text']

Check failure on line 52 in analytics_data_api/insights_snowflake/mappers/performance.py

View workflow job for this annotation

GitHub Actions / Tests (ubuntu-latest, 3.11, main.test)

Missing coverage

Missing coverage on line 52
if 'ANSWER_VALUE_TEXT' in row:
return row['ANSWER_VALUE_TEXT']
return _row_value(row, 'answer_value')

Check failure on line 55 in analytics_data_api/insights_snowflake/mappers/performance.py

View workflow job for this annotation

GitHub Actions / Tests (ubuntu-latest, 3.11, main.test)

Missing coverage

Missing coverage on line 55


def _answer_sort_key(row):
"""Sort answer rows so existing part grouping is deterministic."""
variant = _optional_row_value(row, 'variant')
return (
_row_value(row, 'part_id') or '',
_row_value(row, 'value_id') or '',
-1 if variant is None else int(variant),
)


def map_course_problem_rows(rows):
"""Map Snowflake course problem rows into existing API response dictionaries."""
return [
{
field: _api_value(row, field)
for field in COURSE_PROBLEM_FIELDS
}
for row in rows or []
]


def map_problem_answer_distribution_rows(rows):
"""Map Snowflake answer distribution rows into unsaved model instances."""
return [
ProblemFirstLastResponseAnswerDistribution(
course_id=_row_value(row, 'course_id'),
module_id=_row_value(row, 'module_id'),
part_id=_row_value(row, 'part_id'),
correct=_row_value(row, 'correct'),
value_id=_row_value(row, 'value_id'),
answer_value=_answer_value(row),
variant=_nullable_int(_optional_row_value(row, 'variant')),
problem_display_name=_row_value(row, 'problem_display_name'),
question_text=_row_value(row, 'question_text'),
first_response_count=int(_row_value(row, 'first_response_count')),
last_response_count=int(_row_value(row, 'last_response_count')),
created=_row_value(row, 'created'),
)
for row in sorted(rows or [], key=_answer_sort_key)
]
50 changes: 50 additions & 0 deletions analytics_data_api/insights_snowflake/queries/performance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""Snowflake queries for performance metrics."""

from analytics_data_api.insights_snowflake.client import fetch_all, get_qualified_table_name

PROBLEM_ANSWER_DISTRIBUTION_TABLE = 'PROBLEM_ANSWER_DISTRIBUTION'


def get_course_problem_rows(course_id):
"""Return Snowflake rows for the course problems list."""
table_name = get_qualified_table_name(PROBLEM_ANSWER_DISTRIBUTION_TABLE)
sql = """
SELECT
module_id,
SUM(last_response_count) / COUNT(DISTINCT part_id) AS total_submissions,
SUM(CASE WHEN correct = 1 THEN last_response_count ELSE 0 END) / COUNT(DISTINCT part_id)
AS correct_submissions,
LISTAGG(DISTINCT part_id, ',') WITHIN GROUP (ORDER BY part_id) AS part_ids,
MAX(created) AS created
FROM {table_name}
WHERE course_id = %(course_id)s
GROUP BY module_id
ORDER BY module_id
""".format(table_name=table_name)

return fetch_all(sql, {'course_id': course_id})


def get_problem_answer_distribution_rows(problem_id):
"""Return Snowflake rows for a problem answer distribution."""
table_name = get_qualified_table_name(PROBLEM_ANSWER_DISTRIBUTION_TABLE)
sql = """
SELECT
course_id,
module_id,
part_id,
correct,
value_id,
answer_value_text,
variant,
problem_display_name,
question_text,
first_response_count,
last_response_count,
created
FROM {table_name}
WHERE module_id = %(problem_id)s
ORDER BY part_id, value_id, variant
""".format(table_name=table_name)

return fetch_all(sql, {'problem_id': problem_id})
20 changes: 20 additions & 0 deletions analytics_data_api/insights_snowflake/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
map_course_enrollment_location_rows,
map_course_enrollment_mode_rows,
)
from analytics_data_api.insights_snowflake.mappers.performance import (
map_course_problem_rows,
map_problem_answer_distribution_rows,
)
from analytics_data_api.insights_snowflake.mappers.programs import map_program_metadata_rows
from analytics_data_api.insights_snowflake.mappers.videos import map_course_video_rows, map_video_timeline_rows
from analytics_data_api.insights_snowflake.queries.activity import get_course_activity_weekly_rows
Expand All @@ -24,6 +28,10 @@
get_course_enrollment_location_rows,
get_course_enrollment_mode_rows,
)
from analytics_data_api.insights_snowflake.queries.performance import (
get_course_problem_rows,
get_problem_answer_distribution_rows,
)
from analytics_data_api.insights_snowflake.queries.programs import get_program_metadata_rows
from analytics_data_api.insights_snowflake.queries.videos import get_course_video_rows, get_video_timeline_rows

Expand Down Expand Up @@ -97,3 +105,15 @@ def get_video_timeline(video_id):
"""Return video timeline metrics in the existing API response shape."""
rows = get_video_timeline_rows(video_id)
return map_video_timeline_rows(rows)


def get_course_problems(course_id):
"""Return course problems in the existing API response shape."""
rows = get_course_problem_rows(course_id)
return map_course_problem_rows(rows)


def get_problem_answer_distribution(problem_id):
"""Return problem answer distribution rows in the existing API response shape."""
rows = get_problem_answer_distribution_rows(problem_id)
return map_problem_answer_distribution_rows(rows)
132 changes: 132 additions & 0 deletions analytics_data_api/tests/test_insights_snowflake.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
map_course_enrollment_location_rows,
map_course_enrollment_mode_rows,
)
from analytics_data_api.insights_snowflake.mappers.performance import (
map_course_problem_rows,
map_problem_answer_distribution_rows,
)
from analytics_data_api.insights_snowflake.mappers.programs import map_program_metadata_rows
from analytics_data_api.insights_snowflake.mappers.videos import map_course_video_rows, map_video_timeline_rows
from analytics_data_api.insights_snowflake.queries.activity import get_course_activity_weekly_rows
Expand All @@ -41,6 +45,11 @@
get_course_enrollment_location_rows,
get_course_enrollment_mode_rows,
)
from analytics_data_api.insights_snowflake.queries.performance import (
PROBLEM_ANSWER_DISTRIBUTION_TABLE,
get_course_problem_rows,
get_problem_answer_distribution_rows,
)
from analytics_data_api.insights_snowflake.queries.programs import (
COURSE_PROGRAM_METADATA_TABLE,
get_program_metadata_rows,
Expand All @@ -63,8 +72,10 @@
get_course_enrollment_gender,
get_course_enrollment_location,
get_course_enrollment_mode,
get_course_problems,
get_course_summaries,
get_course_videos,
get_problem_answer_distribution,
get_program_metadata,
get_video_timeline,
)
Expand Down Expand Up @@ -411,6 +422,45 @@ def test_video_query_functions_use_expected_tables(self, mock_get_table_name, _m
self.assertEqual(params, {'video_id': 'video-1'})


class InsightsSnowflakePerformanceQueryTests(SimpleTestCase):
"""Cover performance query construction with mocked Snowflake execution."""

@patch('analytics_data_api.insights_snowflake.queries.performance.fetch_all')
@patch(
'analytics_data_api.insights_snowflake.queries.performance.get_qualified_table_name',
Mock(return_value='PROD.INSIGHTS.PROBLEM_ANSWER_DISTRIBUTION')
)
def test_get_course_problem_rows_uses_expected_table(self, mock_fetch_all):
mock_fetch_all.return_value = [{'module_id': 'problem-1'}]
course_id = 'course-v1:edX+DemoX+Demo_Course'

rows = get_course_problem_rows(course_id)

self.assertEqual(rows, [{'module_id': 'problem-1'}])
sql, params = mock_fetch_all.call_args[0]
self.assertIn('FROM PROD.INSIGHTS.PROBLEM_ANSWER_DISTRIBUTION', sql)
self.assertIn('LISTAGG(DISTINCT part_id', sql)
self.assertIn('WHERE course_id = %(course_id)s', sql)
self.assertEqual(params, {'course_id': course_id})

@patch('analytics_data_api.insights_snowflake.queries.performance.fetch_all')
@patch('analytics_data_api.insights_snowflake.queries.performance.get_qualified_table_name')
def test_get_problem_answer_distribution_rows_uses_expected_table(self, mock_get_table_name, _mock_fetch_all):
mock_get_table_name.return_value = 'PROD.INSIGHTS.PROBLEM_ANSWER_DISTRIBUTION'
problem_id = 'i4x://edX/DemoX/problem/Test'

get_problem_answer_distribution_rows(problem_id)

mock_get_table_name.assert_called_once_with(PROBLEM_ANSWER_DISTRIBUTION_TABLE)
sql, params = _mock_fetch_all.call_args[0]
self.assertIn('answer_value_text', sql)
self.assertIn('first_response_count', sql)
self.assertIn('last_response_count', sql)
self.assertIn('WHERE module_id = %(problem_id)s', sql)
self.assertIn('ORDER BY part_id, value_id, variant', sql)
self.assertEqual(params, {'problem_id': problem_id})


class InsightsSnowflakeActivityMapperTests(SimpleTestCase):
"""Cover Snowflake activity row mapping into the existing API shape."""

Expand Down Expand Up @@ -868,6 +918,62 @@ def test_map_video_timeline_rows(self):
}])


class InsightsSnowflakePerformanceMapperTests(SimpleTestCase):
"""Cover Snowflake performance row mapping into the existing API shapes."""

def test_map_course_problem_rows_accepts_uppercase_snowflake_keys(self):
created = datetime.datetime(2014, 1, 2, tzinfo=datetime.timezone.utc)
rows = [{
'MODULE_ID': 'i4x://test/problem/1',
'TOTAL_SUBMISSIONS': 150,
'CORRECT_SUBMISSIONS': 50,
'PART_IDS': 'part-1,part-2',
'CREATED': created,
}]

self.assertEqual(map_course_problem_rows(rows), [{
'module_id': 'i4x://test/problem/1',
'total_submissions': 150,
'correct_submissions': 50,
'part_ids': ['part-1', 'part-2'],
'created': created,
}])

def test_map_problem_answer_distribution_rows_returns_first_last_model_instances(self):
created = datetime.datetime(2014, 1, 2, tzinfo=datetime.timezone.utc)
rows = [{
'COURSE_ID': 'course-v1:edX+DemoX+Demo_Course',
'MODULE_ID': 'i4x://test/problem/1',
'PART_ID': 'part-1',
'CORRECT': True,
'VALUE_ID': 'choice-1',
'ANSWER_VALUE_TEXT': 'Answer 1',
'VARIANT': 123,
'PROBLEM_DISPLAY_NAME': 'Test Problem',
'QUESTION_TEXT': 'Question Text',
'FIRST_RESPONSE_COUNT': 2,
'LAST_RESPONSE_COUNT': 3,
'CREATED': created,
}]

mapped_rows = map_problem_answer_distribution_rows(rows)

self.assertEqual(len(mapped_rows), 1)
mapped_row = mapped_rows[0]
self.assertEqual(mapped_row.course_id, 'course-v1:edX+DemoX+Demo_Course')
self.assertEqual(mapped_row.module_id, 'i4x://test/problem/1')
self.assertEqual(mapped_row.part_id, 'part-1')
self.assertTrue(mapped_row.correct)
self.assertEqual(mapped_row.value_id, 'choice-1')
self.assertEqual(mapped_row.answer_value, 'Answer 1')
self.assertEqual(mapped_row.variant, 123)
self.assertEqual(mapped_row.problem_display_name, 'Test Problem')
self.assertEqual(mapped_row.question_text, 'Question Text')
self.assertEqual(mapped_row.first_response_count, 2)
self.assertEqual(mapped_row.last_response_count, 3)
self.assertEqual(mapped_row.created, created)


class InsightsSnowflakeServiceTests(SimpleTestCase):
"""Cover service orchestration without real Snowflake calls."""

Expand Down Expand Up @@ -984,6 +1090,32 @@ def test_get_video_timeline_calls_query_and_mapper(self, mock_get_rows, mock_map
mock_get_rows.assert_called_once_with('video-1')
mock_map_rows.assert_called_once_with(raw_rows)

@patch('analytics_data_api.insights_snowflake.service.map_course_problem_rows')
@patch('analytics_data_api.insights_snowflake.service.get_course_problem_rows')
def test_get_course_problems_calls_query_and_mapper(self, mock_get_rows, mock_map_rows):
raw_rows = [{'module_id': 'problem-1'}]
mapped_rows = [{'module_id': 'problem-1', 'total_submissions': 10}]
mock_get_rows.return_value = raw_rows
mock_map_rows.return_value = mapped_rows

self.assertEqual(get_course_problems('course-v1:edX+DemoX+Demo_Course'), mapped_rows)

mock_get_rows.assert_called_once_with('course-v1:edX+DemoX+Demo_Course')
mock_map_rows.assert_called_once_with(raw_rows)

@patch('analytics_data_api.insights_snowflake.service.map_problem_answer_distribution_rows')
@patch('analytics_data_api.insights_snowflake.service.get_problem_answer_distribution_rows')
def test_get_problem_answer_distribution_calls_query_and_mapper(self, mock_get_rows, mock_map_rows):
raw_rows = [{'module_id': 'problem-1'}]
mapped_rows = [{'module_id': 'problem-1', 'last_response_count': 3}]
mock_get_rows.return_value = raw_rows
mock_map_rows.return_value = mapped_rows

self.assertEqual(get_problem_answer_distribution('problem-1'), mapped_rows)

mock_get_rows.assert_called_once_with('problem-1')
mock_map_rows.assert_called_once_with(raw_rows)

@patch('analytics_data_api.insights_snowflake.service.map_program_metadata_rows')
@patch('analytics_data_api.insights_snowflake.service.get_program_metadata_rows')
def test_get_program_metadata_calls_query_and_mapper(self, mock_get_rows, mock_map_rows):
Expand Down
Loading
Loading