Phase 1: Schema and models for shared calendars

Migrations 047-051:
- 047: Add is_shared to calendars
- 048: Create calendar_members table (permissions, status, constraints)
- 049: Create event_locks table (5min TTL, permanent owner locks)
- 050: Expand notification CHECK (calendar_invite types)
- 051: Add updated_by to calendar_events + updated_at index

New models: CalendarMember, EventLock
Updated models: Calendar (is_shared, members), CalendarEvent (updated_by),
  Notification (3 new types)
New schemas: shared_calendar.py (invite, respond, member, lock, sync)
Updated schemas: calendar.py (is_shared, sharing response fields)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-03-06 03:22:44 +08:00
parent b650a94bb8
commit e4b45763b4
13 changed files with 354 additions and 3 deletions

View File

@ -0,0 +1,23 @@
"""Add is_shared to calendars
Revision ID: 047
Revises: 046
"""
from alembic import op
import sqlalchemy as sa
revision = "047"
down_revision = "046"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"calendars",
sa.Column("is_shared", sa.Boolean(), nullable=False, server_default="false"),
)
def downgrade() -> None:
op.drop_column("calendars", "is_shared")

View File

@ -0,0 +1,47 @@
"""Create calendar_members table
Revision ID: 048
Revises: 047
"""
from alembic import op
import sqlalchemy as sa
revision = "048"
down_revision = "047"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"calendar_members",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("calendar_id", sa.Integer(), sa.ForeignKey("calendars.id", ondelete="CASCADE"), nullable=False),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("invited_by", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("permission", sa.String(20), nullable=False),
sa.Column("can_add_others", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("local_color", sa.String(20), nullable=True),
sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
sa.Column("invited_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("accepted_at", sa.DateTime(), nullable=True),
sa.UniqueConstraint("calendar_id", "user_id", name="uq_calendar_members_cal_user"),
sa.CheckConstraint(
"permission IN ('read_only', 'create_modify', 'full_access')",
name="ck_calendar_members_permission",
),
sa.CheckConstraint(
"status IN ('pending', 'accepted', 'rejected')",
name="ck_calendar_members_status",
),
)
op.create_index("ix_calendar_members_user_id", "calendar_members", ["user_id"])
op.create_index("ix_calendar_members_calendar_id", "calendar_members", ["calendar_id"])
op.create_index("ix_calendar_members_status", "calendar_members", ["status"])
def downgrade() -> None:
op.drop_index("ix_calendar_members_status", table_name="calendar_members")
op.drop_index("ix_calendar_members_calendar_id", table_name="calendar_members")
op.drop_index("ix_calendar_members_user_id", table_name="calendar_members")
op.drop_table("calendar_members")

View File

@ -0,0 +1,30 @@
"""Create event_locks table
Revision ID: 049
Revises: 048
"""
from alembic import op
import sqlalchemy as sa
revision = "049"
down_revision = "048"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"event_locks",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("event_id", sa.Integer(), sa.ForeignKey("calendar_events.id", ondelete="CASCADE"), nullable=False, unique=True),
sa.Column("locked_by", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("locked_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("expires_at", sa.DateTime(), nullable=True),
sa.Column("is_permanent", sa.Boolean(), nullable=False, server_default="false"),
)
op.create_index("ix_event_locks_expires_at", "event_locks", ["expires_at"])
def downgrade() -> None:
op.drop_index("ix_event_locks_expires_at", table_name="event_locks")
op.drop_table("event_locks")

View File

@ -0,0 +1,33 @@
"""Expand notification type CHECK for calendar invite types
Revision ID: 050
Revises: 049
"""
from alembic import op
revision = "050"
down_revision = "049"
branch_labels = None
depends_on = None
_OLD_TYPES = (
"connection_request", "connection_accepted", "connection_rejected",
"info", "warning", "reminder", "system",
)
_NEW_TYPES = _OLD_TYPES + (
"calendar_invite", "calendar_invite_accepted", "calendar_invite_rejected",
)
def _check_sql(types: tuple) -> str:
return f"type IN ({', '.join(repr(t) for t in types)})"
def upgrade() -> None:
op.drop_constraint("ck_notifications_type", "notifications", type_="check")
op.create_check_constraint("ck_notifications_type", "notifications", _check_sql(_NEW_TYPES))
def downgrade() -> None:
op.drop_constraint("ck_notifications_type", "notifications", type_="check")
op.create_check_constraint("ck_notifications_type", "notifications", _check_sql(_OLD_TYPES))

View File

@ -0,0 +1,34 @@
"""Add updated_by to calendar_events and ensure updated_at index
Revision ID: 051
Revises: 050
"""
from alembic import op
import sqlalchemy as sa
revision = "051"
down_revision = "050"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"calendar_events",
sa.Column(
"updated_by",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
)
op.create_index(
"ix_calendar_events_updated_at",
"calendar_events",
["updated_at"],
)
def downgrade() -> None:
op.drop_index("ix_calendar_events_updated_at", table_name="calendar_events")
op.drop_column("calendar_events", "updated_by")

View File

@ -18,6 +18,8 @@ from app.models.audit_log import AuditLog
from app.models.notification import Notification from app.models.notification import Notification
from app.models.connection_request import ConnectionRequest from app.models.connection_request import ConnectionRequest
from app.models.user_connection import UserConnection from app.models.user_connection import UserConnection
from app.models.calendar_member import CalendarMember
from app.models.event_lock import EventLock
__all__ = [ __all__ = [
"Settings", "Settings",
@ -40,4 +42,6 @@ __all__ = [
"Notification", "Notification",
"ConnectionRequest", "ConnectionRequest",
"UserConnection", "UserConnection",
"CalendarMember",
"EventLock",
] ]

View File

@ -1,7 +1,10 @@
from sqlalchemy import String, Boolean, Integer, ForeignKey, func from sqlalchemy import String, Boolean, Integer, ForeignKey, func
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime from datetime import datetime
from typing import List from typing import List, TYPE_CHECKING
if TYPE_CHECKING:
from app.models.calendar_member import CalendarMember
from app.database import Base from app.database import Base
@ -17,7 +20,9 @@ class Calendar(Base):
is_default: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") is_default: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
is_system: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") is_system: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
is_visible: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true") is_visible: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
is_shared: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
created_at: Mapped[datetime] = mapped_column(default=func.now()) created_at: Mapped[datetime] = mapped_column(default=func.now())
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
events: Mapped[List["CalendarEvent"]] = relationship(back_populates="calendar") events: Mapped[List["CalendarEvent"]] = relationship(back_populates="calendar")
members: Mapped[List["CalendarMember"]] = relationship(back_populates="calendar", cascade="all, delete-orphan")

View File

@ -32,6 +32,11 @@ class CalendarEvent(Base):
# original_start: the originally computed occurrence datetime (children only) # original_start: the originally computed occurrence datetime (children only)
original_start: Mapped[Optional[datetime]] = mapped_column(nullable=True) original_start: Mapped[Optional[datetime]] = mapped_column(nullable=True)
updated_by: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(default=func.now()) created_at: Mapped[datetime] = mapped_column(default=func.now())
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())

View File

@ -0,0 +1,53 @@
from sqlalchemy import (
Boolean, CheckConstraint, DateTime, Integer, ForeignKey, Index,
String, UniqueConstraint, func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime
from typing import Optional
from app.database import Base
class CalendarMember(Base):
__tablename__ = "calendar_members"
__table_args__ = (
UniqueConstraint("calendar_id", "user_id", name="uq_calendar_members_cal_user"),
CheckConstraint(
"permission IN ('read_only', 'create_modify', 'full_access')",
name="ck_calendar_members_permission",
),
CheckConstraint(
"status IN ('pending', 'accepted', 'rejected')",
name="ck_calendar_members_status",
),
Index("ix_calendar_members_user_id", "user_id"),
Index("ix_calendar_members_calendar_id", "calendar_id"),
Index("ix_calendar_members_status", "status"),
)
id: Mapped[int] = mapped_column(primary_key=True, index=True)
calendar_id: Mapped[int] = mapped_column(
Integer, ForeignKey("calendars.id", ondelete="CASCADE"), nullable=False
)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
invited_by: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
permission: Mapped[str] = mapped_column(String(20), nullable=False)
can_add_others: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false"
)
local_color: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
invited_at: Mapped[datetime] = mapped_column(
DateTime, default=func.now(), server_default=func.now()
)
accepted_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
calendar: Mapped["Calendar"] = relationship(back_populates="members", lazy="selectin")
user: Mapped["User"] = relationship(foreign_keys=[user_id], lazy="selectin")
inviter: Mapped[Optional["User"]] = relationship(
foreign_keys=[invited_by], lazy="selectin"
)

View File

@ -0,0 +1,31 @@
from sqlalchemy import Boolean, DateTime, Integer, ForeignKey, Index, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime
from typing import Optional
from app.database import Base
class EventLock(Base):
__tablename__ = "event_locks"
__table_args__ = (Index("ix_event_locks_expires_at", "expires_at"),)
id: Mapped[int] = mapped_column(primary_key=True, index=True)
event_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("calendar_events.id", ondelete="CASCADE"),
nullable=False,
unique=True,
)
locked_by: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
locked_at: Mapped[datetime] = mapped_column(
DateTime, default=func.now(), server_default=func.now()
)
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
is_permanent: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false"
)
event: Mapped["CalendarEvent"] = relationship(lazy="selectin")
holder: Mapped["User"] = relationship(foreign_keys=[locked_by], lazy="selectin")

View File

@ -5,10 +5,9 @@ from datetime import datetime
from typing import Optional from typing import Optional
from app.database import Base from app.database import Base
# Active: connection_request, connection_accepted
# Reserved: connection_rejected, info, warning, reminder, system
_NOTIFICATION_TYPES = ( _NOTIFICATION_TYPES = (
"connection_request", "connection_accepted", "connection_rejected", "connection_request", "connection_accepted", "connection_rejected",
"calendar_invite", "calendar_invite_accepted", "calendar_invite_rejected",
"info", "warning", "reminder", "system", "info", "warning", "reminder", "system",
) )

View File

@ -8,6 +8,7 @@ class CalendarCreate(BaseModel):
name: str = Field(min_length=1, max_length=100) name: str = Field(min_length=1, max_length=100)
color: str = Field("#3b82f6", max_length=20) color: str = Field("#3b82f6", max_length=20)
is_shared: bool = False
class CalendarUpdate(BaseModel): class CalendarUpdate(BaseModel):
@ -16,6 +17,7 @@ class CalendarUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100) name: Optional[str] = Field(None, min_length=1, max_length=100)
color: Optional[str] = Field(None, max_length=20) color: Optional[str] = Field(None, max_length=20)
is_visible: Optional[bool] = None is_visible: Optional[bool] = None
is_shared: Optional[bool] = None
class CalendarResponse(BaseModel): class CalendarResponse(BaseModel):
@ -27,5 +29,11 @@ class CalendarResponse(BaseModel):
is_visible: bool is_visible: bool
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
is_shared: bool = False
owner_umbral_name: Optional[str] = None
my_permission: Optional[str] = None
my_can_add_others: bool = False
my_local_color: Optional[str] = None
member_count: int = 0
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)

