diff --git a/uqcsbot/utils/uq_course_utils.py b/uqcsbot/utils/uq_course_utils.py index a4c2d9db..93432227 100644 --- a/uqcsbot/utils/uq_course_utils.py +++ b/uqcsbot/utils/uq_course_utils.py @@ -3,9 +3,10 @@ from datetime import datetime from dateutil import parser from bs4 import BeautifulSoup, element -from functools import partial from typing import List, Dict, Optional, Literal, Tuple +from dataclasses import dataclass import json +import re BASE_COURSE_URL = "https://my.uq.edu.au/programs-courses/course.html?course_code=" BASE_ASSESSMENT_URL = ( @@ -105,6 +106,69 @@ def _estimate_current_semester() -> SemesterType: return "Summer" +@dataclass +class AssessmentItem: + course_name: str + task: str + due_date: str + weight: str + + def get_parsed_due_date(self): + """ + Returns the parsed due date for the given assessment item as a datetime + object. If the date cannot be parsed, a DateSyntaxException is raised. + """ + if self.due_date == "Examination Period": + return get_current_exam_period() + parser_info = parser.parserinfo(dayfirst=True) + try: + # If a date range is detected, attempt to split into start and end + # dates. Else, attempt to just parse the whole thing. + if " - " in self.due_date: + start_date, end_date = self.due_date.split(" - ", 1) + start_datetime = parser.parse(start_date, parser_info) + end_datetime = parser.parse(end_date, parser_info) + return start_datetime, end_datetime + due_datetime = parser.parse(self.due_date, parser_info) + return due_datetime, due_datetime + except Exception: + raise DateSyntaxException(self.due_date, self.course_name) + + def is_after(self, cutoff: datetime): + """ + Returns whether the assessment occurs after the given cutoff. + """ + try: + start_datetime, end_datetime = self.get_parsed_due_date() + except DateSyntaxException: + # If we can't parse a date, we're better off keeping it just in case. + return True + return end_datetime >= cutoff if end_datetime else start_datetime >= cutoff + + def is_before(self, cutoff: datetime): + """ + Returns whether the assessment occurs before the given cutoff. + """ + try: + start_datetime, _ = self.get_parsed_due_date() + except DateSyntaxException: + # TODO bot.logger.error(e.message) + # If we can't parse a date, we're better off keeping it just in case. + # TODO(mitch): Keep track of these instances to attempt to accurately + # parse them in future. Will require manual detection + parsing. + return True + return start_datetime <= cutoff + + def get_weight_as_int(self) -> Optional[int]: + """ + Trys to get the weight percentage of an assessment as a percentage. Will return None + if a percentage can not be obtained. + """ + if match := re.match(r"\d+", self.weight): + return int(match.group(0)) + return None + + class DateSyntaxException(Exception): """ Raised when an unparsable date syntax is encountered. @@ -234,14 +298,14 @@ def get_course_profile_url( return url -def get_course_profile_id(course_name: str, offering: Optional[Offering]): +def get_course_profile_id(course_name: str, offering: Optional[Offering] = None) -> int: """ Returns the ID to the latest course profile for the given course. """ profile_url = get_course_profile_url(course_name, offering=offering) # The profile url looks like this # https://course-profiles.uq.edu.au/student_section_loader/section_1/100728 - return profile_url[profile_url.rindex("/") + 1 :] + return int(profile_url[profile_url.rindex("/") + 1 :]) def get_current_exam_period(): @@ -270,44 +334,6 @@ def get_current_exam_period(): return start_datetime, end_datetime -def get_parsed_assessment_due_date(assessment_item: Tuple[str, str, str, str]): - """ - Returns the parsed due date for the given assessment item as a datetime - object. If the date cannot be parsed, a DateSyntaxException is raised. - """ - course_name, _, due_date, _ = assessment_item - if due_date == "Examination Period": - return get_current_exam_period() - parser_info = parser.parserinfo(dayfirst=True) - try: - # If a date range is detected, attempt to split into start and end - # dates. Else, attempt to just parse the whole thing. - if " - " in due_date: - start_date, end_date = due_date.split(" - ", 1) - start_datetime = parser.parse(start_date, parser_info) - end_datetime = parser.parse(end_date, parser_info) - return start_datetime, end_datetime - due_datetime = parser.parse(due_date, parser_info) - return due_datetime, due_datetime - except Exception: - raise DateSyntaxException(due_date, course_name) - - -def is_assessment_after_cutoff(assessment: Tuple[str, str, str, str], cutoff: datetime): - """ - Returns whether the assessment occurs after the given cutoff. - """ - try: - start_datetime, end_datetime = get_parsed_assessment_due_date(assessment) - except DateSyntaxException: - # TODO bot.logger.error(e.message) - # If we can't parse a date, we're better off keeping it just in case. - # TODO(mitch): Keep track of these instances to attempt to accurately - # parse them in future. Will require manual detection + parsing. - return True - return end_datetime >= cutoff if end_datetime else start_datetime >= cutoff - - def get_course_assessment_page( course_names: List[str], offering: Optional[Offering] ) -> str: @@ -316,17 +342,18 @@ def get_course_assessment_page( url to the assessment table for the provided courses """ profile_ids = map( - lambda course: get_course_profile_id(course, offering=offering), course_names + lambda course: str(get_course_profile_id(course, offering=offering)), + course_names, ) return BASE_ASSESSMENT_URL + ",".join(profile_ids) def get_course_assessment( course_names: List[str], - cutoff: Optional[datetime] = None, + cutoff: Tuple[Optional[datetime], Optional[datetime]] = (None, None), assessment_url: Optional[str] = None, offering: Optional[Offering] = None, -) -> List[Tuple[str, str, str, str]]: +) -> List[AssessmentItem]: """ Returns all the course assessment for the given courses that occur after the given cutoff. @@ -346,9 +373,12 @@ def get_course_assessment( assessment = assessment_table.findAll("tr")[1:] parsed_assessment = map(get_parsed_assessment_item, assessment) # If no cutoff is specified, set cutoff to UNIX epoch (i.e. filter nothing). - cutoff = cutoff or datetime.min - assessment_filter = partial(is_assessment_after_cutoff, cutoff=cutoff) - filtered_assessment = filter(assessment_filter, parsed_assessment) + cutoff_min = cutoff[0] or datetime.min + cutoff_max = cutoff[1] or datetime.max + filtered_assessment = filter( + lambda item: item.is_after(cutoff_min) and item.is_before(cutoff_max), + parsed_assessment, + ) return list(filtered_assessment) @@ -360,8 +390,8 @@ def get_element_inner_html(dom_element: element.Tag): def get_parsed_assessment_item( - assessment_item: element.Tag, -) -> Tuple[str, str, str, str]: + assessment_item_tag: element.Tag, +) -> AssessmentItem: """ Returns the parsed assessment details for the given assessment item table row element. @@ -371,7 +401,7 @@ def get_parsed_assessment_item( This is likely insufficient to handle every course's structure, and thus is subject to change. """ - course_name, task, due_date, weight = assessment_item.findAll("div") + course_name, task, due_date, weight = assessment_item_tag.findAll("div") # Handles courses of the form 'CSSE1001 - Sem 1 2018 - St Lucia - Internal'. # Thus, this bit of code will extract the course. course_name = course_name.text.strip().split(" - ")[0] @@ -384,7 +414,7 @@ def get_parsed_assessment_item( # Handles weights of the form '30%
Alternative to oral presentation'. # Thus, this bit of code will keep only the weight portion of the field. weight = get_element_inner_html(weight).strip().split("
")[0] - return (course_name, task, due_date, weight) + return AssessmentItem(course_name, task, due_date, weight) class Exam: diff --git a/uqcsbot/whatsdue.py b/uqcsbot/whatsdue.py index 9dd61c68..7b3a51cb 100644 --- a/uqcsbot/whatsdue.py +++ b/uqcsbot/whatsdue.py @@ -1,6 +1,6 @@ -from datetime import datetime +from datetime import datetime, timedelta import logging -from typing import Optional +from typing import Optional, Callable, Literal, Dict import discord from discord import app_commands @@ -9,14 +9,39 @@ from uqcsbot.yelling import yelling_exemptor from uqcsbot.utils.uq_course_utils import ( + DateSyntaxException, Offering, CourseNotFoundException, HttpException, ProfileNotFoundException, + AssessmentItem, get_course_assessment, get_course_assessment_page, + get_course_profile_id, ) +AssessmentSortType = Literal["Date", "Course Name", "Weight"] +ECP_ASSESSMENT_URL = ( + "https://course-profiles.uq.edu.au/student_section_loader/section_5/" +) + + +def sort_by_date(item: AssessmentItem): + """Provides a key to sort assessment dates by. If the date cannot be parsed, will put it with items occuring during exam block.""" + try: + return item.get_parsed_due_date()[0] + except DateSyntaxException: + return datetime.max + + +SORT_METHODS: Dict[ + AssessmentSortType, Callable[[AssessmentItem], int | str | datetime] +] = { + "Date": sort_by_date, + "Course Name": (lambda item: item.course_name), + "Weight": (lambda item: item.get_weight_as_int() or 0), +} + class WhatsDue(commands.Cog): def __init__(self, bot: commands.Bot): @@ -26,15 +51,14 @@ def __init__(self, bot: commands.Bot): @app_commands.describe( fulloutput="Display the full list of assessment. Defaults to False, which only " + "shows assessment due from today onwards.", + weeks_to_show="Only show assessment due within this number of weeks. If 0 (default), show all assessment.", semester="The semester to get assessment for. Defaults to what UQCSbot believes is the current semester.", campus="The campus the course is held at. Defaults to St Lucia. Note that many external courses are 'hosted' at St Lucia.", mode="The mode of the course. Defaults to Internal.", - course1="Course code", - course2="Course code", - course3="Course code", - course4="Course code", - course5="Course code", - course6="Course code", + courses="Course codes seperated by spaces", + sort_order="The order to sort courses by. Defualts to Date.", + reverse_sort="Whether to reverse the sort order. Defaults to false.", + show_ecp_links="Show the first ECP link for each course page. Defaults to false.", ) @yelling_exemptor( input_args=["course1", "course2", "course3", "course4", "course5", "course6"] @@ -42,16 +66,15 @@ def __init__(self, bot: commands.Bot): async def whatsdue( self, interaction: discord.Interaction, - course1: str, - course2: Optional[str], - course3: Optional[str], - course4: Optional[str], - course5: Optional[str], - course6: Optional[str], + courses: str, fulloutput: bool = False, + weeks_to_show: int = 0, semester: Optional[Offering.SemesterType] = None, campus: Offering.CampusType = "St Lucia", mode: Offering.ModeType = "Internal", + sort_order: AssessmentSortType = "Date", + reverse_sort: bool = False, + show_ecp_links: bool = False, ): """ Returns all the assessment for a given list of course codes that are scheduled to occur. @@ -60,15 +83,19 @@ async def whatsdue( await interaction.response.defer(thinking=True) - possible_courses = [course1, course2, course3, course4, course5, course6] - course_names = [c.upper() for c in possible_courses if c != None] + course_names = [c.upper() for c in courses.split()] offering = Offering(semester=semester, campus=campus, mode=mode) # If full output is not specified, set the cutoff to today's date. - cutoff = None if fulloutput else datetime.today() + cutoff = ( + None if fulloutput else datetime.today(), + datetime.today() + timedelta(weeks=weeks_to_show) + if weeks_to_show > 0 + else None, + ) try: - asses_page = get_course_assessment_page(course_names, offering) - assessment = get_course_assessment(course_names, cutoff, asses_page) + assessment_page = get_course_assessment_page(course_names, offering) + assessment = get_course_assessment(course_names, cutoff, assessment_page) except HttpException as e: logging.error(e.message) await interaction.edit_original_response( @@ -81,15 +108,15 @@ async def whatsdue( embed = discord.Embed( title=f"What's Due: {', '.join(course_names)}", - url=asses_page, + url=assessment_page, description="*WARNING: Assessment information may vary/change/be entirely different! Use at your own discretion. Check your ECP for a true list of assessment.*", ) if assessment: + assessment.sort(key=SORT_METHODS[sort_order], reverse=reverse_sort) for assessment_item in assessment: - course, task, due, weight = assessment_item embed.add_field( - name=course, - value=f"`{weight}` {task} **({due})**", + name=assessment_item.course_name, + value=f"`{assessment_item.weight}` {assessment_item.task} **({assessment_item.due_date})**", inline=False, ) elif fulloutput: @@ -103,6 +130,18 @@ async def whatsdue( value=f"Nothing seems to be due soon", ) + if show_ecp_links: + ecp_links = [ + f"[{course_name}]({ECP_ASSESSMENT_URL + str(get_course_profile_id(course_name))})" + for course_name in course_names + ] + embed.add_field( + name=f"Potential ECP {'Link' if len(course_names) == 1 else 'Links'}", + value=" ".join(ecp_links) + + "\nNote that these may not be the correct ECPs. Check the year and offering type.", + inline=False, + ) + if not fulloutput: embed.set_footer( text="Note: This may not be the full assessment list. Set fulloutput to True to see a potentially more complete list, or check your ECP for a true list of assessment."