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.
+
+