Skip to content
Draft
Show file tree
Hide file tree
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
12 changes: 12 additions & 0 deletions course/grades.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
from course.flow import adjust_flow_session_page_data
from course.models import (
FlowPageVisit,
FlowRuleException,
FlowSession,
GradeChange,
GradeStateMachine,
Expand Down Expand Up @@ -1045,6 +1046,16 @@ def view_single_grade(pctx: CoursePageContext, participation_id: str,

# }}}

flow_rule_exceptions: list[FlowRuleException] | None = None
if show_privileged_info and opportunity.flow_id:
flow_rule_exceptions = list(
FlowRuleException.objects
.filter(
participation=participation,
flow_id=opportunity.flow_id)
.order_by("creation_time")
.select_related("creator"))

return render_course_page(pctx, "course/gradebook-single.html", {
"opportunity": opportunity,
"avg_grade_percentage": avg_grade_percentage,
Expand All @@ -1061,6 +1072,7 @@ def view_single_grade(pctx: CoursePageContext, participation_id: str,
or PPerm.end_flow_session
or PPerm.regrade_flow_session
or PPerm.recalculate_flow_session_grade),
"flow_rule_exceptions": flow_rule_exceptions,
})

# }}}
Expand Down
60 changes: 60 additions & 0 deletions course/templates/course/gradebook-single.html
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,66 @@ <h2>{% trans "Flow sessions" %}</h2>
</a>
</div>
{% endif %}

{% if flow_rule_exceptions != None %}
<h2>{% trans "Flow rule exceptions" %}</h2>
{% if not flow_rule_exceptions %}
<p><i>{% trans "(no exceptions)" %}</i></p>
{% else %}
<table class="table table-condensed table-striped">
<thead>
<tr>
<th>{% trans "Kind" %}</th>
<th>{% trans "Created" %}</th>
<th>{% trans "Creator" %}</th>
<th>{% trans "Expiration" %}</th>
<th>{% trans "Comment" %}</th>
<th>{% trans "Active" %}</th>
{% if pperm.grant_exception %}
<th>{% trans "Actions" %}</th>
{% endif %}
</tr>
</thead>
<tbody>
{% for exc in flow_rule_exceptions %}
<tr{% if not exc.active %} class="text-muted"{% endif %}>
<td>
{% if not exc.active %}<s>{% endif %}
{{ exc.get_kind_display }}
{% if not exc.active %}</s>{% endif %}
</td>
<td>{{ exc.creation_time }}</td>
<td>{% if exc.creator %}{{ exc.creator.username }}{% else %}<i>{% trans "(none)" %}</i>{% endif %}</td>
<td>{% if exc.expiration %}{{ exc.expiration }}{% else %}<i>{% trans "(none)" %}</i>{% endif %}</td>
<td>{% if exc.comment %}<span class="sensitive">{{ exc.comment }}</span>{% endif %}</td>
<td>
{% if exc.active %}
<span class="badge bg-success">{% trans "Yes" %}</span>
{% else %}
<span class="badge bg-secondary">{% trans "No" %}</span>
{% endif %}
</td>
{% if pperm.grant_exception %}
<td>
{% if exc.active %}
<form method="POST"
action="{% url "relate-deactivate_flow_exception" course.identifier exc.id opportunity.id %}"
style="display:inline"
onsubmit="return confirm('{% trans "Deactivate this exception?" %}')">
{% csrf_token %}
<button type="submit" class="btn btn-outline-warning btn-sm">
{% trans "Deactivate" %}
</button>
</form>
{% endif %}
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endif %}
{# }}} #}

{% endblock %}
Expand Down
33 changes: 33 additions & 0 deletions course/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -1356,6 +1356,39 @@ def transfer_attr(name: str):
# }}}


# {{{ deactivate flow rule exception

@http_dec.require_POST
@course_view
@transaction.atomic
def deactivate_flow_exception(pctx: CoursePageContext,
exception_id: str, opportunity_id: str) -> http.HttpResponse:

if not pctx.has_permission(PPerm.grant_exception):
raise PermissionDenied(_("may not grant exceptions"))

exception = get_object_or_404(FlowRuleException, id=int(exception_id))

if exception.participation.course != pctx.course:
raise SuspiciousOperation(_("exception does not belong to this course"))

if not exception.active:
messages.add_message(pctx.request, messages.INFO,
_("Exception was already deactivated."))
else:
exception.active = False
exception.save()
messages.add_message(pctx.request, messages.SUCCESS,
_("Exception deactivated."))

return redirect("relate-view_single_grade",
pctx.course.identifier,
exception.participation.id,
opportunity_id)

# }}}


# {{{ ssh keypair

@login_required
Expand Down
8 changes: 8 additions & 0 deletions relate/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,14 @@
"/$",
course.views.grant_exception_stage_3,
name="relate-grant_exception_stage_3"),
re_path(r"^course"
"/" + COURSE_ID_REGEX
+ "/deactivate-exception"
"/(?P<exception_id>[0-9]+)"
"/(?P<opportunity_id>[0-9]+)"
"/$",
course.views.deactivate_flow_exception,
name="relate-deactivate_flow_exception"),

# }}}

Expand Down
148 changes: 148 additions & 0 deletions tests/test_grades/test_grades.py
Original file line number Diff line number Diff line change
Expand Up @@ -1684,6 +1684,154 @@
self.assertEqual(len(resp_gchanges), 2)


class ViewSingleGradeFlowRuleExceptionsTest(GradesTestMixin, TestCase):
# Tests for flow_rule_exceptions context in view_single_grade

def test_flow_rule_exceptions_not_in_context_for_no_flow_id(self):
gopp = factories.GradingOpportunityFactory(
course=self.course, identifier="no_flow_id", flow_id=None)
resp = self.get_view_single_grade(self.student_participation, gopp)
self.assertEqual(resp.status_code, 200)
self.assertIsNone(resp.context["flow_rule_exceptions"])

def test_flow_rule_exceptions_empty(self):
resp = self.get_view_single_grade(
self.student_participation, self.gopp)
self.assertEqual(resp.status_code, 200)
exceptions = resp.context["flow_rule_exceptions"]
self.assertIsNotNone(exceptions)
self.assertEqual(len(exceptions), 0)

def test_flow_rule_exceptions_shown(self):
exc = factories.FlowRuleExceptionFactory(
participation=self.student_participation,
flow_id=self.flow_id,
kind=constants.FlowRuleKind.access,
active=True)
resp = self.get_view_single_grade(
self.student_participation, self.gopp)
self.assertEqual(resp.status_code, 200)
exceptions = resp.context["flow_rule_exceptions"]
self.assertIsNotNone(exceptions)
self.assertEqual(len(exceptions), 1)
self.assertEqual(exceptions[0].pk, exc.pk)

def test_flow_rule_exceptions_includes_inactive(self):
factories.FlowRuleExceptionFactory(
participation=self.student_participation,
flow_id=self.flow_id,
kind=constants.FlowRuleKind.access,
active=False)
resp = self.get_view_single_grade(
self.student_participation, self.gopp)
self.assertEqual(resp.status_code, 200)
exceptions = resp.context["flow_rule_exceptions"]
self.assertIsNotNone(exceptions)
self.assertEqual(len(exceptions), 1)

def test_flow_rule_exceptions_not_shown_to_student(self):
factories.FlowRuleExceptionFactory(
participation=self.student_participation,
flow_id=self.flow_id,
kind=constants.FlowRuleKind.access,
active=True)
with self.temporarily_switch_to_user(self.student_participation.user):
resp = self.get_view_single_grade(
self.student_participation, self.gopp,
force_login_instructor=False)
self.assertEqual(resp.status_code, 200)
self.assertIsNone(resp.context["flow_rule_exceptions"])


class DeactivateFlowExceptionTest(GradesTestMixin, TestCase):
# Tests for views.deactivate_flow_exception

def get_deactivate_url(self, exception, opportunity, course_identifier=None):

Check warning on line 1749 in tests/test_grades/test_grades.py

View workflow job for this annotation

GitHub Actions / Lint and typecheck Python

Type annotation is missing for parameter "course_identifier" (reportMissingParameterType)

Check warning on line 1749 in tests/test_grades/test_grades.py

View workflow job for this annotation

GitHub Actions / Lint and typecheck Python

Type annotation is missing for parameter "opportunity" (reportMissingParameterType)

Check warning on line 1749 in tests/test_grades/test_grades.py

View workflow job for this annotation

GitHub Actions / Lint and typecheck Python

Type annotation is missing for parameter "exception" (reportMissingParameterType)
course_identifier = (
course_identifier or self.get_default_course_identifier())
return reverse("relate-deactivate_flow_exception", kwargs={
"course_identifier": course_identifier,
"exception_id": exception.pk,
"opportunity_id": opportunity.pk,
})

def post_deactivate(self, exception, opportunity, course_identifier=None,

Check warning on line 1758 in tests/test_grades/test_grades.py

View workflow job for this annotation

GitHub Actions / Lint and typecheck Python

Type annotation is missing for parameter "course_identifier" (reportMissingParameterType)

Check warning on line 1758 in tests/test_grades/test_grades.py

View workflow job for this annotation

GitHub Actions / Lint and typecheck Python

Type annotation is missing for parameter "opportunity" (reportMissingParameterType)

Check warning on line 1758 in tests/test_grades/test_grades.py

View workflow job for this annotation

GitHub Actions / Lint and typecheck Python

Type annotation is missing for parameter "exception" (reportMissingParameterType)
force_login_instructor=True):

Check warning on line 1759 in tests/test_grades/test_grades.py

View workflow job for this annotation

GitHub Actions / Lint and typecheck Python

Type annotation is missing for parameter "force_login_instructor" (reportMissingParameterType)
url = self.get_deactivate_url(exception, opportunity, course_identifier)
if force_login_instructor:
switch_to = self.get_default_instructor_user(
course_identifier or self.get_default_course_identifier())
else:
switch_to = None
with self.temporarily_switch_to_user(switch_to):
return self.client.post(url)

def test_deactivate_success(self):
exc = factories.FlowRuleExceptionFactory(
participation=self.student_participation,
flow_id=self.flow_id,
kind=constants.FlowRuleKind.access,
active=True)
resp = self.post_deactivate(exc, self.gopp)
self.assertEqual(resp.status_code, 302)
exc.refresh_from_db()
self.assertFalse(exc.active)
self.assertAddMessageCalledWith("Exception deactivated.")

def test_deactivate_already_inactive(self):
exc = factories.FlowRuleExceptionFactory(
participation=self.student_participation,
flow_id=self.flow_id,
kind=constants.FlowRuleKind.access,
active=False)
resp = self.post_deactivate(exc, self.gopp)
self.assertEqual(resp.status_code, 302)
exc.refresh_from_db()
self.assertFalse(exc.active)
self.assertAddMessageCalledWith("Exception was already deactivated.")

def test_deactivate_no_permission(self):
exc = factories.FlowRuleExceptionFactory(
participation=self.student_participation,
flow_id=self.flow_id,
kind=constants.FlowRuleKind.access,
active=True)
with self.temporarily_switch_to_user(self.student_participation.user):
resp = self.client.post(self.get_deactivate_url(exc, self.gopp))
self.assertEqual(resp.status_code, 403)
exc.refresh_from_db()
self.assertTrue(exc.active)

def test_deactivate_get_not_allowed(self):
exc = factories.FlowRuleExceptionFactory(
participation=self.student_participation,
flow_id=self.flow_id,
kind=constants.FlowRuleKind.access,
active=True)
url = self.get_deactivate_url(exc, self.gopp)
with self.temporarily_switch_to_user(
self.get_default_instructor_user(
self.get_default_course_identifier())):
resp = self.client.get(url)
self.assertEqual(resp.status_code, 405)
exc.refresh_from_db()
self.assertTrue(exc.active)

def test_deactivate_wrong_course(self):
another_course = factories.CourseFactory(identifier="another-course")
another_participation = factories.ParticipationFactory(
course=another_course)
exc = factories.FlowRuleExceptionFactory(
participation=another_participation,
flow_id=self.flow_id,
kind=constants.FlowRuleKind.access,
active=True)
resp = self.post_deactivate(exc, self.gopp)
self.assertEqual(resp.status_code, 400)
exc.refresh_from_db()
self.assertTrue(exc.active)


class EditGradingOpportunityTest(GradesTestMixin, TestCase):
# test grades.edit_grading_opportunity

Expand Down
Loading