update
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
import recurring_ical_events
|
||||
from icalendar import Calendar
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -10,6 +11,9 @@ from app.models import BusyBlock, Participant
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# How far into the future to expand recurring events
|
||||
RECURRING_EVENT_HORIZON_WEEKS = 8
|
||||
|
||||
|
||||
async def fetch_ics_content(url: str) -> str:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
@@ -24,35 +28,48 @@ def parse_ics_to_busy_blocks(
|
||||
calendar = Calendar.from_ical(ics_content)
|
||||
blocks = []
|
||||
|
||||
for component in calendar.walk():
|
||||
if component.name == "VEVENT":
|
||||
dtstart = component.get("dtstart")
|
||||
dtend = component.get("dtend")
|
||||
# Define the time range for expanding recurring events
|
||||
now = datetime.now(timezone.utc)
|
||||
start_range = now - timedelta(days=7) # Include recent past
|
||||
end_range = now + timedelta(weeks=RECURRING_EVENT_HORIZON_WEEKS)
|
||||
|
||||
if dtstart is None or dtend is None:
|
||||
continue
|
||||
# Use recurring_ical_events to expand recurring events
|
||||
events = recurring_ical_events.of(calendar).between(start_range, end_range)
|
||||
|
||||
start_dt = dtstart.dt
|
||||
end_dt = dtend.dt
|
||||
for event in events:
|
||||
dtstart = event.get("dtstart")
|
||||
dtend = event.get("dtend")
|
||||
|
||||
if not isinstance(start_dt, datetime):
|
||||
start_dt = datetime.combine(start_dt, datetime.min.time())
|
||||
if not isinstance(end_dt, datetime):
|
||||
end_dt = datetime.combine(end_dt, datetime.min.time())
|
||||
if dtstart is None:
|
||||
continue
|
||||
|
||||
if start_dt.tzinfo is None:
|
||||
start_dt = start_dt.replace(tzinfo=timezone.utc)
|
||||
if end_dt.tzinfo is None:
|
||||
end_dt = end_dt.replace(tzinfo=timezone.utc)
|
||||
start_dt = dtstart.dt
|
||||
end_dt = dtend.dt if dtend else None
|
||||
|
||||
blocks.append(
|
||||
BusyBlock(
|
||||
participant_id=participant_id,
|
||||
start_time=start_dt,
|
||||
end_time=end_dt,
|
||||
)
|
||||
# Handle all-day events (date instead of datetime)
|
||||
if not isinstance(start_dt, datetime):
|
||||
start_dt = datetime.combine(start_dt, datetime.min.time())
|
||||
if end_dt is None:
|
||||
# If no end time, assume 1 hour duration
|
||||
end_dt = start_dt + timedelta(hours=1)
|
||||
elif not isinstance(end_dt, datetime):
|
||||
end_dt = datetime.combine(end_dt, datetime.min.time())
|
||||
|
||||
# Ensure timezone awareness
|
||||
if start_dt.tzinfo is None:
|
||||
start_dt = start_dt.replace(tzinfo=timezone.utc)
|
||||
if end_dt.tzinfo is None:
|
||||
end_dt = end_dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
blocks.append(
|
||||
BusyBlock(
|
||||
participant_id=participant_id,
|
||||
start_time=start_dt,
|
||||
end_time=end_dt,
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(f"Parsed {len(blocks)} events (including recurring) for participant {participant_id}")
|
||||
return blocks
|
||||
|
||||
|
||||
|
||||
@@ -7,10 +7,10 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.availability_service import calculate_availability
|
||||
from app.availability_service import calculate_availability, get_busy_blocks_for_participants, is_participant_free
|
||||
from app.database import get_db
|
||||
from app.ics_service import sync_all_calendars, sync_participant_calendar
|
||||
from app.models import Participant
|
||||
from app.models import Participant, BusyBlock
|
||||
from app.schemas import (
|
||||
AvailabilityRequest,
|
||||
AvailabilityResponse,
|
||||
@@ -175,6 +175,15 @@ async def sync_participant(participant_id: UUID, db: AsyncSession = Depends(get_
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.delete("/api/bookings")
|
||||
async def clear_all_bookings(db: AsyncSession = Depends(get_db)):
|
||||
"""Clear all busy blocks (scheduled meetings) from the database."""
|
||||
from sqlalchemy import delete
|
||||
await db.execute(delete(BusyBlock))
|
||||
await db.commit()
|
||||
return {"status": "success", "message": "All bookings cleared"}
|
||||
|
||||
|
||||
@app.post("/api/schedule")
|
||||
async def schedule_meeting(
|
||||
data: ScheduleRequest, db: AsyncSession = Depends(get_db)
|
||||
@@ -182,7 +191,7 @@ async def schedule_meeting(
|
||||
min_start_time = datetime.now(timezone.utc) + timedelta(hours=2)
|
||||
if data.start_time.replace(tzinfo=timezone.utc) < min_start_time:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
status_code=400,
|
||||
detail="Meetings must be scheduled at least 2 hours in advance."
|
||||
)
|
||||
|
||||
@@ -190,10 +199,29 @@ async def schedule_meeting(
|
||||
select(Participant).where(Participant.id.in_(data.participant_ids))
|
||||
)
|
||||
participants = result.scalars().all()
|
||||
|
||||
|
||||
if len(participants) != len(data.participant_ids):
|
||||
raise HTTPException(status_code=400, detail="Some participants not found")
|
||||
|
||||
# Check if all participants are actually free at the requested time
|
||||
start_time = data.start_time.replace(tzinfo=timezone.utc) if data.start_time.tzinfo is None else data.start_time
|
||||
end_time = data.end_time.replace(tzinfo=timezone.utc) if data.end_time.tzinfo is None else data.end_time
|
||||
|
||||
busy_map = await get_busy_blocks_for_participants(
|
||||
db, data.participant_ids, start_time, end_time
|
||||
)
|
||||
|
||||
busy_participants = []
|
||||
for participant in participants:
|
||||
if not is_participant_free(busy_map.get(participant.id, []), start_time, end_time):
|
||||
busy_participants.append(participant.name)
|
||||
|
||||
if busy_participants:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Cannot schedule: {', '.join(busy_participants)} {'is' if len(busy_participants) == 1 else 'are'} not available at this time. Please refresh availability and try again."
|
||||
)
|
||||
|
||||
participant_dicts = [
|
||||
{"name": p.name, "email": p.email} for p in participants
|
||||
]
|
||||
@@ -213,6 +241,16 @@ async def schedule_meeting(
|
||||
participant_dicts
|
||||
)
|
||||
|
||||
# Create busy blocks for all participants so the slot shows as taken immediately
|
||||
for participant in participants:
|
||||
busy_block = BusyBlock(
|
||||
participant_id=participant.id,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
)
|
||||
db.add(busy_block)
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"email_sent": email_success,
|
||||
|
||||
Reference in New Issue
Block a user