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)