mirror of
https://github.com/nexy7574/LCC-bot.git
synced 2024-09-19 18:16:34 +01:00
Add timetable system
This commit is contained in:
parent
d389d0cb30
commit
c684b6f329
6 changed files with 424 additions and 13 deletions
|
@ -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
176
cogs/timetable.py
Normal 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
10
main.py
|
@ -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
0
tests/__init__.py
Normal file
100
tests/test_timetable.py
Normal file
100
tests/test_timetable.py
Normal 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
123
utils/timetable.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
Loading…
Reference in a new issue