+ #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:
s = f'{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
s += f', search:' + ' '.join([f'{k}={str(v).strip()}' for k,v in self if v and k not in ['metadata', 'subj', 'crsno']])
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))
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",
- obj.metadata.user[0].list = [c for c in obj.metadata.user[0].list if check.match(c)]
+ obj.metadata.user[user_id] = [c for c in obj.metadata.user[user_id] if check.match(c)]
#after we sort through that section should not be bound to a specific section now since the data is in user.list
#we do need to set data though to allow other things (e.g. try_ping) to match on whether its in search or section mode
obj.section = True
- courses = set(obj.metadata.user[0].list)
+ courses = set(obj.metadata.user[user_id])
#now we have the proper name for the pinger
name = str(obj)
logger.info(f'Adding {name} to the pingers...')
merged = False
- for pinger_name, pinger_obj in data: #defattrdict not only for easier editing but also for copying for iteration
+ 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)
against = set(pinger_obj.metadata.courses)
#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 courses.issuperset(against):
logger.info(f' Optimization: merging {pinger_name} into {name}')
#done optimization for when multiple people are looking at the same course but want different sections pinged (and not all of them)
#TODO 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 the menu instead of sending another ephemeral msg since we cant delete the old one
#TODO 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],
}
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
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)
-class RemoveCourseList(ui.View):
+#common class for listing courses (used by remove pingers, and also pinger list)
+ raise ValueError("Menu options cannot be empty")
+ super().__init__()
+ #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)
+ self.add_item(self.menu)
+
+
+class RemoveCourseList(SingleMenu):
#i love python asyncio : ) why the fuck
@classmethod
- async def create(self, user):
+ async def create(cls, user):
if not await bot.is_owner(user):
- pingers = [opt(s) for s in get_pingers_with_user(user.id)]
+ 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)
-#TODO 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
+#TODO 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))
+#TODO that button would probably also work as a filtering system for when you dont need that many courses anymore