diff --git a/bbcli/cli.py b/bbcli/cli.py index ac376cadf84b8e0ebe6a9fc4b389b3388737d7de..29bfc866e832332572915a75c3cc4895e625ac2c 100644 --- a/bbcli/cli.py +++ b/bbcli/cli.py @@ -9,8 +9,8 @@ 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 create_assignment, create_courselink, create_folder, delete_content, list_contents, create_document, create_file, create_web_link, update_content, upload_attachment, get_content -from bbcli.commands.assignments import get_assignments +from bbcli.commands.contents import create_assignment_from_contents, create_courselink, create_folder, delete_content, list_contents, create_document, create_file, create_web_link, update_content, upload_attachment, get_content +from bbcli.commands.assignments import get_assignments, submit_attempt, grade_assignment, get_attempts, get_attempt, submit_draft, update_attempt, submit_draft, create_assignment from bbcli.services.authorization_service import login load_dotenv() @@ -100,6 +100,23 @@ def assignments(ctx): assignments.add_command(get_assignments) assignments.add_command(create_assignment) +assignments.add_command(grade_assignment) + + +@assignments.group() +@click.pass_context +def attempts(ctx): + """ + Commands for creating, submitting and listing attempts for an assignment. + """ + pass + + +attempts.add_command(get_attempts) +attempts.add_command(get_attempt) +attempts.add_command(submit_attempt) +attempts.add_command(submit_draft) +attempts.add_command(update_attempt) """ CONTENT COMMANDS ENTRY POINT @@ -140,4 +157,4 @@ create.add_command(create_file) create.add_command(create_folder) create.add_command(create_courselink) create.add_command(upload_attachment) -create.add_command(create_assignment) +create.add_command(create_assignment_from_contents) diff --git a/bbcli/commands/assignments.py b/bbcli/commands/assignments.py index 429cdb605b5010eb2c72c2b1599a7c0216051891..f3e90bd84db6292fcd36273d92bffd41329263a1 100644 --- a/bbcli/commands/assignments.py +++ b/bbcli/commands/assignments.py @@ -1,14 +1,151 @@ -from email.policy import default import click +from bbcli.commands.contents import grading_options, set_dates, standard_options +from bbcli.entities.content_builder_entitites import GradingOptions, StandardOptions +from bbcli.services import assignment_service, contents_service +from bbcli.utils.error_handler import exception_handler +from bbcli.utils.utils import format_date -from bbcli.services import assignment_service +def attempt_options(function): + function = click.option( + '--status', default='Completed', help='The status of this attempt.', show_default=True)(function) + function = click.option( + '--text', help='The text grade associated with this attempt.')(function) + function = click.option( + '--score', type=int, help='The score associated with this attempt.')(function) + function = click.option( + '--notes', help='The instructor notes associated with this attempt.')(function) + function = click.option( + '--feedback', help='The instructor feedback associated with this attempt.')(function) + function = click.option('--exempt', is_flag=True, + help='Whether the score associated with this attempt is ignored when computing the user\'s grade for the associated grade column.')(function) + return function -@click.command(name='get') -@click.argument('course_id', required=True) + +# TODO: This function is a copy of the same function in contents.py. Fix this. +@click.command(name='create') +@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('attachments', required=False, nargs=-1, type=click.Path()) +@standard_options +@grading_options +@click.pass_context +@exception_handler +def create_assignment(ctx, course_id: str, parent_id: str, title: str, + hide_content: bool, reviewable: bool, + start_date: str, end_date: str, + due_date: str, max_attempts: int, unlimited_attempts: bool, score: int, + attachments: tuple): + """ + Create an assignment. + """ + standard_options = StandardOptions(hide_content, reviewable) + grading_options = GradingOptions( + attempts_allowed=max_attempts, is_unlimited_attemps_allowed=unlimited_attempts, score_possible=score) + + set_dates(standard_options, start_date, end_date) + grading_options.due = format_date(due_date) + + response = contents_service.create_assignment( + ctx.obj['SESSION'], course_id, parent_id, title, standard_options, grading_options, attachments) + click.echo(response) + + +@click.command(name='list') +@click.argument('course-id', required=True) @click.pass_context def get_assignments(ctx, course_id): """ - Get assignments + List assignments for a course. """ assignment_service.get_assignments(ctx.obj['SESSION'], course_id) + + +@click.command(name='list') +@click.argument('course_id', required=True) +@click.argument('column_id', required=True) +@click.option('--submitted', is_flag=True) +@click.pass_context +def get_attempts(ctx, course_id, column_id, submitted): + """ + List attempts for an assignment. + """ + assignment_service.get_column_attempts( + ctx.obj['SESSION'], course_id, column_id, print_submitted=submitted) + + +# TODO: Retrieve the submission w/ attachments. +@click.command(name='get') +@click.argument('course_id', required=True) +@click.argument('column_id', required=True) +@click.argument('attempt_id', required=True) +@click.pass_context +def get_attempt(ctx, course_id, column_id, attempt_id): + """ + Get a specific attempt for an assignment. + """ + assignment_service.get_column_attempt( + ctx.obj['SESSION'], course_id, column_id, attempt_id) + + +@click.command(name='submit') +@click.argument('course_id', required=True) +@click.argument('column_id', required=True) +@click.option('--studentComments', help='The student comments associated with this attempt.') +@click.option('--studentSubmission', help='The student submission text associated with this attempt.') +@click.option('--file', help='Attach a file to an attempt for a Student Submission. Relative path of file.') +@click.option('--draft', is_flag=True) +@click.pass_context +def submit_attempt(ctx, course_id, column_id, studentComments, studentSubmission, file, draft): + """ + Submit assignment attempt. + """ + assignment_service.create_column_attempt( + ctx.obj['SESSION'], course_id, column_id, studentComments=studentComments, studentSubmission=studentSubmission, dst=file, status='needsGrading', draft=draft) + + +@click.command(name='submit-draft') +@click.argument('course_id', required=True) +@click.argument('column_id', required=True) +@click.argument('attempt_id', required=True) +@click.pass_context +def submit_draft(ctx, course_id, column_id, attempt_id): + """ + Submit assignment draft. + """ + assignment_service.update_column_attempt( + ctx.obj['SESSION'], course_id=course_id, column_id=column_id, attempt_id=attempt_id, status='needsGrading') + + +@click.command(name='update') +@click.argument('course_id', required=True) +@click.argument('column_id', required=True) +@click.argument('attempt_id', required=True) +@attempt_options +@click.option('--studentComments', help='The student comments associated with this attempt.') +@click.option('--studentSubmission', help='The student submission text associated with this attempt.') +@click.pass_context +def update_attempt(ctx, course_id, column_id, attempt_id, status, comments, submission, file): + """ + Update assignment. + """ + assignment_service.update_column_attempt( + session=ctx.obj['SESSION'], course_id=course_id, column_id=column_id, attempt_id=attempt_id, status=status, studentComments=comments, studentSubmission=submission, dst=file) + + +@click.command(name='grade') +@click.argument('course_id', required=True) +@click.argument('column_id', required=True) +@click.argument('attempt_id', required=True) +@attempt_options +@click.pass_context +def grade_assignment(ctx, course_id, column_id, attempt_id, status, score, text, notes, feedback, exempt): + """ + Grade assignment. + """ + if status is None: + status = 'Completed' + + assignment_service.update_column_attempt(session=ctx.obj['SESSION'], status=status, course_id=course_id, column_id=column_id, + attempt_id=attempt_id, score=score, text=text, notes=notes, feedback=feedback, exempt=exempt) diff --git a/bbcli/commands/contents.py b/bbcli/commands/contents.py index 3e62f3e3d108596e709dc0c38d2cfcda707345f9..872c27819edf25db06ecf3b21eccee718f8f1181 100644 --- a/bbcli/commands/contents.py +++ b/bbcli/commands/contents.py @@ -269,7 +269,7 @@ def create_courselink(ctx, course_id: str, parent_id: str, title: str, target_id @grading_options @click.pass_context @exception_handler -def create_assignment(ctx, course_id: str, parent_id: str, title: str, +def create_assignment_from_contents(ctx, course_id: str, parent_id: str, title: str, hide_content: bool, reviewable: bool, start_date: str, end_date: str, due_date: str, max_attempts: int, unlimited_attempts: bool, score: int, diff --git a/bbcli/services/assignment_service.py b/bbcli/services/assignment_service.py index e4755c5d3d4b2031bd6dab98b84410c8aafa6425..364d25e6c51900819a62127db8c88e810c448cde 100644 --- a/bbcli/services/assignment_service.py +++ b/bbcli/services/assignment_service.py @@ -3,6 +3,9 @@ import json import click import requests import dateutil.parser +from bbcli.services.contents_service import upload_file +from bbcli.services.utils.attempt_builder import AttemptBuilder +from tabulate import tabulate from bbcli.utils.URL_builder import URL_builder @@ -20,18 +23,129 @@ def get_assignments(session: requests.Session, course_id): def print_assignments(assignments): for i in range(len(assignments)): + column_id = assignments[i]['id'] name = assignments[i]['name'] due = 'N/A' - if ('grading' in assignments[i]): - if ('due' in assignments[i]['grading']): + if 'grading' in assignments[i]: + if 'due' in assignments[i]['grading']: due = assignments[i]['grading']['due'] due_datetime = utc_to_local(dateutil.parser.parse(due)) date = str(due_datetime.date()) time = str(due_datetime.time()) due = f'{date} {time}' - click.echo('{:<40s} due {:<10s}'.format(name, due)) + click.echo('{:<12s}{:<40s} due {:<10s}'.format(column_id, name, due)) def utc_to_local(utc_dt): return utc_dt.replace(tzinfo=timezone.utc).astimezone(tz=None) + + +def get_column_attempts(session: requests.Session, course_id, column_id, print_submitted): + url = url_builder.base_v2().add_courses().add_id(course_id).add_gradebook( + ).add_columns().add_id(column_id).add_attempts().create() + + response = session.get(url) + response = json.loads(response.text) + results = response['results'] + + if print_submitted: + print_submitted_attempts(results) + else: + print_all_attempts(results) + + +def print_submitted_attempts(attempts): + table = {'id': [], 'user id': [], 'status': [], 'score': [], 'created': []} + statuses = ['NeedsGrading', 'Completed'] + for attempt in attempts: + for status in statuses: + if (status == attempt['status']): + append_to_table(attempt, table) + continue + + click.echo(tabulate(table, headers='keys')) + + +def print_all_attempts(attempts): + table = {'id': [], 'user id': [], 'status': [], 'score': [], 'created': []} + for attempt in attempts: + append_to_table(attempt, table) + click.echo(tabulate(table, headers='keys')) + + +def append_to_table(attempt, table): + table['id'].append(attempt['id']) + table['user id'].append(attempt['userId']) + table['status'].append(attempt['status']) + table['score'].append( + attempt['score']) if 'score' in attempt else table['score'].append('N/A') + created = utc_to_local(dateutil.parser.parse(attempt['created'])) + table['created'].append(created) + + +def get_column_attempt(session: requests.Session, course_id, column_id, attempt_id): + url = url_builder.base_v2().add_courses().add_id(course_id).add_gradebook( + ).add_columns().add_id(column_id).add_attempts().add_id(attempt_id).create() + + response = session.get(url) + attempt = json.loads(response.text) + attempt = json.dumps(attempt, indent=2) + click.echo(attempt) + + +def create_column_attempt(session: requests.Session, course_id, column_id, studentComments=None, studentSubmission=None, dst: str = None, status=None, draft: bool = False): + url = url_builder.base_v2().add_courses().add_id(course_id).add_gradebook( + ).add_columns().add_id(column_id).add_attempts().create() + + if draft or dst is not None: + status = 'InProgress' + + attempt = AttemptBuilder(studentComments=studentComments, + studentSubmission=studentSubmission, + status=status) + data = attempt.create_json() + json_data = json.dumps(data, indent=2) + response = session.post(url, data=json_data) + response_json = json.loads(response.text) + click.echo(response_json) + + if dst is not None and response.status_code == 201: + attempt_id = response_json['id'] + attach_file(session, course_id, attempt_id, dst) + if draft: + return + update_column_attempt(session, course_id, column_id, + attempt_id, status='NeedsGrading') + + +def update_column_attempt(session: requests.Session, course_id, column_id, attempt_id, status=None, score=None, text=None, notes=None, feedback=None, studentComments=None, studentSubmission=None, exempt=None, dst=None): + + url = url_builder.base_v2().add_courses().add_id(course_id).add_gradebook( + ).add_columns().add_id(column_id).add_attempts().add_id(attempt_id).create() + + attempt = AttemptBuilder(status=status, score=score, text=text, + notes=notes, feedback=feedback, studentComments=studentComments, studentSubmission=studentSubmission, exempt=exempt) + data = attempt.create_json() + json_data = json.dumps(data, indent=2) + + response = session.patch(url, data=json_data) + response = json.loads(response.text) + click.echo(response) + + if dst is not None: + attach_file(session, course_id, attempt_id, dst) + + +def attach_file(session: requests.Session, course_id, attempt_id, dst: str): + url = url_builder.base_v1().add_courses().add_id( + course_id).add_gradebook().add_attempts().add_id(attempt_id).add_files().create() + + uploaded_file = upload_file(session, dst) + + data = {'uploadId': uploaded_file['id']} + data = json.dumps(data) + response = session.post(url, data) + + response_json = json.loads(response.text) + click.echo(response_json) diff --git a/bbcli/services/utils/attempt_builder.py b/bbcli/services/utils/attempt_builder.py new file mode 100644 index 0000000000000000000000000000000000000000..e788a844122c496d12517c31096ccaa3d34153dc --- /dev/null +++ b/bbcli/services/utils/attempt_builder.py @@ -0,0 +1,46 @@ +class AttemptBuilder(object): + def __init__(self, status=None, score=None, text=None, notes=None, feedback=None, studentComments=None, studentSubmission=None, exempt=None) -> None: + self.status = status + self.score = score + self.text = text + self.notes = notes + self.feedback = feedback + self.studentComments = studentComments + self.studentSubmission = studentSubmission + self.exempt = exempt + + def create_json(self): + attempt = {} + if self.status: + attempt.update({ + 'status': self.status + }) + if self.score: + attempt.update({ + 'score': self.score + }) + if self.text: + attempt.update({ + 'text': self.text + }) + if self.notes: + attempt.update({ + 'notes': self.notes + }) + if self.feedback: + attempt.update({ + 'feedback': self.feedback + }) + if self.studentComments: + attempt.update({ + 'studentComments': self.studentComments + }) + if self.studentSubmission: + attempt.update({ + 'studentSubmission': self.studentSubmission + }) + if self.exempt: + attempt.update({ + 'exempt': self.exempt + }) + return attempt diff --git a/bbcli/utils/URL_builder.py b/bbcli/utils/URL_builder.py index 337ce96a18095091b827189fa2a5e0764a1d2501..12c72add6d9ea05ebedcbab3a11b130afed64ba7 100644 --- a/bbcli/utils/URL_builder.py +++ b/bbcli/utils/URL_builder.py @@ -82,6 +82,18 @@ class Builder(ABC): def add_columns(self) -> Builder: pass + @abstractmethod + def add_attempts(self) -> Builder: + pass + + @abstractmethod + def add_files(self) -> Builder: + pass + + @abstractmethod + def create(self) -> Builder: + pass + class URL_builder(Builder): @@ -164,6 +176,14 @@ class URL_builder(Builder): self._product.add('/columns') return self + def add_attempts(self) -> Builder: + self._product.add('/attempts') + return self + + def add_files(self) -> Builder: + self._product.add('/files') + return self + def create(self) -> str: url = self._product.get_url() self._product = URL()