Skip to content
Open
69 changes: 69 additions & 0 deletions docs/concepts/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# MVP Plan: Doodle Addon for Plone

A minimal Doodle-like addon for Plone with folderish and date content types, permissions, and a Bootstrap-styled doodle view with participant selection toggle.

---

## Completed MVP (All Tests Passing ✅)

### Content Types
- ✅ `experimental.doodle.folder` (folderish container type)
- ✅ `experimental.doodle.date` (date item with participants set field)

### Browser View & Interaction
- ✅ Doodle view renders sorted dates in a responsive table
- ✅ Participants displayed as Bootstrap badge pills
- ✅ Toggle button for current user participation (POST-based with CSRF protection)
- ✅ Participation redirect back to referrer or doodle view

### UI/Styling
- ✅ Bootstrap-compatible markup (card, table-striped, badge, btn-primary)
- ✅ Responsive layout with table-responsive wrapper
- ✅ Minimal custom CSS (badge flex-wrap, button nowrap)
- ✅ Bundle registration via csscompilation field (Plone 6.x pattern)

### Permissions & Access Control
- ✅ Custom add permissions for both types
- ✅ Browser layer registration active
- ✅ CSRF token validation on participation toggle
- ✅ Anonymous user rejection on toggle attempt

### Tests (15 Passed)
- ✅ 10 setup tests (install, uninstall, browser layer, permissions, type registration)
- ✅ 1 testing profile test
- ✅ 1 doodle view test (sorted dates rendering)
- ✅ 1 participation toggle test
- ✅ Code lint & quality (ruff, pyroma 10/10, zpretty, python-versions)

---

## Key Implementation Details

**Files:**
- `src/experimental/doodle/browser/doodle.pt` — Template with Bootstrap markup + tal:attributes for dynamic classes
- `src/experimental/doodle/browser/doodle.py` — View with rows() and date_label() methods
- `src/experimental/doodle/browser/participation.py` — ToggleParticipationView with CSRF check
- `src/experimental/doodle/browser/static/doodle.css` — Minimal responsive utilities
- `src/experimental/doodle/profiles/default/registry/main.xml` — Bundle registration

**Key Decisions:**
- Two separate types for structural clarity
- `participants` field as set of user IDs (not explicit selection field)
- POST-based toggle pattern for atomic updates
- Bootstrap-first styling aligned with Plone 6.x defaults
- explicit tal:attributes for class binding (not string interpolation)

---

## Next Atomic Steps (Post-MVP)

1. **Results/Summary View** — Display vote counts per date (new view method, optional aggregation UI)
2. **Control Panel** — Registry settings for participation notifications/preferences
3. **Catalog Indexing** — Add doodle-specific indexes for efficient queries (if scaling)
4. **Edge Case Tests** — Validate CSRF token reuse, anonymous rejection, data persistence
5. **Advanced Features** — Date filtering, search, results export

---

## Session Status
Session completed with all quality gates green. Ready for code review or feature expansion.
1 change: 1 addition & 0 deletions news/+d9b46371.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Initial implementation
18 changes: 18 additions & 0 deletions src/experimental/doodle/browser/configure.zcml
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,22 @@
type="plone"
/>

<browser:page
name="doodle"
for="experimental.doodle.interfaces.IExperimentalDoodleFolder"
class=".doodle.DoodleView"
template="templates/doodle.pt"
permission="zope2.View"
layer="experimental.doodle.interfaces.IBrowserLayer"
/>

<browser:page
name="toggle-participation"
for="experimental.doodle.interfaces.IExperimentalDoodleDate"
class=".participation.ToggleParticipationView"
permission="zope2.View"
layer="experimental.doodle.interfaces.IBrowserLayer"
/>


</configure>
47 changes: 47 additions & 0 deletions src/experimental/doodle/browser/doodle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from datetime import date

import plone.api
from Products.Five.browser import BrowserView


