503 lines
17 KiB
Python
503 lines
17 KiB
Python
"""Family Home Lab console — FastAPI + Jinja2 + HTMX.
|
|
|
|
Screens:
|
|
/login POST form -> sets session cookie, redirects
|
|
/ dashboard (tool grid)
|
|
/tool/{id} embed view (iframe well) or link-out
|
|
/admin user + service management (Sam only)
|
|
/api/status HTMX-polled status endpoint (30s)
|
|
|
|
Every page renders against the design tokens in static/tokens.css.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
from zoneinfo import ZoneInfo
|
|
|
|
MELB = ZoneInfo("Australia/Melbourne")
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Annotated
|
|
|
|
from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile
|
|
from fastapi.concurrency import run_in_threadpool
|
|
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
|
from fastapi.staticfiles import StaticFiles
|
|
from fastapi.templating import Jinja2Templates
|
|
from sqlalchemy import select
|
|
|
|
from . import auth
|
|
from .config import settings
|
|
from .database import (
|
|
session_scope,
|
|
User,
|
|
get_session,
|
|
get_user_by_username,
|
|
init_db,
|
|
)
|
|
from .tasks import ensure_user_bucket, transcribe_audio, transcribe_sheetmusic
|
|
from .s3 import list_prefix, list_recent, s3_bucket_name, s3_client
|
|
from .tools import section_color, section_label, tools_for_user
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
BASE_DIR = Path(__file__).resolve().parent
|
|
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
|
templates.env.globals["settings"] = settings
|
|
|
|
app = FastAPI(title=settings.APP_NAME, docs_url=None if not settings.DEBUG else "/docs")
|
|
app.mount(
|
|
"/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static"
|
|
)
|
|
|
|
|
|
# Per-user tool URL for the dsh chat instance placeholder; actual contract is
|
|
# defined in plan.md §6 to be handed to the dsh agent.
|
|
# (dsh link specialization lives in portal/tools.py::_dsh_for_user)
|
|
|
|
|
|
@app.on_event("startup")
|
|
async def on_startup() -> None:
|
|
await init_db()
|
|
await _seed_admin_if_empty()
|
|
|
|
|
|
async def _seed_admin_if_empty() -> None:
|
|
"""First-run: if the users table is empty, create the admin from env."""
|
|
from sqlalchemy import func
|
|
|
|
admin_user = settings.ADMIN_USERNAME
|
|
admin_pass = settings.ADMIN_PASSWORD
|
|
admin_name = settings.ADMIN_FULLNAME
|
|
if not admin_user or not admin_pass:
|
|
logger.warning("ADMIN_USERNAME/ADMIN_PASSWORD not set — skipping seed")
|
|
return
|
|
|
|
async with session_scope() as session:
|
|
count = await session.scalar(select(func.count()).select_from(User))
|
|
if count:
|
|
return
|
|
admin = User(
|
|
username=admin_user,
|
|
full_name=admin_name or admin_user,
|
|
password_hash=auth.hash_password(admin_pass),
|
|
is_admin=True,
|
|
can_chat=True,
|
|
can_image=True,
|
|
can_video=True,
|
|
can_audio=True,
|
|
is_active=True,
|
|
)
|
|
session.add(admin)
|
|
await session.commit()
|
|
logger.info("Seeded initial admin account %r", admin_user)
|
|
|
|
|
|
def _greeting(now: datetime) -> str:
|
|
h = now.astimezone(MELB).hour
|
|
if h < 5:
|
|
return "Up late"
|
|
if h < 12:
|
|
return "Good morning"
|
|
if h < 18:
|
|
return "Good afternoon"
|
|
return "Good evening"
|
|
|
|
|
|
async def _current_user(request: Request) -> User | None:
|
|
"""Resolve the logged-in user from the session cookie (async-safe)."""
|
|
user = await auth.current_user_dep(request)
|
|
return user
|
|
|
|
|
|
@app.get("/login", response_class=HTMLResponse)
|
|
async def login_page(
|
|
request: Request,
|
|
next: str = "/",
|
|
) -> HTMLResponse:
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"login.html",
|
|
{"greeting": _greeting(datetime.now(timezone.utc)), "next": next},
|
|
)
|
|
|
|
|
|
@app.post("/login")
|
|
async def login_submit(
|
|
request: Request,
|
|
username: Annotated[str, Form()],
|
|
password: Annotated[str, Form()],
|
|
next: str = Form("/"),
|
|
) -> HTMLResponse:
|
|
async with session_scope() as session:
|
|
user = await get_user_by_username(session, username.strip())
|
|
|
|
if user is None or not auth.verify_password(password, user.password_hash):
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"login.html",
|
|
{
|
|
"greeting": _greeting(datetime.now(timezone.utc)),
|
|
"error": "Incorrect username or password.",
|
|
"next": next,
|
|
},
|
|
status_code=401,
|
|
)
|
|
if not user.is_active:
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"login.html",
|
|
{
|
|
"greeting": _greeting(datetime.now(timezone.utc)),
|
|
"error": "This account is disabled.",
|
|
"next": next,
|
|
},
|
|
status_code=403,
|
|
)
|
|
|
|
response = RedirectResponse(next or "/", status_code=303)
|
|
auth.set_session_cookie(request, response, user.username)
|
|
return response
|
|
|
|
|
|
@app.get("/logout")
|
|
async def logout(request: Request):
|
|
response = RedirectResponse("/login", status_code=303)
|
|
auth.clear_session_cookie(request, response)
|
|
return response
|
|
|
|
|
|
@app.get("/", response_class=HTMLResponse)
|
|
async def dashboard(request: Request) -> HTMLResponse:
|
|
user = await _current_user(request)
|
|
if user is None:
|
|
return RedirectResponse("/login", status_code=303)
|
|
groups = tools_for_user(user)
|
|
now = datetime.now(timezone.utc)
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"dashboard.html",
|
|
{
|
|
"user": user,
|
|
"greeting": _greeting(now),
|
|
"sections": [
|
|
{
|
|
"label": section_label(cat),
|
|
"color": section_color(cat),
|
|
"tools": group,
|
|
}
|
|
for cat, group in groups
|
|
],
|
|
},
|
|
)
|
|
|
|
|
|
@app.get("/tool/{tool_id}", response_class=HTMLResponse)
|
|
async def tool_embed(request: Request, tool_id: str) -> HTMLResponse:
|
|
user = await _current_user(request)
|
|
if user is None:
|
|
return RedirectResponse("/login", status_code=303)
|
|
groups = tools_for_user(user)
|
|
tool = next((t for _, g in groups for t in g if t.id == tool_id), None)
|
|
if tool is None:
|
|
return templates.TemplateResponse(
|
|
request, "not_found.html", {"user": user}, status_code=404
|
|
)
|
|
# Workspace files: the user's bucket + the shared pool (does not block the
|
|
# event loop, degrades to empty list on any S3 error).
|
|
user_bucket = s3_bucket_name(user.username)
|
|
my_files = await run_in_threadpool(list_recent, user_bucket, 20)
|
|
shared_files = await run_in_threadpool(list_recent, "shared-media", 20)
|
|
ctx = {
|
|
"user": user,
|
|
"tool": tool,
|
|
"files": my_files,
|
|
"shared_files": shared_files,
|
|
"user_bucket": user_bucket,
|
|
}
|
|
if tool.mode == "window":
|
|
# Link-out fallback for tools that forbid framing.
|
|
return templates.TemplateResponse(request, "linkout.html", ctx)
|
|
return templates.TemplateResponse(request, "embed.html", ctx)
|
|
|
|
|
|
@app.get("/admin", response_class=HTMLResponse)
|
|
async def admin_panel(request: Request) -> HTMLResponse:
|
|
user = await _current_user(request)
|
|
if user is None:
|
|
return RedirectResponse("/login", status_code=303)
|
|
if not user.is_admin:
|
|
return templates.TemplateResponse(
|
|
request, "forbidden.html", {"user": user}, status_code=403
|
|
)
|
|
async with session_scope() as session:
|
|
result = await session.scalars(select(User).order_by(User.username))
|
|
users = list(result)
|
|
return templates.TemplateResponse(
|
|
request, "admin.html", {"user": user, "users": users}
|
|
)
|
|
|
|
|
|
|
|
@app.get("/account/password", response_class=HTMLResponse)
|
|
async def change_password_page(request: Request) -> HTMLResponse:
|
|
user = await _current_user(request)
|
|
if user is None:
|
|
return RedirectResponse("/login", status_code=303)
|
|
return templates.TemplateResponse(
|
|
request, "change_password.html", {"user": user}
|
|
)
|
|
|
|
|
|
@app.post("/account/password")
|
|
async def change_password(
|
|
request: Request,
|
|
current: Annotated[str, Form()],
|
|
password: Annotated[str, Form()],
|
|
confirm: Annotated[str, Form()],
|
|
) -> HTMLResponse:
|
|
user = await _current_user(request)
|
|
if user is None:
|
|
return RedirectResponse("/login", status_code=303)
|
|
error = None
|
|
try:
|
|
ok_current = auth.verify_password(current, user.password_hash)
|
|
except Exception:
|
|
ok_current = False
|
|
if password != confirm:
|
|
error = "New passwords do not match."
|
|
elif not ok_current:
|
|
error = "Current password is incorrect."
|
|
elif len(password) < 8:
|
|
error = "New password must be at least 8 characters."
|
|
else:
|
|
async with session_scope() as session:
|
|
u = await get_user_by_username(session, user.username)
|
|
if u is not None:
|
|
u.password_hash = auth.hash_password(password)
|
|
await session.commit()
|
|
return RedirectResponse("/?pw=1", status_code=303)
|
|
return templates.TemplateResponse(
|
|
request, "change_password.html", {"user": user, "error": error},
|
|
status_code=400,
|
|
)
|
|
|
|
|
|
@app.post("/admin/users")
|
|
async def admin_add_user(
|
|
request: Request,
|
|
username: Annotated[str, Form()],
|
|
full_name: Annotated[str, Form()],
|
|
password: Annotated[str, Form()],
|
|
) -> HTMLResponse:
|
|
"""Admin: create a user and enqueue their S3 bucket provisioning."""
|
|
admin = await _current_user(request)
|
|
if admin is None:
|
|
return RedirectResponse("/login", status_code=303)
|
|
if not admin.is_admin:
|
|
raise HTTPException(status_code=403, detail="Admins only")
|
|
|
|
async with session_scope() as session:
|
|
existing = await get_user_by_username(session, username.strip())
|
|
if existing is not None:
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"admin.html",
|
|
{"user": admin, "error": "Username already exists."},
|
|
status_code=400,
|
|
)
|
|
user = User(
|
|
username=username.strip(),
|
|
full_name=full_name.strip() or username.strip(),
|
|
password_hash=auth.hash_password(password),
|
|
)
|
|
session.add(user)
|
|
await session.commit()
|
|
new_username = user.username
|
|
# Provision bucket in background. User creation must never fail because a
|
|
# background broker hiccup ; log instead so provisioning can be retried.
|
|
try:
|
|
ensure_user_bucket.delay(new_username)
|
|
except Exception:
|
|
logger.warning("Could not enqueue bucket provisioning for %r (broker down?)", new_username)
|
|
return RedirectResponse("/admin", status_code=303)
|
|
|
|
|
|
|
|
@app.get("/admin/pi", response_class=HTMLResponse)
|
|
async def admin_pi(request: Request) -> HTMLResponse:
|
|
"""Admin: Pi Dashboard — agent session cards sourced from ~/.pi/agent/dashboard.
|
|
|
|
Files are synced from the pi host into /pi-dashboard (mounted ro). Shows
|
|
session name, machine, status, last tool, last-seen, cost estimate.
|
|
"""
|
|
user = await _current_user(request)
|
|
if user is None:
|
|
return RedirectResponse("/login", status_code=303)
|
|
if not user.is_admin:
|
|
raise HTTPException(status_code=403, detail="Admins only")
|
|
pi_dir = Path(os.getenv("PI_DASHBOARD_DIR", "/pi-dashboard"))
|
|
sessions = []
|
|
if pi_dir.is_dir():
|
|
for f in sorted(pi_dir.glob("*.json")):
|
|
try:
|
|
sessions.append(json.loads(f.read_text(encoding="utf-8")))
|
|
except Exception:
|
|
continue
|
|
sessions.sort(key=lambda s: (s.get("last_seen_at") or ""), reverse=True)
|
|
return templates.TemplateResponse(
|
|
request, "admin_pi.html", {"user": user, "sessions": sessions, "pi_dir": str(pi_dir)}
|
|
)
|
|
|
|
@app.post("/admin/users/rename")
|
|
async def admin_rename_user(
|
|
request: Request,
|
|
old_username: Annotated[str, Form()],
|
|
new_username: Annotated[str, Form()],
|
|
) -> HTMLResponse:
|
|
"""Admin: rename a user's login username (keeps password, perms, bucket name)."""
|
|
admin = await _current_user(request)
|
|
if admin is None:
|
|
return RedirectResponse("/login", status_code=303)
|
|
if not admin.is_admin:
|
|
raise HTTPException(status_code=403, detail="Admins only")
|
|
old_username = old_username.strip()
|
|
new_username = new_username.strip()
|
|
error = None
|
|
async with session_scope() as session:
|
|
target = await get_user_by_username(session, old_username)
|
|
if target is None:
|
|
error = f"User {old_username!r} not found."
|
|
elif new_username == old_username:
|
|
error = "No change."
|
|
elif await get_user_by_username(session, new_username) is not None:
|
|
error = f"Username {new_username!r} is already taken."
|
|
else:
|
|
target.username = new_username
|
|
await session.commit()
|
|
logger.info("Admin %s renamed user %s -> %s", admin.username, old_username, new_username)
|
|
async with session_scope() as session:
|
|
result = await session.scalars(select(User).order_by(User.username))
|
|
users = list(result)
|
|
return templates.TemplateResponse(
|
|
request, "admin.html", {"user": admin, "users": users, "error": error},
|
|
status_code=400 if error else 200,
|
|
)
|
|
|
|
|
|
_AUDIO_EXT = {".wav", ".mp3", ".flac", ".ogg", ".m4a", ".aiff", ".aif", ".opus"}
|
|
|
|
|
|
@app.get("/transcriber", response_class=HTMLResponse)
|
|
async def transcriber_page(request: Request) -> HTMLResponse:
|
|
user = await _current_user(request)
|
|
if user is None:
|
|
return RedirectResponse("/login", status_code=303)
|
|
bucket = s3_bucket_name(user.username)
|
|
all_keys = await run_in_threadpool(list_prefix, bucket, "", 400)
|
|
audio_files = [k for k in all_keys
|
|
if k.lower().endswith(tuple(_AUDIO_EXT))]
|
|
transcriptions = [k for k in all_keys
|
|
if k.startswith("transcriptions/") or k.startswith("sheetmusic/")]
|
|
return templates.TemplateResponse(
|
|
request, "transcriber.html",
|
|
{"user": user, "bucket": bucket, "audio_files": audio_files,
|
|
"transcriptions": transcriptions, "status": request.query_params.get("status", "")},
|
|
)
|
|
|
|
|
|
@app.post("/transcriber/transcribe")
|
|
async def transcriber_transcribe(
|
|
request: Request,
|
|
key: Annotated[str, Form()],
|
|
) -> HTMLResponse:
|
|
user = await _current_user(request)
|
|
if user is None:
|
|
return RedirectResponse("/login", status_code=303)
|
|
bucket = s3_bucket_name(user.username)
|
|
try:
|
|
transcribe_audio.delay(bucket, key, user.username)
|
|
except Exception:
|
|
logger.warning("could not enqueue transcription (broker down?)", exc_info=True)
|
|
return RedirectResponse("/transcriber?status=started", status_code=303)
|
|
|
|
|
|
@app.post("/transcriber/upload")
|
|
async def transcriber_upload(
|
|
request: Request,
|
|
file: Annotated[UploadFile, File()],
|
|
) -> HTMLResponse:
|
|
user = await _current_user(request)
|
|
if user is None:
|
|
return RedirectResponse("/login", status_code=303)
|
|
import uuid
|
|
bucket = s3_bucket_name(user.username)
|
|
safe = (file.filename or "audio").replace(" ", "_")
|
|
key = f"audio/{uuid.uuid4().hex[:8]}-{safe}"
|
|
content = await file.read()
|
|
await run_in_threadpool(
|
|
lambda: s3_client().put_object(Bucket=bucket, Key=key, Body=content)
|
|
)
|
|
try:
|
|
transcribe_audio.delay(bucket, key, user.username)
|
|
except Exception:
|
|
logger.warning("could not enqueue transcription after upload", exc_info=True)
|
|
return RedirectResponse("/transcriber?status=started", status_code=303)
|
|
|
|
|
|
@app.get("/transcriber/download")
|
|
async def transcriber_download(request: Request, key: str):
|
|
user = await _current_user(request)
|
|
if user is None:
|
|
return RedirectResponse("/login", status_code=303)
|
|
bucket = s3_bucket_name(user.username)
|
|
try:
|
|
data = await run_in_threadpool(
|
|
lambda: (
|
|
s3_client().get_object(Bucket=bucket, Key=key)["Body"]
|
|
.read()
|
|
)
|
|
)
|
|
except Exception:
|
|
return HTMLResponse("not found", status_code=404)
|
|
fname = key.rsplit("/", 1)[-1]
|
|
return Response(content=data, media_type="audio/midi")
|
|
|
|
|
|
|
|
@app.post("/transcriber/sheetmusic")
|
|
async def transcriber_sheetmusic(
|
|
request: Request,
|
|
key: Annotated[str, Form()],
|
|
) -> HTMLResponse:
|
|
user = await _current_user(request)
|
|
if user is None:
|
|
return RedirectResponse("/login", status_code=303)
|
|
bucket = s3_bucket_name(user.username)
|
|
try:
|
|
transcribe_sheetmusic.delay(bucket, key, user.username)
|
|
except Exception:
|
|
logger.warning("could not enqueue sheet-music transcription", exc_info=True)
|
|
return RedirectResponse("/transcriber?status=started-mus", status_code=303)
|
|
|
|
|
|
@app.get("/api/status")
|
|
async def api_status(request: Request) -> HTMLResponse:
|
|
"""HTMX-polled snippet: updates each tool card's status dot (not full page)."""
|
|
user = await _current_user(request)
|
|
if user is None:
|
|
return HTMLResponse("", status_code=401)
|
|
groups = tools_for_user(user)
|
|
# Status is static for now; swap `status` for a live probe later.
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"partials/_status.html",
|
|
{"sections": [{"tools": g} for _, g in groups]},
|
|
)
|
|
|
|
|
|
@app.get("/healthz")
|
|
async def healthz() -> dict:
|
|
return {"ok": True, "app": settings.APP_NAME} |