This commit is contained in:
aaron 2026-04-27 23:09:51 +08:00
parent 105bde0d3c
commit cf695efb38
18 changed files with 120 additions and 101 deletions

View File

@ -1,7 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import ensure_class_permission, require_role, resolve_class_id_for_user from app.core.deps import ensure_class_access, ensure_class_permission, require_role, resolve_class_id_for_user
from app.db.database import get_db from app.db.database import get_db
from app.db.models import User from app.db.models import User
from app.schemas.announcement import AnnouncementCreate, AnnouncementUpdate, AnnouncementOut from app.schemas.announcement import AnnouncementCreate, AnnouncementUpdate, AnnouncementOut
@ -28,7 +28,7 @@ async def get_announcements(
effective_class_id = resolve_class_id_for_user(user, class_id) effective_class_id = resolve_class_id_for_user(user, class_id)
if effective_class_id is None: if effective_class_id is None:
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
ensure_class_permission(user, "class_view", effective_class_id) ensure_class_access(user, effective_class_id)
announcements, total = await list_announcements(db, effective_class_id, page, page_size) announcements, total = await list_announcements(db, effective_class_id, page, page_size)
total_pages = (total + page_size - 1) // page_size total_pages = (total + page_size - 1) // page_size

View File

@ -4,6 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.core.deps import ( from app.core.deps import (
ensure_class_access,
ensure_class_permission, ensure_class_permission,
get_effective_class_permissions, get_effective_class_permissions,
require_role, require_role,
@ -93,7 +94,7 @@ async def get_assignments(
effective_class_id = resolve_class_id_for_user(user, class_id) effective_class_id = resolve_class_id_for_user(user, class_id)
if effective_class_id is None: if effective_class_id is None:
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
ensure_class_permission(user, "class_view", effective_class_id) ensure_class_access(user, effective_class_id)
assignments, total = await list_assignments(db, effective_class_id, page, page_size) assignments, total = await list_assignments(db, effective_class_id, page, page_size)
total_pages = (total + page_size - 1) // page_size total_pages = (total + page_size - 1) // page_size
@ -155,7 +156,7 @@ async def get_assignment_detail(
assignment = await get_assignment_by_id(db, assignment_id) assignment = await get_assignment_by_id(db, assignment_id)
if assignment is None: if assignment is None:
raise HTTPException(status_code=404, detail="Assignment not found") raise HTTPException(status_code=404, detail="Assignment not found")
ensure_class_permission(user, "class_view", assignment.class_id) ensure_class_access(user, assignment.class_id)
base = _build_assignment_out(assignment, user.id, await _get_member_count(db, assignment.class_id)) base = _build_assignment_out(assignment, user.id, await _get_member_count(db, assignment.class_id))
@ -215,7 +216,7 @@ async def submit_assignment(
assignment = await get_assignment_by_id(db, assignment_id) assignment = await get_assignment_by_id(db, assignment_id)
if assignment is None: if assignment is None:
raise HTTPException(status_code=404, detail="Assignment not found") raise HTTPException(status_code=404, detail="Assignment not found")
ensure_class_permission(user, "class_view", assignment.class_id) ensure_class_access(user, assignment.class_id)
# Upload file # Upload file
file_url = None file_url = None

View File

@ -119,7 +119,7 @@ async def get_members(
status: str | None = None, status: str | None = None,
page: int = 1, page: int = 1,
page_size: int = 50, page_size: int = 50,
admin: User = Depends(require_role("super_admin", "teacher", "student")), admin: User = Depends(require_role("super_admin", "teacher")),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
ensure_class_permission(admin, "member_view", class_id) ensure_class_permission(admin, "member_view", class_id)
@ -143,7 +143,7 @@ async def get_class_inactive_members(
class_id: int, class_id: int,
page: int = 1, page: int = 1,
page_size: int = 50, page_size: int = 50,
admin: User = Depends(require_role("super_admin", "teacher", "student")), admin: User = Depends(require_role("super_admin", "teacher")),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
ensure_class_permission(admin, "member_manage", class_id) ensure_class_permission(admin, "member_manage", class_id)
@ -162,7 +162,7 @@ async def get_class_inactive_members(
async def import_class_members( async def import_class_members(
class_id: int, class_id: int,
data: MemberImportRequest, data: MemberImportRequest,
admin: User = Depends(require_role("super_admin", "teacher", "student")), admin: User = Depends(require_role("super_admin", "teacher")),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
ensure_class_permission(admin, "member_manage", class_id) ensure_class_permission(admin, "member_manage", class_id)
@ -174,7 +174,7 @@ async def import_class_members(
async def upload_member_file( async def upload_member_file(
class_id: int, class_id: int,
file: UploadFile = File(...), file: UploadFile = File(...),
admin: User = Depends(require_role("super_admin", "teacher", "student")), admin: User = Depends(require_role("super_admin", "teacher")),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
ensure_class_permission(admin, "member_manage", class_id) ensure_class_permission(admin, "member_manage", class_id)
@ -239,7 +239,7 @@ async def upload_member_file(
async def delete_inactive_member_item( async def delete_inactive_member_item(
class_id: int, class_id: int,
user_id: int, user_id: int,
admin: User = Depends(require_role("super_admin", "teacher", "student")), admin: User = Depends(require_role("super_admin", "teacher")),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
ensure_class_permission(admin, "member_manage", class_id) ensure_class_permission(admin, "member_manage", class_id)
@ -252,7 +252,7 @@ async def delete_inactive_member_item(
@router.post("/{class_id}/inactive-members/clear") @router.post("/{class_id}/inactive-members/clear")
async def clear_class_inactive_members( async def clear_class_inactive_members(
class_id: int, class_id: int,
admin: User = Depends(require_role("super_admin", "teacher", "student")), admin: User = Depends(require_role("super_admin", "teacher")),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
ensure_class_permission(admin, "member_manage", class_id) ensure_class_permission(admin, "member_manage", class_id)
@ -266,7 +266,7 @@ async def clear_class_inactive_members(
@router.get("/{class_id}/invite-code") @router.get("/{class_id}/invite-code")
async def get_invite_code( async def get_invite_code(
class_id: int, class_id: int,
admin: User = Depends(require_role("super_admin", "teacher", "student")), admin: User = Depends(require_role("super_admin", "teacher")),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
ensure_class_permission(admin, "member_manage", class_id) ensure_class_permission(admin, "member_manage", class_id)
@ -279,7 +279,7 @@ async def get_invite_code(
@router.post("/{class_id}/invite-code/regenerate") @router.post("/{class_id}/invite-code/regenerate")
async def regenerate_invite( async def regenerate_invite(
class_id: int, class_id: int,
admin: User = Depends(require_role("super_admin", "teacher", "student")), admin: User = Depends(require_role("super_admin", "teacher")),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
ensure_class_permission(admin, "member_manage", class_id) ensure_class_permission(admin, "member_manage", class_id)

View File

@ -1,7 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import ensure_class_permission, get_current_user, resolve_class_id_for_user from app.core.deps import ensure_class_access, get_current_user, resolve_class_id_for_user
from app.db.database import get_db from app.db.database import get_db
from app.db.models import User from app.db.models import User
from app.schemas.user import UserPublic from app.schemas.user import UserPublic
@ -26,7 +26,7 @@ async def search_members(
effective_class_id = resolve_class_id_for_user(user, class_id) effective_class_id = resolve_class_id_for_user(user, class_id)
if effective_class_id is None: if effective_class_id is None:
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
ensure_class_permission(user, "class_view", effective_class_id) ensure_class_access(user, effective_class_id)
users, total = await search_directory( users, total = await search_directory(
db, effective_class_id, search, industry, company, page, page_size db, effective_class_id, search, industry, company, page, page_size

View File

@ -1,7 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import ensure_class_permission, require_role, resolve_class_id_for_user from app.core.deps import ensure_class_access, ensure_class_permission, require_role, resolve_class_id_for_user
from app.db.database import get_db from app.db.database import get_db
from app.db.models import FundRecord, User from app.db.models import FundRecord, User
from app.schemas.fund import FundRecordCreate, FundRecordUpdate, FundRecordOut, FundStatistics from app.schemas.fund import FundRecordCreate, FundRecordUpdate, FundRecordOut, FundStatistics
@ -42,7 +42,7 @@ async def get_statistics(
total_income=0, total_expense=0, balance=0, total_income=0, total_expense=0, balance=0,
income_by_category=[], expense_by_category=[] income_by_category=[], expense_by_category=[]
) )
ensure_class_permission(user, "class_view", effective_class_id) ensure_class_access(user, effective_class_id)
return await get_fund_statistics(db, effective_class_id) return await get_fund_statistics(db, effective_class_id)
@ -59,7 +59,7 @@ async def get_fund_records(
effective_class_id = resolve_class_id_for_user(user, class_id) effective_class_id = resolve_class_id_for_user(user, class_id)
if effective_class_id is None: if effective_class_id is None:
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
ensure_class_permission(user, "class_view", effective_class_id) ensure_class_access(user, effective_class_id)
records, total = await list_fund_records(db, effective_class_id, page, page_size, type, category) records, total = await list_fund_records(db, effective_class_id, page, page_size, type, category)
total_pages = (total + page_size - 1) // page_size total_pages = (total + page_size - 1) // page_size

View File

@ -1,7 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import ensure_class_permission, require_role, resolve_class_id_for_user from app.core.deps import ensure_class_access, ensure_class_permission, require_role, resolve_class_id_for_user
from app.db.database import get_db from app.db.database import get_db
from app.db.models import User from app.db.models import User
from app.schemas.resource import ResourceCreate, ResourceOut from app.schemas.resource import ResourceCreate, ResourceOut
@ -46,7 +46,7 @@ async def get_resources(
effective_class_id = resolve_class_id_for_user(user, class_id) effective_class_id = resolve_class_id_for_user(user, class_id)
if effective_class_id is None: if effective_class_id is None:
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
ensure_class_permission(user, "class_view", effective_class_id) ensure_class_access(user, effective_class_id)
resources, total = await list_resources(db, effective_class_id, category, page, page_size) resources, total = await list_resources(db, effective_class_id, category, page, page_size)
total_pages = (total + page_size - 1) // page_size total_pages = (total + page_size - 1) // page_size
@ -134,7 +134,7 @@ async def download_resource(
resource = await get_resource_by_id(db, resource_id) resource = await get_resource_by_id(db, resource_id)
if resource is None: if resource is None:
raise HTTPException(status_code=404, detail="Resource not found") raise HTTPException(status_code=404, detail="Resource not found")
ensure_class_permission(user, "class_view", resource.class_id) ensure_class_access(user, resource.class_id)
await increment_download_count(db, resource) await increment_download_count(db, resource)
return {"file_url": resource.file_url} return {"file_url": resource.file_url}

View File

@ -1,7 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import ensure_class_permission, require_role, resolve_class_id_for_user from app.core.deps import ensure_class_access, ensure_class_permission, require_role, resolve_class_id_for_user
from app.db.database import get_db from app.db.database import get_db
from app.db.models import User from app.db.models import User
from app.schemas.schedule import ScheduleCreate, ScheduleUpdate, ScheduleOut from app.schemas.schedule import ScheduleCreate, ScheduleUpdate, ScheduleOut
@ -28,7 +28,7 @@ async def get_upcoming(
effective_class_id = resolve_class_id_for_user(user, class_id) effective_class_id = resolve_class_id_for_user(user, class_id)
if effective_class_id is None: if effective_class_id is None:
return [] return []
ensure_class_permission(user, "class_view", effective_class_id) ensure_class_access(user, effective_class_id)
items = await get_upcoming_schedules(db, effective_class_id, limit) items = await get_upcoming_schedules(db, effective_class_id, limit)
return [ScheduleOut.model_validate(i) for i in items] return [ScheduleOut.model_validate(i) for i in items]
@ -45,7 +45,7 @@ async def get_schedules(
effective_class_id = resolve_class_id_for_user(user, class_id) effective_class_id = resolve_class_id_for_user(user, class_id)
if effective_class_id is None: if effective_class_id is None:
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
ensure_class_permission(user, "class_view", effective_class_id) ensure_class_access(user, effective_class_id)
items, total = await list_schedules(db, effective_class_id, type, page, page_size) items, total = await list_schedules(db, effective_class_id, type, page, page_size)
total_pages = (total + page_size - 1) // page_size total_pages = (total + page_size - 1) // page_size

View File

@ -3,6 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
import asyncio import asyncio
from app.core.deps import ( from app.core.deps import (
ensure_class_access,
ensure_class_permission, ensure_class_permission,
get_effective_class_permissions, get_effective_class_permissions,
require_role, require_role,
@ -79,7 +80,7 @@ async def get_timelines(
effective_class_id = resolve_class_id_for_user(user, class_id) effective_class_id = resolve_class_id_for_user(user, class_id)
if effective_class_id is None: if effective_class_id is None:
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
ensure_class_permission(user, "class_view", effective_class_id) ensure_class_access(user, effective_class_id)
posts, total = await list_timelines(db, effective_class_id, page, page_size) posts, total = await list_timelines(db, effective_class_id, page, page_size)
total_pages = (total + page_size - 1) // page_size total_pages = (total + page_size - 1) // page_size
@ -103,7 +104,7 @@ async def create_new_timeline(
effective_class_id = resolve_class_id_for_user(user, class_id) effective_class_id = resolve_class_id_for_user(user, class_id)
if effective_class_id is None: if effective_class_id is None:
raise HTTPException(status_code=400, detail="You are not assigned to a class") raise HTTPException(status_code=400, detail="You are not assigned to a class")
ensure_class_permission(user, "class_view", effective_class_id) ensure_class_access(user, effective_class_id)
data = TimelineCreate(title=title, content=content) data = TimelineCreate(title=title, content=content)
post = await create_timeline(db, effective_class_id, user.id, data) post = await create_timeline(db, effective_class_id, user.id, data)
@ -153,7 +154,7 @@ async def upload_timeline_images(
can_manage = "timeline_manage" in get_effective_class_permissions(user, post.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: if not can_manage and post.author_id != user.id:
raise HTTPException(status_code=403, detail="Access denied") raise HTTPException(status_code=403, detail="Access denied")
ensure_class_permission(user, "class_view", post.class_id) ensure_class_access(user, post.class_id)
urls = [] urls = []
for f in files: for f in files:
@ -182,7 +183,7 @@ async def update_existing_timeline(
can_manage = "timeline_manage" in get_effective_class_permissions(user, post.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: if not can_manage and post.author_id != user.id:
raise HTTPException(status_code=403, detail="Access denied") raise HTTPException(status_code=403, detail="Access denied")
ensure_class_permission(user, "class_view", post.class_id) ensure_class_access(user, post.class_id)
updated = await update_timeline(db, post, data) updated = await update_timeline(db, post, data)
return _build_timeline_out(updated, user.id) return _build_timeline_out(updated, user.id)
@ -200,7 +201,7 @@ async def delete_existing_timeline(
can_manage = "timeline_manage" in get_effective_class_permissions(user, post.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: if not can_manage and post.author_id != user.id:
raise HTTPException(status_code=403, detail="Access denied") raise HTTPException(status_code=403, detail="Access denied")
ensure_class_permission(user, "class_view", post.class_id) ensure_class_access(user, post.class_id)
await delete_timeline(db, post) await delete_timeline(db, post)
return {"message": "Timeline post deleted"} return {"message": "Timeline post deleted"}
@ -217,7 +218,7 @@ async def like_timeline_post(
post = await get_timeline_by_id(db, post_id) post = await get_timeline_by_id(db, post_id)
if post is None: if post is None:
raise HTTPException(status_code=404, detail="Timeline post not found") raise HTTPException(status_code=404, detail="Timeline post not found")
ensure_class_permission(user, "class_view", post.class_id) ensure_class_access(user, post.class_id)
return await toggle_like(db, post_id, user.id) return await toggle_like(db, post_id, user.id)
@ -232,7 +233,7 @@ async def get_post_comments(
post = await get_timeline_by_id(db, post_id) post = await get_timeline_by_id(db, post_id)
if post is None: if post is None:
raise HTTPException(status_code=404, detail="Timeline post not found") raise HTTPException(status_code=404, detail="Timeline post not found")
ensure_class_permission(user, "class_view", post.class_id) ensure_class_access(user, post.class_id)
comments, total = await list_comments(db, post_id, page, page_size) comments, total = await list_comments(db, post_id, page, page_size)
total_pages = (total + page_size - 1) // page_size total_pages = (total + page_size - 1) // page_size
items = [ items = [
@ -260,7 +261,7 @@ async def add_post_comment(
post = await get_timeline_by_id(db, post_id) post = await get_timeline_by_id(db, post_id)
if post is None: if post is None:
raise HTTPException(status_code=404, detail="Timeline post not found") raise HTTPException(status_code=404, detail="Timeline post not found")
ensure_class_permission(user, "class_view", post.class_id) ensure_class_access(user, post.class_id)
comment = await create_comment(db, post_id, user.id, data) comment = await create_comment(db, post_id, user.id, data)
return TimelineCommentOut( return TimelineCommentOut(
@ -283,13 +284,13 @@ async def delete_timeline_comment(
comment = await get_comment_by_id(db, comment_id) comment = await get_comment_by_id(db, comment_id)
if comment is None: if comment is None:
raise HTTPException(status_code=404, detail="Comment not found") raise HTTPException(status_code=404, detail="Comment not found")
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) post = await get_timeline_by_id(db, comment.post_id)
if post is None: if post is None:
raise HTTPException(status_code=404, detail="Timeline post not found") raise HTTPException(status_code=404, detail="Timeline post not found")
ensure_class_permission(user, "class_view", post.class_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")
ensure_class_access(user, post.class_id)
await delete_comment(db, comment) await delete_comment(db, comment)
return {"message": "Comment deleted"} return {"message": "Comment deleted"}

View File

@ -86,7 +86,7 @@ async def list_all_users(
class_id: int | None = None, class_id: int | None = None,
status: str | None = None, status: str | None = None,
role: str | None = None, role: str | None = None,
admin: User = Depends(require_role("super_admin", "teacher", "student")), admin: User = Depends(require_role("super_admin", "teacher")),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
if class_id is not None: if class_id is not None:
@ -109,7 +109,7 @@ async def list_all_users(
async def change_user_status( async def change_user_status(
user_id: int, user_id: int,
data: UserStatusUpdate, data: UserStatusUpdate,
admin: User = Depends(require_role("super_admin", "teacher", "student")), admin: User = Depends(require_role("super_admin", "teacher")),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
target = await get_user_by_id(db, user_id) target = await get_user_by_id(db, user_id)
@ -151,7 +151,7 @@ async def change_user_role(
async def change_committee_role( async def change_committee_role(
user_id: int, user_id: int,
data: CommitteeRoleUpdate, data: CommitteeRoleUpdate,
admin: User = Depends(require_role("super_admin", "teacher", "student")), admin: User = Depends(require_role("super_admin", "teacher")),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
target = await get_user_by_id(db, user_id) target = await get_user_by_id(db, user_id)
@ -171,7 +171,7 @@ async def change_committee_role(
async def change_class_permissions( async def change_class_permissions(
user_id: int, user_id: int,
data: ClassPermissionsUpdate, data: ClassPermissionsUpdate,
admin: User = Depends(require_role("super_admin", "teacher", "student")), admin: User = Depends(require_role("super_admin", "teacher")),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
target = await get_user_by_id(db, user_id) target = await get_user_by_id(db, user_id)

View File

@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import ( from app.core.deps import (
ensure_class_access,
ensure_class_permission, ensure_class_permission,
get_effective_class_permissions, get_effective_class_permissions,
require_role, require_role,
@ -81,7 +82,7 @@ async def get_votes(
effective_class_id = resolve_class_id_for_user(user, class_id) effective_class_id = resolve_class_id_for_user(user, class_id)
if effective_class_id is None: if effective_class_id is None:
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
ensure_class_permission(user, "class_view", effective_class_id) ensure_class_access(user, effective_class_id)
votes, total = await list_votes(db, effective_class_id, page, page_size) votes, total = await list_votes(db, effective_class_id, page, page_size)
total_pages = (total + page_size - 1) // page_size total_pages = (total + page_size - 1) // page_size
@ -104,7 +105,7 @@ async def create_new_vote(
effective_class_id = resolve_class_id_for_user(user, class_id) effective_class_id = resolve_class_id_for_user(user, class_id)
if effective_class_id is None: if effective_class_id is None:
raise HTTPException(status_code=400, detail="You are not assigned to a class") raise HTTPException(status_code=400, detail="You are not assigned to a class")
ensure_class_permission(user, "class_view", effective_class_id) ensure_class_access(user, effective_class_id)
vote = await create_vote(db, effective_class_id, user.id, data) vote = await create_vote(db, effective_class_id, user.id, data)
# Reload with relationships # Reload with relationships
@ -121,7 +122,7 @@ async def get_vote_detail(
vote = await get_vote_by_id(db, vote_id) vote = await get_vote_by_id(db, vote_id)
if vote is None: if vote is None:
raise HTTPException(status_code=404, detail="Vote not found") raise HTTPException(status_code=404, detail="Vote not found")
ensure_class_permission(user, "class_view", vote.class_id) ensure_class_access(user, vote.class_id)
return _build_vote_out(vote, user.id) return _build_vote_out(vote, user.id)
@ -135,7 +136,7 @@ async def submit_vote_response(
vote = await get_vote_by_id(db, vote_id) vote = await get_vote_by_id(db, vote_id)
if vote is None: if vote is None:
raise HTTPException(status_code=404, detail="Vote not found") raise HTTPException(status_code=404, detail="Vote not found")
ensure_class_permission(user, "class_view", vote.class_id) ensure_class_access(user, vote.class_id)
try: try:
await submit_vote(db, vote_id, user.id, data.option_ids) await submit_vote(db, vote_id, user.id, data.option_ids)
@ -158,7 +159,7 @@ async def close_vote_endpoint(
can_manage = "vote_manage" in get_effective_class_permissions(user, vote.class_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: if not can_manage and user.role == "student" and vote.creator_id != user.id:
raise HTTPException(status_code=403, detail="只有创建者或管理员可以关闭投票") raise HTTPException(status_code=403, detail="只有创建者或管理员可以关闭投票")
ensure_class_permission(user, "class_view", vote.class_id) ensure_class_access(user, vote.class_id)
await close_vote(db, vote) await close_vote(db, vote)
return {"message": "投票已关闭"} return {"message": "投票已关闭"}
@ -176,7 +177,7 @@ async def delete_vote_endpoint(
can_manage = "vote_manage" in get_effective_class_permissions(user, vote.class_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: if not can_manage and user.role == "student" and vote.creator_id != user.id:
raise HTTPException(status_code=403, detail="只有创建者或管理员可以删除投票") raise HTTPException(status_code=403, detail="只有创建者或管理员可以删除投票")
ensure_class_permission(user, "class_view", vote.class_id) ensure_class_access(user, vote.class_id)
await delete_vote(db, vote) await delete_vote(db, vote)
return {"message": "投票已删除"} return {"message": "投票已删除"}

View File

@ -11,7 +11,6 @@ from app.db.models import ClassMembership, User
security = HTTPBearer() security = HTTPBearer()
CLASS_PERMISSIONS = { CLASS_PERMISSIONS = {
"class_view",
"member_view", "member_view",
"member_manage", "member_manage",
"committee_manage", "committee_manage",
@ -26,7 +25,6 @@ CLASS_PERMISSIONS = {
} }
TEACHER_DEFAULT_PERMISSIONS = { TEACHER_DEFAULT_PERMISSIONS = {
"class_view",
"member_view", "member_view",
"member_manage", "member_manage",
"committee_manage", "committee_manage",
@ -118,6 +116,11 @@ def get_effective_class_permissions(user: User, class_id: int | None = None) ->
scoped_permissions = membership.get_class_permissions() if membership else [] scoped_permissions = membership.get_class_permissions() if membership else []
if user.role == "teacher": if user.role == "teacher":
return set(TEACHER_DEFAULT_PERMISSIONS) | set(scoped_permissions) return set(TEACHER_DEFAULT_PERMISSIONS) | set(scoped_permissions)
scoped_permissions = [
permission
for permission in scoped_permissions
if permission not in {"member_view", "member_manage", "committee_manage"}
]
return set(scoped_permissions) return set(scoped_permissions)

View File

@ -5,6 +5,14 @@ from pydantic import BaseModel, EmailStr, Field
from app.db.models import ClassMembership, User from app.db.models import ClassMembership, User
def normalize_class_permissions(permissions: list[str]) -> list[str]:
return [
permission
for permission in permissions
if permission not in {"class_view", "member_view", "member_manage", "committee_manage"}
]
class MembershipOut(BaseModel): class MembershipOut(BaseModel):
id: int id: int
class_id: int class_id: int
@ -129,7 +137,7 @@ def build_membership_out(membership: ClassMembership) -> MembershipOut:
class_name=membership.class_.name if membership.class_ else None, class_name=membership.class_.name if membership.class_ else None,
membership_role=membership.membership_role, membership_role=membership.membership_role,
committee_role=membership.committee_role, committee_role=membership.committee_role,
class_permissions=membership.get_class_permissions(), class_permissions=normalize_class_permissions(membership.get_class_permissions()),
) )
@ -170,7 +178,7 @@ def build_user_list_item(user: User, class_id: int | None = None) -> UserListIte
industry=user.industry, industry=user.industry,
company=user.company, company=user.company,
committee_role=active_membership.committee_role if active_membership else None, committee_role=active_membership.committee_role if active_membership else None,
class_permissions=active_membership.get_class_permissions() if active_membership else [], class_permissions=normalize_class_permissions(active_membership.get_class_permissions()) if active_membership else [],
created_at=user.created_at, created_at=user.created_at,
memberships=memberships, memberships=memberships,
active_membership=build_membership_out(active_membership) if active_membership else None, active_membership=build_membership_out(active_membership) if active_membership else None,

View File

@ -43,7 +43,6 @@ import type {
UserListItem, UserListItem,
} from "@/lib/types"; } from "@/lib/types";
import { CLASS_PERMISSIONS, ROLES } from "@/lib/constants"; import { CLASS_PERMISSIONS, ROLES } from "@/lib/constants";
import { hasClassPermission } from "@/lib/permissions";
type InviteCodeResponse = { type InviteCodeResponse = {
invite_code: string; invite_code: string;
@ -82,8 +81,8 @@ const PERMISSION_GROUPS: Array<{
}> = [ }> = [
{ {
title: "成员与组织", title: "成员与组织",
description: "管理班级成员、班委和基础可见范围", description: "老师与超级管理员固定拥有成员管理与班委设置能力",
permissions: ["class_view", "member_view", "member_manage", "committee_manage"], permissions: [],
}, },
{ {
title: "内容与活动", title: "内容与活动",
@ -112,10 +111,6 @@ const COMMITTEE_PRESETS: Array<{
role: "班长", role: "班长",
description: "统筹班级日常管理与主要内容发布", description: "统筹班级日常管理与主要内容发布",
permissions: [ permissions: [
"class_view",
"member_view",
"member_manage",
"committee_manage",
"announcement_manage", "announcement_manage",
"timeline_manage", "timeline_manage",
"vote_manage", "vote_manage",
@ -127,9 +122,6 @@ const COMMITTEE_PRESETS: Array<{
role: "副班长", role: "副班长",
description: "协助班长处理班级组织和内容管理", description: "协助班长处理班级组织和内容管理",
permissions: [ permissions: [
"class_view",
"member_view",
"member_manage",
"announcement_manage", "announcement_manage",
"timeline_manage", "timeline_manage",
"vote_manage", "vote_manage",
@ -141,8 +133,6 @@ const COMMITTEE_PRESETS: Array<{
role: "学习委员", role: "学习委员",
description: "负责学习通知、作业协同与课程安排", description: "负责学习通知、作业协同与课程安排",
permissions: [ permissions: [
"class_view",
"member_view",
"announcement_manage", "announcement_manage",
"assignment_manage", "assignment_manage",
"schedule_manage", "schedule_manage",
@ -153,8 +143,6 @@ const COMMITTEE_PRESETS: Array<{
role: "组织委员", role: "组织委员",
description: "负责活动组织、投票协同和排期推进", description: "负责活动组织、投票协同和排期推进",
permissions: [ permissions: [
"class_view",
"member_view",
"timeline_manage", "timeline_manage",
"vote_manage", "vote_manage",
"schedule_manage", "schedule_manage",
@ -165,8 +153,6 @@ const COMMITTEE_PRESETS: Array<{
role: "宣传委员", role: "宣传委员",
description: "负责公告发布、班级宣传与内容协同传播", description: "负责公告发布、班级宣传与内容协同传播",
permissions: [ permissions: [
"class_view",
"member_view",
"announcement_manage", "announcement_manage",
"timeline_manage", "timeline_manage",
"vote_manage", "vote_manage",
@ -177,8 +163,6 @@ const COMMITTEE_PRESETS: Array<{
role: "生活委员", role: "生活委员",
description: "负责生活事务通知与班费相关管理", description: "负责生活事务通知与班费相关管理",
permissions: [ permissions: [
"class_view",
"member_view",
"announcement_manage", "announcement_manage",
"fund_manage", "fund_manage",
"resource_manage", "resource_manage",
@ -188,8 +172,6 @@ const COMMITTEE_PRESETS: Array<{
role: "文体委员", role: "文体委员",
description: "负责文体活动、动态发布与投票互动", description: "负责文体活动、动态发布与投票互动",
permissions: [ permissions: [
"class_view",
"member_view",
"timeline_manage", "timeline_manage",
"vote_manage", "vote_manage",
"schedule_manage", "schedule_manage",
@ -238,12 +220,13 @@ export default function MembersPage() {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const isSuperAdmin = user?.role === "super_admin"; const isSuperAdmin = user?.role === "super_admin";
const canManageCommittee = hasClassPermission(user, "committee_manage", activeClassId); const canManageMembers = user?.role === "super_admin" || user?.role === "teacher";
const canOpenMemberManager = isSuperAdmin || canManageCommittee; const canManageCommittee = canManageMembers;
const canOpenMemberManager = canManageMembers;
// Load members // Load members
const loadMembers = useCallback(async () => { const loadMembers = useCallback(async () => {
if (!activeClassId) { if (!activeClassId || !canManageMembers) {
setMembers([]); setMembers([]);
setMembersLoading(false); setMembersLoading(false);
return; return;
@ -266,11 +249,11 @@ export default function MembersPage() {
} finally { } finally {
setMembersLoading(false); setMembersLoading(false);
} }
}, [activeClassId, membersPage]); }, [activeClassId, canManageMembers, membersPage]);
// Load inactive members // Load inactive members
const loadInactiveMembers = useCallback(async () => { const loadInactiveMembers = useCallback(async () => {
if (!activeClassId) { if (!activeClassId || !canManageMembers) {
setInactiveMembers([]); setInactiveMembers([]);
setInviteCode(""); setInviteCode("");
setInactiveLoading(false); setInactiveLoading(false);
@ -294,16 +277,16 @@ export default function MembersPage() {
} finally { } finally {
setInactiveLoading(false); setInactiveLoading(false);
} }
}, [activeClassId, inactivePage]); }, [activeClassId, canManageMembers, inactivePage]);
useEffect(() => { useEffect(() => {
if (!activeClassId) return; if (!activeClassId || !canManageMembers) return;
if (activeTab === "members") { if (activeTab === "members") {
void loadMembers(); void loadMembers();
return; return;
} }
void loadInactiveMembers(); void loadInactiveMembers();
}, [activeClassId, activeTab, loadInactiveMembers, loadMembers]); }, [activeClassId, activeTab, canManageMembers, loadInactiveMembers, loadMembers]);
// Member actions // Member actions
const handleRoleChange = async (userId: number, newRole: string) => { const handleRoleChange = async (userId: number, newRole: string) => {
@ -448,7 +431,15 @@ export default function MembersPage() {
}; };
const applyPermissionPreset = (permissions: ClassPermission[]) => { const applyPermissionPreset = (permissions: ClassPermission[]) => {
setMemberPermissionsValue(Array.from(new Set(permissions))); setMemberPermissionsValue(
Array.from(
new Set(
permissions.filter(
(permission) => !["member_view", "member_manage", "committee_manage"].includes(permission)
)
)
)
);
}; };
const applyCommitteePreset = (presetRole: string) => { const applyCommitteePreset = (presetRole: string) => {
@ -464,7 +455,9 @@ export default function MembersPage() {
try { try {
await putAPI<UserListItem>(`/api/users/${userId}/class-permissions`, { await putAPI<UserListItem>(`/api/users/${userId}/class-permissions`, {
class_id: activeClassId, class_id: activeClassId,
class_permissions: permissions, class_permissions: permissions.filter(
(permission) => !["member_view", "member_manage", "committee_manage"].includes(permission)
),
}); });
} catch (err: unknown) { } catch (err: unknown) {
toast.error(getErrorMessage(err, "操作失败")); toast.error(getErrorMessage(err, "操作失败"));
@ -550,7 +543,11 @@ export default function MembersPage() {
setManagingMember(member); setManagingMember(member);
setMemberRoleValue(member.role); setMemberRoleValue(member.role);
setMemberCommitteeValue(member.committee_role ?? ""); setMemberCommitteeValue(member.committee_role ?? "");
setMemberPermissionsValue(member.class_permissions ?? []); setMemberPermissionsValue(
(member.class_permissions ?? []).filter(
(permission) => !["member_view", "member_manage", "committee_manage"].includes(permission)
)
);
setMemberManagerOpen(true); setMemberManagerOpen(true);
}; };
@ -567,7 +564,9 @@ export default function MembersPage() {
const originalRole = managingMember.role; const originalRole = managingMember.role;
const originalCommittee = managingMember.committee_role ?? ""; const originalCommittee = managingMember.committee_role ?? "";
const originalPermissions = managingMember.class_permissions ?? []; const originalPermissions = (managingMember.class_permissions ?? []).filter(
(permission) => !["member_view", "member_manage", "committee_manage"].includes(permission)
);
const normalizedCommittee = memberCommitteeValue.trim(); const normalizedCommittee = memberCommitteeValue.trim();
const permissionsChanged = const permissionsChanged =
originalPermissions.length !== memberPermissionsValue.length || originalPermissions.length !== memberPermissionsValue.length ||
@ -619,6 +618,14 @@ export default function MembersPage() {
<div className="py-12 text-center text-[#9d806f]"> <div className="py-12 text-center text-[#9d806f]">
{isSuperAdmin ? "请在顶部选择一个班级" : "您尚未分配班级"} {isSuperAdmin ? "请在顶部选择一个班级" : "您尚未分配班级"}
</div> </div>
) : !canManageMembers ? (
<Card className="bg-[#fffaf2]">
<CardContent className="p-6">
<p className="text-sm text-[#765a4d]">
</p>
</CardContent>
</Card>
) : ( ) : (
<> <>
{/* Tab switcher */} {/* Tab switcher */}
@ -1145,7 +1152,6 @@ export default function MembersPage() {
className="h-7 text-xs" className="h-7 text-xs"
onClick={() => onClick={() =>
applyPermissionPreset([ applyPermissionPreset([
"member_view",
"announcement_manage", "announcement_manage",
"timeline_manage", "timeline_manage",
"vote_manage", "vote_manage",
@ -1187,7 +1193,7 @@ export default function MembersPage() {
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{PERMISSION_GROUPS.map((group) => ( {PERMISSION_GROUPS.filter((group) => group.permissions.length > 0).map((group) => (
<div key={group.title} className="rounded-xl border border-[#eadbc8] bg-[#fffaf2] p-3"> <div key={group.title} className="rounded-xl border border-[#eadbc8] bg-[#fffaf2] p-3">
<div className="mb-2"> <div className="mb-2">
<p className="text-sm font-medium text-[#4e1d1a]">{group.title}</p> <p className="text-sm font-medium text-[#4e1d1a]">{group.title}</p>

View File

@ -7,6 +7,7 @@ import Link from "next/link";
export default function AdminPage() { export default function AdminPage() {
const { user } = useAuth(); const { user } = useAuth();
const canManageMembers = user?.role === "super_admin" || user?.role === "teacher";
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@ -19,18 +20,20 @@ export default function AdminPage() {
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Link href="/admin/members"> {canManageMembers && (
<Card className="cursor-pointer bg-[#fffaf2] transition-all hover:-translate-y-0.5 hover:shadow-[0_24px_45px_-32px_rgba(84,29,23,0.38)]"> <Link href="/admin/members">
<CardHeader> <Card className="cursor-pointer bg-[#fffaf2] transition-all hover:-translate-y-0.5 hover:shadow-[0_24px_45px_-32px_rgba(84,29,23,0.38)]">
<CardTitle className="text-lg text-[#4e1d1a]"></CardTitle> <CardHeader>
</CardHeader> <CardTitle className="text-lg text-[#4e1d1a]"></CardTitle>
<CardContent> </CardHeader>
<p className="text-sm text-[#765a4d]"> <CardContent>
<p className="text-sm text-[#765a4d]">
</p>
</CardContent> </p>
</Card> </CardContent>
</Link> </Card>
</Link>
)}
<Link href="/admin/classes"> <Link href="/admin/classes">
<Card className="cursor-pointer bg-[#fffaf2] transition-all hover:-translate-y-0.5 hover:shadow-[0_24px_45px_-32px_rgba(84,29,23,0.38)]"> <Card className="cursor-pointer bg-[#fffaf2] transition-all hover:-translate-y-0.5 hover:shadow-[0_24px_45px_-32px_rgba(84,29,23,0.38)]">

View File

@ -23,7 +23,7 @@ const navItems = [
]; ];
const adminItems = [ const adminItems = [
{ href: "/admin/members", label: "成员管理", icon: "M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z", roles: ["super_admin", "teacher", "student"] as UserRole[], permissions: ["member_view"] as ClassPermission[] }, { href: "/admin/members", label: "成员管理", icon: "M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z", roles: ["super_admin", "teacher"] as UserRole[], permissions: ["member_view"] as ClassPermission[] },
{ href: "/admin/classes", label: "班级管理", icon: "M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4", roles: ["super_admin"] as UserRole[] }, { href: "/admin/classes", label: "班级管理", icon: "M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4", roles: ["super_admin"] as UserRole[] },
{ href: "/admin/modules", label: "模块管理", icon: "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z", roles: ["super_admin", "teacher", "student"] as UserRole[], permissions: ["module_manage"] as ClassPermission[] }, { href: "/admin/modules", label: "模块管理", icon: "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z", roles: ["super_admin", "teacher", "student"] as UserRole[], permissions: ["module_manage"] as ClassPermission[] },
]; ];

View File

@ -5,7 +5,6 @@ export const ROLES = {
} as const; } as const;
export const CLASS_PERMISSIONS = { export const CLASS_PERMISSIONS = {
class_view: "班级查看",
member_view: "成员查看", member_view: "成员查看",
member_manage: "成员管理", member_manage: "成员管理",
committee_manage: "班委设置", committee_manage: "班委设置",
@ -20,7 +19,6 @@ export const CLASS_PERMISSIONS = {
} as const; } as const;
export const TEACHER_DEFAULT_PERMISSIONS = [ export const TEACHER_DEFAULT_PERMISSIONS = [
"class_view",
"member_view", "member_view",
"member_manage", "member_manage",
"committee_manage", "committee_manage",

View File

@ -20,7 +20,6 @@ export function getEffectiveClassPermissions(
if (!user) return new Set(); if (!user) return new Set();
if (user.role === "super_admin") { if (user.role === "super_admin") {
return new Set([ return new Set([
"class_view",
"member_view", "member_view",
"member_manage", "member_manage",
"committee_manage", "committee_manage",

View File

@ -2,7 +2,6 @@ export type UserRole = "super_admin" | "teacher" | "student";
export type UserStatus = "inactive" | "approved" | "disabled"; export type UserStatus = "inactive" | "approved" | "disabled";
export type ScheduleType = "course" | "deadline" | "activity"; export type ScheduleType = "course" | "deadline" | "activity";
export type ClassPermission = export type ClassPermission =
| "class_view"
| "member_view" | "member_view"
| "member_manage" | "member_manage"
| "committee_manage" | "committee_manage"