flaskbb-plugin-vote 1.0.0__tar.gz

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
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # Distribution / packaging
7
+ .Python
8
+ build/
9
+ develop-eggs/
10
+ dist/
11
+ downloads/
12
+ eggs/
13
+ .eggs/
14
+ lib/
15
+ lib64/
16
+ parts/
17
+ sdist/
18
+ var/
19
+ *.egg-info/
20
+ .installed.cfg
21
+ *.egg
22
+
23
+ # Unit test / coverage reports
24
+ htmlcov/
25
+ .tox/
26
+ .coverage
27
+ .coverage.*
28
+ .cache
29
+ .pytest_cache/
30
+ .ruff_cache/
31
+ nosetests.xml
32
+ coverage.xml
33
+ *,cover
34
+ .hypothesis/
35
+
36
+ # Translations
37
+ *.mo
38
+ *.pot
39
+
40
+ *.log
41
+
42
+ .vscode
43
+ .venv/
@@ -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.
@@ -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,15 @@
1
+ Vote
2
+ ====
3
+
4
+ A polls plugin for FlaskBB. Lets a user create polls in a topic or post.
5
+
6
+
7
+ Installation
8
+ ------------
9
+
10
+ Install *flaskbb-plugin-vote* with
11
+ ``pip install flaskbb-plugin-vote``
12
+
13
+ License
14
+ -------
15
+ This project is licensed under the terms of the [BSD License](/LICENSE).
@@ -0,0 +1,90 @@
1
+ [project]
2
+ name = "flaskbb-plugin-vote"
3
+ version = "1.0.0"
4
+ dependencies = [
5
+ "FlaskBB>=3.0.0",
6
+ ]
7
+ requires-python = ">=3.12"
8
+ authors = [
9
+ {name = "Peter Justin", email = "peter.justin@outlook.com"},
10
+ ]
11
+ maintainers = [
12
+ {name = "Peter Justin", email = "peter.justin@outlook.com"}
13
+ ]
14
+ description = "A polls plugin for FlaskBB - attach single or multiple choice polls to topics and posts"
15
+ readme = "README.md"
16
+ license = "BSD-3-Clause"
17
+ license-files = ["LICEN[CS]E"]
18
+ keywords = ["flaskbb", "plugin", "vote", "poll", "polls"]
19
+ classifiers = [
20
+ "Development Status :: 4 - Beta",
21
+ "Framework :: Flask",
22
+ "Environment :: Web Environment",
23
+ "Intended Audience :: Developers",
24
+ "Intended Audience :: End Users/Desktop",
25
+ "Operating System :: OS Independent",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Programming Language :: Python :: 3.13",
28
+ "Programming Language :: Python :: 3.14",
29
+ "Topic :: Internet :: WWW/HTTP :: Dynamic Content",
30
+ "Topic :: Software Development :: Libraries :: Python Modules"
31
+ ]
32
+ [project.urls]
33
+ Homepage = "https://github.com/flaskbb/flaskbb-plugin-vote"
34
+ Repository = "https://github.com/flaskbb/flaskbb-plugin-vote"
35
+ Issues = "https://github.com/flaskbb/flaskbb-plugin-vote/issues"
36
+ Changelog = "https://github.com/flaskbb/flaskbb-plugin-vote/blob/master/CHANGELOG.md"
37
+
38
+ [project.entry-points.'flaskbb_plugins']
39
+ vote = 'vote'
40
+
41
+ [build-system]
42
+ requires = ["hatchling"]
43
+ build-backend = "hatchling.build"
44
+
45
+ [dependency-groups]
46
+ dev = [
47
+ "hatch",
48
+ "Babel",
49
+ "pytest",
50
+ "ruff",
51
+ ]
52
+
53
+ [tool.hatch.build.targets.sdist]
54
+ only-include = ["vote", "tests"]
55
+
56
+ [tool.hatch.build.targets.wheel]
57
+ packages = ["vote"]
58
+
59
+ [tool.uv]
60
+ package = true
61
+
62
+ [tool.hatch.metadata]
63
+ allow-direct-references = true
64
+
65
+ [tool.basedpyright]
66
+ include = ["vote"]
67
+ exclude = ["**/node_modules", "**/resources", "**/__pycache__", "**/docs"]
68
+ defineConstant = { DEBUG = true }
69
+ reportIgnoreCommentWithoutRule = false
70
+ reportMissingImports = "error"
71
+ reportMissingTypeStubs = false
72
+ reportUnusedCallResult = false
73
+ reportUnannotatedClassAttribute = false
74
+ reportUnusedParameter = false
75
+ reportAny = false
76
+ reportExplicitAny = false
77
+
78
+ [tool.pytest]
79
+ minversion = "9.0"
80
+ addopts = [
81
+ "-vvl",
82
+ "--strict-markers",
83
+ "--capture", "fd",
84
+ "--tb", "short",
85
+ "--pythonwarnings", "error::flaskbb.deprecation.FlaskBBDeprecation",
86
+ "--dist", "load"
87
+ ]
88
+ testpaths = [
89
+ "tests",
90
+ ]
@@ -0,0 +1,36 @@
1
+ import pytest
2
+ from flaskbb.forum.models import Post
3
+ from tests.fixtures.app import *
4
+ from tests.fixtures.forum import *
5
+ from tests.fixtures.user import *
6
+
7
+ from vote.models import Poll, PollOption
8
+
9
+
10
+ def _reply(topic, user):
11
+ """A reply post in ``topic`` - polls attach to this, not the first
12
+ post, so cascade-delete tests don't get entangled with Post.delete()
13
+ redirecting to Topic.delete() for a topic's first post."""
14
+ return Post(content="Test reply").save(user=user, topic=topic)
15
+
16
+
17
+ @pytest.fixture
18
+ def poll(topic, user):
19
+ post = _reply(topic, user)
20
+ poll = Poll(post_id=post.id, question="Favorite color?", poll_type="single")
21
+ poll.save()
22
+ PollOption(poll_id=poll.id, text="Red", position=0).save()
23
+ PollOption(poll_id=poll.id, text="Green", position=1).save()
24
+ PollOption(poll_id=poll.id, text="Blue", position=2).save()
25
+ return Poll.get(Poll.id == poll.id)
26
+
27
+
28
+ @pytest.fixture
29
+ def multi_poll(topic, user):
30
+ post = _reply(topic, user)
31
+ poll = Poll(post_id=post.id, question="Favorite toppings?", poll_type="multiple")
32
+ poll.save()
33
+ PollOption(poll_id=poll.id, text="Cheese", position=0).save()
34
+ PollOption(poll_id=poll.id, text="Pepperoni", position=1).save()
35
+ PollOption(poll_id=poll.id, text="Mushroom", position=2).save()
36
+ return Poll.get(Poll.id == poll.id)
@@ -0,0 +1,205 @@
1
+ """
2
+ End-to-end tests for the flaskbb_form_topic/flaskbb_form_post +
3
+ flaskbb_event_post_save_after hook chain that turns a submitted poll_data
4
+ payload into a real Poll row. This only works if the `vote` package is
5
+ installed into flaskbb's environment (so its entry point is discovered by
6
+ create_app() and its hookimpls actually run) - see README.md.
7
+ """
8
+
9
+ import json
10
+ from contextlib import contextmanager
11
+
12
+ from flaskbb.core.settings import flaskbb_config
13
+ from flaskbb.extensions import pluggy
14
+ from flaskbb.forum.forms import EditTopicForm, NewTopicForm, ReplyForm
15
+
16
+ import vote as vote_plugin
17
+ from vote.models import Poll, PollOption
18
+
19
+
20
+ def poll_json(**overrides):
21
+ data = {
22
+ "question": "Favorite color?",
23
+ "type": "single",
24
+ "options": ["Red", "Blue"],
25
+ }
26
+ data.update(overrides)
27
+ return json.dumps(data)
28
+
29
+
30
+ def new_topic_form(**kwargs):
31
+ # flaskbb_form_topic is only invoked by NewTopic.form()/EditTopic.form()
32
+ # in flaskbb/forum/views.py, once per request - never automatically -
33
+ # so a test constructing NewTopicForm() directly has to trigger it
34
+ # first, exactly like those views do.
35
+ pluggy.hook.flaskbb_form_topic(form=NewTopicForm)
36
+ return NewTopicForm(**kwargs)
37
+
38
+
39
+ def reply_form(**kwargs):
40
+ pluggy.hook.flaskbb_form_post(form=ReplyForm)
41
+ return ReplyForm(**kwargs)
42
+
43
+
44
+ @contextmanager
45
+ def topics_only(enabled):
46
+ # default_settings (pulled in transitively by the forum/topic fixtures)
47
+ # already seeds vote's settings group, so this is just a normal update.
48
+ original = flaskbb_config["VOTE_TOPICS_ONLY"]
49
+ flaskbb_config["VOTE_TOPICS_ONLY"] = enabled
50
+ try:
51
+ yield
52
+ finally:
53
+ flaskbb_config["VOTE_TOPICS_ONLY"] = original
54
+
55
+
56
+ def test_new_topic_form_has_poll_data_field(request_context):
57
+ assert hasattr(new_topic_form(), "poll_data")
58
+
59
+
60
+ def test_reply_form_has_poll_data_field(request_context, default_settings):
61
+ # reply_form() now reads the TOPICS_ONLY setting, which needs the
62
+ # settings table to exist.
63
+ assert hasattr(reply_form(), "poll_data")
64
+
65
+
66
+ def test_submitting_a_new_topic_with_poll_data_creates_a_poll(
67
+ request_context, forum, user
68
+ ):
69
+ form = new_topic_form(
70
+ title="A topic with a poll", content="See the attached poll", track_topic=False
71
+ )
72
+ form.poll_data.data = poll_json()
73
+
74
+ topic = form.save(user, forum)
75
+
76
+ poll = Poll.get(Poll.post_id == topic.first_post.id)
77
+ assert poll is not None
78
+ assert poll.question == "Favorite color?"
79
+ assert [o.text for o in poll.options] == ["Red", "Blue"]
80
+
81
+
82
+ def test_submitting_a_new_topic_without_poll_data_creates_no_poll(
83
+ request_context, forum, user
84
+ ):
85
+ form = new_topic_form(
86
+ title="A plain topic", content="No poll here", track_topic=False
87
+ )
88
+
89
+ topic = form.save(user, forum)
90
+
91
+ assert Poll.get(Poll.post_id == topic.first_post.id) is None
92
+
93
+
94
+ def test_submitting_a_reply_with_poll_data_creates_a_poll(request_context, topic, user):
95
+ form = reply_form(content="A reply with a poll", track_topic=False)
96
+ form.poll_data.data = poll_json(question="Pick one", options=["A", "B", "C"])
97
+
98
+ post = form.save(user, topic)
99
+
100
+ poll = Poll.get(Poll.post_id == post.id)
101
+ assert poll is not None
102
+ assert poll.question == "Pick one"
103
+ assert [o.text for o in poll.options] == ["A", "B", "C"]
104
+
105
+
106
+ def test_editing_a_reply_with_poll_data_creates_no_poll(request_context, topic, user):
107
+ """ReplyForm is shared between NewPost and EditPost - poll_data is only
108
+ ever meant to be honored for a brand new post (see the comment on
109
+ flaskbb_tpl_form_new_post_after in vote/__init__.py). Simulating an
110
+ edit here by passing obj=<an existing post> exercises the is_new=False
111
+ guard in flaskbb_event_post_save_after directly, regardless of
112
+ whether the template actually renders the field for edits.
113
+ """
114
+ existing_post = topic.first_post
115
+ form = reply_form(obj=existing_post, content="Edited content", track_topic=False)
116
+ form.poll_data.data = poll_json()
117
+
118
+ form.save(user, topic)
119
+
120
+ assert Poll.get(Poll.post_id == existing_post.id) is None
121
+
122
+
123
+ def test_malformed_poll_data_does_not_prevent_topic_creation(
124
+ request_context, forum, user
125
+ ):
126
+ form = new_topic_form(
127
+ title="A topic with junk poll data", content="Still works", track_topic=False
128
+ )
129
+ form.poll_data.data = "not json"
130
+
131
+ topic = form.save(user, forum)
132
+
133
+ assert topic is not None
134
+ assert topic.id is not None
135
+ assert Poll.get(Poll.post_id == topic.first_post.id) is None
136
+
137
+
138
+ def test_topics_only_removes_poll_data_from_reply_form(
139
+ request_context, default_settings
140
+ ):
141
+ with topics_only(True):
142
+ assert not hasattr(reply_form(), "poll_data")
143
+
144
+
145
+ def test_topics_only_does_not_affect_new_topic_form(request_context, default_settings):
146
+ with topics_only(True):
147
+ assert hasattr(new_topic_form(), "poll_data")
148
+
149
+
150
+ def test_topics_only_disabled_again_restores_the_field(
151
+ request_context, default_settings
152
+ ):
153
+ with topics_only(True):
154
+ assert not hasattr(reply_form(), "poll_data")
155
+ with topics_only(False):
156
+ assert hasattr(reply_form(), "poll_data")
157
+
158
+
159
+ def test_topics_only_prevents_a_reply_poll_end_to_end(request_context, topic, user):
160
+ with topics_only(True):
161
+ form = reply_form(content="Trying to attach a poll anyway")
162
+ assert not hasattr(form, "poll_data")
163
+
164
+ post = form.save(user, topic)
165
+
166
+ assert Poll.get(Poll.post_id == post.id) is None
167
+
168
+
169
+ def test_editing_a_reply_shows_its_poll_read_only(request_context, poll):
170
+ form = reply_form(obj=poll.post, content="Edited content")
171
+
172
+ html = vote_plugin.flaskbb_tpl_form_new_post_after(form)
173
+
174
+ assert html is not None
175
+ assert poll.question in html
176
+ assert 'name="option_id"' not in html
177
+
178
+
179
+ def test_editing_a_reply_without_a_poll_renders_nothing(request_context, topic, user):
180
+ from flaskbb.forum.models import Post
181
+
182
+ post = Post(content="No poll here").save(user=user, topic=topic)
183
+ form = reply_form(obj=post, content="Edited content")
184
+
185
+ assert vote_plugin.flaskbb_tpl_form_new_post_after(form) is None
186
+
187
+
188
+ def test_editing_a_topic_shows_its_poll_read_only(request_context, topic, user):
189
+ poll = Poll(post_id=topic.first_post.id, question="Best pizza?", poll_type="single")
190
+ poll.save()
191
+ PollOption(poll_id=poll.id, text="Margherita", position=0).save()
192
+ PollOption(poll_id=poll.id, text="Pepperoni", position=1).save()
193
+
194
+ form = EditTopicForm(obj=topic.first_post, title=topic.title)
195
+ html = vote_plugin.flaskbb_tpl_form_new_topic_after(form)
196
+
197
+ assert html is not None
198
+ assert "Best pizza?" in html
199
+ assert 'name="option_id"' not in html
200
+
201
+
202
+ def test_editing_a_topic_without_a_poll_renders_nothing(request_context, topic):
203
+ form = EditTopicForm(obj=topic.first_post, title=topic.title)
204
+
205
+ assert vote_plugin.flaskbb_tpl_form_new_topic_after(form) is None
@@ -0,0 +1,61 @@
1
+ from vote.models import Poll, PollOption, PollVote
2
+
3
+
4
+ def test_poll_total_votes_with_no_votes(poll):
5
+ assert poll.total_votes == 0
6
+
7
+
8
+ def test_poll_total_votes_counts_across_options(poll, user, admin_user):
9
+ red, green = poll.options[0], poll.options[1]
10
+ PollVote(poll_option_id=red.id, user_id=user.id).save()
11
+ PollVote(poll_option_id=green.id, user_id=admin_user.id).save()
12
+
13
+ poll = Poll.get(Poll.id == poll.id)
14
+ assert poll.total_votes == 2
15
+
16
+
17
+ def test_option_vote_count(poll, user):
18
+ red = poll.options[0]
19
+ PollVote(poll_option_id=red.id, user_id=user.id).save()
20
+
21
+ assert red.vote_count == 1
22
+ assert poll.options[1].vote_count == 0
23
+
24
+
25
+ def test_option_ids_voted_by_returns_only_that_users_votes(poll, user, admin_user):
26
+ red, green = poll.options[0], poll.options[1]
27
+ PollVote(poll_option_id=red.id, user_id=user.id).save()
28
+ PollVote(poll_option_id=green.id, user_id=admin_user.id).save()
29
+
30
+ assert poll.option_ids_voted_by(user.id) == [red.id]
31
+ assert poll.option_ids_voted_by(admin_user.id) == [green.id]
32
+
33
+
34
+ def test_option_ids_voted_by_empty_for_nonvoter(poll, admin_user):
35
+ assert poll.option_ids_voted_by(admin_user.id) == []
36
+
37
+
38
+ def test_is_multiple_choice(poll, multi_poll):
39
+ assert poll.is_multiple_choice is False
40
+ assert multi_poll.is_multiple_choice is True
41
+
42
+
43
+ def test_deleting_post_cascades_to_poll_and_options(poll):
44
+ post = poll.post
45
+ option_ids = [option.id for option in poll.options]
46
+ poll_id = poll.id
47
+
48
+ post.delete()
49
+
50
+ assert Poll.get(Poll.id == poll_id) is None
51
+ assert PollOption.get_all(PollOption.id.in_(option_ids)) == []
52
+
53
+
54
+ def test_deleting_option_cascades_to_votes(poll, user):
55
+ red = poll.options[0]
56
+ vote = PollVote(poll_option_id=red.id, user_id=user.id).save()
57
+ vote_id = vote.id
58
+
59
+ red.delete()
60
+
61
+ assert PollVote.get(PollVote.id == vote_id) is None