Member invites, gallery resync, content and operator docs

- Invite flow: admin issue/revoke on join requests, public single-use
  claim links (hash-only tokens, 7-day expiry, no state reveal), claim
  creates the Member account and marks the request accepted
- Gallery: admin status page plus resync endpoint; sync refreshes
  thumbnails whose content changed; tools/gallery contract and checker
- Docs: public content page, admin-only operator runbook, api.md
  invite/gallery sections, all routes in the live API docs, docs
  reachability gate test
- Screenshots cover the new pages; version 1.0.18
This commit is contained in:
2026-10-06 02:56:08 +02:00
parent e512556703
commit 9fb4d65787
36 changed files with 1147 additions and 19 deletions
+2 -1
View File
@@ -1,9 +1,10 @@
# retoor <retoor@molodetz.nl>
from molodetz.routers.admin import audit, backups, joins, posts, services, settings, stats, trash, users
from molodetz.routers.admin import audit, backups, gallery, joins, posts, services, settings, stats, trash, users
from molodetz.routers.admin.index import router
router.include_router(posts.router)
router.include_router(joins.router)
router.include_router(gallery.router)
router.include_router(services.router)
router.include_router(trash.router)
router.include_router(settings.router)
+38
View File
@@ -0,0 +1,38 @@
# retoor <retoor@molodetz.nl>
from fastapi import APIRouter, Request
from molodetz.database import count_media
from molodetz.gallery import missing_sources, sync_gallery
from molodetz.responses import action_result, respond
from molodetz.routers.admin._shared import admin_seo
from molodetz.schemas.admin import GalleryStatusOut
from molodetz.utils.audit import record
from molodetz.utils.auth import require_admin
router = APIRouter()
def _status():
return {
"flyers": count_media("flyer"),
"memes": count_media("meme"),
"missing": missing_sources(),
}
@router.get("/gallery")
async def gallery_status(request: Request):
require_admin(request)
ctx = dict(_status())
ctx["seo"] = admin_seo(request, "Gallery")
return respond(request, "admin/gallery.html", ctx, model=GalleryStatusOut)
@router.post("/gallery/resync")
async def gallery_resync(request: Request):
user = require_admin(request)
synced = sync_gallery()
data = dict(_status())
data["synced"] = synced
record(request, "gallery.resync", payload=data, user=user)
return action_result(request, "/admin/gallery", data=data)
+3 -1
View File
@@ -2,7 +2,7 @@
from fastapi import APIRouter, Request
from molodetz.constants import SOFT_DELETE_TABLES
from molodetz.database import all_posts, count_deleted, count_open_join_requests, count_users
from molodetz.database import all_posts, count_deleted, count_media, count_open_join_requests, count_users
from molodetz.responses import respond
from molodetz.routers.admin._shared import admin_seo
from molodetz.schemas.admin import AdminIndexOut
@@ -19,6 +19,8 @@ async def admin_index(request: Request):
"open_joins": count_open_join_requests(),
"user_count": count_users(),
"deleted_count": sum(count_deleted(name) for name in SOFT_DELETE_TABLES),
"flyer_count": count_media("flyer"),
"meme_count": count_media("meme"),
"seo": admin_seo(request, "Admin"),
}
return respond(request, "admin/index.html", ctx, model=AdminIndexOut)
+73 -4
View File
@@ -1,23 +1,47 @@
# retoor <retoor@molodetz.nl>
from fastapi import APIRouter, Request
from fastapi import APIRouter, HTTPException, Request
from molodetz.database import get_join_request, list_join_requests, update_join_request
from molodetz.database import (
create_invite,
get_int_setting,
get_join_request,
list_join_requests,
open_invite_for_join,
revoke_open_invites,
suggest_username,
update_join_request,
)
from molodetz.dependencies import json_or_form
from molodetz.link_check import check_repo_link
from molodetz.models import JoinStatusForm
from molodetz.responses import action_result, respond
from molodetz.responses import action_result, respond, wants_json
from molodetz.routers.admin._shared import admin_seo
from molodetz.schemas.joins import JoinListOut
from molodetz.templating import templates
from molodetz.utils.audit import record
from molodetz.utils.auth import not_found, require_admin
router = APIRouter()
def _invite_contact_email(contact):
contact = (contact or "").strip()
if "@" in contact and " " not in contact and "." in contact.split("@")[-1]:
return contact.lower()
return ""
@router.get("/joins")
async def joins_index(request: Request):
require_admin(request)
return respond(request, "admin/joins.html", {"requests": list_join_requests(), "seo": admin_seo(request, "Join requests")}, model=JoinListOut)
rows = []
for row in list_join_requests():
item = dict(row)
invite = open_invite_for_join(row["uid"])
item["invite_open"] = invite is not None
item["invite_expires_at"] = invite["expires_at"] if invite else None
rows.append(item)
return respond(request, "admin/joins.html", {"requests": rows, "seo": admin_seo(request, "Join requests")}, model=JoinListOut)
@router.post("/joins/{uid}/status")
@@ -41,3 +65,48 @@ async def joins_check(request: Request, uid: str):
result = await check_repo_link(row)
record(request, "join.request.check", payload=result, targets=[("join_request", uid)], user=user)
return action_result(request, "/admin/joins", data=result)
@router.post("/joins/{uid}/invite")
async def joins_invite(request: Request, uid: str):
user = require_admin(request)
row = get_join_request(uid)
if row is None:
not_found("Join request not found")
if row.get("status") == "declined":
raise HTTPException(409, "Cannot invite a declined request")
days = get_int_setting("invite_expiry_days", 7)
invite, token = create_invite(
uid,
_invite_contact_email(row.get("contact")),
suggest_username(row.get("name")),
user["uid"],
days=days,
)
claim_url = str(request.base_url).rstrip("/") + f"/invite/{token}"
record(
request,
"join.invite.issued",
payload={"expires_at": invite["expires_at"]},
targets=[("join_request", uid)],
user=user,
)
data = {"claim_url": claim_url, "expires_at": invite["expires_at"]}
if wants_json(request):
return action_result(request, "/admin/joins", data=data)
return templates.TemplateResponse(
request,
"admin/invite_issued.html",
{"join": row, "claim_url": claim_url, "expires_at": invite["expires_at"], "seo": admin_seo(request, "Invite issued")},
)
@router.post("/joins/{uid}/invite/revoke")
async def joins_invite_revoke(request: Request, uid: str):
user = require_admin(request)
row = get_join_request(uid)
if row is None:
not_found("Join request not found")
revoked = revoke_open_invites(uid, user["uid"])
record(request, "join.invite.revoked", payload={"revoked": revoked}, targets=[("join_request", uid)], user=user)
return action_result(request, "/admin/joins", data={"revoked": revoked})