diff --git a/README.md b/README.md index 80095b6522e157eed2f8f4782877397c907136a0..37669320c8061a853f25a1fcfa4ba6342d8acb7d 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,15 @@ A script for automated (pseudo-)GUI operations in MyCampus systems. ## Backlog -- TODO 1 GET form webpage -- TODO 1 retrieve group IDs +- TODO 1 compute dates/times - TODO 2 retrieve CSRF token - TODO 2 upload attachments and find URL - TODO 2 submit incomplete form (82 params!) and retrieve error messages? -- TODO 2 -- TODO 2 -- TODO 2 -- TODO 2 +- TODO 2 check type of config values +- TODO 2 detect lack of login +- TODO 3 simplify group names by removing the parens (coursename, groupname) +- TODO 3 make script usable for non-group-specific assignments +- TODO 3 implement "peer assessment" part of form ## Development history @@ -20,3 +20,6 @@ A script for automated (pseudo-)GUI operations in MyCampus systems. - 2024-12-10 read YAML - 2024-12-10 find_value_or_print_help() - 2024-12-10 obtain SAKAI2SESSIONID cookie (in fact all cookies) +- 2024-12-12 GET form webpage +- 2024-12-12 retrieve group IDs +- 2024-12-13 read relative deadlines, compute absolute deadlines diff --git a/mycampusscript.py b/mycampusscript.py index 7e386f84762bc77609aaa1673e0656030c09a1fa..69358ed2ea463d1883bb97dc15c910e530cd3fdd 100644 --- a/mycampusscript.py +++ b/mycampusscript.py @@ -1,3 +1,6 @@ +import collections.abc +import datetime as dt +import random import re import sys import typing as tg @@ -25,11 +28,73 @@ cookies_help = """cookies: a dictionary of cookie names to cookie values copy the entire line that starts with "Cookie:" and paste into your config as the value for the 'cookies' config parameter; you must enclose it in double quotes. """ +group_ids_help = """group_ids: a dictionary of group_id->groupname as indicated in the respective + MyCampus form page HTML. The values the script has found are the following (you can copy-and-paste + this directly into your config file): +group_ids: +{group_ids_yaml} +""" +assignment_title_help = """assignment_title: a string as would be provided in the form""" +assignment_instructions_help = """assignment_instructions: an HTML fragment (at least <p>some instructions text</p>)""" +attachments_help = """attachments: list of filenames (the basename will be visible in the assignment) like this: +attachments: + - mypath/file1.pdf + - otherpath/image.jpg +""" +open_date_help = """open_date: A date and time string when the assignment will become visible, e.g. +open_date: 2024-12-13 14:44 +""" +base_due_date_help = """base_due_date: yyyy-mm-dd (due dates will be computed relative to midnight of that day)""" +accept_until_additional_minutes_help = """accept_until_additional_minutes: number of minutes after due_date +that the assignment stays open, e.g. +accept_until_additional_minutes: 5 +""" +relative_due_dates_help = """relative_deadlines: mapping from group_id to relative time. You can copy-paste-modify +the following with true group_ids and random times: +relative_deadlines: +{relative_deadlines_yaml} +""" +send_a_reminder_help = """send_a_reminder: flag, see the web dialog, e.g. +send_a_reminder: false +""" +hide_due_date_from_students_help = """hide_due_date_from_students: flag, see the web dialog""" +add_due_date_to_calendar_help = """add_due_date_to_calendar: flag, see the web dialog""" +add_an_announcement_help = """add_an_announcement: flag, see the web dialog""" +submission_notification_email_help = """submission_notification_email: flag whether to send daily notifications, +see the web dialog, e.g. +submission_notification_email: true +""" +grade_this_assignment_help = """grade_this_assignment: flag. +If true, the grade scale type is always 'points'. +""" +max_points_help = """max_points: int, see the web dialog.""" +gradebook_categories_help = """gradebook_categories: a dictionary of category_id->category_name as indicated + in the respective MyCampus form page HTML. The values the script has found are the following + (you can copy-and-paste this directly into your config file): +gradebook_categories: +{gradebook_categories_yaml} + 0: do not send anything to gradebook at all +""" +send_grades_to_gradebook_category_help = """send_grades_to_gradebook_category: code from the above table, e.g. +send_grades_to_gradebook_category: -1 +""" +released_grade_notification_email_help = """released_grade_notification_email: bool, e.g. +released_grade_notification_email: false +""" -ASSIGNMENTS_CREATION_FORM_PATH = 'tool/1f8f054a-4e58-4965-8d32-141b236e0023?panel=Main' +ASSIGNMENTS_CREATION_FORM_PATH = 'tool/1f8f054a-4e58-4965-8d32-141b236e0023?sakai_action=doNew_assignment' +ASSIGNMENTS_CREATION_ATTACHMENT_UPLOAD_PATH = 'tool/1f8f054a-4e58-4965-8d32-141b236e0023/sakai.filepicker.helper' +SESSIONCOOKIE_NAME = 'SAKAI2SESSIONID' +ASSIGNMENT_FORM_ID = 'newAssignmentForm' +CSRF_TOKEN_CONFIGENTRY_NAME = 'csrf_token' +CSRF_TOKEN_HTML_NAME = 'sakai_csrf_token' +RBCS_TOKEN_CONFIGENTRY_NAME = 'rbcs_token' +RBCS_TOKEN_HTML_NAME = 'rbcs-token' +StrStrDict = dict[str, str] StrAnyDict = dict[str, tg.Any] +ReplacementsFunc = collections.abc.Callable[[StrAnyDict], StrStrDict] def main(scriptname: str, cmdname: str, configfile: str): @@ -37,20 +102,112 @@ def main(scriptname: str, cmdname: str, configfile: str): config = yaml.load(f, Loader=yaml.Loader) site_url: str = find_value_or_help(config, 'site_url') parse_cookies(config) - group_ids: str = find_value_or_help(config, 'group_ids') + obtain_tokens(config) + group_ids: StrStrDict = find_value_or_help(config, 'group_ids', dynamic=get_group_ids) + assignment_title: str = find_value_or_help(config, 'assignment_title') + assignment_instructions: str = find_value_or_help(config, 'assignment_instructions') + attachments: list[str] = find_value_or_help(config, 'attachments') + open_date: str = find_value_or_help(config, 'open_date') + open_date_dt: dt.datetime = dt.datetime.strptime(open_date, "%Y-%m-%d %H:%M") + base_due_date: dt.date = find_value_or_help(config, 'base_due_date') + accept_until_additional_minutes: int = find_value_or_help(config, 'accept_until_additional_minutes') + accept_until_td: dt.timedelta = dt.timedelta(minutes=accept_until_additional_minutes) + relative_due_dates: StrStrDict = find_value_or_help(config, 'relative_due_dates', dynamic=get_deadlines) + absolute_due_dates = compute_due_dates(base_due_date, relative_due_dates) + # for dl in absolute_due_dates.values(): + # print(dt.datetime.strftime(dl, "%Y-%m-%d %H:%M")) + send_a_reminder: bool = find_value_or_help(config, 'send_a_reminder') + hide_due_date_from_students: bool = find_value_or_help(config, 'hide_due_date_from_students') + add_due_date_to_calendar: bool = find_value_or_help(config, 'add_due_date_to_calendar') + add_an_announcement: bool = find_value_or_help(config, 'add_an_announcement') + # TODO 3: ask for submission type + # TODO 3: handle resubmission logic (complicated!) + submission_notification_email: bool = find_value_or_help(config, 'submission_notification_email') + grade_this_assignment: bool = find_value_or_help(config, 'grade_this_assignment') + max_points: int = find_value_or_help(config, 'max_points') + gradebook_categories: int = find_value_or_help(config, 'gradebook_categories', + dynamic=get_gradebook_categories) + send_grades_to_gradebook_category: int = find_value_or_help(config, 'send_grades_to_gradebook_category') + released_grade_notification_email: bool = find_value_or_help(config, 'released_grade_notification_email') + upload_attachments(config, attachments) -def find_value_or_help(config: StrAnyDict, key: str) -> tg.Any: +def find_value_or_help(config: StrAnyDict, key: str, dynamic: ReplacementsFunc = None) -> tg.Any: if key in config: return config[key] the_help = globals()[f"{key}_help"] - if callable(the_help): - !!! - the_help = ... + if dynamic: + replacements = dynamic(config) + the_help = the_help.format(**replacements) b.critical(the_help) +def compute_due_dates(base: dt.date, relative: StrStrDict) -> dict[str, dt.datetime]: + base_dt = dt.datetime(base.year, base.month, base.day) + result = dict() + for group_id, relative_dl in relative.items(): + mm = re.fullmatch(r"(\d+)d(\d+)h(\d+)m", relative_dl) + if not mm: + b.critical(f"Ill-formed relative deadline '{relative_dl}', should be like '4d14h0m'") + days, hours, minutes = int(mm.group(1)), int(mm.group(2)), int(mm.group(3)) + delta = dt.timedelta(days=days, hours=hours, minutes=minutes) + result[group_id] = base_dt + delta + return result + + +def get_gradebook_categories(config: StrAnyDict) -> StrStrDict: + soup = get_formpage_soup(config) + categories = soup.find(id='category') + lines = [] + for option in categories.find_all('option'): + # <option value="706" >Notizzettel (insg. >= n-2)</option> + _id = option['value'] + name = str(option.string) # e.g. Notizzettel (insg. >= n-2) + lines.append(f" {_id}: \"{name}\"") + return dict(gradebook_categories_yaml="\n".join(lines)) + + +def get_group_ids(config: StrAnyDict) -> StrStrDict: + """ + GET the form page and extract the list of group names and group IDs, called selectedGroups. + Store the formpage as config['formpage_soup']. + """ + soup = get_formpage_soup(config) + options = soup.find(id='selectedGroups') + lines = [] + for option in options.find_all('option'): + # <option value="3fe41c9..." >Groupname (Coursename, Groupname) </option> + _id = option['value'] + name = str(option.string) # Groupname (Coursename, Groupname) + lines.append(f" {_id}: \"{name}\"") + return dict(group_ids_yaml="\n".join(lines)) + + +def get_deadlines(config: StrAnyDict) -> StrStrDict: + lines = [] + for group_id, group_name in config['group_ids']: + deadline = "%dd%02dh00m" % (random.randint(1, 5), 2*random.randint(4, 8)) + lines.append(f" {group_id}: {deadline}") + return dict(relative_deadlines_yaml="\n".join(lines)) + + +def get_formpage_soup(config: StrAnyDict) -> bs4.BeautifulSoup: + """ + Retrieves content of form webpage as a soup from config if present or + else GETs it first and stores it there. + """ + if 'formpage_soup' in config: + return config['formpage_soup'] + url = f"{config['site_url']}/{ASSIGNMENTS_CREATION_FORM_PATH}" + sessioncookie = config['cookies'][SESSIONCOOKIE_NAME] + response = requests.get(url, cookies={SESSIONCOOKIE_NAME: sessioncookie}) + assert response.status_code == 200 + config['formpage_soup'] = bs4.BeautifulSoup(response.text, 'html.parser') + return config['formpage_soup'] + + def parse_cookies(config: StrAnyDict): + """Sets config['cookies'] = dict(cookiename=value, ...)""" def perhaps_complain(mm): if not mm: b.critical("config param 'cookies' must have format 'Cookie: \"NAME1=value1; name2=value\"") @@ -64,10 +221,36 @@ def parse_cookies(config: StrAnyDict): mm = re.fullmatch(r"(.+?)=(.+)", pair) # split at the first equals sign perhaps_complain(mm) result[mm.group(1)] = mm.group(2) - print(result) + # print(result) config['cookies'] = result +def obtain_tokens(config: StrAnyDict): + """Find CSRF token and RBCS token in form web page and store them in config""" + soup = get_formpage_soup(config) + form = soup.find(id=ASSIGNMENT_FORM_ID) + csrftoken_input_elem = form.find('input', attrs=dict(name=CSRF_TOKEN_HTML_NAME)) + rbcstoken_input_elem = form.find('input', attrs=dict(name=RBCS_TOKEN_HTML_NAME)) + config[CSRF_TOKEN_CONFIGENTRY_NAME] = csrftoken_input_elem['value'] + config[RBCS_TOKEN_CONFIGENTRY_NAME] = rbcstoken_input_elem['value'] + + +def upload_attachments(config: StrAnyDict, attachments: list[str]): + """ + Make POST request with multipart/formdata body, many headers, and the following format of URL: + POST /portal/site/<site-id>/tool/1f8f054a-4e58-4965-8d32-141b236e0023/sakai.filepicker.helper?special=upload&sakai_csrf_token=<hexstring>&sakai_action=doAttachupload + See c:/temp/a.json log.entries[164]. + """ + base_url = f"{config['site_url']}/{ASSIGNMENTS_CREATION_ATTACHMENT_UPLOAD_PATH}" + query_args = dict(special='upload', panel='Main', sakai_action='doAttachupload', + csrf_token=config[CSRF_TOKEN_CONFIGENTRY_NAME]) + # TODO 1: continue here: + # We next need to construct the multipart body of the POST request. + # See "POST Multiple Multipart-Encoded Files" in https://requests.readthedocs.io/en/latest/user/advanced/ + # We have so far failed to obtain the HTML of the form to be posted, which we need to find the + # name of the attachment file form element. + + if __name__ == '__main__': if len(sys.argv) != 2+1 or sys.argv[1] != 'create_multigroup_assgmt': print(usage)