From 647e138f2fa42cb69ce3e129ad04a43a6100c207 Mon Sep 17 00:00:00 2001 From: EEKIM10 Date: Sun, 9 Oct 2022 19:27:02 +0100 Subject: [PATCH] Add assignments system --- .idea/dataSources.local.xml | 17 + .idea/dataSources.xml | 12 + .../28efee07-d306-4126-bf69-01008b4887e2.xml | 1458 +++++++++++++++++ .idea/sqldialects.xml | 6 + cogs/assignments.py | 578 +++++++ cogs/events.py | 18 +- main.py | 4 +- utils/__init__.py | 48 + utils/db.py | 49 +- 9 files changed, 2177 insertions(+), 13 deletions(-) create mode 100644 .idea/dataSources.local.xml create mode 100644 .idea/dataSources.xml create mode 100644 .idea/dataSources/28efee07-d306-4126-bf69-01008b4887e2.xml create mode 100644 .idea/sqldialects.xml create mode 100644 cogs/assignments.py diff --git a/.idea/dataSources.local.xml b/.idea/dataSources.local.xml new file mode 100644 index 0000000..bf7fc0c --- /dev/null +++ b/.idea/dataSources.local.xml @@ -0,0 +1,17 @@ + + + + + + " + + + no-auth + + + + + + + + \ No newline at end of file diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml new file mode 100644 index 0000000..5157adf --- /dev/null +++ b/.idea/dataSources.xml @@ -0,0 +1,12 @@ + + + + + sqlite.xerial + true + org.sqlite.JDBC + jdbc:sqlite:$PROJECT_DIR$/main.db + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/.idea/dataSources/28efee07-d306-4126-bf69-01008b4887e2.xml b/.idea/dataSources/28efee07-d306-4126-bf69-01008b4887e2.xml new file mode 100644 index 0000000..1b97682 --- /dev/null +++ b/.idea/dataSources/28efee07-d306-4126-bf69-01008b4887e2.xml @@ -0,0 +1,1458 @@ + + + + + 3.39.2 + + + + + + + + + + + + + + + + 1 + + + 1 + + + 1 + + + + + + + + + + + window + + + 1 + + + + + 1 + 1 + + + + + 1 + 1 + + + + + + + window + + + window + + + + + + 1 + 1 + + + 1 + 1 + + + + window + + + + + window + + + + 1 + 1 + + + + + + + 1 + + + window + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + 1 + + + 1 + + + 1 + 1 + + + 1 + window + + + 1 + window + + + 1 + 1 + + + 1 + 1 + + + 1 + + + 1 + + + 1 + 1 + + + 1 + 1 + + + 1 + 1 + + + 1 + + + 1 + + + 1 + 1 + + + window + + + window + + + + window + + + window + + + window + + + + 1 + + + 1 + + + 1 + + + 1 + + + + + + 1 + + + aggregate + + + 1 + + + + + + 1 + 1 + + + window + + + aggregate + + + 1 + 1 + + + window + + + aggregate + + + window + + + window + + + 1 + + + + + + + + window + + + + + 1 + 1 + + + + 1 + + + + + + window + + + 1 + + + 1 + + + + + + 1 + + + window + + + 1 + + + + + 1 + + + 1 + + + + + 1 + + + + + 1 + + + + + + + aggregate + + + + 1 + 1 + + + 1 + + + 1 + + + 1 + + + window + + + + + 1 + 1 + + + window + + + + 1 + + + 1 + + + 1 + + + 1 + 1 + + + 1 + + + 1 + + + aggregate + + + aggregate + + + 1 + + + 1 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + R + + + 1 + + + R + + + R + + + R + + + 1 + + + 2 + + + R + + + 1 + + + 2 + + + 3 + + + R + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + R + + + R + + + R + + + R + + + R + + + R + + + 1 + + + R + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + R + + + 1 + + + 2 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + R + + + 1 + + + 2 + + + R + + + 1 + + + 2 + + + 3 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + R + + + 1 + + + 2 + + + R + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + R + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + R + + + R + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + R + + + 1 + + + 2 + + + R + + + 1 + + + 2 + + + 3 + + + R + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + 2 + + + 3 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + 1 + + + 2 + + + 3 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + R + + + 1 + + + R + + + 1 + + + R + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + 2 + + + R + + + R + + + R + + + 1 + + + 2 + + + R + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + R + + + 1 + + + R + + + R + + + R + + + 1 + + + 2 + + + 3 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + 2 + + + R + + + R + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + R + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + R + + + 1 + + + 2 + + + 3 + + + R + + + 1 + + + 2 + + + 3 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + R + + + 1 + + + R + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + 1 + + + R + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + +
+
+ 1 +
+ + + CHAR(32)|0s + 1 + 1 + + + VARCHAR(7)|0s + 1 + 2 + + + BIGINT|0s + 1 + 3 + + + FLOAT|0s + 1 + 4 + + + entry_id + 1 + 1 + + + student_id + 1 + 1 + + + entry_id + 1 + sqlite_autoindex_banned_1 + + + student_id + sqlite_autoindex_banned_2 + + + INTEGER|0s + 1 + 1 + + + VARCHAR(64)|0s + 1 + 2 + + + BIGINT|0s + 1 + 3 + + + VARCHAR(7)|0s + 1 + 4 + + + code + 1 + 1 + + + id + 1 + + + code + sqlite_autoindex_codes_1 + + + TEXT|0s + 1 + + + TEXT|0s + 2 + + + TEXT|0s + 3 + + + INT|0s + 4 + + + TEXT|0s + 5 + + + CHAR(32)|0s + 1 + 1 + + + VARCHAR(7)|0s + 1 + 2 + + + BIGINT|0s + 1 + 3 + + + entry_id + 1 + 1 + + + id + 1 + 1 + + + user_id + 1 + 1 + + + entry_id + 1 + sqlite_autoindex_students_1 + + + id + sqlite_autoindex_students_2 + + + user_id + sqlite_autoindex_students_3 + + + \ No newline at end of file diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml new file mode 100644 index 0000000..c0e01ca --- /dev/null +++ b/.idea/sqldialects.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/cogs/assignments.py b/cogs/assignments.py new file mode 100644 index 0000000..ee1948c --- /dev/null +++ b/cogs/assignments.py @@ -0,0 +1,578 @@ +import datetime +import sqlite3 +import textwrap +from typing import Optional + +import discord +from discord.ext import commands, tasks +import config +from utils import Assignments, Tutors, simple_embed_paginator, get_or_none, Student, hyperlink + + +TUTOR_OPTION = discord.Option( + str, + "The tutor who assigned the project", + default=None, + choices=[x.title() for x in dir(Tutors) if not x.startswith("__") and x not in ("name", "value")] +) +BOOL_EMOJI = { + True: "\N{white heavy check mark}", + False: "\N{cross mark}" +} + + +class TutorSelector(discord.ui.View): + value: Optional[Tutors] = None + + @discord.ui.select( + placeholder="Select a tutor name", + options=[ + discord.SelectOption( + label=x.title(), + value=x.upper() + ) + for x in [y.name for y in TUTOR_OPTION.choices] + ] + ) + async def select_tutor(self, select: discord.ui.Select, interaction2: discord.Interaction): + await interaction2.response.defer(invisible=True) + self.value = getattr(Tutors, select.values[0].upper()) + self.stop() + + +async def assignment_autocomplete(ctx: discord.AutocompleteContext) -> list[str]: + if not ctx.value: + results: list[Assignments] = await Assignments.objects.order_by("-due_by").limit(7).all() + else: + results: list[Assignments] = await Assignments.objects.filter( + title__icontains=ctx.value + ).limit(30).order_by("-entry_id").all() + return [ + textwrap.shorten(f"{x.entry_id}: {x.title}", 100, placeholder="...") + for x in results + ] + + +# noinspection DuplicatedCode +class AssignmentsCog(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + self.reminder_loop.start() + + def cog_unload(self): + self.reminder_loop.stop() + + @tasks.loop(minutes=10) + 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 + ) + ) + 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") + if not general.can_send(): + return + + msg_format = "@everyone {reminder_name} reminder for project {project_title} for **{project_tutor}**!\n" \ + "Run '%s {project_title}' to view information on the assignment.\n" \ + "*You can mark this assignment as complete with '%s {project_title}', which will prevent" \ + " further reminders.*" % (view_command, edit_command) + + now = datetime.datetime.now() + assignments: list[Assignments] = await Assignments.objects.filter(submitted=False).all() + for assignment in assignments: + due = datetime.datetime.fromtimestamp(assignment.due_by) + for reminder_name, reminder_time in config.reminders.items(): + if reminder_name in assignment.reminders: + # already sent + continue + elif reminder_time != 3600 * 3 and assignment.finished is True: + continue + elif isinstance(reminder_time, int) and reminder_time >= (assignment.due_by - assignment.created_at): + await assignment.update(reminders=assignment.reminders + [reminder_name]) + else: + if isinstance(reminder_time, datetime.time): + if now.date() == due.date: + if now.time().hour == reminder_time.hour: + try: + await general.send( + msg_format.format( + reminder_name=reminder_name, + project_title=textwrap.shorten(assignment.title, 100, placeholder="..."), + project_tutor=assignment.tutor.name.title() + ), + allowed_mentions=allowed_mentions + ) + except discord.HTTPException: + pass + else: + await assignment.update(reminders=assignment.reminders + [reminder_name]) + else: + time = due - datetime.timedelta(seconds=reminder_time) + if time <= now: + try: + await general.send( + msg_format.format( + reminder_name=reminder_name, + project_title=textwrap.shorten(assignment.title, 100, placeholder="..."), + project_tutor=assignment.tutor.name.title() + ), + allowed_mentions=allowed_mentions + ) + except discord.HTTPException: + pass + else: + await assignment.update(reminders=assignment.reminders + [reminder_name]) + + @staticmethod + def generate_assignment_embed(assignment: Assignments) -> discord.Embed: + embed = discord.Embed( + title=f"Assignment #{assignment.entry_id}", + description=f"**Title:**\n>>> {assignment.title}", + colour=discord.Colour.random() + ) + + if assignment.classroom: + classroom = hyperlink(assignment.classroom, max_length=1024) + else: + classroom = "No classroom link." + + if assignment.shared_doc: + shared_doc = hyperlink(assignment.shared_doc, max_length=1024) + else: + shared_doc = "No shared document." + + embed.add_field(name="Classroom URL:", value=classroom, inline=False), + embed.add_field(name="Shared Document URL:", value=shared_doc) + embed.add_field(name="Tutor:", value=assignment.tutor.name.title(), inline=False) + embed.add_field( + name="Created:", + value=f" by <@{assignment.created_by.user_id}>", + inline=False + ) + embed.add_field( + name="Due:", + value=f" " + f"(finished: {BOOL_EMOJI[assignment.finished]} | Submitted: {BOOL_EMOJI[assignment.submitted]})", + inline=False + ) + if assignment.reminders: + embed.set_footer(text="Reminders sent: " + ", ".join(assignment.reminders)) + return embed + + assignments_command = discord.SlashCommandGroup( + "assignments", + "Assignment/project management", + guild_only=True + ) + + @assignments_command.command(name="list") + async def list_assignments( + self, + ctx: discord.ApplicationContext, + limit: int = 20, + upcoming_only: bool = False, + tutor_name: TUTOR_OPTION = None, + unfinished_only: bool = False, + unsubmitted_only: bool = False + ): + """Lists assignments.""" + tutor_name: Optional[str] + query = Assignments.objects.limit(limit).order_by("-due_by") + if upcoming_only is True: + now = datetime.datetime.now().timestamp() + query = query.filter(due_by__gte=now) + + if tutor_name is not None: + query = query.filter(tutor=getattr(Tutors, tutor_name.upper())) + + if unfinished_only is True: + query = query.filter(finished=False) + + if unsubmitted_only: + query = query.filter(submitted=False) + + await ctx.defer() + lines = [] + for assignment in await query.all(): + assignment: Assignments + due_by = datetime.datetime.fromtimestamp(assignment.due_by) + lines.append( + f"#{assignment.entry_id!s}: Set by **{assignment.tutor.name.title()}**, " + f"due {discord.utils.format_dt(due_by, 'R')}" + ) + + embeds = simple_embed_paginator(lines, assert_ten=True, colour=ctx.author.colour) + embeds = embeds or [ + discord.Embed(description="No projects match the provided criteria.") + ] + + return await ctx.respond( + embeds=embeds + ) + + @assignments_command.command(name="add") + async def create_assignment(self, ctx: discord.ApplicationContext): + """Adds/creates an assignment.""" + author = await get_or_none(Student, user_id=ctx.author.id) + if author is None: + return await ctx.respond("\N{cross mark} You must have verified to use this command.", ephemeral=True) + + class AddModal(discord.ui.Modal): + def __init__(self, kwargs: dict = None): + self.msg: Optional[discord.WebhookMessage] = None + self.create_kwargs = kwargs or { + "created_by": author, + "title": None, + "classroom": None, + "shared_doc": None, + "due_by": None, + "tutor": None + } + super().__init__( + discord.ui.InputText( + custom_id="title", + label="Assignment Title", + min_length=2, + max_length=2000, + value=self.create_kwargs["title"] + ), + discord.ui.InputText( + custom_id="classroom", + label="Google Classroom Link", + max_length=4000, + required=False, + placeholder="Optional, can be added later.", + value=self.create_kwargs["classroom"] + ), + discord.ui.InputText( + custom_id="shared_doc", + label="Shared Document Link", + max_length=4000, + required=False, + placeholder="Google docs, slides, powerpoint, etc. Optional.", + value=self.create_kwargs["shared_doc"] + ), + discord.ui.InputText( + custom_id="due_by", + label="Due by", + max_length=16, + min_length=14, + placeholder="dd/mm/yy hh:mm".upper(), + value=( + self.create_kwargs["due_by"].strftime("%d/%m/%y %H:%M") + if self.create_kwargs["due_by"] else None + ) + ), + title="Add an assignment", + timeout=300 + ) + + async def callback(self, interaction: discord.Interaction): + await interaction.response.defer() + self.create_kwargs["title"] = self.children[0].value + self.create_kwargs["classroom"] = self.children[1].value or None + self.create_kwargs["shared_doc"] = self.children[2].value or None + try: + self.create_kwargs["due_by"] = datetime.datetime.strptime( + self.children[3].value, + "%d/%m/%y %H:%M" if len(self.children[3].value) == 14 else "%d/%m/%Y %H:%M" + ) + except ValueError: + class TryAgainView(discord.ui.View): + def __init__(self, kw): + self._mod = None + self.kw = kw + super().__init__(timeout=330) + + @property + def modal(self) -> Optional[AddModal]: + return self._mod + + @discord.ui.button(label="Try again", style=discord.ButtonStyle.primary) + async def try_again(self, _, interaction2: discord.Interaction): + self.disable_all_items() + self._mod = AddModal(self.kw) + await interaction2.response.send_modal(self._mod) + await interaction2.edit_original_response(view=self) + await self._mod.wait() + self.stop() + + v = TryAgainView(self.create_kwargs) + msg = await interaction.followup.send( + "\N{cross mark} Failed to parse date - try again?", + view=v + ) + await v.wait() + if v.modal: + self.create_kwargs = v.modal.create_kwargs + else: + return + else: + view = TutorSelector() + msg = await interaction.followup.send( + "Which tutor assigned this project?", + view=view + ) + await view.wait() + self.create_kwargs["tutor"] = view.value + + self.msg = msg + self.stop() + + modal = AddModal() + await ctx.send_modal(modal) + await modal.wait() + if not modal.msg: + return + await modal.msg.edit( + content="Creating assignment...", + view=None + ) + try: + modal.create_kwargs["due_by"] = modal.create_kwargs["due_by"].timestamp() + await Assignments.objects.create(**modal.create_kwargs) + except sqlite3.Error as e: + return await modal.msg.edit(content="SQL Error: %s.\nAssignment not saved." % e) + else: + return await modal.msg.edit( + content=f"\N{white heavy check mark} Created assignment!" + ) + + @assignments_command.command(name="view") + async def get_assignment( + self, + ctx: discord.ApplicationContext, + title: discord.Option(str, autocomplete=assignment_autocomplete) + ): + """Views an assignment's details""" + try: + entry_id, *_ = title.split(":") + except ValueError: + return await ctx.respond("\N{cross mark} Invalid Input.") + assignment: Assignments = await get_or_none(Assignments, entry_id=int(entry_id)) + if not assignment: + return await ctx.respond("\N{cross mark} Unknown assignment.") + await assignment.created_by.load() + return await ctx.respond(embed=self.generate_assignment_embed(assignment)) + + @assignments_command.command(name="edit") + async def edit_assignment( + self, + ctx: discord.ApplicationContext, + title: discord.Option(str, autocomplete=assignment_autocomplete) + ): + """Edits an assignment""" + try: + entry_id, *_ = title.split(":") + except ValueError: + return await ctx.respond("\N{cross mark} Invalid Input.") + assignment: Assignments = await get_or_none(Assignments, entry_id=int(entry_id)) + if not assignment: + return await ctx.respond("\N{cross mark} Unknown assignment.") + await assignment.created_by.load() + + class EditAssignmentView(discord.ui.View): + def __init__(self): + super().__init__(timeout=300) + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + return interaction.user == ctx.author + + async def on_timeout(self) -> None: + await self.message.delete(delay=0.1) + + @discord.ui.button(label="Update title") + async def update_title(self, _, interaction: discord.Interaction): + class UpdateTitleModal(discord.ui.Modal): + def __init__(self): + super().__init__( + discord.ui.InputText( + style=discord.InputTextStyle.long, + label="New title", + value=assignment.title, + min_length=2, + max_length=4000, + ), + title="Update assignment title" + ) + + async def callback(self, _interaction: discord.Interaction): + await _interaction.response.defer() + await assignment.update(title=self.children[0].value) + await _interaction.followup.send( + "\N{white heavy check mark} Changed assignment title!", + delete_after=5 + ) + self.stop() + + modal = UpdateTitleModal() + await interaction.response.send_modal(modal) + await modal.wait() + + @discord.ui.button(label="Update classroom URL") + async def update_classroom_url(self, _, interaction: discord.Interaction): + class UpdateClassroomURL(discord.ui.Modal): + def __init__(self): + super().__init__( + discord.ui.InputText( + style=discord.InputTextStyle.long, + label="New Classroom URL", + value=assignment.classroom, + required=False, + max_length=4000, + ), + title="Update Classroom url" + ) + + async def callback(self, _interaction: discord.Interaction): + await _interaction.response.defer() + try: + await assignment.update(classroom=self.children[0].value) + await _interaction.followup.send( + "\N{white heavy check mark} Changed classroom URL!", + delete_after=5 + ) + except sqlite3.Error: + await _interaction.followup.send( + "\N{cross mark} Failed to apply changes - are you sure you put a valid URL in?" + ) + finally: + self.stop() + + modal = UpdateClassroomURL() + await interaction.response.send_modal(modal) + await modal.wait() + + @discord.ui.button(label="Update shared document url") + async def update_shared_document_url(self, _, interaction: discord.Interaction): + class UpdateSharedDocumentModal(discord.ui.Modal): + def __init__(self): + super().__init__( + discord.ui.InputText( + style=discord.InputTextStyle.long, + label="New shared document URL", + value=assignment.shared_doc, + required=False, + max_length=4000, + ), + title="Update shared document url" + ) + + async def callback(self, _interaction: discord.Interaction): + await _interaction.response.defer() + try: + await assignment.update(shared_doc=self.children[0].value) + await _interaction.followup.send( + "\N{white heavy check mark} Changed shared doc URL!", + delete_after=5 + ) + except sqlite3.Error: + await _interaction.followup.send( + "\N{cross mark} Failed to apply changes - are you sure you put a valid URL in?" + ) + finally: + self.stop() + + modal = UpdateSharedDocumentModal() + await interaction.response.send_modal(modal) + await modal.wait() + + @discord.ui.button(label="Update tutor") + async def update_tutor(self, _, interaction: discord.Interaction): + await interaction.response.defer() + view = TutorSelector() + msg: discord.WebhookMessage = await interaction.followup.send( + "Which tutor assigned this project?", + view=view + ) + await view.wait() + await assignment.update(tutor=view.value) + await msg.edit( + content=f"\N{white heavy check mark} Changed tutor to {view.value.name.title()}", + view=None + ) + await msg.delete(delay=5) + + @discord.ui.button(label="Update due date", disabled=True) + async def update_due(self, _, interaction: discord.Interaction): + pass + + @discord.ui.button(label="Mark as [in]complete") + async def mark_as_complete(self, _, interaction: discord.Interaction): + await interaction.response.defer() + if assignment.submitted is True and assignment.submitted is True: + return await interaction.followup.send( + "\N{cross mark} You cannot mark an assignment as incomplete if it is marked as submitted!" + ) + await assignment.update(finished=not assignment.finished) + return await interaction.followup.send( + "\N{white heavy check mark} Assignment is now marked as {}complete.".format( + "in" if assignment.finished is False else "" + ) + ) + + @discord.ui.button(label="Mark as [un]submitted") + async def mark_as_submitted(self, _, interaction: discord.Interaction): + await interaction.response.defer() + if assignment.finished is False and assignment.submitted is False: + return await interaction.followup.send( + "\N{cross mark} You cannot mark an assignment as submitted if it is not marked as complete!", + delete_after=10 + ) + await assignment.update(submitted=not assignment.submitted) + return await interaction.followup.send( + "\N{white heavy check mark} Assignment is now marked as {}submitted.".format( + "in" if assignment.submitted is False else "" + ), + delete_after=5 + ) + + @discord.ui.button(label="Save & Exit") + async def finish(self, _, interaction: discord.Interaction): + await interaction.response.defer() + await interaction.delete_original_response(delay=0.1) + self.stop() + + @discord.ui.button(label="View details") + async def view_details(self, _, interaction: discord.Interaction): + await interaction.response.defer(ephemeral=True) + await assignment.created_by.load() + await interaction.followup.send( + embed=AssignmentsCog.generate_assignment_embed(assignment), + ephemeral=True + ) + + await ctx.respond(view=EditAssignmentView()) + + @assignments_command.command(name="remove") + async def remove_assignment( + self, + ctx: discord.ApplicationContext, + title: discord.Option(str, autocomplete=assignment_autocomplete) + ): + """Edits an assignment""" + try: + entry_id, *_ = title.split(":") + except ValueError: + return await ctx.respond("\N{cross mark} Invalid Input.") + assignment: Assignments = await get_or_none(Assignments, entry_id=int(entry_id)) + if not assignment: + return await ctx.respond("\N{cross mark} Unknown assignment.") + await assignment.delete() + return await ctx.respond(f"\N{white heavy check mark} Deleted assignment #{assignment.entry_id}.") + + +def setup(bot): + bot.add_cog(AssignmentsCog(bot)) diff --git a/cogs/events.py b/cogs/events.py index acd33c0..43e8137 100644 --- a/cogs/events.py +++ b/cogs/events.py @@ -16,21 +16,15 @@ class Events(commands.Cog): @commands.Cog.listener("on_raw_reaction_add") async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent): - print("[raw_add]", payload) channel: Optional[discord.TextChannel] = self.bot.get_channel(payload.channel_id) - print("[raw_add]", channel) if channel is not None: try: message: discord.Message = await channel.fetch_message(payload.message_id) - print("[raw_add]", message) except discord.HTTPException: return if message.author.id == self.bot.user.id: - print("[raw_add]", message.author) - if payload.emoji == discord.PartialEmoji.from_str("\N{wastebasket}"): - print("[raw_add]", payload.emoji) + if payload.emoji.name == "\N{wastebasket}\U0000fe0f": await message.delete(delay=1) - print("[raw_add] removed") @commands.Cog.listener() async def on_member_join(self, member: discord.Member): @@ -61,6 +55,16 @@ class Events(commands.Cog): f"{RTL} {member.mention} {f'({student.id})' if student else '(pending verification)'}" ) + @commands.Cog.listener() + async def on_message(self, message: discord.Message): + if message.channel.name == "pinboard": + try: + await message.pin(reason="Automatic pinboard pinning") + except discord.HTTPException as e: + return await message.reply(f"Failed to auto-pin: {e}", delete_after=10) + elif message.type == discord.MessageType.pins_add: + await message.delete(delay=0.01) + def setup(bot): bot.add_cog(Events(bot)) diff --git a/main.py b/main.py index 455a53a..c2ea873 100644 --- a/main.py +++ b/main.py @@ -14,6 +14,7 @@ bot.load_extension("jishaku") bot.load_extension("cogs.verify") bot.load_extension("cogs.mod") bot.load_extension("cogs.events") +bot.load_extension("cogs.assignments") bot.loop.run_until_complete(registry.create_all()) @@ -29,4 +30,5 @@ async def ping(ctx: discord.ApplicationContext): return await ctx.respond(f"\N{white heavy check mark} Pong! `{gateway}ms`.") -bot.run(config.token) +if __name__ == "__main__": + bot.run(config.token) diff --git a/utils/__init__.py b/utils/__init__.py index 8451b88..de3ac85 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -1,4 +1,52 @@ +from typing import Optional +from urllib.parse import urlparse + from ._email import * from .db import * from .console import * from .views import * + + +def simple_embed_paginator( + lines: list[str], + *, + assert_ten: bool = False, + empty_is_none: bool = True, + **kwargs +) -> Optional[list[discord.Embed]]: + """Paginates x lines into x embeds.""" + if not lines and empty_is_none is True: + return + + kwargs.setdefault("description", "") + embeds = [discord.Embed(**kwargs)] + for line in lines: + embed = embeds[-1] + total_length = len(embed) + description_length = len(embed.description) + if total_length + len(line) > 6000 or description_length + len(line) > 4096: + embed = discord.Embed(**kwargs) + embed.description += line + "\n" + embeds.append(embed) + else: + embed.description += line + "\n" + + if assert_ten: + assert len(embeds) <= 10, "Too many embeds." + return embeds + + +def hyperlink(url: str, *, text: str = None, max_length: int = None) -> str: + if max_length < len(url): + raise ValueError(f"Max length ({max_length}) is too low for provided URL ({len(url)}). Hyperlink impossible.") + + fmt = "[{}]({})" + if text: + fmt = fmt.format(text, url) + else: + parsed = urlparse(url) + fmt = fmt.format(parsed.hostname, url) + + if len(fmt) > max_length: + return url + return fmt diff --git a/utils/db.py b/utils/db.py index cbfaae8..45316db 100644 --- a/utils/db.py +++ b/utils/db.py @@ -1,25 +1,35 @@ import datetime import uuid from typing import TYPE_CHECKING, Optional, TypeVar +from enum import IntEnum, auto import orm from databases import Database import os from pathlib import Path + +class Tutors(IntEnum): + JAY = auto() + ZACH = auto() + IAN = auto() + REBECCA = auto() + LUPUPA = auto() + OTHER = -1 # not auto() because if we add more it could mess things up. + + os.chdir(Path(__file__).parent.parent) __all__ = [ "registry", - "get_or_none" -] -_models = [ + "get_or_none", "VerifyCode", "Student", - "BannedStudentID" + "BannedStudentID", + "Assignments", + "Tutors" ] -__all__ += _models T = TypeVar('T') T_co = TypeVar("T_co", covariant=True) @@ -80,3 +90,32 @@ class BannedStudentID(orm.Model): student_id: str associated_account: Optional[int] banned_at_timestamp: float + + +class Assignments(orm.Model): + registry = registry + fields = { + "entry_id": orm.Integer(primary_key=True, default=None), + "created_by": orm.ForeignKey(Student, allow_null=True), + "title": orm.String(min_length=2, max_length=2000), + "classroom": orm.URL(allow_null=True, default=None, max_length=4096), + "shared_doc": orm.URL(allow_null=True, default=None, max_length=4096), + "created_at": orm.Float(default=lambda: datetime.datetime.now().timestamp()), + "due_by": orm.Float(), + "tutor": orm.Enum(Tutors), + "reminders": orm.JSON(default=[]), + "finished": orm.Boolean(default=False), + "submitted": orm.Boolean(default=False) + } + if TYPE_CHECKING: + entry_id: int + created_by: Student + title: str + classroom: Optional[str] + shared_doc: Optional[str] + created_at: float + due_by: float + tutor: Tutors + reminders: list[str] + finished: bool + submitted: bool