feat: per-event reminders + default reminder setting (server)

local_events gains a `reminders` TEXT column (comma-separated minutes-before-
start, like exdate); EventCreate/EventUpdate accept a `reminders: [int]` list
and build_local_event_dict emits it back as a list. user_settings gains
`default_reminder_minutes` (nullable int, null = off), exposed/updatable via
/api/settings (explicit null persists as off). Migrations added in _migrate().
Clients (iOS/Android) schedule the OS notifications locally from these.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-06-06 16:07:19 +02:00
parent fc00bf9114
commit bff9a244e7
5 changed files with 29 additions and 2 deletions

View File

@@ -128,6 +128,7 @@ def build_local_event_dict(
"type": "local", "type": "local",
"creator": creator, "creator": creator,
"private": bool(ev.is_private), "private": bool(ev.is_private),
"reminders": [int(x) for x in (ev.reminders or "").split(",") if x.strip().lstrip("-").isdigit()],
} }
if owner is not None: if owner is not None:
d["owner"] = owner d["owner"] = owner

View File

@@ -102,6 +102,20 @@ def _migrate():
except Exception: except Exception:
pass pass
try:
conn.execute(text("ALTER TABLE local_events ADD COLUMN reminders TEXT"))
conn.commit()
logging.info("Migration: added reminders to local_events")
except Exception:
pass
try:
conn.execute(text("ALTER TABLE user_settings ADD COLUMN default_reminder_minutes INTEGER"))
conn.commit()
logging.info("Migration: added default_reminder_minutes to user_settings")
except Exception:
pass
try: try:
conn.execute(text("ALTER TABLE user_settings ADD COLUMN month_divider_color VARCHAR(7) DEFAULT '#7090c0'")) conn.execute(text("ALTER TABLE user_settings ADD COLUMN month_divider_color VARCHAR(7) DEFAULT '#7090c0'"))
conn.commit() conn.commit()

View File

@@ -101,6 +101,9 @@ class UserSettings(Base):
# The single local calendar this user shares into all their groups # The single local calendar this user shares into all their groups
# (combined view shows only this calendar per member). NULL = share nothing. # (combined view shows only this calendar per member). NULL = share nothing.
group_visible_calendar_id = Column(Integer, nullable=True) group_visible_calendar_id = Column(Integer, nullable=True)
# Default reminder in minutes-before-start applied to all events client-side
# (0 = at start time). NULL = no default reminder.
default_reminder_minutes = Column(Integer, nullable=True)
user = relationship("User", back_populates="settings") user = relationship("User", back_populates="settings")
@@ -133,6 +136,8 @@ class LocalEvent(Base):
color = Column(String(7), nullable=True) color = Column(String(7), nullable=True)
rrule = Column(Text, nullable=True) rrule = Column(Text, nullable=True)
exdate = Column(Text, nullable=True) # Comma-separated YYYYMMDD dates to exclude exdate = Column(Text, nullable=True) # Comma-separated YYYYMMDD dates to exclude
# Comma-separated minutes-before-start for reminders, e.g. "10,60" (0 = at start).
reminders = Column(Text, nullable=True)
# Creator: set server-side from the auth token on create, never from the client. # Creator: set server-side from the auth token on create, never from the client.
creator_id = Column(Integer, ForeignKey("users.id"), nullable=True) creator_id = Column(Integer, ForeignKey("users.id"), nullable=True)
# For imported events without a local user (from the .ics ORGANIZER field). # For imported events without a local user (from the .ics ORGANIZER field).

View File

@@ -1,6 +1,6 @@
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import List, Optional
from fastapi import APIRouter, Depends, Form, HTTPException, Query, UploadFile, File from fastapi import APIRouter, Depends, Form, HTTPException, Query, UploadFile, File
from fastapi.responses import Response from fastapi.responses import Response
@@ -43,6 +43,7 @@ class EventCreate(BaseModel):
color: Optional[str] = None color: Optional[str] = None
rrule: Optional[str] = None rrule: Optional[str] = None
private: bool = False private: bool = False
reminders: Optional[List[int]] = None # minutes before start (0 = at start)
class EventUpdate(BaseModel): class EventUpdate(BaseModel):
@@ -56,6 +57,7 @@ class EventUpdate(BaseModel):
rrule: Optional[str] = None rrule: Optional[str] = None
exdate: Optional[str] = None exdate: Optional[str] = None
private: Optional[bool] = None private: Optional[bool] = None
reminders: Optional[List[int]] = None
class ShareCreate(BaseModel): class ShareCreate(BaseModel):
@@ -247,6 +249,7 @@ def create_event(
color=data.color, color=data.color,
rrule=data.rrule, rrule=data.rrule,
is_private=data.private, is_private=data.private,
reminders=(",".join(str(m) for m in data.reminders) if data.reminders else None),
creator_id=current_user.id, # server-side, never from the client creator_id=current_user.id, # server-side, never from the client
) )
db.add(ev) db.add(ev)
@@ -296,6 +299,8 @@ def update_event(
if data.exdate not in dates: if data.exdate not in dates:
dates.append(data.exdate) dates.append(data.exdate)
ev.exdate = ",".join(dates) ev.exdate = ",".join(dates)
if data.reminders is not None:
ev.reminders = ",".join(str(m) for m in data.reminders) if data.reminders else None
db.commit() db.commit()
return {"ok": True} return {"ok": True}

View File

@@ -29,6 +29,7 @@ class SettingsUpdate(BaseModel):
bg_color: Optional[str] = None bg_color: Optional[str] = None
private_event_visibility: Optional[str] = None private_event_visibility: Optional[str] = None
group_visible_calendar_id: Optional[int] = None group_visible_calendar_id: Optional[int] = None
default_reminder_minutes: Optional[int] = None # null = off
def _settings_dict(s: models.UserSettings) -> dict: def _settings_dict(s: models.UserSettings) -> dict:
@@ -50,6 +51,7 @@ def _settings_dict(s: models.UserSettings) -> dict:
"bg_color": s.bg_color, "bg_color": s.bg_color,
"private_event_visibility": s.private_event_visibility or "busy", "private_event_visibility": s.private_event_visibility or "busy",
"group_visible_calendar_id": s.group_visible_calendar_id, "group_visible_calendar_id": s.group_visible_calendar_id,
"default_reminder_minutes": s.default_reminder_minutes,
} }
@@ -92,7 +94,7 @@ def update_settings(
# For these three override colours, an explicit null is meaningful # For these three override colours, an explicit null is meaningful
# ("reset to default") and must be persisted as NULL. All other fields # ("reset to default") and must be persisted as NULL. All other fields
# keep the previous behaviour where a null/missing value is ignored. # keep the previous behaviour where a null/missing value is ignored.
NULLABLE_OVERRIDES = {"text_color", "line_color", "bg_color", "group_visible_calendar_id"} NULLABLE_OVERRIDES = {"text_color", "line_color", "bg_color", "group_visible_calendar_id", "default_reminder_minutes"}
update_data = data.model_dump(exclude_unset=True) update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items(): for field, value in update_data.items():
if field in NULLABLE_OVERRIDES: if field in NULLABLE_OVERRIDES: