Skip to content

Commit

Permalink
Added semester, campus and mode options to /whatsdue (#125)
Browse files Browse the repository at this point in the history
* Fixed bug where workingon calls same person twice

* Fixed blank output for empty whatsdue

* Revert "Fixed bug where workingon calls same person twice"

This reverts commit 7060da2.

* Whatsdue: Updated formatting for black

* Added semester, mode and campus options to whatsdue

* Added more campuses and modes to whatsdue

* Fixed formatting

* whatsdue: changed to embed

* Whatsdue: Fixed typing

* Whatsdue: added intensive mode

Also replaced binascii andmodified the embed to emphasise that the list of assessment may not be correct.

* Fixed typing
  • Loading branch information
49Indium authored Jul 13, 2023
1 parent 77904eb commit 8a044d8
Show file tree
Hide file tree
Showing 3 changed files with 215 additions and 71 deletions.
2 changes: 0 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
224 changes: 176 additions & 48 deletions uqcsbot/utils/uq_course_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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="
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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):
Expand All @@ -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:
Expand All @@ -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 :]
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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.
Expand Down Expand Up @@ -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"
Expand Down
Loading

0 comments on commit 8a044d8

Please sign in to comment.