From f94d29cf93b244b83567e3ef1ae025db54f96b7c Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Tue, 2 Jun 2026 18:13:33 -0600 Subject: [PATCH] feat: add Teacher Admin role to four-tier permission system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a four-tier role system: Regular User → Student Admin → Teacher Admin → Super Admin. Teacher Admin can manage own contests, problemsets, and view classroom data. Student Admin (renamed from Admin) retains problem management only. Co-Authored-By: Claude Opus 4.6 --- account/decorators.py | 6 +++ .../migrations/0006_alter_user_admin_type.py | 18 +++++++++ .../0007_rename_admin_to_student_admin.py | 27 +++++++++++++ account/models.py | 19 +++++++-- account/views/admin.py | 4 +- account/views/oj.py | 2 +- class_pk/views/oj.py | 10 ++--- contest/views/admin.py | 19 +++++---- contest/views/oj.py | 2 +- problem/views/admin.py | 2 +- problemset/views/admin.py | 40 ++++++++++--------- utils/migrate_data.py | 4 +- 12 files changed, 112 insertions(+), 41 deletions(-) create mode 100644 account/migrations/0006_alter_user_admin_type.py create mode 100644 account/migrations/0007_rename_admin_to_student_admin.py diff --git a/account/decorators.py b/account/decorators.py index acb5387..c4ed886 100644 --- a/account/decorators.py +++ b/account/decorators.py @@ -57,6 +57,12 @@ class super_admin_required(BasePermissionDecorator): return user.is_authenticated and user.is_super_admin() +class teacher_admin_required(BasePermissionDecorator): + def check_permission(self, request): + user = request.user + return user.is_authenticated and user.is_teacher_or_above() + + class admin_role_required(BasePermissionDecorator): def check_permission(self, request): user = request.user diff --git a/account/migrations/0006_alter_user_admin_type.py b/account/migrations/0006_alter_user_admin_type.py new file mode 100644 index 0000000..5606739 --- /dev/null +++ b/account/migrations/0006_alter_user_admin_type.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.4 on 2026-06-03 00:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0005_alter_user_is_disabled_alter_user_open_api_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='admin_type', + field=models.TextField(choices=[('Regular User', 'Regular User'), ('Student Admin', 'Student Admin'), ('Teacher Admin', 'Teacher Admin'), ('Super Admin', 'Super Admin')], default='Regular User'), + ), + ] diff --git a/account/migrations/0007_rename_admin_to_student_admin.py b/account/migrations/0007_rename_admin_to_student_admin.py new file mode 100644 index 0000000..998955a --- /dev/null +++ b/account/migrations/0007_rename_admin_to_student_admin.py @@ -0,0 +1,27 @@ +# Generated by Django 6.0.4 on 2026-06-03 00:08 + +from django.db import migrations + + +def rename_admin_to_student_admin(apps, schema_editor): + User = apps.get_model("account", "User") + User.objects.filter(admin_type="Admin").update(admin_type="Student Admin") + + +def rename_student_admin_to_admin(apps, schema_editor): + User = apps.get_model("account", "User") + User.objects.filter(admin_type="Student Admin").update(admin_type="Admin") + + +class Migration(migrations.Migration): + + dependencies = [ + ("account", "0006_alter_user_admin_type"), + ] + + operations = [ + migrations.RunPython( + rename_admin_to_student_admin, + rename_student_admin_to_admin, + ), + ] diff --git a/account/models.py b/account/models.py index 5ee8fab..4a64ae6 100644 --- a/account/models.py +++ b/account/models.py @@ -7,7 +7,8 @@ from utils.models import JSONField class AdminType(models.TextChoices): REGULAR_USER = "Regular User", "Regular User" - ADMIN = "Admin", "Admin" + STUDENT_ADMIN = "Student Admin", "Student Admin" + TEACHER_ADMIN = "Teacher Admin", "Teacher Admin" SUPER_ADMIN = "Super Admin", "Super Admin" @@ -56,14 +57,24 @@ class User(AbstractBaseUser): def is_regular_user(self): return self.admin_type == AdminType.REGULAR_USER - def is_admin(self): - return self.admin_type == AdminType.ADMIN + def is_student_admin(self): + return self.admin_type == AdminType.STUDENT_ADMIN + + def is_teacher_admin(self): + return self.admin_type == AdminType.TEACHER_ADMIN def is_super_admin(self): return self.admin_type == AdminType.SUPER_ADMIN def is_admin_role(self): - return self.admin_type in [AdminType.ADMIN, AdminType.SUPER_ADMIN] + return self.admin_type in [ + AdminType.STUDENT_ADMIN, + AdminType.TEACHER_ADMIN, + AdminType.SUPER_ADMIN, + ] + + def is_teacher_or_above(self): + return self.admin_type in [AdminType.TEACHER_ADMIN, AdminType.SUPER_ADMIN] def can_mgmt_all_problem(self): return self.problem_permission == ProblemPermission.ALL diff --git a/account/views/admin.py b/account/views/admin.py index 6c15055..c6134ac 100644 --- a/account/views/admin.py +++ b/account/views/admin.py @@ -105,7 +105,9 @@ class UserAdminAPI(APIView): user.admin_type = data["admin_type"] user.is_disabled = data["is_disabled"] - if data["admin_type"] == AdminType.ADMIN: + if data["admin_type"] == AdminType.STUDENT_ADMIN: + user.problem_permission = data["problem_permission"] or ProblemPermission.OWN + elif data["admin_type"] == AdminType.TEACHER_ADMIN: user.problem_permission = data["problem_permission"] or ProblemPermission.OWN elif data["admin_type"] == AdminType.SUPER_ADMIN: user.problem_permission = ProblemPermission.ALL diff --git a/account/views/oj.py b/account/views/oj.py index 86b3135..1054674 100644 --- a/account/views/oj.py +++ b/account/views/oj.py @@ -432,7 +432,7 @@ class UserRankAPI(AsyncAPIView): n = 0 profiles = UserProfile.objects.filter( - user__admin_type__in=[AdminType.REGULAR_USER, AdminType.ADMIN], + user__admin_type__in=[AdminType.REGULAR_USER, AdminType.STUDENT_ADMIN], user__is_disabled=False, user__username__icontains=username, ).select_related("user").filter(accepted_number__gte=0).order_by("-accepted_number", "submission_number") diff --git a/class_pk/views/oj.py b/class_pk/views/oj.py index 775e87b..c1a234f 100644 --- a/class_pk/views/oj.py +++ b/class_pk/views/oj.py @@ -22,7 +22,7 @@ class ClassRankAPI(APIView): User.objects.filter( class_name__isnull=False, is_disabled=False, - admin_type__in=[AdminType.REGULAR_USER, AdminType.ADMIN], + admin_type__in=[AdminType.REGULAR_USER, AdminType.STUDENT_ADMIN], class_name__startswith=str(grade), ) .values("class_name") @@ -35,7 +35,7 @@ class ClassRankAPI(APIView): users = User.objects.filter( class_name=class_name, is_disabled=False, - admin_type__in=[AdminType.REGULAR_USER, AdminType.ADMIN], + admin_type__in=[AdminType.REGULAR_USER, AdminType.STUDENT_ADMIN], ) user_ids = list(users.values_list("id", flat=True)) @@ -99,7 +99,7 @@ class UserClassRankAPI(APIView): class_users = User.objects.filter( class_name=user.class_name, is_disabled=False, - admin_type__in=[AdminType.REGULAR_USER, AdminType.ADMIN], + admin_type__in=[AdminType.REGULAR_USER, AdminType.STUDENT_ADMIN], ).select_related("userprofile") user_ranks = [] @@ -187,7 +187,7 @@ class ClassPKAPI(APIView): User.objects.filter( class_name__in=class_names, is_disabled=False, - admin_type__in=[AdminType.REGULAR_USER, AdminType.ADMIN], + admin_type__in=[AdminType.REGULAR_USER, AdminType.STUDENT_ADMIN], ).values_list("id", flat=True) ) all_ac_list = sorted( @@ -206,7 +206,7 @@ class ClassPKAPI(APIView): users = User.objects.filter( class_name=class_name, is_disabled=False, - admin_type__in=[AdminType.REGULAR_USER, AdminType.ADMIN], + admin_type__in=[AdminType.REGULAR_USER, AdminType.STUDENT_ADMIN], ) user_ids = list(users.values_list("id", flat=True)) diff --git a/contest/views/admin.py b/contest/views/admin.py index d196223..dc59fd9 100644 --- a/contest/views/admin.py +++ b/contest/views/admin.py @@ -8,7 +8,7 @@ import dateutil.parser from django.http import FileResponse from django.utils.timezone import now -from account.decorators import super_admin_required +from account.decorators import ensure_created_by, super_admin_required, teacher_admin_required from account.models import User from problem.models import Problem from submission.models import JudgeStatus, Submission @@ -31,7 +31,7 @@ from ..serializers import ( class ContestAPI(APIView): @validate_serializer(CreateConetestSeriaizer) - @super_admin_required + @teacher_admin_required def post(self, request): data = request.data data["start_time"] = dateutil.parser.parse(data["start_time"]) @@ -50,13 +50,14 @@ class ContestAPI(APIView): return self.success(ContestAdminSerializer(contest).data) @validate_serializer(EditConetestSeriaizer) - @super_admin_required + @teacher_admin_required def put(self, request): data = request.data try: contest = Contest.objects.get(id=data.pop("id")) except Contest.DoesNotExist: return self.error("Contest does not exist") + ensure_created_by(contest, request.user) data["start_time"] = dateutil.parser.parse(data["start_time"]) data["end_time"] = dateutil.parser.parse(data["end_time"]) if data["end_time"] <= data["start_time"]: @@ -73,17 +74,20 @@ class ContestAPI(APIView): contest.save() return self.success(ContestAdminSerializer(contest).data) - @super_admin_required + @teacher_admin_required def get(self, request): contest_id = request.GET.get("id") if contest_id: try: contest = Contest.objects.get(id=contest_id) + ensure_created_by(contest, request.user) return self.success(ContestAdminSerializer(contest).data) except Contest.DoesNotExist: return self.error("Contest does not exist") contests = Contest.objects.all().order_by("-create_time") + if not request.user.is_super_admin(): + contests = contests.filter(created_by=request.user) keyword = request.GET.get("keyword") if keyword: @@ -159,7 +163,7 @@ class ContestAnnouncementAPI(APIView): class ACMContestHelper(APIView): - @super_admin_required + @teacher_admin_required def get(self, request): contest_id = request.GET.get("contest_id") if not contest_id: @@ -168,6 +172,7 @@ class ACMContestHelper(APIView): contest = Contest.objects.get(id=contest_id, visible=True) except Contest.DoesNotExist: return self.error("Contest does not exist") + ensure_created_by(contest, request.user) problems = Problem.objects.filter(contest=contest).values("id", "_id") problem_id_map = {str(p["id"]): p["_id"] for p in problems} @@ -191,7 +196,7 @@ class ACMContestHelper(APIView): results.sort(key=lambda x: -x["ac_info"]["ac_time"]) return self.success(results) - @super_admin_required + @teacher_admin_required @validate_serializer(ACMContesHelperSerializer) def put(self, request): data = request.data @@ -258,7 +263,7 @@ class DownloadContestSubmissions(APIView): class ContestCloneAPI(APIView): @validate_serializer(ContestCloneSerializer) - @super_admin_required + @teacher_admin_required def post(self, request): try: original = Contest.objects.get(id=request.data["contest_id"]) diff --git a/contest/views/oj.py b/contest/views/oj.py index da4c129..bb92064 100644 --- a/contest/views/oj.py +++ b/contest/views/oj.py @@ -124,7 +124,7 @@ class ContestRankAPI(APIView): return ( ACMContestRank.objects.filter( contest=self.contest, - user__admin_type=AdminType.REGULAR_USER, + user__admin_type__in=[AdminType.REGULAR_USER, AdminType.STUDENT_ADMIN], user__is_disabled=False, ) .select_related("user") diff --git a/problem/views/admin.py b/problem/views/admin.py index 04c4d43..bcaf654 100644 --- a/problem/views/admin.py +++ b/problem/views/admin.py @@ -337,7 +337,7 @@ class ContestProblemAPI(ProblemBase): except Contest.DoesNotExist: return self.error("Contest does not exist") problems = Problem.objects.filter(contest=contest).order_by("-create_time") - if user.is_admin(): + if not user.is_super_admin(): problems = problems.filter(contest__created_by=user) keyword = request.GET.get("keyword") if keyword: diff --git a/problemset/views/admin.py b/problemset/views/admin.py index 74dd58e..655cc7c 100644 --- a/problemset/views/admin.py +++ b/problemset/views/admin.py @@ -1,6 +1,6 @@ from django.db.models import Count, Q -from account.decorators import ensure_created_by, super_admin_required +from account.decorators import ensure_created_by, teacher_admin_required from problem.models import Problem from problemset.models import ( ProblemSet, @@ -29,10 +29,12 @@ from utils.api import APIView, validate_serializer class ProblemSetAdminAPI(APIView): """题单管理API""" - @super_admin_required + @teacher_admin_required def get(self, request): """获取题单列表(管理员)""" problem_sets = ProblemSet.objects.filter(visible=True).annotate(problems_count=Count("problemsetproblem", distinct=True)).order_by("-create_time") + if not request.user.is_super_admin(): + problem_sets = problem_sets.filter(created_by=request.user) # 过滤条件 keyword = request.GET.get("keyword", "").strip() @@ -51,7 +53,7 @@ class ProblemSetAdminAPI(APIView): data = self.paginate_data(request, problem_sets, ProblemSetListSerializer) return self.success(data) - @super_admin_required + @teacher_admin_required @validate_serializer(CreateProblemSetSerializer) def post(self, request): """创建题单""" @@ -60,7 +62,7 @@ class ProblemSetAdminAPI(APIView): problem_set = ProblemSet.objects.create(**data) return self.success(ProblemSetSerializer(problem_set).data) - @super_admin_required + @teacher_admin_required @validate_serializer(EditProblemSetSerializer) def put(self, request): """编辑题单""" @@ -79,7 +81,7 @@ class ProblemSetAdminAPI(APIView): return self.success(ProblemSetSerializer(problem_set).data) - @super_admin_required + @teacher_admin_required def delete(self, request): """删除题单""" problem_set_id = request.GET.get("id") @@ -99,7 +101,7 @@ class ProblemSetAdminAPI(APIView): class ProblemSetDetailAdminAPI(APIView): """题单详情管理API""" - @super_admin_required + @teacher_admin_required def get(self, request, problem_set_id): """获取题单详情(管理员)""" try: @@ -115,7 +117,7 @@ class ProblemSetDetailAdminAPI(APIView): class ProblemSetProblemAdminAPI(APIView): """题单题目管理API(管理员)""" - @super_admin_required + @teacher_admin_required def get(self, request, problem_set_id): """获取题单中的题目列表(管理员)""" try: @@ -128,7 +130,7 @@ class ProblemSetProblemAdminAPI(APIView): serializer = ProblemSetProblemSerializer(problems, many=True, context={"request": request}) return self.success(serializer.data) - @super_admin_required + @teacher_admin_required @validate_serializer(AddProblemToSetSerializer) def post(self, request, problem_set_id): """添加题目到题单(管理员)""" @@ -163,7 +165,7 @@ class ProblemSetProblemAdminAPI(APIView): return self.success("题目已添加到题单") - @super_admin_required + @teacher_admin_required @validate_serializer(EditProblemInSetSerializer) def put(self, request, problem_set_id, problem_set_problem_id): """编辑题单中的题目(管理员)""" @@ -193,7 +195,7 @@ class ProblemSetProblemAdminAPI(APIView): return self.success("题目已更新") - @super_admin_required + @teacher_admin_required def delete(self, request, problem_set_id, problem_set_problem_id): """从题单中移除题目(管理员)""" try: @@ -213,7 +215,7 @@ class ProblemSetProblemAdminAPI(APIView): class ProblemSetBadgeAdminAPI(APIView): """题单奖章管理API(管理员)""" - @super_admin_required + @teacher_admin_required def get(self, request, problem_set_id): """获取题单的奖章列表(管理员)""" try: @@ -226,7 +228,7 @@ class ProblemSetBadgeAdminAPI(APIView): serializer = ProblemSetBadgeSerializer(badges, many=True) return self.success(serializer.data) - @super_admin_required + @teacher_admin_required @validate_serializer(CreateProblemSetBadgeSerializer) def post(self, request, problem_set_id): """创建题单奖章(管理员)""" @@ -242,7 +244,7 @@ class ProblemSetBadgeAdminAPI(APIView): return self.success(ProblemSetBadgeSerializer(badge).data) - @super_admin_required + @teacher_admin_required @validate_serializer(EditProblemSetBadgeSerializer) def put(self, request, problem_set_id, badge_id): """编辑题单奖章(管理员)""" @@ -282,7 +284,7 @@ class ProblemSetBadgeAdminAPI(APIView): return self.success("奖章已更新,并重新计算了所有用户的徽章资格") return self.success("奖章已更新") - @super_admin_required + @teacher_admin_required def delete(self, request, problem_set_id, badge_id): """删除题单奖章(管理员)""" try: @@ -302,7 +304,7 @@ class ProblemSetBadgeAdminAPI(APIView): class ProblemSetProgressAdminAPI(APIView): """题单进度管理API(管理员)""" - @super_admin_required + @teacher_admin_required def get(self, request, problem_set_id): """获取题单的所有用户进度(管理员)""" try: @@ -315,7 +317,7 @@ class ProblemSetProgressAdminAPI(APIView): serializer = ProblemSetProgressSerializer(progress_list, many=True) return self.success(serializer.data) - @super_admin_required + @teacher_admin_required def delete(self, request, problem_set_id, user_id): """移除用户从题单(管理员)""" try: @@ -336,7 +338,7 @@ class ProblemSetProgressAdminAPI(APIView): class ProblemSetSyncAPI(APIView): """题单同步管理API""" - @super_admin_required + @teacher_admin_required def post(self, request, problem_set_id): """手动同步题单的所有用户进度(管理员)""" try: @@ -354,7 +356,7 @@ class ProblemSetSyncAPI(APIView): class ProblemSetVisibleAPI(APIView): """题单可见性管理API""" - @super_admin_required + @teacher_admin_required @validate_serializer(ProblemSetVisibleSerializer) def put(self, request): """切换题单可见性""" @@ -373,7 +375,7 @@ class ProblemSetVisibleAPI(APIView): class ProblemSetStatusAPI(APIView): """题单状态管理API""" - @super_admin_required + @teacher_admin_required @validate_serializer(ProblemSetUpdateStatusSerializer) def put(self, request): """更新题单状态""" diff --git a/utils/migrate_data.py b/utils/migrate_data.py index ec68824..bf05cd0 100644 --- a/utils/migrate_data.py +++ b/utils/migrate_data.py @@ -15,7 +15,7 @@ from account.models import User, UserProfile, AdminType, ProblemPermission from problem.models import Problem, ProblemTag, ProblemRuleType from utils.constants import Difficulty -admin_type_map = {0: AdminType.REGULAR_USER, 1: AdminType.ADMIN, 2: AdminType.SUPER_ADMIN} +admin_type_map = {0: AdminType.REGULAR_USER, 1: AdminType.STUDENT_ADMIN, 2: AdminType.SUPER_ADMIN} languages_map = {1: "C", 2: "C++", 3: "Java"} email_regex = re.compile(r"(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)") @@ -95,7 +95,7 @@ def import_users(): user.email = data["email"] admin_type = admin_type_map[data["admin_type"]] user.admin_type = admin_type - if admin_type == AdminType.ADMIN: + if admin_type == AdminType.STUDENT_ADMIN: user.problem_permission = ProblemPermission.OWN elif admin_type == AdminType.SUPER_ADMIN: user.problem_permission = ProblemPermission.ALL