Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 7 additions & 18 deletions core/apps/assignment/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,9 +151,14 @@ def rubric_data(self, data: RubricDataDict):

@classmethod
async def get_session(cls, *, assignment_id: str, learner_id: str, context: str, access_date: AccessDate):
assignment = await Assignment.objects.select_related("owner", "honor_code", "question_pool").aget(
id=assignment_id
assignment = (
await Assignment.objects
.select_related("owner", "honor_code", "question_pool")
.prefetch_related("rubric__rubric_criteria__performance_levels")
.aget(id=assignment_id)
)
assignment.rubric_data = await assignment.get_rubric_data()

session = SessionDict(
access_date=access_date,
grading_date=assignment.get_grading_date(access_date),
Expand All @@ -178,14 +183,6 @@ async def get_session(cls, *, assignment_id: str, learner_id: str, context: str,
)
return session

await aprefetch_related_objects(
[assignment],
Prefetch(
"rubric__rubric_criteria__performance_levels", queryset=PerformanceLevel.objects.order_by("point")
),
)
assignment.rubric_data = await assignment.get_rubric_data()

session["attempt"] = attempt

if not hasattr(attempt, "submission"):
Expand Down Expand Up @@ -298,14 +295,6 @@ async def start(cls, *, assignment_id: str, learner_id: str, lock: datetime, con
if not await OtpLog.check_otp_verification(user_id=learner_id, consumer=assignment):
raise ValueError(ErrorCode.OTP_VERIFICATION_REQUIRED)

await aprefetch_related_objects(
[assignment],
Prefetch(
"rubric__rubric_criteria__performance_levels", queryset=PerformanceLevel.objects.order_by("point")
),
)
assignment.rubric_data = await assignment.get_rubric_data()

question = await QuestionPool(id=assignment.question_pool_id).select_question()
await aprefetch_related_objects([question], "attachments")

Expand Down
1 change: 1 addition & 0 deletions core/apps/exam/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,7 @@ class Meta(GradeFieldMixin.Meta, TimeStampedMixin.Meta):
attempt_id: int
analysis: dict[str, dict[str, int]]
grading_date: GradingDate
question_ids: list[int]

async def grade(self, earned_existing: dict[str, int | None] | None = None, grader_id: str | None = None):
questions = [q async for q in self.attempt.questions.all()]
Expand Down
3 changes: 1 addition & 2 deletions core/apps/exam/tests/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,7 @@ class Meta:
@lazy_attribute
def composition(self):
option = generic.random.choice([(20, 3, 0, 1), (20, 5, 0, 1), (25, 0, 0, 1), (15, 5, 5, 1), (20, 0, 3, 1)])
composed = {"single_choice": option[0], "text_input": option[1], "number_input": option[2], "essay": option[3]}
return {k: v for k, v in composed.items() if v != 0}
return {"single_choice": option[0], "text_input": option[1], "number_input": option[2], "essay": option[3]}

@post_generation
def post_generation(self: QuestionPool, create: bool, extracted: object, **kwargs: object):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Generated by Django 6.0.3 on 2026-03-10 12:55

import django.db.models.deletion
import pgtrigger.compiler
import pgtrigger.migrations
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('operation', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
pgtrigger.migrations.RemoveTrigger(
model_name='appeal',
name='insert_insert',
),
pgtrigger.migrations.RemoveTrigger(
model_name='appeal',
name='update_update',
),
pgtrigger.migrations.RemoveTrigger(
model_name='appeal',
name='delete_delete',
),
migrations.AddField(
model_name='appeal',
name='reviewer',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Reviewer'),
),
migrations.AddField(
model_name='appealevent',
name='reviewer',
field=models.ForeignKey(db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', related_query_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Reviewer'),
),
migrations.AlterField(
model_name='appeal',
name='path',
field=models.CharField(blank=True, db_index=True, default='', max_length=500, verbose_name='Path'),
),
pgtrigger.migrations.AddTrigger(
model_name='appeal',
trigger=pgtrigger.compiler.Trigger(name='insert_insert', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "operation_appealevent" ("created", "explanation", "id", "learner_id", "modified", "path", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "question_id", "question_type_id", "review", "reviewer_id") VALUES (NEW."created", NEW."explanation", NEW."id", NEW."learner_id", NEW."modified", NEW."path", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."question_id", NEW."question_type_id", NEW."review", NEW."reviewer_id"); RETURN NULL;', hash='a0f2a97c3d500389c6ae51a89ab359fed2f11e25', operation='INSERT', pgid='pgtrigger_insert_insert_c7b97', table='operation_appeal', when='AFTER')),
),
pgtrigger.migrations.AddTrigger(
model_name='appeal',
trigger=pgtrigger.compiler.Trigger(name='update_update', sql=pgtrigger.compiler.UpsertTriggerSql(condition='WHEN (OLD."explanation" IS DISTINCT FROM (NEW."explanation") OR OLD."id" IS DISTINCT FROM (NEW."id") OR OLD."learner_id" IS DISTINCT FROM (NEW."learner_id") OR OLD."path" IS DISTINCT FROM (NEW."path") OR OLD."question_id" IS DISTINCT FROM (NEW."question_id") OR OLD."question_type_id" IS DISTINCT FROM (NEW."question_type_id") OR OLD."review" IS DISTINCT FROM (NEW."review") OR OLD."reviewer_id" IS DISTINCT FROM (NEW."reviewer_id"))', func='INSERT INTO "operation_appealevent" ("created", "explanation", "id", "learner_id", "modified", "path", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "question_id", "question_type_id", "review", "reviewer_id") VALUES (NEW."created", NEW."explanation", NEW."id", NEW."learner_id", NEW."modified", NEW."path", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."question_id", NEW."question_type_id", NEW."review", NEW."reviewer_id"); RETURN NULL;', hash='0f827690772392e91649050a0376729b1b0ad8bb', operation='UPDATE', pgid='pgtrigger_update_update_3fe73', table='operation_appeal', when='AFTER')),
),
pgtrigger.migrations.AddTrigger(
model_name='appeal',
trigger=pgtrigger.compiler.Trigger(name='delete_delete', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "operation_appealevent" ("created", "explanation", "id", "learner_id", "modified", "path", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "question_id", "question_type_id", "review", "reviewer_id") VALUES (OLD."created", OLD."explanation", OLD."id", OLD."learner_id", OLD."modified", OLD."path", _pgh_attach_context(), NOW(), \'delete\', OLD."id", OLD."question_id", OLD."question_type_id", OLD."review", OLD."reviewer_id"); RETURN NULL;', hash='e9c2459d59c132d165ef47959f8145e3bee0b847', operation='DELETE', pgid='pgtrigger_delete_delete_1853c', table='operation_appeal', when='AFTER')),
),
]
4 changes: 3 additions & 1 deletion core/apps/operation/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -478,7 +478,8 @@ class Appeal(TimeStampedMixin, AttachmentMixin):
learner = ForeignKey(User, CASCADE, verbose_name=_("Learner"), related_name="+")
explanation = TextField(_("Explanation"))
review = TextField(_("Review"), blank=True, default="")
path = CharField(_("Path"), max_length=500, default="", blank=True)
reviewer = ForeignKey(User, CASCADE, verbose_name=_("Reviewer"), null=True, related_name="+")
path = CharField(_("Path"), max_length=500, default="", blank=True, db_index=True)

limit_choices_to = {"model__in": ["question"]}
question_type = ForeignKey(ContentType, CASCADE, verbose_name=_("Question Type"), limit_choices_to=limit_choices_to)
Expand All @@ -497,6 +498,7 @@ class Meta(TimeStampedMixin.Meta, AttachmentMixin.Meta):

if TYPE_CHECKING:
learner_id: str
reviewer_id: str

@property
def cleaned_explanation(self):
Expand Down
3 changes: 3 additions & 0 deletions core/apps/studio/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from apps.course.models import Engagement as CourseEngagement
from apps.discussion.models import Attempt as DiscussionAttempt
from apps.exam.models import Attempt as ExamAttempt
from apps.operation.models import Appeal
from apps.quiz.models import Attempt as QuizAttempt
from apps.survey.models import Submission as SurveySubmission

Expand All @@ -32,4 +33,6 @@ def cleanup_preview_data():
num, model_num = M.objects.filter(mode="preview", started__lte=threshold).delete()
deleted[M._meta.model_name] = num, model_num

Appeal.objects.filter(started__lte=threshold, path__contains="mode=preview").delete()

return deleted
39 changes: 35 additions & 4 deletions core/apps/tutor/api/v1/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@
from typing import Annotated, Literal

from django.conf import settings
from ninja import Router
from ninja import Field, Router
from ninja.params import functions

from apps.common.schema import ContentTypeSchema, Schema
from apps.common.util import HttpRequest, PaginatedResponse
from apps.operation.api.schema import AppealSchema
from apps.tutor.api.v1.assignment import router as assignment_router
from apps.tutor.api.v1.discussion import router as discussion_router
from apps.tutor.api.v1.exam import router as exam_router
Expand All @@ -16,9 +17,6 @@
router = Router(by_alias=True)


TutoringModel = Literal["exam", "assignment", "discussion"]


class AllocationSchema(Schema):
class TutorContentSchema(Schema):
id: str
Expand Down Expand Up @@ -65,6 +63,39 @@ async def get_allocation_stats(request: HttpRequest):
return await Allocation.get_stats(tutor_id=request.auth)


AppealAppLabel = Literal["exam", "assignment", "discussion"]
AppealModel = Literal["exam", "assignment", "discussion"]


class GradeAppealSchema(AppealSchema):
grade_id: int


@router.get("/{app_label}/{model}/{id}/appeal", response=PaginatedResponse[GradeAppealSchema])
@tutor_required()
async def get_appeals(
request: HttpRequest,
app_label: AppealAppLabel,
model: AppealModel,
id: str,
page: Annotated[int, functions.Query(1, ge=1)],
size: Annotated[int, functions.Query(settings.DEFAULT_PAGINATION_SIZE, gte=1, le=100)],
):
return await Allocation.get_appeals(
tutor_id=request.auth, app_label=app_label, model=model, content_id=id, page=page, size=size
)


class AppealReviewSchema(Schema):
review: Annotated[str, Field(min_length=1)]


@router.post("/appeal/{id}")
@tutor_required()
async def review_appeal(request: HttpRequest, id: int, review: AppealReviewSchema):
await Allocation.review_appeal(tutor_id=request.auth, appeal_id=id, review=review.review, reviewer_id=request.auth)


router.add_router("", exam_router, tags=["tutor"])
router.add_router("", assignment_router, tags=["tutor"])
router.add_router("", discussion_router, tags=["tutor"])
4 changes: 2 additions & 2 deletions core/apps/tutor/api/v1/assignment.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ def resolve_question(grade: Grade):

@staticmethod
def resolve_answer(grade: Grade):
return grade.attempt.submission.answer
return grade.attempt.submission.cleaned_answer


@router.get("/assignment/{id}/grade/{grade_id}", response=TutorAssignmentGradePaperSchema)
Expand All @@ -61,7 +61,7 @@ def resolve_answer(grade: Grade):
async def get_assignment_grade_paper(request: HttpRequest, id: str, grade_id: int):
grade = await aget_object_or_404(
Grade.objects.select_related("attempt__submission", "attempt__question", "grader").prefetch_related(
"attempt__question__attachments"
"attempt__question__attachments", "attempt__submission__attachments"
),
id=grade_id,
attempt__assignment_id=id,
Expand Down
33 changes: 19 additions & 14 deletions core/apps/tutor/api/v1/exam.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from django.shortcuts import aget_object_or_404
from ninja import Router
from ninja.pagination import paginate
from ninja.params import functions

from apps.account.api.schema import OwnerSchema
from apps.common.schema import Schema
Expand Down Expand Up @@ -48,13 +49,15 @@ class TutorExamQuestionSchema(ExamQuestionSchema):

@staticmethod
def resolve_answers(grade: Grade):
question_ids = [q.id for q in grade.attempt.questions.all()]
return {k: v for k, v in grade.attempt.submission.answers.items() if int(k) in question_ids}
return {k: v for k, v in grade.attempt.submission.answers.items() if int(k) in grade.question_ids}

@staticmethod
def resolve_earned_details(grade: Grade):
question_ids = [q.id for q in grade.attempt.questions.all()]
return {k: v for k, v in grade.earned_details.items() if int(k) in question_ids}
return {k: v for k, v in grade.earned_details.items() if int(k) in grade.question_ids}

@staticmethod
def resolve_feedback(grade: Grade):
return {k: v for k, v in grade.feedback.items() if int(k) in grade.question_ids}

@staticmethod
def resolve_questions(grade: Grade):
Expand All @@ -64,23 +67,24 @@ def resolve_questions(grade: Grade):
@router.get("/exam/{id}/grade/{grade_id}", response=TutorExamGradePaperSchema)
@tutor_required()
@allocation_required("exam", "exam")
async def get_exam_grade_paper(request: HttpRequest, id: str, grade_id: int):
async def get_exam_grade_paper(
request: HttpRequest, id: str, grade_id: int, question_id: int | None = functions.Query(None, alias="questionId")
):
if question_id:
q = Q(id=question_id)
else:
q = Q(solution__correct_answers=[]) | Q(
format__in=[Question.ExamQuestionFormatChoices.ESSAY, Question.ExamQuestionFormatChoices.TEXT_INPUT]
)

grade = await aget_object_or_404(
Grade.objects.select_related("attempt__submission", "grader").prefetch_related(
Prefetch(
"attempt__questions",
queryset=Question.objects
.select_related("solution")
.prefetch_related("attachments")
.filter(
Q(solution__correct_answers=[])
| Q(
format__in=[
Question.ExamQuestionFormatChoices.ESSAY,
Question.ExamQuestionFormatChoices.TEXT_INPUT,
]
)
)
.filter(q)
.order_by("id"),
)
),
Expand All @@ -89,6 +93,7 @@ async def get_exam_grade_paper(request: HttpRequest, id: str, grade_id: int):
attempt__active=True,
)
grade.analysis = await Exam().analyze_answers([q.id for q in grade.attempt.questions.all()])
grade.question_ids = [q.id for q in grade.attempt.questions.all()]

return grade

Expand Down
2 changes: 1 addition & 1 deletion core/apps/tutor/decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ async def wrapper(request: HttpRequest, *args, **kwargs):

content_type = await sync_to_async(ContentType.objects.get_by_natural_key)(app_label, model)
allocated = await Allocation.objects.filter(
tutor_id=request.auth, content_type=content_type, content_id=kwargs[id_field]
tutor_id=request.auth, active=True, content_type=content_type, content_id=kwargs[id_field]
).aexists()

if not allocated:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Generated by Django 6.0.3 on 2026-03-10 12:55

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('tutor', '0001_initial'),
]

operations = [
migrations.AlterField(
model_name='allocation',
name='content_type',
field=models.ForeignKey(limit_choices_to={'model__in': ('exam', 'assignment', 'discussion')}, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name='Content type'),
),
migrations.AlterField(
model_name='allocationevent',
name='content_type',
field=models.ForeignKey(db_constraint=False, limit_choices_to={'model__in': ('exam', 'assignment', 'discussion')}, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', related_query_name='+', to='contenttypes.contenttype', verbose_name='Content type'),
),
]
Loading
Loading