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)