refactor(stage-4): partition monolithic main.py into modular APIRouters
This commit is contained in:
1 parent
4e1f809018
commit
dde87e5d6c
15 files changed
+2202
-2138
No files matched your search
@@ -0,0 +1,349 @@
|
||||
import asyncio
|
||||
from typing import Optional, List
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.auth import require_user
|
||||
from app.database import get_db
|
||||
from app.tts import get_audio_hash, prewarm_card_audio
|
||||
|
||||
router = APIRouter(tags=["Cards & Collections"])
|
||||
|
||||
|
||||
class CardCreateRequest(BaseModel):
|
||||
term: str = Field(..., min_length=1)
|
||||
translation: str = Field(..., min_length=1)
|
||||
context_sentence: Optional[str] = ""
|
||||
phonetic: Optional[str] = ""
|
||||
audio_url: Optional[str] = ""
|
||||
part_of_speech: Optional[str] = ""
|
||||
definition_en: Optional[str] = ""
|
||||
tags: Optional[str] = "general"
|
||||
notes: Optional[str] = ""
|
||||
collection: Optional[str] = "General"
|
||||
|
||||
|
||||
class CardUpdateRequest(BaseModel):
|
||||
term: Optional[str] = None
|
||||
translation: Optional[str] = None
|
||||
context_sentence: Optional[str] = None
|
||||
phonetic: Optional[str] = None
|
||||
audio_url: Optional[str] = None
|
||||
part_of_speech: Optional[str] = None
|
||||
definition_en: Optional[str] = None
|
||||
tags: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
collection: Optional[str] = None
|
||||
|
||||
|
||||
class BatchActionRequest(BaseModel):
|
||||
card_ids: List[int] = Field(..., min_length=1)
|
||||
action: str = Field(..., pattern="^(set_collection|add_tags|delete)$")
|
||||
value: Optional[str] = ""
|
||||
|
||||
|
||||
@router.get("/api/collections")
|
||||
async def api_get_collections(user: dict = Depends(require_user)):
|
||||
"""Returns all collections for the current user with card count."""
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT collection, COUNT(*) as count
|
||||
FROM vocabulary_cards
|
||||
WHERE user_id = ?
|
||||
GROUP BY collection
|
||||
ORDER BY count DESC, collection ASC
|
||||
""",
|
||||
(user["id"],),
|
||||
).fetchall()
|
||||
return [{"name": r["collection"], "collection": r["collection"], "count": r["count"]} for r in rows]
|
||||
|
||||
|
||||
@router.get("/api/cards/check-duplicate")
|
||||
async def api_check_duplicate(term: str = Query(..., min_length=1), user: dict = Depends(require_user)):
|
||||
clean_term = term.strip().lower()
|
||||
with get_db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM vocabulary_cards WHERE user_id = ? AND lower(trim(term)) = ?",
|
||||
(user["id"], clean_term),
|
||||
).fetchone()
|
||||
if row:
|
||||
return {"exists": True, "card": dict(row)}
|
||||
return {"exists": False, "card": None}
|
||||
|
||||
|
||||
@router.get("/api/cards")
|
||||
async def api_get_cards(
|
||||
search: Optional[str] = None,
|
||||
tag: Optional[str] = None,
|
||||
collection: Optional[str] = None,
|
||||
srs_state: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
user: dict = Depends(require_user),
|
||||
):
|
||||
with get_db() as conn:
|
||||
query = """
|
||||
SELECT id, term, translation, context_sentence, phonetic, audio_url,
|
||||
part_of_speech, definition_en, tags, notes, collection,
|
||||
srs_reps, srs_interval, srs_ease, srs_next_review, srs_last_review, srs_state, error_count,
|
||||
created_at, updated_at
|
||||
FROM vocabulary_cards
|
||||
WHERE user_id = ?
|
||||
"""
|
||||
params = [user["id"]]
|
||||
|
||||
if search:
|
||||
s_clean = f"%{search.strip()}%"
|
||||
query += " AND (term LIKE ? OR translation LIKE ? OR context_sentence LIKE ? OR notes LIKE ?)"
|
||||
params.extend([s_clean, s_clean, s_clean, s_clean])
|
||||
|
||||
if tag:
|
||||
t_clean = f"%{tag.strip()}%"
|
||||
query += " AND tags LIKE ?"
|
||||
params.append(t_clean)
|
||||
|
||||
if collection:
|
||||
query += " AND collection = ?"
|
||||
params.append(collection.strip())
|
||||
|
||||
if srs_state:
|
||||
query += " AND srs_state = ?"
|
||||
params.append(srs_state.strip())
|
||||
|
||||
# Total count before limit
|
||||
count_query = f"SELECT COUNT(*) FROM ({query})"
|
||||
total = conn.execute(count_query, params).fetchone()[0]
|
||||
|
||||
query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
|
||||
params.extend([limit, offset])
|
||||
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
cards = []
|
||||
for r in rows:
|
||||
c = dict(r)
|
||||
if not c.get("audio_url"):
|
||||
c["audio_url"] = f"/api/audio/{get_audio_hash(c['term'], 'en', 1.0)}.wav"
|
||||
if c.get("context_sentence"):
|
||||
c["context_audio_url"] = f"/api/audio/{get_audio_hash(c['context_sentence'], 'en', 1.0)}.wav"
|
||||
cards.append(c)
|
||||
|
||||
return {"total": total, "cards": cards}
|
||||
|
||||
|
||||
@router.post("/api/cards")
|
||||
async def api_create_card(card: CardCreateRequest, user: dict = Depends(require_user)):
|
||||
clean_term = card.term.strip()
|
||||
with get_db() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT * FROM vocabulary_cards WHERE user_id = ? AND lower(trim(term)) = lower(trim(?))",
|
||||
(user["id"], clean_term),
|
||||
).fetchone()
|
||||
|
||||
if existing:
|
||||
# Word already exists in library!
|
||||
existing_row = dict(existing)
|
||||
new_ctx = (card.context_sentence or "").strip()
|
||||
old_ctx = (existing_row.get("context_sentence") or "").strip()
|
||||
updated_ctx = old_ctx
|
||||
if new_ctx and new_ctx not in old_ctx:
|
||||
updated_ctx = f"{old_ctx}\n\n{new_ctx}" if old_ctx else new_ctx
|
||||
|
||||
# Merge tags
|
||||
old_tags = set([t.strip() for t in (existing_row.get("tags") or "").split(",") if t.strip()])
|
||||
new_tags = [t.strip() for t in (card.tags or "").split(",") if t.strip()]
|
||||
for t in new_tags:
|
||||
old_tags.add(t)
|
||||
updated_tags = ", ".join(sorted(old_tags)) if old_tags else "general"
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE vocabulary_cards
|
||||
SET context_sentence = ?, tags = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""",
|
||||
(updated_ctx, updated_tags, existing_row["id"]),
|
||||
)
|
||||
updated_row = conn.execute("SELECT * FROM vocabulary_cards WHERE id = ?", (existing_row["id"],)).fetchone()
|
||||
res = dict(updated_row)
|
||||
if not res.get("audio_url"):
|
||||
res["audio_url"] = f"/api/audio/{get_audio_hash(res['term'], 'en', 1.0)}.wav"
|
||||
if res.get("context_sentence"):
|
||||
res["context_audio_url"] = f"/api/audio/{get_audio_hash(res['context_sentence'], 'en', 1.0)}.wav"
|
||||
res["already_exists"] = True
|
||||
|
||||
# Pre-warm word and context audio in background
|
||||
asyncio.create_task(prewarm_card_audio(clean_term, card.context_sentence))
|
||||
return res
|
||||
|
||||
audio_url_val = (card.audio_url or "").strip()
|
||||
if not audio_url_val:
|
||||
audio_url_val = f"/api/audio/{get_audio_hash(clean_term, 'en', 1.0)}.wav"
|
||||
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
INSERT INTO vocabulary_cards (
|
||||
user_id, term, translation, context_sentence, phonetic, audio_url,
|
||||
part_of_speech, definition_en, tags, notes, collection,
|
||||
srs_reps, srs_interval, srs_ease, srs_state, error_count
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 2.5, 'new', 0)
|
||||
""",
|
||||
(
|
||||
user["id"],
|
||||
clean_term,
|
||||
card.translation.strip(),
|
||||
(card.context_sentence or "").strip(),
|
||||
(card.phonetic or "").strip(),
|
||||
audio_url_val,
|
||||
(card.part_of_speech or "").strip(),
|
||||
(card.definition_en or "").strip(),
|
||||
(card.tags or "general").strip(),
|
||||
(card.notes or "").strip(),
|
||||
(card.collection or "General").strip(),
|
||||
),
|
||||
)
|
||||
new_id = cursor.lastrowid
|
||||
row = conn.execute("SELECT * FROM vocabulary_cards WHERE id = ?", (new_id,)).fetchone()
|
||||
res = dict(row)
|
||||
if not res.get("audio_url"):
|
||||
res["audio_url"] = f"/api/audio/{get_audio_hash(res['term'], 'en', 1.0)}.wav"
|
||||
if res.get("context_sentence"):
|
||||
res["context_audio_url"] = f"/api/audio/{get_audio_hash(res['context_sentence'], 'en', 1.0)}.wav"
|
||||
|
||||
# Pre-warm word and context audio in background
|
||||
asyncio.create_task(prewarm_card_audio(clean_term, card.context_sentence))
|
||||
return res
|
||||
|
||||
|
||||
@router.get("/api/cards/{card_id}")
|
||||
async def api_get_card(card_id: int, user: dict = Depends(require_user)):
|
||||
with get_db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM vocabulary_cards WHERE id = ? AND user_id = ?",
|
||||
(card_id, user["id"]),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Card not found")
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.put("/api/cards/{card_id}")
|
||||
async def api_update_card(card_id: int, update: CardUpdateRequest, user: dict = Depends(require_user)):
|
||||
with get_db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM vocabulary_cards WHERE id = ? AND user_id = ?",
|
||||
(card_id, user["id"]),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Card not found")
|
||||
current = dict(row)
|
||||
|
||||
def pick(new_val, current_val):
|
||||
return new_val.strip() if new_val is not None else current_val
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE vocabulary_cards
|
||||
SET term = ?, translation = ?, context_sentence = ?, phonetic = ?,
|
||||
audio_url = ?, part_of_speech = ?, definition_en = ?, tags = ?,
|
||||
notes = ?, collection = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND user_id = ?
|
||||
""",
|
||||
(
|
||||
pick(update.term, current["term"]),
|
||||
pick(update.translation, current["translation"]),
|
||||
pick(update.context_sentence, current.get("context_sentence", "")),
|
||||
pick(update.phonetic, current.get("phonetic", "")),
|
||||
pick(update.audio_url, current.get("audio_url", "")),
|
||||
pick(update.part_of_speech, current.get("part_of_speech", "")),
|
||||
pick(update.definition_en, current.get("definition_en", "")),
|
||||
pick(update.tags, current.get("tags", "")),
|
||||
pick(update.notes, current.get("notes", "")),
|
||||
pick(update.collection, current.get("collection", "General")),
|
||||
card_id,
|
||||
user["id"],
|
||||
),
|
||||
)
|
||||
updated_row = conn.execute("SELECT * FROM vocabulary_cards WHERE id = ?", (card_id,)).fetchone()
|
||||
return dict(updated_row)
|
||||
|
||||
|
||||
@router.delete("/api/cards/{card_id}")
|
||||
async def api_delete_card(card_id: int, user: dict = Depends(require_user)):
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute(
|
||||
"DELETE FROM vocabulary_cards WHERE id = ? AND user_id = ?",
|
||||
(card_id, user["id"]),
|
||||
)
|
||||
if cursor.rowcount == 0:
|
||||
raise HTTPException(status_code=404, detail="Card not found")
|
||||
return {"status": "ok", "deleted_id": card_id}
|
||||
|
||||
|
||||
@router.post("/api/cards/batch")
|
||||
async def api_batch_action(req: BatchActionRequest, user: dict = Depends(require_user)):
|
||||
if not req.card_ids:
|
||||
raise HTTPException(status_code=400, detail="No card IDs provided")
|
||||
|
||||
with get_db() as conn:
|
||||
placeholders = ",".join("?" * len(req.card_ids))
|
||||
params_base = list(req.card_ids) + [user["id"]]
|
||||
|
||||
if req.action == "delete":
|
||||
cursor = conn.execute(
|
||||
f"DELETE FROM vocabulary_cards WHERE id IN ({placeholders}) AND user_id = ?",
|
||||
params_base,
|
||||
)
|
||||
return {"status": "ok", "action": "delete", "affected": cursor.rowcount}
|
||||
|
||||
elif req.action == "set_collection":
|
||||
collection_val = (req.value or "General").strip()
|
||||
cursor = conn.execute(
|
||||
f"UPDATE vocabulary_cards SET collection = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN ({placeholders}) AND user_id = ?",
|
||||
[collection_val] + params_base,
|
||||
)
|
||||
return {"status": "ok", "action": "set_collection", "affected": cursor.rowcount}
|
||||
|
||||
elif req.action == "add_tags":
|
||||
new_tags_raw = req.value or ""
|
||||
new_tags = [t.strip() for t in new_tags_raw.split(",") if t.strip()]
|
||||
if not new_tags:
|
||||
return {"status": "ok", "action": "add_tags", "affected": 0}
|
||||
|
||||
rows = conn.execute(
|
||||
f"SELECT id, tags FROM vocabulary_cards WHERE id IN ({placeholders}) AND user_id = ?",
|
||||
params_base,
|
||||
).fetchall()
|
||||
|
||||
affected = 0
|
||||
for r in rows:
|
||||
existing = [t.strip() for t in (r["tags"] or "").split(",") if t.strip()]
|
||||
combined = existing + [t for t in new_tags if t not in existing]
|
||||
conn.execute(
|
||||
"UPDATE vocabulary_cards SET tags = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(", ".join(combined), r["id"]),
|
||||
)
|
||||
affected += 1
|
||||
return {"status": "ok", "action": "add_tags", "affected": affected}
|
||||
|
||||
|
||||
@router.get("/api/tags")
|
||||
async def api_get_tags(user: dict = Depends(require_user)):
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT tags FROM vocabulary_cards WHERE user_id = ?",
|
||||
(user["id"],),
|
||||
).fetchall()
|
||||
tag_counts = {}
|
||||
for r in rows:
|
||||
if r["tags"]:
|
||||
for t in r["tags"].split(","):
|
||||
clean = t.strip()
|
||||
if clean:
|
||||
tag_counts[clean] = tag_counts.get(clean, 0) + 1
|
||||
sorted_tags = sorted(
|
||||
[{"tag": k, "count": v} for k, v in tag_counts.items()],
|
||||
key=lambda x: x["count"],
|
||||
reverse=True,
|
||||
)
|
||||
return sorted_tags
|
||||
Reference in new issue
Block a user