Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Whatsdue: Added Sorting #142

Merged
merged 9 commits into from
Oct 27, 2023
135 changes: 84 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,72 @@ 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:
# 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
49Indium marked this conversation as resolved.
Show resolved Hide resolved
# parse them in future. Will require manual detection + parsing.
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):
andrewj-brown marked this conversation as resolved.
Show resolved Hide resolved
"""
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 +301,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 +337,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 +345,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 +376,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 +393,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 +404,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 +417,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,20 +1,45 @@
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
from discord.ext import commands

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 @@ -24,29 +49,27 @@ 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.",
)
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 @@ -55,15 +78,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 @@ -76,15 +103,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 @@ -98,6 +125,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