diff --git a/src/anonymous.py b/src/anonymous.py new file mode 100644 index 0000000..c591893 --- /dev/null +++ b/src/anonymous.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Literal + +import discord + +from .email import send_email +from .views import MILBotModal, MILBotView + +if TYPE_CHECKING: + from .bot import MILBot + + +IntendedTargets = Literal["schwartz", "operations", "leaders"] + + +class AnonymousReportModal(MILBotModal): + + report = discord.ui.TextInput( + label="Report", + placeholder="Enter your report here", + style=discord.TextStyle.long, + max_length=2000, + ) + + def __init__(self, bot: MILBot, target: IntendedTargets): + self.bot = bot + self.target = target + super().__init__(title="Submit an Anonymous Report") + + async def on_submit(self, interaction: discord.Interaction): + embed = discord.Embed( + title="New Anonymous Report", + color=discord.Color.light_gray(), + description=self.report.value, + ) + embed.set_footer(text="Submitted by an anonymous user") + if self.target == "schwartz": + html = f"""A new anonymous report has been submitted. The report is as follows: + +
{self.report.value}
+ + Replies to this email will not be received. Please address any concerns with the appropriate leadership team.""" + text = f"""A new anonymous report has been submitted. The report is as follows: + + {self.report.value} + + Replies to this email will not be received. Please address any concerns with the appropriate leadership team.""" + await send_email( + "ems@ufl.edu", + "New Anonymous Report Received", + html, + text, + ) + elif self.target == "operations": + await self.bot.operations_leaders_channel.send(embed=embed) + elif self.target == "leaders": + await self.bot.leaders_channel.send(embed=embed) + await interaction.response.send_message( + "Your report has been submitted. Your input is invaluable, and we are committed to making MIL a better place for everyone. Thank you for helping us improve.", + embed=embed, + ephemeral=True, + ) + + +class AnonymousTargetSelect(discord.ui.Select): + def __init__(self, bot: MILBot): + self.bot = bot + options = [ + discord.SelectOption(label="Dr. Schwartz", emoji="πŸ‘¨β€πŸ«", value="schwartz"), + discord.SelectOption( + label="Operations Leadership", + emoji="πŸ‘·", + value="operations", + ), + discord.SelectOption(label="Leaders", emoji="πŸ‘‘", value="leaders"), + ] + super().__init__( + placeholder="Select the target of your report", + options=options, + ) + + async def callback(self, interaction: discord.Interaction): + target = self.values[0] + await interaction.response.send_modal(AnonymousReportModal(self.bot, target)) # type: ignore + + +class AnonymousReportView(MILBotView): + def __init__(self, bot: MILBot): + self.bot = bot + super().__init__(timeout=None) + + @discord.ui.button( + label="Submit an anonymous report", + style=discord.ButtonStyle.red, + custom_id="anonymous_report:submit", + ) + async def submit_anonymous_report( + self, + interaction: discord.Interaction, + button: discord.ui.Button, + ): + view = MILBotView() + view.add_item(AnonymousTargetSelect(self.bot)) + await interaction.response.send_message( + "Select the target of your report.", + view=view, + ephemeral=True, + ) diff --git a/src/bot.py b/src/bot.py index 2d30057..47bb103 100644 --- a/src/bot.py +++ b/src/bot.py @@ -17,6 +17,7 @@ from google.oauth2.service_account import Credentials from rich.logging import RichHandler +from .anonymous import AnonymousReportView from .calendar import CalendarView from .constants import Team from .env import ( @@ -30,9 +31,10 @@ GUILD_ID, ) from .exceptions import MILBotErrorHandler, ResourceNotFound -from .github import GitHub +from .github import GitHub, GitHubInviteView +from .leaders import AwayView from .projects import SoftwareProjectsView -from .reports import ReportsCog, ReportsView +from .reports import ReportsCog, ReportsView, StartReviewView from .roles import MechanicalRolesView, SummerRolesView, TeamRolesView from .tasks import TaskManager from .testing import TestingSignUpView @@ -88,10 +90,11 @@ class MILBot(commands.Bot): leaders_channel: discord.TextChannel leave_channel: discord.TextChannel general_channel: discord.TextChannel - reports_channel: discord.TextChannel + member_services_channel: discord.TextChannel errors_channel: discord.TextChannel software_projects_channel: discord.TextChannel software_category_channel: discord.CategoryChannel + operations_leaders_channel: discord.TextChannel # Emojis loading_emoji: str @@ -228,6 +231,10 @@ async def setup_hook(self) -> None: self.add_view(CalendarView(self)) self.add_view(TestingSignUpView(self, "")) self.add_view(SummerRolesView(self)) + self.add_view(AnonymousReportView(self)) + self.add_view(GitHubInviteView(self)) + self.add_view(AwayView(self)) + self.add_view(StartReviewView(self)) agcm = gspread_asyncio.AsyncioGspreadClientManager(get_creds) self.agc = await agcm.authorize() @@ -256,12 +263,12 @@ async def fetch_vars(self) -> None: assert isinstance(general_channel, discord.TextChannel) self.general_channel = general_channel - reports_channel = discord.utils.get( + member_services_channel = discord.utils.get( self.active_guild.text_channels, - name="reports", + name="member-services", ) - assert isinstance(reports_channel, discord.TextChannel) - self.reports_channel = reports_channel + assert isinstance(member_services_channel, discord.TextChannel) + self.member_services_channel = member_services_channel leaders_channel = discord.utils.get( self.active_guild.text_channels, @@ -270,6 +277,13 @@ async def fetch_vars(self) -> None: assert isinstance(leaders_channel, discord.TextChannel) self.leaders_channel = leaders_channel + operations_leaders_channel = discord.utils.get( + self.active_guild.text_channels, + name="operations-leadership", + ) + assert isinstance(operations_leaders_channel, discord.TextChannel) + self.operations_leaders_channel = operations_leaders_channel + errors_channel = discord.utils.get( self.active_guild.text_channels, name="bot-errors", @@ -334,6 +348,13 @@ async def fetch_vars(self) -> None: assert isinstance(alumni_role, discord.Role) self.alumni_role = alumni_role + away_role = discord.utils.get( + self.active_guild.roles, + name="Away from MIL", + ) + assert isinstance(away_role, discord.Role) + self.away_role = away_role + reports_cog = self.get_cog("ReportsCog") if not reports_cog: raise ResourceNotFound("Reports cog not found.") @@ -366,6 +387,17 @@ async def reading_gif(self) -> discord.File: raise ResourceNotFound("Cat gif not found.") return discord.File(BytesIO(await resp.read()), filename="cat.gif") + async def good_job_gif(self) -> discord.File: + gifs = [ + "https://media0.giphy.com/media/v1.Y2lkPTc5MGI3NjExb3BqbnlzaXBmODdxMzRkeHFxYWg1N3NoM3A4czh3aGo2NHhmNGRtYSZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/ktfInKGOVkdQtwJy9h/giphy.gif", + "https://media3.giphy.com/media/v1.Y2lkPTc5MGI3NjExdjRyOGJjY3cxdTUzb2gycXlmcW1lZ2ZsYXh3aGxjaDY1cTNyMzRnNiZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/UjCXeFnYcI2R2/giphy.gif", + "https://media4.giphy.com/media/v1.Y2lkPTc5MGI3NjExMXo1dHdxem04N2M4ZXJhaTlnb25mYTZsMmtjMGFyMWJweDFleG03ZCZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/8pR7lPuRZzTXVtWlTF/giphy.gif", + ] + async with self.session.get(random.choice(gifs)) as resp: + if resp.status != 200: + raise ResourceNotFound("Cat gif not found.") + return discord.File(BytesIO(await resp.read()), filename="cat.gif") + async def on_message(self, message): # don't respond to ourselves if message.author == self.user: diff --git a/src/constants.py b/src/constants.py index 9ddc0ce..3fbf202 100644 --- a/src/constants.py +++ b/src/constants.py @@ -7,26 +7,39 @@ SEMESTERS = [ (datetime.date(2023, 8, 23), datetime.date(2023, 12, 6)), (datetime.date(2024, 1, 8), datetime.date(2024, 4, 28)), - (datetime.date(2024, 5, 13), datetime.date(2024, 8, 9)), + (datetime.date(2024, 5, 20), datetime.date(2024, 7, 29)), ] +SCHWARTZ_EMAIL = "ems@ufl.edu" + + +def semester_given_date( + date: datetime.datetime, + *, + next_semester: bool = False, +) -> tuple[datetime.date, datetime.date] | None: + for semester in SEMESTERS: + if semester[0] <= date.date() <= semester[1]: + return semester + if next_semester and date.date() < semester[0]: + return semester + return None class Team(enum.Enum): SOFTWARE = auto() ELECTRICAL = auto() MECHANICAL = auto() - SYSTEMS = auto() GENERAL = auto() @classmethod def from_str(cls, ss_str: str) -> Team: - if "software" in ss_str.lower(): + if "software" in ss_str.lower() or "S" in ss_str: return cls.SOFTWARE - if "electrical" in ss_str.lower(): + elif "electrical" in ss_str.lower() or "E" in ss_str: return cls.ELECTRICAL - if "mechanical" in ss_str.lower(): + elif "mechanical" in ss_str.lower() or "M" in ss_str: return cls.MECHANICAL - return cls.SYSTEMS + return cls.GENERAL @property def emoji(self) -> str: diff --git a/src/email.py b/src/email.py index bf37aec..269c275 100644 --- a/src/email.py +++ b/src/email.py @@ -1,11 +1,74 @@ +from dataclasses import dataclass from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText +from enum import Enum import aiosmtplib from .env import EMAIL_PASSWORD, EMAIL_USERNAME +class HeaderPrefix(Enum): + FULL = LONG = "[Machine Intelligence Laboratory]" + INITIALS = SHORT = "[MIL]" + + +@dataclass +class Email: + receiver_emails: list[str] + subject: str + html: str + text: str | None = None + cc_emails: list[str] | None = None + header_prefix: HeaderPrefix | None = HeaderPrefix.INITIALS + + async def send(self) -> None: + port = 587 + hostname = "smtp.ufl.edu" + sender_email = EMAIL_USERNAME + password = EMAIL_PASSWORD + if not sender_email or not password: + raise RuntimeError( + "No email username and/or password found! Cannot send email.", + ) + + smtp_server = aiosmtplib.SMTP() + await smtp_server.connect(hostname=hostname, port=port) + await smtp_server.login(sender_email, password) + custom_email = "bot@mil.ufl.edu" + + # Create a multipart message and set headers + message = MIMEMultipart("alternative") + message["From"] = custom_email + message["To"] = ", ".join(self.receiver_emails) + message["Subject"] = ( + f"{self.header_prefix.value} {self.subject}" + if self.header_prefix + else self.subject + ) + + # Turn these into plain/html MIMEText objects + if self.text is None: + # Attempt to convert HTML to text + self.text = ( + self.html.replace("

