From d52ce77eca8f0e5008f2d2ada36fadc0562b5cdb Mon Sep 17 00:00:00 2001 From: Ciro Silvano Date: Wed, 20 May 2026 11:12:48 +0200 Subject: [PATCH 1/7] Create doodle interface --- pyproject.toml | 2 + src/experimental/doodle/answers.py | 73 ++++ .../doodle/browser/configure.zcml | 24 +- .../doodle/browser/doodle_answer.pt | 95 +++++ .../doodle/browser/doodle_results.pt | 59 +++ .../doodle/browser/static/doodle.css | 347 ++++++++++++++++++ src/experimental/doodle/browser/views.py | 91 +++++ src/experimental/doodle/configure.zcml | 4 + .../doodle/content/candidate_dates_input.pt | 45 +++ .../doodle/content/configure.zcml | 5 + src/experimental/doodle/content/doodle.py | 10 + src/experimental/doodle/content/interfaces.py | 21 ++ src/experimental/doodle/content/widgets.py | 10 + src/experimental/doodle/dependencies.zcml | 4 +- .../doodle/profiles/default/metadata.xml | 1 + .../doodle/profiles/default/registry/main.xml | 8 +- .../doodle/profiles/default/types.xml | 8 +- .../doodle/profiles/default/types/Doodle.xml | 32 ++ src/experimental/doodle/utils.py | 63 ++++ tests/test_doodle.py | 117 ++++++ 20 files changed, 1007 insertions(+), 12 deletions(-) create mode 100644 src/experimental/doodle/answers.py create mode 100644 src/experimental/doodle/browser/doodle_answer.pt create mode 100644 src/experimental/doodle/browser/doodle_results.pt create mode 100644 src/experimental/doodle/browser/static/doodle.css create mode 100644 src/experimental/doodle/browser/views.py create mode 100644 src/experimental/doodle/content/candidate_dates_input.pt create mode 100644 src/experimental/doodle/content/configure.zcml create mode 100644 src/experimental/doodle/content/doodle.py create mode 100644 src/experimental/doodle/content/interfaces.py create mode 100644 src/experimental/doodle/content/widgets.py create mode 100644 src/experimental/doodle/profiles/default/types/Doodle.xml create mode 100644 src/experimental/doodle/utils.py create mode 100644 tests/test_doodle.py diff --git a/pyproject.toml b/pyproject.toml index 8866ab6..8617e1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,8 @@ classifiers = [ dependencies = [ "Products.CMFPlone", "plone.api", + "plone.app.dexterity", + "plone.autoform", "z3c.jbot", ] diff --git a/src/experimental/doodle/answers.py b/src/experimental/doodle/answers.py new file mode 100644 index 0000000..5e18eaf --- /dev/null +++ b/src/experimental/doodle/answers.py @@ -0,0 +1,73 @@ +from datetime import date + +from zope.annotation.interfaces import IAnnotations + + +ANSWERS_KEY = "experimental.doodle.answers" + + +def get_answers(context): + """Return stored answers for a doodle.""" + return list(IAnnotations(context).get(ANSWERS_KEY, [])) + + +def get_answer_for_user(context, userid): + """Return one user's answer or None.""" + for entry in get_answers(context): + if entry.get("userid") == userid: + return entry + return None + + +def upsert_answer(context, userid, display_name, selected_dates): + """Create or replace a user's answer.""" + normalized = sorted( + { + value.isoformat() if isinstance(value, date) else value + for value in selected_dates + } + ) + annotations = IAnnotations(context) + answers = list(annotations.get(ANSWERS_KEY, [])) + for entry in answers: + if entry.get("userid") == userid: + entry["display_name"] = display_name + entry["selected_dates"] = normalized + annotations[ANSWERS_KEY] = answers + return + answers.append( + { + "userid": userid, + "display_name": display_name, + "selected_dates": normalized, + } + ) + annotations[ANSWERS_KEY] = answers + + +def build_results(context): + """Aggregate counts and names per candidate date.""" + candidate_dates = list(context.candidate_dates or []) + by_date = { + candidate.isoformat(): {"count": 0, "names": []} + for candidate in candidate_dates + } + for entry in get_answers(context): + name = entry.get("display_name") or entry.get("userid") + for selected in entry.get("selected_dates", []): + if selected in by_date: + by_date[selected]["count"] += 1 + by_date[selected]["names"].append(name) + rows = [] + for candidate in candidate_dates: + iso = candidate.isoformat() + info = by_date[iso] + rows.append( + { + "date": candidate, + "iso": iso, + "count": info["count"], + "names": info["names"], + } + ) + return rows diff --git a/src/experimental/doodle/browser/configure.zcml b/src/experimental/doodle/browser/configure.zcml index 6bf678a..bf69cab 100644 --- a/src/experimental/doodle/browser/configure.zcml +++ b/src/experimental/doodle/browser/configure.zcml @@ -1,7 +1,6 @@ @@ -15,11 +14,26 @@ layer="experimental.doodle.interfaces.IBrowserLayer" /> - - + + + + + diff --git a/src/experimental/doodle/browser/doodle_answer.pt b/src/experimental/doodle/browser/doodle_answer.pt new file mode 100644 index 0000000..ab313db --- /dev/null +++ b/src/experimental/doodle/browser/doodle_answer.pt @@ -0,0 +1,95 @@ + + + + + +
+
+
+
+

Doodle title

+

+ Which dates work for you? +

+
+ +
+
+ +
+
+

Share this link

+ +

+ Remember to publish this doodle before sharing, or members will not be able to view it. +

+
+ +
+

+ Answering as + Member +

+
+ +
+ + + +
+ Available dates +
    +
  • + + +
  • +
+
+ + + +
+
+
+ + diff --git a/src/experimental/doodle/browser/doodle_results.pt b/src/experimental/doodle/browser/doodle_results.pt new file mode 100644 index 0000000..6918e14 --- /dev/null +++ b/src/experimental/doodle/browser/doodle_results.pt @@ -0,0 +1,59 @@ + + + + + +
+
+
+
+

Doodle title

+

+ Pick the best date to meet. +

+
+ +
+
+ +
+ + + + + + + + + + + + + + + +
DateCountWho can make it
May 20, 2026 + 0 + -
+
+
+
+ + diff --git a/src/experimental/doodle/browser/static/doodle.css b/src/experimental/doodle/browser/static/doodle.css new file mode 100644 index 0000000..13a5f86 --- /dev/null +++ b/src/experimental/doodle/browser/static/doodle.css @@ -0,0 +1,347 @@ +/* Doodle add-on — plain CSS, works with Plone Classic UI */ + +.doodle-card { + --doodle-space-sm: 0.75rem; + --doodle-space-md: 1rem; + --doodle-space-lg: 1.25rem; + --doodle-space-xl: 1.5rem; + + max-width: 40rem; + margin: 2rem auto; + padding: 0; + background: #fff; + border: 1px solid #dee2e6; + border-radius: 0.375rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); + overflow: hidden; +} + +.doodle-results.doodle-card { + max-width: 48rem; +} + +.doodle-header { + margin: 0; + padding: var(--doodle-space-lg) var(--doodle-space-xl); + border-bottom: 1px solid #eee; + background: #f8f9fa; +} + +.doodle-header-top { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: flex-start; + gap: var(--doodle-space-md); +} + +.doodle-header-text { + flex: 1 1 12rem; + min-width: 0; +} + +.doodle-header-actions { + flex: 0 0 auto; +} + +.doodle-header h1 { + margin: 0 0 0.35rem; + font-size: 1.5rem; + line-height: 1.3; +} + +.doodle-subtitle { + margin: 0; + color: #495057; +} + +.doodle-body { + padding: var(--doodle-space-xl); +} + +.doodle-meta { + margin-bottom: var(--doodle-space-lg); + padding: var(--doodle-space-sm) var(--doodle-space-md); + color: #495057; + font-size: 0.9rem; + background: #f8f9fa; + border: 1px solid #e9ecef; + border-radius: 0.25rem; +} + +.doodle-share-box { + margin-bottom: var(--doodle-space-lg); + padding: var(--doodle-space-md); + background: #f8f9fa; + border: 1px solid #e9ecef; + border-radius: 0.25rem; +} + +.doodle-share-label { + margin: 0 0 0.5rem; + font-size: 0.875rem; + font-weight: 600; + color: #495057; +} + +.doodle-share-url { + box-sizing: border-box; + display: block; + width: 100%; + margin: 0 0 var(--doodle-space-sm); + padding: 0.5rem 0.75rem; + color: #212529; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.875rem; + line-height: 1.4; + background: #fff; + border: 1px solid #dee2e6; + border-radius: 0.25rem; + cursor: text; +} + +.doodle-share-warning { + margin: 0; + padding: var(--doodle-space-sm) var(--doodle-space-md); + color: #664d03; + font-size: 0.875rem; + background: #fff3cd; + border: 1px solid #ffecb5; + border-radius: 0.25rem; +} + +.doodle-meta-line { + margin: 0; +} + +.doodle-meta-saved { + margin: 0.35rem 0 0; + color: #6c757d; + font-size: 0.875rem; +} + +.doodle-hint { + margin: 0 0 1rem; + padding: 0.75rem 1rem; + color: #495057; + font-size: 0.9rem; + background: #f8f9fa; + border: 1px solid #e9ecef; + border-radius: 0.25rem; +} + +.doodle-hint-warning { + margin: 0; + padding: var(--doodle-space-sm) var(--doodle-space-md); + color: #664d03; + font-size: 0.875rem; + background: #fff3cd; + border: 1px solid #ffecb5; + border-radius: 0.25rem; +} + +.doodle-actions { + margin-bottom: 1rem; +} + +.doodle-field { + margin-bottom: 1.25rem; +} + +.doodle-field label { + display: block; + margin-bottom: 0.35rem; + font-weight: 600; +} + +.doodle-field input[type="text"] { + box-sizing: border-box; + width: 100%; + max-width: 100%; +} + +.doodle-fieldset { + margin: 0; + padding: 0; + border: 0; +} + +.doodle-fieldset legend { + display: block; + width: 100%; + margin-bottom: var(--doodle-space-sm); + padding: 0; + font-size: 1rem; + font-weight: 600; +} + +.doodle-form-footer { + display: flex; + justify-content: flex-end; + margin-top: var(--doodle-space-lg); + padding-top: var(--doodle-space-lg); + border-top: 1px solid #eee; +} + +.doodle-form-controls { + margin-top: var(--doodle-space-lg); + padding-top: var(--doodle-space-md); + border-top: 1px solid #eee; +} + +.doodle-date-rows { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.doodle-date-actions { + margin-top: 0.75rem; +} + +/* Dexterity add/edit — candidate dates table widget */ +.candidate-dates-table-widget .candidate-dates-table { + table-layout: fixed; + width: auto; + max-width: 100%; + margin-bottom: 0.5rem; +} + +.candidate-dates-table-widget col.candidate-dates-check-col { + width: 2rem; +} + +.candidate-dates-table-widget .candidate-dates-table td { + vertical-align: middle; +} + +.candidate-dates-table-widget .candidate-dates-check { + width: 2rem; + max-width: 2rem; + padding: 0.25rem 0.35rem 0.25rem 0; + text-align: center; + white-space: nowrap; +} + +.candidate-dates-table-widget .candidate-dates-check input { + width: 0.875rem; + height: 0.875rem; + margin: 0; +} + +.candidate-dates-table-widget .candidate-dates-input input { + width: 100%; + max-width: 16rem; +} + +.candidate-dates-table-widget .buttons { + margin-top: 0.75rem; +} + +.candidate-dates-table-widget .buttons input { + margin-right: 0.5rem; +} + +/* Answer form — date option list */ +.doodle-card ul.doodle-date-options { + list-style: none !important; + list-style-type: none !important; + margin-left: 0 !important; + padding-left: 0 !important; +} + +.doodle-card ul.doodle-date-options > li { + list-style: none !important; + list-style-type: none !important; + margin-left: 0 !important; +} + +.doodle-card ul.doodle-date-options { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin: 0; + padding: 0; +} + +.doodle-card ul.doodle-date-options > li::marker { + content: none; +} + +.doodle-card ul.doodle-date-options > li::before { + content: none !important; + display: none !important; +} + +.doodle-card ul.doodle-date-options > li.doodle-date-option { + display: flex; + align-items: center; + gap: var(--doodle-space-sm); + margin: 0; + padding: 0.875rem var(--doodle-space-md); + border: 1px solid #dee2e6; + border-radius: 0.25rem; + background: #fff; +} + +.doodle-card ul.doodle-date-options > li.doodle-date-option:hover { + background: #f8f9fa; + border-color: #adb5bd; +} + +.doodle-card ul.doodle-date-options > li.doodle-date-option:has(input:checked) { + background: #e7f1ff; + border-color: #86b7fe; +} + +.doodle-card ul.doodle-date-options > li.doodle-date-option input.doodle-date-checkbox { + flex: 0 0 1.125rem; + width: 1.125rem; + height: 1.125rem; + margin: 0 !important; + position: static; +} + +.doodle-card ul.doodle-date-options > li.doodle-date-option label.doodle-date-label { + flex: 1; + margin: 0; + padding: 0; + font-weight: normal; + line-height: 1.4; + cursor: pointer; +} + +.doodle-results-table { + width: 100%; + margin: 0; +} + +.doodle-results-table th, +.doodle-results-table td { + padding: 0.75rem; + vertical-align: middle; +} + +.doodle-results-table tr.doodle-best { + background: #d1e7dd; +} + +.doodle-count { + display: inline-block; + min-width: 1.75rem; + padding: 0.15rem 0.5rem; + font-weight: 600; + text-align: center; + background: #e9ecef; + border-radius: 0.25rem; +} + +.doodle-results-table tr.doodle-best .doodle-count { + background: #198754; + color: #fff; +} + +.doodle-table-wrap { + overflow-x: auto; +} diff --git a/src/experimental/doodle/browser/views.py b/src/experimental/doodle/browser/views.py new file mode 100644 index 0000000..ca20b70 --- /dev/null +++ b/src/experimental/doodle/browser/views.py @@ -0,0 +1,91 @@ +from experimental.doodle import _ +from experimental.doodle.answers import build_results +from experimental.doodle.answers import get_answer_for_user +from experimental.doodle.answers import upsert_answer +from experimental.doodle.utils import can_view_results +from experimental.doodle.utils import format_date +from experimental.doodle.utils import format_date_long +from experimental.doodle.utils import get_member_info +from experimental.doodle.utils import parse_iso_dates +from experimental.doodle.utils import require_authenticated +from Products.Five import BrowserView +from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile +from Products.statusmessages.interfaces import IStatusMessage +from zExceptions import Unauthorized + + +class AnswerView(BrowserView): + """Answer form: pick dates that work for you.""" + + index = ViewPageTemplateFile("doodle_answer.pt") + + def __call__(self): + self.update() + return self.index() + + def update(self): + require_authenticated(self.request) + self.userid, self.display_name = get_member_info() + self.candidate_dates = list(self.context.candidate_dates or []) + self.existing = get_answer_for_user(self.context, self.userid) + self.selected = ( + set(self.existing.get("selected_dates", [])) if self.existing else set() + ) + self.is_creator = can_view_results(self.context) + self.results_url = f"{self.context.absolute_url()}/@@results" + + if self.request.method != "POST": + return + if not self.request.form.get("form.submitted"): + return + + selected_dates = parse_iso_dates(self.request.form.get("selected_dates")) + valid = {candidate.isoformat() for candidate in self.candidate_dates} + if not selected_dates: + IStatusMessage(self.request).add( + _("Please select at least one date."), + type="error", + ) + return + if not all(value.isoformat() in valid for value in selected_dates): + IStatusMessage(self.request).add( + _("Invalid date selection."), + type="error", + ) + return + + upsert_answer( + self.context, + self.userid, + self.display_name, + selected_dates, + ) + IStatusMessage(self.request).add(_("Your answer was saved.")) + self.selected = {value.isoformat() for value in selected_dates} + self.existing = get_answer_for_user(self.context, self.userid) + + def format_date(self, value): + return format_date(value) + + def format_date_long(self, value): + return format_date_long(value) + + +class ResultsView(BrowserView): + """Creator-only summary of all answers.""" + + index = ViewPageTemplateFile("doodle_results.pt") + + def __call__(self): + self.update() + return self.index() + + def update(self): + require_authenticated(self.request) + if not can_view_results(self.context): + raise Unauthorized() + self.rows = build_results(self.context) + self.answer_url = self.context.absolute_url() + + def format_date(self, value): + return format_date(value) diff --git a/src/experimental/doodle/configure.zcml b/src/experimental/doodle/configure.zcml index fd737a5..514a1be 100644 --- a/src/experimental/doodle/configure.zcml +++ b/src/experimental/doodle/configure.zcml @@ -1,9 +1,12 @@ + + + diff --git a/src/experimental/doodle/content/candidate_dates_input.pt b/src/experimental/doodle/content/candidate_dates_input.pt new file mode 100644 index 0000000..0b12509 --- /dev/null +++ b/src/experimental/doodle/content/candidate_dates_input.pt @@ -0,0 +1,45 @@ + +
+ + + + + + + + + + + + + +
+ + +
+
+
+
+ +
+
+ + diff --git a/src/experimental/doodle/content/configure.zcml b/src/experimental/doodle/content/configure.zcml new file mode 100644 index 0000000..42bb5e8 --- /dev/null +++ b/src/experimental/doodle/content/configure.zcml @@ -0,0 +1,5 @@ + + diff --git a/src/experimental/doodle/content/doodle.py b/src/experimental/doodle/content/doodle.py new file mode 100644 index 0000000..509621c --- /dev/null +++ b/src/experimental/doodle/content/doodle.py @@ -0,0 +1,10 @@ +from experimental.doodle.content.interfaces import IDoodle +from plone.dexterity.content import Container +from zope.interface import implementer + + +@implementer(IDoodle) +class Doodle(Container): + """A date poll for scheduling meetings.""" + + portal_type = "Doodle" diff --git a/src/experimental/doodle/content/interfaces.py b/src/experimental/doodle/content/interfaces.py new file mode 100644 index 0000000..8ffc7ec --- /dev/null +++ b/src/experimental/doodle/content/interfaces.py @@ -0,0 +1,21 @@ +from experimental.doodle import _ +from experimental.doodle.content.widgets import CandidateDatesWidget +from plone.autoform import directives +from plone.supermodel import model +from zope import schema + + +class IDoodle(model.Schema): + """Scheduling poll with date-only options.""" + + directives.widget("candidate_dates", CandidateDatesWidget) + candidate_dates = schema.List( + title=_("Candidate dates"), + description=_( + "Dates participants can choose from. " + "Check rows to remove, click Remove selected, then Save." + ), + value_type=schema.Date(), + required=True, + min_length=1, + ) diff --git a/src/experimental/doodle/content/widgets.py b/src/experimental/doodle/content/widgets.py new file mode 100644 index 0000000..9656f41 --- /dev/null +++ b/src/experimental/doodle/content/widgets.py @@ -0,0 +1,10 @@ +from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile +from z3c.form.browser.multi import MultiWidget + + +class CandidateDatesWidget(MultiWidget): + """Default multi widget with a table layout for checkbox + date columns.""" + + template = ViewPageTemplateFile("candidate_dates_input.pt") + showLabel = False + klass = "multi-widget candidate-dates-table-widget" diff --git a/src/experimental/doodle/dependencies.zcml b/src/experimental/doodle/dependencies.zcml index 75ca76a..504d278 100644 --- a/src/experimental/doodle/dependencies.zcml +++ b/src/experimental/doodle/dependencies.zcml @@ -1,5 +1,7 @@ - + + + diff --git a/src/experimental/doodle/profiles/default/metadata.xml b/src/experimental/doodle/profiles/default/metadata.xml index b0e12b9..455626f 100644 --- a/src/experimental/doodle/profiles/default/metadata.xml +++ b/src/experimental/doodle/profiles/default/metadata.xml @@ -2,5 +2,6 @@ 1000 + profile-plone.app.contenttypes:default diff --git a/src/experimental/doodle/profiles/default/registry/main.xml b/src/experimental/doodle/profiles/default/registry/main.xml index eae378c..cb8cbfd 100644 --- a/src/experimental/doodle/profiles/default/registry/main.xml +++ b/src/experimental/doodle/profiles/default/registry/main.xml @@ -3,6 +3,12 @@ i18n:domain="experimental.doodle" > - + + True + ++resource++experimental.doodle/doodle.css + logged-in + diff --git a/src/experimental/doodle/profiles/default/types.xml b/src/experimental/doodle/profiles/default/types.xml index bed2b0d..f295efc 100644 --- a/src/experimental/doodle/profiles/default/types.xml +++ b/src/experimental/doodle/profiles/default/types.xml @@ -1,10 +1,8 @@ - + name="Doodle" + /> diff --git a/src/experimental/doodle/profiles/default/types/Doodle.xml b/src/experimental/doodle/profiles/default/types/Doodle.xml new file mode 100644 index 0000000..249db5c --- /dev/null +++ b/src/experimental/doodle/profiles/default/types/Doodle.xml @@ -0,0 +1,32 @@ + + + Doodle + Poll participants on meeting dates. + doodle + experimental.doodle.content.interfaces.IDoodle + experimental.doodle.content.doodle.Doodle + string:${folder_url}/++add++Doodle + string:contenttype/event + True + + True + False + view + True + + + + + + + + + diff --git a/src/experimental/doodle/utils.py b/src/experimental/doodle/utils.py new file mode 100644 index 0000000..8ea04cd --- /dev/null +++ b/src/experimental/doodle/utils.py @@ -0,0 +1,63 @@ +from datetime import date + +from AccessControl import Unauthorized +from plone import api + + +def require_authenticated(request): + """Raise Unauthorized for anonymous users.""" + if api.user.is_anonymous(): + raise Unauthorized() + + +def get_member_info(): + """Return userid and display name for the current member.""" + member = api.user.get_current() + if member is None: + return None, None + userid = member.getId() + return userid, member.getProperty("fullname") or userid + + +def can_view_results(context): + """True if the current user may open the results view.""" + if api.user.is_anonymous(): + return False + member = api.user.get_current() + if member is None: + return False + if member.has_role("Manager"): + return True + creator = getattr(context, "Creator", lambda: None)() + return creator == member.getId() + + +def parse_iso_dates(values): + """Parse and deduplicate ISO date strings from form input.""" + if not values: + return [] + if isinstance(values, str): + values = [values] + parsed = [] + seen = set() + for value in values: + value = value.strip() + if not value or value in seen: + continue + seen.add(value) + parsed.append(date.fromisoformat(value)) + return parsed + + +def format_date(value): + """Format a date for display.""" + if isinstance(value, str): + value = date.fromisoformat(value) + return value.strftime("%B %d, %Y") + + +def format_date_long(value): + """Format a date with weekday for answer form labels.""" + if isinstance(value, str): + value = date.fromisoformat(value) + return value.strftime("%B %d, %Y · %A") diff --git a/tests/test_doodle.py b/tests/test_doodle.py new file mode 100644 index 0000000..3a8c835 --- /dev/null +++ b/tests/test_doodle.py @@ -0,0 +1,117 @@ +from datetime import date + +import pytest +from experimental.doodle.answers import build_results +from experimental.doodle.answers import get_answer_for_user +from experimental.doodle.answers import upsert_answer +from experimental.doodle.browser.views import AnswerView +from experimental.doodle.browser.views import ResultsView +from experimental.doodle.interfaces import IBrowserLayer +from plone import api +from plone.app.testing import SITE_OWNER_NAME +from plone.app.testing import TEST_USER_ID +from plone.app.testing import TEST_USER_NAME +from plone.app.testing import login +from plone.app.testing import logout +from zExceptions import Unauthorized +from zope.interface import alsoProvides + + +CANDIDATE_DATES = [date(2026, 5, 20), date(2026, 5, 22)] + + +def _prepare_request(http_request): + alsoProvides(http_request, IBrowserLayer) + return http_request + + +def _create_doodle(portal, title="Team sync", dates=None): + dates = dates or CANDIDATE_DATES + with api.env.adopt_user(SITE_OWNER_NAME): + obj = api.content.create( + container=portal, + type="Doodle", + title=title, + candidate_dates=dates, + ) + workflow = api.portal.get_tool("portal_workflow") + if workflow.getWorkflowsFor(obj): + api.content.transition(obj, "publish") + return obj + + +class TestDoodleContent: + def test_create_doodle(self, portal): + obj = _create_doodle(portal) + assert obj.portal_type == "Doodle" + assert obj.Title() == "Team sync" + assert [value.isoformat() for value in obj.candidate_dates] == [ + "2026-05-20", + "2026-05-22", + ] + + def test_upsert_answer(self, portal): + obj = _create_doodle(portal) + upsert_answer(obj, "member1", "Member One", [date(2026, 5, 20)]) + upsert_answer( + obj, + "member2", + "Member Two", + [date(2026, 5, 20), date(2026, 5, 22)], + ) + upsert_answer(obj, "member1", "Member One", [date(2026, 5, 22)]) + + answer = get_answer_for_user(obj, "member1") + assert answer["selected_dates"] == ["2026-05-22"] + + rows = build_results(obj) + assert rows[0]["count"] == 1 + assert rows[0]["names"] == ["Member Two"] + assert rows[1]["count"] == 2 + + +class TestDoodleViews: + def test_anonymous_cannot_view_answer(self, portal, http_request): + obj = _create_doodle(portal) + logout() + request = _prepare_request(http_request) + view = AnswerView(obj, request) + with pytest.raises(Unauthorized): + view.update() + + def test_member_can_answer(self, portal, http_request): + obj = _create_doodle(portal) + login(portal, TEST_USER_NAME) + request = _prepare_request(http_request) + request.method = "GET" + + html = AnswerView(obj, request)() + assert "Which dates work for you?" in html + + request.method = "POST" + request.form["form.submitted"] = "1" + request.form["selected_dates"] = ["2026-05-20"] + html = AnswerView(obj, request)() + assert "Your answer was saved." in html + + answer = get_answer_for_user(obj, TEST_USER_ID) + assert answer["selected_dates"] == ["2026-05-20"] + + def test_creator_can_view_results(self, portal, http_request): + obj = _create_doodle(portal) + upsert_answer(obj, "member", "Member", [date(2026, 5, 20)]) + request = _prepare_request(http_request) + + with api.env.adopt_user(SITE_OWNER_NAME): + html = ResultsView(obj, request)() + + assert "Who can make it" in html + assert "Member" in html + + def test_non_creator_cannot_view_results(self, portal, http_request): + obj = _create_doodle(portal) + login(portal, TEST_USER_NAME) + request = _prepare_request(http_request) + view = ResultsView(obj, request) + with pytest.raises(Unauthorized): + view.update() From 5903dd9a73ebc17d7f4a2dc0056cc1548921fcd1 Mon Sep 17 00:00:00 2001 From: Ciro Silvano Date: Wed, 20 May 2026 11:21:40 +0200 Subject: [PATCH 2/7] Adding user count / user total display on answers section --- src/experimental/doodle/browser/doodle_results.pt | 4 ++-- src/experimental/doodle/browser/static/doodle.css | 3 ++- src/experimental/doodle/browser/views.py | 2 ++ 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/experimental/doodle/browser/doodle_results.pt b/src/experimental/doodle/browser/doodle_results.pt index 6918e14..95119f7 100644 --- a/src/experimental/doodle/browser/doodle_results.pt +++ b/src/experimental/doodle/browser/doodle_results.pt @@ -45,8 +45,8 @@ May 20, 2026 0 + tal:content="python:'%d / %d' % (row['count'], view.total_users)" + >0 / 0 - diff --git a/src/experimental/doodle/browser/static/doodle.css b/src/experimental/doodle/browser/static/doodle.css index 13a5f86..9d7b708 100644 --- a/src/experimental/doodle/browser/static/doodle.css +++ b/src/experimental/doodle/browser/static/doodle.css @@ -329,12 +329,13 @@ .doodle-count { display: inline-block; - min-width: 1.75rem; + min-width: 3.25rem; padding: 0.15rem 0.5rem; font-weight: 600; text-align: center; background: #e9ecef; border-radius: 0.25rem; + white-space: nowrap; } .doodle-results-table tr.doodle-best .doodle-count { diff --git a/src/experimental/doodle/browser/views.py b/src/experimental/doodle/browser/views.py index ca20b70..d934b06 100644 --- a/src/experimental/doodle/browser/views.py +++ b/src/experimental/doodle/browser/views.py @@ -1,6 +1,7 @@ from experimental.doodle import _ from experimental.doodle.answers import build_results from experimental.doodle.answers import get_answer_for_user +from experimental.doodle.answers import get_answers from experimental.doodle.answers import upsert_answer from experimental.doodle.utils import can_view_results from experimental.doodle.utils import format_date @@ -85,6 +86,7 @@ def update(self): if not can_view_results(self.context): raise Unauthorized() self.rows = build_results(self.context) + self.total_users = len(get_answers(self.context)) self.answer_url = self.context.absolute_url() def format_date(self, value): From edea36cd1f6b0cb85f02fc16a20199bee5038557 Mon Sep 17 00:00:00 2001 From: Ciro Silvano Date: Wed, 20 May 2026 12:05:09 +0200 Subject: [PATCH 3/7] Apply lint --- .../doodle/browser/configure.zcml | 4 +- .../doodle/browser/doodle_answer.pt | 85 +++++++++++------- .../doodle/browser/doodle_results.pt | 30 ++++--- .../doodle/content/candidate_dates_input.pt | 88 ++++++++++--------- .../doodle/profiles/default/registry/main.xml | 2 +- .../doodle/profiles/default/types.xml | 4 +- .../doodle/profiles/default/types/Doodle.xml | 10 +-- 7 files changed, 129 insertions(+), 94 deletions(-) diff --git a/src/experimental/doodle/browser/configure.zcml b/src/experimental/doodle/browser/configure.zcml index bf69cab..64b8b23 100644 --- a/src/experimental/doodle/browser/configure.zcml +++ b/src/experimental/doodle/browser/configure.zcml @@ -21,16 +21,16 @@ /> + i18n:domain="experimental.doodle" +> + tal:attributes=" + href string:${context/portal_url}/++resource++experimental.doodle/doodle.css; + " + />

Doodle title

-

+

Which dates work for you?

+ > View results + >View results
@@ -34,14 +40,20 @@
-

Share this link

- +

Share this link

+ -

+ type="text" + tal:attributes=" + value context/absolute_url; + " + /> +

Remember to publish this doodle before sharing, or members will not be able to view it.

@@ -53,39 +65,46 @@

-
- - + > + +
Available dates
  • - + + type="checkbox" + tal:attributes=" + id python:'doodle-date-' + candidate.isoformat(); + value candidate/isoformat; + checked python:candidate.isoformat() in view.selected; + " + /> + tal:attributes=" + for python:'doodle-date-' + candidate.isoformat(); + " + >May 20, 2026 · Tuesday
diff --git a/src/experimental/doodle/browser/doodle_results.pt b/src/experimental/doodle/browser/doodle_results.pt index 95119f7..78a1302 100644 --- a/src/experimental/doodle/browser/doodle_results.pt +++ b/src/experimental/doodle/browser/doodle_results.pt @@ -1,30 +1,36 @@ + i18n:domain="experimental.doodle" +> + tal:attributes=" + href string:${context/portal_url}/++resource++experimental.doodle/doodle.css; + " + />

Doodle title

-

+

Pick the best date to meet.

Back to answer form + >Back to answer form
@@ -40,13 +46,15 @@ + tal:attributes=" + class python:'doodle-best' if row['count'] == max((r['count'] for r in view.rows), default=0) and row['count'] > 0 else ''; + " + > May 20, 2026 0 / 0 + >0 / 0 - diff --git a/src/experimental/doodle/content/candidate_dates_input.pt b/src/experimental/doodle/content/candidate_dates_input.pt index 0b12509..acf63cc 100644 --- a/src/experimental/doodle/content/candidate_dates_input.pt +++ b/src/experimental/doodle/content/candidate_dates_input.pt @@ -1,45 +1,53 @@ -
- - - - - - - - - - - - - -
- - -
-
-
-
- +> +
+ + + + + + + + + + + + + +
+ + +
+
+
+
+ +
-
- + diff --git a/src/experimental/doodle/profiles/default/registry/main.xml b/src/experimental/doodle/profiles/default/registry/main.xml index cb8cbfd..c8b71d1 100644 --- a/src/experimental/doodle/profiles/default/registry/main.xml +++ b/src/experimental/doodle/profiles/default/registry/main.xml @@ -5,7 +5,7 @@ + > True ++resource++experimental.doodle/doodle.css logged-in diff --git a/src/experimental/doodle/profiles/default/types.xml b/src/experimental/doodle/profiles/default/types.xml index f295efc..5819219 100644 --- a/src/experimental/doodle/profiles/default/types.xml +++ b/src/experimental/doodle/profiles/default/types.xml @@ -1,8 +1,8 @@ +> + /> diff --git a/src/experimental/doodle/profiles/default/types/Doodle.xml b/src/experimental/doodle/profiles/default/types/Doodle.xml index 249db5c..6b11cd1 100644 --- a/src/experimental/doodle/profiles/default/types/Doodle.xml +++ b/src/experimental/doodle/profiles/default/types/Doodle.xml @@ -1,15 +1,15 @@ - +> Doodle + >Doodle Poll participants on meeting dates. + >Poll participants on meeting dates. doodle experimental.doodle.content.interfaces.IDoodle experimental.doodle.content.doodle.Doodle From ec54c550b6ceaf80288ac37a9c2e636bd1acf7f7 Mon Sep 17 00:00:00 2001 From: Ciro Silvano Date: Wed, 20 May 2026 12:06:18 +0200 Subject: [PATCH 4/7] Add news fragment --- news/+b997801d.feature.md | 1 + src/experimental/doodle/answers.py | 36 +++++++++++++----------------- 2 files changed, 16 insertions(+), 21 deletions(-) create mode 100644 news/+b997801d.feature.md diff --git a/news/+b997801d.feature.md b/news/+b997801d.feature.md new file mode 100644 index 0000000..bb80891 --- /dev/null +++ b/news/+b997801d.feature.md @@ -0,0 +1 @@ +Add the Doodles functionality diff --git a/src/experimental/doodle/answers.py b/src/experimental/doodle/answers.py index 5e18eaf..60f2337 100644 --- a/src/experimental/doodle/answers.py +++ b/src/experimental/doodle/answers.py @@ -21,12 +21,10 @@ def get_answer_for_user(context, userid): def upsert_answer(context, userid, display_name, selected_dates): """Create or replace a user's answer.""" - normalized = sorted( - { - value.isoformat() if isinstance(value, date) else value - for value in selected_dates - } - ) + normalized = sorted({ + value.isoformat() if isinstance(value, date) else value + for value in selected_dates + }) annotations = IAnnotations(context) answers = list(annotations.get(ANSWERS_KEY, [])) for entry in answers: @@ -35,13 +33,11 @@ def upsert_answer(context, userid, display_name, selected_dates): entry["selected_dates"] = normalized annotations[ANSWERS_KEY] = answers return - answers.append( - { - "userid": userid, - "display_name": display_name, - "selected_dates": normalized, - } - ) + answers.append({ + "userid": userid, + "display_name": display_name, + "selected_dates": normalized, + }) annotations[ANSWERS_KEY] = answers @@ -62,12 +58,10 @@ def build_results(context): for candidate in candidate_dates: iso = candidate.isoformat() info = by_date[iso] - rows.append( - { - "date": candidate, - "iso": iso, - "count": info["count"], - "names": info["names"], - } - ) + rows.append({ + "date": candidate, + "iso": iso, + "count": info["count"], + "names": info["names"], + }) return rows From 99ded5b1d9b464d1c0d423d2345c68f93bd4b880 Mon Sep 17 00:00:00 2001 From: Ciro Silvano Date: Wed, 20 May 2026 12:48:26 +0200 Subject: [PATCH 5/7] Make results viewable by users if allowed by creator --- .../doodle/browser/doodle_answer.pt | 2 +- src/experimental/doodle/browser/views.py | 6 +++-- src/experimental/doodle/content/interfaces.py | 10 ++++++++ src/experimental/doodle/utils.py | 17 +++++++++++++ tests/test_doodle.py | 24 +++++++++++++++++++ 5 files changed, 56 insertions(+), 3 deletions(-) diff --git a/src/experimental/doodle/browser/doodle_answer.pt b/src/experimental/doodle/browser/doodle_answer.pt index 6d98f51..c983afa 100644 --- a/src/experimental/doodle/browser/doodle_answer.pt +++ b/src/experimental/doodle/browser/doodle_answer.pt @@ -25,7 +25,7 @@

Date: Wed, 20 May 2026 13:02:15 +0200 Subject: [PATCH 6/7] Make users propose dates and change sorting of table --- src/experimental/doodle/answers.py | 1 + .../doodle/browser/doodle_answer.pt | 32 +++++++ .../doodle/browser/static/doodle.css | 44 +++++++++ .../doodle/browser/static/doodle.js | 44 +++++++++ src/experimental/doodle/browser/views.py | 19 ++++ src/experimental/doodle/content/interfaces.py | 10 ++ src/experimental/doodle/utils.py | 16 ++++ tests/test_doodle.py | 95 ++++++++++++++++++- 8 files changed, 258 insertions(+), 3 deletions(-) create mode 100644 src/experimental/doodle/browser/static/doodle.js diff --git a/src/experimental/doodle/answers.py b/src/experimental/doodle/answers.py index 60f2337..7ea18be 100644 --- a/src/experimental/doodle/answers.py +++ b/src/experimental/doodle/answers.py @@ -64,4 +64,5 @@ def build_results(context): "count": info["count"], "names": info["names"], }) + rows.sort(key=lambda row: (-row["count"], row["iso"])) return rows diff --git a/src/experimental/doodle/browser/doodle_answer.pt b/src/experimental/doodle/browser/doodle_answer.pt index c983afa..98185c0 100644 --- a/src/experimental/doodle/browser/doodle_answer.pt +++ b/src/experimental/doodle/browser/doodle_answer.pt @@ -100,6 +100,38 @@ +
+ Propose new dates +

+ Add dates that work for you. They become options for everyone. +

+
    + + + +
    +