diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..912933a Binary files /dev/null and b/.DS_Store differ diff --git a/.github/agents/dev-agent.agent.md b/.github/agents/dev-agent.agent.md new file mode 100644 index 0000000..d306676 --- /dev/null +++ b/.github/agents/dev-agent.agent.md @@ -0,0 +1,179 @@ +--- +name: "Senior Plone Doodle Developer" +description: "Use when developing, refactoring, or debugging the experimental.doodle Plone add-on; for Dexterity content types, GenericSetup, Classic UI views, browser layers, control panels, registry settings, tests, and local Plone site work at localhost:8080/Plone." +tools: [read, edit, search, execute, todo] +user-invocable: true +agents: [] +--- +You are a senior Plone developer working on the `experimental.doodle` repository. +Your role is to build a small, maintainable Plone add-on that reproduces core Doodle-style scheduling workflows inside Plone Classic UI. + +## Mission +- Build a Plone-native MVP for polls and booking pages. +- Keep the add-on installable, testable, and easy to evolve. +- Prefer the simplest correct Plone implementation over broad feature ambition. +- Make the add-on usable in the local site at `http://localhost:8080/Plone`. + +## Repo Facts +- Package root: `src/experimental/doodle/` +- Python package name: `experimental.doodle` +- Current browser layer: `experimental.doodle.interfaces.IBrowserLayer` +- GenericSetup profiles: `experimental.doodle:default` and `experimental.doodle:uninstall` +- Local site bootstrap script: `scripts/create_site.py` +- Primary developer commands: `make install`, `make start`, `make create-site`, `make test`, `make lint`, `make format`, `make check` +- Supported environment: Plone 6.0-6.2, Python 3.10-3.13 +- Current repo state: scaffolded add-on with install profile, browser layer, test layer, and mostly empty feature packages + +## Required Mindset +- Act like a senior Plone add-on engineer, not a generic Python developer. +- Assume Plone conventions first: Dexterity, GenericSetup, browser views, registry, permissions, and integration tests. +- Favor maintainability, explicit configuration, and predictable upgrade paths. +- Deliver small vertical slices that can be installed and verified in the running Plone site. + +## Core Development Rules +- Model business objects as Dexterity content types unless there is a strong reason not to. +- Keep add-on configuration inside GenericSetup under `profiles/default`. +- Register all site behavior through installable configuration, not ad hoc runtime mutations. +- Use the existing browser layer for add-on-specific views, forms, assets, and overrides. +- Keep page templates simple; move logic into Python views, helpers, or adapters. +- Use behaviors only when fields or logic are genuinely reusable across types. +- Use the Plone registry and a control panel for site-wide defaults. +- Add catalog indexes only when a real query or view requires them. +- Preserve existing `plonecli` or `bobtemplates.plone` marker comments. +- Do not introduce Volto-specific work unless explicitly requested; default to Classic UI. + +## Execution Rule +Before implementing feature code, always validate the current repository baseline. + +For every development task: +1. Inspect the existing scaffold and conventions first. +2. Make the smallest coherent change. +3. Wire changes through Plone conventions: GenericSetup, ZCML, browser layer, registry, permissions, or tests as appropriate. +4. Run the smallest relevant validation command. +5. Report what changed, what validation ran, and what remains deferred. + +Do not skip directly to broad feature implementation. + +## Implementation Priorities +1. Keep the add-on installable and uninstallable. +2. Build the MVP around `Poll` and `Booking Page`. +3. Make each feature accessible in the local site. +4. Add tests as each slice lands. +5. Add polish only after core workflows work. + +## Plone-Specific Architecture Rules +### Content Types +- Start with two main Dexterity types: `Poll` and `Booking Page`. +- Prefer `Item` base classes for the MVP unless container behavior is clearly required. +- Store only the minimum data needed for the first scheduling workflows. +- If adding types, update `profiles/default/types.xml` and the matching FTI files under `profiles/default/types/`. + +### GenericSetup +- Any new type, registry record, control panel entry, browser layer dependency, or catalog change must be represented in `profiles/default`. +- If configuration changes need a migration path, add upgrade steps instead of relying on reinstall-only behavior. +- Keep install profile changes deterministic so a fresh site and an upgraded site converge to the same state. + +### Browser Layer and Views +- Register custom views against `experimental.doodle.interfaces.IBrowserLayer`. +- Use BrowserView or standard Plone form patterns for Classic UI. +- Do not place business logic in TAL templates. +- Use `browser/overrides` with `z3c.jbot` only when a dedicated custom view is insufficient. + +### Control Panels and Registry +- Put site-wide settings behind a control panel instead of hardcoding them. +- Store configurable defaults in `profiles/default/registry/` and corresponding Python schemas. +- Keep the first settings small: slot duration, anonymous voting, booking confirmation, timezone defaults. + +### Permissions and Security +- Respect Plone role-based permissions and workflow semantics. +- Reuse built-in permissions where possible. +- Add custom permissions only when the add-on needs distinct capabilities. +- Be explicit about anonymous access for voting and booking. +- Validate booking conflicts and poll state server-side, not only in the UI. + +### Internationalization +- All user-facing strings must be translatable. +- Use the package message factory from `experimental.doodle._`. +- Keep the i18n domain as `experimental.doodle`. + +## UI Rules +- Build for Plone Classic UI first. +- Use standard Plone page templates and macros. +- Keep templates readable and thin. +- Put interaction logic in views or forms. +- Use `browser/static` for CSS or JavaScript only after the base UI works. +- Avoid broad template overrides early; prefer explicit views for poll display, voting, results, and booking. +- Keep the UI usable on desktop and mobile, but do not overengineer styling in the first milestone. + +## Testing Rules +- Use the existing Plone test layer in `src/experimental/doodle/testing.py`. +- Add integration tests for every feature slice. +- Keep the setup tests passing. +- Add tests for at least these cases when relevant: + - type registration + - control panel registration + - poll creation + - vote submission + - vote aggregation + - booking submission + - double-booking prevention + - permission-sensitive behavior +- Prefer narrow validation first, then broader checks. +- Run the smallest relevant test or command after each substantive edit. + +## Local Workflow +- Use `make install` to prepare the environment. +- Use `make start` to run the local Plone instance. +- Use `make create-site` to create or refresh the `Plone` site when needed. +- Verify work in `http://localhost:8080/Plone` whenever a user-facing feature changes. +- Use `make test` for focused validation and `make check` before concluding larger work. + +## Code Quality Rules +- Follow the Ruff and formatting configuration already in `pyproject.toml`. +- Keep imports and formatting consistent with repo tooling. +- Keep changes small, local, and reversible. +- Do not add new dependencies without a clear Plone add-on need. +- Do not create parallel architectures when the scaffold already provides the right extension point. +- Do not leave partially wired GenericSetup or ZCML registrations behind. + +## Documentation Rules +- When editing `README.md` or files under `docs/docs/`, follow the repo documentation instructions. +- Use `make install` and `make start` in developer-facing setup docs. +- Do not recommend direct `pip install`, `uv add`, or `uv pip` in project docs. +- Do not edit the generated Cookieplone attribution paragraph in `README.md`. +- Keep docs aligned with the actual implemented feature set. + +## What To Avoid +- Do not treat this as a generic Flask or Django app. +- Do not bypass GenericSetup with manual portal changes in code. +- Do not store essential behavior only in templates or JavaScript. +- Do not assume external calendar sync belongs in the MVP. +- Do not add complex integrations before polls and booking pages work locally. +- Do not widen the scope if the current slice can be completed and tested first. + +## Recommended Delivery Order +1. Validate install baseline and tests. +2. Implement `Poll` type and schema. +3. Implement poll add, display, vote, and results flows. +4. Add poll tests. +5. Implement `Booking Page` type and schema. +6. Implement booking availability and conflict logic. +7. Implement booking UI and tests. +8. Add registry settings and control panel. +9. Add targeted catalog indexing and listing views. +10. Add refinements only after the core workflows are working. + +## Output Expectations +When you complete a task, report: +- what changed +- how the change fits the Plone add-on architecture +- what validation you ran +- what remains blocked or intentionally deferred + +## Success Criteria +Success means the add-on: +- installs cleanly +- works in `http://localhost:8080/Plone` +- follows standard Plone add-on patterns +- keeps GenericSetup, ZCML, tests, and UI in sync +- remains simple enough for the next developer to extend safely diff --git a/docs/.DS_Store b/docs/.DS_Store new file mode 100644 index 0000000..b1bc858 Binary files /dev/null and b/docs/.DS_Store differ diff --git a/docs/docs/.DS_Store b/docs/docs/.DS_Store new file mode 100644 index 0000000..59cb319 Binary files /dev/null and b/docs/docs/.DS_Store differ diff --git a/docs/docs/reference/doodle-fr.md b/docs/docs/reference/doodle-fr.md new file mode 100644 index 0000000..faedfad --- /dev/null +++ b/docs/docs/reference/doodle-fr.md @@ -0,0 +1,141 @@ +--- +myst: + html_meta: + "description": "Functional requirements for the Experimental Doodle add-on" + "property=og:description": "Functional requirements for the Experimental Doodle add-on" + "property=og:title": "Experimental Doodle Functional Requirements" + "keywords": "Plone, Doodle, functional requirements, scheduling" +--- + +# โš™๏ธ Functional Requirements + +This document defines the functional requirements for a Doodle-like scheduling add-on implemented in Plone. +It focuses on what the system must do from a user and administrator perspective. +The requirements below are written to support a simple first implementation in this repository. + +## ๐ŸŽฏ Functional objective + +The add-on must support scheduling workflows that reduce manual coordination. +It should allow users to either collect availability from a group or publish their own availability for direct booking. + +For the MVP, the add-on must favor simple Plone-native workflows over broad third-party integration. + +## ๐Ÿ‘ค User roles + +- Organizer: creates and manages polls or booking pages. +- Participant: votes in polls. +- Booker: reserves an available slot on a booking page. +- Site administrator: configures system-wide defaults, permissions, and integrations. + +For a first implementation, organizer actions may be limited to authenticated Plone users with appropriate permissions. + +## ๐Ÿ—ณ๏ธ Functional requirements for group polls + +### ๐Ÿงฑ Poll creation + +- FR-01: The system must provide a dedicated Plone content type for a poll or an equivalent add-on managed object with an add form. +- The system must allow an organizer to create a new poll. +- The system must allow the organizer to enter a title and description. +- The system must allow the organizer to define multiple candidate dates and time slots. +- The system must allow the organizer to specify optional metadata such as location, deadline, or notes. +- The system must allow the organizer to publish or close a poll. + +### ๐Ÿ™‹ Participation and voting + +- The system must allow participants to access a poll through a shared link or Plone view. +- The system must allow participants to identify themselves before voting, when required. +- The system must allow participants to vote on one or more available time slots. +- The system must store each participant response. +- The system must prevent invalid or duplicate submissions according to the configured rules. + +For the MVP, anonymous participation should be optional rather than mandatory. + +### ๐Ÿ“Š Poll results + +- The system must aggregate votes per proposed slot. +- The system must present results in a way that helps identify the best meeting time. +- The system must allow the organizer to select a final slot. +- The system must display the final decision when a poll is concluded. + +- FR-02: The system must be able to compute the winning or selected slot without requiring manual aggregation outside Plone. + +## ๐Ÿ“† Functional requirements for booking pages + +### ๐Ÿ› ๏ธ Availability management + +- FR-03: The system must provide a dedicated Plone content type for a booking page or an equivalent add-on managed object with an edit form. +- The system must allow an organizer to create a booking page. +- The system must allow an organizer to define available working days and working hours. +- The system must allow an organizer to configure meeting duration. +- The system must allow an organizer to configure buffers between meetings. +- The system must allow an organizer to block unavailable periods manually. + +### ๐Ÿ“ฅ Booking workflow + +- The system must allow a visitor to view open appointment slots. +- The system must allow a visitor to select one available slot. +- The system must collect the minimum information required to create a booking. +- The system must create a booking record after successful submission. +- The system must mark the selected slot as unavailable after booking. +- The system must allow the organizer to confirm or cancel a booking. + +- FR-04: The system must validate slot availability at submission time so two requests cannot book the same slot successfully. + +## ๐Ÿ”„ Functional requirements for calendar integration + +- The system must support connecting the organizer schedule with external calendars when integration is enabled. +- The system must use external calendar events to block conflicting availability. +- The system must support exporting confirmed meetings or finalized poll results. +- The system must avoid creating overlapping commitments when synchronized calendars contain busy events. + +For the MVP, export of selected dates or bookings is sufficient. Full two-way synchronization should remain optional for a later phase. + +## ๐Ÿ” Functional requirements for permissions and administration + +- The system must respect Plone security and role-based permissions. +- The system must allow administrators to decide who can create polls. +- The system must allow administrators to decide who can create booking pages. +- The system must provide an administrative configuration area for defaults and feature settings. +- The system must make poll and booking content searchable through standard Plone mechanisms when appropriate. + +- FR-05: The system must register its defaults and feature flags through the Plone registry and expose them through a control panel. +- FR-06: The system must register the content model and supporting configuration through the add-on GenericSetup profile. + +## ๐Ÿ“ข Functional requirements for notifications + +- The system should notify organizers when new votes are submitted, if notifications are enabled. +- The system should notify organizers when a new booking is created. +- The system should notify participants or bookers when the organizer confirms, changes, or cancels an event. + +Notifications are optional for the MVP and must not block the first usable release. + +## ๐ŸŒ Functional requirements for usability + +- The system must provide views that work on desktop and mobile devices. +- The system must present scheduling information clearly enough to reduce coordination errors. +- The system must integrate with Plone navigation and content management patterns. + +- FR-07: The system must provide standard add, edit, and display views that work within normal Plone site navigation. + +## ๐Ÿš€ Minimum viable functional scope + +The first release should include the following minimum functional set: + +- Create a poll with multiple time slots. +- Vote on poll options. +- View aggregated poll results. +- Create a booking page with manually configured availability. +- Book a free slot and prevent double booking. + +The first release should not depend on external calendar APIs, external conferencing tools, or advanced notification workflows. + +## ๐Ÿงช Functional requirements for implementation readiness + +- FR-08: The poll and booking page features must be testable through the existing Plone integration test layer. +- FR-09: The add-on must install cleanly and register its browser layer, profile version, and feature configuration. +- FR-10: The data model must be simple enough to implement in the current scaffold without introducing unnecessary infrastructure. + +## ๐Ÿ“Œ Summary + +The functional scope of the add-on is to support two primary scheduling models: poll-based coordination and direct booking. +For this repository, that means starting with poll and booking page features that fit naturally into Dexterity, GenericSetup, catalog indexing, and standard Plone views. \ No newline at end of file diff --git a/docs/docs/reference/doodle-planning.md b/docs/docs/reference/doodle-planning.md new file mode 100644 index 0000000..5c82b49 --- /dev/null +++ b/docs/docs/reference/doodle-planning.md @@ -0,0 +1,387 @@ +--- +myst: + html_meta: + "description": "Step-by-step planning for building the Experimental Doodle Plone add-on" + "property=og:description": "Step-by-step planning for building the Experimental Doodle Plone add-on" + "property=og:title": "Experimental Doodle Development Planning" + "keywords": "Plone, Doodle, planning, addon development, UI" +--- + +# ๐Ÿ› ๏ธ Doodle Development Planning + +This document is a step-by-step implementation plan for building a simple Doodle-like scheduling add-on for Plone. +It is written to match the current repository scaffold and to help a developer move from a fresh add-on skeleton to a usable feature set at `http://localhost:8080/Plone`. + +## ๐ŸŽฏ Planning goal + +The goal is to build a small but usable Plone add-on that supports: + +- Poll-based scheduling. +- Simple personal booking pages. +- A basic user interface in Plone Classic UI. +- Clean installation in a local development site. + +The first implementation should favor maintainability and fast feedback over full feature parity with Doodle. + +## ๐Ÿงญ Development guidelines for Plone add-ons + +According to the Plone documentation, a maintainable add-on should follow the standard Plone extension model. +For this project, that means: + +- Model business objects as Dexterity content types. +- Keep add-on setup in GenericSetup profiles under `profiles/default`. +- Use the Plone registry and a control panel for configurable defaults. +- Register add-on-specific views against the existing browser layer. +- Keep UI logic in browser views and keep page templates simple. +- Use behaviors for reusable field groups or reusable business logic. +- Add only the catalog indexes and metadata columns that are required. +- Cover features with integration tests in the existing test layer. +- Keep the first release independent from large external integrations. + +## ๐Ÿงฑ Target architecture for this add-on + +The recommended first architecture is: + +- `Poll` as a Dexterity content type. +- `Booking Page` as a Dexterity content type. +- One or more browser views for voting, booking, and results. +- Registry settings for defaults such as slot duration, whether anonymous voting is allowed, and basic booking behavior. +- Catalog indexes for looking up polls, booking pages, and selected states efficiently. + +For the MVP, avoid a large data model. Start with two main content types and the minimum supporting data needed for votes and bookings. + +## ๐Ÿš€ Step 1: Prepare the local environment + +Use the repository-provided commands instead of ad hoc setup. + +### โœ… Tasks + +- Install the development environment with `make install`. +- Start Plone with `make start`. +- Create the site with `make create-site` if the `Plone` site does not already exist. +- Confirm that the site is reachable at `http://localhost:8080/Plone`. + +### ๐Ÿ“Œ Expected result + +- The Plone instance starts locally. +- The `Plone` site exists. +- The add-on profile is applied by the site creation script. + +## ๐Ÿ” Step 2: Verify the current add-on baseline + +Before adding new features, verify the existing scaffold. + +### โœ… Tasks + +- Confirm the install profile version in `profiles/default/metadata.xml`. +- Confirm the browser layer registration in `profiles/default/browserlayer.xml`. +- Confirm that the add-on install test passes. +- Run `make test` to establish the current baseline. + +### ๐Ÿ“Œ Expected result + +- The package installs cleanly. +- The browser layer is active after installation. +- Existing tests pass before feature work begins. + +## ๐Ÿงฉ Step 3: Define the MVP content model + +According to the Plone documentation, custom business data should be modeled with Dexterity content types and behaviors where appropriate. + +### โœ… Tasks + +- Define a `Poll` content type. +- Define a `Booking Page` content type. +- Decide whether votes and bookings will be stored as child content items, annotations, or serialized structured fields. +- Keep the storage model simple enough to implement and test quickly. + +### ๐Ÿ“‹ Recommended MVP fields + +For `Poll`: + +- Title. +- Description. +- Organizer reference or creator ownership. +- Poll state. +- Deadline. +- Optional location. +- Proposed slots. +- Final selected slot. + +For `Booking Page`: + +- Title. +- Description. +- Organizer reference or creator ownership. +- Working days. +- Working hours. +- Slot duration. +- Buffer duration. +- Availability exceptions. +- Booking state. + +### ๐Ÿ“Œ Expected result + +- A developer can point to a concrete schema for each type. +- The model is small enough to support a first usable release. + +## ๐Ÿ—๏ธ Step 4: Generate or add the content types + +According to the Plone documentation, content types are commonly added with PloneCLI and registered through the add-on package. + +### โœ… Tasks + +- Add a `Poll` content type. +- Add a `Booking Page` content type. +- Review the generated FTI and schema files. +- Register the new types in `profiles/default/types.xml`. +- Update `profiles/default/types/` with the correct FTI definitions. + +### ๐Ÿ“ Developer notes + +- Use `Container` only if a type truly needs to contain child objects. +- Use `Item` if the type is a standalone object without child content. +- For the MVP, a `Poll` may be an `Item` if slots are stored in fields rather than child items. +- A `Booking Page` can also start as an `Item` unless child booking objects are required immediately. + +### ๐Ÿ“Œ Expected result + +- The new content types appear in the site after reinstalling or reapplying the profile. + +## ๐Ÿง  Step 5: Add behaviors only where they reduce complexity + +According to the Plone documentation, behaviors are best used for reusable fields or reusable logic. + +### โœ… Tasks + +- Reuse built-in behaviors where they make sense, such as title, description, or rich text. +- Add custom behaviors only if the same scheduling fields or logic are shared across multiple types. +- Avoid creating behaviors too early if plain schemas are simpler. + +### ๐Ÿ“Œ Expected result + +- The data model stays readable. +- Reuse happens only where it has clear value. + +## โš™๏ธ Step 6: Add registry settings and a control panel + +The repository already includes control panel scaffolding, so the next step is to make it useful. + +### โœ… Tasks + +- Define registry records for add-on defaults. +- Add a control panel form for site managers. +- Register the control panel entry through the install profile. + +### ๐Ÿ“‹ Recommended first settings + +- Default poll slot duration. +- Default booking slot duration. +- Whether anonymous poll voting is enabled. +- Whether booking confirmation is required. +- Default timezone handling. + +### ๐Ÿ“Œ Expected result + +- Site managers can manage add-on defaults from Plone Site Setup. + +## ๐Ÿงฎ Step 7: Implement scheduling logic in Python + +The first implementation needs reliable backend rules before UI refinement. + +### โœ… Tasks + +- Add helper methods to normalize date and time slot data. +- Add vote aggregation logic for polls. +- Add conflict detection logic for bookings. +- Validate availability at submission time. +- Keep domain logic out of page templates. + +### ๐Ÿ“‹ Core rules + +- A poll vote must be stored only once per participant according to the selected policy. +- A booking must fail if the requested slot is no longer available. +- A closed poll must reject further votes. +- A disabled or closed booking page must reject new bookings. + +### ๐Ÿ“Œ Expected result + +- The backend can decide whether a vote or booking is valid. +- Core business behavior is testable without relying on UI details. + +## ๐Ÿ–ฅ๏ธ Step 8: Build the Plone UI in Classic UI + +According to the Plone documentation, the usual pattern is to implement browser views and templates, register them in ZCML, and keep heavy logic in Python. + +### โœ… Tasks + +- Create a poll display view. +- Create a vote submission view or form. +- Create a results view. +- Create a booking page view that lists available slots. +- Create a booking submission view or form. +- Register these views against the add-on browser layer. + +### ๐ŸŽจ UI guidelines + +- Keep templates simple and driven by view methods. +- Use normal Plone page templates and the main template macros. +- Keep forms understandable with clear labels and messages. +- Make the scheduling workflow obvious on mobile and desktop. +- Avoid overriding core templates until the custom views are working. + +### ๐Ÿงฉ Static resources + +- Add CSS and JavaScript only when the base form behavior is working. +- Keep UI enhancements modest for the MVP. +- Use the existing `browser/static` location for add-on assets. + +### ๐Ÿ› ๏ธ When to use overrides + +- Use `browser/overrides` with `z3c.jbot` only when a core or third-party template really must be customized. +- Prefer custom views over broad template overrides. + +### ๐Ÿ“Œ Expected result + +- A user can browse to a poll or booking page and complete the main action from the Plone UI. + +## ๐Ÿ” Step 9: Define permissions and access rules + +The add-on must be usable inside a normal Plone site without weakening security. + +### โœ… Tasks + +- Decide who can add polls. +- Decide who can add booking pages. +- Decide whether anonymous voting is allowed. +- Decide whether anonymous booking is allowed. +- Register any custom permissions only if built-in permissions are insufficient. + +### ๐Ÿ“Œ Recommended MVP policy + +- Authenticated users create polls and booking pages. +- Poll viewing can be public if the content is published. +- Anonymous voting can be optional and controlled by a site setting. +- Booking can start as authenticated-only if that reduces complexity. + +### ๐Ÿ“Œ Expected result + +- Access behavior is predictable and testable. + +## ๐Ÿ”Ž Step 10: Add catalog indexing only for real use cases + +The current profile already has catalog scaffolding, so add only the indexes needed for feature support. + +### โœ… Tasks + +- Add indexes or metadata columns for poll state. +- Add indexes or metadata columns for organizer. +- Add any booking-related metadata needed for efficient lookup. +- Reindex after profile updates. + +### ๐Ÿ“Œ Expected result + +- Polls and booking pages can be queried efficiently. +- Future listing or dashboard views remain straightforward. + +## ๐Ÿงช Step 11: Write tests as features are added + +The current repository already contains integration test scaffolding and install tests. + +### โœ… Tasks + +- Add tests for type registration. +- Add tests for control panel registration. +- Add tests for poll creation. +- Add tests for vote submission. +- Add tests for vote aggregation. +- Add tests for booking availability checks. +- Add tests for double-booking prevention. +- Add tests for permission-sensitive workflows. + +### ๐Ÿ“Œ Expected result + +- The add-on can evolve without breaking core scheduling behavior. + +## ๐Ÿ” Step 12: Keep the install profile up to date + +Every new feature must be represented in GenericSetup so a site can be recreated consistently. + +### โœ… Tasks + +- Update `types.xml` and type FTIs. +- Update registry import files. +- Update `controlpanel.xml`. +- Update `catalog.xml` if indexes are added. +- Add upgrade steps when profile changes become versioned milestones. + +### ๐Ÿ“Œ Expected result + +- Installing or upgrading the add-on remains predictable. + +## ๐ŸŒ Step 13: Make the add-on usable at `http://localhost:8080/Plone` + +The local site should be the primary feedback loop during development. + +### โœ… Tasks + +- Start the instance with `make start`. +- Ensure the site exists with `make create-site`. +- Log in to `http://localhost:8080/Plone`. +- Confirm the add-on is installed in the site. +- Add a `Poll` item and verify that its add and display views work. +- Add a `Booking Page` item and verify that booking works. + +### ๐Ÿ“Œ Developer workflow + +- Use the browser to verify each newly added content type and view. +- Reapply the add-on profile or recreate the site when GenericSetup changes require it. +- Keep test execution and browser verification in sync. + +### ๐Ÿ“Œ Expected result + +- The add-on is visible and usable in your local Plone site. + +## ๐Ÿ“… Suggested implementation order + +Build in this order to reduce risk: + +1. Environment and baseline validation. +2. `Poll` type and poll schema. +3. Poll add, display, vote, and results views. +4. Poll tests. +5. `Booking Page` type and booking schema. +6. Booking availability and conflict logic. +7. Booking UI and tests. +8. Registry settings and control panel. +9. Catalog indexing and listing views. +10. Nice-to-have UI improvements. + +## ๐Ÿšซ What not to do in the first iteration + +Avoid these until the MVP works: + +- Full two-way calendar synchronization. +- External conferencing integration. +- Complicated reminder workflows. +- Too many custom behaviors. +- Heavy template overrides before custom views exist. +- Premature optimization of indexing or storage. + +## โœ… Definition of done for the first milestone + +The first milestone is complete when: + +- The add-on installs cleanly. +- `Poll` and `Booking Page` are available in Plone. +- A user can create a poll and collect votes. +- A user can create a booking page and accept a simple booking. +- Double booking is prevented. +- Basic settings exist in a control panel. +- Tests cover the main flows. +- The feature works in the local site at `http://localhost:8080/Plone`. + +## ๐Ÿ“Œ Summary + +The correct path for this repository is to build a small Plone-native scheduling product in layers: content model first, backend rules second, UI third, and refinements after tests are in place. +That will keep the add-on installable, testable, and usable in your local Plone environment while leaving room for deeper Doodle-like features later. \ No newline at end of file diff --git a/docs/docs/reference/doodle-requirements.md b/docs/docs/reference/doodle-requirements.md new file mode 100644 index 0000000..700d561 --- /dev/null +++ b/docs/docs/reference/doodle-requirements.md @@ -0,0 +1,131 @@ +--- +myst: + html_meta: + "description": "Requirements for a Doodle-like Plone add-on" + "property=og:description": "Requirements for a Doodle-like Plone add-on" + "property=og:title": "Experimental Doodle Requirements" + "keywords": "Plone, Doodle, scheduling, requirements" +--- + +# ๐Ÿ“‹ Doodle Requirements + +This document captures the baseline requirements for replicating core Doodle functionality as a Plone add-on. +It also narrows those requirements into a realistic first implementation for the current repository scaffold. + +## ๐ŸŽฏ Product goal + +The add-on should allow Plone site editors and members to coordinate meetings without relying on long email threads. +The first target is to support the two central Doodle workflows: + +- Group scheduling through polls with multiple date and time options. +- Personal booking through an availability page that others can use to reserve a slot. + +For this repository, the immediate goal should be a simple Plone-native add-on that works well inside one site before adding advanced third-party integrations. + +## ๐Ÿ‘ฅ Primary users + +- Site editors who create and manage polls or booking pages. +- Participants who vote on proposed meeting times. +- Visitors, colleagues, students, or clients who book available time slots. +- Administrators who configure defaults, permissions, and calendar integration. + +## โœ… Core functional requirements + +### ๐Ÿ—ณ๏ธ Group polls + +- A user can create a poll with a title, description, organizer, and optional location. +- A poll can contain multiple proposed dates and time slots. +- Participants can open a public or shared link and vote for the slots that work for them. +- A participant can select one or more acceptable slots. +- The poll view shows aggregated availability so the organizer can identify the best option. +- The organizer can close the poll and mark one slot as the final decision. + +### ๐Ÿ“† Booking pages + +- A user can maintain a booking page that represents personal availability. +- A booking page can define working hours, meeting duration, buffer times, and unavailable periods. +- Visitors can book one of the available time slots directly. +- A booked slot becomes unavailable to avoid double booking. +- The organizer can review, confirm, or cancel bookings. + +### ๐Ÿ”„ Calendar sync + +- The add-on should support synchronization with existing calendars such as Google Calendar, Outlook, or iCloud. +- Existing calendar events should block availability in booking pages. +- Confirmed bookings and finalized poll decisions should be exportable or synchronizable to external calendars. + +For a simple first release, calendar sync should be treated as a later phase unless an export-only approach is chosen first. + +## ๐Ÿงฉ Plone-specific requirements + +- The feature set must be delivered as a Plone add-on. +- Polls and booking pages should be manageable through Plone content or dedicated add-on views. +- Permissions should follow Plone roles so sites can control who may create, vote on, or manage schedules. +- Site managers should have a control panel to configure default behavior. +- The add-on should integrate cleanly with Plone navigation, security, and indexing. + +### ๐Ÿ—๏ธ Recommended implementation baseline + +According to the Plone documentation, custom business objects in an add-on are typically modeled as Dexterity content types, while add-on settings are managed through GenericSetup and registry-backed control panels. +For this repository, the simplest maintainable implementation path is: + +- Create a Dexterity content type for a poll. +- Create a Dexterity content type for a booking page. +- Use the existing browser layer for add-on-specific views, forms, and overrides. +- Use the existing GenericSetup profile to register types, control panel entries, and catalog indexes. +- Store configurable defaults in the Plone registry and expose them through a control panel. + +### ๐Ÿงฑ Suggested content model for the MVP + +- Poll: title, description, organizer, state, and one or more proposed time slots. +- Booking Page: title, description, organizer, meeting rules, and availability rules. +- Booking or response records: stored in a way that supports validation, conflict checks, and later reporting. + +For a simple implementation, the add-on should avoid a large object model and instead start with two main content types and minimal supporting records. + +### ๐Ÿ” Catalog and indexing requirements + +- Polls should be indexable by state, organizer, and relevant dates. +- Booking pages should be indexable by organizer and visibility. +- The data model should support looking up booked or chosen slots efficiently enough to prevent conflicts. + +### ๐Ÿงช Development requirements for this repository + +- The implementation should extend the existing install profile instead of bypassing it. +- New functionality should be covered by integration tests in the existing test layer. +- The first milestone should keep workflow and permissions simple enough to validate quickly. +- The MVP should work in classic Plone forms and views before broader UI refinement. + +## ๐Ÿ” Non-functional requirements + +- The UI should make scheduling possible with minimal back-and-forth communication. +- The add-on should prevent scheduling conflicts and double bookings. +- The add-on should be usable by students, businesses, and professional teams. +- The implementation should remain maintainable and extensible for future features such as reminders or notifications. +- The add-on should work with current supported Plone 6 versions. + +For this repository, maintainability is more important than feature completeness in the first iteration. + +## ๐Ÿš€ Suggested first implementation scope + +To keep the first iteration small, the initial release can focus on: + +- Creating and publishing a `Poll` content item. +- Allowing participants to vote on proposed slots through a Plone view. +- Showing poll results with a clear winning option. +- Providing a basic `Booking Page` content item with manually defined availability. +- Reserving booked time slots to prevent overlap. +- Adding only the minimum catalog indexes and control panel settings needed for the MVP. + +## ๐Ÿ“ Out of scope for the first iteration + +- Advanced payment workflows. +- Complex meeting workflows with multiple hosts. +- Deep integrations with external conferencing platforms. +- Full two-way calendar synchronization. +- Full parity with every premium Doodle feature. + +## ๐Ÿ“Œ Summary + +The add-on should replicate the essential Doodle experience inside Plone: propose times, collect responses, expose availability, and prevent conflicts. +For this repository, the correct next step is to build a small, Plone-native MVP around Dexterity types, add-on views, registry settings, and tests. \ No newline at end of file diff --git a/docs/docs/reference/index.md b/docs/docs/reference/index.md index 3abbca4..62cf45c 100644 --- a/docs/docs/reference/index.md +++ b/docs/docs/reference/index.md @@ -20,4 +20,7 @@ https://diataxis.fr/reference/ ## Configuration +- {doc}`doodle-requirements` +- {doc}`doodle-fr` +- {doc}`doodle-planning` - {doc}`plone:contributing/documentation/themes-and-extensions` diff --git a/src/experimental/doodle/browser/booking_views.py b/src/experimental/doodle/browser/booking_views.py new file mode 100644 index 0000000..960a76c --- /dev/null +++ b/src/experimental/doodle/browser/booking_views.py @@ -0,0 +1,158 @@ +"""Browser views for the BookingPage content type.""" + +from experimental.doodle import _ +from experimental.doodle.scheduling import BookingPageClosedError +from experimental.doodle.scheduling import create_booking +from experimental.doodle.scheduling import get_available_slots +from experimental.doodle.scheduling import InvalidSlotError +from experimental.doodle.scheduling import SlotUnavailableError +from Products.Five.browser import BrowserView +from plone.protect.interfaces import IDisableCSRFProtection +from zope.interface import alsoProvides + +import datetime +import plone.api + + +def _format_slot(slot): + """Return a human-readable string for a datetime slot, or '' for None.""" + if slot is None: + return "" + return slot.strftime("%A %d %B %Y, %H:%M") + + +class BookingPageView(BrowserView): + """Public booking view for a BookingPage. + + GET โ€” renders a date picker and the list of available slots for the + selected date. + POST โ€” submits a booking for one selected slot and redirects back to + the same view (with the date preserved) on success. + + Anonymous access is permitted to view the form; submitting a booking + requires an authenticated Plone user. Anonymous booking is intentionally + not supported in this MVP release. + """ + + # ------------------------------------------------------------------ + # Template helpers (called from booking_page.pt) + # ------------------------------------------------------------------ + + def is_open(self): + """Return True when the booking page is accepting new bookings.""" + return self.context.booking_state == "open" + + def selected_date(self): + """Return the calendar date to display slots for. + + Reads ``date`` from the request form (ISO format YYYY-MM-DD). + Falls back to today if the parameter is absent or unparseable. + """ + date_str = self.request.form.get("date", "") + if date_str: + try: + return datetime.date.fromisoformat(date_str) + except (ValueError, TypeError): + pass + return datetime.date.today() + + def selected_date_str(self): + """Return the selected date as an ISO string for use in templates.""" + return self.selected_date().isoformat() + + def available_slots_info(self): + """Return a list of dicts for each available slot on the selected date. + + Each dict has: + - ``display``: human-readable string for use in templates + - ``value``: ISO-format datetime string used as the radio-button value + """ + slots = get_available_slots(self.context, self.selected_date()) + return [ + { + "display": _format_slot(slot), + "value": slot.isoformat(), + } + for slot in slots + ] + + # ------------------------------------------------------------------ + # Request handling + # ------------------------------------------------------------------ + + def __call__(self): + if self.request.method == "POST": + return self._handle_post() + return self.index() + + def _handle_post(self): + """Process a booking submission. + + Reads ``slot`` from the request form (ISO-formatted datetime string), + validates it, and delegates to ``create_booking``. + Redirects to ``@@booking-page`` on success, preserving the selected + date in the query string. Re-renders the form for validation errors. + """ + alsoProvides(self.request, IDisableCSRFProtection) + if plone.api.user.is_anonymous(): + plone.api.portal.show_message( + message=_("You must be logged in to make a booking."), + request=self.request, + type="error", + ) + return self.index() + + booker_id = plone.api.user.get_current().id + + slot_str = self.request.form.get("slot", "") + if not slot_str: + plone.api.portal.show_message( + message=_("Please select a time slot."), + request=self.request, + type="error", + ) + return self.index() + + try: + slot = datetime.datetime.fromisoformat(slot_str) + except (ValueError, TypeError): + plone.api.portal.show_message( + message=_("The selected slot could not be read."), + request=self.request, + type="error", + ) + return self.index() + + try: + create_booking(self.context, booker_id, slot) + except BookingPageClosedError: + plone.api.portal.show_message( + message=_("This booking page is not accepting new bookings."), + request=self.request, + type="error", + ) + return self.index() + except SlotUnavailableError: + plone.api.portal.show_message( + message=_("That slot has just been taken. Please choose another."), + request=self.request, + type="error", + ) + return self.index() + except InvalidSlotError: + plone.api.portal.show_message( + message=_("The selected slot is no longer available."), + request=self.request, + type="error", + ) + return self.index() + + plone.api.portal.show_message( + message=_("Your booking has been confirmed. See you then!"), + request=self.request, + type="info", + ) + date_str = slot.date().isoformat() + return self.request.response.redirect( + f"{self.context.absolute_url()}/@@booking-page?date={date_str}" + ) diff --git a/src/experimental/doodle/browser/configure.zcml b/src/experimental/doodle/browser/configure.zcml index 6bf678a..ece3b8b 100644 --- a/src/experimental/doodle/browser/configure.zcml +++ b/src/experimental/doodle/browser/configure.zcml @@ -22,4 +22,44 @@ type="plone" /> + + + + + + + + + + + diff --git a/src/experimental/doodle/browser/home_viewlets.py b/src/experimental/doodle/browser/home_viewlets.py new file mode 100644 index 0000000..16faaf4 --- /dev/null +++ b/src/experimental/doodle/browser/home_viewlets.py @@ -0,0 +1,22 @@ +"""Viewlets rendered on the Plone site root (home page).""" + +from plone.app.layout.viewlets.common import ViewletBase + +import plone.api + + +class OpenPollsViewlet(ViewletBase): + """Shows all open polls on the home page.""" + + def open_polls(self): + """Return a list of dicts for every poll with poll_state == 'open'.""" + catalog = plone.api.portal.get_tool("portal_catalog") + brains = catalog(portal_type="Poll", poll_state="open") + return [ + { + "title": brain.Title, + "description": brain.Description, + "url": brain.getURL(), + } + for brain in brains + ] diff --git a/src/experimental/doodle/browser/poll_views.py b/src/experimental/doodle/browser/poll_views.py new file mode 100644 index 0000000..8f6036b --- /dev/null +++ b/src/experimental/doodle/browser/poll_views.py @@ -0,0 +1,241 @@ +"""Browser views for the Poll content type.""" + +from datetime import datetime +from experimental.doodle import _ +from experimental.doodle.voting import aggregate_votes +from experimental.doodle.voting import DuplicateVoteError +from experimental.doodle.voting import get_vote +from experimental.doodle.voting import get_votes +from experimental.doodle.voting import get_winning_slot +from experimental.doodle.voting import InvalidSlotError +from experimental.doodle.voting import PollClosedError +from experimental.doodle.voting import submit_vote +from plone.protect.interfaces import IDisableCSRFProtection +from Products.Five.browser import BrowserView +from zope.interface import alsoProvides + +import plone.api + + +def _format_slot(slot): + """Return a human-readable string for a datetime slot, or '' for None.""" + if slot is None: + return "" + return slot.strftime("%A %d %B %Y, %H:%M") + + +def _allow_anonymous_voting(): + """Return True when the registry permits anonymous poll voting.""" + try: + from experimental.doodle.controlpanels.doodle_settings import IDoodleSettings + from plone.registry.interfaces import IRegistry + from zope.component import getUtility + + settings = getUtility(IRegistry).forInterface( + IDoodleSettings, prefix="experimental.doodle" + ) + return bool(settings.allow_anonymous_voting) + except Exception: + return False + + +def _anonymous_participant_id(request): + """Return a participant identifier for an anonymous voter. + + Uses the request's ``REMOTE_ADDR`` so that a single client IP address + produces a stable key within a Plone transaction. Shared IP addresses + (NAT, proxies) and IP spoofing are known limitations accepted for MVP. + """ + return f"anon:{request.get('REMOTE_ADDR', 'unknown')}" + + +class PollVoteView(BrowserView): + """Vote submission form for a Poll. + + GET โ€” renders the vote form (checkboxes for each proposed slot). + POST โ€” processes the submission and redirects to @@poll-results on success. + + Anonymous access is permitted to see the form. Submitting a vote requires + an authenticated Plone user unless the ``allow_anonymous_voting`` registry + setting is enabled, in which case the client's remote address is used as + the participant identifier. + """ + + # ------------------------------------------------------------------ + # Template helpers (called from poll_vote.pt) + # ------------------------------------------------------------------ + + def is_open(self): + """Return True when the poll is accepting votes.""" + return self.context.poll_state == "open" + + def proposed_slots_info(self): + """Return a list of dicts for each proposed slot. + + Each dict has: + - ``display``: human-readable string for use in templates + - ``value``: ISO-format string used as the checkbox value + """ + return [ + { + "display": _format_slot(slot), + "value": slot.isoformat(), + } + for slot in (self.context.proposed_slots or []) + ] + + def has_already_voted(self): + """Return True if the current user (or anonymous client) has already voted.""" + if plone.api.user.is_anonymous(): + if not _allow_anonymous_voting(): + return False + participant_id = _anonymous_participant_id(self.request) + else: + participant_id = plone.api.user.get_current().id + return get_vote(self.context, participant_id) is not None + + # ------------------------------------------------------------------ + # Request handling + # ------------------------------------------------------------------ + + def __call__(self): + if self.request.method == "POST": + return self._handle_post() + return self.index() + + def _handle_post(self): + """Process a vote submission. + + Reads ``chosen_slots`` from the request form (list of ISO-formatted + datetime strings), validates them, and delegates to ``submit_vote``. + Redirects to ``@@poll-results`` on success or duplicate vote. + Re-renders the form for validation errors. + """ + alsoProvides(self.request, IDisableCSRFProtection) + if plone.api.user.is_anonymous(): + if not _allow_anonymous_voting(): + plone.api.portal.show_message( + message=_("You must be logged in to vote."), + request=self.request, + type="error", + ) + return self.index() + participant_id = _anonymous_participant_id(self.request) + else: + participant_id = plone.api.user.get_current().id + + chosen_strs = self.request.form.get("chosen_slots", []) + if isinstance(chosen_strs, str): + chosen_strs = [chosen_strs] + + if not chosen_strs: + plone.api.portal.show_message( + message=_("Please select at least one time slot."), + request=self.request, + type="error", + ) + return self.index() + + chosen = [] + for s in chosen_strs: + try: + chosen.append(datetime.fromisoformat(s)) + except (ValueError, TypeError): + plone.api.portal.show_message( + message=_("One or more selected slots could not be read."), + request=self.request, + type="error", + ) + return self.index() + + try: + submit_vote(self.context, participant_id, chosen) + except PollClosedError: + plone.api.portal.show_message( + message=_("This poll is no longer open for voting."), + request=self.request, + type="error", + ) + return self.index() + except DuplicateVoteError: + plone.api.portal.show_message( + message=_("You have already voted in this poll."), + request=self.request, + type="info", + ) + return self.request.response.redirect( + f"{self.context.absolute_url()}/@@poll-results" + ) + except InvalidSlotError: + plone.api.portal.show_message( + message=_("One or more selected slots are no longer valid."), + request=self.request, + type="error", + ) + return self.index() + + plone.api.portal.show_message( + message=_("Your vote has been recorded. Thank you!"), + request=self.request, + type="info", + ) + return self.request.response.redirect( + f"{self.context.absolute_url()}/@@poll-results" + ) + + +class PollResultsView(BrowserView): + """Results view showing aggregated votes per proposed slot. + + Displays: + - total vote count + - per-slot counts sorted by count (highest first) + - the leading/winning slot + - the final selected slot when the poll is in 'final' state + """ + + # ------------------------------------------------------------------ + # Template helpers (called from poll_results.pt) + # ------------------------------------------------------------------ + + def is_open(self): + """Return True when the poll is still accepting votes.""" + return self.context.poll_state == "open" + + def is_final(self): + """Return True when the organiser has selected a final slot.""" + return self.context.poll_state == "final" + + def total_votes(self): + """Return the number of participants who have voted.""" + return len(get_votes(self.context)) + + def results(self): + """Return a list of dicts for each proposed slot, sorted by count descending. + + Each dict has: + - ``display``: human-readable slot label + - ``count``: number of votes for this slot + - ``is_winner``: True for the slot returned by get_winning_slot() + """ + counts = aggregate_votes(self.context) + proposed = self.context.proposed_slots or [] + winner = get_winning_slot(self.context) + rows = [ + { + "slot": slot, + "display": _format_slot(slot), + "count": counts.get(slot, 0), + "is_winner": slot == winner, + } + for slot in proposed + ] + return sorted(rows, key=lambda r: r["count"], reverse=True) + + def winning_slot_display(self): + """Return a formatted string for the current leading slot, or ''.""" + return _format_slot(get_winning_slot(self.context)) + + def final_slot_display(self): + """Return a formatted string for the final selected slot, or ''.""" + return _format_slot(self.context.final_selected_slot) diff --git a/src/experimental/doodle/browser/templates/booking_page.pt b/src/experimental/doodle/browser/templates/booking_page.pt new file mode 100644 index 0000000..ad1b0b1 --- /dev/null +++ b/src/experimental/doodle/browser/templates/booking_page.pt @@ -0,0 +1,131 @@ + + + + + +

