Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

code refactor and cleanup blackboard_upload #2

Draft
wants to merge 4 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
*.json
*.csv
*.tok
!examples/*
339 changes: 249 additions & 90 deletions blackboard_upload.py
Original file line number Diff line number Diff line change
@@ -1,125 +1,284 @@
#!/usr/bin/env python3
import argparse

from argparse import ArgumentParser, FileType, Namespace
from datetime import datetime
import time
import pandas as pd
from pandas.api.types import is_numeric_dtype
from time import sleep
from json import load as json_load
from csv import DictReader as csv_dictreader
from collections.abc import Mapping

import requests
import pathlib
from dateutil.parser import parse
from dateutil.tz import gettz, UTC

LOCAL_TIMEZONE = "Australia/Sydney"

HEADER_ROWS = ['name', 'start', 'end', 'recurr']

BB_POST_URL = "https://au-lti.bbcollab.com/collab/api/csa/sessions"
BB_DELETE_URL = "https://au-lti.bbcollab.com/collab/api/csa/sessions/{sid}/occurrences/{oid}"

def enable_logging():
import logging

def parse_args() -> Namespace:
"""
Parse command-line args
expect 1-3 readable files
:return: namespace object of command-line args
"""

parser = ArgumentParser(description="Upload classes to Blackboard Collaborate")

parser.add_argument("classes_csv", help="csv file containing classes", type=FileType('r'))
parser.add_argument("config_json", help="json file containing global session config", type=FileType('r'), default=None, nargs='?')
parser.add_argument("-t", "--token", help="Your auth token for BB Collaborate.", type=FileType('r'), default=None)
parser.add_argument("-d", "--debug", help="Turn on requests debug logging", action='store_true')

args = parser.parse_args()

return args


def enable_logging() -> None:
"""
Enable logging within the `requests` package
:return: None
"""

import logging
import http.client as http_client

http_client.HTTPConnection.debuglevel = 1

# You must initialize logging, otherwise you'll not see debug output.
logging.basicConfig()
logging.getLogger().setLevel(logging.DEBUG)
requests_log = logging.getLogger("requests.packages.urllib3")
requests_log.setLevel(logging.DEBUG)
requests_log.propagate = True

def parse_args():
def is_valid_file(parser, arg):
arg = pathlib.Path(arg)
if not arg.exists():
parser.error("The file %s does not exist!" % str(arg))
else:
return arg

parser = argparse.ArgumentParser(description="Upload classes to Blackboard Collaborate")
parser.add_argument("classes_csv", help="csv file containing classes",
type=lambda x: is_valid_file(parser, x))
parser.add_argument("token", help="Your auth token for BB Collaborate.")
parser.add_argument("-d", "--debug", help="Turn on debug logging", action='store_true')
args = parser.parse_args()

return args

def parse_classes(classes_csv):
df = pd.read_csv(classes_csv)
if list(df) != HEADER_ROWS:
raise ValueError(f"File {classes_csv} did not have the headers: {HEADER_ROWS}")

# This doesn't deal with UTC
for col in ['start', 'end']:
df[col] = pd.to_datetime(df[col])
print(df[col])
df[col] = df[col].dt.tz_localize(LOCAL_TIMEZONE)

if not is_numeric_dtype(df['recurr']):
raise ValueError("Recurr column must be numeric.")
def get_authed_session(token: str) -> requests.Session:
"""
Get an authorised Blackboard Collaborate session for the `requests` package
:param token: A Blackboard Collaborate login token manually taken from an authed request
:return: An authorised requests session
"""

return df.to_dict('records')

def get_authed_session(token):
s = requests.Session()
s.headers.update({'Authorization': f'Bearer {token}'})
test_request = s.get(BB_POST_URL)

test_request = s.get("https://au-lti.bbcollab.com/collab/ui/scheduler/session")
if not test_request.ok:
raise ValueError("Could not connect to BB Collab with given token.")
raise ValueError(
f"Could not connect to BB Collab with given token. {test_request.status_code}: {test_request.reason}"
)

return s

def bb_json_from_dict(bb_class):
datestamp = datetime.now().isoformat()
day_of_week = bb_class['start'].strftime("%A").lower()[:2]

return {
"startTime":bb_class['start'].isoformat(),
"endTime":bb_class['end'].isoformat(),
"created":datestamp,
"modified":datestamp,
"canDownloadRecording":False,
"showProfile":False,
"canShareAudio":True,
"canShareVideo":True,
"canPostMessage":True,
"canAnnotateWhiteboard":True,
"allowGuest":False,
"canEnableLargeSession":False,
"telephonyEnabled":True,
"mustBeSupervised":False,
"ltiParticipantRole":"participant",
"boundaryTime":15,
"guestRole":"participant",
"participantCanUseTools":True,

def parse_dict(config: dict) -> dict:
"""
:param config:
:return:
"""

config["occurrenceType"] = "P" if "recurrenceEndType" in config else "S"

if "recurrenceRule" not in config:
config["recurrenceRule"] = {}

if "recurrenceType" in config:
config["recurrenceRule"]["recurrenceType"] = config["recurrenceType"]
if config["recurrenceEndType"] not in ["daily", "weekly", "monthly"]: raise ValueError()
del config["recurrenceType"]

if "interval" in config:
config["recurrenceRule"]["interval"] = int(config["interval"])
del config["interval"]

if "recurrenceEndType" in config:
if config["recurrenceEndType"] not in ["after_occurrences_count", "on_date"]: raise ValueError()
config["recurrenceRule"]["recurrenceEndType"] = config["recurrenceEndType"]
del config["recurrenceEndType"]

if "daysOfTheWeek" in config:
config["recurrenceRule"]["daysOfTheWeek"] = list(map(str.strip, config["daysOfTheWeek"].strip().split(",")))
del config["daysOfTheWeek"]
elif "startTime" in config:
config["recurrenceRule"]["daysOfTheWeek"] = [parse(config['startTime']).strftime("%A").lower()[:2]]

if "numberOfOccurrences" in config:
config["recurrenceRule"]["numberOfOccurrences"] = int(config["numberOfOccurrences"])
del config["numberOfOccurrences"]

if "endDate" in config:
config["recurrenceRule"]["endDate"] = parse(config["endDate"]).isoformat()
del config["endDate"]

if "boundaryTime" in config:
config["boundaryTime"] = int(config["boundaryTime"])

if "startTime" in config:
config["startTime"] = parse(config["startTime"]).replace(tzinfo=gettz(LOCAL_TIMEZONE)).astimezone(UTC).isoformat()

if "endTime" in config:
config["endTime"] = parse(config["endTime"]).replace(tzinfo=gettz(LOCAL_TIMEZONE)).astimezone(UTC).isoformat()

return config


def bb_json_from_dict(bb_course_config: dict, bb_class_config: dict) -> dict:
"""
:param bb_course_config:
:param bb_class_config:
:return:
"""
def update(d, u):
"""
:param d:
:param u:
:return:
"""
for k, v in u.items():
if isinstance(v, Mapping): d[k] = update(d.get(k, {}), v)
else: d[k] = v
return d

datestamp = datetime.now().astimezone(UTC).isoformat()

# If `largeSessionEnable` is `True` then `noEndDate` MUST be false and `occurrenceType` MUST be `S`
# If `noEndDate` is `True` then `occurrenceType` MUST be `S`
# IF `occurrenceType` is `True` then `noEndDate` MUST be false

bb_default_config = {
# Session name {string} (must be provided)
"name": None,
#################
# Event Details #
#################
# Guest access [True, False]
"allowGuest": True,
# Guest role ["participant", "presenter", "moderator"]
"guestRole": "participant",
# timestamp of the start of the *first* session {datetimestamp} (must be provided)
"startTime": None,
# timestamp of the end of the *first* session {datetimestamp} (must be provided)
"endTime": None,
# hidden, timestamp of when this session was created {datetimestamp} (used for ordering)
"created": datestamp,
# hidden, timestamp of when this session was last edited {datetimestamp}
"modified": datestamp,
# hidden, timezone for this sessions timestamps {datetimestamp}
"createdTimezone": LOCAL_TIMEZONE,
"occurrenceType":"P",
"recurrenceRule":{
"recurrenceType":"weekly",
"interval":1,
"recurrenceEndType":"after_occurrences_count",
"numberOfOccurrences":bb_class['recurr'],
"daysOfTheWeek":[day_of_week],
"endDate":None
# No end (open session) [True, False]
"noEndDate": False,
# Repeat session ["S", "P"] S == single, P == ???
"occurrenceType": None,
# Configuration for when `occurrenceType` is `P`, Ignored if `occurrenceType` is `S`
"recurrenceRule": {
# Have a session every day or week or month ["daily", "weekly", "monthly"]
"recurrenceType": "weekly",
# Have a session every Nth day/week/month [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
"interval": 1,
# when to end the recurrence ["after_occurrences_count", "on_date"]
"recurrenceEndType": None,
# required if `recurrenceType` is `weekly`
"daysOfTheWeek": None,
# end the recurrence after this many sessions, required if `recurrenceEndType` is `after_occurrences_count` {integer}
"numberOfOccurrences": None,
# end the recurrence after this date, required if `recurrenceEndType` is `on_date` {datestamp}
"endDate": None,
},
"noEndDate":False,
"name": bb_class['name']
# Early Entry [0, 15, 30, 45, 60]
"boundaryTime": 15,
# Description {string}
"description": "",
####################
# Session Settings #
####################
### Default Attendee Role ###
# Default Attendee Role ["participant", "presenter", "moderator"]
"ltiParticipantRole": "participant",
### Recording ###
# Allow recording downloads [True, False]
"canDownloadRecording": True,
# Anonymize chat messages [True, False]
"anonymizeRecordings": False,
### Moderator permissions ###
# Show profile pictures for moderator only [True, False]
"showProfile": True,
### Participant permissions ###
# hidden, if False pretend that the next four options are also all False [True, False]
"participantCanUseTools": True,
# Share audio [True, False]
"canShareAudio": True,
# Share video [True, False]
"canShareVideo": True,
# Post chat messages [True, False]
"canPostMessage": True,
# Draw on whiteboard and files [True, False]
"canAnnotateWhiteboard": False,
### Enable session telephony ###
# Allow attendees to join the session using a telephone [True, False]
"telephonyEnabled": False,
### Private Chat ###
# Participants can chat privately only with moderators [True, False]
"privateChatRestricted": False,
# Moderators supervise all private chats [True, False]
"mustBeSupervised": False,
### Large scale session (250+) ###
# Allow 250+ attendees to join [True, False]
"largeSessionEnable": False,
### Profanity filter ###
# Hide profanity in chat messages [True, False]
"profanityFilterEnabled": True,
}

def create_bb_class(bb_class, session):
class_json = bb_json_from_dict(bb_class)
session.post(BB_POST_URL, json=class_json)
bb_session_config = update(update(dict(bb_default_config), parse_dict(bb_course_config)), parse_dict(bb_class_config))

if None in bb_session_config.values():
raise ValueError(
f"\
Some required filed are empty: \
{', '.join(map(lambda x: x[0], filter(lambda x: x[1] is None, bb_session_config.items())))}"
)

return bb_session_config


def create_bb_class(session: requests.Session, bb_course_config: dict, bb_class_config: dict) -> None:
"""
:param session:
:param bb_course_config:
:param bb_class_config:
:return:
"""

print(f"Creating Class: {bb_class_config['name']}")
class_json = bb_json_from_dict(bb_course_config, bb_class_config)

r = session.post(BB_POST_URL, json=class_json)

if not r.ok:
print(r.status_code, r.reason)
return

if "guestUrl" in r.json():
print(r.json()['guestUrl'])


def main() -> int:
"""
:return:
"""

def main():
args = parse_args()
if args.debug:
enable_logging()
classes = parse_classes(args.classes_csv)
session = get_authed_session(args.token)
for bb_class in classes:
print(f"Creating Class: {bb_class['name']}")
create_bb_class(bb_class, session)
time.sleep(1)
if args.debug: enable_logging()
session = get_authed_session(args.token.read().strip() if args.token else input("BB Collaborate auth token: "))
config = json_load(args.config_json) if args.config_json else {}
for bb_class in csv_dictreader(args.classes_csv, dialect="unix"):
create_bb_class(session, config, bb_class)
sleep(1)

return 0


if __name__ == "__main__":
main()
exit(main())
17 changes: 17 additions & 0 deletions examples/COMP2041_bb.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
name,startTime,endTime,recurrenceEndType,numberOfOccurrences
"T12B (Tue) Week: 1-5,7-10","2021-02-16 12:00:00","2021-02-16 15:00:00",after_occurrences_count,10
"T12C (Tue) Week: 1-5,7-10","2021-02-16 12:00:00","2021-02-16 15:00:00",after_occurrences_count,10
"T14B (Tue) Week: 1-5,7-10","2021-02-16 14:00:00","2021-02-16 17:00:00",after_occurrences_count,10
"T14C (Tue) Week: 1-5,7-10","2021-02-16 14:00:00","2021-02-16 17:00:00",after_occurrences_count,10
"T17B (Tue) Week: 1-5,7-10","2021-02-16 17:00:00","2021-02-16 20:00:00",after_occurrences_count,10
"T17C (Tue) Week: 1-5,7-10","2021-02-16 17:00:00","2021-02-16 20:00:00",after_occurrences_count,10
"W09B (Wed) Week: 1-5,7-10","2021-02-17 09:00:00","2021-02-17 12:00:00",after_occurrences_count,10
"W09C (Wed) Week: 1-5,7-10","2021-02-17 09:00:00","2021-02-17 12:00:00",after_occurrences_count,10
"W11B (Wed) Week: 1-5,7-10","2021-02-17 11:00:00","2021-02-17 14:00:00",after_occurrences_count,10
"W11C (Wed) Week: 1-5,7-10","2021-02-17 11:00:00","2021-02-17 14:00:00",after_occurrences_count,10
"W13B (Wed) Week: 1-5,7-10","2021-02-17 13:00:00","2021-02-17 16:00:00",after_occurrences_count,10
"W13C (Wed) Week: 1-5,7-10","2021-02-17 13:00:00","2021-02-17 16:00:00",after_occurrences_count,10
"W15B (Wed) Week: 1-5,7-10","2021-02-17 15:00:00","2021-02-17 18:00:00",after_occurrences_count,10
"W15C (Wed) Week: 1-5,7-10","2021-02-17 15:00:00","2021-02-17 18:00:00",after_occurrences_count,10
"H10B (Thu) Week: 1-5,7-10","2021-02-18 10:00:00","2021-02-18 13:00:00",after_occurrences_count,10
"H10C (Thu) Week: 1-5,7-10","2021-02-18 10:00:00","2021-02-18 13:00:00",after_occurrences_count,10
Loading