import json as _json from pydantic import BaseModel, ConfigDict, Field, field_validator from datetime import datetime from typing import Literal, Optional class RecurrenceRule(BaseModel): """Structured recurrence rule — serialized to/from JSON string in the DB column.""" type: Literal["every_n_days", "weekly", "monthly_nth_weekday", "monthly_date"] # every_n_days interval: Optional[int] = Field(None, ge=1, le=365) # weekly / monthly_nth_weekday weekday: Optional[int] = Field(None, ge=0, le=6) # 0=Mon … 6=Sun # monthly_nth_weekday week: Optional[int] = Field(None, ge=1, le=4) # monthly_date day: Optional[int] = Field(None, ge=1, le=31) def _coerce_recurrence_rule(v): """Accept None, dict, RecurrenceRule, or JSON/legacy strings gracefully.""" if v is None or v == "" or v == "null": return None if isinstance(v, dict): return v if isinstance(v, RecurrenceRule): return v if isinstance(v, str): try: parsed = _json.loads(v) if isinstance(parsed, dict): return parsed except (_json.JSONDecodeError, TypeError): pass # Legacy simple strings like "daily", "weekly" — discard (not structured) return None return v class CalendarEventCreate(BaseModel): model_config = ConfigDict(extra="forbid") title: str = Field(min_length=1, max_length=255) description: Optional[str] = Field(None, max_length=5000) start_datetime: datetime end_datetime: datetime all_day: bool = False color: Optional[str] = Field(None, max_length=20) location_id: Optional[int] = None recurrence_rule: Optional[RecurrenceRule] = None is_starred: bool = False calendar_id: Optional[int] = None # If None, server assigns default calendar @field_validator("recurrence_rule", mode="before") @classmethod def coerce_recurrence(cls, v): return _coerce_recurrence_rule(v) class CalendarEventUpdate(BaseModel): model_config = ConfigDict(extra="forbid") title: Optional[str] = Field(None, min_length=1, max_length=255) description: Optional[str] = Field(None, max_length=5000) start_datetime: Optional[datetime] = None end_datetime: Optional[datetime] = None all_day: Optional[bool] = None color: Optional[str] = Field(None, max_length=20) location_id: Optional[int] = None recurrence_rule: Optional[RecurrenceRule] = None is_starred: Optional[bool] = None calendar_id: Optional[int] = None # Controls which occurrences an edit applies to; absent = non-recurring or whole-series edit_scope: Optional[Literal["this", "this_and_future"]] = None @field_validator("recurrence_rule", mode="before") @classmethod def coerce_recurrence(cls, v): return _coerce_recurrence_rule(v) class CalendarEventResponse(BaseModel): id: int title: str description: Optional[str] start_datetime: datetime end_datetime: datetime all_day: bool color: Optional[str] location_id: Optional[int] recurrence_rule: Optional[str] # raw JSON string from DB; frontend parses is_starred: bool calendar_id: int calendar_name: str calendar_color: str is_virtual: bool = False # Recurrence fields parent_event_id: Optional[int] = None is_recurring: bool = False original_start: Optional[datetime] = None created_at: datetime updated_at: datetime model_config = ConfigDict(from_attributes=True)