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."