diff --git a/README.md b/README.md index 423fd7d..40b3963 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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) diff --git a/core/apps/account/migrations/0001_initial.py b/core/apps/account/migrations/0001_initial.py index 868430e..d45301e 100644 --- a/core/apps/account/migrations/0001_initial.py +++ b/core/apps/account/migrations/0001_initial.py @@ -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 diff --git a/core/apps/assignment/api/v1.py b/core/apps/assignment/api/v1.py index 04c9b87..ba6e32e 100644 --- a/core/apps/assignment/api/v1.py +++ b/core/apps/assignment/api/v1.py @@ -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, ) diff --git a/core/apps/assignment/migrations/0001_initial.py b/core/apps/assignment/migrations/0001_initial.py index ef04c2c..4f54554 100644 --- a/core/apps/assignment/migrations/0001_initial.py +++ b/core/apps/assignment/migrations/0001_initial.py @@ -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 @@ -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')), @@ -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')), @@ -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', diff --git a/core/apps/assignment/models.py b/core/apps/assignment/models.py index 779a862..2462a08 100644 --- a/core/apps/assignment/models.py +++ b/core/apps/assignment/models.py @@ -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: @@ -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), @@ -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() diff --git a/core/apps/assignment/tests/factories.py b/core/apps/assignment/tests/factories.py index 17e8f6f..0efd852 100644 --- a/core/apps/assignment/tests/factories.py +++ b/core/apps/assignment/tests/factories.py @@ -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: diff --git a/core/apps/assistant/migrations/0001_initial.py b/core/apps/assistant/migrations/0001_initial.py index f7c6aad..8ebd22f 100644 --- a/core/apps/assistant/migrations/0001_initial.py +++ b/core/apps/assistant/migrations/0001_initial.py @@ -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 diff --git a/core/apps/common/models.py b/core/apps/common/models.py index 3d93109..89df5fa 100644 --- a/core/apps/common/models.py +++ b/core/apps/common/models.py @@ -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) diff --git a/core/apps/competency/migrations/0001_initial.py b/core/apps/competency/migrations/0001_initial.py index ab34855..0f5f0a7 100644 --- a/core/apps/competency/migrations/0001_initial.py +++ b/core/apps/competency/migrations/0001_initial.py @@ -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 diff --git a/core/apps/content/migrations/0001_initial.py b/core/apps/content/migrations/0001_initial.py index 940bebc..437f42c 100644 --- a/core/apps/content/migrations/0001_initial.py +++ b/core/apps/content/migrations/0001_initial.py @@ -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 diff --git a/core/apps/course/api/schema.py b/core/apps/course/api/schema.py index 9d96efb..8cad215 100644 --- a/core/apps/course/api/schema.py +++ b/core/apps/course/api/schema.py @@ -7,6 +7,7 @@ from apps.common.schema import ( AccessDateSchema, AttemptMixinSchema, + GradingDateSchema, LearningObjectMixinSchema, Schema, TimeStampedMixinSchema, @@ -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)] diff --git a/core/apps/course/api/v1.py b/core/apps/course/api/v1.py index fb42fb9..b7dde72 100644 --- a/core/apps/course/api/v1.py +++ b/core/apps/course/api/v1.py @@ -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) diff --git a/core/apps/course/migrations/0001_initial.py b/core/apps/course/migrations/0001_initial.py index 69dfc2d..bcca1ed 100644 --- a/core/apps/course/migrations/0001_initial.py +++ b/core/apps/course/migrations/0001_initial.py @@ -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 @@ -87,6 +87,9 @@ class Migration(migrations.Migration): ('max_attempts', models.PositiveSmallIntegerField(default=0, verbose_name='Max Attempts')), ('verification_required', models.BooleanField(default=False, verbose_name='Verification Required')), ('published', models.DateTimeField(blank=True, null=True, verbose_name='Published')), + ('grade_due_days', models.PositiveSmallIntegerField(verbose_name='Grading Due Days')), + ('appeal_deadline_days', models.PositiveSmallIntegerField(verbose_name='Appeal Deadline Days')), + ('confirm_due_days', models.PositiveSmallIntegerField(verbose_name='Confirm Due Days')), ('objective', models.TextField(blank=True, default='', verbose_name='Objective')), ('preview_url', models.URLField(blank=True, null=True, verbose_name='Preview URL')), ('effort_hours', models.PositiveSmallIntegerField(verbose_name='Effort Hours')), @@ -304,6 +307,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')), @@ -324,6 +328,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')), @@ -498,6 +503,9 @@ class Migration(migrations.Migration): ('max_attempts', models.PositiveSmallIntegerField(default=0, verbose_name='Max Attempts')), ('verification_required', models.BooleanField(default=False, verbose_name='Verification Required')), ('published', models.DateTimeField(blank=True, null=True, verbose_name='Published')), + ('grade_due_days', models.PositiveSmallIntegerField(verbose_name='Grading Due Days')), + ('appeal_deadline_days', models.PositiveSmallIntegerField(verbose_name='Appeal Deadline Days')), + ('confirm_due_days', models.PositiveSmallIntegerField(verbose_name='Confirm Due Days')), ('objective', models.TextField(blank=True, default='', verbose_name='Objective')), ('preview_url', models.URLField(blank=True, null=True, verbose_name='Preview URL')), ('effort_hours', models.PositiveSmallIntegerField(verbose_name='Effort Hours')), @@ -669,15 +677,15 @@ class Migration(migrations.Migration): ), pgtrigger.migrations.AddTrigger( model_name='engagement', - trigger=pgtrigger.compiler.Trigger(name='insert_insert', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "course_engagementevent" ("active", "context", "course_id", "id", "learner_id", "mode", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "started") VALUES (NEW."active", NEW."context", NEW."course_id", NEW."id", NEW."learner_id", NEW."mode", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."started"); RETURN NULL;', hash='355ab145a728354afebc187bffcf5ea2b51ccc24', operation='INSERT', pgid='pgtrigger_insert_insert_dc2ef', table='course_engagement', when='AFTER')), + trigger=pgtrigger.compiler.Trigger(name='insert_insert', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "course_engagementevent" ("active", "context", "course_id", "id", "learner_id", "lock", "mode", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "started") VALUES (NEW."active", NEW."context", NEW."course_id", NEW."id", NEW."learner_id", NEW."lock", NEW."mode", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."started"); RETURN NULL;', hash='329a9d1b0a38124e82f5ca9fc98906983e35b404', operation='INSERT', pgid='pgtrigger_insert_insert_dc2ef', table='course_engagement', when='AFTER')), ), pgtrigger.migrations.AddTrigger( model_name='engagement', - trigger=pgtrigger.compiler.Trigger(name='update_update', sql=pgtrigger.compiler.UpsertTriggerSql(condition='WHEN (OLD.* IS DISTINCT FROM NEW.*)', func='INSERT INTO "course_engagementevent" ("active", "context", "course_id", "id", "learner_id", "mode", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "started") VALUES (NEW."active", NEW."context", NEW."course_id", NEW."id", NEW."learner_id", NEW."mode", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."started"); RETURN NULL;', hash='646532a419d9328d25f82a9d3581963d46628ccb', operation='UPDATE', pgid='pgtrigger_update_update_bc152', table='course_engagement', when='AFTER')), + trigger=pgtrigger.compiler.Trigger(name='update_update', sql=pgtrigger.compiler.UpsertTriggerSql(condition='WHEN (OLD.* IS DISTINCT FROM NEW.*)', func='INSERT INTO "course_engagementevent" ("active", "context", "course_id", "id", "learner_id", "lock", "mode", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "started") VALUES (NEW."active", NEW."context", NEW."course_id", NEW."id", NEW."learner_id", NEW."lock", NEW."mode", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."started"); RETURN NULL;', hash='3e014a2797a672a6ee7d1cb817940323458eb01a', operation='UPDATE', pgid='pgtrigger_update_update_bc152', table='course_engagement', when='AFTER')), ), pgtrigger.migrations.AddTrigger( model_name='engagement', - trigger=pgtrigger.compiler.Trigger(name='delete_delete', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "course_engagementevent" ("active", "context", "course_id", "id", "learner_id", "mode", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "started") VALUES (OLD."active", OLD."context", OLD."course_id", OLD."id", OLD."learner_id", OLD."mode", _pgh_attach_context(), NOW(), \'delete\', OLD."id", OLD."started"); RETURN NULL;', hash='52257ed31f728c7792d36f7f9534d16be2aa05de', operation='DELETE', pgid='pgtrigger_delete_delete_39407', table='course_engagement', when='AFTER')), + trigger=pgtrigger.compiler.Trigger(name='delete_delete', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "course_engagementevent" ("active", "context", "course_id", "id", "learner_id", "lock", "mode", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "started") VALUES (OLD."active", OLD."context", OLD."course_id", OLD."id", OLD."learner_id", OLD."lock", OLD."mode", _pgh_attach_context(), NOW(), \'delete\', OLD."id", OLD."started"); RETURN NULL;', hash='1e50f89a531cfa8133f19012c85db2463a10d691', operation='DELETE', pgid='pgtrigger_delete_delete_39407', table='course_engagement', when='AFTER')), ), pgtrigger.migrations.AddTrigger( model_name='engagementevent', @@ -765,15 +773,15 @@ class Migration(migrations.Migration): ), pgtrigger.migrations.AddTrigger( model_name='course', - trigger=pgtrigger.compiler.Trigger(name='insert_insert', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "course_courseevent" ("audience", "created", "description", "duration", "effort_hours", "faq_id", "featured", "format", "honor_code_id", "id", "level", "max_attempts", "message_preset_id", "modified", "objective", "owner_id", "passing_point", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "preview_url", "published", "thumbnail", "title", "verification_required") VALUES (NEW."audience", NEW."created", NEW."description", NEW."duration", NEW."effort_hours", NEW."faq_id", NEW."featured", NEW."format", NEW."honor_code_id", NEW."id", NEW."level", NEW."max_attempts", NEW."message_preset_id", NEW."modified", NEW."objective", NEW."owner_id", NEW."passing_point", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."preview_url", NEW."published", NEW."thumbnail", NEW."title", NEW."verification_required"); RETURN NULL;', hash='aabd0bb049bc09bd40f2f86de5d072499bf68196', operation='INSERT', pgid='pgtrigger_insert_insert_b0bd1', table='course_course', when='AFTER')), + trigger=pgtrigger.compiler.Trigger(name='insert_insert', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "course_courseevent" ("appeal_deadline_days", "audience", "confirm_due_days", "created", "description", "duration", "effort_hours", "faq_id", "featured", "format", "grade_due_days", "honor_code_id", "id", "level", "max_attempts", "message_preset_id", "modified", "objective", "owner_id", "passing_point", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "preview_url", "published", "thumbnail", "title", "verification_required") VALUES (NEW."appeal_deadline_days", NEW."audience", NEW."confirm_due_days", NEW."created", NEW."description", NEW."duration", NEW."effort_hours", NEW."faq_id", NEW."featured", NEW."format", NEW."grade_due_days", NEW."honor_code_id", NEW."id", NEW."level", NEW."max_attempts", NEW."message_preset_id", NEW."modified", NEW."objective", NEW."owner_id", NEW."passing_point", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."preview_url", NEW."published", NEW."thumbnail", NEW."title", NEW."verification_required"); RETURN NULL;', hash='b3939aedb9fa4063d8597071a191c0a448ce77ff', operation='INSERT', pgid='pgtrigger_insert_insert_b0bd1', table='course_course', when='AFTER')), ), pgtrigger.migrations.AddTrigger( model_name='course', - trigger=pgtrigger.compiler.Trigger(name='update_update', sql=pgtrigger.compiler.UpsertTriggerSql(condition='WHEN (OLD."audience" IS DISTINCT FROM (NEW."audience") OR OLD."description" IS DISTINCT FROM (NEW."description") OR OLD."duration" IS DISTINCT FROM (NEW."duration") OR OLD."effort_hours" IS DISTINCT FROM (NEW."effort_hours") OR OLD."faq_id" IS DISTINCT FROM (NEW."faq_id") OR OLD."featured" IS DISTINCT FROM (NEW."featured") OR OLD."format" IS DISTINCT FROM (NEW."format") OR OLD."honor_code_id" IS DISTINCT FROM (NEW."honor_code_id") OR OLD."id" IS DISTINCT FROM (NEW."id") OR OLD."level" IS DISTINCT FROM (NEW."level") OR OLD."max_attempts" IS DISTINCT FROM (NEW."max_attempts") OR OLD."message_preset_id" IS DISTINCT FROM (NEW."message_preset_id") OR OLD."objective" IS DISTINCT FROM (NEW."objective") OR OLD."owner_id" IS DISTINCT FROM (NEW."owner_id") OR OLD."passing_point" IS DISTINCT FROM (NEW."passing_point") OR OLD."preview_url" IS DISTINCT FROM (NEW."preview_url") OR OLD."published" IS DISTINCT FROM (NEW."published") OR OLD."thumbnail" IS DISTINCT FROM (NEW."thumbnail") OR OLD."title" IS DISTINCT FROM (NEW."title") OR OLD."verification_required" IS DISTINCT FROM (NEW."verification_required"))', func='INSERT INTO "course_courseevent" ("audience", "created", "description", "duration", "effort_hours", "faq_id", "featured", "format", "honor_code_id", "id", "level", "max_attempts", "message_preset_id", "modified", "objective", "owner_id", "passing_point", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "preview_url", "published", "thumbnail", "title", "verification_required") VALUES (NEW."audience", NEW."created", NEW."description", NEW."duration", NEW."effort_hours", NEW."faq_id", NEW."featured", NEW."format", NEW."honor_code_id", NEW."id", NEW."level", NEW."max_attempts", NEW."message_preset_id", NEW."modified", NEW."objective", NEW."owner_id", NEW."passing_point", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."preview_url", NEW."published", NEW."thumbnail", NEW."title", NEW."verification_required"); RETURN NULL;', hash='f47ae042c6e7e7a10354192d997e81b5934e029b', operation='UPDATE', pgid='pgtrigger_update_update_8ff8f', table='course_course', when='AFTER')), + trigger=pgtrigger.compiler.Trigger(name='update_update', sql=pgtrigger.compiler.UpsertTriggerSql(condition='WHEN (OLD."appeal_deadline_days" IS DISTINCT FROM (NEW."appeal_deadline_days") OR OLD."audience" IS DISTINCT FROM (NEW."audience") OR OLD."confirm_due_days" IS DISTINCT FROM (NEW."confirm_due_days") OR OLD."description" IS DISTINCT FROM (NEW."description") OR OLD."duration" IS DISTINCT FROM (NEW."duration") OR OLD."effort_hours" IS DISTINCT FROM (NEW."effort_hours") OR OLD."faq_id" IS DISTINCT FROM (NEW."faq_id") OR OLD."featured" IS DISTINCT FROM (NEW."featured") OR OLD."format" IS DISTINCT FROM (NEW."format") OR OLD."grade_due_days" IS DISTINCT FROM (NEW."grade_due_days") OR OLD."honor_code_id" IS DISTINCT FROM (NEW."honor_code_id") OR OLD."id" IS DISTINCT FROM (NEW."id") OR OLD."level" IS DISTINCT FROM (NEW."level") OR OLD."max_attempts" IS DISTINCT FROM (NEW."max_attempts") OR OLD."message_preset_id" IS DISTINCT FROM (NEW."message_preset_id") OR OLD."objective" IS DISTINCT FROM (NEW."objective") OR OLD."owner_id" IS DISTINCT FROM (NEW."owner_id") OR OLD."passing_point" IS DISTINCT FROM (NEW."passing_point") OR OLD."preview_url" IS DISTINCT FROM (NEW."preview_url") OR OLD."published" IS DISTINCT FROM (NEW."published") OR OLD."thumbnail" IS DISTINCT FROM (NEW."thumbnail") OR OLD."title" IS DISTINCT FROM (NEW."title") OR OLD."verification_required" IS DISTINCT FROM (NEW."verification_required"))', func='INSERT INTO "course_courseevent" ("appeal_deadline_days", "audience", "confirm_due_days", "created", "description", "duration", "effort_hours", "faq_id", "featured", "format", "grade_due_days", "honor_code_id", "id", "level", "max_attempts", "message_preset_id", "modified", "objective", "owner_id", "passing_point", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "preview_url", "published", "thumbnail", "title", "verification_required") VALUES (NEW."appeal_deadline_days", NEW."audience", NEW."confirm_due_days", NEW."created", NEW."description", NEW."duration", NEW."effort_hours", NEW."faq_id", NEW."featured", NEW."format", NEW."grade_due_days", NEW."honor_code_id", NEW."id", NEW."level", NEW."max_attempts", NEW."message_preset_id", NEW."modified", NEW."objective", NEW."owner_id", NEW."passing_point", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."preview_url", NEW."published", NEW."thumbnail", NEW."title", NEW."verification_required"); RETURN NULL;', hash='390a525812f48a2d95f11b97e1ce9d1f567c27e1', operation='UPDATE', pgid='pgtrigger_update_update_8ff8f', table='course_course', when='AFTER')), ), pgtrigger.migrations.AddTrigger( model_name='course', - trigger=pgtrigger.compiler.Trigger(name='delete_delete', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "course_courseevent" ("audience", "created", "description", "duration", "effort_hours", "faq_id", "featured", "format", "honor_code_id", "id", "level", "max_attempts", "message_preset_id", "modified", "objective", "owner_id", "passing_point", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "preview_url", "published", "thumbnail", "title", "verification_required") VALUES (OLD."audience", OLD."created", OLD."description", OLD."duration", OLD."effort_hours", OLD."faq_id", OLD."featured", OLD."format", OLD."honor_code_id", OLD."id", OLD."level", OLD."max_attempts", OLD."message_preset_id", OLD."modified", OLD."objective", OLD."owner_id", OLD."passing_point", _pgh_attach_context(), NOW(), \'delete\', OLD."id", OLD."preview_url", OLD."published", OLD."thumbnail", OLD."title", OLD."verification_required"); RETURN NULL;', hash='2d9743409281694ed38d213212c6c0d5697ce7bf', operation='DELETE', pgid='pgtrigger_delete_delete_36642', table='course_course', when='AFTER')), + trigger=pgtrigger.compiler.Trigger(name='delete_delete', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "course_courseevent" ("appeal_deadline_days", "audience", "confirm_due_days", "created", "description", "duration", "effort_hours", "faq_id", "featured", "format", "grade_due_days", "honor_code_id", "id", "level", "max_attempts", "message_preset_id", "modified", "objective", "owner_id", "passing_point", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "preview_url", "published", "thumbnail", "title", "verification_required") VALUES (OLD."appeal_deadline_days", OLD."audience", OLD."confirm_due_days", OLD."created", OLD."description", OLD."duration", OLD."effort_hours", OLD."faq_id", OLD."featured", OLD."format", OLD."grade_due_days", OLD."honor_code_id", OLD."id", OLD."level", OLD."max_attempts", OLD."message_preset_id", OLD."modified", OLD."objective", OLD."owner_id", OLD."passing_point", _pgh_attach_context(), NOW(), \'delete\', OLD."id", OLD."preview_url", OLD."published", OLD."thumbnail", OLD."title", OLD."verification_required"); RETURN NULL;', hash='5d8e0b26f6fa0ecd69fd5a5f2e3ac07b122e02a9', operation='DELETE', pgid='pgtrigger_delete_delete_36642', table='course_course', when='AFTER')), ), pgtrigger.migrations.AddTrigger( model_name='course', diff --git a/core/apps/course/models.py b/core/apps/course/models.py index ed50fde..4366285 100644 --- a/core/apps/course/models.py +++ b/core/apps/course/models.py @@ -45,8 +45,8 @@ from apps.assignment.models import Assignment from apps.assignment.models import Grade as AssignmentGrade from apps.common.error import ErrorCode -from apps.common.models import AttemptMixin, LearningObjectMixin, OrderableMixin, TimeStampedMixin -from apps.common.util import AccessDate, ModeChoices, OtpTokenDict, issue_active_context, track_fields +from apps.common.models import AttemptMixin, GradeWorkflowMixin, LearningObjectMixin, OrderableMixin, TimeStampedMixin +from apps.common.util import AccessDate, GradingDate, ModeChoices, OtpTokenDict, issue_active_context, track_fields from apps.competency.models import Certificate, CertificateAward, CertificateAwardDataDict from apps.content.models import Media from apps.course.trigger import course_create_grading_policy, lesson_media_unifier @@ -79,6 +79,7 @@ class SessionDict(TypedDict): access_date: AccessDate + grading_date: GradingDate course: Course engagement: NotRequired[Engagement] otp_token: NotRequired[str] @@ -105,7 +106,7 @@ def save(self, *args, **kwargs): @pghistory.track() -class Course(LearningObjectMixin): +class Course(LearningObjectMixin, GradeWorkflowMixin): class LevelChoices(TextChoices): BEGINNER = "beginner", _("Beginner") INTERMEDIATE = "intermediate", _("Intermediate") @@ -168,7 +169,7 @@ async def get_session(cls, *, course_id: str, learner_id: str, access_date: Acce ) course.grading_criteria = await course.grading_policy.grading_criteria(access_date) - session = SessionDict(access_date=access_date, course=course) + session = SessionDict(access_date=access_date, grading_date=course.get_grading_date(access_date), course=course) for unit in [*course.lessons.all(), *course.course_surveys.all()]: unit.start_date = access_date["start"] + timedelta(days=unit.start_offset) @@ -590,7 +591,7 @@ def issue_context(self): return issue_active_context("course", self.course_id, self.pk) @classmethod - async def start(cls, *, course_id: str, learner_id: str, mode: ModeChoices): + async def start(cls, *, course_id: str, learner_id: str, lock: datetime, mode: ModeChoices): course = await Course.objects.aget(id=course_id) if course.verification_required: @@ -599,7 +600,7 @@ async def start(cls, *, course_id: str, learner_id: str, mode: ModeChoices): try: engagement = await Engagement.objects.acreate( - course_id=course_id, learner_id=learner_id, active=True, mode=mode + course_id=course_id, learner_id=learner_id, active=True, lock=lock, mode=mode ) except IntegrityError: raise ValueError(ErrorCode.ALREADY_EXISTS) diff --git a/core/apps/course/tests/factories.py b/core/apps/course/tests/factories.py index 4bd0c56..afeed95 100644 --- a/core/apps/course/tests/factories.py +++ b/core/apps/course/tests/factories.py @@ -7,7 +7,7 @@ from apps.account.tests.factories import UserFactory from apps.assignment.tests.factories import AssignmentFactory -from apps.common.tests.factories import LearningObjectFactory +from apps.common.tests.factories import GradeWorkflowFactory, LearningObjectFactory from apps.competency.tests.factories import CertificateFactory from apps.content.tests.factories import MediaFactory from apps.course.models import ( @@ -44,7 +44,7 @@ class Meta: skip_postgeneration_save = True -class CourseFactory(LearningObjectFactory[Course]): +class CourseFactory(LearningObjectFactory[Course], GradeWorkflowFactory[Course]): passing_point = FactoryField("choice", items=[60, 80]) max_attempts = FactoryField("choice", items=[1, 2]) verification_required = True diff --git a/core/apps/discussion/api/schema.py b/core/apps/discussion/api/schema.py index e5a9e94..6d2fc04 100644 --- a/core/apps/discussion/api/schema.py +++ b/core/apps/discussion/api/schema.py @@ -25,13 +25,6 @@ class DiscussionSchema(LearningObjectMixinSchema): class DiscussionQuestionSchema(Schema): - class DiscussionPointRequirementsSchema(Schema): - post: Annotated[int, Field(None)] - reply: Annotated[int, Field(None)] - tutor_assessment: Annotated[int, Field(None)] - post_min_characters: Annotated[int, Field(None)] - reply_min_characters: Annotated[int, Field(None)] - id: int directive: str supplement: str @@ -55,13 +48,14 @@ class DiscussionAttemptSchema(AttemptMixinSchema): class DiscussionEarnedDetailsSchema(Schema): post: int reply: int - tutor_assessment: int + tutor_assessment: int | None -class DiscussionGradeSchema(GradeFieldMixinSchema, TimeStampedMixinSchema): - class DiscussionFeedbackSchema(Schema): - tutor_assessment: str +class DiscussionFeedbackSchema(Schema): + tutor_assessment: str = "" + +class DiscussionGradeSchema(GradeFieldMixinSchema, TimeStampedMixinSchema): id: int # override earned_details: DiscussionEarnedDetailsSchema diff --git a/core/apps/discussion/api/v1.py b/core/apps/discussion/api/v1.py index 8451956..5f29920 100644 --- a/core/apps/discussion/api/v1.py +++ b/core/apps/discussion/api/v1.py @@ -38,7 +38,11 @@ async def get_session(request: HttpRequest, id: str): @access_date("discussion", "discussion") async def start_attempt(request: HttpRequest, id: str): return await Attempt.start( - discussion_id=id, learner_id=request.auth, context=request.active_context, mode=request.access_mode + discussion_id=id, + learner_id=request.auth, + lock=request.access_date["end"], + context=request.active_context, + mode=request.access_mode, ) diff --git a/core/apps/discussion/migrations/0001_initial.py b/core/apps/discussion/migrations/0001_initial.py index e27d662..624b204 100644 --- a/core/apps/discussion/migrations/0001_initial.py +++ b/core/apps/discussion/migrations/0001_initial.py @@ -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.db.models.deletion @@ -54,6 +54,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')), @@ -179,6 +180,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')), @@ -339,15 +341,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 "discussion_attemptevent" ("active", "context", "discussion_id", "id", "learner_id", "mode", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "question_id", "retry", "started") VALUES (NEW."active", NEW."context", NEW."discussion_id", NEW."id", NEW."learner_id", NEW."mode", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."question_id", NEW."retry", NEW."started"); RETURN NULL;', hash='0d46845e7851c82fcd70d20e8735e894f1550364', operation='INSERT', pgid='pgtrigger_insert_insert_c592d', table='discussion_attempt', when='AFTER')), + trigger=pgtrigger.compiler.Trigger(name='insert_insert', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "discussion_attemptevent" ("active", "context", "discussion_id", "id", "learner_id", "lock", "mode", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "question_id", "retry", "started") VALUES (NEW."active", NEW."context", NEW."discussion_id", 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='1d4074578689732f994af0dd4192acc4648ff9e2', operation='INSERT', pgid='pgtrigger_insert_insert_c592d', table='discussion_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 "discussion_attemptevent" ("active", "context", "discussion_id", "id", "learner_id", "mode", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "question_id", "retry", "started") VALUES (NEW."active", NEW."context", NEW."discussion_id", NEW."id", NEW."learner_id", NEW."mode", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."question_id", NEW."retry", NEW."started"); RETURN NULL;', hash='95ef4e3333085a7128756233a7e58c4a5f7bde79', operation='UPDATE', pgid='pgtrigger_update_update_7f758', table='discussion_attempt', when='AFTER')), + trigger=pgtrigger.compiler.Trigger(name='update_update', sql=pgtrigger.compiler.UpsertTriggerSql(condition='WHEN (OLD.* IS DISTINCT FROM NEW.*)', func='INSERT INTO "discussion_attemptevent" ("active", "context", "discussion_id", "id", "learner_id", "lock", "mode", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "question_id", "retry", "started") VALUES (NEW."active", NEW."context", NEW."discussion_id", 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='a9d6e0b0789b6a98f2f2287913a50100f89d8852', operation='UPDATE', pgid='pgtrigger_update_update_7f758', table='discussion_attempt', when='AFTER')), ), pgtrigger.migrations.AddTrigger( model_name='attempt', - trigger=pgtrigger.compiler.Trigger(name='delete_delete', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "discussion_attemptevent" ("active", "context", "discussion_id", "id", "learner_id", "mode", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "question_id", "retry", "started") VALUES (OLD."active", OLD."context", OLD."discussion_id", OLD."id", OLD."learner_id", OLD."mode", _pgh_attach_context(), NOW(), \'delete\', OLD."id", OLD."question_id", OLD."retry", OLD."started"); RETURN NULL;', hash='785dd072e5f98a9ced87e44aa6bdbde75a2c73a8', operation='DELETE', pgid='pgtrigger_delete_delete_88e52', table='discussion_attempt', when='AFTER')), + trigger=pgtrigger.compiler.Trigger(name='delete_delete', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "discussion_attemptevent" ("active", "context", "discussion_id", "id", "learner_id", "lock", "mode", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "question_id", "retry", "started") VALUES (OLD."active", OLD."context", OLD."discussion_id", 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='67f22e65f2b0563c8cd8f3a2e124629feeeb7f72', operation='DELETE', pgid='pgtrigger_delete_delete_88e52', table='discussion_attempt', when='AFTER')), ), pgtrigger.migrations.AddTrigger( model_name='attempt', diff --git a/core/apps/discussion/models.py b/core/apps/discussion/models.py index 64d3b5d..da27313 100644 --- a/core/apps/discussion/models.py +++ b/core/apps/discussion/models.py @@ -223,7 +223,7 @@ class Meta: max_attempts: int # annotated @classmethod - async def start(cls, *, discussion_id: str, learner_id: str, context: str, mode: ModeChoices): + async def start(cls, *, discussion_id: str, learner_id: str, lock: datetime, context: str, mode: ModeChoices): discussion = await Discussion.objects.aget(id=discussion_id) if discussion.verification_required: @@ -237,6 +237,7 @@ async def start(cls, *, discussion_id: str, learner_id: str, context: str, mode: attempt = await Attempt.objects.acreate( discussion=discussion, learner_id=learner_id, + lock=lock, context=context, active=True, started=timezone.now() + timedelta(seconds=1), @@ -438,24 +439,33 @@ class Meta(TimeStampedMixin.Meta, GradeFieldMixin.Meta): pk: int pgh_event_model: type[Model] - 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): question = self.attempt.question post_count = await self.attempt.post_count() - # existing grade - tutor_assessment_point = (self.earned_details or {}).get("tutor_assessment", 0) - - # update grade - self.earned_details = { + default_details: dict[str, int | None] = { "post": min(post_count["valid_post"], question.post_point), "reply": min(post_count["valid_reply"], question.reply_point), - "tutor_assessment": min(tutor_assessment_point, question.tutor_assessment_point), + "tutor_assessment": None, } + # existing grade + tutor_assessment_point = (self.earned_details or {}).get("tutor_assessment", 0) + default_details["tutor_assessment"] = ( + min(tutor_assessment_point, question.tutor_assessment_point) if tutor_assessment_point is not None else None + ) + self.earned_details = default_details + + 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 = question.point - self.earned_point = sum(self.earned_details.values()) + self.earned_point = sum(v for v in self.earned_details.values() if v is not None) self.score = self.earned_point * 100.0 / self.possible_point if self.possible_point else 0.0 self.passed = self.score >= (self.attempt.discussion.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() diff --git a/core/apps/discussion/tests/factories.py b/core/apps/discussion/tests/factories.py index aa62726..c9281d4 100644 --- a/core/apps/discussion/tests/factories.py +++ b/core/apps/discussion/tests/factories.py @@ -84,6 +84,7 @@ class AttemptFactory(DjangoModelFactory[Attempt]): learner = SubFactory(UserFactory) started = LazyFunction(lambda: timezone.now()) question = LazyAttribute(lambda o: async_to_sync(o.discussion.question_pool.select_question)()) + lock = LazyFunction(timezone.now) active = True class Meta: diff --git a/core/apps/exam/api/v1.py b/core/apps/exam/api/v1.py index e057774..0473a29 100644 --- a/core/apps/exam/api/v1.py +++ b/core/apps/exam/api/v1.py @@ -24,7 +24,11 @@ async def get_session(request: HttpRequest, id: str): @access_date("exam", "exam") async def start_attempt(request: HttpRequest, id: str): return await Attempt.start( - exam_id=id, learner_id=request.auth, context=request.active_context, mode=request.access_mode + exam_id=id, + learner_id=request.auth, + lock=request.access_date["end"], + context=request.active_context, + mode=request.access_mode, ) diff --git a/core/apps/exam/migrations/0001_initial.py b/core/apps/exam/migrations/0001_initial.py index fde618b..b2b385f 100644 --- a/core/apps/exam/migrations/0001_initial.py +++ b/core/apps/exam/migrations/0001_initial.py @@ -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 @@ -26,6 +26,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')), @@ -74,6 +75,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')), @@ -385,15 +387,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 "exam_attemptevent" ("active", "context", "exam_id", "id", "learner_id", "mode", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "retry", "started") VALUES (NEW."active", NEW."context", NEW."exam_id", NEW."id", NEW."learner_id", NEW."mode", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."retry", NEW."started"); RETURN NULL;', hash='de127c1af3300fbd986094463f2c96da0d9a34c8', operation='INSERT', pgid='pgtrigger_insert_insert_d95a6', table='exam_attempt', when='AFTER')), + trigger=pgtrigger.compiler.Trigger(name='insert_insert', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "exam_attemptevent" ("active", "context", "exam_id", "id", "learner_id", "lock", "mode", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "retry", "started") VALUES (NEW."active", NEW."context", NEW."exam_id", NEW."id", NEW."learner_id", NEW."lock", NEW."mode", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."retry", NEW."started"); RETURN NULL;', hash='7dc048c866365e1c045c3273e5d9457c64af9fd7', operation='INSERT', pgid='pgtrigger_insert_insert_d95a6', table='exam_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 "exam_attemptevent" ("active", "context", "exam_id", "id", "learner_id", "mode", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "retry", "started") VALUES (NEW."active", NEW."context", NEW."exam_id", NEW."id", NEW."learner_id", NEW."mode", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."retry", NEW."started"); RETURN NULL;', hash='dd3396dc65ce8b9559a9b48101e6c60cc336d165', operation='UPDATE', pgid='pgtrigger_update_update_3268a', table='exam_attempt', when='AFTER')), + trigger=pgtrigger.compiler.Trigger(name='update_update', sql=pgtrigger.compiler.UpsertTriggerSql(condition='WHEN (OLD.* IS DISTINCT FROM NEW.*)', func='INSERT INTO "exam_attemptevent" ("active", "context", "exam_id", "id", "learner_id", "lock", "mode", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "retry", "started") VALUES (NEW."active", NEW."context", NEW."exam_id", NEW."id", NEW."learner_id", NEW."lock", NEW."mode", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."retry", NEW."started"); RETURN NULL;', hash='f06b143888b64a87108dd0192b6de6848ff4ba3f', operation='UPDATE', pgid='pgtrigger_update_update_3268a', table='exam_attempt', when='AFTER')), ), pgtrigger.migrations.AddTrigger( model_name='attempt', - trigger=pgtrigger.compiler.Trigger(name='delete_delete', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "exam_attemptevent" ("active", "context", "exam_id", "id", "learner_id", "mode", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "retry", "started") VALUES (OLD."active", OLD."context", OLD."exam_id", OLD."id", OLD."learner_id", OLD."mode", _pgh_attach_context(), NOW(), \'delete\', OLD."id", OLD."retry", OLD."started"); RETURN NULL;', hash='9f28d3a773608a78c600c26bd459cce058ea9399', operation='DELETE', pgid='pgtrigger_delete_delete_9811e', table='exam_attempt', when='AFTER')), + trigger=pgtrigger.compiler.Trigger(name='delete_delete', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "exam_attemptevent" ("active", "context", "exam_id", "id", "learner_id", "lock", "mode", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "retry", "started") VALUES (OLD."active", OLD."context", OLD."exam_id", OLD."id", OLD."learner_id", OLD."lock", OLD."mode", _pgh_attach_context(), NOW(), \'delete\', OLD."id", OLD."retry", OLD."started"); RETURN NULL;', hash='b5c366e9638a0bbce3b6337f3b03e1d0bc2f75e9', operation='DELETE', pgid='pgtrigger_delete_delete_9811e', table='exam_attempt', when='AFTER')), ), pgtrigger.migrations.AddTrigger( model_name='attempt', diff --git a/core/apps/exam/models.py b/core/apps/exam/models.py index 9141039..57b8796 100644 --- a/core/apps/exam/models.py +++ b/core/apps/exam/models.py @@ -234,6 +234,22 @@ async def analyze_answers(self, question_ids: Sequence[int]): return await sync_to_async(SubmissionDocument.analyze_answers)(question_ids=question_ids) + @classmethod + async def regrade_question(cls, *, exam_id: str, question_id: int, from_answers: list[str], to_answers: list[str]): + affected = set(from_answers).symmetric_difference(set(to_answers)) + attempts = [ + a + async for a in Attempt.objects.select_related("grade").filter( + exam_id=exam_id, + questions__id=question_id, + active=True, + **{f"submission__answers__{question_id}__in": list(affected)}, + ) + ] + for attempt in attempts: + if hasattr(attempt, "grade"): + await attempt.grade.regrade() + @pghistory.track() class Attempt(AttemptMixin): @@ -266,7 +282,7 @@ def saved_answers(self): return self.tempanswer.answers @classmethod - async def start(cls, *, exam_id: str, learner_id: str, context: str, mode: ModeChoices): + async def start(cls, *, exam_id: str, learner_id: str, lock: datetime, context: str, mode: ModeChoices): exam = await Exam.objects.prefetch_related("question_pool__questions").aget(id=exam_id) if exam.verification_required: @@ -280,6 +296,7 @@ async def start(cls, *, exam_id: str, learner_id: str, context: str, mode: ModeC attempt = await Attempt.objects.acreate( exam=exam, learner_id=learner_id, + lock=lock, context=context, active=True, started=timezone.now() + timedelta(seconds=1), @@ -399,6 +416,8 @@ class Meta(GradeFieldMixin.Meta, TimeStampedMixin.Meta): pk: int pgh_event_model: type[Model] attempt_id: int + analysis: dict[str, dict[str, int]] + grading_date: GradingDate 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()] @@ -438,6 +457,8 @@ async def grade(self, earned_existing: dict[str, int | None] | None = None, grad 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.0 self.passed = self.score >= (self.attempt.exam.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() diff --git a/core/apps/exam/tests/factories.py b/core/apps/exam/tests/factories.py index fe274cf..83b68f7 100644 --- a/core/apps/exam/tests/factories.py +++ b/core/apps/exam/tests/factories.py @@ -129,6 +129,7 @@ class AttemptFactory(DjangoModelFactory[Attempt]): exam = SubFactory(ExamFactory) learner = SubFactory(UserFactory) started = LazyFunction(lambda: timezone.now()) + lock = LazyFunction(timezone.now) active = True class Meta: diff --git a/core/apps/learning/api/access_control.py b/core/apps/learning/api/access_control.py index 8357ad2..2c64b8c 100644 --- a/core/apps/learning/api/access_control.py +++ b/core/apps/learning/api/access_control.py @@ -12,13 +12,18 @@ from apps.course.models import Course from apps.learning.models import ENROLLABLE_MODEL_MAP, Enrollment from apps.quiz.models import Quiz +from apps.tutor.models import TUTORING_MODEL_MAP, Allocation log = logging.getLogger(__name__) +SPECIAL_ACCESS_TIME = timedelta(hours=1) + + def access_date(app_label, model, *, id_field: str = "id"): def decorator(func): # openapi query param + openapi_query_param(func=func, name="mode", schema_type="string", required=False, nullable=False) openapi_query_param(func=func, name="media", schema_type="string", required=False, nullable=False) # currently only quiz is allowed to be inlined @@ -60,17 +65,30 @@ async def wrapper(request: HttpRequest, *args, **kwargs): public_access = await PublicAccessMedia.get_access_date(media_id=media_id) if not (enrollment or public_access): - if "editor" in request.roles: - ContentModel = ENROLLABLE_MODEL_MAP[(app_label, model)] - # Check ownership - if await ContentModel.objects.filter(id=content_id, owner_id=user_id).aexists(): - # grant 1 hour temporary access to editor + # Check preview and special access + if "preview" == request.GET.get("mode"): + has_special_access = False + + # editor requires ownership + if "editor" in request.roles: + ContentModel = ENROLLABLE_MODEL_MAP[(app_label, model)] + if await ContentModel.objects.filter(id=content_id, owner_id=user_id).aexists(): + has_special_access = True + + # tutor rquires allocations + elif "tutor" in request.roles: + ContentModel = TUTORING_MODEL_MAP[(app_label, model)] + if await Allocation.objects.filter( + tutor_id=user_id, active=True, content_id=content_id + ).aexists(): + has_special_access = True + + if has_special_access: + # grant temporary access to tutor now = timezone.now() - accessible = AccessDate( - start=now, end=now + timedelta(hours=1), archive=now + timedelta(hours=1) - ) + expire = now + SPECIAL_ACCESS_TIME + accessible = AccessDate(start=now, end=expire, archive=expire) request.access_date = accessible - request.active_context = "" return await func(request, *args, **kwargs) # more favorable access date between enrollment and public access diff --git a/core/apps/learning/api/v1.py b/core/apps/learning/api/v1.py index 150354d..a64459c 100644 --- a/core/apps/learning/api/v1.py +++ b/core/apps/learning/api/v1.py @@ -26,7 +26,6 @@ async def get_enrolled( page: Annotated[int, functions.Query(1, ge=1)], size: Annotated[int, functions.Query(settings.DEFAULT_PAGINATION_SIZE, gte=1, le=100)], ): - # Custom pagination with generic relationship return await Enrollment.get_enrolled(user_id=request.auth, page=page, size=size) diff --git a/core/apps/learning/management/commands/setup_demo_data.py b/core/apps/learning/management/commands/setup_demo_data.py index eeea4da..a5cd92e 100644 --- a/core/apps/learning/management/commands/setup_demo_data.py +++ b/core/apps/learning/management/commands/setup_demo_data.py @@ -16,14 +16,18 @@ from mimesis.plugins.factory import FactoryField from apps.account.models import User +from apps.assignment.models import Assignment from apps.content.models import Media, MediaQuiz, PublicAccessMedia -from apps.content.tests.factories import MediaFactory +from apps.content.tests.factories import _REAL_DATA, MediaFactory +from apps.discussion.models import Discussion +from apps.exam.models import Exam from apps.learning.models import CatalogItem from apps.learning.tests.factories import CatalogFactory, CohortCatalogFactory, UserCatalogFactory from apps.operation.tests.factories import AnnouncementFactory, InquiryFactory, PolicyFactory from apps.partner.models import CohortMember, Group from apps.partner.tests.factories import CohortFactory, MemberFactory, PartnerFactory from apps.quiz.models import Quiz +from apps.tutor.models import Allocation class Command(BaseCommand): @@ -79,8 +83,6 @@ def handle(self, *args, **options): # all the rest video content - from apps.content.tests.factories import _REAL_DATA - remains = len(_REAL_DATA) - Media.objects.filter(format=Media.MediaFormatChoices.VIDEO).count() if remains > 0: new_medias = MediaFactory.create_batch( @@ -137,6 +139,12 @@ def handle(self, *args, **options): with FactoryField.override_locale(settings.DEFAULT_LANGUAGE): PolicyFactory.create_batch(5) + # tutor allocation + exams = [Allocation(tutor=test_user, content=exam) for exam in Exam.objects.all()] + assignments = [Allocation(tutor=test_user, content=assignment) for assignment in Assignment.objects.all()] + discussions = [Allocation(tutor=test_user, content=discussion) for discussion in Discussion.objects.all()] + Allocation.objects.bulk_create(exams + assignments + discussions, ignore_conflicts=True) + @staticmethod def create_public_catalog(name: str, media_size: int): public_catalog = CatalogFactory.create( diff --git a/core/apps/learning/migrations/0001_initial.py b/core/apps/learning/migrations/0001_initial.py index 23c4756..f7367d8 100644 --- a/core/apps/learning/migrations/0001_initial.py +++ b/core/apps/learning/migrations/0001_initial.py @@ -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 django.utils.timezone diff --git a/core/apps/operation/admin.py b/core/apps/operation/admin.py index de64932..b42dfe9 100644 --- a/core/apps/operation/admin.py +++ b/core/apps/operation/admin.py @@ -140,11 +140,6 @@ class InquiryResponseAdmin(HiddenModelAdmin[InquiryResponse]): @admin.register(Appeal) class AppealAdmin(ModelAdmin[Appeal]): - class AppealForm(BooleanDatetimeFormMixin): - boolean_datetime_fields = ["closed"] - - form = AppealForm - class AttachmentInline(TabularInline[Attachment]): model = Appeal.attachments.through verbose_name = _("Attachments") diff --git a/core/apps/operation/api/schema.py b/core/apps/operation/api/schema.py index bd7262b..babe169 100644 --- a/core/apps/operation/api/schema.py +++ b/core/apps/operation/api/schema.py @@ -107,7 +107,6 @@ class AppealSchema(TimeStampedMixinSchema): question_id: int explanation: str review: str - closed: datetime | None path: str @staticmethod diff --git a/core/apps/operation/migrations/0001_initial.py b/core/apps/operation/migrations/0001_initial.py index f10aed9..b04246a 100644 --- a/core/apps/operation/migrations/0001_initial.py +++ b/core/apps/operation/migrations/0001_initial.py @@ -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 django.contrib.postgres.fields import django.db.models.deletion @@ -87,7 +87,6 @@ class Migration(migrations.Migration): ('modified', models.DateTimeField(auto_now=True, db_index=True, verbose_name='Modified')), ('explanation', models.TextField(verbose_name='Explanation')), ('review', models.TextField(blank=True, default='', verbose_name='Review')), - ('closed', models.DateTimeField(blank=True, null=True, verbose_name='Closed')), ('path', models.CharField(blank=True, default='', max_length=500, verbose_name='Path')), ('question_id', models.IntegerField(verbose_name='Question ID')), ], @@ -108,7 +107,6 @@ class Migration(migrations.Migration): ('modified', models.DateTimeField(auto_now=True, verbose_name='Modified')), ('explanation', models.TextField(verbose_name='Explanation')), ('review', models.TextField(blank=True, default='', verbose_name='Review')), - ('closed', models.DateTimeField(blank=True, null=True, verbose_name='Closed')), ('path', models.CharField(blank=True, default='', max_length=500, verbose_name='Path')), ('question_id', models.IntegerField(verbose_name='Question ID')), ], @@ -1258,21 +1256,25 @@ class Migration(migrations.Migration): model_name='attachment', trigger=pgtrigger.compiler.Trigger(name='delete_delete', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "operation_attachmentevent" ("created", "deleted", "file", "hash", "id", "mime_type", "modified", "owner_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "size") VALUES (OLD."created", OLD."deleted", OLD."file", OLD."hash", OLD."id", OLD."mime_type", OLD."modified", OLD."owner_id", _pgh_attach_context(), NOW(), \'delete\', OLD."id", OLD."size"); RETURN NULL;', hash='b0f21ebd93b944dc5a7f7a10be87ce6d4c3d3497', operation='DELETE', pgid='pgtrigger_delete_delete_b66a6', table='operation_attachment', when='AFTER')), ), + migrations.AddIndex( + model_name='appeal', + index=models.Index(fields=['question_type', 'question_id'], name='operation_a_questio_0c69e6_idx'), + ), migrations.AddConstraint( model_name='appeal', constraint=models.UniqueConstraint(fields=('question_type', 'question_id', 'learner'), name='operation_appeal_quid_quty_le_uniq'), ), pgtrigger.migrations.AddTrigger( model_name='appeal', - trigger=pgtrigger.compiler.Trigger(name='insert_insert', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "operation_appealevent" ("closed", "created", "explanation", "id", "learner_id", "modified", "path", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "question_id", "question_type_id", "review") VALUES (NEW."closed", 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"); RETURN NULL;', hash='07dc4960990fcedf7f9620a936cce816acbd698e', operation='INSERT', pgid='pgtrigger_insert_insert_c7b97', table='operation_appeal', when='AFTER')), + 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") 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"); RETURN NULL;', hash='ac8a43c03a7924854832f25c2d1bf89420d4138a', 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."closed" IS DISTINCT FROM (NEW."closed") OR 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"))', func='INSERT INTO "operation_appealevent" ("closed", "created", "explanation", "id", "learner_id", "modified", "path", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "question_id", "question_type_id", "review") VALUES (NEW."closed", 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"); RETURN NULL;', hash='2c23232d27ee0da91c7f196c32ca8e150e0fbf01', operation='UPDATE', pgid='pgtrigger_update_update_3fe73', table='operation_appeal', when='AFTER')), + 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"))', 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") 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"); RETURN NULL;', hash='00ca54fa87f8eaf2dfeef72eb1487b7eac9adcfc', 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" ("closed", "created", "explanation", "id", "learner_id", "modified", "path", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "question_id", "question_type_id", "review") VALUES (OLD."closed", 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"); RETURN NULL;', hash='dcc8a44c3d2f096c700ab570834bc022b126d98b', operation='DELETE', pgid='pgtrigger_delete_delete_1853c', table='operation_appeal', when='AFTER')), + 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") 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"); RETURN NULL;', hash='f00f7a018e77653a57a9c86faf64ba22175c90a1', operation='DELETE', pgid='pgtrigger_delete_delete_1853c', table='operation_appeal', when='AFTER')), ), pgtrigger.migrations.AddTrigger( model_name='attachmentevent', diff --git a/core/apps/operation/models.py b/core/apps/operation/models.py index 8c52e4b..c3bcc28 100644 --- a/core/apps/operation/models.py +++ b/core/apps/operation/models.py @@ -472,13 +472,12 @@ def save(self, *args, **kwargs): ) -@track_fields("closed") +@track_fields("review") @pghistory.track() class Appeal(TimeStampedMixin, AttachmentMixin): learner = ForeignKey(User, CASCADE, verbose_name=_("Learner"), related_name="+") explanation = TextField(_("Explanation")) review = TextField(_("Review"), blank=True, default="") - closed = DateTimeField(_("Closed"), null=True, blank=True) path = CharField(_("Path"), max_length=500, default="", blank=True) limit_choices_to = {"model__in": ["question"]} @@ -494,6 +493,7 @@ class Meta(TimeStampedMixin.Meta, AttachmentMixin.Meta): fields=["question_type", "question_id", "learner"], name="operation_appeal_quid_quty_le_uniq" ) ] + indexes = [Index(fields=["question_type", "question_id"])] if TYPE_CHECKING: learner_id: str @@ -528,8 +528,8 @@ async def create( await appeal.update_attachments(files=files, owner_id=learner_id, content=appeal.explanation) return appeal - def on_closed_changed(self, old_value: datetime | None): - if self.closed: + def on_review_changed(self, old_value: str): + if not old_value and self.review: user_message_created.send( source=self, path=self.path, diff --git a/core/apps/partner/migrations/0001_initial.py b/core/apps/partner/migrations/0001_initial.py index 13733a4..422b0cd 100644 --- a/core/apps/partner/migrations/0001_initial.py +++ b/core/apps/partner/migrations/0001_initial.py @@ -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 django.db.models.deletion import pgtrigger.compiler diff --git a/core/apps/quiz/api/v1.py b/core/apps/quiz/api/v1.py index 2483a42..6cba4b9 100644 --- a/core/apps/quiz/api/v1.py +++ b/core/apps/quiz/api/v1.py @@ -23,7 +23,11 @@ async def get_session(request: HttpRequest, id: str): @access_date("quiz", "quiz") async def start_attempt(request: HttpRequest, id: str): return await Attempt.start( - quiz_id=id, learner_id=request.auth, context=request.active_context, mode=request.access_mode + quiz_id=id, + learner_id=request.auth, + lock=request.access_date["end"], + context=request.active_context, + mode=request.access_mode, ) diff --git a/core/apps/quiz/migrations/0001_initial.py b/core/apps/quiz/migrations/0001_initial.py index b4840cd..a0d4dac 100644 --- a/core/apps/quiz/migrations/0001_initial.py +++ b/core/apps/quiz/migrations/0001_initial.py @@ -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.util import django.contrib.postgres.fields @@ -26,6 +26,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')), @@ -194,6 +195,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')), @@ -382,15 +384,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 "quiz_attemptevent" ("active", "context", "id", "learner_id", "mode", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "quiz_id", "retry", "started") VALUES (NEW."active", NEW."context", NEW."id", NEW."learner_id", NEW."mode", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."quiz_id", NEW."retry", NEW."started"); RETURN NULL;', hash='975c7ce3d64d1ccb276250e020528303986cebc6', operation='INSERT', pgid='pgtrigger_insert_insert_db65b', table='quiz_attempt', when='AFTER')), + trigger=pgtrigger.compiler.Trigger(name='insert_insert', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "quiz_attemptevent" ("active", "context", "id", "learner_id", "lock", "mode", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "quiz_id", "retry", "started") VALUES (NEW."active", NEW."context", NEW."id", NEW."learner_id", NEW."lock", NEW."mode", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."quiz_id", NEW."retry", NEW."started"); RETURN NULL;', hash='7457692680e34381a5adb7b5861122ab067ade6b', operation='INSERT', pgid='pgtrigger_insert_insert_db65b', table='quiz_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 "quiz_attemptevent" ("active", "context", "id", "learner_id", "mode", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "quiz_id", "retry", "started") VALUES (NEW."active", NEW."context", NEW."id", NEW."learner_id", NEW."mode", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."quiz_id", NEW."retry", NEW."started"); RETURN NULL;', hash='47bc85779a76d44d2ac5cda8b1946b46c03841b4', operation='UPDATE', pgid='pgtrigger_update_update_7b6c0', table='quiz_attempt', when='AFTER')), + trigger=pgtrigger.compiler.Trigger(name='update_update', sql=pgtrigger.compiler.UpsertTriggerSql(condition='WHEN (OLD.* IS DISTINCT FROM NEW.*)', func='INSERT INTO "quiz_attemptevent" ("active", "context", "id", "learner_id", "lock", "mode", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "quiz_id", "retry", "started") VALUES (NEW."active", NEW."context", NEW."id", NEW."learner_id", NEW."lock", NEW."mode", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."quiz_id", NEW."retry", NEW."started"); RETURN NULL;', hash='7e2a121e02c55ad7b584f545f9efd56653c1905c', operation='UPDATE', pgid='pgtrigger_update_update_7b6c0', table='quiz_attempt', when='AFTER')), ), pgtrigger.migrations.AddTrigger( model_name='attempt', - trigger=pgtrigger.compiler.Trigger(name='delete_delete', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "quiz_attemptevent" ("active", "context", "id", "learner_id", "mode", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "quiz_id", "retry", "started") VALUES (OLD."active", OLD."context", OLD."id", OLD."learner_id", OLD."mode", _pgh_attach_context(), NOW(), \'delete\', OLD."id", OLD."quiz_id", OLD."retry", OLD."started"); RETURN NULL;', hash='68622659dc0eba5b20e531b8cac252759a3c68fa', operation='DELETE', pgid='pgtrigger_delete_delete_12426', table='quiz_attempt', when='AFTER')), + trigger=pgtrigger.compiler.Trigger(name='delete_delete', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "quiz_attemptevent" ("active", "context", "id", "learner_id", "lock", "mode", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "quiz_id", "retry", "started") VALUES (OLD."active", OLD."context", OLD."id", OLD."learner_id", OLD."lock", OLD."mode", _pgh_attach_context(), NOW(), \'delete\', OLD."id", OLD."quiz_id", OLD."retry", OLD."started"); RETURN NULL;', hash='9c599e6c2b9b037ff9b78908f7a5e6af412cb523', operation='DELETE', pgid='pgtrigger_delete_delete_12426', table='quiz_attempt', when='AFTER')), ), pgtrigger.migrations.AddTrigger( model_name='attempt', diff --git a/core/apps/quiz/models.py b/core/apps/quiz/models.py index b00e1cc..3b74b08 100644 --- a/core/apps/quiz/models.py +++ b/core/apps/quiz/models.py @@ -1,5 +1,5 @@ import random -from datetime import timedelta +from datetime import datetime, timedelta from typing import TYPE_CHECKING, NotRequired, Sequence, TypedDict import pghistory @@ -260,7 +260,7 @@ class Meta(TimeStampedMixin.Meta): _prefetched_objects_cache: dict[str, QuerySet[Question]] @classmethod - async def start(cls, *, quiz_id: str, learner_id: str, context: str, mode: ModeChoices): + async def start(cls, *, quiz_id: str, learner_id: str, lock: datetime, context: str, mode: ModeChoices): quiz = await Quiz.objects.prefetch_related("question_pool__questions").aget(id=quiz_id) questions = await quiz.question_pool.select_questions() await aprefetch_related_objects(questions, "attachments") # type: ignore @@ -269,6 +269,7 @@ async def start(cls, *, quiz_id: str, learner_id: str, context: str, mode: ModeC attempt = await Attempt.objects.acreate( quiz=quiz, learner_id=learner_id, + lock=lock, context=context, active=True, started=timezone.now() + timedelta(seconds=1), diff --git a/core/apps/quiz/tests/factories.py b/core/apps/quiz/tests/factories.py index 5157467..490a80e 100644 --- a/core/apps/quiz/tests/factories.py +++ b/core/apps/quiz/tests/factories.py @@ -93,6 +93,7 @@ class AttemptFactory(DjangoModelFactory[Attempt]): quiz = SubFactory(QuizFactory) learner = SubFactory(UserFactory) started = LazyFunction(lambda: timezone.now()) + lock = LazyFunction(timezone.now) active = True class Meta: diff --git a/core/apps/sso/migrations/0001_initial.py b/core/apps/sso/migrations/0001_initial.py index 80132a4..d55e70b 100644 --- a/core/apps/sso/migrations/0001_initial.py +++ b/core/apps/sso/migrations/0001_initial.py @@ -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.util import django.db.models.deletion diff --git a/core/apps/store/migrations/0001_initial.py b/core/apps/store/migrations/0001_initial.py index a9f3cfe..6fd251a 100644 --- a/core/apps/store/migrations/0001_initial.py +++ b/core/apps/store/migrations/0001_initial.py @@ -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 django.db.models.deletion import django.utils.timezone diff --git a/core/apps/studio/api/v1/assignment.py b/core/apps/studio/api/v1/assignment.py index 8d900fd..d83bd53 100644 --- a/core/apps/studio/api/v1/assignment.py +++ b/core/apps/studio/api/v1/assignment.py @@ -179,7 +179,7 @@ def create_new(): @editor_required() @track_editing(Assignment, id_field="id") async def delete_assignment(request: HttpRequest, id: str): - if await Attempt.objects.filter(assignment_id=id).exclude(mode=ModeChoices.PREVIEW).aexists(): + if await Attempt.objects.filter(assignment_id=id, mode=ModeChoices.NORMAL).aexists(): raise ValueError(ErrorCode.ATTEMPT_EXISTS) await Assignment.objects.filter(id=id, owner_id=request.auth, published__isnull=True).adelete() @@ -246,7 +246,7 @@ async def save_assignment_questions( @track_editing(Assignment, id_field="id") async def delete_assignment_quesion(request: HttpRequest, id: str, question_id: int): if await Attempt.objects.filter( - assignment_id=id, question=question_id, assignment__owner_id=request.auth + assignment_id=id, question=question_id, assignment__owner_id=request.auth, mode=ModeChoices.NORMAL ).aexists(): raise ValueError(ErrorCode.IN_USE) diff --git a/core/apps/studio/api/v1/course.py b/core/apps/studio/api/v1/course.py index 9b11343..b5536dd 100644 --- a/core/apps/studio/api/v1/course.py +++ b/core/apps/studio/api/v1/course.py @@ -18,6 +18,7 @@ ContentTypeSchema, FileSizeValidator, FileTypeValidator, + GradeWorkflowMixinSchema, LearningObjectMixinSchema, Schema, ) @@ -115,7 +116,7 @@ class CourseAssetsSpec(Schema): course_instructors: list[CourseInstructorSpec] -class CourseSpec(LearningObjectMixinSchema): +class CourseSpec(LearningObjectMixinSchema, GradeWorkflowMixinSchema): id: str objective: str preview_url: str | None @@ -152,6 +153,9 @@ class CourseSaveSpec(Schema): preview_url: HttpUrl effort_hours: int level: Course.LevelChoices + grade_due_days: int + appeal_deadline_days: int + confirm_due_days: int honor_code_id: int faq_id: int grading_policy: GradingPolicySpec @@ -243,7 +247,7 @@ def create_new(): @editor_required() @track_editing(Course, id_field="id") async def delete_course(request: HttpRequest, id: str): - if await Engagement.objects.filter(course_id=id).exclude(mode=ModeChoices.PREVIEW).aexists(): + if await Engagement.objects.filter(course_id=id, mode=ModeChoices.NORMAL).aexists(): raise ValueError(ErrorCode.ATTEMPT_EXISTS) await Course.objects.filter(id=id, owner_id=request.auth, published__isnull=True).adelete() diff --git a/core/apps/studio/api/v1/discussion.py b/core/apps/studio/api/v1/discussion.py index 6845a2b..acae8f1 100644 --- a/core/apps/studio/api/v1/discussion.py +++ b/core/apps/studio/api/v1/discussion.py @@ -132,7 +132,7 @@ def create_new(): @editor_required() @track_editing(Discussion, id_field="id") async def delete_discussion(request: HttpRequest, id: str): - if await Attempt.objects.filter(discussion_id=id).exclude(mode=ModeChoices.PREVIEW).aexists(): + if await Attempt.objects.filter(discussion_id=id, mode=ModeChoices.NORMAL).aexists(): raise ValueError(ErrorCode.ATTEMPT_EXISTS) await Discussion.objects.filter(id=id, owner_id=request.auth, published__isnull=True).adelete() @@ -201,7 +201,7 @@ async def save_discussion_questions( @track_editing(Discussion, id_field="id") async def delete_discussion_quesion(request: HttpRequest, id: str, question_id: int): if await Attempt.objects.filter( - discussion_id=id, question_id=question_id, discussion__owner_id=request.auth + discussion_id=id, question_id=question_id, discussion__owner_id=request.auth, mode=ModeChoices.NORMAL ).aexists(): raise ValueError(ErrorCode.IN_USE) diff --git a/core/apps/studio/api/v1/exam.py b/core/apps/studio/api/v1/exam.py index ac9451e..bb27989 100644 --- a/core/apps/studio/api/v1/exam.py +++ b/core/apps/studio/api/v1/exam.py @@ -152,7 +152,7 @@ def create_new(): @editor_required() @track_editing(Exam, id_field="id") async def delete_exam(request: HttpRequest, id: str): - if await Attempt.objects.filter(exam_id=id).exclude(mode=ModeChoices.PREVIEW).aexists(): + if await Attempt.objects.filter(exam_id=id, mode=ModeChoices.NORMAL).aexists(): raise ValueError(ErrorCode.ATTEMPT_EXISTS) await Exam.objects.filter(id=id, owner_id=request.auth, published__isnull=True).adelete() @@ -224,7 +224,9 @@ async def save_exam_questions( @editor_required() @track_editing(Exam, id_field="id") async def delete_exam_quesion(request: HttpRequest, id: str, question_id: int): - if await Attempt.objects.filter(exam_id=id, questions=question_id, exam__owner_id=request.auth).aexists(): + if await Attempt.objects.filter( + exam_id=id, questions=question_id, exam__owner_id=request.auth, mode=ModeChoices.NORMAL + ).aexists(): raise ValueError(ErrorCode.IN_USE) count, _ = await Question.objects.filter(id=question_id, pool__exam__id=id).adelete() diff --git a/core/apps/studio/api/v1/media.py b/core/apps/studio/api/v1/media.py index 8fe72b4..0a1e4e3 100644 --- a/core/apps/studio/api/v1/media.py +++ b/core/apps/studio/api/v1/media.py @@ -119,7 +119,7 @@ async def save_media( @editor_required() @track_editing(Media, id_field="id") async def delete_media(request: HttpRequest, id: str): - if await Watch.objects.filter(media_id=id).exclude(mode=ModeChoices.PREVIEW).aexists(): + if await Watch.objects.filter(media_id=id, mode=ModeChoices.NORMAL).aexists(): raise ValueError(ErrorCode.ATTEMPT_EXISTS) await Media.objects.filter(id=id, owner_id=request.auth, published__isnull=True).adelete() diff --git a/core/apps/studio/api/v1/quiz.py b/core/apps/studio/api/v1/quiz.py index 92af5e5..3c703bc 100644 --- a/core/apps/studio/api/v1/quiz.py +++ b/core/apps/studio/api/v1/quiz.py @@ -135,7 +135,7 @@ def create_new(): @editor_required() @track_editing(Quiz, id_field="id") async def delete_quiz(request: HttpRequest, id: str): - if await Attempt.objects.filter(quiz_id=id).exclude(mode=ModeChoices.PREVIEW).aexists(): + if await Attempt.objects.filter(quiz_id=id, mode=ModeChoices.NORMAL).aexists(): raise ValueError(ErrorCode.ATTEMPT_EXISTS) await Quiz.objects.filter(id=id, owner_id=request.auth, published__isnull=True).adelete() @@ -207,7 +207,9 @@ async def save_quiz_questions( @editor_required() @track_editing(Quiz, id_field="id") async def delete_quiz_quesion(request: HttpRequest, id: str, question_id: int): - if await Attempt.objects.filter(quiz_id=id, questions=question_id, quiz__owner_id=request.auth).aexists(): + if await Attempt.objects.filter( + quiz_id=id, questions=question_id, quiz__owner_id=request.auth, mode=ModeChoices.NORMAL + ).aexists(): raise ValueError(ErrorCode.IN_USE) count, _ = await Question.objects.filter(id=question_id, pool__quiz__id=id).adelete() diff --git a/core/apps/studio/api/v1/schema.py b/core/apps/studio/api/v1/schema.py deleted file mode 100644 index 896721a..0000000 --- a/core/apps/studio/api/v1/schema.py +++ /dev/null @@ -1,6 +0,0 @@ -from apps.common.schema import Schema - - -class HonorCodeSpec(Schema): - title: str - code: str diff --git a/core/apps/studio/api/v1/survey.py b/core/apps/studio/api/v1/survey.py index b3de3de..f859e61 100644 --- a/core/apps/studio/api/v1/survey.py +++ b/core/apps/studio/api/v1/survey.py @@ -122,7 +122,7 @@ def create_new(): @editor_required() @track_editing(Survey, id_field="id") async def delete_survey(request: HttpRequest, id: str): - if await Submission.objects.filter(survey_id=id).exclude(mode=ModeChoices.PREVIEW).aexists(): + if await Submission.objects.filter(survey_id=id, mode=ModeChoices.NORMAL).aexists(): raise ValueError(ErrorCode.ATTEMPT_EXISTS) await Survey.objects.filter(id=id, owner_id=request.auth, published__isnull=True).adelete() diff --git a/core/apps/studio/migrations/0001_initial.py b/core/apps/studio/migrations/0001_initial.py index b84fb5a..f56eebe 100644 --- a/core/apps/studio/migrations/0001_initial.py +++ b/core/apps/studio/migrations/0001_initial.py @@ -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 django.utils.timezone diff --git a/core/apps/studio/models.py b/core/apps/studio/models.py index d475c48..942ad92 100644 --- a/core/apps/studio/models.py +++ b/core/apps/studio/models.py @@ -1,3 +1,5 @@ +from typing import TYPE_CHECKING + import pghistory from django.contrib.auth import get_user_model from django.contrib.contenttypes.fields import GenericForeignKey @@ -5,7 +7,6 @@ from django.db.models import CASCADE, CharField, DateTimeField, ForeignKey, Model, TextField, UniqueConstraint from django.utils import timezone from django.utils.translation import gettext_lazy as _ -from PIL.GifImagePlugin import TYPE_CHECKING User = get_user_model() diff --git a/core/apps/survey/api/v1.py b/core/apps/survey/api/v1.py index 30470c0..8d4436b 100644 --- a/core/apps/survey/api/v1.py +++ b/core/apps/survey/api/v1.py @@ -1,3 +1,4 @@ +from django.utils import timezone from ninja.router import Router from apps.common.util import HttpRequest @@ -24,6 +25,7 @@ async def submit(request: HttpRequest, id: str, data: SurveyAnswersSchema): respondent_id=request.auth, context=request.active_context, answers=data.model_dump(), + lock=request.access_date["end"], mode=request.access_mode, ) @@ -42,7 +44,9 @@ async def get_anonymous_survey(request: HttpRequest, id: str): @router.post("/{id}/anonymous/submit", auth=None) @access_mode() async def submit_anonymous(request: HttpRequest, id: str, data: SurveyAnswersSchema): - await Submission.submit(survey_id=id, answers=data.model_dump(), anonymous=True, mode=request.access_mode) + await Submission.submit( + survey_id=id, answers=data.model_dump(), lock=timezone.now(), anonymous=True, mode=request.access_mode + ) @router.get("/{id}/anonymous/results", auth=None, response=dict[str, dict[str, int]]) diff --git a/core/apps/survey/migrations/0001_initial.py b/core/apps/survey/migrations/0001_initial.py index c26dcc6..e362430 100644 --- a/core/apps/survey/migrations/0001_initial.py +++ b/core/apps/survey/migrations/0001_initial.py @@ -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.util import django.contrib.postgres.fields @@ -100,6 +100,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')), @@ -148,6 +149,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')), @@ -256,15 +258,15 @@ class Migration(migrations.Migration): ), pgtrigger.migrations.AddTrigger( model_name='submission', - trigger=pgtrigger.compiler.Trigger(name='insert_insert', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "survey_submissionevent" ("active", "answers", "context", "id", "mode", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "respondent_id", "started", "survey_id") VALUES (NEW."active", NEW."answers", NEW."context", NEW."id", NEW."mode", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."respondent_id", NEW."started", NEW."survey_id"); RETURN NULL;', hash='994783c4693b6fcb8288760aadb9d3cb6a148c8a', operation='INSERT', pgid='pgtrigger_insert_insert_ebca0', table='survey_submission', when='AFTER')), + trigger=pgtrigger.compiler.Trigger(name='insert_insert', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "survey_submissionevent" ("active", "answers", "context", "id", "lock", "mode", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "respondent_id", "started", "survey_id") VALUES (NEW."active", NEW."answers", NEW."context", NEW."id", NEW."lock", NEW."mode", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."respondent_id", NEW."started", NEW."survey_id"); RETURN NULL;', hash='a7dc3860509041e407a61d9b50f33251fb4b8d7c', operation='INSERT', pgid='pgtrigger_insert_insert_ebca0', table='survey_submission', when='AFTER')), ), pgtrigger.migrations.AddTrigger( model_name='submission', - trigger=pgtrigger.compiler.Trigger(name='update_update', sql=pgtrigger.compiler.UpsertTriggerSql(condition='WHEN (OLD.* IS DISTINCT FROM NEW.*)', func='INSERT INTO "survey_submissionevent" ("active", "answers", "context", "id", "mode", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "respondent_id", "started", "survey_id") VALUES (NEW."active", NEW."answers", NEW."context", NEW."id", NEW."mode", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."respondent_id", NEW."started", NEW."survey_id"); RETURN NULL;', hash='d773d7c6d9033665cf3f111104f9fc97481c556c', operation='UPDATE', pgid='pgtrigger_update_update_d6efc', table='survey_submission', when='AFTER')), + trigger=pgtrigger.compiler.Trigger(name='update_update', sql=pgtrigger.compiler.UpsertTriggerSql(condition='WHEN (OLD.* IS DISTINCT FROM NEW.*)', func='INSERT INTO "survey_submissionevent" ("active", "answers", "context", "id", "lock", "mode", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "respondent_id", "started", "survey_id") VALUES (NEW."active", NEW."answers", NEW."context", NEW."id", NEW."lock", NEW."mode", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."respondent_id", NEW."started", NEW."survey_id"); RETURN NULL;', hash='2d1002c5696b19c02e894aab167694ff8f31bdbb', operation='UPDATE', pgid='pgtrigger_update_update_d6efc', table='survey_submission', when='AFTER')), ), pgtrigger.migrations.AddTrigger( model_name='submission', - trigger=pgtrigger.compiler.Trigger(name='delete_delete', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "survey_submissionevent" ("active", "answers", "context", "id", "mode", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "respondent_id", "started", "survey_id") VALUES (OLD."active", OLD."answers", OLD."context", OLD."id", OLD."mode", _pgh_attach_context(), NOW(), \'delete\', OLD."id", OLD."respondent_id", OLD."started", OLD."survey_id"); RETURN NULL;', hash='e38565e0b5e22fd144cba1f30438a49c20bb0f56', operation='DELETE', pgid='pgtrigger_delete_delete_fa339', table='survey_submission', when='AFTER')), + trigger=pgtrigger.compiler.Trigger(name='delete_delete', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "survey_submissionevent" ("active", "answers", "context", "id", "lock", "mode", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "respondent_id", "started", "survey_id") VALUES (OLD."active", OLD."answers", OLD."context", OLD."id", OLD."lock", OLD."mode", _pgh_attach_context(), NOW(), \'delete\', OLD."id", OLD."respondent_id", OLD."started", OLD."survey_id"); RETURN NULL;', hash='2b7bf95477a09412dc2ee3a92cfa56a522a157f8', operation='DELETE', pgid='pgtrigger_delete_delete_fa339', table='survey_submission', when='AFTER')), ), pgtrigger.migrations.AddTrigger( model_name='surveyevent', diff --git a/core/apps/survey/models.py b/core/apps/survey/models.py index 1d4b1b6..776e814 100644 --- a/core/apps/survey/models.py +++ b/core/apps/survey/models.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import TYPE_CHECKING import pghistory @@ -142,6 +143,7 @@ async def submit( *, survey_id: str, answers: dict[str, str], + lock: datetime, mode: ModeChoices, respondent_id: str | None = None, context: str = "", @@ -150,7 +152,7 @@ async def submit( survey = await Survey.objects.aget(id=survey_id) if survey.anonymous: - await Submission.objects.acreate(survey=survey, answers=answers, mode=mode) + await Submission.objects.acreate(survey=survey, answers=answers, lock=lock, mode=mode) else: if anonymous: raise ValueError(ErrorCode.ANONYMOUS_NOT_ALLOWED) @@ -160,5 +162,5 @@ async def submit( survey=survey, respondent_id=respondent_id, context=context, - defaults={"answers": answers, "active": True, "mode": mode}, + defaults={"answers": answers, "lock": lock, "active": True, "mode": mode}, ) diff --git a/core/apps/survey/tests/factories.py b/core/apps/survey/tests/factories.py index d9e4d2b..6cfc123 100644 --- a/core/apps/survey/tests/factories.py +++ b/core/apps/survey/tests/factories.py @@ -1,5 +1,6 @@ import mimesis from django.conf import settings +from django.utils import timezone from factory.declarations import Iterator, LazyFunction, Sequence, SubFactory from factory.django import DjangoModelFactory from factory.helpers import lazy_attribute, post_generation @@ -84,6 +85,7 @@ def post_generation(self, create: bool, extracted: object, **kwargs: object): class SubmissionFactory(DjangoModelFactory[Submission]): survey = SubFactory(SurveyFactory) respondent = SubFactory(UserFactory) + lock = LazyFunction(timezone.now) active = True context = "" diff --git a/core/apps/tracking/migrations/0001_initial.py b/core/apps/tracking/migrations/0001_initial.py index ad55837..f41b552 100644 --- a/core/apps/tracking/migrations/0001_initial.py +++ b/core/apps/tracking/migrations/0001_initial.py @@ -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 pghistory.utils from django.db import migrations, models diff --git a/core/apps/tutor/__init__.py b/core/apps/tutor/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/tutor/admin.py b/core/apps/tutor/admin.py new file mode 100644 index 0000000..42f77a5 --- /dev/null +++ b/core/apps/tutor/admin.py @@ -0,0 +1,23 @@ +from django.contrib import admin +from django.utils.translation import gettext_lazy as _ + +from apps.common.admin import ModelAdmin, ReadOnlyHiddenModelAdmin, ReadOnlyTabularInline +from apps.tutor.models import Allocation + + +@admin.register(Allocation) +class AllocationAdmin(ModelAdmin): + class AllocationEventInline(ReadOnlyTabularInline[Allocation.pgh_event_model]): + model = Allocation.pgh_event_model + verbose_name = _("Allocation History") + verbose_name_plural = _("Allocation Histories") + + def get_queryset(self, request): + return super().get_queryset(request).select_related("pgh_context", "tutor", "content_type") + + inlines = (AllocationEventInline,) + + +@admin.register(Allocation.pgh_event_model) +class AllocationEventAdmin(ReadOnlyHiddenModelAdmin[Allocation.pgh_event_model]): + pass diff --git a/core/apps/tutor/api/v1/__init__.py b/core/apps/tutor/api/v1/__init__.py new file mode 100644 index 0000000..d0a2974 --- /dev/null +++ b/core/apps/tutor/api/v1/__init__.py @@ -0,0 +1,70 @@ +from datetime import datetime +from typing import Annotated, Literal + +from django.conf import settings +from ninja import Router +from ninja.params import functions + +from apps.common.schema import ContentTypeSchema, Schema +from apps.common.util import HttpRequest, PaginatedResponse +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 +from apps.tutor.decorator import tutor_required +from apps.tutor.models import Allocation + +router = Router(by_alias=True) + + +TutoringModel = Literal["exam", "assignment", "discussion"] + + +class AllocationSchema(Schema): + class TutorContentSchema(Schema): + id: str + created: datetime + title: str + last_grading: datetime | None + submission_count: int + grade_completed_count: int + grade_confirmed_count: int + appeal_count: int + appeal_open_count: int + + id: int + content: TutorContentSchema + content_type: ContentTypeSchema + + @staticmethod + def resolve_content(allocation: Allocation): + return allocation._content_cache + + +@router.get("/allocation", response=PaginatedResponse[AllocationSchema]) +@tutor_required() +async def get_allocation( + request: HttpRequest, + 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_allocated(tutor_id=request.auth, page=page, size=size) + + +class AllocationStatsSchema(Schema): + allocation_count: int + submission_count: int + grade_completed_count: int + grade_confirmed_count: int + appeal_count: int + appeal_open_count: int + + +@router.get("/allocation/stats", response=AllocationStatsSchema) +@tutor_required() +async def get_allocation_stats(request: HttpRequest): + return await Allocation.get_stats(tutor_id=request.auth) + + +router.add_router("", exam_router, tags=["tutor"]) +router.add_router("", assignment_router, tags=["tutor"]) +router.add_router("", discussion_router, tags=["tutor"]) diff --git a/core/apps/tutor/api/v1/assignment.py b/core/apps/tutor/api/v1/assignment.py new file mode 100644 index 0000000..c2b7e03 --- /dev/null +++ b/core/apps/tutor/api/v1/assignment.py @@ -0,0 +1,106 @@ +from asgiref.sync import sync_to_async +from django.db.models import F +from django.shortcuts import aget_object_or_404 +from ninja import Router +from ninja.pagination import paginate + +from apps.account.api.schema import OwnerSchema +from apps.assignment.api.schema import AssignmentQuestionSchema, RubricSchema +from apps.assignment.documents import SubmissionDocument +from apps.assignment.models import Assignment, Grade +from apps.common.schema import Schema +from apps.common.util import HttpRequest, Pagination +from apps.tutor.api.v1.schema import TutorGradeSaveSchema, TutorGradeSchema, TutorGraeCompleteSchema +from apps.tutor.decorator import allocation_required, tutor_required + +router = Router(by_alias=True) + + +class TutorAssignmentGradeSchema(TutorGradeSchema): + @staticmethod + def resolve_grading_date(grade: Grade): + return grade.attempt.assignment.get_grading_date(access_date={"end": grade.attempt.lock}) + + +@router.get("/assignment/{id}/grade", response=list[TutorAssignmentGradeSchema]) +@tutor_required() +@allocation_required("assignment", "assignment") +@paginate(Pagination) +async def get_assignment_grades(request: HttpRequest, id: str): + return ( + Grade.objects + .select_related("attempt__assignment") + .annotate(attempt_retry=F("attempt__retry")) + .filter(attempt__assignment_id=id, attempt__active=True) + .order_by("-created") + ) + + +class TutorAssignmentGradePaperSchema(Schema): + id: int + earned_details: dict[str, int | None] + answer: str + feedback: dict[str, str] + grader: OwnerSchema | None + question: AssignmentQuestionSchema + analysis: dict[str, dict[str, int]] + similar_answer: str | None + + @staticmethod + def resolve_question(grade: Grade): + return grade.attempt.question + + @staticmethod + def resolve_answer(grade: Grade): + return grade.attempt.submission.answer + + +@router.get("/assignment/{id}/grade/{grade_id}", response=TutorAssignmentGradePaperSchema) +@tutor_required() +@allocation_required("assignment", "assignment") +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" + ), + id=grade_id, + attempt__assignment_id=id, + attempt__active=True, + ) + question_id = grade.attempt.question_id + grade.analysis = await Assignment().analyze_answers([question_id]) + + if grade.attempt.question.plagiarism_threshold > 0: + test_result = await sync_to_async(SubmissionDocument.check_similarity)( + question_id=question_id, user_id=grade.attempt.learner_id, text=grade.attempt.submission.answer + ) + grade.similar_answer = test_result["similar_answer"] + + return grade + + +@router.get("assignment/{id}/rubric", response=RubricSchema) +@tutor_required() +@allocation_required("assignment", "assignment") +async def get_assignment_rubric(request: HttpRequest, id: str): + assignment = await aget_object_or_404( + Assignment.objects.prefetch_related("rubric__rubric_criteria__performance_levels"), id=id + ) + return await assignment.get_rubric_data() + + +@router.post("/assignment/{id}/grade/{grade_id}", response=TutorGraeCompleteSchema) +@tutor_required() +@allocation_required("assignment", "assignment") +async def complete_assignment_grade(request: HttpRequest, id: str, grade_id: int, data: TutorGradeSaveSchema): + grade = await aget_object_or_404( + Grade.objects.select_related("attempt__submission", "attempt__question", "grader").prefetch_related( + "attempt__assignment__rubric__rubric_criteria__performance_levels" + ), + id=grade_id, + attempt__assignment_id=id, + attempt__active=True, + ) + grade.feedback.update(data.feedback) + await grade.grade(data.earned_details, grader_id=request.auth) + return grade diff --git a/core/apps/tutor/api/v1/discussion.py b/core/apps/tutor/api/v1/discussion.py new file mode 100644 index 0000000..f28c37e --- /dev/null +++ b/core/apps/tutor/api/v1/discussion.py @@ -0,0 +1,99 @@ +from django.db.models import F, Prefetch +from django.shortcuts import aget_object_or_404 +from ninja import Router +from ninja.pagination import paginate + +from apps.account.api.schema import OwnerSchema +from apps.common.schema import Schema +from apps.common.util import HttpRequest, Pagination +from apps.discussion.api.schema import ( + DiscussionEarnedDetailsSchema, + DiscussionFeedbackSchema, + DiscussionOwnPostSchema, + DiscussionQuestionSchema, +) +from apps.discussion.models import Grade, Post +from apps.tutor.api.v1.schema import TutorGradeSchema, TutorGraeCompleteSchema +from apps.tutor.decorator import allocation_required, tutor_required + +router = Router(by_alias=True) + + +class TutorDiscussionGradeSchema(TutorGradeSchema): + @staticmethod + def resolve_grading_date(grade: Grade): + return grade.attempt.discussion.get_grading_date(access_date={"end": grade.attempt.lock}) + + +@router.get("/discussion/{id}/grade", response=list[TutorDiscussionGradeSchema]) +@tutor_required() +@allocation_required("discussion", "discussion") +@paginate(Pagination) +async def get_discussion_grades(request: HttpRequest, id: str): + return ( + Grade.objects + .select_related("attempt__discussion") + .annotate(attempt_retry=F("attempt__retry")) + .filter(attempt__discussion_id=id, attempt__active=True) + .order_by("-created") + ) + + +class TutorDiscussionGradePaperSchema(Schema): + id: int + earned_details: DiscussionEarnedDetailsSchema + feedback: DiscussionFeedbackSchema + grader: OwnerSchema | None + question: DiscussionQuestionSchema + posts: list[DiscussionOwnPostSchema] + + @staticmethod + def resolve_question(grade: Grade): + return grade.attempt.question + + @staticmethod + def resolve_posts(grade: Grade): + return grade.attempt.posts + + +@router.get("/discussion/{id}/grade/{grade_id}", response=TutorDiscussionGradePaperSchema) +@tutor_required() +@allocation_required("discussion", "discussion") +async def get_discussion_grade_paper(request: HttpRequest, id: str, grade_id: int): + grade = await aget_object_or_404( + Grade.objects.select_related("attempt__question", "grader").prefetch_related( + "attempt__question__attachments", + Prefetch("attempt__posts", queryset=Post.objects.prefetch_related("attachments").order_by("id")), + ), + id=grade_id, + attempt__discussion_id=id, + attempt__active=True, + ) + + return grade + + +class TutorDiscussionGradeSaveSchema(Schema): + class DiscussionEarnedDetailsSaveSchema(Schema): + tutor_assessment: int + + class DiscussionFeedbackSaveSchema(Schema): + tutor_assessment: str + + earned_details: DiscussionEarnedDetailsSaveSchema + feedback: DiscussionFeedbackSaveSchema + + +@router.post("/discussion/{id}/grade/{grade_id}", response=TutorGraeCompleteSchema) +@tutor_required() +@allocation_required("discussion", "discussion") +async def complete_discussion_grade(request: HttpRequest, id: str, grade_id: int, data: TutorDiscussionGradeSaveSchema): + grade = await aget_object_or_404( + Grade.objects.select_related("attempt__question", "attempt__discussion", "grader"), + id=grade_id, + attempt__discussion_id=id, + attempt__active=True, + ) + grade.feedback.update(data.feedback.model_dump()) + await grade.grade(data.earned_details.model_dump(), grader_id=request.auth) + return grade diff --git a/core/apps/tutor/api/v1/exam.py b/core/apps/tutor/api/v1/exam.py new file mode 100644 index 0000000..e74ec47 --- /dev/null +++ b/core/apps/tutor/api/v1/exam.py @@ -0,0 +1,110 @@ +from django.db.models import F, Prefetch, Q +from django.shortcuts import aget_object_or_404 +from ninja import Router +from ninja.pagination import paginate + +from apps.account.api.schema import OwnerSchema +from apps.common.schema import Schema +from apps.common.util import HttpRequest, Pagination +from apps.exam.api.schema import ExamQuestionSchema, ExamSolutionSchema +from apps.exam.models import Exam, Grade, Question +from apps.tutor.api.v1.schema import TutorGradeSaveSchema, TutorGradeSchema, TutorGraeCompleteSchema +from apps.tutor.decorator import allocation_required, tutor_required + +router = Router(by_alias=True) + + +class TutorExamGradeSchema(TutorGradeSchema): + @staticmethod + def resolve_grading_date(grade: Grade): + return grade.attempt.exam.get_grading_date(access_date={"end": grade.attempt.lock}) + + +@router.get("/exam/{id}/grade", response=list[TutorExamGradeSchema]) +@tutor_required() +@allocation_required("exam", "exam") +@paginate(Pagination) +async def get_exam_grades(request: HttpRequest, id: str): + return ( + Grade.objects + .select_related("attempt__exam") + .annotate(attempt_retry=F("attempt__retry")) + .filter(attempt__exam_id=id, attempt__active=True) + .order_by("-created") + ) + + +class TutorExamGradePaperSchema(Schema): + class TutorExamQuestionSchema(ExamQuestionSchema): + solution: ExamSolutionSchema | None + + id: int + earned_details: dict[str, int | None] + answers: dict[str, str] + feedback: dict[str, str] + grader: OwnerSchema | None + questions: list[TutorExamQuestionSchema] + analysis: dict[str, dict[str, int]] + + @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} + + @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} + + @staticmethod + def resolve_questions(grade: Grade): + return grade.attempt.questions.all() + + +@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): + 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, + ] + ) + ) + .order_by("id"), + ) + ), + id=grade_id, + attempt__exam_id=id, + attempt__active=True, + ) + grade.analysis = await Exam().analyze_answers([q.id for q in grade.attempt.questions.all()]) + + return grade + + +@router.post("/exam/{id}/grade/{grade_id}", response=TutorGraeCompleteSchema) +@tutor_required() +@allocation_required("exam", "exam") +async def complete_exam_grade(request: HttpRequest, id: str, grade_id: int, data: TutorGradeSaveSchema): + grade = await aget_object_or_404( + Grade.objects.select_related("attempt__submission", "attempt__exam").prefetch_related( + Prefetch("attempt__questions__solution") + ), + id=grade_id, + attempt__exam_id=id, + attempt__active=True, + ) + grade.feedback.update(data.feedback) + await grade.grade(data.earned_details, grader_id=request.auth) + return grade diff --git a/core/apps/tutor/api/v1/schema.py b/core/apps/tutor/api/v1/schema.py new file mode 100644 index 0000000..f6cc71b --- /dev/null +++ b/core/apps/tutor/api/v1/schema.py @@ -0,0 +1,26 @@ +from datetime import datetime + +from apps.common.schema import Schema +from apps.common.util import GradingDate + + +class TutorGradeSchema(Schema): + id: int + created: datetime + score: float + passed: bool + completed: datetime | None + confirmed: datetime | None + attempt_retry: int + grading_date: GradingDate + + +class TutorGradeSaveSchema(Schema): + earned_details: dict[str, int | None] + feedback: dict[str, str] + + +class TutorGraeCompleteSchema(Schema): + score: float + passed: bool + completed: datetime | None diff --git a/core/apps/tutor/apps.py b/core/apps/tutor/apps.py new file mode 100644 index 0000000..4fa808a --- /dev/null +++ b/core/apps/tutor/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class TutorConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.tutor" + verbose_name = _("Tutor") diff --git a/core/apps/tutor/decorator.py b/core/apps/tutor/decorator.py new file mode 100644 index 0000000..d4ba2df --- /dev/null +++ b/core/apps/tutor/decorator.py @@ -0,0 +1,41 @@ +from functools import wraps + +from asgiref.sync import sync_to_async +from django.contrib.contenttypes.models import ContentType + +from apps.common.error import ErrorCode +from apps.common.util import HttpRequest +from apps.tutor.models import Allocation + + +def tutor_required(): + def decorator(func): + @wraps(func) + async def wrapper(request: HttpRequest, *args, **kwargs): + if "tutor" not in request.roles: + raise ValueError(ErrorCode.PERMISSION_DENIED) + return await func(request, *args, **kwargs) + + return wrapper + + return decorator + + +def allocation_required(app_label: str, model: str, id_field: str = "id"): + def decorator(func): + @wraps(func) + 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] + ).aexists() + + if not allocated: + raise ValueError(ErrorCode.PERMISSION_DENIED) + + return await func(request, *args, **kwargs) + + return wrapper + + return decorator diff --git a/core/apps/tutor/migrations/0001_initial.py b/core/apps/tutor/migrations/0001_initial.py new file mode 100644 index 0000000..bcdeaed --- /dev/null +++ b/core/apps/tutor/migrations/0001_initial.py @@ -0,0 +1,78 @@ +# Generated by Django 6.0.3 on 2026-03-08 06:49 + +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): + + initial = True + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('pghistory', '0007_auto_20250421_0444'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Allocation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='Created')), + ('modified', models.DateTimeField(auto_now=True, db_index=True, verbose_name='Modified')), + ('active', models.BooleanField(default=True, verbose_name='Active')), + ('content_id', models.CharField(max_length=36, verbose_name='Content ID')), + ('content_type', models.ForeignKey(limit_choices_to={'model__in': ('exam', 'assignment', 'discussion')}, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name='Content type')), + ('tutor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Tutor')), + ], + options={ + 'verbose_name': 'Tutor Allocation', + 'verbose_name_plural': 'Tutor Allocations', + 'abstract': False, + }, + ), + migrations.CreateModel( + name='AllocationEvent', + fields=[ + ('pgh_id', models.AutoField(primary_key=True, serialize=False)), + ('pgh_created_at', models.DateTimeField(auto_now_add=True)), + ('pgh_label', models.TextField(help_text='The event label.')), + ('id', models.BigIntegerField()), + ('created', models.DateTimeField(auto_now_add=True, verbose_name='Created')), + ('modified', models.DateTimeField(auto_now=True, verbose_name='Modified')), + ('active', models.BooleanField(default=True, verbose_name='Active')), + ('content_id', models.CharField(max_length=36, verbose_name='Content ID')), + ('content_type', 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')), + ('pgh_context', models.ForeignKey(db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='pghistory.context')), + ('pgh_obj', models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.DO_NOTHING, related_name='events', to='tutor.allocation')), + ('tutor', models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', related_query_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Tutor')), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddConstraint( + model_name='allocation', + constraint=models.UniqueConstraint(fields=('tutor', 'content_type', 'content_id'), name='tutor_allocation_coty_coid_uniq'), + ), + pgtrigger.migrations.AddTrigger( + model_name='allocation', + trigger=pgtrigger.compiler.Trigger(name='insert_insert', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "tutor_allocationevent" ("active", "content_id", "content_type_id", "created", "id", "modified", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "tutor_id") VALUES (NEW."active", NEW."content_id", NEW."content_type_id", NEW."created", NEW."id", NEW."modified", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."tutor_id"); RETURN NULL;', hash='d32c9391e8701a85b0f0e9ed380044660422222c', operation='INSERT', pgid='pgtrigger_insert_insert_d54dd', table='tutor_allocation', when='AFTER')), + ), + pgtrigger.migrations.AddTrigger( + model_name='allocation', + trigger=pgtrigger.compiler.Trigger(name='update_update', sql=pgtrigger.compiler.UpsertTriggerSql(condition='WHEN (OLD."active" IS DISTINCT FROM (NEW."active") OR OLD."content_id" IS DISTINCT FROM (NEW."content_id") OR OLD."content_type_id" IS DISTINCT FROM (NEW."content_type_id") OR OLD."id" IS DISTINCT FROM (NEW."id") OR OLD."tutor_id" IS DISTINCT FROM (NEW."tutor_id"))', func='INSERT INTO "tutor_allocationevent" ("active", "content_id", "content_type_id", "created", "id", "modified", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "tutor_id") VALUES (NEW."active", NEW."content_id", NEW."content_type_id", NEW."created", NEW."id", NEW."modified", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."tutor_id"); RETURN NULL;', hash='ca721b80fb7aa65fb12a8d402beff29e90f3075a', operation='UPDATE', pgid='pgtrigger_update_update_86aff', table='tutor_allocation', when='AFTER')), + ), + pgtrigger.migrations.AddTrigger( + model_name='allocation', + trigger=pgtrigger.compiler.Trigger(name='delete_delete', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "tutor_allocationevent" ("active", "content_id", "content_type_id", "created", "id", "modified", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "tutor_id") VALUES (OLD."active", OLD."content_id", OLD."content_type_id", OLD."created", OLD."id", OLD."modified", _pgh_attach_context(), NOW(), \'delete\', OLD."id", OLD."tutor_id"); RETURN NULL;', hash='bb5f3cad86ea0cea7eaf7e4e43fd1f0459c0af29', operation='DELETE', pgid='pgtrigger_delete_delete_57622', table='tutor_allocation', when='AFTER')), + ), + pgtrigger.migrations.AddTrigger( + model_name='allocationevent', + trigger=pgtrigger.compiler.Trigger(name='append_only', sql=pgtrigger.compiler.UpsertTriggerSql(func="RAISE EXCEPTION 'pgtrigger: Cannot update or delete rows from % table', TG_TABLE_NAME;", hash='4530583757a0806520333f7a3bab587961ab3263', operation='UPDATE OR DELETE', pgid='pgtrigger_append_only_7a1fd', table='tutor_allocationevent', when='BEFORE')), + ), + ] diff --git a/core/apps/tutor/migrations/__init__.py b/core/apps/tutor/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/tutor/models.py b/core/apps/tutor/models.py new file mode 100644 index 0000000..6243b77 --- /dev/null +++ b/core/apps/tutor/models.py @@ -0,0 +1,351 @@ +import asyncio +from collections import defaultdict +from typing import TYPE_CHECKING + +import pghistory +import psycopg +from asgiref.sync import sync_to_async +from django.contrib.auth import get_user_model +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.db import connection +from django.db.models import CASCADE, BooleanField, CharField, Count, ForeignKey, Max, Model, Q, UniqueConstraint +from django.utils.translation import gettext_lazy as _ + +from apps.assignment.models import Assignment +from apps.assignment.models import Attempt as AssignmentAttempt +from apps.assignment.models import Grade as AssignmentGrade +from apps.common.models import TimeStampedMixin +from apps.common.util import offset_paginate +from apps.discussion.models import Attempt as DiscussionAttempt +from apps.discussion.models import Discussion +from apps.discussion.models import Grade as DiscussionGrade +from apps.exam.models import Attempt as ExamAttempt +from apps.exam.models import Exam +from apps.exam.models import Grade as ExamGrade +from apps.operation.models import Appeal + +User = get_user_model() + + +TUTORING_MODELS = {"exam": Exam, "assignment": Assignment, "discussion": Discussion} +TUTORING_MODEL_MAP = {(m._meta.app_label.lower(), m._meta.model_name): m for m in TUTORING_MODELS.values()} + + +@pghistory.track() +class Allocation(TimeStampedMixin): + tutor = ForeignKey(User, CASCADE, verbose_name=_("Tutor")) + active = BooleanField(_("Active"), default=True) + + limit_choices_to = {"model__in": TUTORING_MODELS.keys()} + content_type = ForeignKey(ContentType, CASCADE, verbose_name=_("Content type"), limit_choices_to=limit_choices_to) + content_id = CharField(_("Content ID"), max_length=36) + content = GenericForeignKey("content_type", "content_id") + + class Meta(TimeStampedMixin.Meta): + verbose_name = _("Tutor Allocation") + verbose_name_plural = _("Tutor Allocations") + constraints = [ + UniqueConstraint(fields=["tutor", "content_type", "content_id"], name="tutor_allocation_coty_coid_uniq") + ] + + if TYPE_CHECKING: + pgh_event_model: type[Model] + _content_cache: GenericForeignKey + + @classmethod + async def get_allocated(cls, *, tutor_id: str, page: int, size: int): + base_qs = cls.objects.select_related("content_type").filter(tutor_id=tutor_id).order_by("-modified") + paginated = await offset_paginate(base_qs, page=page, size=size) + + if not paginated["items"]: + return paginated + + content_ids = defaultdict(set) + for allocation in paginated["items"]: + content_ids[(allocation.content_type.app_label, allocation.content_type.model)].add(allocation.content_id) + + contents = await _fetch_allocatable_contents(content_ids) + appeal_counts = await _fetch_appeal_counts(content_ids) + + valid_items = [] + for item in paginated["items"]: + content = contents.get(item.content_id) + if not content: + continue + content.update(appeal_counts.get(item.content_id, {"appeal_count": 0, "appeal_open_count": 0})) + item._content_cache = content + valid_items.append(item) + + paginated["items"] = valid_items + + return paginated + + @classmethod + async def get_stats(cls, *, tutor_id: str) -> dict: + content_ids_by_type: dict[tuple[str, str], set[str]] = defaultdict(set) + allocation_count = 0 + async for allocation in cls.objects.select_related("content_type").filter(tutor_id=tutor_id): + key = (allocation.content_type.app_label, allocation.content_type.model) + content_ids_by_type[key].add(allocation.content_id) + allocation_count += 1 + + if allocation_count == 0: + return { + "allocation_count": 0, + "submission_count": 0, + "grade_completed_count": 0, + "grade_confirmed_count": 0, + "appeal_count": 0, + "appeal_open_count": 0, + } + + exam_ids = list(content_ids_by_type.get(("exam", "exam"), set())) + assignment_ids = list(content_ids_by_type.get(("assignment", "assignment"), set())) + discussion_ids = list(content_ids_by_type.get(("discussion", "discussion"), set())) + + grade_stats, appeal_stats = await asyncio.gather( + _fetch_grade_stats(exam_ids, assignment_ids, discussion_ids), + _fetch_appeal_stats(exam_ids, assignment_ids, discussion_ids), + ) + + return {"allocation_count": allocation_count, **grade_stats, **appeal_stats} + + +async def _fetch_allocatable_contents(content_ids_by_type: dict): + union_qs = [] + + for M in TUTORING_MODELS.values(): + key = (M._meta.app_label.lower(), M._meta.model_name) + ids = content_ids_by_type.get(key) + + if not ids: + continue + + union_qs.append( + M.objects + .filter(id__in=ids) + .annotate( + last_grading=Max("attempt__grade__completed"), + submission_count=Count("attempt__grade", filter=Q(attempt__active=True), distinct=True), + grade_completed_count=Count( + "attempt__grade", + filter=Q(attempt__active=True, attempt__grade__completed__isnull=False), + distinct=True, + ), + grade_confirmed_count=Count( + "attempt__grade", + filter=Q(attempt__active=True, attempt__grade__confirmed__isnull=False), + distinct=True, + ), + ) + .values( + "id", + "created", + "modified", + "title", + "format", + "last_grading", + "submission_count", + "grade_completed_count", + "grade_confirmed_count", + ) + ) + + if not union_qs: + return {} + + qs = union_qs[0] if len(union_qs) == 1 else union_qs[0].union(*union_qs[1:], all=True) + return {content["id"]: content async for content in qs} + + +async def _fetch_appeal_counts(content_ids_by_type: dict[tuple[str, str], set[str]]) -> dict[str, dict]: + exam_attempt_table = ExamAttempt._meta.db_table + exam_m2m_table = ExamAttempt.questions.through._meta.db_table + assignment_attempt_table = AssignmentAttempt._meta.db_table + discussion_attempt_table = DiscussionAttempt._meta.db_table + appeal_table = Appeal._meta.db_table + + ct_cache = { + app_label: await sync_to_async(ContentType.objects.get_by_natural_key)(app_label, "question") + for app_label in ["exam", "assignment", "discussion"] + } + + exam_ids = list(content_ids_by_type.get(("exam", "exam"), set())) + assignment_ids = list(content_ids_by_type.get(("assignment", "assignment"), set())) + discussion_ids = list(content_ids_by_type.get(("discussion", "discussion"), set())) + + sql = f""" + SELECT ea.exam_id AS content_id, op.review + FROM {appeal_table} op + JOIN {exam_m2m_table} aq ON aq.question_id = op.question_id + JOIN {exam_attempt_table} ea ON ea.id = aq.attempt_id AND ea.active = TRUE + WHERE op.question_type_id = %s AND ea.exam_id = ANY(%s) + UNION ALL + SELECT ea.assignment_id AS content_id, op.review + FROM {appeal_table} op + JOIN {assignment_attempt_table} ea ON ea.question_id = op.question_id AND ea.active = TRUE + WHERE op.question_type_id = %s AND ea.assignment_id = ANY(%s) + UNION ALL + SELECT ea.discussion_id AS content_id, op.review + FROM {appeal_table} op + JOIN {discussion_attempt_table} ea ON ea.question_id = op.question_id AND ea.active = TRUE + WHERE op.question_type_id = %s AND ea.discussion_id = ANY(%s) + """ + + params = connection.get_connection_params() + params["cursor_factory"] = psycopg.AsyncCursor + aconnection = await psycopg.AsyncConnection.connect(**params) + + result: dict[str, dict] = {} + appeal_count: dict[str, int] = defaultdict(int) + open_count: dict[str, int] = defaultdict(int) + + async with aconnection: + async with aconnection.cursor() as cursor: + await cursor.execute( # type: ignore + sql, + [ + ct_cache["exam"].pk, + exam_ids, + ct_cache["assignment"].pk, + assignment_ids, + ct_cache["discussion"].pk, + discussion_ids, + ], + ) + rows = await cursor.fetchall() + + for content_id, review in rows: + appeal_count[content_id] += 1 + if not review: + open_count[content_id] += 1 + + for ids in content_ids_by_type.values(): + for cid in ids: + result[cid] = {"appeal_count": appeal_count.get(cid, 0), "appeal_open_count": open_count.get(cid, 0)} + + return result + + +async def _fetch_grade_stats(exam_ids: list, assignment_ids: list, discussion_ids: list) -> dict: + ea = ExamAttempt._meta.db_table + eg = ExamGrade._meta.db_table + aa = AssignmentAttempt._meta.db_table + ag = AssignmentGrade._meta.db_table + da = DiscussionAttempt._meta.db_table + dg = DiscussionGrade._meta.db_table + + sql = f""" + SELECT + SUM(submission_count) AS submission_count, + SUM(grade_completed_count) AS grade_completed_count, + SUM(grade_confirmed_count) AS grade_confirmed_count + FROM ( + SELECT + COUNT(DISTINCT g.id) FILTER (WHERE ea.active) AS submission_count, + COUNT(DISTINCT g.id) FILTER (WHERE ea.active AND g.completed IS NOT NULL) AS grade_completed_count, + COUNT(DISTINCT g.id) FILTER (WHERE ea.active AND g.confirmed IS NOT NULL) AS grade_confirmed_count + FROM {eg} g + JOIN {ea} ea ON ea.id = g.attempt_id + WHERE ea.exam_id = ANY(%s) + + UNION ALL + + SELECT + COUNT(DISTINCT g.id) FILTER (WHERE aa.active) AS submission_count, + COUNT(DISTINCT g.id) FILTER (WHERE aa.active AND g.completed IS NOT NULL) AS grade_completed_count, + COUNT(DISTINCT g.id) FILTER (WHERE aa.active AND g.confirmed IS NOT NULL) AS grade_confirmed_count + FROM {ag} g + JOIN {aa} aa ON aa.id = g.attempt_id + WHERE aa.assignment_id = ANY(%s) + + UNION ALL + + SELECT + COUNT(DISTINCT g.id) FILTER (WHERE da.active) AS submission_count, + COUNT(DISTINCT g.id) FILTER (WHERE da.active AND g.completed IS NOT NULL) AS grade_completed_count, + COUNT(DISTINCT g.id) FILTER (WHERE da.active AND g.confirmed IS NOT NULL) AS grade_confirmed_count + FROM {dg} g + JOIN {da} da ON da.id = g.attempt_id + WHERE da.discussion_id = ANY(%s) + ) combined + """ + + db_params = connection.get_connection_params() + db_params["cursor_factory"] = psycopg.AsyncCursor + aconnection = await psycopg.AsyncConnection.connect(**db_params) + + async with aconnection: + async with aconnection.cursor() as cursor: + await cursor.execute(sql, [exam_ids, assignment_ids, discussion_ids]) # type: ignore + row = await cursor.fetchone() + + if not row: + return {"submission_count": 0, "grade_completed_count": 0, "grade_confirmed_count": 0} + + return {"submission_count": row[0] or 0, "grade_completed_count": row[1] or 0, "grade_confirmed_count": row[2] or 0} + + +async def _fetch_appeal_stats(exam_ids: list, assignment_ids: list, discussion_ids: list) -> dict: + exam_attempt_table = ExamAttempt._meta.db_table + exam_m2m_table = ExamAttempt.questions.through._meta.db_table + assignment_attempt_table = AssignmentAttempt._meta.db_table + discussion_attempt_table = DiscussionAttempt._meta.db_table + appeal_table = Appeal._meta.db_table + + ct_cache = { + app_label: await sync_to_async(ContentType.objects.get_by_natural_key)(app_label, "question") + for app_label in ["exam", "assignment", "discussion"] + } + + sql = f""" + SELECT + COUNT(*) AS appeal_count, + COUNT(*) FILTER (WHERE review = '') AS appeal_open_count + FROM ( + SELECT op.review + FROM {appeal_table} op + JOIN {exam_m2m_table} aq ON aq.question_id = op.question_id + JOIN {exam_attempt_table} ea ON ea.id = aq.attempt_id AND ea.active = TRUE + WHERE op.question_type_id = %s AND ea.exam_id = ANY(%s) + + UNION ALL + + SELECT op.review + FROM {appeal_table} op + JOIN {assignment_attempt_table} aa ON aa.question_id = op.question_id AND aa.active = TRUE + WHERE op.question_type_id = %s AND aa.assignment_id = ANY(%s) + + UNION ALL + + SELECT op.review + FROM {appeal_table} op + JOIN {discussion_attempt_table} da ON da.question_id = op.question_id AND da.active = TRUE + WHERE op.question_type_id = %s AND da.discussion_id = ANY(%s) + ) combined + """ + + db_params = connection.get_connection_params() + db_params["cursor_factory"] = psycopg.AsyncCursor + aconnection = await psycopg.AsyncConnection.connect(**db_params) + + async with aconnection: + async with aconnection.cursor() as cursor: + await cursor.execute( # type: ignore + sql, + [ + ct_cache["exam"].pk, + exam_ids, + ct_cache["assignment"].pk, + assignment_ids, + ct_cache["discussion"].pk, + discussion_ids, + ], + ) + row = await cursor.fetchone() + + if not row: + return {"appeal_count": 0, "appeal_open_count": 0} + + return {"appeal_count": row[0] or 0, "appeal_open_count": row[1] or 0} diff --git a/core/apps/tutor/tasks.py b/core/apps/tutor/tasks.py new file mode 100644 index 0000000..11a50fd --- /dev/null +++ b/core/apps/tutor/tasks.py @@ -0,0 +1,11 @@ +from asgiref.sync import async_to_sync +from celery import shared_task + +from apps.exam.models import Exam + + +@shared_task +def regrade_question(exam_id: str, question_id: int, from_answers: list[str], to_answers: list[str]): + async_to_sync(Exam.regrade_question)( + exam_id=exam_id, question_id=question_id, from_answers=from_answers, to_answers=to_answers + ) diff --git a/core/apps/tutor/tests/test_tutor_assignment_api.py b/core/apps/tutor/tests/test_tutor_assignment_api.py new file mode 100644 index 0000000..1a35182 --- /dev/null +++ b/core/apps/tutor/tests/test_tutor_assignment_api.py @@ -0,0 +1,62 @@ +import json + +import pytest +from django.contrib.contenttypes.models import ContentType +from django.test.client import Client +from mimesis.providers.generic import Generic + +from apps.assignment.tests.factories import AssignmentFactory +from apps.tutor.models import Allocation +from conftest import AdminUser + + +@pytest.mark.e2e +@pytest.mark.django_db +def test_tutor_assignment_flow(client: Client, mimesis: Generic, admin_user: AdminUser): + admin_user.login() + + assignment = AssignmentFactory() + content_type = ContentType.objects.get_for_model(assignment) + + tutor = admin_user.get_user() + Allocation.objects.create(tutor=tutor, content_type=content_type, content_id=assignment.id) + + res = client.get("/api/v1/tutor/allocation") + assert res.status_code == 200, "get allocation" + + # get assignment grades list + res = client.get(f"/api/v1/tutor/assignment/{assignment.id}/grade") + assert res.status_code == 200, "get assignment grades" + + grades = res.json()["items"] + assert len(grades) > 0, "assignment has grades from factory" + grade_id = grades[0]["id"] + + # get grade paper + res = client.get(f"/api/v1/tutor/assignment/{assignment.id}/grade/{grade_id}") + assert res.status_code == 200, "get grade paper" + + paper = res.json() + assert "earnedDetails" in paper + assert "question" in paper + + # get assignment rubric + res = client.get(f"/api/v1/tutor/assignment/{assignment.id}/rubric") + assert res.status_code == 200, "get assignment rubric" + rubric = res.json() + + earned_details = { + criterion["name"]: performance_level["point"] # highest point + for criterion in rubric["criteria"] + for performance_level in criterion["performanceLevels"] + } + feedback = {name: mimesis.text.sentence() for name in earned_details.keys()} + + # complete grade + res = client.post( + f"/api/v1/tutor/assignment/{assignment.id}/grade/{grade_id}", + data=json.dumps({"earnedDetails": earned_details, "feedback": feedback}), + content_type="application/json", + ) + assert res.status_code == 200, "complete grade" + assert res.json()["completed"] is not None, "grade completed after grading" diff --git a/core/apps/tutor/tests/test_tutor_discussion_api.py b/core/apps/tutor/tests/test_tutor_discussion_api.py new file mode 100644 index 0000000..7bdfdff --- /dev/null +++ b/core/apps/tutor/tests/test_tutor_discussion_api.py @@ -0,0 +1,53 @@ +import json + +import pytest +from django.contrib.contenttypes.models import ContentType +from django.test.client import Client +from mimesis.providers.generic import Generic + +from apps.discussion.tests.factories import DiscussionFactory +from apps.tutor.models import Allocation +from conftest import AdminUser + + +@pytest.mark.e2e +@pytest.mark.django_db +def test_tutor_discussion_flow(client: Client, mimesis: Generic, admin_user: AdminUser): + admin_user.login() + + discussion = DiscussionFactory() + content_type = ContentType.objects.get_for_model(discussion) + + tutor = admin_user.get_user() + Allocation.objects.create(tutor=tutor, content_type=content_type, content_id=discussion.id) + + res = client.get("/api/v1/tutor/allocation") + assert res.status_code == 200, "get allocation" + + # get discussion grades list + res = client.get(f"/api/v1/tutor/discussion/{discussion.id}/grade") + assert res.status_code == 200, "get discussion grades" + + grades = res.json()["items"] + assert len(grades) > 0, "discussion has grades from factory" + grade_id = grades[0]["id"] + + # get grade paper + res = client.get(f"/api/v1/tutor/discussion/{discussion.id}/grade/{grade_id}") + assert res.status_code == 200, "get grade paper" + + paper = res.json() + assert "earnedDetails" in paper + assert "question" in paper + + earned_details = {"tutor_assessment": 1} + feedback = {name: mimesis.text.sentence() for name in earned_details.keys()} + + # complete grade + res = client.post( + f"/api/v1/tutor/discussion/{discussion.id}/grade/{grade_id}", + data=json.dumps({"earnedDetails": earned_details, "feedback": feedback}), + content_type="application/json", + ) + assert res.status_code == 200, "complete grade" + assert res.json()["completed"] is not None, "grade completed after grading" diff --git a/core/apps/tutor/tests/test_tutor_exam_api.py b/core/apps/tutor/tests/test_tutor_exam_api.py new file mode 100644 index 0000000..0038de5 --- /dev/null +++ b/core/apps/tutor/tests/test_tutor_exam_api.py @@ -0,0 +1,56 @@ +import json + +import pytest +from django.contrib.contenttypes.models import ContentType +from django.test.client import Client +from mimesis.providers.generic import Generic +from pytest_mock import MockerFixture + +from apps.exam.tests.factories import ExamFactory +from apps.tutor.models import Allocation +from conftest import AdminUser + + +@pytest.mark.e2e +@pytest.mark.django_db +def test_tutor_exam_flow(client: Client, mimesis: Generic, admin_user: AdminUser, mocker: MockerFixture): + admin_user.login() + + exam = ExamFactory() + content_type = ContentType.objects.get_for_model(exam) + + tutor = admin_user.get_user() + Allocation.objects.create(tutor=tutor, content_type=content_type, content_id=exam.id) + + res = client.get("/api/v1/tutor/allocation") + assert res.status_code == 200, "get allocation" + + # get exam grades list + res = client.get(f"/api/v1/tutor/exam/{exam.id}/grade") + assert res.status_code == 200, "get exam grades" + + grades = res.json()["items"] + assert len(grades) > 0, "exam has grades from factory" + grade_id = grades[0]["id"] + + # get grade paper + res = client.get(f"/api/v1/tutor/exam/{exam.id}/grade/{grade_id}") + assert res.status_code == 200, "get grade paper" + + paper = res.json() + assert "earnedDetails" in paper + assert "questions" in paper + + questions = paper["questions"] + assert len(questions) > 0, "grade paper has manual grading questions" + + # complete grade + earned_details = {str(q["id"]): q["point"] for q in questions} + feedback = {str(q["id"]): mimesis.text.sentence() for q in questions} + res = client.post( + f"/api/v1/tutor/exam/{exam.id}/grade/{grade_id}", + data=json.dumps({"earnedDetails": earned_details, "feedback": feedback}), + content_type="application/json", + ) + assert res.status_code == 200, "complete grade" + assert res.json()["completed"] is not None, "grade completed after grading" diff --git a/core/apps/warehouse/migrations/0001_initial.py b/core/apps/warehouse/migrations/0001_initial.py index ecb7871..f780f2d 100644 --- a/core/apps/warehouse/migrations/0001_initial.py +++ b/core/apps/warehouse/migrations/0001_initial.py @@ -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 from django.db import migrations, models diff --git a/core/apps/warehouse/views.py b/core/apps/warehouse/views.py index f72e9ca..f889ed2 100644 --- a/core/apps/warehouse/views.py +++ b/core/apps/warehouse/views.py @@ -20,7 +20,7 @@ def dashboard_callback(request, context): SELECT 1 FROM {response_table} r WHERE r.inquiry_id = i.id AND r.solved IS NOT NULL )), - (SELECT COUNT(*) FROM {appeal_table} WHERE closed IS NULL) + (SELECT COUNT(*) FROM {appeal_table} WHERE review = '') """.format( inquiry_table=Inquiry._meta.db_table, response_table=InquiryResponse._meta.db_table, diff --git a/core/locale/en/LC_MESSAGES/django.po b/core/locale/en/LC_MESSAGES/django.po index e4185f4..2edd441 100644 --- a/core/locale/en/LC_MESSAGES/django.po +++ b/core/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-03-07 13:30+0900\n" +"POT-Creation-Date: 2026-03-09 19:06+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -52,11 +52,11 @@ msgid "" "Temporary password created for {user}: {password}. Expires in {hours} hours." msgstr "" -#: apps/account/admin.py:87 minima/settings.py:175 +#: apps/account/admin.py:87 minima/settings.py:176 msgid "English" msgstr "" -#: apps/account/admin.py:87 minima/settings.py:175 +#: apps/account/admin.py:87 minima/settings.py:176 msgid "Korean" msgstr "" @@ -206,8 +206,8 @@ msgstr "" msgid "Email" msgstr "" -#: apps/account/models.py:114 apps/assignment/models.py:524 -#: apps/assignment/models.py:542 apps/assignment/models.py:561 +#: apps/account/models.py:114 apps/assignment/models.py:533 +#: apps/assignment/models.py:551 apps/assignment/models.py:570 #: apps/competency/certificate.py:274 apps/competency/models.py:61 #: apps/competency/models.py:102 apps/competency/models.py:122 #: apps/competency/models.py:140 apps/competency/models.py:170 @@ -248,12 +248,12 @@ msgid "Preferences" msgstr "" #: apps/account/models.py:122 apps/assistant/models.py:41 -#: apps/common/models.py:153 apps/competency/models.py:173 +#: apps/common/models.py:154 apps/competency/models.py:173 #: apps/competency/models.py:241 apps/learning/models.py:80 #: apps/learning/models.py:335 apps/operation/models.py:176 #: apps/operation/models.py:224 apps/operation/models.py:619 #: apps/operation/models.py:638 apps/store/models.py:107 -#: apps/store/models.py:148 +#: apps/store/models.py:148 apps/tutor/models.py:38 msgid "Active" msgstr "" @@ -424,7 +424,7 @@ msgstr "" #: apps/assignment/admin.py:107 apps/assignment/admin.py:108 #: apps/discussion/admin.py:69 apps/discussion/admin.py:70 #: apps/operation/admin.py:103 apps/operation/admin.py:104 -#: apps/operation/admin.py:150 apps/operation/admin.py:151 +#: apps/operation/admin.py:145 apps/operation/admin.py:146 #: apps/operation/models.py:244 apps/operation/models.py:265 msgid "Attachments" msgstr "" @@ -447,19 +447,19 @@ msgstr "" msgid "Grading Histories" msgstr "" -#: apps/assignment/admin.py:148 apps/assignment/models.py:435 +#: apps/assignment/admin.py:148 apps/assignment/models.py:436 #: apps/course/admin.py:132 apps/course/admin.py:149 -#: apps/discussion/admin.py:103 apps/discussion/models.py:434 -#: apps/exam/admin.py:133 apps/exam/models.py:395 apps/quiz/models.py:360 +#: apps/discussion/admin.py:103 apps/discussion/models.py:435 +#: apps/exam/admin.py:133 apps/exam/models.py:412 apps/quiz/models.py:361 msgid "Grade" msgstr "" -#: apps/assignment/admin.py:166 apps/common/models.py:163 +#: apps/assignment/admin.py:166 apps/common/models.py:164 #: apps/discussion/admin.py:122 apps/exam/admin.py:152 msgid "Earned Details" msgstr "" -#: apps/assignment/admin.py:171 apps/common/models.py:168 +#: apps/assignment/admin.py:171 apps/common/models.py:169 #: apps/discussion/admin.py:132 apps/exam/admin.py:157 msgid "Feedback" msgstr "" @@ -471,34 +471,34 @@ msgid "Assignment" msgstr "" #: apps/assignment/models.py:81 apps/assistant/models.py:39 -#: apps/common/models.py:121 apps/course/models.py:90 -#: apps/discussion/models.py:79 apps/discussion/models.py:325 +#: apps/common/models.py:121 apps/course/models.py:91 +#: apps/discussion/models.py:79 apps/discussion/models.py:326 #: apps/exam/models.py:76 apps/operation/models.py:128 #: apps/operation/models.py:192 apps/operation/models.py:376 #: apps/operation/models.py:545 apps/operation/models.py:636 -#: apps/operation/models.py:758 apps/quiz/models.py:72 apps/survey/models.py:37 +#: apps/operation/models.py:758 apps/quiz/models.py:72 apps/survey/models.py:38 msgid "Title" msgstr "" -#: apps/assignment/models.py:82 apps/assignment/models.py:525 -#: apps/assignment/models.py:543 apps/assignment/models.py:562 +#: apps/assignment/models.py:82 apps/assignment/models.py:534 +#: apps/assignment/models.py:552 apps/assignment/models.py:571 #: apps/common/models.py:122 apps/competency/models.py:141 #: apps/competency/models.py:171 apps/competency/models.py:237 -#: apps/course/models.py:91 apps/discussion/models.py:80 apps/exam/models.py:77 +#: apps/course/models.py:92 apps/discussion/models.py:80 apps/exam/models.py:77 #: apps/learning/models.py:333 apps/operation/models.py:206 #: apps/operation/models.py:637 apps/operation/models.py:759 #: apps/partner/models.py:44 apps/partner/models.py:67 #: apps/partner/models.py:177 apps/quiz/models.py:73 apps/store/models.py:56 -#: apps/store/models.py:103 apps/survey/models.py:38 +#: apps/store/models.py:103 apps/survey/models.py:39 msgid "Description" msgstr "" #: apps/assignment/models.py:83 apps/assignment/models.py:124 -#: apps/content/models.py:83 apps/course/models.py:115 +#: apps/content/models.py:83 apps/course/models.py:116 #: apps/discussion/models.py:81 apps/discussion/models.py:128 #: apps/exam/models.py:78 apps/exam/models.py:147 apps/operation/models.py:239 -#: apps/quiz/models.py:74 apps/quiz/models.py:126 apps/survey/models.py:39 -#: apps/survey/models.py:78 +#: apps/quiz/models.py:74 apps/quiz/models.py:126 apps/survey/models.py:40 +#: apps/survey/models.py:79 msgid "Owner" msgstr "" @@ -507,12 +507,12 @@ msgstr "" #: apps/discussion/models.py:104 apps/discussion/models.py:130 #: apps/exam/models.py:82 apps/exam/models.py:110 apps/exam/models.py:149 #: apps/quiz/models.py:78 apps/quiz/models.py:94 apps/quiz/models.py:127 -#: apps/survey/models.py:42 apps/survey/models.py:57 apps/survey/models.py:79 +#: apps/survey/models.py:43 apps/survey/models.py:58 apps/survey/models.py:80 msgid "Question Pool" msgstr "" #: apps/assignment/models.py:87 apps/discussion/models.py:85 -#: apps/exam/models.py:83 apps/quiz/models.py:79 apps/survey/models.py:43 +#: apps/exam/models.py:83 apps/quiz/models.py:79 apps/survey/models.py:44 msgid "Question Pools" msgstr "" @@ -522,12 +522,12 @@ msgstr "" #: apps/exam/models.py:118 apps/exam/models.py:135 apps/operation/admin.py:131 #: apps/operation/models.py:222 apps/operation/models.py:377 #: apps/quiz/admin.py:50 apps/quiz/models.py:95 apps/quiz/models.py:101 -#: apps/quiz/models.py:115 apps/survey/models.py:59 apps/survey/models.py:67 +#: apps/quiz/models.py:115 apps/survey/models.py:60 apps/survey/models.py:68 msgid "Question" msgstr "" #: apps/assignment/models.py:108 apps/discussion/models.py:106 -#: apps/exam/models.py:113 apps/quiz/models.py:96 apps/survey/models.py:60 +#: apps/exam/models.py:113 apps/quiz/models.py:96 apps/survey/models.py:61 msgid "Supplement" msgstr "" @@ -544,20 +544,20 @@ msgid "Plagiarism Threshold Percentage" msgstr "" #: apps/assignment/models.py:115 apps/discussion/models.py:115 -#: apps/exam/admin.py:77 apps/exam/models.py:119 apps/exam/models.py:242 +#: apps/exam/admin.py:77 apps/exam/models.py:119 apps/exam/models.py:258 #: apps/quiz/admin.py:51 apps/quiz/models.py:102 apps/quiz/models.py:243 -#: apps/survey/models.py:68 +#: apps/survey/models.py:69 msgid "Questions" msgstr "" -#: apps/assignment/models.py:125 apps/course/models.py:122 +#: apps/assignment/models.py:125 apps/course/models.py:123 #: apps/discussion/models.py:129 apps/exam/models.py:148 #: apps/operation/models.py:196 msgid "Honor Code" msgstr "" -#: apps/assignment/models.py:127 apps/assignment/models.py:528 -#: apps/assignment/models.py:541 +#: apps/assignment/models.py:127 apps/assignment/models.py:537 +#: apps/assignment/models.py:550 msgid "Rubric" msgstr "" @@ -569,134 +569,134 @@ msgstr "" msgid "Assignments" msgstr "" -#: apps/assignment/models.py:269 apps/course/models.py:571 -#: apps/discussion/models.py:203 apps/exam/models.py:241 +#: apps/assignment/models.py:269 apps/course/models.py:572 +#: apps/discussion/models.py:203 apps/exam/models.py:257 #: apps/operation/models.py:478 apps/quiz/models.py:242 msgid "Learner" msgstr "" #: apps/assignment/models.py:271 apps/discussion/models.py:205 -#: apps/exam/models.py:243 apps/quiz/models.py:244 +#: apps/exam/models.py:259 apps/quiz/models.py:244 msgid "Retry" msgstr "" -#: apps/assignment/models.py:274 apps/assignment/models.py:398 -#: apps/assignment/models.py:431 apps/assignment/models.py:487 -#: apps/discussion/models.py:208 apps/discussion/models.py:323 -#: apps/discussion/models.py:430 apps/exam/models.py:246 -#: apps/exam/models.py:365 apps/exam/models.py:375 apps/exam/models.py:391 -#: apps/quiz/models.py:247 apps/quiz/models.py:347 apps/quiz/models.py:357 +#: apps/assignment/models.py:274 apps/assignment/models.py:399 +#: apps/assignment/models.py:432 apps/assignment/models.py:496 +#: apps/discussion/models.py:208 apps/discussion/models.py:324 +#: apps/discussion/models.py:431 apps/exam/models.py:262 +#: apps/exam/models.py:382 apps/exam/models.py:392 apps/exam/models.py:408 +#: apps/quiz/models.py:247 apps/quiz/models.py:348 apps/quiz/models.py:358 msgid "Attempt" msgstr "" #: apps/assignment/models.py:275 apps/discussion/models.py:209 -#: apps/exam/models.py:247 apps/quiz/models.py:248 +#: apps/exam/models.py:263 apps/quiz/models.py:248 msgid "Attempts" msgstr "" -#: apps/assignment/models.py:399 apps/operation/models.py:223 +#: apps/assignment/models.py:400 apps/operation/models.py:223 #: apps/operation/models.py:450 msgid "Answer" msgstr "" -#: apps/assignment/models.py:400 +#: apps/assignment/models.py:401 msgid "Extracted Text" msgstr "" -#: apps/assignment/models.py:403 apps/exam/models.py:379 -#: apps/quiz/models.py:351 apps/survey/models.py:124 +#: apps/assignment/models.py:404 apps/exam/models.py:396 +#: apps/quiz/models.py:352 apps/survey/models.py:125 msgid "Submission" msgstr "" -#: apps/assignment/models.py:404 apps/exam/models.py:380 -#: apps/quiz/models.py:352 apps/survey/models.py:125 +#: apps/assignment/models.py:405 apps/exam/models.py:397 +#: apps/quiz/models.py:353 apps/survey/models.py:126 msgid "Submissions" msgstr "" -#: apps/assignment/models.py:432 apps/course/models.py:778 -#: apps/discussion/models.py:431 apps/exam/models.py:392 +#: apps/assignment/models.py:433 apps/course/models.py:779 +#: apps/discussion/models.py:432 apps/exam/models.py:409 msgid "Grader" msgstr "" -#: apps/assignment/models.py:436 apps/discussion/models.py:435 -#: apps/exam/models.py:396 apps/quiz/models.py:361 +#: apps/assignment/models.py:437 apps/discussion/models.py:436 +#: apps/exam/models.py:413 apps/quiz/models.py:362 msgid "Grades" msgstr "" -#: apps/assignment/models.py:482 +#: apps/assignment/models.py:491 msgid "Not Detected" msgstr "" -#: apps/assignment/models.py:483 +#: apps/assignment/models.py:492 msgid "Detected" msgstr "" -#: apps/assignment/models.py:484 +#: apps/assignment/models.py:493 msgid "Excused" msgstr "" -#: apps/assignment/models.py:485 +#: apps/assignment/models.py:494 msgid "Not Resolved" msgstr "" -#: apps/assignment/models.py:488 apps/store/models.py:54 +#: apps/assignment/models.py:497 apps/store/models.py:54 #: apps/store/models.py:184 apps/store/models.py:361 apps/store/models.py:381 msgid "Status" msgstr "" -#: apps/assignment/models.py:489 +#: apps/assignment/models.py:498 msgid "Similarity Percentage" msgstr "" -#: apps/assignment/models.py:490 +#: apps/assignment/models.py:499 msgid "Flagged Text" msgstr "" -#: apps/assignment/models.py:491 +#: apps/assignment/models.py:500 msgid "Source Text" msgstr "" -#: apps/assignment/models.py:492 +#: apps/assignment/models.py:501 msgid "Source User ID" msgstr "" -#: apps/assignment/models.py:493 apps/store/models.py:385 +#: apps/assignment/models.py:502 apps/store/models.py:385 msgid "Reason" msgstr "" -#: apps/assignment/models.py:496 +#: apps/assignment/models.py:505 msgid "Plagiarism Check" msgstr "" -#: apps/assignment/models.py:497 +#: apps/assignment/models.py:506 msgid "Plagiarism Checks" msgstr "" -#: apps/assignment/models.py:529 +#: apps/assignment/models.py:538 msgid "Rubrics" msgstr "" -#: apps/assignment/models.py:546 +#: apps/assignment/models.py:555 msgid "Rubric Criterion" msgstr "" -#: apps/assignment/models.py:547 +#: apps/assignment/models.py:556 msgid "Rubric Criteria" msgstr "" -#: apps/assignment/models.py:560 +#: apps/assignment/models.py:569 msgid "Criterion" msgstr "" -#: apps/assignment/models.py:563 apps/exam/models.py:115 apps/quiz/models.py:98 +#: apps/assignment/models.py:572 apps/exam/models.py:115 apps/quiz/models.py:98 msgid "Point" msgstr "" -#: apps/assignment/models.py:566 +#: apps/assignment/models.py:575 msgid "Performance Level" msgstr "" -#: apps/assignment/models.py:567 +#: apps/assignment/models.py:576 msgid "Performance Levels" msgstr "" @@ -710,7 +710,7 @@ msgid "AI Assistant" msgstr "" #: apps/assistant/models.py:28 apps/content/models.py:478 -#: apps/content/models.py:483 apps/course/models.py:777 +#: apps/content/models.py:483 apps/course/models.py:778 #: apps/learning/models.py:500 apps/learning/models.py:528 msgid "Note" msgstr "" @@ -749,11 +749,11 @@ msgid "Response" msgstr "" #: apps/assistant/models.py:66 apps/operation/models.py:382 -#: apps/operation/models.py:482 apps/operation/models.py:768 +#: apps/operation/models.py:481 apps/operation/models.py:768 msgid "Path" msgstr "" -#: apps/assistant/models.py:67 apps/common/models.py:169 +#: apps/assistant/models.py:67 apps/common/models.py:170 #: apps/store/models.py:357 apps/store/models.py:378 msgid "Completed" msgstr "" @@ -829,7 +829,7 @@ msgstr "" #: apps/common/models.py:124 apps/competency/models.py:239 #: apps/competency/models.py:298 apps/content/models.py:82 -#: apps/learning/models.py:334 apps/store/models.py:57 apps/survey/models.py:77 +#: apps/learning/models.py:334 apps/store/models.py:57 apps/survey/models.py:78 msgid "Thumbnail" msgstr "" @@ -838,7 +838,7 @@ msgid "Featured" msgstr "" #: apps/common/models.py:126 apps/content/models.py:84 apps/exam/models.py:111 -#: apps/survey/models.py:58 +#: apps/survey/models.py:59 msgid "Format" msgstr "" @@ -866,49 +866,53 @@ msgstr "" msgid "Start Time" msgstr "" -#: apps/common/models.py:154 apps/content/models.py:314 +#: apps/common/models.py:153 +msgid "Lock" +msgstr "" + +#: apps/common/models.py:155 apps/content/models.py:314 #: apps/content/models.py:479 msgid "Context Key" msgstr "" -#: apps/common/models.py:155 apps/content/models.py:315 +#: apps/common/models.py:156 apps/content/models.py:315 msgid "Mode" msgstr "" -#: apps/common/models.py:164 +#: apps/common/models.py:165 msgid "Possible Point" msgstr "" -#: apps/common/models.py:165 +#: apps/common/models.py:166 msgid "Earned Point" msgstr "" -#: apps/common/models.py:166 apps/course/models.py:773 +#: apps/common/models.py:167 apps/course/models.py:774 msgid "Score" msgstr "" -#: apps/common/models.py:167 apps/content/models.py:313 -#: apps/course/models.py:775 +#: apps/common/models.py:168 apps/content/models.py:313 +#: apps/course/models.py:776 msgid "Passed" msgstr "" -#: apps/common/models.py:170 apps/course/models.py:776 +#: apps/common/models.py:171 apps/course/models.py:777 msgid "Confirmed" msgstr "" -#: apps/common/models.py:177 +#: apps/common/models.py:178 msgid "Cannot confirm without completion" msgstr "" -#: apps/common/models.py:182 +#: apps/common/models.py:183 msgid "Grading Due Days" msgstr "" -#: apps/common/models.py:183 +#: apps/common/models.py:184 msgid "Appeal Deadline Days" msgstr "" -#: apps/common/models.py:184 +#: apps/common/models.py:185 msgid "Confirm Due Days" msgstr "" @@ -1353,7 +1357,7 @@ msgstr "" msgid "Classifications" msgstr "" -#: apps/competency/models.py:103 apps/course/models.py:119 +#: apps/competency/models.py:103 apps/course/models.py:120 msgid "Level" msgstr "" @@ -1473,7 +1477,7 @@ msgstr "" #: apps/competency/models.py:246 apps/competency/models.py:262 #: apps/competency/models.py:279 apps/competency/models.py:295 -#: apps/course/admin.py:37 apps/course/models.py:310 +#: apps/course/admin.py:37 apps/course/models.py:311 msgid "Certificate" msgstr "" @@ -1517,7 +1521,7 @@ msgstr "" #: apps/competency/models.py:305 apps/learning/models.py:91 #: apps/learning/models.py:474 apps/operation/models.py:380 -#: apps/studio/models.py:20 +#: apps/studio/models.py:21 apps/tutor/models.py:42 msgid "Content ID" msgstr "" @@ -1646,7 +1650,7 @@ msgstr "" #: apps/content/models.py:92 apps/content/models.py:226 #: apps/content/models.py:238 apps/content/models.py:255 #: apps/content/models.py:309 apps/content/models.py:477 -#: apps/course/models.py:380 apps/warehouse/models.py:125 +#: apps/course/models.py:381 apps/warehouse/models.py:125 msgid "Media" msgstr "" @@ -1682,7 +1686,7 @@ msgstr "" msgid "Public Access Medias" msgstr "" -#: apps/content/models.py:257 apps/discussion/models.py:326 +#: apps/content/models.py:257 apps/discussion/models.py:327 #: apps/operation/models.py:129 apps/operation/models.py:546 #: apps/operation/models.py:698 msgid "Body" @@ -1724,7 +1728,7 @@ msgstr "" msgid "Notes" msgstr "" -#: apps/course/admin.py:30 apps/course/models.py:282 +#: apps/course/admin.py:30 apps/course/models.py:283 #: apps/operation/models.py:77 msgid "Category" msgstr "" @@ -1733,19 +1737,19 @@ msgstr "" msgid "Categories" msgstr "" -#: apps/course/admin.py:68 apps/course/models.py:296 apps/course/models.py:302 +#: apps/course/admin.py:68 apps/course/models.py:297 apps/course/models.py:303 msgid "Related Course" msgstr "" -#: apps/course/admin.py:69 apps/course/models.py:303 +#: apps/course/admin.py:69 apps/course/models.py:304 msgid "Related Courses" msgstr "" -#: apps/course/apps.py:8 apps/course/models.py:128 apps/course/models.py:281 -#: apps/course/models.py:295 apps/course/models.py:309 -#: apps/course/models.py:323 apps/course/models.py:338 -#: apps/course/models.py:358 apps/course/models.py:399 -#: apps/course/models.py:441 apps/course/models.py:570 +#: apps/course/apps.py:8 apps/course/models.py:129 apps/course/models.py:282 +#: apps/course/models.py:296 apps/course/models.py:310 +#: apps/course/models.py:324 apps/course/models.py:339 +#: apps/course/models.py:359 apps/course/models.py:400 +#: apps/course/models.py:442 apps/course/models.py:571 #: apps/warehouse/models.py:156 apps/warehouse/views.py:70 msgid "Course" msgstr "" @@ -2091,208 +2095,208 @@ msgstr "" msgid "Continue Learning" msgstr "" -#: apps/course/models.py:92 +#: apps/course/models.py:93 msgid "Templates" msgstr "" -#: apps/course/models.py:95 apps/course/models.py:123 +#: apps/course/models.py:96 apps/course/models.py:124 msgid "Message Preset" msgstr "" -#: apps/course/models.py:96 +#: apps/course/models.py:97 msgid "Message Presets" msgstr "" -#: apps/course/models.py:110 +#: apps/course/models.py:111 msgid "Beginner" msgstr "" -#: apps/course/models.py:111 +#: apps/course/models.py:112 msgid "Intermediate" msgstr "" -#: apps/course/models.py:112 +#: apps/course/models.py:113 msgid "Advanced" msgstr "" -#: apps/course/models.py:113 +#: apps/course/models.py:114 msgid "Common" msgstr "" -#: apps/course/models.py:116 +#: apps/course/models.py:117 msgid "Objective" msgstr "" -#: apps/course/models.py:117 +#: apps/course/models.py:118 msgid "Preview URL" msgstr "" -#: apps/course/models.py:118 +#: apps/course/models.py:119 msgid "Effort Hours" msgstr "" -#: apps/course/models.py:121 apps/operation/models.py:209 +#: apps/course/models.py:122 apps/operation/models.py:209 #: apps/operation/models.py:221 apps/operation/models.py:228 msgid "FAQ" msgstr "" -#: apps/course/models.py:129 +#: apps/course/models.py:130 msgid "Courses" msgstr "" -#: apps/course/models.py:283 apps/course/models.py:297 -#: apps/course/models.py:311 apps/course/models.py:325 -#: apps/course/models.py:340 apps/course/models.py:359 -#: apps/course/models.py:401 apps/tracking/models.py:28 +#: apps/course/models.py:284 apps/course/models.py:298 +#: apps/course/models.py:312 apps/course/models.py:326 +#: apps/course/models.py:341 apps/course/models.py:360 +#: apps/course/models.py:402 apps/tracking/models.py:28 msgid "Label" msgstr "" -#: apps/course/models.py:288 +#: apps/course/models.py:289 msgid "Course Category" msgstr "" -#: apps/course/models.py:289 +#: apps/course/models.py:290 msgid "Course Categories" msgstr "" -#: apps/course/models.py:316 +#: apps/course/models.py:317 msgid "Course Certificate" msgstr "" -#: apps/course/models.py:317 +#: apps/course/models.py:318 msgid "Course Certificates" msgstr "" -#: apps/course/models.py:324 apps/operation/models.py:179 +#: apps/course/models.py:325 apps/operation/models.py:179 msgid "Instructor" msgstr "" -#: apps/course/models.py:326 +#: apps/course/models.py:327 msgid "Lead" msgstr "" -#: apps/course/models.py:331 +#: apps/course/models.py:332 msgid "Course Instructor" msgstr "" -#: apps/course/models.py:332 +#: apps/course/models.py:333 msgid "Course Instructors" msgstr "" -#: apps/course/models.py:339 apps/survey/apps.py:8 apps/survey/models.py:85 -#: apps/survey/models.py:119 apps/warehouse/models.py:130 +#: apps/course/models.py:340 apps/survey/apps.py:8 apps/survey/models.py:86 +#: apps/survey/models.py:120 apps/warehouse/models.py:130 #: apps/warehouse/views.py:46 msgid "Survey" msgstr "" -#: apps/course/models.py:341 apps/course/models.py:360 -#: apps/course/models.py:402 +#: apps/course/models.py:342 apps/course/models.py:361 +#: apps/course/models.py:403 msgid "Start Offset (Days)" msgstr "" -#: apps/course/models.py:342 apps/course/models.py:361 -#: apps/course/models.py:403 +#: apps/course/models.py:343 apps/course/models.py:362 +#: apps/course/models.py:404 msgid "End Offset (Days)" msgstr "" -#: apps/course/models.py:347 +#: apps/course/models.py:348 msgid "Course Survey" msgstr "" -#: apps/course/models.py:348 +#: apps/course/models.py:349 msgid "Course Surveys" msgstr "" -#: apps/course/models.py:366 apps/course/models.py:379 +#: apps/course/models.py:367 apps/course/models.py:380 msgid "Lesson" msgstr "" -#: apps/course/models.py:367 +#: apps/course/models.py:368 msgid "Lessons" msgstr "" -#: apps/course/models.py:385 +#: apps/course/models.py:386 msgid "Lesson Media" msgstr "" -#: apps/course/models.py:386 +#: apps/course/models.py:387 msgid "Lesson Medias" msgstr "" -#: apps/course/models.py:400 +#: apps/course/models.py:401 msgid "Weight" msgstr "" -#: apps/course/models.py:407 apps/store/models.py:79 +#: apps/course/models.py:408 apps/store/models.py:79 msgid "Item Type" msgstr "" -#: apps/course/models.py:410 apps/store/models.py:80 +#: apps/course/models.py:411 apps/store/models.py:80 msgid "Item ID" msgstr "" -#: apps/course/models.py:416 +#: apps/course/models.py:417 msgid "Assessment" msgstr "" -#: apps/course/models.py:417 +#: apps/course/models.py:418 msgid "Assessments" msgstr "" -#: apps/course/models.py:442 +#: apps/course/models.py:443 msgid "Assessment Weight" msgstr "" -#: apps/course/models.py:443 +#: apps/course/models.py:444 msgid "Completion Weight" msgstr "" -#: apps/course/models.py:444 +#: apps/course/models.py:445 msgid "Completion Passing Point" msgstr "" -#: apps/course/models.py:447 +#: apps/course/models.py:448 msgid "Grading Policy" msgstr "" -#: apps/course/models.py:448 +#: apps/course/models.py:449 msgid "Grading Policies" msgstr "" -#: apps/course/models.py:574 apps/course/models.py:771 +#: apps/course/models.py:575 apps/course/models.py:772 msgid "Engagement" msgstr "" -#: apps/course/models.py:575 +#: apps/course/models.py:576 msgid "Engagements" msgstr "" -#: apps/course/models.py:634 +#: apps/course/models.py:635 msgid "Course Completion Certificate" msgstr "" -#: apps/course/models.py:637 +#: apps/course/models.py:638 #, python-format msgid "%(hours)s hours" msgstr "" -#: apps/course/models.py:772 +#: apps/course/models.py:773 msgid "Details" msgstr "" -#: apps/course/models.py:774 +#: apps/course/models.py:775 msgid "Completion Rate" msgstr "" -#: apps/course/models.py:781 +#: apps/course/models.py:782 msgid "Gradebook" msgstr "" -#: apps/course/models.py:782 +#: apps/course/models.py:783 msgid "Gradebooks" msgstr "" -#: apps/discussion/admin.py:117 apps/discussion/models.py:329 +#: apps/discussion/admin.py:117 apps/discussion/models.py:330 msgid "Post" msgstr "" @@ -2338,15 +2342,15 @@ msgstr "" msgid "Discussions" msgstr "" -#: apps/discussion/models.py:324 apps/operation/models.py:785 +#: apps/discussion/models.py:325 apps/operation/models.py:785 msgid "Parent" msgstr "" -#: apps/discussion/models.py:330 +#: apps/discussion/models.py:331 msgid "Posts" msgstr "" -#: apps/exam/apps.py:8 apps/exam/models.py:153 apps/exam/models.py:240 +#: apps/exam/apps.py:8 apps/exam/models.py:153 apps/exam/models.py:256 #: apps/warehouse/models.py:138 apps/warehouse/views.py:49 msgid "Exam" msgstr "" @@ -2355,15 +2359,15 @@ msgstr "" msgid "Question Composition" msgstr "" -#: apps/exam/models.py:105 apps/survey/models.py:53 +#: apps/exam/models.py:105 apps/survey/models.py:54 msgid "Single Choice" msgstr "" -#: apps/exam/models.py:106 apps/survey/models.py:54 +#: apps/exam/models.py:106 apps/survey/models.py:55 msgid "Text Input" msgstr "" -#: apps/exam/models.py:107 apps/survey/models.py:55 +#: apps/exam/models.py:107 apps/survey/models.py:56 msgid "Number Input" msgstr "" @@ -2371,7 +2375,7 @@ msgstr "" msgid "Essay" msgstr "" -#: apps/exam/models.py:114 apps/quiz/models.py:97 apps/survey/models.py:61 +#: apps/exam/models.py:114 apps/quiz/models.py:97 apps/survey/models.py:62 msgid "Options" msgstr "" @@ -2383,7 +2387,7 @@ msgstr "" msgid "Correct Criteria" msgstr "" -#: apps/exam/models.py:138 apps/operation/admin.py:158 +#: apps/exam/models.py:138 apps/operation/admin.py:153 #: apps/operation/models.py:479 apps/quiz/models.py:117 msgid "Explanation" msgstr "" @@ -2400,16 +2404,16 @@ msgstr "" msgid "Exams" msgstr "" -#: apps/exam/models.py:366 apps/exam/models.py:376 apps/quiz/models.py:348 -#: apps/survey/models.py:121 +#: apps/exam/models.py:383 apps/exam/models.py:393 apps/quiz/models.py:349 +#: apps/survey/models.py:122 msgid "Answers" msgstr "" -#: apps/exam/models.py:369 +#: apps/exam/models.py:386 msgid "Temporary Answer" msgstr "" -#: apps/exam/models.py:370 +#: apps/exam/models.py:387 msgid "Temporary Answers" msgstr "" @@ -2417,32 +2421,32 @@ msgstr "" msgid "Learning" msgstr "" -#: apps/learning/management/commands/setup_demo_data.py:40 -#: apps/learning/management/commands/setup_demo_data.py:78 +#: apps/learning/management/commands/setup_demo_data.py:44 +#: apps/learning/management/commands/setup_demo_data.py:82 msgid "Demo Public Catalog" msgstr "" -#: apps/learning/management/commands/setup_demo_data.py:43 +#: apps/learning/management/commands/setup_demo_data.py:47 msgid "Demo Personal Catalog" msgstr "" -#: apps/learning/management/commands/setup_demo_data.py:45 +#: apps/learning/management/commands/setup_demo_data.py:49 msgid "" "Personal catalogs are available only to you. Video, PDF, Survey, Quiz, " "Assignment, Discussion, and Exam content are available here." msgstr "" -#: apps/learning/management/commands/setup_demo_data.py:56 +#: apps/learning/management/commands/setup_demo_data.py:60 msgid "Demo Cohort Catalog" msgstr "" -#: apps/learning/management/commands/setup_demo_data.py:58 +#: apps/learning/management/commands/setup_demo_data.py:62 msgid "" "Cohort catalogs are available when you are in a cohort. Video, PDF, Survey, " "Quiz, Assignment, Discussion, and Exam content are available here." msgstr "" -#: apps/learning/management/commands/setup_demo_data.py:145 +#: apps/learning/management/commands/setup_demo_data.py:153 msgid "" "Public catalogs are available to everyone. Generally, public catalogs are " "composef of video or PDF content." @@ -2528,11 +2532,11 @@ msgstr "" msgid "Solved" msgstr "" -#: apps/operation/admin.py:213 +#: apps/operation/admin.py:208 msgid "Agreement History" msgstr "" -#: apps/operation/admin.py:214 +#: apps/operation/admin.py:209 msgid "Agreement Histories" msgstr "" @@ -2668,23 +2672,19 @@ msgstr "" msgid "Review" msgstr "" -#: apps/operation/models.py:481 apps/operation/models.py:767 -msgid "Closed" -msgstr "" - -#: apps/operation/models.py:485 +#: apps/operation/models.py:484 msgid "Question Type" msgstr "" -#: apps/operation/models.py:486 +#: apps/operation/models.py:485 msgid "Question ID" msgstr "" -#: apps/operation/models.py:490 +#: apps/operation/models.py:489 msgid "Grade Appeal" msgstr "" -#: apps/operation/models.py:491 +#: apps/operation/models.py:490 msgid "Grade Appeals" msgstr "" @@ -2730,7 +2730,7 @@ msgstr "" msgid "Kind" msgstr "" -#: apps/operation/models.py:639 apps/survey/models.py:62 +#: apps/operation/models.py:639 apps/survey/models.py:63 msgid "Mandatory" msgstr "" @@ -2802,6 +2802,10 @@ msgstr "" msgid "Rating Average" msgstr "" +#: apps/operation/models.py:767 +msgid "Closed" +msgstr "" + #: apps/operation/models.py:771 apps/operation/models.py:784 msgid "Thread" msgstr "" @@ -3232,47 +3236,47 @@ msgstr "" msgid "Editing Histories" msgstr "" -#: apps/studio/models.py:15 +#: apps/studio/models.py:16 msgid "Author" msgstr "" -#: apps/studio/models.py:16 +#: apps/studio/models.py:17 msgid "Edited" msgstr "" -#: apps/studio/models.py:17 +#: apps/studio/models.py:18 msgid "Action" msgstr "" -#: apps/studio/models.py:19 +#: apps/studio/models.py:20 apps/tutor/models.py:41 msgid "Content type" msgstr "" -#: apps/studio/models.py:24 +#: apps/studio/models.py:25 msgid "Content Editing" msgstr "" -#: apps/studio/models.py:25 +#: apps/studio/models.py:26 msgid "Content Editings" msgstr "" -#: apps/survey/models.py:80 +#: apps/survey/models.py:81 msgid "Complete Message" msgstr "" -#: apps/survey/models.py:81 +#: apps/survey/models.py:82 msgid "Anonymous" msgstr "" -#: apps/survey/models.py:82 +#: apps/survey/models.py:83 msgid "Show Results" msgstr "" -#: apps/survey/models.py:86 +#: apps/survey/models.py:87 msgid "Surveys" msgstr "" -#: apps/survey/models.py:120 +#: apps/survey/models.py:121 msgid "Respondent" msgstr "" @@ -3352,6 +3356,26 @@ msgstr "" msgid "Hot Events" msgstr "" +#: apps/tutor/admin.py:12 +msgid "Allocation History" +msgstr "" + +#: apps/tutor/admin.py:13 +msgid "Allocation Histories" +msgstr "" + +#: apps/tutor/apps.py:8 apps/tutor/models.py:37 +msgid "Tutor" +msgstr "" + +#: apps/tutor/models.py:46 +msgid "Tutor Allocation" +msgstr "" + +#: apps/tutor/models.py:47 +msgid "Tutor Allocations" +msgstr "" + #: apps/warehouse/apps.py:8 msgid "Warehouse" msgstr "" @@ -3488,6 +3512,6 @@ msgstr "" msgid "Courses Passed" msgstr "" -#: minima/settings.py:265 +#: minima/settings.py:266 msgid "Storage" msgstr "" diff --git a/core/locale/ko/LC_MESSAGES/django.mo b/core/locale/ko/LC_MESSAGES/django.mo index bf32a3e..3c70ed6 100644 Binary files a/core/locale/ko/LC_MESSAGES/django.mo and b/core/locale/ko/LC_MESSAGES/django.mo differ diff --git a/core/locale/ko/LC_MESSAGES/django.po b/core/locale/ko/LC_MESSAGES/django.po index a368cee..f067592 100644 --- a/core/locale/ko/LC_MESSAGES/django.po +++ b/core/locale/ko/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-03-07 13:30+0900\n" +"POT-Creation-Date: 2026-03-09 19:06+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -54,11 +54,11 @@ msgstr "" "{user}에 대한 임시 비밀번호가 생성되었습니다: {password}. {hours}시간 후에 만" "료됩니다." -#: apps/account/admin.py:87 minima/settings.py:175 +#: apps/account/admin.py:87 minima/settings.py:176 msgid "English" msgstr "영어" -#: apps/account/admin.py:87 minima/settings.py:175 +#: apps/account/admin.py:87 minima/settings.py:176 msgid "Korean" msgstr "한국어" @@ -227,8 +227,8 @@ msgstr "" msgid "Email" msgstr "이메일" -#: apps/account/models.py:114 apps/assignment/models.py:524 -#: apps/assignment/models.py:542 apps/assignment/models.py:561 +#: apps/account/models.py:114 apps/assignment/models.py:533 +#: apps/assignment/models.py:551 apps/assignment/models.py:570 #: apps/competency/certificate.py:274 apps/competency/models.py:61 #: apps/competency/models.py:102 apps/competency/models.py:122 #: apps/competency/models.py:140 apps/competency/models.py:170 @@ -269,12 +269,12 @@ msgid "Preferences" msgstr "환경설정" #: apps/account/models.py:122 apps/assistant/models.py:41 -#: apps/common/models.py:153 apps/competency/models.py:173 +#: apps/common/models.py:154 apps/competency/models.py:173 #: apps/competency/models.py:241 apps/learning/models.py:80 #: apps/learning/models.py:335 apps/operation/models.py:176 #: apps/operation/models.py:224 apps/operation/models.py:619 #: apps/operation/models.py:638 apps/store/models.py:107 -#: apps/store/models.py:148 +#: apps/store/models.py:148 apps/tutor/models.py:38 msgid "Active" msgstr "활성" @@ -447,7 +447,7 @@ msgstr "이용약관" #: apps/assignment/admin.py:107 apps/assignment/admin.py:108 #: apps/discussion/admin.py:69 apps/discussion/admin.py:70 #: apps/operation/admin.py:103 apps/operation/admin.py:104 -#: apps/operation/admin.py:150 apps/operation/admin.py:151 +#: apps/operation/admin.py:145 apps/operation/admin.py:146 #: apps/operation/models.py:244 apps/operation/models.py:265 msgid "Attachments" msgstr "첨부파일" @@ -470,19 +470,19 @@ msgstr "채점 기록" msgid "Grading Histories" msgstr "채점 기록" -#: apps/assignment/admin.py:148 apps/assignment/models.py:435 +#: apps/assignment/admin.py:148 apps/assignment/models.py:436 #: apps/course/admin.py:132 apps/course/admin.py:149 -#: apps/discussion/admin.py:103 apps/discussion/models.py:434 -#: apps/exam/admin.py:133 apps/exam/models.py:395 apps/quiz/models.py:360 +#: apps/discussion/admin.py:103 apps/discussion/models.py:435 +#: apps/exam/admin.py:133 apps/exam/models.py:412 apps/quiz/models.py:361 msgid "Grade" msgstr "채점" -#: apps/assignment/admin.py:166 apps/common/models.py:163 +#: apps/assignment/admin.py:166 apps/common/models.py:164 #: apps/discussion/admin.py:122 apps/exam/admin.py:152 msgid "Earned Details" msgstr "득점 세부사항" -#: apps/assignment/admin.py:171 apps/common/models.py:168 +#: apps/assignment/admin.py:171 apps/common/models.py:169 #: apps/discussion/admin.py:132 apps/exam/admin.py:157 msgid "Feedback" msgstr "피드백" @@ -494,34 +494,34 @@ msgid "Assignment" msgstr "과제" #: apps/assignment/models.py:81 apps/assistant/models.py:39 -#: apps/common/models.py:121 apps/course/models.py:90 -#: apps/discussion/models.py:79 apps/discussion/models.py:325 +#: apps/common/models.py:121 apps/course/models.py:91 +#: apps/discussion/models.py:79 apps/discussion/models.py:326 #: apps/exam/models.py:76 apps/operation/models.py:128 #: apps/operation/models.py:192 apps/operation/models.py:376 #: apps/operation/models.py:545 apps/operation/models.py:636 -#: apps/operation/models.py:758 apps/quiz/models.py:72 apps/survey/models.py:37 +#: apps/operation/models.py:758 apps/quiz/models.py:72 apps/survey/models.py:38 msgid "Title" msgstr "제목" -#: apps/assignment/models.py:82 apps/assignment/models.py:525 -#: apps/assignment/models.py:543 apps/assignment/models.py:562 +#: apps/assignment/models.py:82 apps/assignment/models.py:534 +#: apps/assignment/models.py:552 apps/assignment/models.py:571 #: apps/common/models.py:122 apps/competency/models.py:141 #: apps/competency/models.py:171 apps/competency/models.py:237 -#: apps/course/models.py:91 apps/discussion/models.py:80 apps/exam/models.py:77 +#: apps/course/models.py:92 apps/discussion/models.py:80 apps/exam/models.py:77 #: apps/learning/models.py:333 apps/operation/models.py:206 #: apps/operation/models.py:637 apps/operation/models.py:759 #: apps/partner/models.py:44 apps/partner/models.py:67 #: apps/partner/models.py:177 apps/quiz/models.py:73 apps/store/models.py:56 -#: apps/store/models.py:103 apps/survey/models.py:38 +#: apps/store/models.py:103 apps/survey/models.py:39 msgid "Description" msgstr "설명" #: apps/assignment/models.py:83 apps/assignment/models.py:124 -#: apps/content/models.py:83 apps/course/models.py:115 +#: apps/content/models.py:83 apps/course/models.py:116 #: apps/discussion/models.py:81 apps/discussion/models.py:128 #: apps/exam/models.py:78 apps/exam/models.py:147 apps/operation/models.py:239 -#: apps/quiz/models.py:74 apps/quiz/models.py:126 apps/survey/models.py:39 -#: apps/survey/models.py:78 +#: apps/quiz/models.py:74 apps/quiz/models.py:126 apps/survey/models.py:40 +#: apps/survey/models.py:79 msgid "Owner" msgstr "소유자" @@ -530,12 +530,12 @@ msgstr "소유자" #: apps/discussion/models.py:104 apps/discussion/models.py:130 #: apps/exam/models.py:82 apps/exam/models.py:110 apps/exam/models.py:149 #: apps/quiz/models.py:78 apps/quiz/models.py:94 apps/quiz/models.py:127 -#: apps/survey/models.py:42 apps/survey/models.py:57 apps/survey/models.py:79 +#: apps/survey/models.py:43 apps/survey/models.py:58 apps/survey/models.py:80 msgid "Question Pool" msgstr "문제 은행" #: apps/assignment/models.py:87 apps/discussion/models.py:85 -#: apps/exam/models.py:83 apps/quiz/models.py:79 apps/survey/models.py:43 +#: apps/exam/models.py:83 apps/quiz/models.py:79 apps/survey/models.py:44 msgid "Question Pools" msgstr "문제 은행" @@ -545,12 +545,12 @@ msgstr "문제 은행" #: apps/exam/models.py:118 apps/exam/models.py:135 apps/operation/admin.py:131 #: apps/operation/models.py:222 apps/operation/models.py:377 #: apps/quiz/admin.py:50 apps/quiz/models.py:95 apps/quiz/models.py:101 -#: apps/quiz/models.py:115 apps/survey/models.py:59 apps/survey/models.py:67 +#: apps/quiz/models.py:115 apps/survey/models.py:60 apps/survey/models.py:68 msgid "Question" msgstr "문제" #: apps/assignment/models.py:108 apps/discussion/models.py:106 -#: apps/exam/models.py:113 apps/quiz/models.py:96 apps/survey/models.py:60 +#: apps/exam/models.py:113 apps/quiz/models.py:96 apps/survey/models.py:61 msgid "Supplement" msgstr "보충" @@ -567,20 +567,20 @@ msgid "Plagiarism Threshold Percentage" msgstr "표절 유사도 기준" #: apps/assignment/models.py:115 apps/discussion/models.py:115 -#: apps/exam/admin.py:77 apps/exam/models.py:119 apps/exam/models.py:242 +#: apps/exam/admin.py:77 apps/exam/models.py:119 apps/exam/models.py:258 #: apps/quiz/admin.py:51 apps/quiz/models.py:102 apps/quiz/models.py:243 -#: apps/survey/models.py:68 +#: apps/survey/models.py:69 msgid "Questions" msgstr "문제" -#: apps/assignment/models.py:125 apps/course/models.py:122 +#: apps/assignment/models.py:125 apps/course/models.py:123 #: apps/discussion/models.py:129 apps/exam/models.py:148 #: apps/operation/models.py:196 msgid "Honor Code" msgstr "윤리 서약" -#: apps/assignment/models.py:127 apps/assignment/models.py:528 -#: apps/assignment/models.py:541 +#: apps/assignment/models.py:127 apps/assignment/models.py:537 +#: apps/assignment/models.py:550 msgid "Rubric" msgstr "평가 척도" @@ -592,134 +592,134 @@ msgstr "샘플 첨부파일" msgid "Assignments" msgstr "과제" -#: apps/assignment/models.py:269 apps/course/models.py:571 -#: apps/discussion/models.py:203 apps/exam/models.py:241 +#: apps/assignment/models.py:269 apps/course/models.py:572 +#: apps/discussion/models.py:203 apps/exam/models.py:257 #: apps/operation/models.py:478 apps/quiz/models.py:242 msgid "Learner" msgstr "학습자" #: apps/assignment/models.py:271 apps/discussion/models.py:205 -#: apps/exam/models.py:243 apps/quiz/models.py:244 +#: apps/exam/models.py:259 apps/quiz/models.py:244 msgid "Retry" msgstr "재응시" -#: apps/assignment/models.py:274 apps/assignment/models.py:398 -#: apps/assignment/models.py:431 apps/assignment/models.py:487 -#: apps/discussion/models.py:208 apps/discussion/models.py:323 -#: apps/discussion/models.py:430 apps/exam/models.py:246 -#: apps/exam/models.py:365 apps/exam/models.py:375 apps/exam/models.py:391 -#: apps/quiz/models.py:247 apps/quiz/models.py:347 apps/quiz/models.py:357 +#: apps/assignment/models.py:274 apps/assignment/models.py:399 +#: apps/assignment/models.py:432 apps/assignment/models.py:496 +#: apps/discussion/models.py:208 apps/discussion/models.py:324 +#: apps/discussion/models.py:431 apps/exam/models.py:262 +#: apps/exam/models.py:382 apps/exam/models.py:392 apps/exam/models.py:408 +#: apps/quiz/models.py:247 apps/quiz/models.py:348 apps/quiz/models.py:358 msgid "Attempt" msgstr "응시" #: apps/assignment/models.py:275 apps/discussion/models.py:209 -#: apps/exam/models.py:247 apps/quiz/models.py:248 +#: apps/exam/models.py:263 apps/quiz/models.py:248 msgid "Attempts" msgstr "응시" -#: apps/assignment/models.py:399 apps/operation/models.py:223 +#: apps/assignment/models.py:400 apps/operation/models.py:223 #: apps/operation/models.py:450 msgid "Answer" msgstr "답변" -#: apps/assignment/models.py:400 +#: apps/assignment/models.py:401 msgid "Extracted Text" msgstr "추출된 텍스트" -#: apps/assignment/models.py:403 apps/exam/models.py:379 -#: apps/quiz/models.py:351 apps/survey/models.py:124 +#: apps/assignment/models.py:404 apps/exam/models.py:396 +#: apps/quiz/models.py:352 apps/survey/models.py:125 msgid "Submission" msgstr "제출" -#: apps/assignment/models.py:404 apps/exam/models.py:380 -#: apps/quiz/models.py:352 apps/survey/models.py:125 +#: apps/assignment/models.py:405 apps/exam/models.py:397 +#: apps/quiz/models.py:353 apps/survey/models.py:126 msgid "Submissions" msgstr "제출" -#: apps/assignment/models.py:432 apps/course/models.py:778 -#: apps/discussion/models.py:431 apps/exam/models.py:392 +#: apps/assignment/models.py:433 apps/course/models.py:779 +#: apps/discussion/models.py:432 apps/exam/models.py:409 msgid "Grader" msgstr "채점자" -#: apps/assignment/models.py:436 apps/discussion/models.py:435 -#: apps/exam/models.py:396 apps/quiz/models.py:361 +#: apps/assignment/models.py:437 apps/discussion/models.py:436 +#: apps/exam/models.py:413 apps/quiz/models.py:362 msgid "Grades" msgstr "채점" -#: apps/assignment/models.py:482 +#: apps/assignment/models.py:491 msgid "Not Detected" msgstr "없음" -#: apps/assignment/models.py:483 +#: apps/assignment/models.py:492 msgid "Detected" msgstr "탐지됨" -#: apps/assignment/models.py:484 +#: apps/assignment/models.py:493 msgid "Excused" msgstr "면제됨" -#: apps/assignment/models.py:485 +#: apps/assignment/models.py:494 msgid "Not Resolved" msgstr "알 수 없음" -#: apps/assignment/models.py:488 apps/store/models.py:54 +#: apps/assignment/models.py:497 apps/store/models.py:54 #: apps/store/models.py:184 apps/store/models.py:361 apps/store/models.py:381 msgid "Status" msgstr "상태" -#: apps/assignment/models.py:489 +#: apps/assignment/models.py:498 msgid "Similarity Percentage" msgstr "유사도" -#: apps/assignment/models.py:490 +#: apps/assignment/models.py:499 msgid "Flagged Text" msgstr "검토 텍스트" -#: apps/assignment/models.py:491 +#: apps/assignment/models.py:500 msgid "Source Text" msgstr "소스 텍스트" -#: apps/assignment/models.py:492 +#: apps/assignment/models.py:501 msgid "Source User ID" msgstr "소스 사용자 ID" -#: apps/assignment/models.py:493 apps/store/models.py:385 +#: apps/assignment/models.py:502 apps/store/models.py:385 msgid "Reason" msgstr "사유" -#: apps/assignment/models.py:496 +#: apps/assignment/models.py:505 msgid "Plagiarism Check" msgstr "표절 검사" -#: apps/assignment/models.py:497 +#: apps/assignment/models.py:506 msgid "Plagiarism Checks" msgstr "표절 검사" -#: apps/assignment/models.py:529 +#: apps/assignment/models.py:538 msgid "Rubrics" msgstr "평가 척도" -#: apps/assignment/models.py:546 +#: apps/assignment/models.py:555 msgid "Rubric Criterion" msgstr "평가 항목" -#: apps/assignment/models.py:547 +#: apps/assignment/models.py:556 msgid "Rubric Criteria" msgstr "평가 항목" -#: apps/assignment/models.py:560 +#: apps/assignment/models.py:569 msgid "Criterion" msgstr "평가 기준" -#: apps/assignment/models.py:563 apps/exam/models.py:115 apps/quiz/models.py:98 +#: apps/assignment/models.py:572 apps/exam/models.py:115 apps/quiz/models.py:98 msgid "Point" msgstr "배점" -#: apps/assignment/models.py:566 +#: apps/assignment/models.py:575 msgid "Performance Level" msgstr "성취 수준" -#: apps/assignment/models.py:567 +#: apps/assignment/models.py:576 msgid "Performance Levels" msgstr "성취 수준" @@ -733,7 +733,7 @@ msgid "AI Assistant" msgstr "AI 어시스턴트" #: apps/assistant/models.py:28 apps/content/models.py:478 -#: apps/content/models.py:483 apps/course/models.py:777 +#: apps/content/models.py:483 apps/course/models.py:778 #: apps/learning/models.py:500 apps/learning/models.py:528 msgid "Note" msgstr "노트" @@ -772,11 +772,11 @@ msgid "Response" msgstr "응답" #: apps/assistant/models.py:66 apps/operation/models.py:382 -#: apps/operation/models.py:482 apps/operation/models.py:768 +#: apps/operation/models.py:481 apps/operation/models.py:768 msgid "Path" msgstr "경로" -#: apps/assistant/models.py:67 apps/common/models.py:169 +#: apps/assistant/models.py:67 apps/common/models.py:170 #: apps/store/models.py:357 apps/store/models.py:378 msgid "Completed" msgstr "완료됨" @@ -852,7 +852,7 @@ msgstr "대상" #: apps/common/models.py:124 apps/competency/models.py:239 #: apps/competency/models.py:298 apps/content/models.py:82 -#: apps/learning/models.py:334 apps/store/models.py:57 apps/survey/models.py:77 +#: apps/learning/models.py:334 apps/store/models.py:57 apps/survey/models.py:78 msgid "Thumbnail" msgstr "썸네일" @@ -861,7 +861,7 @@ msgid "Featured" msgstr "추천" #: apps/common/models.py:126 apps/content/models.py:84 apps/exam/models.py:111 -#: apps/survey/models.py:58 +#: apps/survey/models.py:59 msgid "Format" msgstr "형식" @@ -889,49 +889,53 @@ msgstr "게시됨" msgid "Start Time" msgstr "시작 일시" -#: apps/common/models.py:154 apps/content/models.py:314 +#: apps/common/models.py:153 +msgid "Lock" +msgstr "잠김" + +#: apps/common/models.py:155 apps/content/models.py:314 #: apps/content/models.py:479 msgid "Context Key" msgstr "컨텍스트 키" -#: apps/common/models.py:155 apps/content/models.py:315 +#: apps/common/models.py:156 apps/content/models.py:315 msgid "Mode" msgstr "모드" -#: apps/common/models.py:164 +#: apps/common/models.py:165 msgid "Possible Point" msgstr "배점" -#: apps/common/models.py:165 +#: apps/common/models.py:166 msgid "Earned Point" msgstr "득점" -#: apps/common/models.py:166 apps/course/models.py:773 +#: apps/common/models.py:167 apps/course/models.py:774 msgid "Score" msgstr "점수" -#: apps/common/models.py:167 apps/content/models.py:313 -#: apps/course/models.py:775 +#: apps/common/models.py:168 apps/content/models.py:313 +#: apps/course/models.py:776 msgid "Passed" msgstr "합격" -#: apps/common/models.py:170 apps/course/models.py:776 +#: apps/common/models.py:171 apps/course/models.py:777 msgid "Confirmed" msgstr "확정됨" -#: apps/common/models.py:177 +#: apps/common/models.py:178 msgid "Cannot confirm without completion" msgstr "완료 처리 전 승인 할 수 없습니다." -#: apps/common/models.py:182 +#: apps/common/models.py:183 msgid "Grading Due Days" msgstr "채점 마감 예정 (일)" -#: apps/common/models.py:183 +#: apps/common/models.py:184 msgid "Appeal Deadline Days" msgstr "이의 신청 기한 (일)" -#: apps/common/models.py:184 +#: apps/common/models.py:185 msgid "Confirm Due Days" msgstr "성적 확정 예정 (일)" @@ -1387,7 +1391,7 @@ msgstr "분류" msgid "Classifications" msgstr "역량 분류" -#: apps/competency/models.py:103 apps/course/models.py:119 +#: apps/competency/models.py:103 apps/course/models.py:120 msgid "Level" msgstr "레벨" @@ -1507,7 +1511,7 @@ msgstr "템플릿" #: apps/competency/models.py:246 apps/competency/models.py:262 #: apps/competency/models.py:279 apps/competency/models.py:295 -#: apps/course/admin.py:37 apps/course/models.py:310 +#: apps/course/admin.py:37 apps/course/models.py:311 msgid "Certificate" msgstr "수료증" @@ -1551,7 +1555,7 @@ msgstr "콘텐츠 유형" #: apps/competency/models.py:305 apps/learning/models.py:91 #: apps/learning/models.py:474 apps/operation/models.py:380 -#: apps/studio/models.py:20 +#: apps/studio/models.py:21 apps/tutor/models.py:42 msgid "Content ID" msgstr "콘텐츠 ID" @@ -1682,7 +1686,7 @@ msgstr "퀴즈" #: apps/content/models.py:92 apps/content/models.py:226 #: apps/content/models.py:238 apps/content/models.py:255 #: apps/content/models.py:309 apps/content/models.py:477 -#: apps/course/models.py:380 apps/warehouse/models.py:125 +#: apps/course/models.py:381 apps/warehouse/models.py:125 msgid "Media" msgstr "미디어" @@ -1718,7 +1722,7 @@ msgstr "공개 접근" msgid "Public Access Medias" msgstr "공개 접근" -#: apps/content/models.py:257 apps/discussion/models.py:326 +#: apps/content/models.py:257 apps/discussion/models.py:327 #: apps/operation/models.py:129 apps/operation/models.py:546 #: apps/operation/models.py:698 msgid "Body" @@ -1760,7 +1764,7 @@ msgstr "시청" msgid "Notes" msgstr "노트" -#: apps/course/admin.py:30 apps/course/models.py:282 +#: apps/course/admin.py:30 apps/course/models.py:283 #: apps/operation/models.py:77 msgid "Category" msgstr "카테고리" @@ -1769,19 +1773,19 @@ msgstr "카테고리" msgid "Categories" msgstr "카테고리" -#: apps/course/admin.py:68 apps/course/models.py:296 apps/course/models.py:302 +#: apps/course/admin.py:68 apps/course/models.py:297 apps/course/models.py:303 msgid "Related Course" msgstr "관련 과정" -#: apps/course/admin.py:69 apps/course/models.py:303 +#: apps/course/admin.py:69 apps/course/models.py:304 msgid "Related Courses" msgstr "관련 과정" -#: apps/course/apps.py:8 apps/course/models.py:128 apps/course/models.py:281 -#: apps/course/models.py:295 apps/course/models.py:309 -#: apps/course/models.py:323 apps/course/models.py:338 -#: apps/course/models.py:358 apps/course/models.py:399 -#: apps/course/models.py:441 apps/course/models.py:570 +#: apps/course/apps.py:8 apps/course/models.py:129 apps/course/models.py:282 +#: apps/course/models.py:296 apps/course/models.py:310 +#: apps/course/models.py:324 apps/course/models.py:339 +#: apps/course/models.py:359 apps/course/models.py:400 +#: apps/course/models.py:442 apps/course/models.py:571 #: apps/warehouse/models.py:156 apps/warehouse/views.py:70 msgid "Course" msgstr "과정" @@ -2142,208 +2146,208 @@ msgstr "과정 학습을 진행하고 있습니다! 진도를 유지하고 목 msgid "Continue Learning" msgstr "학습 계속" -#: apps/course/models.py:92 +#: apps/course/models.py:93 msgid "Templates" msgstr "템플릿" -#: apps/course/models.py:95 apps/course/models.py:123 +#: apps/course/models.py:96 apps/course/models.py:124 msgid "Message Preset" msgstr "메시지 프리셋" -#: apps/course/models.py:96 +#: apps/course/models.py:97 msgid "Message Presets" msgstr "메시지 프리셋" -#: apps/course/models.py:110 +#: apps/course/models.py:111 msgid "Beginner" msgstr "초급" -#: apps/course/models.py:111 +#: apps/course/models.py:112 msgid "Intermediate" msgstr "중급" -#: apps/course/models.py:112 +#: apps/course/models.py:113 msgid "Advanced" msgstr "고급" -#: apps/course/models.py:113 +#: apps/course/models.py:114 msgid "Common" msgstr "공통" -#: apps/course/models.py:116 +#: apps/course/models.py:117 msgid "Objective" msgstr "목표" -#: apps/course/models.py:117 +#: apps/course/models.py:118 msgid "Preview URL" msgstr "미리보기 URL" -#: apps/course/models.py:118 +#: apps/course/models.py:119 msgid "Effort Hours" msgstr "학습 시간" -#: apps/course/models.py:121 apps/operation/models.py:209 +#: apps/course/models.py:122 apps/operation/models.py:209 #: apps/operation/models.py:221 apps/operation/models.py:228 msgid "FAQ" msgstr "자주 묻는 질문" -#: apps/course/models.py:129 +#: apps/course/models.py:130 msgid "Courses" msgstr "과정" -#: apps/course/models.py:283 apps/course/models.py:297 -#: apps/course/models.py:311 apps/course/models.py:325 -#: apps/course/models.py:340 apps/course/models.py:359 -#: apps/course/models.py:401 apps/tracking/models.py:28 +#: apps/course/models.py:284 apps/course/models.py:298 +#: apps/course/models.py:312 apps/course/models.py:326 +#: apps/course/models.py:341 apps/course/models.py:360 +#: apps/course/models.py:402 apps/tracking/models.py:28 msgid "Label" msgstr "레이블" -#: apps/course/models.py:288 +#: apps/course/models.py:289 msgid "Course Category" msgstr "과정 카테고리" -#: apps/course/models.py:289 +#: apps/course/models.py:290 msgid "Course Categories" msgstr "과정 카테고리" -#: apps/course/models.py:316 +#: apps/course/models.py:317 msgid "Course Certificate" msgstr "과정 수료증" -#: apps/course/models.py:317 +#: apps/course/models.py:318 msgid "Course Certificates" msgstr "과정 수료증" -#: apps/course/models.py:324 apps/operation/models.py:179 +#: apps/course/models.py:325 apps/operation/models.py:179 msgid "Instructor" msgstr "강사" -#: apps/course/models.py:326 +#: apps/course/models.py:327 msgid "Lead" msgstr "리드" -#: apps/course/models.py:331 +#: apps/course/models.py:332 msgid "Course Instructor" msgstr "강사" -#: apps/course/models.py:332 +#: apps/course/models.py:333 msgid "Course Instructors" msgstr "강사" -#: apps/course/models.py:339 apps/survey/apps.py:8 apps/survey/models.py:85 -#: apps/survey/models.py:119 apps/warehouse/models.py:130 +#: apps/course/models.py:340 apps/survey/apps.py:8 apps/survey/models.py:86 +#: apps/survey/models.py:120 apps/warehouse/models.py:130 #: apps/warehouse/views.py:46 msgid "Survey" msgstr "설문조사" -#: apps/course/models.py:341 apps/course/models.py:360 -#: apps/course/models.py:402 +#: apps/course/models.py:342 apps/course/models.py:361 +#: apps/course/models.py:403 msgid "Start Offset (Days)" msgstr "시작 오프셋 (일)" -#: apps/course/models.py:342 apps/course/models.py:361 -#: apps/course/models.py:403 +#: apps/course/models.py:343 apps/course/models.py:362 +#: apps/course/models.py:404 msgid "End Offset (Days)" msgstr "종료 오프셋 (일)" -#: apps/course/models.py:347 +#: apps/course/models.py:348 msgid "Course Survey" msgstr "설문조사" -#: apps/course/models.py:348 +#: apps/course/models.py:349 msgid "Course Surveys" msgstr "설문조사" -#: apps/course/models.py:366 apps/course/models.py:379 +#: apps/course/models.py:367 apps/course/models.py:380 msgid "Lesson" msgstr "강의" -#: apps/course/models.py:367 +#: apps/course/models.py:368 msgid "Lessons" msgstr "강의" -#: apps/course/models.py:385 +#: apps/course/models.py:386 msgid "Lesson Media" msgstr "강의 미디어" -#: apps/course/models.py:386 +#: apps/course/models.py:387 msgid "Lesson Medias" msgstr "미디어" -#: apps/course/models.py:400 +#: apps/course/models.py:401 msgid "Weight" msgstr "가중치" -#: apps/course/models.py:407 apps/store/models.py:79 +#: apps/course/models.py:408 apps/store/models.py:79 msgid "Item Type" msgstr "항목 유형" -#: apps/course/models.py:410 apps/store/models.py:80 +#: apps/course/models.py:411 apps/store/models.py:80 msgid "Item ID" msgstr "항목 ID" -#: apps/course/models.py:416 +#: apps/course/models.py:417 msgid "Assessment" msgstr "평가" -#: apps/course/models.py:417 +#: apps/course/models.py:418 msgid "Assessments" msgstr "평가" -#: apps/course/models.py:442 +#: apps/course/models.py:443 msgid "Assessment Weight" msgstr "평가 가중치" -#: apps/course/models.py:443 +#: apps/course/models.py:444 msgid "Completion Weight" msgstr "진도율 점수 반영" -#: apps/course/models.py:444 +#: apps/course/models.py:445 msgid "Completion Passing Point" msgstr "진도율 합격 점수" -#: apps/course/models.py:447 +#: apps/course/models.py:448 msgid "Grading Policy" msgstr "채점 정책" -#: apps/course/models.py:448 +#: apps/course/models.py:449 msgid "Grading Policies" msgstr "채점 정책" -#: apps/course/models.py:574 apps/course/models.py:771 +#: apps/course/models.py:575 apps/course/models.py:772 msgid "Engagement" msgstr "수강" -#: apps/course/models.py:575 +#: apps/course/models.py:576 msgid "Engagements" msgstr "수강" -#: apps/course/models.py:634 +#: apps/course/models.py:635 msgid "Course Completion Certificate" msgstr "과정 수료증" -#: apps/course/models.py:637 +#: apps/course/models.py:638 #, python-format msgid "%(hours)s hours" msgstr "%(hours)s 시간" -#: apps/course/models.py:772 +#: apps/course/models.py:773 msgid "Details" msgstr "세부사항" -#: apps/course/models.py:774 +#: apps/course/models.py:775 msgid "Completion Rate" msgstr "완료율" -#: apps/course/models.py:781 +#: apps/course/models.py:782 msgid "Gradebook" msgstr "성적" -#: apps/course/models.py:782 +#: apps/course/models.py:783 msgid "Gradebooks" msgstr "성적" -#: apps/discussion/admin.py:117 apps/discussion/models.py:329 +#: apps/discussion/admin.py:117 apps/discussion/models.py:330 msgid "Post" msgstr "게시글" @@ -2389,15 +2393,15 @@ msgstr "댓글 최소 글자수" msgid "Discussions" msgstr "토론" -#: apps/discussion/models.py:324 apps/operation/models.py:785 +#: apps/discussion/models.py:325 apps/operation/models.py:785 msgid "Parent" msgstr "부모" -#: apps/discussion/models.py:330 +#: apps/discussion/models.py:331 msgid "Posts" msgstr "게시글" -#: apps/exam/apps.py:8 apps/exam/models.py:153 apps/exam/models.py:240 +#: apps/exam/apps.py:8 apps/exam/models.py:153 apps/exam/models.py:256 #: apps/warehouse/models.py:138 apps/warehouse/views.py:49 msgid "Exam" msgstr "시험" @@ -2406,15 +2410,15 @@ msgstr "시험" msgid "Question Composition" msgstr "문제 구성" -#: apps/exam/models.py:105 apps/survey/models.py:53 +#: apps/exam/models.py:105 apps/survey/models.py:54 msgid "Single Choice" msgstr "단일 선택" -#: apps/exam/models.py:106 apps/survey/models.py:54 +#: apps/exam/models.py:106 apps/survey/models.py:55 msgid "Text Input" msgstr "텍스트 입력" -#: apps/exam/models.py:107 apps/survey/models.py:55 +#: apps/exam/models.py:107 apps/survey/models.py:56 msgid "Number Input" msgstr "숫자 입력" @@ -2422,7 +2426,7 @@ msgstr "숫자 입력" msgid "Essay" msgstr "서술형" -#: apps/exam/models.py:114 apps/quiz/models.py:97 apps/survey/models.py:61 +#: apps/exam/models.py:114 apps/quiz/models.py:97 apps/survey/models.py:62 msgid "Options" msgstr "선택지" @@ -2434,7 +2438,7 @@ msgstr "정답" msgid "Correct Criteria" msgstr "정답 기준" -#: apps/exam/models.py:138 apps/operation/admin.py:158 +#: apps/exam/models.py:138 apps/operation/admin.py:153 #: apps/operation/models.py:479 apps/quiz/models.py:117 msgid "Explanation" msgstr "해설" @@ -2451,16 +2455,16 @@ msgstr "해답" msgid "Exams" msgstr "시험" -#: apps/exam/models.py:366 apps/exam/models.py:376 apps/quiz/models.py:348 -#: apps/survey/models.py:121 +#: apps/exam/models.py:383 apps/exam/models.py:393 apps/quiz/models.py:349 +#: apps/survey/models.py:122 msgid "Answers" msgstr "답안" -#: apps/exam/models.py:369 +#: apps/exam/models.py:386 msgid "Temporary Answer" msgstr "임시 답안" -#: apps/exam/models.py:370 +#: apps/exam/models.py:387 msgid "Temporary Answers" msgstr "임시 답안" @@ -2468,16 +2472,16 @@ msgstr "임시 답안" msgid "Learning" msgstr "학습" -#: apps/learning/management/commands/setup_demo_data.py:40 -#: apps/learning/management/commands/setup_demo_data.py:78 +#: apps/learning/management/commands/setup_demo_data.py:44 +#: apps/learning/management/commands/setup_demo_data.py:82 msgid "Demo Public Catalog" msgstr "데모 공개 카탈로그" -#: apps/learning/management/commands/setup_demo_data.py:43 +#: apps/learning/management/commands/setup_demo_data.py:47 msgid "Demo Personal Catalog" msgstr "데모 개인 카탈로그" -#: apps/learning/management/commands/setup_demo_data.py:45 +#: apps/learning/management/commands/setup_demo_data.py:49 msgid "" "Personal catalogs are available only to you. Video, PDF, Survey, Quiz, " "Assignment, Discussion, and Exam content are available here." @@ -2485,11 +2489,11 @@ msgstr "" "개인 카탈로그는 개인에게 부여된 카탈로그입니다. 비디오, PDF, 설문조사, 퀴즈, " "과제, 토론, 시험 콘텐츠가 있습니다." -#: apps/learning/management/commands/setup_demo_data.py:56 +#: apps/learning/management/commands/setup_demo_data.py:60 msgid "Demo Cohort Catalog" msgstr "그룹 학습그룹 카탈로그" -#: apps/learning/management/commands/setup_demo_data.py:58 +#: apps/learning/management/commands/setup_demo_data.py:62 msgid "" "Cohort catalogs are available when you are in a cohort. Video, PDF, Survey, " "Quiz, Assignment, Discussion, and Exam content are available here." @@ -2497,7 +2501,7 @@ msgstr "" "학습그룹 카탈로그는 학습그룹에 있는 사람이 등록한 경우에 사용할 수 있습니다. " "비디오, PDF, 설문조사, 퀴즈, 과제, 토론, 시험 콘텐츠가 있습니다." -#: apps/learning/management/commands/setup_demo_data.py:145 +#: apps/learning/management/commands/setup_demo_data.py:153 msgid "" "Public catalogs are available to everyone. Generally, public catalogs are " "composef of video or PDF content." @@ -2585,11 +2589,11 @@ msgstr "그룹 카탈로그" msgid "Solved" msgstr "해결됨" -#: apps/operation/admin.py:213 +#: apps/operation/admin.py:208 msgid "Agreement History" msgstr "동의 기록" -#: apps/operation/admin.py:214 +#: apps/operation/admin.py:209 msgid "Agreement Histories" msgstr "동의 기록" @@ -2725,23 +2729,19 @@ msgstr "문의 응답" msgid "Review" msgstr "검토" -#: apps/operation/models.py:481 apps/operation/models.py:767 -msgid "Closed" -msgstr "종료" - -#: apps/operation/models.py:485 +#: apps/operation/models.py:484 msgid "Question Type" msgstr "문제 유형" -#: apps/operation/models.py:486 +#: apps/operation/models.py:485 msgid "Question ID" msgstr "문제 ID" -#: apps/operation/models.py:490 +#: apps/operation/models.py:489 msgid "Grade Appeal" msgstr "채점 이의" -#: apps/operation/models.py:491 +#: apps/operation/models.py:490 msgid "Grade Appeals" msgstr "채점 이의" @@ -2787,7 +2787,7 @@ msgstr "데이터 보존 정책" msgid "Kind" msgstr "유형" -#: apps/operation/models.py:639 apps/survey/models.py:62 +#: apps/operation/models.py:639 apps/survey/models.py:63 msgid "Mandatory" msgstr "필수" @@ -2859,6 +2859,10 @@ msgstr "평점 합계" msgid "Rating Average" msgstr "평점 평균" +#: apps/operation/models.py:767 +msgid "Closed" +msgstr "종료" + #: apps/operation/models.py:771 apps/operation/models.py:784 msgid "Thread" msgstr "스레드" @@ -3292,47 +3296,47 @@ msgstr "수정 기록" msgid "Editing Histories" msgstr "수정 기록" -#: apps/studio/models.py:15 +#: apps/studio/models.py:16 msgid "Author" msgstr "작성자" -#: apps/studio/models.py:16 +#: apps/studio/models.py:17 msgid "Edited" msgstr "수정" -#: apps/studio/models.py:17 +#: apps/studio/models.py:18 msgid "Action" msgstr "작업" -#: apps/studio/models.py:19 +#: apps/studio/models.py:20 apps/tutor/models.py:41 msgid "Content type" msgstr "콘텐츠 유형" -#: apps/studio/models.py:24 +#: apps/studio/models.py:25 msgid "Content Editing" msgstr "콘텐츠 수정" -#: apps/studio/models.py:25 +#: apps/studio/models.py:26 msgid "Content Editings" msgstr "콘텐츠 수정" -#: apps/survey/models.py:80 +#: apps/survey/models.py:81 msgid "Complete Message" msgstr "완료 메시지" -#: apps/survey/models.py:81 +#: apps/survey/models.py:82 msgid "Anonymous" msgstr "익명" -#: apps/survey/models.py:82 +#: apps/survey/models.py:83 msgid "Show Results" msgstr "결과 보기" -#: apps/survey/models.py:86 +#: apps/survey/models.py:87 msgid "Surveys" msgstr "설문조사" -#: apps/survey/models.py:120 +#: apps/survey/models.py:121 msgid "Respondent" msgstr "응답자" @@ -3412,6 +3416,26 @@ msgstr "최근 이벤트" msgid "Hot Events" msgstr "최근 이벤트" +#: apps/tutor/admin.py:12 +msgid "Allocation History" +msgstr "담당 기록" + +#: apps/tutor/admin.py:13 +msgid "Allocation Histories" +msgstr "담당 기록" + +#: apps/tutor/apps.py:8 apps/tutor/models.py:37 +msgid "Tutor" +msgstr "튜터" + +#: apps/tutor/models.py:46 +msgid "Tutor Allocation" +msgstr "튜터 담당" + +#: apps/tutor/models.py:47 +msgid "Tutor Allocations" +msgstr "튜터 담당" + #: apps/warehouse/apps.py:8 msgid "Warehouse" msgstr "분석 데이터" @@ -3548,7 +3572,7 @@ msgstr "등록" msgid "Courses Passed" msgstr "과정 수료" -#: minima/settings.py:265 +#: minima/settings.py:266 msgid "Storage" msgstr "저장소" diff --git a/core/minima/settings.py b/core/minima/settings.py index 0757d0b..c30edb2 100644 --- a/core/minima/settings.py +++ b/core/minima/settings.py @@ -104,6 +104,7 @@ "apps.store", "apps.assistant", "apps.studio", + "apps.tutor", "apps.tracking", "apps.warehouse", ] diff --git a/core/pyproject.toml b/core/pyproject.toml index fa378d4..9f326cc 100644 --- a/core/pyproject.toml +++ b/core/pyproject.toml @@ -64,6 +64,7 @@ dev = [ "openpyxl", "django-stubs", "typing-extensions", + "celery-types>=0.24.0", ] [tool.django-stubs] diff --git a/core/uv.lock b/core/uv.lock index c2eb927..ef9791e 100644 --- a/core/uv.lock +++ b/core/uv.lock @@ -140,29 +140,29 @@ wheels = [ [[package]] name = "boto3" -version = "1.42.62" +version = "1.42.63" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f7/7e/c952803c8900f14e6f6158fddbd35da5afb2e3fa68bf498a761e6ba2c2ae/boto3-1.42.62.tar.gz", hash = "sha256:6b26ff56c458685caec3d42adde0549f6a55410e557e1f51bebde5c8abcf3037", size = 112848, upload-time = "2026-03-05T21:20:37.755Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/2a/33d5d4b16fd97dfd629421ebed2456392eae1553cc401d9f86010c18065e/boto3-1.42.63.tar.gz", hash = "sha256:cd008cfd0d7ea30f1c5e22daf0998c55b7c6c68cb68eea05110e33fe641173d5", size = 112778, upload-time = "2026-03-06T22:47:55.96Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/68/b5e82dedd9c8d53a9542df4e3475d2d3ec331eef4a4a801e9c5fa98b583a/boto3-1.42.62-py3-none-any.whl", hash = "sha256:eef0ee08f30e5ed16d8296719808801a827fa0f3126a3e2a9ef9be9eb5e6a313", size = 140556, upload-time = "2026-03-05T21:20:35.354Z" }, + { url = "https://files.pythonhosted.org/packages/f5/19/f1d8d2b24871d3d0ccb2cbd0b0cb64a3396d439384bd9643d2c25c641b84/boto3-1.42.63-py3-none-any.whl", hash = "sha256:d502a89a0acc701692ae020d15981f2a82e9eb3485acc651cfd0cf1a3afe79ee", size = 140554, upload-time = "2026-03-06T22:47:53.463Z" }, ] [[package]] name = "boto3-stubs" -version = "1.42.62" +version = "1.42.63" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore-stubs" }, { name = "types-s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/21/ac7e0e79d095ff267d2a4179ec6600fe06ed28d1167c8ef019b03d11b22e/boto3_stubs-1.42.62.tar.gz", hash = "sha256:59eea2a768af74a8fd962f0a2c39f9b3aa897532a49a25ba747af3e4bae5f853", size = 101191, upload-time = "2026-03-06T03:57:00.015Z" } +sdist = { url = "https://files.pythonhosted.org/packages/83/30/45d21d3df4598b6b37d8de9923603010e490dfc73a130c8ffa806750b1ff/boto3_stubs-1.42.63.tar.gz", hash = "sha256:b64726c86c0b3efbc6ccd4f0510fa5a8279caec46da36a91c037a4b395001e68", size = 101176, upload-time = "2026-03-06T22:50:00.514Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/d2/4ef7be58ecfb73bc2be98d06209ff7495930548b4adc97c609b9b776e5c2/boto3_stubs-1.42.62-py3-none-any.whl", hash = "sha256:578687643aae8be69a21bfa024f8b74f3e721136baed80c04aba4811a422b4ee", size = 69915, upload-time = "2026-03-06T03:56:51.905Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/933ecf99d0cc1ae1b463086d203b27a29ba05c1d1cdd8a3efb18bc1e8c98/boto3_stubs-1.42.63-py3-none-any.whl", hash = "sha256:132e9c57bfef5ea943745d6cdad16e62d4e82dca64f6ae9ddfba9789fcdc8378", size = 69913, upload-time = "2026-03-06T22:49:49.765Z" }, ] [package.optional-dependencies] @@ -172,16 +172,16 @@ s3 = [ [[package]] name = "botocore" -version = "1.42.62" +version = "1.42.63" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/af/e7/031f2f03f22817f8a8def7ad1caa138979c20ac35062b055274e0a505c3f/botocore-1.42.62.tar.gz", hash = "sha256:c210dc93b0b81bf72cfe745a7b1c8df765d04bd90b4ac6c8707fbb6714141dae", size = 14966114, upload-time = "2026-03-05T21:20:25.518Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/eb/a1c042f6638ada552399a9977335a6de2668a85bf80bece193c953531236/botocore-1.42.63.tar.gz", hash = "sha256:1fdfc33cff58d21e8622cf620ba2bba3cff324557932aaf935b5374e4610f059", size = 14965362, upload-time = "2026-03-06T22:47:44.158Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/57/9bc5c1aad3a354dd7da54ba52d43ee821badb3deedbea4c5117c4bd05eab/botocore-1.42.62-py3-none-any.whl", hash = "sha256:86d327fded96775268ffe8d8bd6ed96c4a1db86cf24eb64ff85233db12dbc287", size = 14638389, upload-time = "2026-03-05T21:20:22.359Z" }, + { url = "https://files.pythonhosted.org/packages/9a/60/17a2d3b94658bb999c6aee7bba6c76b271905debf0c8c8e6ac63ca8491bc/botocore-1.42.63-py3-none-any.whl", hash = "sha256:83f39d04f2b316bdfc59a3cac2d12238bde7126ac99d9a57d910dbd86d58c528", size = 14639889, upload-time = "2026-03-06T22:47:39.347Z" }, ] [[package]] @@ -229,6 +229,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dd/bd/9ecd619e456ae4ba73b6583cc313f26152afae13e9a82ac4fe7f8856bfd1/celery-5.6.2-py3-none-any.whl", hash = "sha256:3ffafacbe056951b629c7abcf9064c4a2366de0bdfc9fdba421b97ebb68619a5", size = 445502, upload-time = "2026-01-04T12:35:55.894Z" }, ] +[[package]] +name = "celery-types" +version = "0.24.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/25/2276a1f00f8ab9fc88128c939333933a24db7df1d75aa57ecc27b7dd3a22/celery_types-0.24.0.tar.gz", hash = "sha256:c93fbcd0b04a9e9c2f55d5540aca4aa1ea4cc06a870c0c8dee5062fdd59663fe", size = 33148, upload-time = "2025-12-23T17:16:30.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/7e/3252cba5f5c9a65a3f52a69734d8e51e023db8981022b503e8183cf0225e/celery_types-0.24.0-py3-none-any.whl", hash = "sha256:a21e04681e68719a208335e556a79909da4be9c5e0d6d2fd0dd4c5615954b3fd", size = 60473, upload-time = "2025-12-23T17:16:29.89Z" }, +] + [[package]] name = "certifi" version = "2026.2.25" @@ -870,16 +882,16 @@ grpc = [ [[package]] name = "google-auth" -version = "2.48.0" +version = "2.49.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "pyasn1-modules" }, { name = "rsa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0c/41/242044323fbd746615884b1c16639749e73665b718209946ebad7ba8a813/google_auth-2.48.0.tar.gz", hash = "sha256:4f7e706b0cd3208a3d940a19a822c37a476ddba5450156c3e6624a71f7c841ce", size = 326522, upload-time = "2026-01-26T19:22:47.157Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/59/7371175bfd949abfb1170aa076352131d7281bd9449c0f978604fc4431c3/google_auth-2.49.0.tar.gz", hash = "sha256:9cc2d9259d3700d7a257681f81052db6737495a1a46b610597f4b8bafe5286ae", size = 333444, upload-time = "2026-03-06T21:53:06.07Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/1d/d6466de3a5249d35e832a52834115ca9d1d0de6abc22065f049707516d47/google_auth-2.48.0-py3-none-any.whl", hash = "sha256:2e2a537873d449434252a9632c28bfc268b0adb1e53f9fb62afc5333a975903f", size = 236499, upload-time = "2026-01-26T19:22:45.099Z" }, + { url = "https://files.pythonhosted.org/packages/37/45/de64b823b639103de4b63dd193480dce99526bd36be6530c2dba85bf7817/google_auth-2.49.0-py3-none-any.whl", hash = "sha256:f893ef7307f19cf53700b7e2f61b5a6affe3aa0edf9943b13788920ab92d8d87", size = 240676, upload-time = "2026-03-06T21:52:38.304Z" }, ] [package.optional-dependencies] @@ -902,18 +914,19 @@ wheels = [ [[package]] name = "google-cloud-firestore" -version = "2.23.0" +version = "2.24.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core", extra = ["grpc"] }, { name = "google-auth" }, { name = "google-cloud-core" }, + { name = "grpcio" }, { name = "proto-plus" }, { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7c/9c/ec28ca4ec88fa89e41366316cf92c037feaa2c4200aa5d9da69fe011d2f6/google_cloud_firestore-2.23.0.tar.gz", hash = "sha256:a9cffba7cdc6101111d6d54cde22d521c98f9e7d415e67486b137fa16f06aa03", size = 615238, upload-time = "2026-01-14T23:50:54.574Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/5e/9a0f2378623a21d46800815e6ff4c3119af3914e3b8405b1f33f12b4f1b1/google_cloud_firestore-2.24.0.tar.gz", hash = "sha256:e7f831f150e787c46ac3a96407f58d9edccd5455dbc9dae472f4cadb6daf6dc9", size = 620822, upload-time = "2026-03-06T21:53:04.143Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/99/2a627c8ea7ae72a686dda8bf2b79747362b425c237d2729eb76bcee55a25/google_cloud_firestore-2.23.0-py3-none-any.whl", hash = "sha256:19f2326cb466b0d52aed9fabbd89758be431f6ce18c422966cfdb8326b424314", size = 411195, upload-time = "2026-01-14T23:50:52.825Z" }, + { url = "https://files.pythonhosted.org/packages/69/d0/2ac7ff231eb380c88ddac21551156b7d03e25b1699b55641cf694def7c60/google_cloud_firestore-2.24.0-py3-none-any.whl", hash = "sha256:8b778fe766dacc54ef19b4f1b64adc751fca51b59ce569f1ec38c2ebd985dda1", size = 416388, upload-time = "2026-03-06T21:52:50.144Z" }, ] [[package]] @@ -981,14 +994,14 @@ wheels = [ [[package]] name = "googleapis-common-protos" -version = "1.72.0" +version = "1.73.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e5/7b/adfd75544c415c487b33061fe7ae526165241c1ea133f9a9125a56b39fd8/googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5", size = 147433, upload-time = "2025-11-06T18:29:24.087Z" } +sdist = { url = "https://files.pythonhosted.org/packages/99/96/a0205167fa0154f4a542fd6925bdc63d039d88dab3588b875078107e6f06/googleapis_common_protos-1.73.0.tar.gz", hash = "sha256:778d07cd4fbeff84c6f7c72102f0daf98fa2bfd3fa8bea426edc545588da0b5a", size = 147323, upload-time = "2026-03-06T21:53:09.727Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" }, + { url = "https://files.pythonhosted.org/packages/69/28/23eea8acd65972bbfe295ce3666b28ac510dfcb115fac089d3edb0feb00a/googleapis_common_protos-1.73.0-py3-none-any.whl", hash = "sha256:dfdaaa2e860f242046be561e6d6cb5c5f1541ae02cfbcb034371aadb2942b4e8", size = 297578, upload-time = "2026-03-06T21:52:33.933Z" }, ] [[package]] @@ -1228,6 +1241,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "boto3-stubs", extra = ["s3"] }, + { name = "celery-types" }, { name = "django-stubs" }, { name = "factory-boy" }, { name = "mimesis" }, @@ -1290,6 +1304,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "boto3-stubs", extras = ["s3"] }, + { name = "celery-types", specifier = ">=0.24.0" }, { name = "django-stubs" }, { name = "factory-boy" }, { name = "mimesis" }, @@ -1833,11 +1848,11 @@ wheels = [ [[package]] name = "redis" -version = "7.2.1" +version = "7.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e9/31/1476f206482dd9bc53fdbbe9f6fbd5e05d153f18e54667ce839df331f2e6/redis-7.2.1.tar.gz", hash = "sha256:6163c1a47ee2d9d01221d8456bc1c75ab953cbda18cfbc15e7140e9ba16ca3a5", size = 4906735, upload-time = "2026-02-25T20:05:18.171Z" } +sdist = { url = "https://files.pythonhosted.org/packages/da/82/4d1a5279f6c1251d3d2a603a798a1137c657de9b12cfc1fba4858232c4d2/redis-7.3.0.tar.gz", hash = "sha256:4d1b768aafcf41b01022410b3cc4f15a07d9b3d6fe0c66fc967da2c88e551034", size = 4928081, upload-time = "2026-03-06T18:18:16.287Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/98/1dd1a5c060916cf21d15e67b7d6a7078e26e2605d5c37cbc9f4f5454c478/redis-7.2.1-py3-none-any.whl", hash = "sha256:49e231fbc8df2001436ae5252b3f0f3dc930430239bfeb6da4c7ee92b16e5d33", size = 396057, upload-time = "2026-02-25T20:05:16.533Z" }, + { url = "https://files.pythonhosted.org/packages/f0/28/84e57fce7819e81ec5aa1bd31c42b89607241f4fb1a3ea5b0d2dbeaea26c/redis-7.3.0-py3-none-any.whl", hash = "sha256:9d4fcb002a12a5e3c3fbe005d59c48a2cc231f87fbb2f6b70c2d89bb64fec364", size = 404379, upload-time = "2026-03-06T18:18:14.583Z" }, ] [[package]] diff --git a/dev.sh b/dev.sh index 2345e41..f85bdd2 100755 --- a/dev.sh +++ b/dev.sh @@ -37,8 +37,10 @@ up) echo "" echo "Elapsed: $((SECONDS - _start_time))s" echo "" - echo "Admin: http://localhost:8000/admin" - echo "Web: http://localhost:5173" + echo "Admin: http://localhost:8000/admin" + echo "Web: http://localhost:5173" + echo "Studio: http://localhost:5173/studio" + echo "Ttutor: http://localhost:5173/tutor" echo "" ;; diff --git a/screenshot/tutor.webp b/screenshot/tutor.webp new file mode 100644 index 0000000..ab92295 Binary files /dev/null and b/screenshot/tutor.webp differ diff --git a/web/package-lock.json b/web/package-lock.json index 2d4465d..c964e86 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -15,7 +15,7 @@ "@solid-primitives/scroll": "^2.1.5", "@tabler/icons-solidjs": "^3.40.0", "@tailwindcss/vite": "^4.2.1", - "@tanstack/router-plugin": "^1.166.2", + "@tanstack/router-plugin": "^1.166.3", "@tanstack/solid-router": "^1.166.2", "@tiptap/core": "^3.20.1", "@tiptap/extension-dropcursor": "^3.20.1", @@ -34,7 +34,7 @@ "dompurify": "^3.3.2", "fflate": "^0.8.2", "firebase": "^12.10.0", - "i18next": "^25.8.14", + "i18next": "^25.8.16", "i18next-browser-languagedetector": "^8.2.1", "korean-regexp": "^1.0.13", "marked": "^17.0.4", @@ -54,7 +54,7 @@ "@types/node": "^25.3.5", "@types/papaparse": "^5.5.2", "daisyui": "^5.5.19", - "i18next-cli": "^1.49.3", + "i18next-cli": "^1.49.4", "jsdom": "^28.1.0", "typescript": "^5.9.3", "vite": "^7.3.1", @@ -74,7 +74,7 @@ "version": "4.4.4", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@asamuzakjp/css-color": { @@ -160,6 +160,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -428,6 +429,7 @@ "integrity": "sha512-QnHe81PMslpy3mnpL8DnO2M4S4ZnYPkjlGCLWBZT/3R9M6b5daArWMMtEfP52/n174RKnwRIf3oT8+wc9ihSfQ==", "dev": true, "license": "MIT OR Apache-2.0", + "peer": true, "bin": { "biome": "bin/biome" }, @@ -823,6 +825,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -863,6 +866,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -1364,6 +1368,7 @@ "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.14.9.tgz", "integrity": "sha512-3gtUX0e584MYkKBQMgSECMvE1Dwzg+eONefDQ0wxVSe5YMBsZwdN5pL7UapwWBlV8+i8QCztF9TP947tEjZAGA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@firebase/component": "0.7.1", "@firebase/logger": "0.5.0", @@ -1430,6 +1435,7 @@ "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.5.9.tgz", "integrity": "sha512-e5LzqjO69/N2z7XcJeuMzIp4wWnW696dQeaHAUpQvGk89gIWHAIvG6W+mA3UotGW6jBoqdppEJ9DnuwbcBByug==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@firebase/app": "0.14.9", "@firebase/component": "0.7.1", @@ -1445,7 +1451,8 @@ "version": "0.9.3", "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz", "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/@firebase/auth": { "version": "1.12.1", @@ -1896,6 +1903,7 @@ "integrity": "sha512-/gnejm7MKkVIXnSJGpc9L2CvvvzJvtDPeAEq5jAwgVlf/PeNxot+THx/bpD20wQ8uL5sz0xqgXy1nisOYMU+mw==", "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "tslib": "^2.1.0" }, @@ -3983,9 +3991,9 @@ } }, "node_modules/@tanstack/router-plugin": { - "version": "1.166.2", - "resolved": "https://registry.npmjs.org/@tanstack/router-plugin/-/router-plugin-1.166.2.tgz", - "integrity": "sha512-TnyV/7//Vp5fR49mmNbOWHGz9IJTm1lqVxzPdtpzg7D5PjkW2HFmLFLtWwpJgz2R7AJJWR4Ge5kIPmC+fVZ6eQ==", + "version": "1.166.3", + "resolved": "https://registry.npmjs.org/@tanstack/router-plugin/-/router-plugin-1.166.3.tgz", + "integrity": "sha512-yhnJRohpdKB24Fh7fW5mwgffpOcERZlXdk3i8PjXn+OYgAiG/cpuXXOJpZZ6An68vDW+Z5zBuTynXsDi2ZE4JQ==", "license": "MIT", "dependencies": { "@babel/core": "^7.28.5", @@ -4011,7 +4019,7 @@ }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", - "@tanstack/react-router": "^1.166.2", + "@tanstack/react-router": "^1.166.3", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" @@ -4148,7 +4156,7 @@ "version": "6.9.1", "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@adobe/css-tools": "^4.4.0", @@ -4168,7 +4176,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@tiptap/core": { @@ -4176,6 +4184,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.20.1.tgz", "integrity": "sha512-SwkPEWIfaDEZjC8SEIi4kZjqIYUbRgLUHUuQezo5GbphUNC8kM1pi3C3EtoOPtxXrEbY6e4pWEzW54Pcrd+rVA==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -4406,6 +4415,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.20.1.tgz", "integrity": "sha512-euBRAn0mkV7R2VEE+AuOt3R0j9RHEMFXamPFmtvTo8IInxDClusrm6mJoDjS8gCGAXsQCRiAe1SCQBPgGbOOwg==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -4577,6 +4587,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.20.1.tgz", "integrity": "sha512-JRc/v+OBH0qLTdvQ7HvHWTxGJH73QOf1MC0R8NhOX2QnAbg2mPFv1h+FjGa2gfLGuCXBdWQomjekWkUKbC4e5A==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -4591,6 +4602,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.20.1.tgz", "integrity": "sha512-6kCiGLvpES4AxcEuOhb7HR7/xIeJWMjZlb6J7e8zpiIh5BoQc7NoRdctsnmFEjZvC19bIasccshHQ7H2zchWqw==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-collab": "^1.3.1", @@ -4986,7 +4998,7 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "dequal": "^2.0.3" @@ -5177,6 +5189,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5590,7 +5603,7 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/cssesc": { @@ -5765,7 +5778,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -6505,9 +6518,9 @@ } }, "node_modules/i18next": { - "version": "25.8.14", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.14.tgz", - "integrity": "sha512-paMUYkfWJMsWPeE/Hejcw+XLhHrQPehem+4wMo+uELnvIwvCG019L9sAIljwjCmEMtFQQO3YeitJY8Kctei3iA==", + "version": "25.8.16", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.16.tgz", + "integrity": "sha512-/4Xvgm8RiJNcB+sZwplylrFNJ27DVvubGX7y6uXn7hh7aSvbmXVSRIyIGx08fEn05SYwaSYWt753mIpJuPKo+Q==", "funding": [ { "type": "individual", @@ -6523,6 +6536,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.4" }, @@ -6545,9 +6559,9 @@ } }, "node_modules/i18next-cli": { - "version": "1.49.3", - "resolved": "https://registry.npmjs.org/i18next-cli/-/i18next-cli-1.49.3.tgz", - "integrity": "sha512-QVWEc/P8ub7DtXLr2PrHUBYh9QvNE7CvGLCORz7BDgrs+oieYIu6cFoR4RdOZwhPlgcOFVQpOSJlMIX8RVpiAg==", + "version": "1.49.4", + "resolved": "https://registry.npmjs.org/i18next-cli/-/i18next-cli-1.49.4.tgz", + "integrity": "sha512-ts/5iEO7/j30kzw+G0jpwpZ/0ChaggZt2ASQVJPizIrG8pjGh/StnQULfPPn0dWwOpCmEi9DQYm/WwrCJkoRRQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6678,7 +6692,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7478,7 +7492,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -8091,6 +8105,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "license": "MIT", + "peer": true, "dependencies": { "orderedmap": "^2.0.0" } @@ -8120,6 +8135,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -8168,6 +8184,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz", "integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -8246,6 +8263,7 @@ "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "devOptional": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -8326,7 +8344,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "indent-string": "^4.0.0", @@ -8522,6 +8540,7 @@ "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.0.tgz", "integrity": "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==", "license": "MIT", + "peer": true, "engines": { "node": ">=10" } @@ -8586,6 +8605,7 @@ "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.11.tgz", "integrity": "sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", @@ -8702,7 +8722,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "min-indent": "^1.0.0" @@ -8722,7 +8742,8 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.3.0", @@ -8804,6 +8825,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -8921,6 +8943,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9033,6 +9056,7 @@ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } @@ -9063,6 +9087,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -9151,6 +9176,7 @@ "integrity": "sha512-Yr1dQybmtDtDAHkii6hXuc1oVH9CPcS/Zb2jN/P36qqcrkNnVPsMTzQ06jyzFPFjj3U1IYKMVt/9ZqcwGCEbjw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", @@ -9192,6 +9218,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/web/package.json b/web/package.json index 9072a25..ff4c750 100644 --- a/web/package.json +++ b/web/package.json @@ -24,7 +24,7 @@ "@solid-primitives/scroll": "^2.1.5", "@tabler/icons-solidjs": "^3.40.0", "@tailwindcss/vite": "^4.2.1", - "@tanstack/router-plugin": "^1.166.2", + "@tanstack/router-plugin": "^1.166.3", "@tanstack/solid-router": "^1.166.2", "@tiptap/core": "^3.20.1", "@tiptap/extension-dropcursor": "^3.20.1", @@ -43,7 +43,7 @@ "dompurify": "^3.3.2", "fflate": "^0.8.2", "firebase": "^12.10.0", - "i18next": "^25.8.14", + "i18next": "^25.8.16", "i18next-browser-languagedetector": "^8.2.1", "korean-regexp": "^1.0.13", "marked": "^17.0.4", @@ -63,7 +63,7 @@ "@types/node": "^25.3.5", "@types/papaparse": "^5.5.2", "daisyui": "^5.5.19", - "i18next-cli": "^1.49.3", + "i18next-cli": "^1.49.4", "jsdom": "^28.1.0", "typescript": "^5.9.3", "vite": "^7.3.1", diff --git a/web/public/image/logo/logo-dark.png b/web/public/image/logo/logo-dark.png deleted file mode 100644 index 33421db..0000000 Binary files a/web/public/image/logo/logo-dark.png and /dev/null differ diff --git a/web/public/image/logo/logo-icon.png b/web/public/image/logo/logo-icon.png deleted file mode 100644 index 136edee..0000000 Binary files a/web/public/image/logo/logo-icon.png and /dev/null differ diff --git a/web/public/image/logo/logo-large.png b/web/public/image/logo/logo-large.png deleted file mode 100644 index cb65d96..0000000 Binary files a/web/public/image/logo/logo-large.png and /dev/null differ diff --git a/web/public/image/logo/logo-square.png b/web/public/image/logo/logo-square.png deleted file mode 100644 index de5feb5..0000000 Binary files a/web/public/image/logo/logo-square.png and /dev/null differ diff --git a/web/public/image/logo/logo-studio-dark.png b/web/public/image/logo/logo-studio-dark.png deleted file mode 100644 index fb07a45..0000000 Binary files a/web/public/image/logo/logo-studio-dark.png and /dev/null differ diff --git a/web/public/image/logo/logo-studio-large.png b/web/public/image/logo/logo-studio-large.png deleted file mode 100644 index 13c18e6..0000000 Binary files a/web/public/image/logo/logo-studio-large.png and /dev/null differ diff --git a/web/public/image/logo/logo-studio.png b/web/public/image/logo/logo-studio.png deleted file mode 100644 index 42e635c..0000000 Binary files a/web/public/image/logo/logo-studio.png and /dev/null differ diff --git a/web/public/image/logo/logo.png b/web/public/image/logo/logo.png index 7139bb2..d8cb1ce 100644 Binary files a/web/public/image/logo/logo.png and b/web/public/image/logo/logo.png differ diff --git a/web/src/api/index.ts b/web/src/api/index.ts index d32e29c..ff9fcb9 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts -export { accountV1Activate, accountV1ApplyEmailChange, accountV1ApplyPasswordChange, accountV1CompleteOtpSetup, accountV1GetMe, accountV1Join, accountV1Login, accountV1Logout, accountV1RequestActivation, accountV1RequestEmailChange, accountV1RequestPasswordChange, accountV1ResetOtp, accountV1SetupOtp, accountV1UpdateMe, accountV1UploadAvatar, accountV1VerifyOtp, assignmentV1DeactivateAttempt, assignmentV1GetSession, assignmentV1StartAttempt, assignmentV1SubmitAttempt, assistantV1ChatMessage, assistantV1DeleteChat, assistantV1GetChatMessages, assistantV1GetChats, assistantV1SaveAssistantNote, assistantV1UpdateChatMessage, competencyV1DeleteCompetencyGoal, competencyV1GetCertificateAwards, competencyV1GetCertificates, competencyV1GetClassificationSkillsData, competencyV1GetClassificationTree, competencyV1GetCompetencyGoals, competencyV1SaveCompetencyGoal, contentV1DeleteMediaWatch, contentV1GetMedia, contentV1GetMediaNote, contentV1GetMediaWatch, contentV1GetSubtitles, contentV1GetWatchMedias, contentV1SaveMediaNote, contentV1Search, contentV1SearchSuggestion, contentV1UpdateMediaWatch, courseV1GetDetail, courseV1GetSession, courseV1RequestCertificate, courseV1StartEngagement, discussionV1CreatePost, discussionV1DeactivateAttempt, discussionV1DeletePost, discussionV1GetOwnPosts, discussionV1GetPosts, discussionV1GetSession, discussionV1StartAttempt, discussionV1UpdatePost, examV1DeactivateAttempt, examV1GetSession, examV1GetTimestamp, examV1SaveAnswers, examV1StartAttempt, examV1SubmitAttempt, learningV1EnrollCatalogItem, learningV1GetCatalogItems, learningV1GetCatalogs, learningV1GetEnrolled, learningV1GetRecords, learningV1GetReport, learningV1Unenroll, minimaApiHealth, operationV1AgreePolicies, operationV1CreateAppeal, operationV1CreateInquiry, operationV1CreateThread, operationV1DeleteComment, operationV1DeleteDevice, operationV1EffectivePolicies, operationV1GetAnnouncements, operationV1GetComments, operationV1GetDevices, operationV1GetInquiries, operationV1GetThread, operationV1GetThreadComments, operationV1GetUnreadMessages, operationV1ReadAnnouncement, operationV1ReadMessage, operationV1RegisterDevice, operationV1SaveComment, operationV1ToggleDeviceActive, operationV1UpdateInquiry, type Options, partnerV1MemberInfos, quizV1DeactivateAttempt, quizV1GetSession, quizV1StartAttempt, quizV1SubmitAttempt, ssoV1Authorize, ssoV1Callback, ssoV1DeleteAccount, ssoV1GetAccounts, ssoV1Link, studioV1AssessmentSuggestions, studioV1Content, studioV1ContentSuggestions, studioV1CreateMediaQuiz, studioV1DeleteAssignment, studioV1DeleteAssignmentQuesion, studioV1DeleteCourse, studioV1DeleteDiscussion, studioV1DeleteDiscussionQuesion, studioV1DeleteExam, studioV1DeleteExamQuesion, studioV1DeleteMedia, studioV1DeleteMediaSubtitle, studioV1DeleteQuiz, studioV1DeleteQuizQuesion, studioV1DeleteSurvey, studioV1DeleteSurveyQuesion, studioV1GetAssignment, studioV1GetAssignmentQuestions, studioV1GetAssignmentRubric, studioV1GetCourse, studioV1GetDiscussion, studioV1GetDiscussionQuestions, studioV1GetExam, studioV1GetExamQuestions, studioV1GetMedia, studioV1GetQuiz, studioV1GetQuizQuestions, studioV1GetSurvey, studioV1GetSurveyQuestions, studioV1InlineSuggestions, studioV1RemoveCourseAssessment, studioV1RemoveCourseCategory, studioV1RemoveCourseCertificate, studioV1RemoveCourseInstructor, studioV1RemoveCourseLesson, studioV1RemoveCourseRelation, studioV1RemoveCourseSurvey, studioV1SaveAssignment, studioV1SaveAssignmentQuestions, studioV1SaveAssignmentRubric, studioV1SaveCourse, studioV1SaveCourseAssessments, studioV1SaveCourseCategories, studioV1SaveCourseCertificates, studioV1SaveCourseInstructors, studioV1SaveCourseLessons, studioV1SaveCourseRelations, studioV1SaveCourseSurveys, studioV1SaveDiscussion, studioV1SaveDiscussionQuestions, studioV1SaveExam, studioV1SaveExamQuestions, studioV1SaveMedia, studioV1SaveMediaSubtitle, studioV1SaveQuiz, studioV1SaveQuizQuestions, studioV1SaveSurvey, studioV1SaveSurveyQuestions, surveyV1GetAnonymousSurvey, surveyV1GetSurvey, surveyV1Results, surveyV1ResultsAnonymous, surveyV1Submit, surveyV1SubmitAnonymous } from './sdk.gen'; -export type { AccessDateSchema, AccountActivatedSchema, AccountActivateSchema, AccountV1ActivateData, AccountV1ActivateResponse, AccountV1ActivateResponses, AccountV1ApplyEmailChangeData, AccountV1ApplyEmailChangeResponses, AccountV1ApplyPasswordChangeData, AccountV1ApplyPasswordChangeResponses, AccountV1CompleteOtpSetupData, AccountV1CompleteOtpSetupResponse, AccountV1CompleteOtpSetupResponses, AccountV1GetMeData, AccountV1GetMeResponse, AccountV1GetMeResponses, AccountV1JoinData, AccountV1JoinResponses, AccountV1LoginData, AccountV1LoginResponse, AccountV1LoginResponses, AccountV1LogoutData, AccountV1LogoutResponses, AccountV1RequestActivationData, AccountV1RequestActivationResponses, AccountV1RequestEmailChangeData, AccountV1RequestEmailChangeResponses, AccountV1RequestPasswordChangeData, AccountV1RequestPasswordChangeResponses, AccountV1ResetOtpData, AccountV1ResetOtpResponses, AccountV1SetupOtpData, AccountV1SetupOtpResponse, AccountV1SetupOtpResponses, AccountV1UpdateMeData, AccountV1UpdateMeResponse, AccountV1UpdateMeResponses, AccountV1UploadAvatarData, AccountV1UploadAvatarResponse, AccountV1UploadAvatarResponses, AccountV1VerifyOtpData, AccountV1VerifyOtpResponses, AnnounceSchema, AppealCreateSchema, AppealSchema, ApplyEmailChangeSchema, ApplyPasswordChangeSchema, AssessmentSaveSpec, AssessmentSpec, AssessmentSuggestionSpec, AssignmentAttemptSchema, AssignmentGradeSchema, AssignmentQuestionSaveSpec, AssignmentQuestionSchema, AssignmentQuestionSpec, AssignmentQuestionsSaveSpec, AssignmentQuestionsSpec, AssignmentSaveSpec, AssignmentSchema, AssignmentSessionSchema, AssignmentSpec, AssignmentSubmissionSchema, AssignmentSubmitSchema, AssignmentV1DeactivateAttemptData, AssignmentV1DeactivateAttemptResponses, AssignmentV1GetSessionData, AssignmentV1GetSessionResponse, AssignmentV1GetSessionResponses, AssignmentV1StartAttemptData, AssignmentV1StartAttemptResponse, AssignmentV1StartAttemptResponses, AssignmentV1SubmitAttemptData, AssignmentV1SubmitAttemptResponse, AssignmentV1SubmitAttemptResponses, AssistantNoteSaveSchema, AssistantV1ChatMessageData, AssistantV1ChatMessageResponses, AssistantV1DeleteChatData, AssistantV1DeleteChatResponses, AssistantV1GetChatMessagesData, AssistantV1GetChatMessagesResponse, AssistantV1GetChatMessagesResponses, AssistantV1GetChatsData, AssistantV1GetChatsResponse, AssistantV1GetChatsResponses, AssistantV1SaveAssistantNoteData, AssistantV1SaveAssistantNoteResponses, AssistantV1UpdateChatMessageData, AssistantV1UpdateChatMessageResponses, AuthorizeResponseSchema, AuthorizeSchema, CatalogContentSchema, CatalogItemEnrollSchema, CatalogItemSchema, CatalogSchema, CertificateAwardSchema, CertificateEndorsementSchema, CertificateFilterSchema, CertificateSchema, CertificateSkillSchema, ChatListSchema, ChatMessageCreateSchema, ChatMessageSchema, ChatMessageUpdateSchema, ChatSchema, ClassificationSchema, ClassificationTreeNodeSchema, ClientOptions, CohortSchema, CohortStaffSchema, CommentBriefSchema, CommentNestedSchema, CommentSavedSchema, CommentSaveSchema, CommentSchema, CommentThreadSchema, CompetencyFactorySchema, CompetencyGoalSaveSchema, CompetencyGoalSchema, CompetencySkillSchema, CompetencyV1DeleteCompetencyGoalData, CompetencyV1DeleteCompetencyGoalResponses, CompetencyV1GetCertificateAwardsData, CompetencyV1GetCertificateAwardsResponse, CompetencyV1GetCertificateAwardsResponses, CompetencyV1GetCertificatesData, CompetencyV1GetCertificatesResponse, CompetencyV1GetCertificatesResponses, CompetencyV1GetClassificationSkillsDataData, CompetencyV1GetClassificationSkillsDataResponse, CompetencyV1GetClassificationSkillsDataResponses, CompetencyV1GetClassificationTreeData, CompetencyV1GetClassificationTreeResponse, CompetencyV1GetClassificationTreeResponses, CompetencyV1GetCompetencyGoalsData, CompetencyV1GetCompetencyGoalsResponse, CompetencyV1GetCompetencyGoalsResponses, CompetencyV1SaveCompetencyGoalData, CompetencyV1SaveCompetencyGoalResponse, CompetencyV1SaveCompetencyGoalResponses, ContentSuggestionSpec, ContentTypeSchema, ContentV1DeleteMediaWatchData, ContentV1DeleteMediaWatchResponses, ContentV1GetMediaData, ContentV1GetMediaNoteData, ContentV1GetMediaNoteResponse, ContentV1GetMediaNoteResponses, ContentV1GetMediaResponse, ContentV1GetMediaResponses, ContentV1GetMediaWatchData, ContentV1GetMediaWatchResponse, ContentV1GetMediaWatchResponses, ContentV1GetSubtitlesData, ContentV1GetSubtitlesResponse, ContentV1GetSubtitlesResponses, ContentV1GetWatchMediasData, ContentV1GetWatchMediasResponse, ContentV1GetWatchMediasResponses, ContentV1SaveMediaNoteData, ContentV1SaveMediaNoteResponse, ContentV1SaveMediaNoteResponses, ContentV1SearchData, ContentV1SearchResponse, ContentV1SearchResponses, ContentV1SearchSuggestionData, ContentV1SearchSuggestionResponse, ContentV1SearchSuggestionResponses, ContentV1UpdateMediaWatchData, ContentV1UpdateMediaWatchResponses, CourseAssetsSpec, CourseCategorySaveSpec, CourseCategorySchema, CourseCategorySpec, CourseCertificateItemSchema, CourseCertificateRequestSchema, CourseCertificateSaveSpec, CourseCertificateSchema, CourseCertificateSpec, CourseDetailSchema, CourseEngagementSchema, CourseGradebookSchema, CourseInstructorItemSchema, CourseInstructorSaveSpec, CourseInstructorSchema, CourseInstructorSpec, CourseRelationItemSchema, CourseRelationSaveSpec, CourseRelationSchema, CourseRelationSpec, CourseSaveSpec, CourseSchema, CourseSessionSchema, CourseSpec, CourseSurveyItemSchema, CourseSurveySaveSpec, CourseSurveySchema, CourseSurveySpec, CourseV1GetDetailData, CourseV1GetDetailResponse, CourseV1GetDetailResponses, CourseV1GetSessionData, CourseV1GetSessionResponse, CourseV1GetSessionResponses, CourseV1RequestCertificateData, CourseV1RequestCertificateResponse, CourseV1RequestCertificateResponses, CourseV1StartEngagementData, CourseV1StartEngagementResponse, CourseV1StartEngagementResponses, DeviceRegisterSchema, DeviceSchema, DiscussionAttemptSchema, DiscussionEarnedDetailsSchema, DiscussionFeedbackSchema, DiscussionGradeSchema, DiscussionOwnPostSchema, DiscussionPostCountSchema, DiscussionPostNestedSchema, DiscussionPostSaveSchema, DiscussionPostSchema, DiscussionPostWithCountSchema, DiscussionQuestionSaveSpec, DiscussionQuestionSchema, DiscussionQuestionSpec, DiscussionQuestionsSaveSpec, DiscussionQuestionsSpec, DiscussionSaveSpec, DiscussionSchema, DiscussionSessionSchema, DiscussionSpec, DiscussionV1CreatePostData, DiscussionV1CreatePostResponse, DiscussionV1CreatePostResponses, DiscussionV1DeactivateAttemptData, DiscussionV1DeactivateAttemptResponses, DiscussionV1DeletePostData, DiscussionV1DeletePostResponses, DiscussionV1GetOwnPostsData, DiscussionV1GetOwnPostsResponse, DiscussionV1GetOwnPostsResponses, DiscussionV1GetPostsData, DiscussionV1GetPostsResponse, DiscussionV1GetPostsResponses, DiscussionV1GetSessionData, DiscussionV1GetSessionResponse, DiscussionV1GetSessionResponses, DiscussionV1StartAttemptData, DiscussionV1StartAttemptResponse, DiscussionV1StartAttemptResponses, DiscussionV1UpdatePostData, DiscussionV1UpdatePostResponse, DiscussionV1UpdatePostResponses, EnrollmentContentSchema, EnrollmentSchema, EnrollmentSuccessSchema, ExamAttemptAnswersSchema, ExamAttemptSchema, ExamGradeSchema, ExamQuestionFormatChoices, ExamQuestionPoolSchema, ExamQuestionPoolSpec, ExamQuestionSaveSpec, ExamQuestionSchema, ExamQuestionSolutionSpec, ExamQuestionSpec, ExamQuestionsSaveSpec, ExamQuestionsSpec, ExamSaveSpec, ExamSchema, ExamSessionSchema, ExamSolutionSchema, ExamSpec, ExamSubmissionSchema, ExamV1DeactivateAttemptData, ExamV1DeactivateAttemptResponses, ExamV1GetSessionData, ExamV1GetSessionResponse, ExamV1GetSessionResponses, ExamV1GetTimestampData, ExamV1GetTimestampResponse, ExamV1GetTimestampResponses, ExamV1SaveAnswersData, ExamV1SaveAnswersResponses, ExamV1StartAttemptData, ExamV1StartAttemptResponse, ExamV1StartAttemptResponses, ExamV1SubmitAttemptData, ExamV1SubmitAttemptResponse, ExamV1SubmitAttemptResponses, FactoryDataSchema, FaqItemSchema, FaqSchema, GradingCriterionSchema, GradingDateSchema, GradingPolicySpec, HonorCodeSchema, InlineSuggestionSpec, Input, InquiryCreateSchema, InquiryFilterSchema, InquiryResponseSchema, InquirySavedSchema, InquirySchema, InquiryUpdateSchema, JoinSchema, KindChoices, LearningRecordSchema, LearningReportSchema, LearningSessionStep, LearningV1EnrollCatalogItemData, LearningV1EnrollCatalogItemResponse, LearningV1EnrollCatalogItemResponses, LearningV1GetCatalogItemsData, LearningV1GetCatalogItemsResponse, LearningV1GetCatalogItemsResponses, LearningV1GetCatalogsData, LearningV1GetCatalogsResponse, LearningV1GetCatalogsResponses, LearningV1GetEnrolledData, LearningV1GetEnrolledResponse, LearningV1GetEnrolledResponses, LearningV1GetRecordsData, LearningV1GetRecordsResponse, LearningV1GetRecordsResponses, LearningV1GetReportData, LearningV1GetReportResponse, LearningV1GetReportResponses, LearningV1UnenrollData, LearningV1UnenrollResponses, LessonMediaItemSchema, LessonMediaSchema, LessonSaveSpec, LessonSchema, LessonSpec, LevelChoices, LoginSchema, MatchedLineSchema, MediaFormatChoices, MediaSaveSpec, MediaSchema, MediaSpec, MessageDataSchema, MessageSchema, MinimaApiHealthData, MinimaApiHealthResponses, NoteSaveSchema, NoteSchema, OperationV1AgreePoliciesData, OperationV1AgreePoliciesResponses, OperationV1CreateAppealData, OperationV1CreateAppealResponse, OperationV1CreateAppealResponses, OperationV1CreateInquiryData, OperationV1CreateInquiryResponse, OperationV1CreateInquiryResponses, OperationV1CreateThreadData, OperationV1CreateThreadResponse, OperationV1CreateThreadResponses, OperationV1DeleteCommentData, OperationV1DeleteCommentResponses, OperationV1DeleteDeviceData, OperationV1DeleteDeviceResponses, OperationV1EffectivePoliciesData, OperationV1EffectivePoliciesResponse, OperationV1EffectivePoliciesResponses, OperationV1GetAnnouncementsData, OperationV1GetAnnouncementsResponse, OperationV1GetAnnouncementsResponses, OperationV1GetCommentsData, OperationV1GetCommentsResponse, OperationV1GetCommentsResponses, OperationV1GetDevicesData, OperationV1GetDevicesResponse, OperationV1GetDevicesResponses, OperationV1GetInquiriesData, OperationV1GetInquiriesResponse, OperationV1GetInquiriesResponses, OperationV1GetThreadCommentsData, OperationV1GetThreadCommentsResponse, OperationV1GetThreadCommentsResponses, OperationV1GetThreadData, OperationV1GetThreadResponse, OperationV1GetThreadResponses, OperationV1GetUnreadMessagesData, OperationV1GetUnreadMessagesResponse, OperationV1GetUnreadMessagesResponses, OperationV1ReadAnnouncementData, OperationV1ReadAnnouncementResponses, OperationV1ReadMessageData, OperationV1ReadMessageResponses, OperationV1RegisterDeviceData, OperationV1RegisterDeviceResponse, OperationV1RegisterDeviceResponses, OperationV1SaveCommentData, OperationV1SaveCommentResponse, OperationV1SaveCommentResponses, OperationV1ToggleDeviceActiveData, OperationV1ToggleDeviceActiveResponses, OperationV1UpdateInquiryData, OperationV1UpdateInquiryResponse, OperationV1UpdateInquiryResponses, OtpSetupCompleteSchema, OtpSetupSchema, OtpVerifySchema, OwnerSchema, PagedAnnounceSchema, PagedCertificateAwardSchema, PagedChatMessageSchema, PagedCommentBriefSchema, PagedCommentNestedSchema, PagedDiscussionPostNestedSchema, PagedInquirySchema, PagedMessageSchema, PagedWatchedMediaSchema, PaginatedResponseCatalogItemSchema, PaginatedResponseEnrollmentSchema, PaginatedResponseSearchedMediaSchema, PaginatedResponseStudioContentSpec, PartnerGroupMemberSchema, PartnerGroupSchema, PartnerSchema, PartnerV1MemberInfosData, PartnerV1MemberInfosResponse, PartnerV1MemberInfosResponses, PerformanceLevelSchema, PolicyVersionAgreementSchema, QuizAttemptAnswersSchema, QuizAttemptSchema, QuizGradeSchema, QuizQuestionPoolSpec, QuizQuestionSaveSpec, QuizQuestionSchema, QuizQuestionSolutionSpec, QuizQuestionSpec, QuizQuestionsSaveSpec, QuizQuestionsSpec, QuizSaveSpec, QuizSchema, QuizSessionSchema, QuizSolutionSchema, QuizSpec, QuizSubmissionSchema, QuizV1DeactivateAttemptData, QuizV1DeactivateAttemptResponses, QuizV1GetSessionData, QuizV1GetSessionResponse, QuizV1GetSessionResponses, QuizV1StartAttemptData, QuizV1StartAttemptResponse, QuizV1StartAttemptResponses, QuizV1SubmitAttemptData, QuizV1SubmitAttemptResponse, QuizV1SubmitAttemptResponses, RequestActivationSchema, RequestEmailChangeSchema, RequestPasswordChangeSchema, RoleChoices, RootModelListAssessmentSaveSpec, RootModelListCourseCategorySaveSpec, RootModelListCourseCertificateSaveSpec, RootModelListCourseInstructorSaveSpec, RootModelListCourseRelationSaveSpec, RootModelListCourseSurveySaveSpec, RootModelListLessonSaveSpec, RootModelListRubricCriterionSpec, RubricCriterionSchema, RubricCriterionSpec, RubricSchema, ScoreStatsSchema, SearchedMediaSchema, SitePolicySchema, SitePolicyVersionSchema, SkillDataSchema, SsoAccountSchema, SsoV1AuthorizeData, SsoV1AuthorizeResponse, SsoV1AuthorizeResponses, SsoV1CallbackData, SsoV1CallbackResponses, SsoV1DeleteAccountData, SsoV1DeleteAccountResponses, SsoV1GetAccountsData, SsoV1GetAccountsResponse, SsoV1GetAccountsResponses, SsoV1LinkData, SsoV1LinkResponse, SsoV1LinkResponses, StudioContentSpec, StudioV1AssessmentSuggestionsData, StudioV1AssessmentSuggestionsResponse, StudioV1AssessmentSuggestionsResponses, StudioV1ContentData, StudioV1ContentResponse, StudioV1ContentResponses, StudioV1ContentSuggestionsData, StudioV1ContentSuggestionsResponse, StudioV1ContentSuggestionsResponses, StudioV1CreateMediaQuizData, StudioV1CreateMediaQuizResponse, StudioV1CreateMediaQuizResponses, StudioV1DeleteAssignmentData, StudioV1DeleteAssignmentQuesionData, StudioV1DeleteAssignmentQuesionResponses, StudioV1DeleteAssignmentResponses, StudioV1DeleteCourseData, StudioV1DeleteCourseResponses, StudioV1DeleteDiscussionData, StudioV1DeleteDiscussionQuesionData, StudioV1DeleteDiscussionQuesionResponses, StudioV1DeleteDiscussionResponses, StudioV1DeleteExamData, StudioV1DeleteExamQuesionData, StudioV1DeleteExamQuesionResponses, StudioV1DeleteExamResponses, StudioV1DeleteMediaData, StudioV1DeleteMediaResponses, StudioV1DeleteMediaSubtitleData, StudioV1DeleteMediaSubtitleResponses, StudioV1DeleteQuizData, StudioV1DeleteQuizQuesionData, StudioV1DeleteQuizQuesionResponses, StudioV1DeleteQuizResponses, StudioV1DeleteSurveyData, StudioV1DeleteSurveyQuesionData, StudioV1DeleteSurveyQuesionResponses, StudioV1DeleteSurveyResponses, StudioV1GetAssignmentData, StudioV1GetAssignmentQuestionsData, StudioV1GetAssignmentQuestionsResponse, StudioV1GetAssignmentQuestionsResponses, StudioV1GetAssignmentResponse, StudioV1GetAssignmentResponses, StudioV1GetAssignmentRubricData, StudioV1GetAssignmentRubricResponse, StudioV1GetAssignmentRubricResponses, StudioV1GetCourseData, StudioV1GetCourseResponse, StudioV1GetCourseResponses, StudioV1GetDiscussionData, StudioV1GetDiscussionQuestionsData, StudioV1GetDiscussionQuestionsResponse, StudioV1GetDiscussionQuestionsResponses, StudioV1GetDiscussionResponse, StudioV1GetDiscussionResponses, StudioV1GetExamData, StudioV1GetExamQuestionsData, StudioV1GetExamQuestionsResponse, StudioV1GetExamQuestionsResponses, StudioV1GetExamResponse, StudioV1GetExamResponses, StudioV1GetMediaData, StudioV1GetMediaResponse, StudioV1GetMediaResponses, StudioV1GetQuizData, StudioV1GetQuizQuestionsData, StudioV1GetQuizQuestionsResponse, StudioV1GetQuizQuestionsResponses, StudioV1GetQuizResponse, StudioV1GetQuizResponses, StudioV1GetSurveyData, StudioV1GetSurveyQuestionsData, StudioV1GetSurveyQuestionsResponse, StudioV1GetSurveyQuestionsResponses, StudioV1GetSurveyResponse, StudioV1GetSurveyResponses, StudioV1InlineSuggestionsData, StudioV1InlineSuggestionsResponse, StudioV1InlineSuggestionsResponses, StudioV1RemoveCourseAssessmentData, StudioV1RemoveCourseAssessmentResponses, StudioV1RemoveCourseCategoryData, StudioV1RemoveCourseCategoryResponses, StudioV1RemoveCourseCertificateData, StudioV1RemoveCourseCertificateResponses, StudioV1RemoveCourseInstructorData, StudioV1RemoveCourseInstructorResponses, StudioV1RemoveCourseLessonData, StudioV1RemoveCourseLessonResponses, StudioV1RemoveCourseRelationData, StudioV1RemoveCourseRelationResponses, StudioV1RemoveCourseSurveyData, StudioV1RemoveCourseSurveyResponses, StudioV1SaveAssignmentData, StudioV1SaveAssignmentQuestionsData, StudioV1SaveAssignmentQuestionsResponse, StudioV1SaveAssignmentQuestionsResponses, StudioV1SaveAssignmentResponse, StudioV1SaveAssignmentResponses, StudioV1SaveAssignmentRubricData, StudioV1SaveAssignmentRubricResponses, StudioV1SaveCourseAssessmentsData, StudioV1SaveCourseAssessmentsResponse, StudioV1SaveCourseAssessmentsResponses, StudioV1SaveCourseCategoriesData, StudioV1SaveCourseCategoriesResponse, StudioV1SaveCourseCategoriesResponses, StudioV1SaveCourseCertificatesData, StudioV1SaveCourseCertificatesResponse, StudioV1SaveCourseCertificatesResponses, StudioV1SaveCourseData, StudioV1SaveCourseInstructorsData, StudioV1SaveCourseInstructorsResponse, StudioV1SaveCourseInstructorsResponses, StudioV1SaveCourseLessonsData, StudioV1SaveCourseLessonsResponse, StudioV1SaveCourseLessonsResponses, StudioV1SaveCourseRelationsData, StudioV1SaveCourseRelationsResponse, StudioV1SaveCourseRelationsResponses, StudioV1SaveCourseResponse, StudioV1SaveCourseResponses, StudioV1SaveCourseSurveysData, StudioV1SaveCourseSurveysResponse, StudioV1SaveCourseSurveysResponses, StudioV1SaveDiscussionData, StudioV1SaveDiscussionQuestionsData, StudioV1SaveDiscussionQuestionsResponse, StudioV1SaveDiscussionQuestionsResponses, StudioV1SaveDiscussionResponse, StudioV1SaveDiscussionResponses, StudioV1SaveExamData, StudioV1SaveExamQuestionsData, StudioV1SaveExamQuestionsResponse, StudioV1SaveExamQuestionsResponses, StudioV1SaveExamResponse, StudioV1SaveExamResponses, StudioV1SaveMediaData, StudioV1SaveMediaResponse, StudioV1SaveMediaResponses, StudioV1SaveMediaSubtitleData, StudioV1SaveMediaSubtitleResponses, StudioV1SaveQuizData, StudioV1SaveQuizQuestionsData, StudioV1SaveQuizQuestionsResponse, StudioV1SaveQuizQuestionsResponses, StudioV1SaveQuizResponse, StudioV1SaveQuizResponses, StudioV1SaveSurveyData, StudioV1SaveSurveyQuestionsData, StudioV1SaveSurveyQuestionsResponse, StudioV1SaveSurveyQuestionsResponses, StudioV1SaveSurveyResponse, StudioV1SaveSurveyResponses, SubtitleSchema, SubtitleSpec, SurveyAnswersSchema, SurveyQuestionFormatChoices, SurveyQuestionSaveSpec, SurveyQuestionSchema, SurveyQuestionSpec, SurveyQuestionsSaveSpec, SurveyQuestionsSpec, SurveySaveSpec, SurveySchema, SurveySpec, SurveyV1GetAnonymousSurveyData, SurveyV1GetAnonymousSurveyResponse, SurveyV1GetAnonymousSurveyResponses, SurveyV1GetSurveyData, SurveyV1GetSurveyResponse, SurveyV1GetSurveyResponses, SurveyV1ResultsAnonymousData, SurveyV1ResultsAnonymousResponse, SurveyV1ResultsAnonymousResponses, SurveyV1ResultsData, SurveyV1ResultsResponse, SurveyV1ResultsResponses, SurveyV1SubmitAnonymousData, SurveyV1SubmitAnonymousResponses, SurveyV1SubmitData, SurveyV1SubmitResponses, ThreadCreateSchema, ThreadSchema, TotpDeviceSchema, UserSchema, UserUpdateSchema, WatchedMediaSchema, WatchInSchema, WatchOutSchema } from './types.gen'; +export { accountV1Activate, accountV1ApplyEmailChange, accountV1ApplyPasswordChange, accountV1CompleteOtpSetup, accountV1GetMe, accountV1Join, accountV1Login, accountV1Logout, accountV1RequestActivation, accountV1RequestEmailChange, accountV1RequestPasswordChange, accountV1ResetOtp, accountV1SetupOtp, accountV1UpdateMe, accountV1UploadAvatar, accountV1VerifyOtp, assignmentV1DeactivateAttempt, assignmentV1GetSession, assignmentV1StartAttempt, assignmentV1SubmitAttempt, assistantV1ChatMessage, assistantV1DeleteChat, assistantV1GetChatMessages, assistantV1GetChats, assistantV1SaveAssistantNote, assistantV1UpdateChatMessage, competencyV1DeleteCompetencyGoal, competencyV1GetCertificateAwards, competencyV1GetCertificates, competencyV1GetClassificationSkillsData, competencyV1GetClassificationTree, competencyV1GetCompetencyGoals, competencyV1SaveCompetencyGoal, contentV1DeleteMediaWatch, contentV1GetMedia, contentV1GetMediaNote, contentV1GetMediaWatch, contentV1GetSubtitles, contentV1GetWatchMedias, contentV1SaveMediaNote, contentV1Search, contentV1SearchSuggestion, contentV1UpdateMediaWatch, courseV1GetDetail, courseV1GetSession, courseV1RequestCertificate, courseV1StartEngagement, discussionV1CreatePost, discussionV1DeactivateAttempt, discussionV1DeletePost, discussionV1GetOwnPosts, discussionV1GetPosts, discussionV1GetSession, discussionV1StartAttempt, discussionV1UpdatePost, examV1DeactivateAttempt, examV1GetSession, examV1GetTimestamp, examV1SaveAnswers, examV1StartAttempt, examV1SubmitAttempt, learningV1EnrollCatalogItem, learningV1GetCatalogItems, learningV1GetCatalogs, learningV1GetEnrolled, learningV1GetRecords, learningV1GetReport, learningV1Unenroll, minimaApiHealth, operationV1AgreePolicies, operationV1CreateAppeal, operationV1CreateInquiry, operationV1CreateThread, operationV1DeleteComment, operationV1DeleteDevice, operationV1EffectivePolicies, operationV1GetAnnouncements, operationV1GetComments, operationV1GetDevices, operationV1GetInquiries, operationV1GetThread, operationV1GetThreadComments, operationV1GetUnreadMessages, operationV1ReadAnnouncement, operationV1ReadMessage, operationV1RegisterDevice, operationV1SaveComment, operationV1ToggleDeviceActive, operationV1UpdateInquiry, type Options, partnerV1MemberInfos, quizV1DeactivateAttempt, quizV1GetSession, quizV1StartAttempt, quizV1SubmitAttempt, ssoV1Authorize, ssoV1Callback, ssoV1DeleteAccount, ssoV1GetAccounts, ssoV1Link, studioV1AssessmentSuggestions, studioV1Content, studioV1ContentSuggestions, studioV1CreateMediaQuiz, studioV1DeleteAssignment, studioV1DeleteAssignmentQuesion, studioV1DeleteCourse, studioV1DeleteDiscussion, studioV1DeleteDiscussionQuesion, studioV1DeleteExam, studioV1DeleteExamQuesion, studioV1DeleteMedia, studioV1DeleteMediaSubtitle, studioV1DeleteQuiz, studioV1DeleteQuizQuesion, studioV1DeleteSurvey, studioV1DeleteSurveyQuesion, studioV1GetAssignment, studioV1GetAssignmentQuestions, studioV1GetAssignmentRubric, studioV1GetCourse, studioV1GetDiscussion, studioV1GetDiscussionQuestions, studioV1GetExam, studioV1GetExamQuestions, studioV1GetMedia, studioV1GetQuiz, studioV1GetQuizQuestions, studioV1GetSurvey, studioV1GetSurveyQuestions, studioV1InlineSuggestions, studioV1RemoveCourseAssessment, studioV1RemoveCourseCategory, studioV1RemoveCourseCertificate, studioV1RemoveCourseInstructor, studioV1RemoveCourseLesson, studioV1RemoveCourseRelation, studioV1RemoveCourseSurvey, studioV1SaveAssignment, studioV1SaveAssignmentQuestions, studioV1SaveAssignmentRubric, studioV1SaveCourse, studioV1SaveCourseAssessments, studioV1SaveCourseCategories, studioV1SaveCourseCertificates, studioV1SaveCourseInstructors, studioV1SaveCourseLessons, studioV1SaveCourseRelations, studioV1SaveCourseSurveys, studioV1SaveDiscussion, studioV1SaveDiscussionQuestions, studioV1SaveExam, studioV1SaveExamQuestions, studioV1SaveMedia, studioV1SaveMediaSubtitle, studioV1SaveQuiz, studioV1SaveQuizQuestions, studioV1SaveSurvey, studioV1SaveSurveyQuestions, surveyV1GetAnonymousSurvey, surveyV1GetSurvey, surveyV1Results, surveyV1ResultsAnonymous, surveyV1Submit, surveyV1SubmitAnonymous, tutorV1CompleteAssignmentGrade, tutorV1CompleteDiscussionGrade, tutorV1CompleteExamGrade, tutorV1GetAllocation, tutorV1GetAllocationStats, tutorV1GetAssignmentGradePaper, tutorV1GetAssignmentGrades, tutorV1GetAssignmentRubric, tutorV1GetDiscussionGradePaper, tutorV1GetDiscussionGrades, tutorV1GetExamGradePaper, tutorV1GetExamGrades } from './sdk.gen'; +export type { AccessDateSchema, AccountActivatedSchema, AccountActivateSchema, AccountV1ActivateData, AccountV1ActivateResponse, AccountV1ActivateResponses, AccountV1ApplyEmailChangeData, AccountV1ApplyEmailChangeResponses, AccountV1ApplyPasswordChangeData, AccountV1ApplyPasswordChangeResponses, AccountV1CompleteOtpSetupData, AccountV1CompleteOtpSetupResponse, AccountV1CompleteOtpSetupResponses, AccountV1GetMeData, AccountV1GetMeResponse, AccountV1GetMeResponses, AccountV1JoinData, AccountV1JoinResponses, AccountV1LoginData, AccountV1LoginResponse, AccountV1LoginResponses, AccountV1LogoutData, AccountV1LogoutResponses, AccountV1RequestActivationData, AccountV1RequestActivationResponses, AccountV1RequestEmailChangeData, AccountV1RequestEmailChangeResponses, AccountV1RequestPasswordChangeData, AccountV1RequestPasswordChangeResponses, AccountV1ResetOtpData, AccountV1ResetOtpResponses, AccountV1SetupOtpData, AccountV1SetupOtpResponse, AccountV1SetupOtpResponses, AccountV1UpdateMeData, AccountV1UpdateMeResponse, AccountV1UpdateMeResponses, AccountV1UploadAvatarData, AccountV1UploadAvatarResponse, AccountV1UploadAvatarResponses, AccountV1VerifyOtpData, AccountV1VerifyOtpResponses, AllocationSchema, AllocationStatsSchema, AnnounceSchema, AppealCreateSchema, AppealSchema, ApplyEmailChangeSchema, ApplyPasswordChangeSchema, AssessmentSaveSpec, AssessmentSpec, AssessmentSuggestionSpec, AssignmentAttemptSchema, AssignmentGradeSchema, AssignmentQuestionSaveSpec, AssignmentQuestionSchema, AssignmentQuestionSpec, AssignmentQuestionsSaveSpec, AssignmentQuestionsSpec, AssignmentSaveSpec, AssignmentSchema, AssignmentSessionSchema, AssignmentSpec, AssignmentSubmissionSchema, AssignmentSubmitSchema, AssignmentV1DeactivateAttemptData, AssignmentV1DeactivateAttemptResponses, AssignmentV1GetSessionData, AssignmentV1GetSessionResponse, AssignmentV1GetSessionResponses, AssignmentV1StartAttemptData, AssignmentV1StartAttemptResponse, AssignmentV1StartAttemptResponses, AssignmentV1SubmitAttemptData, AssignmentV1SubmitAttemptResponse, AssignmentV1SubmitAttemptResponses, AssistantNoteSaveSchema, AssistantV1ChatMessageData, AssistantV1ChatMessageResponses, AssistantV1DeleteChatData, AssistantV1DeleteChatResponses, AssistantV1GetChatMessagesData, AssistantV1GetChatMessagesResponse, AssistantV1GetChatMessagesResponses, AssistantV1GetChatsData, AssistantV1GetChatsResponse, AssistantV1GetChatsResponses, AssistantV1SaveAssistantNoteData, AssistantV1SaveAssistantNoteResponses, AssistantV1UpdateChatMessageData, AssistantV1UpdateChatMessageResponses, AuthorizeResponseSchema, AuthorizeSchema, CatalogContentSchema, CatalogItemEnrollSchema, CatalogItemSchema, CatalogSchema, CertificateAwardSchema, CertificateEndorsementSchema, CertificateFilterSchema, CertificateSchema, CertificateSkillSchema, ChatListSchema, ChatMessageCreateSchema, ChatMessageSchema, ChatMessageUpdateSchema, ChatSchema, ClassificationSchema, ClassificationTreeNodeSchema, ClientOptions, CohortSchema, CohortStaffSchema, CommentBriefSchema, CommentNestedSchema, CommentSavedSchema, CommentSaveSchema, CommentSchema, CommentThreadSchema, CompetencyFactorySchema, CompetencyGoalSaveSchema, CompetencyGoalSchema, CompetencySkillSchema, CompetencyV1DeleteCompetencyGoalData, CompetencyV1DeleteCompetencyGoalResponses, CompetencyV1GetCertificateAwardsData, CompetencyV1GetCertificateAwardsResponse, CompetencyV1GetCertificateAwardsResponses, CompetencyV1GetCertificatesData, CompetencyV1GetCertificatesResponse, CompetencyV1GetCertificatesResponses, CompetencyV1GetClassificationSkillsDataData, CompetencyV1GetClassificationSkillsDataResponse, CompetencyV1GetClassificationSkillsDataResponses, CompetencyV1GetClassificationTreeData, CompetencyV1GetClassificationTreeResponse, CompetencyV1GetClassificationTreeResponses, CompetencyV1GetCompetencyGoalsData, CompetencyV1GetCompetencyGoalsResponse, CompetencyV1GetCompetencyGoalsResponses, CompetencyV1SaveCompetencyGoalData, CompetencyV1SaveCompetencyGoalResponse, CompetencyV1SaveCompetencyGoalResponses, ContentSuggestionSpec, ContentTypeSchema, ContentV1DeleteMediaWatchData, ContentV1DeleteMediaWatchResponses, ContentV1GetMediaData, ContentV1GetMediaNoteData, ContentV1GetMediaNoteResponse, ContentV1GetMediaNoteResponses, ContentV1GetMediaResponse, ContentV1GetMediaResponses, ContentV1GetMediaWatchData, ContentV1GetMediaWatchResponse, ContentV1GetMediaWatchResponses, ContentV1GetSubtitlesData, ContentV1GetSubtitlesResponse, ContentV1GetSubtitlesResponses, ContentV1GetWatchMediasData, ContentV1GetWatchMediasResponse, ContentV1GetWatchMediasResponses, ContentV1SaveMediaNoteData, ContentV1SaveMediaNoteResponse, ContentV1SaveMediaNoteResponses, ContentV1SearchData, ContentV1SearchResponse, ContentV1SearchResponses, ContentV1SearchSuggestionData, ContentV1SearchSuggestionResponse, ContentV1SearchSuggestionResponses, ContentV1UpdateMediaWatchData, ContentV1UpdateMediaWatchResponses, CourseAssetsSpec, CourseCategorySaveSpec, CourseCategorySchema, CourseCategorySpec, CourseCertificateItemSchema, CourseCertificateRequestSchema, CourseCertificateSaveSpec, CourseCertificateSchema, CourseCertificateSpec, CourseDetailSchema, CourseEngagementSchema, CourseGradebookSchema, CourseInstructorItemSchema, CourseInstructorSaveSpec, CourseInstructorSchema, CourseInstructorSpec, CourseRelationItemSchema, CourseRelationSaveSpec, CourseRelationSchema, CourseRelationSpec, CourseSaveSpec, CourseSchema, CourseSessionSchema, CourseSpec, CourseSurveyItemSchema, CourseSurveySaveSpec, CourseSurveySchema, CourseSurveySpec, CourseV1GetDetailData, CourseV1GetDetailResponse, CourseV1GetDetailResponses, CourseV1GetSessionData, CourseV1GetSessionResponse, CourseV1GetSessionResponses, CourseV1RequestCertificateData, CourseV1RequestCertificateResponse, CourseV1RequestCertificateResponses, CourseV1StartEngagementData, CourseV1StartEngagementResponse, CourseV1StartEngagementResponses, DeviceRegisterSchema, DeviceSchema, DiscussionAttemptSchema, DiscussionEarnedDetailsSaveSchema, DiscussionEarnedDetailsSchema, DiscussionFeedbackSaveSchema, DiscussionFeedbackSchema, DiscussionGradeSchema, DiscussionOwnPostSchema, DiscussionPostCountSchema, DiscussionPostNestedSchema, DiscussionPostSaveSchema, DiscussionPostSchema, DiscussionPostWithCountSchema, DiscussionQuestionSaveSpec, DiscussionQuestionSchema, DiscussionQuestionSpec, DiscussionQuestionsSaveSpec, DiscussionQuestionsSpec, DiscussionSaveSpec, DiscussionSchema, DiscussionSessionSchema, DiscussionSpec, DiscussionV1CreatePostData, DiscussionV1CreatePostResponse, DiscussionV1CreatePostResponses, DiscussionV1DeactivateAttemptData, DiscussionV1DeactivateAttemptResponses, DiscussionV1DeletePostData, DiscussionV1DeletePostResponses, DiscussionV1GetOwnPostsData, DiscussionV1GetOwnPostsResponse, DiscussionV1GetOwnPostsResponses, DiscussionV1GetPostsData, DiscussionV1GetPostsResponse, DiscussionV1GetPostsResponses, DiscussionV1GetSessionData, DiscussionV1GetSessionResponse, DiscussionV1GetSessionResponses, DiscussionV1StartAttemptData, DiscussionV1StartAttemptResponse, DiscussionV1StartAttemptResponses, DiscussionV1UpdatePostData, DiscussionV1UpdatePostResponse, DiscussionV1UpdatePostResponses, EnrollmentContentSchema, EnrollmentSchema, EnrollmentSuccessSchema, ExamAttemptAnswersSchema, ExamAttemptSchema, ExamGradeSchema, ExamQuestionFormatChoices, ExamQuestionPoolSchema, ExamQuestionPoolSpec, ExamQuestionSaveSpec, ExamQuestionSchema, ExamQuestionSolutionSpec, ExamQuestionSpec, ExamQuestionsSaveSpec, ExamQuestionsSpec, ExamSaveSpec, ExamSchema, ExamSessionSchema, ExamSolutionSchema, ExamSpec, ExamSubmissionSchema, ExamV1DeactivateAttemptData, ExamV1DeactivateAttemptResponses, ExamV1GetSessionData, ExamV1GetSessionResponse, ExamV1GetSessionResponses, ExamV1GetTimestampData, ExamV1GetTimestampResponse, ExamV1GetTimestampResponses, ExamV1SaveAnswersData, ExamV1SaveAnswersResponses, ExamV1StartAttemptData, ExamV1StartAttemptResponse, ExamV1StartAttemptResponses, ExamV1SubmitAttemptData, ExamV1SubmitAttemptResponse, ExamV1SubmitAttemptResponses, FactoryDataSchema, FaqItemSchema, FaqSchema, GradingCriterionSchema, GradingDate, GradingDateSchema, GradingPolicySpec, HonorCodeSchema, InlineSuggestionSpec, Input, InquiryCreateSchema, InquiryFilterSchema, InquiryResponseSchema, InquirySavedSchema, InquirySchema, InquiryUpdateSchema, JoinSchema, KindChoices, LearningRecordSchema, LearningReportSchema, LearningSessionStep, LearningV1EnrollCatalogItemData, LearningV1EnrollCatalogItemResponse, LearningV1EnrollCatalogItemResponses, LearningV1GetCatalogItemsData, LearningV1GetCatalogItemsResponse, LearningV1GetCatalogItemsResponses, LearningV1GetCatalogsData, LearningV1GetCatalogsResponse, LearningV1GetCatalogsResponses, LearningV1GetEnrolledData, LearningV1GetEnrolledResponse, LearningV1GetEnrolledResponses, LearningV1GetRecordsData, LearningV1GetRecordsResponse, LearningV1GetRecordsResponses, LearningV1GetReportData, LearningV1GetReportResponse, LearningV1GetReportResponses, LearningV1UnenrollData, LearningV1UnenrollResponses, LessonMediaItemSchema, LessonMediaSchema, LessonSaveSpec, LessonSchema, LessonSpec, LevelChoices, LoginSchema, MatchedLineSchema, MediaFormatChoices, MediaSaveSpec, MediaSchema, MediaSpec, MessageDataSchema, MessageSchema, MinimaApiHealthData, MinimaApiHealthResponses, NoteSaveSchema, NoteSchema, OperationV1AgreePoliciesData, OperationV1AgreePoliciesResponses, OperationV1CreateAppealData, OperationV1CreateAppealResponse, OperationV1CreateAppealResponses, OperationV1CreateInquiryData, OperationV1CreateInquiryResponse, OperationV1CreateInquiryResponses, OperationV1CreateThreadData, OperationV1CreateThreadResponse, OperationV1CreateThreadResponses, OperationV1DeleteCommentData, OperationV1DeleteCommentResponses, OperationV1DeleteDeviceData, OperationV1DeleteDeviceResponses, OperationV1EffectivePoliciesData, OperationV1EffectivePoliciesResponse, OperationV1EffectivePoliciesResponses, OperationV1GetAnnouncementsData, OperationV1GetAnnouncementsResponse, OperationV1GetAnnouncementsResponses, OperationV1GetCommentsData, OperationV1GetCommentsResponse, OperationV1GetCommentsResponses, OperationV1GetDevicesData, OperationV1GetDevicesResponse, OperationV1GetDevicesResponses, OperationV1GetInquiriesData, OperationV1GetInquiriesResponse, OperationV1GetInquiriesResponses, OperationV1GetThreadCommentsData, OperationV1GetThreadCommentsResponse, OperationV1GetThreadCommentsResponses, OperationV1GetThreadData, OperationV1GetThreadResponse, OperationV1GetThreadResponses, OperationV1GetUnreadMessagesData, OperationV1GetUnreadMessagesResponse, OperationV1GetUnreadMessagesResponses, OperationV1ReadAnnouncementData, OperationV1ReadAnnouncementResponses, OperationV1ReadMessageData, OperationV1ReadMessageResponses, OperationV1RegisterDeviceData, OperationV1RegisterDeviceResponse, OperationV1RegisterDeviceResponses, OperationV1SaveCommentData, OperationV1SaveCommentResponse, OperationV1SaveCommentResponses, OperationV1ToggleDeviceActiveData, OperationV1ToggleDeviceActiveResponses, OperationV1UpdateInquiryData, OperationV1UpdateInquiryResponse, OperationV1UpdateInquiryResponses, OtpSetupCompleteSchema, OtpSetupSchema, OtpVerifySchema, OwnerSchema, PagedAnnounceSchema, PagedCertificateAwardSchema, PagedChatMessageSchema, PagedCommentBriefSchema, PagedCommentNestedSchema, PagedDiscussionPostNestedSchema, PagedInquirySchema, PagedMessageSchema, PagedTutorAssignmentGradeSchema, PagedTutorDiscussionGradeSchema, PagedTutorExamGradeSchema, PagedWatchedMediaSchema, PaginatedResponseAllocationSchema, PaginatedResponseCatalogItemSchema, PaginatedResponseEnrollmentSchema, PaginatedResponseSearchedMediaSchema, PaginatedResponseStudioContentSpec, PartnerGroupMemberSchema, PartnerGroupSchema, PartnerSchema, PartnerV1MemberInfosData, PartnerV1MemberInfosResponse, PartnerV1MemberInfosResponses, PerformanceLevelSchema, PolicyVersionAgreementSchema, QuizAttemptAnswersSchema, QuizAttemptSchema, QuizGradeSchema, QuizQuestionPoolSpec, QuizQuestionSaveSpec, QuizQuestionSchema, QuizQuestionSolutionSpec, QuizQuestionSpec, QuizQuestionsSaveSpec, QuizQuestionsSpec, QuizSaveSpec, QuizSchema, QuizSessionSchema, QuizSolutionSchema, QuizSpec, QuizSubmissionSchema, QuizV1DeactivateAttemptData, QuizV1DeactivateAttemptResponses, QuizV1GetSessionData, QuizV1GetSessionResponse, QuizV1GetSessionResponses, QuizV1StartAttemptData, QuizV1StartAttemptResponse, QuizV1StartAttemptResponses, QuizV1SubmitAttemptData, QuizV1SubmitAttemptResponse, QuizV1SubmitAttemptResponses, RequestActivationSchema, RequestEmailChangeSchema, RequestPasswordChangeSchema, RoleChoices, RootModelListAssessmentSaveSpec, RootModelListCourseCategorySaveSpec, RootModelListCourseCertificateSaveSpec, RootModelListCourseInstructorSaveSpec, RootModelListCourseRelationSaveSpec, RootModelListCourseSurveySaveSpec, RootModelListLessonSaveSpec, RootModelListRubricCriterionSpec, RubricCriterionSchema, RubricCriterionSpec, RubricSchema, ScoreStatsSchema, SearchedMediaSchema, SitePolicySchema, SitePolicyVersionSchema, SkillDataSchema, SsoAccountSchema, SsoV1AuthorizeData, SsoV1AuthorizeResponse, SsoV1AuthorizeResponses, SsoV1CallbackData, SsoV1CallbackResponses, SsoV1DeleteAccountData, SsoV1DeleteAccountResponses, SsoV1GetAccountsData, SsoV1GetAccountsResponse, SsoV1GetAccountsResponses, SsoV1LinkData, SsoV1LinkResponse, SsoV1LinkResponses, StudioContentSpec, StudioV1AssessmentSuggestionsData, StudioV1AssessmentSuggestionsResponse, StudioV1AssessmentSuggestionsResponses, StudioV1ContentData, StudioV1ContentResponse, StudioV1ContentResponses, StudioV1ContentSuggestionsData, StudioV1ContentSuggestionsResponse, StudioV1ContentSuggestionsResponses, StudioV1CreateMediaQuizData, StudioV1CreateMediaQuizResponse, StudioV1CreateMediaQuizResponses, StudioV1DeleteAssignmentData, StudioV1DeleteAssignmentQuesionData, StudioV1DeleteAssignmentQuesionResponses, StudioV1DeleteAssignmentResponses, StudioV1DeleteCourseData, StudioV1DeleteCourseResponses, StudioV1DeleteDiscussionData, StudioV1DeleteDiscussionQuesionData, StudioV1DeleteDiscussionQuesionResponses, StudioV1DeleteDiscussionResponses, StudioV1DeleteExamData, StudioV1DeleteExamQuesionData, StudioV1DeleteExamQuesionResponses, StudioV1DeleteExamResponses, StudioV1DeleteMediaData, StudioV1DeleteMediaResponses, StudioV1DeleteMediaSubtitleData, StudioV1DeleteMediaSubtitleResponses, StudioV1DeleteQuizData, StudioV1DeleteQuizQuesionData, StudioV1DeleteQuizQuesionResponses, StudioV1DeleteQuizResponses, StudioV1DeleteSurveyData, StudioV1DeleteSurveyQuesionData, StudioV1DeleteSurveyQuesionResponses, StudioV1DeleteSurveyResponses, StudioV1GetAssignmentData, StudioV1GetAssignmentQuestionsData, StudioV1GetAssignmentQuestionsResponse, StudioV1GetAssignmentQuestionsResponses, StudioV1GetAssignmentResponse, StudioV1GetAssignmentResponses, StudioV1GetAssignmentRubricData, StudioV1GetAssignmentRubricResponse, StudioV1GetAssignmentRubricResponses, StudioV1GetCourseData, StudioV1GetCourseResponse, StudioV1GetCourseResponses, StudioV1GetDiscussionData, StudioV1GetDiscussionQuestionsData, StudioV1GetDiscussionQuestionsResponse, StudioV1GetDiscussionQuestionsResponses, StudioV1GetDiscussionResponse, StudioV1GetDiscussionResponses, StudioV1GetExamData, StudioV1GetExamQuestionsData, StudioV1GetExamQuestionsResponse, StudioV1GetExamQuestionsResponses, StudioV1GetExamResponse, StudioV1GetExamResponses, StudioV1GetMediaData, StudioV1GetMediaResponse, StudioV1GetMediaResponses, StudioV1GetQuizData, StudioV1GetQuizQuestionsData, StudioV1GetQuizQuestionsResponse, StudioV1GetQuizQuestionsResponses, StudioV1GetQuizResponse, StudioV1GetQuizResponses, StudioV1GetSurveyData, StudioV1GetSurveyQuestionsData, StudioV1GetSurveyQuestionsResponse, StudioV1GetSurveyQuestionsResponses, StudioV1GetSurveyResponse, StudioV1GetSurveyResponses, StudioV1InlineSuggestionsData, StudioV1InlineSuggestionsResponse, StudioV1InlineSuggestionsResponses, StudioV1RemoveCourseAssessmentData, StudioV1RemoveCourseAssessmentResponses, StudioV1RemoveCourseCategoryData, StudioV1RemoveCourseCategoryResponses, StudioV1RemoveCourseCertificateData, StudioV1RemoveCourseCertificateResponses, StudioV1RemoveCourseInstructorData, StudioV1RemoveCourseInstructorResponses, StudioV1RemoveCourseLessonData, StudioV1RemoveCourseLessonResponses, StudioV1RemoveCourseRelationData, StudioV1RemoveCourseRelationResponses, StudioV1RemoveCourseSurveyData, StudioV1RemoveCourseSurveyResponses, StudioV1SaveAssignmentData, StudioV1SaveAssignmentQuestionsData, StudioV1SaveAssignmentQuestionsResponse, StudioV1SaveAssignmentQuestionsResponses, StudioV1SaveAssignmentResponse, StudioV1SaveAssignmentResponses, StudioV1SaveAssignmentRubricData, StudioV1SaveAssignmentRubricResponses, StudioV1SaveCourseAssessmentsData, StudioV1SaveCourseAssessmentsResponse, StudioV1SaveCourseAssessmentsResponses, StudioV1SaveCourseCategoriesData, StudioV1SaveCourseCategoriesResponse, StudioV1SaveCourseCategoriesResponses, StudioV1SaveCourseCertificatesData, StudioV1SaveCourseCertificatesResponse, StudioV1SaveCourseCertificatesResponses, StudioV1SaveCourseData, StudioV1SaveCourseInstructorsData, StudioV1SaveCourseInstructorsResponse, StudioV1SaveCourseInstructorsResponses, StudioV1SaveCourseLessonsData, StudioV1SaveCourseLessonsResponse, StudioV1SaveCourseLessonsResponses, StudioV1SaveCourseRelationsData, StudioV1SaveCourseRelationsResponse, StudioV1SaveCourseRelationsResponses, StudioV1SaveCourseResponse, StudioV1SaveCourseResponses, StudioV1SaveCourseSurveysData, StudioV1SaveCourseSurveysResponse, StudioV1SaveCourseSurveysResponses, StudioV1SaveDiscussionData, StudioV1SaveDiscussionQuestionsData, StudioV1SaveDiscussionQuestionsResponse, StudioV1SaveDiscussionQuestionsResponses, StudioV1SaveDiscussionResponse, StudioV1SaveDiscussionResponses, StudioV1SaveExamData, StudioV1SaveExamQuestionsData, StudioV1SaveExamQuestionsResponse, StudioV1SaveExamQuestionsResponses, StudioV1SaveExamResponse, StudioV1SaveExamResponses, StudioV1SaveMediaData, StudioV1SaveMediaResponse, StudioV1SaveMediaResponses, StudioV1SaveMediaSubtitleData, StudioV1SaveMediaSubtitleResponses, StudioV1SaveQuizData, StudioV1SaveQuizQuestionsData, StudioV1SaveQuizQuestionsResponse, StudioV1SaveQuizQuestionsResponses, StudioV1SaveQuizResponse, StudioV1SaveQuizResponses, StudioV1SaveSurveyData, StudioV1SaveSurveyQuestionsData, StudioV1SaveSurveyQuestionsResponse, StudioV1SaveSurveyQuestionsResponses, StudioV1SaveSurveyResponse, StudioV1SaveSurveyResponses, SubtitleSchema, SubtitleSpec, SurveyAnswersSchema, SurveyQuestionFormatChoices, SurveyQuestionSaveSpec, SurveyQuestionSchema, SurveyQuestionSpec, SurveyQuestionsSaveSpec, SurveyQuestionsSpec, SurveySaveSpec, SurveySchema, SurveySpec, SurveyV1GetAnonymousSurveyData, SurveyV1GetAnonymousSurveyResponse, SurveyV1GetAnonymousSurveyResponses, SurveyV1GetSurveyData, SurveyV1GetSurveyResponse, SurveyV1GetSurveyResponses, SurveyV1ResultsAnonymousData, SurveyV1ResultsAnonymousResponse, SurveyV1ResultsAnonymousResponses, SurveyV1ResultsData, SurveyV1ResultsResponse, SurveyV1ResultsResponses, SurveyV1SubmitAnonymousData, SurveyV1SubmitAnonymousResponses, SurveyV1SubmitData, SurveyV1SubmitResponses, ThreadCreateSchema, ThreadSchema, TotpDeviceSchema, TutorAssignmentGradePaperSchema, TutorAssignmentGradeSchema, TutorContentSchema, TutorDiscussionGradePaperSchema, TutorDiscussionGradeSaveSchema, TutorDiscussionGradeSchema, TutorExamGradePaperSchema, TutorExamGradeSchema, TutorExamQuestionSchema, TutorGradeSaveSchema, TutorGraeCompleteSchema, TutorV1CompleteAssignmentGradeData, TutorV1CompleteAssignmentGradeResponse, TutorV1CompleteAssignmentGradeResponses, TutorV1CompleteDiscussionGradeData, TutorV1CompleteDiscussionGradeResponse, TutorV1CompleteDiscussionGradeResponses, TutorV1CompleteExamGradeData, TutorV1CompleteExamGradeResponse, TutorV1CompleteExamGradeResponses, TutorV1GetAllocationData, TutorV1GetAllocationResponse, TutorV1GetAllocationResponses, TutorV1GetAllocationStatsData, TutorV1GetAllocationStatsResponse, TutorV1GetAllocationStatsResponses, TutorV1GetAssignmentGradePaperData, TutorV1GetAssignmentGradePaperResponse, TutorV1GetAssignmentGradePaperResponses, TutorV1GetAssignmentGradesData, TutorV1GetAssignmentGradesResponse, TutorV1GetAssignmentGradesResponses, TutorV1GetAssignmentRubricData, TutorV1GetAssignmentRubricResponse, TutorV1GetAssignmentRubricResponses, TutorV1GetDiscussionGradePaperData, TutorV1GetDiscussionGradePaperResponse, TutorV1GetDiscussionGradePaperResponses, TutorV1GetDiscussionGradesData, TutorV1GetDiscussionGradesResponse, TutorV1GetDiscussionGradesResponses, TutorV1GetExamGradePaperData, TutorV1GetExamGradePaperResponse, TutorV1GetExamGradePaperResponses, TutorV1GetExamGradesData, TutorV1GetExamGradesResponse, TutorV1GetExamGradesResponses, UserSchema, UserUpdateSchema, WatchedMediaSchema, WatchInSchema, WatchOutSchema } from './types.gen'; diff --git a/web/src/api/sdk.gen.ts b/web/src/api/sdk.gen.ts index dabe4ac..a99f6be 100644 --- a/web/src/api/sdk.gen.ts +++ b/web/src/api/sdk.gen.ts @@ -2,7 +2,7 @@ import { type Client, formDataBodySerializer, type Options as Options2, type TDataShape } from './client'; import { client } from './client.gen'; -import type { AccountV1ActivateData, AccountV1ActivateResponses, AccountV1ApplyEmailChangeData, AccountV1ApplyEmailChangeResponses, AccountV1ApplyPasswordChangeData, AccountV1ApplyPasswordChangeResponses, AccountV1CompleteOtpSetupData, AccountV1CompleteOtpSetupResponses, AccountV1GetMeData, AccountV1GetMeResponses, AccountV1JoinData, AccountV1JoinResponses, AccountV1LoginData, AccountV1LoginResponses, AccountV1LogoutData, AccountV1LogoutResponses, AccountV1RequestActivationData, AccountV1RequestActivationResponses, AccountV1RequestEmailChangeData, AccountV1RequestEmailChangeResponses, AccountV1RequestPasswordChangeData, AccountV1RequestPasswordChangeResponses, AccountV1ResetOtpData, AccountV1ResetOtpResponses, AccountV1SetupOtpData, AccountV1SetupOtpResponses, AccountV1UpdateMeData, AccountV1UpdateMeResponses, AccountV1UploadAvatarData, AccountV1UploadAvatarResponses, AccountV1VerifyOtpData, AccountV1VerifyOtpResponses, AssignmentV1DeactivateAttemptData, AssignmentV1DeactivateAttemptResponses, AssignmentV1GetSessionData, AssignmentV1GetSessionResponses, AssignmentV1StartAttemptData, AssignmentV1StartAttemptResponses, AssignmentV1SubmitAttemptData, AssignmentV1SubmitAttemptResponses, AssistantV1ChatMessageData, AssistantV1ChatMessageResponses, AssistantV1DeleteChatData, AssistantV1DeleteChatResponses, AssistantV1GetChatMessagesData, AssistantV1GetChatMessagesResponses, AssistantV1GetChatsData, AssistantV1GetChatsResponses, AssistantV1SaveAssistantNoteData, AssistantV1SaveAssistantNoteResponses, AssistantV1UpdateChatMessageData, AssistantV1UpdateChatMessageResponses, CompetencyV1DeleteCompetencyGoalData, CompetencyV1DeleteCompetencyGoalResponses, CompetencyV1GetCertificateAwardsData, CompetencyV1GetCertificateAwardsResponses, CompetencyV1GetCertificatesData, CompetencyV1GetCertificatesResponses, CompetencyV1GetClassificationSkillsDataData, CompetencyV1GetClassificationSkillsDataResponses, CompetencyV1GetClassificationTreeData, CompetencyV1GetClassificationTreeResponses, CompetencyV1GetCompetencyGoalsData, CompetencyV1GetCompetencyGoalsResponses, CompetencyV1SaveCompetencyGoalData, CompetencyV1SaveCompetencyGoalResponses, ContentV1DeleteMediaWatchData, ContentV1DeleteMediaWatchResponses, ContentV1GetMediaData, ContentV1GetMediaNoteData, ContentV1GetMediaNoteResponses, ContentV1GetMediaResponses, ContentV1GetMediaWatchData, ContentV1GetMediaWatchResponses, ContentV1GetSubtitlesData, ContentV1GetSubtitlesResponses, ContentV1GetWatchMediasData, ContentV1GetWatchMediasResponses, ContentV1SaveMediaNoteData, ContentV1SaveMediaNoteResponses, ContentV1SearchData, ContentV1SearchResponses, ContentV1SearchSuggestionData, ContentV1SearchSuggestionResponses, ContentV1UpdateMediaWatchData, ContentV1UpdateMediaWatchResponses, CourseV1GetDetailData, CourseV1GetDetailResponses, CourseV1GetSessionData, CourseV1GetSessionResponses, CourseV1RequestCertificateData, CourseV1RequestCertificateResponses, CourseV1StartEngagementData, CourseV1StartEngagementResponses, DiscussionV1CreatePostData, DiscussionV1CreatePostResponses, DiscussionV1DeactivateAttemptData, DiscussionV1DeactivateAttemptResponses, DiscussionV1DeletePostData, DiscussionV1DeletePostResponses, DiscussionV1GetOwnPostsData, DiscussionV1GetOwnPostsResponses, DiscussionV1GetPostsData, DiscussionV1GetPostsResponses, DiscussionV1GetSessionData, DiscussionV1GetSessionResponses, DiscussionV1StartAttemptData, DiscussionV1StartAttemptResponses, DiscussionV1UpdatePostData, DiscussionV1UpdatePostResponses, ExamV1DeactivateAttemptData, ExamV1DeactivateAttemptResponses, ExamV1GetSessionData, ExamV1GetSessionResponses, ExamV1GetTimestampData, ExamV1GetTimestampResponses, ExamV1SaveAnswersData, ExamV1SaveAnswersResponses, ExamV1StartAttemptData, ExamV1StartAttemptResponses, ExamV1SubmitAttemptData, ExamV1SubmitAttemptResponses, LearningV1EnrollCatalogItemData, LearningV1EnrollCatalogItemResponses, LearningV1GetCatalogItemsData, LearningV1GetCatalogItemsResponses, LearningV1GetCatalogsData, LearningV1GetCatalogsResponses, LearningV1GetEnrolledData, LearningV1GetEnrolledResponses, LearningV1GetRecordsData, LearningV1GetRecordsResponses, LearningV1GetReportData, LearningV1GetReportResponses, LearningV1UnenrollData, LearningV1UnenrollResponses, MinimaApiHealthData, MinimaApiHealthResponses, OperationV1AgreePoliciesData, OperationV1AgreePoliciesResponses, OperationV1CreateAppealData, OperationV1CreateAppealResponses, OperationV1CreateInquiryData, OperationV1CreateInquiryResponses, OperationV1CreateThreadData, OperationV1CreateThreadResponses, OperationV1DeleteCommentData, OperationV1DeleteCommentResponses, OperationV1DeleteDeviceData, OperationV1DeleteDeviceResponses, OperationV1EffectivePoliciesData, OperationV1EffectivePoliciesResponses, OperationV1GetAnnouncementsData, OperationV1GetAnnouncementsResponses, OperationV1GetCommentsData, OperationV1GetCommentsResponses, OperationV1GetDevicesData, OperationV1GetDevicesResponses, OperationV1GetInquiriesData, OperationV1GetInquiriesResponses, OperationV1GetThreadCommentsData, OperationV1GetThreadCommentsResponses, OperationV1GetThreadData, OperationV1GetThreadResponses, OperationV1GetUnreadMessagesData, OperationV1GetUnreadMessagesResponses, OperationV1ReadAnnouncementData, OperationV1ReadAnnouncementResponses, OperationV1ReadMessageData, OperationV1ReadMessageResponses, OperationV1RegisterDeviceData, OperationV1RegisterDeviceResponses, OperationV1SaveCommentData, OperationV1SaveCommentResponses, OperationV1ToggleDeviceActiveData, OperationV1ToggleDeviceActiveResponses, OperationV1UpdateInquiryData, OperationV1UpdateInquiryResponses, PartnerV1MemberInfosData, PartnerV1MemberInfosResponses, QuizV1DeactivateAttemptData, QuizV1DeactivateAttemptResponses, QuizV1GetSessionData, QuizV1GetSessionResponses, QuizV1StartAttemptData, QuizV1StartAttemptResponses, QuizV1SubmitAttemptData, QuizV1SubmitAttemptResponses, SsoV1AuthorizeData, SsoV1AuthorizeResponses, SsoV1CallbackData, SsoV1CallbackResponses, SsoV1DeleteAccountData, SsoV1DeleteAccountResponses, SsoV1GetAccountsData, SsoV1GetAccountsResponses, SsoV1LinkData, SsoV1LinkResponses, StudioV1AssessmentSuggestionsData, StudioV1AssessmentSuggestionsResponses, StudioV1ContentData, StudioV1ContentResponses, StudioV1ContentSuggestionsData, StudioV1ContentSuggestionsResponses, StudioV1CreateMediaQuizData, StudioV1CreateMediaQuizResponses, StudioV1DeleteAssignmentData, StudioV1DeleteAssignmentQuesionData, StudioV1DeleteAssignmentQuesionResponses, StudioV1DeleteAssignmentResponses, StudioV1DeleteCourseData, StudioV1DeleteCourseResponses, StudioV1DeleteDiscussionData, StudioV1DeleteDiscussionQuesionData, StudioV1DeleteDiscussionQuesionResponses, StudioV1DeleteDiscussionResponses, StudioV1DeleteExamData, StudioV1DeleteExamQuesionData, StudioV1DeleteExamQuesionResponses, StudioV1DeleteExamResponses, StudioV1DeleteMediaData, StudioV1DeleteMediaResponses, StudioV1DeleteMediaSubtitleData, StudioV1DeleteMediaSubtitleResponses, StudioV1DeleteQuizData, StudioV1DeleteQuizQuesionData, StudioV1DeleteQuizQuesionResponses, StudioV1DeleteQuizResponses, StudioV1DeleteSurveyData, StudioV1DeleteSurveyQuesionData, StudioV1DeleteSurveyQuesionResponses, StudioV1DeleteSurveyResponses, StudioV1GetAssignmentData, StudioV1GetAssignmentQuestionsData, StudioV1GetAssignmentQuestionsResponses, StudioV1GetAssignmentResponses, StudioV1GetAssignmentRubricData, StudioV1GetAssignmentRubricResponses, StudioV1GetCourseData, StudioV1GetCourseResponses, StudioV1GetDiscussionData, StudioV1GetDiscussionQuestionsData, StudioV1GetDiscussionQuestionsResponses, StudioV1GetDiscussionResponses, StudioV1GetExamData, StudioV1GetExamQuestionsData, StudioV1GetExamQuestionsResponses, StudioV1GetExamResponses, StudioV1GetMediaData, StudioV1GetMediaResponses, StudioV1GetQuizData, StudioV1GetQuizQuestionsData, StudioV1GetQuizQuestionsResponses, StudioV1GetQuizResponses, StudioV1GetSurveyData, StudioV1GetSurveyQuestionsData, StudioV1GetSurveyQuestionsResponses, StudioV1GetSurveyResponses, StudioV1InlineSuggestionsData, StudioV1InlineSuggestionsResponses, StudioV1RemoveCourseAssessmentData, StudioV1RemoveCourseAssessmentResponses, StudioV1RemoveCourseCategoryData, StudioV1RemoveCourseCategoryResponses, StudioV1RemoveCourseCertificateData, StudioV1RemoveCourseCertificateResponses, StudioV1RemoveCourseInstructorData, StudioV1RemoveCourseInstructorResponses, StudioV1RemoveCourseLessonData, StudioV1RemoveCourseLessonResponses, StudioV1RemoveCourseRelationData, StudioV1RemoveCourseRelationResponses, StudioV1RemoveCourseSurveyData, StudioV1RemoveCourseSurveyResponses, StudioV1SaveAssignmentData, StudioV1SaveAssignmentQuestionsData, StudioV1SaveAssignmentQuestionsResponses, StudioV1SaveAssignmentResponses, StudioV1SaveAssignmentRubricData, StudioV1SaveAssignmentRubricResponses, StudioV1SaveCourseAssessmentsData, StudioV1SaveCourseAssessmentsResponses, StudioV1SaveCourseCategoriesData, StudioV1SaveCourseCategoriesResponses, StudioV1SaveCourseCertificatesData, StudioV1SaveCourseCertificatesResponses, StudioV1SaveCourseData, StudioV1SaveCourseInstructorsData, StudioV1SaveCourseInstructorsResponses, StudioV1SaveCourseLessonsData, StudioV1SaveCourseLessonsResponses, StudioV1SaveCourseRelationsData, StudioV1SaveCourseRelationsResponses, StudioV1SaveCourseResponses, StudioV1SaveCourseSurveysData, StudioV1SaveCourseSurveysResponses, StudioV1SaveDiscussionData, StudioV1SaveDiscussionQuestionsData, StudioV1SaveDiscussionQuestionsResponses, StudioV1SaveDiscussionResponses, StudioV1SaveExamData, StudioV1SaveExamQuestionsData, StudioV1SaveExamQuestionsResponses, StudioV1SaveExamResponses, StudioV1SaveMediaData, StudioV1SaveMediaResponses, StudioV1SaveMediaSubtitleData, StudioV1SaveMediaSubtitleResponses, StudioV1SaveQuizData, StudioV1SaveQuizQuestionsData, StudioV1SaveQuizQuestionsResponses, StudioV1SaveQuizResponses, StudioV1SaveSurveyData, StudioV1SaveSurveyQuestionsData, StudioV1SaveSurveyQuestionsResponses, StudioV1SaveSurveyResponses, SurveyV1GetAnonymousSurveyData, SurveyV1GetAnonymousSurveyResponses, SurveyV1GetSurveyData, SurveyV1GetSurveyResponses, SurveyV1ResultsAnonymousData, SurveyV1ResultsAnonymousResponses, SurveyV1ResultsData, SurveyV1ResultsResponses, SurveyV1SubmitAnonymousData, SurveyV1SubmitAnonymousResponses, SurveyV1SubmitData, SurveyV1SubmitResponses } from './types.gen'; +import type { AccountV1ActivateData, AccountV1ActivateResponses, AccountV1ApplyEmailChangeData, AccountV1ApplyEmailChangeResponses, AccountV1ApplyPasswordChangeData, AccountV1ApplyPasswordChangeResponses, AccountV1CompleteOtpSetupData, AccountV1CompleteOtpSetupResponses, AccountV1GetMeData, AccountV1GetMeResponses, AccountV1JoinData, AccountV1JoinResponses, AccountV1LoginData, AccountV1LoginResponses, AccountV1LogoutData, AccountV1LogoutResponses, AccountV1RequestActivationData, AccountV1RequestActivationResponses, AccountV1RequestEmailChangeData, AccountV1RequestEmailChangeResponses, AccountV1RequestPasswordChangeData, AccountV1RequestPasswordChangeResponses, AccountV1ResetOtpData, AccountV1ResetOtpResponses, AccountV1SetupOtpData, AccountV1SetupOtpResponses, AccountV1UpdateMeData, AccountV1UpdateMeResponses, AccountV1UploadAvatarData, AccountV1UploadAvatarResponses, AccountV1VerifyOtpData, AccountV1VerifyOtpResponses, AssignmentV1DeactivateAttemptData, AssignmentV1DeactivateAttemptResponses, AssignmentV1GetSessionData, AssignmentV1GetSessionResponses, AssignmentV1StartAttemptData, AssignmentV1StartAttemptResponses, AssignmentV1SubmitAttemptData, AssignmentV1SubmitAttemptResponses, AssistantV1ChatMessageData, AssistantV1ChatMessageResponses, AssistantV1DeleteChatData, AssistantV1DeleteChatResponses, AssistantV1GetChatMessagesData, AssistantV1GetChatMessagesResponses, AssistantV1GetChatsData, AssistantV1GetChatsResponses, AssistantV1SaveAssistantNoteData, AssistantV1SaveAssistantNoteResponses, AssistantV1UpdateChatMessageData, AssistantV1UpdateChatMessageResponses, CompetencyV1DeleteCompetencyGoalData, CompetencyV1DeleteCompetencyGoalResponses, CompetencyV1GetCertificateAwardsData, CompetencyV1GetCertificateAwardsResponses, CompetencyV1GetCertificatesData, CompetencyV1GetCertificatesResponses, CompetencyV1GetClassificationSkillsDataData, CompetencyV1GetClassificationSkillsDataResponses, CompetencyV1GetClassificationTreeData, CompetencyV1GetClassificationTreeResponses, CompetencyV1GetCompetencyGoalsData, CompetencyV1GetCompetencyGoalsResponses, CompetencyV1SaveCompetencyGoalData, CompetencyV1SaveCompetencyGoalResponses, ContentV1DeleteMediaWatchData, ContentV1DeleteMediaWatchResponses, ContentV1GetMediaData, ContentV1GetMediaNoteData, ContentV1GetMediaNoteResponses, ContentV1GetMediaResponses, ContentV1GetMediaWatchData, ContentV1GetMediaWatchResponses, ContentV1GetSubtitlesData, ContentV1GetSubtitlesResponses, ContentV1GetWatchMediasData, ContentV1GetWatchMediasResponses, ContentV1SaveMediaNoteData, ContentV1SaveMediaNoteResponses, ContentV1SearchData, ContentV1SearchResponses, ContentV1SearchSuggestionData, ContentV1SearchSuggestionResponses, ContentV1UpdateMediaWatchData, ContentV1UpdateMediaWatchResponses, CourseV1GetDetailData, CourseV1GetDetailResponses, CourseV1GetSessionData, CourseV1GetSessionResponses, CourseV1RequestCertificateData, CourseV1RequestCertificateResponses, CourseV1StartEngagementData, CourseV1StartEngagementResponses, DiscussionV1CreatePostData, DiscussionV1CreatePostResponses, DiscussionV1DeactivateAttemptData, DiscussionV1DeactivateAttemptResponses, DiscussionV1DeletePostData, DiscussionV1DeletePostResponses, DiscussionV1GetOwnPostsData, DiscussionV1GetOwnPostsResponses, DiscussionV1GetPostsData, DiscussionV1GetPostsResponses, DiscussionV1GetSessionData, DiscussionV1GetSessionResponses, DiscussionV1StartAttemptData, DiscussionV1StartAttemptResponses, DiscussionV1UpdatePostData, DiscussionV1UpdatePostResponses, ExamV1DeactivateAttemptData, ExamV1DeactivateAttemptResponses, ExamV1GetSessionData, ExamV1GetSessionResponses, ExamV1GetTimestampData, ExamV1GetTimestampResponses, ExamV1SaveAnswersData, ExamV1SaveAnswersResponses, ExamV1StartAttemptData, ExamV1StartAttemptResponses, ExamV1SubmitAttemptData, ExamV1SubmitAttemptResponses, LearningV1EnrollCatalogItemData, LearningV1EnrollCatalogItemResponses, LearningV1GetCatalogItemsData, LearningV1GetCatalogItemsResponses, LearningV1GetCatalogsData, LearningV1GetCatalogsResponses, LearningV1GetEnrolledData, LearningV1GetEnrolledResponses, LearningV1GetRecordsData, LearningV1GetRecordsResponses, LearningV1GetReportData, LearningV1GetReportResponses, LearningV1UnenrollData, LearningV1UnenrollResponses, MinimaApiHealthData, MinimaApiHealthResponses, OperationV1AgreePoliciesData, OperationV1AgreePoliciesResponses, OperationV1CreateAppealData, OperationV1CreateAppealResponses, OperationV1CreateInquiryData, OperationV1CreateInquiryResponses, OperationV1CreateThreadData, OperationV1CreateThreadResponses, OperationV1DeleteCommentData, OperationV1DeleteCommentResponses, OperationV1DeleteDeviceData, OperationV1DeleteDeviceResponses, OperationV1EffectivePoliciesData, OperationV1EffectivePoliciesResponses, OperationV1GetAnnouncementsData, OperationV1GetAnnouncementsResponses, OperationV1GetCommentsData, OperationV1GetCommentsResponses, OperationV1GetDevicesData, OperationV1GetDevicesResponses, OperationV1GetInquiriesData, OperationV1GetInquiriesResponses, OperationV1GetThreadCommentsData, OperationV1GetThreadCommentsResponses, OperationV1GetThreadData, OperationV1GetThreadResponses, OperationV1GetUnreadMessagesData, OperationV1GetUnreadMessagesResponses, OperationV1ReadAnnouncementData, OperationV1ReadAnnouncementResponses, OperationV1ReadMessageData, OperationV1ReadMessageResponses, OperationV1RegisterDeviceData, OperationV1RegisterDeviceResponses, OperationV1SaveCommentData, OperationV1SaveCommentResponses, OperationV1ToggleDeviceActiveData, OperationV1ToggleDeviceActiveResponses, OperationV1UpdateInquiryData, OperationV1UpdateInquiryResponses, PartnerV1MemberInfosData, PartnerV1MemberInfosResponses, QuizV1DeactivateAttemptData, QuizV1DeactivateAttemptResponses, QuizV1GetSessionData, QuizV1GetSessionResponses, QuizV1StartAttemptData, QuizV1StartAttemptResponses, QuizV1SubmitAttemptData, QuizV1SubmitAttemptResponses, SsoV1AuthorizeData, SsoV1AuthorizeResponses, SsoV1CallbackData, SsoV1CallbackResponses, SsoV1DeleteAccountData, SsoV1DeleteAccountResponses, SsoV1GetAccountsData, SsoV1GetAccountsResponses, SsoV1LinkData, SsoV1LinkResponses, StudioV1AssessmentSuggestionsData, StudioV1AssessmentSuggestionsResponses, StudioV1ContentData, StudioV1ContentResponses, StudioV1ContentSuggestionsData, StudioV1ContentSuggestionsResponses, StudioV1CreateMediaQuizData, StudioV1CreateMediaQuizResponses, StudioV1DeleteAssignmentData, StudioV1DeleteAssignmentQuesionData, StudioV1DeleteAssignmentQuesionResponses, StudioV1DeleteAssignmentResponses, StudioV1DeleteCourseData, StudioV1DeleteCourseResponses, StudioV1DeleteDiscussionData, StudioV1DeleteDiscussionQuesionData, StudioV1DeleteDiscussionQuesionResponses, StudioV1DeleteDiscussionResponses, StudioV1DeleteExamData, StudioV1DeleteExamQuesionData, StudioV1DeleteExamQuesionResponses, StudioV1DeleteExamResponses, StudioV1DeleteMediaData, StudioV1DeleteMediaResponses, StudioV1DeleteMediaSubtitleData, StudioV1DeleteMediaSubtitleResponses, StudioV1DeleteQuizData, StudioV1DeleteQuizQuesionData, StudioV1DeleteQuizQuesionResponses, StudioV1DeleteQuizResponses, StudioV1DeleteSurveyData, StudioV1DeleteSurveyQuesionData, StudioV1DeleteSurveyQuesionResponses, StudioV1DeleteSurveyResponses, StudioV1GetAssignmentData, StudioV1GetAssignmentQuestionsData, StudioV1GetAssignmentQuestionsResponses, StudioV1GetAssignmentResponses, StudioV1GetAssignmentRubricData, StudioV1GetAssignmentRubricResponses, StudioV1GetCourseData, StudioV1GetCourseResponses, StudioV1GetDiscussionData, StudioV1GetDiscussionQuestionsData, StudioV1GetDiscussionQuestionsResponses, StudioV1GetDiscussionResponses, StudioV1GetExamData, StudioV1GetExamQuestionsData, StudioV1GetExamQuestionsResponses, StudioV1GetExamResponses, StudioV1GetMediaData, StudioV1GetMediaResponses, StudioV1GetQuizData, StudioV1GetQuizQuestionsData, StudioV1GetQuizQuestionsResponses, StudioV1GetQuizResponses, StudioV1GetSurveyData, StudioV1GetSurveyQuestionsData, StudioV1GetSurveyQuestionsResponses, StudioV1GetSurveyResponses, StudioV1InlineSuggestionsData, StudioV1InlineSuggestionsResponses, StudioV1RemoveCourseAssessmentData, StudioV1RemoveCourseAssessmentResponses, StudioV1RemoveCourseCategoryData, StudioV1RemoveCourseCategoryResponses, StudioV1RemoveCourseCertificateData, StudioV1RemoveCourseCertificateResponses, StudioV1RemoveCourseInstructorData, StudioV1RemoveCourseInstructorResponses, StudioV1RemoveCourseLessonData, StudioV1RemoveCourseLessonResponses, StudioV1RemoveCourseRelationData, StudioV1RemoveCourseRelationResponses, StudioV1RemoveCourseSurveyData, StudioV1RemoveCourseSurveyResponses, StudioV1SaveAssignmentData, StudioV1SaveAssignmentQuestionsData, StudioV1SaveAssignmentQuestionsResponses, StudioV1SaveAssignmentResponses, StudioV1SaveAssignmentRubricData, StudioV1SaveAssignmentRubricResponses, StudioV1SaveCourseAssessmentsData, StudioV1SaveCourseAssessmentsResponses, StudioV1SaveCourseCategoriesData, StudioV1SaveCourseCategoriesResponses, StudioV1SaveCourseCertificatesData, StudioV1SaveCourseCertificatesResponses, StudioV1SaveCourseData, StudioV1SaveCourseInstructorsData, StudioV1SaveCourseInstructorsResponses, StudioV1SaveCourseLessonsData, StudioV1SaveCourseLessonsResponses, StudioV1SaveCourseRelationsData, StudioV1SaveCourseRelationsResponses, StudioV1SaveCourseResponses, StudioV1SaveCourseSurveysData, StudioV1SaveCourseSurveysResponses, StudioV1SaveDiscussionData, StudioV1SaveDiscussionQuestionsData, StudioV1SaveDiscussionQuestionsResponses, StudioV1SaveDiscussionResponses, StudioV1SaveExamData, StudioV1SaveExamQuestionsData, StudioV1SaveExamQuestionsResponses, StudioV1SaveExamResponses, StudioV1SaveMediaData, StudioV1SaveMediaResponses, StudioV1SaveMediaSubtitleData, StudioV1SaveMediaSubtitleResponses, StudioV1SaveQuizData, StudioV1SaveQuizQuestionsData, StudioV1SaveQuizQuestionsResponses, StudioV1SaveQuizResponses, StudioV1SaveSurveyData, StudioV1SaveSurveyQuestionsData, StudioV1SaveSurveyQuestionsResponses, StudioV1SaveSurveyResponses, SurveyV1GetAnonymousSurveyData, SurveyV1GetAnonymousSurveyResponses, SurveyV1GetSurveyData, SurveyV1GetSurveyResponses, SurveyV1ResultsAnonymousData, SurveyV1ResultsAnonymousResponses, SurveyV1ResultsData, SurveyV1ResultsResponses, SurveyV1SubmitAnonymousData, SurveyV1SubmitAnonymousResponses, SurveyV1SubmitData, SurveyV1SubmitResponses, TutorV1CompleteAssignmentGradeData, TutorV1CompleteAssignmentGradeResponses, TutorV1CompleteDiscussionGradeData, TutorV1CompleteDiscussionGradeResponses, TutorV1CompleteExamGradeData, TutorV1CompleteExamGradeResponses, TutorV1GetAllocationData, TutorV1GetAllocationResponses, TutorV1GetAllocationStatsData, TutorV1GetAllocationStatsResponses, TutorV1GetAssignmentGradePaperData, TutorV1GetAssignmentGradePaperResponses, TutorV1GetAssignmentGradesData, TutorV1GetAssignmentGradesResponses, TutorV1GetAssignmentRubricData, TutorV1GetAssignmentRubricResponses, TutorV1GetDiscussionGradePaperData, TutorV1GetDiscussionGradePaperResponses, TutorV1GetDiscussionGradesData, TutorV1GetDiscussionGradesResponses, TutorV1GetExamGradePaperData, TutorV1GetExamGradePaperResponses, TutorV1GetExamGradesData, TutorV1GetExamGradesResponses } from './types.gen'; export type Options = Options2 & { /** @@ -1575,3 +1575,123 @@ export const surveyV1ResultsAnonymous = (op url: '/api/v1/survey/{id}/anonymous/results', ...options }); + +/** + * Get Allocation + */ +export const tutorV1GetAllocation = (options?: Options) => (options?.client ?? client).get({ + responseType: 'json', + url: '/api/v1/tutor/allocation', + ...options +}); + +/** + * Get Allocation Stats + */ +export const tutorV1GetAllocationStats = (options?: Options) => (options?.client ?? client).get({ + responseType: 'json', + url: '/api/v1/tutor/allocation/stats', + ...options +}); + +/** + * Get Exam Grades + */ +export const tutorV1GetExamGrades = (options: Options) => (options.client ?? client).get({ + responseType: 'json', + url: '/api/v1/tutor/exam/{id}/grade', + ...options +}); + +/** + * Get Exam Grade Paper + */ +export const tutorV1GetExamGradePaper = (options: Options) => (options.client ?? client).get({ + responseType: 'json', + url: '/api/v1/tutor/exam/{id}/grade/{grade_id}', + ...options +}); + +/** + * Complete Exam Grade + */ +export const tutorV1CompleteExamGrade = (options: Options) => (options.client ?? client).post({ + responseType: 'json', + url: '/api/v1/tutor/exam/{id}/grade/{grade_id}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Get Assignment Grades + */ +export const tutorV1GetAssignmentGrades = (options: Options) => (options.client ?? client).get({ + responseType: 'json', + url: '/api/v1/tutor/assignment/{id}/grade', + ...options +}); + +/** + * Get Assignment Grade Paper + */ +export const tutorV1GetAssignmentGradePaper = (options: Options) => (options.client ?? client).get({ + responseType: 'json', + url: '/api/v1/tutor/assignment/{id}/grade/{grade_id}', + ...options +}); + +/** + * Complete Assignment Grade + */ +export const tutorV1CompleteAssignmentGrade = (options: Options) => (options.client ?? client).post({ + responseType: 'json', + url: '/api/v1/tutor/assignment/{id}/grade/{grade_id}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Get Assignment Rubric + */ +export const tutorV1GetAssignmentRubric = (options: Options) => (options.client ?? client).get({ + responseType: 'json', + url: '/api/v1/tutor/assignment/{id}/rubric', + ...options +}); + +/** + * Get Discussion Grades + */ +export const tutorV1GetDiscussionGrades = (options: Options) => (options.client ?? client).get({ + responseType: 'json', + url: '/api/v1/tutor/discussion/{id}/grade', + ...options +}); + +/** + * Get Discussion Grade Paper + */ +export const tutorV1GetDiscussionGradePaper = (options: Options) => (options.client ?? client).get({ + responseType: 'json', + url: '/api/v1/tutor/discussion/{id}/grade/{grade_id}', + ...options +}); + +/** + * Complete Discussion Grade + */ +export const tutorV1CompleteDiscussionGrade = (options: Options) => (options.client ?? client).post({ + responseType: 'json', + url: '/api/v1/tutor/discussion/{id}/grade/{grade_id}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); diff --git a/web/src/api/types.gen.ts b/web/src/api/types.gen.ts index fee35f1..763d1df 100644 --- a/web/src/api/types.gen.ts +++ b/web/src/api/types.gen.ts @@ -360,10 +360,6 @@ export type AppealSchema = { * Review */ review: string; - /** - * Closed - */ - closed: string | null; /** * Path */ @@ -1815,6 +1811,7 @@ export type CourseSchema = { */ export type CourseSessionSchema = { accessDate: AccessDateSchema; + gradingDate: GradingDateSchema; course: CourseSchema; engagement?: CourseEngagementSchema; /** @@ -2277,7 +2274,7 @@ export type DiscussionEarnedDetailsSchema = { /** * Tutorassessment */ - tutorAssessment: number; + tutorAssessment: number | null; }; /** @@ -2287,7 +2284,7 @@ export type DiscussionFeedbackSchema = { /** * Tutorassessment */ - tutorAssessment: string; + tutorAssessment?: string; }; /** @@ -5997,6 +5994,18 @@ export type CourseRelationSpec = { * CourseSpec */ export type CourseSpec = { + /** + * Gradeduedays + */ + gradeDueDays: number; + /** + * Appealdeadlinedays + */ + appealDeadlineDays: number; + /** + * Confirmduedays + */ + confirmDueDays: number; /** * Created */ @@ -6197,6 +6206,18 @@ export type CourseSaveSpec = { */ effortHours: number; level: LevelChoices; + /** + * Gradeduedays + */ + gradeDueDays: number; + /** + * Appealdeadlinedays + */ + appealDeadlineDays: number; + /** + * Confirmduedays + */ + confirmDueDays: number; /** * Honorcodeid */ @@ -6515,244 +6536,746 @@ export type SurveyAnswersSchema = { [key: string]: string; }; -export type MinimaApiHealthData = { - body?: never; - path?: never; - query?: never; - url: '/api/health'; -}; - -export type MinimaApiHealthResponses = { +/** + * AllocationSchema + */ +export type AllocationSchema = { /** - * OK + * Id */ - 200: unknown; -}; - -export type AccountV1LoginData = { - body: LoginSchema; - path?: never; - query?: never; - url: '/api/v1/account/login'; + id: number; + content: TutorContentSchema; + contentType: ContentTypeSchema; }; -export type AccountV1LoginResponses = { +/** + * PaginatedResponse[AllocationSchema] + */ +export type PaginatedResponseAllocationSchema = { /** - * OK + * Items */ - 200: UserSchema; -}; - -export type AccountV1LoginResponse = AccountV1LoginResponses[keyof AccountV1LoginResponses]; - -export type AccountV1JoinData = { - body: JoinSchema; - path?: never; - query?: never; - url: '/api/v1/account/join'; -}; - -export type AccountV1JoinResponses = { + items: Array; /** - * OK + * Count */ - 200: unknown; -}; - -export type AccountV1RequestActivationData = { - body: RequestActivationSchema; - path?: never; - query?: never; - url: '/api/v1/account/requestactivation'; -}; - -export type AccountV1RequestActivationResponses = { + count: number; /** - * OK + * Size */ - 200: unknown; -}; - -export type AccountV1ActivateData = { - body: AccountActivateSchema; - path?: never; - query?: never; - url: '/api/v1/account/activate'; -}; - -export type AccountV1ActivateResponses = { + size: number; /** - * OK + * Page */ - 200: AccountActivatedSchema; -}; - -export type AccountV1ActivateResponse = AccountV1ActivateResponses[keyof AccountV1ActivateResponses]; - -export type AccountV1GetMeData = { - body?: never; - path?: never; - query?: never; - url: '/api/v1/account/me'; -}; - -export type AccountV1GetMeResponses = { + page: number; /** - * OK + * Pages */ - 200: UserSchema; -}; - -export type AccountV1GetMeResponse = AccountV1GetMeResponses[keyof AccountV1GetMeResponses]; - -export type AccountV1UpdateMeData = { - body: UserUpdateSchema; - path?: never; - query?: never; - url: '/api/v1/account/me'; + pages: number; }; -export type AccountV1UpdateMeResponses = { +/** + * TutorContentSchema + */ +export type TutorContentSchema = { /** - * OK + * Id */ - 200: UserSchema; -}; - -export type AccountV1UpdateMeResponse = AccountV1UpdateMeResponses[keyof AccountV1UpdateMeResponses]; - -export type AccountV1UploadAvatarData = { + id: string; /** - * FileParams + * Created */ - body: { - /** - * Avatarfile - * - * Max size: 3MB - */ - avatarFile?: Blob | File | null; - }; - path?: never; - query?: never; - url: '/api/v1/account/me/avatar'; -}; - -export type AccountV1UploadAvatarResponses = { + created: string; /** - * Response - * - * OK + * Title */ - 200: string | null; -}; - -export type AccountV1UploadAvatarResponse = AccountV1UploadAvatarResponses[keyof AccountV1UploadAvatarResponses]; - -export type AccountV1RequestEmailChangeData = { - body: RequestEmailChangeSchema; - path?: never; - query?: never; - url: '/api/v1/account/requestemailchange'; -}; - -export type AccountV1RequestEmailChangeResponses = { + title: string; /** - * OK + * Lastgrading */ - 200: unknown; -}; - -export type AccountV1ApplyEmailChangeData = { - body: ApplyEmailChangeSchema; - path?: never; - query?: never; - url: '/api/v1/account/applyemailchange'; -}; - -export type AccountV1ApplyEmailChangeResponses = { + lastGrading: string | null; /** - * OK + * Submissioncount */ - 200: unknown; -}; - -export type AccountV1RequestPasswordChangeData = { - body: RequestPasswordChangeSchema; - path?: never; - query?: never; - url: '/api/v1/account/requestpasswordchange'; -}; - -export type AccountV1RequestPasswordChangeResponses = { + submissionCount: number; /** - * OK + * Gradecompletedcount */ - 200: unknown; -}; - -export type AccountV1ApplyPasswordChangeData = { - body: ApplyPasswordChangeSchema; - path?: never; - query?: never; - url: '/api/v1/account/applypasswordchange'; -}; - -export type AccountV1ApplyPasswordChangeResponses = { + gradeCompletedCount: number; /** - * OK + * Gradeconfirmedcount */ - 200: unknown; -}; - -export type AccountV1LogoutData = { - body?: never; - path?: never; - query?: never; - url: '/api/v1/account/logout'; -}; - -export type AccountV1LogoutResponses = { + gradeConfirmedCount: number; /** - * OK + * Appealcount */ - 200: unknown; -}; - -export type AccountV1SetupOtpData = { - body?: never; - path?: never; - query?: never; - url: '/api/v1/account/otp/setup'; -}; - -export type AccountV1SetupOtpResponses = { + appealCount: number; /** - * OK + * Appealopencount */ - 200: OtpSetupSchema; -}; - -export type AccountV1SetupOtpResponse = AccountV1SetupOtpResponses[keyof AccountV1SetupOtpResponses]; - -export type AccountV1CompleteOtpSetupData = { - body: OtpSetupCompleteSchema; - path?: never; - query?: never; - url: '/api/v1/account/otp/setup/complete'; + appealOpenCount: number; }; -export type AccountV1CompleteOtpSetupResponses = { +/** + * AllocationStatsSchema + */ +export type AllocationStatsSchema = { /** - * OK + * Allocationcount */ - 200: TotpDeviceSchema; -}; - -export type AccountV1CompleteOtpSetupResponse = AccountV1CompleteOtpSetupResponses[keyof AccountV1CompleteOtpSetupResponses]; - -export type AccountV1VerifyOtpData = { - body: OtpVerifySchema; + allocationCount: number; + /** + * Submissioncount + */ + submissionCount: number; + /** + * Gradecompletedcount + */ + gradeCompletedCount: number; + /** + * Gradeconfirmedcount + */ + gradeConfirmedCount: number; + /** + * Appealcount + */ + appealCount: number; + /** + * Appealopencount + */ + appealOpenCount: number; +}; + +/** + * GradingDate + */ +export type GradingDate = { + /** + * Gradedue + */ + gradeDue: string; + /** + * Appealdeadline + */ + appealDeadline: string; + /** + * Confirmdue + */ + confirmDue: string; +}; + +/** + * PagedTutorExamGradeSchema + */ +export type PagedTutorExamGradeSchema = { + /** + * Items + */ + items: Array; + /** + * Count + */ + count: number; + /** + * Size + */ + size: number; + /** + * Page + */ + page: number; + /** + * Pages + */ + pages: number; +}; + +/** + * TutorExamGradeSchema + */ +export type TutorExamGradeSchema = { + /** + * Id + */ + id: number; + /** + * Created + */ + created: string; + /** + * Score + */ + score: number; + /** + * Passed + */ + passed: boolean; + /** + * Completed + */ + completed: string | null; + /** + * Confirmed + */ + confirmed: string | null; + /** + * Attemptretry + */ + attemptRetry: number; + gradingDate: GradingDate; +}; + +/** + * TutorExamGradePaperSchema + */ +export type TutorExamGradePaperSchema = { + /** + * Id + */ + id: number; + /** + * Earneddetails + */ + earnedDetails: { + [key: string]: number | null; + }; + /** + * Answers + */ + answers: { + [key: string]: string; + }; + /** + * Feedback + */ + feedback: { + [key: string]: string; + }; + grader: OwnerSchema | null; + /** + * Questions + */ + questions: Array; + /** + * Analysis + */ + analysis: { + [key: string]: { + [key: string]: number; + }; + }; +}; + +/** + * TutorExamQuestionSchema + */ +export type TutorExamQuestionSchema = { + /** + * Id + */ + id: number; + format: ExamQuestionFormatChoices; + /** + * Options + */ + options: Array; + /** + * Question + */ + question: string; + /** + * Supplement + */ + supplement: string; + /** + * Point + */ + point: number; + solution: ExamSolutionSchema | null; +}; + +/** + * TutorGraeCompleteSchema + */ +export type TutorGraeCompleteSchema = { + /** + * Score + */ + score: number; + /** + * Passed + */ + passed: boolean; + /** + * Completed + */ + completed: string | null; +}; + +/** + * TutorGradeSaveSchema + */ +export type TutorGradeSaveSchema = { + /** + * Earneddetails + */ + earnedDetails: { + [key: string]: number | null; + }; + /** + * Feedback + */ + feedback: { + [key: string]: string; + }; +}; + +/** + * PagedTutorAssignmentGradeSchema + */ +export type PagedTutorAssignmentGradeSchema = { + /** + * Items + */ + items: Array; + /** + * Count + */ + count: number; + /** + * Size + */ + size: number; + /** + * Page + */ + page: number; + /** + * Pages + */ + pages: number; +}; + +/** + * TutorAssignmentGradeSchema + */ +export type TutorAssignmentGradeSchema = { + /** + * Id + */ + id: number; + /** + * Created + */ + created: string; + /** + * Score + */ + score: number; + /** + * Passed + */ + passed: boolean; + /** + * Completed + */ + completed: string | null; + /** + * Confirmed + */ + confirmed: string | null; + /** + * Attemptretry + */ + attemptRetry: number; + gradingDate: GradingDate; +}; + +/** + * TutorAssignmentGradePaperSchema + */ +export type TutorAssignmentGradePaperSchema = { + /** + * Id + */ + id: number; + /** + * Earneddetails + */ + earnedDetails: { + [key: string]: number | null; + }; + /** + * Answer + */ + answer: string; + /** + * Feedback + */ + feedback: { + [key: string]: string; + }; + grader: OwnerSchema | null; + question: AssignmentQuestionSchema; + /** + * Analysis + */ + analysis: { + [key: string]: { + [key: string]: number; + }; + }; + /** + * Similaranswer + */ + similarAnswer: string | null; +}; + +/** + * PagedTutorDiscussionGradeSchema + */ +export type PagedTutorDiscussionGradeSchema = { + /** + * Items + */ + items: Array; + /** + * Count + */ + count: number; + /** + * Size + */ + size: number; + /** + * Page + */ + page: number; + /** + * Pages + */ + pages: number; +}; + +/** + * TutorDiscussionGradeSchema + */ +export type TutorDiscussionGradeSchema = { + /** + * Id + */ + id: number; + /** + * Created + */ + created: string; + /** + * Score + */ + score: number; + /** + * Passed + */ + passed: boolean; + /** + * Completed + */ + completed: string | null; + /** + * Confirmed + */ + confirmed: string | null; + /** + * Attemptretry + */ + attemptRetry: number; + gradingDate: GradingDate; +}; + +/** + * TutorDiscussionGradePaperSchema + */ +export type TutorDiscussionGradePaperSchema = { + /** + * Id + */ + id: number; + earnedDetails: DiscussionEarnedDetailsSchema; + feedback: DiscussionFeedbackSchema; + grader: OwnerSchema | null; + question: DiscussionQuestionSchema; + /** + * Posts + */ + posts: Array; +}; + +/** + * DiscussionEarnedDetailsSaveSchema + */ +export type DiscussionEarnedDetailsSaveSchema = { + /** + * Tutorassessment + */ + tutorAssessment: number; +}; + +/** + * DiscussionFeedbackSaveSchema + */ +export type DiscussionFeedbackSaveSchema = { + /** + * Tutorassessment + */ + tutorAssessment: string; +}; + +/** + * TutorDiscussionGradeSaveSchema + */ +export type TutorDiscussionGradeSaveSchema = { + earnedDetails: DiscussionEarnedDetailsSaveSchema; + feedback: DiscussionFeedbackSaveSchema; +}; + +export type MinimaApiHealthData = { + body?: never; + path?: never; + query?: never; + url: '/api/health'; +}; + +export type MinimaApiHealthResponses = { + /** + * OK + */ + 200: unknown; +}; + +export type AccountV1LoginData = { + body: LoginSchema; + path?: never; + query?: never; + url: '/api/v1/account/login'; +}; + +export type AccountV1LoginResponses = { + /** + * OK + */ + 200: UserSchema; +}; + +export type AccountV1LoginResponse = AccountV1LoginResponses[keyof AccountV1LoginResponses]; + +export type AccountV1JoinData = { + body: JoinSchema; + path?: never; + query?: never; + url: '/api/v1/account/join'; +}; + +export type AccountV1JoinResponses = { + /** + * OK + */ + 200: unknown; +}; + +export type AccountV1RequestActivationData = { + body: RequestActivationSchema; + path?: never; + query?: never; + url: '/api/v1/account/requestactivation'; +}; + +export type AccountV1RequestActivationResponses = { + /** + * OK + */ + 200: unknown; +}; + +export type AccountV1ActivateData = { + body: AccountActivateSchema; + path?: never; + query?: never; + url: '/api/v1/account/activate'; +}; + +export type AccountV1ActivateResponses = { + /** + * OK + */ + 200: AccountActivatedSchema; +}; + +export type AccountV1ActivateResponse = AccountV1ActivateResponses[keyof AccountV1ActivateResponses]; + +export type AccountV1GetMeData = { + body?: never; + path?: never; + query?: never; + url: '/api/v1/account/me'; +}; + +export type AccountV1GetMeResponses = { + /** + * OK + */ + 200: UserSchema; +}; + +export type AccountV1GetMeResponse = AccountV1GetMeResponses[keyof AccountV1GetMeResponses]; + +export type AccountV1UpdateMeData = { + body: UserUpdateSchema; + path?: never; + query?: never; + url: '/api/v1/account/me'; +}; + +export type AccountV1UpdateMeResponses = { + /** + * OK + */ + 200: UserSchema; +}; + +export type AccountV1UpdateMeResponse = AccountV1UpdateMeResponses[keyof AccountV1UpdateMeResponses]; + +export type AccountV1UploadAvatarData = { + /** + * FileParams + */ + body: { + /** + * Avatarfile + * + * Max size: 3MB + */ + avatarFile?: Blob | File | null; + }; + path?: never; + query?: never; + url: '/api/v1/account/me/avatar'; +}; + +export type AccountV1UploadAvatarResponses = { + /** + * Response + * + * OK + */ + 200: string | null; +}; + +export type AccountV1UploadAvatarResponse = AccountV1UploadAvatarResponses[keyof AccountV1UploadAvatarResponses]; + +export type AccountV1RequestEmailChangeData = { + body: RequestEmailChangeSchema; + path?: never; + query?: never; + url: '/api/v1/account/requestemailchange'; +}; + +export type AccountV1RequestEmailChangeResponses = { + /** + * OK + */ + 200: unknown; +}; + +export type AccountV1ApplyEmailChangeData = { + body: ApplyEmailChangeSchema; + path?: never; + query?: never; + url: '/api/v1/account/applyemailchange'; +}; + +export type AccountV1ApplyEmailChangeResponses = { + /** + * OK + */ + 200: unknown; +}; + +export type AccountV1RequestPasswordChangeData = { + body: RequestPasswordChangeSchema; + path?: never; + query?: never; + url: '/api/v1/account/requestpasswordchange'; +}; + +export type AccountV1RequestPasswordChangeResponses = { + /** + * OK + */ + 200: unknown; +}; + +export type AccountV1ApplyPasswordChangeData = { + body: ApplyPasswordChangeSchema; + path?: never; + query?: never; + url: '/api/v1/account/applypasswordchange'; +}; + +export type AccountV1ApplyPasswordChangeResponses = { + /** + * OK + */ + 200: unknown; +}; + +export type AccountV1LogoutData = { + body?: never; + path?: never; + query?: never; + url: '/api/v1/account/logout'; +}; + +export type AccountV1LogoutResponses = { + /** + * OK + */ + 200: unknown; +}; + +export type AccountV1SetupOtpData = { + body?: never; + path?: never; + query?: never; + url: '/api/v1/account/otp/setup'; +}; + +export type AccountV1SetupOtpResponses = { + /** + * OK + */ + 200: OtpSetupSchema; +}; + +export type AccountV1SetupOtpResponse = AccountV1SetupOtpResponses[keyof AccountV1SetupOtpResponses]; + +export type AccountV1CompleteOtpSetupData = { + body: OtpSetupCompleteSchema; + path?: never; + query?: never; + url: '/api/v1/account/otp/setup/complete'; +}; + +export type AccountV1CompleteOtpSetupResponses = { + /** + * OK + */ + 200: TotpDeviceSchema; +}; + +export type AccountV1CompleteOtpSetupResponse = AccountV1CompleteOtpSetupResponses[keyof AccountV1CompleteOtpSetupResponses]; + +export type AccountV1VerifyOtpData = { + body: OtpVerifySchema; path?: never; query?: never; url: '/api/v1/account/otp/verify'; @@ -6788,6 +7311,7 @@ export type AssignmentV1GetSessionData = { id: string; }; query?: { + mode?: string; media?: string; course?: string; }; @@ -6812,8 +7336,8 @@ export type AssignmentV1StartAttemptData = { id: string; }; query?: { - media?: string; mode?: string; + media?: string; course?: string; }; url: '/api/v1/assignment/{id}/attempt'; @@ -6851,6 +7375,7 @@ export type AssignmentV1SubmitAttemptData = { id: string; }; query?: { + mode?: string; media?: string; course?: string; }; @@ -6875,6 +7400,7 @@ export type AssignmentV1DeactivateAttemptData = { id: string; }; query?: { + mode?: string; media?: string; course?: string; }; @@ -7173,6 +7699,7 @@ export type ContentV1GetMediaData = { id: string; }; query?: { + mode?: string; media?: string; }; url: '/api/v1/content/media/{id}'; @@ -7196,6 +7723,7 @@ export type ContentV1GetSubtitlesData = { id: string; }; query?: { + mode?: string; media?: string; }; url: '/api/v1/content/media/{id}/subtitle'; @@ -7221,6 +7749,7 @@ export type ContentV1DeleteMediaWatchData = { id: string; }; query?: { + mode?: string; media?: string; course?: string; }; @@ -7243,6 +7772,7 @@ export type ContentV1GetMediaWatchData = { id: string; }; query?: { + mode?: string; media?: string; course?: string; }; @@ -7267,8 +7797,8 @@ export type ContentV1UpdateMediaWatchData = { id: string; }; query?: { - media?: string; mode?: string; + media?: string; course?: string; }; url: '/api/v1/content/media/{id}/watch'; @@ -7290,6 +7820,7 @@ export type ContentV1GetMediaNoteData = { id: string; }; query?: { + mode?: string; media?: string; course?: string; }; @@ -7328,6 +7859,7 @@ export type ContentV1SaveMediaNoteData = { id: string; }; query?: { + mode?: string; media?: string; course?: string; }; @@ -7445,6 +7977,7 @@ export type CourseV1GetSessionData = { id: string; }; query?: { + mode?: string; media?: string; }; url: '/api/v1/course/{id}/session'; @@ -7468,8 +8001,8 @@ export type CourseV1StartEngagementData = { id: string; }; query?: { - media?: string; mode?: string; + media?: string; }; url: '/api/v1/course/{id}/engage'; }; @@ -7534,6 +8067,7 @@ export type DiscussionV1GetSessionData = { id: string; }; query?: { + mode?: string; media?: string; course?: string; }; @@ -7558,8 +8092,8 @@ export type DiscussionV1StartAttemptData = { id: string; }; query?: { - media?: string; mode?: string; + media?: string; course?: string; }; url: '/api/v1/discussion/{id}/attempt'; @@ -7583,6 +8117,7 @@ export type DiscussionV1DeactivateAttemptData = { id: string; }; query?: { + mode?: string; media?: string; course?: string; }; @@ -7613,6 +8148,7 @@ export type DiscussionV1GetPostsData = { * Size */ size?: number; + mode?: string; media?: string; course?: string; }; @@ -7659,6 +8195,7 @@ export type DiscussionV1CreatePostData = { id: string; }; query?: { + mode?: string; media?: string; course?: string; }; @@ -7683,6 +8220,7 @@ export type DiscussionV1GetOwnPostsData = { id: string; }; query?: { + mode?: string; media?: string; course?: string; }; @@ -7713,6 +8251,7 @@ export type DiscussionV1DeletePostData = { post_id: number; }; query?: { + mode?: string; media?: string; course?: string; }; @@ -7761,6 +8300,7 @@ export type DiscussionV1UpdatePostData = { post_id: number; }; query?: { + mode?: string; media?: string; course?: string; }; @@ -7785,6 +8325,7 @@ export type ExamV1GetSessionData = { id: string; }; query?: { + mode?: string; media?: string; course?: string; }; @@ -7809,8 +8350,8 @@ export type ExamV1StartAttemptData = { id: string; }; query?: { - media?: string; mode?: string; + media?: string; course?: string; }; url: '/api/v1/exam/{id}/attempt'; @@ -7834,6 +8375,7 @@ export type ExamV1SaveAnswersData = { id: string; }; query?: { + mode?: string; media?: string; course?: string; }; @@ -7856,6 +8398,7 @@ export type ExamV1SubmitAttemptData = { id: string; }; query?: { + mode?: string; media?: string; course?: string; }; @@ -7880,6 +8423,7 @@ export type ExamV1DeactivateAttemptData = { id: string; }; query?: { + mode?: string; media?: string; course?: string; }; @@ -8631,6 +9175,7 @@ export type QuizV1GetSessionData = { id: string; }; query?: { + mode?: string; media?: string; course?: string; }; @@ -8655,9 +9200,9 @@ export type QuizV1StartAttemptData = { id: string; }; query?: { + mode?: string; media?: string; course?: string; - mode?: string; }; url: '/api/v1/quiz/{id}/attempt'; }; @@ -8680,6 +9225,7 @@ export type QuizV1SubmitAttemptData = { id: string; }; query?: { + mode?: string; media?: string; course?: string; }; @@ -8704,6 +9250,7 @@ export type QuizV1DeactivateAttemptData = { id: string; }; query?: { + mode?: string; media?: string; course?: string; }; @@ -10252,6 +10799,7 @@ export type SurveyV1GetSurveyData = { id: string; }; query?: { + mode?: string; media?: string; }; url: '/api/v1/survey/{id}'; @@ -10275,8 +10823,8 @@ export type SurveyV1SubmitData = { id: string; }; query?: { - media?: string; mode?: string; + media?: string; course?: string; }; url: '/api/v1/survey/{id}/submit'; @@ -10298,6 +10846,7 @@ export type SurveyV1ResultsData = { id: string; }; query?: { + mode?: string; media?: string; }; url: '/api/v1/survey/{id}/results'; @@ -10386,3 +10935,305 @@ export type SurveyV1ResultsAnonymousResponses = { }; export type SurveyV1ResultsAnonymousResponse = SurveyV1ResultsAnonymousResponses[keyof SurveyV1ResultsAnonymousResponses]; + +export type TutorV1GetAllocationData = { + body?: never; + path?: never; + query?: { + /** + * Page + */ + page?: number; + /** + * Size + */ + size?: number; + }; + url: '/api/v1/tutor/allocation'; +}; + +export type TutorV1GetAllocationResponses = { + /** + * OK + */ + 200: PaginatedResponseAllocationSchema; +}; + +export type TutorV1GetAllocationResponse = TutorV1GetAllocationResponses[keyof TutorV1GetAllocationResponses]; + +export type TutorV1GetAllocationStatsData = { + body?: never; + path?: never; + query?: never; + url: '/api/v1/tutor/allocation/stats'; +}; + +export type TutorV1GetAllocationStatsResponses = { + /** + * OK + */ + 200: AllocationStatsSchema; +}; + +export type TutorV1GetAllocationStatsResponse = TutorV1GetAllocationStatsResponses[keyof TutorV1GetAllocationStatsResponses]; + +export type TutorV1GetExamGradesData = { + body?: never; + path: { + /** + * Id + */ + id: string; + }; + query?: { + /** + * Page + */ + page?: number; + /** + * Size + */ + size?: number; + }; + url: '/api/v1/tutor/exam/{id}/grade'; +}; + +export type TutorV1GetExamGradesResponses = { + /** + * OK + */ + 200: PagedTutorExamGradeSchema; +}; + +export type TutorV1GetExamGradesResponse = TutorV1GetExamGradesResponses[keyof TutorV1GetExamGradesResponses]; + +export type TutorV1GetExamGradePaperData = { + body?: never; + path: { + /** + * Id + */ + id: string; + /** + * Grade Id + */ + grade_id: number; + }; + query?: never; + url: '/api/v1/tutor/exam/{id}/grade/{grade_id}'; +}; + +export type TutorV1GetExamGradePaperResponses = { + /** + * OK + */ + 200: TutorExamGradePaperSchema; +}; + +export type TutorV1GetExamGradePaperResponse = TutorV1GetExamGradePaperResponses[keyof TutorV1GetExamGradePaperResponses]; + +export type TutorV1CompleteExamGradeData = { + body: TutorGradeSaveSchema; + path: { + /** + * Id + */ + id: string; + /** + * Grade Id + */ + grade_id: number; + }; + query?: never; + url: '/api/v1/tutor/exam/{id}/grade/{grade_id}'; +}; + +export type TutorV1CompleteExamGradeResponses = { + /** + * OK + */ + 200: TutorGraeCompleteSchema; +}; + +export type TutorV1CompleteExamGradeResponse = TutorV1CompleteExamGradeResponses[keyof TutorV1CompleteExamGradeResponses]; + +export type TutorV1GetAssignmentGradesData = { + body?: never; + path: { + /** + * Id + */ + id: string; + }; + query?: { + /** + * Page + */ + page?: number; + /** + * Size + */ + size?: number; + }; + url: '/api/v1/tutor/assignment/{id}/grade'; +}; + +export type TutorV1GetAssignmentGradesResponses = { + /** + * OK + */ + 200: PagedTutorAssignmentGradeSchema; +}; + +export type TutorV1GetAssignmentGradesResponse = TutorV1GetAssignmentGradesResponses[keyof TutorV1GetAssignmentGradesResponses]; + +export type TutorV1GetAssignmentGradePaperData = { + body?: never; + path: { + /** + * Id + */ + id: string; + /** + * Grade Id + */ + grade_id: number; + }; + query?: never; + url: '/api/v1/tutor/assignment/{id}/grade/{grade_id}'; +}; + +export type TutorV1GetAssignmentGradePaperResponses = { + /** + * OK + */ + 200: TutorAssignmentGradePaperSchema; +}; + +export type TutorV1GetAssignmentGradePaperResponse = TutorV1GetAssignmentGradePaperResponses[keyof TutorV1GetAssignmentGradePaperResponses]; + +export type TutorV1CompleteAssignmentGradeData = { + body: TutorGradeSaveSchema; + path: { + /** + * Id + */ + id: string; + /** + * Grade Id + */ + grade_id: number; + }; + query?: never; + url: '/api/v1/tutor/assignment/{id}/grade/{grade_id}'; +}; + +export type TutorV1CompleteAssignmentGradeResponses = { + /** + * OK + */ + 200: TutorGraeCompleteSchema; +}; + +export type TutorV1CompleteAssignmentGradeResponse = TutorV1CompleteAssignmentGradeResponses[keyof TutorV1CompleteAssignmentGradeResponses]; + +export type TutorV1GetAssignmentRubricData = { + body?: never; + path: { + /** + * Id + */ + id: string; + }; + query?: never; + url: '/api/v1/tutor/assignment/{id}/rubric'; +}; + +export type TutorV1GetAssignmentRubricResponses = { + /** + * OK + */ + 200: RubricSchema; +}; + +export type TutorV1GetAssignmentRubricResponse = TutorV1GetAssignmentRubricResponses[keyof TutorV1GetAssignmentRubricResponses]; + +export type TutorV1GetDiscussionGradesData = { + body?: never; + path: { + /** + * Id + */ + id: string; + }; + query?: { + /** + * Page + */ + page?: number; + /** + * Size + */ + size?: number; + }; + url: '/api/v1/tutor/discussion/{id}/grade'; +}; + +export type TutorV1GetDiscussionGradesResponses = { + /** + * OK + */ + 200: PagedTutorDiscussionGradeSchema; +}; + +export type TutorV1GetDiscussionGradesResponse = TutorV1GetDiscussionGradesResponses[keyof TutorV1GetDiscussionGradesResponses]; + +export type TutorV1GetDiscussionGradePaperData = { + body?: never; + path: { + /** + * Id + */ + id: string; + /** + * Grade Id + */ + grade_id: number; + }; + query?: never; + url: '/api/v1/tutor/discussion/{id}/grade/{grade_id}'; +}; + +export type TutorV1GetDiscussionGradePaperResponses = { + /** + * OK + */ + 200: TutorDiscussionGradePaperSchema; +}; + +export type TutorV1GetDiscussionGradePaperResponse = TutorV1GetDiscussionGradePaperResponses[keyof TutorV1GetDiscussionGradePaperResponses]; + +export type TutorV1CompleteDiscussionGradeData = { + body: TutorDiscussionGradeSaveSchema; + path: { + /** + * Id + */ + id: string; + /** + * Grade Id + */ + grade_id: number; + }; + query?: never; + url: '/api/v1/tutor/discussion/{id}/grade/{grade_id}'; +}; + +export type TutorV1CompleteDiscussionGradeResponses = { + /** + * OK + */ + 200: TutorGraeCompleteSchema; +}; + +export type TutorV1CompleteDiscussionGradeResponse = TutorV1CompleteDiscussionGradeResponses[keyof TutorV1CompleteDiscussionGradeResponses]; diff --git a/web/src/api/valibot.gen.ts b/web/src/api/valibot.gen.ts index bf60246..80ce856 100644 --- a/web/src/api/valibot.gen.ts +++ b/web/src/api/valibot.gen.ts @@ -173,7 +173,6 @@ export const vAppealSchema = v.object({ questionId: v.pipe(v.number(), v.integer()), explanation: v.string(), review: v.string(), - closed: v.nullable(v.pipe(v.string(), v.isoTimestamp())), path: v.string() }); @@ -877,6 +876,7 @@ export const vCourseSchema = v.object({ */ export const vCourseSessionSchema = v.object({ accessDate: vAccessDateSchema, + gradingDate: vGradingDateSchema, course: vCourseSchema, engagement: v.optional(vCourseEngagementSchema), otpToken: v.optional(v.string()), @@ -1011,14 +1011,14 @@ export const vCourseCertificateRequestSchema = v.object({ export const vDiscussionEarnedDetailsSchema = v.object({ post: v.pipe(v.number(), v.integer()), reply: v.pipe(v.number(), v.integer()), - tutorAssessment: v.pipe(v.number(), v.integer()) + tutorAssessment: v.nullable(v.pipe(v.number(), v.integer())) }); /** * DiscussionFeedbackSchema */ export const vDiscussionFeedbackSchema = v.object({ - tutorAssessment: v.string() + tutorAssessment: v.optional(v.string(), '') }); /** @@ -2633,6 +2633,9 @@ export const vCourseAssetsSpec = v.object({ * CourseSpec */ export const vCourseSpec = v.object({ + gradeDueDays: v.pipe(v.number(), v.integer()), + appealDeadlineDays: v.pipe(v.number(), v.integer()), + confirmDueDays: v.pipe(v.number(), v.integer()), created: v.pipe(v.string(), v.isoTimestamp()), modified: v.pipe(v.string(), v.isoTimestamp()), title: v.string(), @@ -2673,6 +2676,9 @@ export const vCourseSaveSpec = v.object({ previewUrl: v.pipe(v.string(), v.url(), v.minLength(1), v.maxLength(2083)), effortHours: v.pipe(v.number(), v.integer()), level: vLevelChoices, + gradeDueDays: v.pipe(v.number(), v.integer()), + appealDeadlineDays: v.pipe(v.number(), v.integer()), + confirmDueDays: v.pipe(v.number(), v.integer()), honorCodeId: v.pipe(v.number(), v.integer()), faqId: v.pipe(v.number(), v.integer()), gradingPolicy: vGradingPolicySpec @@ -2827,6 +2833,228 @@ export const vSurveySchema = v.object({ */ export const vSurveyAnswersSchema = v.record(v.string(), v.pipe(v.string(), v.minLength(1))); +/** + * TutorContentSchema + */ +export const vTutorContentSchema = v.object({ + id: v.string(), + created: v.pipe(v.string(), v.isoTimestamp()), + title: v.string(), + lastGrading: v.nullable(v.pipe(v.string(), v.isoTimestamp())), + submissionCount: v.pipe(v.number(), v.integer()), + gradeCompletedCount: v.pipe(v.number(), v.integer()), + gradeConfirmedCount: v.pipe(v.number(), v.integer()), + appealCount: v.pipe(v.number(), v.integer()), + appealOpenCount: v.pipe(v.number(), v.integer()) +}); + +/** + * AllocationSchema + */ +export const vAllocationSchema = v.object({ + id: v.pipe(v.number(), v.integer()), + content: vTutorContentSchema, + contentType: vContentTypeSchema +}); + +/** + * PaginatedResponse[AllocationSchema] + */ +export const vPaginatedResponseAllocationSchema = v.object({ + items: v.array(vAllocationSchema), + count: v.pipe(v.number(), v.integer()), + size: v.pipe(v.number(), v.integer()), + page: v.pipe(v.number(), v.integer()), + pages: v.pipe(v.number(), v.integer()) +}); + +/** + * AllocationStatsSchema + */ +export const vAllocationStatsSchema = v.object({ + allocationCount: v.pipe(v.number(), v.integer()), + submissionCount: v.pipe(v.number(), v.integer()), + gradeCompletedCount: v.pipe(v.number(), v.integer()), + gradeConfirmedCount: v.pipe(v.number(), v.integer()), + appealCount: v.pipe(v.number(), v.integer()), + appealOpenCount: v.pipe(v.number(), v.integer()) +}); + +/** + * GradingDate + */ +export const vGradingDate = v.object({ + gradeDue: v.pipe(v.string(), v.isoTimestamp()), + appealDeadline: v.pipe(v.string(), v.isoTimestamp()), + confirmDue: v.pipe(v.string(), v.isoTimestamp()) +}); + +/** + * TutorExamGradeSchema + */ +export const vTutorExamGradeSchema = v.object({ + id: v.pipe(v.number(), v.integer()), + created: v.pipe(v.string(), v.isoTimestamp()), + score: v.number(), + passed: v.boolean(), + completed: v.nullable(v.pipe(v.string(), v.isoTimestamp())), + confirmed: v.nullable(v.pipe(v.string(), v.isoTimestamp())), + attemptRetry: v.pipe(v.number(), v.integer()), + gradingDate: vGradingDate +}); + +/** + * PagedTutorExamGradeSchema + */ +export const vPagedTutorExamGradeSchema = v.object({ + items: v.array(vTutorExamGradeSchema), + count: v.pipe(v.number(), v.integer()), + size: v.pipe(v.number(), v.integer()), + page: v.pipe(v.number(), v.integer()), + pages: v.pipe(v.number(), v.integer()) +}); + +/** + * TutorExamQuestionSchema + */ +export const vTutorExamQuestionSchema = v.object({ + id: v.pipe(v.number(), v.integer()), + format: vExamQuestionFormatChoices, + options: v.array(v.string()), + question: v.string(), + supplement: v.string(), + point: v.pipe(v.number(), v.integer()), + solution: v.nullable(vExamSolutionSchema) +}); + +/** + * TutorExamGradePaperSchema + */ +export const vTutorExamGradePaperSchema = v.object({ + id: v.pipe(v.number(), v.integer()), + earnedDetails: v.object({}), + answers: v.record(v.string(), v.string()), + feedback: v.record(v.string(), v.string()), + grader: v.nullable(vOwnerSchema), + questions: v.array(vTutorExamQuestionSchema), + analysis: v.record(v.string(), v.record(v.string(), v.pipe(v.number(), v.integer()))) +}); + +/** + * TutorGraeCompleteSchema + */ +export const vTutorGraeCompleteSchema = v.object({ + score: v.number(), + passed: v.boolean(), + completed: v.nullable(v.pipe(v.string(), v.isoTimestamp())) +}); + +/** + * TutorGradeSaveSchema + */ +export const vTutorGradeSaveSchema = v.object({ + earnedDetails: v.object({}), + feedback: v.record(v.string(), v.string()) +}); + +/** + * TutorAssignmentGradeSchema + */ +export const vTutorAssignmentGradeSchema = v.object({ + id: v.pipe(v.number(), v.integer()), + created: v.pipe(v.string(), v.isoTimestamp()), + score: v.number(), + passed: v.boolean(), + completed: v.nullable(v.pipe(v.string(), v.isoTimestamp())), + confirmed: v.nullable(v.pipe(v.string(), v.isoTimestamp())), + attemptRetry: v.pipe(v.number(), v.integer()), + gradingDate: vGradingDate +}); + +/** + * PagedTutorAssignmentGradeSchema + */ +export const vPagedTutorAssignmentGradeSchema = v.object({ + items: v.array(vTutorAssignmentGradeSchema), + count: v.pipe(v.number(), v.integer()), + size: v.pipe(v.number(), v.integer()), + page: v.pipe(v.number(), v.integer()), + pages: v.pipe(v.number(), v.integer()) +}); + +/** + * TutorAssignmentGradePaperSchema + */ +export const vTutorAssignmentGradePaperSchema = v.object({ + id: v.pipe(v.number(), v.integer()), + earnedDetails: v.object({}), + answer: v.string(), + feedback: v.record(v.string(), v.string()), + grader: v.nullable(vOwnerSchema), + question: vAssignmentQuestionSchema, + analysis: v.record(v.string(), v.record(v.string(), v.pipe(v.number(), v.integer()))), + similarAnswer: v.nullable(v.string()) +}); + +/** + * TutorDiscussionGradeSchema + */ +export const vTutorDiscussionGradeSchema = v.object({ + id: v.pipe(v.number(), v.integer()), + created: v.pipe(v.string(), v.isoTimestamp()), + score: v.number(), + passed: v.boolean(), + completed: v.nullable(v.pipe(v.string(), v.isoTimestamp())), + confirmed: v.nullable(v.pipe(v.string(), v.isoTimestamp())), + attemptRetry: v.pipe(v.number(), v.integer()), + gradingDate: vGradingDate +}); + +/** + * PagedTutorDiscussionGradeSchema + */ +export const vPagedTutorDiscussionGradeSchema = v.object({ + items: v.array(vTutorDiscussionGradeSchema), + count: v.pipe(v.number(), v.integer()), + size: v.pipe(v.number(), v.integer()), + page: v.pipe(v.number(), v.integer()), + pages: v.pipe(v.number(), v.integer()) +}); + +/** + * TutorDiscussionGradePaperSchema + */ +export const vTutorDiscussionGradePaperSchema = v.object({ + id: v.pipe(v.number(), v.integer()), + earnedDetails: vDiscussionEarnedDetailsSchema, + feedback: vDiscussionFeedbackSchema, + grader: v.nullable(vOwnerSchema), + question: vDiscussionQuestionSchema, + posts: v.array(vDiscussionOwnPostSchema) +}); + +/** + * DiscussionEarnedDetailsSaveSchema + */ +export const vDiscussionEarnedDetailsSaveSchema = v.object({ + tutorAssessment: v.pipe(v.number(), v.integer()) +}); + +/** + * DiscussionFeedbackSaveSchema + */ +export const vDiscussionFeedbackSaveSchema = v.object({ + tutorAssessment: v.string() +}); + +/** + * TutorDiscussionGradeSaveSchema + */ +export const vTutorDiscussionGradeSaveSchema = v.object({ + earnedDetails: vDiscussionEarnedDetailsSaveSchema, + feedback: vDiscussionFeedbackSaveSchema +}); + export const vMinimaApiHealthData = v.object({ body: v.optional(v.never()), path: v.optional(v.never()), @@ -2974,6 +3202,7 @@ export const vAssignmentV1GetSessionData = v.object({ id: v.string() }), query: v.optional(v.object({ + mode: v.optional(v.string()), media: v.optional(v.string()), course: v.optional(v.string()) })) @@ -2990,8 +3219,8 @@ export const vAssignmentV1StartAttemptData = v.object({ id: v.string() }), query: v.optional(v.object({ - media: v.optional(v.string()), mode: v.optional(v.string()), + media: v.optional(v.string()), course: v.optional(v.string()) })) }); @@ -3010,6 +3239,7 @@ export const vAssignmentV1SubmitAttemptData = v.object({ id: v.string() }), query: v.optional(v.object({ + mode: v.optional(v.string()), media: v.optional(v.string()), course: v.optional(v.string()) })) @@ -3026,6 +3256,7 @@ export const vAssignmentV1DeactivateAttemptData = v.object({ id: v.string() }), query: v.optional(v.object({ + mode: v.optional(v.string()), media: v.optional(v.string()), course: v.optional(v.string()) })) @@ -3186,6 +3417,7 @@ export const vContentV1GetMediaData = v.object({ id: v.string() }), query: v.optional(v.object({ + mode: v.optional(v.string()), media: v.optional(v.string()) })) }); @@ -3201,6 +3433,7 @@ export const vContentV1GetSubtitlesData = v.object({ id: v.string() }), query: v.optional(v.object({ + mode: v.optional(v.string()), media: v.optional(v.string()) })) }); @@ -3218,6 +3451,7 @@ export const vContentV1DeleteMediaWatchData = v.object({ id: v.string() }), query: v.optional(v.object({ + mode: v.optional(v.string()), media: v.optional(v.string()), course: v.optional(v.string()) })) @@ -3229,6 +3463,7 @@ export const vContentV1GetMediaWatchData = v.object({ id: v.string() }), query: v.optional(v.object({ + mode: v.optional(v.string()), media: v.optional(v.string()), course: v.optional(v.string()) })) @@ -3245,8 +3480,8 @@ export const vContentV1UpdateMediaWatchData = v.object({ id: v.string() }), query: v.optional(v.object({ - media: v.optional(v.string()), mode: v.optional(v.string()), + media: v.optional(v.string()), course: v.optional(v.string()) })) }); @@ -3257,6 +3492,7 @@ export const vContentV1GetMediaNoteData = v.object({ id: v.string() }), query: v.optional(v.object({ + mode: v.optional(v.string()), media: v.optional(v.string()), course: v.optional(v.string()) })) @@ -3276,6 +3512,7 @@ export const vContentV1SaveMediaNoteData = v.object({ id: v.string() }), query: v.optional(v.object({ + mode: v.optional(v.string()), media: v.optional(v.string()), course: v.optional(v.string()) })) @@ -3340,6 +3577,7 @@ export const vCourseV1GetSessionData = v.object({ id: v.string() }), query: v.optional(v.object({ + mode: v.optional(v.string()), media: v.optional(v.string()) })) }); @@ -3355,8 +3593,8 @@ export const vCourseV1StartEngagementData = v.object({ id: v.string() }), query: v.optional(v.object({ - media: v.optional(v.string()), - mode: v.optional(v.string()) + mode: v.optional(v.string()), + media: v.optional(v.string()) })) }); @@ -3397,6 +3635,7 @@ export const vDiscussionV1GetSessionData = v.object({ id: v.string() }), query: v.optional(v.object({ + mode: v.optional(v.string()), media: v.optional(v.string()), course: v.optional(v.string()) })) @@ -3413,8 +3652,8 @@ export const vDiscussionV1StartAttemptData = v.object({ id: v.string() }), query: v.optional(v.object({ - media: v.optional(v.string()), mode: v.optional(v.string()), + media: v.optional(v.string()), course: v.optional(v.string()) })) }); @@ -3430,6 +3669,7 @@ export const vDiscussionV1DeactivateAttemptData = v.object({ id: v.string() }), query: v.optional(v.object({ + mode: v.optional(v.string()), media: v.optional(v.string()), course: v.optional(v.string()) })) @@ -3443,6 +3683,7 @@ export const vDiscussionV1GetPostsData = v.object({ query: v.optional(v.object({ page: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1)), 1), size: v.optional(v.pipe(v.number(), v.integer(), v.maxValue(100)), 24), + mode: v.optional(v.string()), media: v.optional(v.string()), course: v.optional(v.string()) })) @@ -3464,6 +3705,7 @@ export const vDiscussionV1CreatePostData = v.object({ id: v.string() }), query: v.optional(v.object({ + mode: v.optional(v.string()), media: v.optional(v.string()), course: v.optional(v.string()) })) @@ -3480,6 +3722,7 @@ export const vDiscussionV1GetOwnPostsData = v.object({ id: v.string() }), query: v.optional(v.object({ + mode: v.optional(v.string()), media: v.optional(v.string()), course: v.optional(v.string()) })) @@ -3499,6 +3742,7 @@ export const vDiscussionV1DeletePostData = v.object({ post_id: v.pipe(v.number(), v.integer()) }), query: v.optional(v.object({ + mode: v.optional(v.string()), media: v.optional(v.string()), course: v.optional(v.string()) })) @@ -3516,6 +3760,7 @@ export const vDiscussionV1UpdatePostData = v.object({ post_id: v.pipe(v.number(), v.integer()) }), query: v.optional(v.object({ + mode: v.optional(v.string()), media: v.optional(v.string()), course: v.optional(v.string()) })) @@ -3532,6 +3777,7 @@ export const vExamV1GetSessionData = v.object({ id: v.string() }), query: v.optional(v.object({ + mode: v.optional(v.string()), media: v.optional(v.string()), course: v.optional(v.string()) })) @@ -3548,8 +3794,8 @@ export const vExamV1StartAttemptData = v.object({ id: v.string() }), query: v.optional(v.object({ - media: v.optional(v.string()), mode: v.optional(v.string()), + media: v.optional(v.string()), course: v.optional(v.string()) })) }); @@ -3565,6 +3811,7 @@ export const vExamV1SaveAnswersData = v.object({ id: v.string() }), query: v.optional(v.object({ + mode: v.optional(v.string()), media: v.optional(v.string()), course: v.optional(v.string()) })) @@ -3576,6 +3823,7 @@ export const vExamV1SubmitAttemptData = v.object({ id: v.string() }), query: v.optional(v.object({ + mode: v.optional(v.string()), media: v.optional(v.string()), course: v.optional(v.string()) })) @@ -3592,6 +3840,7 @@ export const vExamV1DeactivateAttemptData = v.object({ id: v.string() }), query: v.optional(v.object({ + mode: v.optional(v.string()), media: v.optional(v.string()), course: v.optional(v.string()) })) @@ -3978,6 +4227,7 @@ export const vQuizV1GetSessionData = v.object({ id: v.string() }), query: v.optional(v.object({ + mode: v.optional(v.string()), media: v.optional(v.string()), course: v.optional(v.string()) })) @@ -3994,9 +4244,9 @@ export const vQuizV1StartAttemptData = v.object({ id: v.string() }), query: v.optional(v.object({ + mode: v.optional(v.string()), media: v.optional(v.string()), - course: v.optional(v.string()), - mode: v.optional(v.string()) + course: v.optional(v.string()) })) }); @@ -4011,6 +4261,7 @@ export const vQuizV1SubmitAttemptData = v.object({ id: v.string() }), query: v.optional(v.object({ + mode: v.optional(v.string()), media: v.optional(v.string()), course: v.optional(v.string()) })) @@ -4027,6 +4278,7 @@ export const vQuizV1DeactivateAttemptData = v.object({ id: v.string() }), query: v.optional(v.object({ + mode: v.optional(v.string()), media: v.optional(v.string()), course: v.optional(v.string()) })) @@ -4871,6 +5123,7 @@ export const vSurveyV1GetSurveyData = v.object({ id: v.string() }), query: v.optional(v.object({ + mode: v.optional(v.string()), media: v.optional(v.string()) })) }); @@ -4886,8 +5139,8 @@ export const vSurveyV1SubmitData = v.object({ id: v.string() }), query: v.optional(v.object({ - media: v.optional(v.string()), mode: v.optional(v.string()), + media: v.optional(v.string()), course: v.optional(v.string()) })) }); @@ -4898,6 +5151,7 @@ export const vSurveyV1ResultsData = v.object({ id: v.string() }), query: v.optional(v.object({ + mode: v.optional(v.string()), media: v.optional(v.string()) })) }); @@ -4946,3 +5200,173 @@ export const vSurveyV1ResultsAnonymousData = v.object({ * OK */ export const vSurveyV1ResultsAnonymousResponse = v.record(v.string(), v.record(v.string(), v.pipe(v.number(), v.integer()))); + +export const vTutorV1GetAllocationData = v.object({ + body: v.optional(v.never()), + path: v.optional(v.never()), + query: v.optional(v.object({ + page: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1)), 1), + size: v.optional(v.pipe(v.number(), v.integer(), v.maxValue(100)), 24) + })) +}); + +/** + * OK + */ +export const vTutorV1GetAllocationResponse = vPaginatedResponseAllocationSchema; + +export const vTutorV1GetAllocationStatsData = v.object({ + body: v.optional(v.never()), + path: v.optional(v.never()), + query: v.optional(v.never()) +}); + +/** + * OK + */ +export const vTutorV1GetAllocationStatsResponse = vAllocationStatsSchema; + +export const vTutorV1GetExamGradesData = v.object({ + body: v.optional(v.never()), + path: v.object({ + id: v.string() + }), + query: v.optional(v.object({ + page: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1)), 1), + size: v.optional(v.pipe(v.number(), v.integer(), v.maxValue(100)), 24) + })) +}); + +/** + * OK + */ +export const vTutorV1GetExamGradesResponse = vPagedTutorExamGradeSchema; + +export const vTutorV1GetExamGradePaperData = v.object({ + body: v.optional(v.never()), + path: v.object({ + id: v.string(), + grade_id: v.pipe(v.number(), v.integer()) + }), + query: v.optional(v.never()) +}); + +/** + * OK + */ +export const vTutorV1GetExamGradePaperResponse = vTutorExamGradePaperSchema; + +export const vTutorV1CompleteExamGradeData = v.object({ + body: vTutorGradeSaveSchema, + path: v.object({ + id: v.string(), + grade_id: v.pipe(v.number(), v.integer()) + }), + query: v.optional(v.never()) +}); + +/** + * OK + */ +export const vTutorV1CompleteExamGradeResponse = vTutorGraeCompleteSchema; + +export const vTutorV1GetAssignmentGradesData = v.object({ + body: v.optional(v.never()), + path: v.object({ + id: v.string() + }), + query: v.optional(v.object({ + page: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1)), 1), + size: v.optional(v.pipe(v.number(), v.integer(), v.maxValue(100)), 24) + })) +}); + +/** + * OK + */ +export const vTutorV1GetAssignmentGradesResponse = vPagedTutorAssignmentGradeSchema; + +export const vTutorV1GetAssignmentGradePaperData = v.object({ + body: v.optional(v.never()), + path: v.object({ + id: v.string(), + grade_id: v.pipe(v.number(), v.integer()) + }), + query: v.optional(v.never()) +}); + +/** + * OK + */ +export const vTutorV1GetAssignmentGradePaperResponse = vTutorAssignmentGradePaperSchema; + +export const vTutorV1CompleteAssignmentGradeData = v.object({ + body: vTutorGradeSaveSchema, + path: v.object({ + id: v.string(), + grade_id: v.pipe(v.number(), v.integer()) + }), + query: v.optional(v.never()) +}); + +/** + * OK + */ +export const vTutorV1CompleteAssignmentGradeResponse = vTutorGraeCompleteSchema; + +export const vTutorV1GetAssignmentRubricData = v.object({ + body: v.optional(v.never()), + path: v.object({ + id: v.string() + }), + query: v.optional(v.never()) +}); + +/** + * OK + */ +export const vTutorV1GetAssignmentRubricResponse = vRubricSchema; + +export const vTutorV1GetDiscussionGradesData = v.object({ + body: v.optional(v.never()), + path: v.object({ + id: v.string() + }), + query: v.optional(v.object({ + page: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1)), 1), + size: v.optional(v.pipe(v.number(), v.integer(), v.maxValue(100)), 24) + })) +}); + +/** + * OK + */ +export const vTutorV1GetDiscussionGradesResponse = vPagedTutorDiscussionGradeSchema; + +export const vTutorV1GetDiscussionGradePaperData = v.object({ + body: v.optional(v.never()), + path: v.object({ + id: v.string(), + grade_id: v.pipe(v.number(), v.integer()) + }), + query: v.optional(v.never()) +}); + +/** + * OK + */ +export const vTutorV1GetDiscussionGradePaperResponse = vTutorDiscussionGradePaperSchema; + +export const vTutorV1CompleteDiscussionGradeData = v.object({ + body: vTutorDiscussionGradeSaveSchema, + path: v.object({ + id: v.string(), + grade_id: v.pipe(v.number(), v.integer()) + }), + query: v.optional(v.never()) +}); + +/** + * OK + */ +export const vTutorV1CompleteDiscussionGradeResponse = vTutorGraeCompleteSchema; diff --git a/web/src/routeTree.gen.ts b/web/src/routeTree.gen.ts index 0f0caca..a300025 100644 --- a/web/src/routeTree.gen.ts +++ b/web/src/routeTree.gen.ts @@ -9,10 +9,12 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as TutorRouteRouteImport } from './routes/tutor/route' import { Route as StudioRouteRouteImport } from './routes/studio/route' import { Route as authRouteRouteImport } from './routes/(auth)/route' import { Route as appRouteRouteImport } from './routes/(app)/route' import { Route as IndexRouteImport } from './routes/index' +import { Route as TutorIndexRouteImport } from './routes/tutor/index' import { Route as StudioIndexRouteImport } from './routes/studio/index' import { Route as authPasswordChangeRouteImport } from './routes/(auth)/password-change' import { Route as authLoginRouteImport } from './routes/(auth)/login' @@ -37,11 +39,19 @@ import { Route as appAccountLinkRouteImport } from './routes/(app)/account/link' import { Route as appAccountGroupRouteImport } from './routes/(app)/account/group' import { Route as appAccountEmailChangeRouteImport } from './routes/(app)/account/email-change' import { Route as appAccountDeviceRouteImport } from './routes/(app)/account/device' +import { Route as TutorExamIdGradingRouteImport } from './routes/tutor/exam/$id.grading' +import { Route as TutorDiscussionIdGradingRouteImport } from './routes/tutor/discussion/$id.grading' +import { Route as TutorAssignmentIdGradingRouteImport } from './routes/tutor/assignment/$id.grading' import { Route as appExamIdSessionRouteImport } from './routes/(app)/exam/$id.session' import { Route as appDiscussionIdSessionRouteImport } from './routes/(app)/discussion/$id.session' import { Route as appCourseIdSessionRouteImport } from './routes/(app)/course/$id.session' import { Route as appAssignmentIdSessionRouteImport } from './routes/(app)/assignment/$id.session' +const TutorRouteRoute = TutorRouteRouteImport.update({ + id: '/tutor', + path: '/tutor', + getParentRoute: () => rootRouteImport, +} as any) const StudioRouteRoute = StudioRouteRouteImport.update({ id: '/studio', path: '/studio', @@ -60,6 +70,11 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) +const TutorIndexRoute = TutorIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => TutorRouteRoute, +} as any) const StudioIndexRoute = StudioIndexRouteImport.update({ id: '/', path: '/', @@ -181,6 +196,23 @@ const appAccountDeviceRoute = appAccountDeviceRouteImport.update({ path: '/device', getParentRoute: () => appAccountRouteRoute, } as any) +const TutorExamIdGradingRoute = TutorExamIdGradingRouteImport.update({ + id: '/exam/$id/grading', + path: '/exam/$id/grading', + getParentRoute: () => TutorRouteRoute, +} as any) +const TutorDiscussionIdGradingRoute = + TutorDiscussionIdGradingRouteImport.update({ + id: '/discussion/$id/grading', + path: '/discussion/$id/grading', + getParentRoute: () => TutorRouteRoute, + } as any) +const TutorAssignmentIdGradingRoute = + TutorAssignmentIdGradingRouteImport.update({ + id: '/assignment/$id/grading', + path: '/assignment/$id/grading', + getParentRoute: () => TutorRouteRoute, + } as any) const appExamIdSessionRoute = appExamIdSessionRouteImport.update({ id: '/exam/$id/session', path: '/exam/$id/session', @@ -205,6 +237,7 @@ const appAssignmentIdSessionRoute = appAssignmentIdSessionRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/studio': typeof StudioRouteRouteWithChildren + '/tutor': typeof TutorRouteRouteWithChildren '/account': typeof appAccountRouteRouteWithChildren '/dashboard': typeof appDashboardRouteRouteWithChildren '/activate': typeof authActivateRoute @@ -212,6 +245,7 @@ export interface FileRoutesByFullPath { '/login': typeof authLoginRoute '/password-change': typeof authPasswordChangeRoute '/studio/': typeof StudioIndexRoute + '/tutor/': typeof TutorIndexRoute '/account/device': typeof appAccountDeviceRoute '/account/email-change': typeof appAccountEmailChangeRoute '/account/group': typeof appAccountGroupRoute @@ -233,6 +267,9 @@ export interface FileRoutesByFullPath { '/course/$id/session': typeof appCourseIdSessionRoute '/discussion/$id/session': typeof appDiscussionIdSessionRoute '/exam/$id/session': typeof appExamIdSessionRoute + '/tutor/assignment/$id/grading': typeof TutorAssignmentIdGradingRoute + '/tutor/discussion/$id/grading': typeof TutorDiscussionIdGradingRoute + '/tutor/exam/$id/grading': typeof TutorExamIdGradingRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -242,6 +279,7 @@ export interface FileRoutesByTo { '/login': typeof authLoginRoute '/password-change': typeof authPasswordChangeRoute '/studio': typeof StudioIndexRoute + '/tutor': typeof TutorIndexRoute '/account/device': typeof appAccountDeviceRoute '/account/email-change': typeof appAccountEmailChangeRoute '/account/group': typeof appAccountGroupRoute @@ -263,6 +301,9 @@ export interface FileRoutesByTo { '/course/$id/session': typeof appCourseIdSessionRoute '/discussion/$id/session': typeof appDiscussionIdSessionRoute '/exam/$id/session': typeof appExamIdSessionRoute + '/tutor/assignment/$id/grading': typeof TutorAssignmentIdGradingRoute + '/tutor/discussion/$id/grading': typeof TutorDiscussionIdGradingRoute + '/tutor/exam/$id/grading': typeof TutorExamIdGradingRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -270,6 +311,7 @@ export interface FileRoutesById { '/(app)': typeof appRouteRouteWithChildren '/(auth)': typeof authRouteRouteWithChildren '/studio': typeof StudioRouteRouteWithChildren + '/tutor': typeof TutorRouteRouteWithChildren '/(app)/account': typeof appAccountRouteRouteWithChildren '/(app)/dashboard': typeof appDashboardRouteRouteWithChildren '/(auth)/activate': typeof authActivateRoute @@ -277,6 +319,7 @@ export interface FileRoutesById { '/(auth)/login': typeof authLoginRoute '/(auth)/password-change': typeof authPasswordChangeRoute '/studio/': typeof StudioIndexRoute + '/tutor/': typeof TutorIndexRoute '/(app)/account/device': typeof appAccountDeviceRoute '/(app)/account/email-change': typeof appAccountEmailChangeRoute '/(app)/account/group': typeof appAccountGroupRoute @@ -298,12 +341,16 @@ export interface FileRoutesById { '/(app)/course/$id/session': typeof appCourseIdSessionRoute '/(app)/discussion/$id/session': typeof appDiscussionIdSessionRoute '/(app)/exam/$id/session': typeof appExamIdSessionRoute + '/tutor/assignment/$id/grading': typeof TutorAssignmentIdGradingRoute + '/tutor/discussion/$id/grading': typeof TutorDiscussionIdGradingRoute + '/tutor/exam/$id/grading': typeof TutorExamIdGradingRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' | '/studio' + | '/tutor' | '/account' | '/dashboard' | '/activate' @@ -311,6 +358,7 @@ export interface FileRouteTypes { | '/login' | '/password-change' | '/studio/' + | '/tutor/' | '/account/device' | '/account/email-change' | '/account/group' @@ -332,6 +380,9 @@ export interface FileRouteTypes { | '/course/$id/session' | '/discussion/$id/session' | '/exam/$id/session' + | '/tutor/assignment/$id/grading' + | '/tutor/discussion/$id/grading' + | '/tutor/exam/$id/grading' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -341,6 +392,7 @@ export interface FileRouteTypes { | '/login' | '/password-change' | '/studio' + | '/tutor' | '/account/device' | '/account/email-change' | '/account/group' @@ -362,12 +414,16 @@ export interface FileRouteTypes { | '/course/$id/session' | '/discussion/$id/session' | '/exam/$id/session' + | '/tutor/assignment/$id/grading' + | '/tutor/discussion/$id/grading' + | '/tutor/exam/$id/grading' id: | '__root__' | '/' | '/(app)' | '/(auth)' | '/studio' + | '/tutor' | '/(app)/account' | '/(app)/dashboard' | '/(auth)/activate' @@ -375,6 +431,7 @@ export interface FileRouteTypes { | '/(auth)/login' | '/(auth)/password-change' | '/studio/' + | '/tutor/' | '/(app)/account/device' | '/(app)/account/email-change' | '/(app)/account/group' @@ -396,6 +453,9 @@ export interface FileRouteTypes { | '/(app)/course/$id/session' | '/(app)/discussion/$id/session' | '/(app)/exam/$id/session' + | '/tutor/assignment/$id/grading' + | '/tutor/discussion/$id/grading' + | '/tutor/exam/$id/grading' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -403,11 +463,19 @@ export interface RootRouteChildren { appRouteRoute: typeof appRouteRouteWithChildren authRouteRoute: typeof authRouteRouteWithChildren StudioRouteRoute: typeof StudioRouteRouteWithChildren + TutorRouteRoute: typeof TutorRouteRouteWithChildren publicSurveyIdRoute: typeof publicSurveyIdRoute } declare module '@tanstack/solid-router' { interface FileRoutesByPath { + '/tutor': { + id: '/tutor' + path: '/tutor' + fullPath: '/tutor' + preLoaderRoute: typeof TutorRouteRouteImport + parentRoute: typeof rootRouteImport + } '/studio': { id: '/studio' path: '/studio' @@ -436,6 +504,13 @@ declare module '@tanstack/solid-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/tutor/': { + id: '/tutor/' + path: '/' + fullPath: '/tutor/' + preLoaderRoute: typeof TutorIndexRouteImport + parentRoute: typeof TutorRouteRoute + } '/studio/': { id: '/studio/' path: '/' @@ -604,6 +679,27 @@ declare module '@tanstack/solid-router' { preLoaderRoute: typeof appAccountDeviceRouteImport parentRoute: typeof appAccountRouteRoute } + '/tutor/exam/$id/grading': { + id: '/tutor/exam/$id/grading' + path: '/exam/$id/grading' + fullPath: '/tutor/exam/$id/grading' + preLoaderRoute: typeof TutorExamIdGradingRouteImport + parentRoute: typeof TutorRouteRoute + } + '/tutor/discussion/$id/grading': { + id: '/tutor/discussion/$id/grading' + path: '/discussion/$id/grading' + fullPath: '/tutor/discussion/$id/grading' + preLoaderRoute: typeof TutorDiscussionIdGradingRouteImport + parentRoute: typeof TutorRouteRoute + } + '/tutor/assignment/$id/grading': { + id: '/tutor/assignment/$id/grading' + path: '/assignment/$id/grading' + fullPath: '/tutor/assignment/$id/grading' + preLoaderRoute: typeof TutorAssignmentIdGradingRouteImport + parentRoute: typeof TutorRouteRoute + } '/(app)/exam/$id/session': { id: '/(app)/exam/$id/session' path: '/exam/$id/session' @@ -738,11 +834,30 @@ const StudioRouteRouteWithChildren = StudioRouteRoute._addFileChildren( StudioRouteRouteChildren, ) +interface TutorRouteRouteChildren { + TutorIndexRoute: typeof TutorIndexRoute + TutorAssignmentIdGradingRoute: typeof TutorAssignmentIdGradingRoute + TutorDiscussionIdGradingRoute: typeof TutorDiscussionIdGradingRoute + TutorExamIdGradingRoute: typeof TutorExamIdGradingRoute +} + +const TutorRouteRouteChildren: TutorRouteRouteChildren = { + TutorIndexRoute: TutorIndexRoute, + TutorAssignmentIdGradingRoute: TutorAssignmentIdGradingRoute, + TutorDiscussionIdGradingRoute: TutorDiscussionIdGradingRoute, + TutorExamIdGradingRoute: TutorExamIdGradingRoute, +} + +const TutorRouteRouteWithChildren = TutorRouteRoute._addFileChildren( + TutorRouteRouteChildren, +) + const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, appRouteRoute: appRouteRouteWithChildren, authRouteRoute: authRouteRouteWithChildren, StudioRouteRoute: StudioRouteRouteWithChildren, + TutorRouteRoute: TutorRouteRouteWithChildren, publicSurveyIdRoute: publicSurveyIdRoute, } export const routeTree = rootRouteImport diff --git a/web/src/routes/(app)/-shared/AccountButton.tsx b/web/src/routes/(app)/-shared/AccountButton.tsx index 02f4b47..7fec2c3 100644 --- a/web/src/routes/(app)/-shared/AccountButton.tsx +++ b/web/src/routes/(app)/-shared/AccountButton.tsx @@ -1,5 +1,5 @@ import { IconListDetails, IconLogout, IconPuzzle2, IconUser } from '@tabler/icons-solidjs' -import { useNavigate } from '@tanstack/solid-router' +import { useNavigate, useRouter } from '@tanstack/solid-router' import { Show } from 'solid-js' import { accountStore } from '@/routes/(app)/account/-store' import { Avatar } from '@/shared/Avatar' @@ -8,6 +8,7 @@ import { logout } from './logout' export const AccountButton = () => { const { t } = useTranslation() + const router = useRouter() const navigate = useNavigate() const closeDropdown = () => { @@ -17,6 +18,7 @@ export const AccountButton = () => { const handleLogout = async () => { closeDropdown() await logout() + router.invalidate() } const goTo = (to: string) => { @@ -64,10 +66,10 @@ export const AccountButton = () => { diff --git a/web/src/routes/(app)/-shared/Inquiry.tsx b/web/src/routes/(app)/-shared/Inquiry.tsx index 2f381bc..7a86b7b 100644 --- a/web/src/routes/(app)/-shared/Inquiry.tsx +++ b/web/src/routes/(app)/-shared/Inquiry.tsx @@ -71,10 +71,7 @@ export const Inquiry = (props: Props) => { const [inquiries, setObserverEl, { setStore, refetch }] = createCachedInfiniteStore( 'operationV1GetInquiries', () => ({ query: { appLabel: props.appLabel, model: props.model, contentId: props.contentId } }), - async (options, page) => { - const { data } = await operationV1GetInquiries({ ...options, query: { ...options.query, page } }) - return data - }, + async (options, page) => (await operationV1GetInquiries({ ...options, query: { ...options.query, page } })).data, ) onMount(() => props.setRefreshHandler(() => refetch)) diff --git a/web/src/routes/(app)/-shared/Notification.tsx b/web/src/routes/(app)/-shared/Notification.tsx index e15ee94..129a467 100644 --- a/web/src/routes/(app)/-shared/Notification.tsx +++ b/web/src/routes/(app)/-shared/Notification.tsx @@ -18,16 +18,13 @@ export const Notification = () => { const [messages, setObserverEl, { setStore, refetch }] = createCachedInfiniteStore( 'operationV1GetUnreadMessages', () => (enabled() ? {} : undefined), - async (options, page) => { - const { data } = await operationV1GetUnreadMessages({ ...options, query: { page } }) - return data - }, + async (options, page) => (await operationV1GetUnreadMessages({ ...options, query: { page } })).data, ) onMount(() => { setTimeout(() => { setEnabled(true) - }, 1000 * 0.3) + }, 1000 * 1) }) const [unreadCount, setUnreadCount] = createSignal(0) diff --git a/web/src/routes/(app)/-shared/SearchBox.tsx b/web/src/routes/(app)/-shared/SearchBox.tsx index 7e79185..b117983 100644 --- a/web/src/routes/(app)/-shared/SearchBox.tsx +++ b/web/src/routes/(app)/-shared/SearchBox.tsx @@ -26,10 +26,7 @@ export const SearchBox = () => { if (!input_ || hasIncompleteKorean(input_)) return return { query: { q: input_, limit: 20 } } }, - async (options) => { - const { data } = await contentV1SearchSuggestion(options) - return data - }, + async (options) => (await contentV1SearchSuggestion(options)).data, ) const search = (q: string) => { diff --git a/web/src/routes/(app)/-shared/aichat/Chat.tsx b/web/src/routes/(app)/-shared/aichat/Chat.tsx index 6b23dd0..c984f61 100644 --- a/web/src/routes/(app)/-shared/aichat/Chat.tsx +++ b/web/src/routes/(app)/-shared/aichat/Chat.tsx @@ -18,10 +18,7 @@ export const Chat = () => { const chatListStore = createCachedStore( 'assistantV1GetChats', () => (open() ? {} : undefined), - async (options) => { - const { data } = await assistantV1GetChats(options) - return data - }, + async (options) => (await assistantV1GetChats(options)).data, ) const activeChat = () => chatListStore[0].data?.chats.find((chat) => chat.active) @@ -29,10 +26,7 @@ export const Chat = () => { const chatMessageStore = createCachedInfiniteStore( 'assistantV1GetChatMessages', () => (activeChat() ? { path: { id: activeChat()!.id } } : undefined), - async (options, page) => { - const { data } = await assistantV1GetChatMessages({ ...options, query: { page } }) - return data - }, + async (options, page) => (await assistantV1GetChatMessages({ ...options, query: { page } })).data, ) return ( diff --git a/web/src/routes/(app)/-shared/goal/CategorySelect.tsx b/web/src/routes/(app)/-shared/goal/CategorySelect.tsx index 7d4364d..d61ac81 100644 --- a/web/src/routes/(app)/-shared/goal/CategorySelect.tsx +++ b/web/src/routes/(app)/-shared/goal/CategorySelect.tsx @@ -23,10 +23,7 @@ export const CategorySelect = (props: Props) => { const [classifications] = createCachedStore( 'competencyV1GetClassificationTree', () => ({}), - async () => { - const { data } = await competencyV1GetClassificationTree() - return data - }, + async () => (await competencyV1GetClassificationTree()).data, ) const [selection, setSelection] = createStore({ firstLevel: 0, diff --git a/web/src/routes/(app)/-shared/goal/GoalForm.tsx b/web/src/routes/(app)/-shared/goal/GoalForm.tsx index 13939fb..6b11b02 100644 --- a/web/src/routes/(app)/-shared/goal/GoalForm.tsx +++ b/web/src/routes/(app)/-shared/goal/GoalForm.tsx @@ -31,10 +31,7 @@ export const GoalForm = (props: Props) => { const [skills] = createCachedStore( 'competencyV1GetClassificationSkillsData', () => (props.classIdForSkills ? { path: { id: props.classIdForSkills } } : undefined), - async (options) => { - const { data } = await competencyV1GetClassificationSkillsData(options) - return data - }, + async (options) => (await competencyV1GetClassificationSkillsData(options)).data, ) const form = createForm>({ diff --git a/web/src/routes/(app)/-shared/grading/Appeal.tsx b/web/src/routes/(app)/-shared/grading/Appeal.tsx index f87091e..38078b1 100644 --- a/web/src/routes/(app)/-shared/grading/Appeal.tsx +++ b/web/src/routes/(app)/-shared/grading/Appeal.tsx @@ -109,11 +109,11 @@ export const Appeal = (props: Props) => { - {appeal()!.closed ? t('Reviewed') : t('Pending')} + {appeal()!.review ? t('Reviewed') : t('Pending')} diff --git a/web/src/routes/(app)/-shared/grading/SessionStart.tsx b/web/src/routes/(app)/-shared/grading/SessionStart.tsx index 3738c08..1292b22 100644 --- a/web/src/routes/(app)/-shared/grading/SessionStart.tsx +++ b/web/src/routes/(app)/-shared/grading/SessionStart.tsx @@ -1,4 +1,4 @@ -import { createEffect, createSignal, type JSX, Show } from 'solid-js' +import { createEffect, createSignal, type JSX, onCleanup, Show } from 'solid-js' import { OTP_VERIFICATION_EXPIRY_SECONDS } from '@/config' import { ContentViewer } from '@/shared/ContentViewer' import { SubmitButton } from '@/shared/SubmitButton' @@ -41,7 +41,7 @@ export const SessionStart = (props: Props) => { }, OTP_VERIFICATION_EXPIRY_SECONDS * 1000 * 0.8, ) - return () => clearTimeout(timer) + onCleanup(() => clearTimeout(timer)) }) return ( diff --git a/web/src/routes/(app)/-shared/quiz/QuizDialog.tsx b/web/src/routes/(app)/-shared/quiz/QuizDialog.tsx index 839e368..0bd2758 100644 --- a/web/src/routes/(app)/-shared/quiz/QuizDialog.tsx +++ b/web/src/routes/(app)/-shared/quiz/QuizDialog.tsx @@ -22,10 +22,7 @@ export const QuizDialog = (props: Props) => { const [session, { setStore }] = createCachedStore( 'quizV1GetSession', () => ({ path: { id: props.id }, query: { ...props.inlineContext } }), - async (options) => { - const { data } = await quizV1GetSession(options) - return data - }, + async (options) => (await quizV1GetSession(options)).data, ) const s = () => session.data diff --git a/web/src/routes/(app)/-shared/thread/Thread.tsx b/web/src/routes/(app)/-shared/thread/Thread.tsx index 2f074b1..a2c585d 100644 --- a/web/src/routes/(app)/-shared/thread/Thread.tsx +++ b/web/src/routes/(app)/-shared/thread/Thread.tsx @@ -19,10 +19,7 @@ export const Thread = (props: ThreadContextValue['context']) => { const threadStore = createCachedStore( 'operationV1GetThread', () => ({ path: { app_label: appLabel, model, subject_id: subjectId } }), - async (options) => { - const { data } = await operationV1GetThread(options) - return data - }, + async (options) => (await operationV1GetThread(options)).data, ) const thread = () => threadStore[0].data @@ -30,10 +27,7 @@ export const Thread = (props: ThreadContextValue['context']) => { const commentStore = createCachedInfiniteStore( 'operationV1GetThreadComments', () => (thread() ? { path: { id: thread()!.id } } : undefined), - async (options, page) => { - const { data } = await operationV1GetThreadComments({ ...options, query: { page } }) - return data - }, + async (options, page) => (await operationV1GetThreadComments({ ...options, query: { page } })).data, ) const disabled = () => props.options?.readOnly || !!thread()?.closed diff --git a/web/src/routes/(app)/account/device.tsx b/web/src/routes/(app)/account/device.tsx index 3e489a8..69ae91a 100644 --- a/web/src/routes/(app)/account/device.tsx +++ b/web/src/routes/(app)/account/device.tsx @@ -18,10 +18,7 @@ function RouteComponent() { const [devices, { setStore }] = createCachedStore( 'operationV1GetDevices', () => ({}), - async () => { - const { data } = await operationV1GetDevices() - return data - }, + async () => (await operationV1GetDevices()).data, ) const deleteDevice = async (deviceId: number) => { diff --git a/web/src/routes/(app)/account/group.tsx b/web/src/routes/(app)/account/group.tsx index e3df1ed..5c5bec9 100644 --- a/web/src/routes/(app)/account/group.tsx +++ b/web/src/routes/(app)/account/group.tsx @@ -16,10 +16,7 @@ function RouteComponent() { const [members] = createCachedStore( 'partnerV1MemberInfos', () => ({}), - async () => { - const { data } = await partnerV1MemberInfos() - return data - }, + async () => (await partnerV1MemberInfos()).data, ) return ( diff --git a/web/src/routes/(app)/account/link.tsx b/web/src/routes/(app)/account/link.tsx index fb97cd7..7942eae 100644 --- a/web/src/routes/(app)/account/link.tsx +++ b/web/src/routes/(app)/account/link.tsx @@ -29,10 +29,7 @@ function RouteComponent() { const [accounts, { setStore }] = createCachedStore( 'ssoV1GetAccounts', () => ({}), - async () => { - const { data } = await ssoV1GetAccounts() - return data - }, + async () => (await ssoV1GetAccounts()).data, ) const accountMap = createMemo( diff --git a/web/src/routes/(app)/assignment/$id.session.tsx b/web/src/routes/(app)/assignment/$id.session.tsx index 0a490bc..92febd4 100644 --- a/web/src/routes/(app)/assignment/$id.session.tsx +++ b/web/src/routes/(app)/assignment/$id.session.tsx @@ -25,10 +25,7 @@ function RouteComponent() { const store = createCachedStore( 'assignmentV1GetSession', () => ({ path: { id: params().id }, query: accessContextParam() }), - async (options) => { - const { data } = await assignmentV1GetSession(options) - return data - }, + async (options) => (await assignmentV1GetSession(options)).data, ) const s = () => store[0].data diff --git a/web/src/routes/(app)/assignment/-session/GradingReview.tsx b/web/src/routes/(app)/assignment/-session/GradingReview.tsx index 4694c73..b531d41 100644 --- a/web/src/routes/(app)/assignment/-session/GradingReview.tsx +++ b/web/src/routes/(app)/assignment/-session/GradingReview.tsx @@ -66,7 +66,7 @@ export const GradingReview = () => {
diff --git a/web/src/routes/(app)/course/$id.session.tsx b/web/src/routes/(app)/course/$id.session.tsx index 18d697e..44c60c7 100644 --- a/web/src/routes/(app)/course/$id.session.tsx +++ b/web/src/routes/(app)/course/$id.session.tsx @@ -36,10 +36,7 @@ function RouteComponent() { const store = createCachedStore( 'courseV1GetSession', () => ({ path: { id: courseId } }), - async (options) => { - const { data } = await courseV1GetSession(options) - return data - }, + async (options) => (await courseV1GetSession(options)).data, ) const s = () => store[0].data diff --git a/web/src/routes/(app)/course/-session/Achievement.tsx b/web/src/routes/(app)/course/-session/Achievement.tsx index 705ab72..88c6908 100644 --- a/web/src/routes/(app)/course/-session/Achievement.tsx +++ b/web/src/routes/(app)/course/-session/Achievement.tsx @@ -17,10 +17,7 @@ export const Achievement = () => { const [certificates] = createCachedStore( 'competencyV1GetCertificates', () => ({ query: { courseId: s().course.id } }), - async (options) => { - const { data } = await competencyV1GetCertificates(options) - return data - }, + async (options) => (await competencyV1GetCertificates(options)).data, ) return ( diff --git a/web/src/routes/(app)/course/-session/CourseDetail.tsx b/web/src/routes/(app)/course/-session/CourseDetail.tsx index 079233d..3ef8e0b 100644 --- a/web/src/routes/(app)/course/-session/CourseDetail.tsx +++ b/web/src/routes/(app)/course/-session/CourseDetail.tsx @@ -17,10 +17,7 @@ export const CourseDetail = () => { const [courseDetail] = createCachedStore( 'courseV1GetDetail', () => ({ path: { id: s().course.id } }), - async (options) => { - const { data } = await courseV1GetDetail(options) - return data - }, + async (options) => (await courseV1GetDetail(options)).data, ) return ( diff --git a/web/src/routes/(app)/course/-session/GettingStarted.tsx b/web/src/routes/(app)/course/-session/GettingStarted.tsx index 5ddff06..e6f2f8e 100644 --- a/web/src/routes/(app)/course/-session/GettingStarted.tsx +++ b/web/src/routes/(app)/course/-session/GettingStarted.tsx @@ -51,6 +51,14 @@ export const GettingStarted = (props: Props) => { {t('Review')} {`${new Date(s().accessDate.archive).toLocaleDateString()}`} + + {t('Grading Due')} + {`${new Date(s().gradingDate.gradeDue).toLocaleDateString()}`} + + + {t('Grade Confirm Due')} + {`${new Date(s().gradingDate.confirmDue).toLocaleDateString()}`} + diff --git a/web/src/routes/(app)/course/-session/Schedule.tsx b/web/src/routes/(app)/course/-session/Schedule.tsx index 02197b2..5d903a8 100644 --- a/web/src/routes/(app)/course/-session/Schedule.tsx +++ b/web/src/routes/(app)/course/-session/Schedule.tsx @@ -102,6 +102,8 @@ export const Schedule = () => { const courseStart = new Date(s().accessDate.start) const courseEnd = new Date(s().accessDate.end) const courseArchive = new Date(s().accessDate.archive) + const gradingDue = new Date(s().gradingDate.gradeDue) + const confirmDue = new Date(s().gradingDate.confirmDue) const now = new Date() const monthMarkers = createMemo(() => { @@ -152,6 +154,28 @@ export const Schedule = () => {
{t('End')}

+
  • +
    +
    {t(gradingDue.toLocaleDateString())}
    +
    + gradingDue} fallback={}> + + +
    +
    {t('Grading')}
    +
    +
  • +
  • +
    +
    {t(confirmDue.toLocaleDateString())}
    +
    + confirmDue} fallback={}> + + +
    +
    {t('Grading Confirm')}
    +
    +

  • {t(courseArchive.toLocaleDateString())}
    diff --git a/web/src/routes/(app)/dashboard/achievement.tsx b/web/src/routes/(app)/dashboard/achievement.tsx index 9eb88ed..ed28999 100644 --- a/web/src/routes/(app)/dashboard/achievement.tsx +++ b/web/src/routes/(app)/dashboard/achievement.tsx @@ -19,10 +19,7 @@ function RouteComponent() { const [certificates, setObserverEl, { refetch }] = createCachedInfiniteStore( 'competencyV1GetCertificateAwards', () => ({}), - async (options, page) => { - const { data } = await competencyV1GetCertificateAwards({ ...options, query: { page } }) - return data - }, + async (options, page) => (await competencyV1GetCertificateAwards({ ...options, query: { page } })).data, ) onMount(() => setRefreshHandler(() => refetch)) diff --git a/web/src/routes/(app)/dashboard/announcement.tsx b/web/src/routes/(app)/dashboard/announcement.tsx index cea3ddb..73eb388 100644 --- a/web/src/routes/(app)/dashboard/announcement.tsx +++ b/web/src/routes/(app)/dashboard/announcement.tsx @@ -21,10 +21,7 @@ function RouteComponent() { const [announcements, setObserverEl, { setStore, refetch }] = createCachedInfiniteStore( 'operationV1GetAnnouncements', () => ({}), - async (options, page) => { - const { data } = await operationV1GetAnnouncements({ ...options, query: { page } }) - return data - }, + async (options, page) => (await operationV1GetAnnouncements({ ...options, query: { page } })).data, ) const { setRefreshHandler } = useDashboard() diff --git a/web/src/routes/(app)/dashboard/catalog.tsx b/web/src/routes/(app)/dashboard/catalog.tsx index 0492d6a..bf20aec 100644 --- a/web/src/routes/(app)/dashboard/catalog.tsx +++ b/web/src/routes/(app)/dashboard/catalog.tsx @@ -30,10 +30,7 @@ function RouteComponent() { const [catalogs] = createCachedStore( 'learningV1GetCatalogs', () => ({}), - async (params) => { - const { data } = await learningV1GetCatalogs(params) - return data - }, + async (params) => (await learningV1GetCatalogs(params)).data, ) return ( @@ -137,10 +134,7 @@ const ItemList = (props: ItemListProps) => { const [items, setObserverEl, { setStore, refetch }] = createCachedInfiniteStore( 'learningV1GetCatalogItems', () => (props.open ? { path: { id: props.catalog.id } } : undefined), - async (options, page) => { - const { data } = await learningV1GetCatalogItems({ ...options, query: { page } }) - return data - }, + async (options, page) => (await learningV1GetCatalogItems({ ...options, query: { page } })).data, ) return ( diff --git a/web/src/routes/(app)/dashboard/goal.tsx b/web/src/routes/(app)/dashboard/goal.tsx index 91bf250..e8f78c9 100644 --- a/web/src/routes/(app)/dashboard/goal.tsx +++ b/web/src/routes/(app)/dashboard/goal.tsx @@ -20,10 +20,7 @@ function RouteComponent() { const [goals, { setStore }] = createCachedStore( 'competencyV1GetCompetencyGoals', () => ({}), - async () => { - const { data } = await competencyV1GetCompetencyGoals() - return data - }, + async () => (await competencyV1GetCompetencyGoals()).data, ) const existingGoalClassIds = () => goals.data?.map((g) => g.classification.id) @@ -32,7 +29,7 @@ function RouteComponent() { return (
    -
    {t('Competency goals')}
    +
    {t('Competency goals')}
    @@ -77,7 +74,7 @@ function RouteComponent() { {formatDistanceToNow(item.modified, { addSuffix: true })} -
    -

    {reply.title}

    +

    + + {reply.title} +

    diff --git a/web/src/routes/(app)/exam/$id.session.tsx b/web/src/routes/(app)/exam/$id.session.tsx index 3561af8..86f188e 100644 --- a/web/src/routes/(app)/exam/$id.session.tsx +++ b/web/src/routes/(app)/exam/$id.session.tsx @@ -28,10 +28,7 @@ function RouteComponent() { const store = createCachedStore( 'examV1GetSession', () => ({ path: { id: params().id }, query: accessContextParam() }), - async (options) => { - const { data } = await examV1GetSession(options) - return data - }, + async (options) => (await examV1GetSession(options)).data, ) const s = () => store[0].data diff --git a/web/src/routes/(app)/exam/-session/GradingReview.tsx b/web/src/routes/(app)/exam/-session/GradingReview.tsx index 50231ff..ae79aa0 100644 --- a/web/src/routes/(app)/exam/-session/GradingReview.tsx +++ b/web/src/routes/(app)/exam/-session/GradingReview.tsx @@ -84,11 +84,11 @@ export const GradingReview = () => { - {appeal()!.closed ? t('Reviewed') : t('Pending')} + {appeal()!.review ? t('Reviewed') : t('Pending')} {
    {t('Question {{num}}', { num: props.numbering })}
    -

    {question.question}

    +

    {question.question}

    @@ -132,40 +132,42 @@ export const QuestionReview = (props: Props) => {
    - - - - - - - - - - - - - - - - - - - - - - - -
    {t('Points')} -
    = question.point!, - 'badge-secondary': earnedPoint < question.point!, - }} - > - {earnedPoint} / {question.point} -
    -
    {t('Feedback')}{feedback}
    {t('Correct Answer')}{solution?.correctAnswers?.map((answer) => String(answer)).join(', ')}
    {t('Correct Criteria')}{solution?.correctCriteria}
    {t('Explanation')}{solution?.explanation}
    +
    + + + + + + + + + + + + + + + + + + + + + + + +
    {t('Points')} +
    = question.point!, + 'badge-secondary': earnedPoint < question.point!, + }} + > + {earnedPoint} / {question.point} +
    +
    {t('Feedback')}{feedback}
    {t('Correct Answer')}{solution?.correctAnswers?.map((answer) => String(answer)).join(', ')}
    {t('Correct Criteria')}{solution?.correctCriteria}
    {t('Explanation')}{solution?.explanation}
    +
    {
    + ) } diff --git a/web/src/routes/(app)/media/$id.tsx b/web/src/routes/(app)/media/$id.tsx index 53d6b3c..56b1dfa 100644 --- a/web/src/routes/(app)/media/$id.tsx +++ b/web/src/routes/(app)/media/$id.tsx @@ -40,10 +40,7 @@ function RouteComponent() { const [media] = createCachedStore( 'contentV1GetMedia', () => ({ path: { id: params().id }, query: accessContextParam() }), - async (options) => { - const { data } = await contentV1GetMedia(options) - return data - }, + async (options) => (await contentV1GetMedia(options)).data, ) const [currentTime, setCurrentTime] = createSignal(0) diff --git a/web/src/routes/(app)/media/-media/Note.tsx b/web/src/routes/(app)/media/-media/Note.tsx index 6983509..9e605cf 100644 --- a/web/src/routes/(app)/media/-media/Note.tsx +++ b/web/src/routes/(app)/media/-media/Note.tsx @@ -27,10 +27,7 @@ export const Note = (props: Props) => { const [note, { setStore }] = createCachedStore( 'contentV1GetMediaNote', () => ({ path: { id: props.mediaId }, query: accessContextParam() }), - async (options) => { - const { data } = await contentV1GetMediaNote(options) - return data - }, + async (options) => (await contentV1GetMediaNote(options)).data, ) createEffect(() => { diff --git a/web/src/routes/(app)/route.tsx b/web/src/routes/(app)/route.tsx index 253dbda..8edd0f3 100644 --- a/web/src/routes/(app)/route.tsx +++ b/web/src/routes/(app)/route.tsx @@ -1,4 +1,4 @@ -import { createFileRoute, Outlet, redirect } from '@tanstack/solid-router' +import { createFileRoute, Outlet } from '@tanstack/solid-router' import { createEffect, onMount, Suspense } from 'solid-js' import * as v from 'valibot' import { learningV1GetRecords, operationV1RegisterDevice } from '@/api' @@ -10,6 +10,7 @@ import { NavbarLogo } from '@/shared/NavbarLogo' import { createCachedStore } from '@/shared/solid/cached-store' import { ThemeButton } from '@/shared/ThemeButton' import { getDeviceName } from '@/shared/utils' +import { protectedRoute } from '../protected' import { currentDevice, setCurrentDevice } from './-device' import { AccountButton } from './-shared/AccountButton' import { Chat } from './-shared/aichat/Chat' @@ -21,23 +22,11 @@ const searchSchema = v.object({ export const Route = createFileRoute('/(app)')({ validateSearch: searchSchema, - beforeLoad: async () => { - if (!accountStore.user) { - const nextPath = location.pathname + location.search - const shouldIgnoreNext = location.search.includes('token=') - - throw redirect({ - to: '/login', - search: shouldIgnoreNext ? undefined : { next: nextPath }, - }) - } - }, + beforeLoad: protectedRoute, component: RouteComponent, }) function RouteComponent() { - const navigate = Route.useNavigate() - // local database onMount(async () => { createCachedStore( @@ -51,19 +40,6 @@ function RouteComponent() { ) }) - // protected route - createEffect(() => { - if (!accountStore.user && !location.pathname.startsWith('/login')) { - const nextPath = location.pathname + location.search - const shouldIgnoreNext = location.search.includes('token=') - - navigate({ - to: '/login', - search: shouldIgnoreNext ? undefined : { next: nextPath }, - }) - } - }) - createEffect(async () => { if (currentDevice()) return @@ -90,6 +66,7 @@ function RouteComponent() { - -
    - -
    + + + +
    diff --git a/web/src/routes/studio/-course/data.ts b/web/src/routes/studio/-course/data.ts index 5495d61..8ed1a10 100644 --- a/web/src/routes/studio/-course/data.ts +++ b/web/src/routes/studio/-course/data.ts @@ -23,6 +23,9 @@ export const EmptyCourse = (): CourseSpec => { effortHours: -1, level: '' as LevelChoices, published: null, + gradeDueDays: -1, + appealDeadlineDays: -1, + confirmDueDays: -1, honorCodeId: -1, faqId: -1, gradingPolicy: { assessmentWeight: -1, completionWeight: -1, completionPassingPoint: -1 }, @@ -51,6 +54,9 @@ export const vCourseEditingSpec = v.object({ featured: v.boolean(), passingPoint: v.pipe(v.number(), v.integer(), v.minValue(0, AT_LEAST_ZERO)), maxAttempts: v.pipe(v.number(), v.integer(), v.minValue(0, AT_LEAST_ZERO)), + gradeDueDays: v.pipe(v.number(), v.integer(), v.minValue(0, AT_LEAST_ZERO)), + appealDeadlineDays: v.pipe(v.number(), v.integer(), v.minValue(0, AT_LEAST_ZERO)), + confirmDueDays: v.pipe(v.number(), v.integer(), v.minValue(0, AT_LEAST_ZERO)), verificationRequired: v.boolean(), objective: v.pipe(v.string(), v.nonEmpty(REQUIRED)), previewUrl: v.pipe(v.string(), v.url('URL address')), diff --git a/web/src/routes/studio/-studio/NavbarLogo.tsx b/web/src/routes/studio/-studio/NavbarLogo.tsx deleted file mode 100644 index f26aef2..0000000 --- a/web/src/routes/studio/-studio/NavbarLogo.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { useNavigate } from '@tanstack/solid-router' - -export const NavbarLogo = () => { - const navigate = useNavigate() - - return ( -
    navigate({ to: '/dashboard' })}> - Logo - -
    - ) -} diff --git a/web/src/routes/studio/route.tsx b/web/src/routes/studio/route.tsx index 60cd114..5b57e89 100644 --- a/web/src/routes/studio/route.tsx +++ b/web/src/routes/studio/route.tsx @@ -1,29 +1,16 @@ -import { createFileRoute, Outlet, redirect } from '@tanstack/solid-router' -import { createEffect } from 'solid-js' -import { accountStore } from '@/routes/(app)/account/-store' +import { createFileRoute, Outlet } from '@tanstack/solid-router' import { GoToTop } from '@/shared/GoToTop' +import { NavbarLogo } from '@/shared/NavbarLogo' import { ThemeButton } from '@/shared/ThemeButton' import { AccountButton } from '../(app)/-shared/AccountButton' -import { NavbarLogo } from './-studio/NavbarLogo' +import { protectedRoute } from '../protected' export const Route = createFileRoute('/studio')({ - beforeLoad: async () => { - if (!accountStore.user?.roles.includes('editor')) { - throw redirect({ to: '/dashboard' }) - } - }, + beforeLoad: protectedRoute, component: RouteComponent, }) function RouteComponent() { - const navigate = Route.useNavigate() - - createEffect(() => { - if (!accountStore.user?.roles.includes('editor')) { - navigate({ to: '/dashboard', replace: true }) - } - }) - return ( <>