Add timetable system

This commit is contained in:
nex 2022-10-14 21:27:37 +01:00
parent d389d0cb30
commit c684b6f329
6 changed files with 424 additions and 13 deletions

View file

@ -78,6 +78,7 @@ class AssignmentsCog(commands.Cog):
async def reminder_loop(self): async def reminder_loop(self):
if not self.bot.is_ready(): if not self.bot.is_ready():
await self.bot.wait_until_ready() await self.bot.wait_until_ready()
try:
view_command = "</{0.name} view:{0.id}>".format( view_command = "</{0.name} view:{0.id}>".format(
self.bot.get_application_command( self.bot.get_application_command(
"assignments", "assignments",
@ -90,6 +91,9 @@ class AssignmentsCog(commands.Cog):
type=discord.SlashCommandGroup 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() allowed_mentions = discord.AllowedMentions(everyone=True) if not config.dev else discord.AllowedMentions.none()
guild = self.bot.get_guild(config.guilds[0]) guild = self.bot.get_guild(config.guilds[0])
general = discord.utils.get(guild.text_channels, name="general") general = discord.utils.get(guild.text_channels, name="general")

176
cogs/timetable.py Normal file
View file

@ -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))

10
main.py
View file

@ -11,7 +11,15 @@ bot = commands.Bot(
intents=discord.Intents.default() + discord.Intents.members 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) bot.load_extension(ext)
console.log(f"Loaded extension [green]{ext}") console.log(f"Loaded extension [green]{ext}")
bot.loop.run_until_complete(registry.create_all()) bot.loop.run_until_complete(registry.create_all())

0
tests/__init__.py Normal file
View file

100
tests/test_timetable.py Normal file
View file

@ -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"]

123
utils/timetable.json Normal file
View file

@ -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"
}
]
}