added a user system
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user