From e3094db19a0ee739d069ae23bb4526f6fd570ae8 Mon Sep 17 00:00:00 2001 From: Cobel Date: Sun, 15 Mar 2026 16:48:44 +0900 Subject: [PATCH] Preview realm On branch desk Changes to be committed: modified: .gitignore modified: Dockerfile.search modified: apps/account/admin.py modified: apps/account/api/middleware.py modified: apps/account/api/schema.py modified: apps/account/apps.py modified: apps/account/management/commands/create_roles.py modified: apps/account/migrations/0001_initial.py modified: apps/account/models.py modified: apps/assignment/admin.py modified: apps/assignment/api/v1.py modified: apps/assignment/migrations/0001_initial.py modified: apps/assignment/models.py modified: apps/assignment/tests/test_assignment_api.py modified: apps/assistant/migrations/0001_initial.py modified: apps/common/apps.py modified: apps/common/error.py modified: apps/common/models.py new file: apps/common/policy.py modified: apps/common/util.py modified: apps/competency/apps.py modified: apps/competency/migrations/0001_initial.py modified: apps/content/api/test_content_api.py modified: apps/content/api/v1.py modified: apps/content/apps.py modified: apps/content/migrations/0001_initial.py modified: apps/content/models.py modified: apps/course/api/v1.py modified: apps/course/apps.py modified: apps/course/migrations/0001_initial.py modified: apps/course/models.py modified: apps/course/tests/test_course_api.py new file: apps/desk/__init__.py new file: apps/desk/admin.py new file: apps/desk/api/v1/__init__.py new file: apps/desk/api/v1/account.py new file: apps/desk/apps.py new file: apps/desk/migrations/__init__.py new file: apps/desk/models.py modified: apps/discussion/api/v1.py modified: apps/discussion/migrations/0001_initial.py modified: apps/discussion/models.py modified: apps/discussion/tests/test_discussion_api.py modified: apps/exam/api/v1.py modified: apps/exam/apps.py modified: apps/exam/migrations/0001_initial.py modified: apps/exam/models.py modified: apps/exam/tests/test_exam_api.py modified: apps/learning/api/access_control.py modified: apps/learning/apps.py modified: apps/learning/migrations/0001_initial.py deleted: apps/learning/tasks.py modified: apps/operation/apps.py modified: apps/operation/migrations/0001_initial.py modified: apps/partner/admin.py modified: apps/partner/apps.py modified: apps/partner/management/commands/create_platform_partner.py modified: apps/partner/migrations/0001_initial.py modified: apps/partner/models.py modified: apps/partner/tests/factories.py new file: apps/preview/__init__.py new file: apps/preview/admin.py new file: apps/preview/api/v1.py new file: apps/preview/apps.py new file: apps/preview/migrations/0001_initial.py new file: apps/preview/migrations/__init__.py new file: apps/preview/models.py new file: apps/preview/tasks.py modified: apps/quiz/api/v1.py modified: apps/quiz/apps.py modified: apps/quiz/migrations/0001_initial.py modified: apps/quiz/models.py modified: apps/quiz/tests/test_quiz_api.py modified: apps/sso/apps.py modified: apps/sso/migrations/0001_initial.py modified: apps/sso/models.py modified: apps/sso/tests/test_sso_api.py modified: apps/store/apps.py modified: apps/store/migrations/0001_initial.py modified: apps/studio/api/v1/__init__.py modified: apps/studio/api/v1/assignment.py modified: apps/studio/api/v1/course.py modified: apps/studio/api/v1/discussion.py modified: apps/studio/api/v1/exam.py modified: apps/studio/api/v1/media.py modified: apps/studio/api/v1/quiz.py modified: apps/studio/api/v1/survey.py modified: apps/studio/apps.py modified: apps/studio/decorator.py modified: apps/studio/migrations/0001_initial.py modified: apps/survey/api/v1.py modified: apps/survey/apps.py modified: apps/survey/migrations/0001_initial.py modified: apps/survey/models.py modified: apps/survey/tests/test_survey_api.py modified: apps/tracking/apps.py modified: apps/tracking/middleware.py modified: apps/tracking/migrations/0001_initial.py modified: apps/tutor/api/v1/__init__.py modified: apps/tutor/api/v1/assignment.py modified: apps/tutor/api/v1/discussion.py modified: apps/tutor/api/v1/exam.py modified: apps/tutor/apps.py modified: apps/tutor/decorator.py modified: apps/tutor/migrations/0001_initial.py modified: apps/tutor/models.py modified: apps/warehouse/apps.py modified: apps/warehouse/migrations/0001_initial.py modified: conftest.py modified: locale/en/LC_MESSAGES/django.po modified: locale/ko/LC_MESSAGES/django.mo modified: locale/ko/LC_MESSAGES/django.po modified: minima/api.py modified: minima/settings.py modified: pyproject.toml modified: uv.lock Changes not staged for commit: modified: ../web/biome.json modified: ../web/package-lock.json modified: ../web/package.json modified: ../web/src/api/index.ts modified: ../web/src/api/sdk.gen.ts modified: ../web/src/api/types.gen.ts modified: ../web/src/api/valibot.gen.ts modified: ../web/src/config.ts modified: ../web/src/locale/en/translation.json modified: ../web/src/locale/ko/translation.json modified: ../web/src/main.tsx modified: ../web/src/routeTree.gen.ts deleted: ../web/src/routes/(auth)/-ActivationLink.tsx deleted: ../web/src/routes/(auth)/-LoginLink.tsx deleted: ../web/src/routes/(auth)/-SSOButtons.tsx deleted: ../web/src/routes/(auth)/activate.tsx deleted: ../web/src/routes/(auth)/join.tsx deleted: ../web/src/routes/(auth)/login.tsx deleted: ../web/src/routes/(auth)/password-change.tsx deleted: ../web/src/routes/(auth)/route.tsx deleted: ../web/src/routes/(public)/survey/$id.tsx deleted: ../web/src/routes/(public)/survey/-SurveyForm.tsx modified: ../web/src/routes/-SitePolicy.tsx modified: ../web/src/routes/-protected.tsx modified: ../web/src/routes/__root.tsx deleted: ../web/src/routes/account/-account/AccountButton.tsx deleted: ../web/src/routes/account/-account/logout.ts deleted: ../web/src/routes/account/-device.ts deleted: ../web/src/routes/account/-profile/AvatarEdit.tsx deleted: ../web/src/routes/account/-profile/OtpSetup.tsx deleted: ../web/src/routes/account/-store.ts deleted: ../web/src/routes/account/device.tsx deleted: ../web/src/routes/account/email-change.tsx deleted: ../web/src/routes/account/group.tsx deleted: ../web/src/routes/account/link.tsx deleted: ../web/src/routes/account/profile.tsx deleted: ../web/src/routes/account/route.tsx modified: ../web/src/routes/index.tsx modified: ../web/src/routes/student/(dashboard)/inquiry.tsx modified: ../web/src/routes/student/(dashboard)/learning.tsx modified: ../web/src/routes/student/-shared/CertificateAwardList.tsx modified: ../web/src/routes/student/-shared/Inquiry.tsx modified: ../web/src/routes/student/-shared/Notification.tsx modified: ../web/src/routes/student/-shared/OtpVerification.tsx modified: ../web/src/routes/student/-shared/thread/CommentEditor.tsx modified: ../web/src/routes/student/-shared/thread/Thread.tsx modified: ../web/src/routes/student/assignment/-session/Submission.tsx modified: ../web/src/routes/student/course/-session/Outline.tsx modified: ../web/src/routes/student/discussion/-session/Thread.tsx modified: ../web/src/routes/student/media/-media/Subtitle.tsx modified: ../web/src/routes/student/route.tsx modified: ../web/src/routes/studio/-assignment/Assignment.tsx modified: ../web/src/routes/studio/-course/Course.tsx modified: ../web/src/routes/studio/-discussion/Discussion.tsx modified: ../web/src/routes/studio/-exam/Exam.tsx modified: ../web/src/routes/studio/-media/Media.tsx modified: ../web/src/routes/studio/-quiz/Quiz.tsx modified: ../web/src/routes/studio/-survey/Survey.tsx modified: ../web/src/routes/studio/route.tsx modified: ../web/src/routes/tutor/$app.$id.appeal.tsx modified: ../web/src/routes/tutor/-tutor/Breadcrumb.tsx modified: ../web/src/routes/tutor/route.tsx modified: ../web/src/shared/error/NotFound.tsx Untracked files: ../web/mkdir ../web/src/router.tsx ../web/src/routes/auth/ ../web/src/routes/desk/ ../web/src/routes/preview/ ../web/src/routes/public/ ../web/src/routes/student/(account)/ ../web/src/shared/PreviewBanner.tsx --- core/.gitignore | 1 + core/Dockerfile.search | 5 +- core/apps/account/admin.py | 2 +- core/apps/account/api/middleware.py | 42 +- core/apps/account/api/schema.py | 1 + core/apps/account/apps.py | 1 - .../management/commands/create_roles.py | 5 +- core/apps/account/migrations/0001_initial.py | 2 +- core/apps/account/models.py | 42 +- core/apps/assignment/admin.py | 2 +- core/apps/assignment/api/v1.py | 9 +- .../assignment/migrations/0001_initial.py | 10 +- core/apps/assignment/models.py | 13 +- .../assignment/tests/test_assignment_api.py | 5 - .../apps/assistant/migrations/0001_initial.py | 2 +- core/apps/common/apps.py | 1 - core/apps/common/error.py | 6 +- core/apps/common/models.py | 5 +- core/apps/common/policy.py | 9 + core/apps/common/util.py | 20 +- core/apps/competency/apps.py | 1 - .../competency/migrations/0001_initial.py | 2 +- core/apps/content/api/test_content_api.py | 5 - core/apps/content/api/v1.py | 12 +- core/apps/content/apps.py | 1 - core/apps/content/migrations/0001_initial.py | 18 +- core/apps/content/models.py | 41 +- core/apps/course/api/v1.py | 7 +- core/apps/course/apps.py | 1 - core/apps/course/migrations/0001_initial.py | 10 +- core/apps/course/models.py | 6 +- core/apps/course/tests/test_course_api.py | 5 - core/apps/desk/__init__.py | 0 core/apps/desk/admin.py | 3 + core/apps/desk/api/v1/__init__.py | 8 + core/apps/desk/api/v1/account.py | 27 + core/apps/desk/apps.py | 7 + core/apps/desk/migrations/__init__.py | 0 core/apps/desk/models.py | 3 + core/apps/discussion/api/v1.py | 9 +- .../discussion/migrations/0001_initial.py | 10 +- core/apps/discussion/models.py | 13 +- .../discussion/tests/test_discussion_api.py | 5 - core/apps/exam/api/v1.py | 9 +- core/apps/exam/apps.py | 1 - core/apps/exam/migrations/0001_initial.py | 10 +- core/apps/exam/models.py | 13 +- core/apps/exam/tests/test_exam_api.py | 5 - core/apps/learning/api/access_control.py | 33 +- core/apps/learning/apps.py | 1 - core/apps/learning/migrations/0001_initial.py | 2 +- core/apps/learning/tasks.py | 79 --- core/apps/operation/apps.py | 1 - .../apps/operation/migrations/0001_initial.py | 2 +- core/apps/partner/admin.py | 2 +- core/apps/partner/apps.py | 1 - .../commands/create_platform_partner.py | 3 +- core/apps/partner/migrations/0001_initial.py | 12 +- core/apps/partner/models.py | 11 +- core/apps/partner/tests/factories.py | 2 + core/apps/preview/__init__.py | 0 core/apps/preview/admin.py | 9 + core/apps/preview/api/v1.py | 54 ++ core/apps/preview/apps.py | 7 + core/apps/preview/migrations/0001_initial.py | 30 + core/apps/preview/migrations/__init__.py | 0 core/apps/preview/models.py | 16 + core/apps/preview/tasks.py | 18 + core/apps/quiz/api/v1.py | 9 +- core/apps/quiz/apps.py | 1 - core/apps/quiz/migrations/0001_initial.py | 10 +- core/apps/quiz/models.py | 5 +- core/apps/quiz/tests/test_quiz_api.py | 5 - core/apps/sso/apps.py | 1 - core/apps/sso/migrations/0001_initial.py | 2 +- core/apps/sso/models.py | 5 +- core/apps/sso/tests/test_sso_api.py | 2 +- core/apps/store/apps.py | 1 - core/apps/store/migrations/0001_initial.py | 2 +- core/apps/studio/api/v1/__init__.py | 5 - core/apps/studio/api/v1/assignment.py | 16 +- core/apps/studio/api/v1/course.py | 23 +- core/apps/studio/api/v1/discussion.py | 14 +- core/apps/studio/api/v1/exam.py | 16 +- core/apps/studio/api/v1/media.py | 12 +- core/apps/studio/api/v1/quiz.py | 16 +- core/apps/studio/api/v1/survey.py | 12 +- core/apps/studio/apps.py | 2 + core/apps/studio/decorator.py | 15 - core/apps/studio/migrations/0001_initial.py | 2 +- core/apps/survey/api/v1.py | 9 +- core/apps/survey/apps.py | 1 - core/apps/survey/migrations/0001_initial.py | 10 +- core/apps/survey/models.py | 6 +- core/apps/survey/tests/test_survey_api.py | 5 - core/apps/tracking/apps.py | 1 - core/apps/tracking/middleware.py | 2 +- core/apps/tracking/migrations/0001_initial.py | 2 +- core/apps/tutor/api/v1/__init__.py | 5 - core/apps/tutor/api/v1/assignment.py | 6 +- core/apps/tutor/api/v1/discussion.py | 5 +- core/apps/tutor/api/v1/exam.py | 5 +- core/apps/tutor/apps.py | 1 - core/apps/tutor/decorator.py | 13 - core/apps/tutor/migrations/0001_initial.py | 6 +- core/apps/tutor/models.py | 2 +- core/apps/warehouse/apps.py | 1 - .../apps/warehouse/migrations/0001_initial.py | 2 +- core/conftest.py | 5 + core/locale/en/LC_MESSAGES/django.po | 552 ++++++++--------- core/locale/ko/LC_MESSAGES/django.mo | Bin 48069 -> 48328 bytes core/locale/ko/LC_MESSAGES/django.po | 558 +++++++++--------- core/minima/api.py | 14 +- core/minima/settings.py | 26 +- core/pyproject.toml | 4 +- core/uv.lock | 82 +-- 116 files changed, 1071 insertions(+), 1126 deletions(-) create mode 100644 core/apps/common/policy.py create mode 100644 core/apps/desk/__init__.py create mode 100644 core/apps/desk/admin.py create mode 100644 core/apps/desk/api/v1/__init__.py create mode 100644 core/apps/desk/api/v1/account.py create mode 100644 core/apps/desk/apps.py create mode 100644 core/apps/desk/migrations/__init__.py create mode 100644 core/apps/desk/models.py delete mode 100644 core/apps/learning/tasks.py create mode 100644 core/apps/preview/__init__.py create mode 100644 core/apps/preview/admin.py create mode 100644 core/apps/preview/api/v1.py create mode 100644 core/apps/preview/apps.py create mode 100644 core/apps/preview/migrations/0001_initial.py create mode 100644 core/apps/preview/migrations/__init__.py create mode 100644 core/apps/preview/models.py create mode 100644 core/apps/preview/tasks.py diff --git a/core/.gitignore b/core/.gitignore index 2cb8f3c..bfad4e3 100644 --- a/core/.gitignore +++ b/core/.gitignore @@ -9,3 +9,4 @@ .ruff_cache .venv .cache +__pycache__ diff --git a/core/Dockerfile.search b/core/Dockerfile.search index 48edcf7..9ef50bf 100644 --- a/core/Dockerfile.search +++ b/core/Dockerfile.search @@ -1,4 +1,4 @@ -FROM opensearchproject/opensearch:3.2.0 +FROM opensearchproject/opensearch:3 RUN /usr/share/opensearch/bin/opensearch-plugin install analysis-nori @@ -14,4 +14,5 @@ RUN /usr/share/opensearch/bin/opensearch-plugin remove opensearch-skills && \ /usr/share/opensearch/bin/opensearch-plugin remove opensearch-ltr && \ /usr/share/opensearch/bin/opensearch-plugin remove opensearch-notifications && \ /usr/share/opensearch/bin/opensearch-plugin remove opensearch-notifications-core && \ - /usr/share/opensearch/bin/opensearch-plugin remove opensearch-reports-scheduler + /usr/share/opensearch/bin/opensearch-plugin remove opensearch-reports-scheduler && \ + /usr/share/opensearch/bin/opensearch-plugin remove opensearch-security diff --git a/core/apps/account/admin.py b/core/apps/account/admin.py index 8dcae6b..cff0a29 100644 --- a/core/apps/account/admin.py +++ b/core/apps/account/admin.py @@ -46,7 +46,7 @@ class OtpLogInline(ReadOnlyTabularInline[OtpLog]): inlines = (UserEventInline, TokenInline, OtpLogInline) - list_filter = (("is_staff", BooleanRadioFilter), ("is_superuser", BooleanRadioFilter)) + list_filter = (("is_superuser", BooleanRadioFilter),) actions_submit_line = ["create_temp_password"] diff --git a/core/apps/account/api/middleware.py b/core/apps/account/api/middleware.py index 8c5b92b..a8169a7 100644 --- a/core/apps/account/api/middleware.py +++ b/core/apps/account/api/middleware.py @@ -6,7 +6,16 @@ from jwt.exceptions import InvalidTokenError from apps.account.models import BlacklistedToken, auth_cookie_options -from apps.common.util import HttpRequest, decode_token, encode_token +from apps.common.error import ErrorCode +from apps.common.policy import PlatformRealm +from apps.common.util import HttpRequest, decode_token, encode_token, get_realm + + +def check_realm(request: HttpRequest): + realm = get_realm(request) + if realm != settings.PLATFORM_STUDENT_REALM: + if realm not in request.roles and realm not in request.realms: + raise ValueError(ErrorCode.INVALID_REALM) @async_only_middleware @@ -14,6 +23,19 @@ def cookie_auth_middleware(get_response): async def middleware(request: HttpRequest): request.auth = "" request.roles = [] + request.realms = [] + + if get_realm(request) == PlatformRealm.PREVIEW: + try: + access_token = request.COOKIES.get(settings.ACCESS_TOKEN_NAME) + if access_token: + decoded = decode_token(access_token) + request.auth = decoded.get("sub") + request.roles = decoded.get("roles", []) + request.realms = decoded.get("realms", []) + except InvalidTokenError: + pass + return await get_response(request) try: refresh_token = request.COOKIES.get(settings.REFRESH_TOKEN_NAME) @@ -24,8 +46,10 @@ async def middleware(request: HttpRequest): if access_token: try: decoded = decode_token(access_token) - request.auth = decoded.get("sub") request.roles = decoded.get("roles", []) + request.realms = decoded.get("realms", []) + check_realm(request) + request.auth = decoded.get("sub") return await get_response(request) except InvalidTokenError: pass @@ -36,14 +60,22 @@ async def middleware(request: HttpRequest): decoded = decode_token(refresh_token) user_id = decoded.get("sub") roles = decoded.get("roles", []) - request.auth = user_id + realms = decoded.get("realms", []) request.roles = roles + request.realms = realms + check_realm(request) + request.auth = user_id max_age = settings.ACCESS_TOKEN_EXPIRE_SECONDS expires = int(time()) + max_age - access_token = encode_token({"sub": user_id, "exp": expires, "type": "access", "roles": roles}) + access_token = encode_token({ + "sub": user_id, + "exp": expires, + "type": "access", + "roles": roles, + "realms": realms, + }) options = auth_cookie_options() - response = await get_response(request) response.set_cookie(key=settings.ACCESS_TOKEN_NAME, value=access_token, max_age=max_age, **options) return response diff --git a/core/apps/account/api/schema.py b/core/apps/account/api/schema.py index 4474cd3..93a737d 100644 --- a/core/apps/account/api/schema.py +++ b/core/apps/account/api/schema.py @@ -35,6 +35,7 @@ class UserSchema(TimeStampedMixinSchema): token_expires: datetime | None agreement_required: bool | None roles: list[str] + realms: list[str] @staticmethod def resolve_phone(obj): diff --git a/core/apps/account/apps.py b/core/apps/account/apps.py index 25d971c..3177b09 100644 --- a/core/apps/account/apps.py +++ b/core/apps/account/apps.py @@ -3,6 +3,5 @@ class AccountConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" name = "apps.account" verbose_name = _("Account") diff --git a/core/apps/account/management/commands/create_roles.py b/core/apps/account/management/commands/create_roles.py index 025431c..ca69d3b 100644 --- a/core/apps/account/management/commands/create_roles.py +++ b/core/apps/account/management/commands/create_roles.py @@ -4,15 +4,14 @@ from django.core.management.base import BaseCommand from apps.account.models import User +from apps.common.policy import PlatformRealm class Command(BaseCommand): help = "Create default roles (groups)" def handle(self, *args, **options): - roles = ["editor", "tutor", "partner_staff"] - - groups = [Group(name=role_name) for role_name in roles] + groups = [Group(name=role_name) for role_name in PlatformRealm] Group.objects.bulk_create(groups, ignore_conflicts=True) super_user = User.objects.get(email=os.environ.get("DJANGO_SUPERUSER_EMAIL") or "admin@example.com") diff --git a/core/apps/account/migrations/0001_initial.py b/core/apps/account/migrations/0001_initial.py index 2455a36..fef2c82 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-11 17:58 +# Generated by Django 6.0.3 on 2026-03-15 03:05 import apps.common.storage import apps.common.util diff --git a/core/apps/account/models.py b/core/apps/account/models.py index b78bcc2..10bb01f 100644 --- a/core/apps/account/models.py +++ b/core/apps/account/models.py @@ -37,6 +37,7 @@ Model, OneToOneField, Q, + QuerySet, TextField, Value, ) @@ -54,7 +55,7 @@ from apps.common.error import ErrorCode from apps.common.models import TimeStampedMixin, TuidMixin -from apps.common.util import HttpRequest, OtpTokenDict, TokenDict, decode_token, encode_token +from apps.common.util import HttpRequest, OtpTokenDict, TokenDict, decode_token, encode_token, get_realm log = logging.getLogger(__name__) @@ -137,10 +138,14 @@ class Meta(TuidMixin.Meta, TimeStampedMixin.Meta): indexes = [Index(fields=["name"]), Index(fields=["nickname"])] if TYPE_CHECKING: + from apps.partner.models import Member + pgh_event_model: type[Model] otp_enabled: "datetime | None" # annotated token_expires: "datetime | None" # annotated pk: str + members: "QuerySet[Member]" + agreement_required: bool def __str__(self): return f"{self.name} <{self.email}>" @@ -186,19 +191,38 @@ async def token_login( raise ValueError(ErrorCode.INVALID_PASSWORD) roles = [g.name async for g in self.groups.all()] + realms = [m.group.partner.realm async for m in self.members.select_related("group__partner").all()] + + # escalate realm as authentication + realm = get_realm(request) + if realm != settings.PLATFORM_STUDENT_REALM: + if realm not in roles and realm not in realms: + raise ValueError(ErrorCode.INVALID_REALM) # access token options = auth_cookie_options() max_age = settings.ACCESS_TOKEN_EXPIRE_SECONDS - access_payload: TokenDict = {"sub": self.pk, "exp": int(time()) + max_age, "type": "access", "roles": roles} + access_payload: TokenDict = { + "sub": self.pk, + "exp": int(time()) + max_age, + "type": "access", + "roles": roles, + "realms": realms, + } access_token = encode_token(access_payload) - response.set_cookie(key="access_token", value=access_token, max_age=max_age, **options) + response.set_cookie(key=settings.ACCESS_TOKEN_NAME, value=access_token, max_age=max_age, **options) # refresh token max_age = settings.REFRESH_TOKEN_EXPIRE_SECONDS - refresh_payload: TokenDict = {"sub": self.pk, "exp": int(time()) + max_age, "type": "refresh", "roles": roles} + refresh_payload: TokenDict = { + "sub": self.pk, + "exp": int(time()) + max_age, + "type": "refresh", + "roles": roles, + "realms": realms, + } refresh_token = encode_token(refresh_payload) - response.set_cookie(key="refresh_token", value=refresh_token, max_age=max_age, **options) + response.set_cookie(key=settings.REFRESH_TOKEN_NAME, value=refresh_token, max_age=max_age, **options) # save last login time now = timezone.now() @@ -262,7 +286,13 @@ async def get_user(cls, *, is_active: bool | None = None, annotate: bool = False ) ) ), - roles=ArrayAgg("groups__name", filter=Q(groups__isnull=False), default=Value([])), + roles=ArrayAgg("groups__name", filter=Q(groups__isnull=False), default=Value([]), distinct=True), + realms=ArrayAgg( + "members__group__partner__realm", + filter=Q(members__group__partner__realm__isnull=False), + default=Value([]), + distinct=True, + ), ) if annotate else cls.objects diff --git a/core/apps/assignment/admin.py b/core/apps/assignment/admin.py index b808e7f..6aec9b5 100644 --- a/core/apps/assignment/admin.py +++ b/core/apps/assignment/admin.py @@ -157,7 +157,7 @@ def grade(self, request: HttpRequest, obj: Grade): async_to_sync(grade.grade)(grader_id=cast(str, request.user.pk) if request.user else None) def has_grade_permission(self, request, object_id: str | int): - return request.user.is_staff + return request.user.is_superuser def formfield_for_dbfield(self, db_field, request, **kwargs): if db_field.name == "earned_details": diff --git a/core/apps/assignment/api/v1.py b/core/apps/assignment/api/v1.py index ecdf96f..ff2a3a7 100644 --- a/core/apps/assignment/api/v1.py +++ b/core/apps/assignment/api/v1.py @@ -14,7 +14,7 @@ from apps.assignment.models import Assignment, Attempt from apps.common.schema import FileSizeValidator, FileTypeValidator from apps.common.util import HttpRequest -from apps.learning.api.access_control import access_date, access_realm, active_context +from apps.learning.api.access_control import access_date, active_context router = Router(by_alias=True) @@ -30,15 +30,10 @@ async def get_session(request: HttpRequest, id: str): @router.post("/{id}/attempt", response=AssignmentAttemptSchema) @active_context() -@access_realm() @access_date("assignment", "assignment") async def start_attempt(request: HttpRequest, id: str): return await Attempt.start( - assignment_id=id, - learner_id=request.auth, - lock=request.access_date["end"], - context=request.active_context, - realm=request.access_realm, + assignment_id=id, learner_id=request.auth, lock=request.access_date["end"], context=request.active_context ) diff --git a/core/apps/assignment/migrations/0001_initial.py b/core/apps/assignment/migrations/0001_initial.py index 24188de..17ddc1d 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-11 17:59 +# Generated by Django 6.0.3 on 2026-03-15 03:05 import apps.common.util import django.contrib.postgres.fields @@ -155,7 +155,6 @@ class Migration(migrations.Migration): ('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')), - ('realm', models.CharField(choices=[('student', 'Student'), ('studio', 'Studio'), ('tutor', 'Tutor')], db_index=True, default='student', max_length=30, verbose_name='Realm')), ('retry', models.PositiveSmallIntegerField(default=0, verbose_name='Retry')), ('assignment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='assignment.assignment', verbose_name='Assignment')), ('learner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Learner')), @@ -301,7 +300,6 @@ class Migration(migrations.Migration): ('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')), - ('realm', models.CharField(choices=[('student', 'Student'), ('studio', 'Studio'), ('tutor', 'Tutor')], default='student', max_length=30, verbose_name='Realm')), ('retry', models.PositiveSmallIntegerField(default=0, verbose_name='Retry')), ('assignment', models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', related_query_name='+', to='assignment.assignment', verbose_name='Assignment')), ('learner', 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='Learner')), @@ -541,15 +539,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", "lock", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "question_id", "realm", "retry", "started") VALUES (NEW."active", NEW."assignment_id", NEW."context", NEW."id", NEW."learner_id", NEW."lock", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."question_id", NEW."realm", NEW."retry", NEW."started"); RETURN NULL;', hash='b498980eb2901b39b965e058d6a2e805f46299aa', 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", "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", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."question_id", NEW."retry", NEW."started"); RETURN NULL;', hash='26969b0c4a89b3122fb8837aebc475650b0039db', 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", "lock", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "question_id", "realm", "retry", "started") VALUES (NEW."active", NEW."assignment_id", NEW."context", NEW."id", NEW."learner_id", NEW."lock", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."question_id", NEW."realm", NEW."retry", NEW."started"); RETURN NULL;', hash='df61893fe2d59cfae2feb96a3442b1543f3d0882', 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", "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", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."question_id", NEW."retry", NEW."started"); RETURN NULL;', hash='c1144b2abb0bbe0f1701a58f2eeac5e0022032a4', 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", "lock", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "question_id", "realm", "retry", "started") VALUES (OLD."active", OLD."assignment_id", OLD."context", OLD."id", OLD."learner_id", OLD."lock", _pgh_attach_context(), NOW(), \'delete\', OLD."id", OLD."question_id", OLD."realm", OLD."retry", OLD."started"); RETURN NULL;', hash='28078ff75da3c34a469ddee7e2bdd7af74c25283', 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", "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", _pgh_attach_context(), NOW(), \'delete\', OLD."id", OLD."question_id", OLD."retry", OLD."started"); RETURN NULL;', hash='0247890c339b8142505d786839d31cdc813fae4e', 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 3fd7832..a497ff9 100644 --- a/core/apps/assignment/models.py +++ b/core/apps/assignment/models.py @@ -39,15 +39,7 @@ from apps.assignment.trigger import attempt_retry_count from apps.common.error import ErrorCode from apps.common.models import AttemptMixin, GradeFieldMixin, GradeWorkflowMixin, LearningObjectMixin, TimeStampedMixin -from apps.common.util import ( - AccessDate, - GradingDate, - LearningSessionStep, - OtpTokenDict, - RealmChoices, - ScoreStatsDict, - get_score_stats, -) +from apps.common.util import AccessDate, GradingDate, LearningSessionStep, OtpTokenDict, ScoreStatsDict, get_score_stats from apps.operation.models import Appeal, AttachmentMixin, HonorCode, MessageType, user_message_created User = get_user_model() @@ -288,7 +280,7 @@ class Meta: submission: "Submission" @classmethod - async def start(cls, *, assignment_id: str, learner_id: str, lock: datetime, context: str, realm: RealmChoices): + async def start(cls, *, assignment_id: str, learner_id: str, lock: datetime, context: str): assignment = await Assignment.objects.aget(id=assignment_id) if assignment.verification_required: @@ -307,7 +299,6 @@ async def start(cls, *, assignment_id: str, learner_id: str, lock: datetime, con active=True, started=timezone.now() + timedelta(seconds=1), question=question, - realm=realm, ) except IntegrityError: raise ValueError(ErrorCode.ATTEMPT_ALREADY_STARTED) diff --git a/core/apps/assignment/tests/test_assignment_api.py b/core/apps/assignment/tests/test_assignment_api.py index 2f28639..8b4b43a 100644 --- a/core/apps/assignment/tests/test_assignment_api.py +++ b/core/apps/assignment/tests/test_assignment_api.py @@ -10,11 +10,6 @@ from conftest import AdminUser -@pytest.fixture -def client(): - return Client(SERVER_NAME="student.testserver") - - @pytest.mark.e2e @pytest.mark.django_db def test_assignment_flow(client: Client, mimesis: Generic, admin_user: AdminUser): diff --git a/core/apps/assistant/migrations/0001_initial.py b/core/apps/assistant/migrations/0001_initial.py index b750455..a8e06ad 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-11 17:59 +# Generated by Django 6.0.3 on 2026-03-15 03:05 import django.db.models.deletion import pgtrigger.compiler diff --git a/core/apps/common/apps.py b/core/apps/common/apps.py index 37d47c7..95a88fc 100644 --- a/core/apps/common/apps.py +++ b/core/apps/common/apps.py @@ -11,7 +11,6 @@ class CommonConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" name = "apps.common" verbose_name = _("Apps") diff --git a/core/apps/common/error.py b/core/apps/common/error.py index 8cff6b5..77561d9 100644 --- a/core/apps/common/error.py +++ b/core/apps/common/error.py @@ -24,9 +24,7 @@ class ErrorCode: EMPTY_REQUEST = "EMPTY_REQUEST" FILE_TOO_LARGE = "FILE_TOO_LARGE" HONOR_CODE_NAME_ALREADY_EXISTS = "HONOR_CODE_NAME_ALREADY_EXISTS" - IN_USE = "IN_USE" INSUFFICIENT_CONTENT = "INSUFFICIENT_CONTENT" - INVALID_ACCESS_REALM = "INVALID_ACCESS_REALM" INVALID_FILE_SIZE = "INVALID_FILE_SIZE" INVALID_FILE_TYPE = "INVALID_FILE_TYPE" INVALID_OTP_CODE = "INVALID_OTP_CODE" @@ -34,14 +32,18 @@ class ErrorCode: INVALID_OTP_TOKEN = "INVALID_OTP_TOKEN" INVALID_PASSWORD = "INVALID_PASSWORD" INVALID_PHONE = "INVALID_PHONE" + INVALID_REALM = "INVALID_REALM" INVALID_REDIRECT_URL = "INVALID_REDIRECT_URL" INVALID_SSO_PROVIDER = "INVALID_SSO_PROVIDER" INVALID_SSO_STATE = "INVALID_SSO_STATE" INVALID_SSO_TOKEN = "INVALID_SSO_TOKEN" INVALID_TOKEN = "INVALID_TOKEN" + IN_USE = "IN_USE" MANDATORY_POLICY_NOT_ACCEPTED = "MANDATORY_POLICY_NOT_ACCEPTED" MAX_ATTEMPTS_REACHED = "MAX_ATTEMPTS_REACHED" + NOT_ALLOWED_REALM = "NOT_ALLOWED_REALM" NOT_FOUND = "NOT_FOUND" + NOT_LOGGED_IN = "NOT_LOGGED_IN" NOT_QUALIFIED_FOR_CERTIFICATE = "NOT_QUALIFIED_FOR_CERTIFICATE" NO_ANSWERS = "NO_ANSWERS" NO_OTP_SETUP = "NO_OTP_SETUP" diff --git a/core/apps/common/models.py b/core/apps/common/models.py index 9737d25..27192ae 100644 --- a/core/apps/common/models.py +++ b/core/apps/common/models.py @@ -20,7 +20,7 @@ from django.utils import timezone from django.utils.translation import gettext_lazy as _ -from apps.common.util import AccessDate, GradingDate, RealmChoices, track_fields, tuid +from apps.common.util import AccessDate, GradingDate, track_fields, tuid log = logging.getLogger(__name__) @@ -153,9 +153,6 @@ class AttemptMixin(Model): lock = DateTimeField(_("Lock")) active = BooleanField(_("Active"), default=True) context = CharField(_("Context Key"), max_length=255, blank=True, default="") - realm = CharField( - _("Realm"), max_length=30, choices=RealmChoices.choices, default=RealmChoices.STUDENT, db_index=True - ) class Meta: abstract = True diff --git a/core/apps/common/policy.py b/core/apps/common/policy.py new file mode 100644 index 0000000..5ddc89c --- /dev/null +++ b/core/apps/common/policy.py @@ -0,0 +1,9 @@ +from django.db.models import TextChoices +from django.utils.translation import gettext_lazy as _ + + +class PlatformRealm(TextChoices): + STUDIO = "studio", _("Studio") + TUTOR = "tutor", _("Tutor") + DESK = "desk", _("Desk") + PREVIEW = "preview", _("Preview") diff --git a/core/apps/common/util.py b/core/apps/common/util.py index 3e14b95..7cd0fe0 100644 --- a/core/apps/common/util.py +++ b/core/apps/common/util.py @@ -12,11 +12,10 @@ from django.contrib.postgres.forms import SimpleArrayField from django.core.exceptions import ImproperlyConfigured from django.db import connection -from django.db.models import Avg, Count, FloatField, Max, Min, Model, TextChoices, Value +from django.db.models import Avg, Count, FloatField, Max, Min, Model, Value from django.db.models.functions import Coalesce from django.db.models.query import QuerySet from django.http.request import HttpRequest as DjangoHttpRequest -from django.utils.translation import gettext_lazy as _ from ninja.pagination import AsyncPaginationBase from ninja.params import functions @@ -58,6 +57,7 @@ class TokenDict(TypedDict): type: str to: NotRequired[str] roles: NotRequired[list[str]] + realms: NotRequired[list[str]] def encode_token(payload: TokenDict, algorithm: str = "HS256"): @@ -96,28 +96,16 @@ class GradingDate(TypedDict): confirm_due: datetime -class RealmChoices(TextChoices): - STUDENT = "student", _("Student") - STUDIO = "studio", _("Studio") - TUTOR = "tutor", _("Tutor") - - @classmethod - def non_student_realms(cls): - return [cls.STUDIO, cls.TUTOR] - - class HttpRequest(DjangoHttpRequest): auth: str # from auth middleware roles: list[str] # from auth middleware + realms: list[str] # from auth middleware access_date: "AccessDate" # set by access_date decorator active_context: str # set by active_context decorator - access_realm: RealmChoices # set by access_realm decorator def get_realm(request): - host = request.get_host() - subdomain = host.split(".")[0] - return subdomain + return request.get_host().split(".")[0] class OtpTokenDict(TypedDict): diff --git a/core/apps/competency/apps.py b/core/apps/competency/apps.py index e8ac25e..ae14f41 100644 --- a/core/apps/competency/apps.py +++ b/core/apps/competency/apps.py @@ -3,6 +3,5 @@ class CompetencyConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" name = "apps.competency" verbose_name = _("Competency") diff --git a/core/apps/competency/migrations/0001_initial.py b/core/apps/competency/migrations/0001_initial.py index 287b2f3..d56a518 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-11 17:59 +# Generated by Django 6.0.3 on 2026-03-15 03:05 import django.contrib.postgres.fields import django.db.models.deletion diff --git a/core/apps/content/api/test_content_api.py b/core/apps/content/api/test_content_api.py index 3618094..afd921f 100644 --- a/core/apps/content/api/test_content_api.py +++ b/core/apps/content/api/test_content_api.py @@ -15,11 +15,6 @@ WATCH = "1" -@pytest.fixture -def client(): - return Client(SERVER_NAME="student.testserver") - - @pytest.mark.e2e @pytest.mark.django_db def test_media_flow(client: Client, admin_user: AdminUser, mimesis: Generic): diff --git a/core/apps/content/api/v1.py b/core/apps/content/api/v1.py index 9741527..4eaf331 100644 --- a/core/apps/content/api/v1.py +++ b/core/apps/content/api/v1.py @@ -22,7 +22,7 @@ ) from apps.content.documents import get_search_suggestion from apps.content.models import Media, Note, Subtitle, Watch -from apps.learning.api.access_control import access_date, access_realm, active_context +from apps.learning.api.access_control import access_date, active_context router = Router(by_alias=True) @@ -57,14 +57,12 @@ async def delete_media_watch(request: HttpRequest, id: str): @router.post("/media/{id}/watch") @active_context() -@access_realm() @access_date("content", "media") async def update_media_watch(request: HttpRequest, id: str, data: WatchInSchema): await Watch.update_media_watch( media_id=id, user_id=request.auth, context=request.active_context, - realm=request.access_realm, last_position=data.last_position, watch_bits=data.watch_bits, ) @@ -81,7 +79,6 @@ async def get_media_note(request: HttpRequest, id: str): @router.post("/media/{id}/note", response=NoteSchema) @active_context() -@access_realm() @access_date("content", "media") async def save_media_note( request: HttpRequest, @@ -93,12 +90,7 @@ async def save_media_note( ], ): return await Note.upsert( - media_id=id, - user_id=request.auth, - context=request.active_context, - realm=request.access_realm, - note=data.note, - files=files, + media_id=id, user_id=request.auth, context=request.active_context, note=data.note, files=files ) diff --git a/core/apps/content/apps.py b/core/apps/content/apps.py index 25cf9f9..7a8f4d8 100644 --- a/core/apps/content/apps.py +++ b/core/apps/content/apps.py @@ -3,6 +3,5 @@ class ContentConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" name = "apps.content" verbose_name = _("Content") diff --git a/core/apps/content/migrations/0001_initial.py b/core/apps/content/migrations/0001_initial.py index 11b0c01..13b6206 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-11 17:59 +# Generated by Django 6.0.3 on 2026-03-15 03:05 import apps.common.util import apps.content.models @@ -124,7 +124,6 @@ class Migration(migrations.Migration): ('modified', models.DateTimeField(auto_now=True, db_index=True, verbose_name='Modified')), ('note', models.TextField(verbose_name='Note')), ('context', models.CharField(blank=True, default='', max_length=255, verbose_name='Context Key')), - ('realm', models.CharField(choices=[('student', 'Student'), ('studio', 'Studio'), ('tutor', 'Tutor')], db_index=True, default='student', max_length=30, verbose_name='Realm')), ('attachments', models.ManyToManyField(blank=True, related_name='+', to='operation.attachment', verbose_name='Attachments')), ('media', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='content.media', verbose_name='Media')), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User')), @@ -146,7 +145,6 @@ class Migration(migrations.Migration): ('modified', models.DateTimeField(auto_now=True, verbose_name='Modified')), ('note', models.TextField(verbose_name='Note')), ('context', models.CharField(blank=True, default='', max_length=255, verbose_name='Context Key')), - ('realm', models.CharField(choices=[('student', 'Student'), ('studio', 'Studio'), ('tutor', 'Tutor')], default='student', max_length=30, verbose_name='Realm')), ('media', models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', related_query_name='+', to='content.media', verbose_name='Media')), ('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='content.note')), @@ -233,7 +231,6 @@ class Migration(migrations.Migration): ('rate', models.FloatField(verbose_name='Watch Rate')), ('passed', models.BooleanField(default=False, verbose_name='Passed')), ('context', models.CharField(blank=True, default='', max_length=255, verbose_name='Context Key')), - ('realm', models.CharField(choices=[('student', 'Student'), ('studio', 'Studio'), ('tutor', 'Tutor')], db_index=True, default='student', max_length=30, verbose_name='Realm')), ('media', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='content.media', verbose_name='Media')), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User')), ], @@ -256,7 +253,6 @@ class Migration(migrations.Migration): ('rate', models.FloatField(verbose_name='Watch Rate')), ('passed', models.BooleanField(default=False, verbose_name='Passed')), ('context', models.CharField(blank=True, default='', max_length=255, verbose_name='Context Key')), - ('realm', models.CharField(choices=[('student', 'Student'), ('studio', 'Studio'), ('tutor', 'Tutor')], default='student', max_length=30, verbose_name='Realm')), ('media', models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', related_query_name='+', to='content.media', verbose_name='Media')), ('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='content.watch')), @@ -308,15 +304,15 @@ class Migration(migrations.Migration): ), pgtrigger.migrations.AddTrigger( model_name='note', - trigger=pgtrigger.compiler.Trigger(name='insert_insert', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "content_noteevent" ("context", "created", "id", "media_id", "modified", "note", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "realm", "user_id") VALUES (NEW."context", NEW."created", NEW."id", NEW."media_id", NEW."modified", NEW."note", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."realm", NEW."user_id"); RETURN NULL;', hash='2aba7f62260382e19db189c1de789ac428c89d38', operation='INSERT', pgid='pgtrigger_insert_insert_70fb2', table='content_note', when='AFTER')), + trigger=pgtrigger.compiler.Trigger(name='insert_insert', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "content_noteevent" ("context", "created", "id", "media_id", "modified", "note", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "user_id") VALUES (NEW."context", NEW."created", NEW."id", NEW."media_id", NEW."modified", NEW."note", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."user_id"); RETURN NULL;', hash='701c1114f89c3af4c77ee5998c7fd203ca55d079', operation='INSERT', pgid='pgtrigger_insert_insert_70fb2', table='content_note', when='AFTER')), ), pgtrigger.migrations.AddTrigger( model_name='note', - trigger=pgtrigger.compiler.Trigger(name='update_update', sql=pgtrigger.compiler.UpsertTriggerSql(condition='WHEN (OLD."context" IS DISTINCT FROM (NEW."context") OR OLD."id" IS DISTINCT FROM (NEW."id") OR OLD."media_id" IS DISTINCT FROM (NEW."media_id") OR OLD."note" IS DISTINCT FROM (NEW."note") OR OLD."realm" IS DISTINCT FROM (NEW."realm") OR OLD."user_id" IS DISTINCT FROM (NEW."user_id"))', func='INSERT INTO "content_noteevent" ("context", "created", "id", "media_id", "modified", "note", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "realm", "user_id") VALUES (NEW."context", NEW."created", NEW."id", NEW."media_id", NEW."modified", NEW."note", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."realm", NEW."user_id"); RETURN NULL;', hash='841a6e1e414b27dd4485f9f2204a1b10316a1ced', operation='UPDATE', pgid='pgtrigger_update_update_2f73b', table='content_note', when='AFTER')), + trigger=pgtrigger.compiler.Trigger(name='update_update', sql=pgtrigger.compiler.UpsertTriggerSql(condition='WHEN (OLD."context" IS DISTINCT FROM (NEW."context") OR OLD."id" IS DISTINCT FROM (NEW."id") OR OLD."media_id" IS DISTINCT FROM (NEW."media_id") OR OLD."note" IS DISTINCT FROM (NEW."note") OR OLD."user_id" IS DISTINCT FROM (NEW."user_id"))', func='INSERT INTO "content_noteevent" ("context", "created", "id", "media_id", "modified", "note", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "user_id") VALUES (NEW."context", NEW."created", NEW."id", NEW."media_id", NEW."modified", NEW."note", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."user_id"); RETURN NULL;', hash='95a380717ca94e988ec88da822157c191c8c8496', operation='UPDATE', pgid='pgtrigger_update_update_2f73b', table='content_note', when='AFTER')), ), pgtrigger.migrations.AddTrigger( model_name='note', - trigger=pgtrigger.compiler.Trigger(name='delete_delete', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "content_noteevent" ("context", "created", "id", "media_id", "modified", "note", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "realm", "user_id") VALUES (OLD."context", OLD."created", OLD."id", OLD."media_id", OLD."modified", OLD."note", _pgh_attach_context(), NOW(), \'delete\', OLD."id", OLD."realm", OLD."user_id"); RETURN NULL;', hash='d6903bba459142f88294248a93c43c320ba4d99b', operation='DELETE', pgid='pgtrigger_delete_delete_71435', table='content_note', when='AFTER')), + trigger=pgtrigger.compiler.Trigger(name='delete_delete', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "content_noteevent" ("context", "created", "id", "media_id", "modified", "note", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "user_id") VALUES (OLD."context", OLD."created", OLD."id", OLD."media_id", OLD."modified", OLD."note", _pgh_attach_context(), NOW(), \'delete\', OLD."id", OLD."user_id"); RETURN NULL;', hash='875b4e9ef156d9b838065df5a7ac1c00e2bdca47', operation='DELETE', pgid='pgtrigger_delete_delete_71435', table='content_note', when='AFTER')), ), pgtrigger.migrations.AddTrigger( model_name='noteevent', @@ -368,15 +364,15 @@ class Migration(migrations.Migration): ), pgtrigger.migrations.AddTrigger( model_name='watch', - trigger=pgtrigger.compiler.Trigger(name='insert_insert', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "content_watchevent" ("context", "created", "id", "media_id", "modified", "passed", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rate", "realm", "user_id", "watch_bits") VALUES (NEW."context", NEW."created", NEW."id", NEW."media_id", NEW."modified", NEW."passed", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rate", NEW."realm", NEW."user_id", NEW."watch_bits"); RETURN NULL;', hash='0afb07ffe65311dc3414a74ca04dfbeedb1811b0', operation='INSERT', pgid='pgtrigger_insert_insert_ed900', table='content_watch', when='AFTER')), + trigger=pgtrigger.compiler.Trigger(name='insert_insert', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "content_watchevent" ("context", "created", "id", "media_id", "modified", "passed", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rate", "user_id", "watch_bits") VALUES (NEW."context", NEW."created", NEW."id", NEW."media_id", NEW."modified", NEW."passed", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rate", NEW."user_id", NEW."watch_bits"); RETURN NULL;', hash='9a74ef4b9b80aabf6ff3afe1204fa1a7b6ddb663', operation='INSERT', pgid='pgtrigger_insert_insert_ed900', table='content_watch', when='AFTER')), ), pgtrigger.migrations.AddTrigger( model_name='watch', - trigger=pgtrigger.compiler.Trigger(name='update_update', sql=pgtrigger.compiler.UpsertTriggerSql(condition='WHEN (OLD."context" IS DISTINCT FROM (NEW."context") OR OLD."id" IS DISTINCT FROM (NEW."id") OR OLD."media_id" IS DISTINCT FROM (NEW."media_id") OR OLD."passed" IS DISTINCT FROM (NEW."passed") OR OLD."rate" IS DISTINCT FROM (NEW."rate") OR OLD."realm" IS DISTINCT FROM (NEW."realm") OR OLD."user_id" IS DISTINCT FROM (NEW."user_id") OR OLD."watch_bits" IS DISTINCT FROM (NEW."watch_bits"))', func='INSERT INTO "content_watchevent" ("context", "created", "id", "media_id", "modified", "passed", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rate", "realm", "user_id", "watch_bits") VALUES (NEW."context", NEW."created", NEW."id", NEW."media_id", NEW."modified", NEW."passed", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rate", NEW."realm", NEW."user_id", NEW."watch_bits"); RETURN NULL;', hash='007eea42566e78895be6979a771abe639c0acfdf', operation='UPDATE', pgid='pgtrigger_update_update_7cf93', table='content_watch', when='AFTER')), + trigger=pgtrigger.compiler.Trigger(name='update_update', sql=pgtrigger.compiler.UpsertTriggerSql(condition='WHEN (OLD."context" IS DISTINCT FROM (NEW."context") OR OLD."id" IS DISTINCT FROM (NEW."id") OR OLD."media_id" IS DISTINCT FROM (NEW."media_id") OR OLD."passed" IS DISTINCT FROM (NEW."passed") OR OLD."rate" IS DISTINCT FROM (NEW."rate") OR OLD."user_id" IS DISTINCT FROM (NEW."user_id") OR OLD."watch_bits" IS DISTINCT FROM (NEW."watch_bits"))', func='INSERT INTO "content_watchevent" ("context", "created", "id", "media_id", "modified", "passed", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rate", "user_id", "watch_bits") VALUES (NEW."context", NEW."created", NEW."id", NEW."media_id", NEW."modified", NEW."passed", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rate", NEW."user_id", NEW."watch_bits"); RETURN NULL;', hash='a4c30e724130f7aa54b905577ad348c2a08f8236', operation='UPDATE', pgid='pgtrigger_update_update_7cf93', table='content_watch', when='AFTER')), ), pgtrigger.migrations.AddTrigger( model_name='watch', - trigger=pgtrigger.compiler.Trigger(name='delete_delete', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "content_watchevent" ("context", "created", "id", "media_id", "modified", "passed", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rate", "realm", "user_id", "watch_bits") VALUES (OLD."context", OLD."created", OLD."id", OLD."media_id", OLD."modified", OLD."passed", _pgh_attach_context(), NOW(), \'delete\', OLD."id", OLD."rate", OLD."realm", OLD."user_id", OLD."watch_bits"); RETURN NULL;', hash='5699961d84a6a73e9caa612a90d51cda3ae3e468', operation='DELETE', pgid='pgtrigger_delete_delete_38707', table='content_watch', when='AFTER')), + trigger=pgtrigger.compiler.Trigger(name='delete_delete', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "content_watchevent" ("context", "created", "id", "media_id", "modified", "passed", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rate", "user_id", "watch_bits") VALUES (OLD."context", OLD."created", OLD."id", OLD."media_id", OLD."modified", OLD."passed", _pgh_attach_context(), NOW(), \'delete\', OLD."id", OLD."rate", OLD."user_id", OLD."watch_bits"); RETURN NULL;', hash='f09956ec81d4895fa287bcb389053f4a8e034364', operation='DELETE', pgid='pgtrigger_delete_delete_38707', table='content_watch', when='AFTER')), ), pgtrigger.migrations.AddTrigger( model_name='watchevent', diff --git a/core/apps/content/models.py b/core/apps/content/models.py index 7bdd260..52ff8e8 100644 --- a/core/apps/content/models.py +++ b/core/apps/content/models.py @@ -52,7 +52,7 @@ from apps.common.error import ErrorCode from apps.common.models import LearningObjectMixin, TimeStampedMixin -from apps.common.util import PaginationDict, RealmChoices, offset_paginate +from apps.common.util import PaginationDict, offset_paginate from apps.operation.models import AttachmentMixin from apps.quiz.models import Quiz @@ -312,9 +312,6 @@ class Watch(TimeStampedMixin): rate = FloatField(_("Watch Rate")) passed = BooleanField(_("Passed"), default=False) context = CharField(_("Context Key"), max_length=255, blank=True, default="") - realm = CharField( - _("Realm"), max_length=30, choices=RealmChoices.choices, default=RealmChoices.STUDENT, db_index=True - ) class Meta(TimeStampedMixin.Meta): constraints = [UniqueConstraint(fields=["user", "media", "context"], name="content_watch_us_me_co_ke_uniq")] @@ -331,22 +328,12 @@ class Meta(TimeStampedMixin.Meta): @classmethod async def update_media_watch( - cls, - *, - media_id: str, - user_id: str, - context: str, - realm: RealmChoices, - last_position: float, - watch_bits: str | None, + cls, *, media_id: str, user_id: str, context: str, last_position: float, watch_bits: str | None ): def _execute_update(): if watch_bits is None: cls.objects.update_or_create( - media_id=media_id, - user_id=user_id, - context=context, - defaults={"realm": realm, "last_position": last_position}, + media_id=media_id, user_id=user_id, context=context, defaults={"last_position": last_position} ) return @@ -389,13 +376,12 @@ def _execute_update(): SELECT bits, BIT_COUNT(bits) AS bit_count FROM merged ) INSERT INTO {table} ( - user_id, media_id, context, realm, watch_bits, rate, passed, last_position, created, modified + user_id, media_id, context, watch_bits, rate, passed, last_position, created, modified ) VALUES ( %(user_id)s, %(media_id)s, %(context)s, - %(realm)s, (SELECT bits FROM input_bits), %(rate)s, %(rate)s >= (SELECT passing_point FROM media_info), @@ -408,7 +394,6 @@ def _execute_update(): watch_bits = (SELECT bits FROM final), rate = (SELECT bit_count FROM final) * 100.0 / %(bit_length)s, passed = ((SELECT bit_count FROM final) * 100.0 / %(bit_length)s) >= (SELECT passing_point FROM media_info), - realm = CASE WHEN {table}.realm = '{RealmChoices.STUDENT}' THEN {table}.realm ELSE EXCLUDED.realm END, last_position = EXCLUDED.last_position, modified = NOW(); """ @@ -417,7 +402,6 @@ def _execute_update(): "media_id": media_id, "user_id": user_id, "context": context, - "realm": realm, "last_position": last_position, # "watch_bits": watch_bits, # large data, to avoid repeating leteral data, use f-string "rate": bit_count * 100.0 / bit_length, @@ -483,9 +467,6 @@ class Note(TimeStampedMixin, AttachmentMixin): media = ForeignKey(Media, CASCADE, verbose_name=_("Media")) note = TextField(verbose_name=_("Note")) context = CharField(_("Context Key"), max_length=255, blank=True, default="") - realm = CharField( - _("Realm"), max_length=30, choices=RealmChoices.choices, default=RealmChoices.STUDENT, db_index=True - ) class Meta(TimeStampedMixin.Meta, AttachmentMixin.Meta): constraints = [UniqueConstraint(fields=["user", "media", "context"], name="content_note_us_me_co_keuniq")] @@ -500,17 +481,9 @@ def cleaned_note(self): return self.update_attachment_urls(content=self.note) @classmethod - async def upsert( - cls, *, user_id: str, media_id: str, context: str, realm: RealmChoices, note: str, files: Sequence[File] | None - ): - note_, created = await cls.objects.aget_or_create( - user_id=user_id, media_id=media_id, context=context, defaults={"note": note, "realm": realm} + async def upsert(cls, *, user_id: str, media_id: str, context: str, note: str, files: Sequence[File] | None): + note_, created = await cls.objects.aupdate_or_create( + user_id=user_id, media_id=media_id, context=context, defaults={"note": note} ) - if not created: - note_.note = note - # realm promotion rule: student realm is never overwritten - if note_.realm != RealmChoices.STUDENT: - note_.realm = realm - await note_.asave(update_fields=["note", "realm"]) await note_.update_attachments(files=files, owner_id=user_id, content=note_.note) return note_ diff --git a/core/apps/course/api/v1.py b/core/apps/course/api/v1.py index c5fe465..ba2ffe4 100644 --- a/core/apps/course/api/v1.py +++ b/core/apps/course/api/v1.py @@ -10,7 +10,7 @@ CourseSessionSchema, ) from apps.course.models import Course, Engagement -from apps.learning.api.access_control import access_date, access_realm +from apps.learning.api.access_control import access_date router = Router(by_alias=True) @@ -22,12 +22,9 @@ async def get_session(request: HttpRequest, id: str): @router.post("/{id}/engage", response=CourseEngagementSchema) -@access_realm() @access_date("course", "course") async def start_engagement(request: HttpRequest, id: str): - return await Engagement.start( - course_id=id, learner_id=request.auth, lock=request.access_date["end"], realm=request.access_realm - ) + return await Engagement.start(course_id=id, learner_id=request.auth, lock=request.access_date["end"]) @router.get("/{id}/detail", response=CourseDetailSchema) diff --git a/core/apps/course/apps.py b/core/apps/course/apps.py index ca692ef..9fc4ebf 100644 --- a/core/apps/course/apps.py +++ b/core/apps/course/apps.py @@ -3,6 +3,5 @@ class CourseConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" name = "apps.course" verbose_name = _("Course") diff --git a/core/apps/course/migrations/0001_initial.py b/core/apps/course/migrations/0001_initial.py index bb72940..119d97b 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-11 17:59 +# Generated by Django 6.0.3 on 2026-03-15 03:05 import apps.common.util import django.contrib.postgres.fields @@ -310,7 +310,6 @@ class Migration(migrations.Migration): ('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')), - ('realm', models.CharField(choices=[('student', 'Student'), ('studio', 'Studio'), ('tutor', 'Tutor')], db_index=True, default='student', max_length=30, verbose_name='Realm')), ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='course.course', verbose_name='Course')), ('learner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Learner')), ], @@ -331,7 +330,6 @@ class Migration(migrations.Migration): ('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')), - ('realm', models.CharField(choices=[('student', 'Student'), ('studio', 'Studio'), ('tutor', 'Tutor')], default='student', max_length=30, verbose_name='Realm')), ('course', models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', related_query_name='+', to='course.course', verbose_name='Course')), ('learner', 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='Learner')), ('pgh_context', models.ForeignKey(db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='pghistory.context')), @@ -677,15 +675,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", "lock", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "realm", "started") VALUES (NEW."active", NEW."context", NEW."course_id", NEW."id", NEW."learner_id", NEW."lock", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."realm", NEW."started"); RETURN NULL;', hash='1e9e34003967f775b713d52dcd183f50f780aca0', 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", "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", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."started"); RETURN NULL;', hash='a946def0f7912dd64d19254807e13bb3caf9e4a6', 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", "lock", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "realm", "started") VALUES (NEW."active", NEW."context", NEW."course_id", NEW."id", NEW."learner_id", NEW."lock", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."realm", NEW."started"); RETURN NULL;', hash='2ccc4386f50b22851ea503494e995f65fed6d24f', 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", "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", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."started"); RETURN NULL;', hash='99470364a7a04979125c2c62b46ae834a28e0f0a', 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", "lock", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "realm", "started") VALUES (OLD."active", OLD."context", OLD."course_id", OLD."id", OLD."learner_id", OLD."lock", _pgh_attach_context(), NOW(), \'delete\', OLD."id", OLD."realm", OLD."started"); RETURN NULL;', hash='c20d45621859efe71ec12863517037b6c916d06c', 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", "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", _pgh_attach_context(), NOW(), \'delete\', OLD."id", OLD."started"); RETURN NULL;', hash='150c5f87ff3e439af39ebde90dcc4870453095b4', operation='DELETE', pgid='pgtrigger_delete_delete_39407', table='course_engagement', when='AFTER')), ), pgtrigger.migrations.AddTrigger( model_name='engagementevent', diff --git a/core/apps/course/models.py b/core/apps/course/models.py index c5a6da5..dfa1f56 100644 --- a/core/apps/course/models.py +++ b/core/apps/course/models.py @@ -46,7 +46,7 @@ from apps.assignment.models import Grade as AssignmentGrade from apps.common.error import ErrorCode from apps.common.models import AttemptMixin, GradeWorkflowMixin, LearningObjectMixin, OrderableMixin, TimeStampedMixin -from apps.common.util import AccessDate, GradingDate, OtpTokenDict, RealmChoices, issue_active_context, track_fields +from apps.common.util import AccessDate, GradingDate, 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 @@ -591,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, lock: datetime, realm: RealmChoices): + async def start(cls, *, course_id: str, learner_id: str, lock: datetime): course = await Course.objects.aget(id=course_id) if course.verification_required: @@ -600,7 +600,7 @@ async def start(cls, *, course_id: str, learner_id: str, lock: datetime, realm: try: engagement = await Engagement.objects.acreate( - course_id=course_id, learner_id=learner_id, active=True, lock=lock, realm=realm + course_id=course_id, learner_id=learner_id, active=True, lock=lock ) except IntegrityError: raise ValueError(ErrorCode.ALREADY_EXISTS) diff --git a/core/apps/course/tests/test_course_api.py b/core/apps/course/tests/test_course_api.py index 9349367..877d3ce 100644 --- a/core/apps/course/tests/test_course_api.py +++ b/core/apps/course/tests/test_course_api.py @@ -12,11 +12,6 @@ from conftest import AdminUser -@pytest.fixture -def client(): - return Client(SERVER_NAME="student.testserver") - - @pytest.mark.e2e @pytest.mark.django_db def test_course_flow(client: Client, mimesis: Generic, admin_user: AdminUser): diff --git a/core/apps/desk/__init__.py b/core/apps/desk/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/desk/admin.py b/core/apps/desk/admin.py new file mode 100644 index 0000000..4185d36 --- /dev/null +++ b/core/apps/desk/admin.py @@ -0,0 +1,3 @@ +# from django.contrib import admin + +# Register your models here. diff --git a/core/apps/desk/api/v1/__init__.py b/core/apps/desk/api/v1/__init__.py new file mode 100644 index 0000000..889b841 --- /dev/null +++ b/core/apps/desk/api/v1/__init__.py @@ -0,0 +1,8 @@ +from ninja import Router + +from .account import router as account_router + +router = Router(by_alias=True) + + +router.add_router("", account_router, tags=["desk"]) diff --git a/core/apps/desk/api/v1/account.py b/core/apps/desk/api/v1/account.py new file mode 100644 index 0000000..2e3946c --- /dev/null +++ b/core/apps/desk/api/v1/account.py @@ -0,0 +1,27 @@ +from ninja import Router +from ninja.pagination import paginate + +from apps.account.models import User +from apps.common.schema import Schema +from apps.common.util import Pagination + +router = Router(by_alias=True) + + +class DeskUserSpec(Schema): + id: str + email: str + name: str + avatar: str | None + nickname: str + phone: str + brith_date: str + language: str + is_active: bool + is_superuser: bool + + +@router.get("/account/user", response=list[DeskUserSpec]) +@paginate(Pagination) +async def get_users(request): + return User.objects.order_by("-created") diff --git a/core/apps/desk/apps.py b/core/apps/desk/apps.py new file mode 100644 index 0000000..f83a4fb --- /dev/null +++ b/core/apps/desk/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class DeskConfig(AppConfig): + name = "apps.desk" + verbose_name = _("Desk") diff --git a/core/apps/desk/migrations/__init__.py b/core/apps/desk/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/desk/models.py b/core/apps/desk/models.py new file mode 100644 index 0000000..0b4331b --- /dev/null +++ b/core/apps/desk/models.py @@ -0,0 +1,3 @@ +# from django.db import models + +# Create your models here. diff --git a/core/apps/discussion/api/v1.py b/core/apps/discussion/api/v1.py index 7d9a18c..326591c 100644 --- a/core/apps/discussion/api/v1.py +++ b/core/apps/discussion/api/v1.py @@ -18,7 +18,7 @@ DiscussionSessionSchema, ) from apps.discussion.models import Attempt, Discussion, Post -from apps.learning.api.access_control import access_date, access_realm, active_context +from apps.learning.api.access_control import access_date, active_context router = Router(by_alias=True) @@ -34,15 +34,10 @@ async def get_session(request: HttpRequest, id: str): @router.post("/{id}/attempt", response=DiscussionAttemptSchema) @active_context() -@access_realm() @access_date("discussion", "discussion") async def start_attempt(request: HttpRequest, id: str): return await Attempt.start( - discussion_id=id, - learner_id=request.auth, - lock=request.access_date["end"], - context=request.active_context, - realm=request.access_realm, + discussion_id=id, learner_id=request.auth, lock=request.access_date["end"], context=request.active_context ) diff --git a/core/apps/discussion/migrations/0001_initial.py b/core/apps/discussion/migrations/0001_initial.py index 63b5853..a4bbfef 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-11 17:59 +# Generated by Django 6.0.3 on 2026-03-15 03:05 import apps.common.util import django.db.models.deletion @@ -57,7 +57,6 @@ class Migration(migrations.Migration): ('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')), - ('realm', models.CharField(choices=[('student', 'Student'), ('studio', 'Studio'), ('tutor', 'Tutor')], db_index=True, default='student', max_length=30, verbose_name='Realm')), ('retry', models.PositiveSmallIntegerField(default=0, verbose_name='Retry')), ('learner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Learner')), ('discussion', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='discussion.discussion', verbose_name='Discussion')), @@ -183,7 +182,6 @@ class Migration(migrations.Migration): ('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')), - ('realm', models.CharField(choices=[('student', 'Student'), ('studio', 'Studio'), ('tutor', 'Tutor')], default='student', max_length=30, verbose_name='Realm')), ('retry', models.PositiveSmallIntegerField(default=0, verbose_name='Retry')), ('learner', 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='Learner')), ('pgh_context', models.ForeignKey(db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='pghistory.context')), @@ -341,15 +339,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", "lock", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "question_id", "realm", "retry", "started") VALUES (NEW."active", NEW."context", NEW."discussion_id", NEW."id", NEW."learner_id", NEW."lock", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."question_id", NEW."realm", NEW."retry", NEW."started"); RETURN NULL;', hash='0aa7188c8bdbb759f576a8273dbe6d7a5d4616fe', 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", "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", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."question_id", NEW."retry", NEW."started"); RETURN NULL;', hash='d32424969041e4dd2042ce4cbddcd048ebbdf492', 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", "lock", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "question_id", "realm", "retry", "started") VALUES (NEW."active", NEW."context", NEW."discussion_id", NEW."id", NEW."learner_id", NEW."lock", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."question_id", NEW."realm", NEW."retry", NEW."started"); RETURN NULL;', hash='129c041a9d9c2192eaf911a35ce2b009a3f89d27', 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", "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", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."question_id", NEW."retry", NEW."started"); RETURN NULL;', hash='ffea2b2fde525940760d3603c1a0f4d44247d7e2', 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", "lock", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "question_id", "realm", "retry", "started") VALUES (OLD."active", OLD."context", OLD."discussion_id", OLD."id", OLD."learner_id", OLD."lock", _pgh_attach_context(), NOW(), \'delete\', OLD."id", OLD."question_id", OLD."realm", OLD."retry", OLD."started"); RETURN NULL;', hash='9a0d8755fae074bc6741c924ab1cdf43756ae8db', 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", "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", _pgh_attach_context(), NOW(), \'delete\', OLD."id", OLD."question_id", OLD."retry", OLD."started"); RETURN NULL;', hash='5868e8ada20d69c22b5182b1d003c2fbbc3a34ca', 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 0eb01be..6e4865f 100644 --- a/core/apps/discussion/models.py +++ b/core/apps/discussion/models.py @@ -36,15 +36,7 @@ from apps.account.models import OtpLog from apps.common.error import ErrorCode from apps.common.models import AttemptMixin, GradeFieldMixin, GradeWorkflowMixin, LearningObjectMixin, TimeStampedMixin -from apps.common.util import ( - AccessDate, - GradingDate, - LearningSessionStep, - OtpTokenDict, - RealmChoices, - ScoreStatsDict, - get_score_stats, -) +from apps.common.util import AccessDate, GradingDate, LearningSessionStep, OtpTokenDict, ScoreStatsDict, get_score_stats from apps.discussion.trigger import attempt_retry_count from apps.operation.models import Appeal, AttachmentMixin, HonorCode, MessageType, user_message_created @@ -223,7 +215,7 @@ class Meta: max_attempts: int # annotated @classmethod - async def start(cls, *, discussion_id: str, learner_id: str, lock: datetime, context: str, realm: RealmChoices): + async def start(cls, *, discussion_id: str, learner_id: str, lock: datetime, context: str): discussion = await Discussion.objects.aget(id=discussion_id) if discussion.verification_required: @@ -242,7 +234,6 @@ async def start(cls, *, discussion_id: str, learner_id: str, lock: datetime, con active=True, started=timezone.now() + timedelta(seconds=1), question=question, - realm=realm, ) except IntegrityError: raise ValueError(ErrorCode.ATTEMPT_ALREADY_STARTED) diff --git a/core/apps/discussion/tests/test_discussion_api.py b/core/apps/discussion/tests/test_discussion_api.py index c392fab..7c454d4 100644 --- a/core/apps/discussion/tests/test_discussion_api.py +++ b/core/apps/discussion/tests/test_discussion_api.py @@ -8,11 +8,6 @@ from conftest import AdminUser -@pytest.fixture -def client(): - return Client(SERVER_NAME="student.testserver") - - @pytest.mark.e2e @pytest.mark.django_db def test_discussion_flow(client: Client, mimesis: Generic, admin_user: AdminUser): diff --git a/core/apps/exam/api/v1.py b/core/apps/exam/api/v1.py index 00a5ae9..0011a7c 100644 --- a/core/apps/exam/api/v1.py +++ b/core/apps/exam/api/v1.py @@ -4,7 +4,7 @@ from apps.common.util import HttpRequest from apps.exam.api.schema import ExamAttemptAnswersSchema, ExamAttemptSchema, ExamSessionSchema, ExamSubmissionSchema from apps.exam.models import Attempt, Exam -from apps.learning.api.access_control import access_date, access_realm, active_context +from apps.learning.api.access_control import access_date, active_context router = Router(by_alias=True) @@ -20,15 +20,10 @@ async def get_session(request: HttpRequest, id: str): @router.post("/{id}/attempt", response=ExamAttemptSchema) @active_context() -@access_realm() @access_date("exam", "exam") async def start_attempt(request: HttpRequest, id: str): return await Attempt.start( - exam_id=id, - learner_id=request.auth, - lock=request.access_date["end"], - context=request.active_context, - realm=request.access_realm, + exam_id=id, learner_id=request.auth, lock=request.access_date["end"], context=request.active_context ) diff --git a/core/apps/exam/apps.py b/core/apps/exam/apps.py index 8d24ca1..eb5067f 100644 --- a/core/apps/exam/apps.py +++ b/core/apps/exam/apps.py @@ -3,6 +3,5 @@ class ExamConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" name = "apps.exam" verbose_name = _("Exam") diff --git a/core/apps/exam/migrations/0001_initial.py b/core/apps/exam/migrations/0001_initial.py index 417316f..13584a2 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-11 17:59 +# Generated by Django 6.0.3 on 2026-03-15 03:05 import apps.common.util import django.contrib.postgres.fields @@ -29,7 +29,6 @@ class Migration(migrations.Migration): ('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')), - ('realm', models.CharField(choices=[('student', 'Student'), ('studio', 'Studio'), ('tutor', 'Tutor')], db_index=True, default='student', max_length=30, verbose_name='Realm')), ('retry', models.PositiveSmallIntegerField(default=0, verbose_name='Retry')), ('learner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Learner')), ], @@ -78,7 +77,6 @@ class Migration(migrations.Migration): ('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')), - ('realm', models.CharField(choices=[('student', 'Student'), ('studio', 'Studio'), ('tutor', 'Tutor')], default='student', max_length=30, verbose_name='Realm')), ('retry', models.PositiveSmallIntegerField(default=0, verbose_name='Retry')), ('learner', 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='Learner')), ('pgh_context', models.ForeignKey(db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='pghistory.context')), @@ -387,15 +385,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", "lock", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "realm", "retry", "started") VALUES (NEW."active", NEW."context", NEW."exam_id", NEW."id", NEW."learner_id", NEW."lock", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."realm", NEW."retry", NEW."started"); RETURN NULL;', hash='81e7988aa564d43196462cf14a474b17c58dc24c', 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", "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", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."retry", NEW."started"); RETURN NULL;', hash='dfad0d0c15c441fc4af2d2ad744bbbd52de344c2', 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", "lock", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "realm", "retry", "started") VALUES (NEW."active", NEW."context", NEW."exam_id", NEW."id", NEW."learner_id", NEW."lock", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."realm", NEW."retry", NEW."started"); RETURN NULL;', hash='4cbf887b9db9938c12a54c8241aa703a2443e724', 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", "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", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."retry", NEW."started"); RETURN NULL;', hash='4645795340fa1c0e2d7eae692014f5deb6e76fd0', 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", "lock", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "realm", "retry", "started") VALUES (OLD."active", OLD."context", OLD."exam_id", OLD."id", OLD."learner_id", OLD."lock", _pgh_attach_context(), NOW(), \'delete\', OLD."id", OLD."realm", OLD."retry", OLD."started"); RETURN NULL;', hash='453e47899fd45e23e393126a4319408298142dfa', 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", "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", _pgh_attach_context(), NOW(), \'delete\', OLD."id", OLD."retry", OLD."started"); RETURN NULL;', hash='d9df9c75008aa83af6031af15dc9ecf225fee0d2', 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 1537602..20a59f9 100644 --- a/core/apps/exam/models.py +++ b/core/apps/exam/models.py @@ -38,15 +38,7 @@ from apps.account.models import OtpLog from apps.common.error import ErrorCode from apps.common.models import AttemptMixin, GradeFieldMixin, GradeWorkflowMixin, LearningObjectMixin, TimeStampedMixin -from apps.common.util import ( - AccessDate, - GradingDate, - LearningSessionStep, - OtpTokenDict, - RealmChoices, - ScoreStatsDict, - get_score_stats, -) +from apps.common.util import AccessDate, GradingDate, LearningSessionStep, OtpTokenDict, ScoreStatsDict, get_score_stats from apps.exam.trigger import attempt_retry_count from apps.operation.models import Appeal, AttachmentMixin, HonorCode, MessageType, user_message_created @@ -282,7 +274,7 @@ def saved_answers(self): return self.tempanswer.answers @classmethod - async def start(cls, *, exam_id: str, learner_id: str, lock: datetime, context: str, realm: RealmChoices): + async def start(cls, *, exam_id: str, learner_id: str, lock: datetime, context: str): exam = await Exam.objects.prefetch_related("question_pool__questions").aget(id=exam_id) if exam.verification_required: @@ -300,7 +292,6 @@ async def start(cls, *, exam_id: str, learner_id: str, lock: datetime, context: context=context, active=True, started=timezone.now() + timedelta(seconds=1), - realm=realm, ) except IntegrityError: raise ValueError(ErrorCode.ATTEMPT_ALREADY_STARTED) diff --git a/core/apps/exam/tests/test_exam_api.py b/core/apps/exam/tests/test_exam_api.py index ca1c0cc..ef6f65f 100644 --- a/core/apps/exam/tests/test_exam_api.py +++ b/core/apps/exam/tests/test_exam_api.py @@ -12,11 +12,6 @@ from conftest import AdminUser -@pytest.fixture -def client(): - return Client(SERVER_NAME="student.testserver") - - @pytest.mark.e2e @pytest.mark.django_db def test_exam_flow(client: Client, mimesis: Generic, admin_user: AdminUser): diff --git a/core/apps/learning/api/access_control.py b/core/apps/learning/api/access_control.py index a7b41cd..70ecf89 100644 --- a/core/apps/learning/api/access_control.py +++ b/core/apps/learning/api/access_control.py @@ -1,16 +1,17 @@ import logging from datetime import timedelta from functools import wraps -from typing import cast from celery.exceptions import ImproperlyConfigured from django.utils import timezone from apps.common.error import ErrorCode -from apps.common.util import AccessDate, HttpRequest, RealmChoices, get_realm, openapi_query_param +from apps.common.policy import PlatformRealm +from apps.common.util import AccessDate, HttpRequest, get_realm, openapi_query_param from apps.content.models import Media, PublicAccessMedia from apps.course.models import Course from apps.learning.models import ENROLLABLE_MODEL_MAP, Enrollment +from apps.preview.models import PreviewUser from apps.quiz.models import Quiz from apps.tutor.models import TUTORING_MODEL_MAP, Allocation @@ -69,7 +70,7 @@ async def wrapper(request: HttpRequest, *args, **kwargs): realm = get_realm(request) # editor requires ownership - if realm == RealmChoices.STUDIO and "editor" in request.roles: + if realm == PlatformRealm.STUDIO: ContentModel = ENROLLABLE_MODEL_MAP.get((app_label, model)) if not ContentModel: raise ValueError(ErrorCode.UNKNOWN_CONTENT) @@ -77,8 +78,8 @@ async def wrapper(request: HttpRequest, *args, **kwargs): if await ContentModel.objects.filter(id=content_id, owner_id=user_id).aexists(): has_special_access = True - # tutor rquires allocations - elif realm == RealmChoices.TUTOR and "tutor" in request.roles: + # tutor rquires allocation + elif realm == PlatformRealm.TUTOR: ContentModel = TUTORING_MODEL_MAP.get((app_label, model)) if not ContentModel: raise ValueError(ErrorCode.UNKNOWN_CONTENT) @@ -86,6 +87,11 @@ async def wrapper(request: HttpRequest, *args, **kwargs): if await Allocation.objects.filter(tutor_id=user_id, active=True, content_id=content_id).aexists(): has_special_access = True + # preview requires preview worker + elif realm == PlatformRealm.PREVIEW: + if await PreviewUser.objects.filter(user_id=user_id).aexists(): + has_special_access = True + if has_special_access: # grant temporary access to tutor now = timezone.now() @@ -181,23 +187,6 @@ async def wrapper(request: HttpRequest, *args, **kwargs): return decorator -def access_realm(): - def decorator(func): - @wraps(func) - async def wrapper(request: HttpRequest, *args, **kwargs): - realm = get_realm(request) or RealmChoices.STUDENT - if realm not in RealmChoices: - raise ValueError(ErrorCode.INVALID_ACCESS_REALM) - - request.access_realm = cast(RealmChoices, realm) - - return await func(request, *args, **kwargs) - - return wrapper - - return decorator - - def _get_favorable_date(a: Enrollment | None, b: PublicAccessMedia | None) -> AccessDate: if a and b: return AccessDate(start=min(a.start, b.start), end=max(a.end, b.end), archive=max(a.archive, b.archive)) diff --git a/core/apps/learning/apps.py b/core/apps/learning/apps.py index c1ed74f..00aaeca 100644 --- a/core/apps/learning/apps.py +++ b/core/apps/learning/apps.py @@ -3,6 +3,5 @@ class LearningConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" name = "apps.learning" verbose_name = _("Learning") diff --git a/core/apps/learning/migrations/0001_initial.py b/core/apps/learning/migrations/0001_initial.py index 150dd3f..6678871 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-11 17:59 +# Generated by Django 6.0.3 on 2026-03-15 03:05 import django.db.models.deletion import django.utils.timezone diff --git a/core/apps/learning/tasks.py b/core/apps/learning/tasks.py deleted file mode 100644 index e741fb5..0000000 --- a/core/apps/learning/tasks.py +++ /dev/null @@ -1,79 +0,0 @@ -from datetime import timedelta - -from celery import shared_task -from django.db import connection -from django.utils import timezone - -from apps.assignment.models import Attempt as AssignmentAttempt -from apps.common.util import RealmChoices -from apps.content.models import Watch -from apps.course.models import Engagement as CourseEngagement -from apps.discussion.models import Attempt as DiscussionAttempt -from apps.exam.models import Attempt as ExamAttempt -from apps.operation.models import Appeal -from apps.quiz.models import Attempt as QuizAttempt -from apps.survey.models import Submission as SurveySubmission - -REALM_DATA_MODELS = [ - QuizAttempt, - SurveySubmission, - ExamAttempt, - AssignmentAttempt, - DiscussionAttempt, - CourseEngagement, - Watch, -] - -CLEANUP_THRESHOLD_HOURS = 1 - - -@shared_task() -def cleanup_testing_data(): - threshold = timezone.now() - timedelta(hours=CLEANUP_THRESHOLD_HOURS) - non_student_realms = tuple(RealmChoices.non_student_realms()) - deleted = {} - - assignment_attempt_table = AssignmentAttempt._meta.db_table - discussion_attempt_table = DiscussionAttempt._meta.db_table - exam_attempt_table = ExamAttempt._meta.db_table - exam_attempt_questions_table = ExamAttempt.questions.through._meta.db_table - - with connection.cursor() as cursor: - cursor.execute( - f""" - DELETE FROM operation_appeal - WHERE (question_type_id, question_id, learner_id) IN ( - SELECT ct.id, a.question_id, a.learner_id - FROM {assignment_attempt_table} a - CROSS JOIN (SELECT id FROM django_content_type WHERE app_label='assignment' AND model='question') ct - WHERE a.realm = ANY(%s) AND a.started <= %s - UNION - SELECT ct.id, a.question_id, a.learner_id - FROM {discussion_attempt_table} a - CROSS JOIN (SELECT id FROM django_content_type WHERE app_label='discussion' AND model='question') ct - WHERE a.realm = ANY(%s) AND a.started <= %s - UNION - SELECT ct.id, eq.question_id, a.learner_id - FROM {exam_attempt_table} a - JOIN {exam_attempt_questions_table} eq ON eq.attempt_id = a.id - CROSS JOIN (SELECT id FROM django_content_type WHERE app_label='exam' AND model='question') ct - WHERE a.realm = ANY(%s) AND a.started <= %s - ) - """, - [ - list(non_student_realms), - threshold, - list(non_student_realms), - threshold, - list(non_student_realms), - threshold, - ], - ) - deleted[Appeal._meta.model.__name__.lower()] = cursor.rowcount - - for M in REALM_DATA_MODELS: - time_field = "modified" if M is Watch else "started" - num, model_num = M.objects.filter(realm__in=non_student_realms, **{f"{time_field}__lte": threshold}).delete() - deleted[M.__name__.lower()] = num - - return deleted diff --git a/core/apps/operation/apps.py b/core/apps/operation/apps.py index 18c4698..e06fffd 100644 --- a/core/apps/operation/apps.py +++ b/core/apps/operation/apps.py @@ -3,6 +3,5 @@ class OperationConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" name = "apps.operation" verbose_name = _("Operation") diff --git a/core/apps/operation/migrations/0001_initial.py b/core/apps/operation/migrations/0001_initial.py index 008ff65..43bfab7 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-11 17:58 +# Generated by Django 6.0.3 on 2026-03-15 03:05 import django.contrib.postgres.fields import django.db.models.deletion diff --git a/core/apps/partner/admin.py b/core/apps/partner/admin.py index ef25f74..1683ef6 100644 --- a/core/apps/partner/admin.py +++ b/core/apps/partner/admin.py @@ -50,7 +50,7 @@ class MemberEventInline(ReadOnlyTabularInline[Member.pgh_event_model]): ("group__partner", RelatedDropdownFilter), ("group", RelatedDropdownFilter), ("team", FieldTextFilter), - ("cohortmember__cohort", RelatedDropdownFilter), + ("cohort_members__cohort", RelatedDropdownFilter), ] @admin.display(description=_("User"), boolean=True) diff --git a/core/apps/partner/apps.py b/core/apps/partner/apps.py index 92dc004..ff5fb6a 100644 --- a/core/apps/partner/apps.py +++ b/core/apps/partner/apps.py @@ -3,6 +3,5 @@ class PartnerConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" name = "apps.partner" verbose_name = _("Partner") diff --git a/core/apps/partner/management/commands/create_platform_partner.py b/core/apps/partner/management/commands/create_platform_partner.py index 62e3896..204834c 100644 --- a/core/apps/partner/management/commands/create_platform_partner.py +++ b/core/apps/partner/management/commands/create_platform_partner.py @@ -20,8 +20,9 @@ def handle(self, *args: object, **options: dict[str, object]): logo = ContentFile(f.read(), name="logo.png") partner, created = Partner.objects.get_or_create( - name=settings.PLATFORM_NAME, + realm=settings.PLATFORM_STUDENT_REALM, defaults={ + "name": settings.PLATFORM_NAME, "description": _("%s is a LMS for micro learning") % settings.PLATFORM_NAME, "phone": settings.PLATFORM_PHONE_NUMBER, "email": settings.DEFAULT_FROM_EMAIL, diff --git a/core/apps/partner/migrations/0001_initial.py b/core/apps/partner/migrations/0001_initial.py index b40cb5f..cbfac7c 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-11 17:58 +# Generated by Django 6.0.3 on 2026-03-15 03:05 import django.db.models.deletion import pgtrigger.compiler @@ -187,6 +187,7 @@ class Migration(migrations.Migration): ('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')), ('name', models.CharField(max_length=50, unique=True, verbose_name='Name')), + ('realm', models.CharField(max_length=50, unique=True, verbose_name='Realm')), ('description', models.TextField(blank=True, default='', verbose_name='Description')), ('phone', models.CharField(max_length=20, verbose_name='Phone')), ('email', models.EmailField(max_length=254, verbose_name='Email')), @@ -210,6 +211,7 @@ class Migration(migrations.Migration): ('created', models.DateTimeField(auto_now_add=True, verbose_name='Created')), ('modified', models.DateTimeField(auto_now=True, verbose_name='Modified')), ('name', models.CharField(max_length=50, verbose_name='Name')), + ('realm', models.CharField(max_length=50, verbose_name='Realm')), ('description', models.TextField(blank=True, default='', verbose_name='Description')), ('phone', models.CharField(max_length=20, verbose_name='Phone')), ('email', models.EmailField(max_length=254, verbose_name='Email')), @@ -311,7 +313,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='member', name='user', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='User'), + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='members', to=settings.AUTH_USER_MODEL, verbose_name='User'), ), migrations.AddField( model_name='cohortmemberevent', @@ -359,15 +361,15 @@ class Migration(migrations.Migration): ), pgtrigger.migrations.AddTrigger( model_name='partner', - trigger=pgtrigger.compiler.Trigger(name='insert_insert', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "partner_partnerevent" ("address", "created", "description", "email", "id", "logo", "modified", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "phone", "website") VALUES (NEW."address", NEW."created", NEW."description", NEW."email", NEW."id", NEW."logo", NEW."modified", NEW."name", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."phone", NEW."website"); RETURN NULL;', hash='0104aebe8a2aae8b2703d2831b29aa34f83c96a8', operation='INSERT', pgid='pgtrigger_insert_insert_b4de1', table='partner_partner', when='AFTER')), + trigger=pgtrigger.compiler.Trigger(name='insert_insert', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "partner_partnerevent" ("address", "created", "description", "email", "id", "logo", "modified", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "phone", "realm", "website") VALUES (NEW."address", NEW."created", NEW."description", NEW."email", NEW."id", NEW."logo", NEW."modified", NEW."name", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."phone", NEW."realm", NEW."website"); RETURN NULL;', hash='4c0a71195b9c2dbce8e1739ec7d92a1cbeb3034e', operation='INSERT', pgid='pgtrigger_insert_insert_b4de1', table='partner_partner', when='AFTER')), ), pgtrigger.migrations.AddTrigger( model_name='partner', - trigger=pgtrigger.compiler.Trigger(name='update_update', sql=pgtrigger.compiler.UpsertTriggerSql(condition='WHEN (OLD."address" IS DISTINCT FROM (NEW."address") OR OLD."description" IS DISTINCT FROM (NEW."description") OR OLD."email" IS DISTINCT FROM (NEW."email") OR OLD."id" IS DISTINCT FROM (NEW."id") OR OLD."logo" IS DISTINCT FROM (NEW."logo") OR OLD."name" IS DISTINCT FROM (NEW."name") OR OLD."phone" IS DISTINCT FROM (NEW."phone") OR OLD."website" IS DISTINCT FROM (NEW."website"))', func='INSERT INTO "partner_partnerevent" ("address", "created", "description", "email", "id", "logo", "modified", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "phone", "website") VALUES (NEW."address", NEW."created", NEW."description", NEW."email", NEW."id", NEW."logo", NEW."modified", NEW."name", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."phone", NEW."website"); RETURN NULL;', hash='0a6a0ff3c3a7f461b5b1259dace852f4679b7c60', operation='UPDATE', pgid='pgtrigger_update_update_240cd', table='partner_partner', when='AFTER')), + trigger=pgtrigger.compiler.Trigger(name='update_update', sql=pgtrigger.compiler.UpsertTriggerSql(condition='WHEN (OLD."address" IS DISTINCT FROM (NEW."address") OR OLD."description" IS DISTINCT FROM (NEW."description") OR OLD."email" IS DISTINCT FROM (NEW."email") OR OLD."id" IS DISTINCT FROM (NEW."id") OR OLD."logo" IS DISTINCT FROM (NEW."logo") OR OLD."name" IS DISTINCT FROM (NEW."name") OR OLD."phone" IS DISTINCT FROM (NEW."phone") OR OLD."realm" IS DISTINCT FROM (NEW."realm") OR OLD."website" IS DISTINCT FROM (NEW."website"))', func='INSERT INTO "partner_partnerevent" ("address", "created", "description", "email", "id", "logo", "modified", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "phone", "realm", "website") VALUES (NEW."address", NEW."created", NEW."description", NEW."email", NEW."id", NEW."logo", NEW."modified", NEW."name", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."phone", NEW."realm", NEW."website"); RETURN NULL;', hash='8cb4cd517866a795ad7e6c47c13f93be3260010b', operation='UPDATE', pgid='pgtrigger_update_update_240cd', table='partner_partner', when='AFTER')), ), pgtrigger.migrations.AddTrigger( model_name='partner', - trigger=pgtrigger.compiler.Trigger(name='delete_delete', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "partner_partnerevent" ("address", "created", "description", "email", "id", "logo", "modified", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "phone", "website") VALUES (OLD."address", OLD."created", OLD."description", OLD."email", OLD."id", OLD."logo", OLD."modified", OLD."name", _pgh_attach_context(), NOW(), \'delete\', OLD."id", OLD."phone", OLD."website"); RETURN NULL;', hash='40b59d4ae98810f8237de4075b598636fab239a6', operation='DELETE', pgid='pgtrigger_delete_delete_97aaa', table='partner_partner', when='AFTER')), + trigger=pgtrigger.compiler.Trigger(name='delete_delete', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "partner_partnerevent" ("address", "created", "description", "email", "id", "logo", "modified", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "phone", "realm", "website") VALUES (OLD."address", OLD."created", OLD."description", OLD."email", OLD."id", OLD."logo", OLD."modified", OLD."name", _pgh_attach_context(), NOW(), \'delete\', OLD."id", OLD."phone", OLD."realm", OLD."website"); RETURN NULL;', hash='1441238156b51a7e525d8060fca21748b0772316', operation='DELETE', pgid='pgtrigger_delete_delete_97aaa', table='partner_partner', when='AFTER')), ), migrations.AddField( model_name='partnerevent', diff --git a/core/apps/partner/models.py b/core/apps/partner/models.py index d6349d4..47d2429 100644 --- a/core/apps/partner/models.py +++ b/core/apps/partner/models.py @@ -27,6 +27,7 @@ from phonenumber_field.modelfields import PhoneNumberField from apps.common.models import TimeStampedMixin +from apps.common.policy import PlatformRealm from apps.common.util import track_fields from apps.operation.models import MessageType, user_message_created @@ -41,8 +42,9 @@ @pghistory.track() class Partner(TimeStampedMixin): name = CharField(_("Name"), max_length=50, unique=True) + realm = CharField(_("Realm"), max_length=50, unique=True) description = TextField(_("Description"), blank=True, default="") - phone = CharField(_("Phone"), max_length=20) # char field + phone = CharField(_("Phone"), max_length=20) # not PhoneNumberField email = EmailField(_("Email")) address = TextField(_("Address"), blank=True, default="") logo = ImageField(_("Logo"), null=True, blank=True) @@ -59,6 +61,11 @@ class Meta(TimeStampedMixin.Meta): def __str__(self): return self.name + def save(self, *args, **kwargs): + if self.realm in PlatformRealm: + raise ValueError(_("This realm is reserved.")) + super().save(*args, **kwargs) + @pghistory.track() class Group(TimeStampedMixin): @@ -93,7 +100,7 @@ class Member(TimeStampedMixin): job_title = CharField(_("Job Title"), blank=True, default="", max_length=50) employment_status = CharField(_("Employment Status"), blank=True, default="", max_length=50) employment_type = CharField(_("Employment Type"), blank=True, default="", max_length=50) - user = ForeignKey(User, SET_NULL, null=True, blank=True, verbose_name=_("User")) + user = ForeignKey(User, SET_NULL, related_name="members", null=True, blank=True, verbose_name=_("User")) class Meta(TimeStampedMixin.Meta): verbose_name = _("Member") diff --git a/core/apps/partner/tests/factories.py b/core/apps/partner/tests/factories.py index 45ed605..d3ab389 100644 --- a/core/apps/partner/tests/factories.py +++ b/core/apps/partner/tests/factories.py @@ -5,6 +5,7 @@ from factory.declarations import LazyFunction, SubFactory from factory.django import DjangoModelFactory from factory.helpers import post_generation +from mimesis import Locale from mimesis.plugins.factory import FactoryField from apps.account.models import User @@ -17,6 +18,7 @@ class PartnerFactory(DjangoModelFactory[Partner]): name = FactoryField("company") + realm = LazyFunction(lambda: f"{mimesis.Generic(Locale.DEFAULT).food.fruit()}-{tuid()[-4:]}") description = FactoryField("sentence") phone = FactoryField("phone_number") email = FactoryField("email") diff --git a/core/apps/preview/__init__.py b/core/apps/preview/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/preview/admin.py b/core/apps/preview/admin.py new file mode 100644 index 0000000..18a978d --- /dev/null +++ b/core/apps/preview/admin.py @@ -0,0 +1,9 @@ +from django.contrib import admin + +from apps.common.admin import HiddenModelAdmin +from apps.preview.models import PreviewUser + + +@admin.register(PreviewUser) +class PreviewUserAdmin(HiddenModelAdmin): + pass diff --git a/core/apps/preview/api/v1.py b/core/apps/preview/api/v1.py new file mode 100644 index 0000000..954dfd4 --- /dev/null +++ b/core/apps/preview/api/v1.py @@ -0,0 +1,54 @@ +import secrets + +from django.contrib.auth.hashers import make_password +from django.contrib.auth.models import Group +from django.core.cache import cache +from django.http import HttpResponse +from django.utils.translation import gettext as _ +from ninja import Router + +from apps.account.api.schema import UserSchema +from apps.account.models import User +from apps.common.error import ErrorCode +from apps.common.policy import PlatformRealm +from apps.common.util import HttpRequest +from apps.preview.models import PreviewUser + +router = Router(by_alias=True) + +_PREVIEW_OTT_TTL: int = 60 # 1 minute +_PREVIEW_OTT_PREFIX = "preview:ott:" + + +@router.post("/preview/session", response=str) +async def create_preview_session(request: HttpRequest): + if not any(r in request.roles for r in PlatformRealm): + raise ValueError(ErrorCode.PERMISSION_DENIED) + + ott = secrets.token_urlsafe(32) + await cache.aset(f"{_PREVIEW_OTT_PREFIX}{ott}", request.auth, timeout=_PREVIEW_OTT_TTL) + return ott + + +@router.get("/preview/exchange/{ott}", auth=None, response=UserSchema) +async def exchange_preview_session(request: HttpRequest, response: HttpResponse, ott: str): + ott_auth = await cache.aget(f"{_PREVIEW_OTT_PREFIX}{ott}") + if not ott_auth: + raise ValueError(ErrorCode.INVALID_TOKEN) + + await cache.adelete(f"{_PREVIEW_OTT_PREFIX}{ott}") + + preview_user, created = await User.objects.aget_or_create( + email=f"preview+{ott_auth}@preview.worker", + defaults={"name": _("Preview"), "nickname": "?", "is_active": True, "password": make_password(None)}, + ) + + if created: + await PreviewUser.objects.acreate(user=preview_user, creator_id=ott_auth) + await preview_user.groups.aadd(await Group.objects.aget(name=PlatformRealm.PREVIEW)) + + user = await User.get_user(email=preview_user.email, is_active=True, annotate=True) + user.agreement_required = False + await user.token_login(request=request, response=response, skip_password_check=True) + + return user diff --git a/core/apps/preview/apps.py b/core/apps/preview/apps.py new file mode 100644 index 0000000..34d369f --- /dev/null +++ b/core/apps/preview/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class PreviewConfig(AppConfig): + name = "apps.preview" + verbose_name = _("Preview") diff --git a/core/apps/preview/migrations/0001_initial.py b/core/apps/preview/migrations/0001_initial.py new file mode 100644 index 0000000..7abf9e2 --- /dev/null +++ b/core/apps/preview/migrations/0001_initial.py @@ -0,0 +1,30 @@ +# Generated by Django 6.0.3 on 2026-03-15 03:05 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='PreviewUser', + 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')), + ('creator', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Creator')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'verbose_name': 'Preview User', + 'verbose_name_plural': 'Preview Users', + }, + ), + ] diff --git a/core/apps/preview/migrations/__init__.py b/core/apps/preview/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/preview/models.py b/core/apps/preview/models.py new file mode 100644 index 0000000..187fc74 --- /dev/null +++ b/core/apps/preview/models.py @@ -0,0 +1,16 @@ +from django.contrib.auth import get_user_model +from django.db import models +from django.db.models import CASCADE, Model +from django.utils.translation import gettext_lazy as _ + +User = get_user_model() + + +class PreviewUser(Model): + created = models.DateTimeField(_("Created"), auto_now_add=True, db_index=True) + user = models.ForeignKey(User, CASCADE, verbose_name=_("User"), related_name="+") + creator = models.ForeignKey(User, CASCADE, verbose_name=_("Creator"), related_name="+") + + class Meta: + verbose_name = _("Preview User") + verbose_name_plural = _("Preview Users") diff --git a/core/apps/preview/tasks.py b/core/apps/preview/tasks.py new file mode 100644 index 0000000..1f25d32 --- /dev/null +++ b/core/apps/preview/tasks.py @@ -0,0 +1,18 @@ +from datetime import timedelta + +from celery import shared_task +from django.contrib.auth import get_user_model +from django.utils import timezone + +from apps.preview.models import PreviewUser + +User = get_user_model() + +PREVIEW_ACCESS_TOKEN_EXPIRE_SECONDS = 60 * 60 * 1 + + +@shared_task +def cleanup_preview_users(): + cutoff = timezone.now() - timedelta(seconds=PREVIEW_ACCESS_TOKEN_EXPIRE_SECONDS) + user_ids = PreviewUser.objects.filter(created__lt=cutoff).values_list("user_id", flat=True) + return User.objects.filter(pk__in=user_ids).delete() diff --git a/core/apps/quiz/api/v1.py b/core/apps/quiz/api/v1.py index c703529..0bae76c 100644 --- a/core/apps/quiz/api/v1.py +++ b/core/apps/quiz/api/v1.py @@ -1,7 +1,7 @@ from ninja.router import Router from apps.common.util import HttpRequest -from apps.learning.api.access_control import access_date, access_realm, active_context +from apps.learning.api.access_control import access_date, active_context from apps.quiz.api.schema import QuizAttemptAnswersSchema, QuizAttemptSchema, QuizSessionSchema from apps.quiz.models import Attempt, Quiz @@ -19,15 +19,10 @@ async def get_session(request: HttpRequest, id: str): @router.post("/{id}/attempt", response=QuizAttemptSchema) @active_context() -@access_realm() @access_date("quiz", "quiz") async def start_attempt(request: HttpRequest, id: str): return await Attempt.start( - quiz_id=id, - learner_id=request.auth, - lock=request.access_date["end"], - context=request.active_context, - realm=request.access_realm, + quiz_id=id, learner_id=request.auth, lock=request.access_date["end"], context=request.active_context ) diff --git a/core/apps/quiz/apps.py b/core/apps/quiz/apps.py index 3b4d7fa..0b7d698 100644 --- a/core/apps/quiz/apps.py +++ b/core/apps/quiz/apps.py @@ -3,6 +3,5 @@ class QuizConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" name = "apps.quiz" verbose_name = _("Quiz") diff --git a/core/apps/quiz/migrations/0001_initial.py b/core/apps/quiz/migrations/0001_initial.py index 57d504b..ac4d13d 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-11 17:58 +# Generated by Django 6.0.3 on 2026-03-15 03:05 import apps.common.util import django.contrib.postgres.fields @@ -29,7 +29,6 @@ class Migration(migrations.Migration): ('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')), - ('realm', models.CharField(choices=[('student', 'Student'), ('studio', 'Studio'), ('tutor', 'Tutor')], db_index=True, default='student', max_length=30, verbose_name='Realm')), ('retry', models.PositiveSmallIntegerField(default=0, verbose_name='Retry')), ('learner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Learner')), ], @@ -198,7 +197,6 @@ class Migration(migrations.Migration): ('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')), - ('realm', models.CharField(choices=[('student', 'Student'), ('studio', 'Studio'), ('tutor', 'Tutor')], default='student', max_length=30, verbose_name='Realm')), ('retry', models.PositiveSmallIntegerField(default=0, verbose_name='Retry')), ('learner', 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='Learner')), ('pgh_context', models.ForeignKey(db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='pghistory.context')), @@ -384,15 +382,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", "lock", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "quiz_id", "realm", "retry", "started") VALUES (NEW."active", NEW."context", NEW."id", NEW."learner_id", NEW."lock", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."quiz_id", NEW."realm", NEW."retry", NEW."started"); RETURN NULL;', hash='bd417de0f39ef0556d6a454e675f2d41f31ce32c', 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", "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", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."quiz_id", NEW."retry", NEW."started"); RETURN NULL;', hash='949038ba65b8251147d0979a19428063dd75a91a', 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", "lock", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "quiz_id", "realm", "retry", "started") VALUES (NEW."active", NEW."context", NEW."id", NEW."learner_id", NEW."lock", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."quiz_id", NEW."realm", NEW."retry", NEW."started"); RETURN NULL;', hash='4c0fc8ee5d0cfbcdcd987370f285c728bb93e1d4', 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", "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", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."quiz_id", NEW."retry", NEW."started"); RETURN NULL;', hash='7e77b018dbfb0357383ec190fbab44ea8bb7cae4', 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", "lock", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "quiz_id", "realm", "retry", "started") VALUES (OLD."active", OLD."context", OLD."id", OLD."learner_id", OLD."lock", _pgh_attach_context(), NOW(), \'delete\', OLD."id", OLD."quiz_id", OLD."realm", OLD."retry", OLD."started"); RETURN NULL;', hash='83ded90590f90614c554954d69bde0e8d08baf9a', 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", "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", _pgh_attach_context(), NOW(), \'delete\', OLD."id", OLD."quiz_id", OLD."retry", OLD."started"); RETURN NULL;', hash='a2d36318fd87cc00257f18f332568ae7b4270789', 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 802a04a..2aaee57 100644 --- a/core/apps/quiz/models.py +++ b/core/apps/quiz/models.py @@ -32,7 +32,7 @@ from apps.assistant.plugin.quiz import QuizMaker from apps.common.error import ErrorCode from apps.common.models import AttemptMixin, GradeFieldMixin, LearningObjectMixin, TimeStampedMixin -from apps.common.util import AccessDate, LearningSessionStep, RealmChoices, ScoreStatsDict, get_score_stats +from apps.common.util import AccessDate, LearningSessionStep, ScoreStatsDict, get_score_stats from apps.operation.models import AttachmentMixin from apps.quiz.trigger import attempt_retry_count @@ -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, lock: datetime, context: str, realm: RealmChoices): + async def start(cls, *, quiz_id: str, learner_id: str, lock: datetime, context: str): 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 @@ -273,7 +273,6 @@ async def start(cls, *, quiz_id: str, learner_id: str, lock: datetime, context: context=context, active=True, started=timezone.now() + timedelta(seconds=1), - realm=realm, ) except IntegrityError: raise ValueError(ErrorCode.ATTEMPT_ALREADY_STARTED) diff --git a/core/apps/quiz/tests/test_quiz_api.py b/core/apps/quiz/tests/test_quiz_api.py index 4b26343..c5559a7 100644 --- a/core/apps/quiz/tests/test_quiz_api.py +++ b/core/apps/quiz/tests/test_quiz_api.py @@ -11,11 +11,6 @@ from conftest import AdminUser -@pytest.fixture -def client(): - return Client(SERVER_NAME="student.testserver") - - @pytest.mark.e2e @pytest.mark.django_db def test_quiz_flow(client: Client, mimesis: Generic, admin_user: AdminUser): diff --git a/core/apps/sso/apps.py b/core/apps/sso/apps.py index e416404..bce34f0 100644 --- a/core/apps/sso/apps.py +++ b/core/apps/sso/apps.py @@ -3,6 +3,5 @@ class SSOConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" name = "apps.sso" verbose_name = _("SSO") diff --git a/core/apps/sso/migrations/0001_initial.py b/core/apps/sso/migrations/0001_initial.py index b8fb5e0..a1f05e8 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-11 17:58 +# Generated by Django 6.0.3 on 2026-03-15 03:05 import apps.common.util import django.db.models.deletion diff --git a/core/apps/sso/models.py b/core/apps/sso/models.py index f32ade0..3ba5155 100644 --- a/core/apps/sso/models.py +++ b/core/apps/sso/models.py @@ -1,3 +1,4 @@ +import re import secrets from datetime import timedelta from typing import TYPE_CHECKING @@ -62,7 +63,9 @@ def __str__(self): @classmethod async def authorization_url(cls, *, provider: str, redirect_to: str, redirect_uri: str, user_id: str | None = None): parsed = urlparse(redirect_to) - if f"{parsed.scheme}://{parsed.netloc}" not in settings.ALLOWED_REDIRECT_ORIGINS: + if not any( + re.match(pattern, f"{parsed.scheme}://{parsed.netloc}") for pattern in settings.ALLOWED_ORIGIN_REGEXES + ): raise ValueError(ErrorCode.INVALID_REDIRECT_URL) session = await cls.objects.acreate( diff --git a/core/apps/sso/tests/test_sso_api.py b/core/apps/sso/tests/test_sso_api.py index d8121b4..163fa56 100644 --- a/core/apps/sso/tests/test_sso_api.py +++ b/core/apps/sso/tests/test_sso_api.py @@ -12,7 +12,7 @@ @pytest.mark.e2e @pytest.mark.django_db -@override_settings(ALLOWED_REDIRECT_ORIGINS=["http://localhost:3000", "http://testserver"]) +@override_settings(ALLOWED_ORIGIN_REGEXES=["http://localhost:3000", "http://testserver"]) def test_sso_flow(client: Client, mimesis): redirect_to = "http://localhost:3000/auth/callback" diff --git a/core/apps/store/apps.py b/core/apps/store/apps.py index 376bb8c..3f7a2cb 100644 --- a/core/apps/store/apps.py +++ b/core/apps/store/apps.py @@ -3,6 +3,5 @@ class StoreConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" name = "apps.store" verbose_name = _("Store") diff --git a/core/apps/store/migrations/0001_initial.py b/core/apps/store/migrations/0001_initial.py index 1fcf15a..1be013b 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-11 17:58 +# Generated by Django 6.0.3 on 2026-03-15 03:05 import django.db.models.deletion import django.utils.timezone diff --git a/core/apps/studio/api/v1/__init__.py b/core/apps/studio/api/v1/__init__.py index 1b4e431..b630718 100644 --- a/core/apps/studio/api/v1/__init__.py +++ b/core/apps/studio/api/v1/__init__.py @@ -28,7 +28,6 @@ from apps.studio.api.v1.media import router as media_router from apps.studio.api.v1.quiz import router as quiz_router from apps.studio.api.v1.survey import router as survey_router -from apps.studio.decorator import editor_required from apps.studio.models import Editing from apps.survey.models import Survey @@ -62,7 +61,6 @@ class StudioContentSpec(Schema): @router.get("/content", response=PaginatedResponse[StudioContentSpec]) -@editor_required() async def content( request: HttpRequest, page: Annotated[int, functions.Query(1, ge=1)], @@ -131,7 +129,6 @@ class AssessmentSuggestionSpec(Schema): @router.get("/suggestion/assessment", response=list[AssessmentSuggestionSpec]) -@editor_required() async def assessment_suggestions(request: HttpRequest): qs = [ M.objects @@ -151,7 +148,6 @@ class ContentSuggestionSpec(Schema): @router.get("/suggestion/content", response=list[ContentSuggestionSpec]) -@editor_required() async def content_suggestions(request: HttpRequest, kind: Annotated[StudioModel, functions.Query(...)]): return [ raw @@ -172,7 +168,6 @@ class InlineSuggestionSpec(Schema): @router.get("/suggestion/inline", response=list[InlineSuggestionSpec]) -@editor_required() async def inline_suggestions(request: HttpRequest, kind: Annotated[InlineItem, functions.Query(...)]): qs = None diff --git a/core/apps/studio/api/v1/assignment.py b/core/apps/studio/api/v1/assignment.py index 9357728..e5d69dd 100644 --- a/core/apps/studio/api/v1/assignment.py +++ b/core/apps/studio/api/v1/assignment.py @@ -26,8 +26,8 @@ LearningObjectMixinSchema, Schema, ) -from apps.common.util import HttpRequest, RealmChoices -from apps.studio.decorator import editor_required, track_editing +from apps.common.util import HttpRequest +from apps.studio.decorator import track_editing class AssignmentQuestionSaveSpec(Schema): @@ -105,7 +105,6 @@ class AssignmentSaveSpec(Schema): @router.get("/assignment/{id}", response=AssignmentSpec) -@editor_required() async def get_assignment(request: HttpRequest, id: str): assignment = ( await Assignment.objects @@ -125,7 +124,6 @@ async def get_assignment(request: HttpRequest, id: str): @router.post("/assignment", response=str) -@editor_required() @track_editing(Assignment) async def save_assignment( request: HttpRequest, @@ -176,16 +174,14 @@ def create_new(): @router.delete("/assignment/{id}") -@editor_required() @track_editing(Assignment, id_field="id") async def delete_assignment(request: HttpRequest, id: str): - if await Attempt.objects.filter(assignment_id=id, realm=RealmChoices.STUDENT).aexists(): + if await Attempt.objects.filter(assignment_id=id).aexists(): raise ValueError(ErrorCode.ATTEMPT_EXISTS) await Assignment.objects.filter(id=id, owner_id=request.auth, published__isnull=True).adelete() @router.get("/assignment/{id}/question", response=list[AssignmentQuestionSpec]) -@editor_required() async def get_assignment_questions(request: HttpRequest, id: str): return [ q @@ -196,7 +192,6 @@ async def get_assignment_questions(request: HttpRequest, id: str): @router.post("/assignment/{id}/question", response=list[int]) -@editor_required() @track_editing(Assignment, id_field="id") async def save_assignment_questions( request: HttpRequest, @@ -242,11 +237,10 @@ async def save_assignment_questions( @router.delete("/assignment/{id}/question/{question_id}") -@editor_required() @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, realm=RealmChoices.STUDENT + assignment_id=id, question=question_id, assignment__owner_id=request.auth ).aexists(): raise ValueError(ErrorCode.IN_USE) @@ -256,7 +250,6 @@ async def delete_assignment_quesion(request: HttpRequest, id: str, question_id: @router.get("/assignment/{id}/rubric", response=list[RubricCriterionSpec]) -@editor_required() 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, owner_id=request.auth @@ -265,7 +258,6 @@ async def get_assignment_rubric(request: HttpRequest, id: str): @router.post("/assignment/{id}/rubric") -@editor_required() @track_editing(Assignment, id_field="id") async def save_assignment_rubric(request: HttpRequest, id: str, data: RootModel[list[RubricCriterionSpec]]): assignment = await aget_object_or_404(Assignment, id=id, owner_id=request.auth) diff --git a/core/apps/studio/api/v1/course.py b/core/apps/studio/api/v1/course.py index 2c05e26..ff504d2 100644 --- a/core/apps/studio/api/v1/course.py +++ b/core/apps/studio/api/v1/course.py @@ -22,7 +22,7 @@ LearningObjectMixinSchema, Schema, ) -from apps.common.util import HttpRequest, RealmChoices +from apps.common.util import HttpRequest from apps.course.models import ( ASSESSIBLE_MODELS, Assessment, @@ -37,7 +37,7 @@ Lesson, LessonMedia, ) -from apps.studio.decorator import editor_required, track_editing +from apps.studio.decorator import track_editing log = logging.getLogger(__name__) @@ -165,7 +165,6 @@ class CourseSaveSpec(Schema): @router.get("/course/{id}", response=CourseSpec) -@editor_required() async def get_course(request: HttpRequest, id: str): return ( await Course.objects @@ -201,7 +200,6 @@ async def get_course(request: HttpRequest, id: str): @router.post("/course", response=str) -@editor_required() @track_editing(Course) async def save_course( request: HttpRequest, @@ -244,10 +242,9 @@ def create_new(): @router.delete("/course/{id}") -@editor_required() @track_editing(Course, id_field="id") async def delete_course(request: HttpRequest, id: str): - if await Engagement.objects.filter(course_id=id, realm=RealmChoices.STUDENT).aexists(): + if await Engagement.objects.filter(course_id=id).aexists(): raise ValueError(ErrorCode.ATTEMPT_EXISTS) await Course.objects.filter(id=id, owner_id=request.auth, published__isnull=True).adelete() @@ -257,7 +254,6 @@ class CourseSurveySaveSpec(CourseSurveySpec): @router.post("/course/{id}/survey", response=list[int]) -@editor_required() @track_editing(Course, id_field="id") async def save_course_surveys(request: HttpRequest, id: str, data: RootModel[list[CourseSurveySaveSpec]]): course = await aget_object_or_404(Course, id=id, owner_id=request.auth) @@ -274,7 +270,6 @@ async def save_course_surveys(request: HttpRequest, id: str, data: RootModel[lis @router.delete("/course/{id}/survey/{course_survey_id}") -@editor_required() @track_editing(Course, id_field="id") async def remove_course_survey(request: HttpRequest, id: str, course_survey_id: int): count, _ = await CourseSurvey.objects.filter( @@ -289,7 +284,6 @@ class AssessmentSaveSpec(AssessmentSpec): @router.post("/course/{id}/assessment", response=list[int]) -@editor_required() @track_editing(Course, id_field="id") async def save_course_assessments(request: HttpRequest, id: str, data: RootModel[list[AssessmentSaveSpec]]): course = await aget_object_or_404(Course, id=id, owner_id=request.auth) @@ -322,7 +316,6 @@ async def save_course_assessments(request: HttpRequest, id: str, data: RootModel @router.delete("/course/{id}/assessment/{assessment_id}") -@editor_required() @track_editing(Course, id_field="id") async def remove_course_assessment(request: HttpRequest, id: str, assessment_id: int): count, _ = await Assessment.objects.filter(course_id=id, id=assessment_id, course__owner_id=request.auth).adelete() @@ -335,7 +328,6 @@ class LessonSaveSpec(LessonSpec): @router.post("/course/{id}/lesson", response=list[int]) -@editor_required() @track_editing(Course, id_field="id") async def save_course_lessons(request: HttpRequest, id: str, data: RootModel[list[LessonSaveSpec]]): course = await aget_object_or_404(Course, id=id, owner_id=request.auth) @@ -379,7 +371,6 @@ def save_lessons(): @router.delete("/course/{id}/lesson/{lesson_id}") -@editor_required() @track_editing(Course, id_field="id") async def remove_course_lesson(request: HttpRequest, id: str, lesson_id: int): count, _ = await Lesson.objects.filter(course_id=id, id=lesson_id, course__owner_id=request.auth).adelete() @@ -392,7 +383,6 @@ class CourseCertificateSaveSpec(CourseCertificateSpec): @router.post("/course/{id}/certificate", response=list[int]) -@editor_required() @track_editing(Course, id_field="id") async def save_course_certificates(request: HttpRequest, id: str, data: RootModel[list[CourseCertificateSaveSpec]]): course = await aget_object_or_404(Course, id=id, owner_id=request.auth) @@ -409,7 +399,6 @@ async def save_course_certificates(request: HttpRequest, id: str, data: RootMode @router.delete("/course/{id}/certificate/{course_certificate_id}") -@editor_required() @track_editing(Course, id_field="id") async def remove_course_certificate(request: HttpRequest, id: str, course_certificate_id: int): count, _ = await CourseCertificate.objects.filter( @@ -424,7 +413,6 @@ class CourseRelationSaveSpec(CourseRelationSpec): @router.post("/course/{id}/relation", response=list[int]) -@editor_required() @track_editing(Course, id_field="id") async def save_course_relations(request: HttpRequest, id: str, data: RootModel[list[CourseRelationSaveSpec]]): course = await aget_object_or_404(Course, id=id, owner_id=request.auth) @@ -441,7 +429,6 @@ async def save_course_relations(request: HttpRequest, id: str, data: RootModel[l @router.delete("/course/{id}/relation/{course_relation_id}") -@editor_required() @track_editing(Course, id_field="id") async def remove_course_relation(request: HttpRequest, id: str, course_relation_id: int): count, _ = await CourseRelation.objects.filter( @@ -456,7 +443,6 @@ class CourseCategorySaveSpec(CourseCategorySpec): @router.post("/course/{id}/category", response=list[int]) -@editor_required() @track_editing(Course, id_field="id") async def save_course_categories(request: HttpRequest, id: str, data: RootModel[list[CourseCategorySaveSpec]]): course = await aget_object_or_404(Course, id=id, owner_id=request.auth) @@ -473,7 +459,6 @@ async def save_course_categories(request: HttpRequest, id: str, data: RootModel[ @router.delete("/course/{id}/category/{course_category_id}") -@editor_required() @track_editing(Course, id_field="id") async def remove_course_category(request: HttpRequest, id: str, course_category_id: int): count, _ = await CourseCategory.objects.filter( @@ -488,7 +473,6 @@ class CourseInstructorSaveSpec(CourseInstructorSpec): @router.post("/course/{id}/instructor", response=list[int]) -@editor_required() @track_editing(Course, id_field="id") async def save_course_instructors(request: HttpRequest, id: str, data: RootModel[list[CourseInstructorSaveSpec]]): course = await aget_object_or_404(Course, id=id, owner_id=request.auth) @@ -505,7 +489,6 @@ async def save_course_instructors(request: HttpRequest, id: str, data: RootModel @router.delete("/course/{id}/instructor/{course_instructor_id}") -@editor_required() @track_editing(Course, id_field="id") async def remove_course_instructor(request: HttpRequest, id: str, course_instructor_id: int): count, _ = await CourseInstructor.objects.filter( diff --git a/core/apps/studio/api/v1/discussion.py b/core/apps/studio/api/v1/discussion.py index 6769341..df8b59e 100644 --- a/core/apps/studio/api/v1/discussion.py +++ b/core/apps/studio/api/v1/discussion.py @@ -17,9 +17,9 @@ LearningObjectMixinSchema, Schema, ) -from apps.common.util import HttpRequest, RealmChoices +from apps.common.util import HttpRequest from apps.discussion.models import Attempt, Discussion, Question, QuestionPool -from apps.studio.decorator import editor_required, track_editing +from apps.studio.decorator import track_editing class DiscussionQuestionSaveSpec(Schema): @@ -80,7 +80,6 @@ class DiscussionSaveSpec(Schema): @router.get("/discussion/{id}", response=DiscussionSpec) -@editor_required() async def get_discussion(request: HttpRequest, id: str): return await Discussion.objects.prefetch_related( Prefetch("question_pool__questions", queryset=Question.objects.prefetch_related("attachments").order_by("id")) @@ -88,7 +87,6 @@ async def get_discussion(request: HttpRequest, id: str): @router.post("/discussion", response=str) -@editor_required() @track_editing(Discussion) async def save_discussion( request: HttpRequest, @@ -129,16 +127,14 @@ def create_new(): @router.delete("/discussion/{id}") -@editor_required() @track_editing(Discussion, id_field="id") async def delete_discussion(request: HttpRequest, id: str): - if await Attempt.objects.filter(discussion_id=id, realm=RealmChoices.STUDENT).aexists(): + if await Attempt.objects.filter(discussion_id=id).aexists(): raise ValueError(ErrorCode.ATTEMPT_EXISTS) await Discussion.objects.filter(id=id, owner_id=request.auth, published__isnull=True).adelete() @router.get("/discussion/{id}/question", response=list[DiscussionQuestionSpec]) -@editor_required() async def get_discussion_questions(request: HttpRequest, id: str): return [ q @@ -149,7 +145,6 @@ async def get_discussion_questions(request: HttpRequest, id: str): @router.post("/discussion/{id}/question", response=list[int]) -@editor_required() @track_editing(Discussion, id_field="id") async def save_discussion_questions( request: HttpRequest, @@ -197,11 +192,10 @@ async def save_discussion_questions( @router.delete("/discussion/{id}/question/{question_id}") -@editor_required() @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, realm=RealmChoices.STUDENT + discussion_id=id, question_id=question_id, discussion__owner_id=request.auth ).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 d1c9022..cb23bdb 100644 --- a/core/apps/studio/api/v1/exam.py +++ b/core/apps/studio/api/v1/exam.py @@ -17,9 +17,9 @@ LearningObjectMixinSchema, Schema, ) -from apps.common.util import HttpRequest, RealmChoices +from apps.common.util import HttpRequest from apps.exam.models import Attempt, Exam, Question, QuestionPool, Solution -from apps.studio.decorator import editor_required, track_editing +from apps.studio.decorator import track_editing class ExamQuestionSaveSpec(Schema): @@ -90,7 +90,6 @@ class ExamSaveSpec(Schema): @router.get("/exam/{id}", response=ExamSpec) -@editor_required() async def get_exam(request: HttpRequest, id: str): return ( await Exam.objects @@ -106,7 +105,6 @@ async def get_exam(request: HttpRequest, id: str): @router.post("/exam", response=str) -@editor_required() @track_editing(Exam) async def save_exam( request: HttpRequest, @@ -149,16 +147,14 @@ def create_new(): @router.delete("/exam/{id}") -@editor_required() @track_editing(Exam, id_field="id") async def delete_exam(request: HttpRequest, id: str): - if await Attempt.objects.filter(exam_id=id, realm=RealmChoices.STUDENT).aexists(): + if await Attempt.objects.filter(exam_id=id).aexists(): raise ValueError(ErrorCode.ATTEMPT_EXISTS) await Exam.objects.filter(id=id, owner_id=request.auth, published__isnull=True).adelete() @router.get("/exam/{id}/question", response=list[ExamQuestionSpec]) -@editor_required() async def get_exam_questions(request: HttpRequest, id: str): return [ q @@ -170,7 +166,6 @@ async def get_exam_questions(request: HttpRequest, id: str): @router.post("/exam/{id}/question", response=list[int]) -@editor_required() @track_editing(Exam, id_field="id") async def save_exam_questions( request: HttpRequest, @@ -221,12 +216,9 @@ async def save_exam_questions( @router.delete("/exam/{id}/question/{question_id}") -@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, realm=RealmChoices.STUDENT - ).aexists(): + if await Attempt.objects.filter(exam_id=id, questions=question_id, exam__owner_id=request.auth).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 add5045..a22087a 100644 --- a/core/apps/studio/api/v1/media.py +++ b/core/apps/studio/api/v1/media.py @@ -11,10 +11,10 @@ from apps.common.error import ErrorCode from apps.common.schema import FileSizeValidator, FileTypeValidator, LearningObjectMixinSchema, Schema -from apps.common.util import HttpRequest, RealmChoices +from apps.common.util import HttpRequest from apps.content.models import Media, Subtitle, Watch from apps.quiz.models import Quiz -from apps.studio.decorator import editor_required, track_editing +from apps.studio.decorator import track_editing class SubtitleSpec(Schema): @@ -59,7 +59,6 @@ class MediaSaveSpec(Schema): @router.get("/media/{id}", response=MediaSpec) -@editor_required() async def get_media(request: HttpRequest, id: str): return ( await Media.objects @@ -70,7 +69,6 @@ async def get_media(request: HttpRequest, id: str): @router.post("/media", response=str) -@editor_required() @track_editing(Media) async def save_media( request: HttpRequest, @@ -120,16 +118,14 @@ async def save_media( @router.delete("/media/{id}") -@editor_required() @track_editing(Media, id_field="id") async def delete_media(request: HttpRequest, id: str): - if await Watch.objects.filter(media_id=id, realm=RealmChoices.STUDENT).aexists(): + if await Watch.objects.filter(media_id=id).aexists(): raise ValueError(ErrorCode.ATTEMPT_EXISTS) await Media.objects.filter(id=id, owner_id=request.auth, published__isnull=True).adelete() @router.post("/media/{id}/subtitle") -@editor_required() @track_editing(Media, id_field="id") async def save_media_subtitle(request, id: str, data: SubtitleSpec): media = await aget_object_or_404(Media, id=id, owner_id=request.auth) @@ -137,7 +133,6 @@ async def save_media_subtitle(request, id: str, data: SubtitleSpec): @router.delete("/media/{id}/subtitle/{lang}") -@editor_required() @track_editing(Media, id_field="id") async def delete_media_subtitle(request: HttpRequest, id: str, lang: str): count, _ = await Subtitle.objects.filter(lang=lang, media_id=id, media__owner_id=request.auth).adelete() @@ -146,7 +141,6 @@ async def delete_media_subtitle(request: HttpRequest, id: str, lang: str): @router.post("/media/{id}/subtitle/{lang}/quiz", response=str) -@editor_required() @track_editing(Media, id_field="id") async def create_media_quiz(request, id: str, lang: str): media = await aget_object_or_404(Media, id=id, owner_id=request.auth) diff --git a/core/apps/studio/api/v1/quiz.py b/core/apps/studio/api/v1/quiz.py index 04af6fa..57fc9b2 100644 --- a/core/apps/studio/api/v1/quiz.py +++ b/core/apps/studio/api/v1/quiz.py @@ -11,9 +11,9 @@ from apps.common.error import ErrorCode from apps.common.schema import FileSizeValidator, FileTypeValidator, LearningObjectMixinSchema, Schema -from apps.common.util import HttpRequest, RealmChoices +from apps.common.util import HttpRequest from apps.quiz.models import Attempt, Question, QuestionPool, Quiz, Solution -from apps.studio.decorator import editor_required, track_editing +from apps.studio.decorator import track_editing class QuizQuestionSaveSpec(Schema): @@ -75,7 +75,6 @@ class QuizSaveSpec(Schema): @router.get("/quiz/{id}", response=QuizSpec) -@editor_required() async def get_quiz(request: HttpRequest, id: str): return ( await Quiz.objects @@ -91,7 +90,6 @@ async def get_quiz(request: HttpRequest, id: str): @router.post("/quiz", response=str) -@editor_required() @track_editing(Quiz) async def save_quiz( request: HttpRequest, @@ -132,16 +130,14 @@ def create_new(): @router.delete("/quiz/{id}") -@editor_required() @track_editing(Quiz, id_field="id") async def delete_quiz(request: HttpRequest, id: str): - if await Attempt.objects.filter(quiz_id=id, realm=RealmChoices.STUDENT).aexists(): + if await Attempt.objects.filter(quiz_id=id).aexists(): raise ValueError(ErrorCode.ATTEMPT_EXISTS) await Quiz.objects.filter(id=id, owner_id=request.auth, published__isnull=True).adelete() @router.get("/quiz/{id}/question", response=list[QuizQuestionSpec]) -@editor_required() async def get_quiz_questions(request: HttpRequest, id: str): return [ q @@ -153,7 +149,6 @@ async def get_quiz_questions(request: HttpRequest, id: str): @router.post("/quiz/{id}/question", response=list[int]) -@editor_required() @track_editing(Quiz, id_field="id") async def save_quiz_questions( request: HttpRequest, @@ -204,12 +199,9 @@ async def save_quiz_questions( @router.delete("/quiz/{id}/question/{question_id}") -@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, realm=RealmChoices.STUDENT - ).aexists(): + if await Attempt.objects.filter(quiz_id=id, questions=question_id, quiz__owner_id=request.auth).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/survey.py b/core/apps/studio/api/v1/survey.py index 4c40863..47340f0 100644 --- a/core/apps/studio/api/v1/survey.py +++ b/core/apps/studio/api/v1/survey.py @@ -11,8 +11,8 @@ from apps.common.error import ErrorCode from apps.common.schema import FileSizeValidator, FileTypeValidator, LearningObjectMixinSchema, Schema -from apps.common.util import HttpRequest, RealmChoices -from apps.studio.decorator import editor_required, track_editing +from apps.common.util import HttpRequest +from apps.studio.decorator import track_editing from apps.survey.models import Question, QuestionPool, Submission, Survey @@ -70,7 +70,6 @@ class SurveySaveSpec(Schema): @router.get("/survey/{id}", response=SurveySpec) -@editor_required() async def get_survey(request: HttpRequest, id: str): return await Survey.objects.prefetch_related( Prefetch("question_pool__questions", queryset=Question.objects.prefetch_related("attachments").order_by("id")) @@ -78,7 +77,6 @@ async def get_survey(request: HttpRequest, id: str): @router.post("/survey", response=str) -@editor_required() @track_editing(Survey) async def save_survey( request: HttpRequest, @@ -119,16 +117,14 @@ def create_new(): @router.delete("/survey/{id}") -@editor_required() @track_editing(Survey, id_field="id") async def delete_survey(request: HttpRequest, id: str): - if await Submission.objects.filter(survey_id=id, realm=RealmChoices.STUDENT).aexists(): + if await Submission.objects.filter(survey_id=id).aexists(): raise ValueError(ErrorCode.ATTEMPT_EXISTS) await Survey.objects.filter(id=id, owner_id=request.auth, published__isnull=True).adelete() @router.get("/survey/{id}/question", response=list[SurveyQuestionSpec]) -@editor_required() async def get_survey_questions(request: HttpRequest, id: str): return [ q @@ -139,7 +135,6 @@ async def get_survey_questions(request: HttpRequest, id: str): @router.post("/survey/{id}/question", response=list[int]) -@editor_required() @track_editing(Survey, id_field="id") async def save_survey_questions( request: HttpRequest, @@ -179,7 +174,6 @@ async def save_survey_questions( @router.delete("/survey/{id}/question/{question_id}") -@editor_required() @track_editing(Survey, id_field="id") async def delete_survey_quesion(request: HttpRequest, id: str, question_id: int): count, _ = await Question.objects.filter(id=question_id, pool__survey__id=id).adelete() diff --git a/core/apps/studio/apps.py b/core/apps/studio/apps.py index bbfc0f6..dc7574e 100644 --- a/core/apps/studio/apps.py +++ b/core/apps/studio/apps.py @@ -1,5 +1,7 @@ from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ class StudioConfig(AppConfig): name = "apps.studio" + verbose_name = _("Studio") diff --git a/core/apps/studio/decorator.py b/core/apps/studio/decorator.py index 32f3a98..cb946ed 100644 --- a/core/apps/studio/decorator.py +++ b/core/apps/studio/decorator.py @@ -5,25 +5,10 @@ from django.core.exceptions import ImproperlyConfigured from django.utils import timezone -from apps.common.error import ErrorCode from apps.common.util import HttpRequest from apps.studio.models import Editing -def editor_required(): - def decorator(func): - @wraps(func) - async def wrapper(request: HttpRequest, *args, **kwargs): - if "editor" not in request.roles: - raise ValueError(ErrorCode.PERMISSION_DENIED) - - return await func(request, *args, **kwargs) - - return wrapper - - return decorator - - def track_editing(model, *, id_field: str | None = None): def decorator(func): @wraps(func) diff --git a/core/apps/studio/migrations/0001_initial.py b/core/apps/studio/migrations/0001_initial.py index 85173a9..c8b2cb1 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-11 17:59 +# Generated by Django 6.0.3 on 2026-03-15 03:05 import django.db.models.deletion import django.utils.timezone diff --git a/core/apps/survey/api/v1.py b/core/apps/survey/api/v1.py index 50c382a..cb261cc 100644 --- a/core/apps/survey/api/v1.py +++ b/core/apps/survey/api/v1.py @@ -2,7 +2,7 @@ from ninja.router import Router from apps.common.util import HttpRequest -from apps.learning.api.access_control import access_date, access_realm, active_context +from apps.learning.api.access_control import access_date, active_context from apps.survey.api.schema import SurveyAnswersSchema, SurveySchema from apps.survey.models import Submission, Survey @@ -17,7 +17,6 @@ async def get_survey(request: HttpRequest, id: str): @router.post("/{id}/submit") @active_context() -@access_realm() @access_date("survey", "survey") async def submit(request: HttpRequest, id: str, data: SurveyAnswersSchema): await Submission.submit( @@ -26,7 +25,6 @@ async def submit(request: HttpRequest, id: str, data: SurveyAnswersSchema): context=request.active_context, answers=data.model_dump(), lock=request.access_date["end"], - realm=request.access_realm, ) @@ -42,11 +40,8 @@ async def get_anonymous_survey(request: HttpRequest, id: str): @router.post("/{id}/anonymous/submit", auth=None) -@access_realm() async def submit_anonymous(request: HttpRequest, id: str, data: SurveyAnswersSchema): - await Submission.submit( - survey_id=id, answers=data.model_dump(), lock=timezone.now(), anonymous=True, realm=request.access_realm - ) + await Submission.submit(survey_id=id, answers=data.model_dump(), lock=timezone.now(), anonymous=True) @router.get("/{id}/anonymous/results", auth=None, response=dict[str, dict[str, int]]) diff --git a/core/apps/survey/apps.py b/core/apps/survey/apps.py index 52c748e..910ed29 100644 --- a/core/apps/survey/apps.py +++ b/core/apps/survey/apps.py @@ -3,6 +3,5 @@ class SurveyConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" name = "apps.survey" verbose_name = _("Survey") diff --git a/core/apps/survey/migrations/0001_initial.py b/core/apps/survey/migrations/0001_initial.py index d5e35bc..74b26a1 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-11 17:58 +# Generated by Django 6.0.3 on 2026-03-15 03:05 import apps.common.util import django.contrib.postgres.fields @@ -103,7 +103,6 @@ class Migration(migrations.Migration): ('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')), - ('realm', models.CharField(choices=[('student', 'Student'), ('studio', 'Studio'), ('tutor', 'Tutor')], db_index=True, default='student', max_length=30, verbose_name='Realm')), ('answers', models.JSONField(verbose_name='Answers')), ('respondent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Respondent')), ], @@ -152,7 +151,6 @@ class Migration(migrations.Migration): ('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')), - ('realm', models.CharField(choices=[('student', 'Student'), ('studio', 'Studio'), ('tutor', 'Tutor')], default='student', max_length=30, verbose_name='Realm')), ('answers', models.JSONField(verbose_name='Answers')), ('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='survey.submission')), @@ -258,15 +256,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", "lock", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "realm", "respondent_id", "started", "survey_id") VALUES (NEW."active", NEW."answers", NEW."context", NEW."id", NEW."lock", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."realm", NEW."respondent_id", NEW."started", NEW."survey_id"); RETURN NULL;', hash='cc532dda31fdddb1935a97916f498ae6ef8bdb24', 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", "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", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."respondent_id", NEW."started", NEW."survey_id"); RETURN NULL;', hash='10279a85aa1edac97da3d503732f23b1adebbffc', 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", "lock", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "realm", "respondent_id", "started", "survey_id") VALUES (NEW."active", NEW."answers", NEW."context", NEW."id", NEW."lock", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."realm", NEW."respondent_id", NEW."started", NEW."survey_id"); RETURN NULL;', hash='8311e8b84c1aa292e444a89fe71be34267f5c935', 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", "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", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."respondent_id", NEW."started", NEW."survey_id"); RETURN NULL;', hash='ddc886064348b5fac2bdebaf3f7a79915e90214f', 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", "lock", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "realm", "respondent_id", "started", "survey_id") VALUES (OLD."active", OLD."answers", OLD."context", OLD."id", OLD."lock", _pgh_attach_context(), NOW(), \'delete\', OLD."id", OLD."realm", OLD."respondent_id", OLD."started", OLD."survey_id"); RETURN NULL;', hash='bfccfedf96143005566197af7e5b31505e3cfd39', 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", "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", _pgh_attach_context(), NOW(), \'delete\', OLD."id", OLD."respondent_id", OLD."started", OLD."survey_id"); RETURN NULL;', hash='225463d9de79608a3b8a2d095590e95720e5b5ab', 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 b9c30ef..db9ba45 100644 --- a/core/apps/survey/models.py +++ b/core/apps/survey/models.py @@ -27,7 +27,6 @@ from apps.common.error import ErrorCode from apps.common.models import AttemptMixin, LearningObjectMixin, OrderableMixin -from apps.common.util import RealmChoices from apps.operation.models import AttachmentMixin User = get_user_model() @@ -144,7 +143,6 @@ async def submit( survey_id: str, answers: dict[str, str], lock: datetime, - realm: RealmChoices, respondent_id: str | None = None, context: str = "", anonymous: bool = False, @@ -152,7 +150,7 @@ async def submit( survey = await Survey.objects.aget(id=survey_id) if survey.anonymous: - await Submission.objects.acreate(survey=survey, answers=answers, lock=lock, realm=realm) + await Submission.objects.acreate(survey=survey, answers=answers, lock=lock) else: if anonymous: raise ValueError(ErrorCode.ANONYMOUS_NOT_ALLOWED) @@ -162,5 +160,5 @@ async def submit( survey=survey, respondent_id=respondent_id, context=context, - defaults={"answers": answers, "lock": lock, "active": True, "realm": realm}, + defaults={"answers": answers, "lock": lock, "active": True}, ) diff --git a/core/apps/survey/tests/test_survey_api.py b/core/apps/survey/tests/test_survey_api.py index 1abd0fb..cd438b4 100644 --- a/core/apps/survey/tests/test_survey_api.py +++ b/core/apps/survey/tests/test_survey_api.py @@ -12,11 +12,6 @@ from conftest import AdminUser -@pytest.fixture -def client(): - return Client(SERVER_NAME="student.testserver") - - @pytest.mark.e2e @pytest.mark.django_db def test_survey_flow(client: Client, mimesis: Generic, admin_user: AdminUser): diff --git a/core/apps/tracking/apps.py b/core/apps/tracking/apps.py index 64cfa5c..579bce3 100644 --- a/core/apps/tracking/apps.py +++ b/core/apps/tracking/apps.py @@ -3,6 +3,5 @@ class TrackingConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" name = "apps.tracking" verbose_name = _("Tracking") diff --git a/core/apps/tracking/middleware.py b/core/apps/tracking/middleware.py index 06d4306..6513710 100644 --- a/core/apps/tracking/middleware.py +++ b/core/apps/tracking/middleware.py @@ -11,7 +11,7 @@ def __setattr__(self, attr: str, value): # cf. pghistory.middleware.HistoryMiddleware if attr == "user": if hasattr(value, "_meta"): - pghistory.context(user_id=value.pk, is_admin=value.is_superuser or value.is_staff) + pghistory.context(user_id=value.pk, is_admin=value.is_superuser) return super().__setattr__(attr, value) diff --git a/core/apps/tracking/migrations/0001_initial.py b/core/apps/tracking/migrations/0001_initial.py index 0118a3a..c2cbec0 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-11 17:58 +# Generated by Django 6.0.3 on 2026-03-15 03:05 import pghistory.utils from django.db import migrations, models diff --git a/core/apps/tutor/api/v1/__init__.py b/core/apps/tutor/api/v1/__init__.py index d68c915..50a9e62 100644 --- a/core/apps/tutor/api/v1/__init__.py +++ b/core/apps/tutor/api/v1/__init__.py @@ -11,7 +11,6 @@ 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) @@ -43,7 +42,6 @@ def resolve_content(allocation: Allocation): @router.get("/allocation", response=PaginatedResponse[AllocationSchema]) -@tutor_required() async def get_allocation( request: HttpRequest, page: Annotated[int, functions.Query(1, ge=1)], @@ -62,7 +60,6 @@ class AllocationStatsSchema(Schema): @router.get("/allocation/stats", response=AllocationStatsSchema) -@tutor_required() async def get_allocation_stats(request: HttpRequest): return await Allocation.get_stats(tutor_id=request.auth) @@ -76,7 +73,6 @@ class GradeAppealSchema(AppealSchema): @router.get("/{app_label}/{model}/{id}/appeal", response=PaginatedResponse[GradeAppealSchema]) -@tutor_required() async def get_appeals( request: HttpRequest, app_label: AppealAppLabel, @@ -95,7 +91,6 @@ class AppealReviewSchema(Schema): @router.post("/appeal/{id}") -@tutor_required() async def review_appeal(request: HttpRequest, id: int, review: AppealReviewSchema): await Allocation.review_appeal(tutor_id=request.auth, appeal_id=id, review=review.review, reviewer_id=request.auth) diff --git a/core/apps/tutor/api/v1/assignment.py b/core/apps/tutor/api/v1/assignment.py index aa8a35d..0bad16c 100644 --- a/core/apps/tutor/api/v1/assignment.py +++ b/core/apps/tutor/api/v1/assignment.py @@ -11,7 +11,7 @@ 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 +from apps.tutor.decorator import allocation_required router = Router(by_alias=True) @@ -23,7 +23,6 @@ def resolve_grading_date(grade: Grade): @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): @@ -56,7 +55,6 @@ def resolve_answer(grade: Grade): @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( @@ -80,7 +78,6 @@ async def get_assignment_grade_paper(request: HttpRequest, id: str, grade_id: in @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( @@ -90,7 +87,6 @@ async def get_assignment_rubric(request: HttpRequest, id: str): @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( diff --git a/core/apps/tutor/api/v1/discussion.py b/core/apps/tutor/api/v1/discussion.py index f28c37e..b3ef2e0 100644 --- a/core/apps/tutor/api/v1/discussion.py +++ b/core/apps/tutor/api/v1/discussion.py @@ -14,7 +14,7 @@ ) 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 +from apps.tutor.decorator import allocation_required router = Router(by_alias=True) @@ -26,7 +26,6 @@ def resolve_grading_date(grade: Grade): @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): @@ -57,7 +56,6 @@ def resolve_posts(grade: Grade): @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( @@ -85,7 +83,6 @@ class DiscussionFeedbackSaveSchema(Schema): @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( diff --git a/core/apps/tutor/api/v1/exam.py b/core/apps/tutor/api/v1/exam.py index 4853e41..0357da2 100644 --- a/core/apps/tutor/api/v1/exam.py +++ b/core/apps/tutor/api/v1/exam.py @@ -10,7 +10,7 @@ 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 +from apps.tutor.decorator import allocation_required router = Router(by_alias=True) @@ -22,7 +22,6 @@ def resolve_grading_date(grade: Grade): @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): @@ -65,7 +64,6 @@ def resolve_questions(grade: Grade): @router.get("/exam/{id}/grade/{grade_id}", response=TutorExamGradePaperSchema) -@tutor_required() @allocation_required("exam", "exam") async def get_exam_grade_paper( request: HttpRequest, id: str, grade_id: int, question_id: int | None = functions.Query(None, alias="questionId") @@ -99,7 +97,6 @@ async def get_exam_grade_paper( @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( diff --git a/core/apps/tutor/apps.py b/core/apps/tutor/apps.py index 4fa808a..915baaa 100644 --- a/core/apps/tutor/apps.py +++ b/core/apps/tutor/apps.py @@ -3,6 +3,5 @@ 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 index cbf12a5..88becde 100644 --- a/core/apps/tutor/decorator.py +++ b/core/apps/tutor/decorator.py @@ -8,19 +8,6 @@ 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) diff --git a/core/apps/tutor/migrations/0001_initial.py b/core/apps/tutor/migrations/0001_initial.py index a153eba..45ac204 100644 --- a/core/apps/tutor/migrations/0001_initial.py +++ b/core/apps/tutor/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 6.0.3 on 2026-03-11 17:59 +# Generated by Django 6.0.3 on 2026-03-15 03:05 import django.db.models.deletion import pgtrigger.compiler @@ -26,7 +26,7 @@ class Migration(migrations.Migration): ('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')), + ('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={ @@ -46,7 +46,7 @@ class Migration(migrations.Migration): ('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')), + ('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')), diff --git a/core/apps/tutor/models.py b/core/apps/tutor/models.py index ef9b59e..de7aa50 100644 --- a/core/apps/tutor/models.py +++ b/core/apps/tutor/models.py @@ -41,7 +41,7 @@ class Allocation(TimeStampedMixin): tutor = ForeignKey(User, CASCADE, verbose_name=_("Tutor")) active = BooleanField(_("Active"), default=True) - limit_choices_to = {"model__in": TUTORING_MODELS.keys()} + limit_choices_to = {"model__in": list(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") diff --git a/core/apps/warehouse/apps.py b/core/apps/warehouse/apps.py index 190ab01..c49b023 100644 --- a/core/apps/warehouse/apps.py +++ b/core/apps/warehouse/apps.py @@ -3,6 +3,5 @@ class WarehouseConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" name = "apps.warehouse" verbose_name = _("Warehouse") diff --git a/core/apps/warehouse/migrations/0001_initial.py b/core/apps/warehouse/migrations/0001_initial.py index f8bde3a..62adf0b 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-11 17:58 +# Generated by Django 6.0.3 on 2026-03-15 03:05 from django.db import migrations, models diff --git a/core/conftest.py b/core/conftest.py index 1800006..accc07e 100644 --- a/core/conftest.py +++ b/core/conftest.py @@ -34,6 +34,11 @@ def mimesis(): test_user_password = os.environ.get("DJANGO_SUPERUSER_PASSWORD", "1111") +@pytest.fixture +def client(): + return Client(SERVER_NAME="student.testserver") + + class AdminUser: def __init__(self, client: Client): self.client = client diff --git a/core/locale/en/LC_MESSAGES/django.po b/core/locale/en/LC_MESSAGES/django.po index d15328f..1316e78 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-12 02:59+0900\n" +"POT-Creation-Date: 2026-03-15 12:02+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -34,11 +34,11 @@ msgstr "" msgid "Login Tokens" msgstr "" -#: apps/account/admin.py:44 apps/account/models.py:594 +#: apps/account/admin.py:44 apps/account/models.py:624 msgid "OTP Log" msgstr "" -#: apps/account/admin.py:45 apps/account/models.py:595 +#: apps/account/admin.py:45 apps/account/models.py:625 msgid "OTP Logs" msgstr "" @@ -52,15 +52,15 @@ msgid "" "Temporary password created for {user}: {password}. Expires in {hours} hours." msgstr "" -#: apps/account/admin.py:87 minima/settings.py:176 +#: apps/account/admin.py:87 minima/settings.py:179 msgid "English" msgstr "" -#: apps/account/admin.py:87 minima/settings.py:176 +#: apps/account/admin.py:87 minima/settings.py:179 msgid "Korean" msgstr "" -#: apps/account/apps.py:8 apps/warehouse/views.py:41 +#: apps/account/apps.py:7 apps/warehouse/views.py:41 msgid "Account" msgstr "" @@ -201,53 +201,53 @@ msgid "" "copying and pasting the following link into your browser:" msgstr "" -#: apps/account/models.py:113 apps/operation/models.py:172 -#: apps/partner/models.py:46 apps/partner/models.py:87 apps/sso/models.py:32 +#: apps/account/models.py:114 apps/operation/models.py:172 +#: apps/partner/models.py:48 apps/partner/models.py:94 apps/sso/models.py:33 msgid "Email" msgstr "" -#: apps/account/models.py:114 apps/assignment/models.py:523 -#: apps/assignment/models.py:541 apps/assignment/models.py:560 +#: apps/account/models.py:115 apps/assignment/models.py:514 +#: apps/assignment/models.py:532 apps/assignment/models.py:551 #: 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 #: apps/competency/models.py:236 apps/learning/models.py:332 #: apps/operation/models.py:73 apps/operation/models.py:171 -#: apps/operation/models.py:205 apps/partner/models.py:43 -#: apps/partner/models.py:66 apps/partner/models.py:86 -#: apps/partner/models.py:176 apps/store/models.py:55 apps/store/models.py:102 +#: apps/operation/models.py:205 apps/partner/models.py:44 +#: apps/partner/models.py:73 apps/partner/models.py:93 +#: apps/partner/models.py:183 apps/store/models.py:55 apps/store/models.py:102 #: apps/store/models.py:342 msgid "Name" msgstr "" -#: apps/account/models.py:115 apps/operation/models.py:175 +#: apps/account/models.py:116 apps/operation/models.py:175 msgid "Avatar" msgstr "" -#: apps/account/models.py:116 +#: apps/account/models.py:117 msgid "Nickname" msgstr "" -#: apps/account/models.py:117 apps/partner/models.py:45 -#: apps/partner/models.py:90 +#: apps/account/models.py:118 apps/partner/models.py:47 +#: apps/partner/models.py:97 msgid "Phone" msgstr "" -#: apps/account/models.py:118 apps/competency/certificate.py:285 -#: apps/partner/models.py:88 +#: apps/account/models.py:119 apps/competency/certificate.py:285 +#: apps/partner/models.py:95 msgid "Birth Date" msgstr "" -#: apps/account/models.py:119 apps/content/models.py:228 +#: apps/account/models.py:120 apps/content/models.py:228 #: apps/content/models.py:256 msgid "Language" msgstr "" -#: apps/account/models.py:120 +#: apps/account/models.py:121 msgid "Preferences" msgstr "" -#: apps/account/models.py:122 apps/assistant/models.py:41 +#: apps/account/models.py:123 apps/assistant/models.py:41 #: 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 @@ -257,109 +257,110 @@ msgstr "" msgid "Active" msgstr "" -#: apps/account/models.py:123 +#: apps/account/models.py:124 msgid "Staff" msgstr "" -#: apps/account/models.py:124 +#: apps/account/models.py:125 msgid "Superuser" msgstr "" -#: apps/account/models.py:135 apps/account/models.py:549 -#: apps/account/models.py:560 apps/account/models.py:583 +#: apps/account/models.py:136 apps/account/models.py:579 +#: apps/account/models.py:590 apps/account/models.py:613 #: apps/assistant/models.py:27 apps/assistant/models.py:40 #: apps/competency/models.py:139 apps/content/models.py:308 -#: apps/content/models.py:482 apps/learning/models.py:79 +#: apps/content/models.py:466 apps/learning/models.py:79 #: apps/learning/models.py:497 apps/operation/models.py:159 #: apps/operation/models.py:546 apps/operation/models.py:617 #: apps/operation/models.py:733 apps/partner/admin.py:56 -#: apps/partner/models.py:96 apps/partner/models.py:211 apps/sso/models.py:29 -#: apps/sso/models.py:49 apps/store/models.py:147 +#: apps/partner/models.py:103 apps/partner/models.py:218 +#: apps/preview/models.py:11 apps/sso/models.py:30 apps/sso/models.py:50 +#: apps/store/models.py:147 msgid "User" msgstr "" -#: apps/account/models.py:136 +#: apps/account/models.py:137 msgid "Users" msgstr "" -#: apps/account/models.py:297 +#: apps/account/models.py:327 msgid "Account Activation" msgstr "" -#: apps/account/models.py:315 +#: apps/account/models.py:345 msgid "Email Change" msgstr "" -#: apps/account/models.py:326 +#: apps/account/models.py:356 msgid "Password Change" msgstr "" -#: apps/account/models.py:550 +#: apps/account/models.py:580 msgid "Password" msgstr "" -#: apps/account/models.py:551 apps/account/models.py:562 -#: apps/account/models.py:574 apps/competency/models.py:222 -#: apps/competency/models.py:300 apps/sso/models.py:50 apps/store/models.py:344 +#: apps/account/models.py:581 apps/account/models.py:592 +#: apps/account/models.py:604 apps/competency/models.py:222 +#: apps/competency/models.py:300 apps/sso/models.py:51 apps/store/models.py:344 msgid "Expires" msgstr "" -#: apps/account/models.py:554 +#: apps/account/models.py:584 msgid "Temporary Password" msgstr "" -#: apps/account/models.py:555 +#: apps/account/models.py:585 msgid "Temporary Passwords" msgstr "" -#: apps/account/models.py:561 apps/account/models.py:567 -#: apps/account/models.py:573 apps/operation/models.py:618 +#: apps/account/models.py:591 apps/account/models.py:597 +#: apps/account/models.py:603 apps/operation/models.py:618 msgid "Token" msgstr "" -#: apps/account/models.py:563 +#: apps/account/models.py:593 msgid "IP Address" msgstr "" -#: apps/account/models.py:564 +#: apps/account/models.py:594 msgid "User Agent" msgstr "" -#: apps/account/models.py:568 +#: apps/account/models.py:598 msgid "Tokens" msgstr "" -#: apps/account/models.py:577 +#: apps/account/models.py:607 msgid "Blacklisted Token" msgstr "" -#: apps/account/models.py:578 +#: apps/account/models.py:608 msgid "Blacklisted Tokens" msgstr "" -#: apps/account/models.py:584 apps/competency/models.py:60 +#: apps/account/models.py:614 apps/competency/models.py:60 #: apps/competency/models.py:101 apps/competency/models.py:121 #: apps/operation/models.py:193 apps/store/models.py:101 msgid "Code" msgstr "" -#: apps/account/models.py:585 +#: apps/account/models.py:615 msgid "Success" msgstr "" -#: apps/account/models.py:586 +#: apps/account/models.py:616 msgid "Fingerprint" msgstr "" -#: apps/account/models.py:587 +#: apps/account/models.py:617 msgid "Device Type" msgstr "" -#: apps/account/models.py:589 +#: apps/account/models.py:619 msgid "Consumer Type" msgstr "" -#: apps/account/models.py:590 +#: apps/account/models.py:620 msgid "Consumer ID" msgstr "" @@ -447,256 +448,256 @@ msgstr "" msgid "Grading Histories" msgstr "" -#: apps/assignment/admin.py:148 apps/assignment/models.py:425 +#: apps/assignment/admin.py:148 apps/assignment/models.py:416 #: apps/course/admin.py:132 apps/course/admin.py:149 -#: 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 +#: apps/discussion/admin.py:103 apps/discussion/models.py:430 +#: apps/exam/admin.py:133 apps/exam/models.py:403 apps/quiz/models.py:360 msgid "Grade" msgstr "" -#: apps/assignment/admin.py:166 apps/common/models.py:166 +#: apps/assignment/admin.py:166 apps/common/models.py:163 #: apps/discussion/admin.py:122 apps/exam/admin.py:152 msgid "Earned Details" msgstr "" -#: apps/assignment/admin.py:171 apps/common/models.py:171 +#: apps/assignment/admin.py:171 apps/common/models.py:168 #: apps/discussion/admin.py:132 apps/exam/admin.py:157 msgid "Feedback" msgstr "" -#: apps/assignment/apps.py:8 apps/assignment/models.py:131 -#: apps/assignment/models.py:265 apps/warehouse/models.py:144 +#: apps/assignment/apps.py:8 apps/assignment/models.py:123 +#: apps/assignment/models.py:257 apps/warehouse/models.py:144 #: apps/warehouse/views.py:53 msgid "Assignment" msgstr "" -#: apps/assignment/models.py:81 apps/assistant/models.py:39 +#: apps/assignment/models.py:73 apps/assistant/models.py:39 #: 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/discussion/models.py:71 apps/discussion/models.py:317 +#: apps/exam/models.py:68 apps/operation/models.py:128 #: apps/operation/models.py:192 apps/operation/models.py:376 #: apps/operation/models.py:547 apps/operation/models.py:638 -#: apps/operation/models.py:760 apps/quiz/models.py:72 apps/survey/models.py:38 +#: apps/operation/models.py:760 apps/quiz/models.py:72 apps/survey/models.py:37 msgid "Title" msgstr "" -#: apps/assignment/models.py:82 apps/assignment/models.py:524 -#: apps/assignment/models.py:542 apps/assignment/models.py:561 +#: apps/assignment/models.py:74 apps/assignment/models.py:515 +#: apps/assignment/models.py:533 apps/assignment/models.py:552 #: apps/common/models.py:122 apps/competency/models.py:141 #: apps/competency/models.py:171 apps/competency/models.py:237 -#: apps/course/models.py:92 apps/discussion/models.py:80 apps/exam/models.py:77 +#: apps/course/models.py:92 apps/discussion/models.py:72 apps/exam/models.py:69 #: apps/learning/models.py:333 apps/operation/models.py:206 #: apps/operation/models.py:639 apps/operation/models.py:761 -#: 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:39 +#: apps/partner/models.py:46 apps/partner/models.py:74 +#: apps/partner/models.py:184 apps/quiz/models.py:73 apps/store/models.py:56 +#: apps/store/models.py:103 apps/survey/models.py:38 msgid "Description" msgstr "" -#: apps/assignment/models.py:83 apps/assignment/models.py:124 +#: apps/assignment/models.py:75 apps/assignment/models.py:116 #: 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:40 -#: apps/survey/models.py:79 +#: apps/discussion/models.py:73 apps/discussion/models.py:120 +#: apps/exam/models.py:70 apps/exam/models.py:139 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 msgid "Owner" msgstr "" -#: apps/assignment/models.py:86 apps/assignment/models.py:106 -#: apps/assignment/models.py:126 apps/discussion/models.py:84 -#: 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/assignment/models.py:78 apps/assignment/models.py:98 +#: apps/assignment/models.py:118 apps/discussion/models.py:76 +#: apps/discussion/models.py:96 apps/discussion/models.py:122 +#: apps/exam/models.py:74 apps/exam/models.py:102 apps/exam/models.py:141 #: apps/quiz/models.py:78 apps/quiz/models.py:94 apps/quiz/models.py:127 -#: apps/survey/models.py:43 apps/survey/models.py:58 apps/survey/models.py:80 +#: apps/survey/models.py:42 apps/survey/models.py:57 apps/survey/models.py:79 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:44 +#: apps/assignment/models.py:79 apps/discussion/models.py:77 +#: apps/exam/models.py:75 apps/quiz/models.py:79 apps/survey/models.py:43 msgid "Question Pools" msgstr "" -#: apps/assignment/models.py:107 apps/assignment/models.py:114 -#: apps/assignment/models.py:267 apps/discussion/models.py:114 -#: apps/discussion/models.py:204 apps/exam/admin.py:76 apps/exam/models.py:112 -#: apps/exam/models.py:118 apps/exam/models.py:135 apps/operation/admin.py:131 +#: apps/assignment/models.py:99 apps/assignment/models.py:106 +#: apps/assignment/models.py:259 apps/discussion/models.py:106 +#: apps/discussion/models.py:196 apps/exam/admin.py:76 apps/exam/models.py:104 +#: apps/exam/models.py:110 apps/exam/models.py:127 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:60 apps/survey/models.py:68 +#: apps/quiz/models.py:115 apps/survey/models.py:59 apps/survey/models.py:67 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:61 +#: apps/assignment/models.py:100 apps/discussion/models.py:98 +#: apps/exam/models.py:105 apps/quiz/models.py:96 apps/survey/models.py:60 msgid "Supplement" msgstr "" -#: apps/assignment/models.py:109 +#: apps/assignment/models.py:101 msgid "Attachment File Count" msgstr "" -#: apps/assignment/models.py:110 +#: apps/assignment/models.py:102 msgid "Attachment File Types" msgstr "" -#: apps/assignment/models.py:111 +#: apps/assignment/models.py:103 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:258 +#: apps/assignment/models.py:107 apps/discussion/models.py:107 +#: apps/exam/admin.py:77 apps/exam/models.py:111 apps/exam/models.py:250 #: apps/quiz/admin.py:51 apps/quiz/models.py:102 apps/quiz/models.py:243 -#: apps/survey/models.py:69 +#: apps/survey/models.py:68 msgid "Questions" msgstr "" -#: apps/assignment/models.py:125 apps/course/models.py:123 -#: apps/discussion/models.py:129 apps/exam/models.py:148 +#: apps/assignment/models.py:117 apps/course/models.py:123 +#: apps/discussion/models.py:121 apps/exam/models.py:140 #: apps/operation/models.py:196 msgid "Honor Code" msgstr "" -#: apps/assignment/models.py:127 apps/assignment/models.py:527 -#: apps/assignment/models.py:540 +#: apps/assignment/models.py:119 apps/assignment/models.py:518 +#: apps/assignment/models.py:531 msgid "Rubric" msgstr "" -#: apps/assignment/models.py:128 +#: apps/assignment/models.py:120 msgid "Sample Attachment" msgstr "" -#: apps/assignment/models.py:132 +#: apps/assignment/models.py:124 msgid "Assignments" msgstr "" -#: apps/assignment/models.py:266 apps/course/models.py:572 -#: apps/discussion/models.py:203 apps/exam/models.py:257 +#: apps/assignment/models.py:258 apps/course/models.py:572 +#: apps/discussion/models.py:195 apps/exam/models.py:249 #: apps/operation/models.py:478 apps/quiz/models.py:242 msgid "Learner" msgstr "" -#: apps/assignment/models.py:268 apps/discussion/models.py:205 -#: apps/exam/models.py:259 apps/quiz/models.py:244 +#: apps/assignment/models.py:260 apps/discussion/models.py:197 +#: apps/exam/models.py:251 apps/quiz/models.py:244 msgid "Retry" msgstr "" -#: apps/assignment/models.py:271 apps/assignment/models.py:388 -#: apps/assignment/models.py:421 apps/assignment/models.py:486 -#: 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 +#: apps/assignment/models.py:263 apps/assignment/models.py:379 +#: apps/assignment/models.py:412 apps/assignment/models.py:477 +#: apps/discussion/models.py:200 apps/discussion/models.py:315 +#: apps/discussion/models.py:426 apps/exam/models.py:254 +#: apps/exam/models.py:373 apps/exam/models.py:383 apps/exam/models.py:399 +#: apps/quiz/models.py:247 apps/quiz/models.py:347 apps/quiz/models.py:357 msgid "Attempt" msgstr "" -#: apps/assignment/models.py:272 apps/discussion/models.py:209 -#: apps/exam/models.py:263 apps/quiz/models.py:248 +#: apps/assignment/models.py:264 apps/discussion/models.py:201 +#: apps/exam/models.py:255 apps/quiz/models.py:248 msgid "Attempts" msgstr "" -#: apps/assignment/models.py:389 apps/operation/models.py:223 +#: apps/assignment/models.py:380 apps/operation/models.py:223 #: apps/operation/models.py:450 msgid "Answer" msgstr "" -#: apps/assignment/models.py:390 +#: apps/assignment/models.py:381 msgid "Extracted Text" msgstr "" -#: apps/assignment/models.py:393 apps/exam/models.py:396 -#: apps/quiz/models.py:352 apps/survey/models.py:125 +#: apps/assignment/models.py:384 apps/exam/models.py:387 +#: apps/quiz/models.py:351 apps/survey/models.py:124 msgid "Submission" msgstr "" -#: apps/assignment/models.py:394 apps/exam/models.py:397 -#: apps/quiz/models.py:353 apps/survey/models.py:126 +#: apps/assignment/models.py:385 apps/exam/models.py:388 +#: apps/quiz/models.py:352 apps/survey/models.py:125 msgid "Submissions" msgstr "" -#: apps/assignment/models.py:422 apps/course/models.py:779 -#: apps/discussion/models.py:432 apps/exam/models.py:409 +#: apps/assignment/models.py:413 apps/course/models.py:779 +#: apps/discussion/models.py:427 apps/exam/models.py:400 msgid "Grader" msgstr "" -#: apps/assignment/models.py:426 apps/discussion/models.py:436 -#: apps/exam/models.py:413 apps/quiz/models.py:362 +#: apps/assignment/models.py:417 apps/discussion/models.py:431 +#: apps/exam/models.py:404 apps/quiz/models.py:361 msgid "Grades" msgstr "" -#: apps/assignment/models.py:481 +#: apps/assignment/models.py:472 msgid "Not Detected" msgstr "" -#: apps/assignment/models.py:482 +#: apps/assignment/models.py:473 msgid "Detected" msgstr "" -#: apps/assignment/models.py:483 +#: apps/assignment/models.py:474 msgid "Excused" msgstr "" -#: apps/assignment/models.py:484 +#: apps/assignment/models.py:475 msgid "Not Resolved" msgstr "" -#: apps/assignment/models.py:487 apps/store/models.py:54 +#: apps/assignment/models.py:478 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:488 +#: apps/assignment/models.py:479 msgid "Similarity Percentage" msgstr "" -#: apps/assignment/models.py:489 +#: apps/assignment/models.py:480 msgid "Flagged Text" msgstr "" -#: apps/assignment/models.py:490 +#: apps/assignment/models.py:481 msgid "Source Text" msgstr "" -#: apps/assignment/models.py:491 +#: apps/assignment/models.py:482 msgid "Source User ID" msgstr "" -#: apps/assignment/models.py:492 apps/store/models.py:385 +#: apps/assignment/models.py:483 apps/store/models.py:385 msgid "Reason" msgstr "" -#: apps/assignment/models.py:495 +#: apps/assignment/models.py:486 msgid "Plagiarism Check" msgstr "" -#: apps/assignment/models.py:496 +#: apps/assignment/models.py:487 msgid "Plagiarism Checks" msgstr "" -#: apps/assignment/models.py:528 +#: apps/assignment/models.py:519 msgid "Rubrics" msgstr "" -#: apps/assignment/models.py:545 +#: apps/assignment/models.py:536 msgid "Rubric Criterion" msgstr "" -#: apps/assignment/models.py:546 +#: apps/assignment/models.py:537 msgid "Rubric Criteria" msgstr "" -#: apps/assignment/models.py:559 +#: apps/assignment/models.py:550 msgid "Criterion" msgstr "" -#: apps/assignment/models.py:562 apps/exam/models.py:115 apps/quiz/models.py:98 +#: apps/assignment/models.py:553 apps/exam/models.py:107 apps/quiz/models.py:98 msgid "Point" msgstr "" -#: apps/assignment/models.py:565 +#: apps/assignment/models.py:556 msgid "Performance Level" msgstr "" -#: apps/assignment/models.py:566 +#: apps/assignment/models.py:557 msgid "Performance Levels" msgstr "" @@ -709,8 +710,8 @@ msgstr "" msgid "AI Assistant" msgstr "" -#: apps/assistant/models.py:28 apps/content/models.py:484 -#: apps/content/models.py:492 apps/course/models.py:778 +#: apps/assistant/models.py:28 apps/content/models.py:468 +#: apps/content/models.py:473 apps/course/models.py:778 #: apps/learning/models.py:500 apps/learning/models.py:528 msgid "Note" msgstr "" @@ -753,7 +754,7 @@ msgstr "" msgid "Path" msgstr "" -#: apps/assistant/models.py:67 apps/common/models.py:172 +#: apps/assistant/models.py:67 apps/common/models.py:169 #: apps/store/models.py:357 apps/store/models.py:378 msgid "Completed" msgstr "" @@ -794,7 +795,7 @@ msgstr "" msgid "Open" msgstr "" -#: apps/common/apps.py:16 +#: apps/common/apps.py:15 msgid "Apps" msgstr "" @@ -802,7 +803,7 @@ msgstr "" msgid "ID" msgstr "" -#: apps/common/models.py:39 +#: apps/common/models.py:39 apps/preview/models.py:10 msgid "Created" msgstr "" @@ -829,7 +830,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:78 +#: apps/learning/models.py:334 apps/store/models.py:57 apps/survey/models.py:77 msgid "Thumbnail" msgstr "" @@ -837,12 +838,12 @@ msgstr "" msgid "Featured" msgstr "" -#: apps/common/models.py:126 apps/content/models.py:84 apps/exam/models.py:111 -#: apps/survey/models.py:59 +#: apps/common/models.py:126 apps/content/models.py:84 apps/exam/models.py:103 +#: apps/survey/models.py:58 msgid "Format" msgstr "" -#: apps/common/models.py:127 apps/content/models.py:85 apps/exam/models.py:150 +#: apps/common/models.py:127 apps/content/models.py:85 apps/exam/models.py:142 msgid "Duration" msgstr "" @@ -871,52 +872,63 @@ msgid "Lock" msgstr "" #: apps/common/models.py:155 apps/content/models.py:314 -#: apps/content/models.py:485 +#: apps/content/models.py:469 msgid "Context Key" msgstr "" -#: apps/common/models.py:157 apps/content/models.py:316 -#: apps/content/models.py:487 -msgid "Realm" -msgstr "" - -#: apps/common/models.py:167 +#: apps/common/models.py:164 msgid "Possible Point" msgstr "" -#: apps/common/models.py:168 +#: apps/common/models.py:165 msgid "Earned Point" msgstr "" -#: apps/common/models.py:169 apps/course/models.py:774 +#: apps/common/models.py:166 apps/course/models.py:774 msgid "Score" msgstr "" -#: apps/common/models.py:170 apps/content/models.py:313 +#: apps/common/models.py:167 apps/content/models.py:313 #: apps/course/models.py:776 msgid "Passed" msgstr "" -#: apps/common/models.py:173 apps/course/models.py:777 +#: apps/common/models.py:170 apps/course/models.py:777 msgid "Confirmed" msgstr "" -#: apps/common/models.py:180 +#: apps/common/models.py:177 msgid "Cannot confirm without completion" msgstr "" -#: apps/common/models.py:185 +#: apps/common/models.py:182 msgid "Grading Due Days" msgstr "" -#: apps/common/models.py:186 +#: apps/common/models.py:183 msgid "Appeal Deadline Days" msgstr "" -#: apps/common/models.py:187 +#: apps/common/models.py:184 msgid "Confirm Due Days" msgstr "" +#: apps/common/policy.py:6 apps/studio/apps.py:7 +msgid "Studio" +msgstr "" + +#: apps/common/policy.py:7 apps/tutor/apps.py:7 apps/tutor/models.py:41 +msgid "Tutor" +msgstr "" + +#: apps/common/policy.py:8 apps/desk/apps.py:7 +msgid "Desk" +msgstr "" + +#: apps/common/policy.py:9 apps/preview/api/v1.py:43 apps/preview/apps.py:7 +msgid "Preview" +msgstr "" + #: apps/common/translation.py:5 msgid "Add new item" msgstr "" @@ -1316,19 +1328,7 @@ msgstr "" msgid "Please correct the errors below." msgstr "" -#: apps/common/util.py:100 -msgid "Student" -msgstr "" - -#: apps/common/util.py:101 -msgid "Studio" -msgstr "" - -#: apps/common/util.py:102 apps/tutor/apps.py:8 apps/tutor/models.py:41 -msgid "Tutor" -msgstr "" - -#: apps/competency/apps.py:8 apps/warehouse/views.py:44 +#: apps/competency/apps.py:7 apps/warehouse/views.py:44 msgid "Competency" msgstr "" @@ -1431,7 +1431,7 @@ msgid "Badge Skills" msgstr "" #: apps/competency/models.py:208 apps/competency/models.py:281 -#: apps/partner/apps.py:8 apps/partner/models.py:52 apps/partner/models.py:65 +#: apps/partner/apps.py:7 apps/partner/models.py:54 apps/partner/models.py:72 #: apps/warehouse/models.py:115 apps/warehouse/views.py:43 msgid "Partner" msgstr "" @@ -1602,7 +1602,7 @@ msgstr "" msgid "Public Access" msgstr "" -#: apps/content/admin.py:30 apps/content/models.py:227 apps/quiz/apps.py:8 +#: apps/content/admin.py:30 apps/content/models.py:227 apps/quiz/apps.py:7 #: apps/quiz/models.py:130 apps/quiz/models.py:241 apps/warehouse/models.py:134 #: apps/warehouse/views.py:47 msgid "Quiz" @@ -1612,7 +1612,7 @@ msgstr "" msgid "Create Quiz" msgstr "" -#: apps/content/apps.py:8 apps/warehouse/views.py:45 +#: apps/content/apps.py:7 apps/warehouse/views.py:45 msgid "Content" msgstr "" @@ -1654,7 +1654,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:483 +#: apps/content/models.py:309 apps/content/models.py:467 #: apps/course/models.py:381 apps/warehouse/models.py:125 msgid "Media" msgstr "" @@ -1691,7 +1691,7 @@ msgstr "" msgid "Public Access Medias" msgstr "" -#: apps/content/models.py:257 apps/discussion/models.py:327 +#: apps/content/models.py:257 apps/discussion/models.py:318 #: apps/operation/models.py:129 apps/operation/models.py:548 #: apps/operation/models.py:700 msgid "Body" @@ -1721,15 +1721,15 @@ msgstr "" msgid "Watch Rate" msgstr "" -#: apps/content/models.py:321 apps/warehouse/models.py:127 +#: apps/content/models.py:318 apps/warehouse/models.py:127 msgid "Watch" msgstr "" -#: apps/content/models.py:322 +#: apps/content/models.py:319 msgid "Watches" msgstr "" -#: apps/content/models.py:493 +#: apps/content/models.py:474 msgid "Notes" msgstr "" @@ -1750,7 +1750,7 @@ msgstr "" msgid "Related Courses" msgstr "" -#: apps/course/apps.py:8 apps/course/models.py:129 apps/course/models.py:282 +#: apps/course/apps.py:7 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 @@ -2188,8 +2188,8 @@ msgstr "" msgid "Course Instructors" msgstr "" -#: 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/course/models.py:340 apps/survey/apps.py:7 apps/survey/models.py:85 +#: apps/survey/models.py:119 apps/warehouse/models.py:130 #: apps/warehouse/views.py:46 msgid "Survey" msgstr "" @@ -2301,7 +2301,7 @@ msgstr "" msgid "Gradebooks" msgstr "" -#: apps/discussion/admin.py:117 apps/discussion/models.py:330 +#: apps/discussion/admin.py:117 apps/discussion/models.py:321 msgid "Post" msgstr "" @@ -2313,116 +2313,116 @@ msgstr "" msgid "Tutor Assessment" msgstr "" -#: apps/discussion/apps.py:8 apps/discussion/models.py:133 -#: apps/discussion/models.py:202 apps/warehouse/models.py:150 +#: apps/discussion/apps.py:8 apps/discussion/models.py:125 +#: apps/discussion/models.py:194 apps/warehouse/models.py:150 #: apps/warehouse/views.py:62 msgid "Discussion" msgstr "" -#: apps/discussion/models.py:105 +#: apps/discussion/models.py:97 msgid "Directive" msgstr "" -#: apps/discussion/models.py:107 +#: apps/discussion/models.py:99 msgid "Post Point" msgstr "" -#: apps/discussion/models.py:108 +#: apps/discussion/models.py:100 msgid "Reply Point" msgstr "" -#: apps/discussion/models.py:109 +#: apps/discussion/models.py:101 msgid "Tutor Assessment Point" msgstr "" -#: apps/discussion/models.py:110 +#: apps/discussion/models.py:102 msgid "Post Min Characters" msgstr "" -#: apps/discussion/models.py:111 +#: apps/discussion/models.py:103 msgid "Reply Min Characters" msgstr "" -#: apps/discussion/models.py:134 +#: apps/discussion/models.py:126 msgid "Discussions" msgstr "" -#: apps/discussion/models.py:325 apps/operation/models.py:787 +#: apps/discussion/models.py:316 apps/operation/models.py:787 msgid "Parent" msgstr "" -#: apps/discussion/models.py:331 +#: apps/discussion/models.py:322 msgid "Posts" msgstr "" -#: apps/exam/apps.py:8 apps/exam/models.py:153 apps/exam/models.py:256 +#: apps/exam/apps.py:7 apps/exam/models.py:145 apps/exam/models.py:248 #: apps/warehouse/models.py:138 apps/warehouse/views.py:49 msgid "Exam" msgstr "" -#: apps/exam/models.py:79 +#: apps/exam/models.py:71 msgid "Question Composition" msgstr "" -#: apps/exam/models.py:105 apps/survey/models.py:54 +#: apps/exam/models.py:97 apps/survey/models.py:53 msgid "Single Choice" msgstr "" -#: apps/exam/models.py:106 apps/survey/models.py:55 +#: apps/exam/models.py:98 apps/survey/models.py:54 msgid "Text Input" msgstr "" -#: apps/exam/models.py:107 apps/survey/models.py:56 +#: apps/exam/models.py:99 apps/survey/models.py:55 msgid "Number Input" msgstr "" -#: apps/exam/models.py:108 +#: apps/exam/models.py:100 msgid "Essay" msgstr "" -#: apps/exam/models.py:114 apps/quiz/models.py:97 apps/survey/models.py:62 +#: apps/exam/models.py:106 apps/quiz/models.py:97 apps/survey/models.py:61 msgid "Options" msgstr "" -#: apps/exam/models.py:136 apps/quiz/models.py:116 +#: apps/exam/models.py:128 apps/quiz/models.py:116 msgid "Correct Answers" msgstr "" -#: apps/exam/models.py:137 +#: apps/exam/models.py:129 msgid "Correct Criteria" msgstr "" -#: apps/exam/models.py:138 apps/operation/admin.py:153 +#: apps/exam/models.py:130 apps/operation/admin.py:153 #: apps/operation/models.py:479 apps/quiz/models.py:117 msgid "Explanation" msgstr "" -#: apps/exam/models.py:141 apps/quiz/models.py:120 +#: apps/exam/models.py:133 apps/quiz/models.py:120 msgid "Solution" msgstr "" -#: apps/exam/models.py:142 apps/quiz/models.py:121 +#: apps/exam/models.py:134 apps/quiz/models.py:121 msgid "Solutions" msgstr "" -#: apps/exam/models.py:154 +#: apps/exam/models.py:146 msgid "Exams" msgstr "" -#: apps/exam/models.py:383 apps/exam/models.py:393 apps/quiz/models.py:349 -#: apps/survey/models.py:122 +#: apps/exam/models.py:374 apps/exam/models.py:384 apps/quiz/models.py:348 +#: apps/survey/models.py:121 msgid "Answers" msgstr "" -#: apps/exam/models.py:386 +#: apps/exam/models.py:377 msgid "Temporary Answer" msgstr "" -#: apps/exam/models.py:387 +#: apps/exam/models.py:378 msgid "Temporary Answers" msgstr "" -#: apps/learning/apps.py:8 apps/warehouse/views.py:71 +#: apps/learning/apps.py:7 apps/warehouse/views.py:71 msgid "Learning" msgstr "" @@ -2519,8 +2519,8 @@ msgstr "" msgid "User Catalogs" msgstr "" -#: apps/learning/models.py:525 apps/partner/models.py:180 -#: apps/partner/models.py:193 apps/partner/models.py:210 +#: apps/learning/models.py:525 apps/partner/models.py:187 +#: apps/partner/models.py:200 apps/partner/models.py:217 #: apps/warehouse/models.py:117 msgid "Cohort" msgstr "" @@ -2545,7 +2545,7 @@ msgstr "" msgid "Agreement Histories" msgstr "" -#: apps/operation/apps.py:8 apps/warehouse/views.py:42 +#: apps/operation/apps.py:7 apps/warehouse/views.py:42 msgid "Operation" msgstr "" @@ -2697,8 +2697,8 @@ msgstr "" msgid "Grade Appeals" msgstr "" -#: apps/operation/models.py:549 apps/partner/models.py:71 -#: apps/partner/models.py:85 +#: apps/operation/models.py:549 apps/partner/models.py:78 +#: apps/partner/models.py:92 msgid "Group" msgstr "" @@ -2739,7 +2739,7 @@ msgstr "" msgid "Kind" msgstr "" -#: apps/operation/models.py:641 apps/survey/models.py:63 +#: apps/operation/models.py:641 apps/survey/models.py:62 msgid "Mandatory" msgstr "" @@ -2836,140 +2836,160 @@ msgstr "" msgid "Create platform partner" msgstr "" -#: apps/partner/management/commands/create_platform_partner.py:25 +#: apps/partner/management/commands/create_platform_partner.py:26 #, python-format msgid "%s is a LMS for micro learning" msgstr "" -#: apps/partner/models.py:47 +#: apps/partner/models.py:45 +msgid "Realm" +msgstr "" + +#: apps/partner/models.py:49 msgid "Address" msgstr "" -#: apps/partner/models.py:48 +#: apps/partner/models.py:50 msgid "Logo" msgstr "" -#: apps/partner/models.py:49 +#: apps/partner/models.py:51 msgid "Website" msgstr "" -#: apps/partner/models.py:53 +#: apps/partner/models.py:55 msgid "Partners" msgstr "" -#: apps/partner/models.py:68 +#: apps/partner/models.py:66 +msgid "This realm is reserved." +msgstr "" + +#: apps/partner/models.py:75 msgid "Business Number" msgstr "" -#: apps/partner/models.py:72 +#: apps/partner/models.py:79 msgid "Groups" msgstr "" -#: apps/partner/models.py:89 +#: apps/partner/models.py:96 msgid "ID Number" msgstr "" -#: apps/partner/models.py:91 +#: apps/partner/models.py:98 msgid "Team" msgstr "" -#: apps/partner/models.py:92 +#: apps/partner/models.py:99 msgid "Job Position" msgstr "" -#: apps/partner/models.py:93 +#: apps/partner/models.py:100 msgid "Job Title" msgstr "" -#: apps/partner/models.py:94 +#: apps/partner/models.py:101 msgid "Employment Status" msgstr "" -#: apps/partner/models.py:95 +#: apps/partner/models.py:102 msgid "Employment Type" msgstr "" -#: apps/partner/models.py:99 apps/partner/models.py:194 +#: apps/partner/models.py:106 apps/partner/models.py:201 msgid "Member" msgstr "" -#: apps/partner/models.py:100 +#: apps/partner/models.py:107 msgid "Members" msgstr "" -#: apps/partner/models.py:181 +#: apps/partner/models.py:188 msgid "Cohorts" msgstr "" -#: apps/partner/models.py:197 +#: apps/partner/models.py:204 msgid "Cohort Member" msgstr "" -#: apps/partner/models.py:198 +#: apps/partner/models.py:205 msgid "Cohort Members" msgstr "" -#: apps/partner/models.py:208 +#: apps/partner/models.py:215 msgid "Education Manager" msgstr "" -#: apps/partner/models.py:212 +#: apps/partner/models.py:219 msgid "Role" msgstr "" -#: apps/partner/models.py:215 +#: apps/partner/models.py:222 msgid "Cohort Staff" msgstr "" -#: apps/partner/models.py:216 +#: apps/partner/models.py:223 msgid "Cohort Staffs" msgstr "" +#: apps/preview/models.py:12 +msgid "Creator" +msgstr "" + +#: apps/preview/models.py:15 +msgid "Preview User" +msgstr "" + +#: apps/preview/models.py:16 +msgid "Preview Users" +msgstr "" + #: apps/quiz/models.py:75 msgid "Select Count" msgstr "" -#: apps/sso/apps.py:8 +#: apps/sso/apps.py:7 msgid "SSO" msgstr "" -#: apps/sso/models.py:30 apps/sso/models.py:47 +#: apps/sso/models.py:31 apps/sso/models.py:48 msgid "Provider" msgstr "" -#: apps/sso/models.py:31 +#: apps/sso/models.py:32 msgid "Provider User ID" msgstr "" -#: apps/sso/models.py:35 +#: apps/sso/models.py:36 msgid "SSO Account" msgstr "" -#: apps/sso/models.py:36 +#: apps/sso/models.py:37 msgid "SSO Accounts" msgstr "" -#: apps/sso/models.py:45 +#: apps/sso/models.py:46 msgid "State" msgstr "" -#: apps/sso/models.py:46 +#: apps/sso/models.py:47 msgid "Nonce" msgstr "" -#: apps/sso/models.py:48 +#: apps/sso/models.py:49 msgid "Redirect To" msgstr "" -#: apps/sso/models.py:53 +#: apps/sso/models.py:54 msgid "SSO Session" msgstr "" -#: apps/sso/models.py:54 +#: apps/sso/models.py:55 msgid "SSO Sessions" msgstr "" -#: apps/store/apps.py:8 apps/warehouse/views.py:72 +#: apps/store/apps.py:7 apps/warehouse/views.py:72 msgid "Store" msgstr "" @@ -3269,23 +3289,23 @@ msgstr "" msgid "Content Editings" msgstr "" -#: apps/survey/models.py:81 +#: apps/survey/models.py:80 msgid "Complete Message" msgstr "" -#: apps/survey/models.py:82 +#: apps/survey/models.py:81 msgid "Anonymous" msgstr "" -#: apps/survey/models.py:83 +#: apps/survey/models.py:82 msgid "Show Results" msgstr "" -#: apps/survey/models.py:87 +#: apps/survey/models.py:86 msgid "Surveys" msgstr "" -#: apps/survey/models.py:121 +#: apps/survey/models.py:120 msgid "Respondent" msgstr "" @@ -3301,7 +3321,7 @@ msgstr "" msgid "Model" msgstr "" -#: apps/tracking/apps.py:8 +#: apps/tracking/apps.py:7 msgid "Tracking" msgstr "" @@ -3381,7 +3401,7 @@ msgstr "" msgid "Tutor Allocations" msgstr "" -#: apps/warehouse/apps.py:8 +#: apps/warehouse/apps.py:7 msgid "Warehouse" msgstr "" @@ -3517,6 +3537,6 @@ msgstr "" msgid "Courses Passed" msgstr "" -#: minima/settings.py:266 +#: minima/settings.py:274 msgid "Storage" msgstr "" diff --git a/core/locale/ko/LC_MESSAGES/django.mo b/core/locale/ko/LC_MESSAGES/django.mo index 44673bae160b79e74542b2aa7b82b5409c967070..576bc17c684c3986da309e0e1d43f61680f8dfb4 100644 GIT binary patch delta 16937 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 delta 16670 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%0AlL;SK9g z)pG-wtA9(ot}~3CY~#Nocg9Qm{;WuxhT3rTWqsd2#3rNjN)iVs!Q}R;(XL49kv5r9 zEr=EV?}I+YNqlN?Hrgfu4yT{Fmc#W5E9lJ-Y>aves{ z;Sr@dr5cIqAM`j*{U35CXsb_|O36!Z3Z^C>j}B!tEFs>7@2{;?`ceL+e3K~d`wAyg z#*L^q%`YxPgOX|e!yDF*itFDYb3okqt^omYYkIHF6!+!icv`2e3iVq5w{mJg(4*P& bA5Pf%aPhRb@$3Hym{~opN!**wCxZSDFZo-n diff --git a/core/locale/ko/LC_MESSAGES/django.po b/core/locale/ko/LC_MESSAGES/django.po index 857c812..2cb8e95 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-12 02:59+0900\n" +"POT-Creation-Date: 2026-03-15 12:02+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -34,11 +34,11 @@ msgstr "로그인 토큰" msgid "Login Tokens" msgstr "로그인 토큰" -#: apps/account/admin.py:44 apps/account/models.py:594 +#: apps/account/admin.py:44 apps/account/models.py:624 msgid "OTP Log" msgstr "OTP 로그" -#: apps/account/admin.py:45 apps/account/models.py:595 +#: apps/account/admin.py:45 apps/account/models.py:625 msgid "OTP Logs" msgstr "OTP 로그" @@ -54,15 +54,15 @@ msgstr "" "{user}에 대한 임시 비밀번호가 생성되었습니다: {password}. {hours}시간 후에 만" "료됩니다." -#: apps/account/admin.py:87 minima/settings.py:176 +#: apps/account/admin.py:87 minima/settings.py:179 msgid "English" msgstr "영어" -#: apps/account/admin.py:87 minima/settings.py:176 +#: apps/account/admin.py:87 minima/settings.py:179 msgid "Korean" msgstr "한국어" -#: apps/account/apps.py:8 apps/warehouse/views.py:41 +#: apps/account/apps.py:7 apps/warehouse/views.py:41 msgid "Account" msgstr "계정" @@ -222,53 +222,53 @@ msgstr "" "위 버튼이 작동하지 않는 경우, 다음 링크를 복사하여 브라우저에 붙여넣어 비밀번" "호를 재설정할 수 있습니다:" -#: apps/account/models.py:113 apps/operation/models.py:172 -#: apps/partner/models.py:46 apps/partner/models.py:87 apps/sso/models.py:32 +#: apps/account/models.py:114 apps/operation/models.py:172 +#: apps/partner/models.py:48 apps/partner/models.py:94 apps/sso/models.py:33 msgid "Email" msgstr "이메일" -#: apps/account/models.py:114 apps/assignment/models.py:523 -#: apps/assignment/models.py:541 apps/assignment/models.py:560 +#: apps/account/models.py:115 apps/assignment/models.py:514 +#: apps/assignment/models.py:532 apps/assignment/models.py:551 #: 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 #: apps/competency/models.py:236 apps/learning/models.py:332 #: apps/operation/models.py:73 apps/operation/models.py:171 -#: apps/operation/models.py:205 apps/partner/models.py:43 -#: apps/partner/models.py:66 apps/partner/models.py:86 -#: apps/partner/models.py:176 apps/store/models.py:55 apps/store/models.py:102 +#: apps/operation/models.py:205 apps/partner/models.py:44 +#: apps/partner/models.py:73 apps/partner/models.py:93 +#: apps/partner/models.py:183 apps/store/models.py:55 apps/store/models.py:102 #: apps/store/models.py:342 msgid "Name" msgstr "이름" -#: apps/account/models.py:115 apps/operation/models.py:175 +#: apps/account/models.py:116 apps/operation/models.py:175 msgid "Avatar" msgstr "아바타" -#: apps/account/models.py:116 +#: apps/account/models.py:117 msgid "Nickname" msgstr "닉네임" -#: apps/account/models.py:117 apps/partner/models.py:45 -#: apps/partner/models.py:90 +#: apps/account/models.py:118 apps/partner/models.py:47 +#: apps/partner/models.py:97 msgid "Phone" msgstr "전화번호" -#: apps/account/models.py:118 apps/competency/certificate.py:285 -#: apps/partner/models.py:88 +#: apps/account/models.py:119 apps/competency/certificate.py:285 +#: apps/partner/models.py:95 msgid "Birth Date" msgstr "생년월일" -#: apps/account/models.py:119 apps/content/models.py:228 +#: apps/account/models.py:120 apps/content/models.py:228 #: apps/content/models.py:256 msgid "Language" msgstr "언어" -#: apps/account/models.py:120 +#: apps/account/models.py:121 msgid "Preferences" msgstr "환경설정" -#: apps/account/models.py:122 apps/assistant/models.py:41 +#: apps/account/models.py:123 apps/assistant/models.py:41 #: 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 @@ -278,109 +278,110 @@ msgstr "환경설정" msgid "Active" msgstr "활성" -#: apps/account/models.py:123 +#: apps/account/models.py:124 msgid "Staff" msgstr "관리자" -#: apps/account/models.py:124 +#: apps/account/models.py:125 msgid "Superuser" msgstr "최고 관리자" -#: apps/account/models.py:135 apps/account/models.py:549 -#: apps/account/models.py:560 apps/account/models.py:583 +#: apps/account/models.py:136 apps/account/models.py:579 +#: apps/account/models.py:590 apps/account/models.py:613 #: apps/assistant/models.py:27 apps/assistant/models.py:40 #: apps/competency/models.py:139 apps/content/models.py:308 -#: apps/content/models.py:482 apps/learning/models.py:79 +#: apps/content/models.py:466 apps/learning/models.py:79 #: apps/learning/models.py:497 apps/operation/models.py:159 #: apps/operation/models.py:546 apps/operation/models.py:617 #: apps/operation/models.py:733 apps/partner/admin.py:56 -#: apps/partner/models.py:96 apps/partner/models.py:211 apps/sso/models.py:29 -#: apps/sso/models.py:49 apps/store/models.py:147 +#: apps/partner/models.py:103 apps/partner/models.py:218 +#: apps/preview/models.py:11 apps/sso/models.py:30 apps/sso/models.py:50 +#: apps/store/models.py:147 msgid "User" msgstr "사용자" -#: apps/account/models.py:136 +#: apps/account/models.py:137 msgid "Users" msgstr "사용자" -#: apps/account/models.py:297 +#: apps/account/models.py:327 msgid "Account Activation" msgstr "계정 활성화" -#: apps/account/models.py:315 +#: apps/account/models.py:345 msgid "Email Change" msgstr "이메일 변경" -#: apps/account/models.py:326 +#: apps/account/models.py:356 msgid "Password Change" msgstr "비밀번호 변경" -#: apps/account/models.py:550 +#: apps/account/models.py:580 msgid "Password" msgstr "비밀번호" -#: apps/account/models.py:551 apps/account/models.py:562 -#: apps/account/models.py:574 apps/competency/models.py:222 -#: apps/competency/models.py:300 apps/sso/models.py:50 apps/store/models.py:344 +#: apps/account/models.py:581 apps/account/models.py:592 +#: apps/account/models.py:604 apps/competency/models.py:222 +#: apps/competency/models.py:300 apps/sso/models.py:51 apps/store/models.py:344 msgid "Expires" msgstr "만료" -#: apps/account/models.py:554 +#: apps/account/models.py:584 msgid "Temporary Password" msgstr "임시 비밀번호" -#: apps/account/models.py:555 +#: apps/account/models.py:585 msgid "Temporary Passwords" msgstr "임시 비밀번호" -#: apps/account/models.py:561 apps/account/models.py:567 -#: apps/account/models.py:573 apps/operation/models.py:618 +#: apps/account/models.py:591 apps/account/models.py:597 +#: apps/account/models.py:603 apps/operation/models.py:618 msgid "Token" msgstr "토큰" -#: apps/account/models.py:563 +#: apps/account/models.py:593 msgid "IP Address" msgstr "IP 주소" -#: apps/account/models.py:564 +#: apps/account/models.py:594 msgid "User Agent" msgstr "사용자 에이전트" -#: apps/account/models.py:568 +#: apps/account/models.py:598 msgid "Tokens" msgstr "토큰" -#: apps/account/models.py:577 +#: apps/account/models.py:607 msgid "Blacklisted Token" msgstr "차단된 토큰" -#: apps/account/models.py:578 +#: apps/account/models.py:608 msgid "Blacklisted Tokens" msgstr "차단된 토큰" -#: apps/account/models.py:584 apps/competency/models.py:60 +#: apps/account/models.py:614 apps/competency/models.py:60 #: apps/competency/models.py:101 apps/competency/models.py:121 #: apps/operation/models.py:193 apps/store/models.py:101 msgid "Code" msgstr "코드" -#: apps/account/models.py:585 +#: apps/account/models.py:615 msgid "Success" msgstr "성공" -#: apps/account/models.py:586 +#: apps/account/models.py:616 msgid "Fingerprint" msgstr "지문" -#: apps/account/models.py:587 +#: apps/account/models.py:617 msgid "Device Type" msgstr "기기 유형" -#: apps/account/models.py:589 +#: apps/account/models.py:619 msgid "Consumer Type" msgstr "사용 유형" -#: apps/account/models.py:590 +#: apps/account/models.py:620 msgid "Consumer ID" msgstr "사용 ID" @@ -470,256 +471,256 @@ msgstr "채점 기록" msgid "Grading Histories" msgstr "채점 기록" -#: apps/assignment/admin.py:148 apps/assignment/models.py:425 +#: apps/assignment/admin.py:148 apps/assignment/models.py:416 #: apps/course/admin.py:132 apps/course/admin.py:149 -#: 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 +#: apps/discussion/admin.py:103 apps/discussion/models.py:430 +#: apps/exam/admin.py:133 apps/exam/models.py:403 apps/quiz/models.py:360 msgid "Grade" msgstr "채점" -#: apps/assignment/admin.py:166 apps/common/models.py:166 +#: apps/assignment/admin.py:166 apps/common/models.py:163 #: apps/discussion/admin.py:122 apps/exam/admin.py:152 msgid "Earned Details" msgstr "득점 세부사항" -#: apps/assignment/admin.py:171 apps/common/models.py:171 +#: apps/assignment/admin.py:171 apps/common/models.py:168 #: apps/discussion/admin.py:132 apps/exam/admin.py:157 msgid "Feedback" msgstr "피드백" -#: apps/assignment/apps.py:8 apps/assignment/models.py:131 -#: apps/assignment/models.py:265 apps/warehouse/models.py:144 +#: apps/assignment/apps.py:8 apps/assignment/models.py:123 +#: apps/assignment/models.py:257 apps/warehouse/models.py:144 #: apps/warehouse/views.py:53 msgid "Assignment" msgstr "과제" -#: apps/assignment/models.py:81 apps/assistant/models.py:39 +#: apps/assignment/models.py:73 apps/assistant/models.py:39 #: 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/discussion/models.py:71 apps/discussion/models.py:317 +#: apps/exam/models.py:68 apps/operation/models.py:128 #: apps/operation/models.py:192 apps/operation/models.py:376 #: apps/operation/models.py:547 apps/operation/models.py:638 -#: apps/operation/models.py:760 apps/quiz/models.py:72 apps/survey/models.py:38 +#: apps/operation/models.py:760 apps/quiz/models.py:72 apps/survey/models.py:37 msgid "Title" msgstr "제목" -#: apps/assignment/models.py:82 apps/assignment/models.py:524 -#: apps/assignment/models.py:542 apps/assignment/models.py:561 +#: apps/assignment/models.py:74 apps/assignment/models.py:515 +#: apps/assignment/models.py:533 apps/assignment/models.py:552 #: apps/common/models.py:122 apps/competency/models.py:141 #: apps/competency/models.py:171 apps/competency/models.py:237 -#: apps/course/models.py:92 apps/discussion/models.py:80 apps/exam/models.py:77 +#: apps/course/models.py:92 apps/discussion/models.py:72 apps/exam/models.py:69 #: apps/learning/models.py:333 apps/operation/models.py:206 #: apps/operation/models.py:639 apps/operation/models.py:761 -#: 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:39 +#: apps/partner/models.py:46 apps/partner/models.py:74 +#: apps/partner/models.py:184 apps/quiz/models.py:73 apps/store/models.py:56 +#: apps/store/models.py:103 apps/survey/models.py:38 msgid "Description" msgstr "설명" -#: apps/assignment/models.py:83 apps/assignment/models.py:124 +#: apps/assignment/models.py:75 apps/assignment/models.py:116 #: 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:40 -#: apps/survey/models.py:79 +#: apps/discussion/models.py:73 apps/discussion/models.py:120 +#: apps/exam/models.py:70 apps/exam/models.py:139 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 msgid "Owner" msgstr "소유자" -#: apps/assignment/models.py:86 apps/assignment/models.py:106 -#: apps/assignment/models.py:126 apps/discussion/models.py:84 -#: 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/assignment/models.py:78 apps/assignment/models.py:98 +#: apps/assignment/models.py:118 apps/discussion/models.py:76 +#: apps/discussion/models.py:96 apps/discussion/models.py:122 +#: apps/exam/models.py:74 apps/exam/models.py:102 apps/exam/models.py:141 #: apps/quiz/models.py:78 apps/quiz/models.py:94 apps/quiz/models.py:127 -#: apps/survey/models.py:43 apps/survey/models.py:58 apps/survey/models.py:80 +#: apps/survey/models.py:42 apps/survey/models.py:57 apps/survey/models.py:79 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:44 +#: apps/assignment/models.py:79 apps/discussion/models.py:77 +#: apps/exam/models.py:75 apps/quiz/models.py:79 apps/survey/models.py:43 msgid "Question Pools" msgstr "문제 은행" -#: apps/assignment/models.py:107 apps/assignment/models.py:114 -#: apps/assignment/models.py:267 apps/discussion/models.py:114 -#: apps/discussion/models.py:204 apps/exam/admin.py:76 apps/exam/models.py:112 -#: apps/exam/models.py:118 apps/exam/models.py:135 apps/operation/admin.py:131 +#: apps/assignment/models.py:99 apps/assignment/models.py:106 +#: apps/assignment/models.py:259 apps/discussion/models.py:106 +#: apps/discussion/models.py:196 apps/exam/admin.py:76 apps/exam/models.py:104 +#: apps/exam/models.py:110 apps/exam/models.py:127 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:60 apps/survey/models.py:68 +#: apps/quiz/models.py:115 apps/survey/models.py:59 apps/survey/models.py:67 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:61 +#: apps/assignment/models.py:100 apps/discussion/models.py:98 +#: apps/exam/models.py:105 apps/quiz/models.py:96 apps/survey/models.py:60 msgid "Supplement" msgstr "보충" -#: apps/assignment/models.py:109 +#: apps/assignment/models.py:101 msgid "Attachment File Count" msgstr "첨부파일 수" -#: apps/assignment/models.py:110 +#: apps/assignment/models.py:102 msgid "Attachment File Types" msgstr "첨부파일 형식" -#: apps/assignment/models.py:111 +#: apps/assignment/models.py:103 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:258 +#: apps/assignment/models.py:107 apps/discussion/models.py:107 +#: apps/exam/admin.py:77 apps/exam/models.py:111 apps/exam/models.py:250 #: apps/quiz/admin.py:51 apps/quiz/models.py:102 apps/quiz/models.py:243 -#: apps/survey/models.py:69 +#: apps/survey/models.py:68 msgid "Questions" msgstr "문제" -#: apps/assignment/models.py:125 apps/course/models.py:123 -#: apps/discussion/models.py:129 apps/exam/models.py:148 +#: apps/assignment/models.py:117 apps/course/models.py:123 +#: apps/discussion/models.py:121 apps/exam/models.py:140 #: apps/operation/models.py:196 msgid "Honor Code" msgstr "윤리 서약" -#: apps/assignment/models.py:127 apps/assignment/models.py:527 -#: apps/assignment/models.py:540 +#: apps/assignment/models.py:119 apps/assignment/models.py:518 +#: apps/assignment/models.py:531 msgid "Rubric" msgstr "평가 척도" -#: apps/assignment/models.py:128 +#: apps/assignment/models.py:120 msgid "Sample Attachment" msgstr "샘플 첨부파일" -#: apps/assignment/models.py:132 +#: apps/assignment/models.py:124 msgid "Assignments" msgstr "과제" -#: apps/assignment/models.py:266 apps/course/models.py:572 -#: apps/discussion/models.py:203 apps/exam/models.py:257 +#: apps/assignment/models.py:258 apps/course/models.py:572 +#: apps/discussion/models.py:195 apps/exam/models.py:249 #: apps/operation/models.py:478 apps/quiz/models.py:242 msgid "Learner" msgstr "학습자" -#: apps/assignment/models.py:268 apps/discussion/models.py:205 -#: apps/exam/models.py:259 apps/quiz/models.py:244 +#: apps/assignment/models.py:260 apps/discussion/models.py:197 +#: apps/exam/models.py:251 apps/quiz/models.py:244 msgid "Retry" msgstr "재응시" -#: apps/assignment/models.py:271 apps/assignment/models.py:388 -#: apps/assignment/models.py:421 apps/assignment/models.py:486 -#: 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 +#: apps/assignment/models.py:263 apps/assignment/models.py:379 +#: apps/assignment/models.py:412 apps/assignment/models.py:477 +#: apps/discussion/models.py:200 apps/discussion/models.py:315 +#: apps/discussion/models.py:426 apps/exam/models.py:254 +#: apps/exam/models.py:373 apps/exam/models.py:383 apps/exam/models.py:399 +#: apps/quiz/models.py:247 apps/quiz/models.py:347 apps/quiz/models.py:357 msgid "Attempt" msgstr "응시" -#: apps/assignment/models.py:272 apps/discussion/models.py:209 -#: apps/exam/models.py:263 apps/quiz/models.py:248 +#: apps/assignment/models.py:264 apps/discussion/models.py:201 +#: apps/exam/models.py:255 apps/quiz/models.py:248 msgid "Attempts" msgstr "응시" -#: apps/assignment/models.py:389 apps/operation/models.py:223 +#: apps/assignment/models.py:380 apps/operation/models.py:223 #: apps/operation/models.py:450 msgid "Answer" msgstr "답변" -#: apps/assignment/models.py:390 +#: apps/assignment/models.py:381 msgid "Extracted Text" msgstr "추출된 텍스트" -#: apps/assignment/models.py:393 apps/exam/models.py:396 -#: apps/quiz/models.py:352 apps/survey/models.py:125 +#: apps/assignment/models.py:384 apps/exam/models.py:387 +#: apps/quiz/models.py:351 apps/survey/models.py:124 msgid "Submission" msgstr "제출" -#: apps/assignment/models.py:394 apps/exam/models.py:397 -#: apps/quiz/models.py:353 apps/survey/models.py:126 +#: apps/assignment/models.py:385 apps/exam/models.py:388 +#: apps/quiz/models.py:352 apps/survey/models.py:125 msgid "Submissions" msgstr "제출" -#: apps/assignment/models.py:422 apps/course/models.py:779 -#: apps/discussion/models.py:432 apps/exam/models.py:409 +#: apps/assignment/models.py:413 apps/course/models.py:779 +#: apps/discussion/models.py:427 apps/exam/models.py:400 msgid "Grader" msgstr "채점자" -#: apps/assignment/models.py:426 apps/discussion/models.py:436 -#: apps/exam/models.py:413 apps/quiz/models.py:362 +#: apps/assignment/models.py:417 apps/discussion/models.py:431 +#: apps/exam/models.py:404 apps/quiz/models.py:361 msgid "Grades" msgstr "채점" -#: apps/assignment/models.py:481 +#: apps/assignment/models.py:472 msgid "Not Detected" msgstr "없음" -#: apps/assignment/models.py:482 +#: apps/assignment/models.py:473 msgid "Detected" msgstr "탐지됨" -#: apps/assignment/models.py:483 +#: apps/assignment/models.py:474 msgid "Excused" msgstr "면제됨" -#: apps/assignment/models.py:484 +#: apps/assignment/models.py:475 msgid "Not Resolved" msgstr "알 수 없음" -#: apps/assignment/models.py:487 apps/store/models.py:54 +#: apps/assignment/models.py:478 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:488 +#: apps/assignment/models.py:479 msgid "Similarity Percentage" msgstr "유사도" -#: apps/assignment/models.py:489 +#: apps/assignment/models.py:480 msgid "Flagged Text" msgstr "검토 텍스트" -#: apps/assignment/models.py:490 +#: apps/assignment/models.py:481 msgid "Source Text" msgstr "소스 텍스트" -#: apps/assignment/models.py:491 +#: apps/assignment/models.py:482 msgid "Source User ID" msgstr "소스 사용자 ID" -#: apps/assignment/models.py:492 apps/store/models.py:385 +#: apps/assignment/models.py:483 apps/store/models.py:385 msgid "Reason" msgstr "사유" -#: apps/assignment/models.py:495 +#: apps/assignment/models.py:486 msgid "Plagiarism Check" msgstr "표절 검사" -#: apps/assignment/models.py:496 +#: apps/assignment/models.py:487 msgid "Plagiarism Checks" msgstr "표절 검사" -#: apps/assignment/models.py:528 +#: apps/assignment/models.py:519 msgid "Rubrics" msgstr "평가 척도" -#: apps/assignment/models.py:545 +#: apps/assignment/models.py:536 msgid "Rubric Criterion" msgstr "평가 항목" -#: apps/assignment/models.py:546 +#: apps/assignment/models.py:537 msgid "Rubric Criteria" msgstr "평가 항목" -#: apps/assignment/models.py:559 +#: apps/assignment/models.py:550 msgid "Criterion" msgstr "평가 기준" -#: apps/assignment/models.py:562 apps/exam/models.py:115 apps/quiz/models.py:98 +#: apps/assignment/models.py:553 apps/exam/models.py:107 apps/quiz/models.py:98 msgid "Point" msgstr "배점" -#: apps/assignment/models.py:565 +#: apps/assignment/models.py:556 msgid "Performance Level" msgstr "성취 수준" -#: apps/assignment/models.py:566 +#: apps/assignment/models.py:557 msgid "Performance Levels" msgstr "성취 수준" @@ -732,8 +733,8 @@ msgstr "대화 메시지" msgid "AI Assistant" msgstr "AI 어시스턴트" -#: apps/assistant/models.py:28 apps/content/models.py:484 -#: apps/content/models.py:492 apps/course/models.py:778 +#: apps/assistant/models.py:28 apps/content/models.py:468 +#: apps/content/models.py:473 apps/course/models.py:778 #: apps/learning/models.py:500 apps/learning/models.py:528 msgid "Note" msgstr "노트" @@ -776,7 +777,7 @@ msgstr "응답" msgid "Path" msgstr "경로" -#: apps/assistant/models.py:67 apps/common/models.py:172 +#: apps/assistant/models.py:67 apps/common/models.py:169 #: apps/store/models.py:357 apps/store/models.py:378 msgid "Completed" msgstr "완료됨" @@ -817,7 +818,7 @@ msgstr "자세히" msgid "Open" msgstr "열기" -#: apps/common/apps.py:16 +#: apps/common/apps.py:15 msgid "Apps" msgstr "앱" @@ -825,7 +826,7 @@ msgstr "앱" msgid "ID" msgstr "ID" -#: apps/common/models.py:39 +#: apps/common/models.py:39 apps/preview/models.py:10 msgid "Created" msgstr "생성" @@ -852,7 +853,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:78 +#: apps/learning/models.py:334 apps/store/models.py:57 apps/survey/models.py:77 msgid "Thumbnail" msgstr "썸네일" @@ -860,12 +861,12 @@ msgstr "썸네일" msgid "Featured" msgstr "추천" -#: apps/common/models.py:126 apps/content/models.py:84 apps/exam/models.py:111 -#: apps/survey/models.py:59 +#: apps/common/models.py:126 apps/content/models.py:84 apps/exam/models.py:103 +#: apps/survey/models.py:58 msgid "Format" msgstr "형식" -#: apps/common/models.py:127 apps/content/models.py:85 apps/exam/models.py:150 +#: apps/common/models.py:127 apps/content/models.py:85 apps/exam/models.py:142 msgid "Duration" msgstr "진행 시간" @@ -894,52 +895,63 @@ msgid "Lock" msgstr "잠김" #: apps/common/models.py:155 apps/content/models.py:314 -#: apps/content/models.py:485 +#: apps/content/models.py:469 msgid "Context Key" msgstr "컨텍스트 키" -#: apps/common/models.py:157 apps/content/models.py:316 -#: apps/content/models.py:487 -msgid "Realm" -msgstr "영역" - -#: apps/common/models.py:167 +#: apps/common/models.py:164 msgid "Possible Point" msgstr "배점" -#: apps/common/models.py:168 +#: apps/common/models.py:165 msgid "Earned Point" msgstr "득점" -#: apps/common/models.py:169 apps/course/models.py:774 +#: apps/common/models.py:166 apps/course/models.py:774 msgid "Score" msgstr "점수" -#: apps/common/models.py:170 apps/content/models.py:313 +#: apps/common/models.py:167 apps/content/models.py:313 #: apps/course/models.py:776 msgid "Passed" msgstr "합격" -#: apps/common/models.py:173 apps/course/models.py:777 +#: apps/common/models.py:170 apps/course/models.py:777 msgid "Confirmed" msgstr "확정됨" -#: apps/common/models.py:180 +#: apps/common/models.py:177 msgid "Cannot confirm without completion" msgstr "완료 처리 전 승인 할 수 없습니다." -#: apps/common/models.py:185 +#: apps/common/models.py:182 msgid "Grading Due Days" msgstr "채점 마감 예정 (일)" -#: apps/common/models.py:186 +#: apps/common/models.py:183 msgid "Appeal Deadline Days" msgstr "이의 신청 기한 (일)" -#: apps/common/models.py:187 +#: apps/common/models.py:184 msgid "Confirm Due Days" msgstr "성적 확정 예정 (일)" +#: apps/common/policy.py:6 apps/studio/apps.py:7 +msgid "Studio" +msgstr "스튜디오" + +#: apps/common/policy.py:7 apps/tutor/apps.py:7 apps/tutor/models.py:41 +msgid "Tutor" +msgstr "튜터" + +#: apps/common/policy.py:8 apps/desk/apps.py:7 +msgid "Desk" +msgstr "데스크" + +#: apps/common/policy.py:9 apps/preview/api/v1.py:43 apps/preview/apps.py:7 +msgid "Preview" +msgstr "미리보기" + #: apps/common/translation.py:5 msgid "Add new item" msgstr "새 항목 추가" @@ -1350,19 +1362,7 @@ msgstr "예약" msgid "Please correct the errors below." msgstr "오류를 수정하세요." -#: apps/common/util.py:100 -msgid "Student" -msgstr "학습자" - -#: apps/common/util.py:101 -msgid "Studio" -msgstr "스튜디오" - -#: apps/common/util.py:102 apps/tutor/apps.py:8 apps/tutor/models.py:41 -msgid "Tutor" -msgstr "튜터" - -#: apps/competency/apps.py:8 apps/warehouse/views.py:44 +#: apps/competency/apps.py:7 apps/warehouse/views.py:44 msgid "Competency" msgstr "역량" @@ -1465,7 +1465,7 @@ msgid "Badge Skills" msgstr "배지 능력" #: apps/competency/models.py:208 apps/competency/models.py:281 -#: apps/partner/apps.py:8 apps/partner/models.py:52 apps/partner/models.py:65 +#: apps/partner/apps.py:7 apps/partner/models.py:54 apps/partner/models.py:72 #: apps/warehouse/models.py:115 apps/warehouse/views.py:43 msgid "Partner" msgstr "파트너" @@ -1638,7 +1638,7 @@ msgstr "수료증을 찾을 수 없습니다." msgid "Public Access" msgstr "공개 접근" -#: apps/content/admin.py:30 apps/content/models.py:227 apps/quiz/apps.py:8 +#: apps/content/admin.py:30 apps/content/models.py:227 apps/quiz/apps.py:7 #: apps/quiz/models.py:130 apps/quiz/models.py:241 apps/warehouse/models.py:134 #: apps/warehouse/views.py:47 msgid "Quiz" @@ -1648,7 +1648,7 @@ msgstr "퀴즈" msgid "Create Quiz" msgstr "퀴즈 생성" -#: apps/content/apps.py:8 apps/warehouse/views.py:45 +#: apps/content/apps.py:7 apps/warehouse/views.py:45 msgid "Content" msgstr "콘텐츠" @@ -1690,7 +1690,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:483 +#: apps/content/models.py:309 apps/content/models.py:467 #: apps/course/models.py:381 apps/warehouse/models.py:125 msgid "Media" msgstr "미디어" @@ -1727,7 +1727,7 @@ msgstr "공개 접근" msgid "Public Access Medias" msgstr "공개 접근" -#: apps/content/models.py:257 apps/discussion/models.py:327 +#: apps/content/models.py:257 apps/discussion/models.py:318 #: apps/operation/models.py:129 apps/operation/models.py:548 #: apps/operation/models.py:700 msgid "Body" @@ -1757,15 +1757,15 @@ msgstr "시청 비트" msgid "Watch Rate" msgstr "시청률" -#: apps/content/models.py:321 apps/warehouse/models.py:127 +#: apps/content/models.py:318 apps/warehouse/models.py:127 msgid "Watch" msgstr "시청" -#: apps/content/models.py:322 +#: apps/content/models.py:319 msgid "Watches" msgstr "시청" -#: apps/content/models.py:493 +#: apps/content/models.py:474 msgid "Notes" msgstr "노트" @@ -1786,7 +1786,7 @@ msgstr "관련 과정" msgid "Related Courses" msgstr "관련 과정" -#: apps/course/apps.py:8 apps/course/models.py:129 apps/course/models.py:282 +#: apps/course/apps.py:7 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 @@ -2239,8 +2239,8 @@ msgstr "강사" msgid "Course Instructors" msgstr "강사" -#: 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/course/models.py:340 apps/survey/apps.py:7 apps/survey/models.py:85 +#: apps/survey/models.py:119 apps/warehouse/models.py:130 #: apps/warehouse/views.py:46 msgid "Survey" msgstr "설문조사" @@ -2352,7 +2352,7 @@ msgstr "성적" msgid "Gradebooks" msgstr "성적" -#: apps/discussion/admin.py:117 apps/discussion/models.py:330 +#: apps/discussion/admin.py:117 apps/discussion/models.py:321 msgid "Post" msgstr "게시글" @@ -2364,116 +2364,116 @@ msgstr "댓글" msgid "Tutor Assessment" msgstr "튜터 평가" -#: apps/discussion/apps.py:8 apps/discussion/models.py:133 -#: apps/discussion/models.py:202 apps/warehouse/models.py:150 +#: apps/discussion/apps.py:8 apps/discussion/models.py:125 +#: apps/discussion/models.py:194 apps/warehouse/models.py:150 #: apps/warehouse/views.py:62 msgid "Discussion" msgstr "토론" -#: apps/discussion/models.py:105 +#: apps/discussion/models.py:97 msgid "Directive" msgstr "지침" -#: apps/discussion/models.py:107 +#: apps/discussion/models.py:99 msgid "Post Point" msgstr "게시글 점수" -#: apps/discussion/models.py:108 +#: apps/discussion/models.py:100 msgid "Reply Point" msgstr "댓글 점수" -#: apps/discussion/models.py:109 +#: apps/discussion/models.py:101 msgid "Tutor Assessment Point" msgstr "튜터 평가 점수" -#: apps/discussion/models.py:110 +#: apps/discussion/models.py:102 msgid "Post Min Characters" msgstr "게시글 최소 글자수" -#: apps/discussion/models.py:111 +#: apps/discussion/models.py:103 msgid "Reply Min Characters" msgstr "댓글 최소 글자수" -#: apps/discussion/models.py:134 +#: apps/discussion/models.py:126 msgid "Discussions" msgstr "토론" -#: apps/discussion/models.py:325 apps/operation/models.py:787 +#: apps/discussion/models.py:316 apps/operation/models.py:787 msgid "Parent" msgstr "부모" -#: apps/discussion/models.py:331 +#: apps/discussion/models.py:322 msgid "Posts" msgstr "게시글" -#: apps/exam/apps.py:8 apps/exam/models.py:153 apps/exam/models.py:256 +#: apps/exam/apps.py:7 apps/exam/models.py:145 apps/exam/models.py:248 #: apps/warehouse/models.py:138 apps/warehouse/views.py:49 msgid "Exam" msgstr "시험" -#: apps/exam/models.py:79 +#: apps/exam/models.py:71 msgid "Question Composition" msgstr "문제 구성" -#: apps/exam/models.py:105 apps/survey/models.py:54 +#: apps/exam/models.py:97 apps/survey/models.py:53 msgid "Single Choice" msgstr "단일 선택" -#: apps/exam/models.py:106 apps/survey/models.py:55 +#: apps/exam/models.py:98 apps/survey/models.py:54 msgid "Text Input" msgstr "텍스트 입력" -#: apps/exam/models.py:107 apps/survey/models.py:56 +#: apps/exam/models.py:99 apps/survey/models.py:55 msgid "Number Input" msgstr "숫자 입력" -#: apps/exam/models.py:108 +#: apps/exam/models.py:100 msgid "Essay" msgstr "서술형" -#: apps/exam/models.py:114 apps/quiz/models.py:97 apps/survey/models.py:62 +#: apps/exam/models.py:106 apps/quiz/models.py:97 apps/survey/models.py:61 msgid "Options" msgstr "선택지" -#: apps/exam/models.py:136 apps/quiz/models.py:116 +#: apps/exam/models.py:128 apps/quiz/models.py:116 msgid "Correct Answers" msgstr "정답" -#: apps/exam/models.py:137 +#: apps/exam/models.py:129 msgid "Correct Criteria" msgstr "정답 기준" -#: apps/exam/models.py:138 apps/operation/admin.py:153 +#: apps/exam/models.py:130 apps/operation/admin.py:153 #: apps/operation/models.py:479 apps/quiz/models.py:117 msgid "Explanation" msgstr "해설" -#: apps/exam/models.py:141 apps/quiz/models.py:120 +#: apps/exam/models.py:133 apps/quiz/models.py:120 msgid "Solution" msgstr "해답" -#: apps/exam/models.py:142 apps/quiz/models.py:121 +#: apps/exam/models.py:134 apps/quiz/models.py:121 msgid "Solutions" msgstr "해답" -#: apps/exam/models.py:154 +#: apps/exam/models.py:146 msgid "Exams" msgstr "시험" -#: apps/exam/models.py:383 apps/exam/models.py:393 apps/quiz/models.py:349 -#: apps/survey/models.py:122 +#: apps/exam/models.py:374 apps/exam/models.py:384 apps/quiz/models.py:348 +#: apps/survey/models.py:121 msgid "Answers" msgstr "답안" -#: apps/exam/models.py:386 +#: apps/exam/models.py:377 msgid "Temporary Answer" msgstr "임시 답안" -#: apps/exam/models.py:387 +#: apps/exam/models.py:378 msgid "Temporary Answers" msgstr "임시 답안" -#: apps/learning/apps.py:8 apps/warehouse/views.py:71 +#: apps/learning/apps.py:7 apps/warehouse/views.py:71 msgid "Learning" msgstr "학습" @@ -2576,8 +2576,8 @@ msgstr "사용자 카탈로그" msgid "User Catalogs" msgstr "사용자 카탈로그" -#: apps/learning/models.py:525 apps/partner/models.py:180 -#: apps/partner/models.py:193 apps/partner/models.py:210 +#: apps/learning/models.py:525 apps/partner/models.py:187 +#: apps/partner/models.py:200 apps/partner/models.py:217 #: apps/warehouse/models.py:117 msgid "Cohort" msgstr "학습 그룹" @@ -2602,7 +2602,7 @@ msgstr "동의 기록" msgid "Agreement Histories" msgstr "동의 기록" -#: apps/operation/apps.py:8 apps/warehouse/views.py:42 +#: apps/operation/apps.py:7 apps/warehouse/views.py:42 msgid "Operation" msgstr "운영" @@ -2754,8 +2754,8 @@ msgstr "채점 이의" msgid "Grade Appeals" msgstr "채점 이의" -#: apps/operation/models.py:549 apps/partner/models.py:71 -#: apps/partner/models.py:85 +#: apps/operation/models.py:549 apps/partner/models.py:78 +#: apps/partner/models.py:92 msgid "Group" msgstr "그룹" @@ -2796,7 +2796,7 @@ msgstr "데이터 보존 정책" msgid "Kind" msgstr "유형" -#: apps/operation/models.py:641 apps/survey/models.py:63 +#: apps/operation/models.py:641 apps/survey/models.py:62 msgid "Mandatory" msgstr "필수" @@ -2893,140 +2893,160 @@ msgstr "댓글" msgid "Create platform partner" msgstr "플랫폼 기관 생성" -#: apps/partner/management/commands/create_platform_partner.py:25 +#: apps/partner/management/commands/create_platform_partner.py:26 #, python-format msgid "%s is a LMS for micro learning" msgstr "마이크로 러닝을 위한 %s" -#: apps/partner/models.py:47 +#: apps/partner/models.py:45 +msgid "Realm" +msgstr "영역" + +#: apps/partner/models.py:49 msgid "Address" msgstr "주소" -#: apps/partner/models.py:48 +#: apps/partner/models.py:50 msgid "Logo" msgstr "로고" -#: apps/partner/models.py:49 +#: apps/partner/models.py:51 msgid "Website" msgstr "웹사이트" -#: apps/partner/models.py:53 +#: apps/partner/models.py:55 msgid "Partners" msgstr "파트너" -#: apps/partner/models.py:68 +#: apps/partner/models.py:66 +msgid "This realm is reserved." +msgstr "이 영역은 예약되어 있습니다." + +#: apps/partner/models.py:75 msgid "Business Number" msgstr "사업자번호" -#: apps/partner/models.py:72 +#: apps/partner/models.py:79 msgid "Groups" msgstr "그룹" -#: apps/partner/models.py:89 +#: apps/partner/models.py:96 msgid "ID Number" msgstr "ID 번호" -#: apps/partner/models.py:91 +#: apps/partner/models.py:98 msgid "Team" msgstr "팀" -#: apps/partner/models.py:92 +#: apps/partner/models.py:99 msgid "Job Position" msgstr "직위" -#: apps/partner/models.py:93 +#: apps/partner/models.py:100 msgid "Job Title" msgstr "직함" -#: apps/partner/models.py:94 +#: apps/partner/models.py:101 msgid "Employment Status" msgstr "고용 상태" -#: apps/partner/models.py:95 +#: apps/partner/models.py:102 msgid "Employment Type" msgstr "고용 유형" -#: apps/partner/models.py:99 apps/partner/models.py:194 +#: apps/partner/models.py:106 apps/partner/models.py:201 msgid "Member" msgstr "직원" -#: apps/partner/models.py:100 +#: apps/partner/models.py:107 msgid "Members" msgstr "직원" -#: apps/partner/models.py:181 +#: apps/partner/models.py:188 msgid "Cohorts" msgstr "학습 그룹" -#: apps/partner/models.py:197 +#: apps/partner/models.py:204 msgid "Cohort Member" msgstr "학습 그룹 멤버" -#: apps/partner/models.py:198 +#: apps/partner/models.py:205 msgid "Cohort Members" msgstr "학습 그룹 멤버" -#: apps/partner/models.py:208 +#: apps/partner/models.py:215 msgid "Education Manager" msgstr "교육 담당" -#: apps/partner/models.py:212 +#: apps/partner/models.py:219 msgid "Role" msgstr "역할" -#: apps/partner/models.py:215 +#: apps/partner/models.py:222 msgid "Cohort Staff" msgstr "학습 그룹 담당자" -#: apps/partner/models.py:216 +#: apps/partner/models.py:223 msgid "Cohort Staffs" msgstr "학습 그룹 담당자" +#: apps/preview/models.py:12 +msgid "Creator" +msgstr "생성" + +#: apps/preview/models.py:15 +msgid "Preview User" +msgstr "미리보기 사용자" + +#: apps/preview/models.py:16 +msgid "Preview Users" +msgstr "미리보기 사용자" + #: apps/quiz/models.py:75 msgid "Select Count" msgstr "문제 선택 수" -#: apps/sso/apps.py:8 +#: apps/sso/apps.py:7 msgid "SSO" msgstr "SSO" -#: apps/sso/models.py:30 apps/sso/models.py:47 +#: apps/sso/models.py:31 apps/sso/models.py:48 msgid "Provider" msgstr "공급자" -#: apps/sso/models.py:31 +#: apps/sso/models.py:32 msgid "Provider User ID" msgstr "공급자 사용자 ID" -#: apps/sso/models.py:35 +#: apps/sso/models.py:36 msgid "SSO Account" msgstr "SSO 계정" -#: apps/sso/models.py:36 +#: apps/sso/models.py:37 msgid "SSO Accounts" msgstr "SSO 계정" -#: apps/sso/models.py:45 +#: apps/sso/models.py:46 msgid "State" msgstr "상태" -#: apps/sso/models.py:46 +#: apps/sso/models.py:47 msgid "Nonce" msgstr "나운스" -#: apps/sso/models.py:48 +#: apps/sso/models.py:49 msgid "Redirect To" msgstr "리다이렉트" -#: apps/sso/models.py:53 +#: apps/sso/models.py:54 msgid "SSO Session" msgstr "SSO 세션" -#: apps/sso/models.py:54 +#: apps/sso/models.py:55 msgid "SSO Sessions" msgstr "SSO 세션" -#: apps/store/apps.py:8 apps/warehouse/views.py:72 +#: apps/store/apps.py:7 apps/warehouse/views.py:72 msgid "Store" msgstr "판매" @@ -3329,23 +3349,23 @@ msgstr "콘텐츠 수정" msgid "Content Editings" msgstr "콘텐츠 수정" -#: apps/survey/models.py:81 +#: apps/survey/models.py:80 msgid "Complete Message" msgstr "완료 메시지" -#: apps/survey/models.py:82 +#: apps/survey/models.py:81 msgid "Anonymous" msgstr "익명" -#: apps/survey/models.py:83 +#: apps/survey/models.py:82 msgid "Show Results" msgstr "결과 보기" -#: apps/survey/models.py:87 +#: apps/survey/models.py:86 msgid "Surveys" msgstr "설문조사" -#: apps/survey/models.py:121 +#: apps/survey/models.py:120 msgid "Respondent" msgstr "응답자" @@ -3361,7 +3381,7 @@ msgstr "관리자" msgid "Model" msgstr "모델" -#: apps/tracking/apps.py:8 +#: apps/tracking/apps.py:7 msgid "Tracking" msgstr "변경 추적" @@ -3441,7 +3461,7 @@ msgstr "튜터 담당" msgid "Tutor Allocations" msgstr "튜터 담당" -#: apps/warehouse/apps.py:8 +#: apps/warehouse/apps.py:7 msgid "Warehouse" msgstr "분석 데이터" @@ -3577,16 +3597,16 @@ msgstr "등록" msgid "Courses Passed" msgstr "과정 수료" -#: minima/settings.py:266 +#: minima/settings.py:274 msgid "Storage" msgstr "저장소" +#~ msgid "Student" +#~ msgstr "학습자" + #~ msgid "Mode" #~ msgstr "모드" -#~ msgid "Preview" -#~ msgstr "미리보기" - #~ msgid "Audit" #~ msgstr "감사" diff --git a/core/minima/api.py b/core/minima/api.py index e3918d9..37bdb7d 100644 --- a/core/minima/api.py +++ b/core/minima/api.py @@ -8,13 +8,15 @@ import msgspec from django.conf import settings from django.core.exceptions import ObjectDoesNotExist -from django.http import HttpRequest from django.http.response import Http404 from ninja import NinjaAPI +from ninja.errors import AuthenticationError from ninja.parser import Parser from ninja.renderers import BaseRenderer from apps.assignment.models import PlagiarismDetectedException +from apps.common.error import ErrorCode +from apps.common.util import HttpRequest log = logging.getLogger(__name__) @@ -41,9 +43,8 @@ def parse_body(self, request: HttpRequest): return msgspec.json.decode(request.body) -def cookie_auth(request): - # from middleware - return getattr(request, "auth", "") +def cookie_auth(request: HttpRequest): + return request.auth or "" class MinimaAPI(NinjaAPI): @@ -102,6 +103,11 @@ def value_error(request, exc): return api.create_response(request, {"detail": str(exc)}, status=400) +@api.exception_handler(AuthenticationError) +def auth_error(request, exc): + return api.create_response(request, {"detail": ErrorCode.NOT_LOGGED_IN}, status=401) + + @api.exception_handler(ObjectDoesNotExist) @api.exception_handler(Http404) def does_not_exist(request, exc): diff --git a/core/minima/settings.py b/core/minima/settings.py index 2a76ddb..7bdaf2a 100644 --- a/core/minima/settings.py +++ b/core/minima/settings.py @@ -57,18 +57,7 @@ PERSONAL_ID_SALT = "minima" if DEBUG else os.environ["PERSONAL_ID_SALT"] ALLOWED_HOSTS = ( - [ - "localhost", - "minima", - "student.localhost", - "studio.localhost", - "tutor.localhost", - "student.testserver", - "studio.testserver", - "tutor.testserver", - ] - if DEBUG - else json.loads(os.environ["ALLOWED_HOSTS"]) + ["localhost", "minima", ".localhost", ".testserver"] if DEBUG else json.loads(os.environ["ALLOWED_HOSTS"]) ) INSTALLED_APPS = [ @@ -118,6 +107,7 @@ "apps.assistant", "apps.studio", "apps.tutor", + "apps.preview", "apps.tracking", "apps.warehouse", ] @@ -227,7 +217,11 @@ }, } SSO_SESSION_EXPIRE_SECONDS = 600 -ALLOWED_REDIRECT_ORIGINS = ["http://localhost:5173"] if DEBUG else json.loads(os.environ["ALLOWED_REDIRECT_ORIGINS"]) +ALLOWED_ORIGIN_REGEXES = ( + ["http://(?!tutor|studio|desk)[^.]+\\.localhost:5173"] + if DEBUG + else json.loads(os.environ["ALLOWED_ORIGIN_REGEXES"]) +) # smtp @@ -266,6 +260,7 @@ # platform PLATFORM_NAME = os.environ.get("PLATFORM_NAME", "Minima") +PLATFORM_STUDENT_REALM = os.environ.get("PLATFORM_STUDENT_REALM", "student") PLATFORM_ADDRESS = os.environ.get("PLATFORM_ADDRESS", "1234 Main St, San Francisco, CA 94123, USA") PLATFORM_BASE_URL = os.environ.get("PLATFORM_BASE_URL", "http://www.example.com") PLATFORM_PHONE_NUMBER = os.environ.get("PLATFORM_PHONE_NUMBER", "1234-5678") @@ -353,10 +348,7 @@ "sync-hot-events": {"task": "apps.tracking.tasks.sync_hot_event", "schedule": 300.0}, "cleanup-hot-events": {"task": "apps.tracking.tasks.cleanup_hot_event", "schedule": crontab(hour=2, minute=0)}, "collect-daily-data": {"task": "apps.warehouse.tasks.collect_daily_data", "schedule": crontab(hour=1, minute=5)}, - "cleanup-preview-data": { - "task": "apps.learning.tasks.cleanup_testing_data", - "schedule": crontab(hour=1, minute=10), - }, + "cleanup-audit-data": {"task": "apps.learning.tasks.cleanup_audit_data", "schedule": crontab(hour=1, minute=10)}, } # assistant diff --git a/core/pyproject.toml b/core/pyproject.toml index 9f326cc..e2ce571 100644 --- a/core/pyproject.toml +++ b/core/pyproject.toml @@ -43,7 +43,7 @@ dependencies = [ "webvtt-py", "authlib", "httpx", - "firebase-admin>=7.1.0", + "firebase-admin", ] [dependency-groups] @@ -64,7 +64,7 @@ dev = [ "openpyxl", "django-stubs", "typing-extensions", - "celery-types>=0.24.0", + "celery-types", ] [tool.django-stubs] diff --git a/core/uv.lock b/core/uv.lock index aa0e509..a22b407 100644 --- a/core/uv.lock +++ b/core/uv.lock @@ -140,29 +140,29 @@ wheels = [ [[package]] name = "boto3" -version = "1.42.67" +version = "1.42.68" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/29/b3/3b5c929df4e46572d2721f0cef7b9fc85fc9d68b659b46df2724ad2606f6/boto3-1.42.67.tar.gz", hash = "sha256:d4123ceb3be36c5cb7ddccc7a7c43701e1fb6af612ef46e3b5d667daf5447d4b", size = 112820, upload-time = "2026-03-12T19:43:40.444Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/ae/60c642aa5413e560b671da825329f510b29a77274ed0f580bde77562294d/boto3-1.42.68.tar.gz", hash = "sha256:3f349f967ab38c23425626d130962bcb363e75f042734fe856ea8c5a00eef03c", size = 112761, upload-time = "2026-03-13T19:32:17.137Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/d8/41d7657896de9fe3ef666221180dcbcdc9e3f418b88084aba0c9abf0bb0a/boto3-1.42.67-py3-none-any.whl", hash = "sha256:aa900216bdc48bbd0115ed7128a4baed5548c6a60673160a38df8a8566df57cd", size = 140557, upload-time = "2026-03-12T19:43:38.174Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f6/dc6e993479dbb597d68223fbf61cb026511737696b15bd7d2a33e9b2c24f/boto3-1.42.68-py3-none-any.whl", hash = "sha256:dbff353eb7dc93cbddd7926ed24793e0174c04adbe88860dfa639568442e4962", size = 140556, upload-time = "2026-03-13T19:32:14.951Z" }, ] [[package]] name = "boto3-stubs" -version = "1.42.67" +version = "1.42.68" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore-stubs" }, { name = "types-s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/79/97/1423bea1e6488646d75ef20359383c362a6367501fdc74472741f5aef079/boto3_stubs-1.42.67.tar.gz", hash = "sha256:c0debecec7fafac41b7977068d2bb0d6e19d08487b3d272fcd4ad5d5a4b045c4", size = 101393, upload-time = "2026-03-12T20:02:20.083Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/8c/dd4b0c95ff008bed5a35ab411452ece121b355539d2a0b6dcd62a0c47be5/boto3_stubs-1.42.68.tar.gz", hash = "sha256:96ad1020735619483fb9b4da7a5e694b460bf2e18f84a34d5d175d0ffe8c4653", size = 101372, upload-time = "2026-03-13T19:49:54.867Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/49/65aeec6b40104e77ef3977867c8420567a01e51b7185e28a21c7a66e83c4/boto3_stubs-1.42.67-py3-none-any.whl", hash = "sha256:29c4b5bfc5fbdc0ba63a2805ddabde36f9b2b877492c2c24ac48d309d78da8ec", size = 70010, upload-time = "2026-03-12T20:02:13.573Z" }, + { url = "https://files.pythonhosted.org/packages/68/15/3ca5848917214a168134512a5b45f856a56e913659888947a052e02031b5/boto3_stubs-1.42.68-py3-none-any.whl", hash = "sha256:ed7f98334ef7b2377fa8532190e63dc2c6d1dc895e3d7cb3d6d1c83771b81bf6", size = 70011, upload-time = "2026-03-13T19:49:42.801Z" }, ] [package.optional-dependencies] @@ -172,16 +172,16 @@ s3 = [ [[package]] name = "botocore" -version = "1.42.67" +version = "1.42.68" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/34/6a/0326ea8a726e9061d0aff941bc89ff93a1e832f492e6b3d7419301a56c1e/botocore-1.42.67.tar.gz", hash = "sha256:ee307f30fcb798d244fb35a87847b274e1e1f72cd5f7f2e31bd1826df0c45295", size = 14983149, upload-time = "2026-03-12T19:43:27.244Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/22/87502d5fbbfa8189406a617b30b1e2a3dc0ab2669f7268e91b385c1c1c7a/botocore-1.42.68.tar.gz", hash = "sha256:3951c69e12ac871dda245f48dac5c7dd88ea1bfdd74a8879ec356cf2874b806a", size = 14994514, upload-time = "2026-03-13T19:32:03.577Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/0b/cfe18326230476a0b8e3529609190448f2b46453469b07ae95fc57f90fc6/botocore-1.42.67-py3-none-any.whl", hash = "sha256:a94317d2ce83deae230964beb2729639455de65595d0154f285b0ccfd29780cd", size = 14655819, upload-time = "2026-03-12T19:43:21.952Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2a/1428f6594799780fe6ee845d8e6aeffafe026cd16a70c878684e2dcbbfc8/botocore-1.42.68-py3-none-any.whl", hash = "sha256:9df7da26374601f890e2f115bfa573d65bf15b25fe136bb3aac809f6145f52ab", size = 14668816, upload-time = "2026-03-13T19:31:58.572Z" }, ] [[package]] @@ -720,14 +720,14 @@ wheels = [ [[package]] name = "django-unfold" -version = "0.83.1" +version = "0.84.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7c/66/5ddc3a921a6b4540b3bb319faa9a0a097efb5c2b86e4386ddffd349ec248/django_unfold-0.83.1.tar.gz", hash = "sha256:af3f9710496d172f0079343d1ba3128528da6d8ef4a57b070efcfc6456d8ed2b", size = 1111682, upload-time = "2026-03-05T17:40:31.518Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/ad/1010c6667058f9da7cebf24e590418dfed3729695304cebae1925af90e61/django_unfold-0.84.0.tar.gz", hash = "sha256:061d8d11bfe02c48f11d29f9c550dd097ee7d6bc301d79a28e497d3c08e16955", size = 1114203, upload-time = "2026-03-13T15:38:33.577Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/fb/f3822627068f99de66989e3f2bdb9cf0eaafa50cbe49615011c74de41706/django_unfold-0.83.1-py3-none-any.whl", hash = "sha256:233cca8f7d9d173518597ce4dbafb0167f3075b556d7fb2b97e44217a1c6ba0f", size = 1227058, upload-time = "2026-03-05T17:40:30.054Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9f/83f8cc1d744979760fb4e953aa56c0d421cd29b4aaefbce8b2d8dd5f1e35/django_unfold-0.84.0-py3-none-any.whl", hash = "sha256:3c9365c2003a33b18d69cde8554f802961bfc14a3e692f93371aacfe95a15753", size = 1230269, upload-time = "2026-03-13T15:38:35.217Z" }, ] [[package]] @@ -792,14 +792,14 @@ wheels = [ [[package]] name = "faker" -version = "40.8.0" +version = "40.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/70/03/14428edc541467c460d363f6e94bee9acc271f3e62470630fc9a647d0cf2/faker-40.8.0.tar.gz", hash = "sha256:936a3c9be6c004433f20aa4d99095df5dec82b8c7ad07459756041f8c1728875", size = 1956493, upload-time = "2026-03-04T16:18:48.161Z" } +sdist = { url = "https://files.pythonhosted.org/packages/94/dc/b68e5378e5a7db0ab776efcdd53b6fe374b29d703e156fd5bb4c5437069e/faker-40.11.0.tar.gz", hash = "sha256:7c419299103b13126bd02ec14bd2b47b946edb5a5eedf305e66a193b25f9a734", size = 1957570, upload-time = "2026-03-13T14:36:11.844Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4c/3b/c6348f1e285e75b069085b18110a4e6325b763a5d35d5e204356fc7c20b3/faker-40.8.0-py3-none-any.whl", hash = "sha256:eb21bdba18f7a8375382eb94fb436fce07046893dc94cb20817d28deb0c3d579", size = 1989124, upload-time = "2026-03-04T16:18:46.45Z" }, + { url = "https://files.pythonhosted.org/packages/b1/fa/a86c6ba66f0308c95b9288b1e3eaccd934b545646f63494a86f1ec2f8c8e/faker-40.11.0-py3-none-any.whl", hash = "sha256:0e9816c950528d2a37d74863f3ef389ea9a3a936cbcde0b11b8499942e25bf90", size = 1989457, upload-time = "2026-03-13T14:36:09.792Z" }, ] [[package]] @@ -821,27 +821,27 @@ wheels = [ [[package]] name = "fonttools" -version = "4.62.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/96/686339e0fda8142b7ebed39af53f4a5694602a729662f42a6209e3be91d0/fonttools-4.62.0.tar.gz", hash = "sha256:0dc477c12b8076b4eb9af2e440421b0433ffa9e1dcb39e0640a6c94665ed1098", size = 3579521, upload-time = "2026-03-09T16:50:06.217Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/64/61f69298aa6e7c363dcf00dd6371a654676900abe27d1effd1a74b43e5d0/fonttools-4.62.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:4fa5a9c716e2f75ef34b5a5c2ca0ee4848d795daa7e6792bf30fd4abf8993449", size = 2864222, upload-time = "2026-03-09T16:49:28.285Z" }, - { url = "https://files.pythonhosted.org/packages/c6/57/6b08756fe4455336b1fe160ab3c11fccc90768ccb6ee03fb0b45851aace4/fonttools-4.62.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:625f5cbeb0b8f4e42343eaeb4bc2786718ddd84760a2f5e55fdd3db049047c00", size = 2410674, upload-time = "2026-03-09T16:49:30.504Z" }, - { url = "https://files.pythonhosted.org/packages/6f/86/db65b63bb1b824b63e602e9be21b18741ddc99bcf5a7850f9181159ae107/fonttools-4.62.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6247e58b96b982709cd569a91a2ba935d406dccf17b6aa615afaed37ac3856aa", size = 4999387, upload-time = "2026-03-09T16:49:32.593Z" }, - { url = "https://files.pythonhosted.org/packages/86/c8/c6669e42d2f4efd60d38a3252cebbb28851f968890efb2b9b15f9d1092b0/fonttools-4.62.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:840632ea9c1eab7b7f01c369e408c0721c287dfd7500ab937398430689852fd1", size = 4912506, upload-time = "2026-03-09T16:49:34.927Z" }, - { url = "https://files.pythonhosted.org/packages/2e/49/0ae552aa098edd0ec548413fbf818f52ceb70535016215094a5ce9bf8f70/fonttools-4.62.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:28a9ea2a7467a816d1bec22658b0cce4443ac60abac3e293bdee78beb74588f3", size = 4951202, upload-time = "2026-03-09T16:49:37.1Z" }, - { url = "https://files.pythonhosted.org/packages/71/65/ae38fc8a4cea6f162d74cf11f58e9aeef1baa7d0e3d1376dabd336c129e5/fonttools-4.62.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5ae611294f768d413949fd12693a8cba0e6332fbc1e07aba60121be35eac68d0", size = 5060758, upload-time = "2026-03-09T16:49:39.464Z" }, - { url = "https://files.pythonhosted.org/packages/db/3d/bb797496f35c60544cd5af71ffa5aad62df14ef7286908d204cb5c5096fe/fonttools-4.62.0-cp314-cp314-win32.whl", hash = "sha256:273acb61f316d07570a80ed5ff0a14a23700eedbec0ad968b949abaa4d3f6bb5", size = 2283496, upload-time = "2026-03-09T16:49:42.448Z" }, - { url = "https://files.pythonhosted.org/packages/2e/9f/91081ffe5881253177c175749cce5841f5ec6e931f5d52f4a817207b7429/fonttools-4.62.0-cp314-cp314-win_amd64.whl", hash = "sha256:a5f974006d14f735c6c878fc4b117ad031dc93638ddcc450ca69f8fd64d5e104", size = 2335426, upload-time = "2026-03-09T16:49:44.228Z" }, - { url = "https://files.pythonhosted.org/packages/f8/65/f47f9b3db1ec156a1f222f1089ba076b2cc9ee1d024a8b0a60c54258517e/fonttools-4.62.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0361a7d41d86937f1f752717c19f719d0fde064d3011038f9f19bdf5fc2f5c95", size = 2947079, upload-time = "2026-03-09T16:49:46.471Z" }, - { url = "https://files.pythonhosted.org/packages/52/73/bc62e5058a0c22cf02b1e0169ef0c3ca6c3247216d719f95bead3c05a991/fonttools-4.62.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4108c12773b3c97aa592311557c405d5b4fc03db2b969ed928fcf68e7b3c887", size = 2448802, upload-time = "2026-03-09T16:49:48.328Z" }, - { url = "https://files.pythonhosted.org/packages/2b/df/bfaa0e845884935355670e6e68f137185ab87295f8bc838db575e4a66064/fonttools-4.62.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b448075f32708e8fb377fe7687f769a5f51a027172c591ba9a58693631b077a8", size = 5137378, upload-time = "2026-03-09T16:49:50.223Z" }, - { url = "https://files.pythonhosted.org/packages/32/32/04f616979a18b48b52e634988b93d847b6346260faf85ecccaf7e2e9057f/fonttools-4.62.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e5f1fa8cc9f1a56a3e33ee6b954d6d9235e6b9d11eb7a6c9dfe2c2f829dc24db", size = 4920714, upload-time = "2026-03-09T16:49:53.172Z" }, - { url = "https://files.pythonhosted.org/packages/3b/2e/274e16689c1dfee5c68302cd7c444213cfddd23cf4620374419625037ec6/fonttools-4.62.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f8c8ea812f82db1e884b9cdb663080453e28f0f9a1f5027a5adb59c4cc8d38d1", size = 5016012, upload-time = "2026-03-09T16:49:55.762Z" }, - { url = "https://files.pythonhosted.org/packages/7f/0c/b08117270626e7117ac2f89d732fdd4386ec37d2ab3a944462d29e6f89a1/fonttools-4.62.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:03c6068adfdc67c565d217e92386b1cdd951abd4240d65180cec62fa74ba31b2", size = 5042766, upload-time = "2026-03-09T16:49:57.726Z" }, - { url = "https://files.pythonhosted.org/packages/11/83/a48b73e54efa272ee65315a6331b30a9b3a98733310bc11402606809c50e/fonttools-4.62.0-cp314-cp314t-win32.whl", hash = "sha256:d28d5baacb0017d384df14722a63abe6e0230d8ce642b1615a27d78ffe3bc983", size = 2347785, upload-time = "2026-03-09T16:49:59.698Z" }, - { url = "https://files.pythonhosted.org/packages/f8/27/c67eab6dc3525bdc39586511b1b3d7161e972dacc0f17476dbaf932e708b/fonttools-4.62.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3f9e20c4618f1e04190c802acae6dc337cb6db9fa61e492fd97cd5c5a9ff6d07", size = 2413914, upload-time = "2026-03-09T16:50:02.251Z" }, - { url = "https://files.pythonhosted.org/packages/9c/57/c2487c281dde03abb2dec244fd67059b8d118bd30a653cbf69e94084cb23/fonttools-4.62.0-py3-none-any.whl", hash = "sha256:75064f19a10c50c74b336aa5ebe7b1f89fd0fb5255807bfd4b0c6317098f4af3", size = 1152427, upload-time = "2026-03-09T16:50:04.074Z" }, +version = "4.62.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/08/7012b00a9a5874311b639c3920270c36ee0c445b69d9989a85e5c92ebcb0/fonttools-4.62.1.tar.gz", hash = "sha256:e54c75fd6041f1122476776880f7c3c3295ffa31962dc6ebe2543c00dca58b5d", size = 3580737, upload-time = "2026-03-13T13:54:25.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/f0/2888cdac391807d68d90dcb16ef858ddc1b5309bfc6966195a459dd326e2/fonttools-4.62.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fa1d16210b6b10a826d71bed68dd9ec24a9e218d5a5e2797f37c573e7ec215ca", size = 2864442, upload-time = "2026-03-13T13:53:37.509Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b2/e521803081f8dc35990816b82da6360fa668a21b44da4b53fc9e77efcd62/fonttools-4.62.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aa69d10ed420d8121118e628ad47d86e4caa79ba37f968597b958f6cceab7eca", size = 2410901, upload-time = "2026-03-13T13:53:40.55Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/8c3511ff06e53110039358dbbdc1a65d72157a054638387aa2ada300a8b8/fonttools-4.62.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd13b7999d59c5eb1c2b442eb2d0c427cb517a0b7a1f5798fc5c9e003f5ff782", size = 4999608, upload-time = "2026-03-13T13:53:42.798Z" }, + { url = "https://files.pythonhosted.org/packages/28/63/cd0c3b26afe60995a5295f37c246a93d454023726c3261cfbb3559969bb9/fonttools-4.62.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d337fdd49a79b0d51c4da87bc38169d21c3abbf0c1aa9367eff5c6656fb6dae", size = 4912726, upload-time = "2026-03-13T13:53:45.405Z" }, + { url = "https://files.pythonhosted.org/packages/70/b9/ac677cb07c24c685cf34f64e140617d58789d67a3dd524164b63648c6114/fonttools-4.62.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d241cdc4a67b5431c6d7f115fdf63335222414995e3a1df1a41e1182acd4bcc7", size = 4951422, upload-time = "2026-03-13T13:53:48.326Z" }, + { url = "https://files.pythonhosted.org/packages/e6/10/11c08419a14b85b7ca9a9faca321accccc8842dd9e0b1c8a72908de05945/fonttools-4.62.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c05557a78f8fa514da0f869556eeda40887a8abc77c76ee3f74cf241778afd5a", size = 5060979, upload-time = "2026-03-13T13:53:51.366Z" }, + { url = "https://files.pythonhosted.org/packages/4e/3c/12eea4a4cf054e7ab058ed5ceada43b46809fce2bf319017c4d63ae55bb4/fonttools-4.62.1-cp314-cp314-win32.whl", hash = "sha256:49a445d2f544ce4a69338694cad575ba97b9a75fff02720da0882d1a73f12800", size = 2283733, upload-time = "2026-03-13T13:53:53.606Z" }, + { url = "https://files.pythonhosted.org/packages/6b/67/74b070029043186b5dd13462c958cb7c7f811be0d2e634309d9a1ffb1505/fonttools-4.62.1-cp314-cp314-win_amd64.whl", hash = "sha256:1eecc128c86c552fb963fe846ca4e011b1be053728f798185a1687502f6d398e", size = 2335663, upload-time = "2026-03-13T13:53:56.23Z" }, + { url = "https://files.pythonhosted.org/packages/42/c5/4d2ed3ca6e33617fc5624467da353337f06e7f637707478903c785bd8e20/fonttools-4.62.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1596aeaddf7f78e21e68293c011316a25267b3effdaccaf4d59bc9159d681b82", size = 2947288, upload-time = "2026-03-13T13:53:59.397Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e9/7ab11ddfda48ed0f89b13380e5595ba572619c27077be0b2c447a63ff351/fonttools-4.62.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8f8fca95d3bb3208f59626a4b0ea6e526ee51f5a8ad5d91821c165903e8d9260", size = 2449023, upload-time = "2026-03-13T13:54:01.642Z" }, + { url = "https://files.pythonhosted.org/packages/b2/10/a800fa090b5e8819942e54e19b55fc7c21fe14a08757c3aa3ca8db358939/fonttools-4.62.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee91628c08e76f77b533d65feb3fbe6d9dad699f95be51cf0d022db94089cdc4", size = 5137599, upload-time = "2026-03-13T13:54:04.495Z" }, + { url = "https://files.pythonhosted.org/packages/37/dc/8ccd45033fffd74deb6912fa1ca524643f584b94c87a16036855b498a1ed/fonttools-4.62.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f37df1cac61d906e7b836abe356bc2f34c99d4477467755c216b72aa3dc748b", size = 4920933, upload-time = "2026-03-13T13:54:07.557Z" }, + { url = "https://files.pythonhosted.org/packages/99/eb/e618adefb839598d25ac8136cd577925d6c513dc0d931d93b8af956210f0/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92bb00a947e666169c99b43753c4305fc95a890a60ef3aeb2a6963e07902cc87", size = 5016232, upload-time = "2026-03-13T13:54:10.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5f/9b5c9bfaa8ec82def8d8168c4f13615990d6ce5996fe52bd49bfb5e05134/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bdfe592802ef939a0e33106ea4a318eeb17822c7ee168c290273cbd5fabd746c", size = 5042987, upload-time = "2026-03-13T13:54:13.569Z" }, + { url = "https://files.pythonhosted.org/packages/90/aa/dfbbe24c6a6afc5c203d90cc0343e24bcbb09e76d67c4d6eef8c2558d7ba/fonttools-4.62.1-cp314-cp314t-win32.whl", hash = "sha256:b820fcb92d4655513d8402d5b219f94481c4443d825b4372c75a2072aa4b357a", size = 2348021, upload-time = "2026-03-13T13:54:16.98Z" }, + { url = "https://files.pythonhosted.org/packages/13/6f/ae9c4e4dd417948407b680855c2c7790efb52add6009aaecff1e3bc50e8e/fonttools-4.62.1-cp314-cp314t-win_amd64.whl", hash = "sha256:59b372b4f0e113d3746b88985f1c796e7bf830dd54b28374cd85c2b8acd7583e", size = 2414147, upload-time = "2026-03-13T13:54:19.416Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ba/56147c165442cc5ba7e82ecf301c9a68353cede498185869e6e02b4c264f/fonttools-4.62.1-py3-none-any.whl", hash = "sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd", size = 1152647, upload-time = "2026-03-13T13:54:22.735Z" }, ] [[package]] @@ -1280,7 +1280,7 @@ requires-dist = [ { name = "django-taggit" }, { name = "django-treebeard" }, { name = "django-unfold" }, - { name = "firebase-admin", specifier = ">=7.1.0" }, + { name = "firebase-admin" }, { name = "fpdf2" }, { name = "google-genai" }, { name = "httpx" }, @@ -1303,7 +1303,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "boto3-stubs", extras = ["s3"] }, - { name = "celery-types", specifier = ">=0.24.0" }, + { name = "celery-types" }, { name = "django-stubs" }, { name = "factory-boy" }, { name = "mimesis" }, @@ -1683,11 +1683,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.12.0" +version = "2.12.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a8/10/e8192be5f38f3e8e7e046716de4cae33d56fd5ae08927a823bb916be36c1/pyjwt-2.12.0.tar.gz", hash = "sha256:2f62390b667cd8257de560b850bb5a883102a388829274147f1d724453f8fb02", size = 102511, upload-time = "2026-03-12T17:15:30.831Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/15/70/70f895f404d363d291dcf62c12c85fdd47619ad9674ac0f53364d035925a/pyjwt-2.12.0-py3-none-any.whl", hash = "sha256:9bb459d1bdd0387967d287f5656bf7ec2b9a26645d1961628cda1764e087fd6e", size = 29700, upload-time = "2026-03-12T17:15:29.257Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, ] [package.optional-dependencies]