Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Grant Extension interface in the ManageSubmissions section #1906

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions nbgrader/api.py
Original file line number Diff line number Diff line change
@@ -613,7 +613,8 @@ def to_dict(self):
"max_written_score": self.max_written_score,
"task_score": self.task_score,
"max_task_score": self.max_task_score,
"needs_manual_grade": self.needs_manual_grade
"needs_manual_grade": self.needs_manual_grade,
"extension": self.extension.total_seconds() if self.extension is not None else None,
}

def __repr__(self) -> str:
@@ -3116,7 +3117,8 @@ def submission_dicts(self, assignment_id):
func.coalesce(written_scores.c.max_written_score, 0.0),
func.coalesce(task_scores.c.task_score, 0.0),
func.coalesce(task_scores.c.max_task_score, 0.0),
_manual_grade
_manual_grade,
SubmittedAssignment.extension
).select_from(SubmittedAssignment
).join(SubmittedNotebook).join(Assignment).join(Student).join(Grade)\
.outerjoin(code_scores, SubmittedAssignment.id == code_scores.c.id)\
@@ -3145,7 +3147,7 @@ def submission_dicts(self, assignment_id):
"score", "max_score", "code_score", "max_code_score",
"written_score", "max_written_score",
"task_score", "max_task_score",
"needs_manual_grade"
"needs_manual_grade", "extension"
]
return [dict(zip(keys, x)) for x in assignments]

42 changes: 41 additions & 1 deletion nbgrader/apps/api.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import datetime
import glob
import re
import sys
@@ -11,7 +12,7 @@
from ..coursedir import CourseDirectory
from ..converters import GenerateAssignment, Autograde, GenerateFeedback, GenerateSolution
from ..exchange import ExchangeFactory, ExchangeError
from ..api import MissingEntry, Gradebook, Student, SubmittedAssignment
from ..api import MissingEntry, InvalidEntry, Gradebook, Student, SubmittedAssignment
from ..utils import parse_utc, temp_attrs, capture_log, as_timezone, to_numeric_tz
from ..auth import Authenticator

@@ -525,6 +526,7 @@ def get_submission(self, assignment_id, student_id, ungraded=None, students=None
"autograded": False,
"submitted": True,
"student": student_id,
"extension": None,
}

if student_id not in students:
@@ -569,6 +571,7 @@ def get_submission(self, assignment_id, student_id, ungraded=None, students=None
"autograded": False,
"submitted": False,
"student": student_id,
"extension": None,
}

if student_id not in students:
@@ -614,6 +617,8 @@ def get_submissions(self, assignment_id):
submission["display_timestamp"] = None
submission["autograded"] = True
submission["submitted"] = True
if submission["extension"]:
submission["extension"] = submission["extension"].total_seconds()
submissions.append(submission)

for student_id in ungraded:
@@ -1159,3 +1164,38 @@ def fetch_feedback(self, assignment_id, student_id):
assignments = lister_rel.start()
ret_dic["value"] = sorted(assignments, key=lambda x: (x['course_id'], x['assignment_id']))
return ret_dic

def grant_extension_to_student(self, assignment_id, student_id, minutes, hours, days, weeks):
"""Grants extension for a particular assignment and student.

Arguments
---------
assignment_id: string
The name of the assignment
student_id: string
The unique id of the student
minutes: int
The number of minutes to extend the assignment deadline
hours: int
The number of hours to extend the assignment deadline
days: int
The number of days to extend the assignment deadline
weeks: int
The number of weeks to extend the assignment deadline

Returns
-------
result: dict
A dictionary with the following keys (error and log may or may not be present):

- success (bool): whether or not the operation completed successfully
- error (string): formatted traceback
- log (string): captured log output

"""
with self.gradebook as gb:
try:
gb.grant_extension(assignment_id, student_id, minutes, hours, days, weeks)
except InvalidEntry as e:
return {"success": False, "error": str(e)}
return {"success": True}
17 changes: 17 additions & 0 deletions nbgrader/server_extensions/formgrader/apihandlers.py
Original file line number Diff line number Diff line change
@@ -279,6 +279,22 @@ def post(self, assignment_id, student_id):
self.write(json.dumps(self.api.autograde(assignment_id, student_id)))


class ExtensionHandler(BaseApiHandler):
@web.authenticated
@check_xsrf
@check_notebook_dir
def post(self, assignment_id, student_id):
data = self.get_json_body()
try:
minutes = int(data.get('minutes', 0))
hours = int(data.get('hours', 0))
days = int(data.get('days', 0))
weeks = int(data.get('weeks', 0))
except ValueError:
raise web.HTTPError(400, "Invalid extension time")
self.write(json.dumps(self.api.grant_extension_to_student(assignment_id, student_id, minutes, hours, days, weeks)))