class DoodleView(BrowserView):
"""Render date options in a simple doodle-style table."""

def rows(self):
current = plone.api.user.get_current()
userid = current.getId() if current else None
items = [
obj
for obj in self.context.objectValues()
if obj.portal_type == "experimental.doodle.date"
]

def sort_key(item):
value = getattr(item, "date", None)
if isinstance(value, date):
return value
return date.max

rows = []
for item in sorted(items, key=sort_key):
when = getattr(item, "date", None)
values = getattr(item, "participants", None)
if values is None:
values = getattr(item, "participants", set())
participants = sorted(values or set())
rows.append(
{
"title": item.Title(),
"date": when,
"participants": participants,
"selected": bool(userid and userid in participants),
"toggle_url": f"{item.absolute_url()}/@@toggle-participation",
"can_toggle": bool(userid),
}
)
return rows

def date_label(self, value):
if not isinstance(value, date):
return "-"
return value.strftime("%Y-%m-%d")
36 changes: 36 additions & 0 deletions src/experimental/doodle/browser/participation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import plone.api

from AccessControl import Unauthorized
from Products.Five.browser import BrowserView
from plone.protect.authenticator import check


class ToggleParticipationView(BrowserView):
"""Toggle the current user in and out of date participants."""

def __call__(self):
if self.request.get("REQUEST_METHOD", "GET") != "POST":
raise Unauthorized("Participation changes require POST")

check(self.request)

user = plone.api.user.get_current()
userid = user.getId() if user else None
if not userid:
raise Unauthorized("Login required")

values = getattr(self.context, "participants", None)
if values is None:
values = getattr(self.context, "participants", set())
values = set(values or set())
if userid in values:
values.remove(userid)
else:
values.add(userid)
self.context.participants = values

target = self.request.get("HTTP_REFERER")
if not target:
target = f"{self.context.aq_parent.absolute_url()}/@@doodle"
self.request.response.redirect(target)
return ""
16 changes: 16 additions & 0 deletions src/experimental/doodle/browser/static/doodle.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
.doodle-date {
font-weight: 700;
white-space: nowrap;
}

.doodle-view {
border-radius: 0.5rem;
}

.doodle-view td .badge {
margin-right: 0.25rem;
}

.doodle-view button.btn {
white-space: nowrap;
}
70 changes: 70 additions & 0 deletions src/experimental/doodle/browser/templates/doodle.pt
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:metal="http://xml.zope.org/namespaces/metal"
xmlns:tal="http://xml.zope.org/namespaces/tal"
lang="en"
metal:use-macro="context/main_template/macros/master"
>
<body>
<metal:content-core fill-slot="content-core">
<section class="doodle-view card shadow-sm border-0">
<div class="card-body p-3 p-md-4">
<h2 class="h4 mb-2">Doodle</h2>
<p class="text-muted mb-4">Pick the dates that work for you and compare availability at a glance.</p>
<div class="table-responsive"
tal:condition="view/rows"
>
<table class="listing table table-striped table-hover align-middle mb-0">
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">Option</th>
<th scope="col">Participants</th>
<th scope="col">Action</th>
</tr>
</thead>
<tbody>
<tr tal:repeat="row view/rows">
<td class="doodle-date"
tal:content="python:view.date_label(row['date'])"
>2026-01-01</td>
<td class="fw-semibold"
tal:content="row/title"
>Date option</td>
<td>
<span class="text-muted"
tal:condition="python:not row['participants']"
>No participants yet</span>
<span tal:repeat="member row/participants"
tal:content="member"
tal:attributes="
class python:'badge rounded-pill text-bg-success' if row['selected'] and member == user.getId() else 'badge rounded-pill text-bg-light';
"
>member</span>
</td>
<td>
<form method="post"
tal:condition="row/can_toggle"
tal:attributes="
action row/toggle_url;
"
>
<button type="submit"
tal:content="python:'Unselect' if row['selected'] else 'Select'"
tal:attributes="
class python:'btn btn-sm btn-outline-secondary' if row['selected'] else 'btn btn-sm btn-primary';
"
>Select</button>
</form>
</td>
</tr>
</tbody>
</table>
</div>
<p class="text-muted mt-3 mb-0"
tal:condition="python:not view.rows()"
>No date options yet.</p>
</div>
</section>
</metal:content-core>
</body>
</html>
9 changes: 9 additions & 0 deletions src/experimental/doodle/content/date.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from plone.dexterity.content import Item