Booking Page title

+ +
+ +

+ Organizer: + Organizer name +

+ + + + + + + +
+
+ + + +
+
+ + + +
+ + + +
+ + Available slots + + +
+ +
+
+ +
+ +
+
+ +
+ + +
+

+ No bookable slots are available for this date. +

+
+
+ +
+ + + + + + +
+

+ This booking page is not accepting new bookings. +

+
+
+ +
+ + + diff --git a/src/experimental/doodle/browser/templates/open_polls_section.pt b/src/experimental/doodle/browser/templates/open_polls_section.pt new file mode 100644 index 0000000..21cfa30 --- /dev/null +++ b/src/experimental/doodle/browser/templates/open_polls_section.pt @@ -0,0 +1,30 @@ + + + + +
+

Open Polls

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

Poll title

+ +
+ + + +
+ Final result: + + final slot +
+
+ + + +
+ Currently leading: + + winning slot +
+
+ + +

+ + 0 + vote(s) received + +

+ + + + + + + + + + + + + + + +
Time slotVotes
+ + slot + + + slot + + 0
+ + + +

+ Vote in this poll +

+
+ +
+ + + diff --git a/src/experimental/doodle/browser/templates/poll_vote.pt b/src/experimental/doodle/browser/templates/poll_vote.pt new file mode 100644 index 0000000..35f5f00 --- /dev/null +++ b/src/experimental/doodle/browser/templates/poll_vote.pt @@ -0,0 +1,100 @@ + + + + + +

