Repository: jupyter/nbgrader

Issue:
Nbgrader as a webapp
Much of the nbgrader complexity for the user could be reduced if it were a webapp for jupyterhub. It could:
- manage the enrolled students (add, delete, edit students)
- manage the assignments (create, edit, release, preview, grading, return)

Currently, items are spread out in nbgrader_config.py, the sqlite database, and in the file system (local source, release, autograded, and exchange versions). Initially, there doesn't need to be too much javascript, but just coding as a tornado webapp, getting its auth from jupyterhub.

One could also still allow programming uses (say, having programmatic access to the gradebook), but the webapp could allow 80% or more of what instructors do.


Discussion:
dsblank: Great news! I look forward to trying this out!
lgpage: @jhamrick this really really looks awesome :D 

I get an error if I navigate `Gradbook tab -> ps1 -> problem1` without ps1 having been assigned first under `Manage Assignments`. Although this may be a buggy env on my side. This was tried out with only the files created from `nbgrader quickstart`

I think maybe as a future PR a way to view the log files


jhamrick: @lgpage Good catch, thanks!
jhamrick: Thanks @willingc for the review! 😄 Sorry there's so much there to go through... if it helps, the high level changes that I made were to make the webapp source the data for the tables through ajax using a MVC pattern through backbone. I made an earlier PR ( #780 ) which created a high level API that the REST API then accesses for actually querying and modifying the data.

Patch:
diff --git a/nbgrader/apps/api.py b/nbgrader/apps/api.py
index bdc8a545..1dd939f1 100644
--- a/nbgrader/apps/api.py
+++ b/nbgrader/apps/api.py
@@ -6,13 +6,13 @@ import six
 import logging
 
 from traitlets.config import LoggingConfigurable
-from traitlets import Instance, Enum, observe
+from traitlets import Instance, Enum, Unicode, observe
 
 from ..coursedir import CourseDirectory
 from ..converters import Assign, Autograde
 from ..exchange import ExchangeList, ExchangeRelease, ExchangeCollect
 from ..api import MissingEntry, Gradebook
-from ..utils import parse_utc, temp_attrs, capture_log
+from ..utils import parse_utc, temp_attrs, capture_log, as_timezone
 
 
 class NbGraderAPI(LoggingConfigurable):
@@ -27,6 +27,16 @@ class NbGraderAPI(LoggingConfigurable):
         help="Set the log level by value or name."
     ).tag(config=True)
 
+    timezone = Unicode(
+        "UTC",
+        help="Timezone for displaying timestamps"
+    ).tag(config=True)
+
+    timestamp_format = Unicode(
+        "%Y-%m-%d %H:%M:%S %Z",
+        help="Format string for displaying timestamps"
+    ).tag(config=True)
+
     @observe('log_level')
     def _log_level_changed(self, change):
         """Adjust the log level when log_level is set."""
@@ -284,7 +294,16 @@ class NbGraderAPI(LoggingConfigurable):
         # see if there is information about the assignment in the database
         try:
             with self.gradebook as gb:
-                assignment = gb.find_assignment(assignment_id).to_dict()
+                db_assignment = gb.find_assignment(assignment_id)
+                assignment = db_assignment.to_dict()
+                if db_assignment.duedate:
+                    ts = as_timezone(db_assignment.duedate, self.timezone)
+                    assignment["display_duedate"] = ts.strftime(self.timestamp_format)
+                    assignment["duedate_notimezone"] = ts.replace(tzinfo=None).isoformat()
+                else:
+                    assignment["display_duedate"] = None
+                    assignment["duedate_notimezone"] = None
+                assignment["duedate_timezone"] = self.timezone
                 assignment["average_score"] = gb.average_assignment_score(assignment_id)
                 assignment["average_code_score"] = gb.average_assignment_code_score(assignment_id)
                 assignment["average_written_score"] = gb.average_assignment_written_score(assignment_id)
@@ -294,6 +313,9 @@ class NbGraderAPI(LoggingConfigurable):
                 "id": None,
                 "name": assignment_id,
                 "duedate": None,
+                "display_duedate": None,
+                "duedate_notimezone": None,
+                "duedate_timezone": self.timezone,
                 "average_score": 0,
                 "average_code_score": 0,
                 "average_written_score": 0,
@@ -370,7 +392,7 @@ class NbGraderAPI(LoggingConfigurable):
                 assignment = None
 
             # if the assignment exists in the database
-            if assignment:
+            if assignment and assignment.notebooks:
                 notebooks = []
                 for notebook in assignment.notebooks:
                     x = notebook.to_dict()
@@ -435,11 +457,19 @@ class NbGraderAPI(LoggingConfigurable):
             ungraded = self.get_submitted_students(assignment_id) - autograded
 
         if student_id in ungraded:
