added a user system
This commit is contained in:
@@ -28,6 +28,7 @@ class AssessmentService:
|
||||
audio_path: Path,
|
||||
language: Optional[str] = None,
|
||||
retained_audio_filename: Optional[str] = None,
|
||||
user_id: str = "",
|
||||
) -> AssessmentResult:
|
||||
if not self.transcriber.available:
|
||||
raise RuntimeError("MOSS transcription is not configured on this server.")
|
||||
@@ -103,5 +104,6 @@ class AssessmentService:
|
||||
sentence_index=sentence_index,
|
||||
result=result.model_dump(mode="json"),
|
||||
audio_filename=retained_audio_filename,
|
||||
user_id=user_id,
|
||||
)
|
||||
return result
|
||||
|
||||
49
sentence_api/auth.py
Normal file
49
sentence_api/auth.py
Normal file
@@ -0,0 +1,49 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
PBKDF2_ITERATIONS = 120_000
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthenticatedUser:
|
||||
id: str
|
||||
username: str
|
||||
nickname: str
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
salt = secrets.token_hex(16)
|
||||
digest = hashlib.pbkdf2_hmac(
|
||||
"sha256",
|
||||
password.encode("utf-8"),
|
||||
salt.encode("ascii"),
|
||||
PBKDF2_ITERATIONS,
|
||||
).hex()
|
||||
return f"pbkdf2_sha256${PBKDF2_ITERATIONS}${salt}${digest}"
|
||||
|
||||
|
||||
def verify_password(password: str, stored: str) -> bool:
|
||||
try:
|
||||
algorithm, iterations, salt, digest = stored.split("$", 3)
|
||||
if algorithm != "pbkdf2_sha256":
|
||||
return False
|
||||
calculated = hashlib.pbkdf2_hmac(
|
||||
"sha256",
|
||||
password.encode("utf-8"),
|
||||
salt.encode("ascii"),
|
||||
int(iterations),
|
||||
).hex()
|
||||
return hmac.compare_digest(calculated, digest)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def new_token() -> str:
|
||||
return secrets.token_urlsafe(48)
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
@@ -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.")
|
||||
|
||||
@@ -131,3 +131,53 @@ class AssessmentResult(BaseModel):
|
||||
substitutions: List[TextSubstitution]
|
||||
feedback: str
|
||||
details: Dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AuthRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
username: str = Field(min_length=3, max_length=50, pattern=r"^[A-Za-z0-9_.-]+$")
|
||||
password: str = Field(min_length=8, max_length=128)
|
||||
|
||||
|
||||
class UserPublic(BaseModel):
|
||||
id: str
|
||||
username: str
|
||||
nickname: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AuthResponse(BaseModel):
|
||||
token: str
|
||||
user: UserPublic
|
||||
|
||||
|
||||
class CourseSummary(BaseModel):
|
||||
video_hash: str
|
||||
title: str
|
||||
duration_ms: Optional[int] = None
|
||||
language: Optional[str] = None
|
||||
sentence_count: int = Field(ge=0)
|
||||
stream_url: str
|
||||
enrolled: bool
|
||||
|
||||
|
||||
class UserResultSummary(BaseModel):
|
||||
attempt_id: str
|
||||
video_hash: str
|
||||
course_title: str
|
||||
sentence_index: int
|
||||
sentence_text: str
|
||||
overall_score: float = Field(ge=0, le=100)
|
||||
passed: bool
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class UserDubShareSummary(BaseModel):
|
||||
share_id: str
|
||||
video_hash: str
|
||||
course_title: str
|
||||
title: str
|
||||
segment_count: int = Field(ge=0)
|
||||
average_score: float = Field(ge=0, le=100)
|
||||
created_at: datetime
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
import uuid
|
||||
|
||||
from .auth import hash_password, hash_token, new_token, verify_password
|
||||
from .models import SentenceBoundary, SentenceBoundaryDocument
|
||||
from .store import normalize_video_hash
|
||||
|
||||
@@ -70,6 +71,31 @@ class VideoRepository:
|
||||
FOREIGN KEY (video_hash) REFERENCES videos(video_hash) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
nickname TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth_tokens (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS enrollments (
|
||||
user_id TEXT NOT NULL,
|
||||
video_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, video_hash),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (video_hash) REFERENCES videos(video_hash) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dub_shares (
|
||||
share_id TEXT PRIMARY KEY,
|
||||
video_hash TEXT NOT NULL,
|
||||
@@ -114,6 +140,24 @@ class VideoRepository:
|
||||
"ALTER TABLE dub_share_segments ADD COLUMN score_details TEXT NOT NULL DEFAULT '{}'"
|
||||
)
|
||||
|
||||
with self._connect() as connection:
|
||||
attempt_columns = {
|
||||
row["name"]
|
||||
for row in connection.execute("PRAGMA table_info(attempts)")
|
||||
}
|
||||
if "user_id" not in attempt_columns:
|
||||
connection.execute("ALTER TABLE attempts ADD COLUMN user_id TEXT NOT NULL DEFAULT ''")
|
||||
connection.execute("CREATE INDEX IF NOT EXISTS idx_attempts_user ON attempts(user_id)")
|
||||
|
||||
with self._connect() as connection:
|
||||
dub_columns = {
|
||||
row["name"]
|
||||
for row in connection.execute("PRAGMA table_info(dub_shares)")
|
||||
}
|
||||
if "user_id" not in dub_columns:
|
||||
connection.execute("ALTER TABLE dub_shares ADD COLUMN user_id TEXT NOT NULL DEFAULT ''")
|
||||
connection.execute("CREATE INDEX IF NOT EXISTS idx_dub_shares_user ON dub_shares(user_id)")
|
||||
|
||||
with self._connect() as connection:
|
||||
video_columns = {
|
||||
row["name"]
|
||||
@@ -612,6 +656,7 @@ class VideoRepository:
|
||||
video_hash: str,
|
||||
title: str,
|
||||
segments: List[Dict[str, Any]],
|
||||
user_id: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
normalized_hash = normalize_video_hash(video_hash)
|
||||
if self.get_video(normalized_hash) is None:
|
||||
@@ -621,10 +666,10 @@ class VideoRepository:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO dub_shares (share_id, video_hash, title, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
INSERT INTO dub_shares (share_id, video_hash, title, user_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(share_id, normalized_hash, title[:200], now),
|
||||
(share_id, normalized_hash, title[:200], user_id, now),
|
||||
)
|
||||
connection.executemany(
|
||||
"""
|
||||
@@ -684,14 +729,15 @@ class VideoRepository:
|
||||
sentence_index: int,
|
||||
result: Dict[str, Any],
|
||||
audio_filename: Optional[str],
|
||||
user_id: str,
|
||||
) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO attempts (
|
||||
attempt_id, video_hash, sentence_index, result_json,
|
||||
audio_filename, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
audio_filename, user_id, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
attempt_id,
|
||||
@@ -699,10 +745,193 @@ class VideoRepository:
|
||||
sentence_index,
|
||||
json.dumps(result, ensure_ascii=False),
|
||||
audio_filename,
|
||||
user_id,
|
||||
utc_now(),
|
||||
),
|
||||
)
|
||||
|
||||
def create_user(self, *, username: str, password: str, nickname: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||
user_id = uuid.uuid4().hex
|
||||
now = utc_now()
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"""
|
||||
INSERT INTO users (id, username, nickname, password_hash, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
user_id,
|
||||
username.strip().lower(),
|
||||
(nickname or username).strip()[:80],
|
||||
hash_password(password),
|
||||
now,
|
||||
),
|
||||
).fetchone()
|
||||
except sqlite3.IntegrityError:
|
||||
return None
|
||||
return self.get_user_by_id(user_id)
|
||||
|
||||
def get_user_by_username(self, username: str) -> Optional[Dict[str, Any]]:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM users WHERE username = ? COLLATE NOCASE",
|
||||
(username.strip(),),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def get_user_by_id(self, user_id: str) -> Optional[Dict[str, Any]]:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT id, username, nickname, created_at FROM users WHERE id = ?",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def authenticate_user(self, username: str, password: str) -> Optional[Dict[str, Any]]:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM users WHERE username = ? COLLATE NOCASE",
|
||||
(username.strip(),),
|
||||
).fetchone()
|
||||
if row is None or not verify_password(password, row["password_hash"]):
|
||||
return None
|
||||
return self.get_user_by_id(row["id"])
|
||||
|
||||
def create_session(self, user_id: str) -> str:
|
||||
token = new_token()
|
||||
now = datetime.now(timezone.utc)
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO auth_tokens (token_hash, user_id, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
hash_token(token),
|
||||
user_id,
|
||||
now.isoformat(),
|
||||
(now + timedelta(days=30)).isoformat(),
|
||||
),
|
||||
)
|
||||
return token
|
||||
|
||||
def resolve_session(self, token: str) -> Optional[Dict[str, Any]]:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT u.id, u.username, u.nickname, u.created_at
|
||||
FROM auth_tokens t JOIN users u ON u.id = t.user_id
|
||||
WHERE t.token_hash = ? AND t.expires_at > ?
|
||||
""",
|
||||
(hash_token(token), datetime.now(timezone.utc).isoformat()),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def delete_session(self, token: str) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"DELETE FROM auth_tokens WHERE token_hash = ?",
|
||||
(hash_token(token),),
|
||||
)
|
||||
|
||||
def list_courses(self, user_id: str) -> List[Dict[str, Any]]:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT v.*, COUNT(s.sentence_index) AS sentence_count,
|
||||
CASE WHEN e.user_id IS NULL THEN 0 ELSE 1 END AS enrolled
|
||||
FROM videos v
|
||||
LEFT JOIN sentences s ON s.video_hash = v.video_hash
|
||||
LEFT JOIN enrollments e ON e.video_hash = v.video_hash AND e.user_id = ?
|
||||
WHERE v.status = 'ready'
|
||||
GROUP BY v.video_hash
|
||||
ORDER BY e.created_at DESC, v.created_at DESC
|
||||
""",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def enroll_course(self, user_id: str, video_hash: str) -> bool:
|
||||
normalized_hash = normalize_video_hash(video_hash)
|
||||
with self._connect() as connection:
|
||||
video = connection.execute(
|
||||
"SELECT 1 FROM videos WHERE video_hash = ? AND status = 'ready'",
|
||||
(normalized_hash,),
|
||||
).fetchone()
|
||||
if video is None:
|
||||
return False
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO enrollments (user_id, video_hash, created_at)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(user_id, normalized_hash, utc_now()),
|
||||
)
|
||||
return True
|
||||
|
||||
def unenroll_course(self, user_id: str, video_hash: str) -> bool:
|
||||
normalized_hash = normalize_video_hash(video_hash)
|
||||
with self._connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM enrollments WHERE user_id = ? AND video_hash = ?",
|
||||
(user_id, normalized_hash),
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def list_user_results(self, user_id: str) -> List[Dict[str, Any]]:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT a.attempt_id, a.video_hash, a.sentence_index, a.result_json, a.created_at,
|
||||
v.title AS course_title, s.text AS sentence_text
|
||||
FROM attempts a
|
||||
JOIN videos v ON v.video_hash = a.video_hash
|
||||
LEFT JOIN sentences s
|
||||
ON s.video_hash = a.video_hash AND s.sentence_index = a.sentence_index
|
||||
WHERE a.user_id = ?
|
||||
ORDER BY a.created_at DESC
|
||||
LIMIT 500
|
||||
""",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
results = []
|
||||
for row in rows:
|
||||
payload = json.loads(row["result_json"] or "{}")
|
||||
results.append(
|
||||
{
|
||||
"attempt_id": row["attempt_id"],
|
||||
"video_hash": row["video_hash"],
|
||||
"course_title": row["course_title"],
|
||||
"sentence_index": row["sentence_index"],
|
||||
"sentence_text": row["sentence_text"] or "",
|
||||
"overall_score": float(payload.get("overall_score", 0)),
|
||||
"passed": bool(payload.get("passed", False)),
|
||||
"created_at": row["created_at"],
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
||||
def list_user_dub_shares(self, user_id: str) -> List[Dict[str, Any]]:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT d.share_id, d.video_hash, d.title, d.created_at,
|
||||
v.title AS course_title,
|
||||
COUNT(g.id) AS segment_count,
|
||||
COALESCE(AVG(NULLIF(g.overall_score, 0)), 0) AS average_score
|
||||
FROM dub_shares d
|
||||
JOIN videos v ON v.video_hash = d.video_hash
|
||||
LEFT JOIN dub_share_segments g ON g.share_id = d.share_id
|
||||
WHERE d.user_id = ?
|
||||
GROUP BY d.share_id
|
||||
ORDER BY d.created_at DESC
|
||||
LIMIT 500
|
||||
""",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def delete_video(self, video_hash: str) -> Optional[Dict[str, Any]]:
|
||||
video = self.get_video(video_hash)
|
||||
if video is None:
|
||||
|
||||
@@ -38,13 +38,20 @@ def make_client(tmp_path):
|
||||
legacy_boundaries_file=index_path,
|
||||
)
|
||||
settings.ensure_directories()
|
||||
return TestClient(
|
||||
client = TestClient(
|
||||
create_app(
|
||||
BoundaryStore(index_path),
|
||||
settings=settings,
|
||||
repository=VideoRepository(settings.database_path),
|
||||
)
|
||||
)
|
||||
response = client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"username": "tester", "password": "password-123"},
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
client.headers.update({"Authorization": f"Bearer {response.json()['token']}"})
|
||||
return client
|
||||
|
||||
|
||||
def test_lookup_returns_boundaries(tmp_path):
|
||||
|
||||
@@ -90,7 +90,14 @@ def make_client(tmp_path, client_api_key="", transcriber=None):
|
||||
repository=repository,
|
||||
transcriber=transcriber or FakeTranscriber(),
|
||||
)
|
||||
return TestClient(app)
|
||||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"username": "tester", "password": "password-123"},
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
client.headers.update({"Authorization": f"Bearer {response.json()['token']}"})
|
||||
return client
|
||||
|
||||
|
||||
def test_assessment_returns_duration_and_content_breakdown(tmp_path):
|
||||
@@ -202,19 +209,59 @@ def test_create_dub_share_aligns_audio_with_whisper_boundaries(tmp_path):
|
||||
assert calls[0].read_bytes() == b"aligned-audio"
|
||||
|
||||
|
||||
def test_assessment_client_key_is_enforced_when_configured(tmp_path):
|
||||
def test_assessment_requires_a_valid_user_session(tmp_path):
|
||||
client = make_client(tmp_path, client_api_key="tablet-key")
|
||||
endpoint = f"/api/v1/videos/{VIDEO_HASH}/sentences/0/assessments"
|
||||
client.headers.clear()
|
||||
|
||||
unauthorized = client.post(
|
||||
endpoint,
|
||||
files={"audio": ("reading.wav", make_wav(), "audio/wav")},
|
||||
)
|
||||
login = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "tester", "password": "password-123"},
|
||||
)
|
||||
assert login.status_code == 200
|
||||
authorized = client.post(
|
||||
endpoint,
|
||||
headers={"X-Client-Key": "tablet-key"},
|
||||
headers={"Authorization": f"Bearer {login.json()['token']}"},
|
||||
files={"audio": ("reading.wav", make_wav(), "audio/wav")},
|
||||
)
|
||||
|
||||
assert unauthorized.status_code == 401
|
||||
assert authorized.status_code == 200
|
||||
|
||||
|
||||
def test_user_enrollment_results_and_dub_shares_are_scoped(tmp_path):
|
||||
client = make_client(tmp_path)
|
||||
|
||||
assert client.get("/api/v1/courses").status_code == 200
|
||||
assert client.post(f"/api/v1/courses/{VIDEO_HASH}/enroll").status_code == 204
|
||||
courses = client.get("/api/v1/courses").json()
|
||||
assert courses[0]["enrolled"] is True
|
||||
|
||||
assessed = client.post(
|
||||
f"/api/v1/videos/{VIDEO_HASH}/sentences/0/assessments",
|
||||
files={"audio": ("reading.wav", make_wav(), "audio/wav")},
|
||||
data={"language": "en"},
|
||||
)
|
||||
assert assessed.status_code == 200
|
||||
results = client.get("/api/v1/me/results").json()
|
||||
assert len(results) == 1
|
||||
assert results[0]["course_title"] == "Lesson"
|
||||
|
||||
shared = client.post(
|
||||
"/api/v1/dub-shares",
|
||||
data={
|
||||
"video_hash": VIDEO_HASH,
|
||||
"segments": json.dumps([{"sentence_index": 0}]),
|
||||
"scores": json.dumps([{"sentence_index": 0, "overall_score": 88}]),
|
||||
},
|
||||
files={"files": ("dub.wav", make_wav(), "audio/wav")},
|
||||
)
|
||||
assert shared.status_code == 201, shared.text
|
||||
shares = client.get("/api/v1/me/dub-shares").json()
|
||||
assert len(shares) == 1
|
||||
assert shares[0]["segment_count"] == 1
|
||||
assert shares[0]["average_score"] == 88
|
||||
|
||||
Reference in New Issue
Block a user