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:
parent
b650a94bb8
commit
e4b45763b4
23
backend/alembic/versions/047_add_is_shared_to_calendars.py
Normal file
23
backend/alembic/versions/047_add_is_shared_to_calendars.py
Normal 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")
|
||||
47
backend/alembic/versions/048_create_calendar_members.py
Normal file
47
backend/alembic/versions/048_create_calendar_members.py
Normal 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")
|
||||
30
backend/alembic/versions/049_create_event_locks.py
Normal file
30
backend/alembic/versions/049_create_event_locks.py
Normal 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")
|
||||
@ -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))
|
||||
@ -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")
|
||||
@ -18,6 +18,8 @@ from app.models.audit_log import AuditLog
|
||||
from app.models.notification import Notification
|
||||
from app.models.connection_request import ConnectionRequest
|
||||
from app.models.user_connection import UserConnection
|
||||
from app.models.calendar_member import CalendarMember
|
||||
from app.models.event_lock import EventLock
|
||||
|
||||
__all__ = [
|
||||
"Settings",
|
||||
@ -40,4 +42,6 @@ __all__ = [
|
||||
"Notification",
|
||||
"ConnectionRequest",
|
||||
"UserConnection",
|
||||
"CalendarMember",
|
||||
"EventLock",
|
||||
]
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
from sqlalchemy import String, Boolean, Integer, ForeignKey, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
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
|
||||
|
||||
|
||||
@ -17,7 +20,9 @@ class Calendar(Base):
|
||||
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_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())
|
||||
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
|
||||
|
||||
events: Mapped[List["CalendarEvent"]] = relationship(back_populates="calendar")
|
||||
members: Mapped[List["CalendarMember"]] = relationship(back_populates="calendar", cascade="all, delete-orphan")
|
||||
|
||||
@ -32,6 +32,11 @@ class CalendarEvent(Base):
|
||||
# original_start: the originally computed occurrence datetime (children only)
|
||||
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())
|
||||
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
53
backend/app/models/calendar_member.py
Normal file
53
backend/app/models/calendar_member.py
Normal 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"
|
||||
)
|
||||
31
backend/app/models/event_lock.py
Normal file
31
backend/app/models/event_lock.py
Normal 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")
|
||||
@ -5,10 +5,9 @@ from datetime import datetime
|
||||
from typing import Optional
|
||||
from app.database import Base
|
||||
|
||||
# Active: connection_request, connection_accepted
|
||||
# Reserved: connection_rejected, info, warning, reminder, system
|
||||
_NOTIFICATION_TYPES = (
|
||||
"connection_request", "connection_accepted", "connection_rejected",
|
||||
"calendar_invite", "calendar_invite_accepted", "calendar_invite_rejected",
|
||||
"info", "warning", "reminder", "system",
|
||||
)
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@ class CalendarCreate(BaseModel):
|
||||
|
||||
name: str = Field(min_length=1, max_length=100)
|
||||
color: str = Field("#3b82f6", max_length=20)
|
||||
is_shared: bool = False
|
||||
|
||||
|
||||
class CalendarUpdate(BaseModel):
|
||||
@ -16,6 +17,7 @@ class CalendarUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
color: Optional[str] = Field(None, max_length=20)
|
||||
is_visible: Optional[bool] = None
|
||||
is_shared: Optional[bool] = None
|
||||
|
||||
|
||||
class CalendarResponse(BaseModel):
|
||||
@ -27,5 +29,11 @@ class CalendarResponse(BaseModel):
|
||||
is_visible: bool
|
||||
created_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)
|
||||
|
||||
79
backend/app/schemas/shared_calendar.py
Normal file
79
backend/app/schemas/shared_calendar.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user