-            timestamp = self.get_submitted_timestamp(assignment_id, student_id).isoformat()
+            ts = self.get_submitted_timestamp(assignment_id, student_id)
+            if ts:
+                timestamp = ts.isoformat()
+                display_timestamp = as_timezone(ts, self.timezone).strftime(self.timestamp_format)
+            else:
+                timestamp = None
+                display_timestamp = None
+
             submission = {
                 "id": None,
                 "name": assignment_id,
                 "timestamp": timestamp,
+                "display_timestamp": display_timestamp,
                 "score": 0.0,
                 "max_score": 0.0,
                 "code_score": 0.0,
@@ -465,7 +495,10 @@ class NbGraderAPI(LoggingConfigurable):
         elif student_id in autograded:
             with self.gradebook as gb:
                 try:
-                    submission = gb.find_submission(assignment_id, student_id).to_dict()
+                    db_submission = gb.find_submission(assignment_id, student_id)
+                    submission = db_submission.to_dict()
+                    submission["display_timestamp"] = as_timezone(
+                        db_submission.timestamp, self.timezone).strftime(self.timestamp_format)
                 except MissingEntry:
                     return None
 
@@ -477,6 +510,7 @@ class NbGraderAPI(LoggingConfigurable):
                 "id": None,
                 "name": assignment_id,
                 "timestamp": None,
+                "display_timestamp": None,
                 "score": 0.0,
                 "max_score": 0.0,
                 "code_score": 0.0,
@@ -523,7 +557,14 @@ class NbGraderAPI(LoggingConfigurable):
         for submission in db_submissions:
             if submission["student"] in ungraded:
                 continue
-            submission["timestamp"] = submission["timestamp"].isoformat()
+            ts = submission["timestamp"]
+            if ts:
+                submission["timestamp"] = ts.isoformat()
+                submission["display_timestamp"] = as_timezone(
+                    ts, self.timezone).strftime(self.timestamp_format)
+            else:
+                submission["timestamp"] = None
+                submission["display_timestamp"] = None
             submission["autograded"] = True
             submission["submitted"] = True
             submissions.append(submission)
@@ -608,8 +649,13 @@ class NbGraderAPI(LoggingConfigurable):
 
         """
         with self.gradebook as gb:
-            gb.find_notebook(notebook_id, assignment_id)
+            try:
+                gb.find_notebook(notebook_id, assignment_id)
+            except MissingEntry:
+                return []
+
             submissions = gb.notebook_submission_dicts(notebook_id, assignment_id)
+
         indices = self.get_notebook_submission_indices(assignment_id, notebook_id)
         for nb in submissions:
             nb['index'] = indices.get(nb['id'], None)
diff --git a/nbgrader/server_extensions/formgrader/apihandlers.py b/nbgrader/server_extensions/formgrader/apihandlers.py
index 377eb351..e49d2e83 100644
--- a/nbgrader/server_extensions/formgrader/apihandlers.py
+++ b/nbgrader/server_extensions/formgrader/apihandlers.py
@@ -1,13 +1,16 @@
 import json
+import os
+import traceback
 
 from tornado import web
 
-from .base import BaseApiHandler
+from .base import BaseApiHandler, check_xsrf
 from ...api import MissingEntry
 
 
 class GradeCollectionHandler(BaseApiHandler):
     @web.authenticated
+    @check_xsrf
     def get(self):
         submission_id = self.get_argument("submission_id")
         try:
@@ -19,6 +22,7 @@ class GradeCollectionHandler(BaseApiHandler):
 
 class CommentCollectionHandler(BaseApiHandler):
     @web.authenticated
+    @check_xsrf
     def get(self):
         submission_id = self.get_argument("submission_id")
         try:
@@ -30,6 +34,7 @@ class CommentCollectionHandler(BaseApiHandler):
 
 class GradeHandler(BaseApiHandler):
     @web.authenticated
+    @check_xsrf
     def get(self, grade_id):
         try:
             grade = self.gradebook.find_grade_by_id(grade_id)
@@ -38,6 +43,7 @@ class GradeHandler(BaseApiHandler):
         self.write(json.dumps(grade.to_dict()))
 
     @web.authenticated
+    @check_xsrf
     def put(self, grade_id):
         try:
             grade = self.gradebook.find_grade_by_id(grade_id)
@@ -57,6 +63,7 @@ class GradeHandler(BaseApiHandler):
 
 class CommentHandler(BaseApiHandler):
     @web.authenticated
+    @check_xsrf
     def get(self, grade_id):
         try:
             comment = self.gradebook.find_comment_by_id(grade_id)
@@ -65,6 +72,7 @@ class CommentHandler(BaseApiHandler):
         self.write(json.dumps(comment.to_dict()))
 
     @web.authenticated
+    @check_xsrf
     def put(self, grade_id):
         try:
             comment = self.gradebook.find_comment_by_id(grade_id)
@@ -79,6 +87,7 @@ class CommentHandler(BaseApiHandler):
 
 class FlagSubmissionHandler(BaseApiHandler):
     @web.authenticated
+    @check_xsrf
     def post(self, submission_id):
         try:
             submission = self.gradebook.find_submission_notebook_by_id(submission_id)
@@ -90,10 +99,180 @@ class FlagSubmissionHandler(BaseApiHandler):
         self.write(json.dumps(submission.to_dict()))
 
 
+class AssignmentCollectionHandler(BaseApiHandler):
+    @web.authenticated
+    @check_xsrf
+    def get(self):
+        assignments = self.api.get_assignments()
+        self.write(json.dumps(assignments))
+
+
+class AssignmentHandler(BaseApiHandler):
+    @web.authenticated
+    @check_xsrf
+    def get(self, assignment_id):
+        assignment = self.api.get_assignment(assignment_id)
+        if assignment is None:
+            raise web.HTTPError(404)
+        self.write(json.dumps(assignment))
+
+    @web.authenticated
+    @check_xsrf
+    def put(self, assignment_id):
+        data = self.get_json_body()
+        duedate = data.get("duedate_notimezone", None)
+        timezone = data.get("duedate_timezone", None)
+        if duedate and timezone:
+            duedate = duedate + " " + timezone
+        assignment = {"duedate": duedate}
+        self.gradebook.update_or_create_assignment(assignment_id, **assignment)
+        sourcedir = os.path.abspath(self.coursedir.format_path(self.coursedir.source_directory, '.', assignment_id))
+        if not os.path.isdir(sourcedir):
+            os.makedirs(sourcedir)
+        self.write(json.dumps(self.api.get_assignment(assignment_id)))
+
+
+class NotebookCollectionHandler(BaseApiHandler):
+    @web.authenticated
+    @check_xsrf
+    def get(self, assignment_id):
+        notebooks = self.api.get_notebooks(assignment_id)
+        self.write(json.dumps(notebooks))
+
+
+class SubmissionCollectionHandler(BaseApiHandler):
+    @web.authenticated
+    @check_xsrf
+    def get(self, assignment_id):
+        submissions = self.api.get_submissions(assignment_id)
+        self.write(json.dumps(submissions))
+
+
+class SubmissionHandler(BaseApiHandler):
+    @web.authenticated
+    @check_xsrf
+    def get(self, assignment_id, student_id):
+        submission = self.api.get_submission(assignment_id, student_id)
+        if submission is None:
+            raise web.HTTPError(404)
+        self.write(json.dumps(submission))
+
+
+class SubmittedNotebookCollectionHandler(BaseApiHandler):
+    @web.authenticated
+    @check_xsrf
+    def get(self, assignment_id, notebook_id):
+        submissions = self.api.get_notebook_submissions(assignment_id, notebook_id)
+        self.write(json.dumps(submissions))
+
+
+class StudentCollectionHandler(BaseApiHandler):
+    @web.authenticated
+    @check_xsrf
+    def get(self):
+        students = self.api.get_students()
+        self.write(json.dumps(students))
+
+
+class StudentHandler(BaseApiHandler):
+    @web.authenticated
+    @check_xsrf
+    def get(self, student_id):
+        student = self.api.get_student(student_id)
+        if student is None:
+            raise web.HTTPError(404)
+        self.write(json.dumps(student))
+
+    @web.authenticated
+    @check_xsrf
+    def put(self, student_id):
+        data = self.get_json_body()
+        student = {
+            "last_name": data.get("last_name", None),
+            "first_name": data.get("first_name", None),
+            "email": data.get("email", None),
+        }
+        self.gradebook.update_or_create_student(student_id, **student)
+        self.write(json.dumps(self.api.get_student(student_id)))
+
+
+class StudentSubmissionCollectionHandler(BaseApiHandler):
+    @web.authenticated
+    @check_xsrf
+    def get(self, student_id):
+        submissions = self.api.get_student_submissions(student_id)
+        self.write(json.dumps(submissions))
+
+
+class StudentNotebookSubmissionCollectionHandler(BaseApiHandler):
+    @web.authenticated
+    @check_xsrf
+    def get(self, student_id, assignment_id):
+        submissions = self.api.get_student_notebook_submissions(student_id, assignment_id)
+        self.write(json.dumps(submissions))
+
+
+class AssignHandler(BaseApiHandler):
+    @web.authenticated
+    @check_xsrf
+    def post(self, assignment_id):
+        self.write(json.dumps(self.api.assign(assignment_id)))
+
+
+class UnReleaseHandler(BaseApiHandler):
+    @web.authenticated
+    @check_xsrf
+    def post(self, assignment_id):
+        self.write(json.dumps(self.api.unrelease(assignment_id)))
+
+
+class ReleaseHandler(BaseApiHandler):
+    @web.authenticated
+    @check_xsrf
+    def post(self, assignment_id):
+        self.write(json.dumps(self.api.release(assignment_id)))
+
+
+class CollectHandler(BaseApiHandler):
+    @web.authenticated
+    @check_xsrf
+    def post(self, assignment_id):
+        self.write(json.dumps(self.api.collect(assignment_id)))
+
+
+class AutogradeHandler(BaseApiHandler):
+    @web.authenticated
+    @check_xsrf
+    def post(self, assignment_id, student_id):
+        self.write(json.dumps(self.api.autograde(assignment_id, student_id)))
+
+
 default_handlers = [
+    (r"/formgrader/api/assignments", AssignmentCollectionHandler),
+    (r"/formgrader/api/assignment/([^/]+)", AssignmentHandler),
+    (r"/formgrader/api/assignment/([^/]+)/assign", AssignHandler),
+    (r"/formgrader/api/assignment/([^/]+)/unrelease", UnReleaseHandler),
+    (r"/formgrader/api/assignment/([^/]+)/release", ReleaseHandler),
+    (r"/formgrader/api/assignment/([^/]+)/collect", CollectHandler),
+
+    (r"/formgrader/api/notebooks/([^/]+)", NotebookCollectionHandler),
+
+    (r"/formgrader/api/submissions/([^/]+)", SubmissionCollectionHandler),
+    (r"/formgrader/api/submission/([^/]+)/([^/]+)", SubmissionHandler),
+    (r"/formgrader/api/submission/([^/]+)/([^/]+)/autograde", AutogradeHandler),
+
+    (r"/formgrader/api/submitted_notebooks/([^/]+)/([^/]+)", SubmittedNotebookCollectionHandler),
+    (r"/formgrader/api/submitted_notebook/([^/]+)/flag", FlagSubmissionHandler),
+
     (r"/formgrader/api/grades", GradeCollectionHandler),
-    (r"/formgrader/api/comments", CommentCollectionHandler),
     (r"/formgrader/api/grade/([^/]+)", GradeHandler),
+
+    (r"/formgrader/api/comments", CommentCollectionHandler),
     (r"/formgrader/api/comment/([^/]+)", CommentHandler),
-    (r"/formgrader/api/submission/([^/]+)/flag", FlagSubmissionHandler)
+
+    (r"/formgrader/api/students", StudentCollectionHandler),
+    (r"/formgrader/api/student/([^/]+)", StudentHandler),
+
+    (r"/formgrader/api/student_submissions/([^/]+)", StudentSubmissionCollectionHandler),
+    (r"/formgrader/api/student_notebook_submissions/([^/]+)/([^/]+)", StudentNotebookSubmissionCollectionHandler),
 ]
diff --git a/nbgrader/server_extensions/formgrader/base.py b/nbgrader/server_extensions/formgrader/base.py
index dbb63212..7a8473e5 100644
--- a/nbgrader/server_extensions/formgrader/base.py
+++ b/nbgrader/server_extensions/formgrader/base.py
@@ -1,9 +1,11 @@
 import os
 import json
+import functools
 
 from tornado import web
 from notebook.base.handlers import IPythonHandler
 from ...api import Gradebook
+from ...apps.api import NbGraderAPI
 
 
 class BaseHandler(IPythonHandler):
@@ -16,11 +18,15 @@ class BaseHandler(IPythonHandler):
     def db_url(self):
         return self.settings['nbgrader_db_url']
 
+    @property
+    def coursedir(self):
+        return self.settings['nbgrader_coursedir']
+
     @property
     def gradebook(self):
-        self.log.debug("getting gradebook")
         gb = self.settings['nbgrader_gradebook']
         if gb is None:
+            self.log.debug("creating gradebook")
             gb = Gradebook(self.db_url)
             self.settings['nbgrader_gradebook'] = gb
         return gb
@@ -29,50 +35,16 @@ class BaseHandler(IPythonHandler):
     def mathjax_url(self):
         return self.settings['mathjax_url']
 
-    @property
-    def notebook_dir(self):
-        return self.settings['nbgrader_notebook_dir']
-
-    @property
-    def notebook_dir_format(self):
-        return self.settings['nbgrader_notebook_dir_format']
-
-    @property
-    def nbgrader_step(self):
-        return self.settings['nbgrader_step']
-
     @property
     def exporter(self):
         return self.settings['nbgrader_exporter']
 
     @property
-    def notebook_url_prefix(self):
-        return self.settings['nbgrader_notebook_url_prefix']
-
-    def _filter_existing_notebooks(self, assignment_id, notebooks):
-        path = os.path.join(
-            self.notebook_dir,
-            self.notebook_dir_format,
-            "{notebook_id}.ipynb"
-        )
-
-        submissions = list()
-        for nb in notebooks:
-            filename = path.format(
-                nbgrader_step=self.nbgrader_step,
-                assignment_id=assignment_id,
-                notebook_id=nb.name,
-                student_id=nb.student.id
-            )
-            if os.path.exists(filename):
-                submissions.append(nb)
-
-        return sorted(submissions, key=lambda x: x.id)
-
-    def _notebook_submission_indexes(self, assignment_id, notebook_id):
-        notebooks = self.gradebook.notebook_submissions(notebook_id, assignment_id)
-        submissions = self._filter_existing_notebooks(assignment_id, notebooks)
-        return dict([(x.id, i) for i, x in enumerate(submissions)])
+    def api(self):
+        level = self.log.level
+        api = NbGraderAPI(self.coursedir, parent=self.coursedir.parent)
+        api.log_level = level
+        return api
 
     def render(self, name, **ns):
         template = self.settings['nbgrader_jinja2_env'].get_template(name)
@@ -81,19 +53,19 @@ class BaseHandler(IPythonHandler):
     def write_error(self, status_code, **kwargs):
         if status_code == 500:
             html = self.render(
-                'gradebook_500.tpl',
+                'base_500.tpl',
                 base_url=self.base_url,
                 error_code=500)
 
         elif status_code == 502:
             html = self.render(
-                'gradebook_500.tpl',
+                'base_500.tpl',
                 base_url=self.base_url,
                 error_code=502)
 
         elif status_code == 403:
             html = self.render(
-                'gradebook_403.tpl',
+                'base_403.tpl',
                 base_url=self.base_url,
                 error_code=403)
 
@@ -118,3 +90,11 @@ class BaseApiHandler(BaseHandler):
             self.log.error("Couldn't parse JSON", exc_info=True)
             raise web.HTTPError(400, 'Invalid JSON in body of request')
         return model
+
+
+def check_xsrf(f):
+    @functools.wraps(f)
+    def wrapper(self, *args, **kwargs):
+        _ = self.xsrf_token
+        return f(self, *args, **kwargs)
+    return wrapper
diff --git a/nbgrader/server_extensions/formgrader/formgrader.py b/nbgrader/server_extensions/formgrader/formgrader.py
index a6d79243..38006146 100644
--- a/nbgrader/server_extensions/formgrader/formgrader.py
+++ b/nbgrader/server_extensions/formgrader/formgrader.py
@@ -33,14 +33,11 @@ class FormgradeExtension(NbGrader):
 
         # Configure the formgrader settings
         tornado_settings = dict(
-            nbgrader_notebook_dir=self.coursedir.root,
-            nbgrader_notebook_dir_format=self.coursedir.directory_structure,
-            nbgrader_step=self.coursedir.autograded_directory,
+            nbgrader_coursedir=self.coursedir,
             nbgrader_exporter=HTMLExporter(config=self.config),
             nbgrader_gradebook=None,
             nbgrader_db_url=self.coursedir.db_url,
             nbgrader_jinja2_env=jinja_env,
-            nbgrader_notebook_url_prefix=os.path.relpath(self.coursedir.root)
         )
 
         webapp.settings.update(tornado_settings)
@@ -66,7 +63,7 @@ class FormgradeExtension(NbGrader):
 
 def load_jupyter_server_extension(nbapp):
     """Load the formgrader extension"""
-
+    nbapp.log.info("Loading the formgrader nbgrader serverextension")
     webapp = nbapp.web_app
     formgrader = FormgradeExtension(parent=nbapp)
     formgrader.initialize([])
diff --git a/nbgrader/server_extensions/formgrader/handlers.py b/nbgrader/server_extensions/formgrader/handlers.py
index b28b798f..40957153 100644
--- a/nbgrader/server_extensions/formgrader/handlers.py
+++ b/nbgrader/server_extensions/formgrader/handlers.py
@@ -3,157 +3,61 @@ import re
 
 from tornado import web
 
-from .base import BaseHandler
+from .base import BaseHandler, check_xsrf
 from ...api import MissingEntry
 
 
-class AssignmentsHandler(BaseHandler):
+class ManageAssignmentsHandler(BaseHandler):
     @web.authenticated
+    @check_xsrf
     def get(self):
-        assignments = []
-        for assignment in self.gradebook.assignments:
-            x = assignment.to_dict()
-            x["average_score"] = self.gradebook.average_assignment_score(assignment.name)
-            x["average_code_score"] = self.gradebook.average_assignment_code_score(assignment.name)
-            x["average_written_score"] = self.gradebook.average_assignment_written_score(assignment.name)
-            assignments.append(x)
-
         html = self.render(
-            "assignments.tpl",
-            assignments=assignments,
+            "manage_assignments.tpl",
             base_url=self.base_url)
-
         self.write(html)
 
 
-class AssignmentNotebooksHandler(BaseHandler):
+class ManageSubmissionsHandler(BaseHandler):
     @web.authenticated
+    @check_xsrf
     def get(self, assignment_id):
-        try:
-            assignment = self.gradebook.find_assignment(assignment_id)
-        except MissingEntry:
-            raise web.HTTPError(404, "Invalid assignment: {}".format(assignment_id))
-
-        notebooks = []
-        for notebook in assignment.notebooks:
-            x = notebook.to_dict()
-            x["average_score"] = self.gradebook.average_notebook_score(notebook.name, assignment.name)
-            x["average_code_score"] = self.gradebook.average_notebook_code_score(notebook.name, assignment.name)
-            x["average_written_score"] = self.gradebook.average_notebook_written_score(notebook.name, assignment.name)
-            notebooks.append(x)
-        assignment = assignment.to_dict()
-
         html = self.render(
-            "assignment_notebooks.tpl",
-            assignment=assignment,
-            notebooks=notebooks,
-            base_url=self.base_url)
-
-        self.write(html)
-
-
-class AssignmentNotebookSubmissionsHandler(BaseHandler):
-    @web.authenticated
-    def get(self, assignment_id, notebook_id):
-        try:
-            self.gradebook.find_notebook(notebook_id, assignment_id)
-        except MissingEntry:
-            raise web.HTTPError(404, "Invalid notebook: {}/{}".format(assignment_id, notebook_id))
-
-        submissions = self.gradebook.notebook_submission_dicts(notebook_id, assignment_id)
-        indexes = self._notebook_submission_indexes(assignment_id, notebook_id)
-        for nb in submissions:
-            nb['index'] = indexes.get(nb['id'], None)
-
-        submissions = [x for x in submissions if x['index'] is not None]
-        submissions.sort(key=lambda x: x["id"])
-
-        html = self.render(
-            "notebook_submissions.tpl",
-            notebook_id=notebook_id,
+            "manage_submissions.tpl",
+            course_dir=self.coursedir.root,
             assignment_id=assignment_id,
-            submissions=submissions,
-            base_url=self.base_url
-        )
+            base_url=self.base_url)
         self.write(html)
 
 
-class StudentsHandler(BaseHandler):
+class GradebookAssignmentsHandler(BaseHandler):
     @web.authenticated
+    @check_xsrf
     def get(self):
-        students = self.gradebook.student_dicts()
-        students.sort(key=lambda x: x.get("last_name") or "no last name")
-
         html = self.render(
-            "students.tpl",
-            students=students,
+            "gradebook_assignments.tpl",
             base_url=self.base_url)
-
         self.write(html)
 
 
-class StudentAssignmentsHandler(BaseHandler):
+class GradebookNotebooksHandler(BaseHandler):
     @web.authenticated
-    def get(self, student_id):
-        try:
-            student = self.gradebook.find_student(student_id)
-        except MissingEntry:
-            raise web.HTTPError(404, "Invalid student: {}".format(student_id))
-
-        submissions = []
-        for assignment in self.gradebook.assignments:
-            try:
-                submission = self.gradebook.find_submission(assignment.name, student.id).to_dict()
-            except MissingEntry:
-                submission = {
-                    "id": None,
-                    "name": assignment.name,
-                    "student": student.id,
-                    "duedate": None,
-                    "timestamp": None,
-                    "extension": None,
-                    "total_seconds_late": 0,
-                    "score": 0,
-                    "max_score": assignment.max_score,
-                    "code_score": 0,
-                    "max_code_score": assignment.max_code_score,
-                    "written_score": 0,
-                    "max_written_score": assignment.max_written_score,
-                    "needs_manual_grade": False
-                }
-            submissions.append(submission)
-
-        submissions.sort(key=lambda x: x.get("duedate") or "no due date")
-        student = student.to_dict()
-
+    @check_xsrf
+    def get(self, assignment_id):
         html = self.render(
-            "student_assignments.tpl",
-            assignments=submissions,
-            student=student,
+            "gradebook_notebooks.tpl",
+            assignment_id=assignment_id,
             base_url=self.base_url)
-
         self.write(html)
 
 
-class StudentAssignmentNotebooksHandler(BaseHandler):
+class GradebookNotebookSubmissionsHandler(BaseHandler):
     @web.authenticated
-    def get(self, student_id, assignment_id):
-        try:
-            assignment = self.gradebook.find_submission(assignment_id, student_id)
-        except MissingEntry:
-            raise web.HTTPError(404, "Invalid assignment: {} for {}".format(assignment_id, student_id))
-
-        notebooks = assignment.notebooks
-        submissions = self._filter_existing_notebooks(assignment_id, notebooks)
-        submissions = [x.to_dict() for x in assignment.notebooks]
-        for i, nb in enumerate(submissions):
-            nb['index'] = i
-
+    @check_xsrf
+    def get(self, assignment_id, notebook_id):
         html = self.render(
-            "student_submissions.tpl",
+            "gradebook_notebook_submissions.tpl",
             assignment_id=assignment_id,
-            student=assignment.student.to_dict(),
-            submissions=submissions,
+            notebook_id=notebook_id,
             base_url=self.base_url
         )
         self.write(html)
@@ -161,6 +65,7 @@ class StudentAssignmentNotebooksHandler(BaseHandler):
 
 class SubmissionHandler(BaseHandler):
     @web.authenticated
+    @check_xsrf
     def get(self, submission_id):
         try:
             submission = self.gradebook.find_submission_notebook_by_id(submission_id)
@@ -177,28 +82,24 @@ class SubmissionHandler(BaseHandler):
                 url += '?' + self.request.query
             return self.redirect(url, permanent=True)
 
-        notebook_dir_format = os.path.join(self.notebook_dir_format, "{notebook_id}.ipynb")
-        filename = os.path.join(self.notebook_dir, notebook_dir_format.format(
-            nbgrader_step=self.nbgrader_step,
-            assignment_id=assignment_id,
-            notebook_id=notebook_id,
-            student_id=student_id)
-        )
-        relative_path = os.path.relpath(filename, self.notebook_dir)
-        indexes = self._notebook_submission_indexes(assignment_id, notebook_id)
-        ix = indexes.get(submission.id, -2)
+        filename = os.path.join(os.path.abspath(self.coursedir.format_path(
+            self.coursedir.autograded_directory, student_id, assignment_id)), '{}.ipynb'.format(notebook_id))
+        relative_path = os.path.relpath(filename, self.coursedir.root)
+        indices = self.api.get_notebook_submission_indices(assignment_id, notebook_id)
+        ix = indices.get(submission.id, -2)
 
+        url_prefix = os.path.relpath(self.coursedir.root)
         resources = {
             'assignment_id': assignment_id,
             'notebook_id': notebook_id,
             'submission_id': submission.id,
             'index': ix,
-            'total': len(indexes),
+            'total': len(indices),
             'base_url': self.base_url,
             'mathjax_url': self.mathjax_url,
             'last_name': submission.student.last_name,
             'first_name': submission.student.first_name,
-            'notebook_path': self.notebook_url_prefix + '/' + relative_path
+            'notebook_path': url_prefix + '/' + relative_path
         }
 
         if not os.path.exists(filename):
@@ -216,7 +117,7 @@ class SubmissionHandler(BaseHandler):
 class SubmissionNavigationHandler(BaseHandler):
 
     def _assignment_notebook_list_url(self, assignment_id, notebook_id):
-        return '{}/formgrader/assignments/{}/{}'.format(self.base_url, assignment_id, notebook_id)
+        return '{}/formgrader/gradebook/{}/{}'.format(self.base_url, assignment_id, notebook_id)
 
     def _submission_url(self, submission_id):
         url = '{}/formgrader/submissions/{}'.format(self.base_url, submission_id)
@@ -227,12 +128,12 @@ class SubmissionNavigationHandler(BaseHandler):
 
     def _get_submission_ids(self, assignment_id, notebook_id):
         notebooks = self.gradebook.notebook_submissions(notebook_id, assignment_id)
-        submissions = self._filter_existing_notebooks(assignment_id, notebooks)
+        submissions = self.api._filter_existing_notebooks(assignment_id, notebooks)
         return sorted([x.id for x in submissions])
 
     def _get_incorrect_submission_ids(self, assignment_id, notebook_id, submission):
         notebooks = self.gradebook.notebook_submissions(notebook_id, assignment_id)
-        submissions = self._filter_existing_notebooks(assignment_id, notebooks)
+        submissions = self.api._filter_existing_notebooks(assignment_id, notebooks)
         incorrect_ids = set([x.id for x in submissions if x.failed_tests])
         incorrect_ids.add(submission.id)
         incorrect_ids = sorted(incorrect_ids)
@@ -275,6 +176,7 @@ class SubmissionNavigationHandler(BaseHandler):
             return self._submission_url(submission_ids[ix_incorrect - 1])
 
     @web.authenticated
+    @check_xsrf
     def get(self, submission_id, action):
         try:
             submission = self.gradebook.find_submission_notebook_by_id(submission_id)
@@ -290,7 +192,7 @@ class SubmissionNavigationHandler(BaseHandler):
 class SubmissionFilesHandler(web.StaticFileHandler, BaseHandler):
     def initialize(self, default_filename=None):
         super(SubmissionFilesHandler, self).initialize(
-            self.notebook_dir, default_filename=default_filename)
+            self.coursedir.root, default_filename=default_filename)
 
     def parse_url_path(self, url_path):
         submission_id, path = re.match(r"([^/]+)/(.*)", url_path.lstrip("/")).groups()
@@ -302,19 +204,52 @@ class SubmissionFilesHandler(web.StaticFileHandler, BaseHandler):
         except MissingEntry:
             raise web.HTTPError(404, "Invalid submission: {}".format(submission_id))
 
-        dirname = os.path.join(self.notebook_dir, self.notebook_dir_format.format(
-            nbgrader_step=self.nbgrader_step,
-            assignment_id=assignment_id,
-            student_id=student_id))
-
+        dirname = os.path.abspath(self.coursedir.format_path(
+            self.coursedir.autograded_directory, student_id, assignment_id))
         full_path = os.path.join(dirname, path)
         return super(SubmissionFilesHandler, self).parse_url_path(full_path)
 
     @web.authenticated
+    @check_xsrf
     def get(self, *args, **kwargs):
         return super(SubmissionFilesHandler, self).get(*args, **kwargs)
 
 
+class ManageStudentsHandler(BaseHandler):
+    @web.authenticated
+    @check_xsrf
+    def get(self):
+        html = self.render(
+            "manage_students.tpl",
+            base_url=self.base_url)
+        self.write(html)
+
+
+class ManageStudentsAssignmentsHandler(BaseHandler):
+    @web.authenticated
+    @check_xsrf
+    def get(self, student_id):
+        html = self.render(
+            "manage_students_assignments.tpl",
+            student_id=student_id,
+            base_url=self.base_url)
+
+        self.write(html)
+
+
+class ManageStudentNotebookSubmissionsHandler(BaseHandler):
+    @web.authenticated
+    @check_xsrf
+    def get(self, student_id, assignment_id):
+        html = self.render(
+            "manage_students_notebook_submissions.tpl",
+            assignment_id=assignment_id,
+            student_id=student_id,
+            base_url=self.base_url
+        )
+        self.write(html)
+
+
 class Template404(BaseHandler):
     """Render our 404 template"""
     def prepare(self):
@@ -330,19 +265,22 @@ fonts_path = os.path.join(components_path, 'bootstrap', 'fonts')
 _navigation_regex = r"(?P<action>next_incorrect|prev_incorrect|next|prev)"
 
 default_handlers = [
-    (r"/formgrader/?", AssignmentsHandler),
-    (r"/formgrader/assignments/?", AssignmentsHandler),
-    (r"/formgrader/assignments/([^/]+)/?", AssignmentNotebooksHandler),
-    (r"/formgrader/assignments/([^/]+)/([^/]+)/?", AssignmentNotebookSubmissionsHandler),
+    (r"/formgrader/?", ManageAssignmentsHandler),
+    (r"/formgrader/manage_assignments/?", ManageAssignmentsHandler),
+    (r"/formgrader/manage_submissions/([^/]+)/?", ManageSubmissionsHandler),
+
+    (r"/formgrader/gradebook/?", GradebookAssignmentsHandler),
+    (r"/formgrader/gradebook/([^/]+)/?", GradebookNotebooksHandler),
+    (r"/formgrader/gradebook/([^/]+)/([^/]+)/?", GradebookNotebookSubmissionsHandler),
 
-    (r"/formgrader/students/?", StudentsHandler),
-    (r"/formgrader/students/([^/]+)/?", StudentAssignmentsHandler),
-    (r"/formgrader/students/([^/]+)/([^/]+)/?", StudentAssignmentNotebooksHandler),
+    (r"/formgrader/manage_students/?", ManageStudentsHandler),
+    (r"/formgrader/manage_students/([^/]+)/?", ManageStudentsAssignmentsHandler),
+    (r"/formgrader/manage_students/([^/]+)/([^/]+)/?", ManageStudentNotebookSubmissionsHandler),
 
     (r"/formgrader/submissions/components/(.*)", web.StaticFileHandler, {'path': components_path}),
     (r"/formgrader/submissions/([^/]+)/?", SubmissionHandler),
     (r"/formgrader/submissions/(?P<submission_id>[^/]+)/%s/?" % _navigation_regex, SubmissionNavigationHandler),
     (r"/formgrader/submissions/(.*)", SubmissionFilesHandler),
 
-    (r"/formgrader/fonts/(.*)", web.StaticFileHandler, {'path': fonts_path})
+    (r"/formgrader/fonts/(.*)", web.StaticFileHandler, {'path': fonts_path}),
 ]
diff --git a/nbgrader/server_extensions/formgrader/static/css/nbgrader.css b/nbgrader/server_extensions/formgrader/static/css/nbgrader.css
new file mode 100644
index 00000000..61cc0d77
--- /dev/null
+++ b/nbgrader/server_extensions/formgrader/static/css/nbgrader.css
@@ -0,0 +1,46 @@
+table.inner-table {
+    width: 50%;
+    max-width: 50%;
+    margin-left: 1em;
+}
+
+table.inner-table td {
+    padding: 0.25em;
+}
+
+td.inner-table {
+    border-top: none !important;
+    padding-top: 0 !important;
+}
+
+td.inner-table span.glyphicon {
+    font-size: 0.75em;
+}
+
+td.assignment-id span.glyphicon {
+    font-size: 0.5em;
+}
+
+.name-shown {
+  display: none;
+}
+
+.glyphicon.name-shown, .glyphicon.name-hidden {
+  cursor: pointer;
+  cursor: hand;
+  color: #000;
+}
+
+table.form-table > tbody > tr > td {
+  vertical-align: middle;
+}
+
+div.modal div.panel pre {
+  border: none;
+  font-size: 8pt;
+  max-height: 30em;
+}
+
+div.modal p {
+  margin-bottom: 20px;
+}
diff --git a/nbgrader/server_extensions/formgrader/static/js/backbone_xsrf.js b/nbgrader/server_extensions/formgrader/static/js/backbone_xsrf.js
new file mode 100644
index 00000000..c055ab22
--- /dev/null
+++ b/nbgrader/server_extensions/formgrader/static/js/backbone_xsrf.js
@@ -0,0 +1,20 @@
+// Make sure put and post requests are sent with the xsrf cookie
+function getCookie(name) {
+    var r = document.cookie.match("\\b" + name + "=([^;]*)\\b");
+    return r ? r[1] : undefined;
+}
+
+// Backbone requests
+var oldSync = Backbone.sync;
+Backbone.sync = function(method, model, options) {
+    options.beforeSend = function(xhr) {
+        xhr.setRequestHeader('X-CSRFToken', getCookie("_xsrf"));
+    };
+    return oldSync(method, model, options);
+};
+
+$.ajaxPrefilter(function(options, originalOptions, jqXHR){
+    if (options['type'].toLowerCase() === "post") {
+        jqXHR.setRequestHeader('X-CSRFToken', getCookie("_xsrf"));
+    }
+});
diff --git a/nbgrader/server_extensions/formgrader/static/js/formgrade.js b/nbgrader/server_extensions/formgrader/static/js/formgrade.js
index 7b59a5ec..1211f6cc 100644
--- a/nbgrader/server_extensions/formgrader/static/js/formgrade.js
+++ b/nbgrader/server_extensions/formgrader/static/js/formgrade.js
@@ -298,7 +298,7 @@ FormGrader.prototype.configureScrolling = function () {
 FormGrader.prototype.flag = function () {
     $.ajax({
         'method': 'POST',
-        'url': base_url + '/api/submission/' + submission_id + '/flag',
+        'url': base_url + '/api/submitted_notebook/' + submission_id + '/flag',
         'headers': {'X-CSRFToken': getCookie("_xsrf")},
         'success': function (data, status, xhr) {
             var elem = $("#statusmessage");
@@ -323,21 +323,6 @@ FormGrader.prototype.flag = function () {
     });
 };
 
-// Make sure put and post requests are sent with the xsrf cookie
-function getCookie(name) {
-    var r = document.cookie.match("\\b" + name + "=([^;]*)\\b");
-    return r ? r[1] : undefined;
-}
-
-// Backbone requests
-var oldSync = Backbone.sync;
-Backbone.sync = function(method, model, options) {
-    options.beforeSend = function(xhr) {
-        xhr.setRequestHeader('X-CSRFToken', getCookie("_xsrf"));
-    };
-    return oldSync(method, model, options);
-};
-
 var formgrader = new FormGrader(base_url, submission_id);
 $(window).load(function () {
     formgrader.init()
diff --git a/nbgrader/server_extensions/formgrader/static/js/keyboardmanager.js b/nbgrader/server_extensions/formgrader/static/js/formgrade_keyboardmanager.js
similarity index 100%
rename from nbgrader/server_extensions/formgrader/static/js/keyboardmanager.js
rename to nbgrader/server_extensions/formgrader/static/js/formgrade_keyboardmanager.js
diff --git a/nbgrader/server_extensions/formgrader/static/js/models.js b/nbgrader/server_extensions/formgrader/static/js/formgrade_models.js
similarity index 100%
rename from nbgrader/server_extensions/formgrader/static/js/models.js
rename to nbgrader/server_extensions/formgrader/static/js/formgrade_models.js
diff --git a/nbgrader/server_extensions/formgrader/static/js/gradebook_assignments.js b/nbgrader/server_extensions/formgrader/static/js/gradebook_assignments.js
new file mode 100644
index 00000000..b599fac3
--- /dev/null
+++ b/nbgrader/server_extensions/formgrader/static/js/gradebook_assignments.js
@@ -0,0 +1,101 @@
+var Assignment = Backbone.Model.extend({});
+var Assignments = Backbone.Collection.extend({
+    model: Assignment,
+    url: base_url + "/formgrader/api/assignments"
+});
+
+var AssignmentUI = Backbone.View.extend({
+
+    events: {},
+
+    initialize: function () {
+        this.$name = this.$el.find(".name");
+        this.$duedate = this.$el.find(".duedate");
+        this.$num_submissions = this.$el.find(".num-submissions");
+        this.$score = this.$el.find(".score");
+
+        this.render();
+    },
+
+    clear: function () {
+        this.$name.empty();
+        this.$duedate.empty();
+        this.$num_submissions.empty();
+        this.$score.empty();
+    },
+
+    render: function () {
+        this.clear();
+
+        // assignment name
+        var name = this.model.get("name");
+        this.$name.attr("data-order", name);
+        this.$name.append($("<a/>")
+            .attr("href", base_url + "/formgrader/gradebook/" + name)
+            .text(name));
+
+        // duedate
+        var duedate = this.model.get("duedate");
+        var display_duedate = this.model.get("display_duedate");
+        if (duedate === null) {
+            duedate = "None";
+            display_duedate = "None";
+        }
+        this.$duedate.attr("data-order", duedate);
+        this.$duedate.text(display_duedate);
+
+        // number of submissions
+        var num_submissions = this.model.get("num_submissions");
+        this.$num_submissions.attr("data-order", num_submissions)
+        this.$num_submissions.text(num_submissions);
+
+        // score
+        var score = roundToPrecision(this.model.get("average_score"), 2);
+        var max_score = roundToPrecision(this.model.get("max_score"), 2);
+        if (max_score === 0) {
+            this.$score.attr("data-order", 0.0)
+        } else {
+            this.$score.attr("data-order", score / max_score);
+        }
+        this.$score.text(score + " / " + max_score);
+
+    },
+});
+
+var insertRow = function (table) {
+    var row = $("<tr/>");
+    row.append($("<td/>").addClass("name"));
+    row.append($("<td/>").addClass("text-center duedate"));
+    row.append($("<td/>").addClass("text-center num-submissions"));
+    row.append($("<td/>").addClass("text-center score"));
+    table.append(row)
+    return row;
+};
+
+var loadAssignments = function () {
+    var tbl = $("#main-table");
+
+    models = new Assignments();
+    views = [];
+    models.loaded = false;
+    models.fetch({
+        success: function () {
+            tbl.empty();
+            models.each(function (model) {
+                var view = new AssignmentUI({
+                    "model": model,
+                    "el": insertRow(tbl)
+                });
+                views.push(view);
+            });
+            insertDataTable(tbl.parent());
+            models.loaded = true;
+        }
+    });
+};
+
+var models = undefined;
+var views = [];
+$(window).load(function () {
+    loadAssignments();
+});
diff --git a/nbgrader/server_extensions/formgrader/static/js/gradebook_notebook_submissions.js b/nbgrader/server_extensions/formgrader/static/js/gradebook_notebook_submissions.js
new file mode 100644
index 00000000..ce4e3adb
--- /dev/null
+++ b/nbgrader/server_extensions/formgrader/static/js/gradebook_notebook_submissions.js
@@ -0,0 +1,176 @@
+var SubmittedNotebook = Backbone.Model.extend({});
+var SubmittedNotebooks = Backbone.Collection.extend({
+    model: SubmittedNotebook,
+    url: base_url + "/formgrader/api/submitted_notebooks/" + assignment_id + "/" + notebook_id
+});
+
+var SubmittedNotebookUI = Backbone.View.extend({
+
+    events: {},
+
+    initialize: function () {
+        this.$reveal = this.$el.find(".reveal");
+        this.$name = this.$el.find(".name");
+        this.$score = this.$el.find(".score");
+        this.$code_score = this.$el.find(".code-score");
+        this.$written_score = this.$el.find(".written-score");
+        this.$needs_manual_grade = this.$el.find(".needs-manual-grade");
+        this.$tests_failed = this.$el.find(".tests-failed");
+        this.$flagged = this.$el.find(".flagged");
+
+        this.render();
+    },
+
+    clear: function () {
+        this.$reveal.empty();
+        this.$name.empty();
+        this.$score.empty();
+        this.$code_score.empty();
+        this.$written_score.empty();
+        this.$needs_manual_grade.empty();
+        this.$tests_failed.empty();
+        this.$flagged.empty();
+    },
+
+    showName: function () {
+        this.$reveal.parent().find(".name-shown").show();
+        this.$reveal.parent().find(".name-hidden").hide();
+    },
+
+    hideName: function () {
+        this.$reveal.parent().find(".name-hidden").show();
+        this.$reveal.parent().find(".name-shown").hide();
+    },
+
+    render: function () {
+        this.clear();
+
+        // show/hide real name
+        this.$reveal.append($("<span/>")
+            .addClass("glyphicon glyphicon-eye-open name-hidden")
+            .attr("aria-hidden", "true")
+            .click(_.bind(this.showName, this)));
+        this.$reveal.append($("<span/>")
+            .addClass("glyphicon glyphicon-eye-close name-shown")
+            .attr("aria-hidden", "true")
+            .click(_.bind(this.hideName, this)));
+
+        // notebook name
+        this.$name.attr("data-order", this.model.get("index"));
+        this.$name.append($("<a/>")
+            .addClass("name-hidden")
+            .attr("href", base_url + "/formgrader/submissions/" + this.model.get("id"))
+            .text("Submission #" + (this.model.get("index") + 1)));
+        this.$name.append($("<a/>")
+            .addClass("name-shown")
+            .attr("href", base_url + "/formgrader/submissions/" + this.model.get("id"))
+            .text(this.model.get("last_name") + ", " + this.model.get("first_name")));
+
+        // score
+        var score = roundToPrecision(this.model.get("score"), 2);
+        var max_score = roundToPrecision(this.model.get("max_score"), 2);
+        if (max_score === 0) {
+            this.$score.attr("data-order", 0.0);
+        } else {
+            this.$score.attr("data-order", score / max_score);
+        }
+        this.$score.text(score + " / " + max_score);
+
+        // code score
+        score = roundToPrecision(this.model.get("code_score"), 2);
+        max_score = roundToPrecision(this.model.get("max_code_score"), 2);
+        if (max_score === 0) {
+            this.$code_score.attr("data-order", 0.0);
+        } else {
+            this.$code_score.attr("data-order", score / max_score);
+        }
+        this.$code_score.text(score + " / " + max_score);
+
+        // written score
+        score = roundToPrecision(this.model.get("written_score"), 2);
+        max_score = roundToPrecision(this.model.get("max_written_score"), 2);
+        if (max_score === 0) {
+            this.$written_score.attr("data-order", 0.0);
+        } else {
+            this.$written_score.attr("data-order", score / max_score);
+        }
+        this.$written_score.text(score + " / " + max_score);
+
+        // needs manual grade?
+        if (this.model.get("needs_manual_grade")) {
+            this.$needs_manual_grade.attr("data-search", "needs manual grade");
+            this.$needs_manual_grade.attr("data-order", 1);
+            this.$needs_manual_grade.append($("<span/>")
+                .addClass("glyphicon glyphicon-ok"));
+        } else {
+            this.$needs_manual_grade.attr("data-search", "");
+            this.$needs_manual_grade.attr("data-order", 0);
+        }
+
+        // tests failed?
+        if (this.model.get("failed_tests")) {
+            this.$tests_failed.attr("data-search", "tests failed");
+            this.$tests_failed.attr("data-order", 1);
+            this.$tests_failed.append($("<span/>")
+                .addClass("glyphicon glyphicon-ok"));
+        } else {
+            this.$tests_failed.attr("data-search", "");
+            this.$tests_failed.attr("data-order", 0);
+        }
+
+        // flagged?
+        if (this.model.get("flagged")) {
+            this.$flagged.attr("data-search", "flagged");
+            this.$flagged.attr("data-order", 1);
+            this.$flagged.append($("<span/>")
+                .addClass("glyphicon glyphicon-flag"));
+        } else {
+            this.$flagged.attr("data-search", "");
+            this.$flagged.attr("data-order", 0);
+        }
+    },
+});
+
+var insertRow = function (table) {
+    var row = $("<tr/>");
+    row.append($("<td/>").addClass("reveal"));
+    row.append($("<td/>").addClass("name"));
+    row.append($("<td/>").addClass("text-center score"));
+    row.append($("<td/>").addClass("text-center code-score"));
+    row.append($("<td/>").addClass("text-center written-score"));
+    row.append($("<td/>").addClass("text-center needs-manual-grade"));
+    row.append($("<td/>").addClass("text-center tests-failed"));
+    row.append($("<td/>").addClass("text-center flagged"));
+    table.append(row)
+    return row;
+};
+
+var loadSubmittedNotebooks = function () {
+    var tbl = $("#main-table");
+
+    models = new SubmittedNotebooks();
+    views = [];
+    models.loaded = false;
+    models.fetch({
+        success: function () {
+            tbl.empty();
+            models.each(function (model) {
+                var view = new SubmittedNotebookUI({
+                    "model": model,
+                    "el": insertRow(tbl)
+                });
+                views.push(view);
+            });
+            insertDataTable(tbl.parent());
+            $('span.glyphicon.name-hidden').tooltip({title: "Show student name"});
+            $('span.glyphicon.name-shown').tooltip({title: "Hide student name"});
+            models.loaded = true;
+        }
+    });
+};
+
+var models = undefined;
+var views = [];
+$(window).load(function () {
+    loadSubmittedNotebooks();
+});
diff --git a/nbgrader/server_extensions/formgrader/static/js/gradebook_notebooks.js b/nbgrader/server_extensions/formgrader/static/js/gradebook_notebooks.js
new file mode 100644
index 00000000..5fea47e6
--- /dev/null
+++ b/nbgrader/server_extensions/formgrader/static/js/gradebook_notebooks.js
@@ -0,0 +1,119 @@
+var Notebook = Backbone.Model.extend({});
+var Notebooks = Backbone.Collection.extend({
+    model: Notebook,
+    url: base_url + "/formgrader/api/notebooks/" + assignment_id
+});
+
+var NotebookUI = Backbone.View.extend({
+
+    events: {},
+
+    initialize: function () {
+        this.$name = this.$el.find(".name");
+        this.$avg_score = this.$el.find(".avg-score");
+        this.$avg_code_score = this.$el.find(".avg-code-score");
+        this.$avg_written_score = this.$el.find(".avg-written-score");
+        this.$needs_manual_grade = this.$el.find(".needs-manual-grade");
+
+        this.render();
+    },
+
+    clear: function () {
+        this.$name.empty();
+        this.$avg_score.empty();
+        this.$avg_code_score.empty();
+        this.$avg_written_score.empty();
+        this.$needs_manual_grade.empty();
+    },
+
+    render: function () {
+        this.clear();
+
+        // notebook name
+        var name = this.model.get("name");
+        this.$name.attr("data-order", name);
+        this.$name.append($("<a/>")
+            .attr("href", base_url + "/formgrader/gradebook/" + assignment_id + "/" + name)
+            .text(name));
+
+        // average score
+        var score = roundToPrecision(this.model.get("average_score"), 2);
+        var max_score = roundToPrecision(this.model.get("max_score"), 2);
+        if (max_score === 0) {
+            this.$avg_score.attr("data-order", 0.0);
+        } else {
+            this.$avg_score.attr("data-order", score / max_score);
+        }
+        this.$avg_score.text(score + " / " + max_score);
+
+        // average code score
+        score = roundToPrecision(this.model.get("average_code_score"), 2);
+        max_score = roundToPrecision(this.model.get("max_code_score"), 2);
+        if (max_score === 0) {
+            this.$avg_code_score.attr("data-order", 0.0);
+        } else {
+            this.$avg_code_score.attr("data-order", score / max_score);
+        }
+        this.$avg_code_score.text(score + " / " + max_score);
+
+        // average written score
+        score = roundToPrecision(this.model.get("average_written_score"), 2);
+        max_score = roundToPrecision(this.model.get("max_written_score"), 2);
+        if (max_score === 0) {
+            this.$avg_written_score.attr("data-order", 0.0);
+        } else {
+            this.$avg_written_score.attr("data-order", score / max_score);
+        }
+        this.$avg_written_score.text(score + " / " + max_score);
+
+        // needs manual grade
+        if (this.model.get("needs_manual_grade")) {
+            this.$needs_manual_grade.attr("data-search", "needs manual grade");
+            this.$needs_manual_grade.attr("data-order", 1);
+            this.$needs_manual_grade.append($("<span/>")
+                .addClass("glyphicon glyphicon-ok"));
+        } else {
+            this.$needs_manual_grade.attr("data-search", "");
+            this.$needs_manual_grade.attr("data-order", 0);
+        }
+    },
+});
+
+var insertRow = function (table) {
+    var row = $("<tr/>");
+    row.append($("<td/>").addClass("name"));
+    row.append($("<td/>").addClass("text-center avg-score"));
+    row.append($("<td/>").addClass("text-center avg-code-score"));
+    row.append($("<td/>").addClass("text-center avg-written-score"));
+    row.append($("<td/>").addClass("text-center needs-manual-grade"));
+    table.append(row)
+    return row;
+};
+
+var loadNotebooks = function () {
+    var tbl = $("#main-table");
+
+    models = new Notebooks();
+    views = [];
+    models.loaded = false;
+    models.fetch({
+        success: function () {
+            tbl.empty();
+            models.each(function (model) {
+                var view = new NotebookUI({
+                    "model": model,
+                    "el": insertRow(tbl)
+                });
+                views.push(view);
+            });
+            insertDataTable(tbl.parent());
+            models.loaded = true;
+        }
+    });
+};
+
+var models = undefined;
+var views = [];
+$(window).load(function () {
+    loadNotebooks();
+});
diff --git a/nbgrader/server_extensions/formgrader/static/js/manage_assignments.js b/nbgrader/server_extensions/formgrader/static/js/manage_assignments.js
new file mode 100644
index 00000000..820386ab
--- /dev/null
+++ b/nbgrader/server_extensions/formgrader/static/js/manage_assignments.js
@@ -0,0 +1,477 @@
+var Assignment = Backbone.Model.extend({
+    idAttribute: 'name',
+    urlRoot: base_url + "/formgrader/api/assignment"
+});
+
+var Assignments = Backbone.Collection.extend({
+    model: Assignment,
+    url: base_url + "/formgrader/api/assignments"
+});
+
+var AssignmentUI = Backbone.View.extend({
+
+    events: {},
+
+    initialize: function () {
+        this.$modal = undefined;
+        this.$modal_duedate = undefined;
+        this.$modal_timezone = undefined;
+        this.$modal_save = undefined;
+
+        this.$name = this.$el.find(".name");
+        this.$duedate = this.$el.find(".duedate");
+        this.$status = this.$el.find(".status");
+        this.$edit = this.$el.find(".edit");
+        this.$assign = this.$el.find(".assign");
+        this.$preview = this.$el.find(".preview");
+        this.$release = this.$el.find(".release");
+        this.$collect = this.$el.find(".collect");
+        this.$num_submissions = this.$el.find(".num-submissions");
+
+        this.listenTo(this.model, "change", this.render);
+        this.listenTo(this.model, "request", this.animateSaving);
+        this.listenTo(this.model, "sync", this.closeModal);
+
+        this.render();
+    },
+
+    openModal: function () {
+        var body = $("<table/>").addClass("table table-striped form-table");
+        var name = $("<tr/>");
+        body.append(name);
+        name.append($("<td/>").addClass("align-middle").text("Name"));
+        name.append($("<td/>").append($("<input/>")
+            .addClass("modal-name")
+            .attr("type", "text")
+            .attr("disabled", "disabled")));
+
+        var duedate = $("<tr/>");
+        body.append(duedate);
+        duedate.append($("<td/>").addClass("align-middle").text("Due date (optional)"));
+        duedate.append($("<td/>").append($("<input/>").addClass("modal-duedate").attr("type", "datetime-local")));
+
+        var timezone = $("<tr/>");
+        body.append(timezone);
+        timezone.append($("<td/>").addClass("align-middle").text("Timezone (optional)"));
+        timezone.append($("<td/>").append($("<input/>").addClass("modal-timezone").attr("type", "text")));
+
+        var footer = $("<div/>");
+        footer.append($("<button/>")
+            .addClass("btn btn-primary save")
+            .attr("type", "button")
+            .text("Save"));
+        footer.append($("<button/>")
+            .addClass("btn btn-danger")
+            .attr("type", "button")
+            .attr("data-dismiss", "modal")
+            .text("Cancel"));
+
+        this.$modal = createModal("edit-assignment-modal", "Editing " + this.model.get("name"), body, footer);
+        this.$modal.find("input.modal-name").val(this.model.get("name"));
+        this.$modal_duedate = this.$modal.find("input.modal-duedate");
+        this.$modal_duedate.val(this.model.get("duedate_notimezone"));
+        this.$modal_timezone = this.$modal.find("input.modal-timezone");
+        this.$modal_timezone.val(this.model.get("duedate_timezone"));
+        this.$modal_save = this.$modal.find("button.save");
+        this.$modal_save.click(_.bind(this.save, this));
+    },
+
+    clear: function () {
+        this.$name.empty();
+        this.$duedate.empty();
+        this.$status.empty();
+        this.$edit.empty();
+        this.$assign.empty();
+        this.$preview.empty();
+        this.$release.empty();
+        this.$collect.empty();
+        this.$num_submissions.empty();
+    },
+
+    render: function () {
+        this.clear();
+
+        // assignment name
+        var name = this.model.get("name")
+        this.$name.attr("data-order", name);
+        this.$name.append($("<a/>")
+            .attr("target", "_blank")
+            .attr("href", base_url + "/tree/" + this.model.get("source_path"))
+            .text(name));
+
+        // duedate
+        var duedate = this.model.get("duedate");
+        var display_duedate = this.model.get("display_duedate");
+        if (duedate === null) {
+            duedate = "None";
+            display_duedate = "None";
+        }
+        this.$duedate.attr("data-order", duedate);
+        this.$duedate.text(display_duedate);
+
+        // status
+        var status = this.model.get("status");
+        if (status === "draft") {
+            this.$status.attr("data-order", "draft");
+            this.$status.append($("<span/>").addClass("label label-info").text("draft"));
+        } else if (status === "released") {
+            this.$status.attr("data-order", "released");
+            this.$status.append($("<span/>").addClass("label label-success").text("released"));
+        }
+
+        // edit metadata
+        this.$edit.append($("<a/>")
+            .attr("href", "#")
+            .click(_.bind(this.openModal, this))
+            .append($("<span/>")
+                .addClass("glyphicon glyphicon-pencil")
+                .attr("aria-hidden", "true")));
+
+        // generate student version
+        this.$assign.append($("<a/>")
+            .attr("href", "#")
+            .click(_.bind(this.assign, this))
+            .append($("<span/>")
+                .addClass("glyphicon glyphicon-education")
+                .attr("aria-hidden", "true")));
+
+        // preview student version
+        var release_path = this.model.get("release_path");
+        if (release_path) {
+            this.$preview.append($("<a/>")
+                .attr("target", "_blank")
+                .attr("href", base_url + "/tree/" + release_path)
+                .append($("<span/>")
+                    .addClass("glyphicon glyphicon-search")
+                    .attr("aria-hidden", "true")));
+        }
+
+        // release
+        var releaseable = this.model.get("releaseable");
+        if (release_path && releaseable) {
+            if (status === "draft") {
+                this.$release.append($("<a/>")
+                    .attr("href", "#")
+                    .click(_.bind(this.release, this))
+                    .append($("<span/>")
+                        .addClass("glyphicon glyphicon-cloud-upload")
+                        .attr("aria-hidden", "true")));
+            } else {
+                this.$release.append($("<a/>")
+                    .attr("href", "#")
+                    .click(_.bind(this.unrelease, this))
+                    .append($("<span/>")
+                        .addClass("glyphicon glyphicon-remove")
+                        .attr("aria-hidden", "true")));
+            }
+        }
+
+        // collect
+        if (release_path && releaseable) {
+            if (status === "released") {
+                this.$collect.append($("<a/>")
+                    .attr("href", "#")
+                    .click(_.bind(this.collect, this))
+                    .append($("<span/>")
+                        .addClass("glyphicon glyphicon-cloud-download")
+                        .attr("aria-hidden", "true")));
+            }
+        }
+
+        // number of submissions
+        var num_submissions = this.model.get("num_submissions");
+        this.$num_submissions.attr("data-order", num_submissions);
+        if (num_submissions === 0) {
+            this.$num_submissions.text(0);
+        } else {
+            this.$num_submissions.append($("<a/>")
+                .attr("href", base_url + "/formgrader/manage_submissions/" + this.model.get("name"))
+                .text(num_submissions));
+        }
+    },
+
+    assign: function () {
+        this.clear();
+        this.$name.text("Please wait...");
+        $.post(base_url + "/formgrader/api/assignment/" + this.model.get("name") + "/assign")
+            .done(_.bind(this.assign_success, this))
+            .fail(_.bind(this.assign_failure, this));
+    },
+
+    assign_success: function (response) {
+        this.model.fetch();
+        response = JSON.parse(response);
+        if (response["success"]) {
+            createLogModal(
+                "success-modal",
+                "Success",
+                "Successfully created the student version of '" + this.model.get("name") + "':",
+                response["log"]);
+
+        } else {
+            createLogModal(
+                "error-modal",
+                "Error",
+                "There was an error creating the student version of '" + this.model.get("name") + "':",
+                response["log"],
+                response["error"]);
+        }
+    },
+
+    assign_failure: function (response) {
+        this.model.fetch();
+        createModal(
+            "error-modal",
+            "Error",
+            "There was an error creating the student version of '" + this.model.get("name") + "'.");
+    },
+
+    unrelease: function () {
+        this.clear();
+        this.$name.text("Please wait...");
+        $.post(base_url + "/formgrader/api/assignment/" + this.model.get("name") + "/unrelease")
+            .done(_.bind(this.unrelease_success, this))
+            .fail(_.bind(this.unrelase_failure, this));
+    },
+
+    unrelease_success: function (response) {
+        this.model.fetch();
+        response = JSON.parse(response);
+        if (response["success"]) {
+            createLogModal(
+                "success-modal",
+                "Success",
+                "Successfully unreleased '" + this.model.get("name") + "'.",
+                response["log"]);
+
+        } else {
+            createLogModal(
+                "error-modal",
+                "Error",
+                "There was an error unreleasing '" + this.model.get("name") + "':",
+                response["log"],
+                response["error"]);
+        }
+    },
+
+    unrelease_failure: function () {
+        this.model.fetch();
+        createModal(
+            "error-modal",
+            "Error",
+            "There was an error unreleasing '" + this.model.get("name") + "'.");
+    },
+
+    release: function () {
+        this.clear();
+        this.$name.text("Please wait...");
+        $.post(base_url + "/formgrader/api/assignment/" + this.model.get("name") + "/release")
+            .done(_.bind(this.release_success, this))
+            .fail(_.bind(this.release_failure, this));
+    },
+
+    release_success: function (response) {
+        this.model.fetch();
+        response = JSON.parse(response);
+        if (response["success"]) {
+            createLogModal(
+                "success-modal",
+                "Success",
+                "Successfully released '" + this.model.get("name") + "'.",
+                response["log"]);
+
+        } else {
+            createLogModal(
+                "error-modal",
+                "Error",
+                "There was an error releasing '" + this.model.get("name") + "':",
+                response["log"],
+                response["error"]);
+        }
+    },
+
+    release_failure: function () {
+        this.model.fetch();
+        createModal(
+            "error-modal",
+            "Error",
+            "There was an error releasing '" + this.model.get("name") + "'.");
+    },
+
+    collect: function () {
+        this.clear();
+        this.$name.text("Please wait...");
+        $.post(base_url + "/formgrader/api/assignment/" + this.model.get("name") + "/collect")
+            .done(_.bind(this.collect_success, this))
+            .fail(_.bind(this.collect_failure, this));
+    },
+
+    collect_success: function (response) {
+        this.model.fetch();
+        response = JSON.parse(response);
+        if (response["success"]) {
+            createLogModal(
+                "success-modal",
+                "Success",
+                "Successfully collected submissions of '" + this.model.get("name") + "'.",
+                response["log"]);
+
+        } else {
+            createLogModal(
+                "error-modal",
+                "Error",
+                "There was an error collecting '" + this.model.get("name") + "':",
+                response["log"],
+                response["error"]);
+        }
+    },
+
+    collect_failure: function () {
+        this.model.fetch();
+        createModal(
+            "error-modal",
+            "Error",
+            "There was an error collecting submissions of '" + this.model.get("name") + "'.");
+    },
+
+    save: function () {
+        var duedate = this.$modal_duedate.val();
+        var timezone = this.$modal_timezone.val();
+        if (duedate === "") {
+            duedate = null;
+            timezone = null;
+        }
+        this.model.save({"duedate_notimezone": duedate, "duedate_timezone": timezone});
+    },
+
+    animateSaving: function () {
+        if (this.$modal_save) {
+            this.$modal_save.text("Saving...");
+        }
+    },
+
+    closeModal: function () {
+        if (this.$modal) {
+            this.$modal.modal('hide')
+            this.$modal = undefined;
+            this.$modal_duedate = undefined;
+            this.$modal_timezone = undefined;
+            this.$modal_save = undefined;
+        }
+
+        this.render();
+    },
+});
+
+var insertRow = function (table) {
+    var row = $("<tr/>");
+    row.append($("<td/>").addClass("name"));
+    row.append($("<td/>").addClass("text-center duedate"));
+    row.append($("<td/>").addClass("text-center status"));
+    row.append($("<td/>").addClass("text-center edit"));
+    row.append($("<td/>").addClass("text-center assign"));
+    row.append($("<td/>").addClass("text-center preview"));
+    row.append($("<td/>").addClass("text-center release"));
+    row.append($("<td/>").addClass("text-center collect"));
+    row.append($("<td/>").addClass("text-center num-submissions"));
+    table.append(row)
+    return row;
+};
+
+var createAssignmentModal = function () {
+    var modal;
+    var createAssignment = function () {
+        var name = modal.find(".name").val();
+        var duedate = modal.find(".duedate").val();
+        var timezone = modal.find(".timezone").val();
+        if (duedate === "") {
+            duedate = null;
+            timezone = null;
+        }
+        if (timezone == "") {
+            timezone = null;
+        }
+        if (name === "") {
+            modal.modal('hide');
+            return;
+        }
+
+        var model = new Assignment({
+            "name": name,
+            "duedate_notimezone": duedate,
+            "duedate_timezone": timezone,
+        }, {
+            "collection": models
+        });
+
+        var tbl = $("#main-table");
+        var row = insertRow(tbl);
+        var view = new AssignmentUI({
+            "model": model,
+            "el": row
+        });
+        views.push(view);
+        model.save();
+        tbl.parent().DataTable().row.add(row).draw();
+
+        modal.modal('hide');
+    };
+
+    var body = $("<table/>").addClass("table table-striped form-table");
+    var name = $("<tr/>");
+    body.append(name);
+    name.append($("<td/>").addClass("align-middle").text("Name"));
+    name.append($("<td/>").append($("<input/>").addClass("name").attr("type", "text").attr("size", "31")));
+
+    var duedate = $("<tr/>");
+    body.append(duedate);
+    duedate.append($("<td/>").addClass("align-middle").text("Due date (optional)"));
+    duedate.append($("<td/>").append($("<input/>").addClass("duedate").attr("type", "datetime-local")));
+
+    var timezone = $("<tr/>");
+    body.append(timezone);
+    timezone.append($("<td/>").addClass("align-middle").text("Timezone (optional)"));
+    timezone.append($("<td/>").append($("<input/>").addClass("timezone").attr("type", "text")));
+
+    var footer = $("<div/>");
+    footer.append($("<button/>")
+        .addClass("btn btn-primary save")
+        .attr("type", "button")
+        .click(createAssignment)
+        .text("Save"));
+    footer.append($("<button/>")
+        .addClass("btn btn-danger")
+        .attr("type", "button")
+        .attr("data-dismiss", "modal")
+        .text("Cancel"));
+
+    modal = createModal("add-assignment-modal", "Add New Assignment", body, footer);
+};
+
+var loadAssignments = function () {
+    var tbl = $("#main-table");
+
+    models = new Assignments();
+    views = [];
+    models.loaded = false;
+    models.fetch({
+        success: function () {
+            tbl.empty();
+            models.each(function (model) {
+                var view = new AssignmentUI({
+                    "model": model,
+                    "el": insertRow(tbl)
+                });
+                views.push(view);
+            });
+            insertDataTable(tbl.parent());
+            models.loaded = true;
+        }
+    });
+};
+
+var models = undefined;
+var views = [];
+$(window).load(function () {
+    loadAssignments();
+});
diff --git a/nbgrader/server_extensions/formgrader/static/js/manage_students.js b/nbgrader/server_extensions/formgrader/static/js/manage_students.js
new file mode 100644
index 00000000..bb929deb
--- /dev/null
+++ b/nbgrader/server_extensions/formgrader/static/js/manage_students.js
@@ -0,0 +1,284 @@
+var Student = Backbone.Model.extend({
+    urlRoot: base_url + "/formgrader/api/student"
+});
+
+var Students = Backbone.Collection.extend({
+    model: Student,
+    url: base_url + "/formgrader/api/students"
+});
+
+var StudentUI = Backbone.View.extend({
+
+    events: {},
+
+    initialize: function () {
+        this.$modal = undefined;
+        this.$modal_first_name = undefined;
+        this.$modal_last_name = undefined;
+        this.$modal_email = undefined;
+        this.$modal_save = undefined;
+
+        this.$name = this.$el.find(".name");
+        this.$id = this.$el.find(".id");
+        this.$email = this.$el.find(".email");
+        this.$score = this.$el.find(".score");
+        this.$edit = this.$el.find(".edit");
+
+        this.listenTo(this.model, "change", this.render);
+        this.listenTo(this.model, "request", this.animateSaving);
+        this.listenTo(this.model, "sync", this.closeModal);
+
+        this.render();
+    },
+
+    openModal: function () {
+        var body = $("<table/>").addClass("table table-striped form-table");
+        var id = $("<tr/>");
+        body.append(id);
+        id.append($("<td/>").addClass("align-middle").text("Student ID"));
+        id.append($("<td/>").append($("<input/>")
+            .addClass("modal-id")
+            .attr("type", "text")
+            .attr("disabled", "disabled")));
+
+        var first_name = $("<tr/>");
+        body.append(first_name);
+        first_name.append($("<td/>").addClass("align-middle").text("First name (optional)"));
+        first_name.append($("<td/>").append($("<input/>").addClass("modal-first-name").attr("type", "text")));
+
+        var last_name = $("<tr/>");
+        body.append(last_name);
+        last_name.append($("<td/>").addClass("align-middle").text("Last name (optional)"));
+        last_name.append($("<td/>").append($("<input/>").addClass("modal-last-name").attr("type", "text")));
+
+        var email = $("<tr/>");
+        body.append(email);
+        email.append($("<td/>").addClass("align-middle").text("Email (optional)"));
+        email.append($("<td/>").append($("<input/>").addClass("modal-email").attr("type", "text")));
+
+        var footer = $("<div/>");
+        footer.append($("<button/>")
+            .addClass("btn btn-primary save")
+            .attr("type", "button")
+            .text("Save"));
+        footer.append($("<button/>")
+            .addClass("btn btn-danger")
+            .attr("type", "button")
+            .attr("data-dismiss", "modal")
+            .text("Cancel"));
+
+        this.$modal = createModal("edit-student-modal", "Editing " + this.model.get("id"), body, footer);
+        this.$modal.find("input.modal-id").val(this.model.get("id"));
+        this.$modal_first_name = this.$modal.find("input.modal-first-name");
+        this.$modal_first_name.val(this.model.get("first_name"));
+        this.$modal_last_name = this.$modal.find("input.modal-last-name");
+        this.$modal_last_name.val(this.model.get("last_name"));
+        this.$modal_email = this.$modal.find("input.modal-email");
+        this.$modal_email.val(this.model.get("email"));
+        this.$modal_save = this.$modal.find("button.save");
+        this.$modal_save.click(_.bind(this.save, this));
+    },
+
+    clear: function () {
+        this.$name.empty();
+        this.$id.empty();
+        this.$email.empty();
+        this.$score.empty();
+        this.$edit.empty();
+    },
+
+    render: function () {
+        this.clear();
+
+        // student name
+        var last_name = this.model.get("last_name");
+        if (last_name === null) last_name = "None";
+        var first_name = this.model.get("first_name");
+        if (first_name === null) first_name = "None";
+        var name = last_name + ", " + first_name;
+        this.$name.attr("data-order", name);
+        this.$name.append($("<a/>")
+            .attr("href", base_url + "/formgrader/manage_students/" + this.model.get("id"))
+            .text(name));
+
+        // id
+        var id = this.model.get("id");
+        this.$id.attr("data-order", id);
+        this.$id.text(id);
+
+        // email
+        var email = this.model.get("email");
+        if (email === null) email = "None";
+        this.$email.attr("data-order", email);
+        this.$email.text(email);
+
+        // score
+        var score = roundToPrecision(this.model.get("score"), 2);
+        var max_score = roundToPrecision(this.model.get("max_score"), 2);
+        if (max_score === 0) {
+            this.$score.attr("data-order", 0.0);
+        } else {
+            this.$score.attr("data-order", score / max_score);
+        }
+        this.$score.text(score + " / " + max_score);
+
+        // edit metadata
+        this.$edit.append($("<a/>")
+            .attr("href", "#")
+            .click(_.bind(this.openModal, this))
+            .append($("<span/>")
+                .addClass("glyphicon glyphicon-pencil")
+                .attr("aria-hidden", "true")));
+    },
+
+    save: function () {
+        var first_name = this.$modal_first_name.val();
+        if (first_name === "") first_name = null;
+
+        var last_name = this.$modal_last_name.val();
+        if (last_name === "") last_name = null;
+
+        var email = this.$modal_email.val();
+        if (email === "") email = null;
+
+        this.model.save({
+            "first_name": first_name,
+            "last_name": last_name,
+            "email": email
+        });
+    },
+
+    animateSaving: function () {
+        if (this.$modal_save) {
+            this.$modal_save.text("Saving...");
+        }
+    },
+
+    closeModal: function () {
+        if (this.$modal) {
+            this.$modal.modal('hide')
+            this.$modal = undefined;
+            this.$modal_first_name = undefined;
+            this.$modal_last_name = undefined;
+            this.$modal_email = undefined;
+            this.$modal_save = undefined;
+        }
+
+        this.render();
+    },
+});
+
+var insertRow = function (table) {
+    var row = $("<tr/>");
+    row.append($("<td/>").addClass("name"));
+    row.append($("<td/>").addClass("text-center id"));
+    row.append($("<td/>").addClass("text-center email"));
+    row.append($("<td/>").addClass("text-center score"));
+    row.append($("<td/>").addClass("text-center edit"));
+    table.append(row)
+    return row;
+};
+
+var createStudentModal = function () {
+    var modal;
+    var createStudent = function () {
+        var id = modal.find(".id").val();
+        if (id === "") {
+            modal.modal('hide');
+            return;
+        }
+
+        var first_name = modal.find(".first-name").val();
+        if (first_name === "") first_name = null;
+
+        var last_name = modal.find(".last-name").val();
+        if (last_name === "") last_name = null;
+
+        var email = modal.find(".email").val();
+        if (email === "") email = null;
+
+        var model = new Student({
+            "id": id,
+            "first_name": first_name,
+            "last_name": last_name,
+            "email": email
+        }, {
+            "collection": models
+        });
+
+        var tbl = $("#main-table");
+        var row = insertRow(tbl);
+        var view = new StudentUI({
+            "model": model,
+            "el": row
+        });
+        views.push(view);
+        model.save();
+        tbl.parent().DataTable().row.add(row).draw();
+
+        modal.modal('hide');
+    };
+
+    var body = $("<table/>").addClass("table table-striped form-table");
+    var id = $("<tr/>");
+    body.append(id);
+    id.append($("<td/>").addClass("align-middle").text("Student ID"));
+    id.append($("<td/>").append($("<input/>").addClass("id").attr("type", "text")));
+
+    var first_name = $("<tr/>");
+    body.append(first_name);
+    first_name.append($("<td/>").addClass("align-middle").text("First name (optional)"));
+    first_name.append($("<td/>").append($("<input/>").addClass("first-name").attr("type", "text")));
+
+    var last_name = $("<tr/>");
+    body.append(last_name);
+    last_name.append($("<td/>").addClass("align-middle").text("Last name (optional)"));
+    last_name.append($("<td/>").append($("<input/>").addClass("last-name").attr("type", "text")));
+
+    var email = $("<tr/>");
+    body.append(email);
+    email.append($("<td/>").addClass("align-middle").text("Email (optional)"));
+    email.append($("<td/>").append($("<input/>").addClass("email").attr("type", "text")));
+
+    var footer = $("<div/>");
+    footer.append($("<button/>")
+        .addClass("btn btn-primary save")
+        .attr("type", "button")
+        .click(createStudent)
+        .text("Save"));
+    footer.append($("<button/>")
+        .addClass("btn btn-danger")
+        .attr("type", "button")
+        .attr("data-dismiss", "modal")
+        .text("Cancel"));
+
+    modal = createModal("add-student-modal", "Add New Student", body, footer);
+};
+
+var loadStudents = function () {
+    var tbl = $("#main-table");
+
+    models = new Students();
+    views = [];
+    models.loaded = false;
+    models.fetch({
+        success: function () {
+            tbl.empty();
+            models.each(function (model) {
+                var view = new StudentUI({
+                    "model": model,
+                    "el": insertRow(tbl)
+                });
+                views.push(view);
+            });
+            insertDataTable(tbl.parent());
+            models.loaded = true;
+        }
+    });
+};
+
+var models = undefined;
+var views = [];
+$(window).load(function () {
+    loadStudents();
+});
diff --git a/nbgrader/server_extensions/formgrader/static/js/manage_students_assignments.js b/nbgrader/server_extensions/formgrader/static/js/manage_students_assignments.js
new file mode 100644
index 00000000..00d0c798
--- /dev/null
+++ b/nbgrader/server_extensions/formgrader/static/js/manage_students_assignments.js
@@ -0,0 +1,125 @@
+var StudentSubmission = Backbone.Model.extend({});
+var StudentSubmissions = Backbone.Collection.extend({
+    model: StudentSubmission,
+    url: base_url + "/formgrader/api/student_submissions/" + student_id
+});
+
+var StudentSubmissionUI = Backbone.View.extend({
+
+    events: {},
+
+    initialize: function () {
+        this.$name = this.$el.find(".name");
+        this.$score = this.$el.find(".score");
+        this.$code_score = this.$el.find(".code-score");
+        this.$written_score = this.$el.find(".written-score");
+        this.$needs_manual_grade = this.$el.find(".needs-manual-grade");
+
+        this.render();
+    },
+
+    clear: function () {
+        this.$name.empty();
+        this.$score.empty();
+        this.$code_score.empty();
+        this.$written_score.empty();
+        this.$needs_manual_grade.empty();
+    },
+
+    render: function () {
+        this.clear();
+
+        // name
+        var name = this.model.get("name");
+        this.$name.attr("data-order", name);
+        if (!this.model.get("submitted")) {
+            this.$name.text(name + " (no submission)");
+        } else if (!this.model.get("autograded")) {
+            this.$name.text(name + " (not autograded)");
+        } else {
+            this.$name.append($("<a/>")
+                .attr("href", base_url + "/formgrader/manage_students/" + student_id + "/" + name)
+                .text(name));
+        }
+
+        // score
+        var score = roundToPrecision(this.model.get("score"), 2);
+        var max_score = roundToPrecision(this.model.get("max_score"), 2);
+        if (max_score === 0) {
+            this.$score.attr("data-order", 0.0);
+        } else {
+            this.$score.attr("data-order", score / max_score);
+        }
+        this.$score.text(score + " / " + max_score);
+
+        // code score
+        var code_score = roundToPrecision(this.model.get("code_score"), 2);
+        var max_code_score = roundToPrecision(this.model.get("max_code_score"), 2);
+        if (max_code_score === 0) {
+            this.$code_score.attr("data-order", 0.0);
+        } else {
+            this.$code_score.attr("data-order", code_score / max_code_score);
+        }
+        this.$code_score.text(code_score + " / " + max_code_score);
+
+        // written score
+        var written_score = roundToPrecision(this.model.get("written_score"), 2);
+        var max_written_score = roundToPrecision(this.model.get("max_written_score"), 2);
+        if (max_written_score === 0) {
+            this.$written_score.attr("data-order", 0.0);
+        } else {
+            this.$written_score.attr("data-order", written_score / max_written_score);
+        }
+        this.$written_score.text(written_score + " / " + max_written_score);
+
+        // needs manual grade?
+        if (this.model.get("needs_manual_grade")) {
+            this.$needs_manual_grade.attr("data-search", "needs manual grade");
+            this.$needs_manual_grade.attr("data-order", 1);
+            this.$needs_manual_grade.append($("<span/>")
+                .addClass("glyphicon glyphicon-ok"));
+        } else {
+            this.$needs_manual_grade.attr("data-search", "");
+            this.$needs_manual_grade.attr("data-order", 0);
+        }
+    },
+});
+
+var insertRow = function (table) {
+    var row = $("<tr/>");
+    row.append($("<td/>").addClass("name"));
+    row.append($("<td/>").addClass("text-center score"));
+    row.append($("<td/>").addClass("text-center code-score"));
+    row.append($("<td/>").addClass("text-center written-score"));
+    row.append($("<td/>").addClass("text-center needs-manual-grade"));
+    table.append(row)
+    return row;
+};
+
+var loadStudentSubmissions = function () {
+    var tbl = $("#main-table");
+
+    models = new StudentSubmissions();
+    views = [];
+    models.loaded = false;
+    models.fetch({
+        success: function () {
+            tbl.empty();
+            models.each(function (model) {
+                var view = new StudentSubmissionUI({
+                    "model": model,
+                    "el": insertRow(tbl)
+                });
+                views.push(view);
+            });
+            insertDataTable(tbl.parent());
+            models.loaded = true;
+        }
+    });
+};
+
+var models = undefined;
+var views = [];
+$(window).load(function () {
+    loadStudentSubmissions();
+});
diff --git a/nbgrader/server_extensions/formgrader/static/js/manage_students_notebook_submissions.js b/nbgrader/server_extensions/formgrader/static/js/manage_students_notebook_submissions.js
new file mode 100644
index 00000000..f1f087e1
--- /dev/null
+++ b/nbgrader/server_extensions/formgrader/static/js/manage_students_notebook_submissions.js
@@ -0,0 +1,151 @@
+var StudentSubmittedNotebook = Backbone.Model.extend({});
+var StudentSubmittedNotebooks = Backbone.Collection.extend({
+    model: StudentSubmittedNotebook,
+    url: base_url + "/formgrader/api/student_notebook_submissions/" + student_id + "/" + assignment_id
+});
+
+var StudentSubmittedNotebookUI = Backbone.View.extend({
+
+    events: {},
+
+    initialize: function () {
+        this.$name = this.$el.find(".name");
+        this.$score = this.$el.find(".score");
+        this.$code_score = this.$el.find(".code-score");
+        this.$written_score = this.$el.find(".written-score");
+        this.$needs_manual_grade = this.$el.find(".needs-manual-grade");
+        this.$tests_failed = this.$el.find(".tests-failed");
+        this.$flagged = this.$el.find(".flagged");
+
+        this.render();
+    },
+
+    clear: function () {
+        this.$name.empty();
+        this.$score.empty();
+        this.$code_score.empty();
+        this.$written_score.empty();
+        this.$needs_manual_grade.empty();
+        this.$tests_failed.empty();
+        this.$flagged.empty();
+    },
+
+    render: function () {
+        this.clear();
+
+        // notebook name
+        var name = this.model.get("name");
+        this.$name.attr("data-order", name);
+        if (this.model.get("id") === null) {
+            this.$name.append(name + " (file missing)");
+        } else {
+            this.$name.append($("<a/>")
+                .attr("href", base_url + "/formgrader/submissions/" + this.model.get("id"))
+                .text(name));
+        }
+
+        // score
+        var score = roundToPrecision(this.model.get("score"), 2);
+        var max_score = roundToPrecision(this.model.get("max_score"), 2);
+        if (max_score === 0) {
+            this.$score.attr("data-order", 0.0);
+        } else {
+            this.$score.attr("data-order", score / max_score);
+        }
+        this.$score.text(score + " / " + max_score);
+
+        // code score
+        score = roundToPrecision(this.model.get("code_score"), 2);
+        max_score = roundToPrecision(this.model.get("max_code_score"), 2);
+        if (max_score === 0) {
+            this.$code_score.attr("data-order", 0.0);
+        } else {
+            this.$code_score.attr("data-order", score / max_score);
+        }
+        this.$code_score.text(score + " / " + max_score);
+
+        // written score
+        score = roundToPrecision(this.model.get("written_score"), 2);
+        max_score = roundToPrecision(this.model.get("max_written_score"), 2);
+        if (max_score === 0) {
+            this.$written_score.attr("data-order", 0.0);
+        } else {
+            this.$written_score.attr("data-order", score / max_score);
+        }
+        this.$written_score.text(score + " / " + max_score);
+
+        // needs manual grade?
+        if (this.model.get("needs_manual_grade")) {
+            this.$needs_manual_grade.attr("data-search", "needs manual grade");
+            this.$needs_manual_grade.attr("data-order", 1);
+            this.$needs_manual_grade.append($("<span/>")
+                .addClass("glyphicon glyphicon-ok"));
+        } else {
+            this.$needs_manual_grade.attr("data-search", "");
+            this.$needs_manual_grade.attr("data-order", 0);
+        }
+
+        // tests failed?
+        if (this.model.get("failed_tests")) {
+            this.$tests_failed.attr("data-search", "tests failed");
+            this.$tests_failed.attr("data-order", 1);
+            this.$tests_failed.append($("<span/>")
+                .addClass("glyphicon glyphicon-ok"));
+        } else {
+            this.$tests_failed.attr("data-search", "");
+            this.$tests_failed.attr("data-order", 0);
+        }
+
+        // flagged?
+        if (this.model.get("flagged")) {
+            this.$flagged.attr("data-search", "flagged");
+            this.$flagged.attr("data-order", 1);
+            this.$flagged.append($("<span/>")
+                .addClass("glyphicon glyphicon-flag"));
+        } else {
+            this.$flagged.attr("data-search", "");
+            this.$flagged.attr("data-order", 0);
+        }
+    },
+});
+
+var insertRow = function (table) {
+    var row = $("<tr/>");
+    row.append($("<td/>").addClass("name"));
+    row.append($("<td/>").addClass("text-center score"));
+    row.append($("<td/>").addClass("text-center code-score"));
+    row.append($("<td/>").addClass("text-center written-score"));
+    row.append($("<td/>").addClass("text-center needs-manual-grade"));
+    row.append($("<td/>").addClass("text-center tests-failed"));
+    row.append($("<td/>").addClass("text-center flagged"));
+    table.append(row)
+    return row;
+};
+
+var loadStudentSubmittedNotebooks = function () {
+    var tbl = $("#main-table");
+
+    models = new StudentSubmittedNotebooks();
+    views = [];
+    models.loaded = false;
+    models.fetch({
+        success: function () {
+            tbl.empty();
+            models.each(function (model) {
+                var view = new StudentSubmittedNotebookUI({
+                    "model": model,
+                    "el": insertRow(tbl)
+                });
+                views.push(view);
+            });
+            insertDataTable(tbl.parent());
+            models.loaded = true;
+        }
+    });
+};
+
+var models = undefined;
+var views = [];
+$(window).load(function () {
+    loadStudentSubmittedNotebooks();
+});
diff --git a/nbgrader/server_extensions/formgrader/static/js/manage_submissions.js b/nbgrader/server_extensions/formgrader/static/js/manage_submissions.js
new file mode 100644
index 00000000..2b2b40d0
--- /dev/null
+++ b/nbgrader/server_extensions/formgrader/static/js/manage_submissions.js
@@ -0,0 +1,194 @@
+var Submission = Backbone.Model.extend({
+    idAttribute: 'student',
+    urlRoot: base_url + "/formgrader/api/submission/" + assignment_id
+});
+
+var Submissions = Backbone.Collection.extend({
+    model: Submission,
+    url: base_url + "/formgrader/api/submissions/" + assignment_id
+});
+
+var SubmissionUI = Backbone.View.extend({
+
+    events: {},
+
+    initialize: function () {
+        this.$student_name = this.$el.find(".student-name");
+        this.$student_id = this.$el.find(".student-id");
+        this.$timestamp = this.$el.find(".timestamp");
+        this.$status = this.$el.find(".status");
+        this.$score = this.$el.find(".score");
+        this.$autograde = this.$el.find(".autograde");
+
+        this.listenTo(this.model, "sync", this.render);
+
+        this.render();
+    },
+
+    clear: function () {
+        this.$student_name.empty();
+        this.$student_id.empty();
+        this.$timestamp.empty();
+        this.$status.empty();
+        this.$score.empty();
+        this.$autograde.empty();
+    },
+
+    render: function () {
+        this.clear();
+
+        var student = this.model.get("student");
+        var assignment = this.model.get("name");
+
+        // student name
+        var last_name = this.model.get("last_name");
+        var first_name = this.model.get("first_name");
+        if (last_name === null) last_name = "None";
+        if (first_name === null) first_name = "None";
+        var name = last_name + ", " + first_name;
+        this.$student_name.attr("data-order", name);
+        if (this.model.get("autograded")) {
+            this.$student_name.append($("<a/>")
+                .attr("href", base_url + "/formgrader/manage_students/" + student + "/" + assignment)
+                .text(name));
+        } else {
+            this.$student_name.text(name);
+        }
+
+        // student id
+        this.$student_id.attr("data-order", student);
+        this.$student_id.text(student);
+
+        // timestamp
+        var timestamp = this.model.get("timestamp");
+        var display_timestamp = this.model.get("display_timestamp");
+        if (timestamp === null) {
+            timestamp = "None";
+            display_timestamp = "None";
+        }
+        this.$timestamp.attr("data-order", timestamp);
+        this.$timestamp.text(display_timestamp);
+
+        // status
+        if (!this.model.get("autograded")) {
+            this.$status.attr("data-order", 0);
+            this.$status.append($("<span/>")
+                .addClass("label label-warning")
+                .text("needs autograding"));
+        } else if (this.model.get("needs_manual_grade")) {
+            this.$status.attr("data-order", 1);
+            this.$status.append($("<span/>")
+                .addClass("label label-info")
+                .text("needs manual grading"));
+        } else {
+            this.$status.attr("data-order", 2);
+            this.$status.append($("<span/>")
+                .addClass("label label-success")
+                .text("graded"));
+        }
+
+        // score
+        if (this.model.get("autograded")) {
+            var score = roundToPrecision(this.model.get("score"), 2);
+            var max_score = roundToPrecision(this.model.get("max_score"), 2);
+            if (max_score === 0) {
+                this.$score.attr("data-order", 0.0);
+            } else {
+                this.$score.attr("data-order", score / max_score);
+            }
+            this.$score.text(score + " / " + max_score);
+        } else {
+            this.$score.attr("data-order", 0.0);
+        }
+
+        // autograde
+        this.$autograde.append($("<a/>")
+            .attr("href", "#")
+            .click(_.bind(this.autograde, this))
+            .append($("<span/>")
+                .addClass("glyphicon glyphicon-flash")
+                .attr("aria-hidden", "true")));
+    },
+
+    autograde: function () {
+        this.clear();
+        this.$student_name.text("Please wait...");
+        var student = this.model.get("student");
+        var assignment = this.model.get("name");
+        $.post(base_url + "/formgrader/api/submission/" + assignment + "/" + student + "/autograde")
+            .done(_.bind(this.autograde_success, this))
+            .fail(_.bind(this.autograde_failure, this));
+    },
+
+    autograde_success: function (response) {
+        this.model.fetch();
+        response = JSON.parse(response);
+        var student = this.model.get("student");
+        var assignment = this.model.get("name");
+        if (response["success"]) {
+            createLogModal(
+                "success-modal",
+                "Success",
+                "Successfully autograded '" + assignment + "' for student '" + student + "'.",
+                response["log"]);
+
+        } else {
+            createLogModal(
+                "error-modal",
+                "Error",
+                "There was an error autograding '" + assignment + "' for student '" + student + "':",
+                response["log"],
+                response["error"]);
+        }
+    },
+
+    autograde_failure: function (response) {
+        this.model.fetch();
+        var student = this.model.get("student");
+        var assignment = this.model.get("name");
+        createModal(
+            "error-modal",
+            "Error",
+            "There was an error autograding '" + assignment + "' for student '" + student + "'.");
+    },
+});
+
+var insertRow = function (table) {
+    var row = $("<tr/>");
+    row.append($("<td/>").addClass("student-name"));
+    row.append($("<td/>").addClass("text-center student-id"));
+    row.append($("<td/>").addClass("text-center timestamp"));
+    row.append($("<td/>").addClass("text-center status"));
+    row.append($("<td/>").addClass("text-center score"));
+    row.append($("<td/>").addClass("text-center autograde"));
+    table.append(row)
+    return row;
+};
+
+var loadSubmissions = function () {
+    var tbl = $("#main-table");
+
+    models = new Submissions();
+    views = [];
+    models.loaded = false;
+    models.fetch({
+        success: function () {
+            tbl.empty();
+            models.each(function (model) {
+                var view = new SubmissionUI({
+                    "model": model,
+                    "el": insertRow(tbl)
+                });
+                views.push(view);
+            });
+            insertDataTable(tbl.parent());
+            models.loaded = true;
+        }
+    });
+};
+
+var models = undefined;
+var views = [];
+$(window).load(function () {
+    loadSubmissions();
+});
diff --git a/nbgrader/server_extensions/formgrader/static/js/utils.js b/nbgrader/server_extensions/formgrader/static/js/utils.js
new file mode 100644
index 00000000..e5adf5fa
--- /dev/null
+++ b/nbgrader/server_extensions/formgrader/static/js/utils.js
@@ -0,0 +1,89 @@
+var createModal = function (id, title, body, footer) {
+    var modal = $("<div/>")
+        .addClass("modal")
+        .addClass("fade")
+        .attr("id", id)
+        .attr("role", "dialog")
+
+    var dialog = $("<div/>").addClass("modal-dialog");
+    modal.append(dialog);
+
+    var content = $("<div/>").addClass("modal-content");
+    dialog.append(content);
+
+    var header = $("<div/>").addClass("modal-header");
+    content.append(header);
+    header.append($("<button/>")
+        .addClass("close")
+        .attr("data-dismiss", "modal")
+        .attr("aria-label", "Close")
+        .append($("<span/>")
+            .attr("aria-hidden", "true")
+            .html("&times;")));
+    header.append($("<h4/>")
+        .addClass("modal-title")
+        .text(title));
+
+    content.append($("<div/>").addClass("modal-body").append(body));
+
+    if (!footer) {
+        footer = $("<div/>");
+        footer.append($("<button/>")
+            .addClass("btn btn-primary close")
+            .attr("type", "button")
+            .attr("data-dismiss", "modal")
+            .text("OK"));
+    }
+    content.append($("<div/>").addClass("modal-footer").append(footer));
+
+    // remove the modal on close
+    modal.on("hidden.bs.modal", function () {
+        modal.remove();
+    });
+
+    $("body").append(modal);
+    modal.modal();
+    return modal;
+};
+
+var createLogModal = function (id, title, message, log, error) {
+    var body = $("<div>");
+    body.append($("<p/>").text(message));
+
+    if (log) {
+        var log_panel = $("<div/>").addClass("panel panel-warning");
+        log_panel.append($("<div/>").addClass("panel-heading").text("Log Output"));
+        log_panel.append($("<div/>")
+            .addClass("panel-body")
+            .append($("<pre/>").text(log)));
+        body.append(log_panel);
+    }
+
+    if (error) {
+        var err_panel = $("<div/>").addClass("panel panel-danger");
+        err_panel.append($("<div/>").addClass("panel-heading").text("Traceback"));
+        err_panel.append($("<div/>")
+            .addClass("panel-body")
+            .append($("<pre/>").text(error)));
+        body.append(err_panel);
+    }
+
+    return createModal(id, title, body);
+};
+
+var roundToPrecision = function (num, precision) {
+    var factor = Math.pow(10, precision);
+    return Math.round(num * factor) / factor;
+};
+
+var insertDataTable = function (tbl) {
+    tbl.DataTable({
+        info: false,
+        paging: false,
+        saveState: true,
+        columnDefs: [{
+            "targets": "no-sort",
+            "orderable": false
+        }]
+    });
+};
diff --git a/nbgrader/server_extensions/formgrader/templates/assignment_notebooks.tpl b/nbgrader/server_extensions/formgrader/templates/assignment_notebooks.tpl
deleted file mode 100644
index 25feab24..00000000
--- a/nbgrader/server_extensions/formgrader/templates/assignment_notebooks.tpl
+++ /dev/null
@@ -1,61 +0,0 @@
-{%- extends 'gradebook.tpl' -%}
-
-{%- block breadcrumb -%}
-<li><a href="{{ base_url }}/formgrader/assignments">Assignments</a></li>
-<li class="active">{{ assignment.name }}</li>
-{%- endblock -%}
-
-{%- block body -%}
-<div class="panel-body">
-  The following table lists the notebooks that are associated with the
-  assignment "{{ assignment.name }}". Click on a notebook
-  name to see the list of student submissions for that notebook.
-</div>
-{%- endblock -%}
-
-{%- block table -%}
-<thead>
-  <tr>
-    <th>Notebook ID</th>
-    <th class="center">Avg. Score</th>
-    <th class="center">Avg. Code Score</th>
-    <th class="center">Avg. Written Score</th>
-    <th class="center">Needs manual grade?</th>
-  </tr>
-</thead>
-<tbody>
-  {%- for notebook in notebooks -%}
-  <tr>
-    <td><a href="{{ base_url }}/formgrader/assignments/{{ assignment.name }}/{{ notebook.name }}">{{ notebook.name }}</a></td>
-    {%- if notebook.max_score is greaterthan 0 -%}
-    <td data-order="{{ notebook.average_score / notebook.max_score | float | round(2) }}" class="center">
-    {%- else -%}
-    <td data-order="0.00" class="center">
-    {%- endif -%}
-        {{ notebook.average_score | float | round(2) }} / {{ notebook.max_score | float | round(2) }}
-    </td>
-    {%- if notebook.max_code_score is greaterthan 0 -%}
-    <td data-order="{{ notebook.average_code_score / notebook.max_code_score | float | round(2) }}" class="center">
-    {%- else -%}
-    <td data-order="0.00" class="center">
-    {%- endif -%}
-        {{ notebook.average_code_score | float | round(2) }} / {{ notebook.max_code_score | float | round(2) }}
-    </td>
-    {%- if notebook.max_written_score is greaterthan 0 -%}
-    <td data-order="{{ notebook.average_written_score / notebook.max_written_score | float | round(2) }}" class="center">
-    {%- else -%}
-    <td data-order="0.00" class="center">
-    {%- endif -%}
-        {{ notebook.average_written_score | float | round(2) }} / {{ notebook.max_written_score | float | round(2) }}
-    </td>
-    {%- if notebook.needs_manual_grade -%}
-    <td data-search="needs manual grade" class="center">
-      <span class="glyphicon glyphicon-ok"></span>
-    {%- else -%}
-    <td data-search="" class="center">
-    {%- endif -%}
-    </td>
-  </tr>
-  {%- endfor -%}
-</tbody>
-{%- endblock -%}
diff --git a/nbgrader/server_extensions/formgrader/templates/assignments.tpl b/nbgrader/server_extensions/formgrader/templates/assignments.tpl
deleted file mode 100644
index 2c5db59b..00000000
--- a/nbgrader/server_extensions/formgrader/templates/assignments.tpl
+++ /dev/null
@@ -1,36 +0,0 @@
-{%- extends 'gradebook.tpl' -%}
-
-{%- block breadcrumb -%}
-<li class="active">Assignments</li>
-{%- endblock -%}
-
-{%- block body -%}
-<div class="panel-body">
-  The following table lists all of the assignments that have been
-  added to the gradebook. Click on the name of an assignment to see
-  the notebooks that are associated with that assignment.
-</div>
-{%- endblock -%}
-
-{%- block table -%}
-<thead>
-  <tr>
-    <th>Assignment ID</th>
-    <th class="center">Due date</th>
-    <th class="center">Submissions</th>
-    <th class="center">Avg score</th>
-    <th class="center">Max score</th>
-  </tr>
-</thead>
-<tbody>
-  {%- for assignment in assignments -%}
-  <tr>
-    <td><a href="{{ base_url }}/formgrader/assignments/{{ assignment.name }}">{{ assignment.name }}</a></td>
-    <td class="center">{{ assignment.duedate }}</td>
-    <td class="center">{{ assignment.num_submissions }}</td>
-    <td class="center">{{ assignment.average_score | float | round(2) }}</td>
-    <td class="center">{{ assignment.max_score | float | round(2) }}</td>
-  </tr>
-  {%- endfor -%}
-</tbody>
-{%- endblock -%}
diff --git a/nbgrader/server_extensions/formgrader/templates/base.tpl b/nbgrader/server_extensions/formgrader/templates/base.tpl
new file mode 100644
index 00000000..93866c2e
--- /dev/null
+++ b/nbgrader/server_extensions/formgrader/templates/base.tpl
@@ -0,0 +1,79 @@
+<!doctype html>
+<head>
+  <title>nbgrader formgrade</title>
+
+  <script src="{{ base_url }}/formgrader/static/components/jquery/jquery.min.js"></script>
+  <script src="{{ base_url }}/formgrader/static/components/underscore/underscore-min.js"></script>
+  <script src="{{ base_url }}/formgrader/static/components/backbone/backbone-min.js"></script>
+  <script src="{{ base_url }}/formgrader/static/components/bootstrap/js/bootstrap.min.js"></script>
+  <script src="{{ base_url }}/formgrader/static/components/datatables.net/js/jquery.dataTables.min.js"></script>
+  <script src="{{ base_url }}/formgrader/static/components/datatables.net-bs/js/dataTables.bootstrap.min.js"></script>
+  <script src="{{ base_url }}/formgrader/static/js/backbone_xsrf.js"></script>
+  <script src="{{ base_url }}/formgrader/static/js/utils.js"></script>
+
+  <link rel="stylesheet" href="{{ base_url }}/formgrader/static/components/bootstrap/css/bootstrap.min.css" />
+  <link rel="stylesheet" href="{{ base_url }}/formgrader/static/components/datatables.net-bs/css/dataTables.bootstrap.min.css">
+  <link rel="stylesheet" href="{{ base_url }}/formgrader/static/css/nbgrader.css">
+
+  <script>
+  var base_url = "{{ base_url }}";
+  </script>
+
+  {%- block head -%}
+  {%- endblock -%}
+</head>
+
+<body>
+  <div class="container-fluid">
+    <div class="row">
+      <div class="col-md-2">
+        <div class="page-header">
+          <h1>nbgrader</h1>
+        </div>
+      </div>
+      <div class="col-md-10">
+        <div class="page-header">
+          <h1>
+          {%- block title -%}
+          {%- endblock -%}
+          </h1>
+        </div>
+      </div>
+    </div>
+    <div class="row">
+      <div class="col-md-2">
+        <ul class="nav nav-pills nav-stacked">
+          {%- block sidebar -%}
+          <li role="presentation"><a href="{{ base_url }}/formgrader/manage_assignments">Manage Assignments</a></li>
+          <li role="presentation"><a href="{{ base_url }}/formgrader/gradebook">Gradebook</a></li>
+          <li role="presentation"><a href="{{ base_url }}/formgrader/manage_students">Manage Students</a></li>
+          {%- endblock -%}
+        </ul>
+      </div>
+      <div class="col-md-10">
+        {%- block body -%}
+        {%- block breadcrumbs -%}
+        {%- endblock -%}
+        {%- block messages -%}
+        {%- endblock -%}
+        <table class="table table-hover">
+          <thead>
+            {%- block table_header -%}
+            {%- endblock -%}
+          </thead>
+          <tbody id="main-table">
+            {%- block table_body -%}
+            {%- endblock -%}
+          </tbody>
+          <tfoot>
+            {%- block table_footer -%}
+            {%- endblock -%}
+          </tfoot>
+        </table>
+        {%- endblock -%}
+      </div>
+    </div>
+  </div>
+  {%- block script -%}
+  {%- endblock -%}
+</body>
diff --git a/nbgrader/server_extensions/formgrader/templates/gradebook_403.tpl b/nbgrader/server_extensions/formgrader/templates/base_403.tpl
similarity index 60%
rename from nbgrader/server_extensions/formgrader/templates/gradebook_403.tpl
rename to nbgrader/server_extensions/formgrader/templates/base_403.tpl
index 0183d575..6b29da31 100644
--- a/nbgrader/server_extensions/formgrader/templates/gradebook_403.tpl
+++ b/nbgrader/server_extensions/formgrader/templates/base_403.tpl
@@ -1,4 +1,8 @@
-{%- extends 'gradebook.tpl' -%}
+{%- extends 'base.tpl' -%}
+
+{%- block title -%}
+Not Authorized
+{%- endblock -%}
 
 {%- block body -%}
 <div class="panel-body">
diff --git a/nbgrader/server_extensions/formgrader/templates/gradebook_500.tpl b/nbgrader/server_extensions/formgrader/templates/base_500.tpl
similarity index 71%
rename from nbgrader/server_extensions/formgrader/templates/gradebook_500.tpl
rename to nbgrader/server_extensions/formgrader/templates/base_500.tpl
index 382d4f0e..6dfe7789 100644
--- a/nbgrader/server_extensions/formgrader/templates/gradebook_500.tpl
+++ b/nbgrader/server_extensions/formgrader/templates/base_500.tpl
@@ -1,4 +1,8 @@
-{%- extends 'gradebook.tpl' -%}
+{%- extends 'base.tpl' -%}
+
+{%- block title -%}
+Error
+{%- endblock -%}
 
 {%- block body -%}
 <div class="panel-body">
diff --git a/nbgrader/server_extensions/formgrader/templates/formgrade_macros.tpl b/nbgrader/server_extensions/formgrader/templates/formgrade_macros.tpl
index a96e3d62..f6bc8f0e 100644
--- a/nbgrader/server_extensions/formgrader/templates/formgrade_macros.tpl
+++ b/nbgrader/server_extensions/formgrader/templates/formgrade_macros.tpl
@@ -16,8 +16,9 @@ var assignment_id = "{{ resources.assignment_id }}";
 var base_url = "{{ resources.base_url }}/formgrader";
 </script>
 
-<script src="{{ resources.base_url }}/formgrader/static/js/keyboardmanager.js"></script>
-<script src="{{ resources.base_url }}/formgrader/static/js/models.js"></script>
+<script src="{{ resources.base_url }}/formgrader/static/js/backbone_xsrf.js"></script>
+<script src="{{ resources.base_url }}/formgrader/static/js/formgrade_keyboardmanager.js"></script>
+<script src="{{ resources.base_url }}/formgrader/static/js/formgrade_models.js"></script>
 <script src="{{ resources.base_url }}/formgrader/static/js/formgrade.js"></script>
 <script type="text/javascript">
 function toggle_name(on) {
@@ -51,9 +52,9 @@ function toggle_name(on) {
       <div class="col-md-8">
         <ul class="nav text-center">
           <ul class="breadcrumb">
-            <li><a href="{{ resources.base_url }}/formgrader/assignments">Assignments</a></li>
-            <li><a href="{{ resources.base_url }}/formgrader/assignments/{{ resources.assignment_id }}">{{ resources.assignment_id }}</a></li>
-            <li><a href="{{ resources.base_url }}/formgrader/assignments/{{ resources.assignment_id }}/{{ resources.notebook_id }}">{{ resources.notebook_id }}</a></li>
+            <li><a href="{{ resources.base_url }}/formgrader/gradebook">Manual Grading</a></li>
+            <li><a href="{{ resources.base_url }}/formgrader/gradebook/{{ resources.assignment_id }}">{{ resources.assignment_id }}</a></li>
+            <li><a href="{{ resources.base_url }}/formgrader/gradebook/{{ resources.assignment_id }}/{{ resources.notebook_id }}">{{ resources.notebook_id }}</a></li>
             <li class="active live-notebook">
               <a class="name-hidden" data-toggle="tooltip" data-placement="bottom" title="Open live notebook" target="_blank" href="{{ resources.base_url }}/notebooks/{{ resources.notebook_path }}">
                 Submission #{{ resources.index + 1 }}
diff --git a/nbgrader/server_extensions/formgrader/templates/gradebook.tpl b/nbgrader/server_extensions/formgrader/templates/gradebook.tpl
deleted file mode 100644
index 24cd2ee0..00000000
--- a/nbgrader/server_extensions/formgrader/templates/gradebook.tpl
+++ /dev/null
@@ -1,66 +0,0 @@
-<!doctype html>
-<head>
-  <title>nbgrader formgrade</title>
-
-  <script src="{{ base_url }}/formgrader/static/components/jquery/jquery.min.js"></script>
-  <script src="{{ base_url }}/formgrader/static/components/underscore/underscore-min.js"></script>
-  <script src="{{ base_url }}/formgrader/static/components/backbone/backbone-min.js"></script>
-  <script src="{{ base_url }}/formgrader/static/components/bootstrap/js/bootstrap.min.js"></script>
-  <script src="{{ base_url }}/formgrader/static/components/datatables.net/js/jquery.dataTables.min.js"></script>
-  <script src="{{ base_url }}/formgrader/static/components/datatables.net-bs/js/dataTables.bootstrap.min.js"></script>
-
-  <link rel="stylesheet" href="{{ base_url }}/formgrader/static/components/bootstrap/css/bootstrap.min.css" />
-  <link rel="stylesheet" href="{{ base_url }}/formgrader/static/components/datatables.net-bs/css/dataTables.bootstrap.min.css">
-  <link rel="stylesheet" href="{{ base_url }}/formgrader/static/css/formgrade.css">
-
-  {%- block head -%}
-  {%- endblock -%}
-</head>
-
-<body>
-  <nav class="navbar navbar-default navbar-fixed-top" role="navigation">
-    <div class="container">
-      <div class="navbar-header">
-        <a class="navbar-brand" href="#">nbgrader formgrade</a>
-      </div>
-      <div>
-        <ul class="nav navbar-nav navbar-left">
-          <ul class="breadcrumb">
-            {%- block breadcrumb -%}
-            {%- endblock -%}
-          </ul>
-        </ul>
-        <ul class="nav navbar-nav navbar-right">
-          <li class="dropdown">
-            <a href="#" class="dropdown-toggle" data-toggle="dropdown">Change View <b class="caret"></b></a>
-            <ul class="dropdown-menu">
-              <li><a href="{{ base_url }}/formgrader/assignments">Assignments</a></li>
-              <li><a href="{{ base_url }}/formgrader/students">Students</a></li>
-            </ul>
-          </li>
-        </ul>
-      </div>
-    </div>
-  </nav>
-  <div class="container" id="gradebook">
-    <div class="panel panel-default">
-      {%- block body -%}
-      <div class="panel-body"></div>
-      {%- endblock -%}
-      <table id="formgrade-table" class="table table-hover">
-        {%- block table -%}
-        {%- endblock -%}
-      </table>
-    </div>
-  </div>
-  <script type="text/javascript">
-    $(document).ready(function(){
-      $('#formgrade-table').DataTable({
-        info: false,
-        paging: false,
-        saveState: true,
-      });
-    });
-  </script>
-</body>
-
diff --git a/nbgrader/server_extensions/formgrader/templates/gradebook_assignments.tpl b/nbgrader/server_extensions/formgrader/templates/gradebook_assignments.tpl
new file mode 100644
index 00000000..06390b49
--- /dev/null
+++ b/nbgrader/server_extensions/formgrader/templates/gradebook_assignments.tpl
@@ -0,0 +1,24 @@
+{%- extends 'gradebook_base.tpl' -%}
+
+{%- block head -%}
+<script src="{{ base_url }}/formgrader/static/js/gradebook_assignments.js"></script>
+{%- endblock -%}
+
+{%- block breadcrumbs -%}
+<ol class="breadcrumb">
+  <li class="active">Manual Grading</li>
+</ol>
+{%- endblock -%}
+
+{%- block table_header -%}
+<tr>
+  <th>Assignment ID</th>
+  <th class="text-center">Due Date</th>
+  <th class="text-center">Submissions</th>
+  <th class="text-center">Score</th>
+</tr>
+{%- endblock -%}
+
+{%- block table_body -%}
+<tr><td colspan="4">Loading, please wait...</td></tr>
+{%- endblock -%}
diff --git a/nbgrader/server_extensions/formgrader/templates/gradebook_base.tpl b/nbgrader/server_extensions/formgrader/templates/gradebook_base.tpl
new file mode 100644
index 00000000..e280d731
--- /dev/null
+++ b/nbgrader/server_extensions/formgrader/templates/gradebook_base.tpl
@@ -0,0 +1,11 @@
+{%- extends 'base.tpl' -%}
+
+{%- block title -%}
+Manual Grading
+{%- endblock -%}
+
+{%- block sidebar -%}
+<li role="presentation"><a href="{{ base_url }}/formgrader/manage_assignments">Manage Assignments</a></li>
+<li role="presentation" class="active"><a href="{{ base_url }}/formgrader/gradebook">Manual Grading</a></li>
+<li role="presentation"><a href="{{ base_url }}/formgrader/manage_students">Manage Students</a></li>
+{%- endblock -%}
\ No newline at end of file
diff --git a/nbgrader/server_extensions/formgrader/templates/gradebook_notebook_submissions.tpl b/nbgrader/server_extensions/formgrader/templates/gradebook_notebook_submissions.tpl
new file mode 100644
index 00000000..385bc76a
--- /dev/null
+++ b/nbgrader/server_extensions/formgrader/templates/gradebook_notebook_submissions.tpl
@@ -0,0 +1,35 @@
+{%- extends 'gradebook_base.tpl' -%}
+
+{%- block head -%}
+<script>
+var assignment_id = "{{ assignment_id }}";
+var notebook_id = "{{ notebook_id }}";
+</script>
+
+<script src="{{ base_url }}/formgrader/static/js/gradebook_notebook_submissions.js"></script>
+{%- endblock head -%}
+
+{%- block breadcrumbs -%}
+<ol class="breadcrumb">
+  <li><a href="{{ base_url }}/formgrader/gradebook">Manual Grading</a></li>
+  <li><a href="{{ base_url }}/formgrader/gradebook/{{ assignment_id }}">{{ assignment_id }}</a></li>
+  <li class="active">{{ notebook_id }}</li>
+</ol>
+{%- endblock -%}
+
+{%- block table_header -%}
+<tr>
+  <th></th>
+  <th>Submission ID</th>
+  <th class="text-center">Overall Score</th>
+  <th class="text-center">Code Score</th>
+  <th class="text-center">Written Score</th>
+  <th class="text-center">Needs Manual Grade?</th>
+  <th class="text-center">Tests Failed?</th>
+  <th class="text-center">Flagged?</th>
+</tr>
+{%- endblock -%}
+
+{%- block table_body -%}
+<tr><td colspan="7">Loading, please wait...</td></tr>
+{%- endblock -%}
diff --git a/nbgrader/server_extensions/formgrader/templates/gradebook_notebooks.tpl b/nbgrader/server_extensions/formgrader/templates/gradebook_notebooks.tpl
new file mode 100644
index 00000000..75312048
--- /dev/null
+++ b/nbgrader/server_extensions/formgrader/templates/gradebook_notebooks.tpl
@@ -0,0 +1,30 @@
+{%- extends 'gradebook_base.tpl' -%}
+
+{%- block head -%}
+<script>
+var assignment_id = "{{ assignment_id }}";
+</script>
+
+<script src="{{ base_url }}/formgrader/static/js/gradebook_notebooks.js"></script>
+{%- endblock head -%}
+
+{%- block breadcrumbs -%}
+<ol class="breadcrumb">
+  <li><a href="{{ base_url }}/formgrader/gradebook">Manual Grading</a></li>
+  <li class="active">{{ assignment_id }}</li>
+</ol>
+{%- endblock -%}
+
+{%- block table_header -%}
+<tr>
+  <th>Notebook ID</th>
+  <th class="text-center">Avg. Score</th>
+  <th class="text-center">Avg. Code Score</th>
+  <th class="text-center">Avg. Written Score</th>
+  <th class="text-center">Needs Manual Grade?</th>
+</tr>
+{%- endblock -%}
+
+{%- block table_body -%}
+<tr><td colspan="5">Loading, please wait...</td></tr>
+{%- endblock -%}
diff --git a/nbgrader/server_extensions/formgrader/templates/manage_assignments.tpl b/nbgrader/server_extensions/formgrader/templates/manage_assignments.tpl
new file mode 100644
index 00000000..a74e7c3d
--- /dev/null
+++ b/nbgrader/server_extensions/formgrader/templates/manage_assignments.tpl
@@ -0,0 +1,79 @@
+{%- extends 'base.tpl' -%}
+
+{%- block head -%}
+<script src="{{ base_url }}/formgrader/static/js/manage_assignments.js"></script>
+{%- endblock -%}
+
+{%- block title -%}
+Manage Assignments
+{%- endblock -%}
+
+{%- block sidebar -%}
+<li role="presentation" class="active"><a href="{{ base_url }}/formgrader/manage_assignments">Manage Assignments</a></li>
+<li role="presentation"><a href="{{ base_url }}/formgrader/gradebook">Manual Grading</a></li>
+<li role="presentation"><a href="{{ base_url }}/formgrader/manage_students">Manage Students</a></li>
+{%- endblock -%}
+
+{%- block breadcrumbs -%}
+<ol class="breadcrumb">
+  <li class="active">Assignments</li>
+</ol>
+{%- endblock -%}
+
+{%- block messages -%}
+<div class="panel-group" id="accordion" role="tablist" aria-multiselectable="true">
+  <div class="panel panel-default">
+    <div class="panel-heading" role="tab" id="headingOne">
+      <h4 class="panel-title">
+        <a class="collapsed" role="button" data-toggle="collapse" data-parent="#accordion" href="#collapseOne" aria-expanded="false" aria-controls="collapseOne">
+          Instructions (click to expand)
+        </a>
+      </h4>
+    </div>
+    <div id="collapseOne" class="panel-collapse collapse" role="tabpanel" aria-labelledby="headingOne">
+      <div class="panel-body">
+        <ol>
+          <li>To <b>create</b> an assignment, click the "Add new assignment..." button below.</li>
+          <li>To <b>edit assignment files</b>, click on the name of an assignment.</li>
+          <li>To <b>edit the assignment metadata</b>, click on the edit button.</li>
+          <li>To <b>generate</b> the student version of an assignment, click on the generate button.</li>
+          <li>To <b>preview</b> the student version of an assignment, click on the preview button.</li>
+          <li><i>(JupyterHub only)</i> To <b>release</b> the assignment to students, click the release button.
+          You can "unrelease" an assignment by clicking again, though note some students may have
+          already accessed the assignment.</li>
+          <li><i>(JupyterHub only)</i> To <b>collect</b> assignments, click the collect button.</li>
+          <li>To <b>autograde</b> submissions, click on the number of collected submissions. You must run
+          the autograder on the submissions before you can manually grade them.</li>
+        </ol>
+      </div>
+    </div>
+  </div>
+</div>
+{%- endblock -%}
+
+{%- block table_header -%}
+<tr>
+  <th>Name</th>
+  <th class="text-center">Due Date</th>
+  <th class="text-center">Status</th>
+  <th class="text-center no-sort">Edit</th>
+  <th class="text-center no-sort">Generate</th>
+  <th class="text-center no-sort">Preview</th>
+  <th class="text-center no-sort">Release</th>
+  <th class="text-center no-sort">Collect</th>
+  <th class="text-center"># Submissions</th>
+</tr>
+{%- endblock -%}
+
+{%- block table_body -%}
+<tr><td colspan="9">Loading, please wait...</td></tr>
+{%- endblock -%}
+
+{%- block table_footer -%}
+<tr>
+  <td colspan="9">
+    <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
+    <a href="#" onClick="createAssignmentModal();">Add new assignment...</a>
+  </td>
+</tr>
+{%- endblock -%}
diff --git a/nbgrader/server_extensions/formgrader/templates/manage_students.tpl b/nbgrader/server_extensions/formgrader/templates/manage_students.tpl
new file mode 100644
index 00000000..09de6552
--- /dev/null
+++ b/nbgrader/server_extensions/formgrader/templates/manage_students.tpl
@@ -0,0 +1,34 @@
+{%- extends 'manage_students_base.tpl' -%}
+
+{%- block head -%}
+<script src="{{ base_url }}/formgrader/static/js/manage_students.js"></script>
+{%- endblock -%}
+
+{%- block breadcrumbs -%}
+<ol class="breadcrumb">
+  <li class="active">Students</li>
+</ol>
+{%- endblock -%}
+
+{%- block table_header -%}
+<tr>
+  <th>Name</th>
+  <th class="text-center">Student ID</th>
+  <th class="text-center">Email</th>
+  <th class="text-center">Overall Score</th>
+  <th class="text-center no-sort">Edit Student</th>
+</tr>
+{%- endblock -%}
+
+{%- block table_body -%}
+<tr><td colspan="5">Loading, please wait...</td></tr>
+{%- endblock -%}
+
+{%- block table_footer -%}
+<tr>
+  <td colspan="5">
+    <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
+    <a href="#" onClick="createStudentModal();">Add new student...</a>
+  </td>
+</tr>
+{%- endblock -%}
\ No newline at end of file
diff --git a/nbgrader/server_extensions/formgrader/templates/manage_students_assignments.tpl b/nbgrader/server_extensions/formgrader/templates/manage_students_assignments.tpl
new file mode 100644
index 00000000..b3120755
--- /dev/null
+++ b/nbgrader/server_extensions/formgrader/templates/manage_students_assignments.tpl
@@ -0,0 +1,30 @@
+{%- extends 'manage_students_base.tpl' -%}
+
+{%- block head -%}
+<script>
+var student_id = "{{ student_id }}";
+</script>
+
+<script src="{{ base_url }}/formgrader/static/js/manage_students_assignments.js"></script>
+{%- endblock head -%}
+
+{%- block breadcrumbs -%}
+<ol class="breadcrumb">
+  <li><a href="{{ base_url }}/formgrader/manage_students">Students</a></li>
+  <li class="active">{{ student_id }}</li>
+</ol>
+{%- endblock -%}
+
+{%- block table_header -%}
+<tr>
+  <th>Assignment ID</th>
+  <th class="text-center">Overall Score</th>
+  <th class="text-center">Code Score</th>
+  <th class="text-center">Written Score</th>
+  <th class="text-center">Needs Manual Grade?</th>
+</tr>
+{%- endblock -%}
+
+{%- block table_body -%}
+<tr><td colspan="5">Loading, please wait...</td></tr>
+{%- endblock -%}
diff --git a/nbgrader/server_extensions/formgrader/templates/manage_students_base.tpl b/nbgrader/server_extensions/formgrader/templates/manage_students_base.tpl
new file mode 100644
index 00000000..86ecf339
--- /dev/null
+++ b/nbgrader/server_extensions/formgrader/templates/manage_students_base.tpl
@@ -0,0 +1,11 @@
+{%- extends 'base.tpl' -%}
+
+{%- block title -%}
+Manage Students
+{%- endblock -%}
+
+{%- block sidebar -%}
+<li role="presentation"><a href="{{ base_url }}/formgrader/manage_assignments">Manage Assignments</a></li>
+<li role="presentation"><a href="{{ base_url }}/formgrader/gradebook">Manual Grading</a></li>
+<li role="presentation" class="active"><a href="{{ base_url }}/formgrader/manage_students">Manage Students</a></li>
+{%- endblock -%}
diff --git a/nbgrader/server_extensions/formgrader/templates/manage_students_notebook_submissions.tpl b/nbgrader/server_extensions/formgrader/templates/manage_students_notebook_submissions.tpl
new file mode 100644
index 00000000..4ac51e4a
--- /dev/null
+++ b/nbgrader/server_extensions/formgrader/templates/manage_students_notebook_submissions.tpl
@@ -0,0 +1,34 @@
+{%- extends 'manage_students_base.tpl' -%}
+
+{%- block head -%}
+<script>
+var student_id = "{{ student_id }}";
+var assignment_id = "{{ assignment_id }}";
+</script>
+
+<script src="{{ base_url }}/formgrader/static/js/manage_students_notebook_submissions.js"></script>
+{%- endblock head -%}
+
+{%- block breadcrumbs -%}
+<ol class="breadcrumb">
+  <li><a href="{{ base_url }}/formgrader/manage_students">Students</a></li>
+  <li><a href="{{ base_url }}/formgrader/manage_students/{{ student_id }}">{{ student_id }}</a></li>
+  <li class="active">{{ assignment_id }}</li>
+</ol>
+{%- endblock -%}
+
+{%- block table_header -%}
+<tr>
+  <th>Notebook ID</th>
+  <th class="text-center">Overall Score</th>
+  <th class="text-center">Code Score</th>
+  <th class="text-center">Written Score</th>
+  <th class="text-center">Needs manual grade?</th>
+  <th class="text-center">Tests failed?</th>
+  <th class="text-center">Flagged?</th>
+</tr>
+{%- endblock -%}
+
+{%- block table_body -%}
+<tr><td colspan="7">Loading, please wait...</td></tr>
+{%- endblock -%}
diff --git a/nbgrader/server_extensions/formgrader/templates/manage_submissions.tpl b/nbgrader/server_extensions/formgrader/templates/manage_submissions.tpl
new file mode 100644
index 00000000..3c96e4dd
--- /dev/null
+++ b/nbgrader/server_extensions/formgrader/templates/manage_submissions.tpl
@@ -0,0 +1,77 @@
+{%- extends 'base.tpl' -%}
+
+{%- block head -%}
+<script>
+var assignment_id = "{{ assignment_id }}";
+</script>
+
+<script src="{{ base_url }}/formgrader/static/js/manage_submissions.js"></script>
+{%- endblock head -%}
+
+{%- block title -%}
+Manage Submissions
+{%- endblock -%}
+
+{%- block sidebar -%}
+<li role="presentation" class="active"><a href="{{ base_url }}/formgrader/manage_assignments">Manage Assignments</a></li>
+<li role="presentation"><a href="{{ base_url }}/formgrader/gradebook">Manual Grading</a></li>
+<li role="presentation"><a href="{{ base_url }}/formgrader/manage_students">Manage Students</a></li>
+{%- endblock -%}
+
+{%- block breadcrumbs -%}
+<ol class="breadcrumb">
+  <li><a href="{{ base_url }}/formgrader/manage_assignments">Assignments</a></li>
+  <li class="active">{{ assignment_id }}</li>
+</ol>
+{%- endblock -%}
+
+{%- block messages -%}
+<div class="panel-group" id="accordion" role="tablist" aria-multiselectable="true">
+  <div class="panel panel-default">
+    <div class="panel-heading" role="tab" id="headingOne">
+      <h4 class="panel-title">
+        <a class="collapsed" role="button" data-toggle="collapse" data-parent="#accordion" href="#collapseOne" aria-expanded="false" aria-controls="collapseOne">
+          Instructions (click to expand)
+        </a>
+      </h4>
+    </div>
+    <div id="collapseOne" class="panel-collapse collapse" role="tabpanel" aria-labelledby="headingOne">
+      <div class="panel-body">
+        <p>
+          <b>Note:</b> Here you can autograde individual students' submissions by
+          clicking on the autograde icons below. If you want to autograde
+          all submissions at once, you will need to do this via the
+          <a target="_blank" href="{{ base_url }}/terminals/1">command line</a>:
+        </p>
+        <p>
+        <pre>
+cd "{{ course_dir }}"
+nbgrader autograde "{{ assignment_id }}"</pre>
+        </p>
+      </div>
+    </div>
+  </div>
+</div>
+{%- endblock -%}
+
+{%- block table_header -%}
+<tr>
+  <th>Student Name</th>
+  <th class="text-center">Student ID</th>
+  <th class="text-center">Timestamp</th>
+  <th class="text-center">Status</th>
+  <th class="text-center">Score</th>
+  <th class="text-center no-sort">Autograde</th>
+</tr>
+{%- endblock -%}
+
+{%- block table_body -%}
+<tr>
+  <td>Loading, please wait...</td>
+  <td></td>
+  <td></td>
+  <td></td>
+  <td></td>
+  <td></td>
+</tr>
+{%- endblock -%}
diff --git a/nbgrader/server_extensions/formgrader/templates/notebook_submissions.tpl b/nbgrader/server_extensions/formgrader/templates/notebook_submissions.tpl
deleted file mode 100644
index b9714bda..00000000
--- a/nbgrader/server_extensions/formgrader/templates/notebook_submissions.tpl
+++ /dev/null
@@ -1,97 +0,0 @@
-{%- extends 'gradebook.tpl' -%}
-
-{%- block head -%}
-<script type="text/javascript">
-function toggle_name(on, index) {
-  var elem = $("#submission-" + index);
-  if (on) {
-    elem.find(".name-shown").show();
-    elem.find(".name-hidden").hide();
-  } else {
-    elem.find(".name-hidden").show();
-    elem.find(".name-shown").hide();
-  }
-}
-</script>
-{%- endblock -%}
-
-{%- block breadcrumb -%}
-<li><a href="{{ base_url }}/formgrader/assignments">Assignments</a></li>
-<li><a href="{{ base_url }}/formgrader/assignments/{{ assignment_id }}">{{ assignment_id }}</a></li>
-<li class="active">{{ notebook_id }}</li>
-{%- endblock -%}
-
-{%- block body -%}
-<div class="panel-body">
-  The following table lists all the student submissions for the
-  notebook "{{ notebook_id }}", which is part of the assignment "{{
-  assignment_id }}". By clicking on a submission id, you
-  can grade the submitted notebook.
-</div>
-{%- endblock -%}
-
-{%- block table -%}
-<thead>
-  <tr>
-    <th></th>
-    <th>Submission ID</th>
-    <th class="center">Overall Score</th>
-    <th class="center">Code Score</th>
-    <th class="center">Written Score</th>
-    <th class="center">Needs manual grade?</th>
-    <th class="center">Tests failed?</th>
-    <th class="center">Flagged?</th>
-  </tr>
-</thead>
-<tbody>
-  {%- for submission in submissions -%}
-  <tr id="submission-{{ submission.index + 1 }}">
-    <td data-order="{{ submission.index + 1 }}">
-      <span class="glyphicon glyphicon-eye-open name-hidden" aria-hidden="true" onclick="toggle_name(true, {{ submission.index + 1 }});"></span>
-      <span class="glyphicon glyphicon-eye-close name-shown" aria-hidden="true" onclick="toggle_name(false, {{ submission.index + 1 }});"></span>
-    </td>
-    <td data-order="{{ submission.index + 1 }}">
-      <a href="{{ base_url }}/formgrader/submissions/{{ submission.id }}" class="name-hidden">Submission #{{ submission.index + 1 }}</a>
-      <a href="{{ base_url }}/formgrader/submissions/{{ submission.id }}" class="name-shown">{{ submission.last_name }}, {{ submission.first_name }}</a>
-    </td>
-    <td data-order="{{ submission.score }}" class="center">
-      {{ submission.score | float | round(2) }} / {{ submission.max_score | float | round(2) }}
-    </td>
-    <td data-order="{{ submission.code_score }}" class="center">
-      {{ submission.code_score | float | round(2) }} / {{ submission.max_code_score | float | round(2) }}
-    </td>
-    <td data-order="{{ submission.written_score }}" class="center">
-      {{ submission.written_score | float | round(2) }} / {{ submission.max_written_score | float | round(2) }}
-    </td>
-    {%- if submission.needs_manual_grade -%}
-    <td data-search="needs manual grade" class="center">
-      <span class="glyphicon glyphicon-ok"></span>
-    </td>
-    {%- else -%}
-    <td data-search="" class="center">
-    </td>
-    {%- endif -%}
-    {%- if submission.failed_tests -%}
-    <td data-search="tests failed" class="center">
-      <span class="glyphicon glyphicon-ok"></span>
-    </td>
-    {%- else -%}
-    <td data-search="" class="center">
-    </td>
-    {%- endif -%}
-    {%- if submission.flagged -%}
-    <td data-search="flagged" class="center">
-      <span data-search="flagged" class="glyphicon glyphicon-flag"></span>
-    </td>
-    {%- else -%}
-    <td data-search="" class="center">
-    </td>
-    {%- endif -%}
-  </tr>
-  {%- endfor -%}
-</tbody>
-<script type="text/javascript">
-$('span.glyphicon.name-hidden').tooltip({title: "Show student name"});
-$('span.glyphicon.name-shown').tooltip({title: "Hide student name"});
-</script>
-{%- endblock -%}
diff --git a/nbgrader/server_extensions/formgrader/templates/student_assignments.tpl b/nbgrader/server_extensions/formgrader/templates/student_assignments.tpl
deleted file mode 100644
index 81bdfde5..00000000
--- a/nbgrader/server_extensions/formgrader/templates/student_assignments.tpl
+++ /dev/null
@@ -1,64 +0,0 @@
-{%- extends 'gradebook.tpl' -%}
-
-{%- block breadcrumb -%}
-<li><a href="{{ base_url }}/formgrader/students">Students</a></li>
-<li class="active">{{ student.id }}</li>
-{%- endblock -%}
-
-{%- block body -%}
-<div class="panel-body">
-  The following table lists the assignments turned in by {{ student.last_name }}, {{ student.first_name }}. Click on a notebook
-  name to see the scores for individual notebooks.
-</div>
-{%- endblock -%}
-
-{%- block table -%}
-<thead>
-  <tr>
-    <th>Assignment ID</th>
-    <th class="center">Overall Score</th>
-    <th class="center">Code Score</th>
-    <th class="center">Written Score</th>
-    <th class="center">Needs manual grade?</th>
-  </tr>
-</thead>
-<tbody>
-  {%- for assignment in assignments -%}
-  <tr>
-    {%- if assignment.id is none -%}
-    <td>{{ assignment.name }} (no submission)</td>
-    {%- else -%}
-    <td><a href="{{ base_url }}/formgrader/students/{{ student.id }}/{{ assignment.name }}">{{ assignment.name }}</a></td>
-    {%- endif -%}
-    {%- if assignment.max_score is greaterthan 0 -%}
-    <td data-order="{{ assignment.score / assignment.max_score | float | round(2) }}" class="center">
-    {%- else -%}
-    <td data-order="0.00" class="center">
-    {%- endif -%}
-      {{ assignment.score | float | round(2) }} / {{ assignment.max_score | float | round(2) }}
-    </td>
-    {%- if assignment.max_code_score is greaterthan 0 -%}
-    <td data-order="{{ assignment.code_score / assignment.max_code_score | float | round(2) }}" class="center">
-    {%- else -%}
-    <td data-order="0.00" class="center">
-    {%- endif -%}
-      {{ assignment.code_score | float | round(2) }} / {{ assignment.max_code_score | float | round(2) }}
-    </td>
-    {%- if assignment.max_written_score is greaterthan 0 -%}
-    <td data-order="{{ assignment.written_score / assignment.max_written_score | float | round(2) }}" class="center">
-    {%- else -%}
-    <td data-order="0.00" class="center">
-    {%- endif -%}
-      {{ assignment.written_score | float | round(2) }} / {{ assignment.max_written_score | float | round(2) }}
-    </td>
-    {%- if assignment.needs_manual_grade -%}
-    <td data-search="needs manual grade" class="center">
-      <span class="glyphicon glyphicon-ok"></span>
-    {%- else -%}
-    <td data-search="" class="center">
-    {%- endif -%}
-    </td>
-  </tr>
-  {%- endfor -%}
-</tbody>
-{%- endblock -%}
diff --git a/nbgrader/server_extensions/formgrader/templates/student_submissions.tpl b/nbgrader/server_extensions/formgrader/templates/student_submissions.tpl
deleted file mode 100644
index 567f288f..00000000
--- a/nbgrader/server_extensions/formgrader/templates/student_submissions.tpl
+++ /dev/null
@@ -1,81 +0,0 @@
-{%- extends 'gradebook.tpl' -%}
-
-{%- block breadcrumb -%}
-<li><a href="{{ base_url }}/formgrader/students">Students</a></li>
-<li><a href="{{ base_url }}/formgrader/students/{{ student.id }}">{{ student.id }}</a></li>
-<li class="active">{{ assignment_id }}</li>
-{%- endblock -%}
-
-{%- block body -%}
-<div class="panel-body">
-  The following table lists all the notebooks for the assignment "{{ assignment_id }}" by {{ student.last_name }}, {{ student.first_name }}.
-  You can grade a notebook by clicking on its ID.
-</div>
-{%- endblock -%}
-
-{%- block table -%}
-<thead>
-  <tr>
-    <th>Notebook ID</th>
-    <th class="center">Overall Score</th>
-    <th class="center">Code Score</th>
-    <th class="center">Written Score</th>
-    <th class="center">Needs manual grade?</th>
-    <th class="center">Tests failed?</th>
-    <th class="center">Flagged?</th>
-  </tr>
-</thead>
-<tbody>
-  {%- for submission in submissions -%}
-  <tr>
-    <td>
-      <a href="{{ base_url }}/formgrader/submissions/{{ submission.id }}">
-        {{ submission.name }}
-      </a>
-    </td>
-    {%- if submission.max_score is greaterthan 0 -%}
-    <td data-order="{{ submission.score / submission.max_score | float | round(2) }}" class="center">
-    {%- else -%}
-    <td data-order="0.00" class="center">
-    {%- endif -%}
-        {{ submission.score | float | round(2) }} / {{ submission.max_score | float | round(2) }}
-    </td>
-    {%- if submission.max_code_score is greaterthan 0 -%}
-    <td data-order="{{ submission.code_score / submission.max_code_score | float | round(2) }}" class="center">
-    {%- else -%}
-    <td data-order="0.00" class="center">
-    {%- endif -%}
-        {{ submission.code_score | float | round(2) }} / {{ submission.max_code_score | float | round(2) }}
-    </td>
-    {%- if submission.max_written_score is greaterthan 0 -%}
-    <td data-order="{{ submission.written_score / submission.max_written_score | float | round(2) }}" class="center">
-    {%- else -%}
-    <td data-order="0.00" class="center">
-    {%- endif -%}
-        {{ submission.written_score | float | round(2) }} / {{ submission.max_written_score | float | round(2) }}
-    </td>
-    {%- if submission.needs_manual_grade -%}
-    <td data-search="needs manual grade" class="center">
-      <span class="glyphicon glyphicon-ok"></span>
-    {%- else -%}
-    <td data-search="" class="center">
-    {%- endif -%}
-    </td>
-    {%- if submission.failed_tests -%}
-    <td data-search="tests failed" class="center">
-      <span class="glyphicon glyphicon-ok"></span>
-    {%- else -%}
-    <td data-search="" class="center">
-    {%- endif -%}
-    </td>
-    {%- if submission.flagged -%}
-    <td data-search="flagged" class="center">
-      <span class="glyphicon glyphicon-flag"></span>
-    {%- else -%}
-    <td data-search="" class="center">
-    {%- endif -%}
-    </td>
-  </tr>
-  {%- endfor -%}
-</tbody>
-{%- endblock -%}
diff --git a/nbgrader/server_extensions/formgrader/templates/students.tpl b/nbgrader/server_extensions/formgrader/templates/students.tpl
deleted file mode 100644
index 0f28a3c1..00000000
--- a/nbgrader/server_extensions/formgrader/templates/students.tpl
+++ /dev/null
@@ -1,37 +0,0 @@
-{%- extends 'gradebook.tpl' -%}
-
-{%- block breadcrumb -%}
-<li class="active">Students</li>
-{%- endblock -%}
-
-{%- block body -%}
-<div class="panel-body">
-  The following table lists all of the students in the class. Click on the name of a student
-  to see their grades on individual assignments.
-</div>
-{%- endblock -%}
-
-{%- block table -%}
-<thead>
-  <tr>
-    <th>Name</th>
-    <th class="center">Student ID</th>
-    <th class="center">Overall score</th>
-  </tr>
-</thead>
-<tbody>
-  {%- for student in students -%}
-  <tr>
-    <td><a href="{{ base_url }}/formgrader/students/{{ student.id }}">{{ student.last_name }}, {{ student.first_name }}</a></td>
-    <td class="center">{{ student.id }}
-    {%- if student.max_score is greaterthan 0 -%}
-    <td data-order="{{ student.score / student.max_score | float | round(2) }}" class="center">
-    {%- else -%}
-    <td data-order="0.00" class="center">
-    {%- endif -%}
-        {{ student.score | float | round(2) }} / {{ student.max_score | float | round(2) }}
-    </td>
-  </tr>
-  {%- endfor -%}
-</tbody>
-{%- endblock -%}
diff --git a/nbgrader/utils.py b/nbgrader/utils.py
index 6c632fc4..e03b497d 100644
--- a/nbgrader/utils.py
+++ b/nbgrader/utils.py
@@ -14,6 +14,7 @@ from setuptools.archive_util import unpack_tarfile
 from setuptools.archive_util import unpack_zipfile
 from contextlib import contextmanager
 from tornado.log import LogFormatter
+from dateutil.tz import gettz
 
 
 # pwd is for unix passwords only, so we shouldn't import it on
@@ -118,6 +119,17 @@ def parse_utc(ts):
     return ts
 
 
+def as_timezone(ts, timezone):
+    """Converts UTC timestamp ts to have timezone tz."""
+    if not timezone:
+        return ts
+    tz = gettz(timezone)
+    if tz:
+        return (ts + tz.utcoffset(ts)).replace(tzinfo=tz)
+    else:
+        return ts
+
+
 def check_mode(path, read=False, write=False, execute=False):
     """Can the current user can rwx the path."""
     mode = 0


Test patch:
diff --git a/nbgrader/tests/apps/test_api.py b/nbgrader/tests/apps/test_api.py
index 14e91743..75c748e4 100644
--- a/nbgrader/tests/apps/test_api.py
+++ b/nbgrader/tests/apps/test_api.py
@@ -8,7 +8,7 @@ from datetime import datetime
 
 from ...apps.api import NbGraderAPI
 from ...coursedir import CourseDirectory
-from ...utils import rmtree, get_username
+from ...utils import rmtree, get_username, parse_utc
 from .. import run_nbgrader
 from .base import BaseTestApp
 from .conftest import notwindows, windows
@@ -30,6 +30,11 @@ def api(request, course_dir, db, exchange):
 
 class TestNbGraderAPI(BaseTestApp):
 
+    if sys.platform == 'win32':
+        tz = "Coordinated Universal Time"
+    else:
+        tz = "UTC"
+
     def test_get_source_assignments(self, api, course_dir):
         assert api.get_source_assignments() == set([])
 
@@ -120,13 +125,17 @@ class TestNbGraderAPI(BaseTestApp):
             'average_code_score', 'average_score', 'average_written_score',
             'duedate', 'name', 'num_submissions', 'release_path', 'releaseable',
             'source_path', 'status', 'id', 'max_code_score', 'max_score',
-            'max_written_score'])
+            'max_written_score', 'display_duedate', 'duedate_timezone',
+            'duedate_notimezone'])
 
         default = {
             "average_code_score": 0,
             "average_score": 0,
             "average_written_score": 0,
             "duedate": None,
+            "display_duedate": None,
+            "duedate_timezone": "UTC",
+            "duedate_notimezone": None,
             "name": "ps1",
             "num_submissions": 0,
             "release_path": None,
@@ -172,6 +181,21 @@ class TestNbGraderAPI(BaseTestApp):
         target["max_written_score"] = 1
         assert a == target
 
+        # check that timestamps are handled correctly
+        with api.gradebook as gb:
+            assignment = gb.find_assignment("ps1")
+            assignment.duedate = parse_utc("2017-07-05 12:22:08 UTC")
+            gb.db.commit()
+
+        a = api.get_assignment("ps1")
+        default["duedate"] = "2017-07-05T12:22:08"
+        default["display_duedate"] = "2017-07-05 12:22:08 {}".format(self.tz)
+        default["duedate_notimezone"] = "2017-07-05T12:22:08"
+        assert a["duedate"] == default["duedate"]
+        assert a["display_duedate"] == default["display_duedate"]
+        assert a["duedate_notimezone"] == default["duedate_notimezone"]
+        assert a["duedate_timezone"] == default["duedate_timezone"]
+
         # check the values once the assignment has been released and unreleased
         if sys.platform != "win32":
             run_nbgrader(["release", "ps1", "--course", "abc101", "--Exchange.root={}".format(exchange)])
@@ -251,8 +275,15 @@ class TestNbGraderAPI(BaseTestApp):
         assert set(n1.keys()) == keys
         assert n1 == default.copy()
 
+        # add it to the database (but don't assign yet)
+        with api.gradebook as gb:
+            gb.update_or_create_assignment("ps1")
+        n1, = api.get_notebooks("ps1")
+        assert set(n1.keys()) == keys
+        assert n1 == default.copy()
+
         # check values after nbgrader assign is run
-        run_nbgrader(["assign", "ps1", "--create", "--db", db])
+        run_nbgrader(["assign", "ps1", "--create", "--db", db, "--force"])
         n1, = api.get_notebooks("ps1")
         assert set(n1.keys()) == keys
         target = default.copy()
@@ -267,7 +298,7 @@ class TestNbGraderAPI(BaseTestApp):
             "id", "name", "student", "last_name", "first_name", "score",
             "max_score", "code_score", "max_code_score", "written_score",
             "max_written_score", "needs_manual_grade", "autograded",
-            "timestamp", "submitted"])
+            "timestamp", "submitted", "display_timestamp"])
 
         default = {
             "id": None,
@@ -284,6 +315,7 @@ class TestNbGraderAPI(BaseTestApp):
             "needs_manual_grade": False,
             "autograded": False,
             "timestamp": None,
+            "display_timestamp": None,
             "submitted": False
         }
 
@@ -294,13 +326,13 @@ class TestNbGraderAPI(BaseTestApp):
         run_nbgrader(["assign", "ps1", "--create", "--db", db])
 
         self._copy_file(join("files", "submitted-changed.ipynb"), join(course_dir, "submitted", "foo", "ps1", "p1.ipynb"))
-        timestamp = datetime.now()
-        self._make_file(join(course_dir, "submitted", "foo", "ps1", "timestamp.txt"), contents=timestamp.isoformat())
+        self._make_file(join(course_dir, "submitted", "foo", "ps1", "timestamp.txt"), contents="2017-07-05T12:32:56.123456")
         s = api.get_submission("ps1", "foo")
         assert set(s.keys()) == keys
         target = default.copy()
         target["submitted"] = True
-        target["timestamp"] = timestamp.isoformat()
+        target["timestamp"] = "2017-07-05T12:32:56.123456"
+        target["display_timestamp"] = "2017-07-05 12:32:56 {}".format(self.tz)
         assert s == target
 
         run_nbgrader(["autograde", "ps1", "--create", "--no-execute", "--force", "--db", db])
@@ -309,7 +341,8 @@ class TestNbGraderAPI(BaseTestApp):
         target["id"] = s["id"]
         target["autograded"] = True
         target["submitted"] = True
-        target["timestamp"] = timestamp.isoformat()
+        target["timestamp"] = "2017-07-05T12:32:56.123456"
+        target["display_timestamp"] = "2017-07-05 12:32:56 {}".format(self.tz)
         target["code_score"] = 2
         target["max_code_score"] = 5
         target["score"] = 2
@@ -368,6 +401,8 @@ class TestNbGraderAPI(BaseTestApp):
             assert idx[notebooks[1].id] == 1
 
     def test_get_notebook_submissions(self, api, course_dir, db):
+        assert api.get_notebook_submissions("ps1", "p1") == []
+
         self._copy_file(join("files", "submitted-unchanged.ipynb"), join(course_dir, "source", "ps1", "p1.ipynb"))
         run_nbgrader(["assign", "ps1", "--create", "--db", db])
 
diff --git a/nbgrader/tests/nbextensions/conftest.py b/nbgrader/tests/nbextensions/conftest.py
index e762c7ad..03fb3523 100644
--- a/nbgrader/tests/nbextensions/conftest.py
+++ b/nbgrader/tests/nbextensions/conftest.py
@@ -92,8 +92,7 @@ def port():
     return nbserver_port
 
 
-@pytest.fixture(scope="module")
-def nbserver(request, port, tempdir, jupyter_config_dir, jupyter_data_dir, exchange, cache):
+def _make_nbserver(course_id, port, tempdir, jupyter_config_dir, jupyter_data_dir, exchange, cache):
     env = os.environ.copy()
     env['JUPYTER_CONFIG_DIR'] = jupyter_config_dir
     env['JUPYTER_DATA_DIR'] = jupyter_data_dir
@@ -123,14 +122,15 @@ def nbserver(request, port, tempdir, jupyter_config_dir, jupyter_data_dir, excha
                 """
                 c.Exchange.root = "{}"
                 c.Exchange.cache = "{}"
-                """.format(exchange, cache)
+                c.Exchange.course_id = "{}"
+                """.format(exchange, cache, course_id)
             ))
 
     kwargs = dict(env=env)
     if sys.platform == 'win32':
         kwargs['creationflags'] = sp.CREATE_NEW_PROCESS_GROUP
 
