Skip to content
Snippets Groups Projects
Commit 53325e94 authored by prechelt's avatar prechelt
Browse files

mycampusscript.py: all form entries in the config file implemented; CSRF token obtained

parent e9c83396
No related branches found
No related tags found
No related merge requests found
...@@ -4,15 +4,15 @@ A script for automated (pseudo-)GUI operations in MyCampus systems. ...@@ -4,15 +4,15 @@ A script for automated (pseudo-)GUI operations in MyCampus systems.
## Backlog ## Backlog
- TODO 1 GET form webpage - TODO 1 compute dates/times
- TODO 1 retrieve group IDs
- TODO 2 retrieve CSRF token - TODO 2 retrieve CSRF token
- TODO 2 upload attachments and find URL - TODO 2 upload attachments and find URL
- TODO 2 submit incomplete form (82 params!) and retrieve error messages? - TODO 2 submit incomplete form (82 params!) and retrieve error messages?
- TODO 2 - TODO 2 check type of config values
- TODO 2 - TODO 2 detect lack of login
- TODO 2 - TODO 3 simplify group names by removing the parens (coursename, groupname)
- TODO 2 - TODO 3 make script usable for non-group-specific assignments
- TODO 3 implement "peer assessment" part of form
## Development history ## Development history
...@@ -20,3 +20,6 @@ A script for automated (pseudo-)GUI operations in MyCampus systems. ...@@ -20,3 +20,6 @@ A script for automated (pseudo-)GUI operations in MyCampus systems.
- 2024-12-10 read YAML - 2024-12-10 read YAML
- 2024-12-10 find_value_or_print_help() - 2024-12-10 find_value_or_print_help()
- 2024-12-10 obtain SAKAI2SESSIONID cookie (in fact all cookies) - 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
import collections.abc
import datetime as dt
import random
import re import re
import sys import sys
import typing as tg import typing as tg
...@@ -25,11 +28,73 @@ cookies_help = """cookies: a dictionary of cookie names to cookie values ...@@ -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 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. 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] StrAnyDict = dict[str, tg.Any]
ReplacementsFunc = collections.abc.Callable[[StrAnyDict], StrStrDict]
def main(scriptname: str, cmdname: str, configfile: str): def main(scriptname: str, cmdname: str, configfile: str):
...@@ -37,20 +102,112 @@ 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) config = yaml.load(f, Loader=yaml.Loader)
site_url: str = find_value_or_help(config, 'site_url') site_url: str = find_value_or_help(config, 'site_url')
parse_cookies(config) 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: if key in config:
return config[key] return config[key]
the_help = globals()[f"{key}_help"] the_help = globals()[f"{key}_help"]
if callable(the_help): if dynamic:
!!! replacements = dynamic(config)
the_help = ... the_help = the_help.format(**replacements)
b.critical(the_help) 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): def parse_cookies(config: StrAnyDict):
"""Sets config['cookies'] = dict(cookiename=value, ...)"""
def perhaps_complain(mm): def perhaps_complain(mm):
if not mm: if not mm:
b.critical("config param 'cookies' must have format 'Cookie: \"NAME1=value1; name2=value\"") b.critical("config param 'cookies' must have format 'Cookie: \"NAME1=value1; name2=value\"")
...@@ -64,10 +221,36 @@ def parse_cookies(config: StrAnyDict): ...@@ -64,10 +221,36 @@ def parse_cookies(config: StrAnyDict):
mm = re.fullmatch(r"(.+?)=(.+)", pair) # split at the first equals sign mm = re.fullmatch(r"(.+?)=(.+)", pair) # split at the first equals sign
perhaps_complain(mm) perhaps_complain(mm)
result[mm.group(1)] = mm.group(2) result[mm.group(1)] = mm.group(2)
print(result) # print(result)
config['cookies'] = 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 __name__ == '__main__':
if len(sys.argv) != 2+1 or sys.argv[1] != 'create_multigroup_assgmt': if len(sys.argv) != 2+1 or sys.argv[1] != 'create_multigroup_assgmt':
print(usage) print(usage)
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment