Add display calendar support: model, router, service, types, visibility filter
Previously unstaged changes required for the display calendar feature: - EventInvitation model: display_calendar_id column - Event invitations router: display-calendar PUT endpoint - Event invitation service: display calendar update logic - CalendarPage: respect display_calendar_id in visibility filter - Types: display_calendar_id on CalendarEvent interface Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
29c2cbbec8
commit
a68ec0e23e
@ -35,12 +35,16 @@ class EventInvitation(Base):
|
|||||||
DateTime, default=func.now(), server_default=func.now()
|
DateTime, default=func.now(), server_default=func.now()
|
||||||
)
|
)
|
||||||
responded_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
responded_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||||
|
display_calendar_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer, ForeignKey("calendars.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
event: Mapped["CalendarEvent"] = relationship(lazy="raise")
|
event: Mapped["CalendarEvent"] = relationship(lazy="raise")
|
||||||
user: Mapped["User"] = relationship(foreign_keys=[user_id], lazy="raise")
|
user: Mapped["User"] = relationship(foreign_keys=[user_id], lazy="raise")
|
||||||
inviter: Mapped[Optional["User"]] = relationship(
|
inviter: Mapped[Optional["User"]] = relationship(
|
||||||
foreign_keys=[invited_by], lazy="raise"
|
foreign_keys=[invited_by], lazy="raise"
|
||||||
)
|
)
|
||||||
|
display_calendar: Mapped[Optional["Calendar"]] = relationship(lazy="raise")
|
||||||
overrides: Mapped[list["EventInvitationOverride"]] = relationship(
|
overrides: Mapped[list["EventInvitationOverride"]] = relationship(
|
||||||
lazy="raise", cascade="all, delete-orphan"
|
lazy="raise", cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -18,8 +18,9 @@ from app.schemas.event_invitation import (
|
|||||||
EventInvitationCreate,
|
EventInvitationCreate,
|
||||||
EventInvitationRespond,
|
EventInvitationRespond,
|
||||||
EventInvitationOverrideCreate,
|
EventInvitationOverrideCreate,
|
||||||
|
UpdateDisplayCalendar,
|
||||||
)
|
)
|
||||||
from app.services.calendar_sharing import get_user_permission
|
from app.services.calendar_sharing import get_accessible_calendar_ids, get_user_permission
|
||||||
from app.services.event_invitation import (
|
from app.services.event_invitation import (
|
||||||
send_event_invitations,
|
send_event_invitations,
|
||||||
respond_to_invitation,
|
respond_to_invitation,
|
||||||
@ -191,6 +192,45 @@ async def override_occurrence(
|
|||||||
return response_data
|
return response_data
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{invitation_id}/display-calendar")
|
||||||
|
async def update_display_calendar(
|
||||||
|
body: UpdateDisplayCalendar,
|
||||||
|
invitation_id: int = Path(ge=1, le=2147483647),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Change the display calendar for an accepted/tentative invitation."""
|
||||||
|
inv_result = await db.execute(
|
||||||
|
select(EventInvitation).where(
|
||||||
|
EventInvitation.id == invitation_id,
|
||||||
|
EventInvitation.user_id == current_user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
invitation = inv_result.scalar_one_or_none()
|
||||||
|
if not invitation:
|
||||||
|
raise HTTPException(status_code=404, detail="Invitation not found")
|
||||||
|
|
||||||
|
if invitation.status not in ("accepted", "tentative"):
|
||||||
|
raise HTTPException(status_code=400, detail="Can only set display calendar for accepted or tentative invitations")
|
||||||
|
|
||||||
|
# Verify calendar is accessible to this user
|
||||||
|
accessible_ids = await get_accessible_calendar_ids(current_user.id, db)
|
||||||
|
if body.calendar_id not in accessible_ids:
|
||||||
|
raise HTTPException(status_code=404, detail="Calendar not found")
|
||||||
|
|
||||||
|
invitation.display_calendar_id = body.calendar_id
|
||||||
|
|
||||||
|
# Extract response before commit (ORM expiry rule)
|
||||||
|
response_data = {
|
||||||
|
"id": invitation.id,
|
||||||
|
"event_id": invitation.event_id,
|
||||||
|
"display_calendar_id": invitation.display_calendar_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
return response_data
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{invitation_id}", status_code=204)
|
@router.delete("/{invitation_id}", status_code=204)
|
||||||
async def leave_or_revoke_invitation(
|
async def leave_or_revoke_invitation(
|
||||||
invitation_id: int = Path(ge=1, le=2147483647),
|
invitation_id: int = Path(ge=1, le=2147483647),
|
||||||
|
|||||||
@ -11,6 +11,7 @@ from sqlalchemy import delete, select, update
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.models.calendar import Calendar
|
||||||
from app.models.calendar_event import CalendarEvent
|
from app.models.calendar_event import CalendarEvent
|
||||||
from app.models.event_invitation import EventInvitation, EventInvitationOverride
|
from app.models.event_invitation import EventInvitation, EventInvitationOverride
|
||||||
from app.models.user_connection import UserConnection
|
from app.models.user_connection import UserConnection
|
||||||
@ -151,6 +152,27 @@ async def respond_to_invitation(
|
|||||||
invitation.status = status
|
invitation.status = status
|
||||||
invitation.responded_at = datetime.now()
|
invitation.responded_at = datetime.now()
|
||||||
|
|
||||||
|
# Auto-assign display calendar on accept/tentative (atomic: only if not already set)
|
||||||
|
if status in ("accepted", "tentative"):
|
||||||
|
default_cal = await db.execute(
|
||||||
|
select(Calendar.id).where(
|
||||||
|
Calendar.user_id == user_id,
|
||||||
|
Calendar.is_default == True,
|
||||||
|
).limit(1)
|
||||||
|
)
|
||||||
|
default_cal_id = default_cal.scalar_one_or_none()
|
||||||
|
if default_cal_id and invitation.display_calendar_id is None:
|
||||||
|
# Atomic: only set if still NULL (race-safe)
|
||||||
|
await db.execute(
|
||||||
|
update(EventInvitation)
|
||||||
|
.where(
|
||||||
|
EventInvitation.id == invitation_id,
|
||||||
|
EventInvitation.display_calendar_id == None,
|
||||||
|
)
|
||||||
|
.values(display_calendar_id=default_cal_id)
|
||||||
|
)
|
||||||
|
invitation.display_calendar_id = default_cal_id
|
||||||
|
|
||||||
# Notify the inviter only if status actually changed (prevents duplicate notifications)
|
# Notify the inviter only if status actually changed (prevents duplicate notifications)
|
||||||
if invitation.invited_by and old_status != status:
|
if invitation.invited_by and old_status != status:
|
||||||
status_label = {"accepted": "Going", "tentative": "Tentative", "declined": "Declined"}
|
status_label = {"accepted": "Going", "tentative": "Tentative", "declined": "Declined"}
|
||||||
|
|||||||
@ -332,8 +332,16 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
const filteredEvents = useMemo(() => {
|
const filteredEvents = useMemo(() => {
|
||||||
if (calendars.length === 0) return events;
|
if (calendars.length === 0) return events;
|
||||||
// Invited events bypass calendar visibility — they don't belong to the user's calendars
|
// Invited events: if display_calendar_id is set, respect that calendar's visibility;
|
||||||
return events.filter((e) => e.is_invited || visibleCalendarIds.has(e.calendar_id));
|
// otherwise (pending) always show
|
||||||
|
return events.filter((e) => {
|
||||||
|
if (e.is_invited) {
|
||||||
|
return e.display_calendar_id
|
||||||
|
? visibleCalendarIds.has(e.display_calendar_id)
|
||||||
|
: true;
|
||||||
|
}
|
||||||
|
return visibleCalendarIds.has(e.calendar_id);
|
||||||
|
});
|
||||||
}, [events, visibleCalendarIds, calendars.length]);
|
}, [events, visibleCalendarIds, calendars.length]);
|
||||||
|
|
||||||
const searchResults = useMemo(() => {
|
const searchResults = useMemo(() => {
|
||||||
|
|||||||
@ -115,6 +115,7 @@ export interface CalendarEvent {
|
|||||||
is_invited?: boolean;
|
is_invited?: boolean;
|
||||||
invitation_status?: 'pending' | 'accepted' | 'tentative' | 'declined' | null;
|
invitation_status?: 'pending' | 'accepted' | 'tentative' | 'declined' | null;
|
||||||
invitation_id?: number | null;
|
invitation_id?: number | null;
|
||||||
|
display_calendar_id?: number | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user