Skip to content

Commit e136f83

Browse files
feat: Add grade event processing
1 parent 50efa77 commit e136f83

11 files changed

Lines changed: 238 additions & 10 deletions

File tree

openedx_learning/apps/assessment_criteria/admin.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,17 @@ class AssessmentCriteriaGroupAdmin(admin.ModelAdmin):
1717

1818
@admin.register(AssessmentCriteria)
1919
class AssessmentCriteriaAdmin(admin.ModelAdmin):
20-
list_display = ("id", "group", "course_id", "rule_type", "rule", "retake_rule", "competency_tag", "object_tag")
20+
list_display = (
21+
"id",
22+
"group",
23+
"course_id",
24+
"rule_type",
25+
"rule_payload",
26+
"retake_rule",
27+
"competency_tag",
28+
"object_tag",
29+
)
2130
list_filter = ("rule_type", "retake_rule")
22-
search_fields = ("rule",)
2331

2432

2533
@admin.register(StudentAssessmentCriteriaStatus)

openedx_learning/apps/assessment_criteria/api.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ def create_assessment_criteria(
106106
object_tag,
107107
competency_tag,
108108
rule_type: str,
109-
rule: str,
109+
rule_payload: dict,
110110
retake_rule: str,
111111
) -> AssessmentCriteria:
112112
"""
@@ -117,7 +117,7 @@ def create_assessment_criteria(
117117
object_tag=object_tag,
118118
competency_tag=competency_tag,
119119
rule_type=rule_type,
120-
rule=rule,
120+
rule_payload=rule_payload,
121121
retake_rule=retake_rule,
122122
)
123123
criteria.full_clean()
@@ -152,7 +152,7 @@ def update_assessment_criteria(
152152
object_tag=models.NOT_PROVIDED,
153153
competency_tag=models.NOT_PROVIDED,
154154
rule_type: str | models.NOT_PROVIDED = models.NOT_PROVIDED,
155-
rule: str | models.NOT_PROVIDED = models.NOT_PROVIDED,
155+
rule_payload: dict | models.NOT_PROVIDED = models.NOT_PROVIDED,
156156
retake_rule: str | models.NOT_PROVIDED = models.NOT_PROVIDED,
157157
) -> AssessmentCriteria:
158158
"""
@@ -166,8 +166,8 @@ def update_assessment_criteria(
166166
criteria.competency_tag = competency_tag
167167
if rule_type is not models.NOT_PROVIDED:
168168
criteria.rule_type = rule_type
169-
if rule is not models.NOT_PROVIDED:
170-
criteria.rule = rule
169+
if rule_payload is not models.NOT_PROVIDED:
170+
criteria.rule_payload = rule_payload
171171
if retake_rule is not models.NOT_PROVIDED:
172172
criteria.retake_rule = retake_rule
173173
criteria.full_clean()

openedx_learning/apps/assessment_criteria/apps.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,7 @@ class AssessmentCriteriaConfig(AppConfig):
1212
verbose_name = "Learning Core > Assessment Criteria"
1313
default_auto_field = "django.db.models.BigAutoField"
1414
label = "oel_assessment_criteria"
15+
16+
def ready(self):
17+
# Register signal handlers.
18+
from . import events # pylint: disable=unused-import
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
"""
2+
Signal handlers for assessment criteria.
3+
"""
4+
from __future__ import annotations
5+
6+
import logging
7+
8+
from django.contrib.auth import get_user_model
9+
from django.db import models
10+
from django.dispatch import receiver
11+
12+
from openedx_events.learning.signals import PERSISTENT_SUBSECTION_GRADE_CHANGED
13+
14+
from openedx_tagging.core.tagging.models import ObjectTag
15+
16+
from .api import set_student_assessment_criteria_status, set_student_competency_status
17+
from .models import AssessmentCriteria, GroupLogicOperator, RuleType
18+
from .models.student_status import StudentStatus
19+
20+
log = logging.getLogger(__name__)
21+
22+
23+
_OPS = {
24+
"gt": lambda actual, expected: actual > expected,
25+
"gte": lambda actual, expected: actual >= expected,
26+
"lt": lambda actual, expected: actual < expected,
27+
"lte": lambda actual, expected: actual <= expected,
28+
"eq": lambda actual, expected: actual == expected,
29+
}
30+
31+
32+
def _percent_from_grade(grade) -> float | None:
33+
if grade.weighted_graded_possible and grade.weighted_graded_possible > 0:
34+
return (grade.weighted_graded_earned / grade.weighted_graded_possible) * 100.0
35+
return None
36+
37+
38+
def _evaluate_grade_rule(rule_payload: dict, percent: float | None) -> bool | None:
39+
if percent is None:
40+
return None
41+
op = rule_payload.get("op")
42+
value = rule_payload.get("value")
43+
scale = rule_payload.get("scale", "percent")
44+
if op not in _OPS:
45+
log.warning("Unsupported grade rule op: %s", op)
46+
return None
47+
if value is None:
48+
log.warning("Missing grade rule value.")
49+
return None
50+
51+
try:
52+
expected = float(value)
53+
except (TypeError, ValueError):
54+
log.warning("Invalid grade rule value: %s", value)
55+
return None
56+
57+
if scale == "percent":
58+
expected_percent = expected
59+
elif scale == "fraction":
60+
expected_percent = expected * 100.0
61+
else:
62+
log.warning("Unsupported grade rule scale: %s", scale)
63+
return None
64+
65+
return _OPS[op](percent, expected_percent)
66+
67+
68+
def _derive_status(grade, rule_payload: dict) -> StudentStatus:
69+
if grade.first_attempted is None:
70+
return StudentStatus.NOT_ATTEMPTED
71+
passed = _evaluate_grade_rule(rule_payload, _percent_from_grade(grade))
72+
if passed is True:
73+
return StudentStatus.DEMONSTRATED
74+
return StudentStatus.ATTEMPTED_NOT_DEMONSTRATED
75+
76+
77+
def _compute_group_status(group, user) -> StudentStatus:
78+
criteria_qs = AssessmentCriteria.objects.filter(group=group)
79+
statuses = list(
80+
criteria_qs.values_list(
81+
"student_statuses__status",
82+
flat=True,
83+
).filter(student_statuses__user=user)
84+
)
85+
log.info("Group %s statuses for user %s: %s", group.id, user.id, statuses)
86+
if not statuses:
87+
return StudentStatus.NOT_ATTEMPTED
88+
89+
logic_operator = group.logic_operator or GroupLogicOperator.AND
90+
if logic_operator == GroupLogicOperator.OR:
91+
if StudentStatus.DEMONSTRATED in statuses:
92+
return StudentStatus.DEMONSTRATED
93+
if StudentStatus.ATTEMPTED_NOT_DEMONSTRATED in statuses:
94+
return StudentStatus.ATTEMPTED_NOT_DEMONSTRATED
95+
return StudentStatus.NOT_ATTEMPTED
96+
97+
if all(status == StudentStatus.DEMONSTRATED for status in statuses):
98+
return StudentStatus.DEMONSTRATED
99+
if any(status == StudentStatus.ATTEMPTED_NOT_DEMONSTRATED for status in statuses):
100+
return StudentStatus.ATTEMPTED_NOT_DEMONSTRATED
101+
return StudentStatus.NOT_ATTEMPTED
102+
103+
104+
@receiver(PERSISTENT_SUBSECTION_GRADE_CHANGED)
105+
def handle_persistent_subsection_grade_changed(sender, grade, **kwargs): # pylint: disable=unused-argument
106+
"""
107+
Update assessment criteria and competency status when a subsection grade changes.
108+
"""
109+
percent = _percent_from_grade(grade)
110+
log.info(
111+
"Subsection grade event: user_id=%s course=%s usage_key=%s graded=%s/%s percent=%s",
112+
grade.user_id,
113+
grade.course.course_key,
114+
grade.usage_key,
115+
grade.weighted_graded_earned,
116+
grade.weighted_graded_possible,
117+
None if percent is None else round(percent, 2),
118+
)
119+
user = get_user_model().objects.filter(id=grade.user_id).first()
120+
if not user:
121+
log.warning("User not found for grade event: %s", grade.user_id)
122+
return
123+
124+
object_id = str(grade.usage_key)
125+
object_tags = ObjectTag.objects.filter(object_id=object_id)
126+
log.info("Object tags found for %s: %s", object_id, object_tags.count())
127+
if not object_tags.exists():
128+
log.info("No object tags found for %s; skipping.", object_id)
129+
return
130+
131+
course_id = str(grade.course.course_key)
132+
criteria_qs = AssessmentCriteria.objects.filter(object_tag__in=object_tags).filter(
133+
models.Q(course_id__isnull=True) | models.Q(course_id="") | models.Q(course_id=course_id)
134+
)
135+
log.info("Assessment criteria found for course %s: %s", course_id, criteria_qs.count())
136+
if not criteria_qs.exists():
137+
log.info("No assessment criteria found for %s; skipping.", course_id)
138+
return
139+
140+
updated_groups = set()
141+
for criteria in criteria_qs.select_related("group", "competency_tag"):
142+
if criteria.rule_type != RuleType.GRADE:
143+
log.info("Skipping non-grade criteria %s (rule_type=%s)", criteria.id, criteria.rule_type)
144+
continue
145+
if not isinstance(criteria.rule_payload, dict):
146+
log.warning("Invalid rule_payload for criteria %s", criteria.id)
147+
continue
148+
status = _derive_status(grade, criteria.rule_payload)
149+
log.info("Criteria %s rule=%s status=%s", criteria.id, criteria.rule_payload, status)
150+
set_student_assessment_criteria_status(
151+
assessment_criteria=criteria,
152+
user=user,
153+
status=status,
154+
)
155+
updated_groups.add(criteria.group)
156+
157+
for group in updated_groups:
158+
group_status = _compute_group_status(group, user)
159+
log.info(
160+
"Group %s logic=%s computed status=%s",
161+
group.id,
162+
group.logic_operator or GroupLogicOperator.AND,
163+
group_status,
164+
)
165+
set_student_competency_status(
166+
competency_tag=group.competency_tag,
167+
user=user,
168+
status=group_status,
169+
)
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Generated by Django 5.2.10 on 2026-02-02 00:00
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('oel_assessment_criteria', '0002_assessmentcriteria_course_id_and_more'),
10+
]
11+
12+
operations = [
13+
migrations.RemoveField(
14+
model_name='assessmentcriteria',
15+
name='rule',
16+
),
17+
migrations.AddField(
18+
model_name='assessmentcriteria',
19+
name='rule_payload',
20+
field=models.JSONField(blank=True, default=dict),
21+
),
22+
]

