diff --git a/docs/docs/concepts/domain-model.md b/docs/docs/concepts/domain-model.md new file mode 100644 index 0000000..e247c94 --- /dev/null +++ b/docs/docs/concepts/domain-model.md @@ -0,0 +1,68 @@ +--- +myst: + html_meta: + "description": "The Doodle-style domain model used by experimental.doodle" + "property=og:description": "The Doodle-style domain model used by experimental.doodle" + "property=og:title": "Domain model" + "keywords": "Plone, Experimental Doodle, domain, model, poll, vote" +--- + +# Domain model + +`experimental.doodle` provides a Doodle-style service to find the best time +for an appointment among several people. This page explains the building +blocks of that model. + +## Poll + +A **Poll** is the central object. It represents one scheduling question +("Lunch this week, when?") and carries: + +- a **title** and an optional **description**, both provided by the + standard Plone Dublin Core behavior; +- a list of **proposed time slots**: at least two date/time options that + participants can vote on. + +A Poll is a leaf content type and holds no sub-content. The Poll stores +its own voting data directly, rather than relying on separate content +objects. + +## Options + +The **options** form an ordered list of timezone-aware date/time values. +The order matters: each vote refers to options by their position in this +list. Editing or reordering options after participants have voted will +therefore break existing votes. Treat options as fixed once you share the +poll with participants. + +The current version enforces a minimum of two options at the schema level. +A single-option poll isn't a poll. + +## Participants and votes + +A **participant** identifies themselves with a free-text name. Voting +requires no Plone account, which matches how Doodle itself works: you +share a poll by link, and anyone with the link can respond. + +A **vote** is one participant's response to a poll. It contains a `yes` or +`no` choice for each option. The current version intentionally omits a +`maybe` value. + +Votes are stored through the {doc}`vote-storage adapter +`, an annotation-backed Python API on each Poll. +HTTP endpoints and a vote form arrive in later steps. + +## Tally + +The **tally** is the aggregated result of all votes: for each option, the +number of `yes` responses. The package computes it on demand from the +stored votes and doesn't persist it. + +## Why these choices + +- A single Dexterity content type keeps the data flat and easy to reason + about. There are no per-option or per-vote objects polluting the catalog. +- Votes live as annotations on the Poll because they belong to that poll + and have no independent lifecycle. +- Free-text participant names match Doodle's actual product behavior and + keep the current version usable without authentication infrastructure. diff --git a/docs/docs/concepts/index.md b/docs/docs/concepts/index.md index 6328240..61d37af 100644 --- a/docs/docs/concepts/index.md +++ b/docs/docs/concepts/index.md @@ -18,3 +18,11 @@ The Diátaxis framework also calls this class of documentation _explanation_. ```{seealso} https://diataxis.fr/explanation/ ``` + +## Available concepts + +```{toctree} +:maxdepth: 1 + +domain-model +``` diff --git a/docs/docs/glossary.md b/docs/docs/glossary.md index 3997cbd..325921e 100644 --- a/docs/docs/glossary.md +++ b/docs/docs/glossary.md @@ -8,7 +8,7 @@ myst: --- This glossary provides example terms and definitions relevant to **Experimental Doodle**. -A new addon for Plone +A new add-on for Plone ```{note} This is an example glossary demonstrating MyST Markdown’s `{glossary}` directive. You can adapt it for your project’s appendix by editing or replacing these entries with your own terms and definitions. diff --git a/docs/docs/how-to-guides/index.md b/docs/docs/how-to-guides/index.md index 517fc2e..5610857 100644 --- a/docs/docs/how-to-guides/index.md +++ b/docs/docs/how-to-guides/index.md @@ -18,6 +18,13 @@ This part of the documentation contains how-to guides, including installation an https://diataxis.fr/how-to-guides/ ``` +## Polls + +```{toctree} +:maxdepth: 1 + +vote-on-a-poll +``` ## Authors diff --git a/docs/docs/how-to-guides/vote-on-a-poll.md b/docs/docs/how-to-guides/vote-on-a-poll.md new file mode 100644 index 0000000..277bfa7 --- /dev/null +++ b/docs/docs/how-to-guides/vote-on-a-poll.md @@ -0,0 +1,57 @@ +--- +myst: + html_meta: + "description": "How to vote on a poll and read the results in experimental.doodle" + "property=og:description": "How to vote on a poll and read the results" + "property=og:title": "Vote on a poll" + "keywords": "Plone, experimental.doodle, poll, vote, results" +--- + +# Vote on a poll + +This guide explains how a visitor casts a vote on a published Poll and +reads the aggregated results. + +## Prerequisites + +- A Plone site with `experimental.doodle` installed. +- At least one published Poll with two or more time slots. + +## Cast a vote + +1. Open the Poll in your browser. The default view shows the vote form. +2. Enter your name in the **Your name** field. Any non-empty string is + accepted; no account is needed. +3. For each proposed time slot, select **Yes** or **No**. Every slot + defaults to **No** when the page loads. +4. Click **Cast vote**. The page reloads and confirms your vote was + recorded. You can vote again at any time. Casting a second vote with + the same name replaces your earlier response. + +## Read the results + +Click **View results** below the vote form, or navigate to the Poll's +`@@results` view directly (append `/@@results` to the Poll address). The +results page shows: + +- A table with each time slot and the number of **Yes** votes it received. +- A proportional bar for each slot so you can see relative support at a + glance. +- A list of participants who have voted. + +Click **Back to poll** to return to the vote form. + +## Create a poll (for editors) + +1. Navigate to the folder where you want to create the poll. +2. Add a new **Poll** content item. +3. Enter a **title** and, optionally, a **description**. +4. Add at least two **time slots** using the date/time picker for each + entry in the **Proposed time slots** field. +5. Save the item and publish it so visitors can reach it without logging + in. + +## Related references + +- {doc}`/reference/poll-content-type`: the Poll schema and FTI details. +- {doc}`/reference/vote-storage`: the Python API behind the vote form. diff --git a/docs/docs/index.md b/docs/docs/index.md index 278ec0f..ea9b79b 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -1,16 +1,16 @@ --- myst: html_meta: - "description": "A new addon for Plone" - "property=og:description": "A new addon for Plone" + "description": "A new add-on for Plone" + "property=og:description": "A new add-on for Plone" "property=og:title": "Experimental Doodle" - "keywords": "Experimental Doodle, documentation, A new addon for Plone" + "keywords": "Experimental Doodle, documentation, A new add-on for Plone" --- # Experimental Doodle Welcome to the documentation for Experimental Doodle! -A new addon for Plone +A new add-on for Plone This scaffold provides a ready-to-use environment for creating comprehensive documentation for {term}`Plone` projects, based on {term}`Plone Sphinx Theme`. diff --git a/docs/docs/reference/index.md b/docs/docs/reference/index.md index 3abbca4..116764e 100644 --- a/docs/docs/reference/index.md +++ b/docs/docs/reference/index.md @@ -18,6 +18,30 @@ This part of the documentation contains reference material, including APIs, conf https://diataxis.fr/reference/ ``` +## Content types + +```{toctree} +:maxdepth: 1 + +poll-content-type +``` + +## Adapters + +```{toctree} +:maxdepth: 1 + +vote-storage +``` + +## Views + +```{toctree} +:maxdepth: 1 + +poll-views +``` + ## Configuration - {doc}`plone:contributing/documentation/themes-and-extensions` diff --git a/docs/docs/reference/poll-content-type.md b/docs/docs/reference/poll-content-type.md new file mode 100644 index 0000000..371eff6 --- /dev/null +++ b/docs/docs/reference/poll-content-type.md @@ -0,0 +1,144 @@ +--- +myst: + html_meta: + "description": "Reference for the Poll Dexterity content type" + "property=og:description": "Reference for the Poll Dexterity content type" + "property=og:title": "Poll content type" + "keywords": "Plone, Dexterity, Poll, content type, experimental.doodle" +--- + +# `Poll` content type + +The `Poll` Dexterity content type represents a single Doodle-style poll. +The `experimental.doodle:default` GenericSetup profile registers it, and +the type is globally addable to any folderish container. + +## Schema + +The module `experimental.doodle.content.poll` defines the `IPoll` schema. + +| Field | Type | Required | Notes | +|---|---|---|---| +| `title` | `TextLine` (via `plone.dublincore`) | yes | Inherited from the standard Dublin Core behavior. | +| `description` | `Text` (via `plone.dublincore`) | no | Inherited from the standard Dublin Core behavior. | +| `options` | `List(value_type=Datetime)` | yes | Proposed time slots. A schema invariant enforces a minimum of two entries. | + +The `options` field uses `defaultFactory=list` so each new Poll instance +starts with a fresh empty list rather than a shared mutable default. The +"at least two options" rule lives in a schema invariant rather than the +field's `min_length`, so the add form can render with an empty list and +only complains on submit. + +## Factory type information + +The package ships the Factory Type Information (FTI) at +`src/experimental/doodle/profiles/default/types/Poll.xml`. + +Key properties: + +- **`klass`**: `experimental.doodle.content.poll.Poll` +- **`schema`**: `experimental.doodle.content.poll.IPoll` +- **`factory`**: `Poll` +- **`add_permission`**: `cmf.AddPortalContent` +- **`global_allow`**: `True` +- **`filter_content_types`**: `True` with an empty `allowed_content_types` + list. A Poll is a leaf and can't contain other content. +- **`default_view`** and **`immediate_view`**: `view` (the standard + Dexterity default view; a dedicated view ships in a later step). + +### Enabled behaviors + +- `plone.dublincore`: title, description, dates, language, and related metadata. +- `plone.namefromtitle`: derives the id from the title. +- `plone.shortname`: supports manual override of the id when needed. +- `plone.ownership`: tracks creator and contributors. + +## Creating a poll programmatically + +```python +from datetime import datetime, timedelta, timezone +from plone import api + + +def create_lunch_poll(container): + now = datetime.now(tz=timezone.utc) + return api.content.create( + container=container, + type="Poll", + title="Team lunch", + description="When can everyone make it?", + options=[ + now + timedelta(days=1, hours=12), + now + timedelta(days=1, hours=13), + now + timedelta(days=2, hours=12), + ], + ) +``` + +The `options` list must contain at least two date/time values. Otherwise, +schema validation raises `zope.interface.Invalid`. + +## Creating a poll through `plone.restapi` + +```http +POST /plone/@@plone.restapi.services HTTP/1.1 +Content-Type: application/json +Accept: application/json + +{ + "@type": "Poll", + "title": "Team lunch", + "description": "When can everyone make it?", + "options": [ + "2026-06-01T12:00:00+00:00", + "2026-06-01T13:00:00+00:00", + "2026-06-02T12:00:00+00:00" + ] +} +``` + +`plone.restapi` handles the standard create endpoint via `POST` on the +container address with `@type: "Poll"`. + +## Validation + +Schema-level constraints enforced today: + +- The `options` field is **mandatory**. +- Every element of `options` must be a `datetime`. +- The schema invariant `at_least_two_options` rejects a submission whose + `options` list has fewer than two entries. The invariant runs on form + submit, not on widget render, so the add form opens cleanly. + +Other rules, for example forbidding past date/time values, removing +duplicate slots, or restricting how options can change after votes exist, +are intentionally out of scope for the current version. Follow-up steps +will address them. + +## Upgrading existing sites + +The `experimental.doodle:default` profile registers the `Poll` FTI at +version `1001`. Sites installed with profile version `1000` can upgrade in +place: + +1. Open the Plone control panel. +2. Go to {guilabel}`Add-ons` and run the available upgrade step for + `experimental.doodle` ("Install Poll content type"). + +You can run the same step programmatically: + +```python +from plone import api + +setup_tool = api.portal.get_tool("portal_setup") +setup_tool.upgradeProfile("experimental.doodle:default") +``` + +The upgrade handler lives at +`experimental.doodle.upgrades.v1001.install_poll_type` and re-imports the +GenericSetup `typeinfo` step. The step is idempotent: it's safe to run on +sites that already have the `Poll` FTI. + +## Related concepts + +- {doc}`/concepts/domain-model`: the overall Doodle-style domain. diff --git a/docs/docs/reference/poll-views.md b/docs/docs/reference/poll-views.md new file mode 100644 index 0000000..bbfec43 --- /dev/null +++ b/docs/docs/reference/poll-views.md @@ -0,0 +1,91 @@ +--- +myst: + html_meta: + "description": "Reference for the Poll browser views in experimental.doodle" + "property=og:description": "Reference for the Poll browser views" + "property=og:title": "Poll views" + "keywords": "Plone, experimental.doodle, poll, view, results, browser" +--- + +# Poll views + +`experimental.doodle` ships two browser views for `Poll` objects, both +registered against `IPoll` on `IBrowserLayer`. + +## Vote form (`@@poll_view`) + +**Class:** `experimental.doodle.browser.poll.PollView` +**Permission:** `zope2.View` (no login required) +**Default view:** yes (`default_view` on the FTI since profile version `1002`) + +The vote form renders the poll title, description, and a `
` with: + +- a text field for the participant's name; +- one `yes`/`no` radio pair per proposed time slot; +- a submit button. + +### Form submission + +On submit the view: + +1. Strips the `name` field; returns a form error if the result is empty. +2. Reads `votes_0`, `votes_1`, … from the request; absent values default + to `"false"`. +3. Calls `IVoteStorage(context).cast_vote(name, votes)`. +4. Redirects to `@@poll_view` (Post/Redirect/Get pattern). + +Any `ValueError` from `cast_vote` is caught and shown as an inline error +without a redirect. + +### Template helpers + +| Property | Returns | +|---|---| +| `options` | `[(index, label), ...]` where `label` is a human-readable date/time string. | +| `error` | The current error string, or `None`. | +| `participant_count` | Number of participants who have already voted. | + +## Results (`@@results`) + +**Class:** `experimental.doodle.browser.poll.PollResultsView` +**Permission:** `zope2.View` + +The results view shows the aggregated tally and a participant list. It +is read-only; there is no form or POST handling. + +### Template helpers + +| Property | Returns | +|---|---| +| `rows` | `[(label, yes_count, max_count), ...]`, one row per slot. `max_count` is the highest yes-count across all slots, used to compute proportional bar widths. | +| `participants` | `list[str]`, participant names sorted alphabetically. | + +A `@@results` link appears on the vote form. A "Back to poll" link +returns to `@@poll_view` from the results. + +## Styles + +Both views include `++plone++experimental.doodle/poll.css` from +`src/experimental/doodle/browser/static/`. The stylesheet covers only +poll-specific elements and makes no global overrides. + +## Profile version + +The FTI change (`default_view` set to `poll_view`, `results` added to +`view_methods`) ships at profile version `1002`. Sites on `1001` can +upgrade through the {guilabel}`Add-ons` control panel or +programmatically: + +```python +from plone import api + +api.portal.get_tool("portal_setup").upgradeProfile( + "experimental.doodle:default" +) +``` + +## Related + +- {doc}`vote-storage`: the adapter the views delegate to. +- {doc}`poll-content-type`: the content type the views render. +- {doc}`/how-to-guides/vote-on-a-poll`: step-by-step voting guide. diff --git a/docs/docs/reference/vote-storage.md b/docs/docs/reference/vote-storage.md new file mode 100644 index 0000000..dd5cc7a --- /dev/null +++ b/docs/docs/reference/vote-storage.md @@ -0,0 +1,96 @@ +--- +myst: + html_meta: + "description": "Reference for the IVoteStorage adapter on Poll" + "property=og:description": "Reference for the IVoteStorage adapter on Poll" + "property=og:title": "Vote storage" + "keywords": "Plone, experimental.doodle, vote, adapter, annotation" +--- + +# Vote storage + +The `IVoteStorage` adapter records and aggregates votes for a `Poll`. It +provides the Python API that higher-level layers (REST services, browser +views) will build on in later steps. + +## Interface + +The interface lives at `experimental.doodle.interfaces.IVoteStorage` and +adapts an `IPoll`. The implementation is +`experimental.doodle.adapters.vote_storage.VoteStorage`, registered through +ZCML. + +| Method | Returns | Notes | +|---|---|---| +| `cast_vote(name, votes)` | `None` | Records a participant's vote. Idempotent: casting again with the same name overwrites. Raises `ValueError` on invalid input. | +| `get_vote(name)` | `list[bool]` or `None` | The participant's stored votes, or `None` if they haven't voted. | +| `get_votes()` | `dict[str, list[bool]]` | Plain snapshot of all stored votes; safe to mutate. | +| `remove_vote(name)` | `bool` | Removes the entry. Returns `True` if a vote was removed, `False` otherwise. | +| `participants()` | `list[str]` | Participant names sorted alphabetically. | +| `tally()` | `list[int]` | Yes-counts per option, parallel to `poll.options`. | + +## Validation rules + +`cast_vote` enforces three rules: + +- The `name` must be a non-empty string after `.strip()`. Leading and + trailing whitespace is removed; case is preserved. +- The `votes` sequence must have exactly `len(poll.options)` entries. +- Each entry of `votes` is coerced through `bool(...)` before storage. + +Violations raise `ValueError` with a descriptive message. Reading methods +(`get_vote`, `get_votes`, `remove_vote`, `participants`, `tally`) never +raise on missing data; they return sensible empty values instead. + +## Where the data lives + +Votes are stored as a `zope.annotation` entry on the Poll under the key +`experimental.doodle.votes`. The value is a `PersistentMapping` of +participant names to `PersistentList` choices. Both types come from +`persistent` so that ZODB picks up mutations correctly. + +The annotation is created on first adapter instantiation. A poll that +has never been voted on costs nothing extra in storage until someone +casts a vote. + +## Example + +```python +from datetime import datetime, timedelta, timezone +from experimental.doodle.interfaces import IVoteStorage +from plone import api + + +def create_and_vote(container): + now = datetime.now(tz=timezone.utc) + poll = api.content.create( + container=container, + type="Poll", + title="Team lunch", + options=[ + now + timedelta(days=1, hours=12), + now + timedelta(days=1, hours=13), + now + timedelta(days=2, hours=12), + ], + ) + + storage = IVoteStorage(poll) + storage.cast_vote("Alice", [True, False, True]) + storage.cast_vote("Bob", [True, True, False]) + + return storage.tally() # -> [2, 1, 1] +``` + +## What's not in this layer + +The adapter intentionally stays minimal. The following arrive in later +steps: + +- HTTP endpoints (`plone.restapi` services). +- Browser views and a vote form. +- Anonymous voter tokens, email notifications, vote history. + +## Related references + +- {doc}`poll-content-type`: the content type the adapter works on. +- {doc}`/concepts/domain-model`: the overall Doodle-style domain. diff --git a/news/classic-ui.feature b/news/classic-ui.feature new file mode 100644 index 0000000..0f1a426 --- /dev/null +++ b/news/classic-ui.feature @@ -0,0 +1 @@ +Add Classic UI views for Poll: a vote form (``@@poll_view``) and a tally page (``@@results``). diff --git a/news/poll-type.feature b/news/poll-type.feature new file mode 100644 index 0000000..868dfac --- /dev/null +++ b/news/poll-type.feature @@ -0,0 +1 @@ +Add `Poll` content type with title, description, and a list of proposed time slots. diff --git a/news/vote-storage.feature b/news/vote-storage.feature new file mode 100644 index 0000000..f7becb2 --- /dev/null +++ b/news/vote-storage.feature @@ -0,0 +1 @@ +Add an annotation-backed `IVoteStorage` adapter for `Poll` with a Python API to cast, read, remove, and tally votes. diff --git a/src/experimental/doodle/adapters/__init__.py b/src/experimental/doodle/adapters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/experimental/doodle/adapters/configure.zcml b/src/experimental/doodle/adapters/configure.zcml new file mode 100644 index 0000000..ef8fca3 --- /dev/null +++ b/src/experimental/doodle/adapters/configure.zcml @@ -0,0 +1,8 @@ + + + + + diff --git a/src/experimental/doodle/adapters/vote_storage.py b/src/experimental/doodle/adapters/vote_storage.py new file mode 100644 index 0000000..15efabd --- /dev/null +++ b/src/experimental/doodle/adapters/vote_storage.py @@ -0,0 +1,102 @@ +"""Annotation-backed vote storage adapter for ``Poll`` objects.""" + +from collections.abc import Sequence +from experimental.doodle.content.poll import IPoll +from experimental.doodle.interfaces import IVoteStorage +from persistent.list import PersistentList +from persistent.mapping import PersistentMapping +from zope.annotation.interfaces import IAnnotations +from zope.component import adapter +from zope.interface import implementer + + +ANNOTATION_KEY = "experimental.doodle.votes" + +# Immutable empty mapping returned for polls that have no votes yet. +# Using a module-level singleton means no object is created per request. +_EMPTY: PersistentMapping = PersistentMapping() + + +@implementer(IVoteStorage) +@adapter(IPoll) +class VoteStorage: + """Store and aggregate votes as an annotation on a Poll.""" + + def __init__(self, context): + self.context = context + + @property + def _votes(self) -> PersistentMapping: + """Return the persistent vote mapping, creating it only on first write. + + Accessing the annotation on read without creating it avoids a + database write during GET requests, which would trigger Plone's + CSRF check. + """ + annotations = IAnnotations(self.context) + return annotations.get(ANNOTATION_KEY, _EMPTY) + + def _writable_votes(self) -> PersistentMapping: + """Return the persistent vote mapping, creating it if absent.""" + annotations = IAnnotations(self.context) + if ANNOTATION_KEY not in annotations: + annotations[ANNOTATION_KEY] = PersistentMapping() + return annotations[ANNOTATION_KEY] + + # ----- helpers ----------------------------------------------------- + + @property + def _option_count(self) -> int: + return len(self.context.options or []) + + @staticmethod + def _clean_name(name: str) -> str: + if not isinstance(name, str): + raise ValueError("Participant name must be a string.") + cleaned = name.strip() + if not cleaned: + raise ValueError("Participant name must not be empty.") + return cleaned + + # ----- public API -------------------------------------------------- + + def cast_vote(self, name: str, votes: Sequence[bool]) -> None: + cleaned = self._clean_name(name) + votes_list = list(votes) + expected = self._option_count + if len(votes_list) != expected: + raise ValueError( + f"Expected {expected} votes (one per option), got {len(votes_list)}." + ) + self._writable_votes()[cleaned] = PersistentList(bool(v) for v in votes_list) + + def get_vote(self, name: str) -> list[bool] | None: + cleaned = self._clean_name(name) + stored = self._votes.get(cleaned) + if stored is None: + return None + return list(stored) + + def get_votes(self) -> dict[str, list[bool]]: + return {name: list(votes) for name, votes in self._votes.items()} + + def remove_vote(self, name: str) -> bool: + cleaned = self._clean_name(name) + writable = self._writable_votes() + if cleaned in writable: + del writable[cleaned] + return True + return False + + def participants(self) -> list[str]: + return sorted(self._votes.keys()) + + def tally(self) -> list[int]: + counts = [0] * self._option_count + for votes in self._votes.values(): + for index, choice in enumerate(votes): + if index >= len(counts): + break + if choice: + counts[index] += 1 + return counts diff --git a/src/experimental/doodle/browser/configure.zcml b/src/experimental/doodle/browser/configure.zcml index 6bf678a..43f0910 100644 --- a/src/experimental/doodle/browser/configure.zcml +++ b/src/experimental/doodle/browser/configure.zcml @@ -22,4 +22,21 @@ type="plone" /> + + + + + diff --git a/src/experimental/doodle/browser/poll.py b/src/experimental/doodle/browser/poll.py new file mode 100644 index 0000000..d427565 --- /dev/null +++ b/src/experimental/doodle/browser/poll.py @@ -0,0 +1,116 @@ +"""Browser views for the Poll content type.""" + +from experimental.doodle import _ +from experimental.doodle.interfaces import IVoteStorage +from plone.protect.interfaces import IDisableCSRFProtection +from Products.Five.browser import BrowserView +from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile +from zope.interface import alsoProvides + + +def _format_slot(dt) -> str: + """Return a human-readable label for a datetime option.""" + return dt.strftime("%a %d %b %Y, %H:%M UTC") + + +class PollView(BrowserView): + """Default view for a Poll: shows the vote form. + + Handles GET (render) and POST (record vote, then redirect). + """ + + index = ViewPageTemplateFile("templates/poll_view.pt") + + def __call__(self): + if self.request.method == "POST": + error = self._handle_post() + if error is None: + # Post/Redirect/Get: prevent duplicate submissions on refresh. + url = self.context.absolute_url() + "/@@poll_view" + self.request.response.redirect(url) + return "" + self._error = error + else: + self._error = None + return self.index() + + # ------------------------------------------------------------------ + # Template helpers + # ------------------------------------------------------------------ + + @property + def options(self): + """Return ``[(index, label), ...]`` for every proposed slot.""" + return [ + (i, _format_slot(dt)) for i, dt in enumerate(self.context.options or []) + ] + + @property + def error(self): + return self._error + + @property + def participant_count(self): + return len(IVoteStorage(self.context).participants()) + + # ------------------------------------------------------------------ + # POST handler + # ------------------------------------------------------------------ + + def _handle_post(self): + """Validate the submitted form and cast the vote. + + Returns ``None`` on success, or a translated error string to + display on the form. + + Voting is open to anonymous users, so there is no valid CSRF + authenticator token for them. We explicitly opt out of CSRF + protection here — the write is intentional and the form is + publicly accessible by design. + """ + alsoProvides(self.request, IDisableCSRFProtection) + form = self.request.form + name = (form.get("name") or "").strip() + if not name: + return _("Please enter your name.") + + options = self.context.options or [] + votes = [] + for i in range(len(options)): + raw = form.get(f"votes_{i}", "false") + votes.append(raw == "true") + + try: + IVoteStorage(self.context).cast_vote(name, votes) + except ValueError as exc: + return str(exc) + + return None + + +class PollResultsView(BrowserView): + """Results view for a Poll: shows the tally.""" + + index = ViewPageTemplateFile("templates/results.pt") + + def __call__(self): + return self.index() + + # ------------------------------------------------------------------ + # Template helpers + # ------------------------------------------------------------------ + + @property + def rows(self): + """Return ``[(label, yes_count, max_count), ...]`` for each slot.""" + storage = IVoteStorage(self.context) + counts = storage.tally() + max_count = max(counts) if counts else 0 + return [ + (_format_slot(dt), count, max_count) + for dt, count in zip(self.context.options or [], counts) + ] + + @property + def participants(self): + return IVoteStorage(self.context).participants() diff --git a/src/experimental/doodle/browser/static/poll.css b/src/experimental/doodle/browser/static/poll.css new file mode 100644 index 0000000..2312f2a --- /dev/null +++ b/src/experimental/doodle/browser/static/poll.css @@ -0,0 +1,92 @@ +/* experimental.doodle — Poll styles */ + +.poll-form { + margin: 1.5rem 0; +} + +.poll-name-field { + margin-bottom: 1rem; +} + +.poll-name-field label { + display: block; + font-weight: bold; + margin-bottom: 0.25rem; +} + +.poll-name-field input[type="text"] { + width: 20rem; + max-width: 100%; + padding: 0.3rem 0.5rem; +} + +.poll-options { + border-collapse: collapse; + margin-bottom: 1rem; +} + +.poll-options th, +.poll-options td { + border: 1px solid #ccc; + padding: 0.4rem 0.75rem; + text-align: left; +} + +.poll-options th { + background: #f5f5f5; +} + +.poll-yes, +.poll-no { + text-align: center; + white-space: nowrap; +} + +.poll-submit { + margin-top: 0.75rem; +} + +.poll-participant-count { + color: #666; + font-size: 0.9rem; + margin-top: 1rem; +} + +/* Results */ + +.poll-results { + border-collapse: collapse; + margin-bottom: 1rem; + width: 100%; +} + +.poll-results th, +.poll-results td { + border: 1px solid #ccc; + padding: 0.4rem 0.75rem; + text-align: left; +} + +.poll-results th { + background: #f5f5f5; +} + +.poll-count { + text-align: right; + width: 4rem; +} + +.poll-bar-cell { + width: 50%; +} + +.poll-bar { + background: #007bb5; + height: 1.1rem; + min-width: 2px; +} + +.poll-participants { + list-style: disc; + padding-left: 1.5rem; +} diff --git a/src/experimental/doodle/browser/templates/poll_view.pt b/src/experimental/doodle/browser/templates/poll_view.pt new file mode 100644 index 0000000..ebce464 --- /dev/null +++ b/src/experimental/doodle/browser/templates/poll_view.pt @@ -0,0 +1,132 @@ + + + + + + + + + +

