chore: import upstream snapshot with attribution
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,335 @@
|
||||
"""
|
||||
Question Notebook API — persists quiz questions, bookmarks, and categories.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64 as _b64
|
||||
import logging
|
||||
import uuid as _uuid
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Response
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from deeptutor.services.session import get_sqlite_session_store
|
||||
from deeptutor.services.storage import get_attachment_store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Models ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class AnswerImageItem(BaseModel):
|
||||
"""Persisted reference to one image attached to a learner's answer.
|
||||
|
||||
The bytes live in the AttachmentStore at ``url``; we never round-trip
|
||||
base64 back to the client so notebook lookups stay cheap.
|
||||
"""
|
||||
|
||||
id: str = ""
|
||||
url: str = ""
|
||||
filename: str = ""
|
||||
mime_type: str = ""
|
||||
|
||||
|
||||
class NotebookEntryItem(BaseModel):
|
||||
id: int
|
||||
session_id: str
|
||||
session_title: str = ""
|
||||
turn_id: str = ""
|
||||
question_id: str = ""
|
||||
question: str
|
||||
question_type: str = ""
|
||||
options: dict[str, str] = {}
|
||||
correct_answer: str = ""
|
||||
explanation: str = ""
|
||||
difficulty: str = ""
|
||||
user_answer: str = ""
|
||||
user_answer_images: list[AnswerImageItem] = []
|
||||
is_correct: bool = False
|
||||
bookmarked: bool = False
|
||||
followup_session_id: str = ""
|
||||
ai_judgment: str = ""
|
||||
created_at: float
|
||||
updated_at: float
|
||||
categories: list[CategoryItem] | None = None
|
||||
|
||||
|
||||
class NotebookEntryListResponse(BaseModel):
|
||||
items: list[NotebookEntryItem]
|
||||
total: int
|
||||
|
||||
|
||||
class EntryUpdateRequest(BaseModel):
|
||||
bookmarked: bool | None = None
|
||||
followup_session_id: str | None = None
|
||||
ai_judgment: str | None = None
|
||||
|
||||
|
||||
class CategoryItem(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
created_at: float = 0
|
||||
entry_count: int = 0
|
||||
|
||||
|
||||
class CategoryCreateRequest(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
|
||||
|
||||
class CategoryRenameRequest(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
|
||||
|
||||
class CategoryAddRequest(BaseModel):
|
||||
category_id: int
|
||||
|
||||
|
||||
class AnswerImageUpload(BaseModel):
|
||||
"""One image attached to the learner's answer.
|
||||
|
||||
Either ``base64`` (new upload) or ``url`` (re-submit of an already
|
||||
persisted image) must be set. ``id`` is preserved when the client
|
||||
sends one so the same logical image keeps a stable AttachmentStore
|
||||
record across resubmissions.
|
||||
"""
|
||||
|
||||
id: str = ""
|
||||
base64: str = ""
|
||||
url: str = ""
|
||||
filename: str = "answer.png"
|
||||
mime_type: str = "image/png"
|
||||
|
||||
|
||||
class UpsertEntryRequest(BaseModel):
|
||||
session_id: str
|
||||
turn_id: str = ""
|
||||
question_id: str
|
||||
question: str
|
||||
question_type: str = ""
|
||||
options: dict[str, str] | None = None
|
||||
correct_answer: str = ""
|
||||
explanation: str = ""
|
||||
difficulty: str = ""
|
||||
user_answer: str = ""
|
||||
# Optional: list of images attached as part of the learner's answer.
|
||||
# ``None`` means "don't touch any previously-stored images on update";
|
||||
# an empty list explicitly clears them.
|
||||
user_answer_images: list[AnswerImageUpload] | None = None
|
||||
is_correct: bool = False
|
||||
|
||||
|
||||
# ── Entry endpoints ──────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _persist_answer_images(
|
||||
session_id: str, images: list[AnswerImageUpload] | None
|
||||
) -> list[dict[str, str]] | None:
|
||||
"""Materialise base64 image uploads into the AttachmentStore.
|
||||
|
||||
Returns a list of ``{id, url, filename, mime_type}`` records suitable
|
||||
for ``notebook_entries.user_answer_images_json``. ``None`` is returned
|
||||
when ``images`` is ``None`` (no change to existing stored images).
|
||||
Records whose bytes fail to upload are dropped from the result with
|
||||
a warning — losing an image is better than failing the whole upsert.
|
||||
"""
|
||||
if images is None:
|
||||
return None
|
||||
|
||||
attachment_store = get_attachment_store()
|
||||
records: list[dict[str, str]] = []
|
||||
for image in images:
|
||||
record_id = (image.id or _uuid.uuid4().hex[:12]).strip()
|
||||
filename = (image.filename or "answer.png").strip() or "answer.png"
|
||||
mime_type = (image.mime_type or "image/png").strip() or "image/png"
|
||||
url = (image.url or "").strip()
|
||||
|
||||
if not url and image.base64:
|
||||
try:
|
||||
raw_bytes = _b64.b64decode(image.base64, validate=False)
|
||||
except Exception as exc:
|
||||
logger.warning("answer image %s rejected: invalid base64 (%s)", filename, exc)
|
||||
continue
|
||||
try:
|
||||
url = await attachment_store.put(
|
||||
session_id=session_id,
|
||||
attachment_id=record_id,
|
||||
filename=filename,
|
||||
data=raw_bytes,
|
||||
mime_type=mime_type,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("attachment store rejected answer image %s: %s", filename, exc)
|
||||
continue
|
||||
|
||||
if not url:
|
||||
# No url and no base64 — nothing usable.
|
||||
continue
|
||||
records.append(
|
||||
{
|
||||
"id": record_id,
|
||||
"url": url,
|
||||
"filename": filename,
|
||||
"mime_type": mime_type,
|
||||
}
|
||||
)
|
||||
return records
|
||||
|
||||
|
||||
@router.post("/entries/upsert")
|
||||
async def upsert_single_entry(payload: UpsertEntryRequest):
|
||||
store = get_sqlite_session_store()
|
||||
images_records = await _persist_answer_images(payload.session_id, payload.user_answer_images)
|
||||
item = payload.model_dump()
|
||||
# The store expects ``user_answer_images`` as a plain list of dicts
|
||||
# (or absent to mean "leave the stored images alone"). Strip the
|
||||
# upload payload version and replace with the persisted records.
|
||||
item.pop("user_answer_images", None)
|
||||
if images_records is not None:
|
||||
item["user_answer_images"] = images_records
|
||||
try:
|
||||
await store.upsert_notebook_entries(payload.session_id, [item])
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
entry = await store.find_notebook_entry(
|
||||
payload.session_id, payload.question_id, turn_id=payload.turn_id
|
||||
)
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=500, detail="Upsert failed")
|
||||
return entry
|
||||
|
||||
|
||||
@router.get("/entries", response_model=NotebookEntryListResponse)
|
||||
async def list_entries(
|
||||
category_id: int | None = Query(default=None),
|
||||
bookmarked: bool | None = Query(default=None),
|
||||
is_correct: bool | None = Query(default=None),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> NotebookEntryListResponse:
|
||||
store = get_sqlite_session_store()
|
||||
result = await store.list_notebook_entries(
|
||||
category_id=category_id,
|
||||
bookmarked=bookmarked,
|
||||
is_correct=is_correct,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return NotebookEntryListResponse(
|
||||
items=[NotebookEntryItem(**item) for item in result["items"]],
|
||||
total=result["total"],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/entries/lookup/by-question")
|
||||
async def lookup_entry(
|
||||
session_id: str = Query(...),
|
||||
question_id: str = Query(...),
|
||||
turn_id: str | None = Query(default=None),
|
||||
missing_ok: bool = Query(
|
||||
default=False,
|
||||
description="Return 204 No Content instead of 404 when the entry is "
|
||||
"absent — used by the quiz viewer to probe not-yet-saved questions "
|
||||
"without logging noisy 404s.",
|
||||
),
|
||||
):
|
||||
store = get_sqlite_session_store()
|
||||
entry = await store.find_notebook_entry(session_id, question_id, turn_id=turn_id)
|
||||
if entry is None:
|
||||
if missing_ok:
|
||||
return Response(status_code=204)
|
||||
raise HTTPException(status_code=404, detail="Entry not found")
|
||||
return entry
|
||||
|
||||
|
||||
@router.get("/entries/{entry_id}", response_model=NotebookEntryItem)
|
||||
async def get_entry(entry_id: int) -> NotebookEntryItem:
|
||||
store = get_sqlite_session_store()
|
||||
entry = await store.get_notebook_entry(entry_id)
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=404, detail="Entry not found")
|
||||
return NotebookEntryItem(**entry)
|
||||
|
||||
|
||||
@router.patch("/entries/{entry_id}")
|
||||
async def update_entry(entry_id: int, payload: EntryUpdateRequest):
|
||||
store = get_sqlite_session_store()
|
||||
updates = payload.model_dump(exclude_none=True)
|
||||
if not updates:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
updated = await store.update_notebook_entry(entry_id, updates)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=404, detail="Entry not found")
|
||||
return {"updated": True, "id": entry_id}
|
||||
|
||||
|
||||
@router.delete("/entries/{entry_id}")
|
||||
async def delete_entry(entry_id: int):
|
||||
store = get_sqlite_session_store()
|
||||
deleted = await store.delete_notebook_entry(entry_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Entry not found")
|
||||
return {"deleted": True, "id": entry_id}
|
||||
|
||||
|
||||
# ── Entry ↔ Category linking ────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/entries/{entry_id}/categories")
|
||||
async def add_entry_to_category(entry_id: int, payload: CategoryAddRequest):
|
||||
store = get_sqlite_session_store()
|
||||
entry = await store.get_notebook_entry(entry_id)
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=404, detail="Entry not found")
|
||||
ok = await store.add_entry_to_category(entry_id, payload.category_id)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400, detail="Failed to add to category")
|
||||
return {"added": True, "entry_id": entry_id, "category_id": payload.category_id}
|
||||
|
||||
|
||||
@router.delete("/entries/{entry_id}/categories/{category_id}")
|
||||
async def remove_entry_from_category(entry_id: int, category_id: int):
|
||||
store = get_sqlite_session_store()
|
||||
removed = await store.remove_entry_from_category(entry_id, category_id)
|
||||
if not removed:
|
||||
raise HTTPException(status_code=404, detail="Link not found")
|
||||
return {"removed": True, "entry_id": entry_id, "category_id": category_id}
|
||||
|
||||
|
||||
# ── Category CRUD ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/categories", response_model=list[CategoryItem])
|
||||
async def list_categories():
|
||||
store = get_sqlite_session_store()
|
||||
return await store.list_categories()
|
||||
|
||||
|
||||
@router.post("/categories", response_model=CategoryItem, status_code=201)
|
||||
async def create_category(payload: CategoryCreateRequest):
|
||||
store = get_sqlite_session_store()
|
||||
try:
|
||||
return await store.create_category(payload.name)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=409, detail="Category name already exists")
|
||||
|
||||
|
||||
@router.patch("/categories/{category_id}")
|
||||
async def rename_category(category_id: int, payload: CategoryRenameRequest):
|
||||
store = get_sqlite_session_store()
|
||||
updated = await store.rename_category(category_id, payload.name)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=404, detail="Category not found")
|
||||
return {"updated": True, "id": category_id, "name": payload.name}
|
||||
|
||||
|
||||
@router.delete("/categories/{category_id}")
|
||||
async def delete_category(category_id: int):
|
||||
store = get_sqlite_session_store()
|
||||
deleted = await store.delete_category(category_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Category not found")
|
||||
return {"deleted": True, "id": category_id}
|
||||
Reference in New Issue
Block a user