This repository has been archived by the owner on Apr 3, 2024. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 5
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Add event listener for course publish
Creates the edx-platform plugin plumbing, adds some new requirements, maps the appropriate Django Signal to push course structure to ClickHouse.
- Loading branch information
Showing
6 changed files
with
307 additions
and
24 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,157 @@ | ||
import csv | ||
import random | ||
import string | ||
from datetime import datetime | ||
from io import StringIO | ||
from unittest.mock import MagicMock, Mock | ||
|
||
from opaque_keys.edx.keys import CourseKey | ||
from opaque_keys.edx.locator import BlockUsageLocator | ||
|
||
from event_sink_clickhouse.sinks.course_published import CoursePublishedSink | ||
|
||
ORIG_IMPORT = __import__ | ||
ORG = "testorg" | ||
COURSE = "testcourse" | ||
COURSE_RUN = "2023_Fall" | ||
|
||
|
||
class FakeXBlock: | ||
def __init__(self, identifier, detached_block=False): | ||
self.block_type = "course_info" if detached_block else "vertical" | ||
self.scope_ids = Mock() | ||
self.scope_ids.usage_id.course_key = course_key_factory() | ||
self.scope_ids.block_type = self.block_type | ||
self.location = block_usage_locator_factory() | ||
self.display_name_with_default = f"Display name {identifier}" | ||
self.edited_on = datetime.now() | ||
self.children = [] | ||
|
||
def get_children(self): | ||
return self.children | ||
|
||
|
||
def course_str_factory(): | ||
course_str = f"course-v1:{ORG}+{COURSE}+{COURSE_RUN}" | ||
return course_str | ||
|
||
|
||
def course_key_factory(): | ||
return CourseKey.from_string(course_str_factory()) | ||
|
||
|
||
def block_usage_locator_factory(): | ||
block_id = ''.join(random.choices(string.ascii_letters, k=10)) | ||
return BlockUsageLocator(course_key_factory(), block_type="category", block_id=block_id, deprecated=True) | ||
|
||
|
||
def mock_course_overview(): | ||
mock_overview = MagicMock() | ||
mock_overview.get_from_id = MagicMock() | ||
mock_overview.get_from_id.return_value = datetime.now() | ||
# CourseOverview.get_from_id(course_key).modified | ||
return mock_overview | ||
|
||
|
||
def mock_detached_xblock_types(): | ||
# Current values as of 2023-05-01 | ||
return {'static_tab', 'about', 'course_info'} | ||
|
||
|
||
def get_clickhouse_http_params(): | ||
blocks_params = { | ||
"input_format_allow_errors_num": 1, | ||
"input_format_allow_errors_ratio": 0.1, | ||
"query": "INSERT INTO cool_data.course_blocks FORMAT CSV" | ||
} | ||
relationships_params = { | ||
"input_format_allow_errors_num": 1, | ||
"input_format_allow_errors_ratio": 0.1, | ||
"query": "INSERT INTO cool_data.course_relationships FORMAT CSV" | ||
} | ||
|
||
return blocks_params, relationships_params | ||
|
||
|
||
def course_factory(): | ||
top_block = FakeXBlock("top") | ||
course = [top_block, ] | ||
|
||
for i in range(3): | ||
block = FakeXBlock(f"Child {i}") | ||
course.append(block) | ||
top_block.children.append(block) | ||
|
||
if i > 0: | ||
sub_block = FakeXBlock(f"Grandchild {i}") | ||
course.append(sub_block) | ||
block.children.append(sub_block) | ||
|
||
for i in range(3): | ||
course.append(FakeXBlock(f"Detached {i}", detached_block=True)) | ||
|
||
return course | ||
|
||
|
||
def check_block_csv_matcher(course): | ||
def match(request): | ||
body = request.body | ||
lines = body.split("\n")[:-1] | ||
|
||
if len(lines) != len(course): | ||
return False, f"Body has {len(lines)} lines, course has {len(course)}" | ||
|
||
f = StringIO(body) | ||
reader = csv.reader(f) | ||
|
||
i = 0 | ||
try: | ||
for row in reader: | ||
block = course[i] | ||
assert row[0] == block.location.org | ||
assert row[1] == str(block.location.course_key) | ||
assert row[2] == block.location.course | ||
assert row[3] == block.location.run | ||
assert row[4] == str(course[i].location) | ||
assert row[5] == block.display_name_with_default | ||
assert row[6] == str(block.block_type) | ||
i += 1 | ||
except AssertionError as e: | ||
return False, f"Mismatch in row {i}: {e}" | ||
return True, "" | ||
return match | ||
|
||
|
||
def check_relationship_csv_matcher(course): | ||
relationships = [] | ||
for block in course: | ||
course_key = str(block.location.course_key) | ||
for index, child in enumerate(block.get_children()): | ||
parent_node = str(CoursePublishedSink.strip_branch_and_version(block.location)) | ||
child_node = str(CoursePublishedSink.strip_branch_and_version(child.location)) | ||
relationships.append((course_key, parent_node, child_node)) | ||
|
||
def match(request): | ||
body = request.body | ||
lines = body.split("\n")[:-1] | ||
|
||
if len(lines) != len(relationships): | ||
return False, f"Body has {len(lines)} lines but there are {len(relationships)} relationships" | ||
|
||
f = StringIO(body) | ||
reader = csv.reader(f) | ||
|
||
i = 0 | ||
try: | ||
for row in reader: | ||
print(row) | ||
print(relationships[i]) | ||
relation = relationships[i] | ||
assert row[0] == relation[0] | ||
assert row[1] == relation[1] | ||
assert row[2] == relation[2] | ||
i += 1 | ||
except AssertionError as e: | ||
return False, f"Mismatch in row {i}: {e}" | ||
return True, "" | ||
return match |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,50 @@ | ||
import logging | ||
from io import StringIO | ||
from unittest.mock import patch | ||
|
||
import responses | ||
from responses import matchers | ||
from responses.registries import OrderedRegistry | ||
|
||
from test_utils.helpers import ( | ||
check_block_csv_matcher, | ||
check_relationship_csv_matcher, | ||
course_factory, | ||
course_str_factory, | ||
get_clickhouse_http_params, | ||
mock_detached_xblock_types, | ||
) | ||
from event_sink_clickhouse.tasks import dump_course_to_clickhouse | ||
|
||
|
||
@responses.activate(registry=OrderedRegistry) | ||
@patch("event_sink_clickhouse.sinks.course_published.CoursePublishedSink._get_detached_xblock_types") | ||
@patch("event_sink_clickhouse.sinks.course_published.CoursePublishedSink._get_modulestore") | ||
def test_course_publish_success(mock_modulestore, mock_detached, caplog): | ||
caplog.set_level(logging.INFO, logger="edx.celery.task") | ||
course = course_factory() | ||
mock_modulestore.return_value.get_items.return_value = course | ||
mock_detached.return_value = mock_detached_xblock_types() | ||
|
||
blocks_params, relationships_params = get_clickhouse_http_params() | ||
|
||
responses.post( | ||
"https://foo.bar/", | ||
match=[ | ||
matchers.query_param_matcher(blocks_params), | ||
check_block_csv_matcher(course) | ||
], | ||
) | ||
responses.post( | ||
"https://foo.bar/", | ||
match=[ | ||
matchers.query_param_matcher(relationships_params), | ||
check_relationship_csv_matcher(course) | ||
], | ||
) | ||
|
||
course = course_str_factory() | ||
dump_course_to_clickhouse(course) | ||
|
||
assert mock_modulestore.call_count == 1 | ||
assert mock_detached.call_count == 1 |
Oops, something went wrong.