Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions prod-config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
92 changes: 89 additions & 3 deletions src/europython_discord/programme_notifications/cog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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."""
Expand Down
6 changes: 6 additions & 0 deletions src/europython_discord/programme_notifications/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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:
Expand All @@ -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.")

Expand All @@ -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."""
Expand Down
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion tests/program_notifications/mock_schedule.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
},
{
"code": "WQGUTP",
"duration": 45,
"duration": 40,
"event_type": "session",
"level": "beginner",
"rooms": [
Expand Down
15 changes: 14 additions & 1 deletion tests/program_notifications/test_program_connector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand Down
Loading