Skip to content

Commit 2bf00cf

Browse files
committed
Add the poll view for voting and results view
1 parent e926690 commit 2bf00cf

13 files changed

Lines changed: 631 additions & 5 deletions

File tree

news/classic-ui.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add Classic UI views for Poll: a vote form (``@@poll_view``) and a tally page (``@@results``).

src/experimental/doodle/browser/configure.zcml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,21 @@
2222
type="plone"
2323
/>
2424

25+
<!-- Poll views -->
26+
<browser:page
27+
name="poll_view"
28+
for="experimental.doodle.content.poll.IPoll"
29+
class=".poll.PollView"
30+
permission="zope2.View"
31+
layer="experimental.doodle.interfaces.IBrowserLayer"
32+
/>
33+
34+
<browser:page
35+
name="results"
36+
for="experimental.doodle.content.poll.IPoll"
37+
class=".poll.PollResultsView"
38+
permission="zope2.View"
39+
layer="experimental.doodle.interfaces.IBrowserLayer"
40+
/>
41+
2542
</configure>
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"""Browser views for the Poll content type."""
2+
3+
from experimental.doodle import _
4+
from experimental.doodle.interfaces import IVoteStorage
5+
from plone.protect.interfaces import IDisableCSRFProtection
6+
from Products.Five.browser import BrowserView
7+
from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile
8+
from zope.interface import alsoProvides
9+
10+
11+
def _format_slot(dt) -> str:
12+
"""Return a human-readable label for a datetime option."""
13+
return dt.strftime("%a %d %b %Y, %H:%M UTC")
14+
15+
16+
class PollView(BrowserView):
17+
"""Default view for a Poll: shows the vote form.
18+
19+
Handles GET (render) and POST (record vote, then redirect).
20+
"""
21+
22+
index = ViewPageTemplateFile("templates/poll_view.pt")
23+
24+
def __call__(self):
25+
if self.request.method == "POST":
26+
error = self._handle_post()
27+
if error is None:
28+
# Post/Redirect/Get: prevent duplicate submissions on refresh.
29+
url = self.context.absolute_url() + "/@@poll_view"
30+
self.request.response.redirect(url)
31+
return ""
32+
self._error = error
33+
else:
34+
self._error = None
35+
return self.index()
36+
37+
# ------------------------------------------------------------------
38+
# Template helpers
39+
# ------------------------------------------------------------------
40+
41+
@property
42+
def options(self):
43+
"""Return ``[(index, label), ...]`` for every proposed slot."""
44+
return [
45+
(i, _format_slot(dt))
46+
for i, dt in enumerate(self.context.options or [])
47+
]
48+
49+
@property
50+
def error(self):
51+
return self._error
52+
53+
@property
54+
def participant_count(self):
55+
return len(IVoteStorage(self.context).participants())
56+
57+
# ------------------------------------------------------------------
58+
# POST handler
59+
# ------------------------------------------------------------------
60+
61+
def _handle_post(self):
62+
"""Validate the submitted form and cast the vote.
63+
64+
Returns ``None`` on success, or a translated error string to
65+
display on the form.
66+
67+
Voting is open to anonymous users, so there is no valid CSRF
68+
authenticator token for them. We explicitly opt out of CSRF
69+
protection here — the write is intentional and the form is
70+
publicly accessible by design.
71+
"""
72+
alsoProvides(self.request, IDisableCSRFProtection)
73+
form = self.request.form
74+
name = (form.get("name") or "").strip()
75+
if not name:
76+
return _("Please enter your name.")
77+
78+
options = self.context.options or []
79+
votes = []
80+
for i in range(len(options)):
81+
raw = form.get(f"votes_{i}", "false")
82+
votes.append(raw == "true")
83+
84+
try:
85+
IVoteStorage(self.context).cast_vote(name, votes)
86+
except ValueError as exc:
87+
return str(exc)
88+
89+
return None
90+
91+
92+
class PollResultsView(BrowserView):
93+
"""Results view for a Poll: shows the tally."""
94+
95+
index = ViewPageTemplateFile("templates/results.pt")
96+
97+
def __call__(self):
98+
return self.index()
99+
100+
# ------------------------------------------------------------------
101+
# Template helpers
102+
# ------------------------------------------------------------------
103+
104+
@property
105+
def rows(self):
106+
"""Return ``[(label, yes_count, max_count), ...]`` for each slot."""
107+
storage = IVoteStorage(self.context)
108+
counts = storage.tally()
109+
max_count = max(counts) if counts else 0
110+
return [
111+
(_format_slot(dt), count, max_count)
112+
for dt, count in zip(self.context.options or [], counts)
113+
]
114+
115+
@property
116+
def participants(self):
117+
return IVoteStorage(self.context).participants()
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/* experimental.doodle — Poll styles */
2+
3+
.poll-form {
4+
margin: 1.5rem 0;
5+
}
6+
7+
.poll-name-field {
8+
margin-bottom: 1rem;
9+
}
10+
11+
.poll-name-field label {
12+
display: block;
13+
font-weight: bold;
14+
margin-bottom: 0.25rem;
15+
}
16+
17+
.poll-name-field input[type="text"] {
18+
width: 20rem;
19+
max-width: 100%;
20+
padding: 0.3rem 0.5rem;
21+
}
22+
23+
.poll-options {
24+
border-collapse: collapse;
25+
margin-bottom: 1rem;
26+
}
27+
28+
.poll-options th,
29+
.poll-options td {
30+
border: 1px solid #ccc;
31+
padding: 0.4rem 0.75rem;
32+
text-align: left;
33+
}
34+
35+
.poll-options th {
36+
background: #f5f5f5;
37+
}
38+
39+
.poll-yes,
40+
.poll-no {
41+
text-align: center;
42+
white-space: nowrap;
43+
}
44+
45+
.poll-submit {
46+
margin-top: 0.75rem;
47+
}
48+
49+
.poll-participant-count {
50+
color: #666;
51+
font-size: 0.9rem;
52+
margin-top: 1rem;
53+
}
54+
55+
/* Results */
56+
57+
.poll-results {
58+
border-collapse: collapse;
59+
margin-bottom: 1rem;
60+
width: 100%;
61+
}
62+
63+
.poll-results th,
64+
.poll-results td {
65+
border: 1px solid #ccc;
66+
padding: 0.4rem 0.75rem;
67+
text-align: left;
68+
}
69+
70+
.poll-results th {
71+
background: #f5f5f5;
72+
}
73+
74+
.poll-count {
75+
text-align: right;
76+
width: 4rem;
77+
}
78+
79+
.poll-bar-cell {
80+
width: 50%;
81+
}
82+
83+
.poll-bar {
84+
background: #007bb5;
85+
height: 1.1rem;
86+
min-width: 2px;
87+
}
88+
89+
.poll-participants {
90+
list-style: disc;
91+
padding-left: 1.5rem;
92+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
<html xmlns="http://www.w3.org/1999/xhtml"
2+
xmlns:metal="http://xml.zope.org/namespaces/metal"
3+
xmlns:tal="http://xml.zope.org/namespaces/tal"
4+
xmlns:i18n="http://xml.zope.org/namespaces/i18n"
5+
metal:use-macro="context/@@main_template/macros/master"
6+
i18n:domain="experimental.doodle"
7+
>
8+
9+
<body>
10+
<metal:content-core fill-slot="content-core">
11+
<metal:block define-macro="content-core">
12+
13+
<link rel="stylesheet"
14+
tal:attributes="href string:${context/@@plone_portal_state/portal_url}/++plone++experimental.doodle/poll.css"
15+
/>
16+
17+
<tal:no-options condition="not:view/options">
18+
<p i18n:translate="">No time slots have been proposed yet.</p>
19+
</tal:no-options>
20+
21+
<tal:has-options condition="view/options">
22+
23+
<tal:error condition="view/error">
24+
<div class="portalMessage error"
25+
role="alert"
26+
>
27+
<span tal:replace="view/error">Error message</span>
28+
</div>
29+
</tal:error>
30+
31+
<form method="POST"
32+
tal:attributes="action string:${context/absolute_url}/@@poll_view"
33+
class="poll-form"
34+
>
35+
<div class="poll-name-field">
36+
<label for="poll-name"
37+
i18n:translate=""
38+
>Your name</label>
39+
<input type="text"
40+
id="poll-name"
41+
name="name"
42+
required="required"
43+
placeholder="Enter your name"
44+
i18n:attributes="placeholder"
45+
/>
46+
</div>
47+
48+
<table class="poll-options">
49+
<thead>
50+
<tr>
51+
<th i18n:translate="">Time slot</th>
52+
<th i18n:translate="">Yes</th>
53+
<th i18n:translate="">No</th>
54+
</tr>
55+
</thead>
56+
<tbody>
57+
<tr tal:repeat="option view/options">
58+
<td tal:content="python:option[1]">Slot label</td>
59+
<td class="poll-yes">
60+
<input type="radio"
61+
tal:attributes="name python:'votes_{}'.format(option[0]);
62+
id python:'votes_{}_yes'.format(option[0])"
63+
value="true"
64+
/>
65+
<label tal:attributes="for python:'votes_{}_yes'.format(option[0])"
66+
i18n:translate=""
67+
>Yes</label>
68+
</td>
69+
<td class="poll-no">
70+
<input type="radio"
71+
tal:attributes="name python:'votes_{}'.format(option[0]);
72+
id python:'votes_{}_no'.format(option[0])"
73+
value="false"
74+
checked="checked"
75+
/>
76+
<label tal:attributes="for python:'votes_{}_no'.format(option[0])"
77+
i18n:translate=""
78+
>No</label>
79+
</td>
80+
</tr>
81+
</tbody>
82+
</table>
83+
84+
<div class="poll-submit">
85+
<input type="submit"
86+
class="btn-primary"
87+
value="Cast vote"
88+
i18n:attributes="value"
89+
/>
90+
</div>
91+
</form>
92+
93+
<p class="poll-participant-count"
94+
tal:condition="view/participant_count"
95+
>
96+
<tal:count replace="view/participant_count" />
97+
<tal:singular condition="python:view.participant_count == 1">
98+
<span i18n:translate=""> person has already voted.</span>
99+
</tal:singular>
100+
<tal:plural condition="python:view.participant_count != 1">
101+
<span i18n:translate=""> people have already voted.</span>
102+
</tal:plural>
103+
</p>
104+
105+
<p>
106+
<a tal:attributes="href string:${context/absolute_url}/@@results"
107+
i18n:translate=""
108+
>View results</a>
109+
</p>
110+
111+
</tal:has-options>
112+
113+
</metal:block>
114+
</metal:content-core>
115+
</body>
116+
</html>

0 commit comments

Comments
 (0)