feat: Normalized /topics and /videos endpoints from bare lists to pagin…
- "backend/schemas.py" - "backend/routers/topics.py" - "backend/routers/videos.py" - "frontend/src/api/topics.ts" - "frontend/src/pages/TopicsBrowse.tsx" - "frontend/src/pages/Home.tsx" GSD-Task: S05/T03
This commit is contained in:
parent
1bbcb8f5bf
commit
dbc4afcf42
9 changed files with 165 additions and 16 deletions
|
|
@ -122,7 +122,7 @@ All 17 page components are eagerly imported in `App.tsx`. Wrap admin and creator
|
||||||
- Estimate: 30m
|
- Estimate: 30m
|
||||||
- Files: frontend/src/App.tsx
|
- Files: frontend/src/App.tsx
|
||||||
- Verify: cd frontend && npm run build && ls dist/assets/*.js | wc -l && rg 'React.lazy' src/App.tsx
|
- Verify: cd frontend && npm run build && ls dist/assets/*.js | wc -l && rg 'React.lazy' src/App.tsx
|
||||||
- [ ] **T03: Normalize /topics and /videos endpoints to paginated response shape** — ## Description
|
- [x] **T03: Normalized /topics and /videos endpoints from bare lists to paginated {items, total} response shape, updating backend schemas and two frontend consumers** — ## Description
|
||||||
|
|
||||||
Two API endpoints return bare lists instead of the standard `{items, total, offset, limit}` paginated shape. Normalize both backend endpoints and update the frontend consumers.
|
Two API endpoints return bare lists instead of the standard `{items, total, offset, limit}` paginated shape. Normalize both backend endpoints and update the frontend consumers.
|
||||||
|
|
||||||
|
|
|
||||||
30
.gsd/milestones/M019/slices/S05/tasks/T02-VERIFY.json
Normal file
30
.gsd/milestones/M019/slices/S05/tasks/T02-VERIFY.json
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"taskId": "T02",
|
||||||
|
"unitId": "M019/S05/T02",
|
||||||
|
"timestamp": 1775257599084,
|
||||||
|
"passed": false,
|
||||||
|
"discoverySource": "task-plan",
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"command": "cd frontend",
|
||||||
|
"exitCode": 0,
|
||||||
|
"durationMs": 7,
|
||||||
|
"verdict": "pass"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "npm run build",
|
||||||
|
"exitCode": 254,
|
||||||
|
"durationMs": 96,
|
||||||
|
"verdict": "fail"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "rg 'React.lazy' src/App.tsx",
|
||||||
|
"exitCode": 2,
|
||||||
|
"durationMs": 7,
|
||||||
|
"verdict": "fail"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"retryAttempt": 1,
|
||||||
|
"maxRetries": 2
|
||||||
|
}
|
||||||
88
.gsd/milestones/M019/slices/S05/tasks/T03-SUMMARY.md
Normal file
88
.gsd/milestones/M019/slices/S05/tasks/T03-SUMMARY.md
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
---
|
||||||
|
id: T03
|
||||||
|
parent: S05
|
||||||
|
milestone: M019
|
||||||
|
provides: []
|
||||||
|
requires: []
|
||||||
|
affects: []
|
||||||
|
key_files: ["backend/schemas.py", "backend/routers/topics.py", "backend/routers/videos.py", "frontend/src/api/topics.ts", "frontend/src/pages/TopicsBrowse.tsx", "frontend/src/pages/Home.tsx"]
|
||||||
|
key_decisions: ["Added TopicListResponse and VideoListResponse as separate typed schemas rather than reusing generic PaginatedResponse — keeps response_model type-safe for OpenAPI docs"]
|
||||||
|
patterns_established: []
|
||||||
|
drill_down_paths: []
|
||||||
|
observability_surfaces: []
|
||||||
|
duration: ""
|
||||||
|
verification_result: "npm run build exits 0 (80 modules, zero TS errors). rg confirms no response_model=list remains in topics.py or videos.py. All slice-level checks pass: no public-client refs, core API files exist, 11 API modules."
|
||||||
|
completed_at: 2026-04-03T23:09:24.299Z
|
||||||
|
blocker_discovered: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# T03: Normalized /topics and /videos endpoints from bare lists to paginated {items, total} response shape, updating backend schemas and two frontend consumers
|
||||||
|
|
||||||
|
> Normalized /topics and /videos endpoints from bare lists to paginated {items, total} response shape, updating backend schemas and two frontend consumers
|
||||||
|
|
||||||
|
## What Happened
|
||||||
|
---
|
||||||
|
id: T03
|
||||||
|
parent: S05
|
||||||
|
milestone: M019
|
||||||
|
key_files:
|
||||||
|
- backend/schemas.py
|
||||||
|
- backend/routers/topics.py
|
||||||
|
- backend/routers/videos.py
|
||||||
|
- frontend/src/api/topics.ts
|
||||||
|
- frontend/src/pages/TopicsBrowse.tsx
|
||||||
|
- frontend/src/pages/Home.tsx
|
||||||
|
key_decisions:
|
||||||
|
- Added TopicListResponse and VideoListResponse as separate typed schemas rather than reusing generic PaginatedResponse — keeps response_model type-safe for OpenAPI docs
|
||||||
|
duration: ""
|
||||||
|
verification_result: passed
|
||||||
|
completed_at: 2026-04-03T23:09:24.299Z
|
||||||
|
blocker_discovered: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# T03: Normalized /topics and /videos endpoints from bare lists to paginated {items, total} response shape, updating backend schemas and two frontend consumers
|
||||||
|
|
||||||
|
**Normalized /topics and /videos endpoints from bare lists to paginated {items, total} response shape, updating backend schemas and two frontend consumers**
|
||||||
|
|
||||||
|
## What Happened
|
||||||
|
|
||||||
|
Replaced bare list returns on GET /topics (list[TopicCategory] → TopicListResponse) and GET /videos (list[SourceVideoRead] → VideoListResponse). Added count query for videos total. Updated fetchTopics return type and both frontend consumers (TopicsBrowse.tsx, Home.tsx) to read .items. No frontend code calls the public /videos endpoint.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
npm run build exits 0 (80 modules, zero TS errors). rg confirms no response_model=list remains in topics.py or videos.py. All slice-level checks pass: no public-client refs, core API files exist, 11 API modules.
|
||||||
|
|
||||||
|
## Verification Evidence
|
||||||
|
|
||||||
|
| # | Command | Exit Code | Verdict | Duration |
|
||||||
|
|---|---------|-----------|---------|----------|
|
||||||
|
| 1 | `cd frontend && npm run build` | 0 | ✅ pass | 3800ms |
|
||||||
|
| 2 | `rg 'response_model=list' backend/routers/topics.py backend/routers/videos.py` | 1 | ✅ pass (no matches) | 100ms |
|
||||||
|
| 3 | `rg 'public-client' frontend/src/ -g '*.ts' -g '*.tsx'` | 1 | ✅ pass (no matches) | 100ms |
|
||||||
|
| 4 | `test -f frontend/src/api/index.ts && test -f frontend/src/api/client.ts` | 0 | ✅ pass | 100ms |
|
||||||
|
| 5 | `ls frontend/src/api/*.ts | wc -l` | 0 | ✅ pass (11) | 100ms |
|
||||||
|
|
||||||
|
|
||||||
|
## Deviations
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
|
||||||
|
Backend tests require PostgreSQL on ub01 (port 5433) — not available locally.
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
- `backend/schemas.py`
|
||||||
|
- `backend/routers/topics.py`
|
||||||
|
- `backend/routers/videos.py`
|
||||||
|
- `frontend/src/api/topics.ts`
|
||||||
|
- `frontend/src/pages/TopicsBrowse.tsx`
|
||||||
|
- `frontend/src/pages/Home.tsx`
|
||||||
|
|
||||||
|
|
||||||
|
## Deviations
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
Backend tests require PostgreSQL on ub01 (port 5433) — not available locally.
|
||||||
|
|
@ -18,6 +18,7 @@ from schemas import (
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
TechniquePageRead,
|
TechniquePageRead,
|
||||||
TopicCategory,
|
TopicCategory,
|
||||||
|
TopicListResponse,
|
||||||
TopicSubTopic,
|
TopicSubTopic,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -41,10 +42,10 @@ def _load_canonical_tags() -> list[dict[str, Any]]:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[TopicCategory])
|
@router.get("", response_model=TopicListResponse)
|
||||||
async def list_topics(
|
async def list_topics(
|
||||||
db: AsyncSession = Depends(get_session),
|
db: AsyncSession = Depends(get_session),
|
||||||
) -> list[TopicCategory]:
|
) -> TopicListResponse:
|
||||||
"""Return the two-level topic hierarchy with technique/creator counts per sub-topic.
|
"""Return the two-level topic hierarchy with technique/creator counts per sub-topic.
|
||||||
|
|
||||||
Categories come from ``canonical_tags.yaml``. Counts are computed
|
Categories come from ``canonical_tags.yaml``. Counts are computed
|
||||||
|
|
@ -97,7 +98,7 @@ async def list_topics(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
return result
|
return TopicListResponse(items=result, total=len(result))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{category_slug}/{subtopic_slug}", response_model=PaginatedResponse)
|
@router.get("/{category_slug}/{subtopic_slug}", response_model=PaginatedResponse)
|
||||||
|
|
|
||||||
|
|
@ -4,33 +4,43 @@ import logging
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from database import get_session
|
from database import get_session
|
||||||
from models import SourceVideo
|
from models import SourceVideo
|
||||||
from schemas import SourceVideoRead
|
from schemas import SourceVideoRead, VideoListResponse
|
||||||
|
|
||||||
logger = logging.getLogger("chrysopedia.videos")
|
logger = logging.getLogger("chrysopedia.videos")
|
||||||
|
|
||||||
router = APIRouter(prefix="/videos", tags=["videos"])
|
router = APIRouter(prefix="/videos", tags=["videos"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[SourceVideoRead])
|
@router.get("", response_model=VideoListResponse)
|
||||||
async def list_videos(
|
async def list_videos(
|
||||||
offset: Annotated[int, Query(ge=0)] = 0,
|
offset: Annotated[int, Query(ge=0)] = 0,
|
||||||
limit: Annotated[int, Query(ge=1, le=100)] = 50,
|
limit: Annotated[int, Query(ge=1, le=100)] = 50,
|
||||||
creator_id: str | None = None,
|
creator_id: str | None = None,
|
||||||
db: AsyncSession = Depends(get_session),
|
db: AsyncSession = Depends(get_session),
|
||||||
) -> list[SourceVideoRead]:
|
) -> VideoListResponse:
|
||||||
"""List source videos with optional filtering by creator."""
|
"""List source videos with optional filtering by creator."""
|
||||||
stmt = select(SourceVideo).order_by(SourceVideo.created_at.desc())
|
base_stmt = select(SourceVideo).order_by(SourceVideo.created_at.desc())
|
||||||
|
|
||||||
if creator_id:
|
if creator_id:
|
||||||
stmt = stmt.where(SourceVideo.creator_id == creator_id)
|
base_stmt = base_stmt.where(SourceVideo.creator_id == creator_id)
|
||||||
|
|
||||||
stmt = stmt.offset(offset).limit(limit)
|
# Total count (before offset/limit)
|
||||||
|
count_stmt = select(func.count()).select_from(base_stmt.subquery())
|
||||||
|
count_result = await db.execute(count_stmt)
|
||||||
|
total = count_result.scalar() or 0
|
||||||
|
|
||||||
|
stmt = base_stmt.offset(offset).limit(limit)
|
||||||
result = await db.execute(stmt)
|
result = await db.execute(stmt)
|
||||||
videos = result.scalars().all()
|
videos = result.scalars().all()
|
||||||
logger.debug("Listed %d videos (offset=%d, limit=%d)", len(videos), offset, limit)
|
logger.debug("Listed %d videos (offset=%d, limit=%d)", len(videos), offset, limit)
|
||||||
return [SourceVideoRead.model_validate(v) for v in videos]
|
return VideoListResponse(
|
||||||
|
items=[SourceVideoRead.model_validate(v) for v in videos],
|
||||||
|
total=total,
|
||||||
|
offset=offset,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -380,6 +380,20 @@ class TopicCategory(BaseModel):
|
||||||
sub_topics: list[TopicSubTopic] = Field(default_factory=list)
|
sub_topics: list[TopicSubTopic] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class TopicListResponse(BaseModel):
|
||||||
|
"""Paginated list of topic categories."""
|
||||||
|
items: list[TopicCategory] = Field(default_factory=list)
|
||||||
|
total: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class VideoListResponse(BaseModel):
|
||||||
|
"""Paginated list of source videos."""
|
||||||
|
items: list[SourceVideoRead] = Field(default_factory=list)
|
||||||
|
total: int = 0
|
||||||
|
offset: int = 0
|
||||||
|
limit: int = 50
|
||||||
|
|
||||||
|
|
||||||
# ── Creator Browse ───────────────────────────────────────────────────────────
|
# ── Creator Browse ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class CreatorBrowseItem(CreatorRead):
|
class CreatorBrowseItem(CreatorRead):
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,15 @@ export interface TopicCategory {
|
||||||
sub_topics: TopicSubTopic[];
|
sub_topics: TopicSubTopic[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TopicListResponse {
|
||||||
|
items: TopicCategory[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Functions ────────────────────────────────────────────────────────────────
|
// ── Functions ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function fetchTopics(): Promise<TopicCategory[]> {
|
export async function fetchTopics(): Promise<TopicListResponse> {
|
||||||
return request<TopicCategory[]>(`${BASE}/topics`);
|
return request<TopicListResponse>(`${BASE}/topics`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSubTopicTechniques(
|
export async function fetchSubTopicTechniques(
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,8 @@ export default function Home() {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const categories = await fetchTopics();
|
const data = await fetchTopics();
|
||||||
|
const categories = data.items;
|
||||||
const all = categories.flatMap((cat) =>
|
const all = categories.flatMap((cat) =>
|
||||||
cat.sub_topics.map((st) => ({ name: st.name, count: st.technique_count }))
|
cat.sub_topics.map((st) => ({ name: st.name, count: st.technique_count }))
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ export default function TopicsBrowse() {
|
||||||
try {
|
try {
|
||||||
const data = await fetchTopics();
|
const data = await fetchTopics();
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setCategories(data);
|
setCategories(data.items);
|
||||||
// Start collapsed
|
// Start collapsed
|
||||||
setExpanded(new Set());
|
setExpanded(new Set());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue