diff --git a/alembic/versions/20260904_1400_add_poll_option_groups.py b/alembic/versions/20260904_1400_add_poll_option_groups.py
new file mode 100644
index 0000000..dd871c2
--- /dev/null
+++ b/alembic/versions/20260904_1400_add_poll_option_groups.py
@@ -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")
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index e5e4f2c..e8ced80 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -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"
@@ -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.
@@ -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).
diff --git a/src/struudel/blueprints/polls/forms.py b/src/struudel/blueprints/polls/forms.py
index ac61f11..bd1bff9 100644
--- a/src/struudel/blueprints/polls/forms.py
+++ b/src/struudel/blueprints/polls/forms.py
@@ -46,6 +46,16 @@ 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")
@@ -53,6 +63,7 @@ class PollOptionData(BaseModel):
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:
@@ -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]],
@@ -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")
diff --git a/src/struudel/blueprints/polls/routes.py b/src/struudel/blueprints/polls/routes.py
index 3abd7dd..2469faf 100644
--- a/src/struudel/blueprints/polls/routes.py
+++ b/src/struudel/blueprints/polls/routes.py
@@ -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
@@ -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=[],
@@ -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"
),
@@ -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"
),
@@ -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"
),
@@ -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"
),
@@ -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"
),
@@ -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=[],
@@ -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"
),
@@ -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"
),
@@ -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"
),
@@ -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"
),
@@ -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"
),
@@ -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"
),
@@ -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]))
@@ -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,
*,
@@ -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
]
@@ -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 []
diff --git a/src/struudel/blueprints/polls/templates/polls/edit.html b/src/struudel/blueprints/polls/templates/polls/edit.html
index ceb91d6..632bdfe 100644
--- a/src/struudel/blueprints/polls/templates/polls/edit.html
+++ b/src/struudel/blueprints/polls/templates/polls/edit.html
@@ -363,7 +363,32 @@
{% endif %}
-
+
+
+
+ Groups
+
+
+
+ Group options together to cap how many of them a voter may select, e.g. "max 1 of A and B".
+
+
+
+
+
+
+
+
+
No groups yet.
+
+
{{ poll.title }}
{% endif %}
+
+
+
+ : / selected — limit reached
+
+
+
{% for opt in options %}
- {{ poll.title }}
{% if opt.is_custom %}
custom
{% endif %}
+ {% if opt.group %}
+ {{ opt.group.name }}
+ {% endif %}
{% if poll.allow_guests %}
@@ -157,6 +168,7 @@ {{ poll.title }}