mirror of
https://git.victorphan.net/basketballcantho/ten-project.git
synced 2026-08-05 10:33:11 +07:00
thực hiện phân quyền admin giáo viên và học sinh xong
This commit is contained in:
@@ -3,7 +3,7 @@ from jose import JWTError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .database import get_db
|
||||
from .models import User
|
||||
from .models import User, UserRole, UserStatus
|
||||
from .security import decode_access_token
|
||||
|
||||
|
||||
@@ -33,4 +33,35 @@ def get_current_user(
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
|
||||
# Guard: approved users only (pending/rejected cannot use the API)
|
||||
if user.status != UserStatus.approved:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Your account is pending admin approval.",
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
def require_role(*roles: UserRole):
|
||||
"""
|
||||
Returns a FastAPI dependency that asserts the current user has one of the
|
||||
given roles. Usage: Depends(require_role(UserRole.admin, UserRole.teacher))
|
||||
"""
|
||||
def _check(current_user: User = Depends(get_current_user)) -> User:
|
||||
if current_user.role not in roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You do not have permission to perform this action.",
|
||||
)
|
||||
return current_user
|
||||
return _check
|
||||
|
||||
|
||||
def require_admin(current_user: User = Depends(get_current_user)) -> User:
|
||||
if current_user.role != UserRole.admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin access required.",
|
||||
)
|
||||
return current_user
|
||||
|
||||
+2
-1
@@ -3,7 +3,7 @@ from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from .routers import annotations, auth, pdfs, ws
|
||||
from .routers import admin, annotations, auth, pdfs, ws
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -24,6 +24,7 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
app.include_router(auth.router, prefix="/api")
|
||||
app.include_router(admin.router, prefix="/api")
|
||||
app.include_router(pdfs.router, prefix="/api")
|
||||
app.include_router(annotations.router, prefix="/api")
|
||||
app.include_router(ws.router) # WebSocket — no /api prefix (ws:// path)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
from datetime import datetime, timezone
|
||||
import enum
|
||||
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
Enum,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
@@ -13,6 +16,18 @@ from sqlalchemy.orm import relationship
|
||||
from .database import Base
|
||||
|
||||
|
||||
class UserRole(str, enum.Enum):
|
||||
admin = "admin"
|
||||
teacher = "teacher"
|
||||
student = "student"
|
||||
|
||||
|
||||
class UserStatus(str, enum.Enum):
|
||||
pending = "pending"
|
||||
approved = "approved"
|
||||
rejected = "rejected"
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
@@ -20,6 +35,8 @@ class User(Base):
|
||||
username = Column(String(64), unique=True, nullable=False, index=True)
|
||||
email = Column(String(255), unique=True, nullable=False, index=True)
|
||||
password_hash = Column(String(255), nullable=False)
|
||||
role = Column(Enum(UserRole, name="user_role"), nullable=False, default=UserRole.student)
|
||||
status = Column(Enum(UserStatus, name="user_status"), nullable=False, default=UserStatus.pending)
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
|
||||
# Relationships
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..dependencies import require_admin
|
||||
from ..models import User, UserStatus
|
||||
from ..schemas import UserApprove, UserRoleUpdate, UserOut
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
|
||||
# ── GET /api/admin/users ──────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/users", response_model=list[UserOut])
|
||||
def list_users(
|
||||
admin: User = Depends(require_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Return all users (any role / status). Admin only."""
|
||||
return db.query(User).order_by(User.created_at.desc()).all()
|
||||
|
||||
|
||||
# ── PATCH /api/admin/users/{user_id}/status ───────────────────────────────────
|
||||
|
||||
@router.patch("/users/{user_id}/status", response_model=UserOut)
|
||||
def update_user_status(
|
||||
user_id: int,
|
||||
body: UserApprove,
|
||||
admin: User = Depends(require_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Approve or reject a user account. Admin only."""
|
||||
user = db.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found.")
|
||||
if user.id == admin.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot change your own status.")
|
||||
|
||||
user.status = body.status
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
# ── PATCH /api/admin/users/{user_id}/role ─────────────────────────────────────
|
||||
|
||||
@router.patch("/users/{user_id}/role", response_model=UserOut)
|
||||
def update_user_role(
|
||||
user_id: int,
|
||||
body: UserRoleUpdate,
|
||||
admin: User = Depends(require_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Change a user's role. Admin only."""
|
||||
user = db.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found.")
|
||||
if user.id == admin.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot change your own role.")
|
||||
|
||||
user.role = body.role
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
# ── DELETE /api/admin/users/{user_id} ─────────────────────────────────────────
|
||||
|
||||
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_user(
|
||||
user_id: int,
|
||||
admin: User = Depends(require_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Permanently delete a user and all their data. Admin only."""
|
||||
user = db.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found.")
|
||||
if user.id == admin.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete your own account.")
|
||||
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
@@ -6,10 +6,50 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..dependencies import get_current_user
|
||||
from ..models import Annotation, PDF, User
|
||||
from ..models import Annotation, PDF, User, UserRole
|
||||
from ..redis_client import ANN_TTL, ann_temp_key, ann_temp_page_pattern, get_redis
|
||||
from ..schemas import AnnotationIn, AnnotationOut
|
||||
|
||||
|
||||
def _do_delete_annotation(ann: Annotation, current_user: User, db: Session) -> None:
|
||||
"""Shared RBAC + delete logic, used by both delete endpoints."""
|
||||
from ..models import UserRole # avoid circular at module level
|
||||
|
||||
ann_owner = db.get(User, ann.user_id)
|
||||
|
||||
if current_user.role == UserRole.admin:
|
||||
pass # full access
|
||||
elif current_user.role == UserRole.teacher:
|
||||
if ann.user_id == current_user.id:
|
||||
pass # own annotation
|
||||
elif ann_owner and ann_owner.role == UserRole.admin:
|
||||
raise HTTPException(
|
||||
status_code=http_status.HTTP_403_FORBIDDEN,
|
||||
detail="Teachers cannot delete annotations belonging to an admin.",
|
||||
)
|
||||
else:
|
||||
pass # can delete student annotations (or annotations of deleted users)
|
||||
else: # student
|
||||
if ann.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=http_status.HTTP_403_FORBIDDEN,
|
||||
detail="You can only delete your own annotations.",
|
||||
)
|
||||
|
||||
pdf_id = ann.pdf_id
|
||||
page_number = ann.page_number
|
||||
user_id = ann.user_id
|
||||
db.delete(ann)
|
||||
db.commit()
|
||||
|
||||
# Remove Redis temp entry so /all doesn't serve stale data
|
||||
r = get_redis()
|
||||
if r is not None:
|
||||
try:
|
||||
r.delete(ann_temp_key(pdf_id, page_number, user_id))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
router = APIRouter(prefix="/annotations", tags=["annotations"])
|
||||
|
||||
|
||||
@@ -211,3 +251,56 @@ def upsert_annotation(
|
||||
pass
|
||||
|
||||
return ann
|
||||
|
||||
|
||||
# ── DELETE /api/annotations/{annotation_id} ───────────────────────────────────
|
||||
|
||||
@router.delete("/{annotation_id}", status_code=http_status.HTTP_204_NO_CONTENT)
|
||||
def delete_annotation(
|
||||
annotation_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Delete annotation by primary key (RBAC enforced)."""
|
||||
ann = db.get(Annotation, annotation_id)
|
||||
if not ann:
|
||||
raise HTTPException(status_code=404, detail="Annotation not found.")
|
||||
_do_delete_annotation(ann, current_user, db)
|
||||
|
||||
|
||||
# ── DELETE /api/annotations/{pdf_id}/{page_number}/user/{target_user_id} ──────
|
||||
|
||||
@router.delete("/{pdf_id}/{page_number}/user/{target_user_id}", status_code=http_status.HTTP_204_NO_CONTENT)
|
||||
def delete_user_page_annotation(
|
||||
pdf_id: int,
|
||||
page_number: int,
|
||||
target_user_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Delete a specific user's annotation on a given page (RBAC enforced).
|
||||
Useful for teachers clearing a student's page without knowing the annotation id.
|
||||
Returns 204 even when no annotation exists (idempotent).
|
||||
"""
|
||||
_any_pdf_or_404(db, pdf_id)
|
||||
ann = (
|
||||
db.query(Annotation)
|
||||
.filter(
|
||||
Annotation.pdf_id == pdf_id,
|
||||
Annotation.page_number == page_number,
|
||||
Annotation.user_id == target_user_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if ann is None:
|
||||
# Nothing in DB — still clean up any Redis temp entry
|
||||
r = get_redis()
|
||||
if r is not None:
|
||||
try:
|
||||
r.delete(ann_temp_key(pdf_id, page_number, target_user_id))
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
_do_delete_annotation(ann, current_user, db)
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import os
|
||||
|
||||
from ..database import get_db
|
||||
from ..dependencies import get_current_user
|
||||
from ..models import User
|
||||
from ..schemas import UserLogin, UserOut, UserRegister
|
||||
from ..models import User, UserRole, UserStatus
|
||||
from ..schemas import UserLogin, UserOut, UserRegister, UserApprove
|
||||
from ..security import (
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||
create_access_token,
|
||||
@@ -42,16 +42,25 @@ def register(body: UserRegister, response: Response, db: Session = Depends(get_d
|
||||
if db.query(User).filter(User.email == body.email).first():
|
||||
raise HTTPException(status_code=400, detail="Email already registered.")
|
||||
|
||||
# Admin registers as approved immediately; others start as pending
|
||||
initial_status = (
|
||||
UserStatus.approved if body.role == UserRole.admin else UserStatus.pending
|
||||
)
|
||||
|
||||
user = User(
|
||||
username=body.username,
|
||||
email=body.email,
|
||||
password_hash=hash_password(body.password),
|
||||
role=body.role,
|
||||
status=initial_status,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
_set_auth_cookie(response, user.id)
|
||||
# Only set auth cookie if immediately approved (admin self-registration)
|
||||
if user.status == UserStatus.approved:
|
||||
_set_auth_cookie(response, user.id)
|
||||
return user
|
||||
|
||||
|
||||
@@ -68,6 +77,17 @@ def login(body: UserLogin, response: Response, db: Session = Depends(get_db)):
|
||||
if not user or not password_ok:
|
||||
raise HTTPException(status_code=401, detail="Invalid username or password.")
|
||||
|
||||
if user.status == UserStatus.pending:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Your account is pending admin approval.",
|
||||
)
|
||||
if user.status == UserStatus.rejected:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Your account has been rejected.",
|
||||
)
|
||||
|
||||
_set_auth_cookie(response, user.id)
|
||||
return user
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ from datetime import datetime
|
||||
from pydantic import BaseModel, EmailStr, field_validator
|
||||
import re
|
||||
|
||||
from .models import UserRole, UserStatus
|
||||
|
||||
|
||||
# ── Auth ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -9,6 +11,7 @@ class UserRegister(BaseModel):
|
||||
username: str
|
||||
email: EmailStr
|
||||
password: str
|
||||
role: UserRole = UserRole.student
|
||||
|
||||
@field_validator("username")
|
||||
@classmethod
|
||||
@@ -37,11 +40,21 @@ class UserOut(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
email: str
|
||||
role: UserRole
|
||||
status: UserStatus
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class UserApprove(BaseModel):
|
||||
status: UserStatus
|
||||
|
||||
|
||||
class UserRoleUpdate(BaseModel):
|
||||
role: UserRole
|
||||
|
||||
|
||||
class TokenPayload(BaseModel):
|
||||
sub: int # user id
|
||||
exp: int
|
||||
|
||||
Reference in New Issue
Block a user