Skip to content

Commit

Permalink
Whatsdue: Added Sorting (#142)
Browse files Browse the repository at this point in the history
* Whatsdue: Added sorting by type.

* Whatsdue: Added course ECP links as an option

* Whatsdue: Fixed grammar for ECP link(s)

* Whatsdue: Added weeks_to_show to reduce output

* Removed surplus old TODOs and added typing to get_weight_as_int()

* Changed get_weight_as_int() to return 0 if no weight can be parsed

* Revert "Changed get_weight_as_int() to return 0 if no weight can be parsed"

This reverts commit 709071d.

---------

Co-authored-by: Andrew Brown <[email protected]>
  • Loading branch information
49Indium and andrewj-brown authored Oct 27, 2023
1 parent 05c1e05 commit eaadaf8
Show file tree
Hide file tree
Showing 2 changed files with 143 additions and 74 deletions.
132 changes: 81 additions & 51 deletions uqcsbot/utils/uq_course_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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:
Expand All @@ -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.
Expand All @@ -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)


Expand All @@ -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.
Expand All @@ -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]
Expand All @@ -384,7 +414,7 @@ def get_parsed_assessment_item(
# Handles weights of the form '30%<br/>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("<br/>")[0]
return (course_name, task, due_date, weight)
return AssessmentItem(course_name, task, due_date, weight)


class Exam:
Expand Down
85 changes: 62 additions & 23 deletions uqcsbot/whatsdue.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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):
Expand All @@ -26,32 +51,30 @@ 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"]
)
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.
Expand All @@ -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(
Expand All @@ -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:
Expand All @@ -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."
Expand Down

0 comments on commit eaadaf8

Please sign in to comment.