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)