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.
@@ -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