openedx_learning/apps/assessment_criteria/models/criteria.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ class AssessmentCriteria(models.Model):
4343
related_name="assessment_criteria",
4444
)
4545
rule_type = models.CharField(max_length=20, choices=RuleType.choices)
46-
rule = models.CharField(max_length=255)
46+
rule_payload = models.JSONField(default=dict, blank=True)
4747
retake_rule = models.CharField(max_length=20, choices=RetakeRule.choices)
4848

4949
class Meta:
@@ -53,4 +53,4 @@ class Meta:
5353
]
5454

5555
def __str__(self):
56-
return f"{self.rule_type}:{self.rule} ({self.id})"
56+
return f"{self.rule_type}:{self.rule_payload} ({self.id})"

openedx_learning/apps/assessment_criteria/rest_api/v1/serializers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ class Meta:
4343
"object_tag",
4444
"competency_tag",
4545
"rule_type",
46-
"rule",
46+
"rule_payload",
4747
"retake_rule",
4848
]
4949

projects/dev.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
Django settings for testing and development purposes
33
"""
44
from __future__ import annotations
5+
import os
56
from pathlib import Path
67

78
from openedx_learning.api.django import openedx_learning_apps_to_install
@@ -30,6 +31,7 @@
3031
"django.contrib.sessions",
3132
"django.contrib.staticfiles",
3233

34+
"openedx_events",
3335
# Admin
3436
"django.contrib.admin",
3537
"django.contrib.admindocs",
@@ -122,3 +124,19 @@
122124
'DEFAULT_PAGINATION_CLASS': 'edx_rest_framework_extensions.paginators.DefaultPagination',
123125
'PAGE_SIZE': 10,
124126
}
127+
128+
######################### Event Bus ########################
129+
130+
EVENT_BUS_PRODUCER = os.environ.get(
131+
"EVENT_BUS_PRODUCER",
132+
"edx_event_bus_redis.create_producer",
133+
)
134+
EVENT_BUS_CONSUMER = os.environ.get(
135+
"EVENT_BUS_CONSUMER",
136+
"edx_event_bus_redis.RedisEventConsumer",
137+
)
138+
EVENT_BUS_REDIS_CONNECTION_URL = os.environ.get(
139+
"EVENT_BUS_REDIS_CONNECTION_URL",
140+
"redis://@redis:6379/",
141+
)
142+
EVENT_BUS_TOPIC_PREFIX = os.environ.get("EVENT_BUS_TOPIC_PREFIX", "dev")

requirements/base.in

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ Django # Web application framework
99

1010
djangorestframework<4.0 # REST API
1111
edx-drf-extensions # Extensions to the Django REST Framework used by Open edX
12+
edx-event-bus-redis # Redis-backed event bus consumer/producer
13+
openedx-events # Open edX public signal definitions
1214

1315
rules<4.0 # Django extension for rules-based authorization checks
1416

requirements/base.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,17 @@ edx-django-utils==8.0.1
6666
# via edx-drf-extensions
6767
edx-drf-extensions==10.6.0
6868
# via -r requirements/base.in
69+
edx-event-bus-redis==0.6.1
70+
# via -r requirements/base.in
6971
edx-opaque-keys==3.0.0
7072
# via edx-drf-extensions
7173
idna==3.11
7274
# via requests
7375
kombu==5.6.2
7476
# via celery
7577
packaging==26.0
78+
openedx-events==10.5.0
79+
# via -r requirements/base.in
7680
# via kombu
7781
prompt-toolkit==3.0.52
7882
# via click-repl

0 commit comments

Comments
 (0)