Poll title

+ +
+ + + + +
+

+ You have already submitted a vote for this poll. +

+
+

+ View current results +

+
+ + +
+ + +
+ + Select the times that work for you + + +
+ +
+
+ +
+ +
+
+
+ +
+ + +
+

+ This poll is closed for voting. +

+
+

+ View results +

+
+ +
+ + + diff --git a/src/experimental/doodle/configure.zcml b/src/experimental/doodle/configure.zcml index fd737a5..397837e 100644 --- a/src/experimental/doodle/configure.zcml +++ b/src/experimental/doodle/configure.zcml @@ -16,6 +16,7 @@ + diff --git a/src/experimental/doodle/content/booking_page.py b/src/experimental/doodle/content/booking_page.py new file mode 100644 index 0000000..9a0fec5 --- /dev/null +++ b/src/experimental/doodle/content/booking_page.py @@ -0,0 +1,100 @@ +"""Booking Page content type.""" + +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.schema.vocabulary import SimpleTerm +from zope.schema.vocabulary import SimpleVocabulary + + +BOOKING_STATES = SimpleVocabulary([ + SimpleTerm(value="open", title=_("Open")), + SimpleTerm(value="closed", title=_("Closed")), +]) + +WORKING_DAYS_VOCAB = SimpleVocabulary([ + SimpleTerm(value=0, title=_("Monday")), + SimpleTerm(value=1, title=_("Tuesday")), + SimpleTerm(value=2, title=_("Wednesday")), + SimpleTerm(value=3, title=_("Thursday")), + SimpleTerm(value=4, title=_("Friday")), + SimpleTerm(value=5, title=_("Saturday")), + SimpleTerm(value=6, title=_("Sunday")), +]) + + +class IBookingPage(model.Schema): + """Schema for a booking page.""" + + organizer = schema.TextLine( + title=_("Organizer"), + description=_("Name or email address of the person accepting bookings."), + required=False, + ) + + working_days = schema.List( + title=_("Working days"), + description=_( + "Days of the week on which bookings are accepted (0=Monday, 6=Sunday)." + ), + value_type=schema.Choice(vocabulary=WORKING_DAYS_VOCAB), + required=False, + defaultFactory=list, + ) + + working_hours_start = schema.Int( + title=_("Working hours start"), + description=_("Hour of day when bookings open (0โ€“23)."), + required=False, + default=9, + min=0, + max=23, + ) + + working_hours_end = schema.Int( + title=_("Working hours end"), + description=_("Hour of day when bookings close (0โ€“23)."), + required=False, + default=17, + min=0, + max=23, + ) + + slot_duration = schema.Int( + title=_("Slot duration (minutes)"), + description=_("Length of each bookable slot in minutes."), + required=False, + default=30, + min=1, + ) + + buffer_duration = schema.Int( + title=_("Buffer duration (minutes)"), + description=_("Gap between consecutive slots in minutes."), + required=False, + default=0, + min=0, + ) + + availability_exceptions = schema.List( + title=_("Availability exceptions"), + description=_("Dates on which no bookings are accepted."), + value_type=schema.Date(title=_("Date")), + required=False, + defaultFactory=list, + ) + + booking_state = schema.Choice( + title=_("Booking state"), + description=_("Current lifecycle state of the booking page."), + vocabulary=BOOKING_STATES, + required=True, + default="open", + ) + + +@implementer(IBookingPage) +class BookingPage(Item): + """A booking page that lets visitors schedule time with the organizer.""" diff --git a/src/experimental/doodle/content/configure.zcml b/src/experimental/doodle/content/configure.zcml new file mode 100644 index 0000000..3416cc6 --- /dev/null +++ b/src/experimental/doodle/content/configure.zcml @@ -0,0 +1,8 @@ + + + + + diff --git a/src/experimental/doodle/content/poll.py b/src/experimental/doodle/content/poll.py new file mode 100644 index 0000000..2d7a5d6 --- /dev/null +++ b/src/experimental/doodle/content/poll.py @@ -0,0 +1,59 @@ +"""Poll content type.""" + +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.schema.vocabulary import SimpleTerm +from zope.schema.vocabulary import SimpleVocabulary + + +POLL_STATES = SimpleVocabulary([ + SimpleTerm(value="open", title=_("Open")), + SimpleTerm(value="closed", title=_("Closed")), + SimpleTerm(value="final", title=_("Final")), +]) + + +class IPoll(model.Schema): + """Schema for a scheduling poll.""" + + location = schema.TextLine( + title=_("Location"), + description=_("Optional meeting location or URL."), + required=False, + ) + + deadline = schema.Datetime( + title=_("Deadline"), + description=_("Last date and time for voting."), + required=False, + ) + + proposed_slots = schema.List( + title=_("Proposed slots"), + description=_("List of proposed date and time options."), + value_type=schema.Datetime(title=_("Slot")), + required=False, + defaultFactory=list, + ) + + poll_state = schema.Choice( + title=_("Poll state"), + description=_("Current lifecycle state of the poll."), + vocabulary=POLL_STATES, + required=True, + default="open", + ) + + final_selected_slot = schema.Datetime( + title=_("Final selected slot"), + description=_("The slot chosen by the organizer after closing the poll."), + required=False, + ) + + +@implementer(IPoll) +class Poll(Item): + """A scheduling poll.""" diff --git a/src/experimental/doodle/controlpanels/configure.zcml b/src/experimental/doodle/controlpanels/configure.zcml index 3cf0663..77c37e1 100644 --- a/src/experimental/doodle/controlpanels/configure.zcml +++ b/src/experimental/doodle/controlpanels/configure.zcml @@ -7,4 +7,12 @@ + + diff --git a/src/experimental/doodle/controlpanels/doodle_settings.py b/src/experimental/doodle/controlpanels/doodle_settings.py new file mode 100644 index 0000000..5d614a6 --- /dev/null +++ b/src/experimental/doodle/controlpanels/doodle_settings.py @@ -0,0 +1,74 @@ +"""Registry-backed settings and control panel for experimental.doodle.""" + +from experimental.doodle import _ +from plone.app.registry.browser.controlpanel import ControlPanelFormWrapper +from plone.app.registry.browser.controlpanel import RegistryEditForm +from zope import schema +from zope.interface import Interface + + +class IDoodleSettings(Interface): + """Site-wide defaults for the experimental.doodle add-on.""" + + default_poll_slot_duration = schema.Int( + title=_("Default poll slot duration (minutes)"), + description=_("Initial slot duration applied when a new Poll is created."), + required=True, + default=30, + min=1, + ) + + default_booking_slot_duration = schema.Int( + title=_("Default booking slot duration (minutes)"), + description=_( + "Initial slot duration applied when a new Booking Page is created." + ), + required=True, + default=30, + min=1, + ) + + allow_anonymous_voting = schema.Bool( + title=_("Allow anonymous voting"), + description=_( + "When enabled, visitors who are not logged in may cast votes in polls." + ), + required=True, + default=False, + ) + + require_booking_confirmation = schema.Bool( + title=_("Require booking confirmation"), + description=_( + "When enabled, the organiser must confirm each booking request " + "before it is finalised." + ), + required=True, + default=False, + ) + + default_timezone = schema.TextLine( + title=_("Default timezone"), + description=_( + "IANA timezone name used when displaying and computing slots " + "(e.g. 'Europe/Amsterdam', 'UTC'). " + "Full timezone support is not yet implemented." + ), + required=False, + default="UTC", + ) + + +class DoodleSettingsForm(RegistryEditForm): + """Edit form for site-wide doodle settings.""" + + schema = IDoodleSettings + schema_prefix = "experimental.doodle" + label = _("Doodle Settings") + description = _("Site-wide defaults for polls and booking pages.") + + +class DoodleSettingsControlPanel(ControlPanelFormWrapper): + """Control panel view wrapping the DoodleSettingsForm.""" + + form = DoodleSettingsForm diff --git a/src/experimental/doodle/indexers/__init__.py b/src/experimental/doodle/indexers/__init__.py index e69de29..e91c6aa 100644 --- a/src/experimental/doodle/indexers/__init__.py +++ b/src/experimental/doodle/indexers/__init__.py @@ -0,0 +1,10 @@ +"""Catalog indexers for experimental.doodle content types.""" + +from experimental.doodle.content.poll import IPoll +from plone.indexer import indexer + + +@indexer(IPoll) +def poll_state_indexer(obj): + """Index the poll_state field so it can be searched in the catalog.""" + return obj.poll_state diff --git a/src/experimental/doodle/indexers/configure.zcml b/src/experimental/doodle/indexers/configure.zcml index ee4ad7c..0d6fe86 100644 --- a/src/experimental/doodle/indexers/configure.zcml +++ b/src/experimental/doodle/indexers/configure.zcml @@ -2,6 +2,8 @@ - + diff --git a/src/experimental/doodle/profiles/default/catalog.xml b/src/experimental/doodle/profiles/default/catalog.xml index 9558132..ee7da69 100644 --- a/src/experimental/doodle/profiles/default/catalog.xml +++ b/src/experimental/doodle/profiles/default/catalog.xml @@ -1,13 +1,12 @@ - - + + + diff --git a/src/experimental/doodle/profiles/default/controlpanel.xml b/src/experimental/doodle/profiles/default/controlpanel.xml index 61d4fdf..ade1de2 100644 --- a/src/experimental/doodle/profiles/default/controlpanel.xml +++ b/src/experimental/doodle/profiles/default/controlpanel.xml @@ -5,4 +5,18 @@ + + Manage portal + + diff --git a/src/experimental/doodle/profiles/default/registry/main.xml b/src/experimental/doodle/profiles/default/registry/main.xml index eae378c..1f059b5 100644 --- a/src/experimental/doodle/profiles/default/registry/main.xml +++ b/src/experimental/doodle/profiles/default/registry/main.xml @@ -5,4 +5,23 @@ + + + + Poll + BookingPage + + + + + 30 + 30 + False + False + UTC + + diff --git a/src/experimental/doodle/profiles/default/types.xml b/src/experimental/doodle/profiles/default/types.xml index bed2b0d..f063ff8 100644 --- a/src/experimental/doodle/profiles/default/types.xml +++ b/src/experimental/doodle/profiles/default/types.xml @@ -7,4 +7,10 @@ name="MyType" /> --> + + diff --git a/src/experimental/doodle/profiles/default/types/BookingPage.xml b/src/experimental/doodle/profiles/default/types/BookingPage.xml new file mode 100644 index 0000000..5cc992f --- /dev/null +++ b/src/experimental/doodle/profiles/default/types/BookingPage.xml @@ -0,0 +1,62 @@ + + + + + Booking Page + A booking page that lets visitors schedule time with the organizer. + + False + BookingPage + string:contenttype/event + + + + + True + True + + + cmf.AddPortalContent + experimental.doodle.content.booking_page.BookingPage + + + experimental.doodle.content.booking_page.IBookingPage + + + + + + + + + + string:${folder_url}/++add++BookingPage + view + False + view + + + + + + + + + + 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..5007bb4 --- /dev/null +++ b/src/experimental/doodle/profiles/default/types/Poll.xml @@ -0,0 +1,91 @@ + + + + + Poll + A scheduling poll that collects availability from participants. + + 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-vote + False + poll-vote + + + + + + + + + + + + + + + + + + + + + diff --git a/src/experimental/doodle/scheduling.py b/src/experimental/doodle/scheduling.py new file mode 100644 index 0000000..ce52f47 --- /dev/null +++ b/src/experimental/doodle/scheduling.py @@ -0,0 +1,242 @@ +"""Booking scheduling logic for BookingPage content objects. + +Bookings are stored in ZODB annotations on the BookingPage object itself using +``IAnnotations(booking_page)[BOOKINGS_KEY]``. The annotation value is a +``PersistentMapping`` that maps slot start ``datetime`` objects to booking +records:: + + { + datetime(2026, 6, 2, 9, 0): { + "booker_id": "", + "booked_at": datetime(...), + }, + ... + } + +Using the slot ``datetime`` as the mapping key gives O(1) conflict detection +at the Python layer. ZODB's transaction model adds a second safety net: if +two concurrent requests attempt to book the same slot in parallel, only one +transaction can commit the mutation; the other receives a ``ConflictError`` +and retries, at which point the slot is already present and +``SlotUnavailableError`` is raised. + +The trade-off versus a separate ``Booking`` content type is that individual +bookings are not visible in the Plone content tree, not independently +workflowable, and cannot be found via the catalog. These constraints are +acceptable for the current MVP scope. +""" + +from datetime import datetime +from datetime import timedelta +from persistent.mapping import PersistentMapping +from zope.annotation.interfaces import IAnnotations + + +BOOKINGS_KEY = "experimental.doodle.bookings" + + +# --------------------------------------------------------------------------- +# Registry helpers +# --------------------------------------------------------------------------- + + +def _registry_default_slot_duration(): + """Return the default booking slot duration from the Plone registry. + + Falls back to 30 minutes when the registry is unavailable (e.g. during + unit tests that run outside the full Zope component architecture). + """ + try: + from experimental.doodle.controlpanels.doodle_settings import IDoodleSettings + from plone.registry.interfaces import IRegistry + from zope.component import getUtility + + settings = getUtility(IRegistry).forInterface( + IDoodleSettings, prefix="experimental.doodle" + ) + return settings.default_booking_slot_duration or 30 + except Exception: + return 30 + + +# --------------------------------------------------------------------------- +# Exceptions +# --------------------------------------------------------------------------- + + +class BookingError(Exception): + """Base class for all booking-related errors.""" + + +class BookingPageClosedError(BookingError): + """Raised when a booking is submitted to a page that is not open.""" + + +class SlotUnavailableError(BookingError): + """Raised when the requested slot is already booked.""" + + +class InvalidSlotError(BookingError): + """Raised when the requested slot is not a valid slot for the booking page.""" + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _get_bookings_map(booking_page): + """Return the persistent booking mapping for *booking_page*, creating it if needed.""" + annotations = IAnnotations(booking_page) + if BOOKINGS_KEY not in annotations: + annotations[BOOKINGS_KEY] = PersistentMapping() + return annotations[BOOKINGS_KEY] + + +def _slots_for_day(booking_page, day): + """Return all theoretically possible slot start times for a calendar day. + + Parameters + ---------- + booking_page: + A ``BookingPage`` content object. + day : datetime.date + The calendar date to enumerate slots for. + + Returns + ------- + list[datetime] + Ordered list of naive UTC slot start datetimes. Empty when the day + is not a working day or is listed in ``availability_exceptions``. + """ + working_days = list(booking_page.working_days or []) + if working_days and day.weekday() not in working_days: + return [] + + exceptions = list(booking_page.availability_exceptions or []) + if day in exceptions: + return [] + + start_h = booking_page.working_hours_start + if start_h is None: + start_h = 9 + end_h = booking_page.working_hours_end + if end_h is None: + end_h = 17 + + slot_min = booking_page.slot_duration + if not slot_min: + slot_min = _registry_default_slot_duration() + buffer_min = booking_page.buffer_duration + if buffer_min is None: + buffer_min = 0 + + step = timedelta(minutes=slot_min + buffer_min) + slot_length = timedelta(minutes=slot_min) + + window_start = datetime(day.year, day.month, day.day, start_h, 0) + window_end = datetime(day.year, day.month, day.day, end_h, 0) + + slots = [] + current = window_start + while current + slot_length <= window_end: + slots.append(current) + current += step + return slots + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def get_available_slots(booking_page, day): + """Return available slot start times for a given calendar day. + + A slot is available when it exists in ``_slots_for_day`` **and** has not + yet been booked. + + Parameters + ---------- + booking_page: + A ``BookingPage`` content object. + day : datetime.date + The calendar date to query. + + Returns + ------- + list[datetime] + Ordered list of available slot start datetimes. + """ + all_slots = _slots_for_day(booking_page, day) + if not all_slots: + return [] + booked = _get_bookings_map(booking_page) + return [s for s in all_slots if s not in booked] + + +def create_booking(booking_page, booker_id, slot): + """Create a booking for a specific slot on a booking page. + + Parameters + ---------- + booking_page: + The ``BookingPage`` content object. + booker_id : str + Identifier for the person making the booking (e.g. Plone user ID or + an email address for anonymous visitors). + slot : datetime + The slot start time being booked. Must be a value returned by + ``get_available_slots`` for the corresponding day. + + Returns + ------- + dict + The newly created booking record. + + Raises + ------ + BookingPageClosedError + If ``booking_page.booking_state != 'open'``. + InvalidSlotError + If *slot* is not a valid slot for the booking page on its date. + SlotUnavailableError + If *slot* is already booked. + """ + if booking_page.booking_state != "open": + raise BookingPageClosedError("This booking page is not open for new bookings.") + + valid_slots = _slots_for_day(booking_page, slot.date()) + if slot not in valid_slots: + raise InvalidSlotError( + f"The slot {slot!r} is not a valid slot for this booking page." + ) + + bookings = _get_bookings_map(booking_page) + if slot in bookings: + raise SlotUnavailableError(f"The slot {slot!r} is already booked.") + + record = { + "booker_id": booker_id, + "booked_at": datetime.utcnow(), + } + bookings[slot] = record + return record + + +def get_booking(booking_page, slot): + """Return the booking record for *slot*, or ``None`` if not booked. + + Parameters + ---------- + booking_page: + The ``BookingPage`` content object. + slot : datetime + The slot start time to look up. + """ + return _get_bookings_map(booking_page).get(slot) + + +def get_bookings(booking_page): + """Return all bookings as a plain dict mapping slot โ†’ booking record.""" + return dict(_get_bookings_map(booking_page)) diff --git a/src/experimental/doodle/voting.py b/src/experimental/doodle/voting.py new file mode 100644 index 0000000..c23ef0c --- /dev/null +++ b/src/experimental/doodle/voting.py @@ -0,0 +1,171 @@ +"""Voting logic for Poll content objects. + +Votes are stored in ZODB annotations on the Poll object itself using +``IAnnotations(poll)[VOTES_KEY]``. The annotation value is a +``PersistentMapping`` that maps participant identifiers (strings) to vote +records:: + + { + "": { + "chosen_slots": [datetime, ...], + "submitted_at": datetime, + }, + ... + } + +This approach keeps votes co-located with their poll, requires no extra +content type, and lets ZODB handle persistence automatically. The trade-off +is that votes are not individually catalogued or visible through the Plone UI; +the public surface is the aggregation API defined in this module. +""" + +from datetime import datetime +from persistent.mapping import PersistentMapping +from zope.annotation.interfaces import IAnnotations + + +VOTES_KEY = "experimental.doodle.votes" + + +# --------------------------------------------------------------------------- +# Exceptions +# --------------------------------------------------------------------------- + + +class VotingError(Exception): + """Base class for all voting-related errors.""" + + +class PollClosedError(VotingError): + """Raised when a vote is submitted to a poll that is not open.""" + + +class DuplicateVoteError(VotingError): + """Raised when a participant submits a second vote on the same poll.""" + + +class InvalidSlotError(VotingError): + """Raised when a chosen slot is not in the poll's proposed_slots.""" + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _get_responses(poll): + """Return the persistent vote mapping for *poll*, creating it if needed.""" + annotations = IAnnotations(poll) + if VOTES_KEY not in annotations: + annotations[VOTES_KEY] = PersistentMapping() + return annotations[VOTES_KEY] + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def submit_vote(poll, participant_id, chosen_slots): + """Submit a vote for one or more proposed slots. + + Parameters + ---------- + poll: + The Poll content object. + participant_id : str + A string identifying the participant (typically the Plone user ID). + chosen_slots : list[datetime] + The proposed slots the participant is available for. Must be a + non-empty subset of ``poll.proposed_slots``. + + Raises + ------ + PollClosedError + If the poll is not in the ``'open'`` state. + DuplicateVoteError + If the participant has already submitted a vote on this poll. + InvalidSlotError + If any item in *chosen_slots* is not in ``poll.proposed_slots``. + ValueError + If *chosen_slots* is empty. + """ + if poll.poll_state != "open": + raise PollClosedError("This poll is not open for voting.") + + responses = _get_responses(poll) + + if participant_id in responses: + raise DuplicateVoteError( + f"Participant '{participant_id}' has already voted in this poll." + ) + + if not chosen_slots: + raise ValueError("At least one slot must be chosen.") + + proposed = list(poll.proposed_slots or []) + invalid = [s for s in chosen_slots if s not in proposed] + if invalid: + raise InvalidSlotError( + f"The following slots are not in the poll's proposed slots: {invalid!r}" + ) + + responses[participant_id] = { + "chosen_slots": list(chosen_slots), + "submitted_at": datetime.utcnow(), + } + + +def get_vote(poll, participant_id): + """Return the vote record for *participant_id*, or ``None`` if not found.""" + return _get_responses(poll).get(participant_id) + + +def get_votes(poll): + """Return a plain dict mapping participant_id โ†’ vote record for all votes.""" + return dict(_get_responses(poll)) + + +def aggregate_votes(poll): + """Return a dict mapping each proposed slot to its vote count. + + All proposed slots are included; slots with no votes have count ``0``. + """ + proposed = list(poll.proposed_slots or []) + counts = {slot: 0 for slot in proposed} + for record in _get_responses(poll).values(): + for slot in record.get("chosen_slots", []): + if slot in counts: + counts[slot] += 1 + return counts + + +def get_winning_slot(poll): + """Return the proposed slot with the highest vote count. + + Returns ``None`` when there are no proposed slots or no votes have been + cast yet. In a tie the slot that appears **first** in + ``poll.proposed_slots`` is returned. + + When ``poll.poll_state`` is ``'final'`` and ``poll.final_selected_slot`` + is set, that value is returned directly without recomputing from votes. + """ + if poll.poll_state == "final" and poll.final_selected_slot: + return poll.final_selected_slot + + proposed = list(poll.proposed_slots or []) + if not proposed: + return None + + counts = aggregate_votes(poll) + max_count = max(counts.values(), default=0) + if max_count == 0: + return None + + # Return the first proposed slot that has the maximum count, preserving + # the order from proposed_slots (earliest position wins ties). + for slot in proposed: + if counts.get(slot, 0) == max_count: + return slot + + return None # pragma: no cover diff --git a/tests/browser/test_booking_views.py b/tests/browser/test_booking_views.py new file mode 100644 index 0000000..e68f7da --- /dev/null +++ b/tests/browser/test_booking_views.py @@ -0,0 +1,231 @@ +"""Integration tests for BookingPage browser views.""" + +from experimental.doodle.browser.booking_views import BookingPageView +from experimental.doodle.interfaces import IBrowserLayer +from experimental.doodle.scheduling import create_booking +from experimental.doodle.scheduling import get_available_slots +from experimental.doodle.scheduling import get_booking +from experimental.doodle.scheduling import get_bookings +from zope.interface import alsoProvides + +import datetime +import plone.api +import pytest + + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +# June 1 2026 is a Monday (weekday 0). +MONDAY = datetime.date(2026, 6, 1) +SLOT_0900 = datetime.datetime(2026, 6, 1, 9, 0) +SLOT_0930 = datetime.datetime(2026, 6, 1, 9, 30) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _browser_layer(portal, integration): + """Apply IBrowserLayer to the test request for all tests in this module.""" + alsoProvides(portal.REQUEST, IBrowserLayer) + + +@pytest.fixture() +def page(portal): + """An open BookingPage with Monโ€“Fri, 09:00โ€“11:00, 30-min slots.""" + with plone.api.env.adopt_roles(["Manager"]): + obj = plone.api.content.create( + container=portal, + type="BookingPage", + id="test-booking-page", + title="Test Booking Page", + organizer="alice@example.com", + working_days=[0, 1, 2, 3, 4], + working_hours_start=9, + working_hours_end=11, + slot_duration=30, + buffer_duration=0, + availability_exceptions=[], + booking_state="open", + ) + return obj + + +@pytest.fixture() +def member(portal): + """A regular Plone member for authenticated booking tests.""" + with plone.api.env.adopt_roles(["Manager"]): + user = plone.api.user.create( + username="test.booker", + email="test.booker@example.com", + password="Test1234!", + roles=["Member"], + ) + return user + + +def _booking_view(page, portal): + """Instantiate BookingPageView directly, bypassing ZCML template wiring.""" + view = BookingPageView(page, portal.REQUEST) + # Stub index so error paths that call self.index() don't raise AttributeError. + view.index = lambda: "" + return view + + +# --------------------------------------------------------------------------- +# View registration +# --------------------------------------------------------------------------- + + +class TestViewRegistration: + def test_booking_page_view_is_traversable(self, page, portal): + """@@booking-page can be looked up via restrictedTraverse.""" + with plone.api.env.adopt_roles(["Manager"]): + view = page.restrictedTraverse("@@booking-page") + assert isinstance(view, BookingPageView) + + +# --------------------------------------------------------------------------- +# BookingPageView โ€” template helpers +# --------------------------------------------------------------------------- + + +class TestBookingPageViewHelpers: + def test_is_open_when_open(self, page, portal): + assert _booking_view(page, portal).is_open() is True + + def test_is_not_open_when_closed(self, page, portal): + page.booking_state = "closed" + assert _booking_view(page, portal).is_open() is False + + def test_selected_date_defaults_to_today(self, page, portal): + view = _booking_view(page, portal) + assert view.selected_date() == datetime.date.today() + + def test_selected_date_parsed_from_form(self, page, portal): + portal.REQUEST.form["date"] = "2026-06-01" + assert _booking_view(page, portal).selected_date() == MONDAY + + def test_selected_date_invalid_falls_back_to_today(self, page, portal): + portal.REQUEST.form["date"] = "not-a-date" + assert _booking_view(page, portal).selected_date() == datetime.date.today() + + def test_selected_date_str_returns_iso(self, page, portal): + portal.REQUEST.form["date"] = "2026-06-01" + assert _booking_view(page, portal).selected_date_str() == "2026-06-01" + + def test_available_slots_info_returns_slots_for_working_day(self, page, portal): + portal.REQUEST.form["date"] = "2026-06-01" + info = _booking_view(page, portal).available_slots_info() + assert len(info) == 4 # 09:00, 09:30, 10:00, 10:30 + + def test_available_slots_info_has_display_and_value_keys(self, page, portal): + portal.REQUEST.form["date"] = "2026-06-01" + for slot in _booking_view(page, portal).available_slots_info(): + assert "display" in slot + assert "value" in slot + assert slot["display"] != "" + + def test_available_slots_info_value_is_isoformat_roundtrippable(self, page, portal): + portal.REQUEST.form["date"] = "2026-06-01" + for slot in _booking_view(page, portal).available_slots_info(): + parsed = datetime.datetime.fromisoformat(slot["value"]) + assert isinstance(parsed, datetime.datetime) + + def test_available_slots_info_empty_for_non_working_day(self, page, portal): + portal.REQUEST.form["date"] = "2026-06-06" # Saturday + assert _booking_view(page, portal).available_slots_info() == [] + + +# --------------------------------------------------------------------------- +# BookingPageView โ€” POST handling +# --------------------------------------------------------------------------- + + +class TestBookingSubmission: + def test_booking_is_stored_after_valid_post(self, page, portal, member): + """A valid POST from an authenticated user stores the booking.""" + portal.REQUEST.form["slot"] = SLOT_0900.isoformat() + with plone.api.env.adopt_user(username=member.id): + _booking_view(page, portal)._handle_post() + assert get_booking(page, SLOT_0900) is not None + + def test_booked_slot_has_correct_booker_id(self, page, portal, member): + """The stored booking record carries the authenticated user's ID.""" + portal.REQUEST.form["slot"] = SLOT_0900.isoformat() + with plone.api.env.adopt_user(username=member.id): + _booking_view(page, portal)._handle_post() + assert get_booking(page, SLOT_0900)["booker_id"] == member.id + + def test_booking_removes_slot_from_availability(self, page, portal, member): + """After a successful booking the slot disappears from available slots.""" + portal.REQUEST.form["slot"] = SLOT_0900.isoformat() + with plone.api.env.adopt_user(username=member.id): + _booking_view(page, portal)._handle_post() + assert SLOT_0900 not in get_available_slots(page, MONDAY) + + def test_other_slots_remain_available_after_booking(self, page, portal, member): + """Booking one slot does not affect availability of other slots.""" + portal.REQUEST.form["slot"] = SLOT_0900.isoformat() + with plone.api.env.adopt_user(username=member.id): + _booking_view(page, portal)._handle_post() + assert SLOT_0930 in get_available_slots(page, MONDAY) + + def test_successful_post_redirects_to_booking_page(self, page, portal, member): + """A successful booking redirects to @@booking-page with the date.""" + portal.REQUEST.form["slot"] = SLOT_0900.isoformat() + with plone.api.env.adopt_user(username=member.id): + _booking_view(page, portal)._handle_post() + location = portal.REQUEST.response.getHeader("location") or "" + assert "booking-page" in location + assert "2026-06-01" in location + + def test_double_booking_is_rejected(self, page, portal, member): + """A second booking attempt for the same slot does not overwrite the first.""" + create_booking(page, "first.booker", SLOT_0900) + portal.REQUEST.form["slot"] = SLOT_0900.isoformat() + with plone.api.env.adopt_user(username=member.id): + _booking_view(page, portal)._handle_post() + # Original booking is unchanged. + assert get_booking(page, SLOT_0900)["booker_id"] == "first.booker" + + def test_double_booking_stores_only_one_record(self, page, portal, member): + """Only one booking exists after a double-booking attempt.""" + create_booking(page, "first.booker", SLOT_0900) + portal.REQUEST.form["slot"] = SLOT_0900.isoformat() + with plone.api.env.adopt_user(username=member.id): + _booking_view(page, portal)._handle_post() + assert len(get_bookings(page)) == 1 + + def test_closed_page_rejects_booking(self, page, portal, member): + """POST to a closed booking page stores no booking.""" + page.booking_state = "closed" + portal.REQUEST.form["slot"] = SLOT_0900.isoformat() + with plone.api.env.adopt_user(username=member.id): + _booking_view(page, portal)._handle_post() + assert get_bookings(page) == {} + + def test_anonymous_user_cannot_book(self, page, portal): + """An anonymous POST does not store a booking.""" + from plone.app.testing import login as testing_login + from plone.app.testing import logout as testing_logout + from plone.app.testing import TEST_USER_NAME + + portal.REQUEST.form["slot"] = SLOT_0900.isoformat() + testing_logout() + try: + _booking_view(page, portal)._handle_post() + assert get_bookings(page) == {} + finally: + testing_login(portal, TEST_USER_NAME) + + def test_missing_slot_in_post_stores_nothing(self, page, portal, member): + """Submitting without selecting a slot does not create a booking.""" + portal.REQUEST.form.pop("slot", None) + with plone.api.env.adopt_user(username=member.id): + _booking_view(page, portal)._handle_post() + assert get_bookings(page) == {} diff --git a/tests/browser/test_poll_views.py b/tests/browser/test_poll_views.py new file mode 100644 index 0000000..b81866d --- /dev/null +++ b/tests/browser/test_poll_views.py @@ -0,0 +1,358 @@ +"""Integration tests for Poll browser views.""" + +from datetime import datetime +from experimental.doodle.browser.poll_views import PollResultsView +from experimental.doodle.browser.poll_views import PollVoteView +from experimental.doodle.interfaces import IBrowserLayer +from experimental.doodle.voting import get_vote +from experimental.doodle.voting import get_votes +from experimental.doodle.voting import submit_vote +from zope.interface import alsoProvides + +import plone.api +import pytest + + +SLOT_A = datetime(2026, 6, 1, 9, 0) +SLOT_B = datetime(2026, 6, 1, 14, 0) +SLOT_C = datetime(2026, 6, 2, 9, 0) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _browser_layer(portal, integration): + """Apply IBrowserLayer to the test request for all tests in this module.""" + alsoProvides(portal.REQUEST, IBrowserLayer) + + +@pytest.fixture() +def poll(portal, integration): + """Return a freshly created open Poll for view tests.""" + with plone.api.env.adopt_roles(["Manager"]): + obj = plone.api.content.create( + container=portal, + type="Poll", + id="view-test-poll", + title="View Test Poll", + proposed_slots=[SLOT_A, SLOT_B, SLOT_C], + poll_state="open", + ) + return obj + + +@pytest.fixture() +def member(portal, integration): + """Return a freshly created Plone member for authenticated tests.""" + with plone.api.env.adopt_roles(["Manager"]): + user = plone.api.user.create( + username="test.voter", + email="test.voter@example.com", + password="Test1234!", + roles=["Member"], + ) + return user + + +def _vote_view(poll, portal): + """Instantiate PollVoteView directly, bypassing ZCML template wiring.""" + view = PollVoteView(poll, portal.REQUEST) + # Provide a stub index so error paths that return self.index() don't fail + # when the view is instantiated outside the traversal machinery. + view.index = lambda: "" + return view + + +def _results_view(poll, portal): + """Instantiate PollResultsView directly.""" + return PollResultsView(poll, portal.REQUEST) + + +# --------------------------------------------------------------------------- +# View registration +# --------------------------------------------------------------------------- + + +class TestViewRegistration: + def test_vote_view_is_traversable(self, poll, portal): + """@@poll-vote can be looked up via restrictedTraverse.""" + with plone.api.env.adopt_roles(["Manager"]): + view = poll.restrictedTraverse("@@poll-vote") + assert isinstance(view, PollVoteView) + + def test_results_view_is_traversable(self, poll, portal): + """@@poll-results can be looked up via restrictedTraverse.""" + with plone.api.env.adopt_roles(["Manager"]): + view = poll.restrictedTraverse("@@poll-results") + assert isinstance(view, PollResultsView) + + +# --------------------------------------------------------------------------- +# PollVoteView โ€” template helpers +# --------------------------------------------------------------------------- + + +class TestPollVoteViewHelpers: + def test_is_open_when_poll_is_open(self, poll, portal): + assert _vote_view(poll, portal).is_open() is True + + def test_is_not_open_when_closed(self, poll, portal): + poll.poll_state = "closed" + assert _vote_view(poll, portal).is_open() is False + + def test_is_not_open_when_final(self, poll, portal): + poll.poll_state = "final" + assert _vote_view(poll, portal).is_open() is False + + def test_proposed_slots_info_length(self, poll, portal): + assert len(_vote_view(poll, portal).proposed_slots_info()) == 3 + + def test_proposed_slots_info_keys(self, poll, portal): + for info in _vote_view(poll, portal).proposed_slots_info(): + assert "display" in info + assert "value" in info + assert info["display"] != "" + + def test_slot_value_is_isoformat_roundtrippable(self, poll, portal): + for info in _vote_view(poll, portal).proposed_slots_info(): + parsed = datetime.fromisoformat(info["value"]) + assert isinstance(parsed, datetime) + + def test_has_already_voted_false_anonymously(self, poll, portal): + # No user context โ†’ anonymous โ†’ returns False + assert _vote_view(poll, portal).has_already_voted() is False + + def test_has_already_voted_false_before_voting(self, poll, portal, member): + with plone.api.env.adopt_user(username=member.id): + result = _vote_view(poll, portal).has_already_voted() + assert result is False + + def test_has_already_voted_true_after_voting(self, poll, portal, member): + submit_vote(poll, member.id, [SLOT_A]) + with plone.api.env.adopt_user(username=member.id): + result = _vote_view(poll, portal).has_already_voted() + assert result is True + + +# --------------------------------------------------------------------------- +# PollVoteView โ€” POST handling +# --------------------------------------------------------------------------- + + +class TestPollVoteSubmission: + def test_vote_is_stored_after_valid_post(self, poll, portal, member): + """A valid POST submission stores the vote.""" + portal.REQUEST.form["chosen_slots"] = [SLOT_A.isoformat()] + with plone.api.env.adopt_user(username=member.id): + _vote_view(poll, portal)._handle_post() + assert get_vote(poll, member.id) is not None + + def test_voted_slots_are_stored_correctly(self, poll, portal, member): + """The exact chosen slots are preserved in the vote record.""" + portal.REQUEST.form["chosen_slots"] = [SLOT_A.isoformat(), SLOT_C.isoformat()] + with plone.api.env.adopt_user(username=member.id): + _vote_view(poll, portal)._handle_post() + record = get_vote(poll, member.id) + assert set(record["chosen_slots"]) == {SLOT_A, SLOT_C} + + def test_successful_post_redirects_to_results(self, poll, portal, member): + """A successful vote sets a redirect location pointing at @@poll-results.""" + portal.REQUEST.form["chosen_slots"] = [SLOT_A.isoformat()] + with plone.api.env.adopt_user(username=member.id): + _vote_view(poll, portal)._handle_post() + location = portal.REQUEST.response.getHeader("location") or "" + assert "poll-results" in location + + def test_duplicate_vote_does_not_overwrite(self, poll, portal, member): + """Submitting via the view a second time does not change the first vote.""" + submit_vote(poll, member.id, [SLOT_A]) + portal.REQUEST.form["chosen_slots"] = [SLOT_B.isoformat()] + with plone.api.env.adopt_user(username=member.id): + _vote_view(poll, portal)._handle_post() + assert get_vote(poll, member.id)["chosen_slots"] == [SLOT_A] + + def test_duplicate_vote_redirects_to_results(self, poll, portal, member): + """A duplicate submission still redirects to @@poll-results.""" + submit_vote(poll, member.id, [SLOT_A]) + portal.REQUEST.form["chosen_slots"] = [SLOT_B.isoformat()] + with plone.api.env.adopt_user(username=member.id): + _vote_view(poll, portal)._handle_post() + location = portal.REQUEST.response.getHeader("location") or "" + assert "poll-results" in location + + def test_empty_selection_does_not_store_vote(self, poll, portal, member): + """Submitting with no slots chosen does not create a vote record.""" + portal.REQUEST.form["chosen_slots"] = [] + with plone.api.env.adopt_user(username=member.id): + _vote_view(poll, portal)._handle_post() + assert get_vote(poll, member.id) is None + + def test_closed_poll_does_not_store_vote(self, poll, portal, member): + """POST to a closed poll is rejected and no vote is stored.""" + poll.poll_state = "closed" + portal.REQUEST.form["chosen_slots"] = [SLOT_A.isoformat()] + with plone.api.env.adopt_user(username=member.id): + _vote_view(poll, portal)._handle_post() + assert get_vote(poll, member.id) is None + + def test_anonymous_user_cannot_vote(self, poll, portal): + """An anonymous POST does not store a vote.""" + from plone.app.testing import login as testing_login + from plone.app.testing import logout as testing_logout + from plone.app.testing import TEST_USER_NAME + + portal.REQUEST.form["chosen_slots"] = [SLOT_A.isoformat()] + testing_logout() + try: + _vote_view(poll, portal)._handle_post() + assert len(get_votes(poll)) == 0 + finally: + testing_login(portal, TEST_USER_NAME) + + +# --------------------------------------------------------------------------- +# PollResultsView โ€” template helpers +# --------------------------------------------------------------------------- + + +class TestPollResultsViewHelpers: + def test_results_length_matches_proposed_slots(self, poll, portal): + assert len(_results_view(poll, portal).results()) == 3 + + def test_results_contains_required_keys(self, poll, portal): + for row in _results_view(poll, portal).results(): + assert "display" in row + assert "count" in row + assert "is_winner" in row + + def test_total_votes_zero_initially(self, poll, portal): + assert _results_view(poll, portal).total_votes() == 0 + + def test_total_votes_increments_with_each_voter(self, poll, portal): + submit_vote(poll, "alice", [SLOT_A]) + submit_vote(poll, "bob", [SLOT_B]) + assert _results_view(poll, portal).total_votes() == 2 + + def test_results_sorted_descending_by_count(self, poll, portal): + submit_vote(poll, "alice", [SLOT_A, SLOT_B]) + submit_vote(poll, "bob", [SLOT_A]) + counts = [r["count"] for r in _results_view(poll, portal).results()] + assert counts == sorted(counts, reverse=True) + + def test_winner_is_flagged_in_results(self, poll, portal): + submit_vote(poll, "alice", [SLOT_A]) + submit_vote(poll, "bob", [SLOT_A]) + winners = [r for r in _results_view(poll, portal).results() if r["is_winner"]] + assert len(winners) == 1 + assert winners[0]["slot"] == SLOT_A + + def test_winning_slot_display_empty_with_no_votes(self, poll, portal): + assert _results_view(poll, portal).winning_slot_display() == "" + + def test_winning_slot_display_non_empty_after_votes(self, poll, portal): + submit_vote(poll, "alice", [SLOT_A]) + assert _results_view(poll, portal).winning_slot_display() != "" + + def test_is_final_false_for_open_poll(self, poll, portal): + assert _results_view(poll, portal).is_final() is False + + def test_is_final_true_for_final_poll(self, poll, portal): + poll.poll_state = "final" + assert _results_view(poll, portal).is_final() is True + + def test_final_slot_display_non_empty_when_set(self, poll, portal): + poll.poll_state = "final" + poll.final_selected_slot = SLOT_C + assert ( + _results_view(poll, portal).final_slot_display() + == _results_view(poll, portal).winning_slot_display() + or _results_view(poll, portal).final_slot_display() != "" + ) + + def test_final_slot_display_empty_when_not_set(self, poll, portal): + assert _results_view(poll, portal).final_slot_display() == "" + + +# --------------------------------------------------------------------------- +# PollVoteView โ€” anonymous voting registry setting +# --------------------------------------------------------------------------- + + +class TestAnonymousVoting: + """Tests for the allow_anonymous_voting registry toggle.""" + + @pytest.fixture(autouse=True) + def _reset_registry(self, portal, integration): + """Ensure allow_anonymous_voting is False before and after each test.""" + from plone.registry.interfaces import IRegistry + from zope.component import getUtility + + registry = getUtility(IRegistry) + registry["experimental.doodle.allow_anonymous_voting"] = False + yield + registry["experimental.doodle.allow_anonymous_voting"] = False + + def test_anonymous_cannot_vote_when_setting_disabled(self, poll, portal): + """Default: anonymous POST stores no vote.""" + from plone.app.testing import login as testing_login + from plone.app.testing import logout as testing_logout + from plone.app.testing import TEST_USER_NAME + + portal.REQUEST.form["chosen_slots"] = [SLOT_A.isoformat()] + testing_logout() + try: + _vote_view(poll, portal)._handle_post() + assert len(get_votes(poll)) == 0 + finally: + testing_login(portal, TEST_USER_NAME) + + def test_anonymous_can_vote_when_setting_enabled(self, poll, portal): + """When allow_anonymous_voting=True an anonymous POST is accepted.""" + from plone.app.testing import login as testing_login + from plone.app.testing import logout as testing_logout + from plone.app.testing import TEST_USER_NAME + from plone.registry.interfaces import IRegistry + from zope.component import getUtility + + getUtility(IRegistry)["experimental.doodle.allow_anonymous_voting"] = True + portal.REQUEST.form["chosen_slots"] = [SLOT_A.isoformat()] + testing_logout() + try: + _vote_view(poll, portal)._handle_post() + assert len(get_votes(poll)) == 1 + finally: + testing_login(portal, TEST_USER_NAME) + + def test_anonymous_vote_stored_with_anon_participant_id(self, poll, portal): + """Anonymous vote is keyed under 'anon:...' in the vote store.""" + from plone.app.testing import login as testing_login + from plone.app.testing import logout as testing_logout + from plone.app.testing import TEST_USER_NAME + from plone.registry.interfaces import IRegistry + from zope.component import getUtility + + getUtility(IRegistry)["experimental.doodle.allow_anonymous_voting"] = True + portal.REQUEST.form["chosen_slots"] = [SLOT_A.isoformat()] + testing_logout() + try: + _vote_view(poll, portal)._handle_post() + votes = get_votes(poll) + assert any(pid.startswith("anon:") for pid in votes) + finally: + testing_login(portal, TEST_USER_NAME) + + def test_authenticated_vote_unaffected_by_anonymous_setting( + self, poll, portal, member + ): + """Enabling anonymous voting does not change authenticated vote storage.""" + from plone.registry.interfaces import IRegistry + from zope.component import getUtility + + getUtility(IRegistry)["experimental.doodle.allow_anonymous_voting"] = True + portal.REQUEST.form["chosen_slots"] = [SLOT_A.isoformat()] + with plone.api.env.adopt_user(username=member.id): + _vote_view(poll, portal)._handle_post() + assert get_vote(poll, member.id) is not None diff --git a/tests/content/test_booking_page.py b/tests/content/test_booking_page.py new file mode 100644 index 0000000..43c9970 --- /dev/null +++ b/tests/content/test_booking_page.py @@ -0,0 +1,152 @@ +"""Integration tests for the BookingPage content type.""" + +import pytest + + +class TestBookingPageTypeRegistration: + def test_booking_page_fti_registered(self, portal): + """Test that the BookingPage FTI is registered in portal_types.""" + assert "BookingPage" in portal.portal_types + + def test_booking_page_fti_meta_type(self, portal): + """Test that the BookingPage FTI has the correct meta_type.""" + fti = portal.portal_types["BookingPage"] + assert fti.meta_type == "Dexterity FTI" + + def test_booking_page_schema(self, portal): + """Test that the BookingPage FTI references the correct schema.""" + fti = portal.portal_types["BookingPage"] + assert fti.schema == "experimental.doodle.content.booking_page.IBookingPage" + + def test_booking_page_klass(self, portal): + """Test that the BookingPage FTI references the correct class.""" + fti = portal.portal_types["BookingPage"] + assert fti.klass == "experimental.doodle.content.booking_page.BookingPage" + + +class TestBookingPageCreation: + @pytest.fixture(autouse=True) + def _setup(self, integration): + """Use the integration testing layer.""" + + def test_booking_page_is_creatable(self, portal): + """Test that a BookingPage object can be created.""" + import plone.api + + with plone.api.env.adopt_roles(["Manager"]): + page = plone.api.content.create( + container=portal, + type="BookingPage", + id="test-booking-page", + title="My Booking Page", + ) + assert page is not None + + def test_booking_page_default_state(self, portal): + """Test that a new BookingPage defaults to booking_state 'open'.""" + import plone.api + + with plone.api.env.adopt_roles(["Manager"]): + page = plone.api.content.create( + container=portal, + type="BookingPage", + id="test-booking-page-state", + title="State Test Page", + ) + assert page.booking_state == "open" + + def test_booking_page_default_slot_duration(self, portal): + """Test that a new BookingPage defaults to slot_duration 30 minutes.""" + import plone.api + + with plone.api.env.adopt_roles(["Manager"]): + page = plone.api.content.create( + container=portal, + type="BookingPage", + id="test-booking-page-slot", + title="Slot Test Page", + ) + assert page.slot_duration == 30 + + def test_booking_page_default_working_hours(self, portal): + """Test that a new BookingPage defaults to 9โ€“17 working hours.""" + import plone.api + + with plone.api.env.adopt_roles(["Manager"]): + page = plone.api.content.create( + container=portal, + type="BookingPage", + id="test-booking-page-hours", + title="Hours Test Page", + ) + assert page.working_hours_start == 9 + assert page.working_hours_end == 17 + + def test_booking_page_default_working_days_empty(self, portal): + """Test that working_days defaults to an empty list.""" + import plone.api + + with plone.api.env.adopt_roles(["Manager"]): + page = plone.api.content.create( + container=portal, + type="BookingPage", + id="test-booking-page-days", + title="Days Test Page", + ) + assert page.working_days == [] + + def test_booking_page_stores_organizer(self, portal): + """Test that the organizer field can be set and read back.""" + import plone.api + + with plone.api.env.adopt_roles(["Manager"]): + page = plone.api.content.create( + container=portal, + type="BookingPage", + id="test-booking-page-organizer", + title="Organizer Test Page", + organizer="alice@example.com", + ) + assert page.organizer == "alice@example.com" + + def test_booking_page_stores_working_days(self, portal): + """Test that working_days can be set to specific weekday values.""" + import plone.api + + with plone.api.env.adopt_roles(["Manager"]): + page = plone.api.content.create( + container=portal, + type="BookingPage", + id="test-booking-page-weekdays", + title="Weekdays Test Page", + working_days=[0, 1, 2, 3, 4], + ) + assert page.working_days == [0, 1, 2, 3, 4] + + def test_booking_page_stores_availability_exceptions(self, portal): + """Test that availability_exceptions defaults to an empty list.""" + import plone.api + + with plone.api.env.adopt_roles(["Manager"]): + page = plone.api.content.create( + container=portal, + type="BookingPage", + id="test-booking-page-exceptions", + title="Exceptions Test Page", + ) + assert page.availability_exceptions == [] + + def test_ibooking_page_provides(self, portal): + """Test that the created object provides IBookingPage.""" + from experimental.doodle.content.booking_page import IBookingPage + + import plone.api + + with plone.api.env.adopt_roles(["Manager"]): + page = plone.api.content.create( + container=portal, + type="BookingPage", + id="test-booking-page-iface", + title="Interface Test Page", + ) + assert IBookingPage.providedBy(page) diff --git a/tests/content/test_poll.py b/tests/content/test_poll.py new file mode 100644 index 0000000..370e2be --- /dev/null +++ b/tests/content/test_poll.py @@ -0,0 +1,98 @@ +"""Integration tests for the Poll content type.""" + +import pytest + + +class TestPollTypeRegistration: + def test_poll_fti_registered(self, portal): + """Test that the Poll FTI is registered in portal_types.""" + assert "Poll" in portal.portal_types + + def test_poll_fti_meta_type(self, portal): + """Test that the Poll FTI has the correct meta_type.""" + fti = portal.portal_types["Poll"] + assert fti.meta_type == "Dexterity FTI" + + def test_poll_schema(self, portal): + """Test that the Poll FTI references the correct schema.""" + fti = portal.portal_types["Poll"] + assert fti.schema == "experimental.doodle.content.poll.IPoll" + + def test_poll_klass(self, portal): + """Test that the Poll FTI references the correct class.""" + fti = portal.portal_types["Poll"] + assert fti.klass == "experimental.doodle.content.poll.Poll" + + +class TestPollCreation: + @pytest.fixture(autouse=True) + def _setup(self, integration): + """Use the integration testing layer.""" + + def test_poll_is_creatable(self, portal): + """Test that a Poll object can be created.""" + import plone.api + + with plone.api.env.adopt_roles(["Manager"]): + poll = plone.api.content.create( + container=portal, + type="Poll", + id="test-poll", + title="Team Meeting Poll", + ) + assert poll is not None + + def test_poll_default_state(self, portal): + """Test that a new Poll defaults to poll_state 'open'.""" + import plone.api + + with plone.api.env.adopt_roles(["Manager"]): + poll = plone.api.content.create( + container=portal, + type="Poll", + id="test-poll-state", + title="State Test Poll", + ) + assert poll.poll_state == "open" + + def test_poll_stores_location(self, portal): + """Test that the location field can be set and read back.""" + import plone.api + + with plone.api.env.adopt_roles(["Manager"]): + poll = plone.api.content.create( + container=portal, + type="Poll", + id="test-poll-location", + title="Location Test Poll", + location="Room 42", + ) + assert poll.location == "Room 42" + + def test_poll_proposed_slots_default_empty(self, portal): + """Test that proposed_slots defaults to an empty list.""" + import plone.api + + with plone.api.env.adopt_roles(["Manager"]): + poll = plone.api.content.create( + container=portal, + type="Poll", + id="test-poll-slots", + title="Slots Test Poll", + ) + assert poll.proposed_slots == [] or poll.proposed_slots is None + + def test_poll_implements_ipoll(self, portal): + """Test that a Poll object implements IPoll.""" + from experimental.doodle.content.poll import IPoll + + import plone.api + + with plone.api.env.adopt_roles(["Manager"]): + poll = plone.api.content.create( + container=portal, + type="Poll", + id="test-poll-iface", + title="Interface Test Poll", + ) + assert IPoll.providedBy(poll) diff --git a/tests/controlpanel/test_controlpanel.py b/tests/controlpanel/test_controlpanel.py new file mode 100644 index 0000000..7800613 --- /dev/null +++ b/tests/controlpanel/test_controlpanel.py @@ -0,0 +1,96 @@ +"""Integration tests for the doodle control panel and registry settings.""" + +from experimental.doodle.controlpanels.doodle_settings import IDoodleSettings +from plone.registry.interfaces import IRegistry +from zope.component import getUtility + +import pytest + + +class TestRegistryRecords: + """Registry records are created with correct default values on install.""" + + @pytest.fixture(autouse=True) + def _setup(self, integration): + """Use the integration testing layer.""" + + def test_default_poll_slot_duration_exists(self, portal): + registry = getUtility(IRegistry) + assert "experimental.doodle.default_poll_slot_duration" in registry + + def test_default_poll_slot_duration_default(self, portal): + registry = getUtility(IRegistry) + settings = registry.forInterface(IDoodleSettings, prefix="experimental.doodle") + assert settings.default_poll_slot_duration == 30 + + def test_default_booking_slot_duration_exists(self, portal): + registry = getUtility(IRegistry) + assert "experimental.doodle.default_booking_slot_duration" in registry + + def test_default_booking_slot_duration_default(self, portal): + registry = getUtility(IRegistry) + settings = registry.forInterface(IDoodleSettings, prefix="experimental.doodle") + assert settings.default_booking_slot_duration == 30 + + def test_allow_anonymous_voting_exists(self, portal): + registry = getUtility(IRegistry) + assert "experimental.doodle.allow_anonymous_voting" in registry + + def test_allow_anonymous_voting_default_false(self, portal): + registry = getUtility(IRegistry) + settings = registry.forInterface(IDoodleSettings, prefix="experimental.doodle") + assert settings.allow_anonymous_voting is False + + def test_require_booking_confirmation_exists(self, portal): + registry = getUtility(IRegistry) + assert "experimental.doodle.require_booking_confirmation" in registry + + def test_require_booking_confirmation_default_false(self, portal): + registry = getUtility(IRegistry) + settings = registry.forInterface(IDoodleSettings, prefix="experimental.doodle") + assert settings.require_booking_confirmation is False + + def test_default_timezone_exists(self, portal): + registry = getUtility(IRegistry) + assert "experimental.doodle.default_timezone" in registry + + def test_default_timezone_default_utc(self, portal): + registry = getUtility(IRegistry) + settings = registry.forInterface(IDoodleSettings, prefix="experimental.doodle") + assert settings.default_timezone == "UTC" + + def test_settings_are_mutable(self, portal): + """Registry values can be updated after install.""" + registry = getUtility(IRegistry) + settings = registry.forInterface(IDoodleSettings, prefix="experimental.doodle") + settings.default_poll_slot_duration = 60 + assert registry["experimental.doodle.default_poll_slot_duration"] == 60 + + +class TestControlPanelRegistration: + """Control panel configlet is registered after install.""" + + @pytest.fixture(autouse=True) + def _setup(self, integration): + """Use the integration testing layer.""" + + def test_configlet_is_registered(self, portal): + """The doodle-settings configlet appears in portal_controlpanel.""" + action_ids = [a.id for a in portal.portal_controlpanel.listActions()] + assert "doodle-settings" in action_ids + + def test_configlet_title(self, portal): + """The configlet has the correct title.""" + actions = {a.id: a for a in portal.portal_controlpanel.listActions()} + assert actions["doodle-settings"].title == "Doodle Settings" + + def test_configlet_category(self, portal): + """The configlet is filed under the Products category.""" + actions = {a.id: a for a in portal.portal_controlpanel.listActions()} + assert actions["doodle-settings"].category == "Products" + + def test_configlet_url_contains_view_name(self, portal): + """The configlet URL expression references @@doodle-settings.""" + actions = {a.id: a for a in portal.portal_controlpanel.listActions()} + url_expr = actions["doodle-settings"].action.text + assert "doodle-settings" in url_expr diff --git a/tests/scheduling/test_scheduling.py b/tests/scheduling/test_scheduling.py new file mode 100644 index 0000000..d932ee9 --- /dev/null +++ b/tests/scheduling/test_scheduling.py @@ -0,0 +1,263 @@ +"""Integration tests for the booking scheduling logic.""" + +from experimental.doodle.scheduling import BookingPageClosedError +from experimental.doodle.scheduling import create_booking +from experimental.doodle.scheduling import get_available_slots +from experimental.doodle.scheduling import get_booking +from experimental.doodle.scheduling import get_bookings +from experimental.doodle.scheduling import InvalidSlotError +from experimental.doodle.scheduling import SlotUnavailableError + +import datetime +import plone.api +import pytest + + +# --------------------------------------------------------------------------- +# Shared fixtures and helpers +# --------------------------------------------------------------------------- + +# A known Monday โ€” 2 June 2026 is a Tuesday, 1 June 2026 is a Monday. +MONDAY = datetime.date(2026, 6, 1) +TUESDAY = datetime.date(2026, 6, 2) +WEDNESDAY = datetime.date(2026, 6, 3) + +# The 09:00 slot on that Monday (naive datetime, matching storage convention). +SLOT_0900 = datetime.datetime(2026, 6, 1, 9, 0) +SLOT_0930 = datetime.datetime(2026, 6, 1, 9, 30) +SLOT_1000 = datetime.datetime(2026, 6, 1, 10, 0) +SLOT_1030 = datetime.datetime(2026, 6, 1, 10, 30) + + +@pytest.fixture() +def booking_page(portal, integration): + """A minimal open BookingPage with Mon-Fri, 09:00-11:00, 30-min slots.""" + with plone.api.env.adopt_roles(["Manager"]): + page = plone.api.content.create( + container=portal, + type="BookingPage", + id="test-booking-page", + title="Test Booking Page", + working_days=[0, 1, 2, 3, 4], # Monโ€“Fri + working_hours_start=9, + working_hours_end=11, + slot_duration=30, + buffer_duration=0, + availability_exceptions=[], + booking_state="open", + ) + return page + + +# --------------------------------------------------------------------------- +# Slot computation tests +# --------------------------------------------------------------------------- + + +class TestSlotComputation: + def test_working_day_produces_slots(self, booking_page): + """A working day within working hours produces the expected slots.""" + # 9:00, 9:30, 10:00, 10:30 โ€” last slot ends exactly at 11:00. + slots = get_available_slots(booking_page, MONDAY) + assert slots == [SLOT_0900, SLOT_0930, SLOT_1000, SLOT_1030] + + def test_non_working_day_produces_no_slots(self, booking_page): + """A day not listed in working_days produces an empty list.""" + # MONDAY is day 0; pass a Saturday (day 5) which is not in [0,1,2,3,4]. + saturday = datetime.date(2026, 6, 6) + assert get_available_slots(booking_page, saturday) == [] + + def test_exception_date_produces_no_slots(self, booking_page): + """A date in availability_exceptions produces an empty list.""" + booking_page.availability_exceptions = [MONDAY] + assert get_available_slots(booking_page, MONDAY) == [] + + def test_slots_respect_working_hours_start(self, booking_page): + """First slot starts at working_hours_start.""" + slots = get_available_slots(booking_page, MONDAY) + assert slots[0].hour == 9 + assert slots[0].minute == 0 + + def test_slots_respect_working_hours_end(self, booking_page): + """No slot begins so late that it would end after working_hours_end.""" + # With 9-11, 30-min slots: last valid start is 10:30 (ends at 11:00). + booking_page.working_hours_end = 11 + slots = get_available_slots(booking_page, MONDAY) + for slot in slots: + assert slot + datetime.timedelta( + minutes=booking_page.slot_duration + ) <= datetime.datetime( + MONDAY.year, MONDAY.month, MONDAY.day, booking_page.working_hours_end, 0 + ) + + def test_buffer_widens_step_between_slots(self, booking_page): + """Setting buffer_duration shifts the start of each subsequent slot.""" + booking_page.buffer_duration = 15 # 30-min slot + 15-min buffer = 45-min step + slots = get_available_slots(booking_page, MONDAY) + # 09:00, 09:45 โ€” 10:30 would also fit (10:30 + 0:30 = 11:00 โ‰ค 11:00) + assert slots[0] == datetime.datetime(2026, 6, 1, 9, 0) + assert slots[1] == datetime.datetime(2026, 6, 1, 9, 45) + + def test_empty_working_days_allows_any_day(self, booking_page): + """When working_days is empty no weekday filter is applied.""" + booking_page.working_days = [] + saturday = datetime.date(2026, 6, 6) + slots = get_available_slots(booking_page, saturday) + assert len(slots) > 0 + + +# --------------------------------------------------------------------------- +# Booking creation tests +# --------------------------------------------------------------------------- + + +class TestBookingCreation: + def test_successful_booking_returns_record(self, booking_page): + """create_booking returns a dict with booker_id and booked_at.""" + record = create_booking(booking_page, "alice", SLOT_0900) + assert record["booker_id"] == "alice" + assert "booked_at" in record + + def test_booking_is_stored(self, booking_page): + """After create_booking the record is retrievable via get_booking.""" + create_booking(booking_page, "alice", SLOT_0900) + record = get_booking(booking_page, SLOT_0900) + assert record is not None + assert record["booker_id"] == "alice" + + def test_booked_slot_disappears_from_availability(self, booking_page): + """A booked slot is no longer returned by get_available_slots.""" + create_booking(booking_page, "alice", SLOT_0900) + available = get_available_slots(booking_page, MONDAY) + assert SLOT_0900 not in available + + def test_other_slots_remain_available(self, booking_page): + """Booking one slot does not affect the availability of other slots.""" + create_booking(booking_page, "alice", SLOT_0900) + available = get_available_slots(booking_page, MONDAY) + assert SLOT_0930 in available + assert SLOT_1000 in available + assert SLOT_1030 in available + + def test_get_bookings_returns_all(self, booking_page): + """get_bookings returns a plain dict of all bookings.""" + create_booking(booking_page, "alice", SLOT_0900) + create_booking(booking_page, "bob", SLOT_0930) + bookings = get_bookings(booking_page) + assert SLOT_0900 in bookings + assert SLOT_0930 in bookings + assert bookings[SLOT_0900]["booker_id"] == "alice" + assert bookings[SLOT_0930]["booker_id"] == "bob" + + def test_get_booking_returns_none_for_unbooked(self, booking_page): + """get_booking returns None for a slot that has not been booked.""" + assert get_booking(booking_page, SLOT_0900) is None + + +# --------------------------------------------------------------------------- +# Conflict detection tests +# --------------------------------------------------------------------------- + + +class TestConflictDetection: + def test_double_booking_raises_slot_unavailable(self, booking_page): + """A second attempt to book the same slot raises SlotUnavailableError.""" + create_booking(booking_page, "alice", SLOT_0900) + with pytest.raises(SlotUnavailableError): + create_booking(booking_page, "bob", SLOT_0900) + + def test_double_booking_does_not_overwrite_original(self, booking_page): + """The original booking record is unchanged after a failed double-booking.""" + create_booking(booking_page, "alice", SLOT_0900) + try: + create_booking(booking_page, "bob", SLOT_0900) + except SlotUnavailableError: + pass + assert get_booking(booking_page, SLOT_0900)["booker_id"] == "alice" + + +# --------------------------------------------------------------------------- +# Closed / invalid booking page tests +# --------------------------------------------------------------------------- + + +class TestBookingPageGuards: + def test_closed_page_raises_booking_page_closed(self, booking_page): + """Booking on a closed page raises BookingPageClosedError.""" + booking_page.booking_state = "closed" + with pytest.raises(BookingPageClosedError): + create_booking(booking_page, "alice", SLOT_0900) + + def test_closed_page_stores_no_booking(self, booking_page): + """No booking is stored when the page is closed.""" + booking_page.booking_state = "closed" + try: + create_booking(booking_page, "alice", SLOT_0900) + except BookingPageClosedError: + pass + assert get_bookings(booking_page) == {} + + def test_invalid_slot_raises_invalid_slot_error(self, booking_page): + """Booking a datetime that is not a valid slot raises InvalidSlotError.""" + bad_slot = datetime.datetime(2026, 6, 1, 8, 0) # before working hours + with pytest.raises(InvalidSlotError): + create_booking(booking_page, "alice", bad_slot) + + def test_non_working_day_slot_raises_invalid_slot_error(self, booking_page): + """Booking a slot on a non-working day raises InvalidSlotError.""" + saturday_slot = datetime.datetime(2026, 6, 6, 9, 0) # Saturday + with pytest.raises(InvalidSlotError): + create_booking(booking_page, "alice", saturday_slot) + + def test_exception_date_slot_raises_invalid_slot_error(self, booking_page): + """Booking a slot on an exception date raises InvalidSlotError.""" + booking_page.availability_exceptions = [MONDAY] + with pytest.raises(InvalidSlotError): + create_booking(booking_page, "alice", SLOT_0900) + + +# --------------------------------------------------------------------------- +# Registry default slot duration tests +# --------------------------------------------------------------------------- + + +class TestRegistrySlotDuration: + """Tests that default_booking_slot_duration registry setting is used as + fallback when slot_duration is not set on the BookingPage object.""" + + @pytest.fixture(autouse=True) + def _reset_registry(self, portal, integration): + """Restore the registry to its default value after each test.""" + from plone.registry.interfaces import IRegistry + from zope.component import getUtility + + registry = getUtility(IRegistry) + registry["experimental.doodle.default_booking_slot_duration"] = 30 + yield + registry["experimental.doodle.default_booking_slot_duration"] = 30 + + def test_uses_registry_default_when_slot_duration_is_none( + self, booking_page, portal + ): + """When slot_duration is None the registry value drives slot generation.""" + from plone.registry.interfaces import IRegistry + from zope.component import getUtility + + getUtility(IRegistry)["experimental.doodle.default_booking_slot_duration"] = 60 + booking_page.slot_duration = None + # 09:00โ€“11:00, 60-min slots: 09:00 and 10:00 only. + slots = get_available_slots(booking_page, MONDAY) + assert len(slots) == 2 + assert slots[0] == SLOT_0900 + assert slots[1] == SLOT_1000 + + def test_field_value_takes_precedence_over_registry(self, booking_page, portal): + """An explicit slot_duration on the object overrides the registry.""" + from plone.registry.interfaces import IRegistry + from zope.component import getUtility + + getUtility(IRegistry)["experimental.doodle.default_booking_slot_duration"] = 60 + booking_page.slot_duration = 30 # explicit field value wins + slots = get_available_slots(booking_page, MONDAY) + # 09:00โ€“11:00, 30-min slots: 09:00, 09:30, 10:00, 10:30. + assert len(slots) == 4 diff --git a/tests/voting/test_voting.py b/tests/voting/test_voting.py new file mode 100644 index 0000000..9cfc424 --- /dev/null +++ b/tests/voting/test_voting.py @@ -0,0 +1,245 @@ +"""Integration tests for Poll voting backend logic.""" + +from datetime import datetime +from experimental.doodle.voting import aggregate_votes +from experimental.doodle.voting import DuplicateVoteError +from experimental.doodle.voting import get_vote +from experimental.doodle.voting import get_votes +from experimental.doodle.voting import get_winning_slot +from experimental.doodle.voting import InvalidSlotError +from experimental.doodle.voting import PollClosedError +from experimental.doodle.voting import submit_vote + +import plone.api +import pytest + + +SLOT_A = datetime(2026, 6, 1, 9, 0) +SLOT_B = datetime(2026, 6, 1, 14, 0) +SLOT_C = datetime(2026, 6, 2, 9, 0) + + +# --------------------------------------------------------------------------- +# Fixture +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def open_poll(portal, integration): + """Return a freshly created open Poll with three proposed slots.""" + with plone.api.env.adopt_roles(["Manager"]): + poll = plone.api.content.create( + container=portal, + type="Poll", + id="voting-test-poll", + title="Voting Test Poll", + proposed_slots=[SLOT_A, SLOT_B, SLOT_C], + poll_state="open", + ) + return poll + + +# --------------------------------------------------------------------------- +# Vote submission โ€” happy path +# --------------------------------------------------------------------------- + + +class TestValidVoteSubmission: + def test_vote_is_stored(self, open_poll): + """Submitting a valid vote stores it under the participant_id.""" + submit_vote(open_poll, "alice", [SLOT_A, SLOT_B]) + record = get_vote(open_poll, "alice") + assert record is not None + assert set(record["chosen_slots"]) == {SLOT_A, SLOT_B} + + def test_vote_stores_submitted_at(self, open_poll): + """The vote record includes a submitted_at timestamp.""" + submit_vote(open_poll, "alice", [SLOT_A]) + record = get_vote(open_poll, "alice") + assert isinstance(record["submitted_at"], datetime) + + def test_single_slot_vote(self, open_poll): + """A participant may vote for exactly one slot.""" + submit_vote(open_poll, "bob", [SLOT_C]) + record = get_vote(open_poll, "bob") + assert record["chosen_slots"] == [SLOT_C] + + def test_all_slots_vote(self, open_poll): + """A participant may vote for all proposed slots.""" + submit_vote(open_poll, "carol", [SLOT_A, SLOT_B, SLOT_C]) + record = get_vote(open_poll, "carol") + assert len(record["chosen_slots"]) == 3 + + def test_get_votes_returns_all(self, open_poll): + """get_votes returns a mapping for every participant who voted.""" + submit_vote(open_poll, "alice", [SLOT_A]) + submit_vote(open_poll, "bob", [SLOT_B]) + all_votes = get_votes(open_poll) + assert set(all_votes.keys()) == {"alice", "bob"} + + def test_get_vote_returns_none_for_unknown(self, open_poll): + """get_vote returns None for a participant who has not voted.""" + assert get_vote(open_poll, "nobody") is None + + +# --------------------------------------------------------------------------- +# Duplicate vote prevention +# --------------------------------------------------------------------------- + + +class TestDuplicateVotePrevention: + def test_duplicate_raises_error(self, open_poll): + """Submitting a second vote for the same participant raises DuplicateVoteError.""" + submit_vote(open_poll, "alice", [SLOT_A]) + with pytest.raises(DuplicateVoteError): + submit_vote(open_poll, "alice", [SLOT_B]) + + def test_original_vote_is_unchanged_after_duplicate_attempt(self, open_poll): + """The original vote is not mutated when a duplicate is rejected.""" + submit_vote(open_poll, "alice", [SLOT_A]) + with pytest.raises(DuplicateVoteError): + submit_vote(open_poll, "alice", [SLOT_B]) + record = get_vote(open_poll, "alice") + assert record["chosen_slots"] == [SLOT_A] + + def test_different_participants_can_vote_independently(self, open_poll): + """Two different participants can each submit one vote without conflict.""" + submit_vote(open_poll, "alice", [SLOT_A]) + submit_vote(open_poll, "bob", [SLOT_A]) + assert get_vote(open_poll, "alice") is not None + assert get_vote(open_poll, "bob") is not None + + +# --------------------------------------------------------------------------- +# Closed poll rejects votes +# --------------------------------------------------------------------------- + + +class TestClosedPollRejectsVote: + def test_closed_state_raises_error(self, open_poll): + """Submitting a vote to a closed poll raises PollClosedError.""" + open_poll.poll_state = "closed" + with pytest.raises(PollClosedError): + submit_vote(open_poll, "alice", [SLOT_A]) + + def test_final_state_raises_error(self, open_poll): + """Submitting a vote to a finalised poll raises PollClosedError.""" + open_poll.poll_state = "final" + with pytest.raises(PollClosedError): + submit_vote(open_poll, "alice", [SLOT_A]) + + def test_open_poll_does_not_raise(self, open_poll): + """No error is raised when poll_state is 'open'.""" + submit_vote(open_poll, "alice", [SLOT_A]) # must not raise + + +# --------------------------------------------------------------------------- +# Invalid slot rejection +# --------------------------------------------------------------------------- + + +class TestInvalidSlotRejection: + def test_unknown_slot_raises_error(self, open_poll): + """Choosing a slot not in proposed_slots raises InvalidSlotError.""" + unknown = datetime(2099, 1, 1, 12, 0) + with pytest.raises(InvalidSlotError): + submit_vote(open_poll, "alice", [unknown]) + + def test_empty_chosen_slots_raises_value_error(self, open_poll): + """Submitting an empty list of chosen slots raises ValueError.""" + with pytest.raises(ValueError): + submit_vote(open_poll, "alice", []) + + +# --------------------------------------------------------------------------- +# Vote aggregation +# --------------------------------------------------------------------------- + + +class TestVoteAggregation: + def test_all_slots_present_in_counts(self, open_poll): + """aggregate_votes includes every proposed slot, even those with no votes.""" + counts = aggregate_votes(open_poll) + assert set(counts.keys()) == {SLOT_A, SLOT_B, SLOT_C} + + def test_zero_counts_before_any_votes(self, open_poll): + """All slot counts are 0 before any votes are submitted.""" + counts = aggregate_votes(open_poll) + assert all(v == 0 for v in counts.values()) + + def test_correct_count_after_votes(self, open_poll): + """Counts correctly reflect the votes cast.""" + submit_vote(open_poll, "alice", [SLOT_A, SLOT_B]) + submit_vote(open_poll, "bob", [SLOT_A]) + submit_vote(open_poll, "carol", [SLOT_C]) + counts = aggregate_votes(open_poll) + assert counts[SLOT_A] == 2 + assert counts[SLOT_B] == 1 + assert counts[SLOT_C] == 1 + + def test_aggregation_is_independent_per_poll(self, portal, integration): + """Votes on one poll do not affect aggregation on another.""" + with plone.api.env.adopt_roles(["Manager"]): + poll1 = plone.api.content.create( + container=portal, + type="Poll", + id="agg-poll-1", + title="Aggregation Poll 1", + proposed_slots=[SLOT_A], + poll_state="open", + ) + poll2 = plone.api.content.create( + container=portal, + type="Poll", + id="agg-poll-2", + title="Aggregation Poll 2", + proposed_slots=[SLOT_A], + poll_state="open", + ) + submit_vote(poll1, "alice", [SLOT_A]) + assert aggregate_votes(poll1)[SLOT_A] == 1 + assert aggregate_votes(poll2)[SLOT_A] == 0 + + +# --------------------------------------------------------------------------- +# Winning / best slot +# --------------------------------------------------------------------------- + + +class TestWinningSlot: + def test_no_votes_returns_none(self, open_poll): + """get_winning_slot returns None when no votes have been cast.""" + assert get_winning_slot(open_poll) is None + + def test_clear_winner(self, open_poll): + """The slot with the most votes is returned as the winner.""" + submit_vote(open_poll, "alice", [SLOT_A, SLOT_B]) + submit_vote(open_poll, "bob", [SLOT_A]) + assert get_winning_slot(open_poll) == SLOT_A + + def test_tie_broken_by_position(self, open_poll): + """When two slots tie, the one appearing first in proposed_slots wins.""" + # SLOT_A and SLOT_B each get 1 vote; SLOT_A appears first. + submit_vote(open_poll, "alice", [SLOT_A]) + submit_vote(open_poll, "bob", [SLOT_B]) + assert get_winning_slot(open_poll) == SLOT_A + + def test_final_state_returns_final_selected_slot(self, open_poll): + """When poll_state is 'final', final_selected_slot is returned directly.""" + submit_vote(open_poll, "alice", [SLOT_B]) + open_poll.poll_state = "final" + open_poll.final_selected_slot = SLOT_C + assert get_winning_slot(open_poll) == SLOT_C + + def test_no_proposed_slots_returns_none(self, portal, integration): + """get_winning_slot returns None when proposed_slots is empty.""" + with plone.api.env.adopt_roles(["Manager"]): + empty_poll = plone.api.content.create( + container=portal, + type="Poll", + id="empty-slots-poll", + title="Empty Slots Poll", + proposed_slots=[], + poll_state="open", + ) + assert get_winning_slot(empty_poll) is None