Refactored polls module into layered architecture with typed domain objects, isolated SSE handling, and comprehensive tests.
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 4s
CI / Tests (Python 3.12) (push) Successful in 2m54s
CI / Tests (Python 3.13) (push) Successful in 2m54s
CI / Tests (Python 3.14) (push) Successful in 2m52s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 5s

This commit is contained in:
2026-05-11 11:58:47 -04:00
parent 4918547c7d
commit 06b4a0bb9c
19 changed files with 4172 additions and 1002 deletions
+176 -165
View File
@@ -16,8 +16,6 @@
from __future__ import annotations
import asyncio
import contextlib
from http import HTTPStatus
from pathlib import Path
from typing import TYPE_CHECKING, Any
@@ -26,7 +24,7 @@ from aiohttp import web
from owlbot.api import RouteContext, on_route
from .manager import PollError, get_manager
from .manager import get_manager
from .types import (
DEFAULT_DURATION,
MAX_DURATION,
@@ -34,17 +32,31 @@ from .types import (
MAX_OPTIONS,
MAX_QUESTION_LENGTH,
MIN_DURATION,
SSEEvent,
sse_payload,
MIN_OPTIONS,
NoActivePollError,
PollAlreadyActiveError,
PollAuthenticationRequiredError,
PollCreateValidationError,
PollVoteError,
)
if TYPE_CHECKING:
from owlbot.sessions import BrowserSession
from .manager import PollManager
_STATIC_DIR = Path(__file__).resolve().parent / "static"
_POLLS_CSS_PATH = _STATIC_DIR / "polls.css"
_POLLS_JS_PATH = _STATIC_DIR / "polls.js"
_CREATE_JS_PATH = _STATIC_DIR / "create.js"
_STATIC_FILES = {
"polls.css": _POLLS_CSS_PATH,
"polls.js": _POLLS_JS_PATH,
"create.js": _CREATE_JS_PATH,
"shared/config.js": _STATIC_DIR / "shared" / "config.js",
"shared/dom.js": _STATIC_DIR / "shared" / "dom.js",
"shared/poll-events.js": _STATIC_DIR / "shared" / "poll-events.js",
}
def _error_page(ctx: RouteContext, status: int, message: str) -> web.Response:
@@ -60,27 +72,18 @@ def _error_page(ctx: RouteContext, status: int, message: str) -> web.Response:
def _require_session(ctx: RouteContext) -> BrowserSession:
"""Return the browser session for a protected polls route."""
"""Return the browser session for a protected polls route.
:param ctx: The route context.
:return: The resolved browser session.
:raises RuntimeError: If a protected route was called without a session.
"""
session = ctx.session
if session is None:
raise RuntimeError("Protected polls route called without a browser session.")
return session
def _default_form() -> dict[str, Any]:
"""Return default form values for the poll creation template."""
return {
"question": "",
"options": ["", ""],
"duration": DEFAULT_DURATION,
"hidden": False,
"requires_auth": False,
"allow_multiple": False,
"min_selections": "",
"max_selections": "",
}
def _create_form_page(
ctx: RouteContext,
*,
@@ -99,6 +102,7 @@ def _create_form_page(
constraints = {
"min_duration": MIN_DURATION,
"max_duration": MAX_DURATION,
"min_options": MIN_OPTIONS,
"max_options": MAX_OPTIONS,
"max_question_length": MAX_QUESTION_LENGTH,
"max_option_length": MAX_OPTION_LENGTH,
@@ -114,38 +118,104 @@ def _create_form_page(
return web.Response(status=status, text=page, content_type="text/html")
def _current_poll_page(
ctx: RouteContext,
manager: PollManager,
session: BrowserSession,
*,
error: str | None = None,
status: int = HTTPStatus.OK,
show_current_selections: bool = True,
) -> web.Response:
"""Render the current poll page from the latest active-poll state.
This helper keeps route handlers from holding an ``ActivePoll`` reference
across awaits and then mixing it with fresh manager state. If the poll has
ended before rendering, the caller gets the normal no-active-poll response.
:param ctx: The route context.
:param manager: Poll manager used to read the latest active poll state.
:param session: Browser session for the viewer rendering the page.
:param error: Optional inline voting error to display.
:param status: HTTP status code for the rendered vote page.
:param show_current_selections: Whether to load and show the viewer's
existing selections.
:return: HTML response for the active poll, or a no-active-poll error page.
"""
poll = manager.active_poll
if poll is None:
return _error_page(ctx, HTTPStatus.NOT_FOUND, "No active poll.")
current_selections: frozenset[int] | None = None
if show_current_selections:
try:
current_selections = manager.get_selections(session.user)
except NoActivePollError:
return _error_page(ctx, HTTPStatus.NOT_FOUND, "No active poll.")
has_voted = current_selections is not None
page = ctx.templates.render(
"vote.html",
poll=poll,
viewer_is_authenticated=session.is_authenticated,
viewer_is_moderator=session.is_moderator,
has_voted=has_voted,
current_selections=current_selections or frozenset(),
error=error,
vote_url=ctx.routes.url_for("/current/vote"),
events_url=ctx.routes.url_for("/current/events"),
end_url=ctx.routes.url_for("/current/end"),
cancel_url=ctx.routes.url_for("/current/cancel"),
polls_css_url=ctx.routes.url_for("/static/polls.css"),
polls_js_url=ctx.routes.url_for("/static/polls.js"),
)
return web.Response(status=status, text=page, content_type="text/html")
@on_route("/create", methods=["GET"], requires_moderator=True)
async def create_page(ctx: RouteContext) -> web.Response:
"""Serve the poll creation form.
:param ctx: The route context.
:return: HTML response with the creation form.
:return: HTML response with the creation form, or an error response if a
poll is already active.
"""
manager = get_manager(ctx.module)
if manager.active_poll is not None:
return _error_page(ctx, HTTPStatus.CONFLICT, "A poll is already active.")
return _create_form_page(ctx, form=_default_form())
return _create_form_page(
ctx,
form={
"question": "",
"options": ["", ""],
"duration": DEFAULT_DURATION,
"hidden": False,
"requires_auth": False,
"allow_multiple": False,
"min_selections": "",
"max_selections": "",
},
)
@on_route("/create", methods=["POST"], requires_moderator=True)
async def create_submit(ctx: RouteContext) -> web.Response:
"""Handle poll creation form submission.
Validates inputs, delegates to the manager to create the poll,
and redirects to the voting page.
Parses form inputs, delegates normalized fields to the manager, and
redirects to the current poll page.
:param ctx: The route context.
:return: Redirect to the voting page, or error response.
:return: Redirect to the current poll page, or error response.
"""
data = await ctx.request.post()
# Parse form inputs.
question = str(data.get("question", "")).strip()
options_raw = str(data.get("options", "")).strip()
options = [o.strip() for o in options_raw.splitlines() if o.strip()]
options = [str(option).strip() for option in data.getall("options", [])]
try:
duration = int(str(data.get("duration", "0")))
@@ -195,80 +265,51 @@ async def create_submit(ctx: RouteContext) -> web.Response:
max_selections=max_selections,
duration=duration,
)
except PollError as e:
except PollAlreadyActiveError:
return _create_form_page(
ctx,
form=form,
error="A poll is already active.",
status=HTTPStatus.CONFLICT,
)
except PollCreateValidationError as e:
return _create_form_page(
ctx, form=form, error=str(e), status=HTTPStatus.BAD_REQUEST
)
# Redirect to the mod's voting page.
vote_url = ctx.routes.url_for("/vote")
raise web.HTTPFound(vote_url)
# Redirect to the active poll page.
current_url = ctx.routes.url_for("/current")
raise web.HTTPFound(current_url)
@on_route("/vote", methods=["GET"], requires_session=True)
async def vote_page(ctx: RouteContext) -> web.Response:
"""Serve the voting page.
@on_route("/current", methods=["GET"], requires_session=True)
async def current_page(ctx: RouteContext) -> web.Response:
"""Serve the current poll page.
Renders the voting page with appropriate visibility and permission controls
Renders the poll page with appropriate visibility and permission controls
based on the connected viewer's session.
:param ctx: The route context.
:return: HTML response with the voting page.
:return: HTML response with the current poll page, or an error response if
no poll is active.
"""
manager = get_manager(ctx.module)
poll = manager.active_poll
if poll is None:
return _error_page(ctx, HTTPStatus.NOT_FOUND, "No active poll.")
session = _require_session(ctx)
user_id = session.user.id
has_voted = user_id in poll.votes
current_votes = poll.votes.get(user_id, set())
vote_url = ctx.routes.url_for("/vote")
page = ctx.templates.render(
"vote.html",
poll=poll,
viewer_is_authenticated=session.is_authenticated,
viewer_is_moderator=session.is_moderator,
has_voted=has_voted,
current_votes=current_votes,
vote_url=vote_url,
events_url=ctx.routes.url_for("/vote/events"),
end_url=ctx.routes.url_for("/vote/end"),
cancel_url=ctx.routes.url_for("/vote/cancel"),
polls_css_url=ctx.routes.url_for("/static/polls.css"),
polls_js_url=ctx.routes.url_for("/static/polls.js"),
)
return web.Response(text=page, content_type="text/html")
return _current_poll_page(ctx, manager, session)
@on_route("/vote", methods=["POST"], requires_session=True)
@on_route("/current/vote", methods=["POST"], requires_session=True)
async def vote_submit(ctx: RouteContext) -> web.Response:
"""Handle vote form submission.
Parses selections, delegates to the manager, and redirects back to the
voting page.
current poll page.
:param ctx: The route context.
:return: Redirect back to the voting page, or error response.
:return: Redirect back to the current poll page, or error response.
"""
manager = get_manager(ctx.module)
poll = manager.active_poll
if poll is None:
return _error_page(ctx, HTTPStatus.NOT_FOUND, "No active poll.")
session = _require_session(ctx)
if poll.requires_auth and not session.is_authenticated:
return _error_page(
ctx,
HTTPStatus.FORBIDDEN,
"This poll requires an authenticated account to vote.",
)
data = await ctx.request.post()
# Parse selections from form checkboxes/radio buttons.
@@ -276,29 +317,50 @@ async def vote_submit(ctx: RouteContext) -> web.Response:
try:
selections = {int(str(s)) for s in raw_selections}
except ValueError:
return _error_page(ctx, HTTPStatus.BAD_REQUEST, "Invalid selection.")
return _current_poll_page(
ctx,
manager,
session=session,
error="Invalid selection.",
status=HTTPStatus.BAD_REQUEST,
)
try:
await manager.record_vote(
session.user,
selections,
)
except PollError as e:
return _error_page(ctx, HTTPStatus.BAD_REQUEST, str(e))
except NoActivePollError:
return _error_page(ctx, HTTPStatus.NOT_FOUND, "No active poll.")
except PollAuthenticationRequiredError:
return _current_poll_page(
ctx,
manager,
session=session,
show_current_selections=False,
error="This poll requires an authenticated account to vote.",
status=HTTPStatus.FORBIDDEN,
)
except PollVoteError as e:
return _current_poll_page(
ctx,
manager,
session=session,
error=str(e),
status=HTTPStatus.BAD_REQUEST,
)
# Redirect back to the voting page.
vote_url = ctx.routes.url_for("/vote")
raise web.HTTPFound(vote_url)
# Redirect back to the active poll page.
current_url = ctx.routes.url_for("/current")
raise web.HTTPFound(current_url)
@on_route("/vote/events", streaming=True, requires_session=True)
@on_route("/current/events", streaming=True, requires_session=True)
async def events_stream(ctx: RouteContext) -> web.StreamResponse | web.Response:
"""SSE endpoint for live poll updates.
Sends keepalive events every 15 seconds with time remaining.
:param ctx: The route context.
:return: SSE stream response.
:return: SSE stream response, or a 404 response if no poll is active.
"""
manager = get_manager(ctx.module)
poll = manager.active_poll
@@ -307,7 +369,6 @@ async def events_stream(ctx: RouteContext) -> web.StreamResponse | web.Response:
return web.Response(status=HTTPStatus.NOT_FOUND, text="No active poll.")
session = _require_session(ctx)
user_id = session.user.id
response = web.StreamResponse(
status=HTTPStatus.OK,
@@ -320,73 +381,45 @@ async def events_stream(ctx: RouteContext) -> web.StreamResponse | web.Response:
)
await response.prepare(ctx.request)
await manager.register_sse_client(
session.user,
response=response,
)
try:
while manager.active_poll is poll:
with contextlib.suppress(TimeoutError):
await asyncio.wait_for(poll.done_event.wait(), timeout=15)
# If the poll ended, exit immediately.
if manager.active_poll is not poll:
break
try:
remaining = poll.time_remaining
payload = sse_payload(SSEEvent.KEEPALIVE, {"time_remaining": remaining})
await response.write(payload)
except Exception: # noqa: BLE001 # SSE disconnect; no single exception covers all transport failures
ctx.logger.debug("SSE keepalive failed for %s.", user_id)
break
finally:
# Only remove ourselves if we're still the registered client.
# A page reload creates a new SSE connection for the same user,
# so the old handler must not evict the replacement.
client = poll.sse_clients.get(user_id)
if client is not None and client.response is response:
poll.sse_clients.pop(user_id, None)
ctx.logger.debug(
"SSE client disconnected: %s (%d remaining).",
user_id,
len(poll.sse_clients),
)
await manager.serve_sse_stream(session.user, response)
except NoActivePollError:
ctx.logger.debug("SSE stream ended before client registration.")
return response
@on_route("/vote/end", methods=["POST"], requires_moderator=True)
@on_route("/current/end", methods=["POST"], requires_moderator=True)
async def mod_end(ctx: RouteContext) -> web.Response:
"""End the poll via the moderator web UI.
:param ctx: The route context.
:return: Redirect to the results page.
:return: Redirect to the results page, or an error response if no poll is
active.
"""
manager = get_manager(ctx.module)
poll = manager.active_poll
if poll is None:
try:
await manager.end()
except NoActivePollError:
return _error_page(ctx, HTTPStatus.NOT_FOUND, "No active poll.")
await manager.end()
results_url = ctx.routes.url_for("/results")
raise web.HTTPFound(results_url)
@on_route("/vote/cancel", methods=["POST"], requires_moderator=True)
@on_route("/current/cancel", methods=["POST"], requires_moderator=True)
async def mod_cancel(ctx: RouteContext) -> web.Response:
"""Cancel the poll via the moderator web UI.
:param ctx: The route context.
:return: Redirect response or error page.
:return: Cancellation confirmation or error page.
"""
manager = get_manager(ctx.module)
poll = manager.active_poll
if poll is None:
try:
await manager.cancel()
except NoActivePollError:
return _error_page(ctx, HTTPStatus.NOT_FOUND, "No active poll.")
await manager.cancel()
return _error_page(ctx, HTTPStatus.OK, "This poll has been cancelled.")
@@ -394,8 +427,7 @@ async def mod_cancel(ctx: RouteContext) -> web.Response:
async def results_page(ctx: RouteContext) -> web.Response:
"""Serve the results page for the most recently completed poll.
Shows contextual error messages if a poll is active or no
results are available.
Shows contextual messages if a poll is active or no results are available.
:param ctx: The route context.
:return: HTML response with the results page.
@@ -422,40 +454,19 @@ async def results_page(ctx: RouteContext) -> web.Response:
return web.Response(text=page, content_type="text/html")
@on_route("/static/polls.css", methods=["GET"])
async def static_polls_css(ctx: RouteContext) -> web.StreamResponse: # noqa: ARG001 # required by route handler signature
"""Serve the polls stylesheet.
@on_route("/static/{filename:.+}", methods=["GET"])
async def static_asset(ctx: RouteContext) -> web.StreamResponse | web.Response:
"""Serve allow-listed poll module static assets.
:param ctx: The route context.
:return: The polls.css file with caching headers.
:param ctx: The route context containing the requested filename.
:return: Static file response, or 404 for unknown asset names.
"""
filename = ctx.match_info["filename"]
path = _STATIC_FILES.get(filename)
if path is None:
return web.Response(status=HTTPStatus.NOT_FOUND)
cache_control = "no-cache" if filename.startswith("shared/") else "max-age=86400"
return web.FileResponse(
_POLLS_CSS_PATH,
headers={"Cache-Control": "max-age=86400"},
)
@on_route("/static/polls.js", methods=["GET"])
async def static_polls_js(ctx: RouteContext) -> web.StreamResponse: # noqa: ARG001 # required by route handler signature
"""Serve the polls JavaScript file.
:param ctx: The route context.
:return: The polls.js file with caching headers.
"""
return web.FileResponse(
_POLLS_JS_PATH,
headers={"Cache-Control": "max-age=86400"},
)
@on_route("/static/create.js", methods=["GET"])
async def static_create_js(ctx: RouteContext) -> web.StreamResponse: # noqa: ARG001 # required by route handler signature
"""Serve the create form JavaScript file.
:param ctx: The route context.
:return: The create.js file with caching headers.
"""
return web.FileResponse(
_CREATE_JS_PATH,
headers={"Cache-Control": "max-age=86400"},
path,
headers={"Cache-Control": cache_control},
)