class GenerateAllFeedbackHandler(BaseApiHandler):
@web.authenticated
@check_xsrf
@@ -330,6 +346,7 @@ def post(self, assignment_id, student_id):
(r"/formgrader/api/submissions/([^/]+)", SubmissionCollectionHandler),
(r"/formgrader/api/submission/([^/]+)/([^/]+)", SubmissionHandler),
(r"/formgrader/api/submission/([^/]+)/([^/]+)/autograde", AutogradeHandler),
(r"/formgrader/api/submission/extension/([^/]+)/([^/]+)", ExtensionHandler),

(r"/formgrader/api/submitted_notebooks/([^/]+)/([^/]+)", SubmittedNotebookCollectionHandler),
(r"/formgrader/api/submitted_notebook/([^/]+)/flag", FlagSubmissionHandler),
145 changes: 145 additions & 0 deletions nbgrader/server_extensions/formgrader/static/js/manage_submissions.js
Original file line number Diff line number Diff line change
@@ -21,6 +21,10 @@ var SubmissionUI = Backbone.View.extend({
this.$autograde = this.$el.find(".autograde");
this.$generate_feedback = this.$el.find(".generate-feedback");
this.$release_feedback = this.$el.find(".release-feedback");
this.$grant_extension = this.$el.find(".grant-extension");

this.$modal = undefined;
this.$modal_save = undefined;

this.listenTo(this.model, "sync", this.render);

@@ -36,6 +40,7 @@ var SubmissionUI = Backbone.View.extend({
this.$autograde.empty();
this.$generate_feedback.empty();
this.$release_feedback.empty();
this.$grant_extension.empty();
},

render: function () {
@@ -128,6 +133,14 @@ var SubmissionUI = Backbone.View.extend({
.append($("<span/>")
.addClass("glyphicon glyphicon-envelope")
.attr("aria-hidden", "true")));

// grant extension
this.$grant_extension.append($("<a/>")
.attr("href", "#")
.click(_.bind(this.grant_extension, this))
.append($("<span/>")
.addClass("glyphicon glyphicon-calendar")
.attr("aria-hidden", "true")));
},

autograde: function () {
@@ -256,6 +269,137 @@ var SubmissionUI = Backbone.View.extend({
"There was an error releasing feedback for '" + assignment + "' for student '" + student + "'.");
},

grant_extension: function () {
if (!this.model.get("autograded")) {
createModal(
"error-modal",
"Error",
"Extensions cannot be granted for ungraded submissions.");
return;
}
this.openModal();
},

openModal: function () {
var body = $("<table/>").addClass("table table-striped form-table");
var tableBody = $("<tbody/>");
body.append(tableBody);

var weeks = $("<tr/>");
tableBody.append(weeks);
weeks.append($("<td/>").addClass("align-middle").text("Weeks"));
weeks.append($("<td/>").append($("<input/>").addClass("modal-weeks").attr({type: "number", min: 0, step: 1, value: this.modal_extension_weeks})));

var days = $("<tr/>");
tableBody.append(days);
days.append($("<td/>").addClass("align-middle").text("Days"));
days.append($("<td/>").append($("<input/>").addClass("modal-days").attr({type: "number", min: 0, step: 1, value: this.modal_extension_days})));

var hours = $("<tr/>");
tableBody.append(hours);
hours.append($("<td/>").addClass("align-middle").text("Hours"));
hours.append($("<td/>").append($("<input/>").addClass("modal-hours").attr({type: "number", min: 0, step: 1, value: this.modal_extension_hours})));

var minutes = $("<tr/>");
tableBody.append(minutes);
minutes.append($("<td/>").addClass("align-middle").text("Minutes"));
minutes.append($("<td/>").append($("<input/>").addClass("modal-minutes").attr({type: "number", min: 0, step: 1, value: this.modal_extension_minutes})));

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("grant-extension-modal", "Granting Extension to " + this.model.get("student"), body, footer);

var extension = this.model.get("extension") || 0;
var modal_extension_days = Math.trunc(extension / 86400);
var modal_extension_weeks = Math.trunc(modal_extension_days / 7);
modal_extension_days = modal_extension_days % 7;
var modal_extension_hours = Math.trunc((extension % 86400) / 3600);
var modal_extension_minutes = Math.trunc((extension % 3600) / 60);

this.$modal.find("input.modal-weeks").val(modal_extension_weeks);
this.$modal.find("input.modal-days").val(modal_extension_days);
this.$modal.find("input.modal-hours").val(modal_extension_hours);
this.$modal.find("input.modal-minutes").val(modal_extension_minutes);
this.$modal.find("button.save").click(_.bind(this.save, this));
},

save: function () {
this.animateSaving();

var weeks = this.$modal.find("input.modal-weeks").val() || 0;
var days = this.$modal.find("input.modal-days").val() || 0;
var hours = this.$modal.find("input.modal-hours").val() || 0;
var minutes = this.$modal.find("input.modal-minutes").val() || 0;

this.closeModal();
let student = this.model.get("student");
let assignment = this.model.get("name");

$.ajax({
url: base_url + '/formgrader/api/submission/extension/' + assignment + '/' + student,
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({
minutes: minutes,
hours: hours,
days: days,
weeks: weeks,
}),
})
.done(_.bind(this.grant_extension_log, this));
},

grant_extension_log: 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 granted extension to '" + assignment + "' for student '" + student + "'.",
response["log"]);

} else {
createLogModal(
"error-modal",
"Error",
"There was an error granting extension to '" + assignment + "' for student '" + student + "':",
response["log"],
response["error"]);
}
},

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_extension_weeks = 0;
this.modal_extension_days = 0;
this.modal_extension_hours = 0;
this.modal_extension_minutes = 0;
this.$modal_save = undefined;
}

this.render();
},

});