No time slots have been proposed yet.

+
+ + + + + + + + +
+ + +
+ + + + + + + + + + + + + + + + +
Time slotYesNo
Slot label + + + + + +
+ +
+ +
+ + +

+ + + + person has already voted. + + + + people have already voted. + +

+ +

+ View results +

+ +
+ +
+
+ + diff --git a/src/experimental/doodle/browser/templates/results.pt b/src/experimental/doodle/browser/templates/results.pt new file mode 100644 index 0000000..fd3f869 --- /dev/null +++ b/src/experimental/doodle/browser/templates/results.pt @@ -0,0 +1,82 @@ + + + + + + + + +

Poll title

+ +

Description

+ + +

No votes have been cast yet.

+
+ + + + + + + + + + + + + + + + + + +
Time slotYes votes
Slot label0 +
+
+ +
+ + +

Participants

+
    +
  • Name
  • +
+
+ +

+ Back to poll +

+ +
+
+ + diff --git a/src/experimental/doodle/configure.zcml b/src/experimental/doodle/configure.zcml index fd737a5..1a17c94 100644 --- a/src/experimental/doodle/configure.zcml +++ b/src/experimental/doodle/configure.zcml @@ -16,6 +16,8 @@ + + diff --git a/src/experimental/doodle/content/configure.zcml b/src/experimental/doodle/content/configure.zcml new file mode 100644 index 0000000..2dea960 --- /dev/null +++ b/src/experimental/doodle/content/configure.zcml @@ -0,0 +1,10 @@ + + + + + diff --git a/src/experimental/doodle/content/poll.py b/src/experimental/doodle/content/poll.py new file mode 100644 index 0000000..dd42848 --- /dev/null +++ b/src/experimental/doodle/content/poll.py @@ -0,0 +1,44 @@ +"""Poll content type: a Doodle-style scheduling poll.""" + +from experimental.doodle import _ +from plone.dexterity.content import Item +from plone.supermodel import model +from zope import schema +from zope.interface import implementer +from zope.interface import Invalid +from zope.interface import invariant + + +MIN_OPTIONS = 2 + + +class IPoll(model.Schema): + """Schema for a Doodle-style poll. + + A poll proposes a list of date/time options and collects yes/no votes + from participants who identify themselves by a free-text name. + """ + + options = schema.List( + title=_("Proposed time slots"), + description=_("Date and time options that participants can vote on."), + value_type=schema.Datetime( + title=_("Time slot"), + ), + required=True, + defaultFactory=list, + ) + + @invariant + def at_least_two_options(data): + """A poll needs at least two distinct time slots to be meaningful.""" + options = getattr(data, "options", None) or [] + if len(options) < MIN_OPTIONS: + raise Invalid( + _("A poll needs at least two proposed time slots."), + ) + + +@implementer(IPoll) +class Poll(Item): + """A Doodle-style poll.""" diff --git a/src/experimental/doodle/interfaces.py b/src/experimental/doodle/interfaces.py index 7e2f288..defe067 100644 --- a/src/experimental/doodle/interfaces.py +++ b/src/experimental/doodle/interfaces.py @@ -1,7 +1,50 @@ """Module where all interfaces, events and exceptions live.""" +from zope.interface import Interface from zope.publisher.interfaces.browser import IDefaultBrowserLayer class IBrowserLayer(IDefaultBrowserLayer): """Marker interface that defines a browser layer.""" + + +class IVoteStorage(Interface): + """Storage adapter for votes on a Poll. + + Votes are persisted as an annotation on the adapted Poll. Each entry + maps a participant's display name to a list of booleans whose length + equals ``len(poll.options)`` at write time. + """ + + def cast_vote(name, votes): + """Record ``votes`` for the participant ``name``. + + ``name`` is stripped of surrounding whitespace; case is preserved. + Casting again with the same name overwrites the previous vote. + + Raises ``ValueError`` if ``name`` is empty after stripping, or if + ``votes`` has a different length than the poll's options. + """ + + def get_vote(name): + """Return the votes cast by ``name`` as a plain ``list[bool]``, + or ``None`` if the participant has not voted yet.""" + + def get_votes(): + """Return a plain ``dict[str, list[bool]]`` snapshot of all votes. + + Mutating the returned mapping or its lists does not affect the + stored state. + """ + + def remove_vote(name): + """Remove ``name``'s vote. Return ``True`` if a vote was removed, + ``False`` if the participant had not voted.""" + + def participants(): + """Return participant names that have cast a vote, sorted + alphabetically.""" + + def tally(): + """Return a ``list[int]`` of ``yes`` counts, parallel to + ``poll.options``.""" diff --git a/src/experimental/doodle/profiles/default/metadata.xml b/src/experimental/doodle/profiles/default/metadata.xml index b0e12b9..785e1e2 100644 --- a/src/experimental/doodle/profiles/default/metadata.xml +++ b/src/experimental/doodle/profiles/default/metadata.xml @@ -1,6 +1,6 @@ - 1000 + 1002 diff --git a/src/experimental/doodle/profiles/default/types.xml b/src/experimental/doodle/profiles/default/types.xml index bed2b0d..bca4198 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/Poll.xml b/src/experimental/doodle/profiles/default/types/Poll.xml new file mode 100644 index 0000000..bdbd5b0 --- /dev/null +++ b/src/experimental/doodle/profiles/default/types/Poll.xml @@ -0,0 +1,91 @@ + + + + + Poll + A Doodle-style poll to find the best time for an appointment. + + False + Poll + string:contenttype/event + + + + + True + True + + + cmf.AddPortalContent + experimental.doodle.content.poll.Poll + + + experimental.doodle.content.poll.IPoll + + + + + + + + + + + string:${folder_url}/++add++Poll + poll_view + False + poll_view + + + + + + + + + + + + + + + + + + + + diff --git a/src/experimental/doodle/upgrades/configure.zcml b/src/experimental/doodle/upgrades/configure.zcml index 1b7b752..bb3a8c8 100644 --- a/src/experimental/doodle/upgrades/configure.zcml +++ b/src/experimental/doodle/upgrades/configure.zcml @@ -1,20 +1,26 @@ - + handler="experimental.doodle.upgrades.v1001.install_poll_type" + /> + + diff --git a/src/experimental/doodle/upgrades/v1001.py b/src/experimental/doodle/upgrades/v1001.py new file mode 100644 index 0000000..7426ba7 --- /dev/null +++ b/src/experimental/doodle/upgrades/v1001.py @@ -0,0 +1,17 @@ +"""Upgrade step to profile version 1001: install the Poll content type.""" + +from experimental.doodle import logger +from experimental.doodle import PACKAGE_NAME + + +PROFILE_ID = f"profile-{PACKAGE_NAME}:default" + + +def install_poll_type(setup_tool): + """Register the Poll Dexterity FTI on sites installed before 1001. + + Re-imports the ``typeinfo`` step from the default profile, which is + idempotent: existing FTIs are updated, missing ones are added. + """ + logger.info("Upgrading to 1001: installing Poll content type.") + setup_tool.runImportStepFromProfile(PROFILE_ID, "typeinfo") diff --git a/src/experimental/doodle/upgrades/v1002.py b/src/experimental/doodle/upgrades/v1002.py new file mode 100644 index 0000000..979eacd --- /dev/null +++ b/src/experimental/doodle/upgrades/v1002.py @@ -0,0 +1,18 @@ +"""Upgrade step to profile version 1002: register Poll browser views.""" + +from experimental.doodle import logger +from experimental.doodle import PACKAGE_NAME + + +PROFILE_ID = f"profile-{PACKAGE_NAME}:default" + + +def install_poll_views(setup_tool): + """Update the Poll FTI with the new default view on existing sites. + + Re-imports the ``typeinfo`` step from the default profile so that + the ``default_view`` and ``view_methods`` on the Poll FTI are + updated to include ``poll_view`` and ``results``. + """ + logger.info("Upgrading to 1002: registering Poll browser views.") + setup_tool.runImportStepFromProfile(PROFILE_ID, "typeinfo") diff --git a/tests/adapters/__init__.py b/tests/adapters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/adapters/test_vote_storage.py b/tests/adapters/test_vote_storage.py new file mode 100644 index 0000000..8a70932 --- /dev/null +++ b/tests/adapters/test_vote_storage.py @@ -0,0 +1,174 @@ +"""Tests for the VoteStorage adapter.""" + +from datetime import datetime +from datetime import timedelta +from datetime import timezone +from experimental.doodle.adapters.vote_storage import ANNOTATION_KEY +from experimental.doodle.adapters.vote_storage import VoteStorage +from experimental.doodle.interfaces import IVoteStorage +from plone import api +from plone.app.testing import setRoles +from plone.app.testing import TEST_USER_ID +from zope.annotation.interfaces import IAnnotations + +import pytest + + +@pytest.fixture +def three_slots(): + """Three timezone-aware datetimes in the future.""" + base = datetime.now(tz=timezone.utc) + timedelta(days=1) + return [base, base + timedelta(hours=1), base + timedelta(hours=2)] + + +@pytest.fixture +def poll(integration, three_slots): + """A Poll with three options inside the test portal.""" + portal = integration["portal"] + setRoles(portal, TEST_USER_ID, ["Manager"]) + return api.content.create( + container=portal, + type="Poll", + title="Team lunch", + options=three_slots, + ) + + +class TestAdapterLookup: + """``IVoteStorage`` resolves to ``VoteStorage`` for a Poll.""" + + def test_adapter_returns_vote_storage(self, poll): + storage = IVoteStorage(poll) + assert isinstance(storage, VoteStorage) + + def test_adapter_creates_annotation_on_first_write(self, poll): + """The annotation must not be created until a vote is actually cast. + + Creating it on read would cause a database write during GET + requests and trigger Plone's CSRF check. + """ + annotations = IAnnotations(poll) + assert ANNOTATION_KEY not in annotations + + IVoteStorage(poll).cast_vote("Alice", [True, True, True]) + assert ANNOTATION_KEY in annotations + + +class TestCastVote: + """Recording votes.""" + + def test_cast_vote_stores_choices(self, poll): + storage = IVoteStorage(poll) + storage.cast_vote("Alice", [True, False, True]) + assert storage.get_vote("Alice") == [True, False, True] + + def test_cast_vote_is_idempotent_overwrite(self, poll): + storage = IVoteStorage(poll) + storage.cast_vote("Alice", [True, False, True]) + storage.cast_vote("Alice", [False, False, False]) + assert storage.get_vote("Alice") == [False, False, False] + assert storage.participants() == ["Alice"] + + def test_cast_vote_strips_whitespace_preserves_case(self, poll): + storage = IVoteStorage(poll) + storage.cast_vote(" Alice ", [True, True, True]) + assert storage.get_vote("Alice") == [True, True, True] + assert "Alice" in storage.participants() + + def test_cast_vote_coerces_to_bool(self, poll): + storage = IVoteStorage(poll) + storage.cast_vote("Alice", [1, 0, "yes"]) + assert storage.get_vote("Alice") == [True, False, True] + + +class TestCastVoteErrors: + """``cast_vote`` rejects invalid input.""" + + @pytest.mark.parametrize("bad_name", ["", " ", "\t\n"]) + def test_empty_name_raises(self, poll, bad_name): + storage = IVoteStorage(poll) + with pytest.raises(ValueError): + storage.cast_vote(bad_name, [True, False, True]) + + def test_non_string_name_raises(self, poll): + storage = IVoteStorage(poll) + with pytest.raises(ValueError): + storage.cast_vote(None, [True, False, True]) + + def test_wrong_length_raises(self, poll): + storage = IVoteStorage(poll) + with pytest.raises(ValueError): + storage.cast_vote("Alice", [True, False]) # poll has 3 options + + +class TestReading: + """Reading back stored votes.""" + + def test_get_vote_returns_none_for_unknown(self, poll): + storage = IVoteStorage(poll) + assert storage.get_vote("Nobody") is None + + def test_get_votes_returns_plain_dict_snapshot(self, poll): + storage = IVoteStorage(poll) + storage.cast_vote("Alice", [True, False, True]) + + snapshot = storage.get_votes() + assert snapshot == {"Alice": [True, False, True]} + + # Mutating the snapshot must not affect the stored state. + snapshot["Alice"][0] = False + snapshot["Mallory"] = [True, True, True] + assert storage.get_vote("Alice") == [True, False, True] + assert "Mallory" not in storage.participants() + + def test_participants_sorted(self, poll): + storage = IVoteStorage(poll) + storage.cast_vote("Charlie", [True, True, True]) + storage.cast_vote("Alice", [False, False, False]) + storage.cast_vote("Bob", [True, False, True]) + + assert storage.participants() == ["Alice", "Bob", "Charlie"] + + +class TestRemoveVote: + """Removing votes.""" + + def test_remove_vote_existing_returns_true(self, poll): + storage = IVoteStorage(poll) + storage.cast_vote("Alice", [True, False, True]) + + assert storage.remove_vote("Alice") is True + assert storage.get_vote("Alice") is None + + def test_remove_vote_unknown_returns_false(self, poll): + storage = IVoteStorage(poll) + assert storage.remove_vote("Nobody") is False + + +class TestTally: + """Aggregating yes-counts per option.""" + + def test_tally_empty_poll(self, poll): + storage = IVoteStorage(poll) + assert storage.tally() == [0, 0, 0] + + def test_tally_counts_yes_per_option(self, poll): + storage = IVoteStorage(poll) + storage.cast_vote("Alice", [True, False, True]) + storage.cast_vote("Bob", [True, True, False]) + storage.cast_vote("Charlie", [False, True, True]) + + # Option 0: Alice + Bob = 2 yes + # Option 1: Bob + Charlie = 2 yes + # Option 2: Alice + Charlie = 2 yes + assert storage.tally() == [2, 2, 2] + + def test_tally_ignores_extra_entries_when_options_shrink(self, poll): + """If the Poll loses an option after a vote, tally truncates.""" + storage = IVoteStorage(poll) + storage.cast_vote("Alice", [True, True, True]) + + # Simulate the Poll losing one option. + poll.options = poll.options[:2] + + assert storage.tally() == [1, 1] diff --git a/tests/content/__init__.py b/tests/content/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/content/test_poll.py b/tests/content/test_poll.py new file mode 100644 index 0000000..a2d4cf8 --- /dev/null +++ b/tests/content/test_poll.py @@ -0,0 +1,122 @@ +"""Tests for the Poll content type.""" + +from datetime import datetime +from datetime import timedelta +from datetime import timezone +from experimental.doodle.content.poll import IPoll +from experimental.doodle.content.poll import Poll +from plone import api +from plone.app.testing import setRoles +from plone.app.testing import TEST_USER_ID +from types import SimpleNamespace +from zope.interface import Invalid + +import pytest + + +@pytest.fixture +def two_slots(): + """Return two timezone-aware datetimes in the future.""" + base = datetime.now(tz=timezone.utc) + timedelta(days=1) + return [base, base + timedelta(hours=2)] + + +@pytest.fixture +def portal_with_manager(integration): + """Portal with the test user elevated to Manager.""" + portal = integration["portal"] + setRoles(portal, TEST_USER_ID, ["Manager"]) + return portal + + +class TestPollFTI: + """The Poll FTI is registered by the install profile.""" + + def test_fti_registered(self, portal_with_manager): + portal_types = api.portal.get_tool("portal_types") + assert "Poll" in portal_types.objectIds() + + def test_fti_meta_type(self, portal_with_manager): + fti = api.portal.get_tool("portal_types")["Poll"] + assert fti.meta_type == "Dexterity FTI" + + def test_fti_klass(self, portal_with_manager): + fti = api.portal.get_tool("portal_types")["Poll"] + assert fti.klass == "experimental.doodle.content.poll.Poll" + + def test_fti_schema(self, portal_with_manager): + fti = api.portal.get_tool("portal_types")["Poll"] + assert fti.schema == "experimental.doodle.content.poll.IPoll" + + def test_fti_is_leaf(self, portal_with_manager): + """A Poll does not allow nested content.""" + fti = api.portal.get_tool("portal_types")["Poll"] + assert fti.filter_content_types is True + assert tuple(fti.allowed_content_types) == () + + +class TestPollSchema: + """The IPoll schema declares the expected fields.""" + + def test_options_field_exists(self): + assert "options" in IPoll + assert IPoll["options"].required is True + + def test_options_value_type_is_datetime(self): + from zope.schema import Datetime + + assert isinstance(IPoll["options"].value_type, Datetime) + + def test_options_default_is_empty_list(self): + """The default lets the add form render before the user enters data.""" + field = IPoll["options"] + # Either an explicit default of [] or a defaultFactory yielding []. + default = field.default if field.default is not None else field.defaultFactory() + assert default == [] + + +class TestPollInvariant: + """The 'at least two options' rule is enforced via a schema invariant.""" + + def test_invariant_fails_with_one_option(self, two_slots): + data = SimpleNamespace(options=[two_slots[0]]) + with pytest.raises(Invalid): + IPoll.validateInvariants(data) + + def test_invariant_fails_with_empty_options(self): + data = SimpleNamespace(options=[]) + with pytest.raises(Invalid): + IPoll.validateInvariants(data) + + def test_invariant_passes_with_two_options(self, two_slots): + data = SimpleNamespace(options=two_slots) + # Must not raise. + IPoll.validateInvariants(data) + + +class TestPollCreate: + """A Poll can be created and round-trips its fields.""" + + def test_create_poll(self, portal_with_manager, two_slots): + poll = api.content.create( + container=portal_with_manager, + type="Poll", + title="Team lunch", + description="When can everyone make it?", + options=two_slots, + ) + + assert poll.title == "Team lunch" + assert poll.description == "When can everyone make it?" + assert poll.options == two_slots + + def test_created_object_is_poll(self, portal_with_manager, two_slots): + poll = api.content.create( + container=portal_with_manager, + type="Poll", + title="Team lunch", + options=two_slots, + ) + + assert isinstance(poll, Poll) + assert IPoll.providedBy(poll) diff --git a/tests/setup/test_setup_install.py b/tests/setup/test_setup_install.py index 53b02c0..fb286f5 100644 --- a/tests/setup/test_setup_install.py +++ b/tests/setup/test_setup_install.py @@ -14,4 +14,4 @@ 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" + assert profile_last_version(f"{PACKAGE_NAME}:default") == "1002" diff --git a/tests/upgrades/__init__.py b/tests/upgrades/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/upgrades/test_v1001.py b/tests/upgrades/test_v1001.py new file mode 100644 index 0000000..b88b84b --- /dev/null +++ b/tests/upgrades/test_v1001.py @@ -0,0 +1,65 @@ +"""Tests for the 1000 -> 1001 upgrade step.""" + +from experimental.doodle import PACKAGE_NAME +from experimental.doodle.upgrades.v1001 import install_poll_type +from plone import api + + +PROFILE = f"{PACKAGE_NAME}:default" + + +def _list_upgrades(setup_tool, source): + """Return a flat list of upgrade step info dicts starting at ``source``.""" + grouped = setup_tool.listUpgrades(PROFILE, show_old=True) + flat = [] + for entry in grouped: + if isinstance(entry, list): + flat.extend(entry) + else: + flat.append(entry) + return [step for step in flat if step.get("ssource") == source] + + +class TestUpgradeStepRegistration: + """The 1000 -> 1001 upgrade step is registered.""" + + def test_upgrade_step_registered(self, integration): + setup_tool = api.portal.get_tool("portal_setup") + steps = _list_upgrades(setup_tool, "1000") + + destinations = {step.get("sdest") for step in steps} + assert "1001" in destinations, ( + f"No 1000 -> 1001 upgrade step registered for {PROFILE}; " + f"found destinations: {destinations}" + ) + + +class TestInstallPollTypeHandler: + """The handler (re-)installs the Poll FTI.""" + + def test_handler_restores_missing_poll_fti(self, integration): + portal_types = api.portal.get_tool("portal_types") + setup_tool = api.portal.get_tool("portal_setup") + + # Simulate a pre-1001 site: no Poll FTI yet. + portal_types.manage_delObjects(["Poll"]) + assert "Poll" not in portal_types.objectIds() + + install_poll_type(setup_tool) + + assert "Poll" in portal_types.objectIds() + fti = portal_types["Poll"] + assert fti.klass == "experimental.doodle.content.poll.Poll" + assert fti.schema == "experimental.doodle.content.poll.IPoll" + + def test_handler_is_idempotent(self, integration): + portal_types = api.portal.get_tool("portal_types") + setup_tool = api.portal.get_tool("portal_setup") + + # Poll is already there from the install profile. + assert "Poll" in portal_types.objectIds() + + install_poll_type(setup_tool) + install_poll_type(setup_tool) + + assert "Poll" in portal_types.objectIds() diff --git a/tests/views/__init__.py b/tests/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/views/test_poll_views.py b/tests/views/test_poll_views.py new file mode 100644 index 0000000..ca7fa66 --- /dev/null +++ b/tests/views/test_poll_views.py @@ -0,0 +1,177 @@ +"""Functional tests for the Poll browser views.""" + +from datetime import datetime +from datetime import timedelta +from datetime import timezone +from experimental.doodle.interfaces import IVoteStorage +from plone import api +from plone.app.testing import setRoles +from plone.app.testing import TEST_USER_ID +from plone.app.testing import TEST_USER_NAME +from plone.app.testing import TEST_USER_PASSWORD +from plone.testing.zope import Browser + +import pytest +import transaction + + +@pytest.fixture +def three_slots(): + base = datetime.now(tz=timezone.utc) + timedelta(days=1) + return [base, base + timedelta(hours=1), base + timedelta(hours=2)] + + +@pytest.fixture +def poll_url(functional, three_slots): + """Create a Poll, commit it, and return its absolute URL. + + We return the URL string — not the object — so that functional-layer + tests can access it through the WSGI browser without crossing ZODB + connection boundaries. + """ + portal = functional["portal"] + setRoles(portal, TEST_USER_ID, ["Manager"]) + poll = api.content.create( + container=portal, + type="Poll", + title="Lunch poll", + description="Pick a time for lunch.", + options=three_slots, + ) + api.content.transition(poll, "publish") + url = poll.absolute_url() + transaction.commit() + return url + + +@pytest.fixture +def browser(functional): + """An unauthenticated Zope test browser.""" + b = Browser(functional["app"]) + b.handleErrors = False + return b + + +@pytest.fixture +def auth_browser(functional): + """A test browser authenticated as the test manager.""" + b = Browser(functional["app"]) + b.handleErrors = False + portal_url = functional["portal"].absolute_url() + b.open(portal_url + "/login") + b.getControl(name="__ac_name").value = TEST_USER_NAME + b.getControl(name="__ac_password").value = TEST_USER_PASSWORD + b.getControl(name="buttons.login").click() + return b + + +class TestPollView: + """The ``@@poll_view`` renders the vote form.""" + + def test_poll_view_renders(self, poll_url, browser): + browser.open(poll_url + "/@@poll_view") + assert browser.headers["Status"] == "200 OK" + assert "Lunch poll" in browser.contents + + def test_poll_view_shows_options(self, poll_url, browser, three_slots): + browser.open(poll_url + "/@@poll_view") + for slot in three_slots: + assert slot.strftime("%H:%M") in browser.contents + + def test_poll_view_form_present(self, poll_url, browser): + browser.open(poll_url + "/@@poll_view") + assert 'method="POST"' in browser.contents + assert 'name="name"' in browser.contents + + +class TestVotePost: + """Submitting the vote form records the vote and redirects.""" + + def _submit_vote(self, browser, poll_url, name, yes_indices=()): + """Open the poll view, fill in ``name``, set yes on ``yes_indices``.""" + browser.open(poll_url + "/@@poll_view") + browser.getControl(name="name").value = name + for i in yes_indices: + browser.getControl(name=f"votes_{i}", index=0).selected = True + browser.getForm(action="@@poll_view").submit() + + def test_vote_redirects_to_poll_view(self, poll_url, browser): + self._submit_vote(browser, poll_url, "Alice") + assert "@@poll_view" in browser.url + + def test_vote_is_stored(self, poll_url, functional, browser): + self._submit_vote(browser, poll_url, "Alice") + + portal = functional["portal"] + poll_id = poll_url.rstrip("/").split("/")[-1] + poll = portal[poll_id] + assert "Alice" in IVoteStorage(poll).participants() + + def test_vote_missing_name_shows_error(self, poll_url, browser): + self._submit_vote(browser, poll_url, "") + assert "@@poll_view" in browser.url + assert "name" in browser.contents.lower() + + def test_second_vote_overwrites_first(self, poll_url, functional, browser): + for _ in range(2): + self._submit_vote(browser, poll_url, "Alice") + + portal = functional["portal"] + poll_id = poll_url.rstrip("/").split("/")[-1] + poll = portal[poll_id] + assert IVoteStorage(poll).participants().count("Alice") == 1 + + +class TestResultsView: + """The ``@@results`` view shows the tally.""" + + def test_results_view_renders(self, poll_url, browser): + browser.open(poll_url + "/@@results") + assert browser.headers["Status"] == "200 OK" + assert "Lunch poll" in browser.contents + + def test_results_view_shows_tally_after_vote(self, poll_url, browser): + browser.open(poll_url + "/@@poll_view") + browser.getControl(name="name").value = "Alice" + for i in range(3): + browser.getControl(name=f"votes_{i}").value = ["true"] + browser.getForm(action="@@poll_view").submit() + + browser.open(poll_url + "/@@results") + assert ">1<" in browser.contents + + def test_results_view_lists_participants(self, poll_url, browser): + browser.open(poll_url + "/@@poll_view") + browser.getControl(name="name").value = "Bob" + browser.getForm(action="@@poll_view").submit() + + browser.open(poll_url + "/@@results") + assert "Bob" in browser.contents + + def test_results_view_links_back_to_poll(self, poll_url, browser): + browser.open(poll_url + "/@@results") + assert "@@poll_view" in browser.contents + + +class TestUpgradeStep1002: + """The 1001 → 1002 upgrade step updates the Poll FTI.""" + + def test_upgrade_step_registered(self, integration): + setup_tool = api.portal.get_tool("portal_setup") + grouped = setup_tool.listUpgrades("experimental.doodle:default", show_old=True) + flat = [] + for entry in grouped: + flat.extend(entry if isinstance(entry, list) else [entry]) + destinations = {s.get("sdest") for s in flat if s.get("ssource") == "1001"} + assert "1002" in destinations + + def test_handler_updates_fti_default_view(self, integration): + from experimental.doodle.upgrades.v1002 import install_poll_views + + portal_types = api.portal.get_tool("portal_types") + fti = portal_types["Poll"] + fti.default_view = "view" # simulate pre-1002 state + + install_poll_views(api.portal.get_tool("portal_setup")) + + assert fti.default_view == "poll_view"