#json automatically turns int to strings when in keys, so we normalize it ourselves too
def __delitem__(self, __key) -> None:
return super().__delitem__(str(__key)) if isinstance(__key, int) else super().__delitem__(__key)
def __setitem__(self, __key, __value):
return super().__setitem__(str(__key), __value) if isinstance(__key, int) else super().__setitem__(__key, __value)
#TODO seems like this change made it so that the data that should be empty is now '' in json? maybe its only after merge
def __getitem__(self, __key):
return super().__getitem__(str(__key)) if isinstance(__key, int) else super().__getitem__(__key)
def __contains__(self, __key) -> bool:
return super().__contains__(str(__key)) if isinstance(__key, int) else super().__contains__(__key)
__getattr__ = __getitem__
__setattr__ = __setitem__
__delattr__ = __delitem__
#recursively convert all dicts/list into defattrdicts
def convert(self, data):
iter = data.items() if isinstance(data, dict) else enumerate(data)
for key, val in iter:
if type(val) in [dict, list]:
if type(val) == dict:
data[key] = coursedict(val)
self.convert(data[key])
def __init__(self, data: dict):
super().__init__(lambda: '', data)
self.convert(self)
def __str__(self) -> str:
if self.subj:
#since session is now automatically reconciled at add_course we can basically assume sessyr and sesscd always exist
s = f'{self.sessyr}{self.sesscd} | {self.subj} {self.crsno}'
if self.section:
s += f' (section mode)'
else:
#funny ljust needed in actv so strip is needed to revert it on show (TODO activity shows up on v being entirely whitespaces still)
s += f', search:' + ' '.join([f'{k}={str(v).strip()}' for k,v in self if v and k not in ['metadata', 'subj', 'crsno', 'sessyr', 'sesscd']])
return s
else:
return dict.__repr__(self)
#remove the default factory print
__repr__ = dict.__repr__
#implicitly allow iter on items()
def __iter__(self):
return self.items().__iter__()
def get_data():
with open('courses.json') as f:
data = f.read()
return coursedict(json.loads(data)) if data else {}
def set_data(obj):
with open('courses.json', 'w') as data:
data.write(json.dumps(obj, indent=2))
session_re = re.compile('Session: (\\d{4} \\w+)')
def try_ping(pinger: coursedict):
optional = lambda fmtstr, *data: fmtstr.format(*data) if all(d for d in data) else ''
#must include user agent, accept, content type (if post), otherwise ssc will complain
#if no pname+tname specified, session cookie is needed to avoid "Please wait while your request is processed - this may take up to a minute to complete."
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/114.0",
link = soup.find('a', string=re.sub(session_re, '\\1', current_session.text))
if link:
#i have no idea why but there just seem to be no elegant way to get the raw html entities that beautifulsoup incorrectly changed so ill just do the good ol replace method
session = ('', '') #should also mean that ret should be empty, in which case the usual failure scenarios would trigger (but we dont force a failure here just in case SSC has some quirks that breaks session parsing but still works fine)
ret = {}
#remove thead, since we cant search directly with tbody as its doesnt necessarily exist in the doc
thead = soup.select_one('.section-summary thead')
if thead:
thead.clear()
#table likely doesnt exist if thead doesnt, but in any case continue to the for loop since that wont run if its empty anyways
for child in soup.select('.section-summary tr'):
section = child.select_one("td:nth-of-type(2) a")
#some sections have multiple timespans, so ignore the second part of that which has no section and status elements (see CPEN 491F 001 for example)
if not section:
continue
status = child.select_one("td:nth-of-type(1)").text
- ret[section.text] = 'https://courses.students.ubc.ca' + section['href'], (status if status != '\xa0' and status.strip() else 'PING')
+ ret[section.text] = 'https://courses.students.ubc.ca' + section['href'], (status if status != '\xa0' and status.strip() else 'Available')
return session, ret
#pinger.metadata.user is now a dict instead of a list of id and list - this allows us to fix double firing on merge,
#even though that also means on merge it will not be consistent with separate pingers that has overlapping sections
#as i believe most ppl wont want double firing at all (coz why its literally useless and more annoying)
#this change will allow the bot to be able to stop double firing, and have a single consistent list under ViewSectionList
#and also simplifies viewing (even though it complicates merging))
for pinger_name, pinger_obj in coursedict(data): #coursedict not only for easier editing but also for copying for iteration (otherwise "dict was modified during iteration" might pop up)
#only check merging if they are the same session
if pinger_obj.sessyr == obj.sessyr and pinger_obj.sesscd == obj.sesscd:
#there should only be one of the cases below: either existing pinger(s) are subsets of the current one, or this pinger is a subset of an existing pinger
#otherwise it shouldve been optimized out since it is sequentially added and this optimzation runs every time
if total_sections.issuperset(get_sections_in_use(pinger_obj)): #pinger has all the used sections of another pinger, assimilate since the rest arent (and likely wont) be used
logger.info(f' Optimization: merging {pinger_name} into {name}')
elif set(pinger_obj.metadata.courses).issuperset(used_sections): #we dont need to do get_sections_in_use(obj) here since we know for sure that obj.metadata.user only has a single id since we are adding it
#this should be a terminating action to avoid adding it into multiple pingers when one is already handling it
#(think of a case where existing courses are [1234, 1256] and our pinger is [12] - 2 pingers match, but only 1 is needed)
#done optimization for when multiple people are looking at the same course but want different sections pinged (and not all of them)
#done currently the way courses in section mode are handled means that they basically dont get optimized into search modes
#it would be better if pinger.metadata.courses tracks the total set of courses its users are pinging instead,
#and then have the optimizer do special code on reading section mode to not match on the courses in the list, but on the course names
#aka if everything a user is pinging has the same prefix of CPSC 110 for example,
#then just throw it into CPSC 110 section mode regardless even if currently the CPSC 110 pinger.metadata.courses does not have it
#this means that the following has to be changed:
# - instead of obj.metadata.courses = list(resp.keys()), do obj.metadata.courses = <total of all user lists>
# - at the superset checks, add check on whether everything being added is in the same course, and if there is a course pinger in section mode for that already
#EDIT: actually its way easier (and accomodates the current system of how a user can arbitrarily remove sections that they dont need from any mode) to just compare the total sections of a pinger against the superset of all users' sections to see if it can be assimilated
#since pinger.metadata.courses tracks every course a pinger can ping, if someone has a pinger that they are not utilizing well and can be done under another pinger, even if the pinger itself supports a lot more its not necessary in the current stage
#(we can manually optimize it later if someone decides they need the pinger, so we can just put it under the other pinger for now)
#this also allows ppl to help optimizing other ppls pingers by adding an optimized pinger for a certain set of pingers, let the automerging happen, and then remove their own pings on it
#see get_sections_in_use above
def get_pingers_with_user(id):
return [name for name, pinger in get_data() if id in pinger.metadata.user]
#edit the menu instead of sending another ephemeral msg since we cant delete the old one
#done figure out a better way to print since ill be using that to show a dropdown menu for the removal ui too
await interaction.response.edit_message(content=f'The pinger `{name}` is now tracking the requested classes! This includes the following:\n```\n{added}\n```\nYou will now get pinged when it opens up.', view=None)
except Exception as e:
logger.error('AddCourseSearch fail:', exc_info=e)
await interaction.response.edit_message(content=f'Something went wrong (likely wrong params): {type(e)}\n{e}', view=None)
class AddCourseBase(ui.Modal, title='Add a course to ping'):
#cringe discord.py does some cringe things on reading global members but not the constructors before calling super.__init__ so put it here instead even though this means the values are shared across all instances
#response in this case is the same msg as the button, dont do anything to it
await interaction.response.defer()
#parse session
self.sesobj = {}
if self.session.value:
self.sesobj = {
'sessyr': self.session.value[:4],
'sesscd': self.session.value[-1],
}
+ #parse types if any
+ self.types = [type_mapping[s.strip()] for s in self.statuses.value.split(',')] if self.statuses.value else ['Available']
+
if not self.section.value:
#discord doesnt allow us to send another modal right away? (yep check InteractionResponseType, 9 is modal and not in the list)
await interaction.followup.send('Configure the search parameters below:', view=AddCourseSearch(self), ephemeral=True)
else:
if '*' in self.subj.value or '*' in self.course.value:
await interaction.response.send_message('Wildcards for subject and courses are not allowed in section mode! Leave section blank to do a broad search.', ephemeral=True)
return
obj = coursedict({
'subj': self.subj.value,
'crsno': self.course.value,
#even though we dont actually use the wildcard we need to differentiate between no section and a fully wildcard section so dont remove yet
'section': self.section.value,
**self.sesobj,
#we are currently not in coursedict yet so the int to str normalization doesnt apply here, apply ourselves
await interaction.followup.send(f'The pinger `{name}` is now tracking the requested classes! This includes the following:\n```\n{added}\n```\nYou will now get pinged when it opens up.', ephemeral=True)
except Exception as e:
logger.error('AddCourseBase fail:', exc_info=e)
await interaction.followup.send(content=f'Something went wrong (likely wrong params): {type(e)}\n{e}', ephemeral=True)
#TODO dm option? or maybe just only allow dms (iirc the reason why i dont dm is coz theres no mechanism to dm multiple ppl or sth so i just send a single msg to ping everyone)
#common class for listing courses (used by remove pingers, and also pinger list)
#to be consistent we should use self.user instead of interaction.user since they might be different (even though when ephemeral=True they should be the same)
self.user = user
self.menu = ui.Select(placeholder=placeholder, options=[opt(s) for s in list], min_values=1, max_values=len(list) if not single else 1, custom_id=menu_id)
+ del data[self.pinger_name].metadata.user[self.user.id][name.split(':')[0]] #name is currently formatted with status types too so strip them
+ #remove empty pingers if needed
+ if not data[self.pinger_name].metadata.user[self.user.id]:
+ logger.info(f"User {self.user.id} requires no more pings from {data[self.pinger_name]}, removing...")
+ del data[self.pinger_name].metadata.user[self.user.id]
+ if not data[self.pinger_name].metadata.user:
+ logger.info(f"{data[self.pinger_name]} is now empty, removing...")
+ del data[self.pinger_name]
set_data(data)
values = "\n".join(interaction.data['values'])
await interaction.response.edit_message(content=f"Successfully removed your selected sections from the pinger `{self.pinger_name}` as follows:\n```\n{values}\n```\nYou will no longer get pinged even if the pinger sees the sections.", view=None)
await interaction.followup.send("Choose a pinger to view sections", view=await ViewCourseList.create(interaction.user), ephemeral=True)
except ValueError:
await interaction.followup.send("You currently have no pingers!", ephemeral=True)
#done remove button (only allow ppl to remove their own, except if user id matches me so i can actually do cleanup and stuff without going into the server)
#done button for viewing what courses you get pinged for in a pinger? rn its not too descriptive since the optimizer makes it the broadest possible (now that ppl can remove sections the searches might also need to be refined the same way (aka dynamically resize on remove or sth; idk sounds kinda hard))
#done that button would probably also work as a filtering system for when you dont need that many courses anymore
#EDIT: view pinged sections added; its probably fine to leave the optimizer as is without doing resizing - it's a lot of overhead for little benefit especially when theres a mechanism for ppl to now manually do it for each other anyways