Quiiz system
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
@@ -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},
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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")}
|
||||
)
|
||||
@@ -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 "")],
|
||||
)
|
||||
@@ -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(
|
||||
|
||||
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user