stapel-calendar 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. stapel_calendar-0.1.0/LICENSE +21 -0
  2. stapel_calendar-0.1.0/PKG-INFO +111 -0
  3. stapel_calendar-0.1.0/README.md +94 -0
  4. stapel_calendar-0.1.0/__init__.py +31 -0
  5. stapel_calendar-0.1.0/actions.py +25 -0
  6. stapel_calendar-0.1.0/admin.py +25 -0
  7. stapel_calendar-0.1.0/apps.py +24 -0
  8. stapel_calendar-0.1.0/checks.py +73 -0
  9. stapel_calendar-0.1.0/conf.py +60 -0
  10. stapel_calendar-0.1.0/conftest.py +115 -0
  11. stapel_calendar-0.1.0/dto.py +151 -0
  12. stapel_calendar-0.1.0/errors.py +34 -0
  13. stapel_calendar-0.1.0/functions.py +44 -0
  14. stapel_calendar-0.1.0/gdpr.py +62 -0
  15. stapel_calendar-0.1.0/ics.py +131 -0
  16. stapel_calendar-0.1.0/migrations/0001_initial.py +90 -0
  17. stapel_calendar-0.1.0/migrations/__init__.py +0 -0
  18. stapel_calendar-0.1.0/models.py +208 -0
  19. stapel_calendar-0.1.0/py.typed +0 -0
  20. stapel_calendar-0.1.0/pyproject.toml +42 -0
  21. stapel_calendar-0.1.0/recurrence.py +220 -0
  22. stapel_calendar-0.1.0/reminders.py +127 -0
  23. stapel_calendar-0.1.0/schemas/consumes/user.deleted.json +14 -0
  24. stapel_calendar-0.1.0/schemas/emits/calendar.event.reminder_due.json +18 -0
  25. stapel_calendar-0.1.0/schemas/emits/calendar.occurrence.materialized.json +17 -0
  26. stapel_calendar-0.1.0/schemas/functions/calendar.free_busy.json +14 -0
  27. stapel_calendar-0.1.0/scope.py +42 -0
  28. stapel_calendar-0.1.0/serializers.py +57 -0
  29. stapel_calendar-0.1.0/services.py +355 -0
  30. stapel_calendar-0.1.0/setup.cfg +4 -0
  31. stapel_calendar-0.1.0/signals.py +12 -0
  32. stapel_calendar-0.1.0/stapel_calendar.egg-info/PKG-INFO +111 -0
  33. stapel_calendar-0.1.0/stapel_calendar.egg-info/SOURCES.txt +90 -0
  34. stapel_calendar-0.1.0/stapel_calendar.egg-info/dependency_links.txt +1 -0
  35. stapel_calendar-0.1.0/stapel_calendar.egg-info/requires.txt +4 -0
  36. stapel_calendar-0.1.0/stapel_calendar.egg-info/top_level.txt +1 -0
  37. stapel_calendar-0.1.0/tests/__init__.py +0 -0
  38. stapel_calendar-0.1.0/tests/test_api.py +210 -0
  39. stapel_calendar-0.1.0/tests/test_availability.py +115 -0
  40. stapel_calendar-0.1.0/tests/test_checks.py +33 -0
  41. stapel_calendar-0.1.0/tests/test_comm.py +67 -0
  42. stapel_calendar-0.1.0/tests/test_gdpr.py +128 -0
  43. stapel_calendar-0.1.0/tests/test_ics.py +59 -0
  44. stapel_calendar-0.1.0/tests/test_materialize.py +146 -0
  45. stapel_calendar-0.1.0/tests/test_public_api.py +46 -0
  46. stapel_calendar-0.1.0/tests/test_recurrence.py +186 -0
  47. stapel_calendar-0.1.0/tests/test_rsvp_and_reminders.py +142 -0
  48. stapel_calendar-0.1.0/tests/test_scope.py +66 -0
  49. stapel_calendar-0.1.0/tests/urls.py +5 -0
  50. stapel_calendar-0.1.0/urls.py +35 -0
  51. stapel_calendar-0.1.0/views.py +329 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stapel contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.4
