Parsing of schedules

This commit is contained in:
stijndcl 2022-09-17 23:20:46 +02:00
parent 8fea65e4ad
commit 14ccb42424
10 changed files with 228 additions and 20 deletions

View file

@ -53,6 +53,15 @@ class Owner(commands.Cog):
"""Raise an exception for debugging purposes"""
raise Exception(message)
@commands.command(name="Reload")
async def reload(self, ctx: commands.Context, *cogs: str):
"""Reload the cogs passed as an argument"""
for cog in cogs:
await self.client.reload_extension(f"didier.cogs.{cog}")
await self.client.confirm_message(ctx.message)
return await ctx.reply(f"Successfully reloaded {', '.join(cogs)}.", mention_author=False)
@commands.command(name="Sync")
async def sync(
self,

View file

@ -12,6 +12,7 @@ from database.crud.ufora_announcements import remove_old_announcements
from database.crud.wordle import set_daily_word
from didier import Didier
from didier.data.embeds.ufora.announcements import fetch_ufora_announcements
from didier.data.schedules import parse_schedule_from_content
from didier.decorators.tasks import timed_task
from didier.utils.discord.checks import is_owner
from didier.utils.types.datetime import LOCAL_TIMEZONE, tz_aware_now
@ -121,22 +122,25 @@ class Tasks(commands.Cog):
"""
_ = kwargs
for data in settings.SCHEDULE_DATA:
if data.schedule_url is None:
return
async with self.client.postgres_session as session:
for data in settings.SCHEDULE_DATA:
if data.schedule_url is None:
return
async with self.client.http_session.get(data.schedule_url) as response:
# If a schedule couldn't be fetched, log it and move on
if response.status != 200:
await self.client.log_warning(
f"Unable to fetch schedule {data.name} (status {response.status}).", log_to_discord=False
)
continue
async with self.client.http_session.get(data.schedule_url) as response:
# If a schedule couldn't be fetched, log it and move on
if response.status != 200:
await self.client.log_warning(
f"Unable to fetch schedule {data.name} (status {response.status}).", log_to_discord=False
)
continue
# Write the content to a file
content = await response.text()
with open(f"files/schedules/{data.name}.ics", "w+") as fp:
fp.write(content)
# Write the content to a file
content = await response.text()
with open(f"files/schedules/{data.name}.ics", "w+") as fp:
fp.write(content)
await parse_schedule_from_content(content, database_session=session)
@tasks.loop(minutes=10)
@timed_task(enums.TaskType.UFORA_ANNOUNCEMENTS)
@ -194,4 +198,4 @@ async def setup(client: Didier):
cog = Tasks(client)
await client.add_cog(cog)
await cog.reset_wordle_word()
await cog.pull_schedules()
# await cog.pull_schedules()

116
didier/data/schedules.py Normal file
View file

@ -0,0 +1,116 @@
from __future__ import annotations
import pathlib
import re
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional
from arrow import Arrow
from ics import Calendar
from overrides import overrides
from sqlalchemy.ext.asyncio import AsyncSession
from database.crud.ufora_courses import get_course_by_code
from database.schemas import UforaCourse
from didier.utils.types.datetime import LOCAL_TIMEZONE
from settings import ScheduleType
__all__ = ["Schedule", "parse_schedule_from_content", "parse_schedule"]
@dataclass
class Schedule:
"""An entire schedule"""
slots: set[ScheduleSlot]
@dataclass
class ScheduleSlot:
"""A slot in the schedule"""
course: UforaCourse
start_time: datetime
end_time: datetime
location: str
_hash: int = field(init=False)
def __post_init__(self):
"""Fix some properties to display more nicely"""
# Re-format the location data
room, building, campus = re.search(r"Leslokaal (.*)\. Gebouw (.*)\. Campus (.*)\. ", self.location).groups()
self.location = f"{campus} {building} {room}"
self._hash = hash(f"{self.course.course_id} {str(self.start_time)}")
@overrides
def __hash__(self) -> int:
return self._hash
@overrides
def __eq__(self, other: ScheduleSlot):
return self._hash == other._hash
def parse_course_code(summary: str) -> str:
"""Parse a course's code out of the summary"""
code = re.search(r"^([^ ]+)\. ", summary).groups()[0]
# Strip off last character as it's not relevant
if code[-1].isalpha():
return code[:-1]
return code
def parse_time_string(string: str) -> datetime:
"""Parse an ISO string to a timezone-aware datetime instance"""
return datetime.fromisoformat(string).astimezone(LOCAL_TIMEZONE)
async def parse_schedule_from_content(content: str, *, database_session: AsyncSession) -> Schedule:
"""Parse a schedule file, taking the file content as an argument
This can be used to avoid unnecessarily opening the file again if you already have its contents
"""
calendar = Calendar(content)
day = Arrow(year=2022, month=9, day=26)
events = list(calendar.timeline.on(day))
course_codes: dict[str, UforaCourse] = {}
slots: set[ScheduleSlot] = set()
for event in events:
code = parse_course_code(event.name)
if code not in course_codes:
course = await get_course_by_code(database_session, code)
if course is None:
# raise ValueError(f"Unable to find course with code {code} (event {event.name})")
continue # TODO uncomment the line above
course_codes[code] = course
# Overwrite the name to be the sanitized value
event.name = code
slot = ScheduleSlot(
course=course_codes[code],
start_time=parse_time_string(str(event.begin)),
end_time=parse_time_string(str(event.end)),
location=event.location,
)
slots.add(slot)
return Schedule(slots=slots)
async def parse_schedule(name: ScheduleType, *, database_session: AsyncSession) -> Optional[Schedule]:
"""Read and then parse a schedule file"""
schedule_path = pathlib.Path(f"files/schedules/{name}.ics")
if not schedule_path.exists():
return None
with open(schedule_path, "r", encoding="utf-8") as fp:
return await parse_schedule_from_content(fp.read(), database_session=database_session)

View file

@ -13,6 +13,7 @@ from database.crud import custom_commands
from database.engine import DBSession
from database.utils.caches import CacheManager
from didier.data.embeds.error_embed import create_error_embed
from didier.data.schedules import Schedule, parse_schedule
from didier.exceptions import HTTPException, NoMatch
from didier.utils.discord.prefix import get_prefix
@ -29,6 +30,7 @@ class Didier(commands.Bot):
error_channel: discord.abc.Messageable
initial_extensions: tuple[str, ...] = ()
http_session: ClientSession
schedules: dict[settings.ScheduleType, Schedule] = {}
wordle_words: set[str] = set()
def __init__(self):
@ -63,6 +65,9 @@ class Didier(commands.Bot):
# Create directories that are ignored on GitHub
self._create_ignored_directories()
# Load schedules
await self.load_schedules()
# Load the Wordle dictionary
self._load_wordle_words()
@ -120,6 +125,18 @@ class Didier(commands.Bot):
for line in fp:
self.wordle_words.add(line.strip())
async def load_schedules(self):
"""Parse & load all schedules into memory"""
self.schedules = {}
async with self.postgres_session as session:
for schedule_data in settings.SCHEDULE_DATA:
schedule = await parse_schedule(schedule_data.name, database_session=session)
if schedule is None:
continue
self.schedules[schedule_data.name] = schedule
async def get_reply_target(self, ctx: commands.Context) -> discord.Message:
"""Get the target message that should be replied to