rebase
This commit is contained in:
@@ -152,6 +152,34 @@ async def patch_user(
|
||||
return {"id": str(user.id), "is_admin": user.is_admin, "is_active": user.is_active}
|
||||
|
||||
|
||||
@router.delete("/worlds/{world_id}", status_code=200)
|
||||
async def hard_delete_world(
|
||||
world_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(require_admin),
|
||||
) -> dict:
|
||||
"""Hard-delete a world (cascade deletes all related entities, steps, logs).
|
||||
|
||||
Also cleans up Qdrant points for the world (best-effort).
|
||||
"""
|
||||
from app.models import World
|
||||
from app.core.qdrant_client import cleanup_world_points
|
||||
|
||||
world = (
|
||||
await db.execute(select(World).where(World.id == world_id))
|
||||
).scalar_one_or_none()
|
||||
if world is None:
|
||||
raise HTTPException(404, "not_found")
|
||||
await db.delete(world)
|
||||
await db.commit()
|
||||
# Best-effort Qdrant cleanup
|
||||
try:
|
||||
await cleanup_world_points(str(world_id))
|
||||
except Exception as e: # noqa: BLE001
|
||||
_logger.warning("qdrant_cleanup_failed", world_id=str(world_id), error=str(e))
|
||||
return {"ok": True, "deleted": str(world_id)}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Stats
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -163,14 +191,27 @@ async def stats(
|
||||
from app.models import Step, World
|
||||
|
||||
users_count = (await db.execute(select(func.count(User.id)))).scalar_one()
|
||||
worlds_count = (await db.execute(select(func.count(World.id)))).scalar_one()
|
||||
# Active worlds (exclude archived)
|
||||
active_worlds = (
|
||||
await db.execute(
|
||||
select(func.count(World.id)).where(World.status != "archived")
|
||||
)
|
||||
).scalar_one()
|
||||
archived_worlds = (
|
||||
await db.execute(
|
||||
select(func.count(World.id)).where(World.status == "archived")
|
||||
)
|
||||
).scalar_one()
|
||||
total_worlds = active_worlds + archived_worlds
|
||||
steps_count = (await db.execute(select(func.count(Step.id)))).scalar_one()
|
||||
avg_latency = (
|
||||
await db.execute(select(func.avg(LlmCallLog.latency_ms)))
|
||||
).scalar_one()
|
||||
return {
|
||||
"users": users_count,
|
||||
"worlds": worlds_count,
|
||||
"worlds": active_worlds,
|
||||
"worlds_total": total_worlds,
|
||||
"worlds_archived": archived_worlds,
|
||||
"steps": steps_count,
|
||||
"avg_llm_latency_ms": float(avg_latency) if avg_latency else 0,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Misc endpoints: health, i18n."""
|
||||
"""Misc endpoints: health, i18n, public settings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.config import get_settings
|
||||
from app.core.embeddings import HashEmbedder
|
||||
from app.core.logging import get_logger
|
||||
from app.core.qdrant_client import ping_qdrant
|
||||
from app.core.settings_service import get_all_settings, get_setting
|
||||
from app.db import get_db
|
||||
from app.schemas import HealthResponse
|
||||
|
||||
@@ -34,12 +35,9 @@ async def health(db: AsyncSession = Depends(get_db)) -> HealthResponse:
|
||||
except Exception as e: # noqa: BLE001
|
||||
_logger.warning("health_qdrant_failed", error=str(e))
|
||||
|
||||
# LLM health: we treat it as "true" only if api_url is set AND we can avoid a real call.
|
||||
# For the basic health probe we don't make any LLM calls — return True iff api_url is configured.
|
||||
cfg = get_settings()
|
||||
llm_ok = bool(cfg.llm_api_url)
|
||||
|
||||
# Embeddings: True if HashEmbedder works (always does) or if openai provider configured
|
||||
embeddings_ok = True
|
||||
if cfg.embeddings_provider == "offline_hash":
|
||||
try:
|
||||
@@ -59,16 +57,28 @@ async def health(db: AsyncSession = Depends(get_db)) -> HealthResponse:
|
||||
)
|
||||
|
||||
|
||||
@router.get("/settings/public")
|
||||
async def public_settings(db: AsyncSession = Depends(get_db)) -> dict:
|
||||
"""Return UI-relevant settings (no secrets). No auth required.
|
||||
|
||||
The frontend uses this on app load to set the page title, favicon,
|
||||
and logo.
|
||||
"""
|
||||
page_title = await get_setting(db, "ui.page_title")
|
||||
favicon_url = await get_setting(db, "ui.favicon_url")
|
||||
logo_url = await get_setting(db, "ui.logo_url")
|
||||
og_image_url = await get_setting(db, "ui.og_image_url")
|
||||
return {
|
||||
"page_title": page_title or "AI-RPG",
|
||||
"favicon_url": favicon_url or "/icon.png",
|
||||
"logo_url": logo_url or "/icon.png",
|
||||
"og_image_url": og_image_url or "",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/i18n/{lang}")
|
||||
async def i18n(lang: str) -> dict:
|
||||
"""Return translation JSON for the given language.
|
||||
|
||||
Backend only knows the en/ru bundles used by the frontend; we serve them
|
||||
statically from the frontend's `public/i18n/` folder in production, but
|
||||
this endpoint is useful for hot-reloading in dev.
|
||||
"""
|
||||
"""Return translation JSON for the given language."""
|
||||
if lang not in ("en", "ru"):
|
||||
return {"error": "unsupported language"}
|
||||
# The frontend owns the bundles; this endpoint returns an empty dict
|
||||
# (the frontend fetches `/i18n/{lang}.json` as a static asset).
|
||||
return {"language": lang}
|
||||
|
||||
@@ -339,8 +339,18 @@ def _format_sse(evt: dict[str, str]) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _session_scope():
|
||||
"""Open a fresh DB session for the background task."""
|
||||
"""Open a fresh DB session for the background task.
|
||||
|
||||
Must be used as: async with _session_scope() as bg_db: ...
|
||||
The @asynccontextmanager decorator is required — without it, an
|
||||
`async def` with `yield` returns an async generator, which does NOT
|
||||
support `async with`.
|
||||
"""
|
||||
from app.db import get_sessionmaker
|
||||
|
||||
sm = get_sessionmaker()
|
||||
|
||||
@@ -49,9 +49,16 @@ async def list_worlds(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""List the current user's worlds."""
|
||||
"""List the current user's worlds.
|
||||
|
||||
By default, archived worlds are excluded. Pass status_filter='archived'
|
||||
to see only archived, or status_filter='all' to see everything.
|
||||
"""
|
||||
stmt = select(World).where(World.owner_id == user.id)
|
||||
if status_filter and status_filter != "all":
|
||||
if not status_filter or status_filter == "active":
|
||||
# Default: exclude archived
|
||||
stmt = stmt.where(World.status != "archived")
|
||||
elif status_filter != "all":
|
||||
stmt = stmt.where(World.status == status_filter)
|
||||
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one()
|
||||
stmt = stmt.order_by(World.last_played_at.desc().nullslast(), World.created_at.desc())
|
||||
|
||||
@@ -141,16 +141,36 @@ class LlmClient:
|
||||
)
|
||||
if resp.status_code >= 500:
|
||||
raise LLMUnavailableError(
|
||||
f"LLM provider returned {resp.status_code}: {resp.text[:200]}"
|
||||
f"LLM provider returned HTTP {resp.status_code}: {resp.text[:300]}"
|
||||
)
|
||||
if resp.status_code == 429:
|
||||
raise LLMUnavailableError("LLM provider rate-limited (429)")
|
||||
raise LLMUnavailableError("LLM provider rate-limited (HTTP 429)")
|
||||
if resp.status_code >= 400:
|
||||
# Try to extract error message from JSON body
|
||||
err_body = resp.text[:500]
|
||||
try:
|
||||
err_json = resp.json()
|
||||
if "error" in err_json:
|
||||
err_msg = err_json["error"].get("message", err_body)
|
||||
else:
|
||||
err_msg = err_body
|
||||
except Exception: # noqa: BLE001
|
||||
err_msg = err_body
|
||||
raise LLMResponseError(
|
||||
f"LLM provider returned {resp.status_code}: {resp.text[:500]}",
|
||||
f"LLM provider returned HTTP {resp.status_code}: {err_msg}",
|
||||
code="api_error",
|
||||
)
|
||||
data = resp.json()
|
||||
# Parse JSON response — if this fails, the URL is likely wrong
|
||||
# (pointing at an HTML page instead of an OpenAI-compatible API)
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise LLMResponseError(
|
||||
f"LLM provider returned non-JSON response (check that "
|
||||
f"api_url points to an OpenAI-compatible endpoint). "
|
||||
f"First 200 chars: {resp.text[:200]}",
|
||||
code="parse_error",
|
||||
) from e
|
||||
break
|
||||
except (httpx.TimeoutException, asyncio.TimeoutError) as e:
|
||||
last_exc = LLMTimeoutError(str(e))
|
||||
|
||||
44
app/main.py
44
app/main.py
@@ -27,6 +27,25 @@ from app.models import Setting, User
|
||||
_logger = get_logger(__name__)
|
||||
|
||||
|
||||
async def _apply_schema_fixups(engine) -> None:
|
||||
"""Apply idempotent ALTER statements for schema fixes that create_all
|
||||
cannot handle (e.g. changing NOT NULL → NULL on existing tables).
|
||||
|
||||
These run on every startup. Each statement is wrapped in try/except so
|
||||
it silently succeeds if the fix was already applied.
|
||||
"""
|
||||
fixups = [
|
||||
# world_presets.owner_id: was NOT NULL, now nullable (for system presets)
|
||||
"ALTER TABLE world_presets ALTER COLUMN owner_id DROP NOT NULL",
|
||||
]
|
||||
async with engine.begin() as conn:
|
||||
for sql in fixups:
|
||||
try:
|
||||
await conn.execute(text(sql))
|
||||
except Exception: # noqa: BLE001
|
||||
pass # already applied, or table doesn't exist yet
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
"""Application startup / shutdown lifecycle."""
|
||||
@@ -44,10 +63,10 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
try:
|
||||
engine = get_engine()
|
||||
await create_all_tables(engine)
|
||||
await _apply_schema_fixups(engine)
|
||||
_logger.info("db_tables_ready")
|
||||
except Exception as e: # noqa: BLE001
|
||||
_logger.error("db_tables_create_failed", error=str(e))
|
||||
# Continue anyway — /api/health will reflect the broken state
|
||||
|
||||
sm = get_sessionmaker()
|
||||
try:
|
||||
@@ -56,7 +75,9 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
await seed_default_settings(session)
|
||||
# 2) ensure admin setup token
|
||||
token = await get_admin_setup_token(session)
|
||||
# 3) check if any admin exists
|
||||
# 3) check if any admin exists — ALWAYS print the setup URL
|
||||
# (user requested: even if admin exists, show the token for
|
||||
# reference / debugging)
|
||||
from sqlalchemy import func
|
||||
|
||||
admins_count = (
|
||||
@@ -65,16 +86,18 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
)
|
||||
).scalar_one()
|
||||
if admins_count == 0:
|
||||
_logger.warning(
|
||||
"no_admin_yet",
|
||||
setup_url=f"/register/admin?token={token}",
|
||||
)
|
||||
print(f"\n=== AI-RPG Admin Setup ===")
|
||||
print(f"No admin user yet. Open this URL in your browser:")
|
||||
print(f" /register/admin?token={token}")
|
||||
print(f"===========================\n")
|
||||
_logger.warning("no_admin_yet", setup_url=f"/register/admin?token={token}")
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" AI-RPG — No admin user yet.")
|
||||
print(f" Open this URL to create the first admin:")
|
||||
print(f" http://localhost:8080/register/admin?token={token}")
|
||||
print(f"{'=' * 60}\n")
|
||||
else:
|
||||
_logger.info("admins_present", count=admins_count)
|
||||
# Still print the token URL for reference
|
||||
print(f"\n Admin already exists. Admin register URL (for reference):")
|
||||
print(f" /register/admin?token={token}")
|
||||
print(f" (This URL is blocked since an admin already exists.)\n")
|
||||
# 4) seed builtin presets (idempotent)
|
||||
from app.migrations.seed import seed_builtin_presets
|
||||
|
||||
@@ -94,7 +117,6 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
_logger.warning("qdrant_init_failed", error=str(e))
|
||||
except Exception as e: # noqa: BLE001
|
||||
_logger.error("startup_failed", error=str(e))
|
||||
# Don't crash — let /api/health reflect the broken state
|
||||
pass
|
||||
|
||||
yield
|
||||
|
||||
@@ -245,7 +245,12 @@ BUILTIN_PRESETS = [FANTASY_PRESET, SCI_FI_PRESET]
|
||||
|
||||
|
||||
async def seed_builtin_presets(session: AsyncSession) -> None:
|
||||
"""Insert builtin presets if they don't yet exist. Owned by the first admin (or a system sentinel)."""
|
||||
"""Insert builtin presets if they don't yet exist.
|
||||
|
||||
Builtin presets have owner_id=NULL (they are system presets, not owned by
|
||||
any specific user). This avoids the FK violation that occurred on first
|
||||
startup when no admin user existed yet.
|
||||
"""
|
||||
for preset_data in BUILTIN_PRESETS:
|
||||
existing = (
|
||||
await session.execute(
|
||||
@@ -254,18 +259,8 @@ async def seed_builtin_presets(session: AsyncSession) -> None:
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
continue
|
||||
# Find any admin to own the preset, or use a sentinel UUID
|
||||
from app.models import User
|
||||
from sqlalchemy import func
|
||||
|
||||
admin = (
|
||||
await session.execute(
|
||||
select(User).where(User.is_admin.is_(True)).limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
owner_id = admin.id if admin else uuid.UUID("00000000-0000-0000-0000-000000000001")
|
||||
preset = WorldPreset(
|
||||
owner_id=owner_id,
|
||||
owner_id=None, # system preset — no owner
|
||||
**preset_data,
|
||||
version=1,
|
||||
)
|
||||
|
||||
@@ -91,8 +91,11 @@ class WorldPreset(Base):
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
owner_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PG_UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
# owner_id is nullable so that builtin/system presets can exist without
|
||||
# a real user owning them. Custom presets created via the admin UI get
|
||||
# the admin's user_id.
|
||||
owner_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PG_UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
@@ -112,7 +115,7 @@ class WorldPreset(Base):
|
||||
DateTime(timezone=True), nullable=False, default=_now, onupdate=_now
|
||||
)
|
||||
|
||||
owner: Mapped[User] = relationship(back_populates="presets")
|
||||
owner: Mapped[User | None] = relationship(back_populates="presets")
|
||||
worlds: Mapped[list["World"]] = relationship(back_populates="preset")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user