Files
devplacepy/devplacepy/database/users.py
T
retoor 571a0485c5
DevPlace CI / test (push) Failing after 58m59s
Fix circular import, primary-admin NULL trap, and add gateway quota reset
Restores a working import graph and closes two data-correctness bugs, plus
adds a reset for the AI gateway's rolling 24h spend.

Circular import: database/__init__ -> engagement -> content -> utils ->
database made the package unimportable. get_project_devlog moves out of
database/engagement.py into content.py, where enrich_items already lives.

Primary administrator: _can_hold_primary_admin read is_active with
bool(row.get("is_active")), so an admin row whose is_active column is SQL
NULL (any row predating the column) was treated as deactivated and skipped.
Every other site defaults an unknown is_active to active; this one now does
too.

Profile JSON: xp_next_level and xp_progress_pct were computed but only put on
the top-level context, never on profile_user, so they serialised as null even
though UserOut declares them and the API docs document them as embedded there.

Gateway quota reset: a cap previously lifted only with the passage of time.
quota.reset upserts a watermark row into gateway_quota_resets, scoped by the
same three nullable dimensions as a quota rule, and spent_24h sums from
max(24h cutoff, watermark). No ledger row is deleted, so the cost analytics on
/admin/ai-usage stay intact. Reaches every surface: POST
/admin/gateway/quota-resets, a per-rule Reset spend button, the Devii tool
gateway_quota_reset (confirm-gated), devplace gateway quota reset, and the API
docs. Admin's Reset all quotas now stamps a global gateway watermark too,
which is what a caller stuck on "AI gateway daily quota exceeded" needed.

Startup: _backfill_gamification swept every xp=0 user on every boot in every
worker and could never converge, since a user with no content earns no XP.
It now intersects pending users with _milestone_candidates(). db.tables is a
live reflection, so it is hoisted out of the loops that probed it per row.

Docker: the dependency layer now depends on pyproject.toml only, so a source
edit no longer reinstalls every dependency and re-downloads Chromium.
Adds start_interval so the healthcheck probes during the start period, and a
docker-reload target, since docker-up does not restart an unchanged container.

Adds events.md, the audit event catalogue that README, CLAUDE.md, the quiz
docs and the tooling all referenced but which never existed: 288 keys across
28 categories, including the families built from a variable at the call site.

Test fixes: both devlog helpers dated post 0 as the newest while the tests
assumed post 2 was; a profile login posted username= to a form that takes
email=; a devlog assertion matched six buttons under strict mode; and the
primary-admin tests seeded founders newer than the back-dated fixture admin,
so they only passed without the api tier.

Full suite: 2989 passed, 1 skipped.
2026-07-27 11:17:48 +02:00

127 lines
3.8 KiB
Python

# retoor <retoor@molodetz.nl>
from .core import TTLCache, bump_cache_version, db, sync_local_cache
def get_users_by_uids(uids):
if not uids or "users" not in db.tables:
return {}
users = db["users"]
if "uid" not in users.columns:
return {}
seen = set()
unique = [u for u in uids if u not in seen and not seen.add(u)]
return {u["uid"]: u for u in users.find(users.table.columns.uid.in_(unique))}
_admins_cache = TTLCache(ttl=300, max_size=4)
# The primary administrator must be an account that can actually authenticate, so scan a
# few of the earliest admins and skip any that are soft-deleted or deactivated.
PRIMARY_ADMIN_CANDIDATES = 50
def invalidate_admins_cache() -> None:
_admins_cache.clear()
bump_cache_version("admins")
def get_admin_uids():
sync_local_cache("admins", _admins_cache)
cached = _admins_cache.get("uids")
if cached is not None:
return list(cached)
if "users" not in db.tables:
return []
rows = db.query("SELECT uid FROM users WHERE role = 'Admin'")
uids = [row["uid"] for row in rows]
_admins_cache.set("uids", uids)
return list(uids)
def set_user_timezone(user_uid: str, tz_name: str) -> None:
if "users" not in db.tables or not user_uid or not tz_name:
return
users = db["users"]
if not users.has_column("timezone"):
users.create_column_by_example("timezone", "")
current = users.find_one(uid=user_uid)
if current and current.get("timezone") == tz_name:
return
users.update({"uid": user_uid, "timezone": tz_name}, ["uid"])
def set_last_seen(user_uid: str, iso: str) -> None:
if "users" not in db.tables or not user_uid or not iso:
return
users = db["users"]
if not users.has_column("last_seen"):
users.create_column_by_example("last_seen", "")
users.update({"uid": user_uid, "last_seen": iso}, ["uid"])
def get_online_users(cutoff_iso: str, limit: int = 30) -> list:
if "users" not in db.tables:
return []
users = db["users"]
if "last_seen" not in users.columns:
return []
return list(
users.find(
last_seen={">=": cutoff_iso},
order_by=["username"],
_limit=limit,
)
)
def _can_hold_primary_admin(row, tracks_active):
if row.get("deleted_at"):
return False
if not tracks_active:
return True
is_active = row.get("is_active")
return is_active is None or bool(is_active)
def get_primary_admin_uid():
sync_local_cache("admins", _admins_cache)
cached = _admins_cache.get("primary")
if cached is not None:
return cached or None
if "users" not in db.tables:
return None
rows = list(
db.query(
"SELECT * FROM users WHERE role = 'Admin' "
"ORDER BY (created_at IS NULL OR created_at = ''), created_at ASC, id ASC "
"LIMIT :cap",
cap=PRIMARY_ADMIN_CANDIDATES,
)
)
tracks_active = "is_active" in db["users"].columns
primary = next(
(row["uid"] for row in rows if _can_hold_primary_admin(row, tracks_active)),
None,
)
_admins_cache.set("primary", primary or "")
return primary
def search_users_by_username(q, *, exclude_uid=None, limit=10):
if not q or "users" not in db.tables:
return []
if exclude_uid is not None:
rows = db.query(
"SELECT uid, username FROM users WHERE username LIKE :q AND uid != :me LIMIT :limit",
q=f"%{q}%",
me=exclude_uid,
limit=limit,
)
else:
rows = db.query(
"SELECT uid, username FROM users WHERE username LIKE :q LIMIT :limit",
q=f"%{q}%",
limit=limit,
)
return [{"uid": r["uid"], "username": r["username"]} for r in rows]