diff --git a/prod-config.toml b/prod-config.toml index 9927627..2b3f1ca 100644 --- a/prod-config.toml +++ b/prod-config.toml @@ -45,6 +45,7 @@ api_url = "https://static.europython.eu/programme/ep2026/releases/current/schedu schedule_cache_file = "schedule_cache.json" livestream_url_file = "livestreams.toml" main_notification_channel_name = "programme-notifications" +schedule_updates_channel_name = "schedule-updates" # optional simulated start time for testing programme notifications # simulated_start_time = "2026-07-15T08:50:00+02:00" diff --git a/src/europython_discord/programme_notifications/cog.py b/src/europython_discord/programme_notifications/cog.py index 8bdb93b..ef2f9f8 100644 --- a/src/europython_discord/programme_notifications/cog.py +++ b/src/europython_discord/programme_notifications/cog.py @@ -10,7 +10,7 @@ from europython_discord.programme_notifications import session_to_embed from europython_discord.programme_notifications.config import ProgrammeNotificationsConfig from europython_discord.programme_notifications.livestream_connector import LivestreamConnector -from europython_discord.programme_notifications.models import Session +from europython_discord.programme_notifications.models import ScheduleChange, Session from europython_discord.programme_notifications.programme_connector import ProgrammeConnector _logger = logging.getLogger(__name__) @@ -80,7 +80,26 @@ async def cog_unload(self) -> None: @tasks.loop(minutes=5) async def fetch_schedule(self) -> None: _logger.info("Starting the periodic schedule update...") - await self.programme_connector.fetch_schedule() + changes = await self.programme_connector.fetch_schedule() + + if not changes: + return + + _logger.info(f"Found {len(changes)} schedule changes.") + + schedule_updates_channel = discord_get( + self.bot.get_all_channels(), + name=self.config.schedule_updates_channel_name, + ) + + if schedule_updates_channel is None: + _logger.warning("Schedule updates channel not found.") + return + + for change in changes: + message = _format_schedule_change(change) + await schedule_updates_channel.send(content=message) + _logger.info(f"Sent schedule change notification for session {change.new_session.code}") @tasks.loop(minutes=5) async def fetch_livestreams(self) -> None: @@ -175,6 +194,33 @@ def _get_room_channel(self, room_name: str) -> TextChannel | None: return discord_get(self.bot.get_all_channels(), name=channel_name) +def _format_schedule_change(change: ScheduleChange) -> str: + old = change.old_session + new = change.new_session + + messages = [] + + if old.rooms != new.rooms: + messages.append( + f"Room changed: {old.rooms} → {new.rooms}" + ) + + if old.start != new.start: + messages.append( + f"Time changed: {old.start} → {new.start}" + ) + + if old.duration != new.duration: + messages.append( + f"Duration changed: {old.duration} → {new.duration} minutes" + ) + + return "\n".join( + [ + f"**Schedule update: {old.title}**", + *messages, + ] + ) def _get_session_key(session: Session) -> tuple[str, datetime]: """Get a unique key per session.""" diff --git a/src/europython_discord/programme_notifications/models.py b/src/europython_discord/programme_notifications/models.py index 93c1336..9f835b0 100644 --- a/src/europython_discord/programme_notifications/models.py +++ b/src/europython_discord/programme_notifications/models.py @@ -17,6 +17,12 @@ class Schedule(BaseModel): days: dict[date, DaySchedule] +class ScheduleChange(BaseModel): + """Change in the EuroPython schedule.""" + + old_session: Session | None + new_session: Session | None + class Break(BaseModel): """Break in the EuroPython schedule.""" diff --git a/src/europython_discord/programme_notifications/programme_connector.py b/src/europython_discord/programme_notifications/programme_connector.py index ad7d819..bf8d47b 100644 --- a/src/europython_discord/programme_notifications/programme_connector.py +++ b/src/europython_discord/programme_notifications/programme_connector.py @@ -9,7 +9,7 @@ import aiofiles import aiohttp -from europython_discord.programme_notifications.models import Break, Schedule, Session +from europython_discord.programme_notifications.models import Break, Schedule, Session, ScheduleChange _logger = logging.getLogger(__name__) @@ -50,7 +50,37 @@ async def parse_schedule(self, schedule: dict) -> dict[date, list[Session]]: return sessions_by_day - async def fetch_schedule(self) -> None: + def compare_schedules( + self, + old_schedule: dict[date, list[Session]], + new_schedule: dict[date, list[Session]] + ) -> list[ScheduleChange]: + changes = [] + session_lookup = {} + for day, new_sessions in new_schedule.items(): + for session in new_sessions: + session_lookup[session.code] = session + for day, sessions in old_schedule.items(): + for session in sessions: + if session.code in session_lookup: + new_session = session_lookup[session.code] + if ( + new_session.start != session.start + or new_session.rooms != session.rooms + or new_session.duration != session.duration + ): + + changes.append( + ScheduleChange( + old_session=session, + new_session=new_session + ) + ) + return changes + + + + async def fetch_schedule(self) -> list[ScheduleChange]: """Fetch schedule data from the Programme API and write it to a file as backup.""" async with self._fetch_lock: try: @@ -66,11 +96,11 @@ async def fetch_schedule(self) -> None: if self.sessions_by_day is not None: _logger.info("Schedule not updated, using the one loaded in memory.") - return + return [] self.sessions_by_day = await self._get_schedule_from_cache() _logger.info("Schedule loaded from cache file.") - return + return [] _logger.info("Schedule fetched successfully.") @@ -80,9 +110,20 @@ async def fetch_schedule(self) -> None: async with aiofiles.open(self._cache_file, "w") as f: await f.write(json.dumps(schedule, indent=2)) _logger.info("Schedule written to cache file.") - - self.sessions_by_day = await self.parse_schedule(schedule) + new_schedule = await self.parse_schedule(schedule) + + if self.sessions_by_day is not None: + changes = self.compare_schedules( + self.sessions_by_day, + new_schedule + ) + else: + changes = [] + + + self.sessions_by_day = new_schedule _logger.info("Schedule parsed and loaded.") + return changes async def _get_schedule_from_cache(self) -> dict[date, list[Session]] | None: """Get the schedule data from the cache file.""" diff --git a/tests/program_notifications/mock_schedule.json b/tests/program_notifications/mock_schedule.json index 085c2e8..7890d39 100644 --- a/tests/program_notifications/mock_schedule.json +++ b/tests/program_notifications/mock_schedule.json @@ -36,7 +36,7 @@ }, { "code": "WQGUTP", - "duration": 45, + "duration": 40, "event_type": "session", "level": "beginner", "rooms": [ diff --git a/tests/program_notifications/test_program_connector.py b/tests/program_notifications/test_program_connector.py index 599cb2c..054cc8e 100644 --- a/tests/program_notifications/test_program_connector.py +++ b/tests/program_notifications/test_program_connector.py @@ -8,7 +8,7 @@ import pytest from aiohttp import web from aiohttp.test_utils import TestServer - +from europython_discord.programme_notifications.models import Session from europython_discord.programme_notifications.programme_connector import ProgrammeConnector mock_schedule_file = Path(__file__).parent / "mock_schedule.json" @@ -66,6 +66,19 @@ async def test_fetch_schedule(programme_connector, mock_schedule_url, cache_file cached_data = json.loads(await f.read()) assert cached_data == mock_schedule +async def test_compare_schedules_detects_changes(programme_connector, mock_schedule): + old_schedule = await programme_connector.parse_schedule(mock_schedule) + + new_schedule = await programme_connector.parse_schedule(mock_schedule) + + new_schedule[date(2024, 7, 10)][0].duration += 10 + + changes = programme_connector.compare_schedules( + old_schedule, + new_schedule, + ) + + assert len(changes) == 1 async def test_get_schedule_from_cache(programme_connector, mock_schedule, cache_file): async with aiofiles.open(cache_file, "w") as f: diff --git a/tests/program_notifications/test_programme_notifications_cog.py b/tests/program_notifications/test_programme_notifications_cog.py new file mode 100644 index 0000000..8e22836 --- /dev/null +++ b/tests/program_notifications/test_programme_notifications_cog.py @@ -0,0 +1,104 @@ +from datetime import UTC, datetime +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from discord import channel +import pytest + +from europython_discord.programme_notifications.cog import ( + ProgrammeNotificationsCog, + _format_schedule_change, +) +from europython_discord.programme_notifications.models import ( + ScheduleChange, + Session, +) + + +@pytest.mark.asyncio +async def test_fetch_schedule_detects_changes(caplog): + caplog.set_level("INFO") + + cog = object.__new__(ProgrammeNotificationsCog) + + cog.programme_connector = AsyncMock() + + cog.bot = SimpleNamespace() + + cog.config = SimpleNamespace( + schedule_updates_channel_name="schedule-updates" +) + + channel = AsyncMock() + channel.name = "schedule-updates" + + cog.bot.get_all_channels = lambda: [channel] + + old_session = Session( + event_type="session", + code="ABC123", + slug="test-session", + title="Old Title", + session_type="talk", + speakers=[], + tweet="", + level="beginner", + track=None, + rooms=["S1"], + start=datetime.now(tz=UTC), + website_url="", + duration=30, + ) + + new_session = old_session.model_copy( + update={"title": "New Title"} + ) + + change = ScheduleChange( + old_session=old_session, + new_session=new_session, + ) + + cog.programme_connector.fetch_schedule.return_value = [change] + + await cog.fetch_schedule.coro(cog) + + assert "Found 1 schedule changes." in caplog.text + + channel.send.assert_called_once() + + +def test_format_schedule_change(): + old_session = Session( + event_type="session", + code="ABC123", + slug="test-session", + title="Old Title", + session_type="talk", + speakers=[], + tweet="", + level="beginner", + track=None, + rooms=["S1"], + start=datetime(2026, 7, 28, 10, 0, tzinfo=UTC), + website_url="", + duration=30, + ) + + new_session = old_session.model_copy( + update={ + "rooms": ["S2"], + "duration": 45, + } + ) + + change = ScheduleChange( + old_session=old_session, + new_session=new_session, + ) + + message = _format_schedule_change(change) + + assert "Schedule update: Old Title" in message + assert "Room changed: ['S1'] → ['S2']" in message + assert "Duration changed: 30 → 45 minutes" in message \ No newline at end of file