diff --git a/bbcli/cli.py b/bbcli/cli.py index 4f6afff1eb42ed8e87399a3d92331d21ea29c155..6faabf7da09c271e0dcaa705dd067cc04a0d4307 100644 --- a/bbcli/cli.py +++ b/bbcli/cli.py @@ -17,7 +17,6 @@ def entry_point(): pass - entry_point.add_command(get_user) entry_point.add_command(get_course_contents) entry_point.add_command(get_assignments) diff --git a/bbcli/controllers/announcement_controller.py b/bbcli/controllers/announcement_controller.py new file mode 100644 index 0000000000000000000000000000000000000000..c43810c9611b288d90adfa19460609a4087b13f5 --- /dev/null +++ b/bbcli/controllers/announcement_controller.py @@ -0,0 +1,41 @@ +from email.policy import default +import click +from bbcli.services import announcement_service +from bbcli.views import announcement_view +import os +import requests + +from bbcli.utils.utils import set_cookies, set_headers + +@click.command(name='announcements') +@click.argument('course_id', required=False) +@click.argument('announcement_id', required=False) +def list_announcements(course_id=None, announcement_id=None): + + """ + This command lists your announcements. + Either all announcements, all announcements from a spesific course, or one announcement. + """ + + bb_cookie = { + 'name':'BbRouter', + 'value': os.getenv("BB_ROUTER") + } + xsrf = {'X-Blackboard-XSRF': os.getenv('XSRF')} + user_name = os.getenv('BB_USERNAME') + + session = requests.Session() + set_cookies(session, [bb_cookie]) + set_headers(session, [xsrf]) + + response = None + + if announcement_id: + response = announcement_service.list_announcement(session, course_id, announcement_id) + announcement_view.print_course_announcements([response]) + elif course_id: + response = announcement_service.list_course_announcements(session, course_id) + announcement_view.print_course_announcements(response) + else: + response = announcement_service.list_announcements(session, user_name) + announcement_view.print_announcements(response) diff --git a/bbcli/controllers/course_controller.py b/bbcli/controllers/course_controller.py new file mode 100644 index 0000000000000000000000000000000000000000..9a919341c20b031cf4d4d03c7f9f83bb92510c78 --- /dev/null +++ b/bbcli/controllers/course_controller.py @@ -0,0 +1,41 @@ +from email.policy import default +import click +from bbcli.services import course_service +from bbcli.views import course_view +import os +import requests +from bbcli.utils.utils import set_cookies, set_headers + + +#, help='List a spesific course with the corresponding id' +@click.command(name='courses') +@click.argument('course_id', required=False) +@click.option('-a', '--all/--no-all', 'show_all', default=False, help='Lists all courses you have ever been signed up for') +def list_courses(course_id=None, show_all=False): + + """ + This command lists your courses, by default only the courses from + two last semesters + """ + + bb_cookie = { + 'name':'BbRouter', + 'value': os.getenv("BB_ROUTER") + } + user_name = os.getenv('BB_USERNAME') + + session = requests.Session() + set_cookies(session, [bb_cookie]) + + response = None + + if course_id: + response = course_service.list_course(session=session, course_id=course_id) + course_view.print_course(response) + else: + if show_all: + response = course_service.list_all_courses(session=session, user_name=user_name) + else: + response = course_service.list_courses(session=session, user_name=user_name) + course_view.print_courses(response) + diff --git a/bbcli/services/ContentService.py b/bbcli/services/ContentService.py deleted file mode 100644 index b28b04f643122b019e912540f228c8ed20be9eeb..0000000000000000000000000000000000000000 --- a/bbcli/services/ContentService.py +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/bbcli/services/__init__.py b/bbcli/services/__init__.py index 540e4cbbe654d8299db8737108ad2ac292234b79..f30c4e42d3b55b2e31bb4a281935a9f0513de3f0 100644 --- a/bbcli/services/__init__.py +++ b/bbcli/services/__init__.py @@ -1 +1,2 @@ -from .authorization_service import * \ No newline at end of file +from .authorization_service import * +from bbcli.utils.URL_builder import URLBuilder \ No newline at end of file diff --git a/bbcli/services/announcement_service.py b/bbcli/services/announcement_service.py index 90177768b8a4ab591f54d25baec9d2e7c098df59..2de1420d927df92fb927c342a9a5d2e2d3e0b225 100644 --- a/bbcli/services/announcement_service.py +++ b/bbcli/services/announcement_service.py @@ -3,16 +3,20 @@ from subprocess import call from typing import Dict, Any import requests from bbcli.services.course_service import list_courses +from bbcli.utils.utils import set_cookies import click -def list_announcements(cookies: Dict, user_name: str): - courses = list_courses(cookies=cookies, user_name=user_name) +from bbcli.utils.URL_builder import URLBuilder - session = requests.Session() +url_builder = URLBuilder() + +def list_announcements(session: requests.Session, user_name: str): + courses = list_courses(session, user_name=user_name) announcements = [] for course in courses: - course_announcements = session.get('https://ntnu.blackboard.com/learn/api/public/v1/courses/{}/announcements'.format(course['id']), cookies=cookies) + url = url_builder.base_v1().add_courses().add_id(course['id']).add_announcements().create() + course_announcements = session.get(url) course_announcements = json.loads(course_announcements.text) # Adds the course name to each course announcement list to make it easier to display which course the announcement comes from @@ -24,19 +28,21 @@ def list_announcements(cookies: Dict, user_name: str): return announcements -def list_course_announcements(cookies: Dict, course_id: str): - course_announcements = requests.get('https://ntnu.blackboard.com/learn/api/public/v1/courses/{}/announcements'.format(course_id), cookies=cookies) +def list_course_announcements(session: requests.Session, course_id: str): + url = url_builder.base_v1().add_courses().add_id(course_id).add_announcements().create() + course_announcements = session.get(url) + course_announcements.raise_for_status() course_announcements = json.loads(course_announcements.text)['results'] return course_announcements -def list_announcement(cookies: Dict, course_id: str, announcement_id: str): - announcement = requests.get('https://ntnu.blackboard.com/learn/api/public/v1/courses/{}/announcements/{}'.format(course_id, announcement_id), cookies=cookies) +def list_announcement(session: requests.Session, course_id: str, announcement_id: str): + url = url_builder.base_v1().add_courses().add_id(course_id).add_announcements().add_id(announcement_id).create() + announcement = session.get(url) announcement = json.loads(announcement.text) return announcement # TODO: Add compatibility for flags and options to make a more detailed announcement -def create_announcement(cookies: Dict, headers: Dict, course_id: str, title: str): - +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: @@ -48,22 +54,24 @@ def create_announcement(cookies: Dict, headers: Dict, course_id: str, title: str } data = json.dumps(data) - headers['Content-Type'] = 'application/json' + session.headers.update({'Content-Type': 'application/json'}) - response = requests.post('https://ntnu.blackboard.com/learn/api/public/v1/courses/{}/announcements'.format(course_id), cookies=cookies, headers=headers, data=data) + url = url_builder.base_v1().add_courses().add_id(course_id).add_announcements().create() + response = session.post(url, data=data) return response.text -def delete_announcement(cookies: Dict, headers: Dict, course_id: str, announcement_id: str): - response = requests.delete('https://ntnu.blackboard.com/learn/api/public/v1/courses/{}/announcements/{}'.format(course_id, announcement_id), cookies=cookies, headers=headers) +def delete_announcement(session: requests.Session, course_id: str, announcement_id: str): + url = url_builder.base_v1().add_courses().add_id(course_id).add_announcements().add_id(announcement_id).create() + response = session.delete(url) if response.text == '': return 'Sucessfully deleted announcement!' else: return response.text -def update_announcement(cookies: Dict, headers: Dict, course_id: str, announcement_id: str): +def update_announcement(session: requests.Session, course_id: str, announcement_id: str): - announcement = list_announcement(cookies=cookies, course_id=course_id, announcement_id=announcement_id) + announcement = list_announcement(session=session, course_id=course_id, announcement_id=announcement_id) MARKER = '# Everything below is ignored\n' editable_data = { 'title': announcement['title'], @@ -75,7 +83,9 @@ def update_announcement(cookies: Dict, headers: Dict, course_id: str, announceme announcement = json.dumps(editable_data, indent=2) new_data = click.edit(announcement + '\n\n' + MARKER) - headers['Content-Type'] = 'application/json' - response = requests.patch('https://ntnu.blackboard.com/learn/api/public/v1/courses/{}/announcements/{}'.format(course_id, announcement_id), cookies=cookies, headers=headers, data=new_data) + 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) return response.text \ No newline at end of file diff --git a/bbcli/services/authorization_service.py b/bbcli/services/authorization_service.py index 71e5de6f0cb99d0bda0f123c34dd76efc1238713..3fe136bc8345c14adcf5eef68098a9701b1dac52 100644 --- a/bbcli/services/authorization_service.py +++ b/bbcli/services/authorization_service.py @@ -292,8 +292,9 @@ def write_to_env_data(session): xsrf_value = xsrf[1] f = open('.env', 'w') - f.write("BB_ROUTER=" + BB_ROUTER + "\n") - f.write("XSRF=" + xsrf_value + "\n") + f.write(f'BB_ROUTER={BB_ROUTER}\n') + f.write(f'XSRF={xsrf_value}\n') + f.write(f'BB_USERNAME={login_username}\n') f.close() diff --git a/bbcli/services/content_service.py b/bbcli/services/content_service.py new file mode 100644 index 0000000000000000000000000000000000000000..182b1049c503dc120c461b1703254cdb1010f2a9 --- /dev/null +++ b/bbcli/services/content_service.py @@ -0,0 +1,37 @@ +import json +from subprocess import call +from typing import Dict, Any +import requests +from bbcli.services.course_service import list_courses +import click + + +# 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!') + + +# If it is a folder, list it like a tree structure view like mentioned above. +# If it is a document, download and open the document maybe? +# Find all types of content and have an appropriate response for them. This +# should maybe be handled in the view... +def get_content(cookies: Dict, course_id: str, content_id: str): + print('Getting content by its ID.') + + +# List all contents of type assignment, should be executed if a flag for example like --content-type assignment or smth is used +def list_assignments(cookies: Dict, course_id: str): + print('Getting all assignments') + +# TODO: add methods for all content types like the one above + + +# Create content. This should have a flag which says what kind of content type it is + + +# Create assignment. Creates an assignment + +# Delete spesific content + +# Update spesific content \ No newline at end of file diff --git a/bbcli/services/course_service.py b/bbcli/services/course_service.py index 6b893e8b0e4e4b688237bc9a8bf9c2586ddc37f7..c69fae73a0ee618a06c3b7bfbb58748c5ee200b0 100644 --- a/bbcli/services/course_service.py +++ b/bbcli/services/course_service.py @@ -1,35 +1,29 @@ import json -from typing import Dict, Any +from typing import Dict, Any, List import requests from datetime import date -def take_start_date(elem): - return date.fromisoformat(elem['availability']['duration']['start'].split('T')[0]) +from bbcli.utils.URL_builder import URLBuilder +url_builder = URLBuilder() -def list_courses(cookies: Dict, user_name: str) -> Any: - session = requests.Session() - terms = session.get('https://ntnu.blackboard.com/learn/api/public/v1/terms', cookies=cookies) - terms = json.loads(terms.text)['results'] - - # Sort terms by start date to get the two most recent semesters to determine which courses to show - for term in terms: - if term['availability']['duration']['type'] != 'DateRange': - terms.remove(term) - terms.sort(key=take_start_date) +def list_courses(session: requests.Session, user_name: str) -> Any: + + terms = get_terms(session) + sort_terms(terms) term_1 = terms[len(terms) - 1] term_2 = terms[len(terms) - 2] - course_memberships = session.get('https://ntnu.blackboard.com/learn/api/public/v1/users/userName:{}/courses'.format(user_name), cookies=cookies) - course_memberships = json.loads(course_memberships.text)['results'] + course_memberships = get_course_memberships(session, user_name) course_list = [] # Get courses from the correct terms for course in course_memberships: - response = session.get('https://ntnu.blackboard.com/learn/api/public/v3/courses/{}'.format(course['courseId']), cookies=cookies, params={'fields': 'id, name, termId'}) + url = url_builder.base_v3().add_courses().add_id(course['courseId']).create() + response = session.get(url, params={'fields': 'id, name, termId'}) response = json.loads(response.text) if response['termId'] == term_1['id'] or response['termId'] == term_2['id']: course_list.append({ @@ -41,10 +35,53 @@ def list_courses(cookies: Dict, user_name: str) -> Any: return course_list -def list_course(cookies: Dict, course_id:str) -> Any: - response = requests.get('https://ntnu.blackboard.com/learn/api/public/v3/courses/{}'.format(course_id), cookies=cookies) +def list_all_courses(session: requests.Session, user_name: str) -> Any: + course_memberships = get_course_memberships(session, user_name) + + course_list = [] + + for course in course_memberships: + url = url_builder.base_v3().add_courses().add_id(course['courseId']).create() + response = session.get(url, params={'fields': 'id, name'}) + response = json.loads(response.text) + course_list.append(response) + + return course_list + +def list_course(session: requests.Session, course_id:str) -> Any: + url = url_builder.base_v3().add_courses().add_id(course_id).create() + response = session.get(url) return json.loads(response.text) -def list_favorite_courses(cookies: Dict, user_name: str) -> Any: +def list_favorite_courses(session: requests.Session, user_name: str) -> Any: return "Blackboard rest api do not have an option for this yet" # response = requests.get('https://ntnu.blackboard.com/learn/api/public/v1/users/userName:{}/courses'.format(user_name), cookies=cookies) + + +""" + +HELPER FUNCTIONS + +""" + +def take_start_date(elem): + return date.fromisoformat(elem['availability']['duration']['start'].split('T')[0]) + +def get_terms(session: requests.Session): + url = url_builder.base_v1().add_terms().create() + terms = session.get(url) + terms = json.loads(terms.text)['results'] + return terms + +def sort_terms(terms): + # Sort terms by start date to get the two most recent semesters to determine which courses to show + for term in terms: + if term['availability']['duration']['type'] != 'DateRange': + terms.remove(term) + terms.sort(key=take_start_date) + +def get_course_memberships(session: requests.Session, user_name: str): + url = url_builder.base_v1().add_users().add_id(id=user_name, id_type='userName').add_courses().create() + course_memberships = session.get(url) + course_memberships = json.loads(course_memberships.text)['results'] + return course_memberships \ No newline at end of file diff --git a/bbcli/utils/URL_builder.py b/bbcli/utils/URL_builder.py new file mode 100644 index 0000000000000000000000000000000000000000..126e55cde999460ab73e36097d918b248143cbcb --- /dev/null +++ b/bbcli/utils/URL_builder.py @@ -0,0 +1,129 @@ +from __future__ import annotations +from typing import Any +from abc import ABC, abstractmethod + +DOMAIN = 'https://ntnu.blackboard.com' +API_BASE = '/learn/api/public' + + +class Builder(ABC): + + @property + @abstractmethod + def product(self) -> None: + pass + + + """ + Returns the base URL which includes the domain and first part of all the endpoints: domain/learn/api/public/vX, + where X is the version from 1 to 3. + """ + + @abstractmethod + def base_v1(self) -> Builder: + pass + + @abstractmethod + def base_v2(self) -> Builder: + pass + + @abstractmethod + def base_v3(self) -> Builder: + pass + + @abstractmethod + def add_courses(self) -> Builder: + pass + + @abstractmethod + def add_users(self) -> Builder: + pass + + @abstractmethod + def add_announcements(self) -> Builder: + pass + + @abstractmethod + def add_contents(self) -> Builder: + pass + + @abstractmethod + def add_terms(slef) -> Builder: + pass + + @abstractmethod + def add_id(self, id: str, id_type: str = None) -> Builder: + pass + + +class URLBuilder(Builder): + + def __init__(self) -> None: + self.reset() + + def reset(self) -> None: + self._product = URL() + + @property + def product(self) -> URL: + + product = self._product + self.reset() + return product + + + + def base_v1(self) -> URLBuilder: + self._product.add(f'{DOMAIN}{API_BASE}/v1') + return self + + def base_v2(self) -> URLBuilder: + self._product.add(f'{DOMAIN}{API_BASE}/v2') + return self + + def base_v3(self) -> URLBuilder: + self._product.add(f'{DOMAIN}{API_BASE}/v3') + return self + + def add_courses(self) -> URLBuilder: + self._product.add('/courses') + return self + + def add_users(self) -> URLBuilder: + self._product.add('/users') + return self + + def add_announcements(self) -> URLBuilder: + self._product.add('/announcements') + return self + + def add_contents(self) -> URLBuilder: + self._product.add('/contents') + return self + + def add_terms(self) -> URLBuilder: + self._product.add('/terms') + return self + + def add_id(self, id:str, id_type:str=None) -> URLBuilder: + if id_type: + self._product.add(f'/{id_type}:{id}') + else: + self._product.add(f'/{id}') + return self + + def create(self) -> str: + url = self._product.get_url() + self._product = URL() + return url + +class URL(): + + def __init__(self) -> None: + self.URL = '' + + def add(self, url_part: str) -> None: + self.URL += url_part + + def get_url(self) -> None: + return self.URL \ No newline at end of file diff --git a/bbcli/utils/error_handler.py b/bbcli/utils/error_handler.py new file mode 100644 index 0000000000000000000000000000000000000000..3ef18c457e14b69fb63848806eca035cc32ac56f --- /dev/null +++ b/bbcli/utils/error_handler.py @@ -0,0 +1,13 @@ +import requests +import click + +# ERROR HANDLER SHOULD BE USED IN VIEW?? + +def HTTP_exception_handler(func): + def inner_function(*args, **kwargs): + try: + func(*args, **kwargs) + except requests.exceptions.HTTPError as err: + click.echo(err) + click.Abort() + return inner_function \ No newline at end of file diff --git a/bbcli/utils/utils.py b/bbcli/utils/utils.py index 1f58f831eab864aa0066d68e7abf42634376b209..be69a2a95bd56a92257613d0897eb5eb10c117e7 100644 --- a/bbcli/utils/utils.py +++ b/bbcli/utils/utils.py @@ -1,4 +1,8 @@ from datetime import datetime +from typing import Dict, List +from requests import Session +import html2text + def check_valid_key(obj, key) -> bool: # print("the keys are", obj.keys()) @@ -31,3 +35,16 @@ def check_valid_date(cookies) -> bool: return False +def set_cookies(session: Session, cookies: List): + for cookie in cookies: + session.cookies.set(cookie['name'], cookie['value']) + + +def set_headers(session: Session, headers: List): + for header in headers: + session.headers.update(header) + + +def html_to_text(html_data: str): + to_text = html2text.HTML2Text() + return to_text.handle(html_data) \ No newline at end of file diff --git a/bbcli/views/__init__.py b/bbcli/views/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/bbcli/views/announcement_view.py b/bbcli/views/announcement_view.py new file mode 100644 index 0000000000000000000000000000000000000000..866af312e79460ec6ed2910b0513da259d0f3f16 --- /dev/null +++ b/bbcli/views/announcement_view.py @@ -0,0 +1,25 @@ +import click +from typing import List +from bbcli.utils.utils import html_to_text + + +def print_announcements(announcements: List): + for course in announcements: + print_course_announcements(course['course_announcements'], course['course_name']) + +def print_course_announcements(course_announcements: List, course_name: str = None): + + for announcement in course_announcements: + if 'body' in announcement: + announcement_id = announcement['id'] + title = announcement['title'] + body = html_to_text(announcement['body']) + created = announcement['created'].split('T')[0] + + click.echo('----------------------------------------------------------------------\n') + if course_name: + click.echo(f'{course_name}\n') + click.echo('{:<15} {:<15}'.format('Id: ', announcement_id)) + click.echo('{:<15} {:<15}'.format('Title: ', title)) + click.echo('{:<15} {:<15}'.format('Date: ', created)) + click.echo('\n{:<15}\n'.format(body)) diff --git a/bbcli/views/content_view.py b/bbcli/views/content_view.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/bbcli/views/course_view.py b/bbcli/views/course_view.py new file mode 100644 index 0000000000000000000000000000000000000000..2320342b5c914adba28da36c3b9f6f99d7270c9d --- /dev/null +++ b/bbcli/views/course_view.py @@ -0,0 +1,19 @@ +import click + +def print_courses(courses): + click.echo('\n{:<12} {:<5}\n'.format('Id', 'Course Name')) + for course in courses: + course_id = course['id'] + name = course['name'] + click.echo('{:<12} {:<5}'.format(course_id, name)) + click.echo('\n\n') + +def print_course(course): + + primary_id = course['id'] + course_id = course['courseId'] + name = course['name'] + + click.echo('\n{:<12} {:<12}'.format('Id:', primary_id)) + click.echo('{:<12} {:<12}'.format('Course Id:', course_id)) + click.echo('{:<12} {:<12}\n'.format('Name:', name)) \ No newline at end of file