Add per-setting cross-device sync flags to user settings
Introduce an account-wide sync-flag map that is the single server-side authority for which settings each client sends/fetches, plus value columns for two newly-syncable device-local prefs. - models.UserSettings: sync_flags (JSON), cache_months, month_view_paged - main._migrate(): idempotent ALTER TABLE for the three new columns - settings_router: DEFAULT_SYNC map + _resolve_sync_flags(); GET returns fully-resolved sync_flags (+ cache_months, month_view_paged); PUT accepts and merges a partial sync_flags map and the two new value fields Rollout defaults: settings that already lived on the server sync ON; the four newly-syncable prefs (language, share icon, cache range, month paging) sync OFF until the user opts in. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -225,6 +225,20 @@ def _migrate():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Per-setting cross-device sync: value columns for the two newly syncable
|
||||
# device-local prefs, plus the JSON map of which settings sync.
|
||||
for col, ddl in (
|
||||
("cache_months", "ALTER TABLE user_settings ADD COLUMN cache_months INTEGER DEFAULT 3"),
|
||||
("month_view_paged", "ALTER TABLE user_settings ADD COLUMN month_view_paged BOOLEAN DEFAULT 0"),
|
||||
("sync_flags", "ALTER TABLE user_settings ADD COLUMN sync_flags TEXT"),
|
||||
):
|
||||
try:
|
||||
conn.execute(text(ddl))
|
||||
conn.commit()
|
||||
logging.info("Migration: added %s to user_settings", col)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# CalDAV publishing of local calendars (opt-in, secret token URL).
|
||||
for col, ddl in (
|
||||
("caldav_published", "ALTER TABLE local_calendars ADD COLUMN caldav_published BOOLEAN DEFAULT 0"),
|
||||
|
||||
@@ -113,6 +113,16 @@ class UserSettings(Base):
|
||||
default_event_duration_minutes = Column(Integer, default=60)
|
||||
# Icon key (from GROUP_ICON_KEYS) shown next to calendars this user shares with groups.
|
||||
share_calendar_icon = Column(String(16), nullable=True)
|
||||
# How many months around the visible range clients preload/cache. Device-local
|
||||
# by default (only shared when its sync flag is on).
|
||||
cache_months = Column(Integer, default=3)
|
||||
# Whether the month view uses horizontal paging (swipe) instead of a vertical
|
||||
# scroll feed. Device-local by default (only shared when its sync flag is on).
|
||||
month_view_paged = Column(Boolean, default=False)
|
||||
# Per-setting cross-device sync overrides as JSON {key: bool}. Absent keys fall
|
||||
# back to settings_router.DEFAULT_SYNC. Account-wide (one map per user); it is
|
||||
# the single authority for which settings each client sends/fetches.
|
||||
sync_flags = Column(Text, nullable=True)
|
||||
|
||||
user = relationship("User", back_populates="settings")
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
@@ -11,6 +12,48 @@ from database import get_db
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Which settings can sync across a user's devices, and the default flag applied to
|
||||
# an existing/new account until the user overrides it. This map is the single
|
||||
# authority: GET returns the fully-resolved flags so no client duplicates default
|
||||
# logic. Rollout rule: settings that already lived on the server default to True;
|
||||
# the four newly-syncable device-local prefs default to False.
|
||||
DEFAULT_SYNC = {
|
||||
"default_view": True,
|
||||
"week_start_day": True,
|
||||
"dim_past_events": True,
|
||||
"hour_height": True,
|
||||
"primary_color": True,
|
||||
"accent_color": True,
|
||||
"today_color": True,
|
||||
"text_color": True,
|
||||
"line_color": True,
|
||||
"bg_color": True,
|
||||
"month_divider_color": True,
|
||||
"month_label_color": True,
|
||||
"default_event_duration_minutes": True,
|
||||
"default_reminder_minutes": True,
|
||||
"language": False,
|
||||
"share_calendar_icon": False,
|
||||
"cache_months": False,
|
||||
"month_view_paged": False,
|
||||
}
|
||||
|
||||
|
||||
def _resolve_sync_flags(s: models.UserSettings) -> dict:
|
||||
"""Fully-resolved {key: bool} for every syncable setting: stored overrides on
|
||||
top of DEFAULT_SYNC, junk keys dropped."""
|
||||
stored = {}
|
||||
if s.sync_flags:
|
||||
try:
|
||||
stored = json.loads(s.sync_flags) or {}
|
||||
except (ValueError, TypeError):
|
||||
stored = {}
|
||||
return {
|
||||
key: bool(stored[key]) if key in stored else default
|
||||
for key, default in DEFAULT_SYNC.items()
|
||||
}
|
||||
|
||||
|
||||
class SettingsUpdate(BaseModel):
|
||||
default_view: Optional[str] = None
|
||||
week_start_day: Optional[str] = None
|
||||
@@ -32,6 +75,9 @@ class SettingsUpdate(BaseModel):
|
||||
default_reminder_minutes: Optional[int] = None # null = off
|
||||
default_event_duration_minutes: Optional[int] = None
|
||||
share_calendar_icon: Optional[str] = None
|
||||
cache_months: Optional[int] = None
|
||||
month_view_paged: Optional[bool] = None
|
||||
sync_flags: Optional[dict] = None # partial {key: bool}, merged into stored map
|
||||
|
||||
|
||||
def _settings_dict(s: models.UserSettings) -> dict:
|
||||
@@ -56,6 +102,9 @@ def _settings_dict(s: models.UserSettings) -> dict:
|
||||
"default_reminder_minutes": s.default_reminder_minutes,
|
||||
"default_event_duration_minutes": s.default_event_duration_minutes or 60,
|
||||
"share_calendar_icon": s.share_calendar_icon,
|
||||
"cache_months": s.cache_months or 3,
|
||||
"month_view_paged": bool(s.month_view_paged),
|
||||
"sync_flags": _resolve_sync_flags(s),
|
||||
}
|
||||
|
||||
|
||||
@@ -115,6 +164,22 @@ def update_settings(
|
||||
# keep the previous behaviour where a null/missing value is ignored.
|
||||
NULLABLE_OVERRIDES = {"text_color", "line_color", "bg_color", "group_visible_calendar_id", "default_reminder_minutes", "default_event_duration_minutes", "share_calendar_icon"}
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
|
||||
# Merge sync-flag overrides into the stored account-wide JSON map. Only known
|
||||
# syncable keys are kept; a partial map leaves untouched flags as they were.
|
||||
if "sync_flags" in update_data:
|
||||
incoming = update_data.pop("sync_flags") or {}
|
||||
current = {}
|
||||
if settings.sync_flags:
|
||||
try:
|
||||
current = json.loads(settings.sync_flags) or {}
|
||||
except (ValueError, TypeError):
|
||||
current = {}
|
||||
for key, val in incoming.items():
|
||||
if key in DEFAULT_SYNC:
|
||||
current[key] = bool(val)
|
||||
settings.sync_flags = json.dumps(current)
|
||||
|
||||
for field, value in update_data.items():
|
||||
if field in NULLABLE_OVERRIDES:
|
||||
setattr(settings, field, value or None)
|
||||
|
||||
Reference in New Issue
Block a user