From 57a1d2f1aff65422d009ba11b46cc95ebadddb23 Mon Sep 17 00:00:00 2001 From: ale-rt Date: Wed, 20 May 2026 10:43:25 +0200 Subject: [PATCH 01/15] Add a plan --- docs/concepts/plan.md | 51 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 docs/concepts/plan.md diff --git a/docs/concepts/plan.md b/docs/concepts/plan.md new file mode 100644 index 0000000..75e8939 --- /dev/null +++ b/docs/concepts/plan.md @@ -0,0 +1,51 @@ +# MVP Plan: Doodle Addon for Plone + +This document outlines the atomic steps for implementing a minimal Doodle-like addon for Plone, focusing on folderish and date content types, permissions, and a doodle-style view. + +--- + +## Atomic Commit Steps + +1. **Add schema interface for `experimental.doodle.folder`** + - File: `src/experimental/doodle/interfaces.py` +2. **Implement `experimental.doodle.folder` content type** + - File: `src/experimental/doodle/content/folder.py` +3. **Register `experimental.doodle.folder` in ZCML** + - File: `src/experimental/doodle/configure.zcml` +4. **Add type info XML for folder** + - File: `src/experimental/doodle/profiles/default/types/experimental.doodle.folder.xml` +5. **Add custom permissions for folder** + - File: `src/experimental/doodle/permissions.zcml` +6. **Add schema interface for `experimental.doodle.date`** + - File: `src/experimental/doodle/interfaces.py` +7. **Implement `experimental.doodle.date` content type** + - File: `src/experimental/doodle/content/date.py` +8. **Register `experimental.doodle.date` in ZCML** + - File: `src/experimental/doodle/configure.zcml` +9. **Add type info XML for date** + - File: `src/experimental/doodle/profiles/default/types/experimental.doodle.date.xml` +10. **Add custom permissions for date** + - File: `src/experimental/doodle/permissions.zcml` +11. **Implement user selection logic (`participants` field) in date type** +12. **Implement doodle-style browser view** + - File: `src/experimental/doodle/browser/doodleview.py` +13. **Add tests for type creation and permissions** +14. **Update documentation** + - Files: `README.md`, `docs/concepts/plan.md` + +--- + +## Key Decisions +- Two types: `experimental.doodle.folder` (folderish), `experimental.doodle.date` (date, non-folderish) +- `participants` field for user selection +- Custom permissions for each type +- Doodle-style view for folder + +--- + +## Verification +- Addon installs, both types available +- Permission separation works +- Dates can be added, users can select dates +- Doodle view displays sorted dates and user selections +- Tests and lint pass after each step From 0659634b1626143a737b996ea920f6a285e6a85c Mon Sep 17 00:00:00 2001 From: ale-rt Date: Wed, 20 May 2026 10:46:50 +0200 Subject: [PATCH 02/15] Add a basic folderish contenttype --- src/experimental/doodle/content/folder.py | 6 ++++++ src/experimental/doodle/interfaces.py | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+) create mode 100644 src/experimental/doodle/content/folder.py diff --git a/src/experimental/doodle/content/folder.py b/src/experimental/doodle/content/folder.py new file mode 100644 index 0000000..39e7c08 --- /dev/null +++ b/src/experimental/doodle/content/folder.py @@ -0,0 +1,6 @@ +from plone.dexterity.content import Container + +class ExperimentalDoodleFolder(Container): + """Folderish Doodle container (MVP)""" + # Marker for the folderish doodle type + pass diff --git a/src/experimental/doodle/interfaces.py b/src/experimental/doodle/interfaces.py index 7e2f288..de3ea5b 100644 --- a/src/experimental/doodle/interfaces.py +++ b/src/experimental/doodle/interfaces.py @@ -1,7 +1,26 @@ """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, + ) From 4be89b52909ce4ee5ffadee4d9017a12006dfabb Mon Sep 17 00:00:00 2001 From: ale-rt Date: Wed, 20 May 2026 10:57:00 +0200 Subject: [PATCH 03/15] Register the contentype --- .../doodle/profiles/default/types.xml | 4 +- .../types/experimental.doodle.folder.xml | 75 +++++++++++++++++++ tests/setup/test_setup_install.py | 4 + 3 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 src/experimental/doodle/profiles/default/types/experimental.doodle.folder.xml diff --git a/src/experimental/doodle/profiles/default/types.xml b/src/experimental/doodle/profiles/default/types.xml index bed2b0d..155e0cc 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/experimental.doodle.folder.xml b/src/experimental/doodle/profiles/default/types/experimental.doodle.folder.xml new file mode 100644 index 0000000..cfab47d --- /dev/null +++ b/src/experimental/doodle/profiles/default/types/experimental.doodle.folder.xml @@ -0,0 +1,75 @@ + + + Doodle Folder + Folderish container for doodle dates. + string:${portal_url}/++theme++plonetheme.barceloneta/images/contenttypes/folder.svg + experimental.doodle.folder + string:${folder_url}/++add++experimental.doodle.folder + + view + True + False + + False + view + + + + False + cmf.AddPortalContent + experimental.doodle.content.folder.ExperimentalDoodleFolder + experimental.doodle.interfaces.IExperimentalDoodleFolder + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/setup/test_setup_install.py b/tests/setup/test_setup_install.py index 53b02c0..693ca7c 100644 --- a/tests/setup/test_setup_install.py +++ b/tests/setup/test_setup_install.py @@ -15,3 +15,7 @@ def test_browserlayer(self, browser_layers): def test_latest_version(self, profile_last_version): """Test latest version of default profile.""" assert profile_last_version(f"{PACKAGE_NAME}:default") == "1000" + + def test_folder_type_installed(self, portal): + """Test folder content type is registered.""" + assert "experimental.doodle.folder" in portal.portal_types From 018bdcfd92ccc22a50cd9eb1c2ec1adca9188c37 Mon Sep 17 00:00:00 2001 From: ale-rt Date: Wed, 20 May 2026 11:05:35 +0200 Subject: [PATCH 04/15] Change permission to create a doodle folder --- src/experimental/doodle/permissions.zcml | 5 ++++- .../profiles/default/types/experimental.doodle.folder.xml | 2 +- tests/setup/test_setup_install.py | 5 +++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/experimental/doodle/permissions.zcml b/src/experimental/doodle/permissions.zcml index e2bdd95..9c00504 100644 --- a/src/experimental/doodle/permissions.zcml +++ b/src/experimental/doodle/permissions.zcml @@ -1,5 +1,8 @@ - + diff --git a/src/experimental/doodle/profiles/default/types/experimental.doodle.folder.xml b/src/experimental/doodle/profiles/default/types/experimental.doodle.folder.xml index cfab47d..86f7019 100644 --- a/src/experimental/doodle/profiles/default/types/experimental.doodle.folder.xml +++ b/src/experimental/doodle/profiles/default/types/experimental.doodle.folder.xml @@ -24,7 +24,7 @@ False - cmf.AddPortalContent + experimental.doodle.AddDoodleFolder experimental.doodle.content.folder.ExperimentalDoodleFolder experimental.doodle.interfaces.IExperimentalDoodleFolder diff --git a/tests/setup/test_setup_install.py b/tests/setup/test_setup_install.py index 693ca7c..c0478f7 100644 --- a/tests/setup/test_setup_install.py +++ b/tests/setup/test_setup_install.py @@ -19,3 +19,8 @@ def test_latest_version(self, profile_last_version): def test_folder_type_installed(self, portal): """Test folder content type is registered.""" assert "experimental.doodle.folder" in portal.portal_types + + def test_folder_type_add_permission(self, portal): + """Test folder content type uses custom add permission.""" + fti = portal.portal_types["experimental.doodle.folder"] + assert fti.add_permission == "experimental.doodle.AddDoodleFolder" From db9a08563f5054d0e8c89e69fe5b8afef3e4286e Mon Sep 17 00:00:00 2001 From: ale-rt Date: Wed, 20 May 2026 11:19:08 +0200 Subject: [PATCH 05/15] Add the date content type --- src/experimental/doodle/content/date.py | 9 +++ src/experimental/doodle/interfaces.py | 16 ++++ src/experimental/doodle/permissions.zcml | 5 ++ .../doodle/profiles/default/types.xml | 3 + .../types/experimental.doodle.date.xml | 74 +++++++++++++++++++ tests/setup/test_setup_install.py | 26 +++++++ 6 files changed, 133 insertions(+) create mode 100644 src/experimental/doodle/content/date.py create mode 100644 src/experimental/doodle/profiles/default/types/experimental.doodle.date.xml diff --git a/src/experimental/doodle/content/date.py b/src/experimental/doodle/content/date.py new file mode 100644 index 0000000..f379772 --- /dev/null +++ b/src/experimental/doodle/content/date.py @@ -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 diff --git a/src/experimental/doodle/interfaces.py b/src/experimental/doodle/interfaces.py index de3ea5b..fcd189e 100644 --- a/src/experimental/doodle/interfaces.py +++ b/src/experimental/doodle/interfaces.py @@ -24,3 +24,19 @@ class IExperimentalDoodleFolder(model.Schema): title="Body", required=False, ) + + +class IExperimentalDoodleDate(model.Schema): + """Single date option inside a Doodle folder (MVP)""" + + date = schema.Date( + title="Date", + required=True, + ) + + participants = schema.Set( + title="Participants", + description="User ids who selected this date.", + value_type=schema.TextLine(), + required=False, + ) diff --git a/src/experimental/doodle/permissions.zcml b/src/experimental/doodle/permissions.zcml index 9c00504..1366aa3 100644 --- a/src/experimental/doodle/permissions.zcml +++ b/src/experimental/doodle/permissions.zcml @@ -5,4 +5,9 @@ title="experimental.doodle: Add Doodle Folder" /> + + diff --git a/src/experimental/doodle/profiles/default/types.xml b/src/experimental/doodle/profiles/default/types.xml index 155e0cc..af27ac3 100644 --- a/src/experimental/doodle/profiles/default/types.xml +++ b/src/experimental/doodle/profiles/default/types.xml @@ -5,4 +5,7 @@ + diff --git a/src/experimental/doodle/profiles/default/types/experimental.doodle.date.xml b/src/experimental/doodle/profiles/default/types/experimental.doodle.date.xml new file mode 100644 index 0000000..89e327d --- /dev/null +++ b/src/experimental/doodle/profiles/default/types/experimental.doodle.date.xml @@ -0,0 +1,74 @@ + + + Doodle Date + Single date option in a doodle folder. + string:contenttype/event + experimental.doodle.date + string:${folder_url}/++add++experimental.doodle.date + + view + False + False + + False + view + + + + False + experimental.doodle.AddDoodleDate + experimental.doodle.content.date.ExperimentalDoodleDate + experimental.doodle.interfaces.IExperimentalDoodleDate + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/setup/test_setup_install.py b/tests/setup/test_setup_install.py index c0478f7..0e66c1b 100644 --- a/tests/setup/test_setup_install.py +++ b/tests/setup/test_setup_install.py @@ -24,3 +24,29 @@ def test_folder_type_add_permission(self, portal): """Test folder content type uses custom add permission.""" fti = portal.portal_types["experimental.doodle.folder"] assert fti.add_permission == "experimental.doodle.AddDoodleFolder" + + def test_date_type_installed(self, portal): + """Test date content type is registered.""" + assert "experimental.doodle.date" in portal.portal_types + + def test_date_type_add_permission(self, portal): + """Test date content type uses custom add permission.""" + fti = portal.portal_types["experimental.doodle.date"] + assert fti.add_permission == "experimental.doodle.AddDoodleDate" + + def test_folder_allows_only_date_type(self, portal): + """Test folder is configured to accept only date items.""" + fti = portal.portal_types["experimental.doodle.folder"] + assert fti.filter_content_types is True + assert fti.allowed_content_types == ("experimental.doodle.date",) + + def test_folder_default_view_is_doodle(self, portal): + """Test folder type defaults to the doodle layout.""" + fti = portal.portal_types["experimental.doodle.folder"] + assert fti.default_view == "doodle" + assert fti.immediate_view == "doodle" + + def test_date_type_not_global(self, portal): + """Test date type is not globally addable.""" + fti = portal.portal_types["experimental.doodle.date"] + assert fti.global_allow is False From d00683c87cd571064dd39aedf4ce25e557c2cdfd Mon Sep 17 00:00:00 2001 From: ale-rt Date: Wed, 20 May 2026 11:19:32 +0200 Subject: [PATCH 06/15] Add a rolemap --- .../doodle/profiles/default/rolemap.xml | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/experimental/doodle/profiles/default/rolemap.xml b/src/experimental/doodle/profiles/default/rolemap.xml index a803517..371ac52 100644 --- a/src/experimental/doodle/profiles/default/rolemap.xml +++ b/src/experimental/doodle/profiles/default/rolemap.xml @@ -1,6 +1,21 @@ - + + + + + + + + + + + + From 736857b11ef986e108aee562698f132155cfdc3f Mon Sep 17 00:00:00 2001 From: ale-rt Date: Wed, 20 May 2026 11:19:46 +0200 Subject: [PATCH 07/15] Add a view --- .../doodle/browser/configure.zcml | 10 +++++ src/experimental/doodle/browser/doodle.py | 37 +++++++++++++++++ .../doodle/browser/templates/doodle.pt | 39 ++++++++++++++++++ .../types/experimental.doodle.folder.xml | 11 +++-- tests/test_doodle_view.py | 40 +++++++++++++++++++ 5 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 src/experimental/doodle/browser/doodle.py create mode 100644 src/experimental/doodle/browser/templates/doodle.pt create mode 100644 tests/test_doodle_view.py diff --git a/src/experimental/doodle/browser/configure.zcml b/src/experimental/doodle/browser/configure.zcml index 6bf678a..5ab789f 100644 --- a/src/experimental/doodle/browser/configure.zcml +++ b/src/experimental/doodle/browser/configure.zcml @@ -22,4 +22,14 @@ type="plone" /> + + + diff --git a/src/experimental/doodle/browser/doodle.py b/src/experimental/doodle/browser/doodle.py new file mode 100644 index 0000000..931464d --- /dev/null +++ b/src/experimental/doodle/browser/doodle.py @@ -0,0 +1,37 @@ +from datetime import date +from Products.Five.browser import BrowserView + + +class DoodleView(BrowserView): + """Render date options in a simple doodle-style table.""" + + def rows(self): + 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) + participants = sorted(getattr(item, "participants", set()) or set()) + rows.append( + { + "title": item.Title(), + "date": when, + "participants": participants, + } + ) + return rows + + def date_label(self, value): + if not isinstance(value, date): + return "-" + return value.strftime("%Y-%m-%d") diff --git a/src/experimental/doodle/browser/templates/doodle.pt b/src/experimental/doodle/browser/templates/doodle.pt new file mode 100644 index 0000000..b5ccbb7 --- /dev/null +++ b/src/experimental/doodle/browser/templates/doodle.pt @@ -0,0 +1,39 @@ + + + +