", " ").replace("

", " ").replace("\n", " ") + ) + + text_footer = " --- This is an automated message. Replies will not be received." + html_footer = "\n\n

---
This is an automated message. Replies will not be received.

" + part1 = MIMEText(self.text + text_footer, "plain") + part2 = MIMEText(self.html + html_footer, "html") + message.attach(part1) + message.attach(part2) + + await smtp_server.sendmail( + custom_email, + self.receiver_emails, + message.as_string(), + ) + await smtp_server.quit() + + async def send_email(receiver_email, subject, html, text) -> bool: port = 587 hostname = "smtp.ufl.edu" diff --git a/src/github.py b/src/github.py index 4d9ce15..990ec9a 100644 --- a/src/github.py +++ b/src/github.py @@ -8,7 +8,6 @@ import aiohttp import discord -from discord import app_commands from discord.ext import commands from .env import GITHUB_TOKEN @@ -23,6 +22,7 @@ SoftwareProject, User, ) +from .views import MILBotModal, MILBotView if TYPE_CHECKING: from .bot import MILBot @@ -31,6 +31,100 @@ logger = logging.getLogger(__name__) +class GitHubUsernameModal(MILBotModal): + + username = discord.ui.TextInput(label="Username") + + def __init__(self, bot: MILBot, org_name: Literal["uf-mil", "uf-mil-electrical"]): + self.bot = bot + self.org_name = org_name + super().__init__(title="GitHub Username") + + async def on_submit(self, interaction: discord.Interaction): + """ + Invite a user to a MIL GitHub organization. + + Args: + username: The username of the user to invite. + org_name: The name of the organization to invite the user to. + """ + username = self.username.value + # Ensure that the specified username is actually a GitHub user, and get + # their user object + try: + user = await self.bot.github.get_user(username) + except aiohttp.ClientResponseError as e: + if e.status == 404: + await interaction.response.send_message( + f"Failed to find user with username {username}.", + ephemeral=True, + ) + raise e + + try: + # If the org is uf-mil, invite to the "Developers" team + if self.org_name == "uf-mil": + team = await self.bot.github.get_team(self.org_name, "developers") + await self.bot.github.invite_user_to_org( + user["id"], + self.org_name, + team["id"], + ) + else: + await self.bot.github.invite_user_to_org(user["id"], self.org_name) + await interaction.response.send_message( + f"Successfully invited {username} to {self.org_name}.", + ephemeral=True, + ) + except aiohttp.ClientResponseError as e: + if e.status == 422: + await interaction.response.send_message( + "Validaton failed, the user might already be in the organization.", + ephemeral=True, + ) + return + except Exception: + await interaction.response.send_message( + f"Failed to invite {username} to {self.org_name}.", + ephemeral=True, + ) + logger.exception( + f"Failed to invite username {username} to {self.org_name}.", + ) + + +class GitHubInviteView(MILBotView): + def __init__(self, bot: MILBot): + self.bot = bot + super().__init__(timeout=None) + + @discord.ui.button( + label="Invite to uf-mil", + style=discord.ButtonStyle.secondary, + custom_id="github_invite:software", + ) + async def invite_to_uf_mil( + self, + interaction: discord.Interaction, + button: discord.ui.Button, + ): + await interaction.response.send_modal(GitHubUsernameModal(self.bot, "uf-mil")) + + @discord.ui.button( + label="Invite to uf-mil-electrical", + style=discord.ButtonStyle.secondary, + custom_id="github_invite:electrical", + ) + async def invite_to_uf_mil_electrical( + self, + interaction: discord.Interaction, + button: discord.ui.Button, + ): + await interaction.response.send_modal( + GitHubUsernameModal(self.bot, "uf-mil-electrical"), + ) + + class GitHub: def __init__(self, *, auth_token: str, bot: MILBot): self.auth_token = auth_token @@ -64,6 +158,7 @@ async def fetch( logger.error( f"Error fetching GitHub url {url}: {await response.json()}", ) + response.raise_for_status() return await response.json() async def get_repo(self, repo_name: str) -> Repository: @@ -385,54 +480,6 @@ async def on_message(self, message: discord.Message): issue = await self.github.get_issue("uf-mil/mil", int(match)) await message.reply(embed=self.get_issue_or_pull(issue)) - @app_commands.command() - @app_commands.guild_only() - @app_commands.default_permissions(manage_messages=True) - async def invite( - self, - interaction: discord.Interaction, - username: str, - org_name: Literal["uf-mil", "uf-mil-electrical"], - ): - """ - Invite a user to a MIL GitHub organization. - - Args: - username: The username of the user to invite. - org_name: The name of the organization to invite the user to. - """ - # Use the Github class object to send an invite to the user - if org_name not in ["uf-mil", "uf-mil-electrical"]: - await interaction.response.send_message("Invalid organization name.") - return - - # Ensure that the specified username is actually a GitHub user, and get - # their user object - try: - user = await self.github.get_user(username) - except aiohttp.ClientResponseError as e: - if e.status == 404: - await interaction.response.send_message( - f"Failed to find user with username {username}.", - ) - raise e - - try: - # If the org is uf-mil, invite to the "Developers" team - if org_name == "uf-mil": - team = await self.github.get_team(org_name, "developers") - await self.github.invite_user_to_org(user["id"], org_name, team["id"]) - else: - await self.github.invite_user_to_org(user["id"], org_name) - await interaction.response.send_message( - f"Successfully invited {username} to {org_name}.", - ) - except Exception: - await interaction.response.send_message( - f"Failed to invite {username} to {org_name}.", - ) - logger.exception(f"Failed to invite username {username} to {org_name}.") - async def setup(bot: MILBot): await bot.add_cog(GitHubCog(bot)) diff --git a/src/leaders.py b/src/leaders.py index 4b6e8b3..6ee42ee 100644 --- a/src/leaders.py +++ b/src/leaders.py @@ -9,9 +9,12 @@ from typing import TYPE_CHECKING import discord +from discord.app_commands import NoPrivateMessage from discord.ext import commands +from .anonymous import AnonymousReportView from .env import LEADERS_MEETING_NOTES_URL, LEADERS_MEETING_URL +from .github import GitHubInviteView from .tasks import run_on_weekday from .utils import is_active from .verification import StartEmailVerificationView @@ -25,6 +28,38 @@ MEETING_DAY = calendar.TUESDAY +class AwayView(MILBotView): + def __init__(self, bot: MILBot): + self.bot = bot + super().__init__(timeout=None) + + @discord.ui.button( + label="Toggle Away", + custom_id="away:toggle", + style=discord.ButtonStyle.primary, + ) + async def toggle_away( + self, + interaction: discord.Interaction, + button: discord.ui.Button, + ): + member = interaction.user + if not isinstance(member, discord.Member): + raise NoPrivateMessage + if self.bot.away_role in member.roles: + await member.remove_roles(self.bot.away_role) + await interaction.response.send_message( + "You are no longer marked as away. Welcome back!", + ephemeral=True, + ) + else: + await member.add_roles(self.bot.away_role) + await interaction.response.send_message( + f"You are now marked as {self.bot.away_role.mention}. Enjoy your break!", + ephemeral=True, + ) + + class Leaders(commands.Cog): def __init__(self, bot: MILBot): self.bot = bot @@ -121,6 +156,28 @@ async def at_reminder(self): view=view, ) + @commands.Cog.listener() + async def on_message(self, message: discord.Message): + # Check that leaders mentioned are not away - if they are, remind the poster + # that they are away. + if message.author.bot: + return + + mentioned = message.mentions + for member in mentioned: + if ( + isinstance(member, discord.Member) + and self.bot.away_role in member.roles + ): + delay_seconds = 15 + delete_at = message.created_at + datetime.timedelta( + seconds=delay_seconds, + ) + await message.reply( + f"{member.mention} is currently away from MIL for a temporary break, and may not respond immediately. Please consider reaching out to another leader or wait for their return. (deleting this message {discord.utils.format_dt(delete_at, 'R')})", + delete_after=delay_seconds, + ) + @commands.command() @commands.is_owner() async def prepverify(self, ctx: commands.Context): @@ -138,6 +195,50 @@ async def prepverify(self, ctx: commands.Context): view=StartEmailVerificationView(self.bot), ) + @commands.command() + @commands.is_owner() + async def prepanonymous(self, ctx: commands.Context): + embed = discord.Embed( + title="File an Anonymous Report", + description="Your voice matters to us. If you have feedback or concerns about your experience at MIL, please feel comfortable using our anonymous reporting tool. By clicking the button below, you can file a report without revealing your identity, ensuring your privacy and safety." + + "\n\n" + + "We treat all submissions with the utmost seriousness and respect. When filing your report, you have the option to select who will receive and review this information. To help us address your concerns most effectively, please provide as much detail as possible in your submission.", + color=discord.Color.from_rgb(249, 141, 139), + ) + embed.set_footer( + text="Our commitment to your privacy is transparentβ€”feel free to review our source code on GitHub.", + ) + view = AnonymousReportView(self.bot) + await ctx.send( + embed=embed, + view=view, + ) + await ctx.message.delete() + + @commands.command() + @commands.is_owner() + async def prepaway(self, ctx: commands.Context): + view = AwayView(self.bot) + embed = discord.Embed( + title="Take a Short Break", + description="As leaders, it's important to take breaks and recharge. If you're planning to take a short break, you can mark yourself as away to let others know. When you're ready to return, you can toggle this status off.\n\nDuring your break, members who ping you will be notified that you're away and unable to assist in MIL efforts until you return. This is a great way to ensure you're not disturbed during your break.\n\nYou can use the button below to toggle your away status on and off. Enjoy your break!", + color=self.bot.away_role.color, + ) + await ctx.send(embed=embed, view=view) + await ctx.message.delete() + + @commands.command() + @commands.is_owner() + async def prepgithub(self, ctx: commands.Context): + embed = discord.Embed( + title="Invite Members to GitHub", + description="Use the following buttons to invite members to the software or electrical GitHub organizations. Please ensure that the member has a GitHub account before inviting them.", + color=discord.Color.light_gray(), + ) + view = GitHubInviteView(self.bot) + await ctx.send(embed=embed, view=view) + await ctx.message.delete() + async def setup(bot: MILBot): await bot.add_cog(Leaders(bot)) diff --git a/src/reports.py b/src/reports.py index 2fe08d8..5b4dabc 100644 --- a/src/reports.py +++ b/src/reports.py @@ -4,7 +4,9 @@ import datetime import itertools import logging +import random from dataclasses import dataclass +from enum import IntEnum from typing import TYPE_CHECKING import discord @@ -12,7 +14,8 @@ import gspread_asyncio from discord.ext import commands -from .constants import Team +from .constants import SCHWARTZ_EMAIL, Team, semester_given_date +from .email import Email from .tasks import run_on_weekday from .utils import is_active from .views import MILBotView @@ -24,11 +27,347 @@ logger = logging.getLogger(__name__) +class Column(IntEnum): + + NAME_COLUMN = 1 + EMAIL_COLUMN = 2 + UFID_COLUMN = 3 + LEADERS_COLUMN = 4 + TEAM_COLUMN = 5 + CREDITS_COLUMN = 6 + DISCORD_NAME_COLUMN = 7 + SCORE_COLUMN = 8 + + +@dataclass +class WeekColumn: + """ + Represents a column for one week of the semester (which is used for storing + student reports and associated scores). + """ + + report_column: int + + @classmethod + def _start_date(cls) -> datetime.date: + semester = semester_given_date(datetime.datetime.now()) + if not semester: + raise RuntimeError("No semester is occurring right now!") + return semester[0] + + @classmethod + def _end_date(cls) -> datetime.date: + semester = semester_given_date(datetime.datetime.now()) + if not semester: + raise RuntimeError("No semester is occurring right now!") + return semester[1] + + def _date_to_index(self, date: datetime.date) -> int: + return (date - self._start_date()).days // 7 + 1 + + @property + def week(self) -> int: + return (self.report_column - len(Column) - 1) // 2 + + @property + def score_column(self) -> int: + return self.report_column + 1 + + @property + def date_range(self) -> tuple[datetime.date, datetime.date]: + """ + Inclusive date range for this column. + """ + start_date = self._start_date() + datetime.timedelta(weeks=self.week) + end_date = start_date + datetime.timedelta(days=6) + return start_date, end_date + + @classmethod + def from_date(cls, date: datetime.date): + col_offset = (date - cls._start_date()).days // 7 + # Each week has two columns: one for the report and one for the score + # +1 because columns are 1-indexed + return cls((col_offset * 2) + 1 + len(Column)) + + @classmethod + def first(cls): + """ + The first week column of the semester. + """ + return cls(len(Column) + 1) + + @classmethod + def last_week(cls): + """ + The previous week. + """ + return cls.from_date(datetime.date.today() - datetime.timedelta(days=7)) + + @classmethod + def current(cls): + """ + The current week of the semester. + """ + return cls.from_date(datetime.date.today()) + + def __post_init__(self): + weeks = (self._end_date() - self._start_date()) // 7 + if ( + self.report_column < len(Column) + 1 + or self.report_column > len(Column) + 1 + weeks.days + ): + raise ValueError( + f"Cannot create report column with index {self.report_column}.", + ) + + +class FiringEmail(Email): + """ + Email to Dr. Schwartz + team lead about needing to fire someone + """ + + def __init__(self, student: Student): + html = f"

Hello,

A student currently in the Machine Intelligence Laboratory needs to be fired for continually failing to submit required weekly reports despite consistent reminders. This member has failed to produce their sufficient workload for at least several weeks, and has received several Discord messages and emails about this.

Name: {student.name}
Team: {student.team}
Discord Username: {student.discord_id}

For more information, please contact the appropriate team leader.

" + super().__init__( + [SCHWARTZ_EMAIL], + "Member Removal Needed", + html, + ) + + +class InsufficientReportEmail(Email): + def __init__(self, student: Student): + html = f"

Hello,

This email is to inform you that your most recent report has been graded as: Insufficient (yellow). As a reminder, you are expected to fulfill your commitment of {student.hours_commitment} hours each week you are in the lab.

While an occasional lapse is understandable, frequent occurrences may result in your removal from the laboratory. If you anticipate any difficulties in completing your future reports, please contact your team lead immediately.

Your current missing report count is: {student.total_score + 1}. Please note that once your count reaches 4, you will be automatically removed from our lab.

" + super().__init__([student.email], "Insufficient Report Notice", html) + + +class PoorReportEmail(Email): + def __init__(self, student: Student): + html = f"

Hello,

This email is to inform you that your most recent report has been graded as: Low/No Work Done (red). As a reminder, you are expected to fulfill your commitment of {student.hours_commitment} hours per week.

While an occasional lapse is understandable, frequent occurrences may result in your removal from the laboratory. If you anticipate any difficulties in completing your future reports, please contact your team lead immediately.

Your current missing report count is: {student.total_score + 1}. Please note that once your count reaches 4, you will be automatically removed from our lab.

" + super().__init__([student.email], "Unsatisfactory Report Notice", html) + + +class SufficientReportEmail(Email): + def __init__(self, student: Student): + html = f"

Hello {student.first_name},

This email is to inform you that your most recent report has been graded as: Sufficient (green). Keep up the good work.

If you have any questions or concerns, please feel free to reach out to your team lead.

Thank you for your hard work!

" + super().__init__([student.email], "Satisfactory Report Notice", html) + + +class ReportReviewButton(discord.ui.Button): + def __init__( + self, + bot: MILBot, + student: Student, + *, + style: discord.ButtonStyle = discord.ButtonStyle.secondary, + label: str | None = None, + emoji: str | None = None, + row: int | None = None, + ): + self.bot = bot + self.student = student + super().__init__(style=style, label=label, emoji=emoji, row=row) + + async def respond_early(self, interaction: discord.Interaction, content: str): + assert self.view is not None + for children in self.view.children: + children.disabled = True + await interaction.response.edit_message(content=content, view=self.view) + + async def log_score(self, score: float) -> None: + """ + Logs the report score to the spreadsheet. + """ + sh = await self.bot.sh.get_worksheet(0) + col = WeekColumn.last_week().score_column + row = self.student.row + await sh.update_cell(row, col, score) + + +class NegativeReportButton(ReportReviewButton): + def __init__(self, bot: MILBot, student: Student): + self.bot = bot + self.student = student + super().__init__( + bot, + student, + label="Little/no work attempted", + emoji="πŸ›‘", + style=discord.ButtonStyle.red, + row=0, + ) + + async def callback(self, interaction: discord.Interaction): + assert self.view is not None + await self.respond_early( + interaction, + f"{self.bot.loading_emoji} Logging score and sending email to student...", + ) + # 1. Log score to spreadsheet + await self.log_score(1) + # Determine action for student based on their current score + new_score = self.student.total_score + 1 + + # Notify necessary people + if new_score > 4: + # Student needs to be fired + email = FiringEmail(self.student) + await self.bot.leaders_channel.send( + f"πŸ”₯ {self.student.name} has been removed from the lab due to excessive missing reports.", + ) + await email.send() + else: + email = PoorReportEmail(self.student) + await email.send() + self.view.stop() + + +class WarningReportButton(ReportReviewButton): + def __init__(self, bot: MILBot, student: Student): + self.bot = bot + self.student = student + yellow_label = f"~{student.hours_commitment // 3 if student.hours_commitment else 0}+ hours of effort" + super().__init__( + bot, + student, + label=yellow_label, + emoji="⚠️", + style=discord.ButtonStyle.secondary, + row=1, + ) + + async def callback(self, interaction: discord.Interaction): + await self.respond_early( + interaction, + f"{self.bot.loading_emoji} Logging score and sending email to student...", + ) + # 1. Log score to spreadsheet + await self.log_score(0.5) + # Determine action for student based on their current score + new_score = self.student.total_score + 0.5 + + # Notify necessary people + if new_score > 4: + # Student needs to be fired + email = FiringEmail(self.student) + await self.bot.leaders_channel.send( + f"πŸ”₯ {self.student.name} has been removed from the lab due to excessive missing reports.", + ) + await email.send() + else: + email = InsufficientReportEmail(self.student) + await email.send() + assert self.view is not None + self.view.stop() + + +class GoodReportButton(ReportReviewButton): + def __init__(self, bot: MILBot, student: Student): + self.bot = bot + self.student = student + green_label = f"~{student.hours_commitment}+ hours of effort" + super().__init__( + bot, + student, + label=green_label, + emoji="βœ…", + style=discord.ButtonStyle.green, + row=2, + ) + + async def callback(self, interaction: discord.Interaction): + await self.respond_early( + interaction, + f"{self.bot.loading_emoji} Logging score and sending email to student...", + ) + # 1. Log score to spreadsheet + await self.log_score(0) + # Send email + email = SufficientReportEmail(self.student) + await email.send() + assert self.view is not None + self.view.stop() + + +class ReportsReviewView(MILBotView): + def __init__(self, bot: MILBot, student: Student): + self.bot = bot + self.student = student + super().__init__() + self.add_item(NegativeReportButton(bot, student)) + self.add_item(WarningReportButton(bot, student)) + self.add_item(GoodReportButton(bot, student)) + + +class StartReviewView(MILBotView): + def __init__(self, bot: MILBot): + self.bot = bot + super().__init__(timeout=None) + + @discord.ui.button( + label="Start Review", + style=discord.ButtonStyle.green, + custom_id="start_review:start", + ) + async def start(self, interaction: discord.Interaction, _: discord.ui.Button): + # We can assume that this button was pressed in a X-leadership channel + await interaction.response.send_message( + f"{self.bot.loading_emoji} Thanks for starting this review! Pulling data...", + ephemeral=True, + ) + if not interaction.channel or isinstance( + interaction.channel, + discord.DMChannel, + ): + raise discord.app_commands.NoPrivateMessage + + team_name = str(interaction.channel.name).removesuffix("-leadership") + team = Team.from_str(team_name) + week = WeekColumn.last_week() + column = week.report_column + students = await self.bot.reports_cog.students_status(column) + students = [s for s in students if s.team == team and s.report_score is None] + if not len(students): + await interaction.edit_original_response( + content="All responses for last week have already been graded! Nice job being proactive! 😊", + view=None, + ) + return + else: + for student in students: + view = ReportsReviewView(self.bot, student) + await interaction.edit_original_response( + content=f"Please grade the report by **{student.name}**:\n> _{student.report}_" + if student.report + else f"❌ **{student.name}** did not submit a report.", + view=view, + ) + await view.wait() + await interaction.edit_original_response( + content="βœ… Nice work. All reports have been graded. Thank you for your help!", + view=None, + attachments=[await self.bot.good_job_gif()], + ) + view = MILBotView() + view.add_item( + discord.ui.Button( + style=discord.ButtonStyle.secondary, + label="Review Complete", + disabled=True, + ), + ) + assert isinstance(interaction.message, discord.Message) + await interaction.message.edit(view=view) + + class ReportsModal(discord.ui.Modal): - name = discord.ui.TextInput(label="Name", placeholder="Albert Gator") + name = discord.ui.TextInput( + label="Name", + placeholder=random.choice(["Albert Gator", "Alberta Gator"]), + ) ufid = discord.ui.TextInput( label="UFID", - placeholder="86753099", + placeholder="37014744", min_length=8, max_length=8, ) @@ -76,7 +415,7 @@ async def on_submit(self, interaction: discord.Interaction) -> None: # Ensure UFID matches if ( - await main_worksheet.cell(name_cell.row, self.bot.reports_cog.UFID_COLUMN) + await main_worksheet.cell(name_cell.row, Column.UFID_COLUMN) ).value != self.ufid.value: await interaction.edit_original_response( content="❌ The UFID you entered does not match the one we have on file!", @@ -85,15 +424,15 @@ async def on_submit(self, interaction: discord.Interaction) -> None: return # Calculate column to log in. - first_date = self.bot.reports_cog.FIRST_DATE - today = datetime.date.today() - week = self.bot.reports_cog.date_to_column(today) + cur_semester = semester_given_date(datetime.datetime.now()) + first_date = cur_semester[0] if cur_semester else datetime.date.today() + week = WeekColumn.current().report_column # Log a Y for their square if ( await main_worksheet.cell( name_cell.row, - week + self.bot.reports_cog.TOTAL_COLUMNS, + week + len(Column), ) ).value: await interaction.edit_original_response( @@ -104,17 +443,17 @@ async def on_submit(self, interaction: discord.Interaction) -> None: await main_worksheet.update_cell( name_cell.row, - self.bot.reports_cog.TEAM_COLUMN, + Column.TEAM_COLUMN, self.team.value.title(), ) await main_worksheet.update_cell( name_cell.row, - self.bot.reports_cog.DISCORD_NAME_COLUMN, + Column.DISCORD_NAME_COLUMN, str(interaction.user), ) await main_worksheet.update_cell( name_cell.row, - week + self.bot.reports_cog.TOTAL_COLUMNS, + week + len(Column), "Y", ) @@ -191,16 +530,104 @@ async def callback(self, interaction: discord.Interaction): "❌ The weekly reports system is currently inactive due to the interim period between semesters. Please wait until next semester to document any work you have completed in between semesters. Thank you!", ephemeral=True, ) - # Send modal where user fills out report await interaction.response.send_modal(ReportsModal(self.bot)) +class ReportHistoryButton(discord.ui.Button): + def __init__(self, bot: MILBot): + self.bot = bot + super().__init__( + style=discord.ButtonStyle.secondary, + label="Report History", + custom_id="reports_view:history", + ) + + def _embed_color(self, total_score: float) -> discord.Color: + if total_score >= 4: + return discord.Color.dark_red() + elif total_score >= 3: + return discord.Color.brand_red() + elif total_score >= 2: + return discord.Color.orange() + elif total_score >= 1: + return discord.Color.gold() + return discord.Color.brand_green() + + async def callback(self, interaction: discord.Interaction): + # Get the entire row for that member, parse it, and present it to the user + await interaction.response.send_message( + f"{self.bot.loading_emoji} Fetching your report history...", + ephemeral=True, + ) + main_worksheet = await self.bot.sh.get_worksheet(0) + name_cell = await main_worksheet.find( + interaction.user.name, + in_column=Column.DISCORD_NAME_COLUMN.value, + ) + # Get all values for this member + row_values = await main_worksheet.row_values(name_cell.row) + # Iterate through week columns + week = WeekColumn.current() + reports_scores = [] + start_column = len(Column) + 1 + end_column = len(row_values) + len(row_values) % 2 + # Loop through each week (two cols at a time) + for week_i in range(start_column, end_column, 2): + # Only add weeks which are before the current week + if week_i <= week.report_column: + reports_scores.append( + ( + row_values[week_i - 1], + row_values[week_i] if week_i < len(row_values) else None, + ), + ) + week.report_column += 2 + + name = row_values[Column.NAME_COLUMN - 1] + egn_credits = row_values[Column.CREDITS_COLUMN - 1] + hours = float(egn_credits) * 3 + 3 + total_score = row_values[Column.SCORE_COLUMN - 1] + embed_color = self._embed_color(float(total_score)) + embed = discord.Embed( + title=f"Report History for `{name}`", + color=embed_color, + description=f"You currently have a missing score of `{total_score}`.", + ) + emojis = { + 0: "βœ…", + 0.5: "⚠️", + 1: "❌", + } + column = WeekColumn.first() + for report, score in reports_scores: + emoji = emojis.get(float(score) if score else score, "❓") + # Format: May 13 + start_date = column.date_range[0].strftime("%B %-d") + capped_report = f"* {report}" if len(report) < 900 else f"* {report}..." + if score and float(score): + capped_report += ( + f"\n* **This report added +{float(score)} to your missing score.**" + ) + embed.add_field( + name=f"{emoji} Week of `{start_date}`", + value=capped_report, + inline=False, + ) + column.report_column += 2 + embed.set_thumbnail(url=interaction.user.display_avatar.url) + embed.set_footer( + text=f"βœ…: {hours:.0f}+ hours demonstrated | ⚠️: 0-{hours // 3:.0f} hours demonstrated | ❌: Missing report/no work demonstrated", + ) + await interaction.edit_original_response(content=None, embed=embed) + + class ReportsView(MILBotView): def __init__(self, bot: MILBot): self.bot = bot super().__init__(timeout=None) self.add_item(SubmitButton(bot)) + self.add_item(ReportHistoryButton(bot)) @dataclass @@ -208,8 +635,13 @@ class Student: name: str discord_id: str member: discord.Member | None + email: str team: Team - report: str + report: str | None + report_score: float | None + total_score: float + credits: int | None + row: int @property def first_name(self) -> str: @@ -219,40 +651,25 @@ def first_name(self) -> str: def status_emoji(self) -> str: return "βœ…" if self.report else "❌" + @property + def hours_commitment(self) -> int | None: + return self.credits * 3 + 3 if self.credits is not None else None -class ReportsCog(commands.Cog): - - NAME_COLUMN = 1 - EMAIL_COLUMN = 2 - UFID_COLUMN = 3 - LEADERS_COLUMN = 4 - TEAM_COLUMN = 5 - DISCORD_NAME_COLUMN = 6 - - FIRST_DATE = datetime.date(2024, 1, 15) # TODO: Make this automatically derived - - TOTAL_COLUMNS = 6 +class ReportsCog(commands.Cog): def __init__(self, bot: MILBot): self.bot = bot self.post_reminder.start(self) self.last_week_summary.start(self) - self.individual_reminder.start(self) + self.first_individual_reminder.start(self) + self.second_individual_reminder.start(self) self.update_report_channel.start(self) - def date_to_column(self, date: datetime.date) -> int: - """ - Converts a date to the relevant column number. - - The column number is the number of weeks since the first date. - """ - return (date - self.FIRST_DATE).days // 7 + 1 - @run_on_weekday(calendar.FRIDAY, 12, 0, check=is_active) async def post_reminder(self): general_channel = self.bot.general_channel return await general_channel.send( - f"{self.bot.egn4912_role.mention}\nHey everyone! Friendly reminder to submit your weekly progress reports by **Sunday night at 11:59pm**. You can submit your reports in the {self.bot.reports_channel.mention} channel. If you have any questions, please contact your leader. Thank you!", + f"{self.bot.egn4912_role.mention}\nHey everyone! Friendly reminder to submit your weekly progress reports by **Sunday night at 11:59pm**. You can submit your reports in the {self.bot.member_services_channel.mention} channel. If you have any questions, please contact your leader. Thank you!", ) async def safe_col_values( @@ -267,48 +684,110 @@ async def safe_col_values( async def students_status(self, column: int) -> list[Student]: main_worksheet = await self.bot.sh.get_worksheet(0) - names = await self.safe_col_values(main_worksheet, self.NAME_COLUMN) + names = await self.safe_col_values(main_worksheet, Column.NAME_COLUMN) discord_ids = await self.safe_col_values( main_worksheet, - self.DISCORD_NAME_COLUMN, + Column.DISCORD_NAME_COLUMN, ) - teams = await self.safe_col_values(main_worksheet, self.TEAM_COLUMN) + teams = await self.safe_col_values(main_worksheet, Column.TEAM_COLUMN) + emails = await self.safe_col_values(main_worksheet, Column.EMAIL_COLUMN) + reg_credits = await self.safe_col_values(main_worksheet, Column.CREDITS_COLUMN) + scores = await self.safe_col_values(main_worksheet, Column.SCORE_COLUMN) col_vals = await main_worksheet.col_values(column) - students = list(itertools.zip_longest(names, discord_ids, teams, col_vals)) + col_scores = await main_worksheet.col_values(column + 1) + students = list( + itertools.zip_longest( + names, + discord_ids, + teams, + emails, + reg_credits, + scores, + col_vals, + col_scores, + ), + ) res: list[Student] = [] - for name, discord_id, team, value in students[2:]: # (skip header rows) + for i, ( + name, + discord_id, + team, + email, + credit, + total_score, + report, + report_score, + ) in enumerate( + students[2:], + ): # (skip header rows) member = self.bot.active_guild.get_member_named(str(discord_id)) res.append( - Student(name, discord_id, member, Team.from_str(team), str(value)), + Student( + name, + discord_id, + member, + email, + Team.from_str(str(team)), + report if report else None, + float(report_score) if report_score else None, + float(total_score), + int(credit), + i + 3, + ), ) res.sort(key=lambda s: s.first_name) return res - @run_on_weekday(calendar.SUNDAY, 12, 0, check=is_active) - async def individual_reminder(self): - # Get all members who have not completed reports for the week - await self.bot.sh.get_worksheet(0) - today = datetime.date.today() - week = self.date_to_column(today) - column = week + self.TOTAL_COLUMNS + async def members_without_report(self) -> list[Student]: + week = WeekColumn.current().report_column + column = week + len(Column) students = await self.students_status(column) + return [student for student in students if not student.report] + + @run_on_weekday(calendar.SUNDAY, 12, 0, check=is_active) + async def first_individual_reminder(self): + # Get all members who have not completed reports for the week + students = await self.members_without_report() + deadline_tonight = datetime.datetime.combine( + datetime.date.today(), + datetime.time(23, 59, 59), + ) + for student in students: + if student.member: + try: + await student.member.send( + f"Hey **{student.first_name}**! It's your friendly uf-mil-bot here. I noticed you haven't submitted your weekly MIL report yet. Please submit it in the {self.bot.member_services_channel.mention} channel by {discord.utils.format_dt(deadline_tonight, 't')} tonight. Thank you!", + ) + logger.info( + f"Sent first individual report reminder to {student.member}.", + ) + except discord.Forbidden: + logger.info( + f"Could not send first individual report reminder to {student.member}.", + ) + @run_on_weekday(calendar.SUNDAY, 20, 0) + async def second_individual_reminder(self): + # Get all members who have not completed reports for the week + students = await self.members_without_report() deadline_tonight = datetime.datetime.combine( datetime.date.today(), datetime.time(23, 59, 59), ) for student in students: - if student.member and not student.report: + if student.member: try: await student.member.send( - f"Hey **{student.first_name}**! It's your friendly uf-mil-bot here. I noticed you haven't submitted your weekly MIL report yet. Please submit it in the {self.bot.reports_channel.mention} channel by {discord.utils.format_dt(deadline_tonight, 't')} tonight. Thank you!", + f"Hey **{student.first_name}**! It's your friendly uf-mil-bot here again. I noticed you haven't submitted your report yet. There are only **four hours** remaining to submit your report! Please submit it in the {self.bot.member_services_channel.mention} channel by {discord.utils.format_dt(deadline_tonight, 't')} tonight. Thank you!", + ) + logger.info( + f"Sent second individual report reminder to {student.member}.", ) - logger.info(f"Sent individual report reminder to {student.member}.") except discord.Forbidden: logger.info( - f"Could not send individual report reminder to {student.member}.", + f"Could not send second individual report reminder to {student.member}.", ) @run_on_weekday(calendar.MONDAY, 0, 0) @@ -319,22 +798,21 @@ async def last_week_summary(self): await self.bot.sh.get_worksheet(0) # Calculate the week number and get the column - today = datetime.date.today() - week = self.date_to_column(today) - column = week + self.TOTAL_COLUMNS - 1 # -1 because the week has now passed + column = WeekColumn.last_week() # Get all members who have not completed reports for the week - students = await self.students_status(column) + students = await self.students_status(column.report_column) # Generate embed - first_day_of_week = self.FIRST_DATE + datetime.timedelta(days=(week - 1) * 7) - last_day_of_week = first_day_of_week + datetime.timedelta(days=6) + first_day_of_week = column.date_range[0] + last_day_of_week = column.date_range[1] first = first_day_of_week.strftime("%B %-d, %Y") last = last_day_of_week.strftime("%B %-d, %Y") for team in Team: team_members = [s for s in students if s.team == team] if not team_members: continue + team_leads_ch = self.bot.team_leads_ch(team) while team_members: embed = discord.Embed( title=f"Report Summary: `{first}` - `{last}`", @@ -345,20 +823,31 @@ async def last_week_summary(self): for next_member in team_members[:25]: embed.add_field( name=f"{next_member.status_emoji} `{next_member.name.title()}`", - value=f"{next_member.report[:1024] or 'missing :('}", + value=f"{next_member.report[:1024] if next_member.report else 'missing :('}", ) if len(embed) > 6000: embed.remove_field(-1) break team_members.remove(next_member) - team_leads_ch = self.bot.team_leads_ch(team) await team_leads_ch.send(embed=embed) + grading_deadline = discord.utils.utcnow() + datetime.timedelta(days=3) + review_embed = discord.Embed( + title="Begin Report Review", + color=discord.Color.brand_red(), + description=f"In order to provide members with reliable feedback about their performance in MIL, please complete a brief review of each member's reports. Grading reports provides members a method of evaluating their current status in MIL.\n* Reports are graded on a scale of green-yellow-red (green indicating the best performance).\n* Please complete grading by {discord.utils.format_dt(grading_deadline, 'F')} ({discord.utils.format_dt(grading_deadline, 'R')}).", + ) + await team_leads_ch.send( + embed=review_embed, + view=StartReviewView(self.bot), + ) @run_on_weekday([calendar.MONDAY, calendar.WEDNESDAY], 0, 0) async def update_report_channel(self): channel_history = [ m - async for m in self.bot.reports_channel.history(limit=1, oldest_first=True) + async for m in self.bot.member_services_channel.history( + limit=1, + ) ] if not channel_history: return @@ -372,33 +861,35 @@ async def update_report_channel(self): async def reportview(self, ctx): embed = discord.Embed( title="Submit your Weekly Progress Report", - description="In order to keep all members on track, we ask that you complete a weekly report detailing your trials/contributions for the previous week. This is required for all members on all teams.\n\nRemember that members signed up for zero credit hours are expected to work at least **three hours per week** in MIL to sustain their satisfactory grade. If you have any concerns, please contact a leader!", + description="In order to keep all members on track, we ask that you complete a weekly report detailing your trials/contributions for the previous week. This is required for all members on all teams.", color=discord.Color.blue(), ) - questions = [ - { - "question": "How long do our weekly reports need to be?", - "answer": "Roughly one to two sentences is appropriate. We just want to know what you've been working on!", - }, - { - "question": "I was unable to work three hours this week.", - "answer": "No worries, we understand that life happens. Please explain the circumstances in your report, and your leaders will be happy to work with you. If this becomes a recurring issue, please contact your leaders immediately to find an acceptable solution.", - }, - { - "question": "I'm not sure what to write in my report.", - "answer": "If you're not sure what to write, try to answer the following questions:\n- What did you work on this week?\n- What did you learn this week?\n- What do you plan to work on next week?", - }, - { - "question": "How do we complete a report if we work on multiple teams?", - "answer": "Please complete the report for the team you are most active with.", - }, - ] - for question in questions: - embed.add_field( - name=question["question"], - value=question["answer"], - inline=False, - ) + embed.add_field( + name="πŸ“ __How to Submit__", + value="To submit your report, click the button below. In your report, aim for:\n1. **1-2 sentences** describing your progress this week.\n2. **Specific details** about what you worked on. Make sure to mention any specific tasks, projects, or people you worked with.", + inline=False, + ) + embed.add_field( + name="πŸ“… __Deadline__", + value="Reports are due by **Sunday night at 11:59pm**. We cannot accept late reports.", + inline=False, + ) + embed.add_field( + name="πŸ“Š __Grading__", + value="Reports are graded on a scale of **green-yellow-red** (green indicating the best performance).\n* βœ… **Green**: Report demonstrated an actionable attempt at 3 hours of work.\n* ⚠️ **Yellow**: Report demonstrated 0-1 hours of work. (ie, installing a basic software package or reading a tutorial)\n* ❌ **Red**: Report was missing or no work was demonstrated.\nThese details are tracked over a semester using the **missing index**. A yellow report adds +0.5; a red report adds +1. Upon reaching 4, you will be automatically removed from MIL.", + inline=False, + ) + embed.add_field( + name="πŸ” __Review__", + value="After submitting your report, a leader will review your report. If you were graded yellow or red, you will be notified via email.", + inline=False, + ) + embed.add_field( + name="πŸ“ˆ __History__", + value="To view your report history, click the button below.", + inline=False, + ) + embed.set_footer(text="If you have any questions, please contact a leader.") await ctx.send(embed=embed, view=ReportsView(self.bot))