feat: remove review workflow — unused gate that blocked nothing
773 key moments sat at 'pending' with 0 approved/edited/rejected. review_status was never checked by any public-facing query — all content was always visible regardless of review state. Removed: - backend/routers/review.py (10 endpoints) - backend/tests/test_review.py - frontend ReviewQueue, MomentDetail pages - frontend client.ts (review-only API client) - frontend ModeToggle, StatusBadge components - Review link from AdminDropdown, Moments link from pipeline rows - ReviewStatus, PageReviewStatus enums from models - review_mode config flag - review_status columns (migration 007) - ~80 lines of mode-toggle CSS Pipeline now always sets processing_status to 'published'. Migration 007 drops columns, enums, and migrates 'reviewed' → 'published'.
This commit is contained in:
parent
1ac3db77a1
commit
52e7e3bbc2
19 changed files with 38 additions and 1989 deletions
30
alembic/versions/007_drop_review_columns.py
Normal file
30
alembic/versions/007_drop_review_columns.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
"""Drop review_status columns and enums.
|
||||
|
||||
Revision ID: 007_drop_review_columns
|
||||
Revises: 006_debug_columns
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = "007_drop_review_columns"
|
||||
down_revision = "006_debug_columns"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_column("key_moments", "review_status")
|
||||
op.drop_column("technique_pages", "review_status")
|
||||
op.execute("DROP TYPE IF EXISTS review_status")
|
||||
op.execute("DROP TYPE IF EXISTS page_review_status")
|
||||
# Collapse 'reviewed' into 'published' for any existing rows
|
||||
op.execute(
|
||||
"UPDATE source_videos SET processing_status = 'published' "
|
||||
"WHERE processing_status = 'reviewed'"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("CREATE TYPE review_status AS ENUM ('pending', 'approved', 'edited', 'rejected')")
|
||||
op.execute("CREATE TYPE page_review_status AS ENUM ('draft', 'reviewed', 'published')")
|
||||
op.add_column("key_moments", op.Column("review_status", op.Enum("pending", "approved", "edited", "rejected", name="review_status"), server_default="pending", nullable=False))
|
||||
op.add_column("technique_pages", op.Column("review_status", op.Enum("draft", "reviewed", "published", name="page_review_status"), server_default="draft", nullable=False))
|
||||
|
|
@ -59,9 +59,6 @@ class Settings(BaseSettings):
|
|||
# Prompt templates
|
||||
prompts_path: str = "./prompts"
|
||||
|
||||
# Review mode — when True, extracted moments go to review queue before publishing
|
||||
review_mode: bool = True
|
||||
|
||||
# Debug mode — when True, pipeline captures full LLM prompts and responses
|
||||
debug_mode: bool = False
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from fastapi import FastAPI
|
|||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from config import get_settings
|
||||
from routers import creators, health, ingest, pipeline, reports, review, search, techniques, topics, videos
|
||||
from routers import creators, health, ingest, pipeline, reports, search, techniques, topics, videos
|
||||
|
||||
|
||||
def _setup_logging() -> None:
|
||||
|
|
@ -81,7 +81,6 @@ app.include_router(health.router)
|
|||
app.include_router(creators.router, prefix="/api/v1")
|
||||
app.include_router(ingest.router, prefix="/api/v1")
|
||||
app.include_router(pipeline.router, prefix="/api/v1")
|
||||
app.include_router(review.router, prefix="/api/v1")
|
||||
app.include_router(reports.router, prefix="/api/v1")
|
||||
app.include_router(search.router, prefix="/api/v1")
|
||||
app.include_router(techniques.router, prefix="/api/v1")
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@ class ProcessingStatus(str, enum.Enum):
|
|||
pending = "pending"
|
||||
transcribed = "transcribed"
|
||||
extracted = "extracted"
|
||||
reviewed = "reviewed"
|
||||
published = "published"
|
||||
|
||||
|
||||
|
|
@ -55,14 +54,6 @@ class KeyMomentContentType(str, enum.Enum):
|
|||
workflow = "workflow"
|
||||
|
||||
|
||||
class ReviewStatus(str, enum.Enum):
|
||||
"""Human review status for key moments."""
|
||||
pending = "pending"
|
||||
approved = "approved"
|
||||
edited = "edited"
|
||||
rejected = "rejected"
|
||||
|
||||
|
||||
class SourceQuality(str, enum.Enum):
|
||||
"""Derived source quality for technique pages."""
|
||||
structured = "structured"
|
||||
|
|
@ -70,13 +61,6 @@ class SourceQuality(str, enum.Enum):
|
|||
unstructured = "unstructured"
|
||||
|
||||
|
||||
class PageReviewStatus(str, enum.Enum):
|
||||
"""Review lifecycle for technique pages."""
|
||||
draft = "draft"
|
||||
reviewed = "reviewed"
|
||||
published = "published"
|
||||
|
||||
|
||||
class RelationshipType(str, enum.Enum):
|
||||
"""Types of links between technique pages."""
|
||||
same_technique_other_creator = "same_technique_other_creator"
|
||||
|
|
@ -197,11 +181,6 @@ class KeyMoment(Base):
|
|||
nullable=False,
|
||||
)
|
||||
plugins: Mapped[list[str] | None] = mapped_column(ARRAY(String), nullable=True)
|
||||
review_status: Mapped[ReviewStatus] = mapped_column(
|
||||
Enum(ReviewStatus, name="review_status", create_constraint=True),
|
||||
default=ReviewStatus.pending,
|
||||
server_default="pending",
|
||||
)
|
||||
raw_transcript: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
default=_now, server_default=func.now()
|
||||
|
|
@ -237,11 +216,6 @@ class TechniquePage(Base):
|
|||
nullable=True,
|
||||
)
|
||||
view_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
review_status: Mapped[PageReviewStatus] = mapped_column(
|
||||
Enum(PageReviewStatus, name="page_review_status", create_constraint=True),
|
||||
default=PageReviewStatus.draft,
|
||||
server_default="draft",
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
default=_now, server_default=func.now()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -687,7 +687,7 @@ def stage5_synthesis(self, video_id: str) -> str:
|
|||
each group into a TechniquePage, creates/updates page rows, and links
|
||||
KeyMoments to their TechniquePage.
|
||||
|
||||
Sets processing_status to 'reviewed' (or 'published' if review_mode is False).
|
||||
Sets processing_status to 'published'.
|
||||
|
||||
Returns the video_id for chain compatibility.
|
||||
"""
|
||||
|
|
@ -867,10 +867,7 @@ def stage5_synthesis(self, video_id: str) -> str:
|
|||
m.technique_page_id = page.id
|
||||
|
||||
# Update processing_status
|
||||
if settings.review_mode:
|
||||
video.processing_status = ProcessingStatus.reviewed
|
||||
else:
|
||||
video.processing_status = ProcessingStatus.published
|
||||
video.processing_status = ProcessingStatus.published
|
||||
|
||||
session.commit()
|
||||
elapsed = time.monotonic() - start
|
||||
|
|
@ -1085,7 +1082,7 @@ def run_pipeline(video_id: str) -> str:
|
|||
stages that still need to run. For example:
|
||||
- pending/transcribed → stages 2, 3, 4, 5
|
||||
- extracted → stages 4, 5
|
||||
- reviewed/published → no-op
|
||||
- published → no-op
|
||||
|
||||
Returns the video_id.
|
||||
"""
|
||||
|
|
@ -1127,7 +1124,7 @@ def run_pipeline(video_id: str) -> str:
|
|||
stage5_synthesis.s(),
|
||||
stage6_embed_and_index.s(),
|
||||
]
|
||||
elif status in (ProcessingStatus.reviewed, ProcessingStatus.published):
|
||||
elif status == ProcessingStatus.published:
|
||||
logger.info(
|
||||
"run_pipeline: video_id=%s already at status=%s, nothing to do.",
|
||||
video_id, status.value,
|
||||
|
|
|
|||
|
|
@ -1,375 +0,0 @@
|
|||
"""Review queue endpoints for Chrysopedia API.
|
||||
|
||||
Provides admin review workflow: list queue, stats, approve, reject,
|
||||
edit, split, merge key moments, and toggle review/auto mode via Redis.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import case, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import get_settings
|
||||
from database import get_session
|
||||
from models import Creator, KeyMoment, KeyMomentContentType, ReviewStatus, SourceVideo
|
||||
from redis_client import get_redis
|
||||
from schemas import (
|
||||
KeyMomentRead,
|
||||
MomentEditRequest,
|
||||
MomentMergeRequest,
|
||||
MomentSplitRequest,
|
||||
ReviewModeResponse,
|
||||
ReviewModeUpdate,
|
||||
ReviewQueueItem,
|
||||
ReviewQueueResponse,
|
||||
ReviewStatsResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("chrysopedia.review")
|
||||
|
||||
router = APIRouter(prefix="/review", tags=["review"])
|
||||
|
||||
REDIS_MODE_KEY = "chrysopedia:review_mode"
|
||||
|
||||
VALID_STATUSES = {"pending", "approved", "edited", "rejected", "all"}
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _moment_to_queue_item(
|
||||
moment: KeyMoment, video_filename: str, creator_name: str
|
||||
) -> ReviewQueueItem:
|
||||
"""Convert a KeyMoment ORM instance + joined fields to a ReviewQueueItem."""
|
||||
data = KeyMomentRead.model_validate(moment).model_dump()
|
||||
data["video_filename"] = video_filename
|
||||
data["creator_name"] = creator_name
|
||||
return ReviewQueueItem(**data)
|
||||
|
||||
|
||||
# ── Endpoints ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/queue", response_model=ReviewQueueResponse)
|
||||
async def list_queue(
|
||||
status: Annotated[str, Query()] = "pending",
|
||||
offset: Annotated[int, Query(ge=0)] = 0,
|
||||
limit: Annotated[int, Query(ge=1, le=1000)] = 50,
|
||||
db: AsyncSession = Depends(get_session),
|
||||
) -> ReviewQueueResponse:
|
||||
"""List key moments in the review queue, filtered by status."""
|
||||
if status not in VALID_STATUSES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid status filter '{status}'. Must be one of: {', '.join(sorted(VALID_STATUSES))}",
|
||||
)
|
||||
|
||||
# Base query joining KeyMoment → SourceVideo → Creator
|
||||
base = (
|
||||
select(
|
||||
KeyMoment,
|
||||
SourceVideo.filename.label("video_filename"),
|
||||
Creator.name.label("creator_name"),
|
||||
)
|
||||
.join(SourceVideo, KeyMoment.source_video_id == SourceVideo.id)
|
||||
.join(Creator, SourceVideo.creator_id == Creator.id)
|
||||
)
|
||||
|
||||
if status != "all":
|
||||
base = base.where(KeyMoment.review_status == ReviewStatus(status))
|
||||
|
||||
# Count total matching rows
|
||||
count_stmt = select(func.count()).select_from(base.subquery())
|
||||
total = (await db.execute(count_stmt)).scalar_one()
|
||||
|
||||
# Fetch paginated results
|
||||
stmt = base.order_by(KeyMoment.created_at.desc()).offset(offset).limit(limit)
|
||||
rows = (await db.execute(stmt)).all()
|
||||
|
||||
items = [
|
||||
_moment_to_queue_item(row.KeyMoment, row.video_filename, row.creator_name)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
return ReviewQueueResponse(items=items, total=total, offset=offset, limit=limit)
|
||||
|
||||
|
||||
@router.get("/stats", response_model=ReviewStatsResponse)
|
||||
async def get_stats(
|
||||
db: AsyncSession = Depends(get_session),
|
||||
) -> ReviewStatsResponse:
|
||||
"""Return counts of key moments grouped by review status."""
|
||||
stmt = (
|
||||
select(
|
||||
KeyMoment.review_status,
|
||||
func.count().label("cnt"),
|
||||
)
|
||||
.group_by(KeyMoment.review_status)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
counts = {row.review_status.value: row.cnt for row in result.all()}
|
||||
|
||||
return ReviewStatsResponse(
|
||||
pending=counts.get("pending", 0),
|
||||
approved=counts.get("approved", 0),
|
||||
edited=counts.get("edited", 0),
|
||||
rejected=counts.get("rejected", 0),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/moments/{moment_id}/approve", response_model=KeyMomentRead)
|
||||
async def approve_moment(
|
||||
moment_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_session),
|
||||
) -> KeyMomentRead:
|
||||
"""Approve a key moment for publishing."""
|
||||
moment = await db.get(KeyMoment, moment_id)
|
||||
if moment is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Key moment {moment_id} not found",
|
||||
)
|
||||
|
||||
moment.review_status = ReviewStatus.approved
|
||||
await db.commit()
|
||||
await db.refresh(moment)
|
||||
|
||||
logger.info("Approved key moment %s", moment_id)
|
||||
return KeyMomentRead.model_validate(moment)
|
||||
|
||||
|
||||
@router.post("/moments/{moment_id}/reject", response_model=KeyMomentRead)
|
||||
async def reject_moment(
|
||||
moment_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_session),
|
||||
) -> KeyMomentRead:
|
||||
"""Reject a key moment."""
|
||||
moment = await db.get(KeyMoment, moment_id)
|
||||
if moment is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Key moment {moment_id} not found",
|
||||
)
|
||||
|
||||
moment.review_status = ReviewStatus.rejected
|
||||
await db.commit()
|
||||
await db.refresh(moment)
|
||||
|
||||
logger.info("Rejected key moment %s", moment_id)
|
||||
return KeyMomentRead.model_validate(moment)
|
||||
|
||||
|
||||
@router.put("/moments/{moment_id}", response_model=KeyMomentRead)
|
||||
async def edit_moment(
|
||||
moment_id: uuid.UUID,
|
||||
body: MomentEditRequest,
|
||||
db: AsyncSession = Depends(get_session),
|
||||
) -> KeyMomentRead:
|
||||
"""Update editable fields of a key moment and set status to edited."""
|
||||
moment = await db.get(KeyMoment, moment_id)
|
||||
if moment is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Key moment {moment_id} not found",
|
||||
)
|
||||
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
# Convert content_type string to enum if provided
|
||||
if "content_type" in update_data and update_data["content_type"] is not None:
|
||||
try:
|
||||
update_data["content_type"] = KeyMomentContentType(update_data["content_type"])
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid content_type '{update_data['content_type']}'",
|
||||
)
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(moment, field, value)
|
||||
|
||||
moment.review_status = ReviewStatus.edited
|
||||
await db.commit()
|
||||
await db.refresh(moment)
|
||||
|
||||
logger.info("Edited key moment %s (fields: %s)", moment_id, list(update_data.keys()))
|
||||
return KeyMomentRead.model_validate(moment)
|
||||
|
||||
|
||||
@router.post("/moments/{moment_id}/split", response_model=list[KeyMomentRead])
|
||||
async def split_moment(
|
||||
moment_id: uuid.UUID,
|
||||
body: MomentSplitRequest,
|
||||
db: AsyncSession = Depends(get_session),
|
||||
) -> list[KeyMomentRead]:
|
||||
"""Split a key moment into two at the given timestamp."""
|
||||
moment = await db.get(KeyMoment, moment_id)
|
||||
if moment is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Key moment {moment_id} not found",
|
||||
)
|
||||
|
||||
# Validate split_time is strictly between start_time and end_time
|
||||
if body.split_time <= moment.start_time or body.split_time >= moment.end_time:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"split_time ({body.split_time}) must be strictly between "
|
||||
f"start_time ({moment.start_time}) and end_time ({moment.end_time})"
|
||||
),
|
||||
)
|
||||
|
||||
# Update original moment to [start_time, split_time)
|
||||
original_end = moment.end_time
|
||||
moment.end_time = body.split_time
|
||||
moment.review_status = ReviewStatus.pending
|
||||
|
||||
# Create new moment for [split_time, end_time]
|
||||
new_moment = KeyMoment(
|
||||
source_video_id=moment.source_video_id,
|
||||
technique_page_id=moment.technique_page_id,
|
||||
title=f"{moment.title} (split)",
|
||||
summary=moment.summary,
|
||||
start_time=body.split_time,
|
||||
end_time=original_end,
|
||||
content_type=moment.content_type,
|
||||
plugins=moment.plugins,
|
||||
review_status=ReviewStatus.pending,
|
||||
raw_transcript=moment.raw_transcript,
|
||||
)
|
||||
db.add(new_moment)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(moment)
|
||||
await db.refresh(new_moment)
|
||||
|
||||
logger.info(
|
||||
"Split key moment %s at %.2f → original [%.2f, %.2f), new [%.2f, %.2f]",
|
||||
moment_id, body.split_time,
|
||||
moment.start_time, moment.end_time,
|
||||
new_moment.start_time, new_moment.end_time,
|
||||
)
|
||||
|
||||
return [
|
||||
KeyMomentRead.model_validate(moment),
|
||||
KeyMomentRead.model_validate(new_moment),
|
||||
]
|
||||
|
||||
|
||||
@router.post("/moments/{moment_id}/merge", response_model=KeyMomentRead)
|
||||
async def merge_moments(
|
||||
moment_id: uuid.UUID,
|
||||
body: MomentMergeRequest,
|
||||
db: AsyncSession = Depends(get_session),
|
||||
) -> KeyMomentRead:
|
||||
"""Merge two key moments into one."""
|
||||
if moment_id == body.target_moment_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot merge a moment with itself",
|
||||
)
|
||||
|
||||
source = await db.get(KeyMoment, moment_id)
|
||||
if source is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Key moment {moment_id} not found",
|
||||
)
|
||||
|
||||
target = await db.get(KeyMoment, body.target_moment_id)
|
||||
if target is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Target key moment {body.target_moment_id} not found",
|
||||
)
|
||||
|
||||
# Both must belong to the same source video
|
||||
if source.source_video_id != target.source_video_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot merge moments from different source videos",
|
||||
)
|
||||
|
||||
# Merge: combined summary, min start, max end
|
||||
source.summary = f"{source.summary}\n\n{target.summary}"
|
||||
source.start_time = min(source.start_time, target.start_time)
|
||||
source.end_time = max(source.end_time, target.end_time)
|
||||
source.review_status = ReviewStatus.pending
|
||||
|
||||
# Delete target
|
||||
await db.delete(target)
|
||||
await db.commit()
|
||||
await db.refresh(source)
|
||||
|
||||
logger.info(
|
||||
"Merged key moment %s with %s → [%.2f, %.2f]",
|
||||
moment_id, body.target_moment_id,
|
||||
source.start_time, source.end_time,
|
||||
)
|
||||
|
||||
return KeyMomentRead.model_validate(source)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/moments/{moment_id}", response_model=ReviewQueueItem)
|
||||
async def get_moment(
|
||||
moment_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_session),
|
||||
) -> ReviewQueueItem:
|
||||
"""Get a single key moment by ID with video and creator info."""
|
||||
stmt = (
|
||||
select(KeyMoment, SourceVideo.file_path, Creator.name)
|
||||
.join(SourceVideo, KeyMoment.source_video_id == SourceVideo.id)
|
||||
.join(Creator, SourceVideo.creator_id == Creator.id)
|
||||
.where(KeyMoment.id == moment_id)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
row = result.one_or_none()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail=f"Moment {moment_id} not found")
|
||||
moment, file_path, creator_name = row
|
||||
return _moment_to_queue_item(moment, file_path or "", creator_name)
|
||||
|
||||
@router.get("/mode", response_model=ReviewModeResponse)
|
||||
async def get_mode() -> ReviewModeResponse:
|
||||
"""Get the current review mode (review vs auto)."""
|
||||
settings = get_settings()
|
||||
try:
|
||||
redis = await get_redis()
|
||||
try:
|
||||
value = await redis.get(REDIS_MODE_KEY)
|
||||
if value is not None:
|
||||
return ReviewModeResponse(review_mode=value.lower() == "true")
|
||||
finally:
|
||||
await redis.aclose()
|
||||
except Exception as exc:
|
||||
# Redis unavailable — fall back to config default
|
||||
logger.warning("Redis unavailable for mode read, using config default: %s", exc)
|
||||
|
||||
return ReviewModeResponse(review_mode=settings.review_mode)
|
||||
|
||||
|
||||
@router.put("/mode", response_model=ReviewModeResponse)
|
||||
async def set_mode(
|
||||
body: ReviewModeUpdate,
|
||||
) -> ReviewModeResponse:
|
||||
"""Set the review mode (review vs auto)."""
|
||||
try:
|
||||
redis = await get_redis()
|
||||
try:
|
||||
await redis.set(REDIS_MODE_KEY, str(body.review_mode))
|
||||
finally:
|
||||
await redis.aclose()
|
||||
except Exception as exc:
|
||||
logger.error("Failed to set review mode in Redis: %s", exc)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"Redis unavailable: {exc}",
|
||||
)
|
||||
|
||||
logger.info("Review mode set to %s", body.review_mode)
|
||||
return ReviewModeResponse(review_mode=body.review_mode)
|
||||
|
|
@ -109,7 +109,6 @@ class KeyMomentRead(KeyMomentBase):
|
|||
id: uuid.UUID
|
||||
source_video_id: uuid.UUID
|
||||
technique_page_id: uuid.UUID | None = None
|
||||
review_status: str = "pending"
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
|
@ -139,7 +138,6 @@ class TechniquePageRead(TechniquePageBase):
|
|||
creator_slug: str = ""
|
||||
source_quality: str | None = None
|
||||
view_count: int = 0
|
||||
review_status: str = "draft"
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
|
@ -200,60 +198,6 @@ class PaginatedResponse(BaseModel):
|
|||
limit: int = 50
|
||||
|
||||
|
||||
# ── Review Queue ─────────────────────────────────────────────────────────────
|
||||
|
||||
class ReviewQueueItem(KeyMomentRead):
|
||||
"""Key moment enriched with source video and creator info for review UI."""
|
||||
video_filename: str
|
||||
creator_name: str
|
||||
|
||||
|
||||
class ReviewQueueResponse(BaseModel):
|
||||
"""Paginated response for the review queue."""
|
||||
items: list[ReviewQueueItem] = Field(default_factory=list)
|
||||
total: int = 0
|
||||
offset: int = 0
|
||||
limit: int = 50
|
||||
|
||||
|
||||
class ReviewStatsResponse(BaseModel):
|
||||
"""Counts of key moments grouped by review status."""
|
||||
pending: int = 0
|
||||
approved: int = 0
|
||||
edited: int = 0
|
||||
rejected: int = 0
|
||||
|
||||
|
||||
class MomentEditRequest(BaseModel):
|
||||
"""Editable fields for a key moment."""
|
||||
title: str | None = None
|
||||
summary: str | None = None
|
||||
start_time: float | None = None
|
||||
end_time: float | None = None
|
||||
content_type: str | None = None
|
||||
plugins: list[str] | None = None
|
||||
|
||||
|
||||
class MomentSplitRequest(BaseModel):
|
||||
"""Request to split a moment at a given timestamp."""
|
||||
split_time: float
|
||||
|
||||
|
||||
class MomentMergeRequest(BaseModel):
|
||||
"""Request to merge two moments."""
|
||||
target_moment_id: uuid.UUID
|
||||
|
||||
|
||||
class ReviewModeResponse(BaseModel):
|
||||
"""Current review mode state."""
|
||||
review_mode: bool
|
||||
|
||||
|
||||
class ReviewModeUpdate(BaseModel):
|
||||
"""Request to update the review mode."""
|
||||
review_mode: bool
|
||||
|
||||
|
||||
# ── Search ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class SearchResultItem(BaseModel):
|
||||
|
|
|
|||
|
|
@ -312,7 +312,6 @@ def test_stage4_classification_assigns_tags(
|
|||
s.llm_fallback_url = "http://mock:11434/v1"
|
||||
s.llm_fallback_model = "test-model"
|
||||
s.database_url = TEST_DATABASE_URL_SYNC.replace("psycopg2", "asyncpg")
|
||||
s.review_mode = True
|
||||
mock_settings.return_value = s
|
||||
|
||||
mock_tags.return_value = {
|
||||
|
|
@ -391,7 +390,6 @@ def test_stage5_synthesis_creates_technique_pages(
|
|||
s.llm_fallback_url = "http://mock:11434/v1"
|
||||
s.llm_fallback_model = "test-model"
|
||||
s.database_url = TEST_DATABASE_URL_SYNC.replace("psycopg2", "asyncpg")
|
||||
s.review_mode = True
|
||||
mock_settings.return_value = s
|
||||
|
||||
mock_tags.return_value = {
|
||||
|
|
@ -461,7 +459,7 @@ def test_stage5_synthesis_creates_technique_pages(
|
|||
video = session.execute(
|
||||
select(SourceVideo).where(SourceVideo.id == video_id)
|
||||
).scalar_one()
|
||||
assert video.processing_status == ProcessingStatus.reviewed
|
||||
assert video.processing_status == ProcessingStatus.published
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
|
@ -521,7 +519,6 @@ def test_stage6_embeds_and_upserts_to_qdrant(
|
|||
s.llm_fallback_url = "http://mock:11434/v1"
|
||||
s.llm_fallback_model = "test-model"
|
||||
s.database_url = TEST_DATABASE_URL_SYNC.replace("psycopg2", "asyncpg")
|
||||
s.review_mode = True
|
||||
s.embedding_api_url = "http://mock:11434/v1"
|
||||
s.embedding_model = "test-embed"
|
||||
s.embedding_dimensions = 768
|
||||
|
|
|
|||
|
|
@ -1,495 +0,0 @@
|
|||
"""Integration tests for the review queue endpoints.
|
||||
|
||||
Tests run against a real PostgreSQL test database via httpx.AsyncClient.
|
||||
Redis is mocked for mode toggle tests.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from models import (
|
||||
ContentType,
|
||||
Creator,
|
||||
KeyMoment,
|
||||
KeyMomentContentType,
|
||||
ProcessingStatus,
|
||||
ReviewStatus,
|
||||
SourceVideo,
|
||||
)
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
QUEUE_URL = "/api/v1/review/queue"
|
||||
STATS_URL = "/api/v1/review/stats"
|
||||
MODE_URL = "/api/v1/review/mode"
|
||||
|
||||
|
||||
def _moment_url(moment_id: str, action: str = "") -> str:
|
||||
"""Build a moment action URL."""
|
||||
base = f"/api/v1/review/moments/{moment_id}"
|
||||
return f"{base}/{action}" if action else base
|
||||
|
||||
|
||||
async def _seed_creator_and_video(db_engine) -> dict:
|
||||
"""Seed a creator and source video, return their IDs."""
|
||||
session_factory = async_sessionmaker(
|
||||
db_engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
async with session_factory() as session:
|
||||
creator = Creator(
|
||||
name="TestCreator",
|
||||
slug="test-creator",
|
||||
folder_name="TestCreator",
|
||||
)
|
||||
session.add(creator)
|
||||
await session.flush()
|
||||
|
||||
video = SourceVideo(
|
||||
creator_id=creator.id,
|
||||
filename="test-video.mp4",
|
||||
file_path="TestCreator/test-video.mp4",
|
||||
duration_seconds=600,
|
||||
content_type=ContentType.tutorial,
|
||||
processing_status=ProcessingStatus.extracted,
|
||||
)
|
||||
session.add(video)
|
||||
await session.flush()
|
||||
|
||||
result = {
|
||||
"creator_id": creator.id,
|
||||
"creator_name": creator.name,
|
||||
"video_id": video.id,
|
||||
"video_filename": video.filename,
|
||||
}
|
||||
await session.commit()
|
||||
return result
|
||||
|
||||
|
||||
async def _seed_moment(
|
||||
db_engine,
|
||||
video_id: uuid.UUID,
|
||||
title: str = "Test Moment",
|
||||
summary: str = "A test key moment",
|
||||
start_time: float = 10.0,
|
||||
end_time: float = 30.0,
|
||||
review_status: ReviewStatus = ReviewStatus.pending,
|
||||
) -> uuid.UUID:
|
||||
"""Seed a single key moment and return its ID."""
|
||||
session_factory = async_sessionmaker(
|
||||
db_engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
async with session_factory() as session:
|
||||
moment = KeyMoment(
|
||||
source_video_id=video_id,
|
||||
title=title,
|
||||
summary=summary,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
content_type=KeyMomentContentType.technique,
|
||||
review_status=review_status,
|
||||
)
|
||||
session.add(moment)
|
||||
await session.commit()
|
||||
return moment.id
|
||||
|
||||
|
||||
async def _seed_second_video(db_engine, creator_id: uuid.UUID) -> uuid.UUID:
|
||||
"""Seed a second video for cross-video merge tests."""
|
||||
session_factory = async_sessionmaker(
|
||||
db_engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
async with session_factory() as session:
|
||||
video = SourceVideo(
|
||||
creator_id=creator_id,
|
||||
filename="other-video.mp4",
|
||||
file_path="TestCreator/other-video.mp4",
|
||||
duration_seconds=300,
|
||||
content_type=ContentType.tutorial,
|
||||
processing_status=ProcessingStatus.extracted,
|
||||
)
|
||||
session.add(video)
|
||||
await session.commit()
|
||||
return video.id
|
||||
|
||||
|
||||
# ── Queue listing tests ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_queue_empty(client: AsyncClient):
|
||||
"""Queue returns empty list when no moments exist."""
|
||||
resp = await client.get(QUEUE_URL)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["items"] == []
|
||||
assert data["total"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_queue_with_moments(client: AsyncClient, db_engine):
|
||||
"""Queue returns moments enriched with video filename and creator name."""
|
||||
seed = await _seed_creator_and_video(db_engine)
|
||||
await _seed_moment(db_engine, seed["video_id"], title="EQ Basics")
|
||||
|
||||
resp = await client.get(QUEUE_URL)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
item = data["items"][0]
|
||||
assert item["title"] == "EQ Basics"
|
||||
assert item["video_filename"] == seed["video_filename"]
|
||||
assert item["creator_name"] == seed["creator_name"]
|
||||
assert item["review_status"] == "pending"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_queue_filter_by_status(client: AsyncClient, db_engine):
|
||||
"""Queue filters correctly by status query parameter."""
|
||||
seed = await _seed_creator_and_video(db_engine)
|
||||
await _seed_moment(db_engine, seed["video_id"], title="Pending One")
|
||||
await _seed_moment(
|
||||
db_engine, seed["video_id"], title="Approved One",
|
||||
review_status=ReviewStatus.approved,
|
||||
)
|
||||
await _seed_moment(
|
||||
db_engine, seed["video_id"], title="Rejected One",
|
||||
review_status=ReviewStatus.rejected,
|
||||
)
|
||||
|
||||
# Default filter: pending
|
||||
resp = await client.get(QUEUE_URL)
|
||||
assert resp.json()["total"] == 1
|
||||
assert resp.json()["items"][0]["title"] == "Pending One"
|
||||
|
||||
# Approved
|
||||
resp = await client.get(QUEUE_URL, params={"status": "approved"})
|
||||
assert resp.json()["total"] == 1
|
||||
assert resp.json()["items"][0]["title"] == "Approved One"
|
||||
|
||||
# All
|
||||
resp = await client.get(QUEUE_URL, params={"status": "all"})
|
||||
assert resp.json()["total"] == 3
|
||||
|
||||
|
||||
# ── Stats tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stats_counts(client: AsyncClient, db_engine):
|
||||
"""Stats returns correct counts per review status."""
|
||||
seed = await _seed_creator_and_video(db_engine)
|
||||
await _seed_moment(db_engine, seed["video_id"], review_status=ReviewStatus.pending)
|
||||
await _seed_moment(db_engine, seed["video_id"], review_status=ReviewStatus.pending)
|
||||
await _seed_moment(db_engine, seed["video_id"], review_status=ReviewStatus.approved)
|
||||
await _seed_moment(db_engine, seed["video_id"], review_status=ReviewStatus.rejected)
|
||||
|
||||
resp = await client.get(STATS_URL)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["pending"] == 2
|
||||
assert data["approved"] == 1
|
||||
assert data["edited"] == 0
|
||||
assert data["rejected"] == 1
|
||||
|
||||
|
||||
# ── Approve tests ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_moment(client: AsyncClient, db_engine):
|
||||
"""Approve sets review_status to approved."""
|
||||
seed = await _seed_creator_and_video(db_engine)
|
||||
moment_id = await _seed_moment(db_engine, seed["video_id"])
|
||||
|
||||
resp = await client.post(_moment_url(str(moment_id), "approve"))
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["review_status"] == "approved"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_nonexistent_moment(client: AsyncClient):
|
||||
"""Approve returns 404 for nonexistent moment."""
|
||||
fake_id = str(uuid.uuid4())
|
||||
resp = await client.post(_moment_url(fake_id, "approve"))
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ── Reject tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reject_moment(client: AsyncClient, db_engine):
|
||||
"""Reject sets review_status to rejected."""
|
||||
seed = await _seed_creator_and_video(db_engine)
|
||||
moment_id = await _seed_moment(db_engine, seed["video_id"])
|
||||
|
||||
resp = await client.post(_moment_url(str(moment_id), "reject"))
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["review_status"] == "rejected"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reject_nonexistent_moment(client: AsyncClient):
|
||||
"""Reject returns 404 for nonexistent moment."""
|
||||
fake_id = str(uuid.uuid4())
|
||||
resp = await client.post(_moment_url(fake_id, "reject"))
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ── Edit tests ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_moment(client: AsyncClient, db_engine):
|
||||
"""Edit updates fields and sets review_status to edited."""
|
||||
seed = await _seed_creator_and_video(db_engine)
|
||||
moment_id = await _seed_moment(db_engine, seed["video_id"], title="Original Title")
|
||||
|
||||
resp = await client.put(
|
||||
_moment_url(str(moment_id)),
|
||||
json={"title": "Updated Title", "summary": "New summary"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["title"] == "Updated Title"
|
||||
assert data["summary"] == "New summary"
|
||||
assert data["review_status"] == "edited"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_nonexistent_moment(client: AsyncClient):
|
||||
"""Edit returns 404 for nonexistent moment."""
|
||||
fake_id = str(uuid.uuid4())
|
||||
resp = await client.put(
|
||||
_moment_url(fake_id),
|
||||
json={"title": "Won't Work"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ── Split tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_split_moment(client: AsyncClient, db_engine):
|
||||
"""Split creates two moments with correct timestamps."""
|
||||
seed = await _seed_creator_and_video(db_engine)
|
||||
moment_id = await _seed_moment(
|
||||
db_engine, seed["video_id"],
|
||||
title="Full Moment", start_time=10.0, end_time=30.0,
|
||||
)
|
||||
|
||||
resp = await client.post(
|
||||
_moment_url(str(moment_id), "split"),
|
||||
json={"split_time": 20.0},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) == 2
|
||||
|
||||
# First (original): [10.0, 20.0)
|
||||
assert data[0]["start_time"] == 10.0
|
||||
assert data[0]["end_time"] == 20.0
|
||||
|
||||
# Second (new): [20.0, 30.0]
|
||||
assert data[1]["start_time"] == 20.0
|
||||
assert data[1]["end_time"] == 30.0
|
||||
assert "(split)" in data[1]["title"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_split_invalid_time_below_start(client: AsyncClient, db_engine):
|
||||
"""Split returns 400 when split_time is at or below start_time."""
|
||||
seed = await _seed_creator_and_video(db_engine)
|
||||
moment_id = await _seed_moment(
|
||||
db_engine, seed["video_id"], start_time=10.0, end_time=30.0,
|
||||
)
|
||||
|
||||
resp = await client.post(
|
||||
_moment_url(str(moment_id), "split"),
|
||||
json={"split_time": 10.0},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_split_invalid_time_above_end(client: AsyncClient, db_engine):
|
||||
"""Split returns 400 when split_time is at or above end_time."""
|
||||
seed = await _seed_creator_and_video(db_engine)
|
||||
moment_id = await _seed_moment(
|
||||
db_engine, seed["video_id"], start_time=10.0, end_time=30.0,
|
||||
)
|
||||
|
||||
resp = await client.post(
|
||||
_moment_url(str(moment_id), "split"),
|
||||
json={"split_time": 30.0},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_split_nonexistent_moment(client: AsyncClient):
|
||||
"""Split returns 404 for nonexistent moment."""
|
||||
fake_id = str(uuid.uuid4())
|
||||
resp = await client.post(
|
||||
_moment_url(fake_id, "split"),
|
||||
json={"split_time": 20.0},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ── Merge tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_moments(client: AsyncClient, db_engine):
|
||||
"""Merge combines two moments: combined summary, min start, max end, target deleted."""
|
||||
seed = await _seed_creator_and_video(db_engine)
|
||||
m1_id = await _seed_moment(
|
||||
db_engine, seed["video_id"],
|
||||
title="First", summary="Summary A",
|
||||
start_time=10.0, end_time=20.0,
|
||||
)
|
||||
m2_id = await _seed_moment(
|
||||
db_engine, seed["video_id"],
|
||||
title="Second", summary="Summary B",
|
||||
start_time=25.0, end_time=35.0,
|
||||
)
|
||||
|
||||
resp = await client.post(
|
||||
_moment_url(str(m1_id), "merge"),
|
||||
json={"target_moment_id": str(m2_id)},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["start_time"] == 10.0
|
||||
assert data["end_time"] == 35.0
|
||||
assert "Summary A" in data["summary"]
|
||||
assert "Summary B" in data["summary"]
|
||||
|
||||
# Target should be deleted — reject should 404
|
||||
resp2 = await client.post(_moment_url(str(m2_id), "reject"))
|
||||
assert resp2.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_different_videos(client: AsyncClient, db_engine):
|
||||
"""Merge returns 400 when moments are from different source videos."""
|
||||
seed = await _seed_creator_and_video(db_engine)
|
||||
m1_id = await _seed_moment(db_engine, seed["video_id"], title="Video 1 moment")
|
||||
|
||||
other_video_id = await _seed_second_video(db_engine, seed["creator_id"])
|
||||
m2_id = await _seed_moment(db_engine, other_video_id, title="Video 2 moment")
|
||||
|
||||
resp = await client.post(
|
||||
_moment_url(str(m1_id), "merge"),
|
||||
json={"target_moment_id": str(m2_id)},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "different source videos" in resp.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_with_self(client: AsyncClient, db_engine):
|
||||
"""Merge returns 400 when trying to merge a moment with itself."""
|
||||
seed = await _seed_creator_and_video(db_engine)
|
||||
m_id = await _seed_moment(db_engine, seed["video_id"])
|
||||
|
||||
resp = await client.post(
|
||||
_moment_url(str(m_id), "merge"),
|
||||
json={"target_moment_id": str(m_id)},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "itself" in resp.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_nonexistent_target(client: AsyncClient, db_engine):
|
||||
"""Merge returns 404 when target moment does not exist."""
|
||||
seed = await _seed_creator_and_video(db_engine)
|
||||
m_id = await _seed_moment(db_engine, seed["video_id"])
|
||||
|
||||
resp = await client.post(
|
||||
_moment_url(str(m_id), "merge"),
|
||||
json={"target_moment_id": str(uuid.uuid4())},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_nonexistent_source(client: AsyncClient):
|
||||
"""Merge returns 404 when source moment does not exist."""
|
||||
fake_id = str(uuid.uuid4())
|
||||
resp = await client.post(
|
||||
_moment_url(fake_id, "merge"),
|
||||
json={"target_moment_id": str(uuid.uuid4())},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ── Mode toggle tests ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_mode_default(client: AsyncClient):
|
||||
"""Get mode returns config default when Redis has no value."""
|
||||
mock_redis = AsyncMock()
|
||||
mock_redis.get = AsyncMock(return_value=None)
|
||||
mock_redis.aclose = AsyncMock()
|
||||
|
||||
with patch("routers.review.get_redis", return_value=mock_redis):
|
||||
resp = await client.get(MODE_URL)
|
||||
assert resp.status_code == 200
|
||||
# Default from config is True
|
||||
assert resp.json()["review_mode"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_mode(client: AsyncClient):
|
||||
"""Set mode writes to Redis and returns the new value."""
|
||||
mock_redis = AsyncMock()
|
||||
mock_redis.set = AsyncMock()
|
||||
mock_redis.aclose = AsyncMock()
|
||||
|
||||
with patch("routers.review.get_redis", return_value=mock_redis):
|
||||
resp = await client.put(MODE_URL, json={"review_mode": False})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["review_mode"] is False
|
||||
mock_redis.set.assert_called_once_with("chrysopedia:review_mode", "False")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_mode_from_redis(client: AsyncClient):
|
||||
"""Get mode reads the value stored in Redis."""
|
||||
mock_redis = AsyncMock()
|
||||
mock_redis.get = AsyncMock(return_value="False")
|
||||
mock_redis.aclose = AsyncMock()
|
||||
|
||||
with patch("routers.review.get_redis", return_value=mock_redis):
|
||||
resp = await client.get(MODE_URL)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["review_mode"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_mode_redis_error_fallback(client: AsyncClient):
|
||||
"""Get mode falls back to config default when Redis is unavailable."""
|
||||
with patch("routers.review.get_redis", side_effect=ConnectionError("Redis down")):
|
||||
resp = await client.get(MODE_URL)
|
||||
assert resp.status_code == 200
|
||||
# Falls back to config default (True)
|
||||
assert resp.json()["review_mode"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_mode_redis_error(client: AsyncClient):
|
||||
"""Set mode returns 503 when Redis is unavailable."""
|
||||
with patch("routers.review.get_redis", side_effect=ConnectionError("Redis down")):
|
||||
resp = await client.put(MODE_URL, json={"review_mode": False})
|
||||
assert resp.status_code == 503
|
||||
|
|
@ -50,9 +50,7 @@
|
|||
--color-btn-reject: #dc2626;
|
||||
--color-btn-reject-hover: #b91c1c;
|
||||
|
||||
/* Mode toggle (green/amber work on dark) */
|
||||
--color-toggle-review: #10b981;
|
||||
--color-toggle-auto: #f59e0b;
|
||||
/* Toggle colors */
|
||||
--color-toggle-track: #6b7280;
|
||||
--color-toggle-track-active: #059669;
|
||||
--color-toggle-thumb: #fff;
|
||||
|
|
@ -478,73 +476,6 @@ a.app-footer__repo:hover {
|
|||
|
||||
/* ── Mode toggle ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.mode-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.mode-toggle__dot {
|
||||
display: inline-block;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.mode-toggle__dot--review {
|
||||
background: var(--color-toggle-review);
|
||||
}
|
||||
|
||||
.mode-toggle__dot--auto {
|
||||
background: var(--color-toggle-auto);
|
||||
}
|
||||
|
||||
.mode-toggle__label {
|
||||
color: var(--color-text-on-header-label);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 6rem;
|
||||
}
|
||||
|
||||
.mode-toggle__switch {
|
||||
position: relative;
|
||||
width: 2.5rem;
|
||||
height: 1.25rem;
|
||||
background: var(--color-toggle-track);
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mode-toggle__switch--active {
|
||||
background: var(--color-toggle-track-active);
|
||||
}
|
||||
|
||||
.mode-toggle__switch::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0.125rem;
|
||||
left: 0.125rem;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
background: var(--color-toggle-thumb);
|
||||
border-radius: 50%;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.mode-toggle__switch--active::after {
|
||||
transform: translateX(1.25rem);
|
||||
}
|
||||
|
||||
.mode-toggle__switch:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Debug Mode Toggle ────────────────────────────────────────────────────── */
|
||||
|
||||
.debug-toggle {
|
||||
|
|
@ -572,7 +503,7 @@ a.app-footer__repo:hover {
|
|||
}
|
||||
|
||||
.debug-toggle__switch--active {
|
||||
background: var(--color-toggle-review);
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.debug-toggle__switch::after {
|
||||
|
|
@ -2818,20 +2749,6 @@ a.app-footer__repo:hover {
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pipeline-video__review-link {
|
||||
color: var(--color-accent);
|
||||
font-size: 0.75rem;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.pipeline-video__review-link:hover {
|
||||
opacity: 1;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.pipeline-video__actions {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
|
|
|
|||
|
|
@ -5,8 +5,6 @@ import TechniquePage from "./pages/TechniquePage";
|
|||
import CreatorsBrowse from "./pages/CreatorsBrowse";
|
||||
import CreatorDetail from "./pages/CreatorDetail";
|
||||
import TopicsBrowse from "./pages/TopicsBrowse";
|
||||
import ReviewQueue from "./pages/ReviewQueue";
|
||||
import MomentDetail from "./pages/MomentDetail";
|
||||
import AdminReports from "./pages/AdminReports";
|
||||
import AdminPipeline from "./pages/AdminPipeline";
|
||||
import AdminDropdown from "./components/AdminDropdown";
|
||||
|
|
@ -42,8 +40,6 @@ export default function App() {
|
|||
<Route path="/topics" element={<TopicsBrowse />} />
|
||||
|
||||
{/* Admin routes */}
|
||||
<Route path="/admin/review" element={<ReviewQueue />} />
|
||||
<Route path="/admin/review/:momentId" element={<MomentDetail />} />
|
||||
<Route path="/admin/reports" element={<AdminReports />} />
|
||||
<Route path="/admin/pipeline" element={<AdminPipeline />} />
|
||||
|
||||
|
|
|
|||
|
|
@ -1,193 +0,0 @@
|
|||
/**
|
||||
* Typed API client for Chrysopedia review queue endpoints.
|
||||
*
|
||||
* All functions use fetch() with JSON handling and throw on non-OK responses.
|
||||
* Base URL is empty so requests go through the Vite dev proxy or nginx in prod.
|
||||
*/
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface KeyMomentRead {
|
||||
id: string;
|
||||
source_video_id: string;
|
||||
technique_page_id: string | null;
|
||||
title: string;
|
||||
summary: string;
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
content_type: string;
|
||||
plugins: string[] | null;
|
||||
raw_transcript: string | null;
|
||||
review_status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ReviewQueueItem extends KeyMomentRead {
|
||||
video_filename: string;
|
||||
creator_name: string;
|
||||
}
|
||||
|
||||
export interface ReviewQueueResponse {
|
||||
items: ReviewQueueItem[];
|
||||
total: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface ReviewStatsResponse {
|
||||
pending: number;
|
||||
approved: number;
|
||||
edited: number;
|
||||
rejected: number;
|
||||
}
|
||||
|
||||
export interface ReviewModeResponse {
|
||||
review_mode: boolean;
|
||||
}
|
||||
|
||||
export interface MomentEditRequest {
|
||||
title?: string;
|
||||
summary?: string;
|
||||
start_time?: number;
|
||||
end_time?: number;
|
||||
content_type?: string;
|
||||
plugins?: string[];
|
||||
}
|
||||
|
||||
export interface MomentSplitRequest {
|
||||
split_time: number;
|
||||
}
|
||||
|
||||
export interface MomentMergeRequest {
|
||||
target_moment_id: string;
|
||||
}
|
||||
|
||||
export interface QueueParams {
|
||||
status?: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const BASE = "/api/v1/review";
|
||||
|
||||
class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
public detail: string,
|
||||
) {
|
||||
super(`API ${status}: ${detail}`);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let detail = res.statusText;
|
||||
try {
|
||||
const body = await res.json();
|
||||
detail = body.detail ?? detail;
|
||||
} catch {
|
||||
// body not JSON — keep statusText
|
||||
}
|
||||
throw new ApiError(res.status, detail);
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
// ── Queue ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchQueue(
|
||||
params: QueueParams = {},
|
||||
): Promise<ReviewQueueResponse> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.status) qs.set("status", params.status);
|
||||
if (params.offset !== undefined) qs.set("offset", String(params.offset));
|
||||
if (params.limit !== undefined) qs.set("limit", String(params.limit));
|
||||
const query = qs.toString();
|
||||
return request<ReviewQueueResponse>(
|
||||
`${BASE}/queue${query ? `?${query}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchMoment(
|
||||
momentId: string,
|
||||
): Promise<ReviewQueueItem> {
|
||||
return request<ReviewQueueItem>(`${BASE}/moments/${momentId}`);
|
||||
}
|
||||
|
||||
export async function fetchStats(): Promise<ReviewStatsResponse> {
|
||||
return request<ReviewStatsResponse>(`${BASE}/stats`);
|
||||
}
|
||||
|
||||
// ── Actions ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function approveMoment(id: string): Promise<KeyMomentRead> {
|
||||
return request<KeyMomentRead>(`${BASE}/moments/${id}/approve`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export async function rejectMoment(id: string): Promise<KeyMomentRead> {
|
||||
return request<KeyMomentRead>(`${BASE}/moments/${id}/reject`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export async function editMoment(
|
||||
id: string,
|
||||
data: MomentEditRequest,
|
||||
): Promise<KeyMomentRead> {
|
||||
return request<KeyMomentRead>(`${BASE}/moments/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export async function splitMoment(
|
||||
id: string,
|
||||
splitTime: number,
|
||||
): Promise<KeyMomentRead[]> {
|
||||
const body: MomentSplitRequest = { split_time: splitTime };
|
||||
return request<KeyMomentRead[]>(`${BASE}/moments/${id}/split`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export async function mergeMoments(
|
||||
id: string,
|
||||
targetId: string,
|
||||
): Promise<KeyMomentRead> {
|
||||
const body: MomentMergeRequest = { target_moment_id: targetId };
|
||||
return request<KeyMomentRead>(`${BASE}/moments/${id}/merge`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Mode ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getReviewMode(): Promise<ReviewModeResponse> {
|
||||
return request<ReviewModeResponse>(`${BASE}/mode`);
|
||||
}
|
||||
|
||||
export async function setReviewMode(
|
||||
enabled: boolean,
|
||||
): Promise<ReviewModeResponse> {
|
||||
return request<ReviewModeResponse>(`${BASE}/mode`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ review_mode: enabled }),
|
||||
});
|
||||
}
|
||||
|
|
@ -63,7 +63,6 @@ export interface TechniquePageDetail {
|
|||
creator_id: string;
|
||||
source_quality: string | null;
|
||||
view_count: number;
|
||||
review_status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
key_moments: KeyMomentSummary[];
|
||||
|
|
@ -102,7 +101,6 @@ export interface TechniqueListItem {
|
|||
creator_slug: string;
|
||||
source_quality: string | null;
|
||||
view_count: number;
|
||||
review_status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,14 +42,6 @@ export default function AdminDropdown() {
|
|||
</button>
|
||||
{open && (
|
||||
<div className="admin-dropdown__menu" role="menu">
|
||||
<Link
|
||||
to="/admin/review"
|
||||
className="admin-dropdown__item"
|
||||
role="menuitem"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
Review
|
||||
</Link>
|
||||
<Link
|
||||
to="/admin/reports"
|
||||
className="admin-dropdown__item"
|
||||
|
|
|
|||
|
|
@ -1,59 +0,0 @@
|
|||
/**
|
||||
* Review / Auto mode toggle switch.
|
||||
*
|
||||
* Reads and writes mode via getReviewMode / setReviewMode API.
|
||||
* Green dot = review mode active; amber = auto mode.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { getReviewMode, setReviewMode } from "../api/client";
|
||||
|
||||
export default function ModeToggle() {
|
||||
const [reviewMode, setReviewModeState] = useState<boolean | null>(null);
|
||||
const [toggling, setToggling] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getReviewMode()
|
||||
.then((res) => {
|
||||
if (!cancelled) setReviewModeState(res.review_mode);
|
||||
})
|
||||
.catch(() => {
|
||||
// silently fail — mode indicator will just stay hidden
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
async function handleToggle() {
|
||||
if (reviewMode === null || toggling) return;
|
||||
setToggling(true);
|
||||
try {
|
||||
const res = await setReviewMode(!reviewMode);
|
||||
setReviewModeState(res.review_mode);
|
||||
} catch {
|
||||
// swallow — leave previous state
|
||||
} finally {
|
||||
setToggling(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (reviewMode === null) return null;
|
||||
|
||||
return (
|
||||
<div className="mode-toggle">
|
||||
<span
|
||||
className={`mode-toggle__dot ${reviewMode ? "mode-toggle__dot--review" : "mode-toggle__dot--auto"}`}
|
||||
/>
|
||||
<span className="mode-toggle__label">
|
||||
{reviewMode ? "Review Mode" : "Auto Mode"}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`mode-toggle__switch ${reviewMode ? "mode-toggle__switch--active" : ""}`}
|
||||
onClick={handleToggle}
|
||||
disabled={toggling}
|
||||
aria-label={`Switch to ${reviewMode ? "auto" : "review"} mode`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
/**
|
||||
* Reusable status badge with color coding.
|
||||
*
|
||||
* Maps review_status values to colored pill shapes:
|
||||
* pending → amber, approved → green, edited → blue, rejected → red
|
||||
*/
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: string;
|
||||
}
|
||||
|
||||
export default function StatusBadge({ status }: StatusBadgeProps) {
|
||||
const normalized = status.toLowerCase();
|
||||
return (
|
||||
<span className={`badge badge--${normalized}`}>
|
||||
{normalized}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -613,14 +613,6 @@ export default function AdminPipeline() {
|
|||
<span className="pipeline-video__time">
|
||||
{formatDate(video.last_event_at)}
|
||||
</span>
|
||||
<a
|
||||
className="pipeline-video__review-link"
|
||||
href="/admin/review"
|
||||
title={`Review moments from ${video.creator_name}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
→ Moments
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="pipeline-video__actions" onClick={(e) => e.stopPropagation()}>
|
||||
|
|
|
|||
|
|
@ -1,454 +0,0 @@
|
|||
/**
|
||||
* Moment review detail page.
|
||||
*
|
||||
* Displays full moment data with action buttons:
|
||||
* - Approve / Reject → navigate back to queue
|
||||
* - Edit → inline edit mode for title, summary, content_type
|
||||
* - Split → dialog with timestamp input
|
||||
* - Merge → dialog with moment selector
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useParams, useNavigate, Link } from "react-router-dom";
|
||||
import {
|
||||
fetchMoment,
|
||||
fetchQueue,
|
||||
approveMoment,
|
||||
rejectMoment,
|
||||
editMoment,
|
||||
splitMoment,
|
||||
mergeMoments,
|
||||
type ReviewQueueItem,
|
||||
} from "../api/client";
|
||||
import StatusBadge from "../components/StatusBadge";
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export default function MomentDetail() {
|
||||
const { momentId } = useParams<{ momentId: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// ── Data state ──
|
||||
const [moment, setMoment] = useState<ReviewQueueItem | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [acting, setActing] = useState(false);
|
||||
|
||||
// ── Edit state ──
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editTitle, setEditTitle] = useState("");
|
||||
const [editSummary, setEditSummary] = useState("");
|
||||
const [editContentType, setEditContentType] = useState("");
|
||||
|
||||
// ── Split state ──
|
||||
const [showSplit, setShowSplit] = useState(false);
|
||||
const [splitTime, setSplitTime] = useState("");
|
||||
|
||||
// ── Merge state ──
|
||||
const [showMerge, setShowMerge] = useState(false);
|
||||
const [mergeCandidates, setMergeCandidates] = useState<ReviewQueueItem[]>([]);
|
||||
const [mergeTargetId, setMergeTargetId] = useState("");
|
||||
|
||||
const loadMoment = useCallback(async () => {
|
||||
if (!momentId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
// Fetch all moments and find the one matching our ID
|
||||
const found = await fetchMoment(momentId);
|
||||
setMoment(found);
|
||||
setEditTitle(found.title);
|
||||
setEditSummary(found.summary);
|
||||
setEditContentType(found.content_type);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load moment");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [momentId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadMoment();
|
||||
}, [loadMoment]);
|
||||
|
||||
// ── Action handlers ──
|
||||
|
||||
async function handleApprove() {
|
||||
if (!momentId || acting) return;
|
||||
setActing(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
await approveMoment(momentId);
|
||||
navigate("/admin/review");
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : "Approve failed");
|
||||
} finally {
|
||||
setActing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject() {
|
||||
if (!momentId || acting) return;
|
||||
setActing(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
await rejectMoment(momentId);
|
||||
navigate("/admin/review");
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : "Reject failed");
|
||||
} finally {
|
||||
setActing(false);
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit() {
|
||||
if (!moment) return;
|
||||
setEditTitle(moment.title);
|
||||
setEditSummary(moment.summary);
|
||||
setEditContentType(moment.content_type);
|
||||
setEditing(true);
|
||||
setActionError(null);
|
||||
}
|
||||
|
||||
async function handleEditSave() {
|
||||
if (!momentId || acting) return;
|
||||
setActing(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
await editMoment(momentId, {
|
||||
title: editTitle,
|
||||
summary: editSummary,
|
||||
content_type: editContentType,
|
||||
});
|
||||
setEditing(false);
|
||||
await loadMoment();
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : "Edit failed");
|
||||
} finally {
|
||||
setActing(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openSplitDialog() {
|
||||
if (!moment) return;
|
||||
setSplitTime("");
|
||||
setShowSplit(true);
|
||||
setActionError(null);
|
||||
}
|
||||
|
||||
async function handleSplit() {
|
||||
if (!momentId || !moment || acting) return;
|
||||
const t = parseFloat(splitTime);
|
||||
if (isNaN(t) || t <= moment.start_time || t >= moment.end_time) {
|
||||
setActionError(
|
||||
`Split time must be between ${formatTime(moment.start_time)} and ${formatTime(moment.end_time)}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
setActing(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
await splitMoment(momentId, t);
|
||||
setShowSplit(false);
|
||||
navigate("/admin/review");
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : "Split failed");
|
||||
} finally {
|
||||
setActing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function openMergeDialog() {
|
||||
if (!moment) return;
|
||||
setShowMerge(true);
|
||||
setMergeTargetId("");
|
||||
setActionError(null);
|
||||
try {
|
||||
// Load moments from the same video for merge candidates
|
||||
const res = await fetchQueue({ limit: 100 });
|
||||
const candidates = res.items.filter(
|
||||
(m) => m.source_video_id === moment.source_video_id && m.id !== moment.id
|
||||
);
|
||||
setMergeCandidates(candidates);
|
||||
} catch {
|
||||
setMergeCandidates([]);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMerge() {
|
||||
if (!momentId || !mergeTargetId || acting) return;
|
||||
setActing(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
await mergeMoments(momentId, mergeTargetId);
|
||||
setShowMerge(false);
|
||||
navigate("/admin/review");
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : "Merge failed");
|
||||
} finally {
|
||||
setActing(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Render ──
|
||||
|
||||
if (loading) return <div className="loading">Loading…</div>;
|
||||
if (error)
|
||||
return (
|
||||
<div>
|
||||
<Link to="/admin/review" className="back-link">
|
||||
← Back to queue
|
||||
</Link>
|
||||
<div className="loading error-text">Error: {error}</div>
|
||||
</div>
|
||||
);
|
||||
if (!moment) return null;
|
||||
|
||||
return (
|
||||
<div className="detail-page">
|
||||
<Link to="/admin/review" className="back-link">
|
||||
← Back to queue
|
||||
</Link>
|
||||
|
||||
{/* ── Moment header ── */}
|
||||
<div className="detail-header">
|
||||
<h2>{moment.title}</h2>
|
||||
<StatusBadge status={moment.review_status} />
|
||||
</div>
|
||||
|
||||
{/* ── Moment data ── */}
|
||||
<div className="card detail-card">
|
||||
<div className="detail-field">
|
||||
<label>Content Type</label>
|
||||
<span>{moment.content_type}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<label>Time Range</label>
|
||||
<span>
|
||||
{formatTime(moment.start_time)} – {formatTime(moment.end_time)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<label>Source</label>
|
||||
<span>
|
||||
{moment.creator_name} · {moment.video_filename}
|
||||
</span>
|
||||
</div>
|
||||
{moment.plugins && moment.plugins.length > 0 && (
|
||||
<div className="detail-field">
|
||||
<label>Plugins</label>
|
||||
<span>{moment.plugins.join(", ")}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="detail-field detail-field--full">
|
||||
<label>Summary</label>
|
||||
<p>{moment.summary}</p>
|
||||
</div>
|
||||
{moment.raw_transcript && (
|
||||
<div className="detail-field detail-field--full">
|
||||
<label>Raw Transcript</label>
|
||||
<p className="detail-transcript">{moment.raw_transcript}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Action error ── */}
|
||||
{actionError && <div className="action-error">{actionError}</div>}
|
||||
|
||||
{/* ── Edit mode ── */}
|
||||
{editing ? (
|
||||
<div className="card edit-form">
|
||||
<h3>Edit Moment</h3>
|
||||
<div className="edit-field">
|
||||
<label htmlFor="edit-title">Title</label>
|
||||
<input
|
||||
id="edit-title"
|
||||
type="text"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="edit-field">
|
||||
<label htmlFor="edit-summary">Summary</label>
|
||||
<textarea
|
||||
id="edit-summary"
|
||||
rows={4}
|
||||
value={editSummary}
|
||||
onChange={(e) => setEditSummary(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="edit-field">
|
||||
<label htmlFor="edit-content-type">Content Type</label>
|
||||
<input
|
||||
id="edit-content-type"
|
||||
type="text"
|
||||
value={editContentType}
|
||||
onChange={(e) => setEditContentType(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="edit-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--approve"
|
||||
onClick={handleEditSave}
|
||||
disabled={acting}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => setEditing(false)}
|
||||
disabled={acting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* ── Action buttons ── */
|
||||
<div className="action-bar">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--approve"
|
||||
onClick={handleApprove}
|
||||
disabled={acting}
|
||||
>
|
||||
✓ Approve
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--reject"
|
||||
onClick={handleReject}
|
||||
disabled={acting}
|
||||
>
|
||||
✕ Reject
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={startEdit}
|
||||
disabled={acting}
|
||||
>
|
||||
✎ Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={openSplitDialog}
|
||||
disabled={acting}
|
||||
>
|
||||
✂ Split
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={openMergeDialog}
|
||||
disabled={acting}
|
||||
>
|
||||
⊕ Merge
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Split dialog ── */}
|
||||
{showSplit && (
|
||||
<div className="dialog-overlay" onClick={() => setShowSplit(false)}>
|
||||
<div className="dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>Split Moment</h3>
|
||||
<p className="dialog__hint">
|
||||
Enter a timestamp (in seconds) between{" "}
|
||||
{formatTime(moment.start_time)} and {formatTime(moment.end_time)}.
|
||||
</p>
|
||||
<div className="edit-field">
|
||||
<label htmlFor="split-time">Split Time (seconds)</label>
|
||||
<input
|
||||
id="split-time"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min={moment.start_time}
|
||||
max={moment.end_time}
|
||||
value={splitTime}
|
||||
onChange={(e) => setSplitTime(e.target.value)}
|
||||
placeholder={`e.g. ${((moment.start_time + moment.end_time) / 2).toFixed(1)}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="dialog__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--approve"
|
||||
onClick={handleSplit}
|
||||
disabled={acting}
|
||||
>
|
||||
Split
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => setShowSplit(false)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Merge dialog ── */}
|
||||
{showMerge && (
|
||||
<div className="dialog-overlay" onClick={() => setShowMerge(false)}>
|
||||
<div className="dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>Merge Moment</h3>
|
||||
<p className="dialog__hint">
|
||||
Select another moment from the same video to merge with.
|
||||
</p>
|
||||
{mergeCandidates.length === 0 ? (
|
||||
<p className="dialog__hint">
|
||||
No other moments from this video available.
|
||||
</p>
|
||||
) : (
|
||||
<div className="edit-field">
|
||||
<label htmlFor="merge-target">Target Moment</label>
|
||||
<select
|
||||
id="merge-target"
|
||||
value={mergeTargetId}
|
||||
onChange={(e) => setMergeTargetId(e.target.value)}
|
||||
>
|
||||
<option value="">Select a moment…</option>
|
||||
{mergeCandidates.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.title} ({formatTime(c.start_time)} –{" "}
|
||||
{formatTime(c.end_time)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="dialog__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--approve"
|
||||
onClick={handleMerge}
|
||||
disabled={acting || !mergeTargetId}
|
||||
>
|
||||
Merge
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => setShowMerge(false)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,189 +0,0 @@
|
|||
/**
|
||||
* Admin review queue page.
|
||||
*
|
||||
* Shows stats bar, status filter tabs, paginated moment list, and mode toggle.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
fetchQueue,
|
||||
fetchStats,
|
||||
type ReviewQueueItem,
|
||||
type ReviewStatsResponse,
|
||||
} from "../api/client";
|
||||
import StatusBadge from "../components/StatusBadge";
|
||||
import ModeToggle from "../components/ModeToggle";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
type StatusFilter = "all" | "pending" | "approved" | "edited" | "rejected";
|
||||
|
||||
const FILTERS: { label: string; value: StatusFilter }[] = [
|
||||
{ label: "All", value: "all" },
|
||||
{ label: "Pending", value: "pending" },
|
||||
{ label: "Approved", value: "approved" },
|
||||
{ label: "Edited", value: "edited" },
|
||||
{ label: "Rejected", value: "rejected" },
|
||||
];
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export default function ReviewQueue() {
|
||||
const [items, setItems] = useState<ReviewQueueItem[]>([]);
|
||||
const [stats, setStats] = useState<ReviewStatsResponse | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [filter, setFilter] = useState<StatusFilter>("pending");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadData = useCallback(async (status: StatusFilter, page: number) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [queueRes, statsRes] = await Promise.all([
|
||||
fetchQueue({
|
||||
status: status === "all" ? undefined : status,
|
||||
offset: page,
|
||||
limit: PAGE_SIZE,
|
||||
}),
|
||||
fetchStats(),
|
||||
]);
|
||||
setItems(queueRes.items);
|
||||
setTotal(queueRes.total);
|
||||
setStats(statsRes);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load queue");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadData(filter, offset);
|
||||
}, [filter, offset, loadData]);
|
||||
|
||||
function handleFilterChange(f: StatusFilter) {
|
||||
setFilter(f);
|
||||
setOffset(0);
|
||||
}
|
||||
|
||||
const hasNext = offset + PAGE_SIZE < total;
|
||||
const hasPrev = offset > 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* ── Header row with title and mode toggle ── */}
|
||||
<div className="queue-header">
|
||||
<h2>Review Queue</h2>
|
||||
<ModeToggle />
|
||||
</div>
|
||||
|
||||
{/* ── Stats bar ── */}
|
||||
{stats && (
|
||||
<div className="stats-bar">
|
||||
<div className="stats-card stats-card--pending">
|
||||
<span className="stats-card__count">{stats.pending}</span>
|
||||
<span className="stats-card__label">Pending</span>
|
||||
</div>
|
||||
<div className="stats-card stats-card--approved">
|
||||
<span className="stats-card__count">{stats.approved}</span>
|
||||
<span className="stats-card__label">Approved</span>
|
||||
</div>
|
||||
<div className="stats-card stats-card--edited">
|
||||
<span className="stats-card__count">{stats.edited}</span>
|
||||
<span className="stats-card__label">Edited</span>
|
||||
</div>
|
||||
<div className="stats-card stats-card--rejected">
|
||||
<span className="stats-card__count">{stats.rejected}</span>
|
||||
<span className="stats-card__label">Rejected</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Filter tabs ── */}
|
||||
<div className="filter-tabs">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.value}
|
||||
type="button"
|
||||
className={`filter-tab ${filter === f.value ? "filter-tab--active" : ""}`}
|
||||
onClick={() => handleFilterChange(f.value)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Queue list ── */}
|
||||
{loading ? (
|
||||
<div className="loading">Loading…</div>
|
||||
) : error ? (
|
||||
<div className="loading error-text">Error: {error}</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>No moments match the "{filter}" filter.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="queue-list">
|
||||
{items.map((item) => (
|
||||
<Link
|
||||
key={item.id}
|
||||
to={`/admin/review/${item.id}`}
|
||||
className="queue-card"
|
||||
>
|
||||
<div className="queue-card__header">
|
||||
<span className="queue-card__title">{item.title}</span>
|
||||
<StatusBadge status={item.review_status} />
|
||||
</div>
|
||||
<p className="queue-card__summary">
|
||||
{item.summary.length > 150
|
||||
? `${item.summary.slice(0, 150)}…`
|
||||
: item.summary}
|
||||
</p>
|
||||
<div className="queue-card__meta">
|
||||
<span>{item.creator_name}</span>
|
||||
<span className="queue-card__separator">·</span>
|
||||
<span>{item.video_filename}</span>
|
||||
<span className="queue-card__separator">·</span>
|
||||
<span>
|
||||
{formatTime(item.start_time)} – {formatTime(item.end_time)}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Pagination ── */}
|
||||
<div className="pagination">
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={!hasPrev}
|
||||
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
|
||||
>
|
||||
← Previous
|
||||
</button>
|
||||
<span className="pagination__info">
|
||||
{offset + 1}–{Math.min(offset + PAGE_SIZE, total)} of {total}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={!hasNext}
|
||||
onClick={() => setOffset(offset + PAGE_SIZE)}
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue