diff --git a/docs/concepts/plan.md b/docs/concepts/plan.md new file mode 100644 index 0000000..98df6b7 --- /dev/null +++ b/docs/concepts/plan.md @@ -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. 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 diff --git a/src/experimental/doodle/browser/configure.zcml b/src/experimental/doodle/browser/configure.zcml index 6bf678a..58242fc 100644 --- a/src/experimental/doodle/browser/configure.zcml +++ b/src/experimental/doodle/browser/configure.zcml @@ -22,4 +22,22 @@ type="plone" /> + + + + + diff --git a/src/experimental/doodle/browser/doodle.py b/src/experimental/doodle/browser/doodle.py new file mode 100644 index 0000000..b33c84b --- /dev/null +++ b/src/experimental/doodle/browser/doodle.py @@ -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") diff --git a/src/experimental/doodle/browser/participation.py b/src/experimental/doodle/browser/participation.py new file mode 100644 index 0000000..9bee2bf --- /dev/null +++ b/src/experimental/doodle/browser/participation.py @@ -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 "" 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 new file mode 100644 index 0000000..bae46dd --- /dev/null +++ b/src/experimental/doodle/browser/templates/doodle.pt @@ -0,0 +1,70 @@ + + + +
+
+

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/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/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..716035c 100644 --- a/src/experimental/doodle/interfaces.py +++ b/src/experimental/doodle/interfaces.py @@ -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, + ) diff --git a/src/experimental/doodle/permissions.zcml b/src/experimental/doodle/permissions.zcml index e2bdd95..1366aa3 100644 --- a/src/experimental/doodle/permissions.zcml +++ b/src/experimental/doodle/permissions.zcml @@ -1,5 +1,13 @@ - + + + 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" /> + + - + + ++plone++experimental.doodle/doodle.css + + + + True + ++plone++experimental.doodle/doodle.css + False + False + False + plone + 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 @@ - + + + + + + + + + + + + diff --git a/src/experimental/doodle/profiles/default/types.xml b/src/experimental/doodle/profiles/default/types.xml index bed2b0d..af27ac3 100644 --- a/src/experimental/doodle/profiles/default/types.xml +++ b/src/experimental/doodle/profiles/default/types.xml @@ -2,9 +2,10 @@ - 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..7807348 --- /dev/null +++ b/src/experimental/doodle/profiles/default/types/experimental.doodle.date.xml @@ -0,0 +1,66 @@ + + + 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/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..13a74f0 --- /dev/null +++ b/src/experimental/doodle/profiles/default/types/experimental.doodle.folder.xml @@ -0,0 +1,70 @@ + + + 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 + + doodle + True + True + + + + False + doodle + + + + + False + experimental.doodle.AddDoodleFolder + experimental.doodle.content.folder.ExperimentalDoodleFolder + experimental.doodle.interfaces.IExperimentalDoodleFolder + + + + + + + + + + + + + + + + + + diff --git a/src/experimental/doodle/profiles/testing/experimental.doodle.testingdata.txt b/src/experimental/doodle/profiles/testing/experimental.doodle.testingdata.txt new file mode 100644 index 0000000..665e9e0 --- /dev/null +++ b/src/experimental/doodle/profiles/testing/experimental.doodle.testingdata.txt @@ -0,0 +1 @@ +experimental.doodle testing data marker diff --git a/src/experimental/doodle/profiles/testing/import_steps.xml b/src/experimental/doodle/profiles/testing/import_steps.xml new file mode 100644 index 0000000..ecbc19f --- /dev/null +++ b/src/experimental/doodle/profiles/testing/import_steps.xml @@ -0,0 +1,9 @@ + + + + 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_install.py b/tests/setup/test_setup_install.py index 53b02c0..0e66c1b 100644 --- a/tests/setup/test_setup_install.py +++ b/tests/setup/test_setup_install.py @@ -15,3 +15,38 @@ 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 + + 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 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 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 diff --git a/tests/test_participation.py b/tests/test_participation.py new file mode 100644 index 0000000..f39db56 --- /dev/null +++ b/tests/test_participation.py @@ -0,0 +1,40 @@ +from datetime import date + +import plone.api +from plone.protect.authenticator import createToken + + +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.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