flaskbb-plugin-vote 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,43 @@
1
+ Metadata-Version: 2.4
2
+ Name: flaskbb-plugin-vote
3
+ Version: 1.0.0
4
+ Summary: A polls plugin for FlaskBB - attach single or multiple choice polls to topics and posts
5
+ Project-URL: Homepage, https://github.com/flaskbb/flaskbb-plugin-vote
6
+ Project-URL: Repository, https://github.com/flaskbb/flaskbb-plugin-vote
7
+ Project-URL: Issues, https://github.com/flaskbb/flaskbb-plugin-vote/issues
8
+ Project-URL: Changelog, https://github.com/flaskbb/flaskbb-plugin-vote/blob/master/CHANGELOG.md
9
+ Author-email: Peter Justin <peter.justin@outlook.com>
10
+ Maintainer-email: Peter Justin <peter.justin@outlook.com>
11
+ License-Expression: BSD-3-Clause
12
+ License-File: LICENSE
13
+ Keywords: flaskbb,plugin,poll,polls,vote
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Environment :: Web Environment
16
+ Classifier: Framework :: Flask
17
+ Classifier: Intended Audience :: Developers
18
+ Classifier: Intended Audience :: End Users/Desktop
19
+ Classifier: Operating System :: OS Independent
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
23
+ Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
24
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
+ Requires-Python: >=3.12
26
+ Requires-Dist: flaskbb>=3.0.0
27
+ Description-Content-Type: text/markdown
28
+
29
+ Vote
30
+ ====
31
+
32
+ A polls plugin for FlaskBB. Lets a user create polls in a topic or post.
33
+
34
+
35
+ Installation
36
+ ------------
37
+
38
+ Install *flaskbb-plugin-vote* with
39
+ ``pip install flaskbb-plugin-vote``
40
+
41
+ License
42
+ -------
43
+ This project is licensed under the terms of the [BSD License](/LICENSE).
@@ -0,0 +1,18 @@
1
+ vote/__init__.py,sha256=qe5fCgir2CuecJQiHLamJ-u54lS8LTM18TYrmmaigiE,7928
2
+ vote/forms.py,sha256=ZbVW5wXcAJ0jO99VWquk8ko2UrSGFi_eEEv84UWfsNg,724
3
+ vote/models.py,sha256=1n1emFlRvjDuGGGG6-FU_wjnCEx2FJwWFbejbb30uwc,3908
4
+ vote/utils.py,sha256=gHaXezSrHHN4sU6Bt9CXHKG7Pf6qNIpzQA1ncd3DG_0,4967
5
+ vote/views.py,sha256=uL65qSqRTip-DfK5qNxL4Z7YvUG2vKde7g8PektKvK4,2436
6
+ vote/migrations/202607301200_a1f3c9d7e2b4_add_vote_polls.py,sha256=nz2fNmvsm-djuhjYP5nfLQyNPDO-58TqIaFEyz56nxY,3202
7
+ vote/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ vote/static/vote.js,sha256=rKPYgXvz6UTWvs3HBXZ1OsGo-W74x7VWTmPWUDM3sqo,5192
9
+ vote/templates/vote/_cheatsheet.html,sha256=cDqTxG7yrvgjjj9eOygIOeVbUDbh00O-BMlaYcwoWxs,165
10
+ vote/templates/vote/_form_extra.html,sha256=i7TVZyGMaVBtUUcWdiPK98gYrTzTlVbi1FFZ0V7ea0I,2497
11
+ vote/templates/vote/_poll_widget.html,sha256=PsWxWtD3jb1rqYoFF8lAXNAI1xZ_kyRcMNcdXjjmRuE,2828
12
+ vote/templates/vote/_scripts.html,sha256=QGjcrojf6FzIA__K3u6SJJiCGS_tbBT4Pdulr9FOiUo,73
13
+ vote/templates/vote/_toolbar_button.html,sha256=Mfg44LoleFEZ2S54l9ELsvtO7S_UQzNQK22gWHf325E,230
14
+ flaskbb_plugin_vote-1.0.0.dist-info/METADATA,sha256=3_n0vONq3NtZwT2LEaXmlOe5jBCn8rPkYM_c5aTJnSc,1596
15
+ flaskbb_plugin_vote-1.0.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
16
+ flaskbb_plugin_vote-1.0.0.dist-info/entry_points.txt,sha256=5Hc1Y1P4hLv9NSVwnBIiHzIVicSv7vEMzuFoNSfwHHM,30
17
+ flaskbb_plugin_vote-1.0.0.dist-info/licenses/LICENSE,sha256=ebP4t6o_-IT9SPVkjzUZTaUR3CX0QsZ8qLSFbbUSSUM,1500
18
+ flaskbb_plugin_vote-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [flaskbb_plugins]
2
+ vote = vote
@@ -0,0 +1,31 @@
1
+ Copyright (c) 2026 by Peter Justin and contributors. See AUTHORS for more
2
+ details.
3
+
4
+ Some rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are
8
+ met:
9
+
10
+ * Redistributions of source code must retain the above copyright
11
+ notice, this list of conditions and the following disclaimer.
12
+
13
+ * Redistributions in binary form must reproduce the above copyright
14
+ notice, this list of conditions and the following disclaimer in the
15
+ documentation and/or other materials provided with the distribution.
16
+
17
+ * The names of the contributors may not be used to endorse or
18
+ promote products derived from this software without specific
19
+ prior written permission.
20
+
21
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
22
+ "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
23
+ LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
24
+ A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
25
+ OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
26
+ SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
27
+ LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
28
+ DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
29
+ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
30
+ (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
31
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
vote/__init__.py ADDED
@@ -0,0 +1,257 @@
1
+ """
2
+ vote
3
+ ~~~~
4
+
5
+ A polls plugin for FlaskBB. Lets a poster attach a single- or
6
+ multiple-choice poll to a topic or a reply, via a button on the markdown
7
+ editor's toolbar.
8
+
9
+ :copyright: (c) 2026 by Peter Justin.
10
+ :license: BSD License, see LICENSE for more details.
11
+ """
12
+
13
+ import os
14
+
15
+ from flask import Flask
16
+ from flask_login import current_user
17
+ from flaskbb.core.settings import BoolSetting, IntSetting, SettingGroup, flaskbb_config
18
+ from flaskbb.forum.forms import NewTopicForm, ReplyForm
19
+ from flaskbb.forum.models import Post
20
+ from flaskbb.utils.helpers import real, render_template
21
+ from pluggy import HookimplMarker
22
+ from wtforms import HiddenField
23
+
24
+ from .forms import DeletePollForm, VoteForm
25
+ from .models import Poll
26
+ from .utils import can_delete_poll, create_poll_for_post, parse_poll_payload
27
+ from .views import vote_bp
28
+
29
+ __version__ = "1.0.0"
30
+
31
+ hookimpl = HookimplMarker("flaskbb")
32
+
33
+ # Stashes a pending poll payload on the Post between save and post-save hooks,
34
+ # until the post has an id to attach it to.
35
+ # Hooks: flaskbb_form_{topic,post}_save (before the post is saved) and
36
+ # flaskbb_event_post_save_after (once it has an id to attach to).
37
+ _PENDING_ATTR = "_vote_pending_payload"
38
+
39
+
40
+ @hookimpl
41
+ def flaskbb_load_migrations():
42
+ return os.path.join(os.path.dirname(__file__), "migrations")
43
+
44
+
45
+ @hookimpl
46
+ def flaskbb_load_translations():
47
+ return os.path.join(os.path.dirname(__file__), "translations")
48
+
49
+
50
+ @hookimpl
51
+ def flaskbb_load_blueprints(app: Flask):
52
+ app.register_blueprint(
53
+ vote_bp, url_prefix=app.config.get("PLUGIN_VOTE_URL_PREFIX", "/vote")
54
+ )
55
+
56
+
57
+ SETTINGS = SettingGroup(
58
+ key="vote",
59
+ name="Vote Settings",
60
+ description="Settings for the poll plugin.",
61
+ settings=(
62
+ IntSetting(
63
+ key="MAX_OPTIONS",
64
+ value=10,
65
+ min=2,
66
+ max=50,
67
+ name="Maximum poll options",
68
+ description="The maximum number of options a poll may have.",
69
+ ),
70
+ BoolSetting(
71
+ key="TOPICS_ONLY",
72
+ value=False,
73
+ name="Only allow polls on topics",
74
+ description=(
75
+ "If enabled, a poll can only be attached to a topic's first "
76
+ "post, not to a reply. Polls already attached to a reply "
77
+ "are unaffected."
78
+ ),
79
+ ),
80
+ ),
81
+ )
82
+
83
+
84
+ @hookimpl
85
+ def flaskbb_load_setting_groups():
86
+ return SETTINGS
87
+
88
+
89
+ _DEFAULT_MAX_OPTIONS = SETTINGS.settings[0].value
90
+ _DEFAULT_TOPICS_ONLY = SETTINGS.settings[1].value
91
+
92
+
93
+ def _max_options() -> int:
94
+ # Falls back to the setting's own default when the plugin hasn't been
95
+ # installed yet (admin panel > Plugins > Install seeds it into the
96
+ # settings table) - a poll shouldn't be impossible to create just
97
+ # because nobody has visited that page yet.
98
+ value = flaskbb_config["VOTE_MAX_OPTIONS"]
99
+ return int(value) if value is not None else _DEFAULT_MAX_OPTIONS
100
+
101
+
102
+ def _topics_only() -> bool:
103
+ value = flaskbb_config["VOTE_TOPICS_ONLY"]
104
+ return bool(value) if value is not None else _DEFAULT_TOPICS_ONLY
105
+
106
+
107
+ # Attaching the hidden poll_data field to the topic/post forms
108
+ @hookimpl
109
+ def flaskbb_form_topic(form: type[NewTopicForm]):
110
+ form.poll_data = HiddenField()
111
+
112
+
113
+ @hookimpl
114
+ def flaskbb_form_post(form: type[ReplyForm]):
115
+ # Skipping the field entirely when polls are topics-only means a reply
116
+ # form never has anything to submit or stash - no separate enforcement
117
+ # needed at save time, and the toolbar button hides itself here the
118
+ # same way it already does for the quick-reply box (see
119
+ # flaskbb_tpl_form_new_post_after below).
120
+ #
121
+ # form is the *class*, shared and re-patched on every request - if the
122
+ # setting was off on some earlier request, poll_data is still sitting
123
+ # in form.__dict__ from that call and has to be explicitly removed, or
124
+ # toggling the setting on would have no effect until the process
125
+ # restarts.
126
+ if _topics_only():
127
+ if "poll_data" in form.__dict__:
128
+ delattr(form, "poll_data")
129
+ return
130
+ form.poll_data = HiddenField()
131
+
132
+
133
+ @hookimpl
134
+ def flaskbb_form_topic_save(form, topic):
135
+ poll_data = getattr(form, "poll_data", None)
136
+ post = getattr(topic, "_post", None)
137
+ if poll_data is not None and post is not None:
138
+ setattr(post, _PENDING_ATTR, poll_data.data)
139
+
140
+
141
+ @hookimpl
142
+ def flaskbb_form_post_save(form, post: Post):
143
+ poll_data = getattr(form, "poll_data", None)
144
+ if poll_data is not None:
145
+ setattr(post, _PENDING_ATTR, poll_data.data)
146
+
147
+
148
+ @hookimpl
149
+ def flaskbb_event_post_save_after(post: Post, is_new: bool):
150
+ if not is_new:
151
+ return
152
+
153
+ raw = getattr(post, _PENDING_ATTR, None)
154
+ if not raw:
155
+ return
156
+
157
+ payload = parse_poll_payload(raw, max_options=_max_options())
158
+ if payload is None:
159
+ return
160
+
161
+ create_poll_for_post(post, payload)
162
+
163
+
164
+ # --- Rendering ---
165
+
166
+
167
+ def _render_existing_poll(post: Post | None):
168
+ """Renders ``post``'s poll (if it has one) read-only, for display on an
169
+ edit form - editing a post/topic never touches its poll, only the
170
+ dedicated delete action does, so this never offers a voting UI.
171
+ """
172
+ if post is None:
173
+ return None
174
+
175
+ poll: Poll | None = post.poll # pyright: ignore[reportAttributeAccessIssue]
176
+ if poll is None:
177
+ return None
178
+
179
+ user = real(current_user)
180
+ return render_template(
181
+ "vote/_poll_widget.html",
182
+ poll=poll,
183
+ user_vote_ids=[],
184
+ can_vote=False,
185
+ editing=True,
186
+ can_delete=user.is_authenticated and can_delete_poll(user, poll),
187
+ form=VoteForm(),
188
+ delete_form=DeletePollForm(),
189
+ )
190
+
191
+
192
+ @hookimpl
193
+ def flaskbb_tpl_form_new_topic_after(form):
194
+ if hasattr(form, "poll_data"):
195
+ return render_template("vote/_form_extra.html", form=form)
196
+
197
+ # EditTopicForm - flaskbb_form_topic(form=NewTopicForm) above patches a
198
+ # sibling class of EditTopicForm (both just subclass TopicForm), so an
199
+ # EditTopicForm instance never has poll_data. Show its topic's existing
200
+ # poll read-only instead, since flaskbb_form_topic_save never fires a
201
+ # poll onto it either.
202
+ topic = getattr(form, "topic", None)
203
+ if topic is None:
204
+ return None
205
+ return _render_existing_poll(topic.first_post)
206
+
207
+
208
+ @hookimpl
209
+ def flaskbb_tpl_form_new_post_after(form):
210
+ # ReplyForm is shared between NewPost and EditPost (see forum/views.py) -
211
+ # form.post is only set when editing. A poll attached while editing
212
+ # would never be created (flaskbb_event_post_save_after only fires for
213
+ # is_new=True posts), so the create-poll field must not render there -
214
+ # show the post's existing poll read-only instead.
215
+ post = getattr(form, "post", None)
216
+ if post is not None:
217
+ return _render_existing_poll(post)
218
+ if hasattr(form, "poll_data"):
219
+ return render_template("vote/_form_extra.html", form=form)
220
+ return None
221
+
222
+
223
+ @hookimpl
224
+ def flaskbb_tpl_markdown_toolbar_buttons():
225
+ return render_template("vote/_toolbar_button.html")
226
+
227
+
228
+ @hookimpl
229
+ def flaskbb_tpl_markdown_cheatsheet():
230
+ return render_template("vote/_cheatsheet.html")
231
+
232
+
233
+ @hookimpl
234
+ def flaskbb_tpl_scripts():
235
+ return render_template("vote/_scripts.html")
236
+
237
+
238
+ @hookimpl
239
+ def flaskbb_tpl_post_content_before(post: Post):
240
+ # Post doesn't declare `poll` itself - it's added by Poll.post's backref
241
+ # in models.py, since Post is a core model this plugin doesn't own.
242
+ poll: Poll | None = post.poll # pyright: ignore[reportAttributeAccessIssue]
243
+ if poll is None:
244
+ return None
245
+
246
+ user = real(current_user)
247
+ user_vote_ids = poll.option_ids_voted_by(user.id) if user.is_authenticated else []
248
+ return render_template(
249
+ "vote/_poll_widget.html",
250
+ poll=poll,
251
+ user_vote_ids=user_vote_ids,
252
+ can_vote=user.is_authenticated,
253
+ editing=False,
254
+ can_delete=user.is_authenticated and can_delete_poll(user, poll),
255
+ form=VoteForm(),
256
+ delete_form=DeletePollForm(),
257
+ )
vote/forms.py ADDED
@@ -0,0 +1,29 @@
1
+ """
2
+ vote.forms
3
+ ~~~~~~~~~~
4
+
5
+ Forms used to cast a vote or delete a poll. Their only real job is to
6
+ carry the CSRF token - a vote's option ids are read straight from
7
+ ``request.form`` in the view since the set of valid choices is only known
8
+ once the poll is loaded, and depends on whether it's single or multiple
9
+ choice.
10
+
11
+ :copyright: (c) 2026 by Peter Justin.
12
+ :license: BSD License, see LICENSE for more details.
13
+ """
14
+
15
+ from flask_wtf import FlaskForm
16
+
17
+
18
+ class VoteForm(FlaskForm):
19
+ """Used in _poll_widget.html to cast the vote.
20
+ It just contains the hidden CSRF field
21
+ """
22
+
23
+ pass
24
+
25
+
26
+ class DeletePollForm(FlaskForm):
27
+ """Used in _poll_widget.html to delete a poll.
28
+ It just contains the hidden CSRF field
29
+ """
@@ -0,0 +1,91 @@
1
+ """Add vote_polls, vote_poll_options and vote_poll_votes
2
+
3
+ Revision ID: a1f3c9d7e2b4
4
+ Revises:
5
+ Create Date: 2026-07-30 12:00:00.000000
6
+
7
+ """
8
+
9
+ import flaskbb
10
+ import sqlalchemy as sa
11
+ from alembic import op
12
+
13
+ # revision identifiers, used by Alembic.
14
+ revision = "a1f3c9d7e2b4"
15
+ down_revision = None
16
+ branch_labels = ("vote",)
17
+ depends_on = "8ad96e49dc6" # flaskbb core init migration - creates posts/users
18
+
19
+
20
+ def upgrade():
21
+ con = op.get_bind()
22
+
23
+ if not sa.inspect(con.engine).has_table("vote_polls"):
24
+ op.create_table(
25
+ "vote_polls",
26
+ sa.Column("id", sa.Integer(), nullable=False),
27
+ sa.Column("post_id", sa.Integer(), nullable=False),
28
+ sa.Column("question", sa.String(length=255), nullable=False),
29
+ sa.Column("poll_type", sa.String(length=10), nullable=False),
30
+ sa.Column(
31
+ "date_created",
32
+ flaskbb.utils.database.UTCDateTime(timezone=True),
33
+ nullable=False,
34
+ ),
35
+ sa.ForeignKeyConstraint(
36
+ ["post_id"],
37
+ ["posts.id"],
38
+ name=op.f("fk_vote_polls_post_id_posts"),
39
+ ondelete="CASCADE",
40
+ ),
41
+ sa.PrimaryKeyConstraint("id", name=op.f("pk_vote_polls")),
42
+ sa.UniqueConstraint("post_id", name=op.f("uq_vote_polls_post_id")),
43
+ )
44
+
45
+ if not sa.inspect(con.engine).has_table("vote_poll_options"):
46
+ op.create_table(
47
+ "vote_poll_options",
48
+ sa.Column("id", sa.Integer(), nullable=False),
49
+ sa.Column("poll_id", sa.Integer(), nullable=False),
50
+ sa.Column("text", sa.String(length=255), nullable=False),
51
+ sa.Column("position", sa.Integer(), nullable=False),
52
+ sa.ForeignKeyConstraint(
53
+ ["poll_id"],
54
+ ["vote_polls.id"],
55
+ name=op.f("fk_vote_poll_options_poll_id_vote_polls"),
56
+ ondelete="CASCADE",
57
+ ),
58
+ sa.PrimaryKeyConstraint("id", name=op.f("pk_vote_poll_options")),
59
+ )
60
+
61
+ if not sa.inspect(con.engine).has_table("vote_poll_votes"):
62
+ op.create_table(
63
+ "vote_poll_votes",
64
+ sa.Column("id", sa.Integer(), nullable=False),
65
+ sa.Column("poll_option_id", sa.Integer(), nullable=False),
66
+ sa.Column("user_id", sa.Integer(), nullable=False),
67
+ sa.Column(
68
+ "date_created",
69
+ flaskbb.utils.database.UTCDateTime(timezone=True),
70
+ nullable=False,
71
+ ),
72
+ sa.ForeignKeyConstraint(
73
+ ["poll_option_id"],
74
+ ["vote_poll_options.id"],
75
+ name=op.f("fk_vote_poll_votes_poll_option_id_vote_poll_options"),
76
+ ondelete="CASCADE",
77
+ ),
78
+ sa.ForeignKeyConstraint(
79
+ ["user_id"],
80
+ ["users.id"],
81
+ name=op.f("fk_vote_poll_votes_user_id_users"),
82
+ ondelete="CASCADE",
83
+ ),
84
+ sa.PrimaryKeyConstraint("id", name=op.f("pk_vote_poll_votes")),
85
+ )
86
+
87
+
88
+ def downgrade():
89
+ op.drop_table("vote_poll_votes")
90
+ op.drop_table("vote_poll_options")
91
+ op.drop_table("vote_polls")
File without changes
vote/models.py ADDED
@@ -0,0 +1,115 @@
1
+ """
2
+ vote.models
3
+ ~~~~~~~~~~~
4
+
5
+ The models for polls, their options and votes.
6
+
7
+ :copyright: (c) 2026 by Peter Justin.
8
+ :license: BSD License, see LICENSE for more details.
9
+ """
10
+
11
+ import datetime
12
+ import logging
13
+
14
+ from flaskbb.extensions import db
15
+ from flaskbb.forum.models import Post
16
+ from flaskbb.utils.database import CRUDMixin, UTCDateTime
17
+ from flaskbb.utils.helpers import time_utcnow
18
+ from sqlalchemy import ForeignKey, Integer, String
19
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ SINGLE_CHOICE = "single"
24
+ MULTIPLE_CHOICE = "multiple"
25
+ POLL_TYPES = (SINGLE_CHOICE, MULTIPLE_CHOICE)
26
+
27
+
28
+ class Poll(db.Model, CRUDMixin):
29
+ __tablename__ = "vote_polls"
30
+
31
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
32
+ post_id: Mapped[int] = mapped_column(
33
+ ForeignKey("posts.id", ondelete="CASCADE"),
34
+ unique=True,
35
+ nullable=False,
36
+ )
37
+ question: Mapped[str] = mapped_column(String(255), nullable=False)
38
+ poll_type: Mapped[str] = mapped_column(String(10), nullable=False)
39
+ date_created: Mapped[datetime.datetime] = mapped_column(
40
+ UTCDateTime(timezone=True), default=time_utcnow, nullable=False
41
+ )
42
+
43
+ # ``single_parent`` is required by SQLAlchemy for a delete-orphan cascade
44
+ # on a one-to-one backref. Declared here (not on Post) since Post is a
45
+ # core model this plugin doesn't own - see docs/development/plugin/editor.rst
46
+ # in flaskbb for why plugins extend core models this way instead of
47
+ # patching flaskbb.forum.models.
48
+ post: Mapped["Post"] = relationship(
49
+ "Post",
50
+ backref=db.backref(
51
+ "poll", uselist=False, cascade="all, delete-orphan", single_parent=True
52
+ ),
53
+ )
54
+
55
+ options: Mapped[list["PollOption"]] = relationship(
56
+ "PollOption",
57
+ back_populates="poll",
58
+ cascade="all, delete-orphan",
59
+ order_by="PollOption.position",
60
+ )
61
+
62
+ @property
63
+ def is_multiple_choice(self) -> bool:
64
+ return self.poll_type == MULTIPLE_CHOICE
65
+
66
+ @property
67
+ def total_votes(self) -> int:
68
+ return sum(option.vote_count for option in self.options)
69
+
70
+ def option_ids_voted_by(self, user_id: int) -> list[int]:
71
+ """Returns the ids of the options ``user_id`` has voted for."""
72
+ option_ids = [option.id for option in self.options]
73
+ if not option_ids:
74
+ return []
75
+ votes = PollVote.get_all(
76
+ PollVote.user_id == user_id, PollVote.poll_option_id.in_(option_ids)
77
+ )
78
+ return [vote.poll_option_id for vote in votes]
79
+
80
+
81
+ class PollOption(db.Model, CRUDMixin):
82
+ __tablename__ = "vote_poll_options"
83
+
84
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
85
+ poll_id: Mapped[int] = mapped_column(
86
+ ForeignKey("vote_polls.id", ondelete="CASCADE"), nullable=False
87
+ )
88
+ text: Mapped[str] = mapped_column(String(255), nullable=False)
89
+ position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
90
+
91
+ poll: Mapped["Poll"] = relationship("Poll", back_populates="options")
92
+ votes: Mapped[list["PollVote"]] = relationship(
93
+ "PollVote", back_populates="option", cascade="all, delete-orphan"
94
+ )
95
+
96
+ @property
97
+ def vote_count(self) -> int:
98
+ return PollVote.count(PollVote.poll_option_id == self.id)
99
+
100
+
101
+ class PollVote(db.Model, CRUDMixin):
102
+ __tablename__ = "vote_poll_votes"
103
+
104
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
105
+ poll_option_id: Mapped[int] = mapped_column(
106
+ ForeignKey("vote_poll_options.id", ondelete="CASCADE"), nullable=False
107
+ )
108
+ user_id: Mapped[int] = mapped_column(
109
+ ForeignKey("users.id", ondelete="CASCADE"), nullable=False
110
+ )
111
+ date_created: Mapped[datetime.datetime] = mapped_column(
112
+ UTCDateTime(timezone=True), default=time_utcnow, nullable=False
113
+ )
114
+
115
+ option: Mapped["PollOption"] = relationship("PollOption", back_populates="votes")
vote/static/vote.js ADDED
@@ -0,0 +1,161 @@
1
+ (function () {
2
+ "use strict";
3
+
4
+ function optionRow(value) {
5
+ const row = document.createElement("div");
6
+ row.className = "input-group input-group-sm mb-2";
7
+
8
+ const input = document.createElement("input");
9
+ input.type = "text";
10
+ input.className = "form-control vote-poll-option-input";
11
+ input.maxLength = 255;
12
+ input.value = value || "";
13
+
14
+ const removeBtn = document.createElement("button");
15
+ removeBtn.type = "button";
16
+ removeBtn.className = "btn btn-white";
17
+ removeBtn.innerHTML = '<span class="fas fa-xmark"></span>';
18
+ removeBtn.addEventListener("click", () => row.remove());
19
+
20
+ row.append(input, removeBtn);
21
+ return row;
22
+ }
23
+
24
+ function setupPollDialog() {
25
+ const dialog = document.getElementById("vote-poll-dialog");
26
+ if (!dialog) {
27
+ return;
28
+ }
29
+
30
+ const questionInput = document.getElementById("vote-poll-question");
31
+ const typeSingle = document.getElementById("vote-poll-type-single");
32
+ const typeMultiple = document.getElementById("vote-poll-type-multiple");
33
+ const optionsContainer = document.getElementById("vote-poll-options");
34
+ const addOptionBtn = document.getElementById("vote-poll-add-option");
35
+ const insertBtn = document.getElementById("vote-poll-insert");
36
+ const removeBtn = document.getElementById("vote-poll-remove");
37
+ const cancelBtn = document.getElementById("vote-poll-cancel");
38
+ const closeBtn = document.getElementById("vote-poll-close");
39
+
40
+ let activeField = null;
41
+
42
+ function addOptionRow(value) {
43
+ optionsContainer.append(optionRow(value));
44
+ }
45
+
46
+ function notifyFieldChanged() {
47
+ activeField.dispatchEvent(new CustomEvent("change", { bubbles: true }));
48
+ }
49
+
50
+ // Exposed on the dialog element itself so <vote-poll-button> instances
51
+ // don't need to know the dialog's internal field/option-row wiring.
52
+ dialog.openForField = function (field) {
53
+ activeField = field;
54
+
55
+ let payload = null;
56
+ if (field.value) {
57
+ try {
58
+ payload = JSON.parse(field.value);
59
+ } catch (error) {
60
+ payload = null;
61
+ }
62
+ }
63
+
64
+ questionInput.value = payload ? payload.question || "" : "";
65
+ typeMultiple.checked = Boolean(payload) && payload.type === "multiple";
66
+ typeSingle.checked = !typeMultiple.checked;
67
+
68
+ optionsContainer.innerHTML = "";
69
+ const options =
70
+ payload && Array.isArray(payload.options) ? payload.options : [];
71
+ for (const option of options) {
72
+ addOptionRow(option);
73
+ }
74
+ while (optionsContainer.children.length < 2) {
75
+ addOptionRow("");
76
+ }
77
+
78
+ removeBtn.hidden = !payload;
79
+ dialog.showModal();
80
+ };
81
+
82
+ addOptionBtn.addEventListener("click", () => addOptionRow(""));
83
+
84
+ insertBtn.addEventListener("click", () => {
85
+ const question = questionInput.value.trim();
86
+ const options = [
87
+ ...optionsContainer.querySelectorAll(".vote-poll-option-input"),
88
+ ]
89
+ .map((input) => input.value.trim())
90
+ .filter((value) => value.length > 0);
91
+
92
+ if (!question || options.length < 2) {
93
+ window.alert(dialog.dataset.validationMessage);
94
+ return;
95
+ }
96
+
97
+ activeField.value = JSON.stringify({
98
+ question,
99
+ type: typeMultiple.checked ? "multiple" : "single",
100
+ options,
101
+ });
102
+ notifyFieldChanged();
103
+ dialog.close();
104
+ });
105
+
106
+ removeBtn.addEventListener("click", () => {
107
+ activeField.value = "";
108
+ notifyFieldChanged();
109
+ dialog.close();
110
+ });
111
+
112
+ cancelBtn.addEventListener("click", () => dialog.close());
113
+ closeBtn.addEventListener("click", () => dialog.close());
114
+ }
115
+
116
+ setupPollDialog();
117
+
118
+ class VotePollButtonElement extends window.MarkdownButtonElement {
119
+ connectedCallback() {
120
+ super.connectedCallback();
121
+
122
+ const form = this.closest("form");
123
+ // form.elements.namedItem returns a single element normally, but a
124
+ // RadioNodeList if the form ever has more than one field with this
125
+ // name (no addEventListener) - treat that as "not found" too rather
126
+ // than throwing.
127
+ const field = form ? form.elements.namedItem("poll_data") : null;
128
+ this.hiddenField = field instanceof HTMLInputElement ? field : null;
129
+ const dialog = document.getElementById("vote-poll-dialog");
130
+
131
+ // Missing either one means this editor instance has no wired-up poll
132
+ // support (e.g. the quick-reply box, which has no dialog, or editing
133
+ // an existing post) - hide rather than offer a button that can't do
134
+ // anything.
135
+ if (!this.hiddenField || !dialog) {
136
+ this.hidden = true;
137
+ return;
138
+ }
139
+
140
+ this.refreshActiveState();
141
+ this.hiddenField.addEventListener("change", () =>
142
+ this.refreshActiveState()
143
+ );
144
+
145
+ this.addEventListener("click", (event) => {
146
+ event.preventDefault();
147
+ if (dialog.openForField) {
148
+ dialog.openForField(this.hiddenField);
149
+ }
150
+ });
151
+ }
152
+
153
+ refreshActiveState() {
154
+ this.classList.toggle("active", Boolean(this.hiddenField.value));
155
+ }
156
+ }
157
+
158
+ if (!window.customElements.get("vote-poll-button")) {
159
+ window.customElements.define("vote-poll-button", VotePollButtonElement);
160
+ }
161
+ })();
@@ -0,0 +1,4 @@
1
+ <h2>{{ _("Polls") }}</h2>
2
+ <p class="text-center">
3
+ {{ _("Click the poll button in the toolbar to attach a single- or multiple-choice poll to your post.") }}
4
+ </p>
@@ -0,0 +1,41 @@
1
+ <!-- poll_data itself is already rendered by this page's form.hidden_tag() call,
2
+ since it's a HiddenField - only the supporting dialog belongs here. -->
3
+ <dialog id="vote-poll-dialog" class="border-0 rounded p-3"
4
+ style="width: 500px; max-width: 90vw; position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); margin: 0;"
5
+ data-validation-message="{{ _('A poll needs a question and at least two options.') }}">
6
+ <div class="modal-content">
7
+ <div class="modal-header">
8
+ <h5 class="modal-title">{{ _("Insert Poll") }}</h5>
9
+ <button type="button" class="btn-close" id="vote-poll-close" aria-label="{{ _('Close') }}"></button>
10
+ </div>
11
+ <div class="modal-body">
12
+ <div class="mb-3">
13
+ <label class="form-label" for="vote-poll-question">{{ _("Question") }}</label>
14
+ <input type="text" class="form-control" id="vote-poll-question" maxlength="255">
15
+ </div>
16
+ <div class="mb-3">
17
+ <label class="form-label">{{ _("Choice type") }}</label>
18
+ <div class="form-check">
19
+ <input class="form-check-input" type="radio" name="vote-poll-type" id="vote-poll-type-single" checked>
20
+ <label class="form-check-label" for="vote-poll-type-single">{{ _("Single choice") }}</label>
21
+ </div>
22
+ <div class="form-check">
23
+ <input class="form-check-input" type="radio" name="vote-poll-type" id="vote-poll-type-multiple">
24
+ <label class="form-check-label" for="vote-poll-type-multiple">{{ _("Multiple choice") }}</label>
25
+ </div>
26
+ </div>
27
+ <div class="mb-3">
28
+ <label class="form-label">{{ _("Options") }}</label>
29
+ <div id="vote-poll-options"></div>
30
+ <button type="button" class="btn btn-sm btn-white mt-1" id="vote-poll-add-option">
31
+ <span class="fas fa-plus"></span> {{ _("Add option") }}
32
+ </button>
33
+ </div>
34
+ </div>
35
+ <div class="modal-footer">
36
+ <button type="button" class="btn btn-white me-auto" id="vote-poll-remove" hidden>{{ _("Remove poll") }}</button>
37
+ <button type="button" class="btn btn-white me-1" id="vote-poll-cancel">{{ _("Cancel") }}</button>
38
+ <button type="button" class="btn btn-primary" id="vote-poll-insert">{{ _("Insert") }}</button>
39
+ </div>
40
+ </div>
41
+ </dialog>
@@ -0,0 +1,56 @@
1
+ <div class="mb-3 col-12">
2
+ <div class="card my-2 vote-poll">
3
+ <div class="card-body">
4
+ <h6 class="card-title">{{ poll.question }}</h6>
5
+
6
+ {% if can_vote and not editing and not user_vote_ids %}
7
+ <form method="post" action="{{ url_for('vote.cast_vote', poll_id=poll.id) }}">
8
+ {{ form.hidden_tag() }}
9
+ {% for option in poll.options %}
10
+ <div class="form-check">
11
+ <input class="form-check-input" type="{{ 'checkbox' if poll.is_multiple_choice else 'radio' }}"
12
+ name="option_id" value="{{ option.id }}" id="vote-poll-{{ poll.id }}-option-{{ option.id }}">
13
+ <label class="form-check-label" for="vote-poll-{{ poll.id }}-option-{{ option.id }}">
14
+ {{ option.text }}
15
+ </label>
16
+ </div>
17
+ {% endfor %}
18
+ <button type="submit" class="btn btn-sm btn-primary mt-2">{{ _("Vote") }}</button>
19
+ </form>
20
+ {% else %}
21
+ <ul class="list-unstyled mb-1">
22
+ {% for option in poll.options %}
23
+ {% set percentage = (100 * option.vote_count / poll.total_votes) if poll.total_votes else 0 %}
24
+ <li class="mb-1 {{ 'fw-bold' if option.id in user_vote_ids }}">
25
+ <div class="d-flex justify-content-between">
26
+ <span>{{ option.text }}</span>
27
+ <span>{{ "%.0f"|format(percentage) }}% ({{ option.vote_count }})</span>
28
+ </div>
29
+ <div class="progress" style="height: 4px;">
30
+ <div class="progress-bar" role="progressbar" style="width: {{ percentage }}%"></div>
31
+ </div>
32
+ </li>
33
+ {% endfor %}
34
+ </ul>
35
+ <small class="text-muted">
36
+ {% if editing %}
37
+ {{ _("This poll cannot be changed here.") }}
38
+ {% else %}
39
+ {{ _("%(count)s total votes", count=poll.total_votes) }}
40
+ {% if not can_vote %} &middot; {{ _("Log in to vote.") }}{% endif %}
41
+ {% endif %}
42
+ </small>
43
+ {% endif %}
44
+
45
+ {% if can_delete %}
46
+ <form method="post" action="{{ url_for('vote.delete_poll', poll_id=poll.id) }}" class="mt-2">
47
+ {{ delete_form.hidden_tag() }}
48
+ <button type="button" class="btn btn-sm btn-outline-danger" data-bs-toggle="modal" data-bs-target="#confirmModal"
49
+ data-tooltip="tooltip" title="{{ _('Delete this poll') }}">
50
+ <span class="fas fa-trash fa-fw"></span> {{ _("Delete Poll") }}
51
+ </button>
52
+ </form>
53
+ {% endif %}
54
+ </div>
55
+ </div>
56
+ </div>
@@ -0,0 +1 @@
1
+ <script src="{{ url_for('vote.static', filename='vote.js') }}"></script>
@@ -0,0 +1,5 @@
1
+ <div class="btn-group btn-group-sm me-2">
2
+ <vote-poll-button class="btn btn-white" data-tooltip="tooltip" title="{{ _('Insert Poll') }}">
3
+ <span class="fas fa-square-poll-vertical"></span>
4
+ </vote-poll-button>
5
+ </div>
vote/utils.py ADDED
@@ -0,0 +1,159 @@
1
+ """
2
+ vote.utils
3
+ ~~~~~~~~~~
4
+
5
+ Helpers for parsing and validating polls and for creating polls.
6
+
7
+ :copyright: (c) 2026 by Peter Justin.
8
+ :license: BSD License, see LICENSE for more details.
9
+ """
10
+
11
+ import json
12
+ import logging
13
+ from dataclasses import dataclass
14
+
15
+ from flask_allows2 import And, Or, Permission
16
+ from flaskbb.extensions import db
17
+ from flaskbb.forum.models import Forum, Post
18
+ from flaskbb.user.models import User
19
+ from flaskbb.utils.requirements import Has, IsAtleastSuperModerator, IsModeratorInForum
20
+
21
+ from .models import MULTIPLE_CHOICE, POLL_TYPES, Poll, PollOption, PollVote
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ MIN_OPTIONS = 2
26
+ QUESTION_MAX_LENGTH = 255
27
+ OPTION_MAX_LENGTH = 255
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class PollPayload:
32
+ question: str
33
+ poll_type: str
34
+ options: tuple[str, ...]
35
+
36
+
37
+ def parse_poll_payload(raw: str | None, max_options: int) -> PollPayload | None:
38
+ """Parses and validates the JSON poll payload submitted alongside a post.
39
+
40
+ Returns ``None`` if ``raw`` is empty or fails validation - callers treat
41
+ that the same as "no poll was attached", since the field is optional and
42
+ a malformed payload (a tampered request, most likely) shouldn't block
43
+ the post/topic it rode in on from being created.
44
+ """
45
+ if not raw:
46
+ return None
47
+
48
+ try:
49
+ data = json.loads(raw)
50
+ except ValueError:
51
+ logger.info("Discarding poll payload: invalid JSON")
52
+ return None
53
+
54
+ if not isinstance(data, dict):
55
+ return None
56
+
57
+ question = data.get("question")
58
+ poll_type = data.get("type")
59
+ options = data.get("options")
60
+
61
+ if not isinstance(question, str) or not isinstance(poll_type, str):
62
+ return None
63
+
64
+ question = question.strip()
65
+ if not question or len(question) > QUESTION_MAX_LENGTH:
66
+ return None
67
+
68
+ if poll_type not in POLL_TYPES:
69
+ return None
70
+
71
+ if not isinstance(options, list):
72
+ return None
73
+
74
+ cleaned_options: list[str] = []
75
+ for option in options:
76
+ if not isinstance(option, str):
77
+ return None
78
+ option = option.strip()
79
+ if not option:
80
+ continue
81
+ if len(option) > OPTION_MAX_LENGTH:
82
+ return None
83
+ cleaned_options.append(option)
84
+
85
+ if not (MIN_OPTIONS <= len(cleaned_options) <= max_options):
86
+ return None
87
+
88
+ return PollPayload(
89
+ question=question, poll_type=poll_type, options=tuple(cleaned_options)
90
+ )
91
+
92
+
93
+ def create_poll_for_post(post: Post, payload: PollPayload) -> Poll:
94
+ """Creates a poll and its options for ``post`` in a single transaction."""
95
+ poll = Poll(post_id=post.id, question=payload.question, poll_type=payload.poll_type)
96
+ db.session.add(poll)
97
+ db.session.flush()
98
+
99
+ for position, text in enumerate(payload.options):
100
+ db.session.add(PollOption(poll_id=poll.id, text=text, position=position))
101
+
102
+ db.session.commit()
103
+ return poll
104
+
105
+
106
+ def can_access_poll(user: User, poll: Poll) -> bool:
107
+ """Mirrors ``CanAccessForum.fulfill`` for a forum reached via a poll,
108
+ rather than via the current request's URL - the vote endpoint has no
109
+ ``current_forum`` to check against.
110
+ """
111
+ forum: Forum = poll.post.topic.forum
112
+ forum_group_ids = {group.id for group in forum.groups}
113
+ user_group_ids = {group.id for group in user.groups}
114
+ return bool(forum_group_ids & user_group_ids)
115
+
116
+
117
+ def cast_vote(poll: Poll, user: User, option_ids: list[int]) -> None:
118
+ """Replaces ``user``'s vote(s) in ``poll`` with ``option_ids``.
119
+
120
+ Callers are expected to have already validated that every id in
121
+ ``option_ids`` belongs to ``poll``, and that ``option_ids`` has exactly
122
+ one entry for a single-choice poll.
123
+ """
124
+ valid_option_ids = {option.id for option in poll.options}
125
+ existing = PollVote.get_all(
126
+ PollVote.user_id == user.id, PollVote.poll_option_id.in_(valid_option_ids)
127
+ )
128
+ for vote in existing:
129
+ db.session.delete(vote)
130
+
131
+ for option_id in option_ids:
132
+ db.session.add(PollVote(poll_option_id=option_id, user_id=user.id))
133
+
134
+ db.session.commit()
135
+
136
+
137
+ def can_delete_poll(user: User, poll: Poll) -> bool:
138
+ """Mirrors flaskbb's staff branches of ``CanDeletePost`` - only an
139
+ admin, a super moderator, or a moderator of the poll's forum with the
140
+ ``editpost`` permission may delete a poll. Unlike editing/deleting a
141
+ post, the poster themselves is never allowed to delete their own poll.
142
+ """
143
+ forum = poll.post.topic.forum
144
+ requirement = Or(
145
+ IsAtleastSuperModerator,
146
+ And(IsModeratorInForum(forum=forum), Has("editpost")),
147
+ )
148
+ return bool(Permission(requirement, identity=user))
149
+
150
+
151
+ def poll_type_is_valid_for(poll: Poll, option_ids: list[int]) -> bool:
152
+ valid_option_ids = {option.id for option in poll.options}
153
+ if not option_ids:
154
+ return False
155
+ if not set(option_ids) <= valid_option_ids:
156
+ return False
157
+ if poll.poll_type != MULTIPLE_CHOICE and len(option_ids) > 1:
158
+ return False
159
+ return True
vote/views.py ADDED
@@ -0,0 +1,86 @@
1
+ """
2
+ vote.views
3
+ ~~~~~~~~~~
4
+
5
+ The views for casting a vote in a poll and for deleting one.
6
+
7
+ :copyright: (c) 2026 by Peter Justin.
8
+ :license: BSD License, see LICENSE for more details.
9
+ """
10
+
11
+ import logging
12
+
13
+ from flask import Blueprint, abort, flash, redirect, request
14
+ from flask.views import MethodView
15
+ from flask_babelplus import gettext as _
16
+ from flask_login import current_user, login_required
17
+ from flaskbb.utils.helpers import real, register_view
18
+
19
+ from .forms import DeletePollForm, VoteForm
20
+ from .models import Poll
21
+ from .utils import can_access_poll, can_delete_poll, cast_vote, poll_type_is_valid_for
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ vote_bp = Blueprint(
26
+ "vote", __name__, template_folder="templates", static_folder="static"
27
+ )
28
+
29
+
30
+ class CastVote(MethodView):
31
+ decorators = [login_required]
32
+
33
+ def post(self, poll_id: int):
34
+ poll = Poll.get_or_404(Poll.id == poll_id)
35
+ user = real(current_user)
36
+
37
+ if not can_access_poll(user, poll):
38
+ abort(404)
39
+
40
+ form = VoteForm()
41
+ if not form.validate_on_submit():
42
+ flash(_("Could not verify the vote request, please try again."), "danger")
43
+ return redirect(poll.post.url)
44
+
45
+ option_ids = [int(v) for v in request.form.getlist("option_id") if v.isdigit()]
46
+
47
+ if not poll_type_is_valid_for(poll, option_ids):
48
+ flash(_("That is not a valid choice for this poll."), "danger")
49
+ return redirect(poll.post.url)
50
+
51
+ cast_vote(poll, user, option_ids)
52
+ flash(_("Your vote has been recorded."), "success")
53
+ return redirect(poll.post.url)
54
+
55
+
56
+ class DeletePoll(MethodView):
57
+ decorators = [login_required]
58
+
59
+ def post(self, poll_id: int):
60
+ poll = Poll.get_or_404(Poll.id == poll_id)
61
+ user = real(current_user)
62
+
63
+ if not can_delete_poll(user, poll):
64
+ abort(404)
65
+
66
+ form = DeletePollForm()
67
+ if not form.validate_on_submit():
68
+ flash(_("Could not verify the delete request, please try again."), "danger")
69
+ return redirect(poll.post.url)
70
+
71
+ post_url = poll.post.url
72
+ poll.delete()
73
+ flash(_("The poll has been deleted."), "success")
74
+ return redirect(post_url)
75
+
76
+
77
+ register_view(
78
+ vote_bp,
79
+ routes=["/<int:poll_id>/vote"],
80
+ view_func=CastVote.as_view("cast_vote"),
81
+ )
82
+ register_view(
83
+ vote_bp,
84
+ routes=["/<int:poll_id>/delete"],
85
+ view_func=DeletePoll.as_view("delete_poll"),
86
+ )