deeptutor-calendar 1.7.0__py3-none-any.whl
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.
- deeptutor/api/routers/calendar.py +183 -0
- deeptutor/services/calendar/__init__.py +1 -0
- deeptutor/services/calendar/layers.py +142 -0
- deeptutor/services/calendar/models.py +311 -0
- deeptutor/services/calendar/recurrence.py +436 -0
- deeptutor/services/calendar/store.py +529 -0
- deeptutor/services/cron/__init__.py +24 -0
- deeptutor/services/cron/executor.py +214 -0
- deeptutor/services/cron/module_service.py +13 -0
- deeptutor/services/cron/repository.py +251 -0
- deeptutor/services/cron/service.py +431 -0
- deeptutor/tools/cron_tool.py +142 -0
- deeptutor_calendar-1.7.0.dist-info/METADATA +8 -0
- deeptutor_calendar-1.7.0.dist-info/RECORD +16 -0
- deeptutor_calendar-1.7.0.dist-info/WHEEL +5 -0
- deeptutor_calendar-1.7.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Authenticated account calendar and workspace-local learning overlays."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from datetime import timedelta
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, HTTPException, Query, Request
|
|
10
|
+
from fastapi.responses import StreamingResponse
|
|
11
|
+
from pydantic import ValidationError
|
|
12
|
+
|
|
13
|
+
from deeptutor.services.calendar import models as m
|
|
14
|
+
from deeptutor.services.calendar.layers import owner_key, read_layers
|
|
15
|
+
from deeptutor.services.calendar.recurrence import parse_instant, zone
|
|
16
|
+
from deeptutor.services.calendar.store import get_calendar_store
|
|
17
|
+
from deeptutor.services.workspace.context import workspace_context
|
|
18
|
+
|
|
19
|
+
from .task_board import board_workspace
|
|
20
|
+
|
|
21
|
+
router = APIRouter()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@contextmanager
|
|
25
|
+
def errors():
|
|
26
|
+
try:
|
|
27
|
+
yield
|
|
28
|
+
except KeyError as exc:
|
|
29
|
+
raise HTTPException(404, "Calendar item not found.") from exc
|
|
30
|
+
except ValidationError as exc:
|
|
31
|
+
raise HTTPException(422, str(exc)) from exc
|
|
32
|
+
except ValueError as exc:
|
|
33
|
+
raise HTTPException(400, str(exc)) from exc
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def bounds(start, end, tz):
|
|
37
|
+
with errors():
|
|
38
|
+
first, last = parse_instant(start), parse_instant(end)
|
|
39
|
+
zone(tz)
|
|
40
|
+
if not first < last or last - first > timedelta(days=400):
|
|
41
|
+
raise ValueError("Choose a range between one second and 400 days.")
|
|
42
|
+
return first, last
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def validate_task(payload, request):
|
|
46
|
+
from deeptutor.services.task_board import get_task_board_store, workspace_board
|
|
47
|
+
|
|
48
|
+
if (
|
|
49
|
+
"task_workspace_id" in payload.model_fields_set
|
|
50
|
+
and "task_id" not in payload.model_fields_set
|
|
51
|
+
):
|
|
52
|
+
raise HTTPException(400, "Change the task and its workspace together.")
|
|
53
|
+
if not payload.task_id:
|
|
54
|
+
return
|
|
55
|
+
workspace_id = board_workspace(request)
|
|
56
|
+
if payload.task_workspace_id not in (None, workspace_id):
|
|
57
|
+
raise HTTPException(404, "Task not found in this workspace.")
|
|
58
|
+
if not any(
|
|
59
|
+
c.id == payload.task_id and not c.archived
|
|
60
|
+
for c in workspace_board(get_task_board_store().read(), workspace_id).cards
|
|
61
|
+
):
|
|
62
|
+
raise HTTPException(404, "Task not found in this workspace.")
|
|
63
|
+
payload.task_workspace_id = workspace_id
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@router.get("/calendars", response_model=m.CalendarList)
|
|
67
|
+
def calendars():
|
|
68
|
+
return get_calendar_store().calendars()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@router.post("/calendars", response_model=m.CalendarResponse, status_code=201)
|
|
72
|
+
def create_calendar(payload: m.CreateCalendar):
|
|
73
|
+
return get_calendar_store().create_calendar(payload)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@router.patch("/calendars/{id}", response_model=m.CalendarResponse)
|
|
77
|
+
def update_calendar(id: str, payload: m.UpdateCalendar):
|
|
78
|
+
with errors():
|
|
79
|
+
return get_calendar_store().update_calendar(id, payload)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@router.delete("/calendars/{id}", response_model=m.CalendarRevision)
|
|
83
|
+
def delete_calendar(id: str):
|
|
84
|
+
with errors():
|
|
85
|
+
return get_calendar_store().delete_calendar(id)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@router.get("/events", response_model=m.OccurrenceList)
|
|
89
|
+
def events(start: str, end: str, tz: str = "UTC"):
|
|
90
|
+
first, last = bounds(start, end, tz)
|
|
91
|
+
with errors():
|
|
92
|
+
return get_calendar_store().occurrences(first, last, tz)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@router.post("/events", response_model=m.CalendarEventResponse, status_code=201)
|
|
96
|
+
def create_event(payload: m.EventInput, request: Request):
|
|
97
|
+
validate_task(payload, request)
|
|
98
|
+
with errors():
|
|
99
|
+
return get_calendar_store().create(payload)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@router.patch("/events/{id}", response_model=m.CalendarRevision)
|
|
103
|
+
def update_event(id: str, payload: m.EventPatchRequest, request: Request):
|
|
104
|
+
validate_task(payload.changes, request)
|
|
105
|
+
with errors():
|
|
106
|
+
return get_calendar_store().update(id, payload)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@router.delete("/events/{id}")
|
|
110
|
+
def delete_event(id: str, scope: m.Scope = "all", occurrence: str | None = None):
|
|
111
|
+
with errors():
|
|
112
|
+
return get_calendar_store().delete(id, scope, occurrence)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@router.post("/undo/{token}", response_model=m.CalendarRevision)
|
|
116
|
+
def undo(token: str):
|
|
117
|
+
with errors():
|
|
118
|
+
return get_calendar_store().restore(token)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@router.get("/search", response_model=m.CalendarSearchResponse)
|
|
122
|
+
def search(
|
|
123
|
+
q: str = Query(min_length=1, max_length=200), limit: int = Query(default=50, ge=1, le=100)
|
|
124
|
+
):
|
|
125
|
+
with errors():
|
|
126
|
+
return get_calendar_store().search(q, limit)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@router.get("/layers", response_model=m.CalendarLayers)
|
|
130
|
+
def layers(request: Request, start: str, end: str, tz: str = "UTC"):
|
|
131
|
+
first, last = bounds(start, end, tz)
|
|
132
|
+
workspace_id = board_workspace(request)
|
|
133
|
+
with workspace_context(workspace_id):
|
|
134
|
+
return read_layers(workspace_id, first, last, tz)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@router.delete("/automations/{job_id}", response_model=m.CalendarOk)
|
|
138
|
+
def cancel_automation(job_id: str, request: Request):
|
|
139
|
+
from deeptutor.services.cron.service import get_cron_service
|
|
140
|
+
|
|
141
|
+
service = get_cron_service()
|
|
142
|
+
job = service.get_job(job_id)
|
|
143
|
+
if (
|
|
144
|
+
not job
|
|
145
|
+
or job.owner.key != owner_key()
|
|
146
|
+
or job.owner.kind != "chat"
|
|
147
|
+
or job.owner.workspace_id != board_workspace(request)
|
|
148
|
+
):
|
|
149
|
+
raise HTTPException(404, "Automation not found.")
|
|
150
|
+
return {"ok": service.cancel_job(job_id, owner_key=owner_key())}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@router.get("/stream")
|
|
154
|
+
async def stream(request: Request):
|
|
155
|
+
store = await asyncio.to_thread(get_calendar_store)
|
|
156
|
+
|
|
157
|
+
async def changes():
|
|
158
|
+
revision = -1
|
|
159
|
+
tick = 0
|
|
160
|
+
while not await request.is_disconnected():
|
|
161
|
+
current = await asyncio.to_thread(store.revision)
|
|
162
|
+
if current != revision:
|
|
163
|
+
revision = current
|
|
164
|
+
yield f'id: {revision}\ndata: {{"revision":{revision}}}\n\n'
|
|
165
|
+
elif tick % 30 == 0:
|
|
166
|
+
yield ": heartbeat\n\n"
|
|
167
|
+
tick += 1
|
|
168
|
+
await asyncio.sleep(0.5)
|
|
169
|
+
|
|
170
|
+
return StreamingResponse(
|
|
171
|
+
changes(),
|
|
172
|
+
media_type="text/event-stream",
|
|
173
|
+
headers={
|
|
174
|
+
"Cache-Control": "no-cache, no-transform",
|
|
175
|
+
"Content-Encoding": "identity",
|
|
176
|
+
"X-Accel-Buffering": "no",
|
|
177
|
+
},
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@router.get("/task-blocks", response_model=dict[str, int])
|
|
182
|
+
def task_blocks(request: Request):
|
|
183
|
+
return get_calendar_store().task_blocks(board_workspace(request))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Account calendars, recurring events and workspace learning projections."""
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Read-only projections of the selected workspace onto calendar days."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
import math
|
|
7
|
+
|
|
8
|
+
from .models import (
|
|
9
|
+
AutomationRun,
|
|
10
|
+
CalendarLayers,
|
|
11
|
+
FootprintDay,
|
|
12
|
+
FootprintSession,
|
|
13
|
+
ReviewDay,
|
|
14
|
+
ReviewPathCount,
|
|
15
|
+
)
|
|
16
|
+
from .recurrence import format_instant, zone
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def owner_key():
|
|
20
|
+
from deeptutor.multi_user.context import get_current_user
|
|
21
|
+
from deeptutor.services.cron.service import CronOwner
|
|
22
|
+
|
|
23
|
+
return CronOwner(kind="chat", user_id=get_current_user().id).key
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def read_layers(workspace_id, start, end, tzid):
|
|
27
|
+
from deeptutor.modules.catalog import module_enabled
|
|
28
|
+
from deeptutor.services.cron.service import compute_next_run, get_cron_service
|
|
29
|
+
from deeptutor.services.practice.storage import _DUE_AT_SQL, PracticeStore
|
|
30
|
+
from deeptutor.services.session import get_sqlite_session_store
|
|
31
|
+
|
|
32
|
+
tz = zone(tzid)
|
|
33
|
+
lo, hi = start.timestamp(), end.timestamp()
|
|
34
|
+
|
|
35
|
+
def day(ts):
|
|
36
|
+
return datetime.fromtimestamp(ts, tz).date().isoformat()
|
|
37
|
+
|
|
38
|
+
reviews, footprints = {}, {}
|
|
39
|
+
|
|
40
|
+
def review(ts):
|
|
41
|
+
key = day(ts)
|
|
42
|
+
return reviews.setdefault(key, ReviewDay(date=key))
|
|
43
|
+
|
|
44
|
+
def footprint(ts):
|
|
45
|
+
key = day(ts)
|
|
46
|
+
return footprints.setdefault(key, FootprintDay(date=key))
|
|
47
|
+
|
|
48
|
+
if module_enabled("mastery"):
|
|
49
|
+
from deeptutor.learning.policy import path_display_name
|
|
50
|
+
from deeptutor.learning.scheduler import SpacedRepetitionScheduler
|
|
51
|
+
from deeptutor.learning.storage import LearningStore
|
|
52
|
+
|
|
53
|
+
learning = LearningStore()
|
|
54
|
+
scheduler = SpacedRepetitionScheduler()
|
|
55
|
+
for progress, _topic, _count, _interaction in learning.list_topic_snapshots():
|
|
56
|
+
counts = {}
|
|
57
|
+
for task in scheduler.build_review_queue(progress):
|
|
58
|
+
if lo <= task.due_at < hi:
|
|
59
|
+
key = day(task.due_at)
|
|
60
|
+
counts[key] = counts.get(key, 0) + 1
|
|
61
|
+
for key, count in counts.items():
|
|
62
|
+
row = reviews.setdefault(key, ReviewDay(date=key))
|
|
63
|
+
row.mastery += count
|
|
64
|
+
row.paths.append(
|
|
65
|
+
ReviewPathCount(
|
|
66
|
+
id=progress.book_id, title=path_display_name(progress), count=count
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
with learning._connect() as db:
|
|
70
|
+
for row in db.execute(
|
|
71
|
+
"SELECT timestamp FROM mastery_learning_evidence WHERE timestamp>=? AND timestamp<?",
|
|
72
|
+
(lo, hi),
|
|
73
|
+
):
|
|
74
|
+
footprint(row[0]).mastery_evidence += 1
|
|
75
|
+
|
|
76
|
+
practice = PracticeStore(get_sqlite_session_store().db_path)
|
|
77
|
+
# The database is already workspace-scoped; match PracticeStore’s live-entry filter.
|
|
78
|
+
scope = "(n.session_id IS NULL OR s.deleted_at IS NULL)"
|
|
79
|
+
with practice.connect() as db:
|
|
80
|
+
# SQL fragments are local constants; both time bounds use parameters.
|
|
81
|
+
for row in db.execute(
|
|
82
|
+
f"SELECT {_DUE_AT_SQL} AS due FROM notebook_entries n LEFT JOIN sessions s ON s.id=n.session_id LEFT JOIN practice_review_state r ON r.entry_id=n.id WHERE {scope} AND {_DUE_AT_SQL}>=? AND {_DUE_AT_SQL}<?", # nosec B608
|
|
83
|
+
(lo, hi),
|
|
84
|
+
):
|
|
85
|
+
review(row[0]).practice += 1
|
|
86
|
+
for row in db.execute(
|
|
87
|
+
f"SELECT e.reviewed_at FROM practice_review_events e JOIN notebook_entries n ON n.id=e.entry_id LEFT JOIN sessions s ON s.id=n.session_id WHERE {scope} AND e.reviewed_at>=? AND e.reviewed_at<?", # nosec B608
|
|
88
|
+
(lo, hi),
|
|
89
|
+
):
|
|
90
|
+
footprint(row[0]).practice_reviews += 1
|
|
91
|
+
sessions_by_day = {}
|
|
92
|
+
for row in db.execute(
|
|
93
|
+
"SELECT m.created_at,s.id,s.title FROM messages m JOIN sessions s ON s.id=m.session_id WHERE s.deleted_at IS NULL AND m.role='user' AND m.created_at>=? AND m.created_at<?",
|
|
94
|
+
(lo, hi),
|
|
95
|
+
):
|
|
96
|
+
key = day(row[0])
|
|
97
|
+
sessions_by_day.setdefault(key, {})[row[1]] = row[2] or ""
|
|
98
|
+
for key, sessions in sessions_by_day.items():
|
|
99
|
+
row = footprints.setdefault(key, FootprintDay(date=key))
|
|
100
|
+
row.session_count = len(sessions)
|
|
101
|
+
row.sessions = [
|
|
102
|
+
FootprintSession(id=id, title=title) for id, title in list(sessions.items())[:20]
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
automations = []
|
|
106
|
+
for job in get_cron_service().list_jobs(owner_key()):
|
|
107
|
+
if job.owner.kind != "chat" or job.owner.workspace_id != workspace_id or not job.enabled:
|
|
108
|
+
continue
|
|
109
|
+
at = job.state.next_run_at_ms
|
|
110
|
+
if at is None:
|
|
111
|
+
continue
|
|
112
|
+
lower = max(int(lo * 1000), int(datetime.now(timezone.utc).timestamp() * 1000))
|
|
113
|
+
if at < lower:
|
|
114
|
+
if job.schedule.kind == "every":
|
|
115
|
+
step = job.schedule.every_seconds * 1000
|
|
116
|
+
at += max(0, math.ceil((lower - at) / step)) * step
|
|
117
|
+
else:
|
|
118
|
+
at = compute_next_run(job.schedule, lower - 1)
|
|
119
|
+
for _ in range(1000):
|
|
120
|
+
if at is None or at >= hi * 1000:
|
|
121
|
+
break
|
|
122
|
+
when = format_instant(datetime.fromtimestamp(at / 1000, timezone.utc))
|
|
123
|
+
automations.append(
|
|
124
|
+
AutomationRun(
|
|
125
|
+
id=f"{job.id}:{at}",
|
|
126
|
+
job_id=job.id,
|
|
127
|
+
name=job.name,
|
|
128
|
+
message=job.message,
|
|
129
|
+
at=when,
|
|
130
|
+
kind=job.schedule.kind,
|
|
131
|
+
expr=job.schedule.expr,
|
|
132
|
+
every_seconds=job.schedule.every_seconds,
|
|
133
|
+
session_id=job.owner.session_id,
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
at = compute_next_run(job.schedule, at)
|
|
137
|
+
return CalendarLayers(
|
|
138
|
+
workspace_id=workspace_id,
|
|
139
|
+
reviews=sorted(reviews.values(), key=lambda r: r.date),
|
|
140
|
+
footprints=sorted(footprints.values(), key=lambda r: r.date),
|
|
141
|
+
automations=automations,
|
|
142
|
+
)
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Wire models for the account calendar.
|
|
2
|
+
|
|
3
|
+
The encodings follow RFC 5545 so an ICS file maps onto them one to one:
|
|
4
|
+
all-day events carry plain dates with an *exclusive* end date, timed events
|
|
5
|
+
carry UTC instants on the wire and local wall time in ``tzid`` in storage.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Annotated, Literal
|
|
11
|
+
|
|
12
|
+
from pydantic import (
|
|
13
|
+
AfterValidator,
|
|
14
|
+
BaseModel,
|
|
15
|
+
ConfigDict,
|
|
16
|
+
Field,
|
|
17
|
+
StringConstraints,
|
|
18
|
+
field_validator,
|
|
19
|
+
model_validator,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
from .recurrence import format_instant, format_rule, parse_date, parse_instant, parse_rule, zone
|
|
23
|
+
|
|
24
|
+
CalendarColor = Annotated[str, StringConstraints(pattern=r"^#[0-9a-fA-F]{6}$")]
|
|
25
|
+
CalendarName = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=80)]
|
|
26
|
+
EventTitle = Annotated[str, StringConstraints(strip_whitespace=True, max_length=200)]
|
|
27
|
+
EventNotes = Annotated[str, StringConstraints(max_length=5000)]
|
|
28
|
+
EventLocation = Annotated[str, StringConstraints(strip_whitespace=True, max_length=300)]
|
|
29
|
+
Scope = Literal["this", "following", "all"]
|
|
30
|
+
EventSource = Literal["user", "dito", "import"]
|
|
31
|
+
|
|
32
|
+
# Minutes before the start. All-day events may also go negative, down to one
|
|
33
|
+
# minute before the day ends: -540 reads "on the day at 09:00".
|
|
34
|
+
MIN_REMINDER = -1439
|
|
35
|
+
MAX_REMINDER = 40320 # four weeks
|
|
36
|
+
MAX_REMINDERS = 5
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _tzid(value: str) -> str:
|
|
40
|
+
zone(value)
|
|
41
|
+
return value
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _rrule(value: str | None) -> str | None:
|
|
45
|
+
# Syntax only; UNTIL is normalised against the event's start in the store.
|
|
46
|
+
return None if value is None else format_rule(parse_rule(value))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _reminders(value: list[int]) -> list[int]:
|
|
50
|
+
unique = sorted(set(value))
|
|
51
|
+
if len(unique) > MAX_REMINDERS:
|
|
52
|
+
raise ValueError(f"At most {MAX_REMINDERS} reminders.")
|
|
53
|
+
if any(item < MIN_REMINDER or item > MAX_REMINDER for item in unique):
|
|
54
|
+
raise ValueError(f"Reminders must be between {MIN_REMINDER} and {MAX_REMINDER} minutes.")
|
|
55
|
+
return unique
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
TimeZoneId = Annotated[str, StringConstraints(min_length=1, max_length=64), AfterValidator(_tzid)]
|
|
59
|
+
RecurrenceRule = Annotated[str, StringConstraints(max_length=500), AfterValidator(_rrule)]
|
|
60
|
+
Reminders = Annotated[list[int], Field(max_length=20), AfterValidator(_reminders)]
|
|
61
|
+
Identifier = Annotated[str, StringConstraints(min_length=1, max_length=200)]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _reject_null(value: object) -> object:
|
|
65
|
+
if value is None:
|
|
66
|
+
raise ValueError("This field cannot be null.")
|
|
67
|
+
return value
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class CalendarEntry(BaseModel):
|
|
71
|
+
id: str
|
|
72
|
+
# Empty for the default calendar: the UI shows its localized name.
|
|
73
|
+
name: str
|
|
74
|
+
color: str
|
|
75
|
+
visible: bool
|
|
76
|
+
position: int
|
|
77
|
+
is_default: bool
|
|
78
|
+
created_at: str
|
|
79
|
+
updated_at: str
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class CreateCalendar(BaseModel):
|
|
83
|
+
model_config = ConfigDict(extra="forbid")
|
|
84
|
+
name: CalendarName
|
|
85
|
+
color: CalendarColor
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class UpdateCalendar(BaseModel):
|
|
89
|
+
model_config = ConfigDict(extra="forbid")
|
|
90
|
+
name: CalendarName | None = None
|
|
91
|
+
color: CalendarColor | None = None
|
|
92
|
+
visible: bool | None = None
|
|
93
|
+
position: int | None = Field(default=None, ge=0, le=100000)
|
|
94
|
+
|
|
95
|
+
@field_validator("name", "color", "visible", "position", mode="before")
|
|
96
|
+
@classmethod
|
|
97
|
+
def reject_null(cls, value: object) -> object:
|
|
98
|
+
return _reject_null(value)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class EventInput(BaseModel):
|
|
102
|
+
"""A new event, or the merged result of a patch before it is stored."""
|
|
103
|
+
|
|
104
|
+
model_config = ConfigDict(extra="forbid")
|
|
105
|
+
calendar_id: Identifier | None = None # None: the default calendar
|
|
106
|
+
title: EventTitle = "" # may be empty; the UI shows "Untitled"
|
|
107
|
+
notes: EventNotes = ""
|
|
108
|
+
location: EventLocation = ""
|
|
109
|
+
all_day: bool = False
|
|
110
|
+
# all_day: "YYYY-MM-DD" with an exclusive end; timed: instants with an
|
|
111
|
+
# offset or Z, normalised to "YYYY-MM-DDTHH:MM:SSZ".
|
|
112
|
+
start: str = Field(max_length=40)
|
|
113
|
+
end: str = Field(max_length=40)
|
|
114
|
+
tzid: TimeZoneId = "UTC" # the browser's zone when the event was made
|
|
115
|
+
rrule: RecurrenceRule | None = None # RFC 5545 RRULE value, no "RRULE:"
|
|
116
|
+
reminders: Reminders = Field(default_factory=list)
|
|
117
|
+
color: CalendarColor | None = None
|
|
118
|
+
task_id: Identifier | None = None # a time block for a task card
|
|
119
|
+
task_workspace_id: str | None = Field(default=None, max_length=200) # "" = General
|
|
120
|
+
|
|
121
|
+
@model_validator(mode="after")
|
|
122
|
+
def check_times(self) -> EventInput:
|
|
123
|
+
if self.all_day:
|
|
124
|
+
start, end = parse_date(self.start), parse_date(self.end)
|
|
125
|
+
self.start, self.end = start.isoformat(), end.isoformat()
|
|
126
|
+
else:
|
|
127
|
+
start, end = parse_instant(self.start), parse_instant(self.end)
|
|
128
|
+
self.start, self.end = format_instant(start), format_instant(end)
|
|
129
|
+
if end <= start:
|
|
130
|
+
raise ValueError("The end must be after the start.")
|
|
131
|
+
if not self.all_day and any(item < 0 for item in self.reminders):
|
|
132
|
+
raise ValueError("Only all-day events take reminders after the day starts.")
|
|
133
|
+
return self
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class EventPatch(BaseModel):
|
|
137
|
+
"""Every field optional; explicit null clears rrule/color/task links only."""
|
|
138
|
+
|
|
139
|
+
model_config = ConfigDict(extra="forbid")
|
|
140
|
+
calendar_id: Identifier | None = None
|
|
141
|
+
title: EventTitle | None = None
|
|
142
|
+
notes: EventNotes | None = None
|
|
143
|
+
location: EventLocation | None = None
|
|
144
|
+
all_day: bool | None = None
|
|
145
|
+
start: str | None = Field(default=None, max_length=40)
|
|
146
|
+
end: str | None = Field(default=None, max_length=40)
|
|
147
|
+
tzid: TimeZoneId | None = None
|
|
148
|
+
rrule: RecurrenceRule | None = None
|
|
149
|
+
reminders: Reminders | None = None
|
|
150
|
+
color: CalendarColor | None = None
|
|
151
|
+
task_id: Identifier | None = None
|
|
152
|
+
task_workspace_id: str | None = Field(default=None, max_length=200)
|
|
153
|
+
|
|
154
|
+
@field_validator(
|
|
155
|
+
"calendar_id",
|
|
156
|
+
"title",
|
|
157
|
+
"notes",
|
|
158
|
+
"location",
|
|
159
|
+
"all_day",
|
|
160
|
+
"start",
|
|
161
|
+
"end",
|
|
162
|
+
"tzid",
|
|
163
|
+
"reminders",
|
|
164
|
+
mode="before",
|
|
165
|
+
)
|
|
166
|
+
@classmethod
|
|
167
|
+
def reject_null(cls, value: object) -> object:
|
|
168
|
+
return _reject_null(value)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class EventPatchRequest(BaseModel):
|
|
172
|
+
model_config = ConfigDict(extra="forbid")
|
|
173
|
+
changes: EventPatch
|
|
174
|
+
scope: Scope = "all"
|
|
175
|
+
# The occurrence key being edited. With it, ``changes.start``/``end`` are
|
|
176
|
+
# that occurrence's new times rather than the series' first instance.
|
|
177
|
+
occurrence: str | None = Field(default=None, max_length=40)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class CalendarEvent(BaseModel):
|
|
181
|
+
"""A stored master or override row."""
|
|
182
|
+
|
|
183
|
+
id: str
|
|
184
|
+
calendar_id: str
|
|
185
|
+
title: str
|
|
186
|
+
notes: str
|
|
187
|
+
location: str
|
|
188
|
+
all_day: bool
|
|
189
|
+
start: str
|
|
190
|
+
end: str
|
|
191
|
+
tzid: str
|
|
192
|
+
rrule: str | None
|
|
193
|
+
exdates: list[str]
|
|
194
|
+
reminders: list[int]
|
|
195
|
+
color: str | None
|
|
196
|
+
task_id: str | None
|
|
197
|
+
task_workspace_id: str | None
|
|
198
|
+
series_id: str | None # override rows: the master's id
|
|
199
|
+
recurrence_id: str | None # override rows: the occurrence key it replaces
|
|
200
|
+
source: EventSource = "user"
|
|
201
|
+
created_at: str
|
|
202
|
+
updated_at: str
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class Occurrence(BaseModel):
|
|
206
|
+
"""One rendered instance of a stored event."""
|
|
207
|
+
|
|
208
|
+
id: str # f"{event_id}:{occurrence}", unique per instance
|
|
209
|
+
event_id: str # the row to PATCH/DELETE: master, override or single event
|
|
210
|
+
series_id: str | None # the master when part of a recurring series
|
|
211
|
+
occurrence: str # all-day: original start date; timed: original start in UTC
|
|
212
|
+
recurring: bool
|
|
213
|
+
calendar_id: str
|
|
214
|
+
title: str
|
|
215
|
+
notes: str
|
|
216
|
+
location: str
|
|
217
|
+
all_day: bool
|
|
218
|
+
start: str
|
|
219
|
+
end: str
|
|
220
|
+
tzid: str
|
|
221
|
+
rrule: str | None # the series' rule
|
|
222
|
+
reminders: list[int]
|
|
223
|
+
color: str | None
|
|
224
|
+
task_id: str | None
|
|
225
|
+
task_workspace_id: str | None
|
|
226
|
+
source: EventSource = "user"
|
|
227
|
+
|
|
228
|
+
reminder_times: list[str] = Field(default_factory=list)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class CalendarSearchHit(CalendarEvent):
|
|
232
|
+
# The next instance at or after now; a past single event keeps its start.
|
|
233
|
+
next_start: str | None = None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class CalendarList(BaseModel):
|
|
237
|
+
calendars: list[CalendarEntry]
|
|
238
|
+
revision: int
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class CalendarResponse(BaseModel):
|
|
242
|
+
calendar: CalendarEntry
|
|
243
|
+
revision: int
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class CalendarRevision(BaseModel):
|
|
247
|
+
revision: int
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class OccurrenceList(BaseModel):
|
|
251
|
+
occurrences: list[Occurrence]
|
|
252
|
+
revision: int
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class CalendarEventResponse(BaseModel):
|
|
256
|
+
event: CalendarEvent
|
|
257
|
+
revision: int
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class CalendarSearchResponse(BaseModel):
|
|
261
|
+
events: list[CalendarSearchHit]
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class ReviewPathCount(BaseModel):
|
|
265
|
+
id: str
|
|
266
|
+
title: str
|
|
267
|
+
count: int
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class ReviewDay(BaseModel):
|
|
271
|
+
date: str
|
|
272
|
+
mastery: int = 0
|
|
273
|
+
practice: int = 0
|
|
274
|
+
paths: list[ReviewPathCount] = Field(default_factory=list)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class AutomationRun(BaseModel):
|
|
278
|
+
id: str # f"{job_id}:{at}"
|
|
279
|
+
job_id: str
|
|
280
|
+
name: str
|
|
281
|
+
message: str
|
|
282
|
+
at: str # UTC instant
|
|
283
|
+
kind: Literal["at", "every", "cron"]
|
|
284
|
+
expr: str | None = None
|
|
285
|
+
every_seconds: int | None = None
|
|
286
|
+
session_id: str | None = None
|
|
287
|
+
enabled: bool = True
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
class FootprintSession(BaseModel):
|
|
291
|
+
id: str
|
|
292
|
+
title: str
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
class FootprintDay(BaseModel):
|
|
296
|
+
date: str
|
|
297
|
+
session_count: int = 0
|
|
298
|
+
sessions: list[FootprintSession] = Field(default_factory=list)
|
|
299
|
+
practice_reviews: int = 0
|
|
300
|
+
mastery_evidence: int = 0
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class CalendarLayers(BaseModel):
|
|
304
|
+
workspace_id: str
|
|
305
|
+
reviews: list[ReviewDay] = Field(default_factory=list)
|
|
306
|
+
automations: list[AutomationRun] = Field(default_factory=list)
|
|
307
|
+
footprints: list[FootprintDay] = Field(default_factory=list)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
class CalendarOk(BaseModel):
|
|
311
|
+
ok: bool = True
|