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..d38c3b1 100644 --- a/src/europython_discord/programme_notifications/cog.py +++ b/src/europython_discord/programme_notifications/cog.py @@ -10,8 +10,9 @@ 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 +from europython_discord.programme_notifications.schedule_comparison import compare_schedules _logger = logging.getLogger(__name__) @@ -79,8 +80,35 @@ 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() + old_schedule = self.programme_connector.sessions_by_day + new_schedule = await self.programme_connector.fetch_schedule() + + if old_schedule is None or new_schedule is None: + return + + changes = compare_schedules(old_schedule, new_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) + if change.new_session is not None: + _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 +203,64 @@ 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 + if old is None and new is not None: + new_end = new.start + timedelta(minutes=new.duration) + messages = [ + + f"New Session added: {new.title}", + f"Speakers: {', '.join(speaker.name for speaker in new.speakers)}", + f"New Room: {', '.join(new.rooms)}", + f"Time: {new.start.strftime('%d %B %H:%M')} - {new_end.strftime('%d %B %H:%M')}", + ] + elif old is not None and new is None: + old_end = old.start + timedelta(minutes=old.duration) + messages = [ + + f"Session cancelled: {old.title}", + f"Speakers: {', '.join(speaker.name for speaker in old.speakers)}", + f"Room: {', '.join(old.rooms)}", + f"Time: {old.start.strftime('%d %B %H:%M')} - {old_end.strftime('%d %B %H:%M')}", + ] + else: + new_end = new.start + timedelta(minutes=new.duration) + old_end = old.start + timedelta(minutes=old.duration) + messages = [ + f"Session: {new.title}", + f"Speakers: {', '.join(speaker.name for speaker in new.speakers)}", + f"Time: {new.start.strftime('%d %B %H:%M')} - {new_end.strftime('%d %B %H:%M')}", + f"Room: {', '.join(new.rooms)}", + "Changes:", + ] + if old.title != new.title: + messages.append( + f"Session title changed: {old.title} -> {new.title}" + ) + if old.speakers != new.speakers: + messages.append( + f"Speakers changed: " + f" {', '.join(speaker.name for speaker in old.speakers)} ->" + f" {', '.join(speaker.name for speaker in new.speakers)}" + ) + if old.rooms != new.rooms: + messages.append( + f"Room changed: " + f"{', '.join(old.rooms)} -> " + f"{', '.join(new.rooms)}" + ) + if old.start != new.start or old.duration != new.duration: + messages.append( + f"Time changed: {old.start.strftime('%d %B %H:%M')} - " + f"{old_end.strftime('%d %B %H:%M')} -> " + f"{new.start.strftime('%d %B %H:%M')} - " + f"{new_end.strftime('%d %B %H:%M')}" + ) + + return "\n".join(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/src/europython_discord/programme_notifications/schedule_comparison.py b/src/europython_discord/programme_notifications/schedule_comparison.py new file mode 100644 index 0000000..bf24c4e --- /dev/null +++ b/src/europython_discord/programme_notifications/schedule_comparison.py @@ -0,0 +1,43 @@ +from datetime import date + +from europython_discord.programme_notifications.models import ScheduleChange, Session + + +def build_session_lookup(schedule: dict[date, list[Session]]) -> dict[str, Session]: + """Build a lookup dictionary for sessions by their code.""" + session_lookup = {} + for sessions in schedule.values(): + for session in sessions: + session_lookup[session.code] = session + return session_lookup + + +def compare_schedules( + old_schedule: dict[date, list[Session]], new_schedule: dict[date, list[Session]] +) -> list[ScheduleChange]: + old_sessions = build_session_lookup(old_schedule) + new_sessions = build_session_lookup(new_schedule) + + new_session_codes = new_sessions.keys() - old_sessions.keys() + changes = [] + for session_code in new_session_codes: + new_session = new_sessions[session_code] + changes.append(ScheduleChange(old_session=None, new_session=new_session)) + cancelled_sessions = old_sessions.keys() - new_sessions.keys() + for session_code in cancelled_sessions: + old_session = old_sessions[session_code] + changes.append(ScheduleChange(old_session=old_session, new_session=None)) + common_session_codes = old_sessions.keys() & new_sessions.keys() + for session_code in common_session_codes: + old_session = old_sessions[session_code] + new_session = new_sessions[session_code] + if ( + old_session.title != new_session.title + or old_session.start != new_session.start + or old_session.duration != new_session.duration + or old_session.rooms != new_session.rooms + or old_session.speakers != new_session.speakers + ): + changes.append(ScheduleChange(old_session=old_session, new_session=new_session)) + + return changes 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..51b4b03 --- /dev/null +++ b/tests/program_notifications/test_programme_notifications_cog.py @@ -0,0 +1,176 @@ +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"} + ) + + old_schedule = { + old_session.start.date(): [old_session] + } + new_schedule = { + new_session.start.date(): [new_session] + } + + cog.programme_connector.sessions_by_day = old_schedule + cog.programme_connector.fetch_schedule.return_value = new_schedule + + await cog.fetch_schedule.coro(cog) + + assert "Found 1 schedule changes." in caplog.text + + channel.send.assert_called_once() + assert ( + "Session title changed: Old Title -> New Title" + in channel.send.call_args.kwargs["content"] +) + + +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 "Session: Old Title" in message + assert "Speakers: " in message + assert "Time: 28 July 10:00 - 28 July 10:45" in message + assert "Room: S2" in message + assert "Room changed: S1 -> S2" in message + assert ( + "Time changed: 28 July 10:00 - 28 July 10:30 -> " + "28 July 10:00 - 28 July 10:45" + ) in message + + +def test_format_new_session(): + new_session = Session( + event_type="session", + code="NEW123", + slug="new-session", + title="New Session", + session_type="talk", + speakers=[], + tweet="", + level="beginner", + track=None, + rooms=["S1"], + start=datetime(2026, 7, 28, 10, 0, tzinfo=UTC), + website_url="", + duration=45, + ) + + change = ScheduleChange( + old_session=None, + new_session=new_session, + ) + + message = _format_schedule_change(change) + + assert "New Session added: New Session" in message + assert "Speakers: " in message + assert "New Room: S1" in message + assert "Time: 28 July 10:00 - 28 July 10:45" in message + +def test_format_cancelled_session(): + old_session = Session( + event_type="session", + code="OLD123", + slug="old-session", + title="Cancelled Session", + session_type="talk", + speakers=[], + tweet="", + level="beginner", + track=None, + rooms=["S2"], + start=datetime(2026, 7, 28, 11, 0, tzinfo=UTC), + website_url="", + duration=30, + ) + + change = ScheduleChange( + old_session=old_session, + new_session=None, + ) + + message = _format_schedule_change(change) + + assert "Session cancelled: Cancelled Session" in message + assert "Speakers: " in message + assert "Room: S2" in message + assert "Time: 28 July 11:00 - 28 July 11:30" in message \ No newline at end of file