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.
- stapel_calendar-0.1.0/LICENSE +21 -0
- stapel_calendar-0.1.0/PKG-INFO +111 -0
- stapel_calendar-0.1.0/README.md +94 -0
- stapel_calendar-0.1.0/__init__.py +31 -0
- stapel_calendar-0.1.0/actions.py +25 -0
- stapel_calendar-0.1.0/admin.py +25 -0
- stapel_calendar-0.1.0/apps.py +24 -0
- stapel_calendar-0.1.0/checks.py +73 -0
- stapel_calendar-0.1.0/conf.py +60 -0
- stapel_calendar-0.1.0/conftest.py +115 -0
- stapel_calendar-0.1.0/dto.py +151 -0
- stapel_calendar-0.1.0/errors.py +34 -0
- stapel_calendar-0.1.0/functions.py +44 -0
- stapel_calendar-0.1.0/gdpr.py +62 -0
- stapel_calendar-0.1.0/ics.py +131 -0
- stapel_calendar-0.1.0/migrations/0001_initial.py +90 -0
- stapel_calendar-0.1.0/migrations/__init__.py +0 -0
- stapel_calendar-0.1.0/models.py +208 -0
- stapel_calendar-0.1.0/py.typed +0 -0
- stapel_calendar-0.1.0/pyproject.toml +42 -0
- stapel_calendar-0.1.0/recurrence.py +220 -0
- stapel_calendar-0.1.0/reminders.py +127 -0
- stapel_calendar-0.1.0/schemas/consumes/user.deleted.json +14 -0
- stapel_calendar-0.1.0/schemas/emits/calendar.event.reminder_due.json +18 -0
- stapel_calendar-0.1.0/schemas/emits/calendar.occurrence.materialized.json +17 -0
- stapel_calendar-0.1.0/schemas/functions/calendar.free_busy.json +14 -0
- stapel_calendar-0.1.0/scope.py +42 -0
- stapel_calendar-0.1.0/serializers.py +57 -0
- stapel_calendar-0.1.0/services.py +355 -0
- stapel_calendar-0.1.0/setup.cfg +4 -0
- stapel_calendar-0.1.0/signals.py +12 -0
- stapel_calendar-0.1.0/stapel_calendar.egg-info/PKG-INFO +111 -0
- stapel_calendar-0.1.0/stapel_calendar.egg-info/SOURCES.txt +90 -0
- stapel_calendar-0.1.0/stapel_calendar.egg-info/dependency_links.txt +1 -0
- stapel_calendar-0.1.0/stapel_calendar.egg-info/requires.txt +4 -0
- stapel_calendar-0.1.0/stapel_calendar.egg-info/top_level.txt +1 -0
- stapel_calendar-0.1.0/tests/__init__.py +0 -0
- stapel_calendar-0.1.0/tests/test_api.py +210 -0
- stapel_calendar-0.1.0/tests/test_availability.py +115 -0
- stapel_calendar-0.1.0/tests/test_checks.py +33 -0
- stapel_calendar-0.1.0/tests/test_comm.py +67 -0
- stapel_calendar-0.1.0/tests/test_gdpr.py +128 -0
- stapel_calendar-0.1.0/tests/test_ics.py +59 -0
- stapel_calendar-0.1.0/tests/test_materialize.py +146 -0
- stapel_calendar-0.1.0/tests/test_public_api.py +46 -0
- stapel_calendar-0.1.0/tests/test_recurrence.py +186 -0
- stapel_calendar-0.1.0/tests/test_rsvp_and_reminders.py +142 -0
- stapel_calendar-0.1.0/tests/test_scope.py +66 -0
- stapel_calendar-0.1.0/tests/urls.py +5 -0
- stapel_calendar-0.1.0/urls.py +35 -0
- 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)
|