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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions core/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@
.ruff_cache
.venv
.cache
__pycache__
5 changes: 3 additions & 2 deletions core/Dockerfile.search
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM opensearchproject/opensearch:3.2.0
FROM opensearchproject/opensearch:3

RUN /usr/share/opensearch/bin/opensearch-plugin install analysis-nori

Expand All @@ -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
2 changes: 1 addition & 1 deletion core/apps/account/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand Down
42 changes: 37 additions & 5 deletions core/apps/account/api/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,36 @@
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
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)
Expand All @@ -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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions core/apps/account/api/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
1 change: 0 additions & 1 deletion core/apps/account/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,5 @@


class AccountConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.account"
verbose_name = _("Account")
5 changes: 2 additions & 3 deletions core/apps/account/management/commands/create_roles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion core/apps/account/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Generated by Django 6.0.3 on 2026-03-11 17:58
# Generated by Django 6.0.3 on 2026-03-15 03:05

import apps.common.storage
import apps.common.util
Expand Down
42 changes: 36 additions & 6 deletions core/apps/account/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
Model,
OneToOneField,
Q,
QuerySet,
TextField,
Value,
)
Expand All @@ -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__)

Expand Down Expand Up @@ -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}>"
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion core/apps/assignment/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
9 changes: 2 additions & 7 deletions core/apps/assignment/api/v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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
)


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

import apps.common.util
import django.contrib.postgres.fields
Expand Down Expand Up @@ -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')),
Expand Down Expand Up @@ -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')),
Expand Down Expand Up @@ -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',
Expand Down
13 changes: 2 additions & 11 deletions core/apps/assignment/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand Down
5 changes: 0 additions & 5 deletions core/apps/assignment/tests/test_assignment_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion core/apps/assistant/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Generated by Django 6.0.3 on 2026-03-11 17:59
# Generated by Django 6.0.3 on 2026-03-15 03:05

import django.db.models.deletion
import pgtrigger.compiler
Expand Down
1 change: 0 additions & 1 deletion core/apps/common/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@


class CommonConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.common"
verbose_name = _("Apps")

Expand Down
Loading
Loading