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:
|
except Exception:
|
||||||
pass
|
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).
|
# CalDAV publishing of local calendars (opt-in, secret token URL).
|
||||||
for col, ddl in (
|
for col, ddl in (
|
||||||
("caldav_published", "ALTER TABLE local_calendars ADD COLUMN caldav_published BOOLEAN DEFAULT 0"),
|
("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)
|
default_event_duration_minutes = Column(Integer, default=60)
|
||||||
# Icon key (from GROUP_ICON_KEYS) shown next to calendars this user shares with groups.
|
# Icon key (from GROUP_ICON_KEYS) shown next to calendars this user shares with groups.
|
||||||
share_calendar_icon = Column(String(16), nullable=True)
|
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")
|
user = relationship("User", back_populates="settings")
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
@@ -11,6 +12,48 @@ from database import get_db
|
|||||||
router = APIRouter()
|
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):
|
class SettingsUpdate(BaseModel):
|
||||||
default_view: Optional[str] = None
|
default_view: Optional[str] = None
|
||||||
week_start_day: 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_reminder_minutes: Optional[int] = None # null = off
|
||||||
default_event_duration_minutes: Optional[int] = None
|
default_event_duration_minutes: Optional[int] = None
|
||||||
share_calendar_icon: Optional[str] = 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:
|
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_reminder_minutes": s.default_reminder_minutes,
|
||||||
"default_event_duration_minutes": s.default_event_duration_minutes or 60,
|
"default_event_duration_minutes": s.default_event_duration_minutes or 60,
|
||||||
"share_calendar_icon": s.share_calendar_icon,
|
"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.
|
# 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"}
|
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)
|
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():
|
for field, value in update_data.items():
|
||||||
if field in NULLABLE_OVERRIDES:
|
if field in NULLABLE_OVERRIDES:
|
||||||
setattr(settings, field, value or None)
|
setattr(settings, field, value or None)
|
||||||
|
|||||||
Reference in New Issue
Block a user