DevPlace CI / test (push) Failing after 58m59s
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.
192 lines
6.1 KiB
Python
192 lines
6.1 KiB
Python
# retoor <retoor@molodetz.nl>
|
|
|
|
import os
|
|
|
|
from .core import TTLCache, _in_clause, _now_iso, db, get_table
|
|
from .users import get_users_by_uids
|
|
from .soft_delete import soft_delete, soft_delete_in
|
|
|
|
|
|
VOTABLE_TARGETS: dict[str, str] = {
|
|
"post": "posts",
|
|
"project": "projects",
|
|
"gist": "gists",
|
|
"comment": "comments",
|
|
"quiz": "quizzes",
|
|
}
|
|
|
|
|
|
STAR_TARGETS: set[str] = {"post", "project", "gist", "quiz"}
|
|
|
|
|
|
RANKING_TTL = int(os.environ.get("DEVPLACE_RANKING_TTL", "60"))
|
|
|
|
|
|
_authors_cache = TTLCache(ttl=RANKING_TTL, max_size=200)
|
|
|
|
|
|
_stars_cache = TTLCache(ttl=15, max_size=2000)
|
|
|
|
|
|
def _ranked_authors() -> list:
|
|
cached = _authors_cache.get("ranked")
|
|
if cached is not None:
|
|
return cached
|
|
tables = db.tables
|
|
sources = [
|
|
(target_type, table_name)
|
|
for target_type, table_name in VOTABLE_TARGETS.items()
|
|
if table_name in tables
|
|
]
|
|
if "votes" not in tables or not sources:
|
|
_authors_cache.set("ranked", [])
|
|
return []
|
|
target_union = " UNION ALL ".join(
|
|
f"SELECT uid, user_uid, '{target_type}' AS target_type FROM {table_name} WHERE deleted_at IS NULL"
|
|
for target_type, table_name in sources
|
|
)
|
|
rows = db.query(
|
|
f"SELECT t.user_uid, SUM(v.value) AS total "
|
|
f"FROM votes v JOIN ({target_union}) t ON v.target_uid = t.uid AND v.target_type = t.target_type "
|
|
f"WHERE v.deleted_at IS NULL "
|
|
f"GROUP BY t.user_uid HAVING SUM(v.value) > 0 ORDER BY total DESC"
|
|
)
|
|
ranked = [(row["user_uid"], row["total"]) for row in rows]
|
|
users_map = get_users_by_uids([uid for uid, _ in ranked])
|
|
authors = []
|
|
for uid, total in ranked:
|
|
user = users_map.get(uid)
|
|
if user:
|
|
author = dict(user)
|
|
author["stars"] = total
|
|
authors.append(author)
|
|
_authors_cache.set("ranked", authors)
|
|
_authors_cache.set(
|
|
"rank_map",
|
|
{author["uid"]: position for position, author in enumerate(authors, start=1)},
|
|
)
|
|
return authors
|
|
|
|
|
|
def _rank_map() -> dict:
|
|
cached = _authors_cache.get("rank_map")
|
|
if cached is not None:
|
|
return cached
|
|
_ranked_authors()
|
|
return _authors_cache.get("rank_map") or {}
|
|
|
|
|
|
def get_top_authors(limit: int = 5) -> list:
|
|
return _ranked_authors()[:limit]
|
|
|
|
|
|
def get_leaderboard(limit: int = 50, offset: int = 0) -> list:
|
|
sliced = _ranked_authors()[offset : offset + limit]
|
|
leaderboard = []
|
|
for position, author in enumerate(sliced, start=offset + 1):
|
|
entry = dict(author)
|
|
entry["rank"] = position
|
|
leaderboard.append(entry)
|
|
return leaderboard
|
|
|
|
|
|
def get_user_rank(user_uid: str):
|
|
return _rank_map().get(user_uid)
|
|
|
|
|
|
def clear_user_stars(user_uid: str) -> None:
|
|
_stars_cache.pop(user_uid)
|
|
|
|
|
|
def get_user_stars(user_uid: str) -> int:
|
|
cached = _stars_cache.get(user_uid)
|
|
if cached is not None:
|
|
return cached
|
|
tables = db.tables
|
|
if "votes" not in tables:
|
|
return 0
|
|
target_union = " UNION ALL ".join(
|
|
f"SELECT uid, '{target_type}' AS target_type FROM {table_name} WHERE user_uid = :u AND deleted_at IS NULL"
|
|
for target_type, table_name in VOTABLE_TARGETS.items()
|
|
if table_name in tables
|
|
)
|
|
if not target_union:
|
|
return 0
|
|
total = 0
|
|
for row in db.query(
|
|
f"SELECT COALESCE(SUM(v.value), 0) AS s "
|
|
f"FROM votes v JOIN ({target_union}) t ON v.target_uid = t.uid AND v.target_type = t.target_type "
|
|
f"WHERE v.deleted_at IS NULL",
|
|
u=user_uid,
|
|
):
|
|
total = row["s"] or 0
|
|
break
|
|
_stars_cache.set(user_uid, total)
|
|
return total
|
|
|
|
|
|
def update_target_stars(target_type: str, target_uid: str, net_stars: int) -> None:
|
|
table_name = VOTABLE_TARGETS.get(target_type)
|
|
if not table_name:
|
|
return
|
|
if target_type in STAR_TARGETS:
|
|
get_table(table_name).update({"uid": target_uid, "stars": net_stars}, ["uid"])
|
|
|
|
|
|
def soft_delete_engagement(target_type: str, target_uids: list, deleted_by: str) -> None:
|
|
uids = [uid for uid in (target_uids or []) if uid]
|
|
if not uids:
|
|
return
|
|
stamp = _now_iso()
|
|
soft_delete_in(
|
|
"reactions", "target_uid", uids, deleted_by, stamp=stamp, target_type=target_type
|
|
)
|
|
soft_delete_in(
|
|
"bookmarks", "target_uid", uids, deleted_by, stamp=stamp, target_type=target_type
|
|
)
|
|
if target_type == "post" and "polls" in db.tables:
|
|
for uid in uids:
|
|
for poll in db["polls"].find(post_uid=uid, deleted_at=None):
|
|
soft_delete("poll_votes", deleted_by, stamp=stamp, poll_uid=poll["uid"])
|
|
soft_delete("poll_options", deleted_by, stamp=stamp, poll_uid=poll["uid"])
|
|
soft_delete("polls", deleted_by, stamp=stamp, post_uid=uid)
|
|
|
|
|
|
def delete_engagement(target_type: str, target_uids: list) -> None:
|
|
uids = [uid for uid in (target_uids or []) if uid]
|
|
if not uids:
|
|
return
|
|
tables = db.tables
|
|
if "reactions" in tables:
|
|
placeholders, params = _in_clause(uids)
|
|
params["tt"] = target_type
|
|
with db:
|
|
db.query(
|
|
f"DELETE FROM reactions WHERE target_type=:tt AND target_uid IN ({placeholders})",
|
|
**params,
|
|
)
|
|
if "bookmarks" in tables:
|
|
placeholders, params = _in_clause(uids)
|
|
params["tt"] = target_type
|
|
with db:
|
|
db.query(
|
|
f"DELETE FROM bookmarks WHERE target_type=:tt AND target_uid IN ({placeholders})",
|
|
**params,
|
|
)
|
|
if target_type == "post" and "polls" in tables:
|
|
for uid in uids:
|
|
for poll in db["polls"].find(post_uid=uid):
|
|
if "poll_votes" in tables:
|
|
db["poll_votes"].delete(poll_uid=poll["uid"])
|
|
if "poll_options" in tables:
|
|
db["poll_options"].delete(poll_uid=poll["uid"])
|
|
db["polls"].delete(post_uid=uid)
|
|
|
|
|
|
def get_target_owner_uid(target_type: str, target_uid: str) -> str | None:
|
|
table_name = VOTABLE_TARGETS.get(target_type)
|
|
if not table_name:
|
|
return None
|
|
row = get_table(table_name).find_one(uid=target_uid, deleted_at=None)
|
|
return row["user_uid"] if row else None
|