Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions news/+b997801d.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add the Doodles functionality
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ classifiers = [
dependencies = [
"Products.CMFPlone",
"plone.api",
"plone.app.dexterity",
"plone.autoform",
"z3c.jbot",
]

Expand Down
68 changes: 68 additions & 0 deletions src/experimental/doodle/answers.py
Original file line number Diff line number Diff line change
@@ -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
24 changes: 19 additions & 5 deletions src/experimental/doodle/browser/configure.zcml
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
<configure
xmlns="http://namespaces.zope.org/zope"
xmlns:browser="http://namespaces.zope.org/browser"
xmlns:plone="http://namespaces.plone.org/plone"
i18n_domain="experimental.doodle"
>

Expand All @@ -15,11 +14,26 @@
layer="experimental.doodle.interfaces.IBrowserLayer"
/>

<!-- Publish static files -->
<plone:static
directory="static"
<!-- ++resource++ URLs use Five browser resources, not plone:static -->
<browser:resourceDirectory
name="experimental.doodle"
type="plone"
directory="static"
/>

<browser:page
name="view"
for="experimental.doodle.content.interfaces.IDoodle"
class=".views.AnswerView"
permission="zope2.View"
layer="experimental.doodle.interfaces.IBrowserLayer"
/>

<browser:page
name="results"
for="experimental.doodle.content.interfaces.IDoodle"
class=".views.ResultsView"
permission="zope2.View"
layer="experimental.doodle.interfaces.IBrowserLayer"
/>

</configure>
146 changes: 146 additions & 0 deletions src/experimental/doodle/browser/doodle_answer.pt
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:i18n="http://xml.zope.org/namespaces/i18n"
xmlns:metal="http://xml.zope.org/namespaces/metal"
xmlns:tal="http://xml.zope.org/namespaces/tal"
metal:use-macro="context/@@main_template/macros/master"
i18n:domain="experimental.doodle"
>

<body>
<metal:slot fill-slot="main">
<link rel="stylesheet"
tal:attributes="
href string:${context/portal_url}/++resource++experimental.doodle/doodle.css;
"
/>
<div class="doodle-card doodle-answer">
<header class="doodle-header">
<div class="doodle-header-top">
<div class="doodle-header-text">
<h1 tal:content="context/Title">Doodle title</h1>
<p class="doodle-subtitle"
i18n:translate=""
>
Which dates work for you?
</p>
</div>
<div class="doodle-header-actions"
tal:condition="view/can_view_results"
>
<a class="btn btn-secondary btn-sm"
tal:attributes="
href view/results_url;
"
i18n:translate=""
>View results</a>
</div>
</div>
</header>

<div class="doodle-body">
<div class="doodle-share-box"
tal:condition="view/is_creator"
>
<p class="doodle-share-label"
i18n:translate=""
>Share this link</p>
<input class="doodle-share-url"
readonly="readonly"
type="text"
tal:attributes="
value context/absolute_url;
"
/>
<p class="doodle-share-warning"
i18n:translate=""
>
Remember to publish this doodle before sharing, or members will not be able to view it.
</p>
</div>

<div class="doodle-meta">
<p class="doodle-meta-line">
<span i18n:translate="">Answering as</span>
<strong tal:content="view/display_name">Member</strong>
</p>
</div>

<form class="doodle-answer-form"
action=""
method="post"
>
<input name="form.submitted"
type="hidden"
value="1"
/>
<span tal:replace="structure context/@@authenticator/authenticator"></span>

<fieldset class="doodle-fieldset">
<legend i18n:translate="">Available dates</legend>
<ul class="doodle-date-options">
<li class="doodle-date-option"
tal:repeat="candidate view/candidate_dates"
>
<input class="doodle-date-checkbox"
name="selected_dates"
type="checkbox"
tal:attributes="
id python:'doodle-date-' + candidate.isoformat();
value candidate/isoformat;
checked python:candidate.isoformat() in view.selected;
"
/>
<label class="doodle-date-label"
tal:content="python:view.format_date_long(candidate)"
tal:attributes="
for python:'doodle-date-' + candidate.isoformat();
"
>May 20, 2026 &middot; Tuesday</label>
</li>
</ul>
</fieldset>

<fieldset class="doodle-propose-fieldset doodle-fieldset"
tal:condition="view/allow_propose_dates"
>
<legend i18n:translate="">Propose new dates</legend>
<p class="doodle-propose-hint"
i18n:translate=""
>
Add dates that work for you. They become options for everyone.
</p>
<ul class="doodle-propose-dates"></ul>
<template id="doodle-propose-date-template">
<li class="doodle-propose-row">
<input class="doodle-propose-date"
name="proposed_dates"
type="date"
/>
<button class="btn btn-outline-danger btn-sm doodle-propose-remove"
type="button"
i18n:translate=""
>Remove</button>
</li>
</template>
<button class="btn btn-secondary btn-sm doodle-propose-add"
id="doodle-propose-add"
type="button"
i18n:translate=""
>Add another date</button>
<script tal:attributes="
src string:${context/portal_url}/++resource++experimental.doodle/doodle.js;
"></script>
</fieldset>

<div class="doodle-form-footer">
<button class="btn btn-primary"
type="submit"
i18n:translate=""
>Save my answer</button>
</div>
</form>
</div>
</div>
</metal:slot>
</body>
</html>
67 changes: 67 additions & 0 deletions src/experimental/doodle/browser/doodle_results.pt
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:i18n="http://xml.zope.org/namespaces/i18n"
xmlns:metal="http://xml.zope.org/namespaces/metal"
xmlns:tal="http://xml.zope.org/namespaces/tal"
metal:use-macro="context/@@main_template/macros/master"
i18n:domain="experimental.doodle"
>

<body>
<metal:slot fill-slot="main">
<link rel="stylesheet"
tal:attributes="
href string:${context/portal_url}/++resource++experimental.doodle/doodle.css;
"
/>
<div class="doodle-card doodle-results">
<header class="doodle-header">
<div class="doodle-header-top">
<div class="doodle-header-text">
<h1 tal:content="context/Title">Doodle title</h1>
<p class="doodle-subtitle"
i18n:translate=""
>
Pick the best date to meet.
</p>
</div>
<div class="doodle-header-actions">
<a class="btn btn-secondary btn-sm"
tal:attributes="
href view/answer_url;
"
i18n:translate=""
>Back to answer form</a>
</div>
</div>
</header>

<div class="doodle-body doodle-table-wrap">
<table class="table table-striped doodle-results-table">
<thead>
<tr>
<th i18n:translate="">Date</th>
<th i18n:translate="">Count</th>
<th i18n:translate="">Who can make it</th>
</tr>
</thead>
<tbody>
<tr tal:repeat="row view/rows"
tal:attributes="
class python:'doodle-best' if row['count'] == max((r['count'] for r in view.rows), default=0) and row['count'] > 0 else '';
"
>
<td tal:content="python:view.format_date(row['date'])">May 20, 2026</td>
<td>
<span class="doodle-count"
tal:content="python:'%d / %d' % (row['count'], view.total_users)"
>0 / 0</span>
</td>
<td tal:content="python:', '.join(row['names']) if row['names'] else '-'">-</td>
</tr>
</tbody>
</table>
</div>
</div>
</metal:slot>
</body>
</html>
Loading
Loading