import csv import io from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from sqlalchemy.ext.asyncio import AsyncSession 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, 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, update_class, delete_class, get_class_by_id, list_classes, get_member_count, get_class_members, ) from app.services.member_activation_service import ( ensure_invite_code, regenerate_invite_code, import_members, get_inactive_members, delete_inactive_member, clear_inactive_members, ) router = APIRouter(prefix="/api/classes", tags=["classes"]) @router.get("/", response_model=PageResponse[ClassOut]) 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 = [] for c in classes: count = await get_member_count(db, c.id) out = ClassOut.model_validate(c) out.member_count = count result.append(out) return PageResponse( items=result, total=total, page=page, page_size=page_size, total_pages=total_pages ) @router.post("/", response_model=ClassOut) async def create_new_class( data: ClassCreate, admin: User = Depends(require_role("super_admin")), db: AsyncSession = Depends(get_db), ): class_ = await create_class(db, data) out = ClassOut.model_validate(class_) out.member_count = 0 return out @router.put("/{class_id}", response_model=ClassOut) async def update_existing_class( class_id: int, data: ClassUpdate, admin: User = Depends(require_role("super_admin")), db: AsyncSession = Depends(get_db), ): class_ = await get_class_by_id(db, class_id) if class_ is None: raise HTTPException(status_code=404, detail="Class not found") updated = await update_class(db, class_, data) out = ClassOut.model_validate(updated) out.member_count = await get_member_count(db, class_id) return out @router.delete("/{class_id}") async def delete_existing_class( class_id: int, admin: User = Depends(require_role("super_admin")), db: AsyncSession = Depends(get_db), ): class_ = await get_class_by_id(db, class_id) if class_ is None: raise HTTPException(status_code=404, detail="Class not found") await delete_class(db, class_) return {"message": "Class deleted"} @router.get("/{class_id}/members", response_model=PageResponse[UserListItem]) async def get_members( class_id: int, status: str | None = None, page: int = 1, page_size: int = 50, admin: User = Depends(require_role("super_admin", "teacher")), db: AsyncSession = Depends(get_db), ): 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=[build_user_list_item(m, class_id) for m in members], total=total, page=page, page_size=page_size, total_pages=total_pages, ) # --- Inactive member management --- @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", "teacher")), db: AsyncSession = Depends(get_db), ): 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=[build_inactive_member_out(entry) for entry in entries], total=total, page=page, page_size=page_size, total_pages=total_pages, ) @router.post("/{class_id}/inactive-members/import") async def import_class_members( class_id: int, data: MemberImportRequest, admin: User = Depends(require_role("super_admin", "teacher")), db: AsyncSession = Depends(get_db), ): 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}/inactive-members/upload") async def upload_member_file( class_id: int, file: UploadFile = File(...), admin: User = Depends(require_role("super_admin", "teacher")), db: AsyncSession = Depends(get_db), ): ensure_class_permission(admin, "member_manage", class_id) contents = await file.read() filename = file.filename or "" entries: list[dict] = [] if filename.endswith(".csv"): text = contents.decode("utf-8-sig") reader = csv.DictReader(io.StringIO(text)) for row in reader: sid = row.get("student_id") or row.get("学号") or "" name = row.get("name") or row.get("姓名") or "" if sid and name: entries.append({"student_id": sid.strip(), "name": name.strip()}) elif filename.endswith((".xlsx", ".xls")): try: import openpyxl wb = openpyxl.load_workbook(io.BytesIO(contents), read_only=True) ws = wb.active rows = list(ws.iter_rows(values_only=True)) if len(rows) < 2: raise HTTPException(status_code=400, detail="Excel 文件为空") header = [str(h).strip() if h else "" for h in rows[0]] # Find student_id and name columns sid_col = None name_col = None for i, h in enumerate(header): if h in ("student_id", "学号"): sid_col = i elif h in ("name", "姓名"): name_col = i if sid_col is None or name_col is None: raise HTTPException( status_code=400, detail="Excel 需包含 '学号'(student_id) 和 '姓名'(name) 列", ) for row in rows[1:]: sid = str(row[sid_col]).strip() if row[sid_col] else "" name = str(row[name_col]).strip() if row[name_col] else "" if sid and name and sid != "None": entries.append({"student_id": sid, "name": name}) wb.close() except ImportError: raise HTTPException( status_code=400, detail="服务器未安装 openpyxl,请使用 CSV 格式" ) else: raise HTTPException(status_code=400, detail="仅支持 CSV 或 Excel (.xlsx) 文件") if not entries: raise HTTPException(status_code=400, detail="未找到有效数据") count = await import_members(db, class_id, entries) return {"message": f"成功导入 {count} 位成员"} @router.delete("/{class_id}/inactive-members/{user_id}") async def delete_inactive_member_item( class_id: int, user_id: int, admin: User = Depends(require_role("super_admin", "teacher")), db: AsyncSession = Depends(get_db), ): 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="无法删除(已激活、已加入其他班级或不存在)") return {"message": "已删除"} @router.post("/{class_id}/inactive-members/clear") async def clear_class_inactive_members( class_id: int, admin: User = Depends(require_role("super_admin", "teacher")), db: AsyncSession = Depends(get_db), ): ensure_class_permission(admin, "member_manage", class_id) count = await clear_inactive_members(db, class_id) return {"message": f"已清除 {count} 位未激活成员"} # --- Invite code management --- @router.get("/{class_id}/invite-code") async def get_invite_code( class_id: int, admin: User = Depends(require_role("super_admin", "teacher")), 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") return {"invite_code": code} @router.post("/{class_id}/invite-code/regenerate") async def regenerate_invite( class_id: int, admin: User = Depends(require_role("super_admin", "teacher")), 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") return {"invite_code": code} # --- Module management --- @router.get("/{class_id}/modules") async def get_class_modules( class_id: int, admin: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): ensure_class_permission(admin, "module_manage", class_id) class_ = await get_class_by_id(db, class_id) if class_ is None: raise HTTPException(status_code=404, detail="Class not found") return { "class_id": class_id, "enabled_modules": class_.get_enabled_modules(), "available_modules": class_.ALL_MODULES, } @router.put("/{class_id}/modules") async def update_class_modules( class_id: int, data: ModuleUpdate, admin: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): ensure_class_permission(admin, "module_manage", class_id) class_ = await get_class_by_id(db, class_id) if class_ is None: raise HTTPException(status_code=404, detail="Class not found") # Validate module keys valid_keys = set(class_.ALL_MODULES) for m in data.enabled_modules: if m not in valid_keys: raise HTTPException(status_code=400, detail=f"Invalid module: {m}") class_.set_enabled_modules(data.enabled_modules) await db.commit() return { "class_id": class_id, "enabled_modules": class_.get_enabled_modules(), }