From 8715cca6e706c667447d0a62304ddae3ab4ce2cc Mon Sep 17 00:00:00 2001 From: Felipe Montoya Date: Tue, 10 Nov 2026 20:01:22 -0500 Subject: [PATCH] feat: load fake learner competency statuses Co-Authored-By: Claude Opus 5.5 --- README.rst | 39 +++ default_config.yaml | 26 ++ xapi_db_load/backends/chdb.py | 141 +++++++++- xapi_db_load/backends/clickhouse.py | 96 ++++++- xapi_db_load/backends/csv.py | 98 ++++++- xapi_db_load/backends/ralph.py | 8 + xapi_db_load/backends/vector.py | 8 + xapi_db_load/constants.py | 59 ++++ xapi_db_load/course_configs.py | 11 +- xapi_db_load/generate_load_async.py | 313 ++++++++++++++++++++- xapi_db_load/tests/test_event_generator.py | 240 ++++++++++++++++ 11 files changed, 1012 insertions(+), 27 deletions(-) diff --git a/README.rst b/README.rst index 966bd0e..ca59e8c 100644 --- a/README.rst +++ b/README.rst @@ -330,6 +330,45 @@ test:: # real enrollments dumped by the LMS, as with first_catalog_pathway_id. first_pathway_enrollment_id: 100001 + # Fake organizations are named 0, 1... A different + # prefix keeps a load apart from earlier ones, or from real data, in the + # org filters. + org_prefix: Org + + # The ids given to the first fake taxonomy, tag and object tag; the rest + # count up from them (tag and object tag ids across all taxonomies and + # courses). Raise them above the ids of real rows dumped by the LMS into + # the same event_sink tables, or the two overwrite each other. + first_taxonomy_id: 1 + first_tag_id: 1 + first_object_tag_id: 1 + + # The taxonomy export id written on every fake object tag. Aspects tells + # competency taxonomies apart by an export id starting with "competency-", + # so e.g. competency-music makes the fake taxonomy a competency taxonomy. + # Only then are learner competency statuses written to + # event_sink.student_competency_status, which exists only in Aspects installs + # that have run the tutor-contrib-aspects migration + # 0043_event_sink_student_competency_status. Each learner enrolled in a + # course gets a status on every competency tagged on its blocks: none, + # PartiallyAttempted or Demonstrated, with time to mastery spread from hours + # to weeks, and parent competencies rolled up the way openedx-core does. + taxonomy_export_id: fake export id + + # The id given to the first fake Student Competency Status, kept above the + # ids of real statuses dumped by the LMS, as with first_catalog_pathway_id. + first_student_competency_status_id: 100001 + + # A competency load also writes each learner's status per course to + # event_sink.student_competency_criteria_group_status (tutor-contrib-aspects + # migration 0044_event_sink_student_competency_criteria_group_status): one + # course-level criteria group row per learner, competency and course, which + # can be AttemptedNotDemonstrated, the status core never stores per + # competency. The fake criteria groups exist only as ids. These are the first + # status id and the first group id, kept above the real ones as above. + first_competency_group_status_id: 100001 + first_competency_criteria_group_id: 100001 + # The number of learners to create, random subsets of these will be # "registered" for each course and have statements generated for them # between their registration date and the end of the course diff --git a/default_config.yaml b/default_config.yaml index c91de0a..6d7cf4e 100644 --- a/default_config.yaml +++ b/default_config.yaml @@ -62,6 +62,32 @@ first_catalog_pathway_id: 100001 num_enrollments_per_pathway: 5 first_pathway_enrollment_id: 100001 +# Fake organizations are named 0, 1... A different +# prefix keeps a load apart from earlier ones in the org filters. +org_prefix: Org + +# Ids of the fake taxonomies, tags and object tags. Raise them above the ids of +# real rows the LMS dumps into the same event_sink tables, or the two overwrite +# each other. +first_taxonomy_id: 1 +first_tag_id: 1 +first_object_tag_id: 1 + +# Export id written on every fake object tag. One starting with "competency-" +# (e.g. competency-music) makes the fake taxonomy a competency taxonomy and +# generates learner competency statuses into event_sink.student_competency_status, +# which needs the tutor-contrib-aspects migration +# 0043_event_sink_student_competency_status. +taxonomy_export_id: fake export id +first_student_competency_status_id: 100001 + +# Course-level criteria group statuses of a competency load, written to +# event_sink.student_competency_criteria_group_status (tutor-contrib-aspects +# migration 0044_event_sink_student_competency_criteria_group_status), and the ids +# of the fake criteria groups they point at. +first_competency_group_status_id: 100001 +first_competency_criteria_group_id: 100001 + # This replicates users updating their profiles several times, creating # more rows num_actor_profile_changes: 5 diff --git a/xapi_db_load/backends/chdb.py b/xapi_db_load/backends/chdb.py index a4a1114..6b8af1b 100644 --- a/xapi_db_load/backends/chdb.py +++ b/xapi_db_load/backends/chdb.py @@ -85,6 +85,18 @@ def get_test_data_tasks(self) -> List[Waiter]: self.event_generator, self.db_loader, ), + WriteStudentCompetencyStatuses( + self.config, + self.logger, + self.event_generator, + self.db_loader, + ), + WriteStudentCompetencyCriteriaGroupStatuses( + self.config, + self.logger, + self.event_generator, + self.db_loader, + ), WriteBlocks( self.config, self.logger, @@ -666,6 +678,124 @@ async def _run_task(self): await self._write_list_sql_retry(out_data, "pathway_enrollment") +class WriteStudentCompetencyStatuses(XAPILakeCHDBAsync): # pylint: disable=abstract-method + """ + Write the learners' competency statuses to S3 using CHDB, all under one dump. + """ + + task_name = "Insert Student Competency Statuses" + schema = "event_sink" + table = "student_competency_status" + s3_load_prefix = "student_competency_status" + table_definition = """ + `id` Int64, + `actor_id` String, + `tag_id` Int64, + `taxonomy_id` Int64, + `status_id` UInt8, + `status` String, + `created` String, + `modified` String, + `dump_id` UUID, + `time_last_dumped` String + """ + + async def _run_task(self): + """ + Insert the learners' competency statuses into the event sink db. + """ + statuses = self.event_generator.student_competency_statuses + self.update_total_task_count(len(statuses)) + + dump_id = str(uuid.uuid4()) + dump_time = datetime.now(UTC) + out_data = [] + + for s in statuses: + out_data.append( + f"""( + {s["id"]}, + '{s["actor_id"]}', + {s["tag_id"]}, + {s["taxonomy_id"]}, + {s["status_id"]}, + '{s["status"]}', + '{s["created"]}', + '{s["modified"]}', + '{dump_id}', + '{dump_time}' + )""" + ) + self.update_completed_task_count(increment_by=1) + + if out_data: + await self._write_list_sql_retry(out_data, "student_competency_status") + + +class WriteStudentCompetencyCriteriaGroupStatuses(XAPILakeCHDBAsync): # pylint: disable=abstract-method + """ + Write the learners' course-level criteria group statuses to S3 using CHDB, all under one dump. + """ + + task_name = "Insert Student Competency Criteria Group Statuses" + schema = "event_sink" + table = "student_competency_criteria_group_status" + s3_load_prefix = "student_competency_criteria_group_status" + table_definition = """ + `id` Int64, + `actor_id` String, + `group_id` Int64, + `parent_group_id` Nullable(Int64), + `tag_id` Int64, + `taxonomy_id` Int64, + `course_key` String, + `group_level` String, + `status_id` UInt8, + `status` String, + `created` String, + `modified` String, + `dump_id` UUID, + `time_last_dumped` String + """ + + async def _run_task(self): + """ + Insert the learners' course-level criteria group statuses into the event sink db. + """ + statuses = self.event_generator.student_competency_criteria_group_statuses + self.update_total_task_count(len(statuses)) + + dump_id = str(uuid.uuid4()) + dump_time = datetime.now(UTC) + out_data = [] + + for s in statuses: + out_data.append( + f"""( + {s["id"]}, + '{s["actor_id"]}', + {s["group_id"]}, + {s["parent_group_id"]}, + {s["tag_id"]}, + {s["taxonomy_id"]}, + '{s["course_key"]}', + '{s["group_level"]}', + {s["status_id"]}, + '{s["status"]}', + '{s["created"]}', + '{s["modified"]}', + '{dump_id}', + '{dump_time}' + )""" + ) + self.update_completed_task_count(increment_by=1) + + if out_data: + await self._write_list_sql_retry( + out_data, "student_competency_criteria_group_status" + ) + + class WriteBlocks(XAPILakeCHDBAsync): # pylint: disable=abstract-method """ Write the course block data to S3 using CHDB. @@ -774,20 +904,17 @@ async def _run_task(self): obj_tag_out_data = [] object_tags = course.serialize_object_tag_data_for_event_sink() - row_id = 0 for _ in range(num_course_publishes): dump_id = str(uuid.uuid4()) dump_time = datetime.now(UTC) for obj_tag in object_tags: - row_id += 1 - out_tag = f"""( - {row_id}, + {obj_tag["object_tag_id"]}, '{obj_tag["object_id"]}', {obj_tag["taxonomy_id"]}, {obj_tag["tag_id"]}, '{obj_tag["value"]}', - 'fake export id', + '{obj_tag["export_id"]}', '{obj_tag["hierarchy"]}', '{dump_id}', '{dump_time}' @@ -830,9 +957,9 @@ async def _run_task(self): dump_time = datetime.now(UTC) out_data = [] - for taxonomy in taxonomies.keys(): + for taxonomy, taxonomy_id in self.event_generator.taxonomy_ids.items(): out = f"""( - {self.completed_task_count}, + {taxonomy_id}, '{taxonomy}', '{dump_id}', '{dump_time}' diff --git a/xapi_db_load/backends/clickhouse.py b/xapi_db_load/backends/clickhouse.py index afac8d5..8f3e128 100644 --- a/xapi_db_load/backends/clickhouse.py +++ b/xapi_db_load/backends/clickhouse.py @@ -45,6 +45,12 @@ def get_test_data_tasks(self) -> List[Waiter]: InsertCourses(self.config, self.logger, self.event_generator), InsertCatalogPathways(self.config, self.logger, self.event_generator), InsertPathwayEnrollments(self.config, self.logger, self.event_generator), + InsertStudentCompetencyStatuses( + self.config, self.logger, self.event_generator + ), + InsertStudentCompetencyCriteriaGroupStatuses( + self.config, self.logger, self.event_generator + ), InsertBlocks(self.config, self.logger, self.event_generator), InsertObjectTags(self.config, self.logger, self.event_generator), InsertTaxonomies(self.config, self.logger, self.event_generator), @@ -351,6 +357,84 @@ async def _run_task(self): self.update_completed_task_count(increment_by=len(batch)) +class InsertStudentCompetencyStatuses(XAPILakeClickhouseAsync): # pylint: disable=abstract-method + """Inserts Student Competency Status data to ClickHouse.""" + + task_name = "Insert Student Competency Statuses" + + async def _run_task(self): + """ + Insert the learners' competency statuses to ClickHouse, all under one dump, in batches. + """ + statuses = self.event_generator.student_competency_statuses + self.update_total_task_count(len(statuses)) + + dump_id = str(uuid.uuid4()) + dump_time = str(datetime.now(UTC)) + batch_size = self.config["batch_size"] + + for i in range(0, len(statuses), batch_size): + batch = statuses[i:i + batch_size] + out_data = [ + ( + s["id"], + s["actor_id"], + s["tag_id"], + s["taxonomy_id"], + s["status_id"], + s["status"], + s["created"], + s["modified"], + dump_id, + dump_time, + ) + for s in batch + ] + await self._insert_rows("student_competency_status", out_data) + self.update_completed_task_count(increment_by=len(batch)) + + +class InsertStudentCompetencyCriteriaGroupStatuses(XAPILakeClickhouseAsync): # pylint: disable=abstract-method + """Inserts Student Competency Criteria Group Status data to ClickHouse.""" + + task_name = "Insert Student Competency Criteria Group Statuses" + + async def _run_task(self): + """ + Insert the learners' course-level criteria group statuses to ClickHouse, all under one dump, in batches. + """ + statuses = self.event_generator.student_competency_criteria_group_statuses + self.update_total_task_count(len(statuses)) + + dump_id = str(uuid.uuid4()) + dump_time = str(datetime.now(UTC)) + batch_size = self.config["batch_size"] + + for i in range(0, len(statuses), batch_size): + batch = statuses[i:i + batch_size] + out_data = [ + ( + s["id"], + s["actor_id"], + s["group_id"], + s["parent_group_id"], + s["tag_id"], + s["taxonomy_id"], + s["course_key"], + s["group_level"], + s["status_id"], + s["status"], + s["created"], + s["modified"], + dump_id, + dump_time, + ) + for s in batch + ] + await self._insert_rows("student_competency_criteria_group_status", out_data) + self.update_completed_task_count(increment_by=len(batch)) + + class InsertBlocks(XAPILakeClickhouseAsync): # pylint: disable=abstract-method """Inserts course block data to ClickHouse.""" @@ -418,17 +502,15 @@ async def _run_task(self): dump_time = datetime.now(UTC) obj_tag_out_data = [] - row_id = 0 for obj_tag in object_tags: - row_id += 1 obj_tag_out_data.append( ( - row_id, + obj_tag["object_tag_id"], obj_tag["object_id"], obj_tag["taxonomy_id"], obj_tag["tag_id"], obj_tag["value"], - "fake export id", + obj_tag["export_id"], obj_tag["hierarchy"], dump_id, str(dump_time), @@ -455,10 +537,8 @@ async def _run_task(self): self.update_total_task_count(len(taxonomies)) out_data = [] - tag_id = 0 - for taxonomy in taxonomies.keys(): - tag_id += 1 - out_data.append((tag_id, taxonomy, dump_id, str(dump_time))) + for taxonomy, taxonomy_id in self.event_generator.taxonomy_ids.items(): + out_data.append((taxonomy_id, taxonomy, dump_id, str(dump_time))) self.update_completed_task_count(increment_by=1) await self._insert_rows("taxonomy", out_data) diff --git a/xapi_db_load/backends/csv.py b/xapi_db_load/backends/csv.py index 4152948..222450e 100644 --- a/xapi_db_load/backends/csv.py +++ b/xapi_db_load/backends/csv.py @@ -50,6 +50,12 @@ def get_test_data_tasks(self) -> List[Waiter]: WriteCSVCourses(self.config, self.logger, self.event_generator), WriteCSVCatalogPathways(self.config, self.logger, self.event_generator), WriteCSVPathwayEnrollments(self.config, self.logger, self.event_generator), + WriteCSVStudentCompetencyStatuses( + self.config, self.logger, self.event_generator + ), + WriteCSVStudentCompetencyCriteriaGroupStatuses( + self.config, self.logger, self.event_generator + ), WriteCSVBlocks(self.config, self.logger, self.event_generator), WriteCSVObjectTags(self.config, self.logger, self.event_generator), WriteCSVTaxonomies(self.config, self.logger, self.event_generator), @@ -299,6 +305,86 @@ async def _run_task(self): self.update_completed_task_count(increment_by=1) +class WriteCSVStudentCompetencyStatuses(XAPILakeCSVAsync): + """Writes Student Competency Status data to a gzipped CSV file.""" + + task_name = "Write CSV Student Competency Statuses" + schema = "event_sink" + table = "student_competency_status" + filename = "student_competency_statuses.csv.gz" + + async def _run_task(self): + """ + Write out the learners' competency statuses data file, all under one dump. + """ + async with self.get_csv_handle("student_competency_statuses") as status_csv_writer: + statuses = self.event_generator.student_competency_statuses + self.update_total_task_count(len(statuses)) + + dump_id = str(uuid.uuid4()) + dump_time = datetime.now(UTC) + + for s in statuses: + status_csv_writer.writerow( + ( + s["id"], + s["actor_id"], + s["tag_id"], + s["taxonomy_id"], + s["status_id"], + s["status"], + s["created"], + s["modified"], + dump_id, + dump_time, + ) + ) + self.update_completed_task_count(increment_by=1) + + +class WriteCSVStudentCompetencyCriteriaGroupStatuses(XAPILakeCSVAsync): + """Writes Student Competency Criteria Group Status data to a gzipped CSV file.""" + + task_name = "Write CSV Student Competency Criteria Group Statuses" + schema = "event_sink" + table = "student_competency_criteria_group_status" + filename = "student_competency_criteria_group_statuses.csv.gz" + + async def _run_task(self): + """ + Write out the learners' course-level criteria group statuses data file, all under one dump. + """ + async with self.get_csv_handle( + "student_competency_criteria_group_statuses" + ) as status_csv_writer: + statuses = self.event_generator.student_competency_criteria_group_statuses + self.update_total_task_count(len(statuses)) + + dump_id = str(uuid.uuid4()) + dump_time = datetime.now(UTC) + + for s in statuses: + status_csv_writer.writerow( + ( + s["id"], + s["actor_id"], + s["group_id"], + s["parent_group_id"], + s["tag_id"], + s["taxonomy_id"], + s["course_key"], + s["group_level"], + s["status_id"], + s["status"], + s["created"], + s["modified"], + dump_id, + dump_time, + ) + ) + self.update_completed_task_count(increment_by=1) + + class WriteCSVBlocks(XAPILakeCSVAsync): """Writes course block data to a gzipped CSV file.""" @@ -358,7 +444,6 @@ async def _run_task(self): len(self.event_generator.courses) * num_course_publishes ) - row_id = 0 for course in self.event_generator.courses: object_tags = course.serialize_object_tag_data_for_event_sink() @@ -366,15 +451,14 @@ async def _run_task(self): dump_id = str(uuid.uuid4()) dump_time = datetime.now(UTC) for obj_tag in object_tags: - row_id += 1 object_tag_csv_writer.writerow( ( - row_id, + obj_tag["object_tag_id"], obj_tag["object_id"], obj_tag["taxonomy_id"], obj_tag["tag_id"], obj_tag["value"], - "fake export id", + obj_tag["export_id"], obj_tag["hierarchy"], dump_id, dump_time, @@ -403,10 +487,8 @@ async def _run_task(self): dump_id = str(uuid.uuid4()) dump_time = datetime.now(UTC) - tag_id = 1 - for taxonomy in taxonomies.keys(): - tag_id += 1 - taxonomy_csv_writer.writerow((tag_id, taxonomy, dump_id, dump_time)) + for taxonomy, taxonomy_id in self.event_generator.taxonomy_ids.items(): + taxonomy_csv_writer.writerow((taxonomy_id, taxonomy, dump_id, dump_time)) self.update_completed_task_count(increment_by=1) diff --git a/xapi_db_load/backends/ralph.py b/xapi_db_load/backends/ralph.py index d9d292f..c2d8ae4 100644 --- a/xapi_db_load/backends/ralph.py +++ b/xapi_db_load/backends/ralph.py @@ -22,6 +22,8 @@ InsertObjectTags, InsertPathwayEnrollments, InsertProfiles, + InsertStudentCompetencyCriteriaGroupStatuses, + InsertStudentCompetencyStatuses, InsertTags, InsertTaxonomies, InsertXAPIEvents, @@ -45,6 +47,12 @@ def get_test_data_tasks(self): InsertCourses(self.config, self.logger, self.event_generator), InsertCatalogPathways(self.config, self.logger, self.event_generator), InsertPathwayEnrollments(self.config, self.logger, self.event_generator), + InsertStudentCompetencyStatuses( + self.config, self.logger, self.event_generator + ), + InsertStudentCompetencyCriteriaGroupStatuses( + self.config, self.logger, self.event_generator + ), InsertBlocks(self.config, self.logger, self.event_generator), InsertObjectTags(self.config, self.logger, self.event_generator), InsertTaxonomies(self.config, self.logger, self.event_generator), diff --git a/xapi_db_load/backends/vector.py b/xapi_db_load/backends/vector.py index ca59c41..4db485f 100644 --- a/xapi_db_load/backends/vector.py +++ b/xapi_db_load/backends/vector.py @@ -23,6 +23,8 @@ InsertObjectTags, InsertPathwayEnrollments, InsertProfiles, + InsertStudentCompetencyCriteriaGroupStatuses, + InsertStudentCompetencyStatuses, InsertTags, InsertTaxonomies, InsertXAPIEvents, @@ -46,6 +48,12 @@ def get_test_data_tasks(self): InsertCourses(self.config, self.logger, self.event_generator), InsertCatalogPathways(self.config, self.logger, self.event_generator), InsertPathwayEnrollments(self.config, self.logger, self.event_generator), + InsertStudentCompetencyStatuses( + self.config, self.logger, self.event_generator + ), + InsertStudentCompetencyCriteriaGroupStatuses( + self.config, self.logger, self.event_generator + ), InsertBlocks(self.config, self.logger, self.event_generator), InsertObjectTags(self.config, self.logger, self.event_generator), InsertTaxonomies(self.config, self.logger, self.event_generator), diff --git a/xapi_db_load/constants.py b/xapi_db_load/constants.py index a884968..325bd02 100644 --- a/xapi_db_load/constants.py +++ b/xapi_db_load/constants.py @@ -58,6 +58,65 @@ # Share of fake Pathway Enrollments that end up unenrolled (``is_active`` False). PATHWAY_UNENROLLED_RATIO = 0.1 +# Prefix of the fake organizations (``Org0``, ``Org1``...) when ``org_prefix`` +# is not set. A different prefix keeps a load apart from earlier ones in the +# org filters. +DEFAULT_ORG_PREFIX = "Org" + +# Export id written on every fake object tag when ``taxonomy_export_id`` is not +# set. An export id starting with ``COMPETENCY_EXPORT_ID_PREFIX`` makes the fake +# taxonomy a competency taxonomy: Aspects tells competency taxonomies apart by +# that prefix until the taxonomy sink carries a competency flag, and only then +# are learner competency statuses generated. +DEFAULT_TAXONOMY_EXPORT_ID = "fake export id" +COMPETENCY_EXPORT_ID_PREFIX = "competency-" + +# First ids given to the fake taxonomies, tags and object tags when +# ``first_taxonomy_id``, ``first_tag_id`` and ``first_object_tag_id`` are not +# set. Raise them above the ids of real rows the LMS dumps into the same +# event_sink tables, or the two overwrite each other. +DEFAULT_FIRST_TAXONOMY_ID = 1 +DEFAULT_FIRST_TAG_ID = 1 +DEFAULT_FIRST_OBJECT_TAG_ID = 1 + +# First id given to a fake Student Competency Status +# (``event_sink.student_competency_status``) when +# ``first_student_competency_status_id`` is not set, kept above the ids of real +# statuses dumped by the LMS. +DEFAULT_FIRST_STUDENT_COMPETENCY_STATUS_ID = 100001 + +# openedx-core mastery statuses. Core never stores AttemptedNotDemonstrated (10) +# at the competency level, only on criteria groups, and "not started" is no row. +MASTERY_STATUS_ATTEMPTED_NOT_DEMONSTRATED = (10, "AttemptedNotDemonstrated") +MASTERY_STATUS_PARTIALLY_ATTEMPTED = (20, "PartiallyAttempted") +MASTERY_STATUS_DEMONSTRATED = (30, "Demonstrated") + +# How a learner enrolled in a course stands on each competency the course +# assesses, as weights for (no status yet, attempted not demonstrated, partially +# attempted, demonstrated). That is the course-level criteria group status; the +# competency-level status records AttemptedNotDemonstrated as PartiallyAttempted. +COMPETENCY_STATUS_WEIGHTS = (0.2, 0.15, 0.25, 0.4) + +# First id given to a fake Student Competency Criteria Group Status +# (``event_sink.student_competency_criteria_group_status``) and to the fake +# criteria groups those rows point at, when ``first_competency_group_status_id`` +# and ``first_competency_criteria_group_id`` are not set. +DEFAULT_FIRST_COMPETENCY_GROUP_STATUS_ID = 100001 +DEFAULT_FIRST_COMPETENCY_CRITERIA_GROUP_ID = 100001 + +# A learner's first status on a competency lands soon after they enroll: an +# exponential delay with this median, in hours. Their first xAPI activity in the +# course comes after the enrollment too, so time to mastery measured from either +# one is mostly the mastery time below. +COMPETENCY_FIRST_STATUS_MEDIAN_HOURS = 12.0 + +# Each competency gets its own difficulty: the median time its learners take to +# demonstrate it, drawn log-uniformly from this range (in days), so some take +# hours and others weeks. Each learner then takes a log-normal time around that +# median with this sigma. +COMPETENCY_MASTERY_MEDIAN_DAYS_RANGE = (0.15, 30.0) +COMPETENCY_MASTERY_SIGMA = 0.6 + # Range of "course runs" generated per logical course (inclusive of low, # exclusive of high — passed straight to ``random.randrange``). MIN_COURSE_RUNS = 1 diff --git a/xapi_db_load/course_configs.py b/xapi_db_load/course_configs.py index 391e6de..4b70fd7 100644 --- a/xapi_db_load/course_configs.py +++ b/xapi_db_load/course_configs.py @@ -87,6 +87,7 @@ def __init__(self): self.forum_post_ids: List[str] = [] self.actors: list[EnrolledActor] = [] self.all_tags: list = [] + self.object_tags: List[Dict] | None = None self.start_date: datetime.datetime | None = None self.end_date: datetime.datetime | None = None @@ -399,7 +400,14 @@ def serialize_block_data_for_event_sink(self) -> List[Dict]: return course_structure def serialize_object_tag_data_for_event_sink(self) -> List[Dict]: - """Return list of object_tag dicts for the event-sink schema.""" + """ + Return list of object_tag dicts for the event-sink schema. + + The blocks are tagged at random on the first call and the same tags are + returned after that, so every caller sees the same object tags. + """ + if self.object_tags is not None: + return self.object_tags object_tags = [] cnt = 0 for block_type in ( @@ -427,4 +435,5 @@ def serialize_object_tag_data_for_event_sink(self) -> List[Dict]: )["location"] object_tags.append(object_tag) + self.object_tags = object_tags return object_tags diff --git a/xapi_db_load/generate_load_async.py b/xapi_db_load/generate_load_async.py index 415b0a4..b967a28 100644 --- a/xapi_db_load/generate_load_async.py +++ b/xapi_db_load/generate_load_async.py @@ -5,6 +5,7 @@ import asyncio import datetime import json +import math import os import random import uuid @@ -20,12 +21,28 @@ ) from xapi_db_load.constants import ( + COMPETENCY_EXPORT_ID_PREFIX, + COMPETENCY_FIRST_STATUS_MEDIAN_HOURS, + COMPETENCY_MASTERY_MEDIAN_DAYS_RANGE, + COMPETENCY_MASTERY_SIGMA, + COMPETENCY_STATUS_WEIGHTS, COURSE_ID_SHORT_LENGTH, DEFAULT_FIRST_CATALOG_PATHWAY_ID, + DEFAULT_FIRST_COMPETENCY_CRITERIA_GROUP_ID, + DEFAULT_FIRST_COMPETENCY_GROUP_STATUS_ID, + DEFAULT_FIRST_OBJECT_TAG_ID, DEFAULT_FIRST_PATHWAY_ENROLLMENT_ID, + DEFAULT_FIRST_STUDENT_COMPETENCY_STATUS_ID, + DEFAULT_FIRST_TAG_ID, + DEFAULT_FIRST_TAXONOMY_ID, DEFAULT_LMS_URL, DEFAULT_NUM_CATALOG_PATHWAYS, DEFAULT_NUM_ENROLLMENTS_PER_PATHWAY, + DEFAULT_ORG_PREFIX, + DEFAULT_TAXONOMY_EXPORT_ID, + MASTERY_STATUS_ATTEMPTED_NOT_DEMONSTRATED, + MASTERY_STATUS_DEMONSTRATED, + MASTERY_STATUS_PARTIALLY_ATTEMPTED, MAX_COURSE_RUNS, MIN_COURSE_RUNS, PATHWAY_CATEGORIES, @@ -118,6 +135,18 @@ def _random_datetime( return start + datetime.timedelta(microseconds=random.randrange(max(span, 1))) +def _as_utc_datetime(value: datetime.date) -> datetime.datetime: + """ + Return a date or datetime as a UTC datetime; course dates are plain dates + when the configured start and end dates are. + """ + if not isinstance(value, datetime.datetime): + return datetime.datetime.combine(value, datetime.time(), tzinfo=datetime.UTC) + if value.tzinfo is None: + return value.replace(tzinfo=datetime.UTC) + return value + + def to_sink_datetime(value: datetime.datetime) -> str: """ Format a UTC datetime the way the platform-plugin-aspects model sinks write @@ -151,7 +180,11 @@ def __init__( self.catalog_pathways: List[Dict] = [] self.pathway_enrollments: List[Dict] = [] self.taxonomies: Dict = {} + self.taxonomy_ids: Dict[str, int] = {} self.tags: List = [] + self.student_competency_statuses: List[Dict] = [] + self.student_competency_criteria_group_statuses: List[Dict] = [] + self.competency_mastery_median_days: Dict[int, float] = {} self.setup_complete = False self.start_date = config["start_date"] self.end_date = config["end_date"] @@ -194,6 +227,9 @@ async def run_task(self) -> None: await self.setup_courses() # Needs the courses, to enroll learners of the pathway's org. self.setup_pathway_enrollments() + self.setup_object_tags() + # Needs the object tags, to know which competencies each course assesses. + self.setup_student_competency_statuses() # Force this in case of a rounding error self.complete_pct = 1.0 @@ -212,8 +248,9 @@ def setup_orgs(self) -> None: """ Create some random organizations based on the config. """ + prefix = self.config.get("org_prefix", DEFAULT_ORG_PREFIX) for i in range(self.config["num_organizations"]): - self.orgs.append(f"Org{i}") + self.orgs.append(f"{prefix}{i}") def setup_catalog_pathways(self) -> None: """ @@ -313,6 +350,274 @@ def setup_pathway_enrollments(self) -> None: ) next_id += 1 + def setup_object_tags(self) -> None: + """ + Tag each course's blocks once, so every backend writes the same object + tags and the competency statuses match them. + + Object tag ids count up from ``first_object_tag_id`` across all courses, + and every row carries ``taxonomy_export_id`` as its taxonomy's export id. + """ + next_id = self.config.get("first_object_tag_id", DEFAULT_FIRST_OBJECT_TAG_ID) + export_id = self.config.get("taxonomy_export_id", DEFAULT_TAXONOMY_EXPORT_ID) + for course in self.courses: + for object_tag in course.serialize_object_tag_data_for_event_sink(): + object_tag["object_tag_id"] = next_id + object_tag["export_id"] = export_id + next_id += 1 + + def is_competency_load(self) -> bool: + """ + Return whether the fake taxonomy is a competency taxonomy, going by the + export id prefix Aspects uses to recognize one. + """ + return self.config.get( + "taxonomy_export_id", DEFAULT_TAXONOMY_EXPORT_ID + ).startswith(COMPETENCY_EXPORT_ID_PREFIX) + + def setup_student_competency_statuses(self) -> None: + """ + Give learners a mastery status on the competencies their courses assess. + + Only for a competency load (see ``is_competency_load``). A course assesses + every competency tagged on one of its blocks. Each enrolled learner gets, + per competency, no status, PartiallyAttempted or Demonstrated, weighted by + ``COMPETENCY_STATUS_WEIGHTS``. The first status lands soon after the + learner enrolled (``COMPETENCY_FIRST_STATUS_MEDIAN_HOURS``). Each + competency has its own difficulty (``competency_mastery_median_days``, + drawn from ``COMPETENCY_MASTERY_MEDIAN_DAYS_RANGE``), and a Demonstrated + row's ``modified`` comes a log-normal time around it later, so some + competencies are mastered in hours and others in weeks. + + Rows follow openedx-core's rules for ``StudentCompetencyStatus``: + + - one row per learner and competency: across courses the higher status + wins, ``created`` is the earliest, and ``modified`` of a Demonstrated + row is when it was first demonstrated + - only PartiallyAttempted (20) and Demonstrated (30) + - parent competencies roll up: Demonstrated once every child competency + the learner was assessed on is, PartiallyAttempted once any has a status + + Each course outcome is also a course-level criteria group status, which + can be AttemptedNotDemonstrated (10): see + ``_add_course_group_statuses``. The competency-level row records 10 as + PartiallyAttempted, as core's rollup does. + + Rows match the ``event_sink.student_competency_status`` table written by + the platform-plugin-aspects ``student_competency_status`` sink. + """ + if not self.is_competency_load(): + return + + _, end = self._run_window() + tags_by_id = {t["tag_id"]: t for t in self.tags} + outcomes = ( + None, + MASTERY_STATUS_ATTEMPTED_NOT_DEMONSTRATED[0], + MASTERY_STATUS_PARTIALLY_ATTEMPTED[0], + MASTERY_STATUS_DEMONSTRATED[0], + ) + low, high = COMPETENCY_MASTERY_MEDIAN_DAYS_RANGE + self.competency_mastery_median_days = { + tag_id: math.exp(random.uniform(math.log(low), math.log(high))) + for tag_id in tags_by_id + } + first_status_rate = math.log(2) / (COMPETENCY_FIRST_STATUS_MEDIAN_HOURS * 3600) + + # (user_id, tag_id) -> [actor, status_id, created, modified] + statuses: Dict[tuple, list] = {} + # (course, tag_id, actor, status_id, created, modified) per course outcome + course_outcomes: List[tuple] = [] + for course in self.courses: + assessed = {o["tag_id"] for o in course.serialize_object_tag_data_for_event_sink()} + course_end = min(_as_utc_datetime(course.end_date), end) + learners = {e.actor.user_id: e for e in course.actors} + for enrolled in learners.values(): + enrolled_at = _as_utc_datetime(enrolled.enroll_datetime) + for tag_id in assessed: + outcome = random.choices(outcomes, COMPETENCY_STATUS_WEIGHTS)[0] + if outcome is None: + continue + created = min( + enrolled_at + + datetime.timedelta(seconds=random.expovariate(first_status_rate)), + max(course_end, enrolled_at), + ) + status_id = min(outcome, MASTERY_STATUS_PARTIALLY_ATTEMPTED[0]) + modified = created + if outcome == MASTERY_STATUS_DEMONSTRATED[0]: + mastered = created + datetime.timedelta( + days=random.lognormvariate(0, COMPETENCY_MASTERY_SIGMA) + * self.competency_mastery_median_days[tag_id] + ) + if mastered <= end: + status_id = MASTERY_STATUS_DEMONSTRATED[0] + modified = mastered + else: + status_id = MASTERY_STATUS_PARTIALLY_ATTEMPTED[0] + course_outcomes.append( + (course, tag_id, enrolled.actor, status_id, created, modified) + ) + self._merge_status( + statuses, + enrolled.actor, + tag_id, + max(status_id, MASTERY_STATUS_PARTIALLY_ATTEMPTED[0]), + created, + modified, + ) + + self._roll_up_statuses(statuses, tags_by_id) + + next_id = self.config.get( + "first_student_competency_status_id", + DEFAULT_FIRST_STUDENT_COMPETENCY_STATUS_ID, + ) + names = dict((MASTERY_STATUS_PARTIALLY_ATTEMPTED, MASTERY_STATUS_DEMONSTRATED)) + for (_, tag_id), (actor, status_id, created, modified) in sorted( + statuses.items(), key=lambda item: item[1][2] + ): + self.student_competency_statuses.append( + { + "id": next_id, + "actor_id": actor.id, + "tag_id": tag_id, + "taxonomy_id": tags_by_id[tag_id]["taxonomy_id"], + "status_id": status_id, + "status": names[status_id], + "created": to_sink_datetime(created), + "modified": to_sink_datetime(modified), + } + ) + next_id += 1 + + self._add_course_group_statuses(course_outcomes, tags_by_id) + + def _add_course_group_statuses( + self, course_outcomes: List[tuple], tags_by_id: Dict[int, Dict] + ) -> None: + """ + Write one course-level criteria group status per learner, competency and + course they have a status in: AttemptedNotDemonstrated (10), + PartiallyAttempted (20) or Demonstrated (30). + + The fake criteria groups exist only as ids: one root group per + competency and one course group per competency and course run under it, + counting up from ``first_competency_criteria_group_id``. Only the + course-level rows are written; core also writes root and leaf group rows. + + Rows match the ``event_sink.student_competency_criteria_group_status`` + table written by the platform-plugin-aspects + ``student_competency_criteria_group_status`` sink. + """ + next_group_id = self.config.get( + "first_competency_criteria_group_id", + DEFAULT_FIRST_COMPETENCY_CRITERIA_GROUP_ID, + ) + root_groups: Dict[int, int] = {} + course_groups: Dict[tuple, int] = {} + names = dict( + ( + MASTERY_STATUS_ATTEMPTED_NOT_DEMONSTRATED, + MASTERY_STATUS_PARTIALLY_ATTEMPTED, + MASTERY_STATUS_DEMONSTRATED, + ) + ) + next_id = self.config.get( + "first_competency_group_status_id", DEFAULT_FIRST_COMPETENCY_GROUP_STATUS_ID + ) + for course, tag_id, actor, status_id, created, modified in sorted( + course_outcomes, key=lambda o: o[4] + ): + if tag_id not in root_groups: + root_groups[tag_id] = next_group_id + next_group_id += 1 + if (tag_id, course.course_id) not in course_groups: + course_groups[(tag_id, course.course_id)] = next_group_id + next_group_id += 1 + self.student_competency_criteria_group_statuses.append( + { + "id": next_id, + "actor_id": actor.id, + "group_id": course_groups[(tag_id, course.course_id)], + "parent_group_id": root_groups[tag_id], + "tag_id": tag_id, + "taxonomy_id": tags_by_id[tag_id]["taxonomy_id"], + "course_key": course.course_id, + "group_level": "course", + "status_id": status_id, + "status": names[status_id], + "created": to_sink_datetime(created), + "modified": to_sink_datetime(modified), + } + ) + next_id += 1 + + @staticmethod + def _merge_status( # pylint: disable=too-many-positional-arguments + statuses: Dict[tuple, list], + actor: Actor, + tag_id: int, + status_id: int, + created: datetime.datetime, + modified: datetime.datetime, + ) -> None: + """ + Fold one course's outcome into the learner's single status row, the way + openedx-core's raise-only writes would: the higher status wins, created + is the earliest, and a Demonstrated row keeps its first mastery time. + """ + key = (actor.user_id, tag_id) + if key not in statuses: + statuses[key] = [actor, status_id, created, modified] + return + row = statuses[key] + if status_id > row[1]: + row[1], row[3] = status_id, modified + elif status_id == row[1] == MASTERY_STATUS_DEMONSTRATED[0]: + row[3] = min(row[3], modified) + row[2] = min(row[2], created) + if row[1] != MASTERY_STATUS_DEMONSTRATED[0]: + # A PartiallyAttempted row was never raised, so modified is created. + row[3] = row[2] + + @staticmethod + def _roll_up_statuses(statuses: Dict[tuple, list], tags_by_id: Dict[int, Dict]) -> None: + """ + Add each learner's parent competencies, deepest first: Demonstrated once + every child the learner has a status on is (mastered when the last one + was), otherwise PartiallyAttempted from the first child status. + """ + def depth(tag_id: int) -> int: + parent = tags_by_id[tag_id]["parent_int_id"] + return 0 if not parent else 1 + depth(parent) + + by_actor: Dict[int, Dict[int, list]] = {} + for (user_id, tag_id), row in statuses.items(): + by_actor.setdefault(user_id, {})[tag_id] = row + + for user_id, rows in by_actor.items(): + pending = sorted(rows, key=depth, reverse=True) + while pending: + parent = tags_by_id[pending.pop(0)]["parent_int_id"] + if not parent or parent in rows: + continue + children = [ + rows[t] + for t in rows + if tags_by_id[t]["parent_int_id"] == parent + ] + actor = children[0][0] + created = min(c[2] for c in children) + if all(c[1] == MASTERY_STATUS_DEMONSTRATED[0] for c in children): + row = [actor, MASTERY_STATUS_DEMONSTRATED[0], created, max(c[3] for c in children)] + else: + row = [actor, MASTERY_STATUS_PARTIALLY_ATTEMPTED[0], created, created] + rows[parent] = row + statuses[(user_id, parent)] = row + pending.append(parent) + pending.sort(key=depth, reverse=True) + def _run_window(self) -> tuple[datetime.datetime, datetime.datetime]: """ Return the configured start and end dates as UTC datetimes. @@ -423,10 +728,12 @@ def setup_taxonomies_tags(self) -> None: # it should be guaranteed that all parents already exist when we get to # the child. tag_hierarchy = {} - taxonomy_id = 0 + taxonomy_id = self.config.get("first_taxonomy_id", DEFAULT_FIRST_TAXONOMY_ID) - 1 + # Tag ids are unique across taxonomies, as in the LMS. + tag_id = self.config.get("first_tag_id", DEFAULT_FIRST_TAG_ID) - 1 for taxonomy in self.taxonomies: # pylint: disable=consider-using-dict-items taxonomy_id += 1 - tag_id = 0 + self.taxonomy_ids[taxonomy] = taxonomy_id for tag in self.taxonomies[taxonomy]: tag_id += 1 tag["tag_id"] = tag_id diff --git a/xapi_db_load/tests/test_event_generator.py b/xapi_db_load/tests/test_event_generator.py index b65dc2a..5da6f83 100644 --- a/xapi_db_load/tests/test_event_generator.py +++ b/xapi_db_load/tests/test_event_generator.py @@ -6,12 +6,16 @@ import datetime import logging import re +import statistics import pytest from xapi_db_load.constants import ( + COMPETENCY_MASTERY_MEDIAN_DAYS_RANGE, DEFAULT_FIRST_CATALOG_PATHWAY_ID, + DEFAULT_FIRST_COMPETENCY_GROUP_STATUS_ID, DEFAULT_FIRST_PATHWAY_ENROLLMENT_ID, + DEFAULT_FIRST_STUDENT_COMPETENCY_STATUS_ID, DEFAULT_NUM_CATALOG_PATHWAYS, DEFAULT_NUM_ENROLLMENTS_PER_PATHWAY, PATHWAY_CATEGORIES, @@ -342,3 +346,239 @@ def test_pathway_enrollments_none_without_pathways(): gen = asyncio.run(_make_enrolled_generator(num_catalog_pathways=0)) assert not gen.pathway_enrollments + + +def _competency_generator(**overrides) -> EventGenerator: + """A whole setup whose fake taxonomy is a competency taxonomy, with enough learners.""" + config = { + "taxonomy_export_id": "competency-music", + "num_actors": 40, + "num_course_sizes": {"small": 3}, + "course_size_makeup": { + "small": { + "actors": 30, + "problems": 10, + "videos": 5, + "chapters": 2, + "sequences": 6, + "verticals": 8, + "forum_posts": 1, + } + }, + } + config.update(overrides) + return asyncio.run(_make_enrolled_generator(**config)) + + +def test_org_prefix(): + """org_prefix names the fake organizations, so a load can be told apart in the org filters.""" + gen = _make_generator(_base_config(num_organizations=2, org_prefix="Competency")) + gen.setup_orgs() + + assert gen.orgs == ["Competency0", "Competency1"] + + +def test_first_taxonomy_tag_and_object_tag_ids(): + """ + The first_* ids move the fake taxonomy, tag and object tag ids, tag ids are + unique across taxonomies, and object tag ids count up across courses. + """ + gen = _competency_generator(first_taxonomy_id=500, first_tag_id=1000, first_object_tag_id=7000) + + assert list(gen.taxonomy_ids.values()) == [500] + assert [t["tag_id"] for t in gen.tags] == list(range(1000, 1000 + len(gen.tags))) + object_tag_ids = [ + o["object_tag_id"] + for c in gen.courses + for o in c.serialize_object_tag_data_for_event_sink() + ] + assert object_tag_ids == list(range(7000, 7000 + len(object_tag_ids))) + assert { + o["export_id"] + for c in gen.courses + for o in c.serialize_object_tag_data_for_event_sink() + } == {"competency-music"} + + +def test_object_tags_are_stable(): + """Every call returns the same object tags, so all backends write the same rows.""" + gen = _competency_generator() + course = gen.courses[0] + + assert course.serialize_object_tag_data_for_event_sink() is course.serialize_object_tag_data_for_event_sink() + + +def test_no_competency_statuses_without_a_competency_taxonomy(): + """The default export id isn't a competency taxonomy, so no statuses are generated.""" + gen = _competency_generator(taxonomy_export_id="fake export id") + + assert not gen.student_competency_statuses + + +def test_competency_statuses_follow_core_rules(): + """ + One row per learner and competency, only PartiallyAttempted or Demonstrated, + created before modified, a Partially row unchanged since created, ids from the + default first id, and every learner a known actor. + """ + gen = _competency_generator() + statuses = gen.student_competency_statuses + actor_ids = {a.id for a in gen.actors} + + assert statuses + assert [s["id"] for s in statuses] == list( + range( + DEFAULT_FIRST_STUDENT_COMPETENCY_STATUS_ID, + DEFAULT_FIRST_STUDENT_COMPETENCY_STATUS_ID + len(statuses), + ) + ) + assert len({(s["actor_id"], s["tag_id"]) for s in statuses}) == len(statuses) + assert {(s["status_id"], s["status"]) for s in statuses} == { + (20, "PartiallyAttempted"), + (30, "Demonstrated"), + } + for s in statuses: + assert s["actor_id"] in actor_ids + assert SINK_DATETIME_RE.match(s["created"]), s["created"] + assert SINK_DATETIME_RE.match(s["modified"]), s["modified"] + created = datetime.datetime.fromisoformat(s["created"]) + modified = datetime.datetime.fromisoformat(s["modified"]) + assert created <= modified + assert modified.date() <= gen.end_date + if s["status_id"] == 20: + assert created == modified + + +def test_competency_statuses_only_for_assessed_competencies_and_their_parents(): + """ + A learner's statuses cover competencies tagged on their courses' blocks, plus + ancestors, and a parent is Demonstrated only when every child the learner has a + status on is. + """ + gen = _competency_generator() + tags = {t["tag_id"]: t for t in gen.tags} + assessed_by_actor: dict = {} + for course in gen.courses: + tagged = {o["tag_id"] for o in course.serialize_object_tag_data_for_event_sink()} + for enrolled in course.actors: + assessed_by_actor.setdefault(enrolled.actor.id, set()).update(tagged) + + def ancestors(tag_id): + parent = tags[tag_id]["parent_int_id"] + return set() if not parent else {parent} | ancestors(parent) + + by_actor: dict = {} + for s in gen.student_competency_statuses: + by_actor.setdefault(s["actor_id"], {})[s["tag_id"]] = s + for actor_id, rows in by_actor.items(): + allowed = set(assessed_by_actor[actor_id]) + for tag_id in assessed_by_actor[actor_id]: + allowed |= ancestors(tag_id) + assert set(rows) <= allowed + for tag_id, row in rows.items(): + children = [r for t, r in rows.items() if tags[t]["parent_int_id"] == tag_id] + if children and tag_id not in assessed_by_actor[actor_id]: + all_done = all(c["status_id"] == 30 for c in children) + assert (row["status_id"] == 30) == all_done + + +def test_competency_statuses_spread_time_to_mastery(): + """ + Competencies differ in difficulty, from hours to weeks, so their time-to-mastery + boxes sit at different heights; within one competency learners still vary. + """ + gen = _competency_generator(num_actors=200, course_size_makeup={ + "small": { + "actors": 150, "problems": 10, "videos": 5, "chapters": 2, + "sequences": 6, "verticals": 8, "forum_posts": 1, + } + }) + medians = gen.competency_mastery_median_days + low, high = COMPETENCY_MASTERY_MEDIAN_DAYS_RANGE + assert all(low <= m <= high for m in medians.values()) + assert max(medians.values()) / min(medians.values()) > 10 + + tags = {t["tag_id"]: t for t in gen.tags} + leaves = {t for t in tags if not any(o["parent_int_id"] == t for o in tags.values())} + by_tag: dict = {} + for s in gen.student_competency_statuses: + if s["status_id"] == 30 and s["tag_id"] in leaves: + days = ( + datetime.datetime.fromisoformat(s["modified"]) + - datetime.datetime.fromisoformat(s["created"]) + ).total_seconds() / 86400 + by_tag.setdefault(s["tag_id"], []).append(days) + + observed = {t: statistics.median(d) for t, d in by_tag.items() if len(d) >= 20} + assert len(observed) >= 3 + fastest, slowest = min(observed, key=observed.get), max(observed, key=observed.get) + assert observed[slowest] > 5 * observed[fastest] + assert medians[slowest] > medians[fastest] + for days in by_tag.values(): + if len(days) >= 20: + assert max(days) > 1.5 * min(days) + + +def test_first_competency_status_lands_soon_after_enrollment(): + """A learner's first status comes soon after they enroll, not anywhere in the course.""" + gen = _competency_generator() + enrolled = {} + for course in gen.courses: + for e in course.actors: + when = e.enroll_datetime + if not isinstance(when, datetime.datetime): + when = datetime.datetime.combine(when, datetime.time(), tzinfo=datetime.UTC) + enrolled[e.actor.id] = min(enrolled.get(e.actor.id, when), when) + gaps = [ + (datetime.datetime.fromisoformat(s["created"]) - enrolled[s["actor_id"]]).total_seconds() / 3600 + for s in gen.student_competency_statuses + ] + assert min(gaps) >= 0 + assert statistics.median(gaps) < 48 + + +def test_course_group_statuses_include_attempted_not_demonstrated(): + """ + Course-level group statuses use all three stored values, including the + AttemptedNotDemonstrated that the competency level never holds, one row per + learner, competency and course, with ids from the default first id. + """ + gen = _competency_generator() + rows = gen.student_competency_criteria_group_statuses + + assert rows + assert {r["status_id"] for r in rows} == {10, 20, 30} + assert {r["group_level"] for r in rows} == {"course"} + assert [r["id"] for r in rows] == list( + range( + DEFAULT_FIRST_COMPETENCY_GROUP_STATUS_ID, + DEFAULT_FIRST_COMPETENCY_GROUP_STATUS_ID + len(rows), + ) + ) + assert len({(r["actor_id"], r["tag_id"], r["course_key"]) for r in rows}) == len(rows) + course_keys = {c.course_id for c in gen.courses} + assert {r["course_key"] for r in rows} <= course_keys + # One course group per competency and course, all under the competency's root group. + groups = {(r["tag_id"], r["course_key"]): r["group_id"] for r in rows} + assert len(set(groups.values())) == len(groups) + roots = {r["tag_id"]: r["parent_group_id"] for r in rows} + assert all(r["parent_group_id"] == roots[r["tag_id"]] for r in rows) + assert not set(roots.values()) & set(groups.values()) + + +def test_competency_status_agrees_with_course_group_statuses(): + """ + The competency-level row is Demonstrated exactly when some course group is, + and otherwise PartiallyAttempted, including when every course is + AttemptedNotDemonstrated, as core's rollup records it. + """ + gen = _competency_generator() + by_pair: dict = {} + for r in gen.student_competency_criteria_group_statuses: + by_pair.setdefault((r["actor_id"], r["tag_id"]), []).append(r["status_id"]) + competency = {(s["actor_id"], s["tag_id"]): s["status_id"] for s in gen.student_competency_statuses} + + assert by_pair + for pair, course_statuses in by_pair.items(): + expected = 30 if 30 in course_statuses else 20 + assert competency[pair] == expected