Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions alembic/versions/20260904_1400_add_poll_option_groups.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""add poll option groups

Revision ID: 9d4e1f6a2b58
Revises: 7a2c4f9e8b13
Create Date: 2026-09-04 14:00:00

"""

from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op

revision: str = "9d4e1f6a2b58"
down_revision: str | None = "7a2c4f9e8b13"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
op.create_table(
"poll_option_groups",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("poll_id", sa.BigInteger(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("max_selections", sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(["poll_id"], ["polls.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.Index("ix_poll_option_groups_poll_id", "poll_id"),
sa.CheckConstraint(
"max_selections IS NULL OR max_selections >= 1",
name="ck_poll_option_groups_max_selections_positive",
),
)
op.add_column("poll_options", sa.Column("group_id", sa.BigInteger(), nullable=True))
op.create_foreign_key(
"fk_poll_options_group_id_poll_option_groups",
"poll_options",
"poll_option_groups",
["group_id"],
["id"],
ondelete="SET NULL",
)
op.create_index("ix_poll_options_group_id", "poll_options", ["group_id"])


def downgrade() -> None:
op.drop_index("ix_poll_options_group_id", table_name="poll_options")
op.drop_constraint(
"fk_poll_options_group_id_poll_option_groups", "poll_options", type_="foreignkey"
)
op.drop_column("poll_options", "group_id")
op.drop_table("poll_option_groups")
8 changes: 8 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,9 +224,11 @@ Core entities and how they relate. Field-level truth lives in `src/struudel/mode
erDiagram
User ||--o{ Poll : creates
Poll ||--o{ PollOption : has
Poll ||--o{ PollOptionGroup : "option groups"
Poll ||--o{ PollResponse : collects
Poll }o--o{ User : "poll_users (audience)"
Poll }o--o{ Group : "poll_groups (audience)"
PollOptionGroup ||--o{ PollOption : caps
PollOption ||--o{ PollResponseOption : "voted on"
PollResponse ||--o{ PollResponseOption : contains
PollResponse }o--|| User : "by"
Expand All @@ -244,6 +246,7 @@ erDiagram
- `SINGLE_CHOICE` — user picks exactly one option: exactly one `YES`, rest `NO`
- `MULTI_CHOICE` — user picks N options: any number of `YES`, rest `NO`, no `MAYBE`
- **Option types** are mixable per poll (`DATE`, `DATETIME`, `TEXT`). A CHECK constraint (`ck_poll_options_value_matches_type`) enforces that exactly the value column matching `option_type` is non-null.
- **Option groups**: `PollOption.group_id` (nullable) optionally points at a `PollOptionGroup` — an author-defined subset of a poll's options with an optional `max_selections` cap (e.g. "max 1 of A and B"). A group's `max_selections`/`name` are single-sourced on `PollOptionGroup`, not duplicated per option. `group_id` uses `ondelete="SET NULL"`: deleting a group ungroups its member options — they stay on the poll with their votes intact, they just lose the cap.
- **Custom options**: user-added options require `poll.allow_custom_options=True`; they carry `is_custom=True` and `created_by_id`. Other users can still vote on them.
- **One response per user per poll**: enforced by `uq_poll_responses_poll_user`. Re-submitting overwrites. Edit window is bounded by `poll.allow_edit_responses` and `poll.edit_responses_until`.
- **Lifecycle**: `starts_at` gates participation at runtime (no background flip; users simply can't vote yet). `ends_at` triggers an `ACTIVE → CLOSED` transition via the periodic Huey task `close_due_polls_task` (every 5 minutes, `tasks/poll/close_due.py`). `auto_delete_at` is set by the app when a poll transitions to `CLOSED` and the `auto_delete` flag in `poll.attributes` is truthy — the value is `now + settings.poll_retention_days` (default 30). The periodic Huey task `purge_expired_polls_task` (daily at 03:00 UTC, `tasks/poll/purge_expired.py`) deletes polls whose `auto_delete_at` has passed.
Expand Down Expand Up @@ -273,6 +276,11 @@ erDiagram
is rejected in `SINGLE_CHOICE` and `MULTI_CHOICE`; `SINGLE_CHOICE` allows at most
one YES; `MULTI_CHOICE` honours the `max_yes_choices` cap when set. Routes turn
`InvalidVoteError` into a 400.
- **Per-group selection caps**: independent of `response_mode` and unrelated to
`max_yes_choices` (no reconciliation between the two), `_validate_group_limits`
rejects a submission where the YES count among a `PollOptionGroup`'s member
options exceeds its `max_selections`. MAYBE never counts toward a group's cap.
Ungrouped options (`group_id IS NULL`) are never capped.
- **Anonymity**: when `poll.attributes.anonymous_votes=True`, the response summary
returns `ResponseRow.user=None` and the comment field is hidden in the UI (otherwise
the comment becomes a fingerprint).
Expand Down
23 changes: 23 additions & 0 deletions src/struudel/blueprints/polls/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,24 @@ def _normalize_to_utc(value: datetime | None) -> datetime | None:
]


class PollOptionGroupData(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True, extra="ignore")

client_key: str = Field(min_length=1)
name: str = Field(min_length=1)
max_selections: Annotated[int | None, BeforeValidator(_empty_to_none)] = Field(
default=None, ge=1
)


class PollOptionData(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True, extra="ignore")

type: PollOptionType
date_value: OptionalDate = None
datetime_value: OptionalDateTime = None
text_value: OptionalStr = None
group_client_key: OptionalStr = None

@model_validator(mode="after")
def _exactly_one_value(self) -> PollOptionData:
Expand Down Expand Up @@ -107,6 +118,10 @@ class PollForm(BaseModel):
Json[list[PollOptionData]],
BeforeValidator(_empty_to_empty_list),
] = Field(default_factory=list)
groups: Annotated[
Json[list[PollOptionGroupData]],
BeforeValidator(_empty_to_empty_list),
] = Field(default_factory=list)

audience_users: Annotated[
Json[list[AudienceMember]],
Expand Down Expand Up @@ -147,6 +162,14 @@ def _check_max_yes_choices(self) -> PollForm:
raise ValueError("max_yes_choices must be between 1 and 50")
return self

@model_validator(mode="after")
def _check_option_group_references(self) -> PollForm:
group_keys = {g.client_key for g in self.groups}
for opt in self.options:
if opt.group_client_key is not None and opt.group_client_key not in group_keys:
raise ValueError(f"option references unknown group {opt.group_client_key!r}")
return self


class VoteItem(BaseModel):
model_config = ConfigDict(extra="ignore")
Expand Down
79 changes: 63 additions & 16 deletions src/struudel/blueprints/polls/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from struudel.database import SessionLocal
from struudel.models.group import Group
from struudel.models.poll import Poll, PollStatus
from struudel.models.poll_option import PollOption, PollOptionType
from struudel.models.poll_option import PollOption, PollOptionGroup, PollOptionType
from struudel.models.poll_response import PollResponse
from struudel.models.user import User
from struudel.services import poll as poll_service
Expand Down Expand Up @@ -108,6 +108,7 @@ def new() -> str | Response | tuple[str, int]:
poll=None,
form_data={},
poll_options=[],
poll_option_groups=[],
initial_users=[],
initial_groups=[],
errors=[],
Expand All @@ -121,7 +122,8 @@ def new() -> str | Response | tuple[str, int]:
"polls/edit.html",
poll=None,
form_data=request.form,
poll_options=_form_options_fallback(request.form.get("options", "")),
poll_options=_form_list_fallback(request.form.get("options", "")),
poll_option_groups=_form_list_fallback(request.form.get("groups", "")),
initial_users=_audience_chips_from_form(
request.form.get("audience_users", ""), "user"
),
Expand All @@ -148,7 +150,8 @@ def new() -> str | Response | tuple[str, int]:
"polls/edit.html",
poll=None,
form_data=request.form,
poll_options=_form_options_fallback(request.form.get("options", "")),
poll_options=_form_list_fallback(request.form.get("options", "")),
poll_option_groups=_form_list_fallback(request.form.get("groups", "")),
initial_users=_audience_chips_from_form(
request.form.get("audience_users", ""), "user"
),
Expand All @@ -165,7 +168,8 @@ def new() -> str | Response | tuple[str, int]:
"polls/edit.html",
poll=None,
form_data=request.form,
poll_options=_form_options_fallback(request.form.get("options", "")),
poll_options=_form_list_fallback(request.form.get("options", "")),
poll_option_groups=_form_list_fallback(request.form.get("groups", "")),
initial_users=_audience_chips_from_form(
request.form.get("audience_users", ""), "user"
),
Expand All @@ -182,7 +186,8 @@ def new() -> str | Response | tuple[str, int]:
"polls/edit.html",
poll=None,
form_data=request.form,
poll_options=_form_options_fallback(request.form.get("options", "")),
poll_options=_form_list_fallback(request.form.get("options", "")),
poll_option_groups=_form_list_fallback(request.form.get("groups", "")),
initial_users=_audience_chips_from_form(
request.form.get("audience_users", ""), "user"
),
Expand All @@ -199,7 +204,8 @@ def new() -> str | Response | tuple[str, int]:
"polls/edit.html",
poll=None,
form_data=request.form,
poll_options=_form_options_fallback(request.form.get("options", "")),
poll_options=_form_list_fallback(request.form.get("options", "")),
poll_option_groups=_form_list_fallback(request.form.get("groups", "")),
initial_users=_audience_chips_from_form(
request.form.get("audience_users", ""), "user"
),
Expand Down Expand Up @@ -232,6 +238,7 @@ def edit(poll_id: int) -> str | Response | tuple[str, int]:
poll=poll,
form_data=_poll_to_form_data(poll),
poll_options=_poll_options_to_dicts(poll.options),
poll_option_groups=_poll_option_groups_to_dicts(poll.option_groups),
initial_users=_audience_users_to_dicts(users),
initial_groups=_audience_groups_to_dicts(groups),
errors=[],
Expand All @@ -245,10 +252,14 @@ def edit(poll_id: int) -> str | Response | tuple[str, int]:
"polls/edit.html",
poll=poll,
form_data=request.form,
poll_options=_form_options_fallback(
poll_options=_form_list_fallback(
request.form.get("options", ""),
fallback=_poll_options_to_dicts(poll.options),
),
poll_option_groups=_form_list_fallback(
request.form.get("groups", ""),
fallback=_poll_option_groups_to_dicts(poll.option_groups),
),
initial_users=_audience_chips_from_form(
request.form.get("audience_users", ""), "user"
),
Expand All @@ -269,10 +280,14 @@ def edit(poll_id: int) -> str | Response | tuple[str, int]:
"polls/edit.html",
poll=poll,
form_data=request.form,
poll_options=_form_options_fallback(
poll_options=_form_list_fallback(
request.form.get("options", ""),
fallback=_poll_options_to_dicts(poll.options),
),
poll_option_groups=_form_list_fallback(
request.form.get("groups", ""),
fallback=_poll_option_groups_to_dicts(poll.option_groups),
),
initial_users=_audience_chips_from_form(
request.form.get("audience_users", ""), "user"
),
Expand All @@ -297,10 +312,14 @@ def edit(poll_id: int) -> str | Response | tuple[str, int]:
"polls/edit.html",
poll=poll,
form_data=request.form,
poll_options=_form_options_fallback(
poll_options=_form_list_fallback(
request.form.get("options", ""),
fallback=_poll_options_to_dicts(poll.options),
),
poll_option_groups=_form_list_fallback(
request.form.get("groups", ""),
fallback=_poll_option_groups_to_dicts(poll.option_groups),
),
initial_users=_audience_chips_from_form(
request.form.get("audience_users", ""), "user"
),
Expand All @@ -317,10 +336,14 @@ def edit(poll_id: int) -> str | Response | tuple[str, int]:
"polls/edit.html",
poll=poll,
form_data=request.form,
poll_options=_form_options_fallback(
poll_options=_form_list_fallback(
request.form.get("options", ""),
fallback=_poll_options_to_dicts(poll.options),
),
poll_option_groups=_form_list_fallback(
request.form.get("groups", ""),
fallback=_poll_option_groups_to_dicts(poll.option_groups),
),
initial_users=_audience_chips_from_form(
request.form.get("audience_users", ""), "user"
),
Expand All @@ -337,10 +360,14 @@ def edit(poll_id: int) -> str | Response | tuple[str, int]:
"polls/edit.html",
poll=poll,
form_data=request.form,
poll_options=_form_options_fallback(
poll_options=_form_list_fallback(
request.form.get("options", ""),
fallback=_poll_options_to_dicts(poll.options),
),
poll_option_groups=_form_list_fallback(
request.form.get("groups", ""),
fallback=_poll_option_groups_to_dicts(poll.option_groups),
),
initial_users=_audience_chips_from_form(
request.form.get("audience_users", ""), "user"
),
Expand All @@ -357,10 +384,14 @@ def edit(poll_id: int) -> str | Response | tuple[str, int]:
"polls/edit.html",
poll=poll,
form_data=request.form,
poll_options=_form_options_fallback(
poll_options=_form_list_fallback(
request.form.get("options", ""),
fallback=_poll_options_to_dicts(poll.options),
),
poll_option_groups=_form_list_fallback(
request.form.get("groups", ""),
fallback=_poll_option_groups_to_dicts(poll.option_groups),
),
initial_users=_audience_chips_from_form(
request.form.get("audience_users", ""), "user"
),
Expand Down Expand Up @@ -576,8 +607,9 @@ def _poll_to_form_data(poll: Poll) -> dict[str, Any]:
passthrough_attributes: set[str] = {"max_yes_choices"}
audience_fields: set[str] = {"audience_users", "audience_groups"}
for name in PollForm.model_fields:
if name == "options" or name in audience_fields:
# rendered separately via initial_users / initial_groups / poll_options
if name in {"options", "groups"} or name in audience_fields:
# rendered separately via initial_users / initial_groups / poll_options /
# poll_option_groups
continue
if name in bool_attribute_defaults:
data[name] = bool(poll.attributes.get(name, bool_attribute_defaults[name]))
Expand Down Expand Up @@ -616,10 +648,22 @@ def _poll_options_to_dicts(options: Iterable[PollOption]) -> list[dict[str, Any]
entry["datetime_value"] = to_local(o.datetime_value).strftime("%Y-%m-%dT%H:%M")
elif o.option_type == PollOptionType.TEXT:
entry["text_value"] = o.text_value or ""
entry["group_client_key"] = str(o.group_id) if o.group_id is not None else None
result.append(entry)
return result


def _poll_option_groups_to_dicts(groups: Iterable[PollOptionGroup]) -> list[dict[str, Any]]:
return [
{
"client_key": str(g.id),
"name": g.name,
"max_selections": g.max_selections,
}
for g in groups
]


def _render_vote(
db: Any,
*,
Expand All @@ -643,6 +687,9 @@ def _render_vote(
"id": o.id,
"label": _option_label(o),
"is_custom": o.is_custom,
"group_id": o.group_id,
"group_name": o.group.name if o.group is not None else None,
"group_max_selections": o.group.max_selections if o.group is not None else None,
}
for o in options
]
Expand Down Expand Up @@ -719,13 +766,13 @@ def _audience_groups_to_dicts(groups: Iterable[Any]) -> list[dict[str, Any]]:
]


def _form_options_fallback(
def _form_list_fallback(
raw: str, fallback: list[dict[str, Any]] | None = None
) -> list[dict[str, Any]]:
try:
parsed = json.loads(raw) if raw else []
except (ValueError, TypeError):
log.debug("invalid options payload on form re-render, using fallback")
log.debug("invalid list payload on form re-render, using fallback")
return fallback or []
if not isinstance(parsed, list):
return fallback or []
Expand Down
Loading
Loading