feat: add Teacher Admin role to four-tier permission system

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 18:13:33 -06:00
parent 125d57b123
commit f94d29cf93
12 changed files with 112 additions and 41 deletions

View File

@@ -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

View File

@@ -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'),
),
]

View File

@@ -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,
),
]

View File

@@ -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

View File

@@ -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

View File

@@ -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")

View File

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

View File

@@ -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"])

View File

@@ -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")

View File

@@ -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:

View File

@@ -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):
"""更新题单状态"""

View File

@@ -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