Refactored polls module to use browser sessions for creation and voting routes.
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (Python 3.12) (push) Successful in 2m47s
CI / Tests (Python 3.13) (push) Successful in 2m46s
CI / Tests (Python 3.14) (push) Successful in 2m44s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 6s
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (Python 3.12) (push) Successful in 2m47s
CI / Tests (Python 3.13) (push) Successful in 2m46s
CI / Tests (Python 3.14) (push) Successful in 2m44s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 6s
This commit is contained in:
@@ -114,8 +114,7 @@ async def on_user_joined(ctx: EventContext[UserJoinedEvent]) -> None:
|
||||
"""Notify a user joining chat that a poll is active.
|
||||
|
||||
Sends a private message with the poll question, options, and voting
|
||||
instructions. Also refreshes the user's token privileges in case their
|
||||
authentication or moderator status has changed.
|
||||
instructions.
|
||||
|
||||
:param ctx: The event context.
|
||||
"""
|
||||
@@ -124,13 +123,6 @@ async def on_user_joined(ctx: EventContext[UserJoinedEvent]) -> None:
|
||||
if poll is None:
|
||||
return
|
||||
|
||||
event = ctx.event
|
||||
manager.refresh_user_status(
|
||||
event.user.id,
|
||||
is_moderator=event.user.is_moderator,
|
||||
is_authenticated=event.user.is_authenticated,
|
||||
)
|
||||
|
||||
escaped_question = escape(poll.question)
|
||||
option_lines = "".join(
|
||||
f"<strong>{i}.</strong> {escape(opt)}<br>"
|
||||
|
||||
@@ -34,22 +34,19 @@ from .manager import PollError, get_manager
|
||||
async def poll_command(ctx: CommandContext) -> None:
|
||||
"""Start the poll creation flow.
|
||||
|
||||
Generates a moderator token and sends the poll creator a private link
|
||||
to the creation form.
|
||||
Sends the poll creator a private protected link to the creation form.
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
manager = get_manager(ctx.module)
|
||||
|
||||
try:
|
||||
mod_token = await manager.begin_creation(ctx.user.id)
|
||||
except PollError as e:
|
||||
if manager.active_poll is not None:
|
||||
await ctx.owncast_client.send_system_message_to_client(
|
||||
ctx.chat_event.client_id, str(e)
|
||||
ctx.chat_event.client_id, "A poll is already active."
|
||||
)
|
||||
return
|
||||
|
||||
url = ctx.routes.url_for(f"/create/{mod_token}")
|
||||
url = ctx.session_url_for("/create")
|
||||
await ctx.owncast_client.send_system_message_to_client(
|
||||
ctx.chat_event.client_id,
|
||||
f'<a href="{url}"><u>Click here to set up a poll</u></a>.',
|
||||
@@ -114,12 +111,7 @@ async def vote_command(ctx: CommandContext) -> None:
|
||||
return
|
||||
|
||||
user_id = ctx.user.id
|
||||
token = manager.get_voter_token(
|
||||
user_id,
|
||||
is_moderator=ctx.user.is_moderator,
|
||||
is_authenticated=ctx.user.is_authenticated,
|
||||
)
|
||||
vote_url = ctx.routes.url_for(f"/vote/{token.value}")
|
||||
vote_url = ctx.session_url_for("/vote")
|
||||
|
||||
# Check authentication requirement.
|
||||
if poll.requires_auth and not ctx.user.is_authenticated:
|
||||
@@ -165,7 +157,10 @@ async def vote_command(ctx: CommandContext) -> None:
|
||||
# Convert 1-based to 0-based index.
|
||||
selection = choice - 1
|
||||
|
||||
await manager.record_vote(user_id, {selection})
|
||||
await manager.record_vote(
|
||||
ctx.user,
|
||||
{selection},
|
||||
)
|
||||
await ctx.owncast_client.send_system_message_to_client(
|
||||
ctx.chat_event.client_id,
|
||||
f"Your vote for #{choice} ({escape(poll.options[selection])})"
|
||||
@@ -209,13 +204,6 @@ async def handle_bare_vote(ctx: EventContext[ChatEvent]) -> None:
|
||||
|
||||
event = ctx.event
|
||||
|
||||
# Keep token privilege flags in sync with the user's current status.
|
||||
manager.refresh_user_status(
|
||||
event.user.id,
|
||||
is_moderator=event.user.is_moderator,
|
||||
is_authenticated=event.user.is_authenticated,
|
||||
)
|
||||
|
||||
text = event.raw_body.strip()
|
||||
|
||||
try:
|
||||
@@ -231,12 +219,7 @@ async def handle_bare_vote(ctx: EventContext[ChatEvent]) -> None:
|
||||
await ctx.owncast_client.set_message_visibility([event.message_id], visible=False)
|
||||
ctx.stop_propagation("bare number vote in active poll")
|
||||
|
||||
token = manager.get_voter_token(
|
||||
event.user.id,
|
||||
is_moderator=event.user.is_moderator,
|
||||
is_authenticated=event.user.is_authenticated,
|
||||
)
|
||||
vote_url = ctx.routes.url_for(f"/vote/{token.value}")
|
||||
vote_url = ctx.session_url_for("/vote")
|
||||
|
||||
# Check authentication requirement.
|
||||
if poll.requires_auth and not event.user.is_authenticated:
|
||||
@@ -274,7 +257,10 @@ async def handle_bare_vote(ctx: EventContext[ChatEvent]) -> None:
|
||||
# Record the vote.
|
||||
selection = choice - 1
|
||||
|
||||
await manager.record_vote(event.user.id, {selection})
|
||||
await manager.record_vote(
|
||||
event.user,
|
||||
{selection},
|
||||
)
|
||||
await ctx.owncast_client.send_system_message_to_client(
|
||||
event.client_id,
|
||||
f"Your vote for #{choice} ({escape(poll.options[selection])})"
|
||||
|
||||
@@ -22,14 +22,12 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import secrets
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from markupsafe import escape
|
||||
|
||||
from .types import (
|
||||
CREATION_TOKEN_TIMEOUT,
|
||||
MAX_DURATION,
|
||||
MAX_OPTION_LENGTH,
|
||||
MAX_OPTIONS,
|
||||
@@ -41,8 +39,8 @@ from .types import (
|
||||
STREAM_GRACE_PERIOD,
|
||||
ActivePoll,
|
||||
CompletedPoll,
|
||||
PollSSEClient,
|
||||
SSEEvent,
|
||||
Token,
|
||||
compute_results,
|
||||
sse_payload,
|
||||
)
|
||||
@@ -50,7 +48,7 @@ from .types import (
|
||||
if TYPE_CHECKING:
|
||||
from aiohttp import web
|
||||
|
||||
from owlbot.api import ModuleContext
|
||||
from owlbot.api import ModuleContext, User
|
||||
|
||||
|
||||
class PollError(Exception):
|
||||
@@ -70,8 +68,6 @@ class PollManager:
|
||||
"""Initialize the manager with the module context."""
|
||||
self._ctx = ctx
|
||||
self._active_poll: ActivePoll | None = None
|
||||
self._creation_token: Token | None = None
|
||||
self._creation_expiry_task: asyncio.Task[None] | None = None
|
||||
self._grace_task: asyncio.Task[None] | None = None
|
||||
self._last_result: CompletedPoll | None = None
|
||||
self._result_expiry_task: asyncio.Task[None] | None = None
|
||||
@@ -81,57 +77,11 @@ class PollManager:
|
||||
"""The currently active poll, if any."""
|
||||
return self._active_poll
|
||||
|
||||
@property
|
||||
def creation_token_value(self) -> str | None:
|
||||
"""The pending token value for poll creation (or None)."""
|
||||
return self._creation_token.value if self._creation_token else None
|
||||
|
||||
@property
|
||||
def last_result(self) -> CompletedPoll | None:
|
||||
"""The most recently completed poll result, if any."""
|
||||
return self._last_result
|
||||
|
||||
async def begin_creation(self, creator_id: str) -> str:
|
||||
"""Begin the poll creation flow by generating a moderator token.
|
||||
|
||||
The token expires after :data:`CREATION_TOKEN_TIMEOUT` seconds
|
||||
if not used.
|
||||
|
||||
:param creator_id: The user ID of the poll creator.
|
||||
:return: The generated moderator token.
|
||||
:raises PollError: If a poll is already active.
|
||||
"""
|
||||
if self._active_poll is not None:
|
||||
raise PollError("A poll is already active.")
|
||||
|
||||
# Cancel any previous token and its expiry task.
|
||||
self.cancel_creation_token()
|
||||
|
||||
self._creation_token = Token(
|
||||
user_id=creator_id,
|
||||
is_moderator=True,
|
||||
is_authenticated=True,
|
||||
)
|
||||
|
||||
async def _expire_creation_token() -> None:
|
||||
await asyncio.sleep(CREATION_TOKEN_TIMEOUT)
|
||||
self._creation_token = None
|
||||
self._creation_expiry_task = None
|
||||
self._ctx.logger.debug(
|
||||
"Creation token expired after %ds.",
|
||||
CREATION_TOKEN_TIMEOUT,
|
||||
)
|
||||
|
||||
self._creation_expiry_task = asyncio.create_task(_expire_creation_token())
|
||||
return self._creation_token.value
|
||||
|
||||
def cancel_creation_token(self) -> None:
|
||||
"""Cancel any pending creation token and its expiry task."""
|
||||
if self._creation_expiry_task is not None:
|
||||
self._creation_expiry_task.cancel()
|
||||
self._creation_expiry_task = None
|
||||
self._creation_token = None
|
||||
|
||||
def _cancel_result_expiry(self) -> None:
|
||||
"""Cancel any running result expiry task."""
|
||||
if self._result_expiry_task is not None:
|
||||
@@ -141,7 +91,6 @@ class PollManager:
|
||||
async def create_poll(
|
||||
self,
|
||||
*,
|
||||
token: str,
|
||||
question: str,
|
||||
options: list[str],
|
||||
hidden: bool,
|
||||
@@ -153,10 +102,9 @@ class PollManager:
|
||||
) -> ActivePoll:
|
||||
"""Create and activate a new poll.
|
||||
|
||||
Validates the moderator token, constructs an :class:`ActivePoll`,
|
||||
starts the auto-end timer task, and announces in chat.
|
||||
Constructs an :class:`ActivePoll`, starts the auto-end timer task, and
|
||||
announces in chat.
|
||||
|
||||
:param token: Moderator token from :meth:`begin_creation`.
|
||||
:param question: The poll question.
|
||||
:param options: List of poll option strings.
|
||||
:param hidden: Whether vote results are hidden until the poll ends.
|
||||
@@ -166,8 +114,7 @@ class PollManager:
|
||||
:param max_selections: Maximum selections allowed (multi-select).
|
||||
:param duration: Poll duration in seconds.
|
||||
:return: The newly created :class:`ActivePoll`.
|
||||
:raises PollError: If validation fails, the token is invalid,
|
||||
or a poll is already active.
|
||||
:raises PollError: If validation fails or a poll is already active.
|
||||
"""
|
||||
self._last_result = None
|
||||
self._cancel_result_expiry()
|
||||
@@ -175,11 +122,6 @@ class PollManager:
|
||||
if self._active_poll is not None:
|
||||
raise PollError("A poll is already active.")
|
||||
|
||||
if self._creation_token is None or not secrets.compare_digest(
|
||||
self._creation_token.value, token
|
||||
):
|
||||
raise PollError("Invalid or expired creation token.")
|
||||
|
||||
# Validate inputs.
|
||||
if not question:
|
||||
raise PollError("Question is required.")
|
||||
@@ -249,13 +191,9 @@ class PollManager:
|
||||
max_selections=max_selections,
|
||||
duration=duration,
|
||||
created_at=now,
|
||||
tokens={token: self._creation_token},
|
||||
)
|
||||
|
||||
self._active_poll = poll
|
||||
# The token object now lives in poll.tokens as a regular token,
|
||||
# so clearing the creation reference has no effect on it.
|
||||
self.cancel_creation_token()
|
||||
|
||||
# Start auto-end timer task with reminder before expiry.
|
||||
async def _poll_timer() -> None:
|
||||
@@ -321,60 +259,14 @@ class PollManager:
|
||||
|
||||
return poll
|
||||
|
||||
def get_voter_token(
|
||||
self, user_id: str, *, is_moderator: bool, is_authenticated: bool
|
||||
) -> Token:
|
||||
"""Get or create a token for the given user.
|
||||
|
||||
:param user_id: The user's unique identifier.
|
||||
:param is_moderator: Whether the user has moderator privileges.
|
||||
:param is_authenticated: Whether the user has an authenticated Owncast account.
|
||||
:return: The Token object.
|
||||
:raises PollError: If no poll is currently active.
|
||||
"""
|
||||
poll = self._active_poll
|
||||
if poll is None:
|
||||
raise PollError("No active poll.")
|
||||
|
||||
# Return existing token if user already has one.
|
||||
for token in poll.tokens.values():
|
||||
if token.user_id == user_id:
|
||||
return token
|
||||
|
||||
# Create new token with privilege based on mod status.
|
||||
token = Token(
|
||||
user_id=user_id,
|
||||
is_moderator=is_moderator,
|
||||
is_authenticated=is_authenticated,
|
||||
)
|
||||
poll.tokens[token.value] = token
|
||||
return token
|
||||
|
||||
def refresh_user_status(
|
||||
self, user_id: str, *, is_moderator: bool, is_authenticated: bool
|
||||
async def record_vote(
|
||||
self,
|
||||
user: User,
|
||||
selections: set[int],
|
||||
) -> None:
|
||||
"""Update the privilege flags on an existing token for the given user.
|
||||
|
||||
No-op if there is no active poll or the user has no token.
|
||||
|
||||
:param user_id: The user's unique identifier.
|
||||
:param is_moderator: Current moderator status.
|
||||
:param is_authenticated: Current authentication status.
|
||||
"""
|
||||
poll = self._active_poll
|
||||
if poll is None:
|
||||
return
|
||||
|
||||
for token in poll.tokens.values():
|
||||
if token.user_id == user_id:
|
||||
token.is_moderator = is_moderator
|
||||
token.is_authenticated = is_authenticated
|
||||
return
|
||||
|
||||
async def record_vote(self, user_id: str, selections: set[int]) -> None:
|
||||
"""Record a user's vote and broadcast an SSE update.
|
||||
|
||||
:param user_id: The voter's unique identifier.
|
||||
:param user: The voter.
|
||||
:param selections: Set of selected option indices (0-based).
|
||||
:raises PollError: If no poll is active or the selections are invalid.
|
||||
"""
|
||||
@@ -383,16 +275,10 @@ class PollManager:
|
||||
raise PollError("No active poll.")
|
||||
|
||||
# Check authentication requirement.
|
||||
if poll.requires_auth:
|
||||
authenticated = False
|
||||
for token_obj in poll.tokens.values():
|
||||
if token_obj.user_id == user_id:
|
||||
authenticated = token_obj.is_authenticated
|
||||
break
|
||||
if not authenticated:
|
||||
raise PollError("This poll requires an authenticated account to vote.")
|
||||
if poll.requires_auth and not user.is_authenticated:
|
||||
raise PollError("This poll requires an authenticated account to vote.")
|
||||
|
||||
if user_id in poll.votes:
|
||||
if user.id in poll.votes:
|
||||
raise PollError("You have already voted.")
|
||||
|
||||
if not selections:
|
||||
@@ -414,7 +300,7 @@ class PollManager:
|
||||
if poll.max_selections is not None and len(selections) > poll.max_selections:
|
||||
raise PollError(f"At most {poll.max_selections} selections are allowed.")
|
||||
|
||||
poll.votes[user_id] = selections
|
||||
poll.votes[user.id] = selections
|
||||
|
||||
# Broadcast SSE update.
|
||||
results = compute_results(poll.options, poll.votes)
|
||||
@@ -553,14 +439,17 @@ class PollManager:
|
||||
await self._close_sse_clients(poll)
|
||||
|
||||
async def register_sse_client(
|
||||
self, token: str, response: web.StreamResponse
|
||||
self,
|
||||
user: User,
|
||||
*,
|
||||
response: web.StreamResponse,
|
||||
) -> None:
|
||||
"""Register an SSE client connection, replacing any existing one for the token.
|
||||
"""Register an SSE client connection for a user.
|
||||
|
||||
Sends an initial ``tally`` event so the client has current
|
||||
results without needing them baked into the page.
|
||||
|
||||
:param token: The client's token (mod or voter).
|
||||
:param user: Owncast user associated with the browser session.
|
||||
:param response: The SSE stream response object.
|
||||
:raises PollError: If no poll is currently active.
|
||||
"""
|
||||
@@ -568,14 +457,14 @@ class PollManager:
|
||||
if poll is None:
|
||||
raise PollError("No active poll.")
|
||||
|
||||
if token not in poll.tokens:
|
||||
raise PollError("Invalid token.")
|
||||
|
||||
poll.sse_clients[token] = response
|
||||
poll.sse_clients[user.id] = PollSSEClient(
|
||||
user_id=user.id,
|
||||
is_moderator=user.is_moderator,
|
||||
response=response,
|
||||
)
|
||||
|
||||
# Send initial vote counts if this client should see results.
|
||||
token_obj = poll.tokens[token]
|
||||
if token_obj.is_moderator or not poll.hidden:
|
||||
if user.is_moderator or not poll.hidden:
|
||||
results = compute_results(poll.options, poll.votes)
|
||||
payload = sse_payload(
|
||||
SSEEvent.TALLY,
|
||||
@@ -591,8 +480,7 @@ class PollManager:
|
||||
"""Start the stream-stop grace period.
|
||||
|
||||
If the stream does not restart within :data:`STREAM_GRACE_PERIOD`
|
||||
seconds, any pending creation token is cleared and any active poll
|
||||
is cancelled.
|
||||
seconds, any active poll is cancelled.
|
||||
"""
|
||||
self.cancel_stream_grace()
|
||||
|
||||
@@ -602,7 +490,6 @@ class PollManager:
|
||||
STREAM_GRACE_PERIOD,
|
||||
)
|
||||
await asyncio.sleep(STREAM_GRACE_PERIOD)
|
||||
self.cancel_creation_token()
|
||||
if self._active_poll is not None:
|
||||
await self.cancel()
|
||||
self._ctx.logger.info(
|
||||
@@ -621,10 +508,9 @@ class PollManager:
|
||||
async def teardown(self) -> None:
|
||||
"""Clean up on module unload.
|
||||
|
||||
Cancels grace period, creation token, and any active poll.
|
||||
Cancels grace period and any active poll.
|
||||
"""
|
||||
self.cancel_stream_grace()
|
||||
self.cancel_creation_token()
|
||||
self._cancel_result_expiry()
|
||||
if self._active_poll is not None:
|
||||
await self.cancel()
|
||||
@@ -634,8 +520,8 @@ class PollManager:
|
||||
) -> None:
|
||||
"""Broadcast an SSE event to connected clients.
|
||||
|
||||
For ``tally`` events on hidden polls, only the moderator client
|
||||
receives the update. All other event types are sent to every client.
|
||||
For ``tally`` events on hidden polls, only moderator clients receive
|
||||
the update. All other event types are sent to every client.
|
||||
|
||||
:param poll: The active poll.
|
||||
:param event_type: The SSE event type.
|
||||
@@ -646,12 +532,11 @@ class PollManager:
|
||||
|
||||
# Determine which clients to send to.
|
||||
if poll.hidden and event_type == SSEEvent.TALLY:
|
||||
# Only privileged tokens receive vote updates for hidden polls.
|
||||
# Only moderators receive vote updates for hidden polls.
|
||||
targets = {
|
||||
token_value: client
|
||||
for token_value, client in clients.items()
|
||||
if (token_obj := poll.tokens.get(token_value))
|
||||
and token_obj.is_moderator
|
||||
user_id: client
|
||||
for user_id, client in clients.items()
|
||||
if client.is_moderator
|
||||
}
|
||||
else:
|
||||
targets = dict(clients)
|
||||
@@ -660,18 +545,18 @@ class PollManager:
|
||||
return
|
||||
|
||||
# Send to all targets concurrently.
|
||||
tokens = list(targets.keys())
|
||||
user_ids = list(targets.keys())
|
||||
responses = list(targets.values())
|
||||
results = await asyncio.gather(
|
||||
*(r.write(payload) for r in responses),
|
||||
*(client.response.write(payload) for client in responses),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
# Remove failed clients.
|
||||
for token, result in zip(tokens, results, strict=True):
|
||||
for user_id, result in zip(user_ids, results, strict=True):
|
||||
if isinstance(result, BaseException):
|
||||
self._ctx.logger.debug("Removing disconnected SSE client: %s", token)
|
||||
clients.pop(token, None)
|
||||
self._ctx.logger.debug("Removing disconnected SSE client: %s", user_id)
|
||||
clients.pop(user_id, None)
|
||||
|
||||
async def _close_sse_clients(self, poll: ActivePoll) -> None:
|
||||
"""Close all SSE client connections for a poll.
|
||||
@@ -680,7 +565,7 @@ class PollManager:
|
||||
"""
|
||||
for client in poll.sse_clients.values():
|
||||
with contextlib.suppress(Exception):
|
||||
await client.write_eof()
|
||||
await client.response.write_eof()
|
||||
poll.sse_clients.clear()
|
||||
|
||||
|
||||
|
||||
@@ -18,10 +18,9 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import secrets
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
@@ -39,6 +38,9 @@ from .types import (
|
||||
sse_payload,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from owlbot.sessions import BrowserSession
|
||||
|
||||
_STATIC_DIR = Path(__file__).resolve().parent / "static"
|
||||
_POLLS_CSS_PATH = _STATIC_DIR / "polls.css"
|
||||
_POLLS_JS_PATH = _STATIC_DIR / "polls.js"
|
||||
@@ -57,6 +59,14 @@ def _error_page(ctx: RouteContext, status: int, message: str) -> web.Response:
|
||||
return web.Response(status=status, text=page, content_type="text/html")
|
||||
|
||||
|
||||
def _require_session(ctx: RouteContext) -> BrowserSession:
|
||||
"""Return the browser session for a protected polls route."""
|
||||
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 {
|
||||
@@ -73,7 +83,6 @@ def _default_form() -> dict[str, Any]:
|
||||
|
||||
def _create_form_page(
|
||||
ctx: RouteContext,
|
||||
token: str,
|
||||
*,
|
||||
form: dict[str, Any],
|
||||
error: str | None = None,
|
||||
@@ -82,7 +91,6 @@ def _create_form_page(
|
||||
"""Render the poll creation form.
|
||||
|
||||
:param ctx: The route context.
|
||||
:param token: The creation token.
|
||||
:param form: Form field values to populate.
|
||||
:param error: Optional error message to display.
|
||||
:param status: HTTP status code.
|
||||
@@ -97,40 +105,31 @@ def _create_form_page(
|
||||
}
|
||||
page = ctx.templates.render(
|
||||
"create.html",
|
||||
token=token,
|
||||
form=form,
|
||||
error=error,
|
||||
constraints=constraints,
|
||||
submit_url=ctx.routes.url_for(f"/create/{token}"),
|
||||
submit_url=ctx.routes.url_for("/create"),
|
||||
create_js_url=ctx.routes.url_for("/static/create.js"),
|
||||
)
|
||||
return web.Response(status=status, text=page, content_type="text/html")
|
||||
|
||||
|
||||
@on_route("/create/{token}", methods=["GET"])
|
||||
@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.
|
||||
"""
|
||||
token = ctx.match_info["token"]
|
||||
manager = get_manager(ctx.module)
|
||||
|
||||
if manager.active_poll is not None:
|
||||
return _error_page(ctx, HTTPStatus.CONFLICT, "A poll is already active.")
|
||||
|
||||
if manager.creation_token_value is None or not secrets.compare_digest(
|
||||
manager.creation_token_value, token
|
||||
):
|
||||
return _error_page(
|
||||
ctx, HTTPStatus.FORBIDDEN, "Invalid or expired creation link."
|
||||
)
|
||||
|
||||
return _create_form_page(ctx, token, form=_default_form())
|
||||
return _create_form_page(ctx, form=_default_form())
|
||||
|
||||
|
||||
@on_route("/create/{token}", methods=["POST"])
|
||||
@on_route("/create", methods=["POST"], requires_moderator=True)
|
||||
async def create_submit(ctx: RouteContext) -> web.Response:
|
||||
"""Handle poll creation form submission.
|
||||
|
||||
@@ -140,7 +139,6 @@ async def create_submit(ctx: RouteContext) -> web.Response:
|
||||
:param ctx: The route context.
|
||||
:return: Redirect to the voting page, or error response.
|
||||
"""
|
||||
token = ctx.match_info["token"]
|
||||
data = await ctx.request.post()
|
||||
|
||||
# Parse form inputs.
|
||||
@@ -188,7 +186,6 @@ async def create_submit(ctx: RouteContext) -> web.Response:
|
||||
manager = get_manager(ctx.module)
|
||||
try:
|
||||
await manager.create_poll(
|
||||
token=token,
|
||||
question=question,
|
||||
options=options,
|
||||
hidden=hidden,
|
||||
@@ -200,20 +197,20 @@ async def create_submit(ctx: RouteContext) -> web.Response:
|
||||
)
|
||||
except PollError as e:
|
||||
return _create_form_page(
|
||||
ctx, token, form=form, error=str(e), status=HTTPStatus.BAD_REQUEST
|
||||
ctx, form=form, error=str(e), status=HTTPStatus.BAD_REQUEST
|
||||
)
|
||||
|
||||
# Redirect to the mod's voting page.
|
||||
vote_url = ctx.routes.url_for(f"/vote/{token}")
|
||||
vote_url = ctx.routes.url_for("/vote")
|
||||
raise web.HTTPFound(vote_url)
|
||||
|
||||
|
||||
@on_route("/vote/{token}", methods=["GET"])
|
||||
@on_route("/vote", methods=["GET"], requires_session=True)
|
||||
async def vote_page(ctx: RouteContext) -> web.Response:
|
||||
"""Serve the voting page.
|
||||
|
||||
Validates the token and renders the voting page with appropriate
|
||||
visibility and permission controls based on the token's privileges.
|
||||
Renders the voting 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.
|
||||
@@ -224,34 +221,35 @@ async def vote_page(ctx: RouteContext) -> web.Response:
|
||||
if poll is None:
|
||||
return _error_page(ctx, HTTPStatus.NOT_FOUND, "No active poll.")
|
||||
|
||||
token = ctx.match_info["token"]
|
||||
token_obj = poll.tokens.get(token)
|
||||
|
||||
if token_obj is None:
|
||||
return _error_page(ctx, HTTPStatus.FORBIDDEN, "Invalid or expired voting link.")
|
||||
|
||||
has_voted = token_obj.user_id in poll.votes
|
||||
current_votes = poll.votes.get(token_obj.user_id, set())
|
||||
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,
|
||||
token=token_obj,
|
||||
viewer_is_authenticated=session.is_authenticated,
|
||||
viewer_is_moderator=session.is_moderator,
|
||||
has_voted=has_voted,
|
||||
current_votes=current_votes,
|
||||
vote_url=ctx.routes.url_for(f"/vote/{token}"),
|
||||
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")
|
||||
|
||||
|
||||
@on_route("/vote/{token}", methods=["POST"])
|
||||
@on_route("/vote", methods=["POST"], requires_session=True)
|
||||
async def vote_submit(ctx: RouteContext) -> web.Response:
|
||||
"""Handle vote form submission.
|
||||
|
||||
Validates the token, parses selections, delegates to the manager,
|
||||
and redirects back to the voting page.
|
||||
Parses selections, delegates to the manager, and redirects back to the
|
||||
voting page.
|
||||
|
||||
:param ctx: The route context.
|
||||
:return: Redirect back to the voting page, or error response.
|
||||
@@ -262,21 +260,15 @@ async def vote_submit(ctx: RouteContext) -> web.Response:
|
||||
if poll is None:
|
||||
return _error_page(ctx, HTTPStatus.NOT_FOUND, "No active poll.")
|
||||
|
||||
token = ctx.match_info["token"]
|
||||
token_obj = poll.tokens.get(token)
|
||||
session = _require_session(ctx)
|
||||
|
||||
if token_obj is None:
|
||||
return _error_page(ctx, HTTPStatus.FORBIDDEN, "Invalid voting link.")
|
||||
|
||||
if poll.requires_auth and not token_obj.is_authenticated:
|
||||
if poll.requires_auth and not session.is_authenticated:
|
||||
return _error_page(
|
||||
ctx,
|
||||
HTTPStatus.FORBIDDEN,
|
||||
"This poll requires an authenticated account to vote.",
|
||||
)
|
||||
|
||||
voter_id = token_obj.user_id
|
||||
|
||||
data = await ctx.request.post()
|
||||
|
||||
# Parse selections from form checkboxes/radio buttons.
|
||||
@@ -287,16 +279,19 @@ async def vote_submit(ctx: RouteContext) -> web.Response:
|
||||
return _error_page(ctx, HTTPStatus.BAD_REQUEST, "Invalid selection.")
|
||||
|
||||
try:
|
||||
await manager.record_vote(voter_id, selections)
|
||||
await manager.record_vote(
|
||||
session.user,
|
||||
selections,
|
||||
)
|
||||
except PollError as e:
|
||||
return _error_page(ctx, HTTPStatus.BAD_REQUEST, str(e))
|
||||
|
||||
# Redirect back to the voting page.
|
||||
vote_url = ctx.routes.url_for(f"/vote/{token}")
|
||||
vote_url = ctx.routes.url_for("/vote")
|
||||
raise web.HTTPFound(vote_url)
|
||||
|
||||
|
||||
@on_route("/vote/{token}/events", streaming=True)
|
||||
@on_route("/vote/events", streaming=True, requires_session=True)
|
||||
async def events_stream(ctx: RouteContext) -> web.StreamResponse | web.Response:
|
||||
"""SSE endpoint for live poll updates.
|
||||
|
||||
@@ -311,9 +306,8 @@ async def events_stream(ctx: RouteContext) -> web.StreamResponse | web.Response:
|
||||
if poll is None:
|
||||
return web.Response(status=HTTPStatus.NOT_FOUND, text="No active poll.")
|
||||
|
||||
token = ctx.match_info["token"]
|
||||
if token not in poll.tokens:
|
||||
return web.Response(status=HTTPStatus.FORBIDDEN, text="Invalid token.")
|
||||
session = _require_session(ctx)
|
||||
user_id = session.user.id
|
||||
|
||||
response = web.StreamResponse(
|
||||
status=HTTPStatus.OK,
|
||||
@@ -326,7 +320,10 @@ async def events_stream(ctx: RouteContext) -> web.StreamResponse | web.Response:
|
||||
)
|
||||
await response.prepare(ctx.request)
|
||||
|
||||
await manager.register_sse_client(token, response)
|
||||
await manager.register_sse_client(
|
||||
session.user,
|
||||
response=response,
|
||||
)
|
||||
|
||||
try:
|
||||
while manager.active_poll is poll:
|
||||
@@ -340,24 +337,25 @@ async def events_stream(ctx: RouteContext) -> web.StreamResponse | web.Response:
|
||||
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...", token[:8])
|
||||
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 under the same token,
|
||||
# A page reload creates a new SSE connection for the same user,
|
||||
# so the old handler must not evict the replacement.
|
||||
if poll.sse_clients.get(token) is response:
|
||||
poll.sse_clients.pop(token, None)
|
||||
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).",
|
||||
token[:8],
|
||||
"SSE client disconnected: %s (%d remaining).",
|
||||
user_id,
|
||||
len(poll.sse_clients),
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@on_route("/vote/{token}/end", methods=["POST"])
|
||||
@on_route("/vote/end", methods=["POST"], requires_moderator=True)
|
||||
async def mod_end(ctx: RouteContext) -> web.Response:
|
||||
"""End the poll via the moderator web UI.
|
||||
|
||||
@@ -370,19 +368,12 @@ async def mod_end(ctx: RouteContext) -> web.Response:
|
||||
if poll is None:
|
||||
return _error_page(ctx, HTTPStatus.NOT_FOUND, "No active poll.")
|
||||
|
||||
token = ctx.match_info["token"]
|
||||
token_obj = poll.tokens.get(token)
|
||||
if token_obj is None or not token_obj.is_moderator:
|
||||
return _error_page(
|
||||
ctx, HTTPStatus.FORBIDDEN, "Only moderators can end the poll."
|
||||
)
|
||||
|
||||
await manager.end()
|
||||
results_url = ctx.routes.url_for("/results")
|
||||
raise web.HTTPFound(results_url)
|
||||
|
||||
|
||||
@on_route("/vote/{token}/cancel", methods=["POST"])
|
||||
@on_route("/vote/cancel", methods=["POST"], requires_moderator=True)
|
||||
async def mod_cancel(ctx: RouteContext) -> web.Response:
|
||||
"""Cancel the poll via the moderator web UI.
|
||||
|
||||
@@ -395,13 +386,6 @@ async def mod_cancel(ctx: RouteContext) -> web.Response:
|
||||
if poll is None:
|
||||
return _error_page(ctx, HTTPStatus.NOT_FOUND, "No active poll.")
|
||||
|
||||
token = ctx.match_info["token"]
|
||||
token_obj = poll.tokens.get(token)
|
||||
if token_obj is None or not token_obj.is_moderator:
|
||||
return _error_page(
|
||||
ctx, HTTPStatus.FORBIDDEN, "Only moderators can cancel the poll."
|
||||
)
|
||||
|
||||
await manager.cancel()
|
||||
return _error_page(ctx, HTTPStatus.OK, "This poll has been cancelled.")
|
||||
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
"use strict";
|
||||
|
||||
const config = window.POLL_CONFIG;
|
||||
const tokenInfo = window.TOKEN_INFO;
|
||||
if (!config || !tokenInfo) return;
|
||||
const viewerInfo = window.VIEWER_INFO;
|
||||
if (!config || !viewerInfo) return;
|
||||
|
||||
const timerEl = document.getElementById("timer");
|
||||
let remaining = config.timeRemaining;
|
||||
@@ -71,7 +71,7 @@
|
||||
|
||||
// Show vote counts immediately for non-hidden polls so the UI
|
||||
// doesn't look identical to a hidden poll before SSE delivers data.
|
||||
if (!config.hidden || tokenInfo.isModerator) {
|
||||
if (!config.hidden || viewerInfo.isModerator) {
|
||||
for (const el of document.querySelectorAll(".vote-count")) {
|
||||
el.classList.remove("d-none");
|
||||
}
|
||||
@@ -79,7 +79,7 @@
|
||||
|
||||
// --- SSE connection ---
|
||||
|
||||
const evtSource = new EventSource(tokenInfo.eventsUrl);
|
||||
const evtSource = new EventSource(viewerInfo.eventsUrl);
|
||||
|
||||
const parse = (e) => {
|
||||
try {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<div id="poll-status" class="mb-4">
|
||||
<p class="text-body-secondary mb-1">Time remaining: <strong id="timer">--:--</strong></p>
|
||||
{% if poll.hidden %}
|
||||
{% if token.is_moderator %}
|
||||
{% if viewer_is_moderator %}
|
||||
<p class="text-body-secondary mb-0">Results are hidden until this poll ends. As a moderator, you can still see the real-time results.</p>
|
||||
{% else %}
|
||||
<p class="text-body-secondary mb-0">Results are hidden until this poll ends.</p>
|
||||
@@ -27,11 +27,11 @@
|
||||
{% if poll.allow_multiple %}
|
||||
<input type="checkbox" name="option" value="{{ loop.index0 }}" class="form-check-input me-2"
|
||||
{% if loop.index0 in current_votes %}checked{% endif %}
|
||||
{% if has_voted or (not token.is_authenticated and poll.requires_auth) %}disabled{% endif %}>
|
||||
{% if has_voted or (not viewer_is_authenticated and poll.requires_auth) %}disabled{% endif %}>
|
||||
{% else %}
|
||||
<input type="radio" name="option" value="{{ loop.index0 }}" class="form-check-input me-2" required
|
||||
{% if loop.index0 in current_votes %}checked{% endif %}
|
||||
{% if has_voted or (not token.is_authenticated and poll.requires_auth) %}disabled{% endif %}>
|
||||
{% if has_voted or (not viewer_is_authenticated and poll.requires_auth) %}disabled{% endif %}>
|
||||
{% endif %}
|
||||
{{ option }}
|
||||
</label>
|
||||
@@ -42,7 +42,7 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if poll.requires_auth and not token.is_authenticated %}
|
||||
{% if poll.requires_auth and not viewer_is_authenticated %}
|
||||
<div class="alert alert-warning mb-0" role="alert">This poll requires an authenticated account to vote. You are viewing results only.</div>
|
||||
{% elif not has_voted %}
|
||||
<button type="submit" class="btn btn-primary me-2">Vote</button>
|
||||
@@ -51,15 +51,15 @@
|
||||
{% endif %}
|
||||
</form>
|
||||
|
||||
{% if token.is_moderator %}
|
||||
{% if viewer_is_moderator %}
|
||||
<hr class="my-4">
|
||||
<h5>Manage Poll</h5>
|
||||
<p class="text-body-secondary">As a moderator, you can perform the following actions on this poll:</p>
|
||||
<div class="d-flex gap-2">
|
||||
<form method="POST" action="{{ vote_url }}/end">
|
||||
<form method="POST" action="{{ end_url }}">
|
||||
<button type="submit" class="btn btn-success">End Poll</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ vote_url }}/cancel">
|
||||
<form method="POST" action="{{ cancel_url }}">
|
||||
<button type="submit" class="btn btn-danger">Cancel Poll</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -74,9 +74,9 @@ window.POLL_CONFIG = {
|
||||
minSelections: {{ poll.min_selections | tojson }},
|
||||
maxSelections: {{ poll.max_selections | tojson }}
|
||||
};
|
||||
window.TOKEN_INFO = {
|
||||
isModerator: {{ token.is_moderator | tojson }},
|
||||
eventsUrl: {{ (vote_url + "/events") | tojson }}
|
||||
window.VIEWER_INFO = {
|
||||
isModerator: {{ viewer_is_moderator | tojson }},
|
||||
eventsUrl: {{ events_url | tojson }}
|
||||
};
|
||||
</script>
|
||||
<script src="{{ polls_js_url }}?v={{ owlbot_version }}"></script>
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
@@ -39,7 +38,6 @@ DEFAULT_DURATION = 240
|
||||
MIN_DURATION = 30
|
||||
MAX_DURATION = 600
|
||||
REMINDER_THRESHOLD = 60
|
||||
CREATION_TOKEN_TIMEOUT = 15 * 60
|
||||
STREAM_GRACE_PERIOD = 5 * 60
|
||||
RESULT_EXPIRY = 3600
|
||||
|
||||
@@ -64,19 +62,17 @@ def sse_payload(event_type: SSEEvent, data: dict[str, Any]) -> bytes:
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Token:
|
||||
"""A token for poll access.
|
||||
class PollSSEClient:
|
||||
"""A connected live-results stream for a poll viewer.
|
||||
|
||||
:param user_id: The Owncast user ID this token belongs to.
|
||||
:param is_moderator: Whether the user has moderator privileges.
|
||||
:param is_authenticated: Whether the user has an authenticated Owncast account.
|
||||
:param value: The token string used in URLs (auto-generated if not provided).
|
||||
:param user_id: Owncast user ID associated with the browser session.
|
||||
:param is_moderator: Whether this viewer can see hidden live tallies.
|
||||
:param response: The active SSE response.
|
||||
"""
|
||||
|
||||
user_id: str
|
||||
is_moderator: bool
|
||||
is_authenticated: bool
|
||||
value: str = field(default_factory=lambda: secrets.token_urlsafe(32))
|
||||
response: web.StreamResponse
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -93,9 +89,8 @@ class ActivePoll:
|
||||
:param duration: Poll duration in seconds.
|
||||
:param created_at: Timestamp when the poll was created.
|
||||
:param votes: Mapping of user ID to set of selected option indices.
|
||||
:param sse_clients: Mapping of client token to SSE stream response.
|
||||
:param sse_clients: Mapping of user ID to SSE client response metadata.
|
||||
:param timer_task: Asyncio task for the poll timer.
|
||||
:param tokens: Mapping of token string to Token object.
|
||||
"""
|
||||
|
||||
question: str
|
||||
@@ -108,9 +103,8 @@ class ActivePoll:
|
||||
duration: int
|
||||
created_at: datetime
|
||||
votes: dict[str, set[int]] = field(default_factory=dict)
|
||||
sse_clients: dict[str, web.StreamResponse] = field(default_factory=dict)
|
||||
sse_clients: dict[str, PollSSEClient] = field(default_factory=dict)
|
||||
timer_task: asyncio.Task[None] | None = None
|
||||
tokens: dict[str, Token] = field(default_factory=dict)
|
||||
done_event: asyncio.Event = field(default_factory=asyncio.Event)
|
||||
_started_at_mono: float = field(default_factory=time.monotonic)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user