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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Access with username `admin@example.com` and password `1111`
- web: [http://localhost:5173](http://localhost:5173)
- admin: [http://localhost:8000](http://localhost:8000/admin/)
- studio: [http://localhost:5173/studio](http://localhost:5173/studio)
- tutor: [http://localhost:5173/tutor](http://localhost:5173/tutor)

## Screenshots

Expand All @@ -35,6 +36,12 @@ Access with username `admin@example.com` and password `1111`

![Content Studio](screenshot/studio.webp)

- Tutor Dashboard

![Tutor Dashboard](screenshot/tutor.webp)

![Tutor Dashboard](screenshot/tutor.webp)

- Admin Panel

![Admin Panel](screenshot/admin.webp)
Expand Down
2 changes: 1 addition & 1 deletion core/apps/account/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Generated by Django 6.0.3 on 2026-03-07 04:59
# Generated by Django 6.0.3 on 2026-03-08 06:48

import apps.common.storage
import apps.common.util
Expand Down
6 changes: 5 additions & 1 deletion core/apps/assignment/api/v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,11 @@ async def get_session(request: HttpRequest, id: str):
@access_date("assignment", "assignment")
async def start_attempt(request: HttpRequest, id: str):
return await Attempt.start(
assignment_id=id, learner_id=request.auth, context=request.active_context, mode=request.access_mode
assignment_id=id,
learner_id=request.auth,
lock=request.access_date["end"],
context=request.active_context,
mode=request.access_mode,
)


Expand Down
10 changes: 6 additions & 4 deletions core/apps/assignment/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Generated by Django 6.0.3 on 2026-03-07 04:59
# Generated by Django 6.0.3 on 2026-03-08 06:49

import apps.common.util
import django.contrib.postgres.fields
Expand Down Expand Up @@ -152,6 +152,7 @@ class Migration(migrations.Migration):
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('started', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Start Time')),
('lock', models.DateTimeField(verbose_name='Lock')),
('active', models.BooleanField(default=True, verbose_name='Active')),
('context', models.CharField(blank=True, default='', max_length=255, verbose_name='Context Key')),
('mode', models.CharField(choices=[('', ''), ('preview', 'Preview'), ('audit', 'Audit')], db_index=True, default='', max_length=30, verbose_name='Mode')),
Expand Down Expand Up @@ -297,6 +298,7 @@ class Migration(migrations.Migration):
('pgh_label', models.TextField(help_text='The event label.')),
('id', models.BigIntegerField()),
('started', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Start Time')),
('lock', models.DateTimeField(verbose_name='Lock')),
('active', models.BooleanField(default=True, verbose_name='Active')),
('context', models.CharField(blank=True, default='', max_length=255, verbose_name='Context Key')),
('mode', models.CharField(choices=[('', ''), ('preview', 'Preview'), ('audit', 'Audit')], default='', max_length=30, verbose_name='Mode')),
Expand Down Expand Up @@ -539,15 +541,15 @@ class Migration(migrations.Migration):
),
pgtrigger.migrations.AddTrigger(
model_name='attempt',
trigger=pgtrigger.compiler.Trigger(name='insert_insert', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "assignment_attemptevent" ("active", "assignment_id", "context", "id", "learner_id", "mode", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "question_id", "retry", "started") VALUES (NEW."active", NEW."assignment_id", NEW."context", NEW."id", NEW."learner_id", NEW."mode", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."question_id", NEW."retry", NEW."started"); RETURN NULL;', hash='f2adaad4af56a38d9b01bb6e2196d41bb08a6568', operation='INSERT', pgid='pgtrigger_insert_insert_0e484', table='assignment_attempt', when='AFTER')),
trigger=pgtrigger.compiler.Trigger(name='insert_insert', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "assignment_attemptevent" ("active", "assignment_id", "context", "id", "learner_id", "lock", "mode", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "question_id", "retry", "started") VALUES (NEW."active", NEW."assignment_id", NEW."context", NEW."id", NEW."learner_id", NEW."lock", NEW."mode", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."question_id", NEW."retry", NEW."started"); RETURN NULL;', hash='f34057d0b9dd986803a99bfc1e8efe71b535560c', operation='INSERT', pgid='pgtrigger_insert_insert_0e484', table='assignment_attempt', when='AFTER')),
),
pgtrigger.migrations.AddTrigger(
model_name='attempt',
trigger=pgtrigger.compiler.Trigger(name='update_update', sql=pgtrigger.compiler.UpsertTriggerSql(condition='WHEN (OLD.* IS DISTINCT FROM NEW.*)', func='INSERT INTO "assignment_attemptevent" ("active", "assignment_id", "context", "id", "learner_id", "mode", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "question_id", "retry", "started") VALUES (NEW."active", NEW."assignment_id", NEW."context", NEW."id", NEW."learner_id", NEW."mode", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."question_id", NEW."retry", NEW."started"); RETURN NULL;', hash='ef8bee4e4f31cdcf4bd1af6f7eab0f270a234241', operation='UPDATE', pgid='pgtrigger_update_update_a72bb', table='assignment_attempt', when='AFTER')),
trigger=pgtrigger.compiler.Trigger(name='update_update', sql=pgtrigger.compiler.UpsertTriggerSql(condition='WHEN (OLD.* IS DISTINCT FROM NEW.*)', func='INSERT INTO "assignment_attemptevent" ("active", "assignment_id", "context", "id", "learner_id", "lock", "mode", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "question_id", "retry", "started") VALUES (NEW."active", NEW."assignment_id", NEW."context", NEW."id", NEW."learner_id", NEW."lock", NEW."mode", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."question_id", NEW."retry", NEW."started"); RETURN NULL;', hash='2988f1e3ae4f1ab0055c62b5b28a25636202ea71', operation='UPDATE', pgid='pgtrigger_update_update_a72bb', table='assignment_attempt', when='AFTER')),
),
pgtrigger.migrations.AddTrigger(
model_name='attempt',
trigger=pgtrigger.compiler.Trigger(name='delete_delete', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "assignment_attemptevent" ("active", "assignment_id", "context", "id", "learner_id", "mode", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "question_id", "retry", "started") VALUES (OLD."active", OLD."assignment_id", OLD."context", OLD."id", OLD."learner_id", OLD."mode", _pgh_attach_context(), NOW(), \'delete\', OLD."id", OLD."question_id", OLD."retry", OLD."started"); RETURN NULL;', hash='f18e7d26c3f3e955ec528dee5aa66ecafeed9146', operation='DELETE', pgid='pgtrigger_delete_delete_49776', table='assignment_attempt', when='AFTER')),
trigger=pgtrigger.compiler.Trigger(name='delete_delete', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "assignment_attemptevent" ("active", "assignment_id", "context", "id", "learner_id", "lock", "mode", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "question_id", "retry", "started") VALUES (OLD."active", OLD."assignment_id", OLD."context", OLD."id", OLD."learner_id", OLD."lock", OLD."mode", _pgh_attach_context(), NOW(), \'delete\', OLD."id", OLD."question_id", OLD."retry", OLD."started"); RETURN NULL;', hash='2344f65f79763ea1f77ecf15b43170d326ff0c53', operation='DELETE', pgid='pgtrigger_delete_delete_49776', table='assignment_attempt', when='AFTER')),
),
pgtrigger.migrations.AddTrigger(
model_name='attempt',
Expand Down
16 changes: 13 additions & 3 deletions core/apps/assignment/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ class Meta:
submission: "Submission"

@classmethod
async def start(cls, *, assignment_id: str, learner_id: str, context: str, mode: ModeChoices):
async def start(cls, *, assignment_id: str, learner_id: str, lock: datetime, context: str, mode: ModeChoices):
assignment = await Assignment.objects.aget(id=assignment_id)

if assignment.verification_required:
Expand All @@ -313,6 +313,7 @@ async def start(cls, *, assignment_id: str, learner_id: str, context: str, mode:
attempt = await Attempt.objects.acreate(
assignment=assignment,
learner_id=learner_id,
lock=lock,
context=context,
active=True,
started=timezone.now() + timedelta(seconds=1),
Expand Down Expand Up @@ -439,15 +440,24 @@ class Meta(GradeFieldMixin.Meta, TimeStampedMixin.Meta):
pk: int
attempt_id: int
pgh_event_model: type[Model]
analysis: dict[str, dict[str, int]]
similar_answer: str | None

async def grade(self, grader_id: str | None = None):
async def grade(self, earned_existing: dict[str, int | None] | None = None, grader_id: str | None = None):
rubric_data = await self.attempt.assignment.get_rubric_data()
default_details = {criterion["name"]: None for criterion in rubric_data["criteria"]}
default_details: dict[str, int | None] = {criterion["name"]: None for criterion in rubric_data["criteria"]}
self.earned_details = default_details | (self.earned_details or {})

if earned_existing is not None:
valid_keys = set(self.earned_details.keys())
self.earned_details.update({k: v for k, v in earned_existing.items() if k in valid_keys})

self.possible_point = rubric_data["possible_point"]
self.earned_point = sum(filter(None, self.earned_details.values()))
self.score = (self.earned_point * 100.0 / self.possible_point) if self.possible_point else 0
self.passed = self.score >= (self.attempt.assignment.passing_point or 0)
if not self.completed:
self.completed = None if None in self.earned_details.values() else timezone.now()
self.grader_id = grader_id
await self.asave()

Expand Down
1 change: 1 addition & 0 deletions core/apps/assignment/tests/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ class AttemptFactory(DjangoModelFactory[Attempt]):
learner = SubFactory(UserFactory)
started = LazyFunction(lambda: timezone.now())
question = LazyAttribute(lambda o: async_to_sync(o.assignment.question_pool.select_question)())
lock = LazyFunction(timezone.now)
active = True

class Meta:
Expand Down
2 changes: 1 addition & 1 deletion core/apps/assistant/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Generated by Django 6.0.3 on 2026-03-07 04:59
# Generated by Django 6.0.3 on 2026-03-08 06:49

import django.db.models.deletion
import pgtrigger.compiler
Expand Down
1 change: 1 addition & 0 deletions core/apps/common/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ def duration_seconds(self, value: int | None):

class AttemptMixin(Model):
started = DateTimeField(_("Start Time"), default=timezone.now)
lock = DateTimeField(_("Lock"))
active = BooleanField(_("Active"), default=True)
context = CharField(_("Context Key"), max_length=255, blank=True, default="")
mode = CharField(_("Mode"), max_length=30, choices=ModeChoices.choices, default=ModeChoices.NORMAL, db_index=True)
Expand Down
2 changes: 1 addition & 1 deletion core/apps/competency/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Generated by Django 6.0.3 on 2026-03-07 04:59
# Generated by Django 6.0.3 on 2026-03-08 06:49

import django.contrib.postgres.fields
import django.db.models.deletion
Expand Down
2 changes: 1 addition & 1 deletion core/apps/content/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Generated by Django 6.0.3 on 2026-03-07 04:59
# Generated by Django 6.0.3 on 2026-03-08 06:49

import apps.common.util
import apps.content.models
Expand Down
2 changes: 2 additions & 0 deletions core/apps/course/api/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from apps.common.schema import (
AccessDateSchema,
AttemptMixinSchema,
GradingDateSchema,
LearningObjectMixinSchema,
Schema,
TimeStampedMixinSchema,
Expand Down Expand Up @@ -133,6 +134,7 @@ class GradingCriterionSchema(Schema):

class CourseSessionSchema(Schema):
access_date: AccessDateSchema
grading_date: GradingDateSchema
course: CourseSchema
engagement: Annotated[CourseEngagementSchema, Field(None)]
otp_token: Annotated[str, Field(None)]
Expand Down
4 changes: 3 additions & 1 deletion core/apps/course/api/v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ async def get_session(request: HttpRequest, id: str):
@access_mode()
@access_date("course", "course")
async def start_engagement(request: HttpRequest, id: str):
return await Engagement.start(course_id=id, learner_id=request.auth, mode=request.access_mode)
return await Engagement.start(
course_id=id, learner_id=request.auth, lock=request.access_date["end"], mode=request.access_mode
)


@router.get("/{id}/detail", response=CourseDetailSchema)
Expand Down
Loading