From 51df7101359c71b42322975f84e4b13eef2c2fe0 Mon Sep 17 00:00:00 2001 From: Ruben Date: Wed, 20 May 2026 13:16:33 +0200 Subject: [PATCH] next gen doodle --- .DS_Store | Bin 0 -> 6148 bytes .github/agents/dev-agent.agent.md | 179 ++++++++ docs/.DS_Store | Bin 0 -> 6148 bytes docs/docs/.DS_Store | Bin 0 -> 6148 bytes docs/docs/reference/doodle-fr.md | 141 +++++++ docs/docs/reference/doodle-planning.md | 387 ++++++++++++++++++ docs/docs/reference/doodle-requirements.md | 131 ++++++ docs/docs/reference/index.md | 3 + .../doodle/browser/booking_views.py | 158 +++++++ .../doodle/browser/configure.zcml | 40 ++ .../doodle/browser/home_viewlets.py | 22 + src/experimental/doodle/browser/poll_views.py | 241 +++++++++++ .../doodle/browser/templates/booking_page.pt | 131 ++++++ .../browser/templates/open_polls_section.pt | 30 ++ .../doodle/browser/templates/poll_results.pt | 92 +++++ .../doodle/browser/templates/poll_vote.pt | 100 +++++ src/experimental/doodle/configure.zcml | 1 + .../doodle/content/booking_page.py | 100 +++++ .../doodle/content/configure.zcml | 8 + src/experimental/doodle/content/poll.py | 59 +++ .../doodle/controlpanels/configure.zcml | 8 + .../doodle/controlpanels/doodle_settings.py | 74 ++++ src/experimental/doodle/indexers/__init__.py | 10 + .../doodle/indexers/configure.zcml | 4 +- .../doodle/profiles/default/catalog.xml | 13 +- .../doodle/profiles/default/controlpanel.xml | 14 + .../doodle/profiles/default/registry/main.xml | 19 + .../doodle/profiles/default/types.xml | 6 + .../profiles/default/types/BookingPage.xml | 62 +++ .../doodle/profiles/default/types/Poll.xml | 91 ++++ src/experimental/doodle/scheduling.py | 242 +++++++++++ src/experimental/doodle/voting.py | 171 ++++++++ tests/browser/test_booking_views.py | 231 +++++++++++ tests/browser/test_poll_views.py | 358 ++++++++++++++++ tests/content/test_booking_page.py | 152 +++++++ tests/content/test_poll.py | 98 +++++ tests/controlpanel/test_controlpanel.py | 96 +++++ tests/scheduling/test_scheduling.py | 263 ++++++++++++ tests/voting/test_voting.py | 245 +++++++++++ 39 files changed, 3972 insertions(+), 8 deletions(-) create mode 100644 .DS_Store create mode 100644 .github/agents/dev-agent.agent.md create mode 100644 docs/.DS_Store create mode 100644 docs/docs/.DS_Store create mode 100644 docs/docs/reference/doodle-fr.md create mode 100644 docs/docs/reference/doodle-planning.md create mode 100644 docs/docs/reference/doodle-requirements.md create mode 100644 src/experimental/doodle/browser/booking_views.py create mode 100644 src/experimental/doodle/browser/home_viewlets.py create mode 100644 src/experimental/doodle/browser/poll_views.py create mode 100644 src/experimental/doodle/browser/templates/booking_page.pt create mode 100644 src/experimental/doodle/browser/templates/open_polls_section.pt create mode 100644 src/experimental/doodle/browser/templates/poll_results.pt create mode 100644 src/experimental/doodle/browser/templates/poll_vote.pt create mode 100644 src/experimental/doodle/content/booking_page.py create mode 100644 src/experimental/doodle/content/configure.zcml create mode 100644 src/experimental/doodle/content/poll.py create mode 100644 src/experimental/doodle/controlpanels/doodle_settings.py create mode 100644 src/experimental/doodle/profiles/default/types/BookingPage.xml create mode 100644 src/experimental/doodle/profiles/default/types/Poll.xml create mode 100644 src/experimental/doodle/scheduling.py create mode 100644 src/experimental/doodle/voting.py create mode 100644 tests/browser/test_booking_views.py create mode 100644 tests/browser/test_poll_views.py create mode 100644 tests/content/test_booking_page.py create mode 100644 tests/content/test_poll.py create mode 100644 tests/controlpanel/test_controlpanel.py create mode 100644 tests/scheduling/test_scheduling.py create mode 100644 tests/voting/test_voting.py diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..912933ac6dccd55e3ee8abfbde1481bb6799babd GIT binary patch literal 6148 zcmeHKyK2Kg5S)!w7}B^*dB2c95XSie|3I)*8UzvPRpq<#X_(=?sAxYwN9VZ?+1^Cf?S8E+WA zL-(GiJSiXrq<|EV0#e|%0#&BBkGIpbD+Q#$KUBcK4~_2F7cPnM>EIA80I_2@jPvLv zh|L4UzHmuogl0)4Ce^CNu%t8IDz7hG5|a*#n|V&%Y}KJy+|GE5bXcFLQ3^b>!3`IX%4*|M(?5HIN=naG*JwYxI#2y292)gU&`J}kS%^VoPCqO=tGGY4< zmI=TPfAbG80@%@=*n3!+F&{8v!Gzm5@b5#RJ8QCa#;1cJMgVePIgIO=CCK6hvL;(6D>TdL!Lrq2 z4DoujlO?aK$=2E1VL5zQ-r0PLp;>Q-6(%&R0R<@_1y%}t_I&j7|Cj!3{$I5yl>$=W z%@nZVcsd^VQhB!idp)n8v+C-@IeJj3^L}GcK~1lWb+byLPhQ2aG#%-T~Yf{`R;zpO3ap zqXJZb3Qz$mKn1?2Ko-~G_={)qC@Me&{zC!#J`}iNO>6@F(}BTT0N?;&H_W}402T`X zYhn|K2uy + + + + + + + + + + + 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