Skip to main content
Glama
calendar.py15.3 kB
""" Calendar management for the meeting scheduler. Manages free and blocked time slots in a YAML file. """ from __future__ import annotations import logging from dataclasses import dataclass from datetime import date, datetime, time, timedelta from enum import Enum from pathlib import Path from typing import List, Optional from zoneinfo import ZoneInfo import yaml from pydantic import BaseModel, Field, field_validator, model_validator from .holidays import HolidayChecker from .mail import EmailClientProtocol, IMAPEmailClient logger = logging.getLogger(__name__) class Weekday(str, Enum): """Weekdays.""" MON = "mon" TUE = "tue" WED = "wed" THU = "thu" FRI = "fri" SAT = "sat" SUN = "sun" @property def iso_weekday(self) -> int: """ISO weekday (1=Monday, 7=Sunday).""" return { self.MON: 1, self.TUE: 2, self.WED: 3, self.THU: 4, self.FRI: 5, self.SAT: 6, self.SUN: 7, }[self] class TimeSlot(BaseModel): """A time window within a day.""" start: time end: time @model_validator(mode="after") def validate_order(self) -> "TimeSlot": if self.end <= self.start: raise ValueError(f"end ({self.end}) must be after start ({self.start})") return self def duration_minutes(self) -> int: """Duration in minutes.""" return (self.end.hour * 60 + self.end.minute) - (self.start.hour * 60 + self.start.minute) class WeeklyAvailability(BaseModel): """Availability for specific weekdays.""" days: list[Weekday] slots: list[TimeSlot] @field_validator("days") @classmethod def validate_days_not_empty(cls, v: list[Weekday]) -> list[Weekday]: if not v: raise ValueError("days must not be empty") return v class BlockedTime(BaseModel): """A blocked time.""" datetime: str # ISO 8601: "2024-12-23" or "2024-12-23T10:00+01:00" duration: Optional[int] = None # minutes until: Optional[str] = None # ISO 8601 reason: Optional[str] = None @model_validator(mode="after") def validate_duration_or_until(self) -> "BlockedTime": if self.duration is not None and self.until is not None: raise ValueError("Either duration or until must be specified, not both") return self def is_all_day(self) -> bool: """Checks if this is an all-day block.""" return "T" not in self.datetime def get_start(self, default_tz: ZoneInfo) -> datetime: """Parses datetime and returns start.""" if self.is_all_day(): d = date.fromisoformat(self.datetime) return datetime.combine(d, time(0, 0), tzinfo=default_tz) return datetime.fromisoformat(self.datetime) def get_end(self, default_tz: ZoneInfo) -> datetime: """Calculates end timepoint.""" start = self.get_start(default_tz) if self.until: if "T" in self.until: return datetime.fromisoformat(self.until) else: # All day until end of until date d = date.fromisoformat(self.until) return datetime.combine(d, time(23, 59, 59), tzinfo=default_tz) if self.duration: return start + timedelta(minutes=self.duration) # All day if self.is_all_day(): return datetime.combine(start.date(), time(23, 59, 59), tzinfo=default_tz) raise ValueError("Either duration, until, or all-day date required") class Schedule(BaseModel): """Complete schedule configuration.""" timezone: str slot_duration: int = Field(ge=5, le=480) holidays: Optional[str] = None # e.g. "DE" weekly: list[WeeklyAvailability] @field_validator("timezone") @classmethod def validate_timezone(cls, v: str) -> str: try: ZoneInfo(v) except KeyError: raise ValueError(f"Invalid timezone: {v}") return v def get_tz(self) -> ZoneInfo: """Returns ZoneInfo.""" return ZoneInfo(self.timezone) class Calendar(BaseModel): """Root model for calendar.yaml.""" schedule: Schedule blocked: list[BlockedTime] = Field(default_factory=list) @dataclass(slots=True) class AvailableSlot: """An available time slot.""" date: date start_time: time end_time: time timezone: str def __str__(self) -> str: weekdays = { 1: "Mon", 2: "Tue", 3: "Wed", 4: "Thu", 5: "Fri", 6: "Sat", 7: "Sun", } return f"{weekdays[self.date.isoweekday()]} {self.date.strftime('%d.%m.')}, {self.start_time.strftime('%H:%M')}" def to_dict(self) -> dict[str, str]: """Serialize to dictionary for API responses.""" return { "date": self.date.isoformat(), "start": self.start_time.isoformat(), "end": self.end_time.isoformat(), "timezone": self.timezone, } class CalendarStore: """Loads and saves calendar.yaml.""" def __init__(self, path: Path | str): self.path = Path(path) def load(self) -> Calendar: """Load calendar from YAML.""" with open(self.path) as f: data = yaml.safe_load(f) return Calendar.model_validate(data) def save(self, calendar: Calendar) -> None: """Save calendar as YAML.""" data = calendar.model_dump(mode="json") with open(self.path, "w") as f: yaml.dump( data, f, default_flow_style=False, allow_unicode=True, sort_keys=False ) def add_blocked( self, dt: datetime, duration: int | None = None, until: datetime | None = None, reason: str | None = None, ) -> None: """Add a blocked time.""" calendar = self.load() blocked = BlockedTime( datetime=dt.isoformat(), duration=duration, until=until.isoformat() if until else None, reason=reason, ) calendar.blocked.append(blocked) self.save(calendar) class SlotFinder: """Finds available slots.""" def __init__(self, calendar: Calendar): self.calendar = calendar self.tz = calendar.schedule.get_tz() self.holiday_checker = HolidayChecker(calendar.schedule.holidays) def find_available_slots( self, from_date: date | None = None, to_date: date | None = None, max_results: int = 10, min_notice_hours: int = 2, ) -> list[AvailableSlot]: """Find available slots.""" now = datetime.now(self.tz) from_date = from_date or now.date() to_date = to_date or (from_date + timedelta(days=30)) min_bookable = now + timedelta(hours=min_notice_hours) available: list[AvailableSlot] = [] current = from_date while current <= to_date and len(available) < max_results: day_slots = self._get_slots_for_date(current, min_bookable) available.extend(day_slots) current += timedelta(days=1) return available[:max_results] def _get_slots_for_date( self, d: date, min_bookable: datetime ) -> list[AvailableSlot]: """Generate slots for a date.""" # Holiday? if self.holiday_checker.is_holiday(d): return [] # Find weekday iso_weekday = d.isoweekday() weekly_slots: list[TimeSlot] = [] for weekly in self.calendar.schedule.weekly: if any(day.iso_weekday == iso_weekday for day in weekly.days): weekly_slots.extend(weekly.slots) if not weekly_slots: return [] # Generate slots slot_duration = timedelta(minutes=self.calendar.schedule.slot_duration) available: list[AvailableSlot] = [] for time_slot in weekly_slots: current = datetime.combine(d, time_slot.start, tzinfo=self.tz) end = datetime.combine(d, time_slot.end, tzinfo=self.tz) while current + slot_duration <= end: slot_start = current.time() slot_end = (current + slot_duration).time() if current >= min_bookable and not self._is_blocked( d, slot_start, slot_end ): available.append( AvailableSlot( date=d, start_time=slot_start, end_time=slot_end, timezone=self.calendar.schedule.timezone, ) ) current += slot_duration return available def _is_blocked(self, d: date, start: time, end: time) -> bool: """Check if slot is blocked.""" slot_start = datetime.combine(d, start, tzinfo=self.tz) slot_end = datetime.combine(d, end, tzinfo=self.tz) for blocked in self.calendar.blocked: block_start = blocked.get_start(self.tz) block_end = blocked.get_end(self.tz) # Check overlap if slot_start < block_end and block_start < slot_end: return True return False def is_slot_bookable(self, d: date, start: time, end: time) -> tuple[bool, str]: """Check if a slot is bookable.""" now = datetime.now(self.tz) slot_dt = datetime.combine(d, start, tzinfo=self.tz) # Past? if slot_dt < now: return False, "Timepoint is in the past" # Holiday? if self.holiday_checker.is_holiday(d): name = self.holiday_checker.get_holiday_name(d) or "Holiday" return False, f"{name}" # Weekday available? iso_weekday = d.isoweekday() day_available = False for weekly in self.calendar.schedule.weekly: if any(day.iso_weekday == iso_weekday for day in weekly.days): for slot in weekly.slots: if slot.start <= start and end <= slot.end: day_available = True break if not day_available: return False, "Outside of availability" # Blocked? if self._is_blocked(d, start, end): for blocked in self.calendar.blocked: block_start = blocked.get_start(self.tz) block_end = blocked.get_end(self.tz) slot_start = datetime.combine(d, start, tzinfo=self.tz) slot_end = datetime.combine(d, end, tzinfo=self.tz) if slot_start < block_end and block_start < slot_end: return False, blocked.reason or "Blocked" return True, "" class CalendarManager: """Manages the calendar in a YAML file.""" def __init__(self, file_path: str = "calendar.yaml"): self.file_path = Path(file_path) self.calendar_store = CalendarStore(file_path) self._ensure_file_exists() def _ensure_file_exists(self) -> None: """Ensures that the calendar file exists.""" if not self.file_path.exists(): # Create default calendar with reasonable settings default_calendar = Calendar( schedule=Schedule( timezone="Europe/Berlin", slot_duration=30, holidays="DE", weekly=[ WeeklyAvailability( days=[ Weekday.MON, Weekday.TUE, Weekday.WED, Weekday.THU, Weekday.FRI, ], slots=[ TimeSlot(start=time(9, 0), end=time(12, 0)), TimeSlot(start=time(13, 0), end=time(17, 0)), ], ) ], ), blocked=[], ) self.calendar_store.save(default_calendar) def get_free_slots(self) -> List[AvailableSlot]: """Gets all free (unblocked) time slots using the PRD slot finder. Returns: List[AvailableSlot]: List of free time slots """ try: calendar = self.calendar_store.load() finder = SlotFinder(calendar) return finder.find_available_slots(max_results=50) except FileNotFoundError as e: logger.error("Calendar file not found: %s", e) return [] except Exception as e: logger.error("Error finding free slots: %s", e) return [] def save_draft_and_block_slot( self, datetime_str: str, duration: int, reason: str, subject: str, body: str, to: str, in_reply_to: str = "", email_client: EmailClientProtocol | None = None, ) -> bool: """Blocks a time slot and saves a confirmation email as a draft. Args: datetime_str: ISO 8601 formatted datetime (e.g., "2025-12-15T14:00:00+01:00") duration: Duration in minutes reason: Reason for blocking the slot subject: Subject of the confirmation email body: Content of the confirmation email to: Recipient of the confirmation email in_reply_to: Message-ID of the email this is replying to (for email threading) email_client: Optional email client implementation (for dependency injection) Returns: bool: True on success, False on failure """ try: # Parse the datetime string try: slot_start = datetime.fromisoformat(datetime_str) except ValueError as e: logger.error("Invalid datetime format: %s", e) return False # Validate duration if duration <= 0: logger.error("Invalid duration: %d (must be positive)", duration) return False # Add blocked time using CalendarStore self.calendar_store.add_blocked( dt=slot_start, duration=duration, reason=reason ) # Save email as draft with threading support # Use injected email client or create default if email_client is None: email_client = IMAPEmailClient() with email_client: success = email_client.save_draft( subject, body, to, in_reply_to=in_reply_to ) return success except ValueError as e: logger.error("Invalid datetime format: %s", e) return False except FileNotFoundError as e: logger.error("Calendar file not found: %s", e) return False except (ConnectionError, OSError) as e: logger.error("Failed to save draft: %s", e) return False except Exception as e: logger.error("Error in save_draft_and_block_slot: %s", e) return False

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/seb-schulz/meeting-scheduler-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server