From b37f316c6047da488bc43ceb5bd2402d51ec5f1c Mon Sep 17 00:00:00 2001 From: magnus2142 <magnus.bredeli@hotmail.com> Date: Thu, 31 Mar 2022 15:42:32 +0200 Subject: [PATCH] CReated create file and document method. ADded features to content builder. --- bbcli/cli.py | 37 +++- bbcli/commands/contents.py | 75 ++++++++ bbcli/entities/content_builder_entitites.py | 39 ++++ bbcli/services/announcements_service.py | 10 +- bbcli/services/contents_service.py | 179 +++++++++++++---- bbcli/services/utils/content_builder.py | 201 ++++++++++++++++++++ bbcli/utils/URL_builder.py | 9 +- bbcli/utils/utils.py | 10 +- requirements.txt | 4 +- 9 files changed, 506 insertions(+), 58 deletions(-) create mode 100644 bbcli/entities/content_builder_entitites.py create mode 100644 bbcli/services/utils/content_builder.py diff --git a/bbcli/cli.py b/bbcli/cli.py index 2f8314e..279f9b8 100644 --- a/bbcli/cli.py +++ b/bbcli/cli.py @@ -13,9 +13,19 @@ import click from bbcli.commands.courses import list_courses from bbcli.commands.announcements import list_announcements, create_announcement, delete_announcement, update_announcement -from bbcli.commands.contents import list_contents, create_content +from bbcli.commands.contents import list_contents, create_content, create_document, create_file from bbcli.services.authorization_service import login +load_dotenv() +cookies = {'BbRouter' : os.getenv("BB_ROUTER")} +headers = {'X-Blackboard-XSRF': os.getenv('XSRF')} + +#----- AUTHORIZATION MODULE -----# +# @app.command(name='login', help='Authorize the user.') +def authorize_user(): + if cookies['BbRouter'] == None or check_valid_date(cookies) == False: + login() + def initiate_session(): bb_cookie = { 'name':'BbRouter', @@ -26,6 +36,7 @@ def initiate_session(): session = requests.Session() set_cookies(session, [bb_cookie]) set_headers(session, [xsrf]) + session.headers.update({'Content-Type': 'application/json'}) return session @@ -83,14 +94,20 @@ def contents(ctx): pass contents.add_command(list_contents) -contents.add_command(create_content) +# contents.add_command(upload_file) -load_dotenv() -cookies = {'BbRouter' : os.getenv("BB_ROUTER")} -headers = {'X-Blackboard-XSRF': os.getenv('XSRF')} +""" +CONTENTS CREATE COMMANDS ENTRY POINT +""" -#----- AUTHORIZATION MODULE -----# -# @app.command(name='login', help='Authorize the user.') -def authorize_user(): - if check_valid_date(cookies) == False: - login() +@contents.group() +@click.pass_context +def create(ctx): + """ + Commands for creating different types of content types in blackboard + """ + pass + +# create.add_command(create_content) +create.add_command(create_document) +create.add_command(create_file) \ No newline at end of file diff --git a/bbcli/commands/contents.py b/bbcli/commands/contents.py index c80e7bf..b740156 100644 --- a/bbcli/commands/contents.py +++ b/bbcli/commands/contents.py @@ -1,8 +1,21 @@ +from datetime import datetime import click +from bbcli.entities.content_builder_entitites import FileOptions, StandardOptions from bbcli.services import contents_service from bbcli.views import content_view import os +def standard_options(function): + function = click.option('-h', '--hide-content', is_flag=True)(function) + function = click.option('-r', '--reviewable', is_flag=True)(function) + function = click.option('--start-date', type=str)(function) + function = click.option('--end-date', type=str)(function) + return function + +def file_options(function): + function = click.option('-n', '--new-window', 'launch_in_new_window', is_flag=True)(function) + return function + #, help='List a spesific course with the corresponding id' @click.command(name='list') @@ -29,3 +42,65 @@ def list_contents(ctx, course_id: str=None, content_id: str=None): @click.pass_context def create_content(ctx, course_id: str, content_id: str): contents_service.test_create_assignment(ctx.obj['SESSION'], course_id, content_id) + +@click.command(name='upload') +@click.pass_context +def upload_file(ctx): + contents_service.test_upload_file(ctx.obj['SESSION'], '/home/magnus/Downloads/3_meeting_notes.pdf') + + +@click.command(name='document') +@click.argument('course_id', required=True, type=str) +@click.argument('parent_id', required=True, type=str) +@click.argument('title', required=True, type=str) +@standard_options +@click.pass_context +def create_document(ctx, course_id: str, parent_id: str, title: str, hide_content: bool, reviewable: bool, start_date: str=None, end_date: str=None): + """ + Creates a document content in blackboard + """ + + standard_options = StandardOptions(hide_content=hide_content, reviewable=reviewable) + validate_dates(standard_options, start_date, end_date) + + response = contents_service.create_document(ctx.obj['SESSION'], course_id, parent_id, title, standard_options) + print(response) + + +@click.command(name='file') +@click.argument('course_id', required=True, type=str) +@click.argument('parent_id', required=True, type=str) +@click.argument('title', required=True, type=str) +@click.argument('file_path', required=True, type=click.Path(exists=True)) +@file_options +@standard_options +@click.pass_context +def create_file(ctx, course_id: str, parent_id: str, title: str, file_path: str, + launch_in_new_window:bool, hide_content: bool, reviewable: bool, + start_date: str=None, end_date: str=None): + """ + Creates a file content in blackboard + """ + + file_options = FileOptions(launch_in_new_window) + standard_options = StandardOptions(hide_content=hide_content, reviewable=reviewable) + validate_dates(standard_options, start_date, end_date) + response = contents_service.create_file(ctx.obj['SESSION'], course_id, parent_id, title, file_path, file_options, standard_options) + print(response) + + +def validate_dates(standard_options: StandardOptions, start_date: str, end_date: str): + if start_date: + try: + standard_options.date_interval.start_date = datetime.strptime(start_date, '%d/%m/%y %H:%M:%S') + except ValueError: + click.echo('Value format is not valid, please see --help for more info') + raise click.Abort() + + if end_date: + try: + standard_options.date_interval.end_date = datetime.strptime(end_date, '%d/%m/%y %H:%M:%S') + except ValueError: + click.echo('Value format is not valid, please see --help for more info') + raise click.Abort() + diff --git a/bbcli/entities/content_builder_entitites.py b/bbcli/entities/content_builder_entitites.py new file mode 100644 index 0000000..0a11317 --- /dev/null +++ b/bbcli/entities/content_builder_entitites.py @@ -0,0 +1,39 @@ +from dataclasses import dataclass +from datetime import date + + +""" +OPTIONS DATA CLASSES +""" + +@dataclass +class DateInterval: + start_date: date = None + end_date: date = None + +@dataclass +class StandardOptions: + hide_content: bool = False + reviewable: bool = False + date_interval: DateInterval = DateInterval() + +@dataclass +class FileOptions: + launch_in_new_window: bool = False + +@dataclass +class WeblinkOptions: + launch_in_new_window: bool = False + + +""" +CONTENT-TYPE DATA CLASSES +""" + +@dataclass +class FileContent: + upload_id: str + file_name: str + mime_type: str + duplicate_file_handling: str = 'Rename' # Options are Rename, Replace, ThrowError + diff --git a/bbcli/services/announcements_service.py b/bbcli/services/announcements_service.py index e7351e3..985bd53 100644 --- a/bbcli/services/announcements_service.py +++ b/bbcli/services/announcements_service.py @@ -3,7 +3,7 @@ from subprocess import call from typing import Dict, Any import requests from bbcli.services.courses_service import list_courses -from bbcli.utils.utils import set_cookies +from bbcli.utils.utils import input_body, set_cookies import click from bbcli.utils.URL_builder import URLBuilder @@ -43,10 +43,7 @@ def list_announcement(session: requests.Session, course_id: str, announcement_id # TODO: Add compatibility for flags and options to make a more detailed announcement def create_announcement(session: requests.Session, course_id: str, title: str): - MARKER = '# Everything below is ignored\n' - body = click.edit('\n\n' + MARKER) - if body is not None: - body = body.split(MARKER, 1)[0].rstrip('\n') + body = input_body() data = { 'title': title, @@ -54,7 +51,6 @@ def create_announcement(session: requests.Session, course_id: str, title: str): } data = json.dumps(data) - session.headers.update({'Content-Type': 'application/json'}) url = url_builder.base_v1().add_courses().add_id(course_id).add_announcements().create() response = session.post(url, data=data) @@ -83,8 +79,6 @@ def update_announcement(session: requests.Session, course_id: str, announcement_ announcement = json.dumps(editable_data, indent=2) new_data = click.edit(announcement + '\n\n' + MARKER) - session.headers.update({'Content-Type': 'application/json'}) - url = url_builder.base_v1().add_courses().add_id(course_id).add_announcements().add_id(announcement_id).create() response = session.patch(url, data=new_data) diff --git a/bbcli/services/contents_service.py b/bbcli/services/contents_service.py index aa11c41..887b042 100644 --- a/bbcli/services/contents_service.py +++ b/bbcli/services/contents_service.py @@ -1,17 +1,25 @@ import base64 +from datetime import date import json import os from subprocess import call from tarfile import ENCODING -from typing import Dict, Any +from typing import Dict, Any, List import requests +import magic from bbcli.services.courses_service import list_courses from bbcli.utils.URL_builder import URLBuilder +from bbcli.services.utils.content_builder import ContentBuilder +from bbcli.entities.content_builder_entitites import DateInterval, FileContent, StandardOptions, FileOptions, WeblinkOptions +from bbcli.utils.utils import input_body url_builder = URLBuilder() +content_builder = ContentBuilder() # User gets a tree structure view of the courses content # where each content is listed something like this: _030303_1 Lectures Folder + + def list_course_content(cookies: Dict, course_id: str): print('Getting course content!') @@ -40,47 +48,144 @@ def list_assignments(cookies: Dict, course_id: str): # Update spesific content +# NB: Alle options sende som values i enkle fields i bodyen + +# Alle disse blir sendt til post content endpoint. title, body er samme. Options varirerer. Største ulikheten +# er i content-handler. None kan også ha attachments. -def test_create_assignment(session: requests.Session, course_id: str, content_id: str): +# Tror svaret er en ContentBuilder + +# Hvis dato er inkludert for visning av content etter en spesifikk tid osv. oppdater ny regel ellr hva det nå er +# Kilde: https://docs.blackboard.com/rest-apis/learn/getting-started/adaptive-release. KANSKJE IKKE NØDVENDIG + +# Title, body, eventuelt attachements?, Standard Options: permit users to view, track number of views, date (start, end) +def create_document(session:requests.Session, course_id: str, parent_id: str, title: str, standard_options: StandardOptions=None, attachments: List[str]=None): + + data_body = input_body() + data = content_builder\ + .add_parent_id(parent_id)\ + .add_title(title)\ + .add_body(data_body)\ + .add_standard_options(standard_options)\ + .add_content_handler_document()\ + .create() - with open('/home/magnus/Downloads/3_meeting_notes.pdf', 'rb') as f: - byte_content = f.read() - - base64_bytes = base64.b64encode(byte_content) - base64_string = base64_bytes.decode(ENCODING) - - data = { - "parentId": content_id, - "title": "Test file", - 'body': 'jaja', - "description": "string", - "position": 0, - "launchInNewWindow": True, - "availability": { - "available": "Yes", - "allowGuests": True, - "allowObservers": True, - "adaptiveRelease": { - "start": "2022-03-29T09:32:35.571Z", - } - }, - "contentHandler": { - 'id':'resource/x-bb-file', - 'file': base64_string - }, - } data = json.dumps(data) - session.headers.update({'Content-Type': 'application/json'}) - # files = { - # 'pdf_document': open('/home/magnus/Downloads/3_meeting_notes.pdf', 'rb') - # } + url = generate_create_content_url(course_id, parent_id) + response = session.post(url, data=data) + return response.text - - # Returns the string: domain + /learn/api/public/v1/courses/{courseId}/contents/{contentId}/children - url = url_builder.base_v1().add_courses().add_id(course_id).add_contents().add_id(content_id).add_children().create() +# Title, file itself, FIle Options: open in new window, add alignment to content, Standard Options: as above + +def create_file(session: requests.Session, course_id: str, parent_id: str, title: str, file_dst: str, file_options: FileOptions, standard_options: StandardOptions): + + uploaded_file = upload_file(session, file_dst) + mime = magic.Magic(mime=True) + mime_type = mime.from_file(file_dst) + + with open(file_dst, 'rb') as f: + file_name = os.path.basename(f.name) + + file_content = FileContent(uploaded_file['id'], file_name, mime_type) + data = content_builder\ + .add_parent_id(parent_id)\ + .add_title(title)\ + .add_standard_options(standard_options)\ + .add_file_options(file_options)\ + .add_content_handler_file(file_content)\ + .create() + + data = json.dumps(data) + url = generate_create_content_url(course_id, parent_id) response = session.post(url, data=data) - print(response.text) + return response.text + +# Title, URL, body, eventuelt attachments?, Weblink options: open in new window?, Standard options: as above + + +def create_externallink(): + pass + +# Title, body, Standard options: as above + + +def create_folder(): + pass + +# Title, body, location(?), targetId content, Standard Options: as above + + +def create_courselink(): + pass - \ No newline at end of file +# Se egen metode i BBL REST API + + +def create_assignment(): + pass + +# Vet ikke enda + + +def create_forumlink(): + pass + +# Vet ikke enda + + +def create_blti_link(): + pass + + +# TODO: Check how to publish audio and image, it doesn't have a content handler +# SOLUTION: See the upload files section. There it says something about uploading images and audio and videoetc, +# SOLUTION 2: THIS IS EMBEDDED IN THE BODY I BELIEVE + +# TODO: Module page has it own 'resource/x-bb-module-page' content typ, but is not mentioned in the documentation +# Do reserach on this later + +# TODO: Same as the one above, just with blank page 'resource/x-bb-blankpage', Both are under new page option in the +# bb web interface + + +# TODO: 'x-bb-lesson' + + +# TODO: CReat an own method for attachments posting for either being +# called straight after a content of type file, document, or assignment is created. +# OR afterwards + + + +""" + +HELPER FUNCTIONS + +""" + +def upload_file(session: requests.Session, dst: str): + + del session.headers['Content-Type'] + with open(dst, 'rb') as f: + file_name = os.path.basename(f.name) + files = {file_name: f.read()} + + url = url_builder.base_v1().add_uploads().create() + response = session.post(url, files=files) + session.headers.update({ + 'Content-Type': 'application/json' + }) + file = json.loads(response.text) + return file + +def generate_create_content_url(course_id: str, content_id: str): + return url_builder\ + .base_v1()\ + .add_courses()\ + .add_id(course_id)\ + .add_contents()\ + .add_id(content_id)\ + .add_children()\ + .create() \ No newline at end of file diff --git a/bbcli/services/utils/content_builder.py b/bbcli/services/utils/content_builder.py new file mode 100644 index 0000000..53be014 --- /dev/null +++ b/bbcli/services/utils/content_builder.py @@ -0,0 +1,201 @@ +from __future__ import annotations +from datetime import date, datetime +from typing import Any, Dict +from abc import ABC, abstractmethod + +from bbcli.entities.content_builder_entitites import FileContent, FileOptions, StandardOptions, WeblinkOptions + +class Builder(ABC): + + @property + @abstractmethod + def product(self) -> None: + pass + + @abstractmethod + def add_parent_id(self, title: str) -> Builder: + pass + + @abstractmethod + def add_title(self, title: str) -> Builder: + pass + + @abstractmethod + def add_body(self, body: str) -> Builder: + pass + + @abstractmethod + def add_standard_options(self, standard_options: StandardOptions) -> Builder: + pass + + # Alignment option is available in creation in the web interface, but not in the actual content objects that is created + @abstractmethod + def add_file_options(self, file_options: FileOptions) -> Builder: + pass + + @abstractmethod + def add_weblink_options(self, web_link_options: WeblinkOptions) -> Builder: + pass + + @abstractmethod + def add_content_handler_document(self) -> Builder: + pass + + @abstractmethod + def add_content_handler_file(self, file_content: FileContent) -> Builder: + pass + + @abstractmethod + def add_content_handler_externallink(self, url: str) -> Builder: + pass + + @abstractmethod + def add_content_handler_folder(self, is_bb_page: bool) -> Builder: + pass + + # Possible target types: + # Unset + # CourseAssessment + # CourseTOC + # Forum + # Tool + # CollabSession (deprecated since 3000.1.0) + # Group + # BlogJournal + # StaffInfo + # ModulePage + + @abstractmethod + def add_content_handler_courselink(self, target_id: str, target_type: str='Unset') -> Builder: + pass + + +class ContentBuilder(Builder): + + def __init__(self) -> None: + self.reset() + + def reset(self) -> None: + self._product = Content() + + @property + def product(self) -> Content: + product = self._product + self.reset() + return product + + def add_parent_id(self, parent_id: str) -> Builder: + self._product.add({ + 'parentId': parent_id + }) + return self + + def add_title(self, title: str) -> Builder: + self._product.add({ + 'title': title + }) + return self + + def add_body(self, body: str) -> Builder: + self._product.add({ + 'body': body + }) + return self + + def add_standard_options(self, standard_options: StandardOptions) -> Builder: + start_date_str = datetime.strftime(standard_options.date_interval.start_date,'%Y-%m-%dT%H:%m:%S.%fZ') if standard_options.date_interval.start_date else None + end_date_str = datetime.strftime(standard_options.date_interval.end_date, '%Y-%m-%dT%H:%m:%S.%fZ') if standard_options.date_interval.end_date else None + self._product.add({ + 'reviewable': standard_options.reviewable, + 'availability': { + 'available': 'No' if standard_options.hide_content else 'Yes', + 'allowGuests': True, + 'allowObservers': True, + 'adaptiveRelease': { + "start": start_date_str, + "end": end_date_str + } + } + }) + return self + + # Missing an extra option, but don't know what it is called + def add_file_options(self, file_options: FileOptions) -> Builder: + self._product.add({ + 'launchInNewWindow': file_options.launch_in_new_window + }) + return self + + def add_weblink_options(self, web_link_options: WeblinkOptions) -> Builder: + self._product.add({ + 'launchInNewWindow': web_link_options.launch_in_new_window + }) + return self + + def add_content_handler_document(self) -> Builder: + self._product.add({ + 'contentHandler' : { + 'id': 'resource/x-bb-document' + } + }) + return self + + def add_content_handler_file(self, file_content: FileContent) -> Builder: + self._product.add({ + 'contentHandler' : { + 'id': 'resource/x-bb-file', + 'file': { + 'uploadId': file_content.upload_id, + 'fileName': file_content.file_name, + 'mimeType': file_content.mime_type, + 'duplicateFileHandling': file_content.duplicate_file_handling + } + + } + }) + return self + + def add_content_handler_externallink(self, url: str) -> Builder: + self._product.add({ + 'contentHandler' : { + 'id': 'resource/x-bb-externallink', + 'url': url + } + }) + return self + + def add_content_handler_folder(self, is_bb_page: bool) -> Builder: + self._product.add({ + 'contentHandler' : { + 'id': 'resource/x-bb-folder', + 'isBbPage': is_bb_page + } + }) + return self + + def add_content_handler_courselink(self, target_id: str, target_type: str = 'Unset') -> Builder: + self._product.add({ + 'contentHandler' : { + 'id': 'resource/x-bb-folder', + 'targetId': target_id, + 'targeType': target_type + } + }) + return self + + def create(self) -> Dict: + content = self._product.get_content() + self._product = Content() + return content + + +class Content(): + + def __init__(self) -> None: + self.content = {} + + def add(self, content_part: str) -> None: + self.content.update(content_part) + + def get_content(self) -> None: + return self.content \ No newline at end of file diff --git a/bbcli/utils/URL_builder.py b/bbcli/utils/URL_builder.py index b8db2a8..5bba19c 100644 --- a/bbcli/utils/URL_builder.py +++ b/bbcli/utils/URL_builder.py @@ -59,6 +59,10 @@ class Builder(ABC): def add_attachments(self) -> Builder: pass + @abstractmethod + def add_uploads(self) -> Builder: + pass + @abstractmethod def add_id(self, id: str, id_type: str = None) -> Builder: pass @@ -74,7 +78,6 @@ class URLBuilder(Builder): @property def product(self) -> URL: - product = self._product self.reset() return product @@ -120,6 +123,10 @@ class URLBuilder(Builder): def add_attachments(self) -> Builder: self._product.add('/attachments') return self + + def add_uploads(self) -> Builder: + self._product.add('/uploads') + return self def add_id(self, id:str, id_type:str=None) -> URLBuilder: if id_type: diff --git a/bbcli/utils/utils.py b/bbcli/utils/utils.py index be69a2a..809888f 100644 --- a/bbcli/utils/utils.py +++ b/bbcli/utils/utils.py @@ -2,6 +2,7 @@ from datetime import datetime from typing import Dict, List from requests import Session import html2text +import click def check_valid_key(obj, key) -> bool: @@ -47,4 +48,11 @@ def set_headers(session: Session, headers: List): def html_to_text(html_data: str): to_text = html2text.HTML2Text() - return to_text.handle(html_data) \ No newline at end of file + return to_text.handle(html_data) + +def input_body(): + MARKER = '# Everything below is ignored\n' + body = click.edit('\n\n' + MARKER) + if body is not None: + body = body.split(MARKER, 1)[0].rstrip('\n') + return body \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index cf98491..65659aa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,4 +6,6 @@ requests==2.27.1 python-dotenv==0.19.2 beautifulsoup4==4.10.0 lxml==4.8.0 -anytree==2.8.0 \ No newline at end of file +anytree==2.8.0 +html2text==2020.1.16 +python-magic==0.4.25 \ No newline at end of file -- GitLab