diff --git a/cogs/assignments.py b/cogs/assignments.py index 74e93aa..3dc7802 100644 --- a/cogs/assignments.py +++ b/cogs/assignments.py @@ -78,18 +78,22 @@ class AssignmentsCog(commands.Cog): async def reminder_loop(self): if not self.bot.is_ready(): await self.bot.wait_until_ready() - view_command = "".format( - self.bot.get_application_command( - "assignments", - type=discord.SlashCommandGroup - ) - ) - edit_command = "".format( - self.bot.get_application_command( - "assignments", - type=discord.SlashCommandGroup - ) - ) + try: + view_command = "".format( + self.bot.get_application_command( + "assignments", + type=discord.SlashCommandGroup + ) + ) + edit_command = "".format( + self.bot.get_application_command( + "assignments", + type=discord.SlashCommandGroup + ) + ) + except AttributeError: + view_command = "`/assignments view`" + edit_command = "`/assignments edit`" allowed_mentions = discord.AllowedMentions(everyone=True) if not config.dev else discord.AllowedMentions.none() guild = self.bot.get_guild(config.guilds[0]) general = discord.utils.get(guild.text_channels, name="general") diff --git a/cogs/timetable.py b/cogs/timetable.py new file mode 100644 index 0000000..5ab57f4 --- /dev/null +++ b/cogs/timetable.py @@ -0,0 +1,176 @@ +import asyncio +from typing import Optional, Union + +import discord +from discord.ext import commands, tasks +import json +from pathlib import Path +from utils import console +from datetime import time, datetime, timedelta + + +def schedule_times(): + times = [] + for h in range(24): + for m in range(0, 60, 15): + times.append(time(h, m, 0)) + return times + + +class TimeTableCog(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + with (Path.cwd() / "utils" / "timetable.json").open() as file: + self.timetable = json.load(file) + self.update_status.start() + + def cog_unload(self): + self.update_status.stop() + + def current_lesson(self, date: datetime = None) -> Optional[dict]: + date = date or datetime.now() + lessons = self.timetable[date.strftime("%A").lower()] + if not lessons: + return + for lesson in lessons: + now = date.time() + start_time = time(*lesson["start"]) + end_time = time(*lesson["end"]) + if now >= end_time: + continue + elif now < start_time: + continue + else: + # We are now currently actively in the lesson. + lesson = lesson.copy() + end_datetime = date.replace(hour=end_time.hour, minute=end_time.minute) + lesson["end"] = end_time + lesson["start"] = start_time + lesson["end_datetime"] = end_datetime + return lesson + + def next_lesson(self, date: datetime = None) -> Optional[dict]: + date = date or datetime.now() + lessons = self.timetable.get(date.strftime("%A").lower(), []).copy() + if not lessons: + return + for lesson in lessons: + now = date.time() + start_time = time(*lesson["start"]) + end_time = time(*lesson["end"]) + if now > end_time: + continue + elif now < start_time: + lesson = lesson.copy() + end_datetime = date.replace(hour=start_time.hour, minute=start_time.minute) + lesson["end"] = end_time + lesson["start"] = start_time + lesson["start_datetime"] = end_datetime + return lesson + + def absolute_next_lesson(self, date: datetime = None) -> dict: + # this function wastes so many CPU cycles. + # Its also so computationally expensive that its async and cast to a thread to avoid blocking. + # Why do I not just do this the smart way? I'm lazy and have a headache. + lesson = None + date = date or datetime.now() + while lesson is None: + lesson = self.next_lesson(date) + if lesson is None: + date += timedelta(minutes=5) + else: + break + return lesson + + async def update_timetable_message( + self, + message: Union[discord.Message, discord.Interaction], + date: datetime = None, + *, + no_prefix: bool = False, + ): + date = date or datetime.now() + lesson = self.current_lesson(date) + if not lesson: + next_lesson = self.next_lesson(date) + if not next_lesson: + next_lesson = await asyncio.to_thread( + self.absolute_next_lesson + ) + text = "[tt] No more lessons today!\n" \ + f"[tt] Next Lesson: {next_lesson['name']!r} with {next_lesson['tutor']} in " \ + f"{next_lesson['room']} - " \ + f"Starts {discord.utils.format_dt(next_lesson['start_datetime'], 'R')}" + + else: + text = f"[tt] Next Lesson: {next_lesson['name']!r} with {next_lesson['tutor']} in " \ + f"{next_lesson['room']} - Starts {discord.utils.format_dt(next_lesson['start_datetime'], 'R')}" + else: + text = f"[tt] Current Lesson: {lesson['name']!r} with {lesson['tutor']} in {lesson['room']} - " \ + f"ends {discord.utils.format_dt(lesson['end_datetime'], 'R')}" + + if no_prefix: + text = text.replace("[tt] ", "") + await message.edit(content=text, allowed_mentions=discord.AllowedMentions.none()) + + # noinspection DuplicatedCode + @tasks.loop(time=schedule_times()) + async def update_status(self): + if not self.bot.is_ready(): + await self.bot.wait_until_ready() + guild: discord.Guild = self.bot.get_guild(994710566612500550) + channel = discord.utils.get(guild.text_channels, name="timetable") + channel = channel or discord.utils.get(guild.text_channels, name="general") + channel: discord.TextChannel + + async for _message in channel.history(limit=20, oldest_first=False): + if _message.author == self.bot.user and _message.content.startswith("[tt]"): + message = _message + break + else: + message = await channel.send("[tt] (loading)") + + message: discord.Message + await self.update_timetable_message(message) + + @commands.slash_command() + async def lesson(self, ctx: discord.ApplicationContext, *, date: str = None): + """Shows the current/next lesson.""" + if date: + try: + date = datetime.strptime(date, "%d/%m/%Y %H:%M") + except ValueError: + return await ctx.respond("Invalid date (DD/MM/YYYY HH:MM).") + else: + date = datetime.now() + await ctx.defer() + await self.update_timetable_message(ctx.interaction, date, no_prefix=True) + + @commands.slash_command(name="timetable") + async def _timetable(self, ctx: discord.ApplicationContext, date: str = None): + """Shows the timetable for today/the specified date""" + if date: + try: + date = datetime.strptime(date, "%d/%m/%Y") + except ValueError: + return await ctx.respond("Invalid date (DD/MM/YYYY).") + else: + date = datetime.now() + + lessons = self.timetable[date.strftime("%A").lower()] + if not lessons: + return await ctx.respond(f"No lessons on {discord.utils.format_dt(date, 'D')}.") + + blocks = [f"```\nTimetable for {date.strftime('%A')}:\n```"] + for lesson in lessons: + start_datetime = date.replace(hour=lesson["start"][0], minute=lesson["start"][1]) + end_datetime = date.replace(hour=lesson["end"][0], minute=lesson["end"][1]) + text = f"{discord.utils.format_dt(start_datetime, 't')} to {discord.utils.format_dt(end_datetime, 't')}" \ + f":\n> Lesson Name: {lesson['name']!r}" \ + f"> Tutor: **{lesson['tutor']}**\n> Room: `{lesson['room']}`" + blocks.append(text) + await ctx.respond("\n\n".join(blocks)) + + +def setup(bot): + bot.add_cog(TimeTableCog(bot)) diff --git a/main.py b/main.py index 22f9258..be00e29 100644 --- a/main.py +++ b/main.py @@ -11,7 +11,15 @@ bot = commands.Bot( intents=discord.Intents.default() + discord.Intents.members ) -for ext in ["jishaku", "cogs.verify", "cogs.mod", "cogs.events", "cogs.assignments"]: +extensions = [ + "jishaku", + "cogs.verify", + "cogs.mod", + "cogs.events", + "cogs.assignments", + "cogs.timetable" +] +for ext in extensions: bot.load_extension(ext) console.log(f"Loaded extension [green]{ext}") bot.loop.run_until_complete(registry.create_all()) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_timetable.py b/tests/test_timetable.py new file mode 100644 index 0000000..730955a --- /dev/null +++ b/tests/test_timetable.py @@ -0,0 +1,100 @@ +import pytest +from pathlib import Path +from utils import Tutors +from typing import Union +import warnings +import json + +file = (Path(__file__).parent.parent / "utils" / "timetable.json").resolve() + + +def is_sane_time(time: list[int, int]) -> Union[bool, AssertionError]: + def inner(): + hour, minute = time + assert hour >= 9, "We aren't in college before 9am" + assert hour <= 5, "We aren't in college after 5pm" + assert minute in range(0, 60), "Invalid minute range - must be between (inclusive) 0 & 59" + if minute % 15 != 0: + warnings.warn( + UserWarning( + "Time '%s:%s' is probably not a valid timetable time, as lessons are every 15 minutes." + ) + ) + return True + try: + return inner() + except AssertionError as e: + return e + + +def get_lesson_times() -> list[list[int, int]]: + data = json.loads(file.read_text()) + master = [] + for day, lessons in data.items(): + for lesson in lessons: + master.append(lesson["start"]) + master.append(lesson["end"]) + return master + + +@pytest.mark.dependency() +def test_exists(): + assert file.exists() + assert file.is_file() + + +@pytest.mark.dependency(depends=["test_exists"]) +def test_can_read(): + try: + file.read_text() + except (IOError, OSError): + assert 0, "Unable to read file." + + +@pytest.mark.dependency(depends=["test_exists", "test_can_read"]) +def test_valid_json(): + try: + data = json.loads(file.read_text()) + except (IOError, OSError, json.JSONDecodeError): + data = None + assert data is not None + + +@pytest.mark.dependency(depends=["test_exists", "test_can_read", "test_valid_json"]) +def test_valid_structure(): + data = json.loads(file.read_text()) + assert isinstance(data, dict) + assert len(data) == 5, "insufficient days" + for key, value in data.items(): + assert isinstance(key, str) + assert key.islower() + assert isinstance(value, list) + for entry in value: + assert isinstance(entry, dict) + required_keys = {"name": 0, "start": 0, "end": 0, "tutor": 0, "room": 0} + for entry_key, entry_value in entry.items(): + assert isinstance(entry_key, str) + assert entry_key in required_keys, f"unknown key {entry_key!r}" + required_keys[entry_key] = 1 + if isinstance(entry_value, list): + assert len(entry_value) == 2 + assert [isinstance(x, int) for x in entry_value] + else: + assert isinstance(entry_value, str) + + assert all(required_keys[k] for k in required_keys.keys()) + + +@pytest.mark.dependency(depends=["test_exists", "test_can_read", "test_valid_json", "test_valid_structure"]) +@pytest.mark.parametrize("time", get_lesson_times()) +def test_sane_times(time: list[int, int]): + assert is_sane_time(time), "insane lesson time: %s:%s" % time + + +@pytest.mark.dependency(depends=["test_exists", "test_can_read", "test_valid_json"]) +def test_sane_tutors(): + data = json.loads(file.read_text()) + for day, lessons in data.items(): + for lesson in lessons: + exists = getattr(Tutors, lesson["tutor"].upper(), None) is not None + assert exists, "Tutor %s is invalid" % lesson["tutor"] diff --git a/utils/timetable.json b/utils/timetable.json new file mode 100644 index 0000000..2ab2695 --- /dev/null +++ b/utils/timetable.json @@ -0,0 +1,123 @@ +{ + "monday": [ + { + "name": "Unit 1, 2 & 3 (PCE)", + "start": [9, 0], + "end": [10, 15], + "tutor": "Jay", + "room": "A113" + }, + { + "name": "Unit 10 & 11", + "start": [10, 30], + "end": [11, 45], + "tutor": "Zach", + "room": "A145" + } + ], + "tuesday": [ + { + "name": "Unit 5", + "start": [9, 0], + "end": [10, 15], + "tutor": "Zach", + "room": "A229" + }, + { + "name": "1:1", + "start": [10, 30], + "end": [11, 45], + "tutor": "Other", + "room": "A113" + }, + { + "name": "Unit 7, 9 & 12", + "start": [11, 45], + "end": [13, 0], + "tutor": "Ian", + "room": "A145" + }, + { + "name": "Unit 1, 2, 6 & 8", + "start": [15, 30], + "end": [16, 45], + "tutor": "Rebecca", + "room": "A047" + } + ], + "wednesday": [], + "thursday": [ + { + "name": "Employer Set Project", + "start": [9, 0], + "end": [10, 15], + "tutor": "Zach", + "room": "A145" + }, + { + "name": "Unit 3 & 4", + "start": [10, 30], + "end": [11, 45], + "tutor": "Lupupa", + "room": "A145" + }, + { + "name": "Unit 1, 2, 6 & 8", + "start": [11, 45], + "end": [13, 0], + "tutor": "Rebecca", + "room": "A145" + }, + { + "name": "Unit 3 & 4", + "start": [14, 0], + "end": [15, 15], + "tutor": "Lupupa", + "room": "A145" + }, + { + "name": "Employer Readiness", + "start": [15, 30], + "end": [16, 45], + "tutor": "Other", + "room": "A227" + } + ], + "friday": [ + { + "name": "Unit 5", + "start": [9, 0], + "end": [10, 15], + "tutor": "Zach", + "room": "A145" + }, + { + "name": "IBM Badges", + "start": [10, 30], + "end": [11, 45], + "tutor": "Other", + "room": "A145" + }, + { + "name": "1, 2, 6 & 8", + "start": [11, 45], + "end": [13, 0], + "tutor": "Rebecca", + "room": "A145" + }, + { + "name": "Group Tutorial", + "start": [14, 0], + "end": [15, 15], + "tutor": "Other", + "room": "Oracle" + }, + { + "name": "Unit 1, 2 & 3 (PCE)", + "start": [15, 30], + "end": [16, 45], + "tutor": "Jay", + "room": "A126" + } + ] +}