Files
Calendarr/backend/models.py
T
ScarriffleandClaude Opus 5 25f015a15b Let each person decide whether a shared calendar reminds them
reminders_enabled belongs to the calendar, so it belongs to its owner: whoever
shares a calendar decides whether it notifies everyone it reaches, and a
recipient's PUT is refused with 404 by the owner-only filter. For a group
calendar that is worse than it sounds — the creator owns it, so silencing it
for themselves would silence every member.

CalendarReminderPref follows CalendarColorPref exactly, which already solves
this for colours. Only local calendars are shareable (CalendarShare,
GroupCalendar and group_visible_calendar_id all point at local_calendars.id),
so no source discrimination is needed and the other four routers are untouched.
No migration: create_all makes new tables, which is why calendar_color_prefs
has no migration entry either.

A recipient without an override gets reminders on, rather than inheriting the
owner's choice. Muting is personal and should not spread. The new endpoint
decides owner-vs-override server-side, so the existing owner-only PUT stays the
guard it already is, and GET keeps returning reminders_enabled — now carrying
the effective value, so no client model has to change.

Birthday reminders were injected without consulting the flag at all, so a muted
birthday calendar still handed the schedulers something to fire.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 17:20:14 +02:00

553 lines
25 KiB
Python

from datetime import datetime
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text, UniqueConstraint
from sqlalchemy.orm import relationship
from database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
# Login name: always lowercase, unique, used for authentication.
username = Column(String(50), unique=True, nullable=False)
# Human-facing name with original casing; editable. Falls back to username.
display_name = Column(String(100), nullable=True)
email = Column(String(100), unique=True, nullable=True)
password_hash = Column(String(255), nullable=False)
is_admin = Column(Boolean, default=False)
avatar_filename = Column(String(255), nullable=True)
totp_secret = Column(String(32), nullable=True)
totp_enabled = Column(Boolean, default=False)
# When true, the user is hidden from sharing/group picker directories
# (/users/directory). Admin user management (/users/) still shows them.
directory_hidden = Column(Boolean, default=False, nullable=False)
# 'local' (password login) or 'sso' (provisioned via OpenID Connect).
# SSO users have a random, unusable password_hash — see oidc_identity.
auth_source = Column(String(20), default="local")
caldav_accounts = relationship(
"CalDAVAccount", back_populates="user", cascade="all, delete-orphan"
)
settings = relationship(
"UserSettings", back_populates="user", uselist=False, cascade="all, delete-orphan"
)
local_calendars = relationship(
"LocalCalendar", back_populates="user", cascade="all, delete-orphan"
)
ical_subscriptions = relationship(
"ICalSubscription", back_populates="user", cascade="all, delete-orphan"
)
google_accounts = relationship(
"GoogleAccount", back_populates="user", cascade="all, delete-orphan"
)
homeassistant_accounts = relationship(
"HomeAssistantAccount", back_populates="user", cascade="all, delete-orphan"
)
oidc_identities = relationship(
"OIDCIdentity", back_populates="user", cascade="all, delete-orphan"
)
@property
def display(self) -> str:
"""The name to show users: display_name if set, else the login name."""
return self.display_name or self.username
class CalDAVAccount(Base):
__tablename__ = "caldav_accounts"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
name = Column(String(100), nullable=False)
url = Column(String(500), nullable=False)
username = Column(String(100), nullable=False)
password = Column(String(255), nullable=False)
color = Column(String(7), default="#4285f4")
enabled = Column(Boolean, default=True)
user = relationship("User", back_populates="caldav_accounts")
calendars = relationship(
"Calendar", back_populates="account", cascade="all, delete-orphan"
)
class Calendar(Base):
__tablename__ = "calendars"
id = Column(Integer, primary_key=True, index=True)
account_id = Column(Integer, ForeignKey("caldav_accounts.id"), nullable=False)
cal_id = Column(String(500), nullable=False)
name = Column(String(100), nullable=False)
color = Column(String(7), nullable=True)
enabled = Column(Boolean, default=True)
sidebar_hidden = Column(Boolean, default=False)
# Whether events of this calendar generate reminders/notifications on clients.
reminders_enabled = Column(Boolean, default=True, nullable=False)
account = relationship("CalDAVAccount", back_populates="calendars")
class UserSettings(Base):
__tablename__ = "user_settings"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), unique=True, nullable=False)
default_view = Column(String(20), default="month")
week_start_day = Column(String(10), default="monday")
# NULL = inherit the admin instance default (see admin_router). A baked-in
# column default would mean every user always *has* an own colour, so the
# instance default could never apply — which is exactly what happened.
primary_color = Column(String(7), nullable=True)
accent_color = Column(String(7), nullable=True)
today_color = Column(String(7), nullable=True)
dim_past_events = Column(Boolean, default=False)
text_contrast = Column(Integer, default=3)
line_contrast = Column(Integer, default=3)
hour_height = Column(Integer, default=60)
language = Column(String(5), default="de")
month_divider_color = Column(String(7), nullable=True) # NULL = inherit
month_label_color = Column(String(7), nullable=True) # NULL = inherit
text_color = Column(String(7), nullable=True) # Override für --text-1 (NULL = nutze text_contrast)
line_color = Column(String(7), nullable=True) # Override für --border (NULL = nutze line_contrast)
bg_color = Column(String(7), nullable=True) # Override für --bg-app (NULL = Default)
# Surface/sidebar/topbar colour (web sidebar + top bar, iOS top bar).
# NULL = derive from bg_color. Device-local by default (not synced).
surface_color = Column(String(7), nullable=True)
# Fine-grained web element colours (all optional overrides; NULL = derive the
# previous default). See frontend THEME.md for what each one paints.
hover_highlight_color = Column(String(7), nullable=True) # general interactive hover (buttons, rows)
icon_inactive_color = Column(String(7), nullable=True) # sidebar action icons: resting/off state
icon_active_color = Column(String(7), nullable=True) # sidebar action icons: hovered/on state
day_hover_color = Column(String(7), nullable=True) # calendar day-cell hover
day_selected_color = Column(String(7), nullable=True) # selected day background
day_bg_color = Column(String(7), nullable=True) # normal day background
today_bg_color = Column(String(7), nullable=True) # today's day-cell background
# How this user's private events appear to other group members:
# 'hidden' = invisible, 'busy' = anonymous busy block (default).
private_event_visibility = Column(String(10), default="busy")
# The single local calendar this user shares into all their groups
# (combined view shows only this calendar per member). NULL = share nothing.
group_visible_calendar_id = Column(Integer, nullable=True)
# Default reminder in minutes-before-start applied to all events client-side
# (0 = at start time). NULL = no default reminder.
default_reminder_minutes = Column(Integer, nullable=True)
# Default duration (in minutes) applied to a newly created event's end time.
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")
class InstanceSettings(Base):
"""Server-wide (singleton, id=1) branding + default theme set by an admin.
Applies to everyone; a user's own settings still override the default theme."""
__tablename__ = "instance_settings"
id = Column(Integer, primary_key=True) # always 1
# JSON {colorKey: "#RRGGBB"} — the instance default theme. Empty/NULL = use the
# client's built-in defaults. A user's own colour wins over this.
default_theme = Column(Text, nullable=True)
# Uploaded branding files (stored under DATA_DIR/branding). NULL = use bundled.
logo_filename = Column(String(255), nullable=True)
favicon_filename = Column(String(255), nullable=True)
class AppPassword(Base):
"""Per-device app-specific password for CalDAV (Basic Auth).
Keeps MFA intact: accounts with 2FA can't use their normal password over
CalDAV (clients can't send a TOTP code), so they authenticate with one of
these revocable app passwords instead. Only the bcrypt hash is stored.
"""
__tablename__ = "app_passwords"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
label = Column(String(100), nullable=False)
password_hash = Column(String(255), nullable=False)
created_at = Column(String(50), nullable=True)
last_used_at = Column(String(50), nullable=True)
user = relationship("User")
class LocalCalendar(Base):
__tablename__ = "local_calendars"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
name = Column(String(100), nullable=False)
color = Column(String(7), default="#34a853")
enabled = Column(Boolean, default=True)
# Hidden from the sidebar calendar list (still owned/kept; just not shown).
sidebar_hidden = Column(Boolean, default=False, nullable=False)
# Whether events of this calendar generate reminders/notifications on clients.
reminders_enabled = Column(Boolean, default=True, nullable=False)
# CalDAV publishing (opt-in): expose this calendar as a two-way CalDAV
# collection reachable via a secret token URL. dav_ctag changes on every
# event write so clients detect changes; rotating the token revokes access.
caldav_published = Column(Boolean, default=False, nullable=False)
dav_token = Column(String(64), nullable=True, unique=True)
dav_ctag = Column(String(32), nullable=True)
# Birthday calendar: events are all-day, yearly-recurring; the server adds the
# age suffix ("Anna (30)") and an is_birthday flag so clients show a cake icon.
is_birthday = Column(Boolean, default=False, nullable=False)
# How many days before a birthday to remind (0 = on the day). NULL = no reminder.
# Injected as an event reminder on read so the mobile schedulers fire it.
birthday_notify_days_before = Column(Integer, nullable=True)
user = relationship("User", back_populates="local_calendars")
events = relationship("LocalEvent", back_populates="calendar", cascade="all, delete-orphan")
class LocalEvent(Base):
__tablename__ = "local_events"
id = Column(Integer, primary_key=True, index=True)
calendar_id = Column(Integer, ForeignKey("local_calendars.id"), nullable=False)
uid = Column(String(255), nullable=False, unique=True)
title = Column(String(255), nullable=False)
start = Column(String(50), nullable=False)
end = Column(String(50), nullable=False)
all_day = Column(Boolean, default=False)
location = Column(String(500), nullable=True)
description = Column(Text, nullable=True)
color = Column(String(7), nullable=True)
rrule = Column(Text, nullable=True)
exdate = Column(Text, nullable=True) # Comma-separated YYYYMMDD dates to exclude
# Comma-separated minutes-before-start for reminders, e.g. "10,60" (0 = at start).
reminders = Column(Text, nullable=True)
# Creator: set server-side from the auth token on create, never from the client.
creator_id = Column(Integer, ForeignKey("users.id"), nullable=True)
# For imported events without a local user (from the .ics ORGANIZER field).
creator_name_external = Column(Text, nullable=True)
# Private events are filtered for other group members per their visibility setting.
is_private = Column(Boolean, default=False)
# CalDAV entity tag — changes on every write so CalDAV clients detect updates.
etag = Column(String(32), nullable=True)
# Stable external identity for imported entries (e.g. a Contacts birthday keyed
# by "contact:<id>"), so a re-sync can mirror the address book without dupes.
external_uid = Column(String(255), nullable=True, index=True)
# Birth year for birthday events; NULL = year unknown (no age shown).
birth_year = Column(Integer, nullable=True)
calendar = relationship("LocalCalendar", back_populates="events")
creator = relationship("User")
# Deleting the event drops its attachment rows; the FILES are removed by
# attachments_store.purge_for_events() — the ORM cascade never touches disk.
attachments = relationship(
"EventAttachment", cascade="all, delete-orphan", passive_deletes=False
)
class EventAttachment(Base):
"""A file attached to an event (PDF, image or text).
The event is addressed by ``(source, event_uid)`` so non-local sources
(CalDAV, Google, Home Assistant) can be enabled later without a migration.
Today only ``source="local"`` is ever written, and for those rows
``event_id`` carries the FK that makes joins and the ORM cascade work.
"""
__tablename__ = "event_attachments"
id = Column(Integer, primary_key=True, index=True)
source = Column(String(20), nullable=False, default="local")
event_uid = Column(String(255), nullable=False, index=True)
event_id = Column(Integer, ForeignKey("local_events.id"), nullable=True, index=True)
# The name the user uploaded — only ever used to label the download. It is
# NEVER part of an on-disk path: for CalDAV-created events even the event
# UID comes from an external client, so paths are built from stored_name.
filename = Column(String(255), nullable=False)
# Server-generated uuid4 hex plus extension, e.g. "a1b2...ef.pdf".
stored_name = Column(String(80), nullable=False, unique=True)
# The result of sniffing the bytes, never the client's Content-Type header.
content_type = Column(String(100), nullable=False)
size_bytes = Column(Integer, nullable=False)
has_thumb = Column(Boolean, default=False)
# Capability token for the ICS ATTACH URL, which external CalDAV clients
# fetch without any credentials. Unguessable, and revoked only by deleting
# the attachment.
token = Column(String(64), nullable=False, unique=True, index=True)
uploaded_by = Column(Integer, ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
uploader = relationship("User")
class ICalSubscription(Base):
__tablename__ = "ical_subscriptions"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
name = Column(String(100), nullable=False)
url = Column(String(1000), nullable=False)
color = Column(String(7), default="#46bdc6")
enabled = Column(Boolean, default=True)
# Hidden from the sidebar calendar list (still subscribed; just not shown).
sidebar_hidden = Column(Boolean, default=False, nullable=False)
# Whether events of this subscription generate reminders/notifications on clients.
reminders_enabled = Column(Boolean, default=True, nullable=False)
refresh_minutes = Column(Integer, default=60)
last_fetched = Column(DateTime, nullable=True)
cached_ics = Column(Text, nullable=True)
user = relationship("User", back_populates="ical_subscriptions")
overrides = relationship("ICalOverride", back_populates="subscription", cascade="all, delete-orphan")
class ICalOverride(Base):
__tablename__ = "ical_overrides"
id = Column(Integer, primary_key=True, index=True)
subscription_id = Column(Integer, ForeignKey("ical_subscriptions.id"), nullable=False)
event_uid = Column(String(500), nullable=False)
hidden = Column(Boolean, default=False)
title = Column(String(255), nullable=True)
start = Column(String(50), nullable=True)
end = Column(String(50), nullable=True)
all_day = Column(Boolean, nullable=True)
location = Column(String(500), nullable=True)
description = Column(Text, nullable=True)
color = Column(String(7), nullable=True)
subscription = relationship("ICalSubscription", back_populates="overrides")
class GoogleAccount(Base):
__tablename__ = "google_accounts"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
email = Column(String(255), nullable=False)
access_token = Column(Text, nullable=False)
refresh_token = Column(Text, nullable=False)
token_expiry = Column(DateTime, nullable=True)
user = relationship("User", back_populates="google_accounts")
calendars = relationship(
"GoogleCalendar", back_populates="account", cascade="all, delete-orphan"
)
class GoogleCalendar(Base):
__tablename__ = "google_calendars"
id = Column(Integer, primary_key=True, index=True)
account_id = Column(Integer, ForeignKey("google_accounts.id"), nullable=False)
cal_id = Column(String(500), nullable=False)
name = Column(String(255), nullable=False)
color = Column(String(7), nullable=True)
enabled = Column(Boolean, default=True)
sidebar_hidden = Column(Boolean, default=False)
# Whether events of this calendar generate reminders/notifications on clients.
reminders_enabled = Column(Boolean, default=True, nullable=False)
account = relationship("GoogleAccount", back_populates="calendars")
class HomeAssistantAccount(Base):
__tablename__ = "homeassistant_accounts"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
name = Column(String(100), nullable=False)
url = Column(String(500), nullable=False)
token = Column(Text, nullable=False)
auth_method = Column(String(20), default="token")
refresh_token = Column(Text, nullable=True)
token_expiry = Column(DateTime, nullable=True)
client_id = Column(String(500), nullable=True)
user = relationship("User", back_populates="homeassistant_accounts")
calendars = relationship(
"HomeAssistantCalendar", back_populates="account", cascade="all, delete-orphan"
)
class HomeAssistantCalendar(Base):
__tablename__ = "homeassistant_calendars"
id = Column(Integer, primary_key=True, index=True)
account_id = Column(Integer, ForeignKey("homeassistant_accounts.id"), nullable=False)
entity_id = Column(String(255), nullable=False)
name = Column(String(255), nullable=False)
color = Column(String(7), nullable=True)
enabled = Column(Boolean, default=True)
sidebar_hidden = Column(Boolean, default=False)
# Whether events of this calendar generate reminders/notifications on clients.
reminders_enabled = Column(Boolean, default=True, nullable=False)
account = relationship("HomeAssistantAccount", back_populates="calendars")
# ── Collaboration: sharing & groups (local calendars only) ────────────────
class CalendarShare(Base):
"""A local calendar shared with another Calendarr user."""
__tablename__ = "calendar_shares"
__table_args__ = (
UniqueConstraint("calendar_id", "user_id", name="uq_calendar_share"),
)
id = Column(Integer, primary_key=True, index=True)
calendar_id = Column(Integer, ForeignKey("local_calendars.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
permission = Column(String(20), default="read") # 'read' | 'read_write'
created_at = Column(String(50), nullable=True) # ISO 8601
calendar = relationship("LocalCalendar")
user = relationship("User")
class CalendarColorPref(Base):
"""A user's personal colour for a calendar they don't own — works for any
way a foreign calendar becomes visible (direct share, group calendar, or a
co-member's group-visible calendar). NULL/absent = the owner's colour."""
__tablename__ = "calendar_color_prefs"
__table_args__ = (
UniqueConstraint("calendar_id", "user_id", name="uq_calendar_color_pref"),
)
id = Column(Integer, primary_key=True, index=True)
calendar_id = Column(Integer, ForeignKey("local_calendars.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
color = Column(String(16), nullable=False)
class CalendarReminderPref(Base):
"""A user's personal reminder mute for a calendar they don't own — works for
any way a foreign calendar becomes visible (direct share, group calendar, or
a co-member's group-visible calendar). Absent = reminders on, regardless of
what the owner chose: muting is a personal decision and must not spread from
the owner to everyone a calendar reaches."""
__tablename__ = "calendar_reminder_prefs"
__table_args__ = (
UniqueConstraint("calendar_id", "user_id", name="uq_calendar_reminder_pref"),
)
id = Column(Integer, primary_key=True, index=True)
calendar_id = Column(Integer, ForeignKey("local_calendars.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
enabled = Column(Boolean, nullable=False)
class Group(Base):
__tablename__ = "groups"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), nullable=False)
icon = Column(String(16), nullable=True) # emoji shown for the group
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(String(50), nullable=True) # ISO 8601
members = relationship(
"GroupMember", back_populates="group", cascade="all, delete-orphan"
)
group_calendar = relationship(
"GroupCalendar", back_populates="group", uselist=False,
cascade="all, delete-orphan",
)
class GroupMember(Base):
__tablename__ = "group_members"
__table_args__ = (
UniqueConstraint("group_id", "user_id", name="uq_group_member"),
)
id = Column(Integer, primary_key=True, index=True)
group_id = Column(Integer, ForeignKey("groups.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
role = Column(String(10), default="member") # 'owner' | 'member'
color = Column(String(7), nullable=True) # this member's colour within the group
joined_at = Column(String(50), nullable=True) # ISO 8601
group = relationship("Group", back_populates="members")
user = relationship("User")
class BirthdaySyncDevice(Base):
"""A device that has synced Contacts birthdays into the user's birthday
calendar. Powers the web "birthdays come from these devices" list. One row
per (user, device); the client sends a stable device_id + human name."""
__tablename__ = "birthday_sync_devices"
__table_args__ = (
UniqueConstraint("user_id", "device_id", name="uq_birthday_sync_device"),
)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
device_id = Column(String(64), nullable=False)
device_name = Column(String(120), nullable=False)
last_sync = Column(String(50), nullable=True) # ISO 8601
count = Column(Integer, default=0)
user = relationship("User")
class GroupCalendar(Base):
"""1:1 link between a group and its shared local calendar."""
__tablename__ = "group_calendars"
__table_args__ = (
UniqueConstraint("group_id", name="uq_group_calendar_group"),
UniqueConstraint("calendar_id", name="uq_group_calendar_calendar"),
)
id = Column(Integer, primary_key=True, index=True)
group_id = Column(Integer, ForeignKey("groups.id"), nullable=False)
calendar_id = Column(Integer, ForeignKey("local_calendars.id"), nullable=False)
group = relationship("Group", back_populates="group_calendar")
calendar = relationship("LocalCalendar")
class OIDCIdentity(Base):
"""An external OpenID Connect identity linked to a Calendarr user.
A separate table rather than columns on ``users`` so one person can hold
identities at several providers (the multi-provider requirement).
Keyed on ``(provider_key, subject)`` and NOT on ``(issuer, subject)``: if
the web and mobile clients are registered as two Authentik *applications*
they have different issuer URLs, which would otherwise fork one human into
two accounts. ``issuer`` is kept for auditing.
"""
__tablename__ = "oidc_identities"
__table_args__ = (
UniqueConstraint("provider_key", "subject", name="ux_oidc_identity_provider_subject"),
)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
# The configured provider key, e.g. "authentik".
provider_key = Column(String(50), nullable=False)
issuer = Column(String(500), nullable=False)
subject = Column(String(255), nullable=False)
email = Column(String(255), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
last_login_at = Column(DateTime, nullable=True)
user = relationship("User", back_populates="oidc_identities")