View File

@ -0,0 +1,79 @@
import re
from pydantic import BaseModel, ConfigDict, Field, field_validator
from typing import Optional, Literal
from datetime import datetime
class InviteMemberRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
connection_id: int = Field(ge=1, le=2147483647)
permission: Literal["read_only", "create_modify", "full_access"]
can_add_others: bool = False
class RespondInviteRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
action: Literal["accept", "reject"]
class UpdateMemberRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
permission: Optional[Literal["read_only", "create_modify", "full_access"]] = None
can_add_others: Optional[bool] = None
class UpdateLocalColorRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
local_color: Optional[str] = Field(None, max_length=20)
@field_validator("local_color")
@classmethod
def validate_color(cls, v: Optional[str]) -> Optional[str]:
if v is not None and not re.match(r"^#[0-9a-fA-F]{6}$", v):
raise ValueError("Color must be a hex color code (#RRGGBB)")
return v
class ConvertToSharedRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
class CalendarMemberResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
calendar_id: int
user_id: int
umbral_name: str
preferred_name: Optional[str] = None
permission: str
can_add_others: bool
local_color: Optional[str] = None
status: str
invited_at: datetime
accepted_at: Optional[datetime] = None
class CalendarInviteResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
calendar_id: int
calendar_name: str
calendar_color: str
owner_umbral_name: str
inviter_umbral_name: str
permission: str
invited_at: datetime
class LockStatusResponse(BaseModel):
locked: bool
locked_by_name: Optional[str] = None
expires_at: Optional[datetime] = None
is_permanent: bool = False
class SyncResponse(BaseModel):
events: list[dict]
member_changes: list[dict]
server_time: datetime
truncated: bool = False