Skip to content

Commit d52ce77

Browse files
committed
Create doodle interface
1 parent 26a062f commit d52ce77

20 files changed

Lines changed: 1007 additions & 12 deletions

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ classifiers = [
3131
dependencies = [
3232
"Products.CMFPlone",
3333
"plone.api",
34+
"plone.app.dexterity",
35+
"plone.autoform",
3436
"z3c.jbot",
3537
]
3638

src/experimental/doodle/answers.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
from datetime import date
2+
3+
from zope.annotation.interfaces import IAnnotations
4+
5+
6+
ANSWERS_KEY = "experimental.doodle.answers"
7+
8+
9+
def get_answers(context):
10+
"""Return stored answers for a doodle."""
11+
return list(IAnnotations(context).get(ANSWERS_KEY, []))
12+
13+
14+
def get_answer_for_user(context, userid):
15+
"""Return one user's answer or None."""
16+
for entry in get_answers(context):
17+
if entry.get("userid") == userid:
18+
return entry
19+
return None
20+
21+
22+
def upsert_answer(context, userid, display_name, selected_dates):
23+
"""Create or replace a user's answer."""
24+
normalized = sorted(
25+
{
26+
value.isoformat() if isinstance(value, date) else value
27+
for value in selected_dates
28+
}
29+
)
30+
annotations = IAnnotations(context)
31+
answers = list(annotations.get(ANSWERS_KEY, []))
32+
for entry in answers:
33+
if entry.get("userid") == userid:
34+
entry["display_name"] = display_name
35+
entry["selected_dates"] = normalized
36+
annotations[ANSWERS_KEY] = answers
37+
return
38+
answers.append(
39+
{
40+
"userid": userid,
41+
"display_name": display_name,
42+
"selected_dates": normalized,
43+
}
44+
)
45+
annotations[ANSWERS_KEY] = answers
46+
47+
48+
def build_results(context):
49+
"""Aggregate counts and names per candidate date."""
50+
candidate_dates = list(context.candidate_dates or [])
51+
by_date = {
52+
candidate.isoformat(): {"count": 0, "names": []}
53+
for candidate in candidate_dates
54+
}
55+
for entry in get_answers(context):
56+
name = entry.get("display_name") or entry.get("userid")
57+
for selected in entry.get("selected_dates", []):
58+
if selected in by_date:
59+
by_date[selected]["count"] += 1
60+
by_date[selected]["names"].append(name)
61+
rows = []
62+
for candidate in candidate_dates:
63+
iso = candidate.isoformat()
64+
info = by_date[iso]
65+
rows.append(
66+
{
67+
"date": candidate,
68+
"iso": iso,
69+
"count": info["count"],
70+
"names": info["names"],
71+
}
72+
)
73+
return rows

src/experimental/doodle/browser/configure.zcml

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
<configure
22
xmlns="http://namespaces.zope.org/zope"
33
xmlns:browser="http://namespaces.zope.org/browser"
4-
xmlns:plone="http://namespaces.plone.org/plone"
54
i18n_domain="experimental.doodle"
65
>
76

@@ -15,11 +14,26 @@
1514
layer="experimental.doodle.interfaces.IBrowserLayer"
1615
/>
1716

18-
<!-- Publish static files -->
19-
<plone:static
20-
directory="static"
17+
<!-- ++resource++ URLs use Five browser resources, not plone:static -->
18+
<browser:resourceDirectory
2119
name="experimental.doodle"
22-
type="plone"
20+
directory="static"
21+
/>
22+
23+
<browser:page
24+
for="experimental.doodle.content.interfaces.IDoodle"
25+
name="view"
26+
class=".views.AnswerView"
27+
permission="zope2.View"
28+
layer="experimental.doodle.interfaces.IBrowserLayer"
29+
/>
30+
31+
<browser:page
32+
for="experimental.doodle.content.interfaces.IDoodle"
33+
name="results"
34+
class=".views.ResultsView"
35+
permission="zope2.View"
36+
layer="experimental.doodle.interfaces.IBrowserLayer"
2337
/>
2438

2539
</configure>
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
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+
i18n:domain="experimental.doodle"
6+
metal:use-macro="context/@@main_template/macros/master"
7+
>
8+
9+
<body>
10+
<metal:slot fill-slot="main">
11+
<link rel="stylesheet"
12+
tal:attributes="href string:${context/portal_url}/++resource++experimental.doodle/doodle.css"
13+
/>
14+
<div class="doodle-card doodle-answer">
15+
<header class="doodle-header">
16+
<div class="doodle-header-top">
17+
<div class="doodle-header-text">
18+
<h1 tal:content="context/Title">Doodle title</h1>
19+
<p class="doodle-subtitle" i18n:translate="">
20+
Which dates work for you?
21+
</p>
22+
</div>
23+
<div class="doodle-header-actions"
24+
tal:condition="view/is_creator"
25+
>
26+
<a class="btn btn-secondary btn-sm"
27+
tal:attributes="href view/results_url"
28+
i18n:translate=""
29+
>View results</a>
30+
</div>
31+
</div>
32+
</header>
33+
34+
<div class="doodle-body">
35+
<div class="doodle-share-box"
36+
tal:condition="view/is_creator"
37+
>
38+
<p class="doodle-share-label" i18n:translate="">Share this link</p>
39+
<input type="text"
40+
readonly="readonly"
41+
class="doodle-share-url"
42+
tal:attributes="value context/absolute_url"
43+
/>
44+
<p class="doodle-share-warning" i18n:translate="">
45+
Remember to publish this doodle before sharing, or members will not be able to view it.
46+
</p>
47+
</div>
48+
49+
<div class="doodle-meta">
50+
<p class="doodle-meta-line">
51+
<span i18n:translate="">Answering as</span>
52+
<strong tal:content="view/display_name">Member</strong>
53+
</p>
54+
</div>
55+
56+
<form action=""
57+
method="post"
58+
class="doodle-answer-form"
59+
>
60+
<input type="hidden" name="form.submitted" value="1" />
61+
<span tal:replace="structure context/@@authenticator/authenticator" />
62+
63+
<fieldset class="doodle-fieldset">
64+
<legend i18n:translate="">Available dates</legend>
65+
<ul class="doodle-date-options">
66+
<li class="doodle-date-option"
67+
tal:repeat="candidate view/candidate_dates"
68+
>
69+
<input type="checkbox"
70+
name="selected_dates"
71+
class="doodle-date-checkbox"
72+
tal:attributes="id python:'doodle-date-' + candidate.isoformat();
73+
value candidate/isoformat;
74+
checked python:candidate.isoformat() in view.selected"
75+
/>
76+
<label class="doodle-date-label"
77+
tal:attributes="for python:'doodle-date-' + candidate.isoformat()"
78+
tal:content="python:view.format_date_long(candidate)"
79+
>May 20, 2026 · Tuesday</label>
80+
</li>
81+
</ul>
82+
</fieldset>
83+
84+
<div class="doodle-form-footer">
85+
<button type="submit"
86+
class="btn btn-primary"
87+
i18n:translate=""
88+
>Save my answer</button>
89+
</div>
90+
</form>
91+
</div>
92+
</div>
93+
</metal:slot>
94+
</body>
95+
</html>
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
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+
i18n:domain="experimental.doodle"
6+
metal:use-macro="context/@@main_template/macros/master"
7+
>
8+
9+
<body>
10+
<metal:slot fill-slot="main">
11+
<link rel="stylesheet"
12+
tal:attributes="href string:${context/portal_url}/++resource++experimental.doodle/doodle.css"
13+
/>
14+
<div class="doodle-card doodle-results">
15+
<header class="doodle-header">
16+
<div class="doodle-header-top">
17+
<div class="doodle-header-text">
18+
<h1 tal:content="context/Title">Doodle title</h1>
19+
<p class="doodle-subtitle" i18n:translate="">
20+
Pick the best date to meet.
21+
</p>
22+
</div>
23+
<div class="doodle-header-actions">
24+
<a class="btn btn-secondary btn-sm"
25+
tal:attributes="href view/answer_url"
26+
i18n:translate=""
27+
>Back to answer form</a>
28+
</div>
29+
</div>
30+
</header>
31+
32+
<div class="doodle-body doodle-table-wrap">
33+
<table class="table table-striped doodle-results-table">
34+
<thead>
35+
<tr>
36+
<th i18n:translate="">Date</th>
37+
<th i18n:translate="">Count</th>
38+
<th i18n:translate="">Who can make it</th>
39+
</tr>
40+
</thead>
41+
<tbody>
42+
<tr tal:repeat="row view/rows"
43+
tal:attributes="class python:'doodle-best' if row['count'] == max((r['count'] for r in view.rows), default=0) and row['count'] > 0 else ''"
44+
>
45+
<td tal:content="python:view.format_date(row['date'])">May 20, 2026</td>
46+
<td>
47+
<span class="doodle-count"
48+
tal:content="row/count"
49+
>0</span>
50+
</td>
51+
<td tal:content="python:', '.join(row['names']) if row['names'] else '-'">-</td>
52+
</tr>
53+
</tbody>
54+
</table>
55+
</div>
56+
</div>
57+
</metal:slot>
58+
</body>
59+
</html>

0 commit comments

Comments
 (0)