Molodetz blogrol volgens DPP-template

This commit is contained in:
2026-10-05 09:36:20 +02:00
commit f290a4c766
277 changed files with 15758 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
# retoor <retoor@molodetz.nl>
from molodetz.routers.admin import audit, backups, 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(services.router)
router.include_router(trash.router)
router.include_router(settings.router)
router.include_router(users.router)
router.include_router(audit.router)
router.include_router(backups.router)
router.include_router(stats.router)
+6
View File
@@ -0,0 +1,6 @@
# retoor <retoor@molodetz.nl>
from molodetz.seo import base_seo_context
def admin_seo(request, title):
return base_seo_context(request, title, robots="noindex,nofollow")
+17
View File
@@ -0,0 +1,17 @@
# retoor <retoor@molodetz.nl>
from fastapi import APIRouter, Request
from molodetz.database import list_audit
from molodetz.responses import respond
from molodetz.routers.admin._shared import admin_seo
from molodetz.schemas.admin import AuditOut
from molodetz.utils.auth import require_admin
router = APIRouter()
@router.get("/audit")
async def audit_index(request: Request):
require_admin(request)
category = request.query_params.get("category") or None
return respond(request, "admin/audit.html", {"rows": list_audit(200, category), "category": category, "seo": admin_seo(request, "Auditlog")}, model=AuditOut)
+60
View File
@@ -0,0 +1,60 @@
# retoor <retoor@molodetz.nl>
import aiofiles
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from molodetz.attachments import format_size
from molodetz.database import enqueue_backup, get_backup, list_backups
from molodetz.dependencies import json_or_form
from molodetz.models import BackupForm
from molodetz.responses import action_result, respond
from molodetz.routers.admin._shared import admin_seo
from molodetz.schemas.admin import BackupsOut
from molodetz.services.backup import backup_path
from molodetz.utils.audit import record
from molodetz.utils.auth import is_primary_admin, not_found, require_admin
router = APIRouter()
@router.get("/backups")
async def backups_index(request: Request):
user = require_admin(request)
rows = [{**row, "size_label": format_size(row.get("size_bytes"))} for row in list_backups()]
ctx = {"backups": rows, "can_download": is_primary_admin(user), "seo": admin_seo(request, "Back-ups")}
return respond(request, "admin/backups.html", ctx, model=BackupsOut)
@router.post("/backups")
async def backups_enqueue(request: Request):
user = require_admin(request)
data = await json_or_form(request, BackupForm)
row = enqueue_backup(data.target, user["uid"])
record(request, "backup.enqueue", payload={"target": data.target}, targets=[("backup", row["uid"])], user=user)
return action_result(request, "/admin/backups", data={"uid": row["uid"]})
@router.get("/backups/{uid}/download")
async def backups_download(request: Request, uid: str):
user = require_admin(request)
if not is_primary_admin(user):
record(request, "backup.download", result="denied", targets=[("backup", uid)], user=user)
raise HTTPException(403, "Alleen de primaire beheerder")
row = get_backup(uid)
if row is None or row.get("status") != "done":
not_found("Back-up niet gevonden")
path = backup_path(row)
if not path.exists():
not_found("Archief ontbreekt")
record(request, "backup.download", targets=[("backup", uid)], user=user)
return StreamingResponse(
_stream_file(path),
media_type="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="{row["stored_name"]}"', "Content-Length": str(path.stat().st_size)},
)
async def _stream_file(path, chunk_size=1024 * 256):
async with aiofiles.open(path, "rb") as handle:
while chunk := await handle.read(chunk_size):
yield chunk
+24
View File
@@ -0,0 +1,24 @@
# retoor <retoor@molodetz.nl>
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.responses import respond
from molodetz.routers.admin._shared import admin_seo
from molodetz.schemas.admin import AdminIndexOut
from molodetz.utils.auth import require_admin
router = APIRouter()
@router.get("")
async def admin_index(request: Request):
require_admin(request)
ctx = {
"post_count": len(all_posts()),
"open_joins": count_open_join_requests(),
"user_count": count_users(),
"deleted_count": sum(count_deleted(name) for name in SOFT_DELETE_TABLES),
"seo": admin_seo(request, "Beheer"),
}
return respond(request, "admin/index.html", ctx, model=AdminIndexOut)
+43
View File
@@ -0,0 +1,43 @@
# retoor <retoor@molodetz.nl>
from fastapi import APIRouter, Request
from molodetz.database import get_join_request, list_join_requests, 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.routers.admin._shared import admin_seo
from molodetz.schemas.joins import JoinListOut
from molodetz.utils.audit import record
from molodetz.utils.auth import not_found, require_admin
router = APIRouter()
@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, "Aanmeldingen")}, model=JoinListOut)
@router.post("/joins/{uid}/status")
async def joins_status(request: Request, uid: str):
user = require_admin(request)
row = get_join_request(uid)
if row is None:
not_found("Aanmelding niet gevonden")
data = await json_or_form(request, JoinStatusForm)
update_join_request(uid, status=data.status)
record(request, "join.request.status", payload={"status": data.status}, targets=[("join_request", uid)], user=user)
return action_result(request, "/admin/joins", data={"uid": uid, "status": data.status})
@router.post("/joins/{uid}/check")
async def joins_check(request: Request, uid: str):
user = require_admin(request)
row = get_join_request(uid)
if row is None:
not_found("Aanmelding niet gevonden")
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)
+88
View File
@@ -0,0 +1,88 @@
# retoor <retoor@molodetz.nl>
from fastapi import APIRouter, HTTPException, Request
from molodetz.constants import TOPICS
from molodetz.content import create_post, delete_post, edit_post
from molodetz.database import all_posts, get_post
from molodetz.dependencies import json_or_form
from molodetz.models import PostForm
from molodetz.responses import action_result, respond
from molodetz.routers.admin._shared import admin_seo
from molodetz.schemas.admin import AdminPostsOut
from molodetz.schemas.posts import PostDetailOut
from molodetz.utils.audit import record
from molodetz.utils.auth import is_admin, is_owner, not_found, require_admin
router = APIRouter()
@router.get("/posts")
async def posts_index(request: Request):
require_admin(request)
return respond(request, "admin/posts.html", {"posts": all_posts(), "seo": admin_seo(request, "Berichten")}, model=AdminPostsOut)
@router.get("/posts/new")
async def posts_new(request: Request):
require_admin(request)
ctx = {"post": None, "topics": TOPICS, "errors": [], "seo": admin_seo(request, "Nieuw bericht")}
return respond(request, "admin/post_form.html", ctx, model=PostDetailOut)
@router.post("/posts")
async def posts_create(request: Request):
user = require_admin(request)
data = await json_or_form(request, PostForm)
row = create_post(user, data.title, data.body, data.topic, data.status, data.is_placeholder, request=request)
return action_result(request, "/admin/posts", data={"uid": row["uid"], "slug": row["slug"]})
@router.get("/posts/{uid}/edit")
async def posts_edit(request: Request, uid: str):
require_admin(request)
post = get_post(uid)
if post is None:
not_found("Bericht niet gevonden")
ctx = {"post": post, "topics": TOPICS, "errors": [], "seo": admin_seo(request, "Bericht bewerken")}
return respond(request, "admin/post_form.html", ctx, model=PostDetailOut)
def _owned_post(request, user, uid):
post = get_post(uid)
if post is None:
not_found("Bericht niet gevonden")
if not is_owner(post, user):
record(request, "post.edit.denied", result="denied", targets=[("post", uid)], user=user)
raise HTTPException(403, "Alleen de auteur mag dit bericht bewerken")
return post
@router.post("/posts/{uid}")
async def posts_update(request: Request, uid: str):
user = require_admin(request)
post = _owned_post(request, user, uid)
data = await json_or_form(request, PostForm)
edit_post(post, user, data.title, data.body, data.topic, data.status, data.is_placeholder, request=request)
return action_result(request, "/admin/posts", data={"uid": uid})
@router.post("/posts/{uid}/publish")
async def posts_publish(request: Request, uid: str):
user = require_admin(request)
post = _owned_post(request, user, uid)
status = "draft" if post["status"] == "published" else "published"
edit_post(post, user, post["title"], post["body"], post["topic"], status, post.get("is_placeholder"), request=request)
return action_result(request, "/admin/posts", data={"uid": uid, "status": status})
@router.post("/posts/{uid}/delete")
async def posts_delete(request: Request, uid: str):
user = require_admin(request)
post = get_post(uid)
if post is None:
not_found("Bericht niet gevonden")
if not (is_owner(post, user) or is_admin(user)):
record(request, "post.delete.denied", result="denied", targets=[("post", uid)], user=user)
raise HTTPException(403, "Niet toegestaan")
stamp = delete_post(post, user, request=request)
return action_result(request, "/admin/posts", data={"uid": uid, "stamp": stamp})
+69
View File
@@ -0,0 +1,69 @@
# retoor <retoor@molodetz.nl>
from fastapi import APIRouter, HTTPException, Request
from molodetz.responses import action_result, respond
from molodetz.routers.admin._shared import admin_seo
from molodetz.schemas.admin import ServiceDetailOut, ServicesOut
from molodetz.services.base import manager
from molodetz.utils.audit import record
from molodetz.utils.auth import not_found, require_admin
router = APIRouter()
VERBS = ("start", "stop", "run", "clear")
def _service(name):
service = manager.get(name)
if service is None:
not_found("Onbekende service")
return service
@router.get("/services")
async def services_index(request: Request):
require_admin(request)
return respond(request, "admin/services.html", {"services": manager.describe_all(), "seo": admin_seo(request, "Services")}, model=ServicesOut)
@router.get("/services/{name}")
async def services_detail(request: Request, name: str):
require_admin(request)
service = _service(name)
ctx = {"service": manager.describe(service), "seo": admin_seo(request, service.title)}
return respond(request, "admin/service.html", ctx, model=ServiceDetailOut)
async def _payload(request):
if request.headers.get("content-type", "").startswith("application/json"):
data = await request.json()
return data if isinstance(data, dict) else {}
form = await request.form()
return {key: value for key, value in form.items()}
@router.post("/services/{name}/command")
async def services_command(request: Request, name: str):
user = require_admin(request)
service = _service(name)
verb = (await _payload(request)).get("verb", "")
if verb not in VERBS:
raise HTTPException(400, "Onbekend commando")
if verb in ("start", "stop"):
manager.set_enabled(service.name, verb == "start")
else:
manager.send_command(service.name, verb)
record(request, f"service.command.{verb}", targets=[("service", service.name)], user=user)
return action_result(request, f"/admin/services/{service.name}", data={"verb": verb})
@router.post("/services/{name}/config")
async def services_config(request: Request, name: str):
user = require_admin(request)
service = _service(name)
values = await _payload(request)
try:
updates = manager.save_config(service.name, values)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
record(request, "service.config.save", payload={"keys": sorted(updates)}, targets=[("service", service.name)], user=user)
return action_result(request, f"/admin/services/{service.name}", data={"saved": sorted(updates)})
+43
View File
@@ -0,0 +1,43 @@
# retoor <retoor@molodetz.nl>
from fastapi import APIRouter, Request
from molodetz.database import all_settings, get_setting, set_setting
from molodetz.dependencies import json_or_form
from molodetz.models import SettingsForm
from molodetz.responses import action_result, respond
from molodetz.routers.admin._shared import admin_seo
from molodetz.schemas.admin import SettingsOut
from molodetz.utils.audit import record
from molodetz.utils.auth import require_admin
router = APIRouter()
NUMERIC = ("rate_limit_per_minute", "rate_limit_window_seconds", "session_max_age_days", "session_remember_days", "audit_retention_days")
BOOLEAN = ("maintenance_mode", "friendly_404")
@router.get("/settings")
async def settings_index(request: Request):
require_admin(request)
return respond(request, "admin/settings.html", {"settings": all_settings(), "seo": admin_seo(request, "Instellingen")}, model=SettingsOut)
@router.post("/settings")
async def settings_save(request: Request):
user = require_admin(request)
data = await json_or_form(request, SettingsForm)
changed = []
for key, value in data.model_dump(exclude_none=True).items():
if value == "":
continue
if key in NUMERIC:
try:
value = str(max(1, int(value)))
except ValueError:
continue
if key in BOOLEAN and value not in ("0", "1"):
continue
if get_setting(key) != value:
set_setting(key, value)
changed.append(key)
record(request, "admin.settings.save", payload={"keys": changed}, user=user)
return action_result(request, "/admin/settings", data={"changed": changed})
+17
View File
@@ -0,0 +1,17 @@
# retoor <retoor@molodetz.nl>
from fastapi import APIRouter, Request
from molodetz.database import daily_totals, top_paths
from molodetz.responses import respond
from molodetz.routers.admin._shared import admin_seo
from molodetz.schemas.admin import StatsOut
from molodetz.utils.auth import require_admin
router = APIRouter()
@router.get("/stats")
async def stats_index(request: Request):
require_admin(request)
ctx = {"top_paths": [dict(row) for row in top_paths()], "daily": [dict(row) for row in daily_totals()], "seo": admin_seo(request, "Statistieken")}
return respond(request, "admin/stats.html", ctx, model=StatsOut)
+62
View File
@@ -0,0 +1,62 @@
# retoor <retoor@molodetz.nl>
from fastapi import APIRouter, HTTPException, Request
from molodetz.constants import SOFT_DELETE_TABLES
from molodetz.database import list_deleted, purge_event, restore_event
from molodetz.responses import action_result, respond
from molodetz.routers.admin._shared import admin_seo
from molodetz.schemas.admin import TrashOut
from molodetz.utils.audit import record
from molodetz.utils.auth import require_admin
router = APIRouter()
LABEL_FIELDS = ("title", "name", "username", "message", "filename", "token")
def _label(row):
for key in LABEL_FIELDS:
if row.get(key):
return str(row[key])[:24] if key == "token" else str(row[key])[:120]
return row.get("uid", "")
@router.get("/trash")
async def trash_index(request: Request):
require_admin(request)
items = []
for table in SOFT_DELETE_TABLES:
if table == "sessions":
continue
for row in list_deleted(table):
items.append({"table": table, "uid": row.get("uid"), "label": _label(row), "deleted_at": row.get("deleted_at"), "deleted_by": row.get("deleted_by")})
items.sort(key=lambda item: item["deleted_at"] or "", reverse=True)
return respond(request, "admin/trash.html", {"items": items, "seo": admin_seo(request, "Prullenbak")}, model=TrashOut)
async def _stamp(request):
if request.headers.get("content-type", "").startswith("application/json"):
data = await request.json()
stamp = data.get("stamp") if isinstance(data, dict) else None
else:
stamp = (await request.form()).get("stamp")
if not stamp:
raise HTTPException(400, "stamp ontbreekt")
return str(stamp)
@router.post("/trash/restore")
async def trash_restore(request: Request):
user = require_admin(request)
stamp = await _stamp(request)
tables = restore_event(stamp)
record(request, "admin.trash.restore", payload={"stamp": stamp}, user=user)
return action_result(request, "/admin/trash", data={"tables": tables})
@router.post("/trash/purge")
async def trash_purge(request: Request):
user = require_admin(request)
stamp = await _stamp(request)
purged = purge_event(stamp)
record(request, "admin.trash.purge", payload={"stamp": stamp, "tables": [name for name, _ in purged]}, user=user)
return action_result(request, "/admin/trash", data={"purged": [name for name, _ in purged]})
+35
View File
@@ -0,0 +1,35 @@
# retoor <retoor@molodetz.nl>
from fastapi import APIRouter, HTTPException, Request
from molodetz.database import get_user, list_users, set_role
from molodetz.dependencies import json_or_form
from molodetz.models import RoleForm
from molodetz.responses import action_result, respond
from molodetz.routers.admin._shared import admin_seo
from molodetz.schemas.admin import AdminUsersOut
from molodetz.utils.audit import record
from molodetz.utils.auth import invalidate_user_cache, is_senior_admin, not_found, require_admin
router = APIRouter()
@router.get("/users")
async def users_index(request: Request):
require_admin(request)
return respond(request, "admin/users.html", {"users": list_users(), "seo": admin_seo(request, "Accounts")}, model=AdminUsersOut)
@router.post("/users/{uid}/role")
async def users_role(request: Request, uid: str):
actor = require_admin(request)
target = get_user(uid)
if target is None:
not_found("Account niet gevonden")
data = await json_or_form(request, RoleForm)
if is_senior_admin(actor, target) or (target["uid"] == actor["uid"] and data.role != "Admin"):
record(request, "admin.user.role", result="denied", targets=[("user", uid)], user=actor)
raise HTTPException(403, "Je kunt een senior beheerder niet beheren")
set_role(uid, data.role)
invalidate_user_cache(propagate=True)
record(request, "admin.user.role", payload={"role": data.role}, targets=[("user", uid)], user=actor)
return action_result(request, "/admin/users", data={"uid": uid, "role": data.role})