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:"), 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 UserEmailAddress(Base): """An address under which this user appears in an ATTENDEE list. A list rather than a column: the mail arrives at a forwarding mailbox while the invitation still names the original address, and people hand out more than one. Globally unique so nobody can claim someone else's address and push events into their calendar. """ __tablename__ = "user_email_addresses" __table_args__ = (UniqueConstraint("address", name="uq_user_email_address"),) id = Column(Integer, primary_key=True, index=True) user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) # Always lowercase and without the "mailto:" prefix. address = Column(String(255), nullable=False) user = relationship("User") class MailboxAccount(Base): """The mailbox watched for invitations, configured in the UI. One row per instance: this is a shared inbox that invitations are forwarded to, not a per-user account. Administrators manage it. The password is stored as given, the same as CalDAVAccount, GoogleAccount and HomeAssistantAccount — IMAP needs the original to log in, so a hash is no use. Worth knowing: unlike those three, this one opens an entire mailbox. No endpoint ever returns it. SMTP lives here too so replies to organisers have somewhere to come from. """ __tablename__ = "mailbox_accounts" id = Column(Integer, primary_key=True, index=True) enabled = Column(Boolean, default=True, nullable=False) imap_host = Column(String(255), nullable=False) imap_port = Column(Integer, default=993, nullable=False) imap_ssl = Column(Boolean, default=True, nullable=False) imap_user = Column(String(255), nullable=False) imap_password = Column(String(255), nullable=False) imap_folder = Column(String(255), default="INBOX", nullable=False) poll_minutes = Column(Integer, default=5, nullable=False) smtp_host = Column(String(255), nullable=True) smtp_port = Column(Integer, default=587, nullable=True) smtp_ssl = Column(Boolean, default=False, nullable=True) smtp_user = Column(String(255), nullable=True) smtp_password = Column(String(255), nullable=True) smtp_from = Column(String(255), nullable=True) # Surfaced in the UI so a mailbox that stopped working says so, instead of # invitations quietly never arriving. last_run = Column(DateTime, nullable=True) last_error = Column(Text, nullable=True) class EventInvite(Base): """An iTIP invitation (METHOD:REQUEST) that arrived by mail. Raw *and* parsed. ``raw_ics`` is the only thing the organiser actually sent, and the reply is built from it — a rebuilt DTSTART is what strict counterparts reject. The parsed columns exist so clients can render a list without parsing iCalendar, and use the same formats as LocalEvent. Nothing here is visible in any calendar read path: an invitation becomes an event only when a human accepts it. """ __tablename__ = "event_invites" __table_args__ = (UniqueConstraint("user_id", "uid", name="uq_event_invite_user_uid"),) id = Column(Integer, primary_key=True, index=True) user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) # The organiser's UID. Only ever matched together with user_id, so two # users invited to the same meeting never collide. uid = Column(String(255), nullable=False) sequence = Column(Integer, default=0, nullable=False) # pending | accepted | declined | cancelled status = Column(String(12), default="pending", nullable=False) 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) rrule = Column(Text, nullable=True) organizer_email = Column(String(255), nullable=True) organizer_name = Column(String(255), nullable=True) # The ATTENDEE address that matched this user. The reply must name exactly # this one or the organiser will not recognise the answer. attendee_email = Column(String(255), nullable=False) raw_ics = Column(Text, nullable=False) # The event created on acceptance, so an update or a CANCEL can find it. event_id = Column(Integer, ForeignKey("local_events.id"), nullable=True) received_at = Column(DateTime, default=datetime.utcnow) # None | sent | failed | skipped reply_state = Column(String(12), nullable=True) user = relationship("User") 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")