M4 Calendar: reminders form, invitations, short publish links, settings UX
Calendar settings (Settings > Calendars):
- Reminders are now {method, minutes}: Add-reminder opens a boxed form with a
method dropdown (Show an alert / Email me) + time dropdown, bell icons.
Backend normalizes the legacy list[int] transparently.
- New Invitations section (auto-add emailed events): policy radios, "Add new
events from" (My contacts / Anyone), "Add to calendar", mark-read+file toggle.
Persisted to user settings (inbound iMIP processing still deferred).
- Time zone options show GMT codes, e.g. "Central European Summer Time (GMT+2)".
- Default calendar now defaults to "Personal" instead of a generic placeholder.
- Colour picker is a circular-swatch-only dropdown (sized so swatches don't overlap).
- Delete calendar / remove subscription now confirm via a modal.
- Create form: dropped the breadcrumb; heading stays "Create calendar" even after
a Publish toggle auto-creates the calendar.
Publish links: replaced the 244-char vault-sealed token with a 16-char random
slug (secrets.token_urlsafe). New sdk.public_session() (privileged, non-RLS) lets
the public /public/{token} feed resolve the slug directly with explicit filters.
Settings shell: "<- Mail" is now a context-aware "<- Back" via ?from=; Calendar
opens Settings with from=/p/calendar so Back returns to the calendar.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
This commit is contained in:
+58
-10
@@ -19,6 +19,14 @@ DbSession = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
_ALLOWED_ORDER = {"asc", "desc"}
|
||||
_ALLOWED_TIME_FORMAT = {"12", "24"}
|
||||
_ALLOWED_REMINDER_METHOD = {"alert", "email"}
|
||||
_ALLOWED_INVITE_POLICY = {"add_update", "update_only", "none"}
|
||||
_ALLOWED_INVITE_FROM = {"contacts", "anyone"}
|
||||
|
||||
|
||||
class ReminderPref(BaseModel):
|
||||
method: str = "alert" # alert = in-app notification, email = email me
|
||||
minutes: int = 0 # minutes before the event start (0 = at time of event)
|
||||
|
||||
|
||||
class SettingsOut(BaseModel):
|
||||
@@ -27,8 +35,12 @@ class SettingsOut(BaseModel):
|
||||
time_format: str = "24" # 12 = AM/PM, 24 = 24-hour (default); applies app-wide
|
||||
jitsi_server: str = "" # blank => https://meet.jit.si/
|
||||
default_calendar: str = "" # calendar id new events default to (blank => first)
|
||||
default_reminders_timed: list[int] = [] # minutes-before defaults for timed events
|
||||
default_reminders_allday: list[int] = [] # minutes-before defaults for all-day events
|
||||
default_reminders_timed: list[ReminderPref] = [] # defaults for timed events
|
||||
default_reminders_allday: list[ReminderPref] = [] # defaults for all-day events
|
||||
invitation_policy: str = "add_update" # how emailed events update the calendar
|
||||
invitation_from: str = "contacts" # whose emailed events auto-add (contacts|anyone)
|
||||
invitation_calendar: str = "" # calendar id emailed events land in (blank => default)
|
||||
invitation_file: bool = False # also mark the invite message read + filed
|
||||
allowed_image_senders: list[str] = []
|
||||
|
||||
|
||||
@@ -38,8 +50,12 @@ class SettingsPatch(BaseModel):
|
||||
time_format: str | None = None
|
||||
jitsi_server: str | None = None
|
||||
default_calendar: str | None = None
|
||||
default_reminders_timed: list[int] | None = None
|
||||
default_reminders_allday: list[int] | None = None
|
||||
default_reminders_timed: list[ReminderPref] | None = None
|
||||
default_reminders_allday: list[ReminderPref] | None = None
|
||||
invitation_policy: str | None = None
|
||||
invitation_from: str | None = None
|
||||
invitation_calendar: str | None = None
|
||||
invitation_file: bool | None = None
|
||||
|
||||
|
||||
class QuotaOut(BaseModel):
|
||||
@@ -62,8 +78,24 @@ def add_allowed_image_sender(user: User, email: str) -> None:
|
||||
user.settings = {**user.settings, "allowed_image_senders": current}
|
||||
|
||||
|
||||
def _int_list(value: object) -> list[int]:
|
||||
return [int(x) for x in value] if isinstance(value, list) else []
|
||||
def _reminder_list(value: object) -> list[ReminderPref]:
|
||||
"""Normalize stored reminders. Accepts the legacy ``list[int]`` (minutes only,
|
||||
method defaults to alert) as well as the ``{method, minutes}`` object form."""
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
out: list[ReminderPref] = []
|
||||
for x in value:
|
||||
if isinstance(x, dict):
|
||||
method = str(x.get("method", "alert"))
|
||||
out.append(
|
||||
ReminderPref(
|
||||
method=method if method in _ALLOWED_REMINDER_METHOD else "alert",
|
||||
minutes=int(x.get("minutes", 0)),
|
||||
)
|
||||
)
|
||||
else:
|
||||
out.append(ReminderPref(method="alert", minutes=int(x)))
|
||||
return out
|
||||
|
||||
|
||||
def _settings_out(user: User) -> SettingsOut:
|
||||
@@ -74,8 +106,12 @@ def _settings_out(user: User) -> SettingsOut:
|
||||
time_format=str(s.get("time_format", "24")),
|
||||
jitsi_server=str(s.get("jitsi_server", "")),
|
||||
default_calendar=str(s.get("default_calendar", "")),
|
||||
default_reminders_timed=_int_list(s.get("default_reminders_timed")),
|
||||
default_reminders_allday=_int_list(s.get("default_reminders_allday")),
|
||||
default_reminders_timed=_reminder_list(s.get("default_reminders_timed")),
|
||||
default_reminders_allday=_reminder_list(s.get("default_reminders_allday")),
|
||||
invitation_policy=str(s.get("invitation_policy", "add_update")),
|
||||
invitation_from=str(s.get("invitation_from", "contacts")),
|
||||
invitation_calendar=str(s.get("invitation_calendar", "")),
|
||||
invitation_file=bool(s.get("invitation_file", False)),
|
||||
allowed_image_senders=sorted(allowed_image_senders(user)),
|
||||
)
|
||||
|
||||
@@ -108,9 +144,21 @@ async def patch_settings(body: SettingsPatch, user: CurrentUser, db: DbSession)
|
||||
if body.default_calendar is not None:
|
||||
updates["default_calendar"] = body.default_calendar
|
||||
if body.default_reminders_timed is not None:
|
||||
updates["default_reminders_timed"] = [int(x) for x in body.default_reminders_timed]
|
||||
updates["default_reminders_timed"] = [
|
||||
r.model_dump() for r in body.default_reminders_timed
|
||||
]
|
||||
if body.default_reminders_allday is not None:
|
||||
updates["default_reminders_allday"] = [int(x) for x in body.default_reminders_allday]
|
||||
updates["default_reminders_allday"] = [
|
||||
r.model_dump() for r in body.default_reminders_allday
|
||||
]
|
||||
if body.invitation_policy is not None and body.invitation_policy in _ALLOWED_INVITE_POLICY:
|
||||
updates["invitation_policy"] = body.invitation_policy
|
||||
if body.invitation_from is not None and body.invitation_from in _ALLOWED_INVITE_FROM:
|
||||
updates["invitation_from"] = body.invitation_from
|
||||
if body.invitation_calendar is not None:
|
||||
updates["invitation_calendar"] = body.invitation_calendar
|
||||
if body.invitation_file is not None:
|
||||
updates["invitation_file"] = body.invitation_file
|
||||
user.settings = updates
|
||||
await db.commit()
|
||||
return _settings_out(user)
|
||||
|
||||
@@ -22,6 +22,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.accounts.ssrf import guarded_get
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import session_scope
|
||||
from app.events import get_event_bus
|
||||
from app.identity.deps import current_user, require_csrf
|
||||
from app.indexer import blobstore
|
||||
@@ -95,6 +96,16 @@ class PluginSdk:
|
||||
across users (see :mod:`app.plugins.db`)."""
|
||||
return plugin_session(user_id)
|
||||
|
||||
def public_session(self) -> AbstractAsyncContextManager[AsyncSession]:
|
||||
"""A privileged, NON-RLS session for PUBLIC, unauthenticated endpoints that
|
||||
resolve their own opaque token and therefore must look up rows before any
|
||||
user is known (e.g. a public ``.ics`` feed keyed by an unguessable slug).
|
||||
|
||||
There is no row-level safety net here: EVERY query MUST filter explicitly by
|
||||
the token (or an id derived from it). Never use this for authenticated
|
||||
handlers — use :meth:`session` so RLS scopes queries to the user."""
|
||||
return session_scope()
|
||||
|
||||
def kv(self, user_id: uuid.UUID) -> PluginKvStore:
|
||||
"""The namespaced KV store for this plugin and ``user_id`` (SPEC §11)."""
|
||||
return PluginKvStore(self.name, user_id)
|
||||
|
||||
Reference in New Issue
Block a user