language-learning-app/api/app/routers/api/admin/packs.py

299 lines
9.6 KiB
Python

import uuid
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from ....auth import require_admin
from ....domain.services.pack_service import PackService, PackNotFoundError
from ....outbound.postgres.database import get_db
from ....outbound.postgres.repositories.pack_repository import PostgresPackRepository
from ....outbound.postgres.repositories.vocab_repository import PostgresVocabRepository
from ....outbound.postgres.repositories.flashcard_repository import PostgresFlashcardRepository
from ....outbound.postgres.repositories.dictionary_repository import PostgresDictionaryRepository
router = APIRouter(prefix="/admin/packs", tags=["admin-packs"])
# ── Request / Response models ─────────────────────────────────────────────────
class CreatePackRequest(BaseModel):
name: str
name_target: str
description: str
description_target: str
source_lang: str
target_lang: str
proficiencies: list[str] = []
class UpdatePackRequest(BaseModel):
name: str | None = None
name_target: str | None = None
description: str | None = None
description_target: str | None = None
proficiencies: list[str] | None = None
class AddEntryRequest(BaseModel):
sense_id: str | None = None
surface_text: str
class AddFlashcardTemplateRequest(BaseModel):
prompt_text: str
answer_text: str
prompt_context_text: str | None = None
answer_context_text: str | None = None
class FlashcardTemplateResponse(BaseModel):
id: str
pack_entry_id: str
prompt_text: str
answer_text: str
prompt_context_text: str | None
answer_context_text: str | None
created_at: str
class PackEntryResponse(BaseModel):
id: str
pack_id: str
sense_id: str | None
surface_text: str
created_at: str
flashcard_templates: list[FlashcardTemplateResponse] = []
class PackResponse(BaseModel):
id: str
name: str
name_target: str
description: str
description_target: str
source_lang: str
target_lang: str
proficiencies: list[str]
is_published: bool
created_at: str
class PackDetailResponse(PackResponse):
entries: list[PackEntryResponse] = []
# ── Dependency ────────────────────────────────────────────────────────────────
def _service(db: AsyncSession) -> PackService:
return PackService(
pack_repo=PostgresPackRepository(db),
vocab_repo=PostgresVocabRepository(db),
flashcard_repo=PostgresFlashcardRepository(db),
dict_repo=PostgresDictionaryRepository(db),
)
def _pack_repo(db: AsyncSession) -> PostgresPackRepository:
return PostgresPackRepository(db)
# ── Endpoints ─────────────────────────────────────────────────────────────────
@router.post("", response_model=PackResponse, status_code=201)
async def create_pack(
request: CreatePackRequest,
db: AsyncSession = Depends(get_db),
_: dict = Depends(require_admin),
) -> PackResponse:
pack = await _service(db).create_pack(
name=request.name,
name_target=request.name_target,
description=request.description,
description_target=request.description_target,
source_lang=request.source_lang,
target_lang=request.target_lang,
proficiencies=request.proficiencies,
)
return _to_pack_response(pack)
@router.get("", response_model=list[PackResponse])
async def list_packs(
source_lang: str | None = None,
target_lang: str | None = None,
db: AsyncSession = Depends(get_db),
_: dict = Depends(require_admin),
) -> list[PackResponse]:
packs = await _pack_repo(db).list_packs(source_lang=source_lang, target_lang=target_lang)
return [_to_pack_response(p) for p in packs]
@router.get("/{pack_id}", response_model=PackDetailResponse)
async def get_pack(
pack_id: str,
db: AsyncSession = Depends(get_db),
_: dict = Depends(require_admin),
) -> PackDetailResponse:
repo = _pack_repo(db)
pack = await repo.get_pack(_parse_uuid(pack_id))
if pack is None:
raise HTTPException(status_code=404, detail="Pack not found")
entries = await repo.get_entries_for_pack(uuid.UUID(pack.id))
entry_ids = [uuid.UUID(e.id) for e in entries]
templates_by_entry = await repo.get_templates_for_entries(entry_ids)
entry_responses = [
PackEntryResponse(
id=e.id,
pack_id=e.pack_id,
sense_id=e.sense_id,
surface_text=e.surface_text,
created_at=e.created_at.isoformat(),
flashcard_templates=[
_to_template_response(t) for t in templates_by_entry.get(e.id, [])
],
)
for e in entries
]
return PackDetailResponse(**_to_pack_response(pack).model_dump(), entries=entry_responses)
@router.patch("/{pack_id}", response_model=PackResponse)
async def update_pack(
pack_id: str,
request: UpdatePackRequest,
db: AsyncSession = Depends(get_db),
_: dict = Depends(require_admin),
) -> PackResponse:
try:
pack = await _service(db).update_pack(
pack_id=_parse_uuid(pack_id),
name=request.name,
name_target=request.name_target,
description=request.description,
description_target=request.description_target,
proficiencies=request.proficiencies,
)
except PackNotFoundError:
raise HTTPException(status_code=404, detail="Pack not found")
return _to_pack_response(pack)
@router.post("/{pack_id}/publish", response_model=PackResponse)
async def publish_pack(
pack_id: str,
db: AsyncSession = Depends(get_db),
_: dict = Depends(require_admin),
) -> PackResponse:
try:
pack = await _service(db).publish_pack(_parse_uuid(pack_id))
except PackNotFoundError:
raise HTTPException(status_code=404, detail="Pack not found")
return _to_pack_response(pack)
@router.post("/{pack_id}/entries", response_model=PackEntryResponse, status_code=201)
async def add_entry(
pack_id: str,
request: AddEntryRequest,
db: AsyncSession = Depends(get_db),
_: dict = Depends(require_admin),
) -> PackEntryResponse:
sense_id = _parse_uuid(request.sense_id) if request.sense_id else None
try:
entry = await _service(db).add_entry_to_pack(
pack_id=_parse_uuid(pack_id),
sense_id=sense_id,
surface_text=request.surface_text,
)
except PackNotFoundError:
raise HTTPException(status_code=404, detail="Pack not found")
return PackEntryResponse(
id=entry.id,
pack_id=entry.pack_id,
sense_id=entry.sense_id,
surface_text=entry.surface_text,
created_at=entry.created_at.isoformat(),
flashcard_templates=[],
)
@router.delete("/{pack_id}/entries/{entry_id}", status_code=204)
async def remove_entry(
pack_id: str,
entry_id: str,
db: AsyncSession = Depends(get_db),
_: dict = Depends(require_admin),
) -> None:
await _pack_repo(db).remove_entry(_parse_uuid(entry_id))
@router.post(
"/{pack_id}/entries/{entry_id}/flashcards",
response_model=FlashcardTemplateResponse,
status_code=201,
)
async def add_flashcard_template(
pack_id: str,
entry_id: str,
request: AddFlashcardTemplateRequest,
db: AsyncSession = Depends(get_db),
_: dict = Depends(require_admin),
) -> FlashcardTemplateResponse:
template = await _service(db).add_flashcard_template_to_entry(
pack_entry_id=_parse_uuid(entry_id),
prompt_text=request.prompt_text,
answer_text=request.answer_text,
prompt_context_text=request.prompt_context_text,
answer_context_text=request.answer_context_text,
)
return _to_template_response(template)
@router.delete("/{pack_id}/entries/{entry_id}/flashcards/{template_id}", status_code=204)
async def remove_flashcard_template(
pack_id: str,
entry_id: str,
template_id: str,
db: AsyncSession = Depends(get_db),
_: dict = Depends(require_admin),
) -> None:
await _pack_repo(db).remove_flashcard_template(_parse_uuid(template_id))
# ── Helpers ───────────────────────────────────────────────────────────────────
def _parse_uuid(value: str) -> uuid.UUID:
try:
return uuid.UUID(value)
except ValueError:
raise HTTPException(status_code=400, detail=f"Invalid UUID: {value!r}")
def _to_pack_response(pack) -> PackResponse:
return PackResponse(
id=pack.id,
name=pack.name,
name_target=pack.name_target,
description=pack.description,
description_target=pack.description_target,
source_lang=pack.source_lang,
target_lang=pack.target_lang,
proficiencies=pack.proficiencies,
is_published=pack.is_published,
created_at=pack.created_at.isoformat(),
)
def _to_template_response(template) -> FlashcardTemplateResponse:
return FlashcardTemplateResponse(
id=template.id,
pack_entry_id=template.pack_entry_id,
prompt_text=template.prompt_text,
answer_text=template.answer_text,
prompt_context_text=template.prompt_context_text,
answer_context_text=template.answer_context_text,
created_at=template.created_at.isoformat(),
)