mirror of
https://github.com/nexy7574/LCC-bot.git
synced 2024-09-19 18:16:34 +01:00
Add assignments system
This commit is contained in:
parent
132458e080
commit
647e138f2f
9 changed files with 2177 additions and 13 deletions
17
.idea/dataSources.local.xml
Normal file
17
.idea/dataSources.local.xml
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="dataSourceStorageLocal" created-in="PY-222.4167.33">
|
||||||
|
<data-source name="main" uuid="28efee07-d306-4126-bf69-01008b4887e2">
|
||||||
|
<database-info product="SQLite" version="3.39.2" jdbc-version="2.1" driver-name="SQLite JDBC" driver-version="3.39.2.0" dbms="SQLITE" exact-version="3.39.2" exact-driver-version="3.39">
|
||||||
|
<identifier-quote-string>"</identifier-quote-string>
|
||||||
|
</database-info>
|
||||||
|
<case-sensitivity plain-identifiers="mixed" quoted-identifiers="mixed" />
|
||||||
|
<auth-provider>no-auth</auth-provider>
|
||||||
|
<schema-mapping>
|
||||||
|
<introspection-scope>
|
||||||
|
<node kind="schema" qname="@" />
|
||||||
|
</introspection-scope>
|
||||||
|
</schema-mapping>
|
||||||
|
</data-source>
|
||||||
|
</component>
|
||||||
|
</project>
|
12
.idea/dataSources.xml
Normal file
12
.idea/dataSources.xml
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||||
|
<data-source source="LOCAL" name="main" uuid="28efee07-d306-4126-bf69-01008b4887e2">
|
||||||
|
<driver-ref>sqlite.xerial</driver-ref>
|
||||||
|
<synchronize>true</synchronize>
|
||||||
|
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
|
||||||
|
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/main.db</jdbc-url>
|
||||||
|
<working-dir>$ProjectFileDir$</working-dir>
|
||||||
|
</data-source>
|
||||||
|
</component>
|
||||||
|
</project>
|
1458
.idea/dataSources/28efee07-d306-4126-bf69-01008b4887e2.xml
Normal file
1458
.idea/dataSources/28efee07-d306-4126-bf69-01008b4887e2.xml
Normal file
File diff suppressed because it is too large
Load diff
6
.idea/sqldialects.xml
Normal file
6
.idea/sqldialects.xml
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="SqlDialectMappings">
|
||||||
|
<file url="PROJECT" dialect="SQLite" />
|
||||||
|
</component>
|
||||||
|
</project>
|
578
cogs/assignments.py
Normal file
578
cogs/assignments.py
Normal file
|
@ -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 = "</{0.name} view:{0.id}>".format(
|
||||||
|
self.bot.get_application_command(
|
||||||
|
"assignments",
|
||||||
|
type=discord.SlashCommandGroup
|
||||||
|
)
|
||||||
|
)
|
||||||
|
edit_command = "</{0.name} edit:{0.id}>".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"<t:{assignment.created_at:.0f}:R> by <@{assignment.created_by.user_id}>",
|
||||||
|
inline=False
|
||||||
|
)
|
||||||
|
embed.add_field(
|
||||||
|
name="Due:",
|
||||||
|
value=f"<t:{assignment.due_by:.0f}:R> "
|
||||||
|
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))
|
|
@ -16,21 +16,15 @@ class Events(commands.Cog):
|
||||||
|
|
||||||
@commands.Cog.listener("on_raw_reaction_add")
|
@commands.Cog.listener("on_raw_reaction_add")
|
||||||
async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent):
|
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)
|
channel: Optional[discord.TextChannel] = self.bot.get_channel(payload.channel_id)
|
||||||
print("[raw_add]", channel)
|
|
||||||
if channel is not None:
|
if channel is not None:
|
||||||
try:
|
try:
|
||||||
message: discord.Message = await channel.fetch_message(payload.message_id)
|
message: discord.Message = await channel.fetch_message(payload.message_id)
|
||||||
print("[raw_add]", message)
|
|
||||||
except discord.HTTPException:
|
except discord.HTTPException:
|
||||||
return
|
return
|
||||||
if message.author.id == self.bot.user.id:
|
if message.author.id == self.bot.user.id:
|
||||||
print("[raw_add]", message.author)
|
if payload.emoji.name == "\N{wastebasket}\U0000fe0f":
|
||||||
if payload.emoji == discord.PartialEmoji.from_str("\N{wastebasket}"):
|
|
||||||
print("[raw_add]", payload.emoji)
|
|
||||||
await message.delete(delay=1)
|
await message.delete(delay=1)
|
||||||
print("[raw_add] removed")
|
|
||||||
|
|
||||||
@commands.Cog.listener()
|
@commands.Cog.listener()
|
||||||
async def on_member_join(self, member: discord.Member):
|
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)'}"
|
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):
|
def setup(bot):
|
||||||
bot.add_cog(Events(bot))
|
bot.add_cog(Events(bot))
|
||||||
|
|
4
main.py
4
main.py
|
@ -14,6 +14,7 @@ bot.load_extension("jishaku")
|
||||||
bot.load_extension("cogs.verify")
|
bot.load_extension("cogs.verify")
|
||||||
bot.load_extension("cogs.mod")
|
bot.load_extension("cogs.mod")
|
||||||
bot.load_extension("cogs.events")
|
bot.load_extension("cogs.events")
|
||||||
|
bot.load_extension("cogs.assignments")
|
||||||
bot.loop.run_until_complete(registry.create_all())
|
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`.")
|
return await ctx.respond(f"\N{white heavy check mark} Pong! `{gateway}ms`.")
|
||||||
|
|
||||||
|
|
||||||
bot.run(config.token)
|
if __name__ == "__main__":
|
||||||
|
bot.run(config.token)
|
||||||
|
|
|
@ -1,4 +1,52 @@
|
||||||
|
from typing import Optional
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from ._email import *
|
from ._email import *
|
||||||
from .db import *
|
from .db import *
|
||||||
from .console import *
|
from .console import *
|
||||||
from .views 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
|
||||||
|
|
49
utils/db.py
49
utils/db.py
|
@ -1,25 +1,35 @@
|
||||||
import datetime
|
import datetime
|
||||||
import uuid
|
import uuid
|
||||||
from typing import TYPE_CHECKING, Optional, TypeVar
|
from typing import TYPE_CHECKING, Optional, TypeVar
|
||||||
|
from enum import IntEnum, auto
|
||||||
|
|
||||||
import orm
|
import orm
|
||||||
from databases import Database
|
from databases import Database
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
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)
|
os.chdir(Path(__file__).parent.parent)
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"registry",
|
"registry",
|
||||||
"get_or_none"
|
"get_or_none",
|
||||||
]
|
|
||||||
_models = [
|
|
||||||
"VerifyCode",
|
"VerifyCode",
|
||||||
"Student",
|
"Student",
|
||||||
"BannedStudentID"
|
"BannedStudentID",
|
||||||
|
"Assignments",
|
||||||
|
"Tutors"
|
||||||
]
|
]
|
||||||
__all__ += _models
|
|
||||||
|
|
||||||
T = TypeVar('T')
|
T = TypeVar('T')
|
||||||
T_co = TypeVar("T_co", covariant=True)
|
T_co = TypeVar("T_co", covariant=True)
|
||||||
|
@ -80,3 +90,32 @@ class BannedStudentID(orm.Model):
|
||||||
student_id: str
|
student_id: str
|
||||||
associated_account: Optional[int]
|
associated_account: Optional[int]
|
||||||
banned_at_timestamp: float
|
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
|
||||||
|
|
Loading…
Reference in a new issue