diff --git a/pyproject.toml b/pyproject.toml index a815ef9..2ecc96c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,9 +51,7 @@ exclude = [ "**/snailrace.py", "**/starboard.py", "**/uptime.py", - "**/whatsdue.py", "**/working_on.py", "**/utils/command_utils.py", "**/utils/snailrace_utils.py", - "**/utils/uq_course_utils.py" ] \ No newline at end of file diff --git a/uqcsbot/utils/uq_course_utils.py b/uqcsbot/utils/uq_course_utils.py index cd06a94..a4c2d9d 100644 --- a/uqcsbot/utils/uq_course_utils.py +++ b/uqcsbot/utils/uq_course_utils.py @@ -2,10 +2,9 @@ from requests.exceptions import RequestException from datetime import datetime from dateutil import parser -from bs4 import BeautifulSoup +from bs4 import BeautifulSoup, element from functools import partial -from binascii import hexlify -from typing import List, Dict, Optional, Iterable +from typing import List, Dict, Optional, Literal, Tuple import json BASE_COURSE_URL = "https://my.uq.edu.au/programs-courses/course.html?course_code=" @@ -18,12 +17,100 @@ BASE_PAST_EXAMS_URL = "https://api.library.uq.edu.au/v1/exams/search/" +class Offering: + """ + A semester, campus and mode (e.g. Internal) that many courses occur within + """ + + CampusType = Literal["St Lucia", "Gatton", "Herston"] + # The codes used internally within UQ systems + campus_codes: Dict[CampusType, str] = { + "St Lucia": "STLUC", + "Gatton": "GATTN", + "Herston": "HERST", + } + + ModeType = Literal["Internal", "External", "Flexible Delivery", "Intensive"] + # The codes used internally within UQ systems + mode_codes: Dict[ModeType, str] = { + "Internal": "IN", + "External": "EX", + "Flexible Delivery": "FD", + "Intensive": "IT", + } + + SemesterType = Literal["1", "2", "Summer"] + semester_codes: Dict[SemesterType, int] = {"1": 1, "2": 2, "Summer": 3} + + semester: SemesterType + campus: CampusType + mode: ModeType + + def __init__( + self, + semester: Optional[SemesterType], + campus: CampusType = "St Lucia", + mode: ModeType = "Internal", + ): + """ + semester defaults to the current semester if None + """ + if semester is not None: + self.semester = semester + else: + self.semester = self._estimate_current_semester() + self.semester + self.campus = campus + self.mode = mode + + def get_semester_code(self) -> int: + """ + Returns the code used interally within UQ for the semester of the offering. + """ + return self.semester_codes[self.semester] + + def get_campus_code(self) -> str: + """ + Returns the code used interally within UQ for the campus of the offering. + """ + self.campus + return self.campus_codes[self.campus] + + def get_mode_code(self) -> str: + """ + Returns the code used interally within UQ for the mode of the offering. + """ + return self.mode_codes[self.mode] + + def get_offering_code(self) -> str: + """ + Returns the hex encoded offering string (containing all offering information) for the offering. + """ + offering_code_text = ( + f"{self.get_campus_code()}{self.get_semester_code()}{self.get_mode_code()}" + ) + return offering_code_text.encode("utf-8").hex() + + @staticmethod + def _estimate_current_semester() -> SemesterType: + """ + Returns an estimate of the current semester (represented by an integer) based on the current month. 3 represents summer semester. + """ + current_month = datetime.today().month + if 2 <= current_month <= 6: + return "1" + elif 7 <= current_month <= 11: + return "2" + else: + return "Summer" + + class DateSyntaxException(Exception): """ Raised when an unparsable date syntax is encountered. """ - def __init__(self, date, course_name): + def __init__(self, date: str, course_name: str): self.message = f"Could not parse date '{date}' for course '{course_name}'." self.date = date self.course_name = course_name @@ -35,7 +122,7 @@ class CourseNotFoundException(Exception): Raised when a given course cannot be found for UQ. """ - def __init__(self, course_name): + def __init__(self, course_name: str): self.message = f"Could not find course '{course_name}'." self.course_name = course_name super().__init__(self.message, self.course_name) @@ -46,10 +133,30 @@ class ProfileNotFoundException(Exception): Raised when a profile cannot be found for a given course. """ - def __init__(self, course_name): - self.message = f"Could not find profile for course '{course_name}'." + def __init__(self, course_name: str, offering: Optional[Offering] = None): + if offering is None: + self.message = f"Could not find profile for course '{course_name}'. This profile may not be out yet." + else: + self.message = f"Could not find profile for course '{course_name}' during semester {offering.semester} at {offering.campus} done in mode '{offering.mode}'. This profile may not be out yet." self.course_name = course_name - super().__init__(self.message, self.course_name) + self.offering = offering + super().__init__(self.message, self.course_name, self.offering) + + +class AssessmentNotFoundException(Exception): + """ + Raised when the assessment table cannot be found for assess page. + """ + + def __init__(self, course_names: List[str], offering: Optional[Offering] = None): + if offering is None: + self.message = ( + f"Could not find the assessment table for '{', '.join(course_names)}'." + ) + else: + self.message = f"Could not find the assessment table for '{', '.join(course_names)}' during semester {offering.semester} at {offering.campus} done in mode '{offering.mode}'." + self.course_names = course_names + super().__init__(self.message, self.course_names) class HttpException(Exception): @@ -58,29 +165,13 @@ class HttpException(Exception): unsuccessful (i.e. not 200 OK) status code. """ - def __init__(self, url, status_code): + def __init__(self, url: str, status_code: int): self.message = f"Received status code {status_code} from '{url}'." self.url = url self.status_code = status_code super().__init__(self.message, self.url, self.status_code) -def get_offering_code(semester=None, campus="STLUC", is_internal=True): - """ - Returns the hex encoded offering string for the given semester and campus. - - Keyword Arguments: - semester {str} -- Semester code (3 is summer) (default: current semester) - campus {str} -- Campus code string (one of STLUC, etc.) - is_internal {bool} -- True for internal, false for external. - """ - # TODO: Codes for other campuses. - if semester is None: - semester = 1 if datetime.today().month <= 6 else 2 - location = "IN" if is_internal else "EX" - return hexlify(f"{campus}{semester}{location}".encode("utf-8")).decode("utf-8") - - def get_uq_request( url: str, params: Optional[Dict[str, str]] = None ) -> requests.Response: @@ -100,30 +191,54 @@ def get_uq_request( raise HttpException(message, 500) -def get_course_profile_url(course_name): +def get_course_profile_url( + course_name: str, offering: Optional[Offering] = None +) -> str: """ - Returns the URL to the latest course profile for the given course. + Returns the URL to the course profile for the given course for a given offering. + If no offering is give, will return the first course profile on the course page. """ - course_url = BASE_COURSE_URL + course_name - http_response = get_uq_request( - course_url, params={OFFERING_PARAMETER: get_offering_code()} - ) + if offering is None: + course_url = BASE_COURSE_URL + course_name + else: + course_url = ( + BASE_COURSE_URL + + course_name + + "&" + + OFFERING_PARAMETER + + "=" + + offering.get_offering_code() + ) + + http_response = get_uq_request(course_url) if http_response.status_code != requests.codes.ok: raise HttpException(course_url, http_response.status_code) html = BeautifulSoup(http_response.content, "html.parser") if html.find(id="course-notfound"): raise CourseNotFoundException(course_name) - profile = html.find("a", class_="profile-available") - if profile is None: - raise ProfileNotFoundException(course_name) - return profile.get("href") + if offering is None: + profile = html.find("a", class_="profile-available") + else: + # The profile row on the course page that corresponds to the given offering + table_row = html.find("tr", class_="current") + if not isinstance(table_row, element.Tag): + raise ProfileNotFoundException(course_name, offering) + profile = table_row.find("a", class_="profile-available") + + if not isinstance(profile, element.Tag): + raise ProfileNotFoundException(course_name, offering) + url = profile.get("href") + if not isinstance(url, str): + raise ProfileNotFoundException(course_name, offering) + return url -def get_course_profile_id(course_name): + +def get_course_profile_id(course_name: str, offering: Optional[Offering]): """ Returns the ID to the latest course profile for the given course. """ - profile_url = get_course_profile_url(course_name) + 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 :] @@ -155,7 +270,7 @@ def get_current_exam_period(): return start_datetime, end_datetime -def get_parsed_assessment_due_date(assessment_item): +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. @@ -178,13 +293,13 @@ def get_parsed_assessment_due_date(assessment_item): raise DateSyntaxException(due_date, course_name) -def is_assessment_after_cutoff(assessment, cutoff): +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 as e: + 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 @@ -193,29 +308,40 @@ def is_assessment_after_cutoff(assessment, cutoff): return end_datetime >= cutoff if end_datetime else start_datetime >= cutoff -def get_course_assessment_page(course_names: List[str]) -> str: +def get_course_assessment_page( + course_names: List[str], offering: Optional[Offering] +) -> str: """ Determines the course ids from the course names and returns the url to the assessment table for the provided courses """ - profile_ids = map(get_course_profile_id, course_names) + profile_ids = map( + lambda course: get_course_profile_id(course, offering=offering), course_names + ) return BASE_ASSESSMENT_URL + ",".join(profile_ids) -def get_course_assessment(course_names, cutoff=None, assessment_url=None): +def get_course_assessment( + course_names: List[str], + cutoff: Optional[datetime] = None, + assessment_url: Optional[str] = None, + offering: Optional[Offering] = None, +) -> List[Tuple[str, str, str, str]]: """ Returns all the course assessment for the given courses that occur after the given cutoff. """ if assessment_url is None: - joined_assessment_url = get_course_assessment_page(course_names) + joined_assessment_url = get_course_assessment_page(course_names, offering) else: joined_assessment_url = assessment_url - http_response = get_uq_request(joined_assessment_url) + http_response = get_uq_request(joined_assessment_url) if http_response.status_code != requests.codes.ok: raise HttpException(joined_assessment_url, http_response.status_code) html = BeautifulSoup(http_response.content, "html.parser") assessment_table = html.find("table", class_="tblborder") + if not isinstance(assessment_table, element.Tag): + raise AssessmentNotFoundException(course_names, offering) # Start from 1st index to skip over the row containing column names. assessment = assessment_table.findAll("tr")[1:] parsed_assessment = map(get_parsed_assessment_item, assessment) @@ -226,14 +352,16 @@ def get_course_assessment(course_names, cutoff=None, assessment_url=None): return list(filtered_assessment) -def get_element_inner_html(dom_element): +def get_element_inner_html(dom_element: element.Tag): """ Returns the inner html for the given element. """ return dom_element.decode_contents(formatter="html") -def get_parsed_assessment_item(assessment_item): +def get_parsed_assessment_item( + assessment_item: element.Tag, +) -> Tuple[str, str, str, str]: """ Returns the parsed assessment details for the given assessment item table row element. @@ -294,7 +422,7 @@ def get_past_exams(course_code: str) -> List[Exam]: return [] exam_list_json = exam_list_json[0] - exam_list = [] + exam_list: List[Exam] = [] for exam_json in exam_list_json: year = int(exam_json[0]["examYear"]) # Semesters are given as "Sem.1", so we will change this to "Sem 1" diff --git a/uqcsbot/whatsdue.py b/uqcsbot/whatsdue.py index 8c41828..79b1b79 100644 --- a/uqcsbot/whatsdue.py +++ b/uqcsbot/whatsdue.py @@ -7,6 +7,7 @@ from discord.ext import commands from uqcsbot.utils.uq_course_utils import ( + Offering, CourseNotFoundException, HttpException, ProfileNotFoundException, @@ -19,18 +20,13 @@ class WhatsDue(commands.Cog): def __init__(self, bot: commands.Bot): self.bot = bot - def get_formatted_assessment_item(self, assessment_item): - """ - Returns the given assessment item in a pretty - message format to display to a user. - """ - course, task, due, weight = assessment_item - return f"**{course}**: `{weight}` *{task}* **({due})**" - @app_commands.command() @app_commands.describe( fulloutput="Display the full list of assessment. Defaults to False, which only " - + "shows assessment due from today onwards", + + "shows assessment due from today onwards.", + 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", @@ -47,7 +43,10 @@ async def whatsdue( course4: Optional[str], course5: Optional[str], course6: Optional[str], - fulloutput: Optional[bool] = False, + fulloutput: bool = False, + semester: Optional[Offering.SemesterType] = None, + campus: Offering.CampusType = "St Lucia", + mode: Offering.ModeType = "Internal", ): """ Returns all the assessment for a given list of course codes that are scheduled to occur. @@ -57,12 +56,13 @@ async def whatsdue( await interaction.response.defer(thinking=True) possible_courses = [course1, course2, course3, course4, course5, course6] - course_names = [c for c in possible_courses if c != None] + course_names = [c.upper() for c in possible_courses if c != None] + 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() try: - asses_page = get_course_assessment_page(course_names) + asses_page = get_course_assessment_page(course_names, offering) assessment = get_course_assessment(course_names, cutoff, asses_page) except HttpException as e: logging.error(e.message) @@ -74,18 +74,36 @@ async def whatsdue( await interaction.edit_original_response(content=e.message) return - message = ( - "_*WARNING:* Assessment information may vary/change/be entirely" - + " different! Use at your own discretion_\n> " + embed = discord.Embed( + title=f"What's Due: {', '.join(course_names)}", + url=asses_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.*", ) - message += "\n> ".join(map(self.get_formatted_assessment_item, assessment)) + if assessment: + for assessment_item in assessment: + course, task, due, weight = assessment_item + embed.add_field( + name=course, + value=f"`{weight}` {task} **({due})**", + inline=False, + ) + elif fulloutput: + embed.add_field( + name="", + value=f"No assessment items could be found", + ) + else: + embed.add_field( + name="", + value=f"Nothing seems to be due soon", + ) + if not fulloutput: - message += ( - "\n_Note: This may not be the full assessment list. Set fulloutput" - + "to True for the full list._" + 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." ) - message += f"\nLink to assessment page <{asses_page}|here>" - await interaction.edit_original_response(content=message) + + await interaction.edit_original_response(embed=embed) async def setup(bot: commands.Bot):