Rules editor: admins can edit items, skills, professions (and categories/groups) in the app

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
This commit is contained in:
Claude
2026-10-22 14:48:55 +00:00
parent a8c9740a05
commit 78f0de5333
9 changed files with 453 additions and 6 deletions
+107 -4
View File
@@ -13,6 +13,7 @@ from fastapi.staticfiles import StaticFiles
from . import auth
from .derive import compute
from . import rules_edit
from .export import to_docx, to_pdf
BASE = Path(__file__).resolve().parent.parent
@@ -41,11 +42,30 @@ TRUST_PROXY = os.environ.get("AW_TRUST_PROXY", "") == "1" # behind a reverse pr
def load_rules() -> dict:
# Re-read on every request so rules.json can be edited without a restart.
# Changes made in the rules editor live next to the database (writable), the shipped rules.json stays untouched.
OVERRIDES_PATH = Path(DB_PATH).parent / "rules_overrides.json"
def base_rules() -> dict:
return json.loads(RULES_PATH.read_text(encoding="utf-8"))
def read_overrides() -> dict:
try:
return json.loads(OVERRIDES_PATH.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
def load_rules() -> dict:
# Re-read on every request so rules.json can be edited without a restart; editor changes replace whole sections.
r = base_rules()
for sec, items in read_overrides().items():
if sec in rules_edit.SECTIONS:
r[sec] = items
return r
def db() -> sqlite3.Connection:
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
con = sqlite3.connect(DB_PATH)
@@ -154,6 +174,16 @@ async def security(request: Request, call_next):
return resp
def is_admin(user: dict) -> bool:
"""Admins (rules editor): the names in AW_ADMINS, or without that setting the very first account."""
names = {n.strip().lower() for n in os.environ.get("AW_ADMINS", "").split(",") if n.strip()}
if names:
return user["username"].lower() in names
with db() as con:
first = con.execute("SELECT id FROM users ORDER BY id LIMIT 1").fetchone()
return bool(first) and first["id"] == user["id"]
def current_user(request: Request) -> dict:
with db() as con:
user = auth.user_for_token(con, request.cookies.get(auth.SESSION_COOKIE))
@@ -187,6 +217,8 @@ def check_password_rules(pw: str):
def me(request: Request):
with db() as con:
user = auth.user_for_token(con, request.cookies.get(auth.SESSION_COOKIE))
if user:
user = {**user, "admin": is_admin(user)}
return {"user": user, "registration_open": bool(auth.registration_code())}
@@ -213,7 +245,8 @@ def register(request: Request, response: Response, body: dict = Body(...)):
(username, auth.hash_password(password), time.time()))
token = auth.create_session(con, cur.lastrowid)
set_cookie(request, response, token)
return {"user": {"id": cur.lastrowid, "username": username}}
u = {"id": cur.lastrowid, "username": username}
return {"user": {**u, "admin": is_admin(u)}}
@app.post("/api/auth/login")
@@ -235,7 +268,8 @@ def login(request: Request, response: Response, body: dict = Body(...)):
token = auth.create_session(con, row["id"])
limiter.reset(keys[1])
set_cookie(request, response, token)
return {"user": {"id": row["id"], "username": row["username"]}}
u = {"id": row["id"], "username": row["username"]}
return {"user": {**u, "admin": is_admin(u)}}
@app.post("/api/auth/logout")
@@ -883,6 +917,75 @@ def export(fmt: str, ch: dict = Body(...), user: dict = Depends(current_user)):
headers={"Content-Disposition": f'attachment; filename="{_filename(ch, fmt)}"'})
# ---- rules editor (admins only)
def admin_user(user: dict = Depends(current_user)) -> dict:
if not is_admin(user):
raise HTTPException(403, "Only admins can edit the rules")
return user
@api.get("/admin/rules")
def editor_state(user: dict = Depends(admin_user)):
"""The editable sections as they are now, and which of them differ from the shipped rules.json."""
r = load_rules()
return {"sections": {k: r[k] for k in rules_edit.SECTIONS}, "customized": [k for k in read_overrides() if k in rules_edit.SECTIONS]}
def _all_characters(con) -> list:
return [json.loads(x["data"]) for x in con.execute("SELECT data FROM characters")]
def _save_overrides(data: dict):
OVERRIDES_PATH.parent.mkdir(parents=True, exist_ok=True)
if OVERRIDES_PATH.exists():
OVERRIDES_PATH.with_suffix(".json.bak").write_bytes(OVERRIDES_PATH.read_bytes())
tmp = OVERRIDES_PATH.with_suffix(".json.tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8")
os.replace(tmp, OVERRIDES_PATH)
def _apply(section: str, new: list, confirm: bool):
r = load_rules()
old_ids = {x["id"] for x in r[section]}
removed = old_ids - {x["id"] for x in new}
bad = rules_edit.dangling(section, new, r)
if bad:
raise HTTPException(422, "Cannot save: " + "; ".join(bad[:5]) + (" ..." if len(bad) > 5 else ""))
with db() as con:
used = rules_edit.usage(_all_characters(con), section, removed)
if used and not confirm:
raise HTTPException(409, f"{used} saved character{'s' if used != 1 else ''} use{'' if used != 1 else 's'} entries you are removing. "
"Their data stays in the characters but the removed entries disappear from the sheets.")
return used
@api.put("/admin/rules/{section}")
def editor_save(section: str, body: dict = Body(...), user: dict = Depends(admin_user)):
try:
new = rules_edit.clean(section, body.get("items"), load_rules())
except rules_edit.RuleError as e:
raise HTTPException(422, str(e))
_apply(section, new, bool(body.get("confirm")))
data = read_overrides()
data[section] = new
_save_overrides(data)
return {"ok": True, "count": len(new)}
@api.delete("/admin/rules/{section}")
def editor_reset(section: str, confirm: bool = False, user: dict = Depends(admin_user)):
"""Back to the shipped rules.json for this section."""
if section not in rules_edit.SECTIONS:
raise HTTPException(404, "Unknown section")
data = read_overrides()
if section in data:
_apply(section, base_rules()[section], confirm)
del data[section]
_save_overrides(data)
return {"ok": True}
app.include_router(api)