diff --git a/openedx_learning/apps/assessment_criteria/__init__.py b/openedx_learning/apps/assessment_criteria/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/openedx_learning/apps/assessment_criteria/admin.py b/openedx_learning/apps/assessment_criteria/admin.py new file mode 100644 index 00000000..cd709e59 --- /dev/null +++ b/openedx_learning/apps/assessment_criteria/admin.py @@ -0,0 +1,44 @@ +from django.contrib import admin + +from .models import ( + AssessmentCriteria, + AssessmentCriteriaGroup, + StudentAssessmentCriteriaStatus, + StudentCompetencyStatus, +) + + +@admin.register(AssessmentCriteriaGroup) +class AssessmentCriteriaGroupAdmin(admin.ModelAdmin): + list_display = ("id", "name", "course_id", "parent", "ordering", "logic_operator", "competency_tag") + list_filter = ("logic_operator",) + search_fields = ("name",) + + +@admin.register(AssessmentCriteria) +class AssessmentCriteriaAdmin(admin.ModelAdmin): + list_display = ( + "id", + "group", + "course_id", + "rule_type", + "rule_payload", + "retake_rule", + "competency_tag", + "object_tag", + ) + list_filter = ("rule_type", "retake_rule") + + +@admin.register(StudentAssessmentCriteriaStatus) +class StudentAssessmentCriteriaStatusAdmin(admin.ModelAdmin): + list_display = ("id", "assessment_criteria", "user", "status", "timestamp") + list_filter = ("status",) + search_fields = ("user__username", "user__email") + + +@admin.register(StudentCompetencyStatus) +class StudentCompetencyStatusAdmin(admin.ModelAdmin): + list_display = ("id", "competency_tag", "user", "status", "timestamp") + list_filter = ("status",) + search_fields = ("user__username", "user__email") diff --git a/openedx_learning/apps/assessment_criteria/api.py b/openedx_learning/apps/assessment_criteria/api.py new file mode 100644 index 00000000..b1b0da87 --- /dev/null +++ b/openedx_learning/apps/assessment_criteria/api.py @@ -0,0 +1,248 @@ +""" +Assessment criteria API. + +Use these helpers instead of manipulating models directly, so future logic can +stay centralized here. +""" +from __future__ import annotations + +from django.db import models + +from .models import ( + AssessmentCriteria, + AssessmentCriteriaGroup, + StudentAssessmentCriteriaStatus, + StudentCompetencyStatus, +) +from .models.student_status import StudentStatus + + +AssessmentCriteriaGroupDoesNotExist = AssessmentCriteriaGroup.DoesNotExist +AssessmentCriteriaDoesNotExist = AssessmentCriteria.DoesNotExist +StudentAssessmentCriteriaStatusDoesNotExist = StudentAssessmentCriteriaStatus.DoesNotExist +StudentCompetencyStatusDoesNotExist = StudentCompetencyStatus.DoesNotExist + + +def create_assessment_criteria_group( + *, + parent: AssessmentCriteriaGroup | None, + competency_tag, + name: str, + ordering: int, + logic_operator: str | None = None, +) -> AssessmentCriteriaGroup: + """ + Create and return an AssessmentCriteriaGroup. + """ + group = AssessmentCriteriaGroup( + parent=parent, + competency_tag=competency_tag, + name=name, + ordering=ordering, + logic_operator=logic_operator, + ) + group.full_clean() + group.save() + return group + + +def get_assessment_criteria_group(group_id: int) -> AssessmentCriteriaGroup | None: + """ + Return a group by id, or None if not found. + """ + return AssessmentCriteriaGroup.objects.filter(id=group_id).first() + + +def list_assessment_criteria_groups( + *, + parent: AssessmentCriteriaGroup | None = None, +) -> models.QuerySet[AssessmentCriteriaGroup]: + """ + Return groups, optionally filtered by parent. + """ + qs = AssessmentCriteriaGroup.objects.all() + if parent is not None: + qs = qs.filter(parent=parent) + return qs.order_by("ordering", "id") + + +def update_assessment_criteria_group( + group: AssessmentCriteriaGroup, + *, + parent: AssessmentCriteriaGroup | None | models.NOT_PROVIDED = models.NOT_PROVIDED, + competency_tag=models.NOT_PROVIDED, + name: str | models.NOT_PROVIDED = models.NOT_PROVIDED, + ordering: int | models.NOT_PROVIDED = models.NOT_PROVIDED, + logic_operator: str | None | models.NOT_PROVIDED = models.NOT_PROVIDED, +) -> AssessmentCriteriaGroup: + """ + Update and return an AssessmentCriteriaGroup. + """ + if parent is not models.NOT_PROVIDED: + group.parent = parent + if competency_tag is not models.NOT_PROVIDED: + group.competency_tag = competency_tag + if name is not models.NOT_PROVIDED: + group.name = name + if ordering is not models.NOT_PROVIDED: + group.ordering = ordering + if logic_operator is not models.NOT_PROVIDED: + group.logic_operator = logic_operator + group.full_clean() + group.save() + return group + + +def delete_assessment_criteria_group(group: AssessmentCriteriaGroup) -> None: + """ + Delete the provided AssessmentCriteriaGroup. + """ + group.delete() + + +def create_assessment_criteria( + *, + group: AssessmentCriteriaGroup, + object_tag, + competency_tag, + rule_type: str, + rule_payload: dict, + retake_rule: str, +) -> AssessmentCriteria: + """ + Create and return an AssessmentCriteria. + """ + criteria = AssessmentCriteria( + group=group, + object_tag=object_tag, + competency_tag=competency_tag, + rule_type=rule_type, + rule_payload=rule_payload, + retake_rule=retake_rule, + ) + criteria.full_clean() + criteria.save() + return criteria + + +def get_assessment_criteria(criteria_id: int) -> AssessmentCriteria | None: + """ + Return assessment criteria by id, or None if not found. + """ + return AssessmentCriteria.objects.filter(id=criteria_id).first() + + +def list_assessment_criteria( + *, + group: AssessmentCriteriaGroup | None = None, +) -> models.QuerySet[AssessmentCriteria]: + """ + Return criteria, optionally filtered by group. + """ + qs = AssessmentCriteria.objects.all() + if group is not None: + qs = qs.filter(group=group) + return qs.order_by("id") + + +def update_assessment_criteria( + criteria: AssessmentCriteria, + *, + group: AssessmentCriteriaGroup | models.NOT_PROVIDED = models.NOT_PROVIDED, + object_tag=models.NOT_PROVIDED, + competency_tag=models.NOT_PROVIDED, + rule_type: str | models.NOT_PROVIDED = models.NOT_PROVIDED, + rule_payload: dict | models.NOT_PROVIDED = models.NOT_PROVIDED, + retake_rule: str | models.NOT_PROVIDED = models.NOT_PROVIDED, +) -> AssessmentCriteria: + """ + Update and return AssessmentCriteria. + """ + if group is not models.NOT_PROVIDED: + criteria.group = group + if object_tag is not models.NOT_PROVIDED: + criteria.object_tag = object_tag + if competency_tag is not models.NOT_PROVIDED: + criteria.competency_tag = competency_tag + if rule_type is not models.NOT_PROVIDED: + criteria.rule_type = rule_type + if rule_payload is not models.NOT_PROVIDED: + criteria.rule_payload = rule_payload + if retake_rule is not models.NOT_PROVIDED: + criteria.retake_rule = retake_rule + criteria.full_clean() + criteria.save() + return criteria + + +def delete_assessment_criteria(criteria: AssessmentCriteria) -> None: + """ + Delete the provided AssessmentCriteria. + """ + criteria.delete() + + +def set_student_assessment_criteria_status( + *, + assessment_criteria: AssessmentCriteria, + user, + status: StudentStatus, +) -> StudentAssessmentCriteriaStatus: + """ + Create or update student assessment criteria status. + """ + entry, _created = StudentAssessmentCriteriaStatus.objects.update_or_create( + assessment_criteria=assessment_criteria, + user=user, + defaults={"status": status}, + ) + return entry + + +def set_student_competency_status( + *, + competency_tag, + user, + status: StudentStatus, +) -> StudentCompetencyStatus: + """ + Create or update student competency status. + """ + entry, _created = StudentCompetencyStatus.objects.update_or_create( + competency_tag=competency_tag, + user=user, + defaults={"status": status}, + ) + return entry + + +def list_student_assessment_criteria_statuses( + *, + assessment_criteria: AssessmentCriteria | None = None, + user=None, +) -> models.QuerySet[StudentAssessmentCriteriaStatus]: + """ + Return student assessment criteria statuses with optional filters. + """ + qs = StudentAssessmentCriteriaStatus.objects.all() + if assessment_criteria is not None: + qs = qs.filter(assessment_criteria=assessment_criteria) + if user is not None: + qs = qs.filter(user=user) + return qs.order_by("-timestamp", "id") + + +def list_student_competency_statuses( + *, + competency_tag=None, + user=None, +) -> models.QuerySet[StudentCompetencyStatus]: + """ + Return student competency statuses with optional filters. + """ + qs = StudentCompetencyStatus.objects.all() + if competency_tag is not None: + qs = qs.filter(competency_tag=competency_tag) + if user is not None: + qs = qs.filter(user=user) + return qs.order_by("-timestamp", "id") diff --git a/openedx_learning/apps/assessment_criteria/apps.py b/openedx_learning/apps/assessment_criteria/apps.py new file mode 100644 index 00000000..9349fe75 --- /dev/null +++ b/openedx_learning/apps/assessment_criteria/apps.py @@ -0,0 +1,18 @@ +""" +Assessment criteria Django application initialization. +""" +from django.apps import AppConfig + + +class AssessmentCriteriaConfig(AppConfig): + """ + Configuration for the assessment criteria Django application. + """ + name = "openedx_learning.apps.assessment_criteria" + verbose_name = "Learning Core > Assessment Criteria" + default_auto_field = "django.db.models.BigAutoField" + label = "oel_assessment_criteria" + + def ready(self): + # Register signal handlers. + from . import events # pylint: disable=unused-import diff --git a/openedx_learning/apps/assessment_criteria/events.py b/openedx_learning/apps/assessment_criteria/events.py new file mode 100644 index 00000000..b83fb3b1 --- /dev/null +++ b/openedx_learning/apps/assessment_criteria/events.py @@ -0,0 +1,169 @@ +""" +Signal handlers for assessment criteria. +""" +from __future__ import annotations + +import logging + +from django.contrib.auth import get_user_model +from django.db import models +from django.dispatch import receiver + +from openedx_events.learning.signals import PERSISTENT_SUBSECTION_GRADE_CHANGED + +from openedx_tagging.core.tagging.models import ObjectTag + +from .api import set_student_assessment_criteria_status, set_student_competency_status +from .models import AssessmentCriteria, GroupLogicOperator, RuleType +from .models.student_status import StudentStatus + +log = logging.getLogger(__name__) + + +_OPS = { + "gt": lambda actual, expected: actual > expected, + "gte": lambda actual, expected: actual >= expected, + "lt": lambda actual, expected: actual < expected, + "lte": lambda actual, expected: actual <= expected, + "eq": lambda actual, expected: actual == expected, +} + + +def _percent_from_grade(grade) -> float | None: + if grade.weighted_graded_possible and grade.weighted_graded_possible > 0: + return (grade.weighted_graded_earned / grade.weighted_graded_possible) * 100.0 + return None + + +def _evaluate_grade_rule(rule_payload: dict, percent: float | None) -> bool | None: + if percent is None: + return None + op = rule_payload.get("op") + value = rule_payload.get("value") + scale = rule_payload.get("scale", "percent") + if op not in _OPS: + log.warning("Unsupported grade rule op: %s", op) + return None + if value is None: + log.warning("Missing grade rule value.") + return None + + try: + expected = float(value) + except (TypeError, ValueError): + log.warning("Invalid grade rule value: %s", value) + return None + + if scale == "percent": + expected_percent = expected + elif scale == "fraction": + expected_percent = expected * 100.0 + else: + log.warning("Unsupported grade rule scale: %s", scale) + return None + + return _OPS[op](percent, expected_percent) + + +def _derive_status(grade, rule_payload: dict) -> StudentStatus: + if grade.first_attempted is None: + return StudentStatus.NOT_ATTEMPTED + passed = _evaluate_grade_rule(rule_payload, _percent_from_grade(grade)) + if passed is True: + return StudentStatus.DEMONSTRATED + return StudentStatus.ATTEMPTED_NOT_DEMONSTRATED + + +def _compute_group_status(group, user) -> StudentStatus: + criteria_qs = AssessmentCriteria.objects.filter(group=group) + statuses = list( + criteria_qs.values_list( + "student_statuses__status", + flat=True, + ).filter(student_statuses__user=user) + ) + log.info("Group %s statuses for user %s: %s", group.id, user.id, statuses) + if not statuses: + return StudentStatus.NOT_ATTEMPTED + + logic_operator = group.logic_operator or GroupLogicOperator.AND + if logic_operator == GroupLogicOperator.OR: + if StudentStatus.DEMONSTRATED in statuses: + return StudentStatus.DEMONSTRATED + if StudentStatus.ATTEMPTED_NOT_DEMONSTRATED in statuses: + return StudentStatus.ATTEMPTED_NOT_DEMONSTRATED + return StudentStatus.NOT_ATTEMPTED + + if all(status == StudentStatus.DEMONSTRATED for status in statuses): + return StudentStatus.DEMONSTRATED + if any(status == StudentStatus.ATTEMPTED_NOT_DEMONSTRATED for status in statuses): + return StudentStatus.ATTEMPTED_NOT_DEMONSTRATED + return StudentStatus.NOT_ATTEMPTED + + +@receiver(PERSISTENT_SUBSECTION_GRADE_CHANGED) +def handle_persistent_subsection_grade_changed(sender, grade, **kwargs): # pylint: disable=unused-argument + """ + Update assessment criteria and competency status when a subsection grade changes. + """ + percent = _percent_from_grade(grade) + log.info( + "Subsection grade event: user_id=%s course=%s usage_key=%s graded=%s/%s percent=%s", + grade.user_id, + grade.course.course_key, + grade.usage_key, + grade.weighted_graded_earned, + grade.weighted_graded_possible, + None if percent is None else round(percent, 2), + ) + user = get_user_model().objects.filter(id=grade.user_id).first() + if not user: + log.warning("User not found for grade event: %s", grade.user_id) + return + + object_id = str(grade.usage_key) + object_tags = ObjectTag.objects.filter(object_id=object_id) + log.info("Object tags found for %s: %s", object_id, object_tags.count()) + if not object_tags.exists(): + log.info("No object tags found for %s; skipping.", object_id) + return + + course_id = str(grade.course.course_key) + criteria_qs = AssessmentCriteria.objects.filter(object_tag__in=object_tags).filter( + models.Q(course_id__isnull=True) | models.Q(course_id="") | models.Q(course_id=course_id) + ) + log.info("Assessment criteria found for course %s: %s", course_id, criteria_qs.count()) + if not criteria_qs.exists(): + log.info("No assessment criteria found for %s; skipping.", course_id) + return + + updated_groups = set() + for criteria in criteria_qs.select_related("group", "competency_tag"): + if criteria.rule_type != RuleType.GRADE: + log.info("Skipping non-grade criteria %s (rule_type=%s)", criteria.id, criteria.rule_type) + continue + if not isinstance(criteria.rule_payload, dict): + log.warning("Invalid rule_payload for criteria %s", criteria.id) + continue + status = _derive_status(grade, criteria.rule_payload) + log.info("Criteria %s rule=%s status=%s", criteria.id, criteria.rule_payload, status) + set_student_assessment_criteria_status( + assessment_criteria=criteria, + user=user, + status=status, + ) + updated_groups.add(criteria.group) + + for group in updated_groups: + group_status = _compute_group_status(group, user) + log.info( + "Group %s logic=%s computed status=%s", + group.id, + group.logic_operator or GroupLogicOperator.AND, + group_status, + ) + set_student_competency_status( + competency_tag=group.competency_tag, + user=user, + status=group_status, + ) diff --git a/openedx_learning/apps/assessment_criteria/migrations/0001_initial.py b/openedx_learning/apps/assessment_criteria/migrations/0001_initial.py new file mode 100644 index 00000000..94a4d448 --- /dev/null +++ b/openedx_learning/apps/assessment_criteria/migrations/0001_initial.py @@ -0,0 +1,82 @@ +# Generated by Django 5.2.10 on 2026-01-14 17:15 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('oel_tagging', '0018_objecttag_is_copied'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='AssessmentCriteriaGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('ordering', models.PositiveIntegerField()), + ('logic_operator', models.CharField(blank=True, choices=[('AND', 'AND'), ('OR', 'OR')], max_length=3, null=True)), + ('competency_tag', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='assessment_criteria_groups', to='oel_tagging.tag')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='oel_assessment_criteria.assessmentcriteriagroup')), + ], + ), + migrations.CreateModel( + name='AssessmentCriteria', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('rule_type', models.CharField(choices=[('Grade', 'Grade'), ('MasteryLevel', 'MasteryLevel')], max_length=20)), + ('rule', models.CharField(max_length=255)), + ('retake_rule', models.CharField(choices=[('SimpleAverage', 'SimpleAverage'), ('WeightedAverage', 'WeightedAverage'), ('DecayingAverage', 'DecayingAverage'), ('MostRecent', 'MostRecent'), ('Highest', 'Highest')], max_length=20)), + ('competency_tag', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='assessment_criteria', to='oel_tagging.tag')), + ('object_tag', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='assessment_criteria', to='oel_tagging.objecttag')), + ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='criteria', to='oel_assessment_criteria.assessmentcriteriagroup')), + ], + ), + migrations.CreateModel( + name='StudentAssessmentCriteriaStatus', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.CharField(choices=[('demonstrated', 'Demonstrated'), ('attempted_not_demonstrated', 'Attempted, Not Demonstrated'), ('not_attempted', 'Not Attempted')], max_length=32)), + ('timestamp', models.DateTimeField(default=django.utils.timezone.now)), + ('assessment_criteria', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='student_statuses', to='oel_assessment_criteria.assessmentcriteria')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assessment_criteria_statuses', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='StudentCompetencyStatus', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.CharField(choices=[('demonstrated', 'Demonstrated'), ('attempted_not_demonstrated', 'Attempted, Not Demonstrated'), ('not_attempted', 'Not Attempted')], max_length=32)), + ('timestamp', models.DateTimeField(default=django.utils.timezone.now)), + ('competency_tag', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='student_competency_statuses', to='oel_tagging.tag')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='competency_statuses', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddIndex( + model_name='assessmentcriteriagroup', + index=models.Index(fields=['parent', 'ordering'], name='oel_assessm_parent__7fbf05_idx'), + ), + migrations.AddIndex( + model_name='assessmentcriteria', + index=models.Index(fields=['group'], name='oel_assessm_group_i_51f792_idx'), + ), + migrations.AddIndex( + model_name='assessmentcriteria', + index=models.Index(fields=['competency_tag'], name='oel_assessm_compete_690bf7_idx'), + ), + migrations.AddIndex( + model_name='studentassessmentcriteriastatus', + index=models.Index(fields=['assessment_criteria', 'user'], name='oel_assessm_assessm_da136e_idx'), + ), + migrations.AddIndex( + model_name='studentcompetencystatus', + index=models.Index(fields=['competency_tag', 'user'], name='oel_assessm_compete_7e9173_idx'), + ), + ] diff --git a/openedx_learning/apps/assessment_criteria/migrations/0002_assessmentcriteria_course_id_and_more.py b/openedx_learning/apps/assessment_criteria/migrations/0002_assessmentcriteria_course_id_and_more.py new file mode 100644 index 00000000..00cbb85d --- /dev/null +++ b/openedx_learning/apps/assessment_criteria/migrations/0002_assessmentcriteria_course_id_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.10 on 2026-01-15 15:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('oel_assessment_criteria', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='assessmentcriteria', + name='course_id', + field=models.CharField(blank=True, db_index=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='assessmentcriteriagroup', + name='course_id', + field=models.CharField(blank=True, db_index=True, max_length=255, null=True), + ), + ] diff --git a/openedx_learning/apps/assessment_criteria/migrations/0003_assessmentcriteria_rule_payload.py b/openedx_learning/apps/assessment_criteria/migrations/0003_assessmentcriteria_rule_payload.py new file mode 100644 index 00000000..e81b130b --- /dev/null +++ b/openedx_learning/apps/assessment_criteria/migrations/0003_assessmentcriteria_rule_payload.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.10 on 2026-02-02 00:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('oel_assessment_criteria', '0002_assessmentcriteria_course_id_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='assessmentcriteria', + name='rule', + ), + migrations.AddField( + model_name='assessmentcriteria', + name='rule_payload', + field=models.JSONField(blank=True, default=dict), + ), + ] diff --git a/openedx_learning/apps/assessment_criteria/migrations/__init__.py b/openedx_learning/apps/assessment_criteria/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/openedx_learning/apps/assessment_criteria/models/__init__.py b/openedx_learning/apps/assessment_criteria/models/__init__.py new file mode 100644 index 00000000..8bcf92f8 --- /dev/null +++ b/openedx_learning/apps/assessment_criteria/models/__init__.py @@ -0,0 +1,14 @@ +from .criteria import AssessmentCriteria, RetakeRule, RuleType +from .criteria_group import AssessmentCriteriaGroup, GroupLogicOperator +from .student_status import StudentAssessmentCriteriaStatus, StudentCompetencyStatus, StudentStatus + +__all__ = [ + "AssessmentCriteria", + "AssessmentCriteriaGroup", + "GroupLogicOperator", + "RetakeRule", + "RuleType", + "StudentAssessmentCriteriaStatus", + "StudentCompetencyStatus", + "StudentStatus", +] diff --git a/openedx_learning/apps/assessment_criteria/models/criteria.py b/openedx_learning/apps/assessment_criteria/models/criteria.py new file mode 100644 index 00000000..52ac47ee --- /dev/null +++ b/openedx_learning/apps/assessment_criteria/models/criteria.py @@ -0,0 +1,56 @@ +""" +Assessment criteria model. +""" +from django.db import models + + +class RuleType(models.TextChoices): + GRADE = "Grade", "Grade" + MASTERY_LEVEL = "MasteryLevel", "MasteryLevel" + + +class RetakeRule(models.TextChoices): + SIMPLE_AVERAGE = "SimpleAverage", "SimpleAverage" + WEIGHTED_AVERAGE = "WeightedAverage", "WeightedAverage" + DECAYING_AVERAGE = "DecayingAverage", "DecayingAverage" + MOST_RECENT = "MostRecent", "MostRecent" + HIGHEST = "Highest", "Highest" + + +class AssessmentCriteria(models.Model): + """ + Single assessment rule within a group. + """ + course_id = models.CharField( + max_length=255, + null=True, + blank=True, + db_index=True, + ) + group = models.ForeignKey( + "oel_assessment_criteria.AssessmentCriteriaGroup", + on_delete=models.CASCADE, + related_name="criteria", + ) + object_tag = models.ForeignKey( + "oel_tagging.ObjectTag", + on_delete=models.PROTECT, + related_name="assessment_criteria", + ) + competency_tag = models.ForeignKey( + "oel_tagging.Tag", + on_delete=models.PROTECT, + related_name="assessment_criteria", + ) + rule_type = models.CharField(max_length=20, choices=RuleType.choices) + rule_payload = models.JSONField(default=dict, blank=True) + retake_rule = models.CharField(max_length=20, choices=RetakeRule.choices) + + class Meta: + indexes = [ + models.Index(fields=["group"]), + models.Index(fields=["competency_tag"]), + ] + + def __str__(self): + return f"{self.rule_type}:{self.rule_payload} ({self.id})" diff --git a/openedx_learning/apps/assessment_criteria/models/criteria_group.py b/openedx_learning/apps/assessment_criteria/models/criteria_group.py new file mode 100644 index 00000000..f7a6b5a1 --- /dev/null +++ b/openedx_learning/apps/assessment_criteria/models/criteria_group.py @@ -0,0 +1,49 @@ +""" +Assessment criteria group model. +""" +from django.db import models + + +class GroupLogicOperator(models.TextChoices): + AND = "AND", "AND" + OR = "OR", "OR" + + +class AssessmentCriteriaGroup(models.Model): + """ + Group of assessment criteria, optionally nested. + """ + course_id = models.CharField( + max_length=255, + null=True, + blank=True, + db_index=True, + ) + parent = models.ForeignKey( + "self", + null=True, + blank=True, + on_delete=models.CASCADE, + related_name="children", + ) + competency_tag = models.ForeignKey( + "oel_tagging.Tag", + on_delete=models.PROTECT, + related_name="assessment_criteria_groups", + ) + name = models.CharField(max_length=255) + ordering = models.PositiveIntegerField() + logic_operator = models.CharField( + max_length=3, + choices=GroupLogicOperator.choices, + null=True, + blank=True, + ) + + class Meta: + indexes = [ + models.Index(fields=["parent", "ordering"]), + ] + + def __str__(self): + return f"{self.name} ({self.id})" diff --git a/openedx_learning/apps/assessment_criteria/models/student_status.py b/openedx_learning/apps/assessment_criteria/models/student_status.py new file mode 100644 index 00000000..006201f2 --- /dev/null +++ b/openedx_learning/apps/assessment_criteria/models/student_status.py @@ -0,0 +1,61 @@ +""" +Student status models for assessment criteria. +""" +from django.conf import settings +from django.db import models +from django.utils import timezone + +class StudentStatus(models.TextChoices): + """ + Shared status for student progress tables. + """ + + DEMONSTRATED = "demonstrated", "Demonstrated" + ATTEMPTED_NOT_DEMONSTRATED = "attempted_not_demonstrated", "Attempted, Not Demonstrated" + NOT_ATTEMPTED = "not_attempted", "Not Attempted" + + +class StudentAssessmentCriteriaStatus(models.Model): + """ + Student status for an individual assessment criteria. + """ + assessment_criteria = models.ForeignKey( + "oel_assessment_criteria.AssessmentCriteria", + on_delete=models.CASCADE, + related_name="student_statuses", + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="assessment_criteria_statuses", + ) + status = models.CharField(max_length=32, choices=StudentStatus.choices) + timestamp = models.DateTimeField(default=timezone.now) + + class Meta: + indexes = [ + models.Index(fields=["assessment_criteria", "user"]), + ] + + +class StudentCompetencyStatus(models.Model): + """ + Student status for a competency (tag). + """ + competency_tag = models.ForeignKey( + "oel_tagging.Tag", + on_delete=models.PROTECT, + related_name="student_competency_statuses", + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="competency_statuses", + ) + status = models.CharField(max_length=32, choices=StudentStatus.choices) + timestamp = models.DateTimeField(default=timezone.now) + + class Meta: + indexes = [ + models.Index(fields=["competency_tag", "user"]), + ] diff --git a/openedx_learning/apps/assessment_criteria/readme.rst b/openedx_learning/apps/assessment_criteria/readme.rst new file mode 100644 index 00000000..e69de29b diff --git a/openedx_learning/apps/assessment_criteria/rest_api/__init__.py b/openedx_learning/apps/assessment_criteria/rest_api/__init__.py new file mode 100644 index 00000000..15ddb1d3 --- /dev/null +++ b/openedx_learning/apps/assessment_criteria/rest_api/__init__.py @@ -0,0 +1,3 @@ +""" +Assessment criteria REST API package. +""" diff --git a/openedx_learning/apps/assessment_criteria/rest_api/urls.py b/openedx_learning/apps/assessment_criteria/rest_api/urls.py new file mode 100644 index 00000000..c2f1e062 --- /dev/null +++ b/openedx_learning/apps/assessment_criteria/rest_api/urls.py @@ -0,0 +1,8 @@ +""" +Assessment criteria API URLs. +""" +from django.urls import include, path + +from .v1 import urls as v1_urls + +urlpatterns = [path("v1/", include(v1_urls))] diff --git a/openedx_learning/apps/assessment_criteria/rest_api/v1/__init__.py b/openedx_learning/apps/assessment_criteria/rest_api/v1/__init__.py new file mode 100644 index 00000000..28ca7dc0 --- /dev/null +++ b/openedx_learning/apps/assessment_criteria/rest_api/v1/__init__.py @@ -0,0 +1,3 @@ +""" +Assessment criteria API v1. +""" diff --git a/openedx_learning/apps/assessment_criteria/rest_api/v1/serializers.py b/openedx_learning/apps/assessment_criteria/rest_api/v1/serializers.py new file mode 100644 index 00000000..e86246ec --- /dev/null +++ b/openedx_learning/apps/assessment_criteria/rest_api/v1/serializers.py @@ -0,0 +1,80 @@ +""" +Assessment criteria API serializers. +""" +from rest_framework import serializers + +from ...models import ( + AssessmentCriteria, + AssessmentCriteriaGroup, + StudentAssessmentCriteriaStatus, + StudentCompetencyStatus, +) + + +class AssessmentCriteriaGroupSerializer(serializers.ModelSerializer): + """ + Serializer for AssessmentCriteriaGroup. + """ + + class Meta: + model = AssessmentCriteriaGroup + fields = [ + "id", + "course_id", + "parent", + "competency_tag", + "name", + "ordering", + "logic_operator", + ] + + +class AssessmentCriteriaSerializer(serializers.ModelSerializer): + """ + Serializer for AssessmentCriteria. + """ + + class Meta: + model = AssessmentCriteria + fields = [ + "id", + "course_id", + "group", + "object_tag", + "competency_tag", + "rule_type", + "rule_payload", + "retake_rule", + ] + + +class StudentAssessmentCriteriaStatusSerializer(serializers.ModelSerializer): + """ + Serializer for StudentAssessmentCriteriaStatus. + """ + + class Meta: + model = StudentAssessmentCriteriaStatus + fields = [ + "id", + "assessment_criteria", + "user", + "status", + "timestamp", + ] + + +class StudentCompetencyStatusSerializer(serializers.ModelSerializer): + """ + Serializer for StudentCompetencyStatus. + """ + + class Meta: + model = StudentCompetencyStatus + fields = [ + "id", + "competency_tag", + "user", + "status", + "timestamp", + ] diff --git a/openedx_learning/apps/assessment_criteria/rest_api/v1/urls.py b/openedx_learning/apps/assessment_criteria/rest_api/v1/urls.py new file mode 100644 index 00000000..5a484a47 --- /dev/null +++ b/openedx_learning/apps/assessment_criteria/rest_api/v1/urls.py @@ -0,0 +1,23 @@ +""" +Assessment criteria API v1 URLs. +""" +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from . import views + +router = DefaultRouter() +router.register("criteria-groups", views.AssessmentCriteriaGroupView, basename="assessment-criteria-group") +router.register("criteria", views.AssessmentCriteriaView, basename="assessment-criteria") +router.register( + "student-criteria-statuses", + views.StudentAssessmentCriteriaStatusView, + basename="student-assessment-criteria-status", +) +router.register( + "student-competency-statuses", + views.StudentCompetencyStatusView, + basename="student-competency-status", +) + +urlpatterns = [path("", include(router.urls))] diff --git a/openedx_learning/apps/assessment_criteria/rest_api/v1/views.py b/openedx_learning/apps/assessment_criteria/rest_api/v1/views.py new file mode 100644 index 00000000..c5762b05 --- /dev/null +++ b/openedx_learning/apps/assessment_criteria/rest_api/v1/views.py @@ -0,0 +1,63 @@ +""" +Assessment criteria API v1 views. +""" +from rest_framework import viewsets + +from ...models import ( + AssessmentCriteria, + AssessmentCriteriaGroup, + StudentAssessmentCriteriaStatus, + StudentCompetencyStatus, +) +from .serializers import ( + AssessmentCriteriaGroupSerializer, + AssessmentCriteriaSerializer, + StudentAssessmentCriteriaStatusSerializer, + StudentCompetencyStatusSerializer, +) + + +class AssessmentCriteriaGroupView(viewsets.ModelViewSet): + """ + CRUD for AssessmentCriteriaGroup. + """ + + queryset = AssessmentCriteriaGroup.objects.all().order_by("ordering", "id") + serializer_class = AssessmentCriteriaGroupSerializer + + +class AssessmentCriteriaView(viewsets.ModelViewSet): + """ + CRUD for AssessmentCriteria. + """ + + queryset = AssessmentCriteria.objects.all().order_by("id") + serializer_class = AssessmentCriteriaSerializer + + def get_queryset(self): + """ + Optionally filter by criteria group via ?group=. + """ + queryset = super().get_queryset() + group_id = self.request.query_params.get("group") + if group_id: + queryset = queryset.filter(group_id=group_id) + return queryset + + +class StudentAssessmentCriteriaStatusView(viewsets.ModelViewSet): + """ + CRUD for StudentAssessmentCriteriaStatus. + """ + + queryset = StudentAssessmentCriteriaStatus.objects.all().order_by("-timestamp", "id") + serializer_class = StudentAssessmentCriteriaStatusSerializer + + +class StudentCompetencyStatusView(viewsets.ModelViewSet): + """ + CRUD for StudentCompetencyStatus. + """ + + queryset = StudentCompetencyStatus.objects.all().order_by("-timestamp", "id") + serializer_class = StudentCompetencyStatusSerializer diff --git a/openedx_learning/apps/assessment_criteria/urls.py b/openedx_learning/apps/assessment_criteria/urls.py new file mode 100644 index 00000000..ea022778 --- /dev/null +++ b/openedx_learning/apps/assessment_criteria/urls.py @@ -0,0 +1,9 @@ +""" +Assessment criteria API URLs. +""" +from django.urls import include, path + +from .rest_api import urls + +app_name = "oel_assessment_criteria" +urlpatterns = [path("", include(urls))] diff --git a/projects/dev.py b/projects/dev.py index ebc3e94e..ac9fed6e 100644 --- a/projects/dev.py +++ b/projects/dev.py @@ -2,6 +2,7 @@ Django settings for testing and development purposes """ from __future__ import annotations +import os from pathlib import Path from openedx_learning.api.django import openedx_learning_apps_to_install @@ -30,6 +31,7 @@ "django.contrib.sessions", "django.contrib.staticfiles", + "openedx_events", # Admin "django.contrib.admin", "django.contrib.admindocs", @@ -45,6 +47,8 @@ # Tagging Core Apps "openedx_tagging.core.tagging.apps.TaggingConfig", + # Assessment Criteria App + "openedx_learning.apps.assessment_criteria.apps.AssessmentCriteriaConfig", # Debugging "debug_toolbar", @@ -120,3 +124,19 @@ 'DEFAULT_PAGINATION_CLASS': 'edx_rest_framework_extensions.paginators.DefaultPagination', 'PAGE_SIZE': 10, } + +######################### Event Bus ######################## + +EVENT_BUS_PRODUCER = os.environ.get( + "EVENT_BUS_PRODUCER", + "edx_event_bus_redis.create_producer", +) +EVENT_BUS_CONSUMER = os.environ.get( + "EVENT_BUS_CONSUMER", + "edx_event_bus_redis.RedisEventConsumer", +) +EVENT_BUS_REDIS_CONNECTION_URL = os.environ.get( + "EVENT_BUS_REDIS_CONNECTION_URL", + "redis://@redis:6379/", +) +EVENT_BUS_TOPIC_PREFIX = os.environ.get("EVENT_BUS_TOPIC_PREFIX", "dev") diff --git a/projects/urls.py b/projects/urls.py index 583b004b..f88d963c 100644 --- a/projects/urls.py +++ b/projects/urls.py @@ -8,6 +8,10 @@ path("admin/doc/", include("django.contrib.admindocs.urls")), path("admin/", admin.site.urls), path("media_server/", include("openedx_learning.contrib.media_server.urls")), + path( + "assessment_criteria/rest_api/", + include("openedx_learning.apps.assessment_criteria.urls"), + ), path("tagging/rest_api/", include("openedx_tagging.core.tagging.urls")), path('__debug__/', include('debug_toolbar.urls')), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/requirements/base.in b/requirements/base.in index 15bcd974..ef1c1507 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -9,6 +9,8 @@ Django # Web application framework djangorestframework<4.0 # REST API edx-drf-extensions # Extensions to the Django REST Framework used by Open edX +edx-event-bus-redis # Redis-backed event bus consumer/producer +openedx-events # Open edX public signal definitions rules<4.0 # Django extension for rules-based authorization checks diff --git a/requirements/base.txt b/requirements/base.txt index 175292b1..f08e3bca 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -66,6 +66,8 @@ edx-django-utils==8.0.1 # via edx-drf-extensions edx-drf-extensions==10.6.0 # via -r requirements/base.in +edx-event-bus-redis==0.6.1 + # via -r requirements/base.in edx-opaque-keys==3.0.0 # via edx-drf-extensions idna==3.11 @@ -73,6 +75,8 @@ idna==3.11 kombu==5.6.2 # via celery packaging==26.0 +openedx-events==10.5.0 + # via -r requirements/base.in # via kombu prompt-toolkit==3.0.52 # via click-repl diff --git a/test_settings.py b/test_settings.py index 73c97bc4..7c88aefa 100644 --- a/test_settings.py +++ b/test_settings.py @@ -45,6 +45,7 @@ def root(*args): "django.contrib.messages", "django.contrib.sessions", "django.contrib.staticfiles", + "openedx_events", # Admin 'django.contrib.admin', 'django.contrib.admindocs', @@ -55,6 +56,16 @@ def root(*args): # Our own apps *openedx_learning_apps_to_install(), "openedx_tagging.core.tagging", + "openedx_learning.apps.authoring.collections.apps.CollectionsConfig", + "openedx_learning.apps.authoring.components.apps.ComponentsConfig", + "openedx_learning.apps.authoring.contents.apps.ContentsConfig", + "openedx_learning.apps.authoring.publishing.apps.PublishingConfig", + "openedx_tagging.core.tagging.apps.TaggingConfig", + "openedx_learning.apps.authoring.sections.apps.SectionsConfig", + "openedx_learning.apps.authoring.subsections.apps.SubsectionsConfig", + "openedx_learning.apps.authoring.units.apps.UnitsConfig", + "openedx_learning.apps.authoring.backup_restore.apps.BackupRestoreConfig", + "openedx_learning.apps.assessment_criteria.apps.AssessmentCriteriaConfig", ] AUTHENTICATION_BACKENDS = [