Doodle

+ + + + + + + + + + + + + + + +
DateOptionParticipants
2026-01-01 10:00Date option + No participants yet + member +
+

No date options yet.

+
+ + diff --git a/src/experimental/doodle/profiles/default/types/experimental.doodle.folder.xml b/src/experimental/doodle/profiles/default/types/experimental.doodle.folder.xml index 86f7019..44d01c8 100644 --- a/src/experimental/doodle/profiles/default/types/experimental.doodle.folder.xml +++ b/src/experimental/doodle/profiles/default/types/experimental.doodle.folder.xml @@ -14,14 +14,17 @@ experimental.doodle.folder string:${folder_url}/++add++experimental.doodle.folder - view + doodle True - False - + True + + + False - view + doodle + False experimental.doodle.AddDoodleFolder diff --git a/tests/test_doodle_view.py b/tests/test_doodle_view.py new file mode 100644 index 0000000..c971a71 --- /dev/null +++ b/tests/test_doodle_view.py @@ -0,0 +1,40 @@ +from datetime import date + +import plone.api + + +class TestDoodleView: + def test_doodle_view_lists_sorted_dates(self, portal, request): + with plone.api.env.adopt_roles(["Manager"]): + folder = plone.api.content.create( + container=portal, + type="experimental.doodle.folder", + id="poll", + title="Poll", + ) + + plone.api.content.create( + container=folder, + type="experimental.doodle.date", + id="late", + title="Late option", + date=date(2026, 6, 1), + participants={"bob"}, + ) + plone.api.content.create( + container=folder, + type="experimental.doodle.date", + id="early", + title="Early option", + date=date(2026, 5, 1), + participants={"alice", "bob"}, + ) + + view = folder.restrictedTraverse("@@doodle") + html = view() + + assert "Early option" in html + assert "Late option" in html + assert html.index("Early option") < html.index("Late option") + assert "alice" in html + assert "bob" in html From d093fe90becbb63dedf5e5ebb1025225663d10e1 Mon Sep 17 00:00:00 2001 From: ale-rt Date: Wed, 20 May 2026 11:40:37 +0200 Subject: [PATCH 08/15] Remove not needed behaviors --- .../profiles/default/types/experimental.doodle.date.xml | 8 -------- .../profiles/default/types/experimental.doodle.folder.xml | 8 -------- 2 files changed, 16 deletions(-) diff --git a/src/experimental/doodle/profiles/default/types/experimental.doodle.date.xml b/src/experimental/doodle/profiles/default/types/experimental.doodle.date.xml index 89e327d..7807348 100644 --- a/src/experimental/doodle/profiles/default/types/experimental.doodle.date.xml +++ b/src/experimental/doodle/profiles/default/types/experimental.doodle.date.xml @@ -31,15 +31,7 @@ - - - - - - - - - - - - - - - - Date: Wed, 20 May 2026 11:47:09 +0200 Subject: [PATCH 09/15] Toggle participation --- .../doodle/browser/configure.zcml | 8 +++++ src/experimental/doodle/browser/doodle.py | 7 ++++ .../doodle/browser/participation.py | 25 +++++++++++++ .../doodle/browser/templates/doodle.pt | 11 +++++- src/experimental/doodle/interfaces.py | 1 + tests/test_participation.py | 36 +++++++++++++++++++ 6 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 src/experimental/doodle/browser/participation.py create mode 100644 tests/test_participation.py diff --git a/src/experimental/doodle/browser/configure.zcml b/src/experimental/doodle/browser/configure.zcml index 5ab789f..58242fc 100644 --- a/src/experimental/doodle/browser/configure.zcml +++ b/src/experimental/doodle/browser/configure.zcml @@ -31,5 +31,13 @@ layer="experimental.doodle.interfaces.IBrowserLayer" /> + + diff --git a/src/experimental/doodle/browser/doodle.py b/src/experimental/doodle/browser/doodle.py index 931464d..dd0e11d 100644 --- a/src/experimental/doodle/browser/doodle.py +++ b/src/experimental/doodle/browser/doodle.py @@ -1,4 +1,6 @@ from datetime import date + +import plone.api from Products.Five.browser import BrowserView @@ -6,6 +8,8 @@ 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() @@ -27,6 +31,9 @@ def sort_key(item): "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 diff --git a/src/experimental/doodle/browser/participation.py b/src/experimental/doodle/browser/participation.py new file mode 100644 index 0000000..f94ab5a --- /dev/null +++ b/src/experimental/doodle/browser/participation.py @@ -0,0 +1,25 @@ +import plone.api + +from Products.Five.browser import BrowserView + + +class ToggleParticipationView(BrowserView): + """Toggle the current user in and out of date participants.""" + + def __call__(self): + user = plone.api.user.get_current() + userid = user.getId() if user else None + + if userid: + values = set(getattr(self.context, "participants", set()) 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 "" diff --git a/src/experimental/doodle/browser/templates/doodle.pt b/src/experimental/doodle/browser/templates/doodle.pt index b5ccbb7..e1c95e3 100644 --- a/src/experimental/doodle/browser/templates/doodle.pt +++ b/src/experimental/doodle/browser/templates/doodle.pt @@ -15,11 +15,12 @@ Date Option Participants + Action - 2026-01-01 10:00 + 2026-01-01 Date option No participants yet @@ -30,6 +31,14 @@ " >member + + Select + diff --git a/src/experimental/doodle/interfaces.py b/src/experimental/doodle/interfaces.py index fcd189e..716035c 100644 --- a/src/experimental/doodle/interfaces.py +++ b/src/experimental/doodle/interfaces.py @@ -34,6 +34,7 @@ class IExperimentalDoodleDate(model.Schema): required=True, ) + form.write_permission(participants="zope2.View") participants = schema.Set( title="Participants", description="User ids who selected this date.", diff --git a/tests/test_participation.py b/tests/test_participation.py new file mode 100644 index 0000000..7635a7b --- /dev/null +++ b/tests/test_participation.py @@ -0,0 +1,36 @@ +from datetime import date + +import plone.api + + +class TestParticipation: + def test_member_can_toggle_own_participation(self, portal): + with plone.api.env.adopt_roles(["Manager"]): + plone.api.user.create( + username="alice", + email="alice@example.com", + roles=["Member"], + ) + folder = plone.api.content.create( + container=portal, + type="experimental.doodle.folder", + id="poll-toggle", + title="Toggle Poll", + ) + option = plone.api.content.create( + container=folder, + type="experimental.doodle.date", + id="date1", + title="Date 1", + date=date(2026, 9, 1), + participants=set(), + ) + option.manage_setLocalRoles("alice", ["Owner"]) + option.reindexObjectSecurity() + + with plone.api.env.adopt_user(username="alice"): + option.restrictedTraverse("@@toggle-participation")() + assert "alice" in option.participants + + option.restrictedTraverse("@@toggle-participation")() + assert "alice" not in option.participants From c5642f0c475435b0117b66fcf8ee1a648a2a8f1a Mon Sep 17 00:00:00 2001 From: ale-rt Date: Wed, 20 May 2026 11:53:31 +0200 Subject: [PATCH 10/15] Fix participation --- .../doodle/browser/participation.py | 22 +++++++++++++------ .../doodle/browser/templates/doodle.pt | 17 +++++++++----- tests/test_participation.py | 4 ++++ 3 files changed, 30 insertions(+), 13 deletions(-) diff --git a/src/experimental/doodle/browser/participation.py b/src/experimental/doodle/browser/participation.py index f94ab5a..928c2c5 100644 --- a/src/experimental/doodle/browser/participation.py +++ b/src/experimental/doodle/browser/participation.py @@ -1,22 +1,30 @@ 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") - if userid: - values = set(getattr(self.context, "participants", set()) or set()) - if userid in values: - values.remove(userid) - else: - values.add(userid) - self.context.participants = values + values = set(getattr(self.context, "participants", set()) 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: diff --git a/src/experimental/doodle/browser/templates/doodle.pt b/src/experimental/doodle/browser/templates/doodle.pt index e1c95e3..7d51f3c 100644 --- a/src/experimental/doodle/browser/templates/doodle.pt +++ b/src/experimental/doodle/browser/templates/doodle.pt @@ -32,12 +32,17 @@ >member - Select +
+ + +
diff --git a/tests/test_participation.py b/tests/test_participation.py index 7635a7b..f39db56 100644 --- a/tests/test_participation.py +++ b/tests/test_participation.py @@ -1,6 +1,7 @@ from datetime import date import plone.api +from plone.protect.authenticator import createToken class TestParticipation: @@ -29,8 +30,11 @@ def test_member_can_toggle_own_participation(self, portal): option.reindexObjectSecurity() with plone.api.env.adopt_user(username="alice"): + option.REQUEST.environ["REQUEST_METHOD"] = "POST" + option.REQUEST.form["_authenticator"] = createToken() option.restrictedTraverse("@@toggle-participation")() assert "alice" in option.participants + option.REQUEST.form["_authenticator"] = createToken() option.restrictedTraverse("@@toggle-participation")() assert "alice" not in option.participants From fbd0c05e935eb1b885ab47acc769d83663895fce Mon Sep 17 00:00:00 2001 From: ale-rt Date: Wed, 20 May 2026 11:58:57 +0200 Subject: [PATCH 11/15] Fix the participants --- src/experimental/doodle/browser/doodle.py | 5 ++++- src/experimental/doodle/browser/participation.py | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/experimental/doodle/browser/doodle.py b/src/experimental/doodle/browser/doodle.py index dd0e11d..b33c84b 100644 --- a/src/experimental/doodle/browser/doodle.py +++ b/src/experimental/doodle/browser/doodle.py @@ -25,7 +25,10 @@ def sort_key(item): rows = [] for item in sorted(items, key=sort_key): when = getattr(item, "date", None) - participants = sorted(getattr(item, "participants", set()) or set()) + values = getattr(item, "participants", None) + if values is None: + values = getattr(item, "participants", set()) + participants = sorted(values or set()) rows.append( { "title": item.Title(), diff --git a/src/experimental/doodle/browser/participation.py b/src/experimental/doodle/browser/participation.py index 928c2c5..9bee2bf 100644 --- a/src/experimental/doodle/browser/participation.py +++ b/src/experimental/doodle/browser/participation.py @@ -19,7 +19,10 @@ def __call__(self): if not userid: raise Unauthorized("Login required") - values = set(getattr(self.context, "participants", set()) or set()) + 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: From ee6ab241b77e15daf2b07b78178237ca936e4cb2 Mon Sep 17 00:00:00 2001 From: ale-rt Date: Wed, 20 May 2026 12:23:39 +0200 Subject: [PATCH 12/15] Add a testing profile --- src/experimental/doodle/profiles.zcml | 8 +++ .../experimental.doodle.testingdata.txt | 1 + .../doodle/profiles/testing/import_steps.xml | 9 +++ .../doodle/profiles/testing/metadata.xml | 7 ++ .../doodle/setuphandlers/testing.py | 67 +++++++++++++++++++ tests/setup/test_setup_testing_profile.py | 27 ++++++++ 6 files changed, 119 insertions(+) create mode 100644 src/experimental/doodle/profiles/testing/experimental.doodle.testingdata.txt create mode 100644 src/experimental/doodle/profiles/testing/import_steps.xml create mode 100644 src/experimental/doodle/profiles/testing/metadata.xml create mode 100644 src/experimental/doodle/setuphandlers/testing.py create mode 100644 tests/setup/test_setup_testing_profile.py diff --git a/src/experimental/doodle/profiles.zcml b/src/experimental/doodle/profiles.zcml index 465f9e7..2cef98b 100644 --- a/src/experimental/doodle/profiles.zcml +++ b/src/experimental/doodle/profiles.zcml @@ -20,6 +20,14 @@ directory="profiles/uninstall" /> + + + + + diff --git a/src/experimental/doodle/profiles/testing/metadata.xml b/src/experimental/doodle/profiles/testing/metadata.xml new file mode 100644 index 0000000..6072b36 --- /dev/null +++ b/src/experimental/doodle/profiles/testing/metadata.xml @@ -0,0 +1,7 @@ + + + 1000 + + profile-experimental.doodle:default + + diff --git a/src/experimental/doodle/setuphandlers/testing.py b/src/experimental/doodle/setuphandlers/testing.py new file mode 100644 index 0000000..9056b61 --- /dev/null +++ b/src/experimental/doodle/setuphandlers/testing.py @@ -0,0 +1,67 @@ +from datetime import date +from datetime import timedelta + +import plone.api + + +TEST_USERNAMES = ( + "alice", + "bob", + "carol", + "dave", +) + + +def _ensure_test_users(password="5upersecret!"): + for username in TEST_USERNAMES: + if plone.api.user.get(username=username): + continue + plone.api.user.create( + username=username, + email=f"{username}@example.com", + password=password, + roles=["Member"], + ) + + +def _seed_doodle_folders(portal, count=10): + base = date.today() + for idx in range(1, count + 1): + folder_id = f"test-{idx:02d}" + folder = getattr(portal, folder_id, None) + if folder is None: + folder = plone.api.content.create( + container=portal, + type="experimental.doodle.folder", + id=folder_id, + title=f"Test Doodle {idx:02d}", + description="Generated testing content", + body="Generated by testing GenericSetup profile.", + ) + + for option in range(1, 4): + option_id = f"date-{option}" + if getattr(folder, option_id, None) is not None: + continue + offset = (idx - 1) * 3 + option + participants = { + TEST_USERNAMES[(idx + option) % len(TEST_USERNAMES)], + } + plone.api.content.create( + container=folder, + type="experimental.doodle.date", + id=option_id, + title=f"Option {option}", + date=base + timedelta(days=offset), + participants=participants, + ) + + +def populate_testing_data(context): + if context.readDataFile("experimental.doodle.testingdata.txt") is None: + return + + portal = context.getSite() + with plone.api.env.adopt_roles(["Manager"]): + _ensure_test_users(password="5upersecret!") + _seed_doodle_folders(portal, count=10) diff --git a/tests/setup/test_setup_testing_profile.py b/tests/setup/test_setup_testing_profile.py new file mode 100644 index 0000000..c6b6542 --- /dev/null +++ b/tests/setup/test_setup_testing_profile.py @@ -0,0 +1,27 @@ +from plone.app.testing import applyProfile + +import plone.api + + +class TestTestingProfile: + def test_testing_profile_creates_users_and_doodles(self, portal): + applyProfile(portal, "experimental.doodle:testing") + + for username in ("alice", "bob", "carol", "dave"): + assert plone.api.user.get(username=username) is not None + + doodles = [ + obj + for obj in portal.objectValues() + if obj.portal_type == "experimental.doodle.folder" and obj.getId().startswith("test-") + ] + assert len(doodles) == 10 + + first = portal.get("test-01") + assert first is not None + options = [ + obj + for obj in first.objectValues() + if obj.portal_type == "experimental.doodle.date" + ] + assert len(options) == 3 From 39b175c3be59cc984c348644e9d1921c40cf5dad Mon Sep 17 00:00:00 2001 From: ale-rt Date: Wed, 20 May 2026 12:24:46 +0200 Subject: [PATCH 13/15] Add a changelog --- news/+d9b46371.feature.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 news/+d9b46371.feature.md diff --git a/news/+d9b46371.feature.md b/news/+d9b46371.feature.md new file mode 100644 index 0000000..68d3d07 --- /dev/null +++ b/news/+d9b46371.feature.md @@ -0,0 +1 @@ +Initial implementation From 9316e1a43137b942cf5aa2186b7402267f6f40a1 Mon Sep 17 00:00:00 2001 From: ale-rt Date: Wed, 20 May 2026 13:33:18 +0200 Subject: [PATCH 14/15] Add some styles --- .../doodle/browser/static/doodle.css | 16 +++ .../doodle/browser/templates/doodle.pt | 101 ++++++++++-------- .../doodle/profiles/default/registry/main.xml | 17 ++- 3 files changed, 91 insertions(+), 43 deletions(-) create mode 100644 src/experimental/doodle/browser/static/doodle.css diff --git a/src/experimental/doodle/browser/static/doodle.css b/src/experimental/doodle/browser/static/doodle.css new file mode 100644 index 0000000..5f07ce6 --- /dev/null +++ b/src/experimental/doodle/browser/static/doodle.css @@ -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; +} diff --git a/src/experimental/doodle/browser/templates/doodle.pt b/src/experimental/doodle/browser/templates/doodle.pt index 7d51f3c..bae46dd 100644 --- a/src/experimental/doodle/browser/templates/doodle.pt +++ b/src/experimental/doodle/browser/templates/doodle.pt @@ -6,48 +6,65 @@ > -

Doodle

- - - - - - - - - - - - - - - - - -
DateOptionParticipantsAction
2026-01-01Date option - No participants yet - member - -
- - -
-
-

No date options yet.

+
+
+

Doodle

+

Pick the dates that work for you and compare availability at a glance.

+
+ + + + + + + + + + + + + + + + + +
DateOptionParticipantsAction
2026-01-01Date option + No participants yet + member + +
+ +
+
+
+

No date options yet.

+
+
diff --git a/src/experimental/doodle/profiles/default/registry/main.xml b/src/experimental/doodle/profiles/default/registry/main.xml index eae378c..7eb992f 100644 --- a/src/experimental/doodle/profiles/default/registry/main.xml +++ b/src/experimental/doodle/profiles/default/registry/main.xml @@ -3,6 +3,21 @@ i18n:domain="experimental.doodle" > - + + ++plone++experimental.doodle/doodle.css + + + + True + ++plone++experimental.doodle/doodle.css + False + False + False + plone + From 5397f75d3fd2461770866e0308275d699bec9e93 Mon Sep 17 00:00:00 2001 From: ale-rt Date: Wed, 20 May 2026 19:01:22 +0200 Subject: [PATCH 15/15] Update the plan based on how effectively the session turned out to be. --- docs/concepts/plan.md | 98 +++++++++++++++++++++++++------------------ 1 file changed, 58 insertions(+), 40 deletions(-) diff --git a/docs/concepts/plan.md b/docs/concepts/plan.md index 75e8939..98df6b7 100644 --- a/docs/concepts/plan.md +++ b/docs/concepts/plan.md @@ -1,51 +1,69 @@ # MVP Plan: Doodle Addon for Plone -This document outlines the atomic steps for implementing a minimal Doodle-like addon for Plone, focusing on folderish and date content types, permissions, and a doodle-style view. +A minimal Doodle-like addon for Plone with folderish and date content types, permissions, and a Bootstrap-styled doodle view with participant selection toggle. --- -## Atomic Commit Steps - -1. **Add schema interface for `experimental.doodle.folder`** - - File: `src/experimental/doodle/interfaces.py` -2. **Implement `experimental.doodle.folder` content type** - - File: `src/experimental/doodle/content/folder.py` -3. **Register `experimental.doodle.folder` in ZCML** - - File: `src/experimental/doodle/configure.zcml` -4. **Add type info XML for folder** - - File: `src/experimental/doodle/profiles/default/types/experimental.doodle.folder.xml` -5. **Add custom permissions for folder** - - File: `src/experimental/doodle/permissions.zcml` -6. **Add schema interface for `experimental.doodle.date`** - - File: `src/experimental/doodle/interfaces.py` -7. **Implement `experimental.doodle.date` content type** - - File: `src/experimental/doodle/content/date.py` -8. **Register `experimental.doodle.date` in ZCML** - - File: `src/experimental/doodle/configure.zcml` -9. **Add type info XML for date** - - File: `src/experimental/doodle/profiles/default/types/experimental.doodle.date.xml` -10. **Add custom permissions for date** - - File: `src/experimental/doodle/permissions.zcml` -11. **Implement user selection logic (`participants` field) in date type** -12. **Implement doodle-style browser view** - - File: `src/experimental/doodle/browser/doodleview.py` -13. **Add tests for type creation and permissions** -14. **Update documentation** - - Files: `README.md`, `docs/concepts/plan.md` +## 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 Decisions -- Two types: `experimental.doodle.folder` (folderish), `experimental.doodle.date` (date, non-folderish) -- `participants` field for user selection -- Custom permissions for each type -- Doodle-style view for folder +## 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 --- -## Verification -- Addon installs, both types available -- Permission separation works -- Dates can be added, users can select dates -- Doodle view displays sorted dates and user selections -- Tests and lint pass after each step +## Session Status +Session completed with all quality gates green. Ready for code review or feature expansion.