class ExperimentalDoodleDate(Item):
"""Single date option for a doodle poll (MVP)."""

# Marker class for the experimental.doodle.date type
pass
6 changes: 6 additions & 0 deletions src/experimental/doodle/content/folder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from plone.dexterity.content import Container

class ExperimentalDoodleFolder(Container):
"""Folderish Doodle container (MVP)"""
# Marker for the folderish doodle type
pass
36 changes: 36 additions & 0 deletions src/experimental/doodle/interfaces.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,43 @@
"""Module where all interfaces, events and exceptions live."""

from zope.publisher.interfaces.browser import IDefaultBrowserLayer
from plone.autoform import directives as form
from plone.supermodel import model
from zope import schema


class IBrowserLayer(IDefaultBrowserLayer):
"""Marker interface that defines a browser layer."""


# MVP: Folderish Doodle container schema interface
class IExperimentalDoodleFolder(model.Schema):
"""Folderish Doodle container (MVP)"""
# Dublin Core fields (title, description) are included by default
form.order_after(description='title')
description = schema.Text(
title="Description",
required=False,
)
# Optionally, add a text field for body/content
body = schema.Text(
title="Body",
required=False,
)


class IExperimentalDoodleDate(model.Schema):
"""Single date option inside a Doodle folder (MVP)"""

date = schema.Date(
title="Date",
required=True,
)

form.write_permission(participants="zope2.View")
participants = schema.Set(
title="Participants",
description="User ids who selected this date.",
value_type=schema.TextLine(),
required=False,
)
10 changes: 9 additions & 1 deletion src/experimental/doodle/permissions.zcml
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
<configure xmlns="http://namespaces.zope.org/zope">

<!-- -*- extra stuff goes here -*- -->
<permission
id="experimental.doodle.AddDoodleFolder"
title="experimental.doodle: Add Doodle Folder"
/>

<permission
id="experimental.doodle.AddDoodleDate"
title="experimental.doodle: Add Doodle Date"
/>

</configure>
8 changes: 8 additions & 0 deletions src/experimental/doodle/profiles.zcml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@
directory="profiles/uninstall"
/>

<genericsetup:registerProfile
name="testing"
title="Experimental Doodle: Testing Data"
description="Create test users and sample doodle content for development/testing."
provides="Products.GenericSetup.interfaces.EXTENSION"
directory="profiles/testing"
/>

<!-- Hide Uninstall Profile-->
<utility
factory=".setuphandlers.HiddenProfiles"
Expand Down
17 changes: 16 additions & 1 deletion src/experimental/doodle/profiles/default/registry/main.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,21 @@
i18n:domain="experimental.doodle"
>

<!-- -*- extra stuff goes here -*- -->
<records interface="plone.base.interfaces.IResourceRegistry"
prefix="plone.resources/experimental-doodle-style"
>
<value key="css">++plone++experimental.doodle/doodle.css</value>
</records>

<records interface="plone.base.interfaces.IBundleRegistry"
prefix="plone.bundles/experimental-doodle-style"
>
<value key="enabled">True</value>
<value key="csscompilation">++plone++experimental.doodle/doodle.css</value>
<value key="load_async">False</value>
<value key="load_defer">False</value>
<value key="compile">False</value>
<value key="depends">plone</value>
</records>

</registry>
Loading
Loading