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