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 @@ + + + + + +
+
+
+
+

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 +
    +
  • + + +
  • +
+
+ +
+ Propose new dates +

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

+
    + + + +
    + + +
    +
    +
    +
    + + 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 @@ + + + + + +
    +
    +
    +
    +

    Doodle title

    +

    + Pick the best date to meet. +

    +
    + +
    +
    + +
    + + + + + + + + + + + + + + + +
    DateCountWho 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 @@ + + + Doodle + Poll participants on meeting dates. + doodle + experimental.doodle.content.interfaces.IDoodle + experimental.doodle.content.doodle.Doodle + string:${folder_url}/++add++Doodle + string:contenttype/doodle + 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..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