Admin step B: stats endpoint + /admin dashboard
- users.is_admin (+ migration); admin = is_admin OR email in GOODNEWS_ADMIN_EMAILS (normalized). is_admin exposed on /api/auth/me. Server-authorized GET /api/admin/stats (403 for non-admins). - queries.admin_stats: visitors (today/7d/30d), returning vs one-and-done, top opened articles, popular groupings + topics (derived from article_id at query time), share breakdown, daily opens/visits trend — all aggregate, no PII. - /admin page (gated, redirects non-admins): stat cards, CSS bar lists, a daily trend; "Admin dashboard" link on /account for admins. 129 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -59,6 +59,8 @@ SESSION_COOKIE = "ub_session"
|
||||
OAUTH_COOKIE = "ub_oauth"
|
||||
SESSION_MAX_AGE = int(auth.SESSION_TTL.total_seconds())
|
||||
SESSION_SECRET = os.environ.get("GOODNEWS_SESSION_SECRET", "dev-insecure-secret")
|
||||
# Emails that are always admins (normalized), in addition to users.is_admin.
|
||||
ADMIN_EMAILS = {e.strip().lower() for e in os.environ.get("GOODNEWS_ADMIN_EMAILS", "").split(",") if e.strip()}
|
||||
# Secure cookies in production (https); off for http (local/test) so they round-trip.
|
||||
_COOKIE_SECURE = PUBLIC_BASE_URL.startswith("https")
|
||||
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||
@@ -104,12 +106,24 @@ def _require_user(conn: sqlite3.Connection, request: Request) -> sqlite3.Row:
|
||||
return user
|
||||
|
||||
|
||||
def _is_admin(user: sqlite3.Row) -> bool:
|
||||
return bool(user["is_admin"]) or auth.normalize_email(user["email"]) in ADMIN_EMAILS
|
||||
|
||||
|
||||
def _require_admin(conn: sqlite3.Connection, request: Request) -> sqlite3.Row:
|
||||
user = _require_user(conn, request)
|
||||
if not _is_admin(user):
|
||||
raise HTTPException(status_code=403, detail="Admins only.")
|
||||
return user
|
||||
|
||||
|
||||
def _user_out(user: sqlite3.Row) -> dict:
|
||||
return {
|
||||
"id": user["id"],
|
||||
"email": user["email"],
|
||||
"display_name": user["display_name"],
|
||||
"avatar_url": user["avatar_url"],
|
||||
"is_admin": _is_admin(user),
|
||||
}
|
||||
|
||||
|
||||
@@ -316,6 +330,7 @@ class UserOut(BaseModel):
|
||||
email: str
|
||||
display_name: str | None = None
|
||||
avatar_url: str | None = None
|
||||
is_admin: bool = False
|
||||
|
||||
|
||||
class SessionOut(BaseModel):
|
||||
@@ -697,6 +712,12 @@ def create_app() -> FastAPI:
|
||||
conn.commit()
|
||||
return {"ok": True} # always identical; dedup'd by the unique key
|
||||
|
||||
@app.get("/api/admin/stats")
|
||||
def admin_stats(request: Request) -> dict:
|
||||
with get_conn() as conn:
|
||||
_require_admin(conn, request)
|
||||
return queries.admin_stats(conn)
|
||||
|
||||
@app.get("/api/summary/{article_id}")
|
||||
def article_summary(article_id: int, background_tasks: BackgroundTasks) -> dict:
|
||||
with get_conn() as conn:
|
||||
|
||||
Reference in New Issue
Block a user