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
50 changes: 48 additions & 2 deletions src/europython_discord/programme_notifications/cog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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."""
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
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
104 changes: 104 additions & 0 deletions tests/program_notifications/test_programme_notifications_cog.py
Original file line number Diff line number Diff line change
@@ -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
Loading