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.
- flaskbb_plugin_vote-1.0.0.dist-info/METADATA +43 -0
- flaskbb_plugin_vote-1.0.0.dist-info/RECORD +18 -0
- flaskbb_plugin_vote-1.0.0.dist-info/WHEEL +4 -0
- flaskbb_plugin_vote-1.0.0.dist-info/entry_points.txt +2 -0
- flaskbb_plugin_vote-1.0.0.dist-info/licenses/LICENSE +31 -0
- vote/__init__.py +257 -0
- vote/forms.py +29 -0
- vote/migrations/202607301200_a1f3c9d7e2b4_add_vote_polls.py +91 -0
- vote/migrations/__init__.py +0 -0
- vote/models.py +115 -0
- vote/static/vote.js +161 -0
- vote/templates/vote/_cheatsheet.html +4 -0
- vote/templates/vote/_form_extra.html +41 -0
- vote/templates/vote/_poll_widget.html +56 -0
- vote/templates/vote/_scripts.html +1 -0
- vote/templates/vote/_toolbar_button.html +5 -0
- vote/utils.py +159 -0
- vote/views.py +86 -0
|
@@ -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,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,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 %} · {{ _("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>
|
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
|
+
)
|