2
+ Name: stapel-calendar
3
+ Version: 0.1.0
4
+ Summary: Calendar and scheduling for the Stapel framework
5
+ License: MIT
6
+ Keywords: django,stapel,calendar
7
+ Classifier: Framework :: Django
8
+ Classifier: Programming Language :: Python :: 3.11
9
+ Classifier: Programming Language :: Python :: 3.12
10
+ Requires-Python: >=3.11
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: stapel-core<0.4,>=0.3.0
14
+ Requires-Dist: python-dateutil<3,>=2.8
15
+ Provides-Extra: all
16
+ Dynamic: license-file
17
+
18
+ # stapel-calendar
19
+
20
+ Calendar, recurrence and scheduling for the [Stapel framework](https://github.com/usestapel) —
21
+ composable Django apps that deploy as a monolith or as microservices
22
+ without changing module code.
23
+
24
+ A generic calendar core — **Event / Participant / RSVP**, an RFC 5545
25
+ **recurrence engine** (RRULE via `python-dateutil`, virtual expansion +
26
+ on-demand materialization), **availability** (working windows, free/busy,
27
+ slots) and **ICS export**. Two flavors of one domain — *meetings* and
28
+ *bookings* — share this core; everything app-specific lives behind seams.
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ pip install stapel-calendar
34
+ ```
35
+
36
+ ```python
37
+ INSTALLED_APPS = [
38
+ # ...
39
+ "stapel_calendar",
40
+ ]
41
+
42
+ # urls.py
43
+ path("calendar/", include("stapel_calendar.urls"))
44
+ ```
45
+
46
+ ## Concepts
47
+
48
+ - **Event** — a scheduled thing (meeting/booking/appointment) with tz-aware
49
+ `start`/`end`, an `owner` and an opaque `scope_key`. No FK to any host
50
+ concept (org, room) — scoping and resources are seams.
51
+ - **Recurrence** — the series master stores a canonical RRULE. Occurrences
52
+ are *virtual* until they gain their own state (an RSVP or a resource), at
53
+ which point they are *materialized* (persisted) on demand.
54
+ - **Availability** — recurring working windows minus busy events give open
55
+ booking slots.
56
+
57
+ ```python
58
+ from stapel_calendar import services
59
+
60
+ series = services.create_event(
61
+ owner=user, title="Standup",
62
+ start=start, end=end,
63
+ recurrence_type="weekdays", # -> FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR
64
+ )
65
+ occurrences = services.expand_event(series, range_start, range_end) # virtual
66
+ services.materialize(series, occurrence_start) # persist one + emit hook
67
+ busy = services.free_busy(user, range_start, range_end)
68
+ slots = services.compute_slots(user, range_start, range_end, slot_minutes=30)
69
+ ```
70
+
71
+ ## Settings
72
+
73
+ All configuration lives in the `STAPEL_CALENDAR` namespace (dict setting,
74
+ flat setting, or env var — resolved lazily):
75
+
76
+ | Key | Default | Meaning |
77
+ |---|---|---|
78
+ | `SCOPE_PROVIDER` | `…scope.DefaultScopeProvider` | Resolve/filter the opaque `scope_key` |
79
+ | `REMINDER_POLICY` | `…reminders.DefaultReminderPolicy` | When/what to remind |
80
+ | `PRESETS` | `{}` | Custom recurrence presets (merged over built-ins) |
81
+ | `REMINDER_OFFSETS` | `[10]` | Minutes before start to remind |
82
+ | `REMINDER_SCAN_WINDOW_SECONDS` | `60` | Reminder cron granularity |
83
+ | `DEFAULT_EXPANSION_HORIZON_DAYS` | `90` | Default range end |
84
+ | `MAX_EXPANSION_OCCURRENCES` | `1000` | Expansion safety cap |
85
+ | `DEFAULT_SLOT_MINUTES` | `30` | Default slot length |
86
+
87
+ ## comm surface
88
+
89
+ | Kind | Name | Contract |
90
+ |---|---|---|
91
+ | Emit | `calendar.occurrence.materialized` | An occurrence was persisted — subscribe to attach a resource |
92
+ | Emit | `calendar.event.reminder_due` | A reminder is due — deliver it |
93
+ | Function | `calendar.free_busy` | `{user_id, start, end, scope_key?}` -> `{busy: [...]}` |
94
+
95
+ ## Extension points
96
+
97
+ See [MODULE.md](MODULE.md) — the agent-facing map of every fork-free seam
98
+ (the resource hook, reminder policy, scope provider, recurrence presets,
99
+ serializer seams, settings).
100
+
101
+ ## Development
102
+
103
+ ```bash
104
+ pip install -e . && pip install pytest pytest-django ruff
105
+ ./setup-hooks.sh
106
+ pytest tests/
107
+ ```
108
+
109
+ ## License
110
+
111
+ MIT
@@ -0,0 +1,94 @@
1
+ # stapel-calendar
2
+
3
+ Calendar, recurrence and scheduling for the [Stapel framework](https://github.com/usestapel) —
4
+ composable Django apps that deploy as a monolith or as microservices
5
+ without changing module code.
6
+
7
+ A generic calendar core — **Event / Participant / RSVP**, an RFC 5545
8
+ **recurrence engine** (RRULE via `python-dateutil`, virtual expansion +
9
+ on-demand materialization), **availability** (working windows, free/busy,
10
+ slots) and **ICS export**. Two flavors of one domain — *meetings* and
11
+ *bookings* — share this core; everything app-specific lives behind seams.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ pip install stapel-calendar
17
+ ```
18
+
19
+ ```python
20
+ INSTALLED_APPS = [
21
+ # ...
22
+ "stapel_calendar",
23
+ ]
24
+
25
+ # urls.py
26
+ path("calendar/", include("stapel_calendar.urls"))
27
+ ```
28
+
29
+ ## Concepts
30
+
31
+ - **Event** — a scheduled thing (meeting/booking/appointment) with tz-aware
32
+ `start`/`end`, an `owner` and an opaque `scope_key`. No FK to any host
33
+ concept (org, room) — scoping and resources are seams.
34
+ - **Recurrence** — the series master stores a canonical RRULE. Occurrences
35
+ are *virtual* until they gain their own state (an RSVP or a resource), at
36
+ which point they are *materialized* (persisted) on demand.
37
+ - **Availability** — recurring working windows minus busy events give open
38
+ booking slots.
39
+
40
+ ```python
41
+ from stapel_calendar import services
42
+
43
+ series = services.create_event(
44
+ owner=user, title="Standup",
45
+ start=start, end=end,
46
+ recurrence_type="weekdays", # -> FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR
47
+ )
48
+ occurrences = services.expand_event(series, range_start, range_end) # virtual
49
+ services.materialize(series, occurrence_start) # persist one + emit hook
50
+ busy = services.free_busy(user, range_start, range_end)
51
+ slots = services.compute_slots(user, range_start, range_end, slot_minutes=30)
52
+ ```
53
+
54
+ ## Settings
55
+
56
+ All configuration lives in the `STAPEL_CALENDAR` namespace (dict setting,
57
+ flat setting, or env var — resolved lazily):
58
+
59
+ | Key | Default | Meaning |
60
+ |---|---|---|
61
+ | `SCOPE_PROVIDER` | `…scope.DefaultScopeProvider` | Resolve/filter the opaque `scope_key` |
62
+ | `REMINDER_POLICY` | `…reminders.DefaultReminderPolicy` | When/what to remind |
63
+ | `PRESETS` | `{}` | Custom recurrence presets (merged over built-ins) |
64
+ | `REMINDER_OFFSETS` | `[10]` | Minutes before start to remind |
65
+ | `REMINDER_SCAN_WINDOW_SECONDS` | `60` | Reminder cron granularity |
66
+ | `DEFAULT_EXPANSION_HORIZON_DAYS` | `90` | Default range end |
67
+ | `MAX_EXPANSION_OCCURRENCES` | `1000` | Expansion safety cap |
68
+ | `DEFAULT_SLOT_MINUTES` | `30` | Default slot length |
69
+
70
+ ## comm surface
71
+
72
+ | Kind | Name | Contract |
73
+ |---|---|---|
74
+ | Emit | `calendar.occurrence.materialized` | An occurrence was persisted — subscribe to attach a resource |
75
+ | Emit | `calendar.event.reminder_due` | A reminder is due — deliver it |
76
+ | Function | `calendar.free_busy` | `{user_id, start, end, scope_key?}` -> `{busy: [...]}` |
77
+
78
+ ## Extension points
79
+
80
+ See [MODULE.md](MODULE.md) — the agent-facing map of every fork-free seam
81
+ (the resource hook, reminder policy, scope provider, recurrence presets,
82
+ serializer seams, settings).
83
+
84
+ ## Development
85
+
86
+ ```bash
87
+ pip install -e . && pip install pytest pytest-django ruff
88
+ ./setup-hooks.sh
89
+ pytest tests/
90
+ ```
91
+
92
+ ## License
93
+
94
+ MIT
@@ -0,0 +1,31 @@
1
+ """stapel-calendar — Calendar and scheduling for the Stapel framework.
2
+
3
+ Public API (lazily exported, PEP 562 — importing this package never pulls
4
+ in Django or requires configured settings):
5
+
6
+ - ``calendar_settings`` — resolved app settings (``stapel_calendar.conf``).
7
+ """
8
+
9
+ __all__ = [
10
+ "calendar_settings",
11
+ ]
12
+
13
+ # name -> submodule that defines it. Resolution is deferred until first
14
+ # attribute access so that `import stapel_calendar` stays Django-free.
15
+ _LAZY_EXPORTS = {
16
+ "calendar_settings": ".conf",
17
+ }
18
+
19
+
20
+ def __getattr__(name):
21
+ if name in _LAZY_EXPORTS:
22
+ from importlib import import_module
23
+
24
+ value = getattr(import_module(_LAZY_EXPORTS[name], __name__), name)
25
+ globals()[name] = value # cache for subsequent lookups
26
+ return value
27
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
28
+
29
+
30
+ def __dir__():
31
+ return sorted(set(globals()) | set(__all__))
@@ -0,0 +1,25 @@
1
+ """Action subscriptions of stapel-calendar.
2
+
3
+ Handlers must be idempotent: delivery is at-least-once (outbox retries,
4
+ broker redelivery). Consumes contracts live in ``schemas/consumes/``.
5
+ """
6
+ import logging
7
+
8
+ from stapel_core.comm import on_action
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ @on_action("user.deleted")
14
+ def handle_user_deleted(event):
15
+ """Erase this module's PII when an account deletion is executed:
16
+ the user's owned events (and their occurrences/participants), their
17
+ participations in other events, and their availability windows."""
18
+ from .gdpr import CalendarGDPRProvider
19
+
20
+ user_id = event.payload.get("user_id")
21
+ if not user_id:
22
+ logger.error("user.deleted event without user_id: %s", event.event_id)
23
+ return
24
+ CalendarGDPRProvider().delete(user_id)
25
+ logger.info("calendar data erased for deleted user %s", user_id)
@@ -0,0 +1,25 @@
1
+ """Admin registrations for stapel-calendar (observability; kept minimal)."""
2
+ from django.contrib import admin
3
+
4
+ from .models import AvailabilityWindow, Event, Participant
5
+
6
+
7
+ class ParticipantInline(admin.TabularInline):
8
+ model = Participant
9
+ extra = 0
10
+ fields = ("user", "rsvp")
11
+
12
+
13
+ @admin.register(Event)
14
+ class EventAdmin(admin.ModelAdmin):
15
+ list_display = ("title", "start", "end", "owner", "scope_key", "recurrence_type")
16
+ list_filter = ("recurrence_type", "status")
17
+ search_fields = ("title", "scope_key")
18
+ date_hierarchy = "start"
19
+ inlines = [ParticipantInline]
20
+
21
+
22
+ @admin.register(AvailabilityWindow)
23
+ class AvailabilityWindowAdmin(admin.ModelAdmin):
24
+ list_display = ("user", "weekday", "start_time", "end_time", "timezone")
25
+ list_filter = ("weekday",)
@@ -0,0 +1,24 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class CalendarConfig(AppConfig):
5
+ name = "stapel_calendar"
6
+ label = "calendar"
7
+ verbose_name = "Calendar and scheduling"
8
+ default_auto_field = "django.db.models.BigAutoField"
9
+
10
+ def ready(self):
11
+ # Import-time side effects: comm functions/actions, system checks,
12
+ # error-key registration. Keep each in its own module.
13
+ from . import actions # noqa: F401
14
+ from . import checks # noqa: F401
15
+ from . import errors # noqa: F401
16
+ from . import functions # noqa: F401
17
+
18
+ # GDPR: register the per-app data handler (monolith in-process mode).
19
+ from stapel_core.gdpr import gdpr_registry
20
+
21
+ from .gdpr import CalendarGDPRProvider
22
+
23
+ if not any(p.section == "calendar" for p in gdpr_registry.providers):
24
+ gdpr_registry.register(CalendarGDPRProvider())
@@ -0,0 +1,73 @@
1
+ """Django system checks for stapel-calendar configuration.
2
+
3
+ Policy (docs/library-standard.md §3.7): E-level for configuration the
4
+ service cannot run with; W-level for entries that only degrade lazily.
5
+
6
+ - SCOPE_PROVIDER unimportable / not a ScopeProvider -> E (create & list
7
+ cannot resolve/filter scope).
8
+ - REMINDER_POLICY unimportable -> W (only reminders degrade; the calendar
9
+ still serves).
10
+ - REMINDER_OFFSETS not a list of non-negative ints -> E (would crash the
11
+ reminder cron with a confusing error).
12
+ """
13
+ from django.core import checks
14
+
15
+
16
+ @checks.register(checks.Tags.compatibility)
17
+ def check_scope_provider(app_configs, **kwargs):
18
+ from .conf import calendar_settings
19
+ from .scope import ScopeProvider
20
+
21
+ try:
22
+ provider = calendar_settings.SCOPE_PROVIDER
23
+ except Exception as exc:
24
+ return [
25
+ checks.Error(
26
+ f"STAPEL_CALENDAR['SCOPE_PROVIDER'] could not be imported: {exc}",
27
+ id="stapel_calendar.E001",
28
+ )
29
+ ]
30
+ target = provider if isinstance(provider, type) else type(provider)
31
+ if not issubclass(target, ScopeProvider):
32
+ return [
33
+ checks.Error(
34
+ "STAPEL_CALENDAR['SCOPE_PROVIDER'] must be a ScopeProvider subclass",
35
+ id="stapel_calendar.E002",
36
+ )
37
+ ]
38
+ return []
39
+
40
+
41
+ @checks.register(checks.Tags.compatibility)
42
+ def check_reminder_policy(app_configs, **kwargs):
43
+ from .conf import calendar_settings
44
+
45
+ try:
46
+ calendar_settings.REMINDER_POLICY
47
+ except Exception as exc:
48
+ return [
49
+ checks.Warning(
50
+ f"STAPEL_CALENDAR['REMINDER_POLICY'] could not be imported: {exc}. "
51
+ "Reminders are disabled until this resolves.",
52
+ id="stapel_calendar.W001",
53
+ )
54
+ ]
55
+ return []
56
+
57
+
58
+ @checks.register(checks.Tags.compatibility)
59
+ def check_reminder_offsets(app_configs, **kwargs):
60
+ from .conf import calendar_settings
61
+
62
+ offsets = calendar_settings.REMINDER_OFFSETS
63
+ if not isinstance(offsets, (list, tuple)) or any(
64
+ not isinstance(o, int) or o < 0 for o in offsets
65
+ ):
66
+ return [
67
+ checks.Error(
68
+ "STAPEL_CALENDAR['REMINDER_OFFSETS'] must be a list of "
69
+ "non-negative integers (minutes).",
70
+ id="stapel_calendar.E003",
71
+ )
72
+ ]
73
+ return []
@@ -0,0 +1,60 @@
1
+ """Settings namespace for stapel-calendar.
2
+
3
+ All configuration is read through ``calendar_settings`` (lazily, at call
4
+ time) — never via module-level ``os.getenv`` (values would freeze at import).
5
+ Resolution order per key: ``settings.STAPEL_CALENDAR`` dict -> flat Django
6
+ setting of the same name -> environment variable -> default below.
7
+
8
+ Dotted-path keys listed in ``import_strings`` are resolved with
9
+ ``import_string`` — the fork-free escape hatch for swappable behavior.
10
+
11
+ The four documented extension seams (see MODULE.md):
12
+
13
+ - ``SCOPE_PROVIDER`` — resolves/filters the opaque ``scope_key`` from the
14
+ request (meettoday supplies ``workspace_id``). The library itself is
15
+ scope-agnostic.
16
+ - ``REMINDER_POLICY`` — decides *when* and *what* to remind; the default
17
+ emits ``calendar.event.reminder_due`` for stapel-notifications to deliver.
18
+ - ``PRESETS`` — recurrence presets merged OVER the built-ins
19
+ (none/daily/weekdays/weekly/biweekly/monthly/custom), so a host can add
20
+ custom RRULE presets without restating the built-ins (``None`` removes).
21
+ - the *resource hook* is the ``calendar.occurrence.materialized`` comm
22
+ emit (schemas/emits/) — no setting; app-layers subscribe.
23
+ """
24
+ from stapel_core.conf import AppSettings
25
+
26
+ calendar_settings = AppSettings(
27
+ "STAPEL_CALENDAR",
28
+ defaults={
29
+ # Cap for virtual expansion of an unbounded series (no UNTIL/COUNT
30
+ # in the RRULE and no explicit range end): how many days ahead of
31
+ # the range start to expand for display.
32
+ "DEFAULT_EXPANSION_HORIZON_DAYS": 90,
33
+ # Hard safety cap on the number of occurrences a single expansion
34
+ # may yield — protects against a pathological RRULE.
35
+ "MAX_EXPANSION_OCCURRENCES": 1000,
36
+ # Reminder offsets in minutes before an event's start that the
37
+ # default policy fires a `calendar.event.reminder_due` for.
38
+ "REMINDER_OFFSETS": [10],
39
+ # Granularity (seconds) of the reminder cron scan — a reminder is
40
+ # "due" when now falls in [fire_time, fire_time + this window).
41
+ "REMINDER_SCAN_WINDOW_SECONDS": 60,
42
+ # Dotted path to a ReminderPolicy — the reminder seam. The default
43
+ # emits the reminder_due event; swap for a custom cadence/channel
44
+ # decision without forking.
45
+ "REMINDER_POLICY": "stapel_calendar.reminders.DefaultReminderPolicy",
46
+ # Dotted path to a ScopeProvider — resolves the opaque scope_key
47
+ # from a request and filters querysets by it. The default is a
48
+ # no-op (single global scope); meettoday returns workspace_id.
49
+ "SCOPE_PROVIDER": "stapel_calendar.scope.DefaultScopeProvider",
50
+ # Recurrence presets merged OVER recurrence.BUILTIN_PRESETS. A value
51
+ # is a dict of dateutil.rrule kwargs (freq/interval/byweekday/...);
52
+ # None removes a built-in.
53
+ "PRESETS": {},
54
+ # Default slot length (minutes) for availability slot computation.
55
+ "DEFAULT_SLOT_MINUTES": 30,
56
+ },
57
+ import_strings=("REMINDER_POLICY", "SCOPE_PROVIDER"),
58
+ )
59
+
60
+ __all__ = ["calendar_settings"]
@@ -0,0 +1,115 @@
1
+ def pytest_configure(config):
2
+ from django.conf import settings
3
+ if not settings.configured:
4
+ settings.configure(
5
+ SECRET_KEY="test-secret-key-not-for-production",
6
+ INSTALLED_APPS=[
7
+ "django.contrib.contenttypes",
8
+ "django.contrib.auth",
9
+ "django.contrib.sessions",
10
+ "django.contrib.admin",
11
+ "django.contrib.messages",
12
+ "stapel_core.django.users",
13
+ "rest_framework",
14
+ "stapel_calendar",
15
+ ],
16
+ AUTH_USER_MODEL="users.User",
17
+ DATABASES={
18
+ "default": {
19
+ "ENGINE": "django.db.backends.sqlite3",
20
+ "NAME": ":memory:",
21
+ }
22
+ },
23
+ DEFAULT_AUTO_FIELD="django.db.models.BigAutoField",
24
+ USE_TZ=True,
25
+ ROOT_URLCONF="stapel_calendar.tests.urls",
26
+ CACHES={
27
+ "default": {
28
+ "BACKEND": "django.core.cache.backends.locmem.LocMemCache",
29
+ }
30
+ },
31
+ # Synchronous in-process comm with schema validation ON, so the
32
+ # committed contracts in schemas/ are enforced by the tests.
33
+ STAPEL_BUS_BACKEND="stapel_core.bus.backends.memory.MemoryBus",
34
+ STAPEL_COMM={
35
+ "OUTBOX_ENABLED": False,
36
+ "ACTION_TRANSPORT": "inprocess",
37
+ "VALIDATE_SCHEMAS": True,
38
+ },
39
+ MIGRATION_MODULES={
40
+ "users": None,
41
+ "calendar": None,
42
+ },
43
+ )
44
+ import django
45
+ django.setup()
46
+
47
+ from stapel_core.comm.schemas import autoload_schemas
48
+ autoload_schemas()
49
+
50
+
51
+ import pytest # noqa: E402
52
+
53
+
54
+ @pytest.fixture
55
+ def api_client():
56
+ from rest_framework.test import APIClient
57
+ return APIClient()
58
+
59
+
60
+ @pytest.fixture
61
+ def user(db):
62
+ from django.contrib.auth import get_user_model
63
+ return get_user_model().objects.create_user(
64
+ username="alice", email="alice@example.com", password="x"
65
+ )
66
+
67
+
68
+ @pytest.fixture
69
+ def other_user(db):
70
+ from django.contrib.auth import get_user_model
71
+ return get_user_model().objects.create_user(
72
+ username="bob", email="bob@example.com", password="x"
73
+ )
74
+
75
+
76
+ @pytest.fixture
77
+ def auth_client(api_client, user):
78
+ api_client.force_authenticate(user=user)
79
+ return api_client
80
+
81
+
82
+ @pytest.fixture(autouse=True)
83
+ def _reset_recurrence_presets():
84
+ """Keep the recurrence preset registry clean between tests."""
85
+ from stapel_calendar.recurrence import reset_presets
86
+ reset_presets()
87
+ yield
88
+ reset_presets()
89
+
90
+
91
+ @pytest.fixture
92
+ def captured_events():
93
+ """Subscribe to calendar emits (in-process) and collect the Event
94
+ envelopes. Delivery is synchronous with OUTBOX disabled, so the list is
95
+ populated by the time emit() returns."""
96
+ from stapel_core.comm import action_registry, subscribe_action
97
+
98
+ collected = []
99
+
100
+ def _handler(event):
101
+ collected.append(event)
102
+
103
+ names = [
104
+ "calendar.occurrence.materialized",
105
+ "calendar.event.reminder_due",
106
+ ]
107
+ for name in names:
108
+ subscribe_action(name, _handler)
109
+ try:
110
+ yield collected
111
+ finally:
112
+ for name in names:
113
+ handlers = action_registry._subscribers.get(name, [])
114
+ if _handler in handlers:
115
+ handlers.remove(_handler)