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()