diff --git a/nbgrader/alembic/env.py b/nbgrader/alembic/env.py
index 058378b9d..cdc897f26 100644
--- a/nbgrader/alembic/env.py
+++ b/nbgrader/alembic/env.py
@@ -13,9 +13,8 @@
# add your model's MetaData object here
# for 'autogenerate' support
-# from myapp import mymodel
-# target_metadata = mymodel.Base.metadata
-target_metadata = None
+from nbgrader.api import Base
+target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
diff --git a/nbgrader/alembic/versions/724cde206c17_add_extra_credit_column.py b/nbgrader/alembic/versions/724cde206c17_add_extra_credit_column.py
new file mode 100644
index 000000000..dedb88ddd
--- /dev/null
+++ b/nbgrader/alembic/versions/724cde206c17_add_extra_credit_column.py
@@ -0,0 +1,24 @@
+"""add extra credit column
+
+Revision ID: 724cde206c17
+Revises: 50a4d84c131a
+Create Date: 2017-06-02 13:05:22.347671
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '724cde206c17'
+down_revision = '50a4d84c131a'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ op.add_column('grade', sa.Column('extra_credit', sa.Float(), nullable=True))
+
+
+def downgrade():
+ op.drop_column('grade', 'extra_credit')
diff --git a/nbgrader/api.py b/nbgrader/api.py
index 0b3ec079d..7928506d6 100644
--- a/nbgrader/api.py
+++ b/nbgrader/api.py
@@ -672,6 +672,9 @@ class Grade(Base):
#: Score assigned by a human grader
manual_score = Column(Float())
+ #: Extra credit assigned by a human grader
+ extra_credit = Column(Float())
+
#: Whether a score needs to be assigned manually. This is True by default.
needs_manual_grade = Column(Boolean, default=True, nullable=False)
@@ -682,8 +685,8 @@ class Grade(Base):
#: for the score.
score = column_property(case(
[
- (manual_score != None, manual_score),
- (auto_score != None, auto_score)
+ (manual_score != None, manual_score + case([(extra_credit != None, extra_credit)], else_=literal_column("0.0"))),
+ (auto_score != None, auto_score + case([(extra_credit != None, extra_credit)], else_=literal_column("0.0")))
],
else_=literal_column("0.0")
))
@@ -713,6 +716,7 @@ def to_dict(self):
"student": self.student.id,
"auto_score": self.auto_score,
"manual_score": self.manual_score,
+ "extra_credit": self.extra_credit,
"max_score": self.max_score,
"needs_manual_grade": self.needs_manual_grade,
"failed_tests": self.failed_tests,
diff --git a/nbgrader/docs/source/api/models.rst b/nbgrader/docs/source/api/models.rst
index dc1ea4333..30b5e53ce 100644
--- a/nbgrader/docs/source/api/models.rst
+++ b/nbgrader/docs/source/api/models.rst
@@ -82,6 +82,8 @@ Master version of an assignment
.. autoattribute:: needs_manual_grade
+ .. autoattribute:: kernelspec
+
.. automethod:: to_dict
.. autoclass:: GradeCell
@@ -257,6 +259,8 @@ Submitted assignments
.. autoattribute:: manual_score
+ .. autoattribute:: extra_credit
+
.. autoattribute:: score
.. autoattribute:: max_score
diff --git a/nbgrader/docs/source/spelling_wordlist.txt b/nbgrader/docs/source/spelling_wordlist.txt
index be768a0ae..79214ce00 100644
--- a/nbgrader/docs/source/spelling_wordlist.txt
+++ b/nbgrader/docs/source/spelling_wordlist.txt
@@ -93,6 +93,7 @@ JUPYTER
Jupyter
jupyter
jupyterhub
+kernelspec
lgpage
linkcheck
linux
diff --git a/nbgrader/server_extensions/formgrader/apihandlers.py b/nbgrader/server_extensions/formgrader/apihandlers.py
index 3106a3467..377eb3515 100644
--- a/nbgrader/server_extensions/formgrader/apihandlers.py
+++ b/nbgrader/server_extensions/formgrader/apihandlers.py
@@ -46,6 +46,7 @@ def put(self, grade_id):
data = self.get_json_body()
grade.manual_score = data.get("manual_score", None)
+ grade.extra_credit = data.get("extra_credit", None)
if grade.manual_score is None and grade.auto_score is None:
grade.needs_manual_grade = True
else:
diff --git a/nbgrader/server_extensions/formgrader/static/css/formgrade.css b/nbgrader/server_extensions/formgrader/static/css/formgrade.css
index 97d928050..001fcd06a 100644
--- a/nbgrader/server_extensions/formgrader/static/css/formgrade.css
+++ b/nbgrader/server_extensions/formgrader/static/css/formgrade.css
@@ -102,7 +102,7 @@ div.nbgrader_cell .input_area {
border: none;
}
-.score {
+.score, .extra-credit {
color: black;
height: 29px;
margin-left: 1em;
diff --git a/nbgrader/server_extensions/formgrader/static/js/models.js b/nbgrader/server_extensions/formgrader/static/js/models.js
index 134049cbc..dbe943af7 100644
--- a/nbgrader/server_extensions/formgrader/static/js/models.js
+++ b/nbgrader/server_extensions/formgrader/static/js/models.js
@@ -2,6 +2,7 @@ var GradeUI = Backbone.View.extend({
events: {
"change .score": "save",
+ "change .extra-credit": "save",
"click .full-credit": "assignFullCredit",
"click .no-credit": "assignNoCredit",
"click .mark-graded": "save"
@@ -10,6 +11,7 @@ var GradeUI = Backbone.View.extend({
initialize: function () {
this.$glyph = this.$el.find(".score-saved");
this.$score = this.$el.find(".score");
+ this.$extra_credit = this.$el.find(".extra-credit");
this.$mark_graded = this.$el.find(".mark-graded");
this.listenTo(this.model, "change", this.render);
@@ -17,11 +19,13 @@ var GradeUI = Backbone.View.extend({
this.listenTo(this.model, "sync", this.animateSaved);
this.$score.attr("placeholder", this.model.get("auto_score"));
+ this.$extra_credit.attr("placeholder", 0.0);
this.render();
},
render: function () {
this.$score.val(this.model.get("manual_score"));
+ this.$extra_credit.val(this.model.get("extra_credit"));
if (this.model.get("needs_manual_grade")) {
this.$score.addClass("needs_manual_grade");
if (this.model.get("manual_score") !== null) {
@@ -34,22 +38,36 @@ var GradeUI = Backbone.View.extend({
},
save: function () {
+ var score, extra_credit;
if (this.$score.val() === "") {
- this.model.save({"manual_score": null});
+ score = null;
} else {
var val = this.$score.val();
var max_score = this.model.get("max_score");
if (val > max_score) {
- this.animateInvalidValue();
- this.model.save({"manual_score": max_score});
+ this.animateInvalidValue(this.$score);
+ score = max_score;
} else if (val < 0) {
- this.animateInvalidValue();
- this.model.save({"manual_score": 0});
+ this.animateInvalidValue(this.$score);
+ score = 0;
} else {
- this.model.save({"manual_score": val});
+ score = val;
}
}
+ if (this.$extra_credit.val() == "") {
+ extra_credit = null;
+ } else {
+ var val = this.$extra_credit.val();
+ if (val < 0) {
+ this.animateInvalidValue(this.$extra_credit);
+ extra_credit = 0;
+ } else {
+ extra_credit = val;
+ }
+ }
+
+ this.model.save({"manual_score": score, "extra_credit": extra_credit});
this.render();
},
@@ -69,14 +87,14 @@ var GradeUI = Backbone.View.extend({
$(document).trigger("finished_saving");
},
- animateInvalidValue: function () {
+ animateInvalidValue: function (elem) {
var that = this;
- this.$score.animate({
+ elem.animate({
"background-color": "#FF8888",
"border-color": "red"
}, 100, undefined, function () {
setTimeout(function () {
- that.$score.animate({
+ elem.animate({
"background-color": "white",
"border-color": "white"
}, 100);
@@ -91,7 +109,7 @@ var GradeUI = Backbone.View.extend({
},
assignNoCredit: function () {
- this.model.save({"manual_score": 0});
+ this.model.save({"manual_score": 0, "extra_credit": 0});
this.$score.select();
this.$score.focus();
}
diff --git a/nbgrader/server_extensions/formgrader/templates/formgrade.tpl b/nbgrader/server_extensions/formgrader/templates/formgrade.tpl
index 292774186..c917ab7c9 100644
--- a/nbgrader/server_extensions/formgrader/templates/formgrade.tpl
+++ b/nbgrader/server_extensions/formgrader/templates/formgrade.tpl
@@ -79,6 +79,9 @@ window.MathJax = {
/ {{ cell.metadata.nbgrader.points | float | round(2) }}
+
+ + (extra credit)
+
{%- endmacro %}
diff --git a/nbgrader/tests/api/test_models.py b/nbgrader/tests/api/test_models.py
index 70240fe83..790fc2c15 100644
--- a/nbgrader/tests/api/test_models.py
+++ b/nbgrader/tests/api/test_models.py
@@ -851,6 +851,49 @@ def test_query_code_score_manualgraded(submissions):
assert [1.5, 3] == sorted(x[1] for x in db.query(api.SubmittedNotebook.id, api.SubmittedNotebook.code_score).all())
assert [1.5, 3] == sorted(x[1] for x in db.query(api.SubmittedAssignment.id, api.SubmittedAssignment.code_score).all())
+def test_query_auto_score_extra_credit(submissions):
+ db, grades, _ = submissions
+
+ grades[0].auto_score = 10
+ grades[1].auto_score = 0
+ grades[2].auto_score = 5
+ grades[3].auto_score = 2.5
+
+ grades[0].extra_credit = 0.5
+ grades[1].extra_credit = 0
+ grades[2].extra_credit = 2.3
+ grades[3].extra_credit = 1.1
+ db.commit()
+
+ assert sorted(x[0] for x in db.query(api.Grade.score).all()) == [0, 3.6, 7.3, 10.5]
+ assert sorted(x[1] for x in db.query(api.SubmittedNotebook.id, api.SubmittedNotebook.score).all()) == [10.5, 10.9]
+ assert sorted(x[1] for x in db.query(api.SubmittedAssignment.id, api.SubmittedAssignment.score).all()) == [10.5, 10.9]
+ assert sorted(x[1] for x in db.query(api.Student.id, api.Student.score).all()) == [10.5, 10.9]
+
+def test_query_manual_score_extra_credit(submissions):
+ db, grades, _ = submissions
+
+ grades[0].auto_score = 10
+ grades[1].auto_score = 0
+ grades[2].auto_score = 5
+ grades[3].auto_score = 2.5
+
+ grades[0].manual_score = 4
+ grades[1].manual_score = 1.5
+ grades[2].manual_score = 9
+ grades[3].manual_score = 3
+
+ grades[0].extra_credit = 0.5
+ grades[1].extra_credit = 0
+ grades[2].extra_credit = 2.3
+ grades[3].extra_credit = 1.1
+ db.commit()
+
+ assert sorted(x[0] for x in db.query(api.Grade.score).all()) == [1.5, 4.1, 4.5, 11.3]
+ assert sorted(x[1] for x in db.query(api.SubmittedNotebook.id, api.SubmittedNotebook.score).all()) == [6, 15.4]
+ assert sorted(x[1] for x in db.query(api.SubmittedAssignment.id, api.SubmittedAssignment.score).all()) == [6, 15.4]
+ assert sorted(x[1] for x in db.query(api.Student.id, api.Student.score).all()) == [6, 15.4]
+
def test_query_num_submissions(submissions):
db = submissions[0]
@@ -1187,7 +1230,7 @@ def test_grade_to_dict(submissions):
assert set(gd.keys()) == {
'id', 'name', 'notebook', 'assignment', 'student', 'auto_score',
'manual_score', 'max_score', 'needs_manual_grade', 'failed_tests',
- 'cell_type'}
+ 'cell_type', 'extra_credit'}
assert gd['id'] == g.id
assert gd['name'] == g.name
@@ -1196,6 +1239,7 @@ def test_grade_to_dict(submissions):
assert gd['student'] == g.student.id
assert gd['auto_score'] is None
assert gd['manual_score'] is None
+ assert gd['extra_credit'] is None
assert gd['needs_manual_grade']
assert not gd['failed_tests']
assert gd['cell_type'] == g.cell_type
diff --git a/nbgrader/tests/nbextensions/formgrade_utils.py b/nbgrader/tests/nbextensions/formgrade_utils.py
index 36ecb9ea9..f25401f24 100644
--- a/nbgrader/tests/nbextensions/formgrade_utils.py
+++ b/nbgrader/tests/nbextensions/formgrade_utils.py
@@ -137,6 +137,10 @@ def _get_score_box(browser, index):
return browser.find_elements_by_css_selector(".score")[index]
+def _get_extra_credit_box(browser, index):
+ return browser.find_elements_by_css_selector(".extra-credit")[index]
+
+
def _save_comment(browser, index):
_send_keys_to_body(browser, Keys.ESCAPE)
glyph = browser.find_elements_by_css_selector(".comment-saved")[index]
diff --git a/nbgrader/tests/nbextensions/test_formgrader.py b/nbgrader/tests/nbextensions/test_formgrader.py
index 1937b0a2d..eeeec21a1 100644
--- a/nbgrader/tests/nbextensions/test_formgrader.py
+++ b/nbgrader/tests/nbextensions/test_formgrader.py
@@ -441,45 +441,75 @@ def test_tabbing(browser, port, gradebook):
assert utils._get_active_element(browser) == utils._get_score_box(browser, 0)
assert utils._get_index(browser) == 2
+ # tab to the next and check that the first extra credit is selected
+ utils._send_keys_to_body(browser, Keys.TAB)
+ assert utils._get_active_element(browser) == utils._get_extra_credit_box(browser, 0)
+ assert utils._get_index(browser) == 3
+
# tab to the next and check that the second points is selected
utils._send_keys_to_body(browser, Keys.TAB)
assert utils._get_active_element(browser) == utils._get_score_box(browser, 1)
- assert utils._get_index(browser) == 3
+ assert utils._get_index(browser) == 4
+
+ # tab to the next and check that the second extra credit is selected
+ utils._send_keys_to_body(browser, Keys.TAB)
+ assert utils._get_active_element(browser) == utils._get_extra_credit_box(browser, 1)
+ assert utils._get_index(browser) == 5
# tab to the next and check that the second comment is selected
utils._send_keys_to_body(browser, Keys.TAB)
assert utils._get_active_element(browser) == utils._get_comment_box(browser, 1)
- assert utils._get_index(browser) == 4
+ assert utils._get_index(browser) == 6
# tab to the next and check that the third points is selected
utils._send_keys_to_body(browser, Keys.TAB)
assert utils._get_active_element(browser) == utils._get_score_box(browser, 2)
- assert utils._get_index(browser) == 5
+ assert utils._get_index(browser) == 7
+
+ # tab to the next and check that the third extra credit is selected
+ utils._send_keys_to_body(browser, Keys.TAB)
+ assert utils._get_active_element(browser) == utils._get_extra_credit_box(browser, 2)
+ assert utils._get_index(browser) == 8
# tab to the next and check that the fourth points is selected
utils._send_keys_to_body(browser, Keys.TAB)
assert utils._get_active_element(browser) == utils._get_score_box(browser, 3)
- assert utils._get_index(browser) == 6
+ assert utils._get_index(browser) == 9
+
+ # tab to the next and check that the fourth extra credit is selected
+ utils._send_keys_to_body(browser, Keys.TAB)
+ assert utils._get_active_element(browser) == utils._get_extra_credit_box(browser, 3)
+ assert utils._get_index(browser) == 10
# tab to the next and check that the fifth points is selected
utils._send_keys_to_body(browser, Keys.TAB)
assert utils._get_active_element(browser) == utils._get_score_box(browser, 4)
- assert utils._get_index(browser) == 7
+ assert utils._get_index(browser) == 11
+
+ # tab to the next and check that the fifth extra credit is selected
+ utils._send_keys_to_body(browser, Keys.TAB)
+ assert utils._get_active_element(browser) == utils._get_extra_credit_box(browser, 4)
+ assert utils._get_index(browser) == 12
# tab to the next and check that the third comment is selected
utils._send_keys_to_body(browser, Keys.TAB)
assert utils._get_active_element(browser) == utils._get_comment_box(browser, 2)
- assert utils._get_index(browser) == 8
+ assert utils._get_index(browser) == 13
# tab to the next and check that the sixth points is selected
utils._send_keys_to_body(browser, Keys.TAB)
assert utils._get_active_element(browser) == utils._get_score_box(browser, 5)
- assert utils._get_index(browser) == 9
+ assert utils._get_index(browser) == 14
+
+ # tab to the next and check that the sixth extra credit is selected
+ utils._send_keys_to_body(browser, Keys.TAB)
+ assert utils._get_active_element(browser) == utils._get_extra_credit_box(browser, 5)
+ assert utils._get_index(browser) == 15
# tab to the next and check that the fourth comment is selected
utils._send_keys_to_body(browser, Keys.TAB)
assert utils._get_active_element(browser) == utils._get_comment_box(browser, 3)
- assert utils._get_index(browser) == 10
+ assert utils._get_index(browser) == 16
# tab to the next and check that the next arrow is selected
utils._send_keys_to_body(browser, Keys.TAB)
@@ -565,6 +595,37 @@ def test_save_score(browser, port, gradebook, index):
assert "needs_manual_grade" in elem.get_attribute("class").split(" ")
+@pytest.mark.nbextensions
+@pytest.mark.parametrize("index", range(6))
+def test_save_extra_credit(browser, port, gradebook, index):
+ utils._load_formgrade(browser, port, gradebook)
+ elem = utils._get_extra_credit_box(browser, index)
+
+ if elem.get_attribute("value") != "":
+ elem.click()
+ elem.clear()
+ utils._save_score(browser, index)
+ utils._load_formgrade(browser, port, gradebook)
+ elem = utils._get_extra_credit_box(browser, index)
+ assert elem.get_attribute("value") == ""
+
+ # set the grade
+ elem.click()
+ elem.send_keys("{}".format((index + 1) / 10.0))
+ utils._save_score(browser, index)
+ utils._load_formgrade(browser, port, gradebook)
+ elem = utils._get_extra_credit_box(browser, index)
+ assert elem.get_attribute("value") == "{}".format((index + 1) / 10.0)
+
+ # clear the grade
+ elem.click()
+ elem.clear()
+ utils._save_score(browser, index)
+ utils._load_formgrade(browser, port, gradebook)
+ elem = utils._get_extra_credit_box(browser, index)
+ assert elem.get_attribute("value") == ""
+
+
@pytest.mark.nbextensions
def test_same_part_navigation(browser, port, gradebook):
problem = gradebook.find_notebook("Problem 1", "Problem Set 1")
@@ -578,24 +639,24 @@ def test_same_part_navigation(browser, port, gradebook):
# Click the second comment box and navigate to the next submission
utils._get_comment_box(browser, 1).click()
utils._send_keys_to_body(browser, Keys.CONTROL, ".")
- utils._wait_for_formgrader(browser, port, "submissions/{}/?index=4".format(submissions[1].id))
+ utils._wait_for_formgrader(browser, port, "submissions/{}/?index=6".format(submissions[1].id))
assert utils._get_active_element(browser) == utils._get_comment_box(browser, 1)
# Click the third score box and navigate to the previous submission
utils._get_score_box(browser, 2).click()
utils._send_keys_to_body(browser, Keys.CONTROL, ",")
- utils._wait_for_formgrader(browser, port, "submissions/{}/?index=5".format(submissions[0].id))
+ utils._wait_for_formgrader(browser, port, "submissions/{}/?index=7".format(submissions[0].id))
assert utils._get_active_element(browser) == utils._get_score_box(browser, 2)
# Click the third comment box and navigate to the next submission
utils._get_comment_box(browser, 2).click()
utils._send_keys_to_body(browser, Keys.CONTROL, ".")
- utils._wait_for_formgrader(browser, port, "submissions/{}/?index=7".format(submissions[1].id))
+ utils._wait_for_formgrader(browser, port, "submissions/{}/?index=11".format(submissions[1].id))
assert utils._get_active_element(browser) == utils._get_score_box(browser, 4)
# Navigate to the previous submission
utils._send_keys_to_body(browser, Keys.CONTROL, ",")
- utils._wait_for_formgrader(browser, port, "submissions/{}/?index=7".format(submissions[0].id))
+ utils._wait_for_formgrader(browser, port, "submissions/{}/?index=11".format(submissions[0].id))
assert utils._get_active_element(browser) == utils._get_score_box(browser, 4)