Skip to content

Commit 31cf690

Browse files
feat: Initial REST CRUD for assessment criteria
1 parent b19d122 commit 31cf690

21 files changed

Lines changed: 751 additions & 0 deletions

File tree

openedx_learning/apps/assessment_criteria/__init__.py

Whitespace-only changes.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from django.contrib import admin
2+
3+
from .models import (
4+
AssessmentCriteria,
5+
AssessmentCriteriaGroup,
6+
StudentAssessmentCriteriaStatus,
7+
StudentCompetencyStatus,
8+
)
9+
10+
11+
@admin.register(AssessmentCriteriaGroup)
12+
class AssessmentCriteriaGroupAdmin(admin.ModelAdmin):
13+
list_display = ("id", "name", "parent", "ordering", "logic_operator", "competency_tag")
14+
list_filter = ("logic_operator",)
15+
search_fields = ("name",)
16+
17+
18+
@admin.register(AssessmentCriteria)
19+
class AssessmentCriteriaAdmin(admin.ModelAdmin):
20+
list_display = ("id", "group", "rule_type", "rule", "retake_rule", "competency_tag", "object_tag")
21+
list_filter = ("rule_type", "retake_rule")
22+
search_fields = ("rule",)
23+
24+
25+
@admin.register(StudentAssessmentCriteriaStatus)
26+
class StudentAssessmentCriteriaStatusAdmin(admin.ModelAdmin):
27+
list_display = ("id", "assessment_criteria", "user", "status", "timestamp")
28+
list_filter = ("status",)
29+
search_fields = ("user__username", "user__email")
30+
31+
32+
@admin.register(StudentCompetencyStatus)
33+
class StudentCompetencyStatusAdmin(admin.ModelAdmin):
34+
list_display = ("id", "competency_tag", "user", "status", "timestamp")
35+
list_filter = ("status",)
36+
search_fields = ("user__username", "user__email")
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
"""
2+
Assessment criteria API.
3+
4+
Use these helpers instead of manipulating models directly, so future logic can
5+
stay centralized here.
6+
"""
7+
from __future__ import annotations
8+
9+
from django.db import models
10+
11+
from .models import (
12+
AssessmentCriteria,
13+
AssessmentCriteriaGroup,
14+
StudentAssessmentCriteriaStatus,
15+
StudentCompetencyStatus,
16+
)
17+
from .models.student_status import StudentStatus
18+
19+
20+
AssessmentCriteriaGroupDoesNotExist = AssessmentCriteriaGroup.DoesNotExist
21+
AssessmentCriteriaDoesNotExist = AssessmentCriteria.DoesNotExist
22+
StudentAssessmentCriteriaStatusDoesNotExist = StudentAssessmentCriteriaStatus.DoesNotExist
23+
StudentCompetencyStatusDoesNotExist = StudentCompetencyStatus.DoesNotExist
24+
25+
26+
def create_assessment_criteria_group(
27+
*,
28+
parent: AssessmentCriteriaGroup | None,
29+
competency_tag,
30+
name: str,
31+
ordering: int,
32+
logic_operator: str | None = None,
33+
) -> AssessmentCriteriaGroup:
34+
"""
35+
Create and return an AssessmentCriteriaGroup.
36+
"""
37+
group = AssessmentCriteriaGroup(
38+
parent=parent,
39+
competency_tag=competency_tag,
40+
name=name,
41+
ordering=ordering,
42+
logic_operator=logic_operator,
43+
)
44+
group.full_clean()
45+
group.save()
46+
return group
47+
48+
49+
def get_assessment_criteria_group(group_id: int) -> AssessmentCriteriaGroup | None:
50+
"""
51+
Return a group by id, or None if not found.
52+
"""
53+
return AssessmentCriteriaGroup.objects.filter(id=group_id).first()
54+
55+
56+
def list_assessment_criteria_groups(
57+
*,
58+
parent: AssessmentCriteriaGroup | None = None,
59+
) -> models.QuerySet[AssessmentCriteriaGroup]:
60+
"""
61+
Return groups, optionally filtered by parent.
62+
"""
63+
qs = AssessmentCriteriaGroup.objects.all()
64+
if parent is not None:
65+
qs = qs.filter(parent=parent)
66+
return qs.order_by("ordering", "id")
67+
68+
69+
def update_assessment_criteria_group(
70+
group: AssessmentCriteriaGroup,
71+
*,
72+
parent: AssessmentCriteriaGroup | None | models.NOT_PROVIDED = models.NOT_PROVIDED,
73+
competency_tag=models.NOT_PROVIDED,
74+
name: str | models.NOT_PROVIDED = models.NOT_PROVIDED,
75+
ordering: int | models.NOT_PROVIDED = models.NOT_PROVIDED,
76+
logic_operator: str | None | models.NOT_PROVIDED = models.NOT_PROVIDED,
77+
) -> AssessmentCriteriaGroup:
78+
"""
79+
Update and return an AssessmentCriteriaGroup.
80+
"""
81+
if parent is not models.NOT_PROVIDED:
82+
group.parent = parent
83+
if competency_tag is not models.NOT_PROVIDED:
84+
group.competency_tag = competency_tag
85+
if name is not models.NOT_PROVIDED:
86+
group.name = name
87+
if ordering is not models.NOT_PROVIDED:
88+
group.ordering = ordering
89+
if logic_operator is not models.NOT_PROVIDED:
90+
group.logic_operator = logic_operator
91+
group.full_clean()
92+
group.save()
93+
return group
94+
95+
96+
def delete_assessment_criteria_group(group: AssessmentCriteriaGroup) -> None:
97+
"""
98+
Delete the provided AssessmentCriteriaGroup.
99+
"""
100+
group.delete()
101+
102+
103+
def create_assessment_criteria(
104+
*,
105+
group: AssessmentCriteriaGroup,
106+
object_tag,
107+
competency_tag,
108+
rule_type: str,
109+
rule: str,
110+
retake_rule: str,
111+
) -> AssessmentCriteria:
112+
"""
113+
Create and return an AssessmentCriteria.
114+
"""
115+
criteria = AssessmentCriteria(
116+
group=group,
117+
object_tag=object_tag,
118+
competency_tag=competency_tag,
119+
rule_type=rule_type,
120+
rule=rule,
121+
retake_rule=retake_rule,
122+
)
123+
criteria.full_clean()
124+
criteria.save()
125+
return criteria
126+
127+
128+
def get_assessment_criteria(criteria_id: int) -> AssessmentCriteria | None:
129+
"""
130+
Return assessment criteria by id, or None if not found.
131+
"""
132+
return AssessmentCriteria.objects.filter(id=criteria_id).first()
133+
134+
135+
def list_assessment_criteria(
136+
*,
137+
group: AssessmentCriteriaGroup | None = None,
138+
) -> models.QuerySet[AssessmentCriteria]:
139+
"""
140+
Return criteria, optionally filtered by group.
141+
"""
142+
qs = AssessmentCriteria.objects.all()
143+
if group is not None:
144+
qs = qs.filter(group=group)
145+
return qs.order_by("id")
146+
147+
148+
def update_assessment_criteria(
149+
criteria: AssessmentCriteria,
150+
*,
151+
group: AssessmentCriteriaGroup | models.NOT_PROVIDED = models.NOT_PROVIDED,
152+
object_tag=models.NOT_PROVIDED,
153+
competency_tag=models.NOT_PROVIDED,
154+
rule_type: str | models.NOT_PROVIDED = models.NOT_PROVIDED,
155+
rule: str | models.NOT_PROVIDED = models.NOT_PROVIDED,
156+
retake_rule: str | models.NOT_PROVIDED = models.NOT_PROVIDED,
157+
) -> AssessmentCriteria:
158+
"""
159+
Update and return AssessmentCriteria.
160+
"""
161+
if group is not models.NOT_PROVIDED:
162+
criteria.group = group
163+
if object_tag is not models.NOT_PROVIDED:
164+
criteria.object_tag = object_tag
165+
if competency_tag is not models.NOT_PROVIDED:
166+
criteria.competency_tag = competency_tag
167+
if rule_type is not models.NOT_PROVIDED:
168+
criteria.rule_type = rule_type
169+
if rule is not models.NOT_PROVIDED:
170+
criteria.rule = rule
171+
if retake_rule is not models.NOT_PROVIDED:
172+
criteria.retake_rule = retake_rule
173+
criteria.full_clean()
174+
criteria.save()
175+
return criteria
176+
177+
178+
def delete_assessment_criteria(criteria: AssessmentCriteria) -> None:
179+
"""
180+
Delete the provided AssessmentCriteria.
181+
"""
182+
criteria.delete()
183+
184+
185+
def set_student_assessment_criteria_status(
186+
*,
187+
assessment_criteria: AssessmentCriteria,
188+
user,
189+
status: StudentStatus,
190+
) -> StudentAssessmentCriteriaStatus:
191+
"""
192+
Create or update student assessment criteria status.
193+
"""
194+
entry, _created = StudentAssessmentCriteriaStatus.objects.update_or_create(
195+
assessment_criteria=assessment_criteria,
196+
user=user,
197+
defaults={"status": status},
198+
)
199+
return entry
200+
201+
202+
def set_student_competency_status(
203+
*,
204+
competency_tag,
205+
user,
206+
status: StudentStatus,
207+
) -> StudentCompetencyStatus:
208+
"""
209+
Create or update student competency status.
210+
"""
211+
entry, _created = StudentCompetencyStatus.objects.update_or_create(
212+
competency_tag=competency_tag,
213+
user=user,
214+
defaults={"status": status},
215+
)
216+
return entry
217+
218+
219+
def list_student_assessment_criteria_statuses(
220+
*,
221+
assessment_criteria: AssessmentCriteria | None = None,
222+
user=None,
223+
) -> models.QuerySet[StudentAssessmentCriteriaStatus]:
224+
"""
225+
Return student assessment criteria statuses with optional filters.
226+
"""
227+
qs = StudentAssessmentCriteriaStatus.objects.all()
228+
if assessment_criteria is not None:
229+
qs = qs.filter(assessment_criteria=assessment_criteria)
230+
if user is not None:
231+
qs = qs.filter(user=user)
232+
return qs.order_by("-timestamp", "id")
233+
234+
235+
def list_student_competency_statuses(
236+
*,
237+
competency_tag=None,
238+
user=None,
239+
) -> models.QuerySet[StudentCompetencyStatus]:
240+
"""
241+
Return student competency statuses with optional filters.
242+
"""
243+
qs = StudentCompetencyStatus.objects.all()
244+
if competency_tag is not None:
245+
qs = qs.filter(competency_tag=competency_tag)
246+
if user is not None:
247+
qs = qs.filter(user=user)
248+
return qs.order_by("-timestamp", "id")
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
"""
2+
Assessment criteria Django application initialization.
3+
"""
4+
from django.apps import AppConfig
5+
6+
7+
class AssessmentCriteriaConfig(AppConfig):
8+
"""
9+
Configuration for the assessment criteria Django application.
10+
"""
11+
name = "openedx_learning.apps.assessment_criteria"
12+
verbose_name = "Learning Core > Assessment Criteria"
13+
default_auto_field = "django.db.models.BigAutoField"
14+
label = "oel_assessment_criteria"
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# Generated by Django 5.2.10 on 2026-01-14 17:15
2+
3+
import django.db.models.deletion
4+
import django.utils.timezone
5+
from django.conf import settings
6+
from django.db import migrations, models
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
initial = True
12+
13+
dependencies = [
14+
('oel_tagging', '0018_objecttag_is_copied'),
15+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
16+
]
17+
18+
operations = [
19+
migrations.CreateModel(
20+
name='AssessmentCriteriaGroup',
21+
fields=[
22+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
23+
('name', models.CharField(max_length=255)),
24+
('ordering', models.PositiveIntegerField()),
25+
('logic_operator', models.CharField(blank=True, choices=[('AND', 'AND'), ('OR', 'OR')], max_length=3, null=True)),
26+
('competency_tag', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='assessment_criteria_groups', to='oel_tagging.tag')),
27+
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='oel_assessment_criteria.assessmentcriteriagroup')),
28+
],
29+
),
30+
migrations.CreateModel(
31+
name='AssessmentCriteria',
32+
fields=[
33+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
34+
('rule_type', models.CharField(choices=[('Grade', 'Grade'), ('MasteryLevel', 'MasteryLevel')], max_length=20)),
35+
('rule', models.CharField(max_length=255)),
36+
('retake_rule', models.CharField(choices=[('SimpleAverage', 'SimpleAverage'), ('WeightedAverage', 'WeightedAverage'), ('DecayingAverage', 'DecayingAverage'), ('MostRecent', 'MostRecent'), ('Highest', 'Highest')], max_length=20)),
37+
('competency_tag', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='assessment_criteria', to='oel_tagging.tag')),
38+
('object_tag', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='assessment_criteria', to='oel_tagging.objecttag')),
39+
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='criteria', to='oel_assessment_criteria.assessmentcriteriagroup')),
40+
],
41+
),
42+
migrations.CreateModel(
43+
name='StudentAssessmentCriteriaStatus',
44+
fields=[
45+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
46+
('status', models.CharField(choices=[('demonstrated', 'Demonstrated'), ('attempted_not_demonstrated', 'Attempted, Not Demonstrated'), ('not_attempted', 'Not Attempted')], max_length=32)),
47+
('timestamp', models.DateTimeField(default=django.utils.timezone.now)),
48+
('assessment_criteria', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='student_statuses', to='oel_assessment_criteria.assessmentcriteria')),
49+
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assessment_criteria_statuses', to=settings.AUTH_USER_MODEL)),
50+
],
51+
),
52+
migrations.CreateModel(
53+
name='StudentCompetencyStatus',
54+
fields=[
55+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
56+
('status', models.CharField(choices=[('demonstrated', 'Demonstrated'), ('attempted_not_demonstrated', 'Attempted, Not Demonstrated'), ('not_attempted', 'Not Attempted')], max_length=32)),
57+
('timestamp', models.DateTimeField(default=django.utils.timezone.now)),
58+
('competency_tag', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='student_competency_statuses', to='oel_tagging.tag')),
59+
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='competency_statuses', to=settings.AUTH_USER_MODEL)),
60+
],
61+
),
62+
migrations.AddIndex(
63+
model_name='assessmentcriteriagroup',
64+
index=models.Index(fields=['parent', 'ordering'], name='oel_assessm_parent__7fbf05_idx'),
65+
),
66+
migrations.AddIndex(
67+
model_name='assessmentcriteria',
68+
index=models.Index(fields=['group'], name='oel_assessm_group_i_51f792_idx'),
69+
),
70+
migrations.AddIndex(
71+
model_name='assessmentcriteria',
72+
index=models.Index(fields=['competency_tag'], name='oel_assessm_compete_690bf7_idx'),
73+
),
74+
migrations.AddIndex(
75+
model_name='studentassessmentcriteriastatus',
76+
index=models.Index(fields=['assessment_criteria', 'user'], name='oel_assessm_assessm_da136e_idx'),
77+
),
78+
migrations.AddIndex(
79+
model_name='studentcompetencystatus',
80+
index=models.Index(fields=['competency_tag', 'user'], name='oel_assessm_compete_7e9173_idx'),
81+
),
82+
]

openedx_learning/apps/assessment_criteria/migrations/__init__.py

Whitespace-only changes.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from .criteria import AssessmentCriteria, RetakeRule, RuleType
2+
from .criteria_group import AssessmentCriteriaGroup, GroupLogicOperator
3+
from .student_status import StudentAssessmentCriteriaStatus, StudentCompetencyStatus, StudentStatus
4+
5+
__all__ = [
6+
"AssessmentCriteria",
7+
"AssessmentCriteriaGroup",
8+
"GroupLogicOperator",
9+
"RetakeRule",
10+
"RuleType",
11+
"StudentAssessmentCriteriaStatus",
12+
"StudentCompetencyStatus",
13+
"StudentStatus",
14+
]

0 commit comments

Comments
 (0)