diff --git a/backend/Dockerfile b/backend/Dockerfile
index 613e19f..3023269 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -14,4 +14,4 @@ RUN mkdir -p /app/data
EXPOSE 8000
-CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000"]
diff --git a/backend/alembic/env.py b/backend/alembic/env.py
index 21a77bf..619aa60 100644
--- a/backend/alembic/env.py
+++ b/backend/alembic/env.py
@@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import async_engine_from_config
from app.config import settings
from app.db.base import Base
-from app.db.models import Class_, User, Timeline, Schedule, Announcement, Resource, Notification # noqa: ensure models registered
+from app.db import models as _models # noqa: F401 ensure all models registered
config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url)
diff --git a/backend/alembic/versions/20260426_remove_legacy_roster_and_user_columns.py b/backend/alembic/versions/20260426_remove_legacy_roster_and_user_columns.py
new file mode 100644
index 0000000..2db9249
--- /dev/null
+++ b/backend/alembic/versions/20260426_remove_legacy_roster_and_user_columns.py
@@ -0,0 +1,66 @@
+"""remove legacy roster and user columns
+
+Revision ID: 20260426_remove_legacy
+Revises:
+Create Date: 2026-04-26 23:40:00
+"""
+
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision = "20260426_remove_legacy"
+down_revision = None
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+ inspector = sa.inspect(bind)
+
+ tables = set(inspector.get_table_names())
+ if "student_rosters" in tables:
+ op.drop_table("student_rosters")
+
+ user_columns = {column["name"] for column in inspector.get_columns("users")}
+ legacy_columns = {"class_id", "committee_role", "class_permissions"}
+ if legacy_columns.intersection(user_columns):
+ with op.batch_alter_table("users") as batch_op:
+ if "class_id" in user_columns:
+ batch_op.drop_column("class_id")
+ if "committee_role" in user_columns:
+ batch_op.drop_column("committee_role")
+ if "class_permissions" in user_columns:
+ batch_op.drop_column("class_permissions")
+
+
+def downgrade() -> None:
+ with op.batch_alter_table("users") as batch_op:
+ batch_op.add_column(sa.Column("class_id", sa.Integer(), nullable=True))
+ batch_op.add_column(sa.Column("committee_role", sa.String(length=50), nullable=True))
+ batch_op.add_column(sa.Column("class_permissions", sa.Text(), nullable=True))
+
+ op.create_foreign_key(
+ "fk_users_class_id_classes",
+ "users",
+ "classes",
+ ["class_id"],
+ ["id"],
+ )
+ op.create_table(
+ "student_rosters",
+ sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column("class_id", sa.Integer(), nullable=False),
+ sa.Column("student_id", sa.String(length=50), nullable=False),
+ sa.Column("name", sa.String(length=100), nullable=False),
+ sa.Column("status", sa.String(length=20), nullable=False, server_default="inactive"),
+ sa.Column("user_id", sa.Integer(), nullable=True),
+ sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
+ sa.ForeignKeyConstraint(["class_id"], ["classes.id"]),
+ sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
+ sa.PrimaryKeyConstraint("id"),
+ )
+ op.create_index("ix_student_rosters_class_id", "student_rosters", ["class_id"])
diff --git a/backend/app/api/announcements.py b/backend/app/api/announcements.py
index e8a2671..c9343ec 100644
--- a/backend/app/api/announcements.py
+++ b/backend/app/api/announcements.py
@@ -1,7 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
-from app.core.deps import require_role
+from app.core.deps import ensure_class_permission, require_role, resolve_class_id_for_user
from app.db.database import get_db
from app.db.models import User
from app.schemas.announcement import AnnouncementCreate, AnnouncementUpdate, AnnouncementOut
@@ -22,12 +22,13 @@ async def get_announcements(
page: int = 1,
page_size: int = 20,
class_id: int | None = None,
- user: User = Depends(require_role("super_admin", "class_admin", "student")),
+ user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
- effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
+ effective_class_id = resolve_class_id_for_user(user, class_id)
if effective_class_id is None:
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
+ ensure_class_permission(user, "class_view", effective_class_id)
announcements, total = await list_announcements(db, effective_class_id, page, page_size)
total_pages = (total + page_size - 1) // page_size
@@ -57,12 +58,13 @@ async def get_announcements(
async def create_new_announcement(
data: AnnouncementCreate,
class_id: int | None = None,
- user: User = Depends(require_role("super_admin", "class_admin")),
+ user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
- effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
+ effective_class_id = resolve_class_id_for_user(user, class_id)
if effective_class_id is None:
raise HTTPException(status_code=400, detail="You are not assigned to a class")
+ ensure_class_permission(user, "announcement_manage", effective_class_id)
announcement = await create_announcement(db, effective_class_id, user.id, data)
return AnnouncementOut(
@@ -82,14 +84,13 @@ async def create_new_announcement(
async def update_existing_announcement(
announcement_id: int,
data: AnnouncementUpdate,
- user: User = Depends(require_role("super_admin", "class_admin")),
+ user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
announcement = await get_announcement_by_id(db, announcement_id)
if announcement is None:
raise HTTPException(status_code=404, detail="Announcement not found")
- if user.role != "super_admin" and announcement.class_id != user.class_id:
- raise HTTPException(status_code=403, detail="Access denied")
+ ensure_class_permission(user, "announcement_manage", announcement.class_id)
updated = await update_announcement(db, announcement, data)
return AnnouncementOut(
@@ -108,14 +109,13 @@ async def update_existing_announcement(
@router.delete("/{announcement_id}")
async def delete_existing_announcement(
announcement_id: int,
- user: User = Depends(require_role("super_admin", "class_admin")),
+ user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
announcement = await get_announcement_by_id(db, announcement_id)
if announcement is None:
raise HTTPException(status_code=404, detail="Announcement not found")
- if user.role != "super_admin" and announcement.class_id != user.class_id:
- raise HTTPException(status_code=403, detail="Access denied")
+ ensure_class_permission(user, "announcement_manage", announcement.class_id)
await delete_announcement(db, announcement)
return {"message": "Announcement deleted"}
diff --git a/backend/app/api/assignments.py b/backend/app/api/assignments.py
index a7ec865..8a86c0a 100644
--- a/backend/app/api/assignments.py
+++ b/backend/app/api/assignments.py
@@ -3,7 +3,12 @@ from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
-from app.core.deps import require_role
+from app.core.deps import (
+ ensure_class_permission,
+ get_effective_class_permissions,
+ require_role,
+ resolve_class_id_for_user,
+)
from app.db.database import get_db
from app.db.models import User, Class_
from app.schemas.assignment import (
@@ -28,10 +33,11 @@ from app.services.cos_service import upload_file
router = APIRouter(prefix="/api/assignments", tags=["assignments"])
-async def _get_roster_count(db: AsyncSession, class_id: int) -> int:
- from app.db.models import StudentRoster
+async def _get_member_count(db: AsyncSession, class_id: int) -> int:
result = await db.execute(
- select(func.count(StudentRoster.id)).where(StudentRoster.class_id == class_id)
+ select(func.count(User.id))
+ .join(Class_.memberships)
+ .where(Class_.id == class_id)
)
return result.scalar() or 0
@@ -81,17 +87,18 @@ async def get_assignments(
page: int = 1,
page_size: int = 20,
class_id: int | None = None,
- user: User = Depends(require_role("super_admin", "class_admin", "student")),
+ user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
- effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
+ effective_class_id = resolve_class_id_for_user(user, class_id)
if effective_class_id is None:
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
+ ensure_class_permission(user, "class_view", effective_class_id)
assignments, total = await list_assignments(db, effective_class_id, page, page_size)
total_pages = (total + page_size - 1) // page_size
- roster_count = await _get_roster_count(db, effective_class_id)
- items = [_build_assignment_out(a, user.id, roster_count) for a in assignments]
+ member_count = await _get_member_count(db, effective_class_id)
+ items = [_build_assignment_out(a, user.id, member_count) for a in assignments]
return PageResponse(items=items, total=total, page=page, page_size=page_size, total_pages=total_pages)
@@ -99,30 +106,30 @@ async def get_assignments(
async def create_new_assignment(
data: AssignmentCreate,
class_id: int | None = None,
- user: User = Depends(require_role("super_admin", "class_admin")),
+ user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
- effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
+ effective_class_id = resolve_class_id_for_user(user, class_id)
if effective_class_id is None:
raise HTTPException(status_code=400, detail="You are not assigned to a class")
+ ensure_class_permission(user, "assignment_manage", effective_class_id)
assignment = await create_assignment(db, effective_class_id, user.id, data)
- roster_count = await _get_roster_count(db, effective_class_id)
- return _build_assignment_out(assignment, user.id, roster_count)
+ member_count = await _get_member_count(db, effective_class_id)
+ return _build_assignment_out(assignment, user.id, member_count)
@router.post("/{assignment_id}/attachments")
async def upload_assignment_attachments(
assignment_id: int,
files: list[UploadFile] = File(...),
- user: User = Depends(require_role("super_admin", "class_admin")),
+ user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
assignment = await get_assignment_by_id(db, assignment_id)
if assignment is None:
raise HTTPException(status_code=404, detail="Assignment not found")
- if user.role != "super_admin" and assignment.class_id != user.class_id:
- raise HTTPException(status_code=403, detail="Access denied")
+ ensure_class_permission(user, "assignment_manage", assignment.class_id)
urls = []
for f in files:
@@ -142,19 +149,18 @@ async def upload_assignment_attachments(
@router.get("/{assignment_id}", response_model=AssignmentDetailOut)
async def get_assignment_detail(
assignment_id: int,
- user: User = Depends(require_role("super_admin", "class_admin", "student")),
+ user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
assignment = await get_assignment_by_id(db, assignment_id)
if assignment is None:
raise HTTPException(status_code=404, detail="Assignment not found")
- if user.role != "super_admin" and assignment.class_id != user.class_id:
- raise HTTPException(status_code=403, detail="Access denied")
+ ensure_class_permission(user, "class_view", assignment.class_id)
- base = _build_assignment_out(assignment, user.id, await _get_roster_count(db, assignment.class_id))
+ base = _build_assignment_out(assignment, user.id, await _get_member_count(db, assignment.class_id))
# Student only sees their own submission
- if user.role == "student":
+ if "assignment_manage" not in get_effective_class_permissions(user, assignment.class_id) and user.role == "student":
my_submission = None
for s in (assignment.submissions or []):
if s.student_id == user.id:
@@ -171,30 +177,28 @@ async def get_assignment_detail(
async def update_existing_assignment(
assignment_id: int,
data: AssignmentUpdate,
- user: User = Depends(require_role("super_admin", "class_admin")),
+ user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
assignment = await get_assignment_by_id(db, assignment_id)
if assignment is None:
raise HTTPException(status_code=404, detail="Assignment not found")
- if user.role != "super_admin" and assignment.class_id != user.class_id:
- raise HTTPException(status_code=403, detail="Access denied")
+ ensure_class_permission(user, "assignment_manage", assignment.class_id)
updated = await update_assignment(db, assignment, data)
- return _build_assignment_out(updated, user.id, await _get_roster_count(db, updated.class_id))
+ return _build_assignment_out(updated, user.id, await _get_member_count(db, updated.class_id))
@router.delete("/{assignment_id}")
async def delete_existing_assignment(
assignment_id: int,
- user: User = Depends(require_role("super_admin", "class_admin")),
+ user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
assignment = await get_assignment_by_id(db, assignment_id)
if assignment is None:
raise HTTPException(status_code=404, detail="Assignment not found")
- if user.role != "super_admin" and assignment.class_id != user.class_id:
- raise HTTPException(status_code=403, detail="Access denied")
+ ensure_class_permission(user, "assignment_manage", assignment.class_id)
await delete_assignment(db, assignment)
return {"message": "Assignment deleted"}
@@ -205,14 +209,13 @@ async def submit_assignment(
assignment_id: int,
notes: str = "",
file: UploadFile = File(...),
- user: User = Depends(require_role("super_admin", "class_admin", "student")),
+ user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
assignment = await get_assignment_by_id(db, assignment_id)
if assignment is None:
raise HTTPException(status_code=404, detail="Assignment not found")
- if user.role != "super_admin" and assignment.class_id != user.class_id:
- raise HTTPException(status_code=403, detail="Access denied")
+ ensure_class_permission(user, "class_view", assignment.class_id)
# Upload file
file_url = None
@@ -250,7 +253,7 @@ async def submit_assignment(
async def grade_assignment_submission(
submission_id: int,
data: SubmissionGrade,
- user: User = Depends(require_role("super_admin", "class_admin")),
+ user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
from sqlalchemy import select
@@ -262,6 +265,10 @@ async def grade_assignment_submission(
submission = result.scalar_one_or_none()
if submission is None:
raise HTTPException(status_code=404, detail="Submission not found")
+ assignment = await get_assignment_by_id(db, submission.assignment_id)
+ if assignment is None:
+ raise HTTPException(status_code=404, detail="Assignment not found")
+ ensure_class_permission(user, "assignment_manage", assignment.class_id)
graded = await grade_submission(db, submission, data)
diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py
index f7ee257..6a787dd 100644
--- a/backend/app/api/auth.py
+++ b/backend/app/api/auth.py
@@ -1,90 +1,98 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
from app.core.auth import hash_password, verify_password, create_access_token
from app.core.deps import get_current_user
from app.db.database import get_db
-from app.db.models import User
+from app.db.models import ClassMembership, User
from app.schemas.auth import LoginRequest, RegisterRequest, ChangePasswordRequest
-from app.schemas.user import TokenResponse, UserOut
-from app.services.roster_service import validate_registration
+from app.schemas.user import TokenResponse, UserOut, build_user_out
+from app.services.member_activation_service import validate_registration
router = APIRouter(prefix="/api/auth", tags=["auth"])
-@router.post("/register")
-async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
- # 1. Check if email is already registered
+@router.post("/activate")
+async def activate_account(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
+ # 1. Check if email is already in use
existing = await db.execute(select(User).where(User.email == req.email))
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="该邮箱已注册")
- # 2. Validate invite_code + student_id against roster
- roster_entry = await validate_registration(db, req.invite_code, req.student_id)
- if roster_entry is None:
- raise HTTPException(
- status_code=400, detail="邀请码或学号无效,或该学号已注册"
- )
+ # 2. Validate invite_code + student_id against inactive class member
+ activation_target = await validate_registration(db, req.invite_code, req.student_id)
+ if activation_target is None:
+ raise HTTPException(status_code=400, detail="邀请码或学号无效,或账号已激活")
- # 3. Create user with approved status directly
- user = User(
- email=req.email,
- password_hash=hash_password(req.password),
- name=req.name,
- student_id=req.student_id,
- role="student",
- status="approved",
- class_id=roster_entry.class_id,
- )
- db.add(user)
- await db.flush()
-
- # 4. Mark roster entry as registered
- roster_entry.status = "registered"
- roster_entry.user_id = user.id
+ user, class_id = activation_target
+ user.email = req.email
+ user.password_hash = hash_password(req.password)
+ user.status = "approved"
await db.commit()
+ result = await db.execute(
+ select(User)
+ .options(
+ selectinload(User.memberships),
+ selectinload(User.memberships).selectinload(ClassMembership.class_),
+ )
+ .where(User.id == user.id)
+ )
+ user = result.scalar_one()
+ user.set_active_membership(class_id)
- # 5. Issue token — register and login in one step
+ # 3. Issue token — activation and login in one step
token = create_access_token({"sub": str(user.id), "role": user.role})
return {
- "message": "注册成功",
+ "message": "账号激活成功",
"token": token,
- "user": UserOut.model_validate(user),
+ "user": build_user_out(user, class_id),
}
@router.post("/login", response_model=TokenResponse)
async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
- result = await db.execute(select(User).where(User.email == req.email))
+ result = await db.execute(
+ select(User)
+ .options(
+ selectinload(User.memberships),
+ selectinload(User.memberships).selectinload(ClassMembership.class_),
+ )
+ .where(User.email == req.email)
+ )
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(status_code=401, detail="邮箱或密码错误")
+ if user.status == "inactive":
+ raise HTTPException(status_code=401, detail="账号尚未激活")
+
if user.status == "disabled":
raise HTTPException(status_code=401, detail="账号已被禁用")
- if not verify_password(req.password, user.password_hash):
+ if not user.password_hash or not verify_password(req.password, user.password_hash):
raise HTTPException(status_code=401, detail="邮箱或密码错误")
token = create_access_token({"sub": str(user.id), "role": user.role})
return TokenResponse(
token=token,
- user=UserOut.model_validate(user),
+ user=build_user_out(user),
)
@router.get("/me", response_model=UserOut)
async def get_me(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
- user_out = UserOut.model_validate(user)
+ default_membership = user.get_default_membership()
+ user_out = build_user_out(user, default_membership.class_id if default_membership else None)
- # Attach enabled_modules from user's class
- if user.class_id:
+ # Attach enabled_modules from active class
+ if default_membership:
from app.db.models import Class_
from sqlalchemy import select
- result = await db.execute(select(Class_).where(Class_.id == user.class_id))
+ result = await db.execute(select(Class_).where(Class_.id == default_membership.class_id))
class_ = result.scalar_one_or_none()
if class_:
user_out.enabled_modules = class_.get_enabled_modules()
@@ -98,7 +106,7 @@ async def change_password(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
- if not verify_password(req.old_password, user.password_hash):
+ if not user.password_hash or not verify_password(req.old_password, user.password_hash):
raise HTTPException(status_code=400, detail="Old password is incorrect")
user.password_hash = hash_password(req.new_password)
await db.commit()
diff --git a/backend/app/api/classes.py b/backend/app/api/classes.py
index 5de7746..803cfdb 100644
--- a/backend/app/api/classes.py
+++ b/backend/app/api/classes.py
@@ -4,12 +4,20 @@ import io
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from sqlalchemy.ext.asyncio import AsyncSession
-from app.core.deps import require_role
+from app.core.deps import (
+ ensure_class_permission,
+ get_current_user,
+ require_role,
+)
from app.db.database import get_db
from app.db.models import User
from app.schemas.class_ import ClassCreate, ClassUpdate, ClassOut, ModuleUpdate
-from app.schemas.user import UserListItem
-from app.schemas.roster import RosterOut, RosterImportRequest
+from app.schemas.user import UserListItem, build_user_list_item
+from app.schemas.inactive_member import (
+ InactiveMemberOut,
+ MemberImportRequest,
+ build_inactive_member_out,
+)
from app.schemas.common import PageResponse
from app.services.class_service import (
create_class,
@@ -20,13 +28,13 @@ from app.services.class_service import (
get_member_count,
get_class_members,
)
-from app.services.roster_service import (
+from app.services.member_activation_service import (
ensure_invite_code,
regenerate_invite_code,
- import_roster,
- get_roster,
- delete_roster_entry,
- clear_unregistered_roster,
+ import_members,
+ get_inactive_members,
+ delete_inactive_member,
+ clear_inactive_members,
)
router = APIRouter(prefix="/api/classes", tags=["classes"])
@@ -36,8 +44,21 @@ router = APIRouter(prefix="/api/classes", tags=["classes"])
async def get_classes(
page: int = 1,
page_size: int = 50,
+ user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
+ if user.role not in {"super_admin", "teacher"}:
+ membership = user.get_default_membership()
+ if membership is None:
+ return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
+ class_ = await get_class_by_id(db, membership.class_id)
+ if class_ is None:
+ return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
+ count = await get_member_count(db, class_.id)
+ out = ClassOut.model_validate(class_)
+ out.member_count = count
+ return PageResponse(items=[out], total=1, page=1, page_size=page_size, total_pages=1)
+
classes, total = await list_classes(db, page, page_size)
total_pages = (total + page_size - 1) // page_size
result = []
@@ -98,16 +119,15 @@ async def get_members(
status: str | None = None,
page: int = 1,
page_size: int = 50,
- admin: User = Depends(require_role("super_admin", "class_admin")),
+ admin: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
- if admin.role == "class_admin" and admin.class_id != class_id:
- raise HTTPException(status_code=403, detail="Access denied for this class")
+ ensure_class_permission(admin, "member_view", class_id)
members, total = await get_class_members(db, class_id, status, page, page_size)
total_pages = (total + page_size - 1) // page_size
return PageResponse(
- items=[UserListItem.model_validate(m) for m in members],
+ items=[build_user_list_item(m, class_id) for m in members],
total=total,
page=page,
page_size=page_size,
@@ -115,23 +135,22 @@ async def get_members(
)
-# --- Roster management ---
+# --- Inactive member management ---
-@router.get("/{class_id}/roster", response_model=PageResponse[RosterOut])
-async def get_class_roster(
+@router.get("/{class_id}/inactive-members", response_model=PageResponse[InactiveMemberOut])
+async def get_class_inactive_members(
class_id: int,
page: int = 1,
page_size: int = 50,
- admin: User = Depends(require_role("super_admin", "class_admin")),
+ admin: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
- if admin.role == "class_admin" and admin.class_id != class_id:
- raise HTTPException(status_code=403, detail="Access denied")
- entries, total = await get_roster(db, class_id, page, page_size)
+ ensure_class_permission(admin, "member_manage", class_id)
+ entries, total = await get_inactive_members(db, class_id, page, page_size)
total_pages = (total + page_size - 1) // page_size
return PageResponse(
- items=[RosterOut.model_validate(e) for e in entries],
+ items=[build_inactive_member_out(entry) for entry in entries],
total=total,
page=page,
page_size=page_size,
@@ -139,28 +158,26 @@ async def get_class_roster(
)
-@router.post("/{class_id}/roster/import")
-async def import_class_roster(
+@router.post("/{class_id}/inactive-members/import")
+async def import_class_members(
class_id: int,
- data: RosterImportRequest,
- admin: User = Depends(require_role("super_admin", "class_admin")),
+ data: MemberImportRequest,
+ admin: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
- if admin.role == "class_admin" and admin.class_id != class_id:
- raise HTTPException(status_code=403, detail="Access denied")
- count = await import_roster(db, class_id, data.entries)
- return {"message": f"成功导入 {count} 条记录"}
+ ensure_class_permission(admin, "member_manage", class_id)
+ count = await import_members(db, class_id, data.entries)
+ return {"message": f"成功导入 {count} 位成员"}
-@router.post("/{class_id}/roster/upload")
-async def upload_roster_file(
+@router.post("/{class_id}/inactive-members/upload")
+async def upload_member_file(
class_id: int,
file: UploadFile = File(...),
- admin: User = Depends(require_role("super_admin", "class_admin")),
+ admin: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
- if admin.role == "class_admin" and admin.class_id != class_id:
- raise HTTPException(status_code=403, detail="Access denied")
+ ensure_class_permission(admin, "member_manage", class_id)
contents = await file.read()
filename = file.filename or ""
@@ -214,33 +231,33 @@ async def upload_roster_file(
if not entries:
raise HTTPException(status_code=400, detail="未找到有效数据")
- count = await import_roster(db, class_id, entries)
- return {"message": f"成功导入 {count} 条记录"}
+ count = await import_members(db, class_id, entries)
+ return {"message": f"成功导入 {count} 位成员"}
-@router.delete("/{class_id}/roster/{roster_id}")
-async def delete_roster_item(
+@router.delete("/{class_id}/inactive-members/{user_id}")
+async def delete_inactive_member_item(
class_id: int,
- roster_id: int,
- admin: User = Depends(require_role("super_admin", "class_admin")),
+ user_id: int,
+ admin: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
- success = await delete_roster_entry(db, roster_id)
+ ensure_class_permission(admin, "member_manage", class_id)
+ success = await delete_inactive_member(db, class_id, user_id)
if not success:
- raise HTTPException(status_code=400, detail="无法删除(已注册或不存在)")
+ raise HTTPException(status_code=400, detail="无法删除(已激活、已加入其他班级或不存在)")
return {"message": "已删除"}
-@router.post("/{class_id}/roster/clear")
-async def clear_roster(
+@router.post("/{class_id}/inactive-members/clear")
+async def clear_class_inactive_members(
class_id: int,
- admin: User = Depends(require_role("super_admin", "class_admin")),
+ admin: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
- if admin.role == "class_admin" and admin.class_id != class_id:
- raise HTTPException(status_code=403, detail="Access denied")
- count = await clear_unregistered_roster(db, class_id)
- return {"message": f"已清除 {count} 条未注册记录"}
+ ensure_class_permission(admin, "member_manage", class_id)
+ count = await clear_inactive_members(db, class_id)
+ return {"message": f"已清除 {count} 位未激活成员"}
# --- Invite code management ---
@@ -249,9 +266,10 @@ async def clear_roster(
@router.get("/{class_id}/invite-code")
async def get_invite_code(
class_id: int,
- admin: User = Depends(require_role("super_admin", "class_admin")),
+ admin: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
+ ensure_class_permission(admin, "member_manage", class_id)
code = await ensure_invite_code(db, class_id)
if not code:
raise HTTPException(status_code=404, detail="Class not found")
@@ -261,9 +279,10 @@ async def get_invite_code(
@router.post("/{class_id}/invite-code/regenerate")
async def regenerate_invite(
class_id: int,
- admin: User = Depends(require_role("super_admin", "class_admin")),
+ admin: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
+ ensure_class_permission(admin, "member_manage", class_id)
code = await regenerate_invite_code(db, class_id)
if not code:
raise HTTPException(status_code=404, detail="Class not found")
@@ -276,11 +295,10 @@ async def regenerate_invite(
@router.get("/{class_id}/modules")
async def get_class_modules(
class_id: int,
- admin: User = Depends(require_role("super_admin", "class_admin")),
+ admin: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
- if admin.role == "class_admin" and admin.class_id != class_id:
- raise HTTPException(status_code=403, detail="Access denied")
+ ensure_class_permission(admin, "module_manage", class_id)
class_ = await get_class_by_id(db, class_id)
if class_ is None:
@@ -297,11 +315,10 @@ async def get_class_modules(
async def update_class_modules(
class_id: int,
data: ModuleUpdate,
- admin: User = Depends(require_role("super_admin", "class_admin")),
+ admin: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
- if admin.role == "class_admin" and admin.class_id != class_id:
- raise HTTPException(status_code=403, detail="Access denied")
+ ensure_class_permission(admin, "module_manage", class_id)
class_ = await get_class_by_id(db, class_id)
if class_ is None:
diff --git a/backend/app/api/directory.py b/backend/app/api/directory.py
index 4c604a3..f84862f 100644
--- a/backend/app/api/directory.py
+++ b/backend/app/api/directory.py
@@ -1,7 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
-from app.core.deps import get_current_user
+from app.core.deps import ensure_class_permission, get_current_user, resolve_class_id_for_user
from app.db.database import get_db
from app.db.models import User
from app.schemas.user import UserPublic
@@ -23,18 +23,21 @@ async def search_members(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
- # Determine effective class_id: super_admin can specify one, others use their own
- effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
+ effective_class_id = resolve_class_id_for_user(user, class_id)
if effective_class_id is None:
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
+ ensure_class_permission(user, "class_view", effective_class_id)
users, total = await search_directory(
db, effective_class_id, search, industry, company, page, page_size
)
total_pages = (total + page_size - 1) // page_size
- include_contact = True # Same class, approved users can see contact
+ include_contact = True # Same class, active members can see contact
return PageResponse(
- items=[user_to_public(u, include_contact=include_contact) for u in users],
+ items=[
+ user_to_public(u, effective_class_id, include_contact=include_contact)
+ for u in users
+ ],
total=total,
page=page,
page_size=page_size,
@@ -53,5 +56,11 @@ async def get_member_detail(
raise HTTPException(status_code=404, detail="User not found")
# Privacy: only show contact info to same-class members
- include_contact = user.class_id == target.class_id
- return user_to_public(target, include_contact=include_contact)
+ shared_class_ids = {
+ membership.class_id for membership in user.memberships
+ } & {
+ membership.class_id for membership in target.memberships
+ }
+ include_contact = bool(shared_class_ids)
+ scoped_class_id = next(iter(shared_class_ids), None)
+ return user_to_public(target, scoped_class_id, include_contact=include_contact)
diff --git a/backend/app/api/fund.py b/backend/app/api/fund.py
index 701798c..285371e 100644
--- a/backend/app/api/fund.py
+++ b/backend/app/api/fund.py
@@ -1,7 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
-from app.core.deps import require_role
+from app.core.deps import ensure_class_permission, require_role, resolve_class_id_for_user
from app.db.database import get_db
from app.db.models import FundRecord, User
from app.schemas.fund import FundRecordCreate, FundRecordUpdate, FundRecordOut, FundStatistics
@@ -33,15 +33,16 @@ def record_to_out(record: FundRecord) -> FundRecordOut:
@router.get("/statistics", response_model=FundStatistics)
async def get_statistics(
class_id: int | None = None,
- user: User = Depends(require_role("super_admin", "class_admin", "student")),
+ user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
- effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
+ effective_class_id = resolve_class_id_for_user(user, class_id)
if effective_class_id is None:
return FundStatistics(
total_income=0, total_expense=0, balance=0,
income_by_category=[], expense_by_category=[]
)
+ ensure_class_permission(user, "class_view", effective_class_id)
return await get_fund_statistics(db, effective_class_id)
@@ -52,12 +53,13 @@ async def get_fund_records(
type: str | None = None,
category: str | None = None,
class_id: int | None = None,
- user: User = Depends(require_role("super_admin", "class_admin", "student")),
+ user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
- effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
+ effective_class_id = resolve_class_id_for_user(user, class_id)
if effective_class_id is None:
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
+ ensure_class_permission(user, "class_view", effective_class_id)
records, total = await list_fund_records(db, effective_class_id, page, page_size, type, category)
total_pages = (total + page_size - 1) // page_size
@@ -69,12 +71,13 @@ async def get_fund_records(
async def create_new_record(
data: FundRecordCreate,
class_id: int | None = None,
- user: User = Depends(require_role("super_admin", "class_admin")),
+ user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
- effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
+ effective_class_id = resolve_class_id_for_user(user, class_id)
if effective_class_id is None:
raise HTTPException(status_code=400, detail="No class specified")
+ ensure_class_permission(user, "fund_manage", effective_class_id)
if data.type not in ("income", "expense"):
raise HTTPException(status_code=400, detail="Type must be 'income' or 'expense'")
@@ -91,14 +94,13 @@ async def create_new_record(
async def update_existing_record(
record_id: int,
data: FundRecordUpdate,
- user: User = Depends(require_role("super_admin", "class_admin")),
+ user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
record = await get_fund_record_by_id(db, record_id)
if record is None:
raise HTTPException(status_code=404, detail="Record not found")
- if user.role != "super_admin" and record.class_id != user.class_id:
- raise HTTPException(status_code=403, detail="Access denied")
+ ensure_class_permission(user, "fund_manage", record.class_id)
if data.type is not None and data.type not in ("income", "expense"):
raise HTTPException(status_code=400, detail="Type must be 'income' or 'expense'")
@@ -114,14 +116,13 @@ async def update_existing_record(
@router.delete("/{record_id}")
async def delete_existing_record(
record_id: int,
- user: User = Depends(require_role("super_admin", "class_admin")),
+ user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
record = await get_fund_record_by_id(db, record_id)
if record is None:
raise HTTPException(status_code=404, detail="Record not found")
- if user.role != "super_admin" and record.class_id != user.class_id:
- raise HTTPException(status_code=403, detail="Access denied")
+ ensure_class_permission(user, "fund_manage", record.class_id)
await delete_fund_record(db, record)
- return {"message": "Record deleted"}
\ No newline at end of file
+ return {"message": "Record deleted"}
diff --git a/backend/app/api/resources.py b/backend/app/api/resources.py
index 6d8f83d..f358fa0 100644
--- a/backend/app/api/resources.py
+++ b/backend/app/api/resources.py
@@ -1,7 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from sqlalchemy.ext.asyncio import AsyncSession
-from app.core.deps import require_role
+from app.core.deps import ensure_class_permission, require_role, resolve_class_id_for_user
from app.db.database import get_db
from app.db.models import User
from app.schemas.resource import ResourceCreate, ResourceOut
@@ -40,12 +40,13 @@ async def get_resources(
page_size: int = 20,
category: str | None = None,
class_id: int | None = None,
- user: User = Depends(require_role("super_admin", "class_admin", "student")),
+ user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
- effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
+ effective_class_id = resolve_class_id_for_user(user, class_id)
if effective_class_id is None:
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
+ ensure_class_permission(user, "class_view", effective_class_id)
resources, total = await list_resources(db, effective_class_id, category, page, page_size)
total_pages = (total + page_size - 1) // page_size
@@ -81,12 +82,13 @@ async def upload_new_resource(
category: str = Form(...),
description: str | None = Form(None),
class_id: int | None = Form(None),
- user: User = Depends(require_role("super_admin", "class_admin")),
+ user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
- effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
+ effective_class_id = resolve_class_id_for_user(user, class_id)
if effective_class_id is None:
raise HTTPException(status_code=400, detail="You are not assigned to a class")
+ ensure_class_permission(user, "resource_manage", effective_class_id)
contents = await file.read()
if len(contents) > 50 * 1024 * 1024: # 50MB limit
@@ -126,12 +128,13 @@ async def upload_new_resource(
@router.post("/{resource_id}/download")
async def download_resource(
resource_id: int,
- user: User = Depends(require_role("super_admin", "class_admin", "student")),
+ user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
resource = await get_resource_by_id(db, resource_id)
if resource is None:
raise HTTPException(status_code=404, detail="Resource not found")
+ ensure_class_permission(user, "class_view", resource.class_id)
await increment_download_count(db, resource)
return {"file_url": resource.file_url}
@@ -140,14 +143,13 @@ async def download_resource(
@router.delete("/{resource_id}")
async def delete_existing_resource(
resource_id: int,
- user: User = Depends(require_role("super_admin", "class_admin")),
+ user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
resource = await get_resource_by_id(db, resource_id)
if resource is None:
raise HTTPException(status_code=404, detail="Resource not found")
- if user.role != "super_admin" and resource.class_id != user.class_id:
- raise HTTPException(status_code=403, detail="Access denied")
+ ensure_class_permission(user, "resource_manage", resource.class_id)
await delete_resource(db, resource)
return {"message": "Resource deleted"}
diff --git a/backend/app/api/schedule.py b/backend/app/api/schedule.py
index 5174539..cfd3057 100644
--- a/backend/app/api/schedule.py
+++ b/backend/app/api/schedule.py
@@ -1,7 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
-from app.core.deps import require_role
+from app.core.deps import ensure_class_permission, require_role, resolve_class_id_for_user
from app.db.database import get_db
from app.db.models import User
from app.schemas.schedule import ScheduleCreate, ScheduleUpdate, ScheduleOut
@@ -22,12 +22,13 @@ router = APIRouter(prefix="/api/schedule", tags=["schedule"])
async def get_upcoming(
limit: int = 10,
class_id: int | None = None,
- user: User = Depends(require_role("super_admin", "class_admin", "student")),
+ user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
- effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
+ effective_class_id = resolve_class_id_for_user(user, class_id)
if effective_class_id is None:
return []
+ ensure_class_permission(user, "class_view", effective_class_id)
items = await get_upcoming_schedules(db, effective_class_id, limit)
return [ScheduleOut.model_validate(i) for i in items]
@@ -38,12 +39,13 @@ async def get_schedules(
page: int = 1,
page_size: int = 50,
class_id: int | None = None,
- user: User = Depends(require_role("super_admin", "class_admin", "student")),
+ user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
- effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
+ effective_class_id = resolve_class_id_for_user(user, class_id)
if effective_class_id is None:
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
+ ensure_class_permission(user, "class_view", effective_class_id)
items, total = await list_schedules(db, effective_class_id, type, page, page_size)
total_pages = (total + page_size - 1) // page_size
@@ -60,12 +62,13 @@ async def get_schedules(
async def create_new_schedule(
data: ScheduleCreate,
class_id: int | None = None,
- user: User = Depends(require_role("super_admin", "class_admin")),
+ user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
- effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
+ effective_class_id = resolve_class_id_for_user(user, class_id)
if effective_class_id is None:
raise HTTPException(status_code=400, detail="You are not assigned to a class")
+ ensure_class_permission(user, "schedule_manage", effective_class_id)
item = await create_schedule(db, effective_class_id, data)
return ScheduleOut.model_validate(item)
@@ -75,14 +78,13 @@ async def create_new_schedule(
async def update_existing_schedule(
schedule_id: int,
data: ScheduleUpdate,
- user: User = Depends(require_role("super_admin", "class_admin")),
+ user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
item = await get_schedule_by_id(db, schedule_id)
if item is None:
raise HTTPException(status_code=404, detail="Schedule not found")
- if user.role != "super_admin" and item.class_id != user.class_id:
- raise HTTPException(status_code=403, detail="Access denied")
+ ensure_class_permission(user, "schedule_manage", item.class_id)
updated = await update_schedule(db, item, data)
return ScheduleOut.model_validate(updated)
@@ -91,14 +93,13 @@ async def update_existing_schedule(
@router.delete("/{schedule_id}")
async def delete_existing_schedule(
schedule_id: int,
- user: User = Depends(require_role("super_admin", "class_admin")),
+ user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
item = await get_schedule_by_id(db, schedule_id)
if item is None:
raise HTTPException(status_code=404, detail="Schedule not found")
- if user.role != "super_admin" and item.class_id != user.class_id:
- raise HTTPException(status_code=403, detail="Access denied")
+ ensure_class_permission(user, "schedule_manage", item.class_id)
await delete_schedule(db, item)
return {"message": "Schedule deleted"}
diff --git a/backend/app/api/timeline.py b/backend/app/api/timeline.py
index a03f653..58a71c7 100644
--- a/backend/app/api/timeline.py
+++ b/backend/app/api/timeline.py
@@ -2,7 +2,12 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from sqlalchemy.ext.asyncio import AsyncSession
import asyncio
-from app.core.deps import require_role
+from app.core.deps import (
+ ensure_class_permission,
+ get_effective_class_permissions,
+ require_role,
+ resolve_class_id_for_user,
+)
from app.db.database import get_db
from app.db.models import User
from app.schemas.timeline import (
@@ -68,12 +73,13 @@ async def get_timelines(
page: int = 1,
page_size: int = 20,
class_id: int | None = None,
- user: User = Depends(require_role("super_admin", "class_admin", "student")),
+ user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
- effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
+ effective_class_id = resolve_class_id_for_user(user, class_id)
if effective_class_id is None:
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
+ ensure_class_permission(user, "class_view", effective_class_id)
posts, total = await list_timelines(db, effective_class_id, page, page_size)
total_pages = (total + page_size - 1) // page_size
@@ -91,12 +97,13 @@ async def create_new_timeline(
content: str | None = Form(None),
class_id: int | None = Form(None),
files: list[UploadFile] = File(default=[]),
- user: User = Depends(require_role("super_admin", "class_admin", "student")),
+ user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
- effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
+ effective_class_id = resolve_class_id_for_user(user, class_id)
if effective_class_id is None:
raise HTTPException(status_code=400, detail="You are not assigned to a class")
+ ensure_class_permission(user, "class_view", effective_class_id)
data = TimelineCreate(title=title, content=content)
post = await create_timeline(db, effective_class_id, user.id, data)
@@ -135,7 +142,7 @@ async def create_new_timeline(
async def upload_timeline_images(
post_id: int,
files: list[UploadFile] = File(...),
- user: User = Depends(require_role("super_admin", "class_admin", "student")),
+ user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
post = await get_timeline_by_id(db, post_id)
@@ -143,10 +150,10 @@ async def upload_timeline_images(
raise HTTPException(status_code=404, detail="Timeline post not found")
# Student can only upload to own post; admin can upload to any in their class
- if user.role == "student" and post.author_id != user.id:
- raise HTTPException(status_code=403, detail="Access denied")
- if user.role != "super_admin" and post.class_id != user.class_id:
+ can_manage = "timeline_manage" in get_effective_class_permissions(user, post.class_id)
+ if not can_manage and post.author_id != user.id:
raise HTTPException(status_code=403, detail="Access denied")
+ ensure_class_permission(user, "class_view", post.class_id)
urls = []
for f in files:
@@ -166,16 +173,16 @@ async def upload_timeline_images(
async def update_existing_timeline(
post_id: int,
data: TimelineUpdate,
- user: User = Depends(require_role("super_admin", "class_admin", "student")),
+ user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
post = await get_timeline_by_id(db, post_id)
if post is None:
raise HTTPException(status_code=404, detail="Timeline post not found")
- if user.role == "student" and post.author_id != user.id:
- raise HTTPException(status_code=403, detail="Access denied")
- if user.role != "super_admin" and post.class_id != user.class_id:
+ can_manage = "timeline_manage" in get_effective_class_permissions(user, post.class_id)
+ if not can_manage and post.author_id != user.id:
raise HTTPException(status_code=403, detail="Access denied")
+ ensure_class_permission(user, "class_view", post.class_id)
updated = await update_timeline(db, post, data)
return _build_timeline_out(updated, user.id)
@@ -184,16 +191,16 @@ async def update_existing_timeline(
@router.delete("/{post_id}")
async def delete_existing_timeline(
post_id: int,
- user: User = Depends(require_role("super_admin", "class_admin", "student")),
+ user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
post = await get_timeline_by_id(db, post_id)
if post is None:
raise HTTPException(status_code=404, detail="Timeline post not found")
- if user.role == "student" and post.author_id != user.id:
- raise HTTPException(status_code=403, detail="Access denied")
- if user.role != "super_admin" and post.class_id != user.class_id:
+ can_manage = "timeline_manage" in get_effective_class_permissions(user, post.class_id)
+ if not can_manage and post.author_id != user.id:
raise HTTPException(status_code=403, detail="Access denied")
+ ensure_class_permission(user, "class_view", post.class_id)
await delete_timeline(db, post)
return {"message": "Timeline post deleted"}
@@ -204,14 +211,13 @@ async def delete_existing_timeline(
@router.post("/{post_id}/like")
async def like_timeline_post(
post_id: int,
- user: User = Depends(require_role("super_admin", "class_admin", "student")),
+ user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
post = await get_timeline_by_id(db, post_id)
if post is None:
raise HTTPException(status_code=404, detail="Timeline post not found")
- if user.role != "super_admin" and post.class_id != user.class_id:
- raise HTTPException(status_code=403, detail="Access denied")
+ ensure_class_permission(user, "class_view", post.class_id)
return await toggle_like(db, post_id, user.id)
@@ -220,9 +226,13 @@ async def get_post_comments(
post_id: int,
page: int = 1,
page_size: int = 50,
- user: User = Depends(require_role("super_admin", "class_admin", "student")),
+ user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
+ post = await get_timeline_by_id(db, post_id)
+ if post is None:
+ raise HTTPException(status_code=404, detail="Timeline post not found")
+ ensure_class_permission(user, "class_view", post.class_id)
comments, total = await list_comments(db, post_id, page, page_size)
total_pages = (total + page_size - 1) // page_size
items = [
@@ -244,14 +254,13 @@ async def get_post_comments(
async def add_post_comment(
post_id: int,
data: TimelineCommentCreate,
- user: User = Depends(require_role("super_admin", "class_admin", "student")),
+ user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
post = await get_timeline_by_id(db, post_id)
if post is None:
raise HTTPException(status_code=404, detail="Timeline post not found")
- if user.role != "super_admin" and post.class_id != user.class_id:
- raise HTTPException(status_code=403, detail="Access denied")
+ ensure_class_permission(user, "class_view", post.class_id)
comment = await create_comment(db, post_id, user.id, data)
return TimelineCommentOut(
@@ -268,14 +277,19 @@ async def add_post_comment(
@router.delete("/comments/{comment_id}")
async def delete_timeline_comment(
comment_id: int,
- user: User = Depends(require_role("super_admin", "class_admin", "student")),
+ user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
comment = await get_comment_by_id(db, comment_id)
if comment is None:
raise HTTPException(status_code=404, detail="Comment not found")
- if user.role == "student" and comment.author_id != user.id:
+ can_manage = "timeline_manage" in get_effective_class_permissions(user, post.class_id)
+ if not can_manage and comment.author_id != user.id:
raise HTTPException(status_code=403, detail="Access denied")
+ post = await get_timeline_by_id(db, comment.post_id)
+ if post is None:
+ raise HTTPException(status_code=404, detail="Timeline post not found")
+ ensure_class_permission(user, "class_view", post.class_id)
await delete_comment(db, comment)
return {"message": "Comment deleted"}
diff --git a/backend/app/api/upload.py b/backend/app/api/upload.py
index bb9e687..05bad15 100644
--- a/backend/app/api/upload.py
+++ b/backend/app/api/upload.py
@@ -10,7 +10,7 @@ router = APIRouter(prefix="/api/upload", tags=["upload"])
@router.post("/image")
async def upload_image_api(
file: UploadFile = File(...),
- user: User = Depends(require_role("super_admin", "class_admin")),
+ user: User = Depends(require_role("super_admin", "teacher", "student")),
):
"""Upload an image to Tencent COS."""
contents = await file.read()
diff --git a/backend/app/api/users.py b/backend/app/api/users.py
index 2dbc612..91dfbdf 100644
--- a/backend/app/api/users.py
+++ b/backend/app/api/users.py
@@ -1,29 +1,50 @@
-import json
-
-from pydantic import BaseModel
-from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status
+from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from sqlalchemy.ext.asyncio import AsyncSession
-from app.core.deps import get_current_user, require_role
+from app.core.deps import (
+ CLASS_PERMISSIONS,
+ ensure_class_access,
+ ensure_class_permission,
+ get_current_user,
+ require_role,
+)
from app.db.database import get_db
from app.db.models import User
-from app.schemas.user import UserOut, UserUpdate, UserListItem, UserStatusUpdate
+from app.schemas.user import (
+ TeacherAssignRequest,
+ TeacherAssignResponse,
+ TeacherCreateRequest,
+ TeacherCreateResponse,
+ UserOut,
+ UserUpdate,
+ UserListItem,
+ UserStatusUpdate,
+ UserRoleUpdate,
+ CommitteeRoleUpdate,
+ ClassPermissionsUpdate,
+ build_user_list_item,
+ build_user_out,
+)
from app.schemas.common import PageResponse
from app.services.user_service import (
update_profile,
update_user_status,
+ update_user_role,
+ update_user_committee_role,
+ update_user_class_permissions,
+ assign_existing_teacher_to_class,
+ create_or_assign_teacher,
list_users,
get_user_by_id,
)
from app.services.cos_service import upload_image
-from app.services.email_service import send_approval_notification
router = APIRouter(prefix="/api/users", tags=["users"])
@router.get("/me", response_model=UserOut)
async def get_my_profile(user: User = Depends(get_current_user)):
- return UserOut.model_validate(user)
+ return build_user_out(user)
@router.put("/me", response_model=UserOut)
@@ -36,7 +57,7 @@ async def update_my_profile(
updated = await update_profile(db, user, data)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
- return UserOut.model_validate(updated)
+ return build_user_out(updated)
@router.post("/me/avatar")
@@ -65,13 +86,18 @@ async def list_all_users(
class_id: int | None = None,
status: str | None = None,
role: str | None = None,
- admin: User = Depends(require_role("super_admin")),
+ admin: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
+ if class_id is not None:
+ ensure_class_permission(admin, "member_view", class_id)
+ elif admin.role != "super_admin":
+ raise HTTPException(status_code=400, detail="class_id is required")
+
users, total = await list_users(db, page, page_size, class_id, status, role)
total_pages = (total + page_size - 1) // page_size
return PageResponse(
- items=[UserListItem.model_validate(u) for u in users],
+ items=[build_user_list_item(u, class_id) for u in users],
total=total,
page=page,
page_size=page_size,
@@ -83,18 +109,15 @@ async def list_all_users(
async def change_user_status(
user_id: int,
data: UserStatusUpdate,
- admin: User = Depends(require_role("super_admin", "class_admin")),
+ admin: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
target = await get_user_by_id(db, user_id)
if target is None:
raise HTTPException(status_code=404, detail="User not found")
- # Class admin can only manage users in their own class
- if admin.role == "class_admin" and target.class_id != admin.class_id:
- raise HTTPException(
- status_code=403, detail="Cannot manage users outside your class"
- )
+ target_class_id = target.get_default_membership().class_id if target.get_default_membership() else None
+ ensure_class_permission(admin, "member_manage", target_class_id)
# Only super_admin can change roles
if data.role and admin.role != "super_admin":
@@ -103,53 +126,107 @@ async def change_user_status(
)
updated = await update_user_status(db, user_id, data.status, data.role)
-
- # Send email notification
- if data.status in ("approved", "rejected"):
- await send_approval_notification(target.email, data.status == "approved")
-
- return {"message": f"User status updated to {data.status}"}
+ return {"message": f"用户状态已更新为 {data.status}"}
@router.put("/{user_id}/role")
async def change_user_role(
user_id: int,
- role: str,
+ data: UserRoleUpdate,
admin: User = Depends(require_role("super_admin")),
db: AsyncSession = Depends(get_db),
):
- if role not in ("super_admin", "class_admin", "student"):
+ if data.role not in ("super_admin", "teacher", "student"):
raise HTTPException(status_code=400, detail="Invalid role")
target = await get_user_by_id(db, user_id)
if target is None:
raise HTTPException(status_code=404, detail="User not found")
- target.role = role
- await db.commit()
- return {"message": f"User role updated to {role}"}
-
-
-class CommitteeRoleUpdate(BaseModel):
- committee_role: str | None = None
+ await update_user_role(db, target, data.role)
+ return {"message": f"User role updated to {data.role}"}
@router.put("/{user_id}/committee-role")
async def change_committee_role(
user_id: int,
data: CommitteeRoleUpdate,
- admin: User = Depends(require_role("super_admin", "class_admin")),
+ admin: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
target = await get_user_by_id(db, user_id)
if target is None:
raise HTTPException(status_code=404, detail="User not found")
- if admin.role == "class_admin" and target.class_id != admin.class_id:
- raise HTTPException(
- status_code=403, detail="Cannot manage users outside your class"
- )
+ ensure_class_permission(admin, "committee_manage", data.class_id)
- target.committee_role = data.committee_role
- await db.commit()
+ try:
+ await update_user_committee_role(db, target, data.class_id, data.committee_role)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
return {"message": "Committee role updated"}
+
+
+@router.put("/{user_id}/class-permissions")
+async def change_class_permissions(
+ user_id: int,
+ data: ClassPermissionsUpdate,
+ admin: User = Depends(require_role("super_admin", "teacher", "student")),
+ db: AsyncSession = Depends(get_db),
+):
+ target = await get_user_by_id(db, user_id)
+ if target is None:
+ raise HTTPException(status_code=404, detail="User not found")
+
+ ensure_class_permission(admin, "committee_manage", data.class_id)
+
+ invalid = [permission for permission in data.class_permissions if permission not in CLASS_PERMISSIONS]
+ if invalid:
+ raise HTTPException(status_code=400, detail=f"Invalid permissions: {', '.join(invalid)}")
+
+ try:
+ await update_user_class_permissions(db, target, data.class_id, data.class_permissions)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+ return {"message": "Class permissions updated"}
+
+
+@router.post("/teachers", response_model=TeacherCreateResponse)
+async def create_teacher_user(
+ data: TeacherCreateRequest,
+ admin: User = Depends(require_role("super_admin")),
+ db: AsyncSession = Depends(get_db),
+):
+ try:
+ user, created, assigned = await create_or_assign_teacher(db, data)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+
+ if created:
+ message = "老师账号已创建并加入当前班级"
+ elif assigned:
+ message = "老师账号已加入当前班级"
+ else:
+ message = "该老师已存在于当前班级"
+
+ return TeacherCreateResponse(
+ message=message,
+ user=build_user_out(user, data.class_id),
+ )
+
+
+@router.post("/teachers/assign", response_model=TeacherAssignResponse)
+async def assign_teacher_to_class(
+ data: TeacherAssignRequest,
+ admin: User = Depends(require_role("super_admin")),
+ db: AsyncSession = Depends(get_db),
+):
+ try:
+ user, assigned = await assign_existing_teacher_to_class(db, data)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+
+ return TeacherAssignResponse(
+ message="老师已加入当前班级" if assigned else "该老师已在当前班级",
+ user=build_user_out(user, data.class_id),
+ )
diff --git a/backend/app/api/votes.py b/backend/app/api/votes.py
index 59137ab..d4ea8b8 100644
--- a/backend/app/api/votes.py
+++ b/backend/app/api/votes.py
@@ -1,7 +1,12 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
-from app.core.deps import require_role
+from app.core.deps import (
+ ensure_class_permission,
+ get_effective_class_permissions,
+ require_role,
+ resolve_class_id_for_user,
+)
from app.db.database import get_db
from app.db.models import User
from app.schemas.vote import VoteCreate, VoteUpdate, VoteSubmit, VoteOptionOut, VoteOut
@@ -70,12 +75,13 @@ async def get_votes(
page: int = 1,
page_size: int = 20,
class_id: int | None = None,
- user: User = Depends(require_role("super_admin", "class_admin", "student")),
+ user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
- effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
+ effective_class_id = resolve_class_id_for_user(user, class_id)
if effective_class_id is None:
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
+ ensure_class_permission(user, "class_view", effective_class_id)
votes, total = await list_votes(db, effective_class_id, page, page_size)
total_pages = (total + page_size - 1) // page_size
@@ -87,7 +93,7 @@ async def get_votes(
async def create_new_vote(
data: VoteCreate,
class_id: int | None = None,
- user: User = Depends(require_role("super_admin", "class_admin", "student")),
+ user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
if len(data.options) < 2:
@@ -95,9 +101,10 @@ async def create_new_vote(
if data.vote_type == "multiple" and data.max_choices < 2:
raise HTTPException(status_code=400, detail="多选投票最多可选数不能小于 2")
- effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
+ effective_class_id = resolve_class_id_for_user(user, class_id)
if effective_class_id is None:
raise HTTPException(status_code=400, detail="You are not assigned to a class")
+ ensure_class_permission(user, "class_view", effective_class_id)
vote = await create_vote(db, effective_class_id, user.id, data)
# Reload with relationships
@@ -108,14 +115,13 @@ async def create_new_vote(
@router.get("/{vote_id}", response_model=VoteOut)
async def get_vote_detail(
vote_id: int,
- user: User = Depends(require_role("super_admin", "class_admin", "student")),
+ user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
vote = await get_vote_by_id(db, vote_id)
if vote is None:
raise HTTPException(status_code=404, detail="Vote not found")
- if user.role != "super_admin" and vote.class_id != user.class_id:
- raise HTTPException(status_code=403, detail="Access denied")
+ ensure_class_permission(user, "class_view", vote.class_id)
return _build_vote_out(vote, user.id)
@@ -123,14 +129,13 @@ async def get_vote_detail(
async def submit_vote_response(
vote_id: int,
data: VoteSubmit,
- user: User = Depends(require_role("super_admin", "class_admin", "student")),
+ user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
vote = await get_vote_by_id(db, vote_id)
if vote is None:
raise HTTPException(status_code=404, detail="Vote not found")
- if user.role != "super_admin" and vote.class_id != user.class_id:
- raise HTTPException(status_code=403, detail="Access denied")
+ ensure_class_permission(user, "class_view", vote.class_id)
try:
await submit_vote(db, vote_id, user.id, data.option_ids)
@@ -143,17 +148,17 @@ async def submit_vote_response(
@router.put("/{vote_id}/close")
async def close_vote_endpoint(
vote_id: int,
- user: User = Depends(require_role("super_admin", "class_admin", "student")),
+ user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
vote = await get_vote_by_id(db, vote_id)
if vote is None:
raise HTTPException(status_code=404, detail="Vote not found")
# Only creator or admin can close
- if user.role == "student" and vote.creator_id != user.id:
+ can_manage = "vote_manage" in get_effective_class_permissions(user, vote.class_id)
+ if not can_manage and user.role == "student" and vote.creator_id != user.id:
raise HTTPException(status_code=403, detail="只有创建者或管理员可以关闭投票")
- if user.role != "super_admin" and vote.class_id != user.class_id:
- raise HTTPException(status_code=403, detail="Access denied")
+ ensure_class_permission(user, "class_view", vote.class_id)
await close_vote(db, vote)
return {"message": "投票已关闭"}
@@ -162,16 +167,16 @@ async def close_vote_endpoint(
@router.delete("/{vote_id}")
async def delete_vote_endpoint(
vote_id: int,
- user: User = Depends(require_role("super_admin", "class_admin", "student")),
+ user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
vote = await get_vote_by_id(db, vote_id)
if vote is None:
raise HTTPException(status_code=404, detail="Vote not found")
- if user.role == "student" and vote.creator_id != user.id:
+ can_manage = "vote_manage" in get_effective_class_permissions(user, vote.class_id)
+ if not can_manage and user.role == "student" and vote.creator_id != user.id:
raise HTTPException(status_code=403, detail="只有创建者或管理员可以删除投票")
- if user.role != "super_admin" and vote.class_id != user.class_id:
- raise HTTPException(status_code=403, detail="Access denied")
+ ensure_class_permission(user, "class_view", vote.class_id)
await delete_vote(db, vote)
return {"message": "投票已删除"}
diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py
index e1a8bba..ca2b25b 100644
--- a/backend/app/core/deps.py
+++ b/backend/app/core/deps.py
@@ -1,14 +1,44 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select
+from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.auth import decode_access_token
from app.db.database import get_db
-from app.db.models import User
+from app.db.models import ClassMembership, User
security = HTTPBearer()
+CLASS_PERMISSIONS = {
+ "class_view",
+ "member_view",
+ "member_manage",
+ "committee_manage",
+ "announcement_manage",
+ "timeline_manage",
+ "vote_manage",
+ "schedule_manage",
+ "resource_manage",
+ "assignment_manage",
+ "fund_manage",
+ "module_manage",
+}
+
+TEACHER_DEFAULT_PERMISSIONS = {
+ "class_view",
+ "member_view",
+ "member_manage",
+ "committee_manage",
+ "announcement_manage",
+ "timeline_manage",
+ "vote_manage",
+ "schedule_manage",
+ "resource_manage",
+ "assignment_manage",
+ "module_manage",
+}
+
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
@@ -28,13 +58,24 @@ async def get_current_user(
detail="Invalid token format",
)
- result = await db.execute(select(User).where(User.id == int(user_id)))
+ result = await db.execute(
+ select(User)
+ .options(
+ selectinload(User.memberships),
+ selectinload(User.memberships).selectinload(ClassMembership.class_),
+ )
+ .where(User.id == int(user_id))
+ )
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found"
)
+ if user.status == "inactive":
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN, detail="Account inactive"
+ )
if user.status == "disabled":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Account disabled"
@@ -55,3 +96,68 @@ def require_role(*roles: str):
return user
return _check
+
+
+def get_membership_for_class(user: User, class_id: int | None) -> ClassMembership | None:
+ return user.get_membership(class_id)
+
+
+def get_active_membership(
+ user: User, class_id: int | None = None
+) -> ClassMembership | None:
+ membership = get_membership_for_class(user, class_id)
+ if membership is not None:
+ return membership
+ return user.get_default_membership()
+
+
+def get_effective_class_permissions(user: User, class_id: int | None = None) -> set[str]:
+ if user.role == "super_admin":
+ return set(CLASS_PERMISSIONS)
+ membership = get_active_membership(user, class_id)
+ scoped_permissions = membership.get_class_permissions() if membership else []
+ if user.role == "teacher":
+ return set(TEACHER_DEFAULT_PERMISSIONS) | set(scoped_permissions)
+ return set(scoped_permissions)
+
+
+def can_access_class(user: User, class_id: int | None) -> bool:
+ if class_id is None:
+ return False
+ if user.role in {"super_admin", "teacher"}:
+ return True
+ return get_membership_for_class(user, class_id) is not None
+
+
+def resolve_class_id_for_user(user: User, requested_class_id: int | None) -> int | None:
+ if user.role in {"super_admin", "teacher"}:
+ return requested_class_id
+ if requested_class_id is not None and can_access_class(user, requested_class_id):
+ return requested_class_id
+ membership = user.get_default_membership()
+ return membership.class_id if membership else None
+
+
+def ensure_class_access(user: User, class_id: int | None):
+ if not can_access_class(user, class_id):
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="Access denied for this class",
+ )
+
+
+def ensure_class_permission(user: User, permission: str, class_id: int | None = None):
+ if permission not in CLASS_PERMISSIONS:
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail=f"Unknown permission: {permission}",
+ )
+ if class_id is not None:
+ ensure_class_access(user, class_id)
+ if user.role == "super_admin":
+ return
+ if permission not in get_effective_class_permissions(user, class_id):
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="Insufficient permissions",
+ )
diff --git a/backend/app/db/database.py b/backend/app/db/database.py
index 3982c31..bfe5cc5 100644
--- a/backend/app/db/database.py
+++ b/backend/app/db/database.py
@@ -9,9 +9,3 @@ async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit
async def get_db():
async with async_session() as session:
yield session
-
-
-async def create_tables():
- from app.db.base import Base
- async with engine.begin() as conn:
- await conn.run_sync(Base.metadata.create_all)
diff --git a/backend/app/db/models.py b/backend/app/db/models.py
index 572a89b..1addef3 100644
--- a/backend/app/db/models.py
+++ b/backend/app/db/models.py
@@ -35,7 +35,9 @@ class Class_(Base):
def set_enabled_modules(self, modules: list[str]):
self.enabled_modules = json.dumps(modules, ensure_ascii=False) if modules else None
- members: Mapped[list["User"]] = relationship("User", back_populates="class_")
+ memberships: Mapped[list["ClassMembership"]] = relationship(
+ "ClassMembership", back_populates="class_", cascade="all, delete-orphan"
+ )
timelines: Mapped[list["Timeline"]] = relationship(
"Timeline", back_populates="class_", cascade="all, delete-orphan"
)
@@ -48,9 +50,6 @@ class Class_(Base):
resources: Mapped[list["Resource"]] = relationship(
"Resource", back_populates="class_", cascade="all, delete-orphan"
)
- roster: Mapped[list["StudentRoster"]] = relationship(
- "StudentRoster", back_populates="class_", cascade="all, delete-orphan"
- )
assignments: Mapped[list["Assignment"]] = relationship(
"Assignment", back_populates="class_", cascade="all, delete-orphan"
)
@@ -67,25 +66,19 @@ class User(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
- password_hash: Mapped[str] = mapped_column(Text, nullable=False)
+ password_hash: Mapped[str | None] = mapped_column(Text, nullable=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
student_id: Mapped[str | None] = mapped_column(String(50), nullable=True, unique=True)
- # role: super_admin | class_admin | student
+ # role: super_admin | teacher | student
role: Mapped[str] = mapped_column(String(20), default="student", nullable=False)
- # status: pending | approved | rejected | disabled
- status: Mapped[str] = mapped_column(String(20), default="pending", nullable=False)
-
- class_id: Mapped[int | None] = mapped_column(
- Integer, ForeignKey("classes.id"), nullable=True
- )
- class_: Mapped["Class_ | None"] = relationship("Class_", back_populates="members")
+ # status: inactive | approved | disabled
+ status: Mapped[str] = mapped_column(String(20), default="inactive", nullable=False)
# Profile
industry: Mapped[str | None] = mapped_column(String(100), nullable=True)
company: Mapped[str | None] = mapped_column(String(100), nullable=True)
position: Mapped[str | None] = mapped_column(String(100), nullable=True)
- committee_role: Mapped[str | None] = mapped_column(String(50), nullable=True)
skills_tags: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON array
wechat_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
phone: Mapped[str | None] = mapped_column(String(20), nullable=True)
@@ -97,6 +90,9 @@ class User(Base):
DateTime, server_default=func.now(), onupdate=func.now()
)
+ memberships: Mapped[list["ClassMembership"]] = relationship(
+ "ClassMembership", back_populates="user", cascade="all, delete-orphan"
+ )
timeline_posts: Mapped[list["Timeline"]] = relationship(
"Timeline", back_populates="author"
)
@@ -121,6 +117,65 @@ class User(Base):
def set_skills_list(self, tags: list[str]):
self.skills_tags = json.dumps(tags, ensure_ascii=False) if tags else None
+ def get_membership(self, class_id: int | None) -> "ClassMembership | None":
+ if class_id is None:
+ return None
+ return next((m for m in self.memberships if m.class_id == class_id), None)
+
+ def get_default_membership(self) -> "ClassMembership | None":
+ active_membership = getattr(self, "_active_membership", None)
+ if active_membership is not None:
+ return active_membership
+ if len(self.memberships) == 1:
+ return self.memberships[0]
+ return self.memberships[0] if self.memberships else None
+
+ def set_active_membership(self, class_id: int | None = None):
+ setattr(self, "_active_membership", self.get_membership(class_id))
+
+
+class ClassMembership(Base):
+ __tablename__ = "class_memberships"
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+ user_id: Mapped[int] = mapped_column(
+ Integer, ForeignKey("users.id"), nullable=False, index=True
+ )
+ class_id: Mapped[int] = mapped_column(
+ Integer, ForeignKey("classes.id"), nullable=False, index=True
+ )
+ membership_role: Mapped[str] = mapped_column(
+ String(20), default="student", nullable=False
+ )
+ committee_role: Mapped[str | None] = mapped_column(String(50), nullable=True)
+ class_permissions: Mapped[str | None] = mapped_column(Text, nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime, server_default=func.now(), onupdate=func.now()
+ )
+
+ user: Mapped["User"] = relationship("User", back_populates="memberships")
+ class_: Mapped["Class_"] = relationship("Class_", back_populates="memberships")
+
+ __table_args__ = (
+ UniqueConstraint("user_id", "class_id", name="uq_class_membership_user_class"),
+ )
+
+ def get_class_permissions(self) -> list[str]:
+ if not self.class_permissions:
+ return []
+ try:
+ return json.loads(self.class_permissions)
+ except (json.JSONDecodeError, TypeError):
+ return []
+
+ def set_class_permissions(self, permissions: list[str]):
+ self.class_permissions = (
+ json.dumps(sorted(set(permissions)), ensure_ascii=False)
+ if permissions
+ else None
+ )
+
class Timeline(Base):
__tablename__ = "timelines"
@@ -245,27 +300,6 @@ class Notification(Base):
user: Mapped["User"] = relationship("User")
-class StudentRoster(Base):
- __tablename__ = "student_rosters"
-
- id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
- class_id: Mapped[int] = mapped_column(
- Integer, ForeignKey("classes.id"), nullable=False, index=True
- )
- student_id: Mapped[str] = mapped_column(String(50), nullable=False)
- name: Mapped[str] = mapped_column(String(100), nullable=False)
- status: Mapped[str] = mapped_column(
- String(20), default="unregistered", nullable=False
- )
- user_id: Mapped[int | None] = mapped_column(
- Integer, ForeignKey("users.id"), nullable=True
- )
- created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
-
- class_: Mapped["Class_"] = relationship("Class_", back_populates="roster")
- user: Mapped["User | None"] = relationship("User")
-
-
class TimelineLike(Base):
__tablename__ = "timeline_likes"
diff --git a/backend/app/main.py b/backend/app/main.py
index 96651d6..0a3a9b7 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -5,7 +5,6 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
-from app.db.database import create_tables
from app.api import auth, users, classes, directory, timeline, schedule, upload, announcements, resources, notifications, votes, assignments, fund
logging.basicConfig(
@@ -37,45 +36,9 @@ async def ensure_super_admin():
logger.info("Super admin seeded: %s", settings.super_admin_email)
-async def ensure_sample_class():
- """Seed a sample class if none exists."""
- from sqlalchemy import select, func, text
- from app.db.database import async_session
- from app.db.models import Class_
-
- async with async_session() as db:
- result = await db.execute(select(func.count(Class_.id)))
- count = result.scalar()
- if count == 0:
- sample = Class_(
- name="HKU ICB Sample Class",
- cohort_year=2025,
- description="Sample class for testing",
- )
- db.add(sample)
- await db.commit()
- logger.info("Sample class seeded")
-
-
-async def migrate_add_enabled_modules():
- """Add enabled_modules column to classes table if not exists."""
- from sqlalchemy import text
- from app.db.database import engine
-
- async with engine.begin() as conn:
- result = await conn.execute(text("PRAGMA table_info(classes)"))
- columns = [row[1] for row in result.fetchall()]
- if "enabled_modules" not in columns:
- await conn.execute(text("ALTER TABLE classes ADD COLUMN enabled_modules TEXT"))
- logger.info("Migration: added enabled_modules column to classes table")
-
-
@asynccontextmanager
async def lifespan(app: FastAPI):
- await create_tables()
- await migrate_add_enabled_modules()
await ensure_super_admin()
- await ensure_sample_class()
yield
diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py
index 851f3f7..04d4c5f 100644
--- a/backend/app/schemas/auth.py
+++ b/backend/app/schemas/auth.py
@@ -9,7 +9,6 @@ class LoginRequest(BaseModel):
class RegisterRequest(BaseModel):
invite_code: str
student_id: str
- name: str
email: EmailStr
password: str
diff --git a/backend/app/schemas/inactive_member.py b/backend/app/schemas/inactive_member.py
new file mode 100644
index 0000000..2670ed2
--- /dev/null
+++ b/backend/app/schemas/inactive_member.py
@@ -0,0 +1,25 @@
+from pydantic import BaseModel
+
+from app.db.models import User
+
+
+class InactiveMemberOut(BaseModel):
+ id: int
+ student_id: str
+ name: str
+ status: str # "inactive"
+ user_id: int | None
+
+
+class MemberImportRequest(BaseModel):
+ entries: list[dict] # [{"student_id": "...", "name": "..."}, ...]
+
+
+def build_inactive_member_out(user: User) -> InactiveMemberOut:
+ return InactiveMemberOut(
+ id=user.id,
+ student_id=user.student_id or "",
+ name=user.name,
+ status="inactive",
+ user_id=user.id,
+ )
diff --git a/backend/app/schemas/roster.py b/backend/app/schemas/roster.py
deleted file mode 100644
index 75640e7..0000000
--- a/backend/app/schemas/roster.py
+++ /dev/null
@@ -1,15 +0,0 @@
-from pydantic import BaseModel
-
-
-class RosterOut(BaseModel):
- id: int
- student_id: str
- name: str
- status: str # "unregistered" | "registered"
- user_id: int | None
-
- model_config = {"from_attributes": True}
-
-
-class RosterImportRequest(BaseModel):
- entries: list[dict] # [{"student_id": "...", "name": "..."}, ...]
diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py
index 2a1d468..74b0cf9 100644
--- a/backend/app/schemas/user.py
+++ b/backend/app/schemas/user.py
@@ -1,7 +1,17 @@
-import json
from datetime import datetime
-from pydantic import BaseModel, EmailStr, field_validator
+from pydantic import BaseModel, EmailStr, Field
+
+from app.db.models import ClassMembership, User
+
+
+class MembershipOut(BaseModel):
+ id: int
+ class_id: int
+ class_name: str | None
+ membership_role: str
+ committee_role: str | None = None
+ class_permissions: list[str]
class UserOut(BaseModel):
@@ -11,34 +21,21 @@ class UserOut(BaseModel):
student_id: str | None
role: str
status: str
- class_id: int | None
industry: str | None
company: str | None
position: str | None
- committee_role: str | None
skills_tags: list[str] | None
wechat_id: str | None
phone: str | None
avatar_url: str | None
bio: str | None
created_at: datetime
+ memberships: list[MembershipOut]
+ active_membership: MembershipOut | None = None
enabled_modules: list[str] | None = None
- model_config = {"from_attributes": True}
-
- @field_validator("skills_tags", mode="before")
- @classmethod
- def parse_skills_tags(cls, v):
- if isinstance(v, str):
- try:
- return json.loads(v)
- except (json.JSONDecodeError, TypeError):
- return []
- return v
-
class UserPublic(BaseModel):
- """Shown to same-class approved members (includes contact info)."""
id: int
name: str
student_id: str | None
@@ -53,20 +50,19 @@ class UserPublic(BaseModel):
class UserListItem(BaseModel):
- """For admin user management list."""
id: int
email: str
name: str
student_id: str | None
role: str
status: str
- class_id: int | None
industry: str | None
company: str | None
committee_role: str | None = None
+ class_permissions: list[str]
created_at: datetime
-
- model_config = {"from_attributes": True}
+ memberships: list[MembershipOut]
+ active_membership: MembershipOut | None = None
class UserUpdate(BaseModel):
@@ -81,10 +77,101 @@ class UserUpdate(BaseModel):
class UserStatusUpdate(BaseModel):
- status: str # approved | rejected | disabled
+ status: str
role: str | None = None
+class UserRoleUpdate(BaseModel):
+ role: str
+
+
+class TeacherCreateRequest(BaseModel):
+ class_id: int
+ name: str = Field(min_length=1, max_length=100)
+ email: EmailStr
+ password: str = Field(min_length=8, max_length=128)
+
+
+class TeacherCreateResponse(BaseModel):
+ message: str
+ user: UserOut
+
+
+class TeacherAssignRequest(BaseModel):
+ class_id: int
+ email: EmailStr
+
+
+class TeacherAssignResponse(BaseModel):
+ message: str
+ user: UserOut
+
+
+class CommitteeRoleUpdate(BaseModel):
+ class_id: int
+ committee_role: str | None = None
+
+
+class ClassPermissionsUpdate(BaseModel):
+ class_id: int
+ class_permissions: list[str]
+
+
class TokenResponse(BaseModel):
token: str
user: UserOut
+
+
+def build_membership_out(membership: ClassMembership) -> MembershipOut:
+ return MembershipOut(
+ id=membership.id,
+ class_id=membership.class_id,
+ class_name=membership.class_.name if membership.class_ else None,
+ membership_role=membership.membership_role,
+ committee_role=membership.committee_role,
+ class_permissions=membership.get_class_permissions(),
+ )
+
+
+def build_user_out(user: User, class_id: int | None = None) -> UserOut:
+ active_membership = user.get_membership(class_id) if class_id is not None else user.get_default_membership()
+ memberships = [build_membership_out(membership) for membership in user.memberships]
+ return UserOut(
+ id=user.id,
+ email=user.email,
+ name=user.name,
+ student_id=user.student_id,
+ role=user.role,
+ status=user.status,
+ industry=user.industry,
+ company=user.company,
+ position=user.position,
+ skills_tags=user.get_skills_list(),
+ wechat_id=user.wechat_id,
+ phone=user.phone,
+ avatar_url=user.avatar_url,
+ bio=user.bio,
+ created_at=user.created_at,
+ memberships=memberships,
+ active_membership=build_membership_out(active_membership) if active_membership else None,
+ )
+
+
+def build_user_list_item(user: User, class_id: int | None = None) -> UserListItem:
+ active_membership = user.get_membership(class_id) if class_id is not None else user.get_default_membership()
+ memberships = [build_membership_out(membership) for membership in user.memberships]
+ return UserListItem(
+ id=user.id,
+ email=user.email,
+ name=user.name,
+ student_id=user.student_id,
+ role=user.role,
+ status=user.status,
+ industry=user.industry,
+ company=user.company,
+ committee_role=active_membership.committee_role if active_membership else None,
+ class_permissions=active_membership.get_class_permissions() if active_membership else [],
+ created_at=user.created_at,
+ memberships=memberships,
+ active_membership=build_membership_out(active_membership) if active_membership else None,
+ )
diff --git a/backend/app/services/class_service.py b/backend/app/services/class_service.py
index 003efd1..81c65c7 100644
--- a/backend/app/services/class_service.py
+++ b/backend/app/services/class_service.py
@@ -1,7 +1,8 @@
from sqlalchemy import select, func
+from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession
-from app.db.models import Class_, User
+from app.db.models import Class_, ClassMembership, User
from app.schemas.class_ import ClassCreate, ClassUpdate
@@ -49,8 +50,11 @@ async def list_classes(
async def get_member_count(db: AsyncSession, class_id: int) -> int:
result = await db.execute(
- select(func.count(User.id)).where(
- User.class_id == class_id, User.status == "approved"
+ select(func.count(ClassMembership.id))
+ .join(User, User.id == ClassMembership.user_id)
+ .where(
+ ClassMembership.class_id == class_id,
+ User.status == "approved",
)
)
return result.scalar() or 0
@@ -63,8 +67,18 @@ async def get_class_members(
page: int = 1,
page_size: int = 50,
) -> tuple[list[User], int]:
- query = select(User).where(User.class_id == class_id)
- count_query = select(func.count(User.id)).where(User.class_id == class_id)
+ query = (
+ select(User)
+ .options(
+ selectinload(User.memberships),
+ selectinload(User.memberships).selectinload(ClassMembership.class_),
+ )
+ .join(ClassMembership)
+ .where(ClassMembership.class_id == class_id)
+ )
+ count_query = select(func.count(User.id)).join(ClassMembership).where(
+ ClassMembership.class_id == class_id
+ )
if status:
query = query.where(User.status == status)
@@ -76,4 +90,4 @@ async def get_class_members(
result = await db.execute(
query.order_by(User.name).offset((page - 1) * page_size).limit(page_size)
)
- return list(result.scalars().all()), total
+ return list(result.scalars().unique().all()), total
diff --git a/backend/app/services/directory_service.py b/backend/app/services/directory_service.py
index fd9c605..b805b62 100644
--- a/backend/app/services/directory_service.py
+++ b/backend/app/services/directory_service.py
@@ -1,8 +1,9 @@
import json
from sqlalchemy import select, or_, func, case
+from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession
-from app.db.models import User
+from app.db.models import ClassMembership, User
from app.schemas.user import UserPublic
@@ -15,12 +16,20 @@ async def search_directory(
page: int = 1,
page_size: int = 20,
) -> tuple[list[User], int]:
- """Search approved members in a class."""
- query = select(User).where(
- User.class_id == class_id, User.status == "approved"
+ """Search active members in a class."""
+ query = (
+ select(User)
+ .options(
+ selectinload(User.memberships),
+ selectinload(User.memberships).selectinload(ClassMembership.class_),
+ )
+ .join(ClassMembership)
+ .where(ClassMembership.class_id == class_id, User.status == "approved")
)
- count_query = select(func.count(User.id)).where(
- User.class_id == class_id, User.status == "approved"
+ count_query = (
+ select(func.count(User.id))
+ .join(ClassMembership)
+ .where(ClassMembership.class_id == class_id, User.status == "approved")
)
if search:
@@ -54,23 +63,26 @@ async def search_directory(
# Committee role priority: 班长(1) > 副班长(2) > other roles(3) > no role(4)
committee_order = case(
- (User.committee_role == None, 4),
- (User.committee_role == "班长", 1),
- (User.committee_role == "副班长", 2),
+ (ClassMembership.committee_role == None, 4),
+ (ClassMembership.committee_role == "班长", 1),
+ (ClassMembership.committee_role == "副班长", 2),
else_=3,
)
result = await db.execute(
- query.order_by(committee_order, User.committee_role, User.name)
+ query.order_by(committee_order, ClassMembership.committee_role, User.name)
.offset((page - 1) * page_size)
.limit(page_size)
)
- users = list(result.scalars().all())
+ users = list(result.scalars().unique().all())
return users, total
-def user_to_public(user: User, include_contact: bool = True) -> UserPublic:
+def user_to_public(
+ user: User, class_id: int | None = None, include_contact: bool = True
+) -> UserPublic:
"""Convert User model to public profile, optionally hiding contact info."""
+ membership = user.get_membership(class_id) if class_id is not None else user.get_default_membership()
return UserPublic(
id=user.id,
name=user.name,
@@ -78,7 +90,7 @@ def user_to_public(user: User, include_contact: bool = True) -> UserPublic:
industry=user.industry,
company=user.company,
position=user.position,
- committee_role=user.committee_role,
+ committee_role=membership.committee_role if membership else None,
wechat_id=user.wechat_id if include_contact else None,
phone=user.phone if include_contact else None,
avatar_url=user.avatar_url,
diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py
index 28f0b20..8d532d8 100644
--- a/backend/app/services/email_service.py
+++ b/backend/app/services/email_service.py
@@ -36,27 +36,13 @@ async def send_email(to: str, subject: str, html_body: str) -> bool:
return False
-async def send_registration_notification(
- admin_email: str, student_name: str, class_name: str
-):
- html = f"""
-
New Registration Pending Approval
- {student_name} has registered for {class_name}.
- Please log in to HKU ICB to review and approve.
+async def send_account_activated_email(member_email: str):
+ html = """
+ Account Activated
+ Your HKU ICB account has been activated successfully.
+ You can now log in to the platform.
"""
- await send_email(admin_email, "HKU ICB: New Registration", html)
-
-
-async def send_approval_notification(student_email: str, approved: bool):
- status_text = "approved" if approved else "rejected"
- html = f"""
- Registration {status_text.capitalize()}
- Your registration has been {status_text}.
- {"You can now log in to HKU ICB.
" if approved else ""}
- """
- await send_email(
- student_email, f"HKU ICB: Registration {status_text.capitalize()}", html
- )
+ await send_email(member_email, "HKU ICB: Account Activated", html)
async def send_class_notification_email(
diff --git a/backend/app/services/member_activation_service.py b/backend/app/services/member_activation_service.py
new file mode 100644
index 0000000..d0b852f
--- /dev/null
+++ b/backend/app/services/member_activation_service.py
@@ -0,0 +1,208 @@
+import secrets
+
+from sqlalchemy import select, func
+from sqlalchemy.orm import selectinload
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.db.models import ClassMembership, Class_, User
+
+
+def generate_invite_code(length: int = 8) -> str:
+ chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
+ return "".join(secrets.choice(chars) for _ in range(length))
+
+
+async def ensure_invite_code(db: AsyncSession, class_id: int) -> str:
+ result = await db.execute(select(Class_).where(Class_.id == class_id))
+ class_ = result.scalar_one_or_none()
+ if class_ is None:
+ return ""
+ if not class_.invite_code:
+ class_.invite_code = generate_invite_code()
+ await db.commit()
+ await db.refresh(class_)
+ return class_.invite_code
+
+
+async def regenerate_invite_code(db: AsyncSession, class_id: int) -> str:
+ result = await db.execute(select(Class_).where(Class_.id == class_id))
+ class_ = result.scalar_one_or_none()
+ if class_ is None:
+ return ""
+ class_.invite_code = generate_invite_code()
+ await db.commit()
+ await db.refresh(class_)
+ return class_.invite_code
+
+
+async def import_members(
+ db: AsyncSession, class_id: int, entries: list[dict]
+) -> int:
+ incoming_entries: list[tuple[str, str]] = []
+ for entry in entries:
+ sid = entry.get("student_id", "").strip()
+ name = entry.get("name", "").strip()
+ if sid and name:
+ incoming_entries.append((sid, name))
+
+ if not incoming_entries:
+ return 0
+
+ student_ids = {sid for sid, _ in incoming_entries}
+ result = await db.execute(
+ select(User)
+ .options(
+ selectinload(User.memberships),
+ selectinload(User.memberships).selectinload(ClassMembership.class_),
+ )
+ .where(User.student_id.in_(student_ids))
+ )
+ existing_users = {user.student_id: user for user in result.scalars().unique().all() if user.student_id}
+
+ count = 0
+ seen_in_batch: set[str] = set()
+ for sid, name in incoming_entries:
+ if sid in seen_in_batch:
+ continue
+ seen_in_batch.add(sid)
+
+ user = existing_users.get(sid)
+ if user is not None:
+ if user.get_membership(class_id) is not None:
+ continue
+ if user.status == "inactive":
+ user.name = name
+ db.add(
+ ClassMembership(
+ user_id=user.id,
+ class_id=class_id,
+ membership_role="student",
+ )
+ )
+ count += 1
+ continue
+
+ placeholder_email = f"inactive+{class_id}.{sid}@member.local"
+ new_user = User(
+ email=placeholder_email,
+ password_hash=None,
+ name=name,
+ student_id=sid,
+ role="student",
+ status="inactive",
+ )
+ db.add(new_user)
+ await db.flush()
+ db.add(
+ ClassMembership(
+ user_id=new_user.id,
+ class_id=class_id,
+ membership_role="student",
+ )
+ )
+ existing_users[sid] = new_user
+ count += 1
+
+ await db.commit()
+ return count
+
+
+async def get_inactive_members(
+ db: AsyncSession, class_id: int, page: int = 1, page_size: int = 50
+) -> tuple[list[User], int]:
+ query = (
+ select(User)
+ .options(
+ selectinload(User.memberships),
+ selectinload(User.memberships).selectinload(ClassMembership.class_),
+ )
+ .join(ClassMembership)
+ .where(
+ ClassMembership.class_id == class_id,
+ User.status == "inactive",
+ )
+ )
+ count_query = (
+ select(func.count(User.id))
+ .join(ClassMembership)
+ .where(
+ ClassMembership.class_id == class_id,
+ User.status == "inactive",
+ )
+ )
+
+ total_result = await db.execute(count_query)
+ total = total_result.scalar() or 0
+
+ result = await db.execute(
+ query.order_by(User.student_id)
+ .offset((page - 1) * page_size)
+ .limit(page_size)
+ )
+ return list(result.scalars().unique().all()), total
+
+
+async def validate_registration(
+ db: AsyncSession, invite_code: str, student_id: str
+) -> tuple[User, int] | None:
+ class_result = await db.execute(
+ select(Class_).where(Class_.invite_code == invite_code)
+ )
+ class_ = class_result.scalar_one_or_none()
+ if class_ is None:
+ return None
+ user_result = await db.execute(
+ select(User)
+ .options(
+ selectinload(User.memberships),
+ selectinload(User.memberships).selectinload(ClassMembership.class_),
+ )
+ .join(ClassMembership)
+ .where(
+ ClassMembership.class_id == class_.id,
+ User.student_id == student_id,
+ User.status == "inactive",
+ )
+ )
+ user = user_result.scalar_one_or_none()
+ if user is None:
+ return None
+ user.set_active_membership(class_.id)
+ return user, class_.id
+
+
+async def delete_inactive_member(db: AsyncSession, class_id: int, user_id: int) -> bool:
+ result = await db.execute(
+ select(User)
+ .options(selectinload(User.memberships))
+ .where(User.id == user_id)
+ )
+ user = result.scalar_one_or_none()
+ if user is None or user.status != "inactive":
+ return False
+ membership = user.get_membership(class_id)
+ if membership is None:
+ return False
+ other_memberships = [item for item in user.memberships if item.class_id != class_id]
+ await db.delete(membership)
+ await db.flush()
+ if not other_memberships:
+ await db.delete(user)
+ await db.commit()
+ return True
+
+
+async def clear_inactive_members(db: AsyncSession, class_id: int) -> int:
+ result = await db.execute(
+ select(User.id)
+ .join(ClassMembership)
+ .where(
+ ClassMembership.class_id == class_id,
+ User.status == "inactive",
+ )
+ )
+ user_ids = list(result.scalars().all())
+ removed = 0
+ for user_id in user_ids:
+ removed += 1 if await delete_inactive_member(db, class_id, user_id) else 0
+ return removed
diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py
index f379b8a..b54d29c 100644
--- a/backend/app/services/notification_service.py
+++ b/backend/app/services/notification_service.py
@@ -4,7 +4,7 @@ import logging
from sqlalchemy import select, func, update
from sqlalchemy.ext.asyncio import AsyncSession
-from app.db.models import Notification, User
+from app.db.models import ClassMembership, Notification, User
from app.services.email_service import send_class_notification_email
logger = logging.getLogger(__name__)
@@ -43,10 +43,12 @@ async def create_notifications_for_class(
email_body: str | None = None,
email_action_path: str | None = None,
):
- """Create in-app notifications + send email for all approved users in a class."""
+ """Create in-app notifications + send email for all active users in a class."""
result = await db.execute(
- select(User.id, User.email).where(
- User.class_id == class_id,
+ select(User.id, User.email)
+ .join(ClassMembership, ClassMembership.user_id == User.id)
+ .where(
+ ClassMembership.class_id == class_id,
User.status == "approved",
)
)
diff --git a/backend/app/services/roster_service.py b/backend/app/services/roster_service.py
deleted file mode 100644
index b0b2fc0..0000000
--- a/backend/app/services/roster_service.py
+++ /dev/null
@@ -1,125 +0,0 @@
-import secrets
-
-from sqlalchemy import select, func
-from sqlalchemy.ext.asyncio import AsyncSession
-
-from app.db.models import StudentRoster, Class_
-
-
-def generate_invite_code(length: int = 8) -> str:
- chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
- return "".join(secrets.choice(chars) for _ in range(length))
-
-
-async def ensure_invite_code(db: AsyncSession, class_id: int) -> str:
- result = await db.execute(select(Class_).where(Class_.id == class_id))
- class_ = result.scalar_one_or_none()
- if class_ is None:
- return ""
- if not class_.invite_code:
- class_.invite_code = generate_invite_code()
- await db.commit()
- await db.refresh(class_)
- return class_.invite_code
-
-
-async def regenerate_invite_code(db: AsyncSession, class_id: int) -> str:
- result = await db.execute(select(Class_).where(Class_.id == class_id))
- class_ = result.scalar_one_or_none()
- if class_ is None:
- return ""
- class_.invite_code = generate_invite_code()
- await db.commit()
- await db.refresh(class_)
- return class_.invite_code
-
-
-async def import_roster(
- db: AsyncSession, class_id: int, entries: list[dict]
-) -> int:
- existing_ids: set[str] = set()
- result = await db.execute(
- select(StudentRoster.student_id).where(StudentRoster.class_id == class_id)
- )
- for row in result.all():
- existing_ids.add(row[0])
-
- count = 0
- for entry in entries:
- sid = entry.get("student_id", "").strip()
- name = entry.get("name", "").strip()
- if not sid or not name or sid in existing_ids:
- continue
- roster = StudentRoster(class_id=class_id, student_id=sid, name=name)
- db.add(roster)
- existing_ids.add(sid)
- count += 1
- await db.commit()
- return count
-
-
-async def get_roster(
- db: AsyncSession, class_id: int, page: int = 1, page_size: int = 50
-) -> tuple[list[StudentRoster], int]:
- query = select(StudentRoster).where(StudentRoster.class_id == class_id)
- count_query = select(func.count(StudentRoster.id)).where(
- StudentRoster.class_id == class_id
- )
-
- total_result = await db.execute(count_query)
- total = total_result.scalar() or 0
-
- result = await db.execute(
- query.order_by(StudentRoster.student_id)
- .offset((page - 1) * page_size)
- .limit(page_size)
- )
- return list(result.scalars().all()), total
-
-
-async def validate_registration(
- db: AsyncSession, invite_code: str, student_id: str
-) -> StudentRoster | None:
- class_result = await db.execute(
- select(Class_).where(Class_.invite_code == invite_code)
- )
- class_ = class_result.scalar_one_or_none()
- if class_ is None:
- return None
-
- roster_result = await db.execute(
- select(StudentRoster).where(
- StudentRoster.class_id == class_.id,
- StudentRoster.student_id == student_id,
- StudentRoster.status == "unregistered",
- )
- )
- return roster_result.scalar_one_or_none()
-
-
-async def delete_roster_entry(db: AsyncSession, roster_id: int) -> bool:
- result = await db.execute(
- select(StudentRoster).where(StudentRoster.id == roster_id)
- )
- entry = result.scalar_one_or_none()
- if entry is None:
- return False
- if entry.status == "registered":
- return False
- await db.delete(entry)
- await db.commit()
- return True
-
-
-async def clear_unregistered_roster(db: AsyncSession, class_id: int) -> int:
- result = await db.execute(
- select(StudentRoster).where(
- StudentRoster.class_id == class_id,
- StudentRoster.status == "unregistered",
- )
- )
- entries = list(result.scalars().all())
- for entry in entries:
- await db.delete(entry)
- await db.commit()
- return len(entries)
diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py
index a951866..10a8d96 100644
--- a/backend/app/services/user_service.py
+++ b/backend/app/services/user_service.py
@@ -1,17 +1,33 @@
from sqlalchemy import select, func
+from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession
-from app.db.models import User
-from app.schemas.user import UserOut, UserUpdate
+from app.db.models import ClassMembership, User
+from app.core.auth import hash_password
+from app.schemas.user import TeacherCreateRequest, TeacherAssignRequest, UserUpdate
async def get_user_by_email(db: AsyncSession, email: str) -> User | None:
- result = await db.execute(select(User).where(User.email == email))
+ result = await db.execute(
+ select(User)
+ .options(
+ selectinload(User.memberships),
+ selectinload(User.memberships).selectinload(ClassMembership.class_),
+ )
+ .where(User.email == email)
+ )
return result.scalar_one_or_none()
async def get_user_by_id(db: AsyncSession, user_id: int) -> User | None:
- result = await db.execute(select(User).where(User.id == user_id))
+ result = await db.execute(
+ select(User)
+ .options(
+ selectinload(User.memberships),
+ selectinload(User.memberships).selectinload(ClassMembership.class_),
+ )
+ .where(User.id == user_id)
+ )
return result.scalar_one_or_none()
@@ -48,6 +64,39 @@ async def update_user_status(
return user
+async def update_user_role(
+ db: AsyncSession, user: User, role: str
+) -> User:
+ user.role = role
+ await db.commit()
+ await db.refresh(user)
+ return user
+
+
+async def update_user_committee_role(
+ db: AsyncSession, user: User, class_id: int, committee_role: str | None
+) -> User:
+ membership = user.get_membership(class_id)
+ if membership is None:
+ raise ValueError("User is not a member of the class")
+ membership.committee_role = committee_role
+ await db.commit()
+ await db.refresh(user)
+ return user
+
+
+async def update_user_class_permissions(
+ db: AsyncSession, user: User, class_id: int, class_permissions: list[str]
+) -> User:
+ membership = user.get_membership(class_id)
+ if membership is None:
+ raise ValueError("User is not a member of the class")
+ membership.set_class_permissions(class_permissions)
+ await db.commit()
+ await db.refresh(user)
+ return user
+
+
async def list_users(
db: AsyncSession,
page: int = 1,
@@ -56,12 +105,15 @@ async def list_users(
status: str | None = None,
role: str | None = None,
) -> tuple[list[User], int]:
- query = select(User)
+ query = select(User).options(
+ selectinload(User.memberships),
+ selectinload(User.memberships).selectinload(ClassMembership.class_),
+ )
count_query = select(func.count(User.id))
if class_id is not None:
- query = query.where(User.class_id == class_id)
- count_query = count_query.where(User.class_id == class_id)
+ query = query.join(ClassMembership).where(ClassMembership.class_id == class_id)
+ count_query = count_query.join(ClassMembership).where(ClassMembership.class_id == class_id)
if status is not None:
query = query.where(User.status == status)
count_query = count_query.where(User.status == status)
@@ -75,6 +127,96 @@ async def list_users(
query = query.order_by(User.created_at.desc())
query = query.offset((page - 1) * page_size).limit(page_size)
result = await db.execute(query)
- users = list(result.scalars().all())
+ users = list(result.scalars().unique().all())
return users, total
+
+
+async def create_or_assign_teacher(
+ db: AsyncSession,
+ data: TeacherCreateRequest,
+) -> tuple[User, bool, bool]:
+ existing_user = await get_user_by_email(db, data.email)
+ created = False
+ assigned = False
+
+ if existing_user is not None:
+ if existing_user.role != "teacher":
+ raise ValueError("该邮箱已存在且不是老师账号")
+
+ membership = existing_user.get_membership(data.class_id)
+ if membership is None:
+ db.add(
+ ClassMembership(
+ user_id=existing_user.id,
+ class_id=data.class_id,
+ membership_role="teacher",
+ )
+ )
+ assigned = True
+ await db.commit()
+ refreshed = await get_user_by_id(db, existing_user.id)
+ if refreshed is None:
+ raise ValueError("老师账号创建失败")
+ refreshed.set_active_membership(data.class_id)
+ return refreshed, created, assigned
+
+ existing_user.set_active_membership(data.class_id)
+ return existing_user, created, assigned
+
+ user = User(
+ email=data.email,
+ password_hash=hash_password(data.password),
+ name=data.name.strip(),
+ student_id=None,
+ role="teacher",
+ status="approved",
+ )
+ db.add(user)
+ await db.flush()
+ db.add(
+ ClassMembership(
+ user_id=user.id,
+ class_id=data.class_id,
+ membership_role="teacher",
+ )
+ )
+ await db.commit()
+ created = True
+ assigned = True
+
+ refreshed = await get_user_by_id(db, user.id)
+ if refreshed is None:
+ raise ValueError("老师账号创建失败")
+ refreshed.set_active_membership(data.class_id)
+ return refreshed, created, assigned
+
+
+async def assign_existing_teacher_to_class(
+ db: AsyncSession,
+ data: TeacherAssignRequest,
+) -> tuple[User, bool]:
+ user = await get_user_by_email(db, data.email)
+ if user is None:
+ raise ValueError("未找到该邮箱对应的老师账号")
+ if user.role != "teacher":
+ raise ValueError("该邮箱对应的账号不是老师")
+
+ membership = user.get_membership(data.class_id)
+ if membership is not None:
+ user.set_active_membership(data.class_id)
+ return user, False
+
+ db.add(
+ ClassMembership(
+ user_id=user.id,
+ class_id=data.class_id,
+ membership_role="teacher",
+ )
+ )
+ await db.commit()
+ refreshed = await get_user_by_id(db, user.id)
+ if refreshed is None:
+ raise ValueError("老师分配失败")
+ refreshed.set_active_membership(data.class_id)
+ return refreshed, True
diff --git a/backend/seed_demo.py b/backend/seed_demo.py
index 9d70c43..6cd68a3 100644
--- a/backend/seed_demo.py
+++ b/backend/seed_demo.py
@@ -18,7 +18,7 @@ from app.db.base import Base
from app.db.models import (
Class_, User, Timeline, TimelineLike, TimelineComment,
Schedule, Announcement, Resource, Notification,
- StudentRoster, Vote, VoteOption, VoteResponse,
+ ClassMembership, Vote, VoteOption, VoteResponse,
Assignment, AssignmentSubmission,
)
from app.core.auth import hash_password
@@ -231,23 +231,29 @@ async def seed():
await db.flush()
print(f"[+] Class: {cls.name} (invite code: {cls.invite_code})")
- # ── 2. Create class admin ────────────────────────────────────────
- admin = User(
+ # ── 2. Create teacher ────────────────────────────────────────────
+ teacher = User(
email=CLASS_ADMIN["email"],
password_hash=pwd_hash,
name=CLASS_ADMIN["name"],
- role="class_admin",
+ role="teacher",
status="approved",
- class_id=cls.id,
industry="教育",
company="香港大学",
position="教授",
bio="香港大学中国商业学院教授,专注于战略管理和企业转型研究。",
wechat_id="lin_prof_hku",
)
- db.add(admin)
+ db.add(teacher)
await db.flush()
- print(f"[+] Class Admin: {admin.name} ({admin.email})")
+ db.add(
+ ClassMembership(
+ user_id=teacher.id,
+ class_id=cls.id,
+ membership_role="teacher",
+ )
+ )
+ print(f"[+] Teacher: {teacher.name} ({teacher.email})")
# ── 3. Create students ───────────────────────────────────────────
COMMITTEE_MAP = {0: "班长", 1: "副班长", 3: "学习委员", 5: "组织委员", 7: "宣传委员", 9: "文体委员"}
@@ -261,11 +267,9 @@ async def seed():
student_id=s["student_id"],
role="student",
status="approved",
- class_id=cls.id,
industry=INDUSTRIES[i % len(INDUSTRIES)],
company=COMPANIES[i % len(COMPANIES)],
position=POSITIONS[i % len(POSITIONS)],
- committee_role=COMMITTEE_MAP.get(i),
skills_tags='["' + '", "'.join(skills) + '"]',
bio=f"在{COMPANIES[i % len(COMPANIES)]}担任{POSITIONS[i % len(POSITIONS)]},拥有丰富的{INDUSTRIES[i % len(INDUSTRIES)]}行业经验。",
wechat_id=f"wx_{s['student_id']}",
@@ -275,23 +279,19 @@ async def seed():
students.append(user)
await db.flush()
+ for user in students:
+ db.add(
+ ClassMembership(
+ user_id=user.id,
+ class_id=cls.id,
+ membership_role="student",
+ committee_role=COMMITTEE_MAP.get(i),
+ )
+ )
print(f"[+] {len(students)} students created (password: demo123)")
- # ── 4. Student roster ────────────────────────────────────────────
- for s in students:
- roster = StudentRoster(
- class_id=cls.id,
- student_id=s.student_id,
- name=s.name,
- status="registered",
- user_id=s.id,
- )
- db.add(roster)
- await db.flush()
- print(f"[+] {len(students)} roster entries created")
-
- # ── 5. Timelines with likes and comments ─────────────────────────
- all_users = [admin] + students
+ # ── 4. Timelines with likes and comments ─────────────────────────
+ all_users = [teacher] + students
for i, post_data in enumerate(TIMELINE_POSTS):
author = random.choice(all_users)
post = Timeline(
diff --git a/frontend/next.config.ts b/frontend/next.config.ts
index bf85763..2012776 100644
--- a/frontend/next.config.ts
+++ b/frontend/next.config.ts
@@ -3,6 +3,18 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
allowedDevOrigins: ["192.168.31.172"],
+ images: {
+ remotePatterns: [
+ {
+ protocol: "http",
+ hostname: "**",
+ },
+ {
+ protocol: "https",
+ hostname: "**",
+ },
+ ],
+ },
};
export default nextConfig;
diff --git a/frontend/src/app/(app)/admin/classes/page.tsx b/frontend/src/app/(app)/admin/classes/page.tsx
index df728cc..df70224 100644
--- a/frontend/src/app/(app)/admin/classes/page.tsx
+++ b/frontend/src/app/(app)/admin/classes/page.tsx
@@ -1,8 +1,7 @@
"use client";
import { useEffect, useState } from "react";
-import { useAuth } from "@/hooks/use-auth";
-import { fetchAPI, postAPI, putAPI, deleteAPI } from "@/lib/api";
+import { fetchAPI, postAPI, putAPI, deleteAPI, getErrorMessage } from "@/lib/api";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -21,7 +20,6 @@ import {
} from "@/components/ui/dialog";
export default function ClassesPage() {
- const { user } = useAuth();
const [classes, setClasses] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
@@ -35,10 +33,10 @@ export default function ClassesPage() {
const loadClasses = async () => {
setError(null);
try {
- const res = await fetchAPI("/api/classes/");
+ const res = await fetchAPI<{ items: ClassInfo[] }>("/api/classes/");
setClasses(res.items || []);
- } catch (err: any) {
- setError(err.message || "加载失败");
+ } catch (err: unknown) {
+ setError(getErrorMessage(err, "加载失败"));
} finally {
setLoading(false);
}
@@ -90,8 +88,8 @@ export default function ClassesPage() {
setDialogOpen(false);
resetForm();
loadClasses();
- } catch (err: any) {
- toast.error(err.message || "操作失败");
+ } catch (err: unknown) {
+ toast.error(getErrorMessage(err, "操作失败"));
} finally {
setSubmitting(false);
}
@@ -105,8 +103,8 @@ export default function ClassesPage() {
toast.success("已删除");
setDeleteTarget(null);
loadClasses();
- } catch (err: any) {
- toast.error(err.message || "删除失败");
+ } catch (err: unknown) {
+ toast.error(getErrorMessage(err, "删除失败"));
}
};
@@ -114,8 +112,9 @@ export default function ClassesPage() {
-
班级管理
-
创建和管理班级
+
Classes
+
班级管理
+
创建、调整与维护研究生班级信息