UMBRA/backend/app/schemas/settings.py
Kyle Pope 75fc3e3485 Fix notification background polling, add first/last name sharing
Notifications: enable refetchIntervalInBackground on unread count
query so notifications appear in background tabs without requiring
a tab switch to trigger refetchOnWindowFocus.

Name sharing: add share_first_name and share_last_name to the full
sharing pipeline — migration 045, Settings model/schema, SHAREABLE_FIELDS,
resolve_shared_profile, create_person_from_connection (now populates
first_name + last_name + computed display name), SharingOverrideUpdate,
frontend types and SettingsPage toggles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 07:34:13 +08:00

208 lines
7.2 KiB
Python

import re
from pydantic import BaseModel, ConfigDict, Field, field_validator
from datetime import datetime
from typing import Literal, Optional
AccentColor = Literal["cyan", "blue", "green", "purple", "red", "orange", "pink", "yellow"]
_NTFY_TOPIC_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')
class SettingsUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
accent_color: Optional[AccentColor] = None
upcoming_days: int | None = None
preferred_name: str | None = Field(None, max_length=100)
weather_city: str | None = Field(None, max_length=100)
weather_lat: float | None = None
weather_lon: float | None = None
first_day_of_week: int | None = None
# ntfy configuration fields
ntfy_server_url: Optional[str] = Field(None, max_length=500)
ntfy_topic: Optional[str] = Field(None, max_length=100)
# Empty string means "clear the token"; None means "leave unchanged"
ntfy_auth_token: Optional[str] = None
ntfy_enabled: Optional[bool] = None
ntfy_events_enabled: Optional[bool] = None
ntfy_reminders_enabled: Optional[bool] = None
ntfy_todos_enabled: Optional[bool] = None
ntfy_projects_enabled: Optional[bool] = None
ntfy_event_lead_minutes: Optional[int] = None
ntfy_todo_lead_days: Optional[int] = None
ntfy_project_lead_days: Optional[int] = None
# Auto-lock settings
auto_lock_enabled: Optional[bool] = None
auto_lock_minutes: Optional[int] = None
# Profile fields (shareable with connections)
phone: Optional[str] = Field(None, max_length=50)
mobile: Optional[str] = Field(None, max_length=50)
address: Optional[str] = Field(None, max_length=2000)
company: Optional[str] = Field(None, max_length=255)
job_title: Optional[str] = Field(None, max_length=255)
# Social settings
accept_connections: Optional[bool] = None
# Sharing defaults
share_first_name: Optional[bool] = None
share_last_name: Optional[bool] = None
share_preferred_name: Optional[bool] = None
share_email: Optional[bool] = None
share_phone: Optional[bool] = None
share_mobile: Optional[bool] = None
share_birthday: Optional[bool] = None
share_address: Optional[bool] = None
share_company: Optional[bool] = None
share_job_title: Optional[bool] = None
# ntfy connections toggle
ntfy_connections_enabled: Optional[bool] = None
@field_validator('auto_lock_minutes')
@classmethod
def validate_auto_lock_minutes(cls, v: Optional[int]) -> Optional[int]:
if v is not None and not (1 <= v <= 60):
raise ValueError("auto_lock_minutes must be between 1 and 60")
return v
@field_validator('first_day_of_week')
@classmethod
def validate_first_day(cls, v: int | None) -> int | None:
if v is not None and v not in (0, 1):
raise ValueError('first_day_of_week must be 0 (Sunday) or 1 (Monday)')
return v
@field_validator('weather_lat')
@classmethod
def validate_lat(cls, v: float | None) -> float | None:
if v is not None and (v < -90 or v > 90):
raise ValueError('Latitude must be between -90 and 90')
return v
@field_validator('weather_lon')
@classmethod
def validate_lon(cls, v: float | None) -> float | None:
if v is not None and (v < -180 or v > 180):
raise ValueError('Longitude must be between -180 and 180')
return v
@field_validator('ntfy_server_url')
@classmethod
def validate_ntfy_url(cls, v: Optional[str]) -> Optional[str]:
if v is None or v == "":
return None
from urllib.parse import urlparse
parsed = urlparse(v)
if parsed.scheme not in ("http", "https"):
raise ValueError("ntfy server URL must use http or https")
if not parsed.netloc:
raise ValueError("ntfy server URL must include a hostname")
# Strip trailing slash — ntfy base URL must not have one
return v.rstrip("/")
@field_validator('ntfy_topic')
@classmethod
def validate_ntfy_topic(cls, v: Optional[str]) -> Optional[str]:
if v is None or v == "":
return None
if not _NTFY_TOPIC_RE.match(v):
raise ValueError(
"ntfy topic must be 1-64 alphanumeric characters, hyphens, or underscores"
)
return v
@field_validator('ntfy_auth_token')
@classmethod
def validate_ntfy_token(cls, v: Optional[str]) -> Optional[str]:
# Empty string signals "clear the token" — normalise to None for storage
if v == "":
return None
if v is not None and len(v) > 500:
raise ValueError("ntfy auth token must be at most 500 characters")
return v
@field_validator('ntfy_event_lead_minutes')
@classmethod
def validate_event_lead(cls, v: Optional[int]) -> Optional[int]:
if v is not None and not (1 <= v <= 1440):
raise ValueError("ntfy_event_lead_minutes must be between 1 and 1440")
return v
@field_validator('ntfy_todo_lead_days')
@classmethod
def validate_todo_lead(cls, v: Optional[int]) -> Optional[int]:
if v is not None and not (0 <= v <= 30):
raise ValueError("ntfy_todo_lead_days must be between 0 and 30")
return v
@field_validator('ntfy_project_lead_days')
@classmethod
def validate_project_lead(cls, v: Optional[int]) -> Optional[int]:
if v is not None and not (0 <= v <= 30):
raise ValueError("ntfy_project_lead_days must be between 0 and 30")
return v
class SettingsResponse(BaseModel):
id: int
user_id: int
accent_color: str
upcoming_days: int
preferred_name: str | None = None
weather_city: str | None = None
weather_lat: float | None = None
weather_lon: float | None = None
first_day_of_week: int = 0
# ntfy fields — ntfy_auth_token is NEVER included here (security requirement 6.2)
ntfy_server_url: Optional[str] = None
ntfy_topic: Optional[str] = None
ntfy_enabled: bool = False
ntfy_events_enabled: bool = True
ntfy_reminders_enabled: bool = True
ntfy_todos_enabled: bool = True
ntfy_projects_enabled: bool = True
ntfy_event_lead_minutes: int = 15
ntfy_todo_lead_days: int = 1
ntfy_project_lead_days: int = 2
# Derived field: computed via Settings.ntfy_has_token property (from_attributes reads it)
ntfy_has_token: bool = False
# Auto-lock settings
auto_lock_enabled: bool = False
auto_lock_minutes: int = 5
# Profile fields
phone: Optional[str] = None
mobile: Optional[str] = None
address: Optional[str] = None
company: Optional[str] = None
job_title: Optional[str] = None
# Social settings
accept_connections: bool = False
# Sharing defaults
share_first_name: bool = False
share_last_name: bool = False
share_preferred_name: bool = True
share_email: bool = False
share_phone: bool = False
share_mobile: bool = False
share_birthday: bool = False
share_address: bool = False
share_company: bool = False
share_job_title: bool = False
# ntfy connections toggle
ntfy_connections_enabled: bool = True
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)