manual submit

This commit is contained in:
Khoa (Revenovich) Tran Gia
2026-03-07 21:49:16 +07:00
parent 1748cbf8d2
commit 6004b000a7
39 changed files with 5794 additions and 614 deletions

View File

@@ -3,12 +3,14 @@ POST /api/history/{prompt_id}/share; DELETE /api/history/{prompt_id}/share"""
from __future__ import annotations
import base64
from datetime import datetime, timedelta, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request
from fastapi.responses import Response
from pydantic import BaseModel
from web.auth import require_auth
from web.auth import require_admin, require_auth
router = APIRouter()
@@ -31,22 +33,135 @@ def _assert_owns(prompt_id: str, user: dict) -> None:
async def get_history(
user: dict = Depends(require_auth),
q: Optional[str] = Query(None, description="Keyword to search in overrides JSON"),
persons: list[str] = Query(default=[], alias="persons", description="Filter by person name/alias substring (repeatable)"),
):
"""Return generation history. Admins see all; regular users see only their own.
Pass ?q=keyword to filter by prompt text or any override field."""
Pass ?q=keyword to filter by prompt text or any override field.
Pass ?persons=name (repeatable) to filter by persons detected in output images."""
import face_db as face_db_mod
from generation_db import (
get_history as db_get_history,
get_history_for_user,
search_history,
search_history_for_user,
get_generation_ids_for_file_ids,
get_file_ids_for_generation_ids,
)
# Person filter: OR union across all named persons, then filter rows
gen_id_filter: list[int] | None = None
all_file_ids: set[int] = set()
active_persons = [p.strip() for p in persons if p.strip()]
if active_persons:
for p in active_persons:
ids = face_db_mod.get_source_ids_for_person_query(p, "output")
all_file_ids.update(ids)
if not all_file_ids:
return {"history": []}
gen_ids = get_generation_ids_for_file_ids(list(all_file_ids))
if not gen_ids:
return {"history": []}
gen_id_filter = gen_ids
if q and q.strip():
if user.get("admin"):
return {"history": search_history(q.strip(), limit=50)}
return {"history": search_history_for_user(user["sub"], q.strip(), limit=50)}
if user.get("admin"):
return {"history": db_get_history(limit=50)}
return {"history": get_history_for_user(user["sub"], limit=50)}
rows = search_history(q.strip(), limit=50)
else:
rows = search_history_for_user(user["sub"], q.strip(), limit=50)
else:
if user.get("admin"):
rows = db_get_history(limit=50)
else:
rows = get_history_for_user(user["sub"], limit=50)
if gen_id_filter is not None:
gen_id_set = set(gen_id_filter)
rows = [r for r in rows if r["id"] in gen_id_set]
# Annotate each row with detected_persons when person filtering is active
if active_persons and rows:
row_ids = [r["id"] for r in rows]
file_ids_per_gen = get_file_ids_for_generation_ids(row_ids)
all_annotate_ids = [fid for fids in file_ids_per_gen.values() for fid in fids]
person_map = face_db_mod.get_persons_for_source_id_map(all_annotate_ids, "output") if all_annotate_ids else {}
for row in rows:
row["detected_persons"] = list({
name
for fid in file_ids_per_gen.get(row["id"], [])
for name in person_map.get(fid, [])
})
return {"history": rows}
@router.get("/{prompt_id}/persons")
async def get_generation_persons(prompt_id: str, user: dict = Depends(require_auth)):
"""Return persons detected in the output images of a generation."""
_assert_owns(prompt_id, user)
import generation_db as gen_db
import face_db as face_db_mod
file_ids = gen_db.get_file_ids_for_prompt(prompt_id)
persons = face_db_mod.get_persons_for_source_ids(file_ids, "output")
return {"persons": persons}
class _AddPersonRequest(BaseModel):
name: str
@router.post("/{prompt_id}/persons")
async def add_generation_person(
prompt_id: str,
body: _AddPersonRequest,
_: dict = Depends(require_admin),
):
"""Manually tag a person to a generation's output files."""
import generation_db as gen_db
import face_db as face_db_mod
file_ids = gen_db.get_file_ids_for_prompt(prompt_id)
if not file_ids:
raise HTTPException(404, f"Generation {prompt_id!r} not found")
name = body.name.strip()
if not name:
raise HTTPException(400, "Name cannot be empty")
if len(name) > 100:
raise HTTPException(400, "Name too long (max 100 chars)")
person_id, _ = face_db_mod.get_or_create_person(name)
# Only add if not already tagged on any file of this generation
existing = face_db_mod.get_persons_for_source_ids(file_ids, "output")
if not any(p["id"] == person_id for p in existing):
face_db_mod.insert_detection(
source_type="output",
source_id=file_ids[0],
embedding=None,
bbox={},
face_index=0,
person_id=person_id,
)
return {"person_id": person_id, "name": name}
@router.delete("/{prompt_id}/persons/{person_id}")
async def remove_generation_person(
prompt_id: str,
person_id: int,
_: dict = Depends(require_admin),
):
"""Remove all face detections linking a person to this generation's output files."""
import generation_db as gen_db
import face_db as face_db_mod
file_ids = gen_db.get_file_ids_for_prompt(prompt_id)
if not file_ids:
raise HTTPException(404, f"Generation {prompt_id!r} not found")
face_db_mod.remove_person_from_source_ids(file_ids, person_id, "output")
return {"ok": True}
@router.get("/{prompt_id}/images")
@@ -101,6 +216,11 @@ async def get_history_file(
start = int(start_str) if start_str else 0
end = int(end_str) if end_str else total - 1
end = min(end, total - 1)
if start < 0 or start > end:
return Response(
status_code=416,
headers={"Content-Range": f"bytes */{total}"},
)
chunk = data[start : end + 1]
return Response(
content=chunk,
@@ -110,6 +230,7 @@ async def get_history_file(
"Content-Range": f"bytes {start}-{end}/{total}",
"Accept-Ranges": "bytes",
"Content-Length": str(len(chunk)),
"Cache-Control": "private",
},
)
@@ -119,25 +240,62 @@ async def get_history_file(
headers={
"Accept-Ranges": "bytes",
"Content-Length": str(total),
"Cache-Control": "private",
},
)
class _ShareRequest(BaseModel):
is_public: bool = False
expires_in_hours: Optional[float] = None
max_views: Optional[int] = None
@router.post("/{prompt_id}/share")
async def create_generation_share(prompt_id: str, user: dict = Depends(require_auth)):
async def create_generation_share(
prompt_id: str,
body: _ShareRequest = Body(default_factory=_ShareRequest),
user: dict = Depends(require_auth),
):
"""Create a share token for a generation. Only the owner may share."""
# Use the same 404-for-everything helper to avoid leaking prompt_id existence
_assert_owns(prompt_id, user)
from generation_db import create_share as db_create_share
token = db_create_share(prompt_id, user["sub"])
return {"share_token": token}
from generation_db import (
create_share as db_create_share,
get_active_share_for_prompt as db_get_active_share,
)
existing = db_get_active_share(prompt_id, user["sub"])
if existing:
raise HTTPException(409, "A share already exists — revoke it first")
if body.is_public and body.expires_in_hours is None and body.max_views is None:
raise HTTPException(400, "Public shares must have at least one expiry condition (time or view limit)")
if body.max_views is not None and body.max_views < 1:
raise HTTPException(400, "max_views must be >= 1")
if body.expires_in_hours is not None and body.expires_in_hours <= 0:
raise HTTPException(400, "expires_in_hours must be > 0")
expires_at = None
if body.expires_in_hours is not None:
expires_at = (datetime.now(timezone.utc) + timedelta(hours=body.expires_in_hours)).isoformat()
share = db_create_share(
prompt_id, user["sub"],
is_public=body.is_public,
expires_at=expires_at,
max_views=body.max_views,
)
return {
"share_token": share["share_token"],
"is_public": bool(share["is_public"]),
"expires_at": share["expires_at"],
"max_views": share["max_views"],
}
@router.delete("/{prompt_id}/share")
async def revoke_generation_share(prompt_id: str, user: dict = Depends(require_auth)):
"""Revoke a share token for a generation. Only the owner may revoke."""
"""Revoke a share token for a generation. Admins can revoke any share."""
from generation_db import revoke_share as db_revoke_share
deleted = db_revoke_share(prompt_id, user["sub"])
# Admins pass None for owner_label to delete by prompt_id alone
owner_label = None if user.get("admin") else user["sub"]
deleted = db_revoke_share(prompt_id, owner_label)
if not deleted:
raise HTTPException(404, "No active share found for this generation")
return {"ok": True}