# retoor 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")} )