2026-07-26 14:57:18 +02:00
|
|
|
# retoor <retoor@molodetz.nl>
|
|
|
|
|
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
|
|
|
|
from devplacepy.database import get_table
|
|
|
|
|
from devplacepy.services.quiz import store
|
|
|
|
|
from devplacepy.utils import generate_uid, make_combined_slug
|
|
|
|
|
|
|
|
|
|
_counter = [0]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _user(prefix):
|
|
|
|
|
_counter[0] += 1
|
|
|
|
|
uid = generate_uid()
|
|
|
|
|
get_table("users").insert(
|
|
|
|
|
{
|
|
|
|
|
"uid": uid,
|
|
|
|
|
"username": f"{prefix}{_counter[0]}",
|
Add the trust and safety subsystem and the App Store compliance work
Implements the moderation and consent obligations a social platform carries,
so the web version and any client that speaks to it enforce the same rules.
Moderation core (services/moderation/, database/moderation.py): a reportable
target registry, the content filter and its choke points, the report queue with
atomic resolution, enforcement actions, consent tracking, maturity gating, and
account deletion with a grace window.
Surfaces: POST /reports plus the member report list, /admin/moderation and the
per-report admin view, /workspaces, terms acceptance at /auth/terms, consent and
account deletion under /profile, the report button and dialog partials, the
maturity gate, and the moderation stylesheet and ReportDialog client.
Every user-generated surface stays reportable by construction: new content tables
are registered in REPORTABLE_TARGETS or listed in UNREPORTABLE_TABLES with a
reason, and the registry test fails the suite on anything left unclassified.
Docs: community guidelines, content moderation, intellectual property, privacy,
terms, contact, and the admin-only moderation operations page, plus the
moderation API group and the Devii moderation actions.
Compliance record: applecomp.md is the requirement register, applechanges.md the
gap analysis against this codebase, and appleimpl.md the implementation design
they resolve to.
Tests cover the report flow, admin moderation, consent, account deletion, terms
acceptance, workspaces, and the registry invariant across the unit, api, and e2e
tiers.
2026-08-09 00:18:20 +02:00
|
|
|
"terms_version": "1",
|
2026-07-26 14:57:18 +02:00
|
|
|
"email": f"{prefix}{_counter[0]}@t.dev",
|
|
|
|
|
"role": "Member",
|
|
|
|
|
"api_key": generate_uid(),
|
|
|
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
return get_table("users").find_one(uid=uid)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _published(owner, points=10):
|
|
|
|
|
uid = generate_uid()
|
|
|
|
|
get_table("quizzes").insert(
|
|
|
|
|
store.born_live(
|
|
|
|
|
{
|
|
|
|
|
"uid": uid,
|
|
|
|
|
"user_uid": owner["uid"],
|
|
|
|
|
"slug": make_combined_slug("Board", uid),
|
|
|
|
|
"title": "Board",
|
|
|
|
|
"description": "",
|
|
|
|
|
"status": "published",
|
|
|
|
|
"published_at": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
"shuffle_questions": 0,
|
|
|
|
|
"shuffle_options": 0,
|
|
|
|
|
"reveal_answers": 0,
|
|
|
|
|
"allow_review": 1,
|
|
|
|
|
"time_limit_seconds": 0,
|
|
|
|
|
"pass_percent": 0,
|
|
|
|
|
"question_count": 1,
|
|
|
|
|
"total_points": points,
|
|
|
|
|
"attempt_count": 0,
|
|
|
|
|
"stars": 0,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
return store.get_quiz(uid)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _completed(player, quiz, points, percent):
|
|
|
|
|
uid = generate_uid()
|
|
|
|
|
get_table("quiz_attempts").insert(
|
|
|
|
|
store.born_live(
|
|
|
|
|
{
|
|
|
|
|
"uid": uid,
|
|
|
|
|
"quiz_uid": quiz["uid"],
|
|
|
|
|
"user_uid": player["uid"],
|
|
|
|
|
"status": "completed",
|
|
|
|
|
"question_order": "[]",
|
|
|
|
|
"started_at": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
"expires_at": "",
|
|
|
|
|
"completed_at": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
"answered_count": 1,
|
|
|
|
|
"score_points": float(points),
|
|
|
|
|
"max_points": int(quiz["total_points"]),
|
|
|
|
|
"score_percent": float(percent),
|
|
|
|
|
"passed": 0,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
store.clear_cache()
|
|
|
|
|
return uid
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _in_progress(player, quiz):
|
|
|
|
|
uid = generate_uid()
|
|
|
|
|
get_table("quiz_attempts").insert(
|
|
|
|
|
store.born_live(
|
|
|
|
|
{
|
|
|
|
|
"uid": uid,
|
|
|
|
|
"quiz_uid": quiz["uid"],
|
|
|
|
|
"user_uid": player["uid"],
|
|
|
|
|
"status": "in_progress",
|
|
|
|
|
"question_order": "[]",
|
|
|
|
|
"started_at": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
"expires_at": "",
|
|
|
|
|
"completed_at": "",
|
|
|
|
|
"answered_count": 0,
|
|
|
|
|
"score_points": 0.0,
|
|
|
|
|
"max_points": int(quiz["total_points"]),
|
|
|
|
|
"score_percent": 0.0,
|
|
|
|
|
"passed": 0,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
store.clear_cache()
|
|
|
|
|
return uid
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _entry_for(player):
|
|
|
|
|
for entry in store.scoreboard(100):
|
|
|
|
|
if entry["user"]["uid"] == player["uid"]:
|
|
|
|
|
return entry
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_completed_attempt_puts_a_member_on_the_board(local_db):
|
|
|
|
|
owner = _user("sb")
|
|
|
|
|
player = _user("sb")
|
|
|
|
|
quiz = _published(owner)
|
|
|
|
|
_completed(player, quiz, 8, 80.0)
|
|
|
|
|
entry = _entry_for(player)
|
|
|
|
|
assert entry["total_points"] == 8.0
|
|
|
|
|
assert entry["quizzes_completed"] == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_only_the_best_attempt_per_quiz_counts(local_db):
|
|
|
|
|
owner = _user("sb")
|
|
|
|
|
player = _user("sb")
|
|
|
|
|
quiz = _published(owner)
|
|
|
|
|
_completed(player, quiz, 4, 40.0)
|
|
|
|
|
_completed(player, quiz, 9, 90.0)
|
|
|
|
|
_completed(player, quiz, 2, 20.0)
|
|
|
|
|
entry = _entry_for(player)
|
|
|
|
|
assert entry["total_points"] == 9.0
|
|
|
|
|
assert entry["quizzes_completed"] == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_replaying_a_quiz_can_never_multiply_a_score(local_db):
|
|
|
|
|
owner = _user("sb")
|
|
|
|
|
player = _user("sb")
|
|
|
|
|
quiz = _published(owner)
|
|
|
|
|
_completed(player, quiz, 7, 70.0)
|
|
|
|
|
before = _entry_for(player)["total_points"]
|
|
|
|
|
for _ in range(5):
|
|
|
|
|
_completed(player, quiz, 7, 70.0)
|
|
|
|
|
assert _entry_for(player)["total_points"] == before
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_worse_attempt_never_lowers_the_contribution(local_db):
|
|
|
|
|
owner = _user("sb")
|
|
|
|
|
player = _user("sb")
|
|
|
|
|
quiz = _published(owner)
|
|
|
|
|
_completed(player, quiz, 9, 90.0)
|
|
|
|
|
_completed(player, quiz, 1, 10.0)
|
|
|
|
|
assert _entry_for(player)["total_points"] == 9.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_better_attempt_raises_by_exactly_the_difference(local_db):
|
|
|
|
|
owner = _user("sb")
|
|
|
|
|
player = _user("sb")
|
|
|
|
|
quiz = _published(owner)
|
|
|
|
|
_completed(player, quiz, 4, 40.0)
|
|
|
|
|
_completed(player, quiz, 10, 100.0)
|
|
|
|
|
assert _entry_for(player)["total_points"] == 10.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_two_quizzes_sum_their_best_attempts(local_db):
|
|
|
|
|
owner = _user("sb")
|
|
|
|
|
player = _user("sb")
|
|
|
|
|
first = _published(owner)
|
|
|
|
|
second = _published(owner)
|
|
|
|
|
_completed(player, first, 6, 60.0)
|
|
|
|
|
_completed(player, second, 3, 30.0)
|
|
|
|
|
entry = _entry_for(player)
|
|
|
|
|
assert entry["total_points"] == 9.0
|
|
|
|
|
assert entry["quizzes_completed"] == 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_an_author_scores_on_their_own_quiz(local_db):
|
|
|
|
|
owner = _user("sb")
|
|
|
|
|
quiz = _published(owner)
|
|
|
|
|
_completed(owner, quiz, 10, 100.0)
|
|
|
|
|
entry = _entry_for(owner)
|
|
|
|
|
assert entry["total_points"] == 10.0
|
|
|
|
|
assert entry["quizzes_completed"] == 1
|
|
|
|
|
assert entry["perfect_count"] == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_an_in_progress_attempt_is_not_counted(local_db):
|
|
|
|
|
owner = _user("sb")
|
|
|
|
|
player = _user("sb")
|
|
|
|
|
quiz = _published(owner)
|
|
|
|
|
_in_progress(player, quiz)
|
|
|
|
|
assert _entry_for(player) is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_draft_quiz_is_not_counted(local_db):
|
|
|
|
|
owner = _user("sb")
|
|
|
|
|
player = _user("sb")
|
|
|
|
|
quiz = _published(owner)
|
|
|
|
|
get_table("quizzes").update({"uid": quiz["uid"], "status": "draft"}, ["uid"])
|
|
|
|
|
_completed(player, quiz, 10, 100.0)
|
|
|
|
|
assert _entry_for(player) is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_deleted_attempt_is_not_counted(local_db):
|
|
|
|
|
owner = _user("sb")
|
|
|
|
|
player = _user("sb")
|
|
|
|
|
quiz = _published(owner)
|
|
|
|
|
attempt_uid = _completed(player, quiz, 10, 100.0)
|
|
|
|
|
get_table("quiz_attempts").update(
|
|
|
|
|
{"uid": attempt_uid, "deleted_at": datetime.now(timezone.utc).isoformat()}, ["uid"]
|
|
|
|
|
)
|
|
|
|
|
store.clear_cache()
|
|
|
|
|
assert _entry_for(player) is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ranks_are_a_gapless_total_order(local_db):
|
|
|
|
|
owner = _user("sb")
|
|
|
|
|
quiz = _published(owner)
|
|
|
|
|
for score in (3, 9, 6):
|
|
|
|
|
_completed(_user("sb"), quiz, score, score * 10.0)
|
|
|
|
|
ranks = [entry["rank"] for entry in store.scoreboard(100)]
|
|
|
|
|
assert ranks == list(range(1, len(ranks) + 1))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_equal_totals_resolve_deterministically(local_db):
|
|
|
|
|
owner = _user("sb")
|
|
|
|
|
quiz = _published(owner)
|
|
|
|
|
for _ in range(3):
|
|
|
|
|
_completed(_user("sb"), quiz, 5, 50.0)
|
|
|
|
|
first = [entry["user"]["uid"] for entry in store.scoreboard(100)]
|
|
|
|
|
store.clear_cache()
|
|
|
|
|
assert [entry["user"]["uid"] for entry in store.scoreboard(100)] == first
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_limit_bounds_the_board(local_db):
|
|
|
|
|
owner = _user("sb")
|
|
|
|
|
quiz = _published(owner)
|
|
|
|
|
for score in range(1, 6):
|
|
|
|
|
_completed(_user("sb"), quiz, score, score * 10.0)
|
|
|
|
|
assert len(store.scoreboard(3)) == 3
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_perfect_attempts_are_counted(local_db):
|
|
|
|
|
owner = _user("sb")
|
|
|
|
|
player = _user("sb")
|
|
|
|
|
quiz = _published(owner)
|
|
|
|
|
_completed(player, quiz, 10, 100.0)
|
|
|
|
|
assert _entry_for(player)["perfect_count"] == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_standing_for_finds_a_member_outside_the_top(local_db):
|
|
|
|
|
owner = _user("sb")
|
|
|
|
|
player = _user("sb")
|
|
|
|
|
quiz = _published(owner)
|
|
|
|
|
_completed(player, quiz, 2, 20.0)
|
|
|
|
|
standing = store.standing_for(player["uid"])
|
|
|
|
|
assert standing["user"]["uid"] == player["uid"]
|
|
|
|
|
assert standing["rank"] >= 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_standing_for_is_none_without_attempts(local_db):
|
|
|
|
|
assert store.standing_for(_user("sb")["uid"]) is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_attempt_states_reports_done_with_the_best_percentage(local_db):
|
|
|
|
|
owner = _user("sb")
|
|
|
|
|
player = _user("sb")
|
|
|
|
|
quiz = _published(owner)
|
|
|
|
|
_completed(player, quiz, 4, 40.0)
|
|
|
|
|
_completed(player, quiz, 9, 90.0)
|
|
|
|
|
states = store.attempt_states_for(player["uid"], [quiz["uid"]])
|
|
|
|
|
assert states[quiz["uid"]]["state"] == "done"
|
|
|
|
|
assert states[quiz["uid"]]["best_percent"] == 90.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_attempt_states_reports_in_progress(local_db):
|
|
|
|
|
owner = _user("sb")
|
|
|
|
|
player = _user("sb")
|
|
|
|
|
quiz = _published(owner)
|
|
|
|
|
_in_progress(player, quiz)
|
|
|
|
|
states = store.attempt_states_for(player["uid"], [quiz["uid"]])
|
|
|
|
|
assert states[quiz["uid"]]["state"] == "in_progress"
|
|
|
|
|
assert states[quiz["uid"]]["attempt_uid"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_attempt_states_is_empty_for_a_guest(local_db):
|
|
|
|
|
owner = _user("sb")
|
|
|
|
|
quiz = _published(owner)
|
|
|
|
|
assert store.attempt_states_for("", [quiz["uid"]]) == {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_attempt_states_is_empty_without_quiz_uids(local_db):
|
|
|
|
|
assert store.attempt_states_for(_user("sb")["uid"], []) == {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_completed_quiz_uids_lists_only_finished_quizzes(local_db):
|
|
|
|
|
owner = _user("sb")
|
|
|
|
|
player = _user("sb")
|
|
|
|
|
done = _published(owner)
|
|
|
|
|
open_quiz = _published(owner)
|
|
|
|
|
_completed(player, done, 5, 50.0)
|
|
|
|
|
_in_progress(player, open_quiz)
|
|
|
|
|
completed = store.completed_quiz_uids(player["uid"])
|
|
|
|
|
assert done["uid"] in completed
|
|
|
|
|
assert open_quiz["uid"] not in completed
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_progress_for_summarises_the_viewer(local_db):
|
|
|
|
|
owner = _user("sb")
|
|
|
|
|
player = _user("sb")
|
|
|
|
|
quiz = _published(owner)
|
|
|
|
|
_completed(player, quiz, 10, 100.0)
|
|
|
|
|
progress = store.progress_for(player["uid"])
|
|
|
|
|
assert progress["completed"] == 1
|
|
|
|
|
assert progress["avg_percent"] == 100.0
|
|
|
|
|
assert progress["perfect_count"] == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_quiz_leaderboard_ranks_by_percentage(local_db):
|
|
|
|
|
owner = _user("sb")
|
|
|
|
|
quiz = _published(owner)
|
|
|
|
|
_completed(_user("sb"), quiz, 3, 30.0)
|
|
|
|
|
_completed(_user("sb"), quiz, 9, 90.0)
|
|
|
|
|
entries = store.quiz_leaderboard(quiz["uid"])
|
|
|
|
|
assert entries[0]["score_percent"] == 90.0
|
|
|
|
|
assert entries[0]["rank"] == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_quiz_leaderboard_ignores_unfinished_attempts(local_db):
|
|
|
|
|
owner = _user("sb")
|
|
|
|
|
quiz = _published(owner)
|
|
|
|
|
_in_progress(_user("sb"), quiz)
|
|
|
|
|
assert store.quiz_leaderboard(quiz["uid"]) == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_progress_agrees_with_the_scoreboard_for_an_author_only_completion(local_db):
|
|
|
|
|
author = _user("sb")
|
|
|
|
|
quiz = _published(author)
|
|
|
|
|
_completed(author, quiz, 10, 100.0)
|
|
|
|
|
progress = store.progress_for(author["uid"])
|
|
|
|
|
entry = _entry_for(author)
|
|
|
|
|
assert progress["completed"] == entry["quizzes_completed"] == 1
|
|
|
|
|
assert progress["avg_percent"] == entry["avg_percent"] == 100.0
|
|
|
|
|
assert progress["total_points"] == entry["total_points"] == 10.0
|
|
|
|
|
assert progress["perfect_count"] == entry["perfect_count"] == 1
|
|
|
|
|
assert progress["rank"] == entry["rank"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_progress_counts_a_ranked_completion(local_db):
|
|
|
|
|
owner = _user("sb")
|
|
|
|
|
player = _user("sb")
|
|
|
|
|
quiz = _published(owner)
|
|
|
|
|
_completed(player, quiz, 8, 80.0)
|
|
|
|
|
progress = store.progress_for(player["uid"])
|
|
|
|
|
entry = _entry_for(player)
|
|
|
|
|
assert progress["completed"] == entry["quizzes_completed"] == 1
|
|
|
|
|
assert progress["avg_percent"] == entry["avg_percent"] == 80.0
|
|
|
|
|
assert progress["total_points"] == entry["total_points"] == 8.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_completing_your_own_quiz_clears_it_from_todo(local_db):
|
|
|
|
|
author = _user("sb")
|
|
|
|
|
before = store.progress_for(author["uid"])["todo"]
|
|
|
|
|
quiz = _published(author)
|
|
|
|
|
assert store.progress_for(author["uid"])["todo"] == before + 1
|
|
|
|
|
_completed(author, quiz, 10, 100.0)
|
|
|
|
|
assert store.progress_for(author["uid"])["todo"] == before
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_progress_todo_counts_every_published_quiz(local_db):
|
|
|
|
|
author = _user("sb")
|
|
|
|
|
other = _user("sb")
|
|
|
|
|
before = store.progress_for(author["uid"])["todo"]
|
|
|
|
|
_published(author)
|
|
|
|
|
_published(other)
|
|
|
|
|
_published(other)
|
|
|
|
|
assert store.progress_for(author["uid"])["todo"] == before + 3
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_completing_a_quiz_moves_it_from_todo_to_completed(local_db):
|
|
|
|
|
owner = _user("sb")
|
|
|
|
|
player = _user("sb")
|
|
|
|
|
quiz = _published(owner)
|
|
|
|
|
before = store.progress_for(player["uid"])
|
|
|
|
|
_completed(player, quiz, 5, 50.0)
|
|
|
|
|
after = store.progress_for(player["uid"])
|
|
|
|
|
assert after["completed"] == before["completed"] + 1
|
|
|
|
|
assert after["todo"] == before["todo"] - 1
|