-    nbserver = sp.Popen([
+    server = sp.Popen([
         sys.executable, "-m", "jupyter", "notebook",
         "--no-browser",
         "--NotebookApp.token=''",  # Notebook >=4.3
@@ -140,35 +140,33 @@ def nbserver(request, port, tempdir, jupyter_config_dir, jupyter_data_dir, excha
     # wait for a few seconds to allow the notebook server to finish starting
     time.sleep(5)
 
-    def fin():
-        if sys.platform == 'win32':
-            nbserver.send_signal(signal.CTRL_BREAK_EVENT)
-        else:
-            nbserver.terminate()
+    return server
 
-        for i in range(10):
-            retcode = nbserver.poll()
-            if retcode is not None:
-                break
-            time.sleep(0.1)
 
-        if retcode is None:
-            print("couldn't shutdown notebook server, force killing it")
-            nbserver.kill()
+def _close_nbserver(server):
+    if sys.platform == 'win32':
+        server.send_signal(signal.CTRL_BREAK_EVENT)
+    else:
+        server.terminate()
 
-        nbserver.wait()
-        copy_coverage_files()
+    for _ in range(10):
+        retcode = server.poll()
+        if retcode is not None:
+            break
+        time.sleep(0.1)
 
-        # wait a short period of time for kernels to finish shutting down
-        time.sleep(1)
+    if retcode is None:
+        print("couldn't shutdown notebook server, force killing it")
+        server.kill()
 
-    request.addfinalizer(fin)
+    server.wait()
+    copy_coverage_files()
 
-    return nbserver
+    # wait a short period of time for kernels to finish shutting down
+    time.sleep(1)
 
 
-@pytest.fixture
-def browser(request, tempdir, nbserver):
+def _make_browser(tempdir):
     for filename in glob.glob(os.path.join(os.path.dirname(__file__), "files", "*.ipynb")):
         shutil.copy(filename, os.path.join(tempdir, os.path.basename(filename)))
 
@@ -184,21 +182,21 @@ def browser(request, tempdir, nbserver):
     browser.set_page_load_timeout(30)
     browser.set_script_timeout(30)
 
-    def fin():
-        console_messages = browser.get_log('browser')
-        if len(console_messages) > 0:
-            print("\n<-- CAPTURED JAVASCRIPT CONSOLE MESSAGES -->")
-            for message in console_messages:
-                print(message)
-            print("<------------------------------------------>")
-        browser.save_screenshot(os.path.join(os.path.dirname(__file__), 'selenium.screenshot.png'))
-        browser.service.process.send_signal(signal.SIGTERM)
-        browser.quit()
-
-    request.addfinalizer(fin)
     return browser
 
 
+def _close_browser(browser):
+    console_messages = browser.get_log('browser')
+    if len(console_messages) > 0:
+        print("\n<-- CAPTURED JAVASCRIPT CONSOLE MESSAGES -->")
+        for message in console_messages:
+            print(message)
+        print("<------------------------------------------>")
+    browser.save_screenshot(os.path.join(os.path.dirname(__file__), 'selenium.screenshot.png'))
+    browser.service.process.send_signal(signal.SIGTERM)
+    browser.quit()
+
+
 notwindows = pytest.mark.skipif(
     sys.platform == 'win32',
     reason="Assignment List extension is not available on windows"
diff --git a/nbgrader/tests/nbextensions/formgrade_utils.py b/nbgrader/tests/nbextensions/formgrade_utils.py
index f25401f2..253994b0 100644
--- a/nbgrader/tests/nbextensions/formgrade_utils.py
+++ b/nbgrader/tests/nbextensions/formgrade_utils.py
@@ -3,7 +3,7 @@ from selenium.webdriver.common.keys import Keys
 from selenium.webdriver.support.ui import WebDriverWait
 from selenium.webdriver.support import expected_conditions as EC
 from selenium.webdriver.common.by import By
-from selenium.common.exceptions import TimeoutException
+from selenium.common.exceptions import TimeoutException, NoSuchElementException
 
 
 
@@ -15,6 +15,10 @@ def _notebook_url(port, url=""):
     return urljoin("http://localhost:{}/notebooks/".format(port), url).rstrip("/")
 
 
+def _tree_url(port, url=""):
+    return urljoin("http://localhost:{}/tree/".format(port), url).rstrip("/")
+
+
 def _check_url(browser, port, url):
     if not url.startswith("http"):
         url = _formgrade_url(port, url)
@@ -23,11 +27,11 @@ def _check_url(browser, port, url):
 
 def _check_breadcrumbs(browser, *breadcrumbs):
     # check that breadcrumbs are correct
-    elements = browser.find_elements_by_css_selector("ul.breadcrumb li")
+    elements = browser.find_elements_by_css_selector(".breadcrumb li")
     assert tuple([e.text for e in elements]) == breadcrumbs
 
     # check that the active breadcrumb is correct
-    element = browser.find_element_by_css_selector("ul.breadcrumb li.active")
+    element = browser.find_element_by_css_selector(".breadcrumb li.active")
     assert element.text == breadcrumbs[-1]
 
 
@@ -52,13 +56,16 @@ def _wait_for_visibility_of_element(browser, element_id, time=10):
 
 
 def _wait_for_gradebook_page(browser, port, url):
-    _wait_for_element(browser, "gradebook")
+    page_loaded = lambda browser: browser.execute_script(
+        """return typeof models !== "undefined" && models !== undefined && models.loaded === true;""")
+    WebDriverWait(browser, 10).until(page_loaded)
     _check_url(browser, port, url)
 
 
 def _get(browser, url, retries=5):
     try:
         browser.get(url)
+        assert browser.get_cookies()
     except TimeoutException:
         if retries == 0:
             raise
@@ -72,6 +79,11 @@ def _load_gradebook_page(browser, port, url):
     _wait_for_gradebook_page(browser, port, url)
 
 
+def _wait_for_tree_page(browser, port, url):
+    _wait_for_element(browser, "ipython-main-app")
+    _check_url(browser, port, url)
+
+
 def _wait_for_notebook_page(browser, port, url):
     _wait_for_element(browser, "notebook-container")
     _check_url(browser, port, url)
@@ -181,6 +193,15 @@ def _load_formgrade(browser, port, gradebook):
     submissions = problem.submissions
     submissions.sort(key=lambda x: x.id)
 
-    _load_gradebook_page(browser, port, "assignments/Problem Set 1/Problem 1")
+    _load_gradebook_page(browser, port, "gradebook/Problem Set 1/Problem 1")
     _click_link(browser, "Submission #1")
     _wait_for_formgrader(browser, port, "submissions/{}/?index=0".format(submissions[0].id))
+
+
+def _child_exists(elem, selector):
+    try:
+        elem.find_element_by_css_selector(selector)
+    except NoSuchElementException:
+        return False
+    else:
+        return True
diff --git a/nbgrader/tests/nbextensions/test_assignment_list.py b/nbgrader/tests/nbextensions/test_assignment_list.py
index 46aa89b1..6840656b 100644
--- a/nbgrader/tests/nbextensions/test_assignment_list.py
+++ b/nbgrader/tests/nbextensions/test_assignment_list.py
@@ -13,10 +13,32 @@ from nbformat.v4 import new_notebook
 from textwrap import dedent
 
 from .. import run_nbgrader
-from .conftest import notwindows
+from .conftest import notwindows, _make_nbserver, _make_browser, _close_nbserver, _close_browser
 from ...utils import rmtree
 
 
+@pytest.fixture(scope="module")
+def nbserver(request, port, tempdir, jupyter_config_dir, jupyter_data_dir, exchange, cache):
+    server = _make_nbserver("", port, tempdir, jupyter_config_dir, jupyter_data_dir, exchange, cache)
+
+    def fin():
+        _close_nbserver(server)
+    request.addfinalizer(fin)
+
+    return server
+
+
+@pytest.fixture
+def browser(request, tempdir, nbserver):
+    browser = _make_browser(tempdir)
+
+    def fin():
+        _close_browser(browser)
+    request.addfinalizer(fin)
+
+    return browser
+
+
 @pytest.fixture(scope="module")
 def class_files(coursedir):
     # copy files from the user guide
diff --git a/nbgrader/tests/nbextensions/test_create_assignment.py b/nbgrader/tests/nbextensions/test_create_assignment.py
index fe4fc2da..da9bbc34 100644
--- a/nbgrader/tests/nbextensions/test_create_assignment.py
+++ b/nbgrader/tests/nbextensions/test_create_assignment.py
@@ -9,9 +9,32 @@ from selenium.common.exceptions import TimeoutException, NoSuchElementException
 from textwrap import dedent
 
 from ...nbgraderformat import read
+from .conftest import _make_nbserver, _make_browser, _close_nbserver, _close_browser
 from nbformat import current_nbformat
 
 
+@pytest.fixture(scope="module")
+def nbserver(request, port, tempdir, jupyter_config_dir, jupyter_data_dir, exchange, cache):
+    server = _make_nbserver("", port, tempdir, jupyter_config_dir, jupyter_data_dir, exchange, cache)
+
+    def fin():
+        _close_nbserver(server)
+    request.addfinalizer(fin)
+
+    return server
+
+
+@pytest.fixture
+def browser(request, tempdir, nbserver):
+    browser = _make_browser(tempdir)
+
+    def fin():
+        _close_browser(browser)
+    request.addfinalizer(fin)
+
+    return browser
+
+
 def _wait(browser):
     return WebDriverWait(browser, 30)
 
diff --git a/nbgrader/tests/nbextensions/test_formgrader.py b/nbgrader/tests/nbextensions/test_formgrader.py
index eeeec21a..6f5d4feb 100644
--- a/nbgrader/tests/nbextensions/test_formgrader.py
+++ b/nbgrader/tests/nbextensions/test_formgrader.py
@@ -1,6 +1,10 @@
 import pytest
 import os
 import shutil
+import sys
+import glob
+
+from os.path import join
 
 from selenium.webdriver.common.keys import Keys
 from selenium.webdriver.support.ui import WebDriverWait
@@ -10,40 +14,74 @@ from selenium.webdriver.common.by import By
 from .. import run_nbgrader
 from ...api import Gradebook, MissingEntry
 from . import formgrade_utils as utils
+from .conftest import notwindows, _make_nbserver, _make_browser, _close_nbserver, _close_browser
+from ...utils import rmtree
+
+
+if sys.platform == 'win32':
+    tz = "Coordinated Universal Time"
+else:
+    tz = "UTC"
+
+
+@pytest.fixture(scope="module")
+def nbserver(request, port, tempdir, jupyter_config_dir, jupyter_data_dir, exchange, cache):
+    server = _make_nbserver("course101", port, tempdir, jupyter_config_dir, jupyter_data_dir, exchange, cache)
+
+    def fin():
+        _close_nbserver(server)
+    request.addfinalizer(fin)
+
+    return server
+
+
+@pytest.fixture
+def browser(request, tempdir, nbserver):
+    browser = _make_browser(tempdir)
+
+    def fin():
+        _close_browser(browser)
+    request.addfinalizer(fin)
+
+    return browser
 
 
 @pytest.fixture(scope="module")
 def gradebook(request, tempdir, nbserver):
     # copy files from the user guide
-    source_path = os.path.join(os.path.dirname(__file__), "..", "..", "docs", "source", "user_guide", "source")
-    submitted_path = os.path.join(os.path.dirname(__file__), "..", "..", "docs", "source", "user_guide", "submitted")
+    source_path = join(os.path.dirname(__file__), "..", "..", "docs", "source", "user_guide", "source")
+    submitted_path = join(os.path.dirname(__file__), "..", "..", "docs", "source", "user_guide", "submitted")
 
-    shutil.copytree(os.path.join(os.path.dirname(__file__), source_path), os.path.join("source"))
-    shutil.copytree(os.path.join(os.path.dirname(__file__), submitted_path), os.path.join("submitted"))
+    shutil.copytree(source_path, "source")
+    for student in ["bitdiddle", "hacker"]:
+        shutil.copytree(join(submitted_path, student), join("submitted", student))
 
     # rename to old names -- we do this rather than changing all the tests
     # because I want the tests to operate on files with spaces in the names
-    os.rename(os.path.join("source", "ps1"), os.path.join("source", "Problem Set 1"))
-    os.rename(os.path.join("source", "Problem Set 1", "problem1.ipynb"), os.path.join("source", "Problem Set 1", "Problem 1.ipynb"))
-    os.rename(os.path.join("source", "Problem Set 1", "problem2.ipynb"), os.path.join("source", "Problem Set 1", "Problem 2.ipynb"))
-    os.rename(os.path.join("submitted", "bitdiddle"), os.path.join("submitted", "Bitdiddle"))
-    os.rename(os.path.join("submitted", "Bitdiddle", "ps1"), os.path.join("submitted", "Bitdiddle", "Problem Set 1"))
-    os.rename(os.path.join("submitted", "Bitdiddle", "Problem Set 1", "problem1.ipynb"), os.path.join("submitted", "Bitdiddle", "Problem Set 1", "Problem 1.ipynb"))
-    os.rename(os.path.join("submitted", "Bitdiddle", "Problem Set 1", "problem2.ipynb"), os.path.join("submitted", "Bitdiddle", "Problem Set 1", "Problem 2.ipynb"))
-    os.rename(os.path.join("submitted", "hacker"), os.path.join("submitted", "Hacker"))
-    os.rename(os.path.join("submitted", "Hacker", "ps1"), os.path.join("submitted", "Hacker", "Problem Set 1"))
-    os.rename(os.path.join("submitted", "Hacker", "Problem Set 1", "problem1.ipynb"), os.path.join("submitted", "Hacker", "Problem Set 1", "Problem 1.ipynb"))
-    os.rename(os.path.join("submitted", "Hacker", "Problem Set 1", "problem2.ipynb"), os.path.join("submitted", "Hacker", "Problem Set 1", "Problem 2.ipynb"))
+    os.rename(join("source", "ps1"), join("source", "Problem Set 1"))
+    os.rename(join("source", "Problem Set 1", "problem1.ipynb"), join("source", "Problem Set 1", "Problem 1.ipynb"))
+    os.rename(join("source", "Problem Set 1", "problem2.ipynb"), join("source", "Problem Set 1", "Problem 2.ipynb"))
+    os.rename(join("submitted", "bitdiddle"), join("submitted", "Bitdiddle"))
+    os.rename(join("submitted", "Bitdiddle", "ps1"), join("submitted", "Bitdiddle", "Problem Set 1"))
+    os.rename(join("submitted", "Bitdiddle", "Problem Set 1", "problem1.ipynb"), join("submitted", "Bitdiddle", "Problem Set 1", "Problem 1.ipynb"))
+    os.rename(join("submitted", "Bitdiddle", "Problem Set 1", "problem2.ipynb"), join("submitted", "Bitdiddle", "Problem Set 1", "Problem 2.ipynb"))
+    os.rename(join("submitted", "hacker"), join("submitted", "Hacker"))
+    os.rename(join("submitted", "Hacker", "ps1"), join("submitted", "Hacker", "Problem Set 1"))
+    os.rename(join("submitted", "Hacker", "Problem Set 1", "problem1.ipynb"), join("submitted", "Hacker", "Problem Set 1", "Problem 1.ipynb"))
+    os.rename(join("submitted", "Hacker", "Problem Set 1", "problem2.ipynb"), join("submitted", "Hacker", "Problem Set 1", "Problem 2.ipynb"))
 
     # run nbgrader assign
     run_nbgrader([
         "assign", "Problem Set 1",
-        "--IncludeHeaderFooter.header={}".format(os.path.join("source", "header.ipynb"))
+        "--IncludeHeaderFooter.header={}".format(join("source", "header.ipynb"))
     ])
 
     # run the autograder
     run_nbgrader(["autograde", "Problem Set 1"])
 
+    # make sure louis is in the database (won't get added because he hasn't submitted anything!)
+    run_nbgrader(["db", "student", "add", "Reasoner", "--first-name", "Louis", "--last-name", "R"])
+
     gb = Gradebook("sqlite:///gradebook.db")
 
     def fin():
@@ -54,52 +92,100 @@ def gradebook(request, tempdir, nbserver):
 
 
 @pytest.mark.nbextensions
-def test_load_assignment_list(browser, port, gradebook):
+def test_load_manage_assignments(browser, port, gradebook):
     # load the main page and make sure it is the Assignments page
     utils._get(browser, utils._formgrade_url(port))
     utils._wait_for_gradebook_page(browser, port, "")
     utils._check_breadcrumbs(browser, "Assignments")
 
-    # load the assignments page
-    utils._load_gradebook_page(browser, port, "assignments")
-    utils._check_breadcrumbs(browser, "Assignments")
-
     # click on the "Problem Set 1" link
     utils._click_link(browser, "Problem Set 1")
-    utils._wait_for_gradebook_page(browser, port, "assignments/Problem Set 1")
+    browser.switch_to_window(browser.window_handles[1])
+    utils._wait_for_tree_page(
+        browser, port,
+        utils._tree_url(port, "source/Problem Set 1"))
+    browser.close()
+    browser.switch_to_window(browser.window_handles[0])
+
+    # click on the preview link
+    browser.find_element_by_css_selector("td.preview .glyphicon").click()
+    browser.switch_to_window(browser.window_handles[1])
+    utils._wait_for_tree_page(
+        browser, port,
+        utils._tree_url(port, "release/Problem Set 1"))
+    browser.close()
+    browser.switch_to_window(browser.window_handles[0])
+
+    # click on the number of submissions
+    browser.find_element_by_css_selector("td.num-submissions a").click()
+    utils._wait_for_gradebook_page(browser, port, "manage_submissions/Problem Set 1")
 
 
 @pytest.mark.nbextensions
-def test_load_assignment_notebook_list(browser, port, gradebook):
-    utils._load_gradebook_page(browser, port, "assignments/Problem Set 1")
+def test_load_manage_submissions(browser, port, gradebook):
+    # load the submissions page
+    utils._load_gradebook_page(browser, port, "manage_submissions/Problem Set 1")
     utils._check_breadcrumbs(browser, "Assignments", "Problem Set 1")
 
-    # click the "Assignments" link
+    # click on the "Assignments" link
     utils._click_link(browser, "Assignments")
-    utils._wait_for_gradebook_page(browser, port, "assignments")
+    utils._wait_for_gradebook_page(browser, port, "manage_assignments")
+    browser.back()
+
+    # click on students
+    for student in gradebook.students:
+        try:
+            gradebook.find_submission("Problem Set 1", student.id)
+        except MissingEntry:
+            continue
+
+        utils._click_link(browser, "{}, {}".format(student.last_name, student.first_name))
+        utils._wait_for_gradebook_page(browser, port, "manage_students/{}/Problem Set 1".format(student.id))
+        browser.back()
+
+
+@pytest.mark.nbextensions
+def test_load_gradebook1(browser, port, gradebook):
+    # load the assignments page
+    utils._load_gradebook_page(browser, port, "gradebook")
+    utils._check_breadcrumbs(browser, "Manual Grading")
+
+    # click on the "Problem Set 1" link
+    utils._click_link(browser, "Problem Set 1")
+    utils._wait_for_gradebook_page(browser, port, "gradebook/Problem Set 1")
+
+
+@pytest.mark.nbextensions
+def test_load_gradebook2(browser, port, gradebook):
+    utils._load_gradebook_page(browser, port, "gradebook/Problem Set 1")
+    utils._check_breadcrumbs(browser, "Manual Grading", "Problem Set 1")
+
+    # click the "Manual Grading" link
+    utils._click_link(browser, "Manual Grading")
+    utils._wait_for_gradebook_page(browser, port, "gradebook")
     browser.back()
 
     # click on the problem link
     for problem in gradebook.find_assignment("Problem Set 1").notebooks:
         utils._click_link(browser, problem.name)
-        utils._wait_for_gradebook_page(browser, port, "assignments/Problem Set 1/{}".format(problem.name))
+        utils._wait_for_gradebook_page(browser, port, "gradebook/Problem Set 1/{}".format(problem.name))
         browser.back()
 
 
 @pytest.mark.nbextensions
-def test_load_assignment_notebook_submissions_list(browser, port, gradebook):
+def test_load_gradebook3(browser, port, gradebook):
     for problem in gradebook.find_assignment("Problem Set 1").notebooks:
-        utils._load_gradebook_page(browser, port, "assignments/Problem Set 1/{}".format(problem.name))
-        utils._check_breadcrumbs(browser, "Assignments", "Problem Set 1", problem.name)
+        utils._load_gradebook_page(browser, port, "gradebook/Problem Set 1/{}".format(problem.name))
+        utils._check_breadcrumbs(browser, "Manual Grading", "Problem Set 1", problem.name)
 
-        # click the "Assignments" link
-        utils._click_link(browser, "Assignments")
-        utils._wait_for_gradebook_page(browser, port, "assignments")
+        # click the "Manual Grading" link
+        utils._click_link(browser, "Manual Grading")
+        utils._wait_for_gradebook_page(browser, port, "gradebook")
         browser.back()
 
         # click the "Problem Set 1" link
         utils._click_link(browser, "Problem Set 1")
-        utils._wait_for_gradebook_page(browser, port, "assignments/Problem Set 1")
+        utils._wait_for_gradebook_page(browser, port, "gradebook/Problem Set 1")
         browser.back()
 
         submissions = problem.submissions
@@ -112,14 +198,14 @@ def test_load_assignment_notebook_submissions_list(browser, port, gradebook):
 
 
 @pytest.mark.nbextensions
-def test_assignment_notebook_submissions_show_hide_names(browser, port, gradebook):
+def test_gradebook3_show_hide_names(browser, port, gradebook):
     problem = gradebook.find_assignment("Problem Set 1").notebooks[0]
-    utils._load_gradebook_page(browser, port, "assignments/Problem Set 1/{}".format(problem.name))
+    utils._load_gradebook_page(browser, port, "gradebook/Problem Set 1/{}".format(problem.name))
     submissions = problem.submissions
     submissions.sort(key=lambda x: x.id)
     submission = submissions[0]
 
-    top_elem = browser.find_element_by_css_selector("#submission-1")
+    top_elem = browser.find_elements_by_css_selector("tbody tr")[0]
     col1, col2 = top_elem.find_elements_by_css_selector("td")[:2]
     hidden = col1.find_element_by_css_selector(".glyphicon.name-hidden")
     shown = col1.find_element_by_css_selector(".glyphicon.name-shown")
@@ -147,47 +233,41 @@ def test_assignment_notebook_submissions_show_hide_names(browser, port, gradeboo
 
 
 @pytest.mark.nbextensions
-def test_load_student_list(browser, port, gradebook):
+def test_load_student1(browser, port, gradebook):
     # load the student view
-    utils._load_gradebook_page(browser, port, "students")
+    utils._load_gradebook_page(browser, port, "manage_students")
     utils._check_breadcrumbs(browser, "Students")
 
     # click on student
     for student in gradebook.students:
-        ## TODO: they should have a link here, even if they haven't submitted anything!
-        if len(student.submissions) == 0:
-            continue
         utils._click_link(browser, "{}, {}".format(student.last_name, student.first_name))
-        utils._wait_for_gradebook_page(browser, port, "students/{}".format(student.id))
+        utils._wait_for_gradebook_page(browser, port, "manage_students/{}".format(student.id))
         browser.back()
 
 
 @pytest.mark.nbextensions
-def test_load_student_assignment_list(browser, port, gradebook):
+def test_load_student2(browser, port, gradebook):
     for student in gradebook.students:
-        utils._load_gradebook_page(browser, port, "students/{}".format(student.id))
+        utils._load_gradebook_page(browser, port, "manage_students/{}".format(student.id))
         utils._check_breadcrumbs(browser, "Students", student.id)
-
         try:
-            gradebook.find_submission("Problem Set 1", student.id)
+            submission = gradebook.find_submission("Problem Set 1", student.id)
         except MissingEntry:
-            ## TODO: make sure link doesn't exist
             continue
 
         utils._click_link(browser, "Problem Set 1")
-        utils._wait_for_gradebook_page(browser, port, "students/{}/Problem Set 1".format(student.id))
+        utils._wait_for_gradebook_page(browser, port, "manage_students/{}/Problem Set 1".format(student.id))
 
 
 @pytest.mark.nbextensions
-def test_load_student_assignment_submissions_list(browser, port, gradebook):
+def test_load_student3(browser, port, gradebook):
     for student in gradebook.students:
         try:
             submission = gradebook.find_submission("Problem Set 1", student.id)
         except MissingEntry:
-            ## TODO: make sure link doesn't exist
             continue
 
-        utils._load_gradebook_page(browser, port, "students/{}/Problem Set 1".format(student.id))
+        utils._load_gradebook_page(browser, port, "manage_students/{}/Problem Set 1".format(student.id))
         utils._check_breadcrumbs(browser, "Students", student.id, "Problem Set 1")
 
         for problem in gradebook.find_assignment("Problem Set 1").notebooks:
@@ -195,27 +275,24 @@ def test_load_student_assignment_submissions_list(browser, port, gradebook):
             utils._click_link(browser, problem.name)
             utils._wait_for_formgrader(browser, port, "submissions/{}/?index=0".format(submission.id))
             browser.back()
-            utils._wait_for_gradebook_page(browser, port, "students/{}/Problem Set 1".format(student.id))
+            utils._wait_for_gradebook_page(browser, port, "manage_students/{}/Problem Set 1".format(student.id))
 
 
 @pytest.mark.nbextensions
 def test_switch_views(browser, port, gradebook):
-    # load the main page
-    utils._load_gradebook_page(browser, port, "assignments")
-
-    # click the "Change View" button
-    utils._click_link(browser, "Change View", partial=True)
-
-    # click the "Students" option
-    utils._click_link(browser, "Students")
-    utils._wait_for_gradebook_page(browser, port, "students")
-
-    # click the "Change View" button
-    utils._click_link(browser, "Change View", partial=True)
+    pages = ["", "manage_assignments", "gradebook", "manage_students"]
+    links = [
+        ("Manage Assignments", "manage_assignments"),
+        ("Manual Grading", "gradebook"),
+        ("Manage Students", "manage_students")
+    ]
 
-    # click the "Assignments" option
-    utils._click_link(browser, "Assignments")
-    utils._wait_for_gradebook_page(browser, port, "assignments")
+    for page in pages:
+        utils._load_gradebook_page(browser, port, page)
+        for link, target in links:
+            utils._click_link(browser, link)
+            utils._wait_for_gradebook_page(browser, port, target)
+            browser.back()
 
 
 @pytest.mark.nbextensions
@@ -228,9 +305,9 @@ def test_formgrade_view_breadcrumbs(browser, port, gradebook):
             utils._get(browser, utils._formgrade_url(port, "submissions/{}".format(submission.id)))
             utils._wait_for_formgrader(browser, port, "submissions/{}/?index=0".format(submission.id))
 
-            # click on the "Assignments" link
-            utils._click_link(browser, "Assignments")
-            utils._wait_for_gradebook_page(browser, port, "assignments")
+            # click on the "Manual Grading" link
+            utils._click_link(browser, "Manual Grading")
+            utils._wait_for_gradebook_page(browser, port, "gradebook")
 
             # go back
             browser.back()
@@ -238,7 +315,7 @@ def test_formgrade_view_breadcrumbs(browser, port, gradebook):
 
             # click on the "Problem Set 1" link
             utils._click_link(browser, "Problem Set 1")
-            utils._wait_for_gradebook_page(browser, port, "assignments/Problem Set 1")
+            utils._wait_for_gradebook_page(browser, port, "gradebook/Problem Set 1")
 
             # go back
             browser.back()
@@ -246,7 +323,7 @@ def test_formgrade_view_breadcrumbs(browser, port, gradebook):
 
             # click on the problem link
             utils._click_link(browser, problem.name)
-            utils._wait_for_gradebook_page(browser, port, "assignments/Problem Set 1/{}".format(problem.name))
+            utils._wait_for_gradebook_page(browser, port, "gradebook/Problem Set 1/{}".format(problem.name))
 
             # go back
             browser.back()
@@ -323,7 +400,7 @@ def test_next_prev_assignments(browser, port, gradebook):
 
         # Move to the next submission (should return to notebook list)
         next_function()
-        utils._wait_for_gradebook_page(browser, port, "assignments/Problem Set 1/Problem 1")
+        utils._wait_for_gradebook_page(browser, port, "gradebook/Problem Set 1/Problem 1")
 
         # Go back
         browser.back()
@@ -335,7 +412,7 @@ def test_next_prev_assignments(browser, port, gradebook):
 
         # Move to the previous submission (should return to the notebook list)
         prev_function()
-        utils._wait_for_gradebook_page(browser, port, "assignments/Problem Set 1/Problem 1")
+        utils._wait_for_gradebook_page(browser, port, "gradebook/Problem Set 1/Problem 1")
 
 
 @pytest.mark.nbextensions
@@ -359,7 +436,7 @@ def test_next_prev_failed_assignments(browser, port, gradebook):
     if submissions[0].failed_tests:
         # Go to the next failed submission (should return to the notebook list)
         utils._send_keys_to_body(browser, Keys.CONTROL, Keys.SHIFT, ".")
-        utils._wait_for_gradebook_page(browser, port, "assignments/Problem Set 1/Problem 1")
+        utils._wait_for_gradebook_page(browser, port, "gradebook/Problem Set 1/Problem 1")
 
         # Go back
         browser.back()
@@ -367,7 +444,7 @@ def test_next_prev_failed_assignments(browser, port, gradebook):
 
         # Go to the previous failed submission (should return to the notebook list)
         utils._send_keys_to_body(browser, Keys.CONTROL, Keys.SHIFT, ",")
-        utils._wait_for_gradebook_page(browser, port, "assignments/Problem Set 1/Problem 1")
+        utils._wait_for_gradebook_page(browser, port, "gradebook/Problem Set 1/Problem 1")
 
         # Go back
         browser.back()
@@ -379,7 +456,7 @@ def test_next_prev_failed_assignments(browser, port, gradebook):
 
         # Go to the next failed submission (should return to the notebook list)
         utils._send_keys_to_body(browser, Keys.CONTROL, Keys.SHIFT, ".")
-        utils._wait_for_gradebook_page(browser, port, "assignments/Problem Set 1/Problem 1")
+        utils._wait_for_gradebook_page(browser, port, "gradebook/Problem Set 1/Problem 1")
 
         # Go back
         browser.back()
@@ -400,7 +477,7 @@ def test_next_prev_failed_assignments(browser, port, gradebook):
 
         # Go to the previous failed submission (should return to the notebook list)
         utils._send_keys_to_body(browser, Keys.CONTROL, Keys.SHIFT, ",")
-        utils._wait_for_gradebook_page(browser, port, "assignments/Problem Set 1/Problem 1")
+        utils._wait_for_gradebook_page(browser, port, "gradebook/Problem Set 1/Problem 1")
 
         # Go back
         browser.back()
@@ -412,7 +489,7 @@ def test_next_prev_failed_assignments(browser, port, gradebook):
 
         # Go to the next failed submission (should return to the notebook list)
         utils._send_keys_to_body(browser, Keys.CONTROL, Keys.SHIFT, ".")
-        utils._wait_for_gradebook_page(browser, port, "assignments/Problem Set 1/Problem 1")
+        utils._wait_for_gradebook_page(browser, port, "gradebook/Problem Set 1/Problem 1")
 
         # Go back
         browser.back()
@@ -420,7 +497,7 @@ def test_next_prev_failed_assignments(browser, port, gradebook):
 
         # Go to the previous failed submission (should return to the notebook list)
         utils._send_keys_to_body(browser, Keys.CONTROL, Keys.SHIFT, ",")
-        utils._wait_for_gradebook_page(browser, port, "assignments/Problem Set 1/Problem 1")
+        utils._wait_for_gradebook_page(browser, port, "gradebook/Problem Set 1/Problem 1")
 
 
 @pytest.mark.nbextensions
@@ -727,3 +804,487 @@ def test_formgrade_show_hide_names(browser, port, gradebook):
     assert name.text == "Submission #1"
     assert not shown.is_displayed()
     assert hidden.is_displayed()
+
+
+@pytest.mark.nbextensions
+def test_add_new_assignment(browser, port, gradebook):
+    utils._load_gradebook_page(browser, port, "")
+
+    # click the "add new assignment" button
+    utils._click_link(browser, "Add new assignment...")
+    utils._wait_for_element(browser, "add-assignment-modal")
+    WebDriverWait(browser, 10).until(EC.visibility_of_element_located((By.CSS_SELECTOR, "#add-assignment-modal button.btn-primary")))
+
+    # set the name and dudedate
+    elem = browser.find_element_by_css_selector("#add-assignment-modal .name")
+    elem.click()
+    elem.send_keys("ps2")
+    elem = browser.find_element_by_css_selector("#add-assignment-modal .duedate")
+    elem.click()
+    elem.send_keys("2017-07-05T17:00")
+    elem = browser.find_element_by_css_selector("#add-assignment-modal .timezone")
+    elem.click()
+    elem.send_keys("UTC")
+
+    # click save and wait for the modal to close
+    utils._click_element(browser, "#add-assignment-modal .save")
+    modal_not_present = lambda browser: browser.execute_script("""return $("#add-assignment-modal").length === 0;""")
+    WebDriverWait(browser, 10).until(modal_not_present)
+
+    # wait until both rows are present
+    rows_present = lambda browser: len(browser.find_elements_by_css_selector("tbody tr")) == 2
+    WebDriverWait(browser, 10).until(rows_present)
+
+    # check that the new row is correct
+    row = browser.find_elements_by_css_selector("tbody tr")[1]
+    assert row.find_element_by_css_selector(".name").text == "ps2"
+    assert row.find_element_by_css_selector(".duedate").text == "2017-07-05 17:00:00 {}".format(tz)
+    assert row.find_element_by_css_selector(".status").text == "draft"
+    assert utils._child_exists(row, ".edit a")
+    assert utils._child_exists(row, ".assign a")
+    assert not utils._child_exists(row, ".preview a")
+    assert not utils._child_exists(row, ".release a")
+    assert not utils._child_exists(row, ".collect a")
+    assert row.find_element_by_css_selector(".num-submissions").text == "0"
+
+    # reload the page and make sure everything is still correct
+    utils._load_gradebook_page(browser, port, "")
+    row = browser.find_elements_by_css_selector("tbody tr")[1]
+    assert row.find_element_by_css_selector(".name").text == "ps2"
+    assert row.find_element_by_css_selector(".duedate").text == "2017-07-05 17:00:00 {}".format(tz)
+    assert row.find_element_by_css_selector(".status").text == "draft"
+    assert utils._child_exists(row, ".edit a")
+    assert utils._child_exists(row, ".assign a")
+    assert not utils._child_exists(row, ".preview a")
+    assert not utils._child_exists(row, ".release a")
+    assert not utils._child_exists(row, ".collect a")
+    assert row.find_element_by_css_selector(".num-submissions").text == "0"
+
+
+@pytest.mark.nbextensions
+def test_edit_assignment(browser, port, gradebook):
+    utils._load_gradebook_page(browser, port, "")
+
+    # click on the edit button
+    row = browser.find_elements_by_css_selector("tbody tr")[1]
+    row.find_element_by_css_selector(".edit a").click()
+    utils._wait_for_element(browser, "edit-assignment-modal")
+    WebDriverWait(browser, 10).until(EC.visibility_of_element_located((By.CSS_SELECTOR, "#edit-assignment-modal button.btn-primary")))
+
+    # modify the duedate
+    elem = browser.find_element_by_css_selector("#edit-assignment-modal .modal-duedate")
+    elem.clear()
+    elem.click()
+    elem.send_keys("2017-07-05T18:00")
+
+    # click save and wait for the modal to close
+    utils._click_element(browser, "#edit-assignment-modal .save")
+    modal_not_present = lambda browser: browser.execute_script("""return $("#edit-assignment-modal").length === 0;""")
+    WebDriverWait(browser, 10).until(modal_not_present)
+
+    # check that the modified row is correct
+    row = browser.find_elements_by_css_selector("tbody tr")[1]
+    assert row.find_element_by_css_selector(".name").text == "ps2"
+    assert row.find_element_by_css_selector(".duedate").text == "2017-07-05 18:00:00 {}".format(tz)
+    assert row.find_element_by_css_selector(".status").text == "draft"
+    assert utils._child_exists(row, ".edit a")
+    assert utils._child_exists(row, ".assign a")
+    assert not utils._child_exists(row, ".preview a")
+    assert not utils._child_exists(row, ".release a")
+    assert not utils._child_exists(row, ".collect a")
+    assert row.find_element_by_css_selector(".num-submissions").text == "0"
+
+    # reload the page and make sure everything is still correct
+    utils._load_gradebook_page(browser, port, "")
+    row = browser.find_elements_by_css_selector("tbody tr")[1]
+    assert row.find_element_by_css_selector(".name").text == "ps2"
+    assert row.find_element_by_css_selector(".duedate").text == "2017-07-05 18:00:00 {}".format(tz)
+    assert row.find_element_by_css_selector(".status").text == "draft"
+    assert utils._child_exists(row, ".edit a")
+    assert utils._child_exists(row, ".assign a")
+    assert not utils._child_exists(row, ".preview a")
+    assert not utils._child_exists(row, ".release a")
+    assert not utils._child_exists(row, ".collect a")
+    assert row.find_element_by_css_selector(".num-submissions").text == "0"
+
+
+@pytest.mark.nbextensions
+def test_generate_assignment_fail(browser, port, gradebook):
+    utils._load_gradebook_page(browser, port, "")
+
+    # click on the generate button -- should produce an error because there
+    # are no notebooks for ps2 yet
+    row = browser.find_elements_by_css_selector("tbody tr")[1]
+    row.find_element_by_css_selector(".assign a").click()
+    utils._wait_for_element(browser, "error-modal")
+    WebDriverWait(browser, 10).until(EC.visibility_of_element_located((By.CSS_SELECTOR, "#error-modal button.btn-primary")))
+    utils._click_element(browser, "#error-modal .close")
+    modal_not_present = lambda browser: browser.execute_script("""return $("#error-modal").length === 0;""")
+    WebDriverWait(browser, 10).until(modal_not_present)
+
+
+@pytest.mark.nbextensions
+def test_generate_assignment_success(browser, port, gradebook):
+    utils._load_gradebook_page(browser, port, "")
+
+    # add a notebook for ps2
+    source_path = join(os.path.dirname(__file__), "..", "..", "docs", "source", "user_guide", "source", "ps1", "problem1.ipynb")
+    shutil.copy(source_path, join("source", "ps2", "Problem 1.ipynb"))
+
+    # click on the generate button -- should now succeed
+    row = browser.find_elements_by_css_selector("tbody tr")[1]
+    row.find_element_by_css_selector(".assign a").click()
+    utils._wait_for_element(browser, "success-modal")
+    WebDriverWait(browser, 10).until(EC.visibility_of_element_located((By.CSS_SELECTOR, "#success-modal button.btn-primary")))
+    utils._click_element(browser, "#success-modal .close")
+    modal_not_present = lambda browser: browser.execute_script("""return $("#success-modal").length === 0;""")
+    WebDriverWait(browser, 10).until(modal_not_present)
+
+    # check that the modified row is correct
+    row = browser.find_elements_by_css_selector("tbody tr")[1]
+    assert row.find_element_by_css_selector(".name").text == "ps2"
+    assert row.find_element_by_css_selector(".duedate").text == "2017-07-05 18:00:00 {}".format(tz)
+    assert row.find_element_by_css_selector(".status").text == "draft"
+    assert utils._child_exists(row, ".edit a")
+    assert utils._child_exists(row, ".assign a")
+    assert utils._child_exists(row, ".preview a")
+    if sys.platform == 'win32':
+        assert not utils._child_exists(row, ".release a")
+    else:
+        assert utils._child_exists(row, ".release a")
+    assert not utils._child_exists(row, ".collect a")
+    assert row.find_element_by_css_selector(".num-submissions").text == "0"
+
+    # reload the page and make sure everything is still correct
+    utils._load_gradebook_page(browser, port, "")
+    row = browser.find_elements_by_css_selector("tbody tr")[1]
+    assert row.find_element_by_css_selector(".name").text == "ps2"
+    assert row.find_element_by_css_selector(".duedate").text == "2017-07-05 18:00:00 {}".format(tz)
+    assert row.find_element_by_css_selector(".status").text == "draft"
+    assert utils._child_exists(row, ".edit a")
+    assert utils._child_exists(row, ".assign a")
+    assert utils._child_exists(row, ".preview a")
+    if sys.platform == 'win32':
+        assert not utils._child_exists(row, ".release a")
+    else:
+        assert utils._child_exists(row, ".release a")
+    assert not utils._child_exists(row, ".collect a")
+    assert row.find_element_by_css_selector(".num-submissions").text == "0"
+
+
+@notwindows
+@pytest.mark.nbextensions
+def test_release_assignment(browser, port, gradebook):
+    utils._load_gradebook_page(browser, port, "")
+
+    # click on the release button
+    row = browser.find_elements_by_css_selector("tbody tr")[1]
+    row.find_element_by_css_selector(".release a").click()
+    utils._wait_for_element(browser, "success-modal")
+    WebDriverWait(browser, 10).until(EC.visibility_of_element_located((By.CSS_SELECTOR, "#success-modal button.btn-primary")))
+    utils._click_element(browser, "#success-modal .close")
+    modal_not_present = lambda browser: browser.execute_script("""return $("#success-modal").length === 0;""")
+    WebDriverWait(browser, 10).until(modal_not_present)
+
+    # check that the modified row is correct
+    row = browser.find_elements_by_css_selector("tbody tr")[1]
+    assert row.find_element_by_css_selector(".name").text == "ps2"
+    assert row.find_element_by_css_selector(".duedate").text == "2017-07-05 18:00:00 {}".format(tz)
+    assert row.find_element_by_css_selector(".status").text == "released"
+    assert utils._child_exists(row, ".edit a")
+    assert utils._child_exists(row, ".assign a")
+    assert utils._child_exists(row, ".preview a")
+    assert utils._child_exists(row, ".release a")
+    assert utils._child_exists(row, ".collect a")
+    assert row.find_element_by_css_selector(".num-submissions").text == "0"
+
+    # reload the page and make sure everything is still correct
+    utils._load_gradebook_page(browser, port, "")
+    row = browser.find_elements_by_css_selector("tbody tr")[1]
+    assert row.find_element_by_css_selector(".name").text == "ps2"
+    assert row.find_element_by_css_selector(".duedate").text == "2017-07-05 18:00:00 {}".format(tz)
+    assert row.find_element_by_css_selector(".status").text == "released"
+    assert utils._child_exists(row, ".edit a")
+    assert utils._child_exists(row, ".assign a")
+    assert utils._child_exists(row, ".preview a")
+    assert utils._child_exists(row, ".release a")
+    assert utils._child_exists(row, ".collect a")
+    assert row.find_element_by_css_selector(".num-submissions").text == "0"
+
+
+@notwindows
+@pytest.mark.nbextensions
+def test_collect_assignment(browser, port, gradebook):
+    run_nbgrader(["fetch", "ps2"])
+    run_nbgrader(["submit", "ps2"])
+    rmtree("ps2")
+
+    utils._load_gradebook_page(browser, port, "")
+
+    # click on the collect button
+    row = browser.find_elements_by_css_selector("tbody tr")[1]
+    row.find_element_by_css_selector(".collect a").click()
+    utils._wait_for_element(browser, "success-modal")
+    WebDriverWait(browser, 10).until(EC.visibility_of_element_located((By.CSS_SELECTOR, "#success-modal button.btn-primary")))
+    utils._click_element(browser, "#success-modal .close")
+    modal_not_present = lambda browser: browser.execute_script("""return $("#success-modal").length === 0;""")
+    WebDriverWait(browser, 10).until(modal_not_present)
+
+    # check that the modified row is correct
+    row = browser.find_elements_by_css_selector("tbody tr")[1]
+    assert row.find_element_by_css_selector(".name").text == "ps2"
+    assert row.find_element_by_css_selector(".duedate").text == "2017-07-05 18:00:00 {}".format(tz)
+    assert row.find_element_by_css_selector(".status").text == "released"
+    assert utils._child_exists(row, ".edit a")
+    assert utils._child_exists(row, ".assign a")
+    assert utils._child_exists(row, ".preview a")
+    assert utils._child_exists(row, ".release a")
+    assert utils._child_exists(row, ".collect a")
+    assert row.find_element_by_css_selector(".num-submissions").text == "1"
+
+    # reload the page and make sure everything is still correct
+    utils._load_gradebook_page(browser, port, "")
+    row = browser.find_elements_by_css_selector("tbody tr")[1]
+    assert row.find_element_by_css_selector(".name").text == "ps2"
+    assert row.find_element_by_css_selector(".duedate").text == "2017-07-05 18:00:00 {}".format(tz)
+    assert row.find_element_by_css_selector(".status").text == "released"
+    assert utils._child_exists(row, ".edit a")
+    assert utils._child_exists(row, ".assign a")
+    assert utils._child_exists(row, ".preview a")
+    assert utils._child_exists(row, ".release a")
+    assert utils._child_exists(row, ".collect a")
+    assert row.find_element_by_css_selector(".num-submissions").text == "1"
+
+
+@notwindows
+@pytest.mark.nbextensions
+def test_unrelease_assignment(browser, port, gradebook):
+    utils._load_gradebook_page(browser, port, "")
+
+    # click on the unrelease button
+    row = browser.find_elements_by_css_selector("tbody tr")[1]
+    row.find_element_by_css_selector(".release a").click()
+    utils._wait_for_element(browser, "success-modal")
+    WebDriverWait(browser, 10).until(EC.visibility_of_element_located((By.CSS_SELECTOR, "#success-modal button.btn-primary")))
+    utils._click_element(browser, "#success-modal .close")
+    modal_not_present = lambda browser: browser.execute_script("""return $("#success-modal").length === 0;""")
+    WebDriverWait(browser, 10).until(modal_not_present)
+
+    # check that the modified row is correct
+    row = browser.find_elements_by_css_selector("tbody tr")[1]
+    assert row.find_element_by_css_selector(".name").text == "ps2"
+    assert row.find_element_by_css_selector(".duedate").text == "2017-07-05 18:00:00 {}".format(tz)
+    assert row.find_element_by_css_selector(".status").text == "draft"
+    assert utils._child_exists(row, ".edit a")
+    assert utils._child_exists(row, ".assign a")
+    assert utils._child_exists(row, ".preview a")
+    assert utils._child_exists(row, ".release a")
+    assert not utils._child_exists(row, ".collect a")
+    assert row.find_element_by_css_selector(".num-submissions").text == "1"
+
+    # reload the page and make sure everything is still correct
+    utils._load_gradebook_page(browser, port, "")
+    row = browser.find_elements_by_css_selector("tbody tr")[1]
+    assert row.find_element_by_css_selector(".name").text == "ps2"
+    assert row.find_element_by_css_selector(".duedate").text == "2017-07-05 18:00:00 {}".format(tz)
+    assert row.find_element_by_css_selector(".status").text == "draft"
+    assert utils._child_exists(row, ".edit a")
+    assert utils._child_exists(row, ".assign a")
+    assert utils._child_exists(row, ".preview a")
+    assert utils._child_exists(row, ".release a")
+    assert not utils._child_exists(row, ".collect a")
+    assert row.find_element_by_css_selector(".num-submissions").text == "1"
+
+
+@pytest.mark.nbextensions
+def test_manually_collect_assignment(browser, port, gradebook):
+    existing_submissions = glob.glob(join("submitted", "*", "ps2"))
+    for dirname in existing_submissions:
+        rmtree(dirname)
+    dest = join("submitted", "Bitdiddle", "ps2")
+    if not os.path.exists(os.path.dirname(dest)):
+        os.makedirs(os.path.dirname(dest))
+    shutil.copytree(join("release", "ps2"), dest)
+    with open(join(dest, "timestamp.txt"), "w") as fh:
+        fh.write("2017-07-05 18:05:21 UTC")
+
+    utils._load_gradebook_page(browser, port, "")
+
+    # check that the row is correct
+    row = browser.find_elements_by_css_selector("tbody tr")[1]
+    assert row.find_element_by_css_selector(".name").text == "ps2"
+    assert row.find_element_by_css_selector(".duedate").text == "2017-07-05 18:00:00 {}".format(tz)
+    assert row.find_element_by_css_selector(".status").text == "draft"
+    assert utils._child_exists(row, ".edit a")
+    assert utils._child_exists(row, ".assign a")
+    assert utils._child_exists(row, ".preview a")
+    if sys.platform == 'win32':
+        assert not utils._child_exists(row, ".release a")
+    else:
+        assert utils._child_exists(row, ".release a")
+    assert not utils._child_exists(row, ".collect a")
+    assert row.find_element_by_css_selector(".num-submissions").text == "1"
+
+
+@pytest.mark.nbextensions
+def test_autograde_assignment(browser, port, gradebook):
+    utils._load_gradebook_page(browser, port, "manage_submissions/ps2")
+
+    # check the contents of the row before grading
+    row = browser.find_elements_by_css_selector("tbody tr")[0]
+    assert row.find_element_by_css_selector(".student-name").text == "B, Ben"
+    assert row.find_element_by_css_selector(".student-id").text == "Bitdiddle"
+    assert row.find_element_by_css_selector(".timestamp").text == "2017-07-05 18:05:21 {}".format(tz)
+    assert row.find_element_by_css_selector(".status").text == "needs autograding"
+    assert row.find_element_by_css_selector(".score").text == ""
+    assert utils._child_exists(row, ".autograde a")
+
+    # click on the autograde button
+    row = browser.find_elements_by_css_selector("tbody tr")[0]
+    row.find_element_by_css_selector(".autograde a").click()
+    utils._wait_for_element(browser, "success-modal")
+    WebDriverWait(browser, 10).until(EC.visibility_of_element_located((By.CSS_SELECTOR, "#success-modal button.btn-primary")))
+    utils._click_element(browser, "#success-modal .close")
+    modal_not_present = lambda browser: browser.execute_script("""return $("#success-modal").length === 0;""")
+    WebDriverWait(browser, 10).until(modal_not_present)
+
+    # check that the modified row is correct
+    row = browser.find_elements_by_css_selector("tbody tr")[0]
+    assert row.find_element_by_css_selector(".student-name").text == "B, Ben"
+    assert row.find_element_by_css_selector(".student-id").text == "Bitdiddle"
+    assert row.find_element_by_css_selector(".timestamp").text == "2017-07-05 18:05:21 {}".format(tz)
+    assert row.find_element_by_css_selector(".status").text == "graded"
+    assert row.find_element_by_css_selector(".score").text == "0 / 6"
+    assert utils._child_exists(row, ".autograde a")
+
+    # refresh and check again
+    utils._load_gradebook_page(browser, port, "manage_submissions/ps2")
+    row = browser.find_elements_by_css_selector("tbody tr")[0]
+    assert row.find_element_by_css_selector(".student-name").text == "B, Ben"
+    assert row.find_element_by_css_selector(".student-id").text == "Bitdiddle"
+    assert row.find_element_by_css_selector(".timestamp").text == "2017-07-05 18:05:21 {}".format(tz)
+    assert row.find_element_by_css_selector(".status").text == "graded"
+    assert row.find_element_by_css_selector(".score").text == "0 / 6"
+    assert utils._child_exists(row, ".autograde a")
+
+    # overwrite the file
+    source_path = join(os.path.dirname(__file__), "..", "..", "docs", "source", "user_guide", "source", "ps1", "problem1.ipynb")
+    shutil.copy(source_path, join("submitted", "Bitdiddle", "ps2", "Problem 1.ipynb"))
+
+    # click on the autograde button
+    row = browser.find_elements_by_css_selector("tbody tr")[0]
+    row.find_element_by_css_selector(".autograde a").click()
+    utils._wait_for_element(browser, "success-modal")
+    WebDriverWait(browser, 10).until(EC.visibility_of_element_located((By.CSS_SELECTOR, "#success-modal button.btn-primary")))
+    utils._click_element(browser, "#success-modal .close")
+    modal_not_present = lambda browser: browser.execute_script("""return $("#success-modal").length === 0;""")
+    WebDriverWait(browser, 10).until(modal_not_present)
+
+    # check that the modified row is correct
+    row = browser.find_elements_by_css_selector("tbody tr")[0]
+    assert row.find_element_by_css_selector(".student-name").text == "B, Ben"
+    assert row.find_element_by_css_selector(".student-id").text == "Bitdiddle"
+    assert row.find_element_by_css_selector(".timestamp").text == "2017-07-05 18:05:21 {}".format(tz)
+    assert row.find_element_by_css_selector(".status").text == "needs manual grading"
+    assert row.find_element_by_css_selector(".score").text == "3 / 6"
+    assert utils._child_exists(row, ".autograde a")
+
+    # refresh and check again
+    utils._load_gradebook_page(browser, port, "manage_submissions/ps2")
+    row = browser.find_elements_by_css_selector("tbody tr")[0]
+    assert row.find_element_by_css_selector(".student-name").text == "B, Ben"
+    assert row.find_element_by_css_selector(".student-id").text == "Bitdiddle"
+    assert row.find_element_by_css_selector(".timestamp").text == "2017-07-05 18:05:21 {}".format(tz)
+    assert row.find_element_by_css_selector(".status").text == "needs manual grading"
+    assert row.find_element_by_css_selector(".score").text == "3 / 6"
+    assert utils._child_exists(row, ".autograde a")
+
+
+@pytest.mark.nbextensions
+def test_add_new_student(browser, port, gradebook):
+    utils._load_gradebook_page(browser, port, "manage_students")
+    assert len(browser.find_elements_by_css_selector("tbody tr")) == 3
+
+    # click the "add new assignment" button
+    utils._click_link(browser, "Add new student...")
+    utils._wait_for_element(browser, "add-student-modal")
+    WebDriverWait(browser, 10).until(EC.visibility_of_element_located((By.CSS_SELECTOR, "#add-student-modal button.btn-primary")))
+
+    # set the name and dudedate
+    elem = browser.find_element_by_css_selector("#add-student-modal .id")
+    elem.click()
+    elem.send_keys("ator")
+    elem = browser.find_element_by_css_selector("#add-student-modal .first-name")
+    elem.click()
+    elem.send_keys("Eva Lou")
+    elem = browser.find_element_by_css_selector("#add-student-modal .last-name")
+    elem.click()
+    elem.send_keys("Ator")
+    elem = browser.find_element_by_css_selector("#add-student-modal .email")
+    elem.click()
+    elem.send_keys("ela@email.com")
+
+    # click save and wait for the modal to close
+    utils._click_element(browser, "#add-student-modal .save")
+    modal_not_present = lambda browser: browser.execute_script("""return $("#add-student-modal").length === 0;""")
+    WebDriverWait(browser, 10).until(modal_not_present)
+
+    # wait until both rows are present
+    rows_present = lambda browser: len(browser.find_elements_by_css_selector("tbody tr")) == 4
+    WebDriverWait(browser, 10).until(rows_present)
+
+    # check that the new row is correct
+    row = browser.find_elements_by_css_selector("tbody tr")[0]
+    assert row.find_element_by_css_selector(".name").text == "Ator, Eva Lou"
+    assert row.find_element_by_css_selector(".id").text == "ator"
+    assert row.find_element_by_css_selector(".email").text == "ela@email.com"
+    assert row.find_element_by_css_selector(".score").text == "0 / 15"
+    assert utils._child_exists(row, ".edit a")
+
+    # reload the page and make sure everything is still correct
+    utils._load_gradebook_page(browser, port, "manage_students")
+    row = browser.find_elements_by_css_selector("tbody tr")[0]
+    assert row.find_element_by_css_selector(".name").text == "Ator, Eva Lou"
+    assert row.find_element_by_css_selector(".id").text == "ator"
+    assert row.find_element_by_css_selector(".email").text == "ela@email.com"
+    assert row.find_element_by_css_selector(".score").text == "0 / 15"
+    assert utils._child_exists(row, ".edit a")
+
+
+@pytest.mark.nbextensions
+def test_edit_student(browser, port, gradebook):
+    utils._load_gradebook_page(browser, port, "manage_students")
+
+    # click on the edit button
+    row = browser.find_elements_by_css_selector("tbody tr")[0]
+    row.find_element_by_css_selector(".edit a").click()
+    utils._wait_for_element(browser, "edit-student-modal")
+    WebDriverWait(browser, 10).until(EC.visibility_of_element_located((By.CSS_SELECTOR, "#edit-student-modal button.btn-primary")))
+
+    # modify the duedate
+    elem = browser.find_element_by_css_selector("#edit-student-modal .modal-email")
+    elem.clear()
+    elem.click()
+    elem.send_keys("ela@email.net")
+
+    # click save and wait for the modal to close
+    utils._click_element(browser, "#edit-student-modal .save")
+    modal_not_present = lambda browser: browser.execute_script("""return $("#edit-student-modal").length === 0;""")
+    WebDriverWait(browser, 10).until(modal_not_present)
+
+    # check that the modified row is correct
+    row = browser.find_elements_by_css_selector("tbody tr")[0]
+    assert row.find_element_by_css_selector(".name").text == "Ator, Eva Lou"
+    assert row.find_element_by_css_selector(".id").text == "ator"
+    assert row.find_element_by_css_selector(".email").text == "ela@email.net"
+    assert row.find_element_by_css_selector(".score").text == "0 / 15"
+    assert utils._child_exists(row, ".edit a")
+
+    # reload the page and make sure everything is still correct
+    utils._load_gradebook_page(browser, port, "manage_students")
+    row = browser.find_elements_by_css_selector("tbody tr")[0]
+    assert row.find_element_by_css_selector(".name").text == "Ator, Eva Lou"
+    assert row.find_element_by_css_selector(".id").text == "ator"
+    assert row.find_element_by_css_selector(".email").text == "ela@email.net"
+    assert row.find_element_by_css_selector(".score").text == "0 / 15"
+    assert utils._child_exists(row, ".edit a")
diff --git a/nbgrader/tests/nbextensions/test_validate_assignment.py b/nbgrader/tests/nbextensions/test_validate_assignment.py
index 9a300d1b..4b5f32ae 100644
--- a/nbgrader/tests/nbextensions/test_validate_assignment.py
+++ b/nbgrader/tests/nbextensions/test_validate_assignment.py
@@ -5,6 +5,30 @@ from selenium.webdriver.support.ui import WebDriverWait
 from selenium.webdriver.support import expected_conditions as EC
 from selenium.common.exceptions import TimeoutException, NoSuchElementException
 
+from .conftest import _make_nbserver, _make_browser, _close_nbserver, _close_browser
+
+
+@pytest.fixture(scope="module")
+def nbserver(request, port, tempdir, jupyter_config_dir, jupyter_data_dir, exchange, cache):
+    server = _make_nbserver("", port, tempdir, jupyter_config_dir, jupyter_data_dir, exchange, cache)
+
+    def fin():
+        _close_nbserver(server)
+    request.addfinalizer(fin)
+
+    return server
+
+
+@pytest.fixture
+def browser(request, tempdir, nbserver):
+    browser = _make_browser(tempdir)
+
+    def fin():
+        _close_browser(browser)
+    request.addfinalizer(fin)
+
+    return browser
+
 
 def _wait(browser):
     return WebDriverWait(browser, 30)