Quiiz system

This commit is contained in:
2026-07-26 16:46:41 +02:00
parent 7f17d69f5c
commit 4780016980
192 changed files with 15031 additions and 566 deletions
+3 -1
View File
@@ -43,6 +43,7 @@ Prefixes are wired in `main.py`:
| `/xmlrpc` | xmlrpc.py - reverse-proxies XML-RPC calls to the forking XML-RPC bridge (`services/xmlrpc/`, supervised by `XmlrpcService` on loopback `config.XMLRPC_PORT`), which generates one XML-RPC method per documented REST endpoint from `docs_api.API_GROUPS` (`posts.create`, `feed.list`, ...). One struct of named params per call; auth via in-band `api_key`, `X-API-KEY`/`Bearer` header, or `http://user:pass@host/xmlrpc` Basic. Full introspection + `system.multicall`; REST errors become XML-RPC faults. Exempt from the rate limiter (enforced on the forwarded internal hop). See `devplacepy/services/xmlrpc/CLAUDE.md` |
| `/api` | devrant/ package - devRant-compatible REST protocol (`auth.py`, `rants.py`, `comments.py`, `notifs.py`). Translates devRant requests onto DevPlace data: rants<->posts, comments/votes onto the native engagement layer, devRant integer ids onto each table's auto-increment `id`. Token auth via `devrant_tokens`. See `routers/devrant/CLAUDE.md` for the deep detail on this tree |
| `/dbapi` | dbapi/ package - **primary-administrator-only, strictly READ-ONLY** generic database API over `dataset` (never inserts/updates/deletes/restores; the write surface was removed because it bypassed every per-route admin safeguard). `tables.py` (`GET /tables`, `GET /{table}/schema`), `crud.py` (read only: `GET /{table}` + `GET /{table}/{key}/{value}`, `?include_deleted`), `query.py` (`POST /query` hard SELECT-only validated read-only run; `POST /query/async` + `GET /query/{uid}` + `WS /query/{uid}/ws` via `DbApiJobService` kind `dbquery`), `nl.py` (`POST /nl` natural-language->SQL via the AI gateway, returns validated SELECT, `execute=true` runs it read-only). **Auth = the PRIMARY administrator only** (the earliest-created Admin, `utils.is_primary_admin` / `database.get_primary_admin_uid` - the same identity that gates backup downloads) by session/api_key (`services/dbapi/policy.py`); every other administrator is refused like a member, and there is **no internal-key path** - the gateway `internal_gateway_key()` is NOT accepted (no service-to-service access). SQL validated by `services/dbapi/validate.py` (sqlglot classify + suspicious-flag + read-only `EXPLAIN` dry-run). Devii tools `db_*` are read-only (list/get/query/nl; no write tools) and flagged `requires_primary_admin=True`, so they are added to the LLM tool list ONLY for the primary administrator (every other session never sees them and Devii is unaware they exist). `services/pubsub/policy.py` resolves its own admin/internal actor and does NOT reuse `dbapi.policy.caller_for`, so the primary-admin restriction does not leak onto the pub/sub bus. See `devplacepy/services/dbapi/CLAUDE.md` |
| `/quizzes` | quizzes/ package - **Quizzes**: `index.py` (hub, create, import, detail, export, edit, delete, publish, per-quiz leaderboard, cross-quiz scoreboard), `questions.py` (builder page + question CRUD + reorder), `attempts.py` (start/resume, play, answer, finish, results). The hub `GET /quizzes` is the approved three-column `.feed-layout` reused verbatim from `/feed`: filters + search left (`?filter=all|todo|done|mine|drafts`, `?search=`, `?page=`), the quiz list centre with the **New quiz** and **Create quiz with Devii** actions, and the cross-quiz **scoreboard** rail right (score per user, best attempt per quiz, 15s display cache). Publishing is terminal - every write on a published quiz is a 400 and there is no unpublish route. See `devplacepy/services/quiz/CLAUDE.md` |
| `/game` | game/ package - the **Code Farm** idle game (`index.py` + `farm.py`): `GET /game` (page), `GET /game/state`, `GET /game/leaderboard?board=`, `POST /game/{plant,harvest,buy-plot,upgrade,fertilize,daily,grant,perk,prestige,legacy,mastery,quests/claim}`, `POST /game/{defense/upgrade,defense/downgrade,infrastructure/buy,cosmetics/buy,cosmetics/equip}`, plus social `GET /game/farm/{username}` and `POST /game/farm/{username}/{water,steal}`. See `devplacepy/services/game/CLAUDE.md` |
| (none) | push.py - web push + PWA: `GET`/`POST /push.json` (VAPID public key / subscription register), `GET /service-worker.js`, `GET /manifest.json` |
| (none) | docs.py (docs/ package) - the documentation site (prose pages + API reference). See `routers/docs/CLAUDE.md` for the deep detail on this tree (`DOCS_PAGES`, audience tiers, prose rendering pipeline) |
@@ -286,7 +287,8 @@ All SEO features are implemented across the following locations:
## Engagement: reactions, bookmarks, polls, contribution heatmap
### Emoji reactions
- Curated palette only: `REACTION_EMOJI` in `constants.py` (registered as a template global). `ReactionForm` rejects anything outside it; free-text emoji are not allowed.
- **Any single emoji is allowed.** `ReactionForm` validates with `rendering.is_single_emoji(value)` (`emoji.emoji_count(text) == 1 and emoji.purely_emoji(text)`, whitespace stripped) - so every emoji the picker can emit (all 3953 fully-qualified sequences, skin tones and ZWJ families included) is accepted, while text, mixed text+emoji, and multi-emoji strings are rejected. `REACTION_EMOJI` in `constants.py` (a template global) is now only the **quick-pick palette** shown by default, not an allowlist; the full set comes from the vendored `emoji-picker-element` opened by the palette's `+` button.
- The rendered chips are `reaction_emojis(_reactions)` (a `templating.py` global): the quick-pick palette plus any other emoji already used on that target (from `counts`/`mine`), so an off-palette reaction renders server-side too. `ReactionBar.js` creates a chip on the fly for any emoji returned by the JSON response that has none yet.
- Endpoint `POST /reactions/{target_type}/{target_uid}` (`routers/reactions.py`) toggles one `(user, target, emoji)` row in the `reactions` table. Target types: `post`, `comment`, `gist`, `project`. AJAX (`x-requested-with: fetch`) returns `{counts, mine}`.
- Reactions are **non-ranking** - they never touch `stars` or XP and intentionally send **no notifications** (votes already notify; reactions would be notification spam).
- Batch reads via `get_reactions_by_targets(target_type, uids, user)` in `database.py` (used by feed, profile, comment loader, `load_detail`) - never per-row. The `_reaction_bar.html` partial takes `_type`, `_uid`, `_reactions` ({counts, mine}) and renders the full palette as toggle chips; `ReactionBar.js` uses document-level click delegation. All four engagement controllers (`ReactionBar`, `VoteManager`, `BookmarkManager`, `PollManager`) extend the shared `OptimisticAction` base (the `Http.sendForm -> render -> error` core); each keeps only its own event wiring and `_render`.
+2
View File
@@ -8,6 +8,7 @@ from devplacepy.routers.admin import (
backups,
bots,
containers,
devii_tasks,
game,
gateway_configs,
issues,
@@ -37,6 +38,7 @@ router.include_router(auditlog.router)
router.include_router(backups.router)
router.include_router(bots.router)
router.include_router(gateway_configs.router)
router.include_router(devii_tasks.router)
router.include_router(game.router)
router.include_router(services.router, prefix="/services")
router.include_router(containers.router, prefix="/containers")
+182
View File
@@ -0,0 +1,182 @@
# retoor <retoor@molodetz.nl>
import logging
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from devplacepy.database import db, get_admin_uids, get_int_setting, get_users_by_uids
from devplacepy.responses import action_result, respond
from devplacepy.schemas import AdminDeviiTasksOut
from devplacepy.seo import base_seo_context, site_url, website_schema
from devplacepy.services.audit import record as audit
from devplacepy.services.devii import config as devii_config
from devplacepy.services.devii.tasks.guards import DEFAULT_MAX_PER_OWNER
from devplacepy.services.devii.tasks.store import TABLE, TaskStore
from devplacepy.utils import not_found, require_admin
logger = logging.getLogger(__name__)
router = APIRouter()
STATES = ("active", "inactive", "all")
def _schedule_text(row: dict) -> str:
kind = row.get("kind") or ""
if kind == "interval":
return f"every {row.get('every_seconds')}s"
if kind == "cron":
return f"cron {row.get('cron')}"
return f"once {row.get('run_at') or ''}".strip()
def _rows(state: str) -> list[dict]:
if TABLE not in db.tables:
return []
criteria: dict = {"deleted_at": None}
if state == "active":
criteria["enabled"] = True
elif state == "inactive":
criteria["enabled"] = False
rows = list(db[TABLE].find(**criteria))
rows.sort(key=lambda row: int(row.get("run_count") or 0), reverse=True)
return rows
def _items(rows: list[dict]) -> list[dict]:
owners = get_users_by_uids([row.get("owner_id") for row in rows if row.get("owner_id")])
admins = get_admin_uids()
items = []
for row in rows:
owner_uid = row.get("owner_id") or ""
owner = owners.get(owner_uid)
items.append(
{
"uid": row.get("uid"),
"label": row.get("label") or row.get("uid"),
"owner_uid": owner_uid,
"owner": owner["username"] if owner else owner_uid,
"owner_is_admin": owner_uid in admins,
"schedule": _schedule_text(row),
"status": row.get("status") or "",
"enabled": bool(row.get("enabled")),
"run_count": int(row.get("run_count") or 0),
"max_runs": row.get("max_runs"),
"failure_count": int(row.get("failure_count") or 0),
"next_run_at": row.get("next_run_at"),
"expires_at": row.get("expires_at"),
"last_error": row.get("last_error"),
}
)
return items
def _require_task(uid: str) -> dict:
row = db[TABLE].find_one(uid=uid, deleted_at=None) if TABLE in db.tables else None
if row is None:
raise not_found("Unknown task")
return row
@router.get("/devii-tasks", response_class=HTMLResponse)
async def admin_devii_tasks(request: Request, state: str = "active"):
admin = require_admin(request)
if state not in STATES:
state = "active"
rows = _rows(state)
items = _items(rows)
limits = {
"max_concurrent": get_int_setting(
devii_config.FIELD_TASK_MAX_CONCURRENT,
devii_config.DEFAULT_TASK_MAX_CONCURRENT,
),
"max_per_owner": get_int_setting(
devii_config.FIELD_TASK_MAX_PER_OWNER, DEFAULT_MAX_PER_OWNER
),
"max_failures": get_int_setting(
devii_config.FIELD_TASK_MAX_FAILURES,
devii_config.DEFAULT_TASK_MAX_FAILURES,
),
"idle_days": get_int_setting(
devii_config.FIELD_TASK_IDLE_DAYS, devii_config.DEFAULT_TASK_IDLE_DAYS
),
}
tabs = [
{"key": key, "label": key.capitalize(), "active": key == state}
for key in STATES
]
base = site_url(request)
seo_ctx = base_seo_context(
request,
title="Devii tasks - Admin",
description="Every scheduled Devii task, its owner, and its bounds.",
breadcrumbs=[
{"name": "Home", "url": "/feed"},
{"name": "Admin", "url": "/admin"},
{"name": "Devii tasks", "url": "/admin/devii-tasks"},
],
schemas=[website_schema(base)],
)
return respond(
request,
"admin_devii_tasks.html",
{
**seo_ctx,
"request": request,
"user": admin,
"items": items,
"state": state,
"tabs": tabs,
"limits": limits,
"admin_section": "devii-tasks",
},
model=AdminDeviiTasksOut,
)
@router.post("/devii-tasks/{uid}/disable")
async def admin_devii_task_disable(request: Request, uid: str):
admin = require_admin(request)
row = _require_task(uid)
store = TaskStore(db, str(row.get("owner_kind") or "user"), str(row.get("owner_id") or ""))
store.update(
uid,
{
"enabled": False,
"status": "disabled",
"next_run_at": None,
"last_error": f"disabled by {admin['username']}",
},
)
logger.info(f"Admin {admin['username']} disabled Devii task {uid}")
audit.record(
request,
"admin.devii_task.disable",
user=admin,
target_type="task",
target_uid=uid,
target_label=row.get("label"),
metadata={"owner_id": row.get("owner_id"), "run_count": row.get("run_count")},
summary=f"{admin['username']} disabled Devii task {uid}",
)
return action_result(request, "/admin/devii-tasks")
@router.post("/devii-tasks/{uid}/delete")
async def admin_devii_task_delete(request: Request, uid: str):
admin = require_admin(request)
row = _require_task(uid)
store = TaskStore(db, str(row.get("owner_kind") or "user"), str(row.get("owner_id") or ""))
store.delete(uid)
logger.info(f"Admin {admin['username']} deleted Devii task {uid}")
audit.record(
request,
"admin.devii_task.delete",
user=admin,
target_type="task",
target_uid=uid,
target_label=row.get("label"),
metadata={"owner_id": row.get("owner_id"), "run_count": row.get("run_count")},
summary=f"{admin['username']} deleted Devii task {uid}",
)
return action_result(request, "/admin/devii-tasks")
+1
View File
@@ -30,6 +30,7 @@ TRASH_TABLES = [
{"key": "projects", "label": "Projects", "icon": "\U0001f680", "type": "project"},
{"key": "news", "label": "News", "icon": "\U0001f4f0", "type": "news"},
{"key": "awards", "label": "Awards", "icon": "\U0001f3c6", "type": "award"},
{"key": "quizzes", "label": "Quizzes", "icon": "\U0001f9e9", "type": "quiz"},
{"key": "project_files", "label": "Project files", "icon": "\U0001f4c1", "type": None},
{"key": "attachments", "label": "Attachments", "icon": "\U0001f4ce", "type": None},
]
+1 -1
View File
@@ -13,7 +13,7 @@ from devplacepy.services.audit import record as audit
logger = logging.getLogger(__name__)
router = APIRouter()
BOOKMARKABLE: set[str] = {"post", "gist", "project", "news"}
BOOKMARKABLE: set[str] = {"post", "gist", "project", "news", "quiz"}
TABLE_BY_TYPE: dict[str, str] = {
"post": "posts",
+6
View File
@@ -69,6 +69,12 @@ DOCS_PAGES = [
"kind": "prose",
"section": SECTION_GENERAL,
},
{
"slug": "quizzes",
"title": "Quizzes",
"kind": "prose",
"section": SECTION_GENERAL,
},
{
"slug": "block-and-mute",
"title": "Block and mute",
+23 -1
View File
@@ -25,8 +25,30 @@ def owner_by_username(username: str) -> dict | None:
def state_payload(user: dict, viewer: dict | None = None) -> dict:
from devplacepy.services.game import economy
from devplacepy.utils import award_rewards, track_action
farm = store.ensure_farm(user["uid"])
return store.serialize_farm(farm, viewer=viewer or user, owner=user)
payload = store.serialize_farm(farm, viewer=viewer or user, owner=user)
harvested = int(payload.get("auto_harvested") or 0)
if harvested:
track_action(user["uid"], "harvest")
award_rewards(user["uid"], economy.site_xp_for(payload.get("auto_harvest_xp") or 0))
return payload
def action_error(request: Request, message: str, redirect_url: str):
from urllib.parse import quote
from devplacepy.responses import json_error, wants_json
from fastapi.responses import RedirectResponse
if wants_json(request):
return json_error(400, message)
separator = "&" if "?" in redirect_url else "?"
return RedirectResponse(
url=f"{redirect_url}{separator}error={quote(message)}", status_code=302
)
def game_seo(request: Request, title: str, description: str) -> dict:
+10 -9
View File
@@ -6,7 +6,7 @@ from fastapi import APIRouter, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from devplacepy.models import GameSlotForm
from devplacepy.responses import json_error, respond, wants_json
from devplacepy.responses import respond, wants_json
from devplacepy.schemas import GameFarmViewOut
from devplacepy.services.game import GameError, store
from devplacepy.utils import (
@@ -16,7 +16,7 @@ from devplacepy.utils import (
track_action,
)
from ._shared import game_seo, notify_farm, owner_by_username
from ._shared import action_error, game_seo, notify_farm, owner_by_username
router = APIRouter()
@@ -43,6 +43,7 @@ async def view_farm(request: Request, username: str):
"user": viewer,
"viewer": viewer,
"farm": data,
"game_error": request.query_params.get("error", ""),
},
model=GameFarmViewOut,
)
@@ -59,9 +60,7 @@ async def water_farm(
try:
store.water(viewer, owner, data.slot)
except GameError as exc:
if wants_json(request):
return json_error(400, str(exc))
return RedirectResponse(url=f"/game/farm/{username}", status_code=302)
return action_error(request, str(exc), f"/game/farm/{username}")
track_action(viewer["uid"], "water")
await notify_farm(owner["username"])
if wants_json(request):
@@ -83,9 +82,7 @@ async def steal_farm(
try:
result = store.steal(viewer, owner, data.slot)
except GameError as exc:
if wants_json(request):
return json_error(400, str(exc))
return RedirectResponse(url=f"/game/farm/{username}", status_code=302)
return action_error(request, str(exc), f"/game/farm/{username}")
track_action(viewer["uid"], "harvest_stolen")
track_action(owner["uid"], "got_stolen_from")
if result.get("underdog_triggered"):
@@ -93,7 +90,11 @@ async def steal_farm(
create_notification(
owner["uid"],
"harvest_stolen",
"Someone raided your Code Farm and stole a ready build.",
(
f"{viewer['username']} raided your Code Farm and took "
f"{result['coins']} coins ({round(result['share'] * 100)}%) "
f"from your {result['crop_name']} build. You keep the rest - harvest it."
),
viewer["uid"],
"/game",
)
+84 -10
View File
@@ -18,10 +18,11 @@ from devplacepy.models import (
from devplacepy.database import mark_notifications_read_by_target
from devplacepy.responses import json_error, respond, wants_json
from devplacepy.schemas import GameLeaderboardOut, GameStateOut
from devplacepy.services.game import GameError, store
from devplacepy.services.game import GameError, economy, store
from devplacepy.services.audit import record as audit
from devplacepy.utils import award_rewards, get_current_user, require_user, track_action
from ._shared import game_seo, notify_farm, state_payload
from ._shared import action_error, game_seo, notify_farm, state_payload
router = APIRouter()
@@ -31,6 +32,7 @@ async def game_home(request: Request):
user = require_user(request)
mark_notifications_read_by_target(user["uid"], "/game")
farm = state_payload(user)
error = request.query_params.get("error", "")
seo_ctx = game_seo(
request,
"Code Farm",
@@ -40,7 +42,7 @@ async def game_home(request: Request):
return respond(
request,
"game.html",
{**seo_ctx, "request": request, "user": user, "farm": farm},
{**seo_ctx, "request": request, "user": user, "farm": farm, "game_error": error},
model=GameStateOut,
)
@@ -64,9 +66,7 @@ async def _respond_action(request: Request, user: dict, fn, on_success=None):
try:
result = fn()
except GameError as exc:
if wants_json(request):
return json_error(400, str(exc))
return RedirectResponse(url="/game", status_code=302)
return action_error(request, str(exc), "/game")
if on_success:
on_success(result)
await notify_farm(user.get("username", ""))
@@ -91,7 +91,7 @@ async def game_harvest(request: Request, data: Annotated[GameSlotForm, Form()]):
def reward(result):
track_action(user["uid"], "harvest")
award_rewards(user["uid"], result.get("xp", 0))
award_rewards(user["uid"], economy.site_xp_for(result.get("xp", 0)))
return await _respond_action(
request, user, lambda: store.harvest(user, data.slot), reward
@@ -125,7 +125,17 @@ async def game_daily(request: Request):
@router.post("/grant")
async def game_claim_grant(request: Request):
user = require_user(request)
return await _respond_action(request, user, lambda: store.claim_grant(user))
def recorded(result):
audit.record(
request,
"game.grant.claim",
user=user,
metadata=result,
summary=f"{user['username']} claimed a {result['amount']} coin community grant",
)
return await _respond_action(request, user, lambda: store.claim_grant(user), recorded)
@router.post("/perk")
@@ -139,7 +149,20 @@ async def game_perk(request: Request, data: Annotated[GamePerkForm, Form()]):
@router.post("/prestige")
async def game_prestige(request: Request):
user = require_user(request)
return await _respond_action(request, user, lambda: store.prestige(user))
def recorded(result):
audit.record(
request,
"game.prestige",
user=user,
metadata=result,
summary=(
f"{user['username']} refactored to prestige {result['prestige']} "
f"for {result['fee']} coins"
),
)
return await _respond_action(request, user, lambda: store.prestige(user), recorded)
@router.post("/legacy")
@@ -155,7 +178,7 @@ async def game_claim_quest(request: Request, data: Annotated[GameQuestForm, Form
user = require_user(request)
def reward(result):
award_rewards(user["uid"], result.get("reward_xp", 0))
award_rewards(user["uid"], economy.site_xp_for(result.get("reward_xp", 0)))
return await _respond_action(
request, user, lambda: store.claim_quest(user, data.quest, data.scope), reward
@@ -168,16 +191,57 @@ async def game_upgrade_defense(request: Request):
def reward(result):
track_action(user["uid"], "defense_upgraded")
audit.record(
request,
"game.defense.upgrade",
user=user,
metadata=result,
summary=(
f"{user['username']} bought Code Farm defense level "
f"{result['defense_level']} for {result['spent']} coins"
),
)
return await _respond_action(request, user, lambda: store.upgrade_defense(user), reward)
@router.post("/defense/downgrade")
async def game_downgrade_defense(request: Request):
user = require_user(request)
def recorded(result):
audit.record(
request,
"game.defense.downgrade",
user=user,
metadata=result,
summary=(
f"{user['username']} dropped Code Farm defense to level "
f"{result['defense_level']}"
),
)
return await _respond_action(
request, user, lambda: store.downgrade_defense(user), recorded
)
@router.post("/infrastructure/buy")
async def game_buy_infrastructure(request: Request, data: Annotated[GameInfraForm, Form()]):
user = require_user(request)
def reward(result):
track_action(user["uid"], "infra_bought")
audit.record(
request,
"game.infrastructure.buy",
user=user,
metadata=result,
summary=(
f"{user['username']} bought Code Farm infrastructure {result['key']} "
f"for {result['spent']} coins"
),
)
return await _respond_action(
request, user, lambda: store.buy_infrastructure(user, data.key), reward
@@ -198,6 +262,16 @@ async def game_buy_cosmetic(request: Request, data: Annotated[GameCosmeticForm,
def reward(result):
track_action(user["uid"], "cosmetic_bought")
audit.record(
request,
"game.cosmetic.buy",
user=user,
metadata=result,
summary=(
f"{user['username']} bought Code Farm cosmetic {result['key']} "
f"for {result['spent']} coins"
),
)
return await _respond_action(
request, user, lambda: store.buy_cosmetic(user, data.key), reward
+9
View File
@@ -0,0 +1,9 @@
# retoor <retoor@molodetz.nl>
from .index import router
from . import attempts, questions
router.include_router(questions.router)
router.include_router(attempts.router)
__all__ = ["router"]
+86
View File
@@ -0,0 +1,86 @@
# retoor <retoor@molodetz.nl>
from urllib.parse import quote
from fastapi import Request
from fastapi.responses import RedirectResponse
from devplacepy.database import get_table, resolve_by_slug
from devplacepy.responses import json_error, wants_json
from devplacepy.seo import base_seo_context
from devplacepy.services.pubsub import publish
from devplacepy.services.quiz import QuizError, store
from devplacepy.utils import is_admin, not_found
def quiz_topic(quiz_uid: str) -> str:
return f"public.quiz.{quiz_uid}"
async def notify_quiz(quiz_uid: str) -> None:
if not quiz_uid:
return
try:
await publish(quiz_topic(quiz_uid), {"kind": "update", "quiz_uid": quiz_uid})
except Exception:
pass
def load_quiz(slug: str, user: dict | None) -> dict:
quiz = resolve_by_slug(get_table("quizzes"), slug)
if not quiz or not store.can_view_quiz(quiz, user):
raise not_found("Quiz not found")
return quiz
def require_owner(quiz: dict, user: dict):
if not (store.is_quiz_owner(quiz, user) or is_admin(user)):
raise not_found("Quiz not found")
return quiz
def require_editor(quiz: dict, user: dict):
if not store.is_quiz_owner(quiz, user):
raise not_found("Quiz not found")
return quiz
def action_error(request: Request, message: str, redirect_url: str):
if wants_json(request):
return json_error(400, message)
separator = "&" if "?" in redirect_url else "?"
return RedirectResponse(
url=f"{redirect_url}{separator}error={quote(message)}", status_code=302
)
def quiz_seo(request: Request, title: str, description: str, robots: str = "index,follow", **extra):
return base_seo_context(
request,
title=title,
description=description,
robots=robots,
breadcrumbs=[
{"name": "Home", "url": "/feed"},
{"name": "Quizzes", "url": "/quizzes"},
*extra.pop("breadcrumbs", []),
],
**extra,
)
def quiz_url(quiz: dict) -> str:
return f"/quizzes/{quiz.get('slug') or quiz['uid']}"
__all__ = [
"QuizError",
"action_error",
"load_quiz",
"notify_quiz",
"quiz_seo",
"quiz_topic",
"quiz_url",
"require_editor",
"require_owner",
]
+244
View File
@@ -0,0 +1,244 @@
# retoor <retoor@molodetz.nl>
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, JSONResponse
from devplacepy.config import QUIZ_ANSWER_MAX_CHARS
from devplacepy.database import get_users_by_uids
from devplacepy.dependencies import json_or_form
from devplacepy.models import QuizAnswerForm
from devplacepy.responses import action_result, respond, wants_json
from devplacepy.schemas import (
QuizAnswerResultOut,
QuizAttemptPageOut,
QuizResultOut,
)
from devplacepy.services.audit import record as audit
from devplacepy.services.quiz import QuizError, store
from devplacepy.utils import (
award_rewards,
create_notification,
is_admin,
not_found,
require_user,
track_action,
XP_QUIZ_COMPLETE,
)
from ._shared import action_error, load_quiz, notify_quiz, quiz_seo, quiz_url
router = APIRouter()
def _load_attempt(quiz: dict, attempt_uid: str, user: dict, allow_admin: bool = False):
attempt = store.get_attempt(attempt_uid)
if not attempt or attempt.get("quiz_uid") != quiz["uid"]:
raise not_found("Attempt not found")
if attempt.get("user_uid") != user["uid"] and not (allow_admin and is_admin(user)):
raise not_found("Attempt not found")
return attempt
def _author(quiz: dict):
return get_users_by_uids([quiz["user_uid"]]).get(quiz["user_uid"])
@router.post("/{slug}/attempts")
async def start_attempt(request: Request, slug: str):
user = require_user(request)
quiz = load_quiz(slug, user)
if quiz.get("status") != "published":
return action_error(request, "This quiz is not published yet", quiz_url(quiz))
try:
attempt = store.start_attempt(user, quiz)
except QuizError as exc:
return action_error(request, str(exc), quiz_url(quiz))
audit.record(
request,
"quiz.attempt.start",
user=user,
target_type="quiz",
target_uid=quiz["uid"],
target_label=quiz.get("title") or quiz["uid"],
summary=f"{user['username']} started an attempt on quiz {quiz.get('title')}",
metadata={"attempt_uid": attempt["uid"]},
links=[audit.target("quiz", quiz["uid"], quiz.get("title") or "")],
)
url = f"{quiz_url(quiz)}/attempts/{attempt['uid']}"
return action_result(
request, url, data={"uid": attempt["uid"], "url": url, "status": attempt["status"]}
)
@router.get("/{slug}/attempts/{attempt_uid}", response_class=HTMLResponse)
async def attempt_page(request: Request, slug: str, attempt_uid: str):
user = require_user(request)
quiz = load_quiz(slug, user)
attempt = _load_attempt(quiz, attempt_uid, user)
payload = store.serialize_attempt(quiz, attempt, user)
seo_ctx = quiz_seo(
request,
quiz.get("title") or "Quiz",
"Play this quiz on DevPlace.",
robots="noindex,follow",
breadcrumbs=[{"name": quiz.get("title") or "Quiz", "url": quiz_url(quiz)}],
)
return respond(
request,
"quiz_play.html",
{
**seo_ctx,
"request": request,
"user": user,
"quiz": store.serialize_quiz(quiz, user, _author(quiz)),
"attempt": payload,
"answer_max_chars": QUIZ_ANSWER_MAX_CHARS,
"quiz_error": request.query_params.get("error", ""),
},
model=QuizAttemptPageOut,
)
@router.post("/{slug}/attempts/{attempt_uid}/answer")
async def submit_answer(
request: Request,
slug: str,
attempt_uid: str,
data: Annotated[QuizAnswerForm, Depends(json_or_form(QuizAnswerForm))],
):
user = require_user(request)
quiz = load_quiz(slug, user)
attempt = _load_attempt(quiz, attempt_uid, user)
attempt_url = f"{quiz_url(quiz)}/attempts/{attempt_uid}"
try:
answer, updated, result = await store.answer(
user,
quiz,
attempt,
data.question_uid,
data.submission(),
)
except QuizError as exc:
return action_error(request, str(exc), attempt_url)
if result.graded_by == "fallback":
audit.record_system(
"quiz.grade.failed",
result="failure",
target_type="quiz",
target_uid=quiz["uid"],
summary=(
f"AI grading unavailable for question {data.question_uid}, "
"deterministic fallback used"
),
metadata={"attempt_uid": attempt_uid, "question_uid": data.question_uid},
)
audit.record(
request,
"quiz.attempt.answer",
user=user,
target_type="quiz",
target_uid=quiz["uid"],
target_label=quiz.get("title") or quiz["uid"],
summary=(
f"{user['username']} answered a question on quiz {quiz.get('title')} "
f"for {answer.get('awarded_points')} points"
),
metadata={
"attempt_uid": attempt_uid,
"question_uid": data.question_uid,
"graded_by": result.graded_by,
},
links=[audit.target("quiz", quiz["uid"], quiz.get("title") or "")],
)
if not wants_json(request):
return action_result(request, attempt_url)
return JSONResponse(
QuizAnswerResultOut(
answer=store.serialize_answer(answer),
attempt=store.serialize_attempt(quiz, updated, user),
).model_dump(mode="json")
)
@router.post("/{slug}/attempts/{attempt_uid}/finish")
async def finish_attempt(request: Request, slug: str, attempt_uid: str):
user = require_user(request)
quiz = load_quiz(slug, user)
attempt = _load_attempt(quiz, attempt_uid, user)
finished, won = store.finish(quiz, attempt)
results_url = f"{quiz_url(quiz)}/attempts/{attempt_uid}/results"
if won:
store.clear_cache()
award_rewards(user["uid"], XP_QUIZ_COMPLETE, "Quiz Taker")
track_action(user["uid"], "quiz_complete")
if float(finished.get("score_percent") or 0.0) >= 100.0:
track_action(user["uid"], "quiz_perfect")
if quiz["user_uid"] != user["uid"]:
create_notification(
quiz["user_uid"],
"quiz_attempt",
f"{user['username']} completed your quiz {quiz.get('title')}",
user["uid"],
quiz_url(quiz),
)
audit.record(
request,
"quiz.attempt.finish",
user=user,
target_type="quiz",
target_uid=quiz["uid"],
target_label=quiz.get("title") or quiz["uid"],
summary=(
f"{user['username']} finished quiz {quiz.get('title')} with "
f"{finished.get('score_percent')}%"
),
metadata={
"attempt_uid": attempt_uid,
"score_percent": finished.get("score_percent"),
"passed": finished.get("passed"),
},
links=[audit.target("quiz", quiz["uid"], quiz.get("title") or "")],
)
await notify_quiz(quiz["uid"])
if not wants_json(request):
return action_result(request, results_url)
result = store.serialize_result(quiz, finished, user)
return JSONResponse(
QuizResultOut(
quiz=store.serialize_quiz(quiz, user, _author(quiz)),
attempt=result,
review=result["review"],
fallback_count=result["fallback_count"],
).model_dump(mode="json")
)
@router.get("/{slug}/attempts/{attempt_uid}/results", response_class=HTMLResponse)
async def attempt_results(request: Request, slug: str, attempt_uid: str):
user = require_user(request)
quiz = load_quiz(slug, user)
attempt = _load_attempt(quiz, attempt_uid, user, allow_admin=True)
result = store.serialize_result(quiz, attempt, user)
seo_ctx = quiz_seo(
request,
f"{quiz.get('title') or 'Quiz'} results",
"Your quiz result on DevPlace.",
robots="noindex,follow",
breadcrumbs=[{"name": quiz.get("title") or "Quiz", "url": quiz_url(quiz)}],
)
return respond(
request,
"quiz_results.html",
{
**seo_ctx,
"request": request,
"user": user,
"quiz": store.serialize_quiz(quiz, user, _author(quiz)),
"attempt": result,
"review": result["review"],
"fallback_count": result["fallback_count"],
},
model=QuizResultOut,
)
+363
View File
@@ -0,0 +1,363 @@
# retoor <retoor@molodetz.nl>
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, JSONResponse
from devplacepy.config import QUIZ_SCOREBOARD_LIMIT
from devplacepy.content import (
canonical_redirect,
create_content_item,
delete_content_item,
detail_context,
load_detail,
)
from devplacepy.database import (
get_reactions_by_targets,
get_recent_comments_by_target_uids,
get_user_bookmarks,
get_users_by_uids,
get_user_votes,
mark_notifications_read_by_target,
resolve_object_url,
)
from devplacepy.dependencies import json_or_form
from devplacepy.models import QuizForm, QuizImportForm
from devplacepy.responses import action_result, respond
from devplacepy.schemas import (
QuizDetailOut,
QuizDocumentOut,
QuizFormPageOut,
QuizLeaderboardOut,
QuizScoreboardOut,
QuizzesOut,
)
from devplacepy.seo import quiz_schema, site_url, website_schema
from devplacepy.services.audit import record as audit
from devplacepy.services.quiz import QuizError, store
from devplacepy.utils import (
award_rewards,
get_current_user,
require_user,
time_ago,
track_action,
XP_QUIZ,
XP_QUIZ_PUBLISH,
)
from ._shared import action_error, load_quiz, notify_quiz, quiz_seo, quiz_url, require_editor
router = APIRouter()
def _list_items(rows: list[dict], user: dict | None) -> list[dict]:
if not rows:
return []
uids = [row["uid"] for row in rows]
authors = get_users_by_uids([row["user_uid"] for row in rows])
counts = store.comment_counts(uids)
reactions = get_reactions_by_targets("quiz", uids, user)
recent = get_recent_comments_by_target_uids("quiz", uids, 3, user)
bookmarks = get_user_bookmarks(user["uid"], "quiz", uids) if user else set()
votes = get_user_votes(user["uid"], uids) if user else {}
states = store.attempt_states_for(user["uid"], uids) if user else {}
items = []
for row in rows:
state = states.get(row["uid"], {})
items.append(
{
"uid": row["uid"],
"slug": row.get("slug") or row["uid"],
"url": quiz_url(row),
"title": row.get("title") or "",
"description": row.get("description") or "",
"status": row.get("status") or "published",
"created_at": row.get("created_at") or "",
"time_ago": time_ago(row.get("created_at") or ""),
"author": authors.get(row["user_uid"]),
"question_count": int(row.get("question_count") or 0),
"total_points": int(row.get("total_points") or 0),
"attempt_count": int(row.get("attempt_count") or 0),
"time_limit_seconds": int(row.get("time_limit_seconds") or 0),
"pass_percent": int(row.get("pass_percent") or 0),
"stars": int(row.get("stars") or 0),
"my_vote": votes.get(row["uid"], 0),
"comment_count": counts.get(row["uid"], 0),
"bookmarked": row["uid"] in bookmarks,
"reactions": reactions.get(row["uid"], {"counts": {}, "mine": []}),
"recent_comments": recent.get(row["uid"], []),
"viewer_state": state.get("state", "todo"),
"viewer_best_percent": state.get("best_percent", 0.0),
"viewer_best_points": state.get("best_points", 0.0),
"viewer_attempt_uid": state.get("attempt_uid", ""),
}
)
return items
@router.get("", response_class=HTMLResponse)
async def quizzes_page(
request: Request, filter: str = "all", search: str = "", page: int = 1
):
user = get_current_user(request)
current_filter = filter if filter in store.FILTERS else "all"
rows, pagination = store.list_quizzes(
viewer=user, quiz_filter=current_filter, search=search, page=max(1, page)
)
standing = store.standing_for(user["uid"]) if user else None
seo_ctx = quiz_seo(
request,
"Quizzes",
"Author quizzes, play them, and climb the DevPlace quiz scoreboard.",
schemas=[website_schema(site_url(request))],
)
return respond(
request,
"quizzes.html",
{
**seo_ctx,
"request": request,
"user": user,
"quizzes": _list_items(rows, user),
"search": search,
"filter": current_filter,
"current_filter": current_filter,
"counts": store.filter_counts(user, search),
"pagination": pagination,
"scoreboard": store.scoreboard(QUIZ_SCOREBOARD_LIMIT),
"viewer_standing": standing,
"viewer_progress": store.progress_for(user["uid"]) if user else {},
"viewer_can_create": bool(user),
},
model=QuizzesOut,
)
@router.get("/scoreboard")
async def quizzes_scoreboard(request: Request, limit: int = QUIZ_SCOREBOARD_LIMIT):
user = get_current_user(request)
bounded = max(1, min(100, int(limit or QUIZ_SCOREBOARD_LIMIT)))
return JSONResponse(
QuizScoreboardOut(
scoreboard=store.scoreboard(bounded),
viewer_standing=store.standing_for(user["uid"]) if user else None,
limit=bounded,
).model_dump(mode="json")
)
@router.get("/new", response_class=HTMLResponse)
async def quiz_new_page(request: Request):
user = require_user(request)
seo_ctx = quiz_seo(
request,
"New quiz",
"Create a quiz on DevPlace.",
robots="noindex,follow",
)
return respond(
request,
"quiz_new.html",
{**seo_ctx, "request": request, "user": user, "viewer_can_create": True},
model=QuizFormPageOut,
)
@router.post("/create")
async def create_quiz(
request: Request, data: Annotated[QuizForm, Depends(json_or_form(QuizForm))]
):
user = require_user(request)
fields = store.quiz_fields(data)
uid, slug = create_content_item(
"quizzes",
"quiz",
user,
fields,
fields["title"],
XP_QUIZ,
"First Quiz",
fields["description"],
None,
request,
)
url = f"/quizzes/{slug}/edit"
return action_result(request, url, data={"uid": uid, "slug": slug, "url": url})
@router.post("/import")
async def import_quiz(
request: Request, data: Annotated[QuizImportForm, Depends(json_or_form(QuizImportForm))]
):
user = require_user(request)
document = data.document
fields = {
"title": document.title.strip(),
"description": document.description.strip(),
"status": "draft",
"published_at": "",
"question_count": 0,
"total_points": 0,
"attempt_count": 0,
**store.document_settings(document),
}
uid, slug = create_content_item(
"quizzes",
"quiz",
user,
fields,
fields["title"],
XP_QUIZ,
"First Quiz",
fields["description"],
None,
request,
)
imported = store.import_questions(uid, document)
audit.record(
request,
"quiz.import",
user=user,
target_type="quiz",
target_uid=uid,
target_label=fields["title"],
summary=f"{user['username']} imported quiz {fields['title']} with {imported} questions",
metadata={"question_count": imported},
links=[audit.target("quiz", uid, fields["title"])],
)
url = f"/quizzes/{slug}/edit"
return action_result(
request,
url,
data={"uid": uid, "slug": slug, "url": url, "question_count": imported},
)
@router.get("/{slug}", response_class=HTMLResponse)
async def quiz_detail(request: Request, slug: str):
user = get_current_user(request)
quiz = load_quiz(slug, user)
redirect = canonical_redirect("quizzes", quiz, slug)
if redirect:
return redirect
detail = load_detail("quizzes", "quiz", quiz["uid"], user)
if not detail:
return action_error(request, "Quiz not found", "/quizzes")
if user:
mark_notifications_read_by_target(user["uid"], resolve_object_url("quiz", quiz["uid"]))
states = store.attempt_states_for(user["uid"], [quiz["uid"]]) if user else {}
state = states.get(quiz["uid"], {})
author = detail["author"]
seo_ctx = quiz_seo(
request,
quiz.get("title") or "Quiz",
quiz.get("description") or "",
robots="index,follow" if quiz.get("status") == "published" else "noindex,nofollow",
seo_target=("quiz", quiz["uid"]),
og_type="article",
breadcrumbs=[{"name": quiz.get("title") or "Quiz", "url": quiz_url(quiz)}],
schemas=[quiz_schema(quiz, author, site_url(request))],
)
context = detail_context(
request,
user,
detail,
"quiz_row",
seo_ctx,
{
"quiz": store.serialize_quiz(quiz, user, author),
"questions": store.serialize_builder(quiz, user)
if store.is_quiz_owner(quiz, user)
else [],
"leaderboard": store.quiz_leaderboard(quiz["uid"]),
"viewer_state": state.get("state", "todo"),
"viewer_attempt_uid": state.get("attempt_uid", ""),
"target_type": "quiz",
"target_uid": quiz["uid"],
},
)
return respond(request, "quiz.html", context, model=QuizDetailOut)
@router.get("/{slug}/export")
async def export_quiz(request: Request, slug: str):
user = get_current_user(request)
quiz = load_quiz(slug, user)
include_answers = store.is_quiz_owner(quiz, user)
document = store.export_document(quiz["uid"], include_answers)
return JSONResponse(QuizDocumentOut.model_validate(document).model_dump(mode="json"))
@router.get("/{slug}/leaderboard")
async def quiz_leaderboard(request: Request, slug: str, limit: int = 25):
user = get_current_user(request)
quiz = load_quiz(slug, user)
return JSONResponse(
QuizLeaderboardOut(
quiz_uid=quiz["uid"],
entries=store.quiz_leaderboard(quiz["uid"], max(1, min(100, int(limit or 25)))),
).model_dump(mode="json")
)
@router.post("/edit/{slug}")
async def edit_quiz(
request: Request, slug: str, data: Annotated[QuizForm, Depends(json_or_form(QuizForm))]
):
user = require_user(request)
quiz = require_editor(load_quiz(slug, user), user)
try:
updated = store.edit_quiz(quiz["uid"], store.settings_update(data))
except QuizError as exc:
return action_error(request, str(exc), quiz_url(quiz))
audit.record(
request,
"quiz.edit",
user=user,
target_type="quiz",
target_uid=quiz["uid"],
target_label=updated.get("title") or quiz["uid"],
summary=f"{user['username']} edited quiz {updated.get('title')}",
links=[audit.target("quiz", quiz["uid"], updated.get("title") or "")],
)
await notify_quiz(quiz["uid"])
url = quiz_url(updated)
return action_result(request, url, data={"uid": quiz["uid"], "url": url})
@router.post("/delete/{slug}")
async def delete_quiz(request: Request, slug: str):
user = require_user(request)
response = delete_content_item(request, "quizzes", "quiz", user, slug, "/quizzes")
store.clear_cache()
return response
@router.post("/{slug}/publish")
async def publish_quiz(request: Request, slug: str):
user = require_user(request)
quiz = require_editor(load_quiz(slug, user), user)
try:
published = store.publish_quiz(quiz["uid"])
except QuizError as exc:
return action_error(request, str(exc), f"/quizzes/{slug}/edit")
if published.get("publish_won"):
award_rewards(user["uid"], XP_QUIZ_PUBLISH, "Quiz Author")
track_action(user["uid"], "quiz_publish")
audit.record(
request,
"quiz.publish",
user=user,
target_type="quiz",
target_uid=quiz["uid"],
target_label=published.get("title") or quiz["uid"],
summary=f"{user['username']} published quiz {published.get('title')}",
links=[audit.target("quiz", quiz["uid"], published.get("title") or "")],
)
store.clear_cache()
await notify_quiz(quiz["uid"])
url = quiz_url(published)
return action_result(
request, url, data={"uid": quiz["uid"], "url": url, "status": published.get("status")}
)
+171
View File
@@ -0,0 +1,171 @@
# retoor <retoor@molodetz.nl>
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from devplacepy.database import get_users_by_uids
from devplacepy.dependencies import json_or_form
from devplacepy.models import QuizQuestionForm, QuizReorderForm
from devplacepy.responses import action_result, respond
from devplacepy.schemas import QuizBuilderOut
from devplacepy.services.audit import record as audit
from devplacepy.services.quiz import QuizError, scoring, store
from devplacepy.utils import require_user
from ._shared import action_error, load_quiz, notify_quiz, quiz_seo, quiz_url, require_editor
router = APIRouter()
KIND_SPECS = [
{
"key": kind.key,
"label": kind.label,
"icon": kind.icon,
"has_options": kind.has_options,
"graded_by": kind.graded_by,
}
for kind in scoring.QUESTION_KINDS
]
def _question_payload(data: QuizQuestionForm) -> dict:
return {
"kind": data.kind,
"prompt": data.prompt.strip(),
"explanation": data.explanation.strip(),
"points": data.points,
"media_attachment_uid": data.media_attachment_uid.strip(),
"correct_boolean": int(bool(data.correct_boolean)),
"expected_answer": data.expected_answer.strip(),
"grading_criteria": data.grading_criteria.strip(),
"numeric_value": data.numeric_value,
"numeric_tolerance": data.numeric_tolerance,
"case_sensitive": int(bool(data.case_sensitive)),
"options": data.option_rows(),
}
@router.get("/{slug}/edit", response_class=HTMLResponse)
async def quiz_builder(request: Request, slug: str):
user = require_user(request)
quiz = require_editor(load_quiz(slug, user), user)
author = get_users_by_uids([quiz["user_uid"]]).get(quiz["user_uid"])
seo_ctx = quiz_seo(
request,
f"Edit {quiz.get('title') or 'quiz'}",
"Build and publish your quiz.",
robots="noindex,follow",
breadcrumbs=[{"name": quiz.get("title") or "Quiz", "url": quiz_url(quiz)}],
)
return respond(
request,
"quiz_edit.html",
{
**seo_ctx,
"request": request,
"user": user,
"quiz": store.serialize_quiz(quiz, user, author),
"questions": store.serialize_builder(quiz, user),
"kinds": KIND_SPECS,
"validation_errors": store.validation_errors(quiz["uid"]),
"quiz_error": request.query_params.get("error", ""),
},
model=QuizBuilderOut,
)
@router.post("/{slug}/questions")
async def add_question(
request: Request,
slug: str,
data: Annotated[QuizQuestionForm, Depends(json_or_form(QuizQuestionForm))],
):
user = require_user(request)
quiz = require_editor(load_quiz(slug, user), user)
try:
question = store.add_question(quiz["uid"], _question_payload(data))
except QuizError as exc:
return action_error(request, str(exc), f"/quizzes/{slug}/edit")
_audit_question(request, user, quiz, question, "create")
await notify_quiz(quiz["uid"])
return action_result(
request,
f"/quizzes/{slug}/edit",
data={"uid": question["uid"], "position": question["position"]},
)
@router.post("/{slug}/questions/reorder")
async def reorder_questions(
request: Request,
slug: str,
data: Annotated[QuizReorderForm, Depends(json_or_form(QuizReorderForm))],
):
user = require_user(request)
quiz = require_editor(load_quiz(slug, user), user)
try:
store.reorder_questions(quiz["uid"], data.order)
except QuizError as exc:
return action_error(request, str(exc), f"/quizzes/{slug}/edit")
audit.record(
request,
"quiz.question.reorder",
user=user,
target_type="quiz",
target_uid=quiz["uid"],
target_label=quiz.get("title") or quiz["uid"],
summary=f"{user['username']} reordered the questions of quiz {quiz.get('title')}",
links=[audit.target("quiz", quiz["uid"], quiz.get("title") or "")],
)
await notify_quiz(quiz["uid"])
return action_result(request, f"/quizzes/{slug}/edit", data={"order": data.order})
@router.post("/{slug}/questions/{question_uid}")
async def edit_question(
request: Request,
slug: str,
question_uid: str,
data: Annotated[QuizQuestionForm, Depends(json_or_form(QuizQuestionForm))],
):
user = require_user(request)
quiz = require_editor(load_quiz(slug, user), user)
try:
question = store.edit_question(quiz["uid"], question_uid, _question_payload(data))
except QuizError as exc:
return action_error(request, str(exc), f"/quizzes/{slug}/edit")
_audit_question(request, user, quiz, question, "edit")
await notify_quiz(quiz["uid"])
return action_result(request, f"/quizzes/{slug}/edit", data={"uid": question_uid})
@router.post("/{slug}/questions/{question_uid}/delete")
async def delete_question(request: Request, slug: str, question_uid: str):
user = require_user(request)
quiz = require_editor(load_quiz(slug, user), user)
try:
question = store.delete_question(quiz["uid"], question_uid, user["uid"])
except QuizError as exc:
return action_error(request, str(exc), f"/quizzes/{slug}/edit")
_audit_question(request, user, quiz, question, "delete")
await notify_quiz(quiz["uid"])
return action_result(request, f"/quizzes/{slug}/edit", data={"uid": question_uid})
def _audit_question(request, user: dict, quiz: dict, question: dict, action: str) -> None:
audit.record(
request,
f"quiz.question.{action}",
user=user,
target_type="quiz",
target_uid=quiz["uid"],
target_label=quiz.get("title") or quiz["uid"],
summary=(
f"{user['username']} {action}d a {question.get('kind')} question "
f"on quiz {quiz.get('title')}"
),
metadata={"question_uid": question.get("uid"), "kind": question.get("kind")},
links=[audit.target("quiz", quiz["uid"], quiz.get("title") or "")],
)
+1 -1
View File
@@ -13,7 +13,7 @@ from devplacepy.dependencies import json_or_form
logger = logging.getLogger(__name__)
router = APIRouter()
REACTABLE: set[str] = {"post", "comment", "gist", "project"}
REACTABLE: set[str] = {"post", "comment", "gist", "project", "quiz"}
@router.post("/{target_type}/{target_uid}")
async def react(
+1 -1
View File
@@ -12,7 +12,7 @@ from devplacepy.dependencies import json_or_form
logger = logging.getLogger(__name__)
router = APIRouter()
VOTABLE = {"post", "comment", "gist", "project"}
VOTABLE = {"post", "comment", "gist", "project", "quiz"}
@router.post("/{target_type}/{target_uid}")