var insertRow = function (table) {
@@ -268,6 +412,7 @@ var insertRow = function (table) {
row.append($("<td/>").addClass("text-center autograde"));
row.append($("<td/>").addClass("text-center generate-feedback"));
row.append($("<td/>").addClass("text-center release-feedback"));
row.append($("<td/>").addClass("text-center grant-extension"));
table.append(row)
return row;
};
Original file line number Diff line number Diff line change
@@ -64,6 +64,7 @@ nbgrader autograde "{{ assignment_id }}"</pre>
<th class="text-center no-sort">Autograde</th>
<th class="text-center no-sort">Generate Feedback</th>
<th class="text-center no-sort">Release Feedback</th>
<th class="text-center no-sort">Grant Extension</th>
</tr>
{%- endblock -%}

@@ -77,5 +78,6 @@ nbgrader autograde "{{ assignment_id }}"</pre>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
{%- endblock -%}
26 changes: 21 additions & 5 deletions nbgrader/tests/apps/test_api.py
Original file line number Diff line number Diff line change
@@ -6,7 +6,7 @@

from os.path import join
from traitlets.config import Config
from datetime import datetime
from datetime import datetime, timedelta

from ...apps.api import NbGraderAPI
from ...coursedir import CourseDirectory
@@ -310,7 +310,7 @@ def test_get_submission(self, api, course_dir, db):
"id", "name", "student", "last_name", "first_name", "score",
"max_score", "code_score", "max_code_score", "written_score",
"max_written_score", "task_score", "max_task_score", "needs_manual_grade", "autograded",
"timestamp", "submitted", "display_timestamp"])
"timestamp", "submitted", "display_timestamp", "extension"])

default = {
"id": None,
@@ -330,7 +330,8 @@ def test_get_submission(self, api, course_dir, db):
"autograded": False,
"timestamp": None,
"display_timestamp": None,
"submitted": False
"submitted": False,
"extension": None,
}

s = api.get_submission("ps1", "foo")
@@ -364,14 +365,15 @@ def test_get_submission(self, api, course_dir, db):
target["written_score"] = 0
target["max_written_score"] = 2
target["needs_manual_grade"] = True
target["extension"] = None
assert s == target

def test_get_submission_no_timestamp(self, api, course_dir, db):
keys = set([
"id", "name", "student", "last_name", "first_name", "score",
"max_score", "code_score", "max_code_score", "written_score",
"max_written_score", "task_score", "max_task_score", "needs_manual_grade", "autograded",
"timestamp", "submitted", "display_timestamp"])
"timestamp", "submitted", "display_timestamp", "extension"])

default = {
"id": None,
@@ -391,7 +393,8 @@ def test_get_submission_no_timestamp(self, api, course_dir, db):
"autograded": False,
"timestamp": None,
"display_timestamp": None,
"submitted": False
"submitted": False,
"extension": None,
}

s = api.get_submission("ps1", "foo")
@@ -420,6 +423,7 @@ def test_get_submission_no_timestamp(self, api, course_dir, db):
target["written_score"] = 0
target["max_written_score"] = 2
target["needs_manual_grade"] = True
target["extension"] = None
assert s == target

def test_get_submissions(self, api, course_dir, db):
@@ -826,3 +830,15 @@ def test_fetch_feedback(self, api, course_dir, db, cache):
result = api.fetch_feedback("ps2", "foo")
assert result["success"]
assert os.path.exists(join("ps2", "feedback", timestamp, "p2.html"))

def test_grant_extension_to_student(self, api, course_dir):
self._copy_file(join("files", "submitted-unchanged.ipynb"), join(course_dir, "source", "ps1", "p1.ipynb"))
self._copy_file(join("files", "submitted-changed.ipynb"), join(course_dir, "submitted", "foo", "ps1", "p1.ipynb"))
api.generate_assignment("ps1")
api.autograde("ps1", "foo")

result = api.grant_extension_to_student("ps1", "foo", 1, 2, 3, 4)
assert result["success"]

extension = api.get_submission("ps1", "foo")["extension"]
assert extension == timedelta(weeks=4, days=3, hours=2, minutes=1).total_seconds()