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/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..7ea18be
--- /dev/null
+++ b/src/experimental/doodle/answers.py
@@ -0,0 +1,68 @@
+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"],
+ })
+ rows.sort(key=lambda row: (-row["count"], row["iso"]))
+ return rows
diff --git a/src/experimental/doodle/browser/configure.zcml b/src/experimental/doodle/browser/configure.zcml
index 6bf678a..64b8b23 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..98185c0
--- /dev/null
+++ b/src/experimental/doodle/browser/doodle_answer.pt
@@ -0,0 +1,146 @@
+
+
+
+
+
+
+
+
+
+
+
Share this link
+
+
+ Remember to publish this doodle before sharing, or members will not be able to view it.
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/experimental/doodle/browser/doodle_results.pt b/src/experimental/doodle/browser/doodle_results.pt
new file mode 100644
index 0000000..78a1302
--- /dev/null
+++ b/src/experimental/doodle/browser/doodle_results.pt
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ | Date |
+ Count |
+ Who can make it |
+
+
+
+
+ | May 20, 2026 |
+
+ 0 / 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..7cb2161
--- /dev/null
+++ b/src/experimental/doodle/browser/static/doodle.css
@@ -0,0 +1,392 @@
+/* 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-propose-fieldset {
+ margin-top: var(--doodle-space-lg);
+ padding-top: var(--doodle-space-lg);
+ border-top: 1px solid #eee;
+}
+
+.doodle-propose-hint {
+ margin: 0 0 var(--doodle-space-md);
+ color: #6c757d;
+ font-size: 0.9375rem;
+}
+
+.doodle-propose-dates {
+ display: flex;
+ flex-direction: column;
+ gap: var(--doodle-space-sm);
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.doodle-propose-dates > li {
+ margin: 0;
+ padding: 0;
+}
+
+.doodle-propose-row {
+ display: flex;
+ align-items: center;
+ gap: var(--doodle-space-sm);
+}
+
+.doodle-propose-date {
+ width: 100%;
+ max-width: 16rem;
+ padding: 0.5rem 0.75rem;
+ border: 1px solid #dee2e6;
+ border-radius: 0.25rem;
+}
+
+.doodle-propose-add {
+ margin-top: var(--doodle-space-md);
+}
+
+.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: 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 {
+ background: #198754;
+ color: #fff;
+}
+
+.doodle-table-wrap {
+ overflow-x: auto;
+}
diff --git a/src/experimental/doodle/browser/static/doodle.js b/src/experimental/doodle/browser/static/doodle.js
new file mode 100644
index 0000000..2a57f82
--- /dev/null
+++ b/src/experimental/doodle/browser/static/doodle.js
@@ -0,0 +1,44 @@
+(function () {
+ "use strict";
+
+ function initProposeDates() {
+ var addButton = document.getElementById("doodle-propose-add");
+ var list = document.querySelector(".doodle-propose-dates");
+ var template = document.getElementById("doodle-propose-date-template");
+ if (!addButton || !list || !template) {
+ return;
+ }
+
+ addButton.addEventListener("click", function () {
+ list.appendChild(template.content.cloneNode(true));
+ });
+
+ list.addEventListener("change", function (event) {
+ var input = event.target.closest(".doodle-propose-date");
+ if (!input || !input.value) {
+ return;
+ }
+ var checkbox = document.getElementById("doodle-date-" + input.value);
+ if (checkbox) {
+ checkbox.checked = true;
+ }
+ });
+
+ list.addEventListener("click", function (event) {
+ var removeButton = event.target.closest(".doodle-propose-remove");
+ if (!removeButton || !list.contains(removeButton)) {
+ return;
+ }
+ var row = removeButton.closest("li");
+ if (row) {
+ row.remove();
+ }
+ });
+ }
+
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", initProposeDates);
+ } else {
+ initProposeDates();
+ }
+})();
diff --git a/src/experimental/doodle/browser/views.py b/src/experimental/doodle/browser/views.py
new file mode 100644
index 0000000..9625fc2
--- /dev/null
+++ b/src/experimental/doodle/browser/views.py
@@ -0,0 +1,114 @@
+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
+from experimental.doodle.utils import format_date_long
+from experimental.doodle.utils import get_member_info
+from experimental.doodle.utils import is_doodle_manager
+from experimental.doodle.utils import merge_proposed_dates
+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 = is_doodle_manager(self.context)
+ self.can_view_results = can_view_results(self.context)
+ self.allow_propose_dates = getattr(
+ self.context, "allow_members_propose_dates", False
+ )
+ self.results_url = f"{self.context.absolute_url()}/@@results"
+
+ if self.request.method != "POST":
+ return
+ if not self.request.form.get("form.submitted"):
+ return
+
+ proposed_dates = (
+ parse_iso_dates(self.request.form.get("proposed_dates"))
+ if self.allow_propose_dates
+ else []
+ )
+ selected_dates = parse_iso_dates(self.request.form.get("selected_dates"))
+ if proposed_dates:
+ merge_proposed_dates(self.context, proposed_dates)
+ self.candidate_dates = list(self.context.candidate_dates or [])
+ selected_isos = {value.isoformat() for value in selected_dates}
+ for value in proposed_dates:
+ iso = value.isoformat()
+ if iso not in selected_isos:
+ selected_dates.append(value)
+ selected_isos.add(iso)
+
+ 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):
+ """Summary of all answers for users allowed to view results."""
+
+ 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.total_users = len(get_answers(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..acf63cc
--- /dev/null
+++ b/src/experimental/doodle/content/candidate_dates_input.pt
@@ -0,0 +1,53 @@
+
+
+
+
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..dfd9bfe
--- /dev/null
+++ b/src/experimental/doodle/content/interfaces.py
@@ -0,0 +1,41 @@
+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,
+ )
+
+ allow_members_view_results = schema.Bool(
+ title=_("Allow participants to view results"),
+ description=_(
+ "When enabled, any logged-in member who can view this doodle "
+ "may also open the results page."
+ ),
+ required=False,
+ default=False,
+ )
+
+ allow_members_propose_dates = schema.Bool(
+ title=_("Allow participants to propose dates"),
+ description=_(
+ "When enabled, members can suggest additional dates on the answer page. "
+ "New dates are added for everyone."
+ ),
+ required=False,
+ default=False,
+ )
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/icons_contenttype.xml b/src/experimental/doodle/profiles/default/registry/icons_contenttype.xml
new file mode 100644
index 0000000..ded7d36
--- /dev/null
+++ b/src/experimental/doodle/profiles/default/registry/icons_contenttype.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+ Doodle content type icon
+
+ ++plone++bootstrap-icons/list-check.svg
+
+
+
diff --git a/src/experimental/doodle/profiles/default/registry/main.xml b/src/experimental/doodle/profiles/default/registry/main.xml
index eae378c..c8b71d1 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..5819219 100644
--- a/src/experimental/doodle/profiles/default/types.xml
+++ b/src/experimental/doodle/profiles/default/types.xml
@@ -2,9 +2,7 @@
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..2f0d6f5
--- /dev/null
+++ b/src/experimental/doodle/profiles/default/types/Doodle.xml
@@ -0,0 +1,32 @@
+
+
diff --git a/src/experimental/doodle/utils.py b/src/experimental/doodle/utils.py
new file mode 100644
index 0000000..e8583f8
--- /dev/null
+++ b/src/experimental/doodle/utils.py
@@ -0,0 +1,96 @@
+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)()
+ if creator == member.getId():
+ return True
+ if getattr(context, "allow_members_view_results", False):
+ return True
+ return False
+
+
+def is_doodle_manager(context):
+ """True if the current user may manage doodle settings (creator UI)."""
+ 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 merge_proposed_dates(context, new_dates):
+ """Add proposed dates to candidate_dates and return the merged list."""
+ if not new_dates:
+ return list(context.candidate_dates or [])
+ existing = {
+ value.isoformat() if isinstance(value, date) else value
+ for value in (context.candidate_dates or [])
+ }
+ for value in new_dates:
+ existing.add(value.isoformat() if isinstance(value, date) else value)
+ merged = [date.fromisoformat(iso) for iso in sorted(existing)]
+ with api.env.adopt_roles(["Manager"]):
+ context.candidate_dates = merged
+ return merged
+
+
+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..2c17fd7
--- /dev/null
+++ b/tests/test_doodle.py
@@ -0,0 +1,230 @@
+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"] == 2
+ assert rows[0]["names"] == ["Member One", "Member Two"]
+ assert rows[1]["count"] == 1
+ assert rows[1]["names"] == ["Member Two"]
+
+
+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()
+
+ def test_member_can_view_results_when_allowed(self, portal, http_request):
+ obj = _create_doodle(portal)
+ obj.allow_members_view_results = True
+ upsert_answer(obj, "member", "Member", [date(2026, 5, 20)])
+ login(portal, TEST_USER_NAME)
+ request = _prepare_request(http_request)
+
+ html = ResultsView(obj, request)()
+
+ assert "Who can make it" in html
+ assert "Member" in html
+
+ def test_member_sees_results_link_when_allowed(self, portal, http_request):
+ obj = _create_doodle(portal)
+ obj.allow_members_view_results = True
+ login(portal, TEST_USER_NAME)
+ request = _prepare_request(http_request)
+ request.method = "GET"
+
+ html = AnswerView(obj, request)()
+
+ assert "View results" in html
+ assert "Share this link" not in html
+
+ def test_proposed_dates_ignored_when_disabled(self, portal, http_request):
+ obj = _create_doodle(portal)
+ login(portal, TEST_USER_NAME)
+ request = _prepare_request(http_request)
+ request.method = "POST"
+ request.form["form.submitted"] = "1"
+ request.form["proposed_dates"] = ["2026-05-25"]
+ request.form["selected_dates"] = ["2026-05-20"]
+
+ AnswerView(obj, request)()
+
+ assert [value.isoformat() for value in obj.candidate_dates] == [
+ "2026-05-20",
+ "2026-05-22",
+ ]
+ answer = get_answer_for_user(obj, TEST_USER_ID)
+ assert answer["selected_dates"] == ["2026-05-20"]
+
+ def test_member_can_propose_dates_when_allowed(self, portal, http_request):
+ obj = _create_doodle(portal)
+ obj.allow_members_propose_dates = True
+ login(portal, TEST_USER_NAME)
+ request = _prepare_request(http_request)
+ request.method = "POST"
+ request.form["form.submitted"] = "1"
+ request.form["proposed_dates"] = ["2026-05-25", "2026-05-26"]
+ request.form["selected_dates"] = ["2026-05-20"]
+
+ AnswerView(obj, request)()
+
+ assert [value.isoformat() for value in obj.candidate_dates] == [
+ "2026-05-20",
+ "2026-05-22",
+ "2026-05-25",
+ "2026-05-26",
+ ]
+ answer = get_answer_for_user(obj, TEST_USER_ID)
+ assert answer["selected_dates"] == [
+ "2026-05-20",
+ "2026-05-25",
+ "2026-05-26",
+ ]
+
+ def test_proposing_existing_date_selects_it(self, portal, http_request):
+ obj = _create_doodle(portal)
+ obj.allow_members_propose_dates = True
+ login(portal, TEST_USER_NAME)
+ request = _prepare_request(http_request)
+ request.method = "POST"
+ request.form["form.submitted"] = "1"
+ request.form["proposed_dates"] = ["2026-05-22"]
+ request.form["selected_dates"] = []
+
+ AnswerView(obj, request)()
+
+ assert [value.isoformat() for value in obj.candidate_dates] == [
+ "2026-05-20",
+ "2026-05-22",
+ ]
+ answer = get_answer_for_user(obj, TEST_USER_ID)
+ assert answer["selected_dates"] == ["2026-05-22"]
+
+ def test_propose_section_hidden_when_disabled(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 "Propose new dates" not in html
+
+ def test_propose_section_visible_when_enabled(self, portal, http_request):
+ obj = _create_doodle(portal)
+ obj.allow_members_propose_dates = True
+ login(portal, TEST_USER_NAME)
+ request = _prepare_request(http_request)
+ request.method = "GET"
+
+ html = AnswerView(obj, request)()
+
+ assert "Propose new dates" in html
+ assert 'name="proposed_dates"' in html
+ assert "doodle-propose-date-template" in html
+ assert "doodle-propose-remove" in html
+ assert "Add another date" in html
+ assert "doodle.js" in html