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()
|
||||
)
|
||||
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")
|
||||
user: Mapped["User"] = relationship(foreign_keys=[user_id], lazy="raise")
|
||||
inviter: Mapped[Optional["User"]] = relationship(
|
||||
foreign_keys=[invited_by], lazy="raise"
|
||||
)
|
||||
display_calendar: Mapped[Optional["Calendar"]] = relationship(lazy="raise")
|
||||
overrides: Mapped[list["EventInvitationOverride"]] = relationship(
|
||||
lazy="raise", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
@ -18,8 +18,9 @@ from app.schemas.event_invitation import (
|
||||
EventInvitationCreate,
|
||||
EventInvitationRespond,
|
||||
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 (
|
||||
send_event_invitations,
|
||||
respond_to_invitation,
|
||||
@ -191,6 +192,45 @@ async def override_occurrence(
|
||||
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)
|
||||
async def leave_or_revoke_invitation(
|
||||
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.orm import selectinload
|
||||
|
||||
from app.models.calendar import Calendar
|
||||
from app.models.calendar_event import CalendarEvent
|
||||
from app.models.event_invitation import EventInvitation, EventInvitationOverride
|
||||
from app.models.user_connection import UserConnection
|
||||
@ -151,6 +152,27 @@ async def respond_to_invitation(
|
||||
invitation.status = status
|
||||
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)
|
||||
if invitation.invited_by and old_status != status:
|
||||
status_label = {"accepted": "Going", "tentative": "Tentative", "declined": "Declined"}
|
||||
|
||||
@ -332,8 +332,16 @@ export default function CalendarPage() {
|
||||
|
||||
const filteredEvents = useMemo(() => {
|
||||
if (calendars.length === 0) return events;
|
||||
// Invited events bypass calendar visibility — they don't belong to the user's calendars
|
||||
return events.filter((e) => e.is_invited || visibleCalendarIds.has(e.calendar_id));
|
||||
// Invited events: if display_calendar_id is set, respect that calendar's visibility;
|
||||
// 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]);
|
||||
|
||||
const searchResults = useMemo(() => {
|
||||
|
||||
@ -115,6 +115,7 @@ export interface CalendarEvent {
|
||||
is_invited?: boolean;
|
||||
invitation_status?: 'pending' | 'accepted' | 'tentative' | 'declined' | null;
|
||||
invitation_id?: number | null;
|
||||
display_calendar_id?: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user