Skip to content

Commit 8057e2d

Browse files
committed
Vote storage through adapter
1 parent f52538d commit 8057e2d

8 files changed

Lines changed: 303 additions & 0 deletions

File tree

news/vote-storage.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add an annotation-backed `IVoteStorage` adapter for `Poll` with a Python API to cast, read, remove, and tally votes.

src/experimental/doodle/adapters/__init__.py

Whitespace-only changes.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<configure
2+
xmlns="http://namespaces.zope.org/zope"
3+
i18n_domain="experimental.doodle"
4+
>
5+
6+
<adapter factory=".vote_storage.VoteStorage" />
7+
8+
</configure>
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Annotation-backed vote storage adapter for ``Poll`` objects."""
2+
3+
from collections.abc import Sequence
4+
from experimental.doodle.content.poll import IPoll
5+
from experimental.doodle.interfaces import IVoteStorage
6+
from persistent.list import PersistentList
7+
from persistent.mapping import PersistentMapping
8+
from zope.annotation.interfaces import IAnnotations
9+
from zope.component import adapter
10+
from zope.interface import implementer
11+
12+
13+
ANNOTATION_KEY = "experimental.doodle.votes"
14+
15+
16+
@implementer(IVoteStorage)
17+
@adapter(IPoll)
18+
class VoteStorage:
19+
"""Store and aggregate votes as an annotation on a Poll."""
20+
21+
def __init__(self, context):
22+
self.context = context
23+
annotations = IAnnotations(context)
24+
if ANNOTATION_KEY not in annotations:
25+
annotations[ANNOTATION_KEY] = PersistentMapping()
26+
self._votes: PersistentMapping = annotations[ANNOTATION_KEY]
27+
28+
# ----- helpers -----------------------------------------------------
29+
30+
@property
31+
def _option_count(self) -> int:
32+
return len(self.context.options or [])
33+
34+
@staticmethod
35+
def _clean_name(name: str) -> str:
36+
if not isinstance(name, str):
37+
raise ValueError("Participant name must be a string.")
38+
cleaned = name.strip()
39+
if not cleaned:
40+
raise ValueError("Participant name must not be empty.")
41+
return cleaned
42+
43+
# ----- public API --------------------------------------------------
44+
45+
def cast_vote(self, name: str, votes: Sequence[bool]) -> None:
46+
cleaned = self._clean_name(name)
47+
votes_list = list(votes)
48+
expected = self._option_count
49+
if len(votes_list) != expected:
50+
raise ValueError(
51+
f"Expected {expected} votes (one per option), got {len(votes_list)}."
52+
)
53+
self._votes[cleaned] = PersistentList(bool(v) for v in votes_list)
54+
55+
def get_vote(self, name: str) -> list[bool] | None:
56+
cleaned = self._clean_name(name)
57+
stored = self._votes.get(cleaned)
58+
if stored is None:
59+
return None
60+
return list(stored)
61+
62+
def get_votes(self) -> dict[str, list[bool]]:
63+
return {name: list(votes) for name, votes in self._votes.items()}
64+
65+
def remove_vote(self, name: str) -> bool:
66+
cleaned = self._clean_name(name)
67+
if cleaned in self._votes:
68+
del self._votes[cleaned]
69+
return True
70+
return False
71+
72+
def participants(self) -> list[str]:
73+
return sorted(self._votes.keys())
74+
75+
def tally(self) -> list[int]:
76+
counts = [0] * self._option_count
77+
for votes in self._votes.values():
78+
for index, choice in enumerate(votes):
79+
if index >= len(counts):
80+
break
81+
if choice:
82+
counts[index] += 1
83+
return counts

src/experimental/doodle/configure.zcml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
<include file="permissions.zcml" />
1818

1919
<include package=".content" />
20+
<include package=".adapters" />
2021
<include package=".controlpanels" />
2122
<include package=".indexers" />
2223
<include package=".vocabularies" />
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,50 @@
11
"""Module where all interfaces, events and exceptions live."""
22

3+
from zope.interface import Interface
34
from zope.publisher.interfaces.browser import IDefaultBrowserLayer
45

56

67
class IBrowserLayer(IDefaultBrowserLayer):
78
"""Marker interface that defines a browser layer."""
9+
10+
11+
class IVoteStorage(Interface):
12+
"""Storage adapter for votes on a Poll.
13+
14+
Votes are persisted as an annotation on the adapted Poll. Each entry
15+
maps a participant's display name to a list of booleans whose length
16+
equals ``len(poll.options)`` at write time.
17+
"""
18+
19+
def cast_vote(name, votes):
20+
"""Record ``votes`` for the participant ``name``.
21+
22+
``name`` is stripped of surrounding whitespace; case is preserved.
23+
Casting again with the same name overwrites the previous vote.
24+
25+
Raises ``ValueError`` if ``name`` is empty after stripping, or if
26+
``votes`` has a different length than the poll's options.
27+
"""
28+
29+
def get_vote(name):
30+
"""Return the votes cast by ``name`` as a plain ``list[bool]``,
31+
or ``None`` if the participant has not voted yet."""
32+
33+
def get_votes():
34+
"""Return a plain ``dict[str, list[bool]]`` snapshot of all votes.
35+
36+
Mutating the returned mapping or its lists does not affect the
37+
stored state.
38+
"""
39+
40+
def remove_vote(name):
41+
"""Remove ``name``'s vote. Return ``True`` if a vote was removed,
42+
``False`` if the participant had not voted."""
43+
44+
def participants():
45+
"""Return participant names that have cast a vote, sorted
46+
alphabetically."""
47+
48+
def tally():
49+
"""Return a ``list[int]`` of ``yes`` counts, parallel to
50+
``poll.options``."""

tests/adapters/__init__.py

Whitespace-only changes.
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
"""Tests for the VoteStorage adapter."""
2+
3+
from datetime import datetime
4+
from datetime import timedelta
5+
from datetime import timezone
6+
from experimental.doodle.adapters.vote_storage import ANNOTATION_KEY
7+
from experimental.doodle.adapters.vote_storage import VoteStorage
8+
from experimental.doodle.interfaces import IVoteStorage
9+
from plone import api
10+
from plone.app.testing import setRoles
11+
from plone.app.testing import TEST_USER_ID
12+
from zope.annotation.interfaces import IAnnotations
13+
14+
import pytest
15+
16+
17+
@pytest.fixture
18+
def three_slots():
19+
"""Three timezone-aware datetimes in the future."""
20+
base = datetime.now(tz=timezone.utc) + timedelta(days=1)
21+
return [base, base + timedelta(hours=1), base + timedelta(hours=2)]
22+
23+
24+
@pytest.fixture
25+
def poll(integration, three_slots):
26+
"""A Poll with three options inside the test portal."""
27+
portal = integration["portal"]
28+
setRoles(portal, TEST_USER_ID, ["Manager"])
29+
return api.content.create(
30+
container=portal,
31+
type="Poll",
32+
title="Team lunch",
33+
options=three_slots,
34+
)
35+
36+
37+
class TestAdapterLookup:
38+
"""``IVoteStorage`` resolves to ``VoteStorage`` for a Poll."""
39+
40+
def test_adapter_returns_vote_storage(self, poll):
41+
storage = IVoteStorage(poll)
42+
assert isinstance(storage, VoteStorage)
43+
44+
def test_adapter_uses_annotation_key(self, poll):
45+
IVoteStorage(poll) # triggers annotation initialization
46+
annotations = IAnnotations(poll)
47+
assert ANNOTATION_KEY in annotations
48+
49+
50+
class TestCastVote:
51+
"""Recording votes."""
52+
53+
def test_cast_vote_stores_choices(self, poll):
54+
storage = IVoteStorage(poll)
55+
storage.cast_vote("Alice", [True, False, True])
56+
assert storage.get_vote("Alice") == [True, False, True]
57+
58+
def test_cast_vote_is_idempotent_overwrite(self, poll):
59+
storage = IVoteStorage(poll)
60+
storage.cast_vote("Alice", [True, False, True])
61+
storage.cast_vote("Alice", [False, False, False])
62+
assert storage.get_vote("Alice") == [False, False, False]
63+
assert storage.participants() == ["Alice"]
64+
65+
def test_cast_vote_strips_whitespace_preserves_case(self, poll):
66+
storage = IVoteStorage(poll)
67+
storage.cast_vote(" Alice ", [True, True, True])
68+
assert storage.get_vote("Alice") == [True, True, True]
69+
assert "Alice" in storage.participants()
70+
71+
def test_cast_vote_coerces_to_bool(self, poll):
72+
storage = IVoteStorage(poll)
73+
storage.cast_vote("Alice", [1, 0, "yes"])
74+
assert storage.get_vote("Alice") == [True, False, True]
75+
76+
77+
class TestCastVoteErrors:
78+
"""``cast_vote`` rejects invalid input."""
79+
80+
@pytest.mark.parametrize("bad_name", ["", " ", "\t\n"])
81+
def test_empty_name_raises(self, poll, bad_name):
82+
storage = IVoteStorage(poll)
83+
with pytest.raises(ValueError):
84+
storage.cast_vote(bad_name, [True, False, True])
85+
86+
def test_non_string_name_raises(self, poll):
87+
storage = IVoteStorage(poll)
88+
with pytest.raises(ValueError):
89+
storage.cast_vote(None, [True, False, True])
90+
91+
def test_wrong_length_raises(self, poll):
92+
storage = IVoteStorage(poll)
93+
with pytest.raises(ValueError):
94+
storage.cast_vote("Alice", [True, False]) # poll has 3 options
95+
96+
97+
class TestReading:
98+
"""Reading back stored votes."""
99+
100+
def test_get_vote_returns_none_for_unknown(self, poll):
101+
storage = IVoteStorage(poll)
102+
assert storage.get_vote("Nobody") is None
103+
104+
def test_get_votes_returns_plain_dict_snapshot(self, poll):
105+
storage = IVoteStorage(poll)
106+
storage.cast_vote("Alice", [True, False, True])
107+
108+
snapshot = storage.get_votes()
109+
assert snapshot == {"Alice": [True, False, True]}
110+
111+
# Mutating the snapshot must not affect the stored state.
112+
snapshot["Alice"][0] = False
113+
snapshot["Mallory"] = [True, True, True]
114+
assert storage.get_vote("Alice") == [True, False, True]
115+
assert "Mallory" not in storage.participants()
116+
117+
def test_participants_sorted(self, poll):
118+
storage = IVoteStorage(poll)
119+
storage.cast_vote("Charlie", [True, True, True])
120+
storage.cast_vote("Alice", [False, False, False])
121+
storage.cast_vote("Bob", [True, False, True])
122+
123+
assert storage.participants() == ["Alice", "Bob", "Charlie"]
124+
125+
126+
class TestRemoveVote:
127+
"""Removing votes."""
128+
129+
def test_remove_vote_existing_returns_true(self, poll):
130+
storage = IVoteStorage(poll)
131+
storage.cast_vote("Alice", [True, False, True])
132+
133+
assert storage.remove_vote("Alice") is True
134+
assert storage.get_vote("Alice") is None
135+
136+
def test_remove_vote_unknown_returns_false(self, poll):
137+
storage = IVoteStorage(poll)
138+
assert storage.remove_vote("Nobody") is False
139+
140+
141+
class TestTally:
142+
"""Aggregating yes-counts per option."""
143+
144+
def test_tally_empty_poll(self, poll):
145+
storage = IVoteStorage(poll)
146+
assert storage.tally() == [0, 0, 0]
147+
148+
def test_tally_counts_yes_per_option(self, poll):
149+
storage = IVoteStorage(poll)
150+
storage.cast_vote("Alice", [True, False, True])
151+
storage.cast_vote("Bob", [True, True, False])
152+
storage.cast_vote("Charlie", [False, True, True])
153+
154+
# Option 0: Alice + Bob = 2 yes
155+
# Option 1: Bob + Charlie = 2 yes
156+
# Option 2: Alice + Charlie = 2 yes
157+
assert storage.tally() == [2, 2, 2]
158+
159+
def test_tally_ignores_extra_entries_when_options_shrink(self, poll):
160+
"""If the Poll loses an option after a vote, tally truncates."""
161+
storage = IVoteStorage(poll)
162+
storage.cast_vote("Alice", [True, True, True])
163+
164+
# Simulate the Poll losing one option.
165+
poll.options = poll.options[:2]
166+
167+
assert storage.tally() == [1, 1]

0 commit comments

Comments
 (0)