added a user system

This commit is contained in:
2026-08-30 17:07:55 +08:00
parent 0b85a2ed57
commit 637101bcdb
13 changed files with 1082 additions and 20 deletions

View File

@@ -28,14 +28,21 @@ from starlette.concurrency import run_in_threadpool
from starlette.requests import ClientDisconnect
from .assessment import AssessmentService
from .auth import AuthenticatedUser
from .audio_metrics import AudioAnalysisError
from .config import Settings
from .models import (
AssessmentResult,
AuthRequest,
AuthResponse,
CourseSummary,
SentenceBoundaryAdjust,
SentenceBoundary,
SentenceBoundaryDocument,
SentenceTextUpdate,
UserDubShareSummary,
UserPublic,
UserResultSummary,
VideoDetailResponse,
VideoListResponse,
VideoSummary,
@@ -124,6 +131,20 @@ def create_app(
if expected and not hmac.compare_digest(x_client_key or "", expected):
raise HTTPException(status_code=401, detail="A valid X-Client-Key header is required.")
def current_user(
authorization: Optional[str] = Header(default=None),
x_user_token: Optional[str] = Header(default=None),
) -> AuthenticatedUser:
token = x_user_token
if authorization and authorization.lower().startswith("bearer "):
token = authorization[7:].strip()
if not token:
raise HTTPException(status_code=401, detail="Login required.")
row = video_repository.resolve_session(token)
if row is None:
raise HTTPException(status_code=401, detail="Session expired. Please sign in again.")
return AuthenticatedUser(id=row["id"], username=row["username"], nickname=row["nickname"])
def find_document(video_hash: str) -> Optional[SentenceBoundaryDocument]:
return video_repository.get_document(video_hash) or legacy_store.get(video_hash)
@@ -139,6 +160,86 @@ def create_app(
"scoring_version": "asr-fluency-v1",
}
@application.post("/api/v1/auth/register", response_model=AuthResponse, status_code=201)
def register_user(payload: AuthRequest) -> AuthResponse:
user = video_repository.create_user(
username=payload.username,
password=payload.password,
nickname=payload.username,
)
if user is None:
raise HTTPException(status_code=409, detail="Username is already taken.")
token = video_repository.create_session(user["id"])
return AuthResponse(token=token, user=UserPublic(**user))
@application.post("/api/v1/auth/login", response_model=AuthResponse)
def login_user(payload: AuthRequest) -> AuthResponse:
user = video_repository.authenticate_user(payload.username, payload.password)
if user is None:
raise HTTPException(status_code=401, detail="Incorrect username or password.")
token = video_repository.create_session(user["id"])
return AuthResponse(token=token, user=UserPublic(**user))
@application.get("/api/v1/auth/me", response_model=UserPublic)
def get_authenticated_user(user: AuthenticatedUser = Depends(current_user)) -> UserPublic:
row = video_repository.get_user_by_id(user.id)
if row is None:
raise HTTPException(status_code=404, detail="User was not found.")
return UserPublic(**row)
@application.post("/api/v1/auth/logout", status_code=204)
def logout_user(
authorization: Optional[str] = Header(default=None),
x_user_token: Optional[str] = Header(default=None),
) -> Response:
token = x_user_token
if authorization and authorization.lower().startswith("bearer "):
token = authorization[7:].strip()
if token:
video_repository.delete_session(token)
return Response(status_code=204)
@application.get("/api/v1/courses", response_model=List[CourseSummary])
def list_courses(user: AuthenticatedUser = Depends(current_user)) -> List[CourseSummary]:
return [
CourseSummary(
video_hash=row["video_hash"],
title=row["title"],
duration_ms=row["duration_ms"],
language=row["language"],
sentence_count=row["sentence_count"],
stream_url=_video_summary(row, service_settings).stream_url,
enrolled=bool(row["enrolled"]),
)
for row in video_repository.list_courses(user.id)
]
@application.post("/api/v1/courses/{video_hash}/enroll", status_code=204)
def enroll_course(
video_hash: str = SHA256_PATH,
user: AuthenticatedUser = Depends(current_user),
) -> Response:
if not video_repository.enroll_course(user.id, video_hash):
raise HTTPException(status_code=404, detail="Course is not available.")
return Response(status_code=204)
@application.delete("/api/v1/courses/{video_hash}/enroll", status_code=204)
def unenroll_course(
video_hash: str = SHA256_PATH,
user: AuthenticatedUser = Depends(current_user),
) -> Response:
if not video_repository.unenroll_course(user.id, video_hash):
raise HTTPException(status_code=404, detail="Enrollment was not found.")
return Response(status_code=204)
@application.get("/api/v1/me/results", response_model=List[UserResultSummary])
def my_results(user: AuthenticatedUser = Depends(current_user)) -> List[UserResultSummary]:
return [UserResultSummary(**row) for row in video_repository.list_user_results(user.id)]
@application.get("/api/v1/me/dub-shares", response_model=List[UserDubShareSummary])
def my_dub_shares(user: AuthenticatedUser = Depends(current_user)) -> List[UserDubShareSummary]:
return [UserDubShareSummary(**row) for row in video_repository.list_user_dub_shares(user.id)]
@application.get("/admin", include_in_schema=False)
def admin_page() -> FileResponse:
page = static_dir / "admin.html"
@@ -181,11 +282,13 @@ def create_app(
@application.get(
"/api/v1/videos/{video_hash}/sentence-boundaries",
response_model=SentenceBoundaryDocument,
dependencies=[Depends(current_user)],
)
@application.get(
"/api/v1/sentence-boundaries/{video_hash}",
response_model=SentenceBoundaryDocument,
include_in_schema=False,
dependencies=[Depends(current_user)],
)
def get_sentence_boundaries(video_hash: str = SHA256_PATH) -> SentenceBoundaryDocument:
document = find_document(video_hash)
@@ -401,13 +504,13 @@ def create_app(
@application.post(
"/api/v1/videos/{video_hash}/sentences/{sentence_index}/assessments",
response_model=AssessmentResult,
dependencies=[Depends(require_client)],
)
async def assess_sentence(
video_hash: str = SHA256_PATH,
sentence_index: int = ApiPath(ge=0),
audio: UploadFile = File(...),
language: Optional[str] = Form(default=None),
user: AuthenticatedUser = Depends(current_user),
) -> AssessmentResult:
document = find_document(video_hash)
if document is None:
@@ -433,6 +536,7 @@ def create_app(
audio_path=temporary_path,
language=language,
retained_audio_filename=temporary_name if service_settings.keep_attempt_audio else None,
user_id=user.id,
)
completed = True
return result
@@ -457,6 +561,7 @@ def create_app(
segments: str = Form(...),
scores: str = Form(default="[]"),
files: List[UploadFile] = File(...),
user: AuthenticatedUser = Depends(current_user),
) -> Dict[str, Any]:
try:
segment_items = json.loads(segments)
@@ -526,6 +631,7 @@ def create_app(
video_hash=video_hash.lower(),
title=title,
segments=prepared_segments,
user_id=user.id,
)
if share is None:
raise HTTPException(status_code=404, detail="Video was not found.")