Skip to content
# -*- coding: utf-8 -*-
from __future__ import annotations
from __future__ import division
__copyright__ = "Copyright (C) 2014 Andreas Kloeckner"
......@@ -24,75 +23,60 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
"""
from six.moves import intern
from sys import intern
from typing import TYPE_CHECKING, Any
from django.utils.translation import (
ugettext_lazy as _,
pgettext,
string_concat)
from django.shortcuts import ( # noqa
render, get_object_or_404, redirect)
from crispy_forms.layout import Submit
from django import (
forms,
http,
)
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.contrib.auth import get_user_model
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import PermissionDenied, SuspiciousOperation
from django.conf import settings
from django.db import IntegrityError, transaction
from django.shortcuts import get_object_or_404, redirect, render # noqa
from django.urls import reverse
from django.db import transaction, IntegrityError
from django import forms
from django import http # noqa
from django.utils import translation
from django.utils.safestring import mark_safe
from crispy_forms.layout import Submit
from course.models import (
user_status,
Course,
Participation,
ParticipationPreapproval,
ParticipationPermission,
ParticipationRole,
ParticipationTag,
participation_status)
from course.constants import (
PARTICIPATION_PERMISSION_CHOICES,
participation_permission as pperm,
)
from django.utils.translation import gettext_lazy as _, pgettext
from pytools.lex import RE as REBase # noqa
from course.auth import UserSearchWidget
from course.constants import (
PARTICIPATION_PERMISSION_CHOICES,
participation_permission as pperm,
)
from course.models import (
Course,
Participation,
ParticipationPermission,
ParticipationPreapproval,
ParticipationRole,
ParticipationTag,
participation_status,
user_status,
)
from course.utils import LanguageOverride, course_view, render_course_page
from relate.utils import StyledForm, StyledModelForm, string_concat
from course.utils import course_view, render_course_page
from relate.utils import StyledForm, StyledModelForm
from pytools.lex import RE as REBase
# {{{ for mypy
from typing import Any, Tuple, Text, Optional # noqa
from course.utils import CoursePageContext # noqa
if TYPE_CHECKING:
import accounts.models
from course.utils import CoursePageContext
# }}}
# {{{ get_participation_for_request
def get_participation_for_request(request, course):
# type: (http.HttpRequest, Course) -> Optional[Participation]
# "wake up" lazy object
# http://stackoverflow.com/questions/20534577/int-argument-must-be-a-string-or-a-number-not-simplelazyobject # noqa
user = request.user
try:
possible_user = user._wrapped
except AttributeError:
pass
else:
if isinstance(possible_user, get_user_model()):
user = possible_user
# {{{ get_participation_for_{user,request}
def get_participation_for_user(
user: accounts.models.User | AnonymousUser, course: Course
) -> Participation | None:
if not user.is_authenticated:
return None
......@@ -110,16 +94,20 @@ def get_participation_for_request(request, course):
return participations[0]
def get_participation_for_request(
request: http.HttpRequest, course: Course) -> Participation | None:
return get_participation_for_user(request.user, course)
# }}}
# {{{ get_participation_role_identifiers
def get_participation_role_identifiers(course, participation):
# type: (Course, Optional[Participation]) -> List[Text]
def get_participation_role_identifiers(
course: Course, participation: Participation | None) -> list[str]:
if participation is None:
return (
return list(
ParticipationRole.objects.filter(
course=course,
is_default_for_unenrolled=True)
......@@ -134,10 +122,9 @@ def get_participation_role_identifiers(course, participation):
# {{{ get_permissions
def get_participation_permissions(
course, # type: Course
participation, # type: Optional[Participation]
):
# type: (...) -> frozenset[Tuple[Text, Optional[Text]]]
course: Course,
participation: Participation | None,
) -> frozenset[tuple[str, str | None]]:
if participation is not None:
return participation.permissions()
......@@ -162,15 +149,40 @@ def get_participation_permissions(
@login_required
@transaction.atomic
def enroll_view(request, course_identifier):
# type: (http.HttpRequest, str) -> http.HttpResponse
def enroll_view(
request: http.HttpRequest, course_identifier: str) -> http.HttpResponse:
course = get_object_or_404(Course, identifier=course_identifier)
participation = get_participation_for_request(request, course)
user = request.user
assert user.is_authenticated
participations = Participation.objects.filter(course=course, user=user)
if not participations.count():
participation = None
else:
participation = participations.first()
if participation is not None:
messages.add_message(request, messages.ERROR,
_("Already enrolled. Cannot re-renroll."))
if participation.status == participation_status.requested:
messages.add_message(request, messages.ERROR,
_("You have previously sent the enrollment "
"request. Re-sending the request is not "
"allowed."))
return redirect("relate-course_page", course_identifier)
elif participation.status == participation_status.denied:
messages.add_message(request, messages.ERROR,
_("Your enrollment request had been denied. "
"Enrollment is not allowed."))
return redirect("relate-course_page", course_identifier)
elif participation.status == participation_status.dropped:
messages.add_message(request, messages.ERROR,
_("You had been dropped from the course. "
"Re-enrollment is not allowed."))
return redirect("relate-course_page", course_identifier)
else:
assert participation.status == participation_status.active
messages.add_message(request, messages.ERROR,
_("Already enrolled. Cannot re-enroll."))
return redirect("relate-course_page", course_identifier)
if not course.accepts_enrollment:
......@@ -185,83 +197,99 @@ def enroll_view(request, course_identifier):
_("Can only enroll using POST request"))
return redirect("relate-course_page", course_identifier)
user = request.user
if (course.enrollment_required_email_suffix
and user.status != user_status.active):
if user.status != user_status.active:
messages.add_message(request, messages.ERROR,
_("Your email address is not yet confirmed. "
"Confirm your email to continue."))
return redirect("relate-course_page", course_identifier)
preapproval = None
if request.user.email:
if user.email: # pragma: no branch (user email NOT NULL constraint)
try:
preapproval = ParticipationPreapproval.objects.get(
course=course, email__iexact=request.user.email)
course=course, email__iexact=user.email)
except ParticipationPreapproval.DoesNotExist:
if user.institutional_id:
if not (course.preapproval_require_verified_inst_id
and not user.institutional_id_verified):
try:
preapproval = ParticipationPreapproval.objects.get(
course=course,
institutional_id__iexact=user.institutional_id)
except ParticipationPreapproval.DoesNotExist:
pass
pass
if (
preapproval is None
and course.enrollment_required_email_suffix
and not user.email.endswith(course.enrollment_required_email_suffix)):
if preapproval is None:
if user.institutional_id:
if not (course.preapproval_require_verified_inst_id
and not user.institutional_id_verified):
try:
preapproval = ParticipationPreapproval.objects.get(
course=course,
institutional_id__iexact=user.institutional_id)
except ParticipationPreapproval.DoesNotExist:
pass
def email_suffix_matches(email: str, suffix: str) -> bool:
if suffix.startswith("@"):
return email.endswith(suffix)
else:
return email.endswith(f"@{suffix}") or email.endswith(f".{suffix}")
if (preapproval is None
and course.enrollment_required_email_suffix
and not email_suffix_matches(
user.email, course.enrollment_required_email_suffix)):
messages.add_message(request, messages.ERROR,
_("Enrollment not allowed. Please use your '%s' email to "
"enroll.") % course.enrollment_required_email_suffix)
return redirect("relate-course_page", course_identifier)
roles = ParticipationRole.objects.filter(
course=course,
is_default_for_new_participants=True)
if preapproval is not None:
roles = list(preapproval.roles.all())
roles = preapproval.roles.all()
else:
roles = ParticipationRole.objects.filter(
course=course,
is_default_for_new_participants=True)
roles_list = list(roles)
try:
if course.enrollment_approval_required and preapproval is None:
participation = handle_enrollment_request(
course, user, participation_status.requested,
roles, request)
with translation.override(settings.RELATE_ADMIN_EMAIL_LOCALE):
from django.template.loader import render_to_string
message = render_to_string("course/enrollment-request-email.txt", {
"user": user,
"course": course,
"admin_uri": mark_safe(
request.build_absolute_uri(
reverse("relate-edit_participation",
args=(course.identifier, participation.id))))
roles_list, request)
assert participation is not None
with LanguageOverride(course=course):
from relate.utils import render_email_template
message = render_email_template(
"course/enrollment-request-email.txt", {
"user": user,
"course": course,
"admin_uri": mark_safe(
request.build_absolute_uri(
reverse("relate-edit_participation",
args=(course.identifier, participation.id))))
})
from django.core.mail import EmailMessage
msg = EmailMessage(
string_concat("[%s] ", _("New enrollment request"))
% course_identifier,
message,
settings.ROBOT_EMAIL_FROM,
[course.notify_email])
string_concat("[%s] ", _("New enrollment request"))
% course_identifier,
message,
getattr(settings, "ENROLLMENT_EMAIL_FROM",
settings.ROBOT_EMAIL_FROM),
[course.notify_email])
from relate.utils import get_outbound_mail_connection
msg.connection = get_outbound_mail_connection("robot")
msg.connection = (
get_outbound_mail_connection("enroll")
if hasattr(settings, "ENROLLMENT_EMAIL_FROM")
else get_outbound_mail_connection("robot"))
msg.send()
messages.add_message(request, messages.INFO,
_("Enrollment request sent. You will receive notifcation "
_("Enrollment request sent. You will receive notification "
"by email once your request has been acted upon."))
else:
handle_enrollment_request(course, user, participation_status.active,
roles, request)
roles_list, request)
messages.add_message(request, messages.SUCCESS,
_("Successfully enrolled."))
......@@ -274,8 +302,13 @@ def enroll_view(request, course_identifier):
@transaction.atomic
def handle_enrollment_request(course, user, status, roles, request=None):
# type: (Course, Any, Text, List[Text], Optional[http.HttpRequest]) -> Participation # noqa
def handle_enrollment_request(
course: Course,
user: Any,
status: str,
roles: list[ParticipationRole] | None,
request: http.HttpRequest | None = None
) -> Participation:
participations = Participation.objects.filter(course=course, user=user)
assert participations.count() <= 1
......@@ -286,13 +319,14 @@ def handle_enrollment_request(course, user, status, roles, request=None):
participation.status = status
participation.save()
if roles is not None:
participation.roles.set(roles)
else:
(participation,) = participations
participation.status = status
participation.save()
if roles is not None:
participation.roles.set(roles)
if status == participation_status.active:
send_enrollment_decision(participation, True, request)
elif status == participation_status.denied:
......@@ -327,11 +361,12 @@ def decide_enrollment(approved, modeladmin, request, queryset):
_("%d requests processed.") % count)
def send_enrollment_decision(participation, approved, request=None):
# type: (Participation, bool, http.HttpRequest) -> None
with translation.override(settings.RELATE_ADMIN_EMAIL_LOCALE):
course = participation.course
def send_enrollment_decision(
participation: Participation,
approved: bool,
request: http.HttpRequest | None = None) -> None:
course = participation.course
with LanguageOverride(course=course):
if request:
course_uri = request.build_absolute_uri(
reverse("relate-course_page",
......@@ -339,12 +374,12 @@ def send_enrollment_decision(participation, approved, request=None):
else:
# This will happen when this method is triggered by
# a model signal which doesn't contain a request object.
from six.moves.urllib.parse import urljoin
course_uri = urljoin(getattr(settings, "RELATE_BASE_URL"),
from urllib.parse import urljoin
course_uri = urljoin(settings.RELATE_BASE_URL,
course.get_absolute_url())
from django.template.loader import render_to_string
message = render_to_string("course/enrollment-decision-email.txt", {
from relate.utils import render_email_template
message = render_email_template("course/enrollment-decision-email.txt", {
"user": participation.user,
"approved": approved,
"course": course,
......@@ -352,16 +387,27 @@ def send_enrollment_decision(participation, approved, request=None):
})
from django.core.mail import EmailMessage
email_kwargs = {}
if settings.RELATE_EMAIL_SMTP_ALLOW_NONAUTHORIZED_SENDER:
from_email = course.get_from_email()
else:
from_email = getattr(settings, "ENROLLMENT_EMAIL_FROM",
settings.ROBOT_EMAIL_FROM)
from relate.utils import get_outbound_mail_connection
email_kwargs.update(
{"connection": (
get_outbound_mail_connection("enroll")
if hasattr(settings, "ENROLLMENT_EMAIL_FROM")
else get_outbound_mail_connection("robot"))})
msg = EmailMessage(
string_concat("[%s] ", _("Your enrollment request"))
% course.identifier,
message,
course.get_from_email(),
[participation.user.email])
from_email,
[participation.user.email],
**email_kwargs)
msg.bcc = [course.notify_email]
if not settings.RELATE_EMAIL_SMTP_ALLOW_NONAUTHORIZED_SENDER:
from relate.utils import get_outbound_mail_connection
msg.connection = get_outbound_mail_connection("robot")
msg.send()
......@@ -383,7 +429,7 @@ deny_enrollment.short_description = _("Deny enrollment") # type:ignore # noqa
class BulkPreapprovalsForm(StyledForm):
def __init__(self, course, *args, **kwargs):
super(BulkPreapprovalsForm, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
self.fields["roles"] = forms.ModelMultipleChoiceField(
queryset=(
......@@ -401,7 +447,7 @@ class BulkPreapprovalsForm(StyledForm):
self.fields["preapproval_data"] = forms.CharField(
required=True, widget=forms.Textarea,
help_text=_("Enter fully qualified data according to the "
"\"Preapproval type\" you selected, one per line."),
"'Preapproval type' you selected, one per line."),
label=_("Preapproval data"))
self.helper.add_input(
......@@ -426,93 +472,59 @@ def create_preapprovals(pctx):
pending_approved_count = 0
roles = form.cleaned_data["roles"]
for l in form.cleaned_data["preapproval_data"].split("\n"):
l = l.strip()
preapp_type = form.cleaned_data["preapproval_type"]
if not l:
continue
preapp_type = form.cleaned_data["preapproval_type"]
if preapp_type == "email":
try:
preapproval = ParticipationPreapproval.objects.get(
email__iexact=l,
course=pctx.course)
except ParticipationPreapproval.DoesNotExist:
# approve if l is requesting enrollment
try:
pending = Participation.objects.get(
course=pctx.course,
status=participation_status.requested,
user__email__iexact=l)
except Participation.DoesNotExist:
pass
else:
pending.status = \
participation_status.active
pending.save()
send_enrollment_decision(
pending, True, request)
pending_approved_count += 1
for ln in form.cleaned_data["preapproval_data"].split("\n"):
ln = ln.strip()
else:
exist_count += 1
continue
if not ln:
continue
preapproval = ParticipationPreapproval()
preapproval.email = l
preapproval.course = pctx.course
preapproval.creator = request.user
preapproval.save()
preapproval.roles.set(roles)
preapp_filter_kwargs = {f"{preapp_type}__iexact": ln}
created_count += 1
try:
ParticipationPreapproval.objects.get(
course=pctx.course, **preapp_filter_kwargs)
except ParticipationPreapproval.DoesNotExist:
elif preapp_type == "institutional_id":
# approve if ln is requesting enrollment
user_filter_kwargs = {f"user__{preapp_type}__iexact": ln}
if preapp_type == "institutional_id":
if pctx.course.preapproval_require_verified_inst_id:
user_filter_kwargs.update(
{"user__institutional_id_verified": True})
try:
preapproval = ParticipationPreapproval.objects.get(
course=pctx.course, institutional_id__iexact=l)
except ParticipationPreapproval.DoesNotExist:
# approve if l is requesting enrollment
try:
pending = Participation.objects.get(
course=pctx.course,
status=participation_status.requested,
user__institutional_id__iexact=l)
if (
pctx.course.preapproval_require_verified_inst_id
and not pending.user.institutional_id_verified):
raise Participation.DoesNotExist
except Participation.DoesNotExist:
pass
else:
pending.status = participation_status.active
pending.save()
send_enrollment_decision(
pending, True, request)
pending_approved_count += 1
pending = Participation.objects.get(
course=pctx.course,
status=participation_status.requested,
**user_filter_kwargs)
except Participation.DoesNotExist:
pass
else:
exist_count += 1
continue
pending.status = participation_status.active
pending.save()
send_enrollment_decision(pending, True, request)
pending_approved_count += 1
preapproval = ParticipationPreapproval()
preapproval.institutional_id = l
preapproval.course = pctx.course
preapproval.creator = request.user
preapproval.save()
preapproval.roles.set(roles)
else:
exist_count += 1
continue
preapproval = ParticipationPreapproval()
if preapp_type == "email":
preapproval.email = ln
else:
assert preapp_type == "institutional_id"
preapproval.institutional_id = ln
preapproval.course = pctx.course
preapproval.creator = request.user
preapproval.save()
preapproval.roles.set(roles)
created_count += 1
created_count += 1
messages.add_message(request, messages.INFO,
_(
......@@ -520,9 +532,9 @@ def create_preapprovals(pctx):
"%(n_exist)d already existed, "
"%(n_requested_approved)d pending requests approved.")
% {
'n_created': created_count,
'n_exist': exist_count,
'n_requested_approved': pending_approved_count
"n_created": created_count,
"n_exist": exist_count,
"n_requested_approved": pending_approved_count
})
return redirect("relate-course_page", pctx.course.identifier)
......@@ -552,6 +564,8 @@ _email = intern("email")
_email_contains = intern("email_contains")
_user = intern("user")
_user_contains = intern("user_contains")
_institutional_id = intern("institutional_id")
_institutional_id_contains = intern("institutional_id__contains")
_tagged = intern("tagged")
_role = intern("role")
_status = intern("status")
......@@ -563,10 +577,9 @@ _whitespace = intern("whitespace")
class RE(REBase):
def __init__(self, s):
# type: (str) -> None
def __init__(self, s: str) -> None:
import re
super(RE, self).__init__(s, re.UNICODE)
super().__init__(s, re.UNICODE)
_LEX_TABLE = [
......@@ -582,6 +595,9 @@ _LEX_TABLE = [
(_email_contains, RE(r"email-contains:([^ \t\n\r\f\v)]+)")),
(_user, RE(r"username:([^ \t\n\r\f\v)]+)")),
(_user_contains, RE(r"username-contains:([^ \t\n\r\f\v)]+)")),
(_institutional_id, RE(r"institutional-id:([^ \t\n\r\f\v)]+)")),
(_institutional_id_contains,
RE(r"institutional-id-contains:([^ \t\n\r\f\v)]+)")),
(_tagged, RE(r"tagged:([-\w]+)")),
(_role, RE(r"role:(\w+)")),
(_status, RE(r"status:(\w+)")),
......@@ -593,7 +609,9 @@ _LEX_TABLE = [
_TERMINALS = ([
_id, _email, _email_contains, _user, _user_contains, _tagged, _role, _status])
_id, _email, _email_contains, _user, _user_contains, _tagged, _role, _status,
_institutional_id, _institutional_id_contains
])
# {{{ operator precedence
......@@ -636,8 +654,20 @@ def parse_query(course, expr_str):
pstate.advance()
return result
elif next_tag is _institutional_id:
result = Q(
user__institutional_id__iexact=pstate.next_match_obj().group(1))
pstate.advance()
return result
elif next_tag is _institutional_id_contains:
result = Q(
user__institutional_id__icontains=pstate.next_match_obj().group(1))
pstate.advance()
return result
elif next_tag is _tagged:
ptag, created = ParticipationTag.objects.get_or_create(
ptag, _created = ParticipationTag.objects.get_or_create(
course=course,
name=pstate.next_match_obj().group(1))
......@@ -647,7 +677,13 @@ def parse_query(course, expr_str):
return result
elif next_tag is _role:
result = Q(role=pstate.next_match_obj().group(1))
name_map = {"teaching_assistant": "ta"}
name = pstate.next_match_obj().group(1)
prole, _created = ParticipationRole.objects.get_or_create(
course=course,
identifier=name_map.get(name, name))
result = Q(roles__pk=prole.pk)
pstate.advance()
return result
......@@ -708,7 +744,7 @@ def parse_query(course, expr_str):
pstate.advance()
left_query = left_query | inner_parse(pstate, _PREC_OR)
did_something = True
elif (next_tag in _TERMINALS + [_not, _openpar]
elif (next_tag in [*_TERMINALS, _not, _openpar]
and _PREC_AND > min_precedence):
left_query = left_query & inner_parse(pstate, _PREC_AND)
did_something = True
......@@ -742,7 +778,7 @@ class ParticipationQueryForm(StyledForm):
required=True,
widget=forms.Textarea,
help_text=string_concat(
_("Enter queries, one per line."), " ",
_("Enter queries, one per line. Union of results is shown."), " ",
_("Allowed"), ": ",
"<code>and</code>, "
"<code>or</code>, "
......@@ -752,6 +788,8 @@ class ParticipationQueryForm(StyledForm):
"<code>email-contains:abc</code>, "
"<code>username:abc</code>, "
"<code>username-contains:abc</code>, "
"<code>institutional-id:2015abcd</code>, "
"<code>institutional-id-contains:2015</code>, "
"<code>tagged:abc</code>, "
"<code>role:instructor|teaching_assistant|"
"student|observer|auditor</code>, "
......@@ -773,19 +811,31 @@ class ParticipationQueryForm(StyledForm):
required=False)
def __init__(self, *args, **kwargs):
super(ParticipationQueryForm, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
self.helper.add_input(
Submit("list", _("List")))
self.helper.add_input(
Submit("apply", _("Apply operation")))
def clean_tag(self):
tag = self.cleaned_data.get("tag")
if tag:
if not tag.isidentifier():
self.add_error(
"tag",
_("Name contains invalid characters."))
return tag
@login_required
@transaction.atomic
@course_view
def query_participations(pctx):
if not pctx.has_permission(pperm.query_participation):
if (
not pctx.has_permission(pperm.query_participation)
or pctx.has_permission(pperm.view_participant_masked_profile)):
raise PermissionDenied(_("may not query participations"))
request = pctx.request
......@@ -797,7 +847,8 @@ def query_participations(pctx):
if form.is_valid():
parsed_query = None
try:
for lineno, q in enumerate(form.cleaned_data["queries"].split("\n")):
for lineno, q in enumerate( # noqa: B007
form.cleaned_data["queries"].split("\n")):
q = q.strip()
if not q:
......@@ -840,12 +891,11 @@ def query_participations(pctx):
course=pctx.course, name=form.cleaned_data["tag"])
for p in result:
p.tags.remove(ptag)
elif form.cleaned_data["op"] == "drop":
else:
assert form.cleaned_data["op"] == "drop"
for p in result:
p.status = participation_status.dropped
p.save()
else:
raise RuntimeError("unexpected operation")
messages.add_message(request, messages.INFO,
"Operation successful on %d participations."
......@@ -865,9 +915,14 @@ def query_participations(pctx):
# {{{ edit_participation
class EditParticipationForm(StyledModelForm):
def __init__(self, add_new, pctx, *args, **kwargs):
# type: (bool, CoursePageContext, *Any, **Any) -> None
super(EditParticipationForm, self).__init__(*args, **kwargs)
def __init__(self, add_new: bool, pctx: CoursePageContext,
*args: Any, **kwargs: Any) -> None:
if not add_new:
kwargs.setdefault("initial", {})["individual_permissions"] = (
list(kwargs["instance"].individual_permissions.all()))
super().__init__(*args, **kwargs)
participation = self.instance
......@@ -877,27 +932,31 @@ class EditParticipationForm(StyledModelForm):
if not add_new:
self.fields["user"].disabled = True
else:
participation_users = Participation.objects.filter(
course=participation.course).values_list("user__pk", flat=True)
self.fields["user"].queryset = ( # type: ignore[attr-defined]
get_user_model().objects.exclude(pk__in=participation_users)
)
self.add_new = add_new
may_edit_permissions = pctx.has_permission(pperm.edit_course_permissions)
if not may_edit_permissions:
self.fields["roles"].disabled = True
# FIXME Add individual permissions
self.fields["roles"].queryset = (
self.fields["roles"].queryset = ( # type: ignore[attr-defined]
ParticipationRole.objects.filter(
course=participation.course))
self.fields["tags"].queryset = (
self.fields["tags"].queryset = ( # type: ignore[attr-defined]
ParticipationTag.objects.filter(
course=participation.course))
self.fields["individual_permissions"] = forms.MultipleChoiceField(
choices=PARTICIPATION_PERMISSION_CHOICES,
disabled=not may_edit_permissions,
#widget=forms.CheckboxSelectMultiple,
widget=forms.CheckboxSelectMultiple,
help_text=_("Permissions for this participant in addition to those "
"granted by their role"),
initial=self.instance.individual_permissions.values_list(
"permission", flat=True),
required=False)
self.helper.add_input(
......@@ -908,14 +967,23 @@ class EditParticipationForm(StyledModelForm):
if participation.status == participation_status.requested:
self.helper.add_input(
Submit("deny", _("Deny"), css_class="btn-danger"))
elif participation.status == participation_status.active:
else:
self.helper.add_input(
Submit("drop", _("Drop"), css_class="btn-danger"))
def save(self):
# type: () -> None
def clean_user(self):
user = self.cleaned_data["user"]
if not self.add_new:
return user
if user.status == user_status.active:
return user
super(EditParticipationForm, self).save()
raise forms.ValidationError(
_("This user has not confirmed his/her email."))
def save(self, commit: bool = True) -> Participation:
inst = super().save(commit)
(ParticipationPermission.objects
.filter(participation=self.instance)
......@@ -930,6 +998,8 @@ class EditParticipationForm(StyledModelForm):
pps.append(pp)
self.instance.individual_permissions.set(pps)
return inst
class Meta:
model = Participation
exclude = (
......@@ -943,8 +1013,8 @@ class EditParticipationForm(StyledModelForm):
@course_view
def edit_participation(pctx, participation_id):
# type: (CoursePageContext, int) -> http.HttpResponse
def edit_participation(
pctx: CoursePageContext, participation_id: int) -> http.HttpResponse:
if not pctx.has_permission(pperm.edit_participation):
raise PermissionDenied()
......@@ -964,44 +1034,64 @@ def edit_participation(pctx, participation_id):
if participation.course.id != pctx.course.id:
raise SuspiciousOperation("may not edit participation in different course")
if request.method == 'POST':
form = None # type: Optional[EditParticipationForm]
if request.method == "POST":
form = EditParticipationForm(
add_new, pctx, request.POST, instance=participation)
reset_form = False
if "submit" in request.POST:
form = EditParticipationForm(
add_new, pctx, request.POST, instance=participation)
try:
if form.is_valid():
if "submit" in request.POST:
form.save()
if form.is_valid(): # type: ignore
form.save() # type: ignore
elif "approve" in request.POST:
messages.add_message(request, messages.SUCCESS,
_("Changes saved."))
send_enrollment_decision(participation, True, pctx.request)
elif "approve" in request.POST:
participation.status = participation_status.active
participation.save()
# FIXME: Double-saving
participation = form.save()
participation.status = participation_status.active
participation.save()
reset_form = True
messages.add_message(request, messages.SUCCESS,
_("Successfully enrolled."))
send_enrollment_decision(participation, True, pctx.request)
elif "deny" in request.POST:
send_enrollment_decision(participation, False, pctx.request)
messages.add_message(request, messages.SUCCESS,
_("Successfully enrolled."))
participation.status = participation_status.denied
participation.save()
elif "deny" in request.POST:
messages.add_message(request, messages.SUCCESS,
_("Successfully denied."))
# FIXME: Double-saving
participation = form.save()
participation.status = participation_status.denied
participation.save()
reset_form = True
elif "drop" in request.POST:
participation.status = participation_status.dropped
participation.save()
send_enrollment_decision(participation, False, pctx.request)
messages.add_message(request, messages.SUCCESS,
_("Successfully dropped."))
messages.add_message(request, messages.SUCCESS,
_("Successfully denied."))
if form is None:
form = EditParticipationForm(add_new, pctx, instance=participation)
elif "drop" in request.POST:
# FIXME: Double-saving
participation = form.save()
participation.status = participation_status.dropped
participation.save()
reset_form = True
messages.add_message(request, messages.SUCCESS,
_("Successfully dropped."))
except IntegrityError as e:
messages.add_message(request, messages.ERROR,
_("A data integrity issue was detected when saving "
"this participation. Maybe a participation for "
"this user already exists? (%s)")
% str(e))
if reset_form:
form = EditParticipationForm(
add_new, pctx, instance=participation)
else:
form = EditParticipationForm(add_new, pctx, instance=participation)
......
# -*- coding: utf-8 -*-
from __future__ import annotations
from __future__ import division
__copyright__ = "Copyright (C) 2015 Andreas Kloeckner"
......@@ -24,41 +23,64 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
"""
import six
from django.contrib.auth import get_user_model
from collections.abc import Collection
from dataclasses import dataclass
from typing import TYPE_CHECKING, cast
import django.forms as forms
from django.utils.translation import (
ugettext, ugettext_lazy as _, string_concat,
pgettext)
from django.shortcuts import ( # noqa
render, get_object_or_404, redirect)
from django.core.exceptions import ( # noqa
PermissionDenied, ObjectDoesNotExist, SuspiciousOperation)
from django.contrib import messages # noqa
from crispy_forms.layout import Submit
from django import http
from django.contrib import messages
from django.contrib.auth import get_user_model
from django.contrib.auth.decorators import permission_required
from django import http # noqa
from django.core.exceptions import ( # noqa
ObjectDoesNotExist,
PermissionDenied,
SuspiciousOperation,
)
from django.db import transaction
from django.db.models import Q
from django.http.request import HttpRequest
from django.shortcuts import get_object_or_404, redirect, render # noqa
from django.urls import reverse
from django.views.decorators.debug import sensitive_post_parameters
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.translation import gettext, gettext_lazy as _, pgettext
from django.views.decorators.cache import never_cache
from django.views.decorators.csrf import csrf_protect
from django.views.decorators.debug import sensitive_post_parameters
from django_select2.forms import Select2Widget
from crispy_forms.layout import Submit
from course.models import (Exam, ExamTicket, Participation,
FlowSession)
from course.utils import course_view, render_course_page
from course.constants import (
exam_ticket_states,
participation_status,
participation_permission as pperm)
from course.views import get_now_or_fake_time
SESSION_LOCKED_TO_FLOW_PK,
exam_ticket_states,
participation_permission as pperm,
participation_status,
)
from course.models import (
Course,
Exam,
ExamTicket,
FlowSession,
Participation,
ParticipationTag,
)
from course.utils import course_view, render_course_page
from relate.utils import (
HTML5DateTimeInput,
RelateHttpRequest,
StyledForm,
not_none,
string_concat,
)
from relate.utils import StyledForm
# {{{ mypy
if TYPE_CHECKING:
import datetime
# }}}
ticket_alphabet = "ABCDEFGHJKLPQRSTUVWXYZabcdefghjkpqrstuvwxyz23456789"
......@@ -71,28 +93,19 @@ def gen_ticket_code():
# {{{ issue ticket
class UserChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj):
user = obj
return (
"%(username)s - %(user_fullname)s"
% {
"username": user.username,
"user_fullname": user.get_full_name(),
})
class IssueTicketForm(StyledForm):
def __init__(self, now_datetime, *args, **kwargs):
initial_exam = kwargs.pop("initial_exam", None)
super(IssueTicketForm, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
from course.auth import UserSearchWidget
self.fields["user"] = UserChoiceField(
self.fields["user"] = forms.ModelChoiceField(
queryset=(get_user_model().objects
.filter(is_active=True)
.order_by("last_name")),
widget=Select2Widget(),
widget=UserSearchWidget(),
required=True,
help_text=_("Select participant for whom ticket is to "
"be issued."),
......@@ -112,6 +125,30 @@ class IssueTicketForm(StyledForm):
initial=initial_exam,
label=_("Exam"))
self.fields["valid_start_time"] = forms.DateTimeField(
label=_("Start validity"),
widget=HTML5DateTimeInput(),
required=False)
self.fields["valid_end_time"] = forms.DateTimeField(
label=_("End validity"),
widget=HTML5DateTimeInput(),
required=False)
self.fields["restrict_to_facility"] = forms.CharField(
label=_("Restrict to facility"),
help_text=_("If not blank, the exam ticket may only be used in the "
"given facility"),
required=False)
self.fields["require_login"] = forms.BooleanField(
required=False,
help_text=_(
"If set, the exam ticket can only be used once logged in"))
self.fields["code"] = forms.CharField(
help_text=_(
"If non-empty, this code will be used for the exam ticket"),
required=False,
widget=forms.PasswordInput())
self.fields["revoke_prior"] = forms.BooleanField(
label=_("Revoke prior exam tickets for this user"),
required=False,
......@@ -123,8 +160,10 @@ class IssueTicketForm(StyledForm):
_("Issue ticket")))
@permission_required("course.can_issue_exam_tickets")
@permission_required("course.can_issue_exam_tickets", raise_exception=True)
def issue_exam_ticket(request):
# must import locally for mock to work
from course.views import get_now_or_fake_time
now_datetime = get_now_or_fake_time(request)
if request.method == "POST":
......@@ -160,15 +199,29 @@ def issue_exam_ticket(request):
ticket.participation = participation
ticket.creator = request.user
ticket.state = exam_ticket_states.valid
ticket.code = gen_ticket_code()
if form.cleaned_data["code"]:
ticket.code = form.cleaned_data["code"]
else:
ticket.code = gen_ticket_code()
ticket.valid_start_time = form.cleaned_data["valid_start_time"]
ticket.valid_end_time = form.cleaned_data["valid_end_time"]
ticket.restrict_to_facility = \
form.cleaned_data["restrict_to_facility"]
ticket.require_login = form.cleaned_data["require_login"]
ticket.save()
messages.add_message(request, messages.SUCCESS,
_(
"Ticket issued for <b>%(participation)s</b>. "
"The ticket code is <b>%(ticket_code)s</b>."
) % {"participation": participation,
"ticket_code": ticket.code})
if form.cleaned_data["code"]:
messages.add_message(request, messages.SUCCESS,
_(
f"Ticket issued for <b>{participation}</b>. "
))
else:
messages.add_message(request, messages.SUCCESS,
_(
f"Ticket issued for <b>{participation}</b>. "
f"The ticket code is <b>{ticket.code}</b>."
))
form = IssueTicketForm(now_datetime, initial_exam=exam)
......@@ -201,10 +254,10 @@ INITIAL_EXAM_TICKET_TEMPLATE = string_concat("""\
{% for ticket in tickets %}
<tr>
<td>
{{ ticket.participation.user.username }}
{{ ticket.user_name }}
</td>
<td>
{{ ticket.participation.user.get_full_name }}
{{ ticket.full_name }}
</td>
<td>
{{ ticket.code }}
......@@ -217,12 +270,12 @@ INITIAL_EXAM_TICKET_TEMPLATE = string_concat("""\
{% for ticket in tickets %}
<h2 style="page-break-before: always">""",
_("Instructions for " # noqa
"{{ ticket.exam.description }}"), """
_("Instructions for "
"{{ ticket.exam_description }}"), """
</h2>
""", _("These are personalized instructions for "
"{{ ticket.participation.user.get_full_name }}."), """
"{{ ticket.full_name }}."), """
""", _("If this is not you, please let the proctor know "
"so that you can get the correct set of instructions."), """
......@@ -237,9 +290,10 @@ _("Instructions for " # noqa
""", _("Enter the following information"), ":", """
""", _("User name"), """: **`{{ ticket.participation.user.username }}`**
""", _("User name"), """: **`{{ ticket.user_name }}`**
""", pgettext("ticket code required to login exam", "Code"), """: **`{{ ticket.code }}`**
""", pgettext("ticket code required to login exam", "Code"),
""": **`{{ ticket.code }}`**
""", _("You have one hour to complete the exam."), """
......@@ -254,22 +308,22 @@ class BatchIssueTicketsForm(StyledForm):
use_required_attribute = False
def __init__(self, course, editor_mode, *args, **kwargs):
super(BatchIssueTicketsForm, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
from course.utils import get_codemirror_widget
cm_widget, cm_help_text = get_codemirror_widget(
language_mode={"name": "markdown", "xml": True},
dependencies=("xml",),
cm_widget, _cm_help_text = get_codemirror_widget(
language_mode="markdown",
interaction_mode=editor_mode)
help_text = (ugettext("Enter <a href=\"http://documen.tician.de/"
"relate/content.html#relate-markup\">"
help_text = (gettext('Enter <a href="http://documen.tician.de/'
'relate/content.html#relate-markup">'
"RELATE markup</a> containing Django template statements to render "
"your exam tickets. <tt>tickets</tt> contains a list of "
"data structures "
"containing ticket information. For each entry <tt>tkt</tt> "
"in this list, "
"use <tt>{{ tkt.participation.user.user_name }}</tt>, "
"use <tt>{{ tkt.user_name }}</tt>, "
"<tt>{{ tkt.full_name }}</tt>, "
"<tt>{{ tkt.code }}</tt>, <tt>{{ tkt.exam.description }}</tt>, "
"and <tt>{{ checkin_uri }}</tt> as placeholders. "
"See the example for how to use this."))
......@@ -282,16 +336,52 @@ class BatchIssueTicketsForm(StyledForm):
)),
required=True,
label=_("Exam"))
self.fields["limit_to_tag"] = forms.ModelChoiceField(
queryset=(
ParticipationTag.objects.filter(
course=course,
)),
label=_("Limit to tag"),
required=False,
help_text=_("If set, only issue tickets for participants having "
"this tag"))
self.fields["valid_start_time"] = forms.DateTimeField(
label=_("Start validity"),
widget=HTML5DateTimeInput(),
required=False)
self.fields["valid_end_time"] = forms.DateTimeField(
label=_("End validity"),
widget=HTML5DateTimeInput(),
required=False)
self.fields["restrict_to_facility"] = forms.CharField(
label=_("Restrict to facility"),
help_text=_("If not blank, the exam ticket may only be used in the "
"given facility"),
required=False)
self.fields["revoke_prior"] = forms.BooleanField(
label=_("Revoke prior exam tickets"),
required=False,
initial=False)
self.fields["format"] = forms.CharField(
label=_("Ticket Format"),
help_text=help_text,
widget=cm_widget,
initial=INITIAL_EXAM_TICKET_TEMPLATE,
required=True)
self.fields["revoke_prior"] = forms.BooleanField(
label=_("Revoke prior exam tickets"),
self.fields["require_login"] = forms.BooleanField(
required=False,
initial=False)
help_text=_(
"If set, exam tickets can only be used once logged in"))
self.fields["code"] = forms.CharField(
help_text=_(
"If non-empty, this code will be used for all exam tickets"),
required=False,
widget=forms.PasswordInput())
self.helper.add_input(
Submit(
......@@ -299,6 +389,14 @@ class BatchIssueTicketsForm(StyledForm):
_("Issue tickets")))
@dataclass(frozen=True)
class TicketInfo:
user_name: str
full_name: str
code: str
exam_description: str
@course_view
def batch_issue_exam_tickets(pctx):
if not pctx.has_permission(pperm.batch_issue_exam_ticket):
......@@ -314,7 +412,8 @@ def batch_issue_exam_tickets(pctx):
if form.is_valid():
exam = form.cleaned_data["exam"]
from jinja2 import TemplateSyntaxError
import minijinja
from course.content import markup_to_html
try:
with transaction.atomic():
......@@ -328,21 +427,41 @@ def batch_issue_exam_tickets(pctx):
).update(state=exam_ticket_states.revoked)
tickets = []
for participation in (
participation_qset = (
Participation.objects.filter(
course=pctx.course,
status=participation_status.active)
.order_by("user__last_name")
):
.order_by("user__last_name"))
if form.cleaned_data["limit_to_tag"]:
participation_qset = participation_qset.filter(
tags__pk=form.cleaned_data["limit_to_tag"].pk)
for participation in participation_qset:
ticket = ExamTicket()
ticket.exam = exam
ticket.participation = participation
ticket.creator = request.user
ticket.state = exam_ticket_states.valid
ticket.code = gen_ticket_code()
if form.cleaned_data["code"]:
ticket.code = form.cleaned_data["code"]
else:
ticket.code = gen_ticket_code()
ticket.require_login = form.cleaned_data["require_login"]
ticket.valid_start_time = \
form.cleaned_data["valid_start_time"]
ticket.valid_end_time = form.cleaned_data["valid_end_time"]
ticket.restrict_to_facility = \
form.cleaned_data["restrict_to_facility"]
ticket.save()
tickets.append(ticket)
tickets.append(
TicketInfo(
user_name=ticket.participation.user.username,
full_name=(
ticket.participation.user.get_full_name()),
code=ticket.code,
exam_description=ticket.exam.description,
))
checkin_uri = pctx.request.build_absolute_uri(
reverse("relate-check_in_for_exam"))
......@@ -352,21 +471,19 @@ def batch_issue_exam_tickets(pctx):
"tickets": tickets,
"checkin_uri": checkin_uri,
})
except TemplateSyntaxError as e:
except minijinja.TemplateError as e:
messages.add_message(request, messages.ERROR,
string_concat(
mark_safe(string_concat(
_("Template rendering failed"),
": line %(lineno)d: %(err_str)s")
% {
"lineno": e.lineno,
"err_str": e.message.decode("utf-8")})
": <pre>%(err_str)s</pre>")
% {"err_str": escape(str(e))}))
except Exception as e:
messages.add_message(request, messages.ERROR,
string_concat(
_("Template rendering failed"),
": %(err_type)s: %(err_str)s")
% {"err_type": type(e).__name__,
"err_str": str(e)})
"err_str": escape(str(e))})
else:
messages.add_message(request, messages.SUCCESS,
_("%d tickets issued.") % len(tickets))
......@@ -377,7 +494,7 @@ def batch_issue_exam_tickets(pctx):
return render_course_page(pctx, "course/batch-exam-tickets-form.html", {
"form": form,
"form_text": form_text,
"form_description": ugettext("Batch-Issue Exam Tickets")
"form_description": gettext("Batch-Issue Exam Tickets")
})
# }}}
......@@ -385,52 +502,116 @@ def batch_issue_exam_tickets(pctx):
# {{{ check in
def check_exam_ticket(username, code, now_datetime):
def _redirect_to_exam(
request: http.HttpRequest,
ticket: ExamTicket,
now_datetime: datetime.datetime) -> http.HttpResponse:
"""Assumes ticket is checked and valid."""
if ticket.state == exam_ticket_states.valid:
ticket.state = exam_ticket_states.used
ticket.usage_time = now_datetime
ticket.save()
pretend_facilities = request.session.get(
"relate_pretend_facilities", None)
if pretend_facilities:
# Make pretend-facilities survive exam login.
request.session["relate_pretend_facilities"] = pretend_facilities
request.session["relate_exam_ticket_pk_used_for_login"] = ticket.pk
return redirect("relate-view_start_flow",
ticket.exam.course.identifier,
ticket.exam.flow_id)
def check_exam_ticket(
username: str | None,
code: str | None,
now_datetime: datetime.datetime,
facilities: Collection[str] | None,
logged_in: bool,
restrict_to_course: Course | None = None
) -> tuple[bool, ExamTicket | None, str]:
"""
:returns: (is_valid, msg)
"""
_ = gettext
try:
user = get_user_model().objects.get(
username=username,
is_active=True)
ticket_kwargs = {}
if restrict_to_course is not None:
ticket_kwargs["participation__course"] = restrict_to_course
ticket = ExamTicket.objects.get(
participation__user=user,
code=code,
**ticket_kwargs
)
except ObjectDoesNotExist:
return (False, _("User name or ticket code not recognized."))
return (False, None, _("User name or ticket code not recognized."))
if ticket.state not in [
exam_ticket_states.valid,
exam_ticket_states.used
]:
return (False, _("Ticket is not in usable state. (Has it been revoked?)"))
return (False, ticket,
_("Ticket is not in usable state. (Has it been revoked?)"))
from django.conf import settings
from datetime import timedelta
from django.conf import settings
validity_period = timedelta(
minutes=settings.RELATE_TICKET_MINUTES_VALID_AFTER_USE)
if (ticket.state == exam_ticket_states.used
and now_datetime >= ticket.usage_time + validity_period):
return (False, _("Ticket has exceeded its validity period."))
and now_datetime >= not_none(ticket.usage_time) + validity_period):
return (False, ticket, _("Ticket has exceeded its validity period."))
if not ticket.exam.active:
return (False, ticket, _("Exam is not active."))
if ticket.exam.no_exams_before >= now_datetime:
return (False, _("Exam has not started yet."))
if now_datetime < ticket.exam.no_exams_before:
return (False, ticket, _("Exam has not started yet."))
if (
ticket.exam.no_exams_after is not None
and
ticket.exam.no_exams_after <= now_datetime):
return (False, _("Exam has ended."))
and ticket.exam.no_exams_after <= now_datetime):
return (False, ticket, _("Exam has ended."))
if (ticket.restrict_to_facility
and (
facilities is None
or ticket.restrict_to_facility not in facilities)):
return (False, ticket,
_("Exam ticket requires presence in facility '%s'.")
% ticket.restrict_to_facility)
if (
ticket.valid_start_time is not None
and now_datetime < ticket.valid_start_time):
return (False, ticket, _("Exam ticket is not yet valid."))
if (
ticket.valid_end_time is not None
and ticket.valid_end_time < now_datetime):
return (False, ticket, _("Exam ticket has expired."))
if not logged_in and ticket.require_login:
return (False, ticket, _("Exam ticket can only be used after logging in."))
return True, _("Ticket is valid.")
return True, ticket, _("Ticket is valid.")
class ExamTicketBackend(object):
def authenticate(self, username=None, code=None, now_datetime=None):
is_valid, msg = check_exam_ticket(username, code, now_datetime)
class ExamTicketBackend:
def authenticate(self, request, username=None, code=None, now_datetime=None,
facilities=None):
is_valid, _ticket, _msg = check_exam_ticket(
username, code, now_datetime, facilities, logged_in=False)
if not is_valid:
return None
......@@ -458,8 +639,8 @@ class ExamCheckInForm(StyledForm):
"given to you by a staff member. If you do not have one, "
"please follow the link above to log in."))
def __init__(self, *args, **kwargs):
super(ExamCheckInForm, self).__init__(*args, **kwargs)
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.helper.add_input(
Submit("submit", _("Check in")))
......@@ -468,7 +649,11 @@ class ExamCheckInForm(StyledForm):
@sensitive_post_parameters()
@csrf_protect
@never_cache
def check_in_for_exam(request):
def check_in_for_exam(request: http.HttpRequest) -> http.HttpResponse:
request = cast(RelateHttpRequest, request)
# must import locally for mock to work
from course.views import get_now_or_fake_time
now_datetime = get_now_or_fake_time(request)
if request.method == "POST":
......@@ -477,43 +662,27 @@ def check_in_for_exam(request):
username = form.cleaned_data["username"]
code = form.cleaned_data["code"]
pretend_facilities = request.session.get(
"relate_pretend_facilities", None)
is_valid, msg = check_exam_ticket(username, code, now_datetime)
is_valid, ticket, msg = check_exam_ticket(
username, code, now_datetime,
request.relate_facilities,
logged_in=False)
if not is_valid:
messages.add_message(request, messages.ERROR, msg)
else:
from django.contrib.auth import authenticate, login
user = authenticate(username=username, code=code,
now_datetime=now_datetime)
user = authenticate(
username=username,
code=code,
now_datetime=now_datetime,
facilities=request.relate_facilities)
assert user is not None
login(request, user)
ticket = ExamTicket.objects.get(
participation__user=user,
code=code,
state__in=(
exam_ticket_states.valid,
exam_ticket_states.used,
)
)
if ticket.state == exam_ticket_states.valid:
ticket.state = exam_ticket_states.used
ticket.usage_time = now_datetime
ticket.save()
if pretend_facilities:
# Make pretend-facilities survive exam login.
request.session["relate_pretend_facilities"] = pretend_facilities
request.session["relate_exam_ticket_pk_used_for_login"] = ticket.pk
assert ticket is not None
return redirect("relate-view_start_flow",
ticket.exam.course.identifier,
ticket.exam.flow_id)
return _redirect_to_exam(request, ticket, now_datetime)
else:
form = ExamCheckInForm()
......@@ -527,21 +696,24 @@ def check_in_for_exam(request):
# }}}
def is_from_exams_only_facility(request):
def is_from_exams_only_facility(request: HttpRequest) -> bool:
request = cast(RelateHttpRequest, request)
from course.utils import get_facilities_config
for name, props in six.iteritems(get_facilities_config(request)):
if not props.get("exams_only", False):
continue
facilities_config = get_facilities_config(request)
if facilities_config:
for name, props in facilities_config.items():
if not props.get("exams_only", False):
continue
# By now we know that this facility is exams-only
if name in request.relate_facilities:
return True
# By now we know that this facility is exams-only
if name in request.relate_facilities:
return True
return False
def get_login_exam_ticket(request):
# type: (http.HttpRequest) -> ExamTicket
def get_login_exam_ticket(request: http.HttpRequest) -> ExamTicket | None:
exam_ticket_pk = request.session.get("relate_exam_ticket_pk_used_for_login")
if exam_ticket_pk is None:
......@@ -552,30 +724,36 @@ def get_login_exam_ticket(request):
# {{{ lockdown middleware
class ExamFacilityMiddleware(object):
class ExamFacilityMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
def __call__(self, request: http.HttpRequest) -> http.HttpResponse:
exams_only = is_from_exams_only_facility(request)
if not exams_only:
return self.get_response(request)
if (exams_only and
"relate_session_locked_to_exam_flow_session_pk" in request.session):
if (exams_only and (SESSION_LOCKED_TO_FLOW_PK in request.session)):
# ExamLockdownMiddleware is in control.
return self.get_response(request)
from django.urls import resolve
resolver_match = resolve(request.path)
from course.auth import (
impersonate,
sign_in_by_email,
sign_in_by_user_pw,
sign_in_choice,
sign_in_stage2_with_token,
sign_out,
stop_impersonating,
user_profile,
)
from course.exam import check_in_for_exam, issue_exam_ticket
from course.auth import (user_profile, sign_in_choice, sign_in_by_email,
sign_in_stage2_with_token, sign_in_by_user_pw, sign_out, impersonate,
stop_impersonating)
from course.flow import view_flow_page, view_resume_flow, view_start_flow
from course.views import set_pretend_facilities
from course.flow import view_start_flow, view_resume_flow, view_flow_page
ok = False
if resolver_match.func in [
......@@ -602,10 +780,8 @@ class ExamFacilityMiddleware(object):
elif (
(request.user.is_staff
or
request.user.has_perm("course.can_issue_exam_tickets"))
and
resolver_match.func == issue_exam_ticket):
or request.user.has_perm("course.can_issue_exam_tickets"))
and resolver_match.func == issue_exam_ticket):
ok = True
if not ok:
......@@ -625,16 +801,15 @@ class ExamFacilityMiddleware(object):
return self.get_response(request)
class ExamLockdownMiddleware(object):
class ExamLockdownMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
request.relate_exam_lockdown = False
if "relate_session_locked_to_exam_flow_session_pk" in request.session:
exam_flow_session_pk = request.session[
"relate_session_locked_to_exam_flow_session_pk"]
if SESSION_LOCKED_TO_FLOW_PK in request.session:
exam_flow_session_pk = request.session[SESSION_LOCKED_TO_FLOW_PK]
try:
exam_flow_session = FlowSession.objects.get(pk=exam_flow_session_pk)
......@@ -642,20 +817,31 @@ class ExamLockdownMiddleware(object):
msg = _("Error while processing exam lockdown: "
"flow session not found.")
messages.add_message(request, messages.ERROR, msg)
raise SuspiciousOperation(msg)
raise PermissionDenied(msg)
request.relate_exam_lockdown = True
from django.urls import resolve
resolver_match = resolve(request.path)
from course.views import (get_repo_file, get_current_repo_file)
from course.auth import (
sign_in_by_email,
sign_in_by_user_pw,
sign_in_choice,
sign_in_stage2_with_token,
sign_out,
user_profile,
)
from course.flow import (
view_start_flow, view_resume_flow, view_flow_page,
update_expiration_mode, update_page_bookmark_state,
finish_flow_session_view)
from course.auth import (user_profile, sign_in_choice, sign_in_by_email,
sign_in_stage2_with_token, sign_in_by_user_pw, sign_out)
finish_flow_session_view,
update_expiration_mode,
update_page_bookmark_state,
view_flow_page,
view_flow_page_with_ext_resource_tabs,
view_resume_flow,
view_start_flow,
)
from course.views import get_current_repo_file, get_repo_file
ok = False
if resolver_match.func in [
......@@ -683,19 +869,20 @@ class ExamLockdownMiddleware(object):
resolver_match.func in [
view_resume_flow,
view_flow_page,
view_flow_page_with_ext_resource_tabs,
update_expiration_mode,
update_page_bookmark_state,
finish_flow_session_view]
and
int(resolver_match.kwargs["flow_session_id"])
== exam_flow_session_pk):
and (
int(resolver_match.kwargs["flow_session_id"])
== exam_flow_session_pk)):
ok = True
elif (
resolver_match.func == view_start_flow
and
resolver_match.kwargs["flow_id"]
== exam_flow_session.flow_id):
and (
resolver_match.kwargs["flow_id"]
== exam_flow_session.flow_id)):
ok = True
if not ok:
......@@ -703,7 +890,7 @@ class ExamLockdownMiddleware(object):
_("Your RELATE session is currently locked down "
"to this exam flow. Navigating to other parts of "
"RELATE is not currently allowed. "
"To abandon this exam, log out."))
"To exit this exam, log out."))
return redirect("relate-view_start_flow",
exam_flow_session.course.identifier,
exam_flow_session.flow_id)
......@@ -716,6 +903,8 @@ class ExamLockdownMiddleware(object):
# {{{ list available exams
def list_available_exams(request):
# must import locally for mock to work
from course.views import get_now_or_fake_time
now_datetime = get_now_or_fake_time(request)
if request.user.is_authenticated:
......@@ -732,11 +921,11 @@ def list_available_exams(request):
.filter(
course__in=[p.course for p in participations],
active=True,
listed=True,
no_exams_before__lt=now_datetime)
.filter(
Q(no_exams_after__isnull=True)
|
Q(no_exams_after__gt=now_datetime))
| Q(no_exams_after__gt=now_datetime))
.order_by("no_exams_before", "course__number"))
return render(request, "course/list-exams.html", {
......@@ -757,4 +946,59 @@ def exam_lockdown_context_processor(request):
# }}}
# {{{ access exam (with ticket, when logged in)
class ExamAccessForm(StyledForm):
code = forms.CharField(required=True, label=_("Code"),
widget=forms.PasswordInput(),
help_text=_("This is not your password, but a code that was "
"given to you by a staff member."))
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper.add_input(
Submit("submit", _("Go to exam")))
@course_view
def access_exam(pctx):
if pctx.participation is None:
raise PermissionDenied(_("must be logged in to access an exam"))
from course.views import get_now_or_fake_time
now_datetime = get_now_or_fake_time(pctx.request)
if pctx.request.method == "POST":
form = ExamAccessForm(pctx.request.POST)
if form.is_valid():
is_valid, ticket, msg = check_exam_ticket(
pctx.request.user.username,
form.cleaned_data["code"],
now_datetime,
pctx.request.relate_facilities,
logged_in=True,
restrict_to_course=pctx.course)
if not is_valid:
messages.add_message(pctx.request, messages.ERROR, msg)
raise PermissionDenied(msg)
assert ticket is not None
assert ticket.participation.pk == pctx.participation.pk
return _redirect_to_exam(pctx.request, ticket, now_datetime)
else:
form = ExamAccessForm()
return render(pctx.request, "course/generic-course-form.html", {
"course": pctx.course,
"form_description":
_("Access an Exam"),
"form": form
})
# }}}
# vim: foldmethod=marker
# -*- coding: utf-8 -*-
from __future__ import annotations
from __future__ import division
__copyright__ = "Copyright (C) 2014 Andreas Kloeckner"
......@@ -24,85 +23,90 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
"""
from django.utils import six
from django.utils.translation import (
ugettext, ugettext_lazy as _, string_concat)
from django.utils.functional import lazy
from django.shortcuts import ( # noqa
render, get_object_or_404, redirect)
from collections.abc import Iterable
from typing import TYPE_CHECKING, Any, cast
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Submit
from django import forms, http
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.exceptions import (
PermissionDenied, SuspiciousOperation,
ObjectDoesNotExist)
ObjectDoesNotExist,
PermissionDenied,
SuspiciousOperation,
)
from django.db import transaction
from django.db.models import query # noqa
from django.utils.safestring import mark_safe
mark_safe_lazy = lazy(mark_safe, six.text_type)
from django import forms
from django import http
from django.utils import translation
from django.conf import settings
from django.db.models import query
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from relate.utils import (
StyledForm, local_now, as_local_time,
format_datetime_local)
from crispy_forms.layout import Submit
from django.utils.safestring import mark_safe
from django.utils.translation import gettext, gettext_lazy as _
from django_select2.forms import Select2Widget
from course.constants import (
flow_permission,
participation_permission as pperm,
flow_session_expiration_mode,
FLOW_SESSION_EXPIRATION_MODE_CHOICES,
is_expiration_mode_allowed,
grade_aggregation_strategy,
GRADE_AGGREGATION_STRATEGY_CHOICES,
flow_session_interaction_kind
)
from course.models import (
FlowSession, FlowPageData, FlowPageVisit,
FlowPageVisitGrade,
get_feedback_for_grade,
GradeChange, update_bulk_feedback)
from course.utils import (
FlowContext,
FlowPageContext,
PageOrdinalOutOfRange,
instantiate_flow_page_with_ctx,
course_view, render_course_page,
get_session_start_rule,
get_session_access_rule,
get_session_grading_rule,
FlowSessionGradingRule,
)
FLOW_SESSION_EXPIRATION_MODE_CHOICES,
GRADE_AGGREGATION_STRATEGY_CHOICES,
SESSION_LOCKED_TO_FLOW_PK,
flow_permission,
flow_session_expiration_mode,
flow_session_interaction_kind,
grade_aggregation_strategy,
is_expiration_mode_allowed,
participation_permission as pperm,
)
from course.content import FlowPageDesc, TabDesc
from course.exam import get_login_exam_ticket
from course.models import (
Course,
FlowPageData,
FlowPageVisit,
FlowPageVisitGrade,
FlowSession,
GradeChange,
Participation,
get_feedback_for_grade,
update_bulk_feedback,
)
from course.page import InvalidPageData
from course.utils import (
FlowContext,
FlowPageContext,
FlowSessionGradingRule,
LanguageOverride,
PageOrdinalOutOfRange,
course_view,
get_session_access_rule,
get_session_grading_rule,
get_session_start_rule,
instantiate_flow_page_with_ctx,
render_course_page,
)
from course.views import get_now_or_fake_time
from relate.utils import retry_transaction_decorator
from relate.utils import (
StyledForm,
as_local_time,
format_datetime_local,
local_now,
not_none,
remote_address_from_request,
retry_transaction_decorator,
string_concat,
)
# {{{ mypy
from typing import Any, Optional, Iterable, Tuple, Text # noqa
import datetime # noqa
from course.models import ( # noqa
Course,
Participation
)
from course.utils import ( # noqa
CoursePageContext,
FlowSessionStartRule,
)
from course.content import ( # noqa
FlowDesc,
)
from course.page.base import ( # noqa
PageBase,
PageBehavior,
AnswerFeedback
)
from relate.utils import Repo_ish # noqa
if TYPE_CHECKING:
import datetime
from accounts.models import User
from course.content import FlowDesc
from course.models import Course
from course.page.base import AnswerFeedback, PageBase, PageBehavior
from course.utils import CoursePageContext, FlowSessionStartRule
from relate.utils import Repo_ish
# }}}
......@@ -110,8 +114,12 @@ from relate.utils import Repo_ish # noqa
# {{{ page data wrangling
@retry_transaction_decorator(serializable=True)
def _adjust_flow_session_page_data_inner(repo, flow_session,
course_identifier, flow_desc, commit_sha):
def _adjust_flow_session_page_data_inner(
repo: Repo_ish, flow_session: FlowSession,
course_identifier: str, flow_desc, commit_sha: bytes) -> int:
"""
:returns: new page count
"""
from course.page.base import PageContext
pctx = PageContext(
course=flow_session.course,
......@@ -121,92 +129,90 @@ def _adjust_flow_session_page_data_inner(repo, flow_session,
in_sandbox=False,
page_uri=None)
from course.models import FlowPageData
def remove_page(fpd):
if fpd.ordinal is not None:
fpd.ordinal = None
fpd.save()
desc_group_ids = []
ordinal = [0]
for grp in flow_desc.groups:
desc_group_ids.append(grp.id)
# {{{ helper functions
shuffle = getattr(grp, "shuffle", False)
max_page_count = getattr(grp, "max_page_count", None)
def remove_page(fpd: FlowPageData) -> None:
if fpd.page_ordinal is not None:
fpd.page_ordinal = None
fpd.save()
available_page_ids = [page_desc.id for page_desc in grp.pages]
def find_page_desc(page_id: str) -> FlowPageDesc:
new_page_desc = None
if max_page_count is None:
max_page_count = len(available_page_ids)
for page_desc in grp.pages: # pragma: no branch
if page_desc.id == page_id:
new_page_desc = page_desc
break
group_pages = []
assert new_page_desc is not None
# {{{ helper functions
return new_page_desc
def find_page_desc(page_id):
new_page_desc = None
def instantiate_page(page_desc: FlowPageDesc) -> PageBase:
from course.content import instantiate_flow_page
return instantiate_flow_page(
f"course '{course_identifier}', flow '{flow_session.flow_id}', "
f"page '{grp.id}/{page_desc.id}'",
repo, page_desc, commit_sha)
for page_desc in grp.pages:
if page_desc.id == page_id:
new_page_desc = page_desc
break
def create_fpd(new_page_desc: FlowPageDesc) -> FlowPageData:
page = instantiate_page(new_page_desc)
assert new_page_desc is not None
data = page.initialize_page_data(pctx)
return FlowPageData(
flow_session=flow_session,
page_ordinal=None,
page_type=new_page_desc.type,
group_id=grp.id,
page_id=new_page_desc.id,
data=data,
title=page.title(pctx, data))
def add_page(fpd: FlowPageData) -> None:
if fpd.page_ordinal != ordinal[0]:
fpd.page_ordinal = ordinal[0]
fpd.save()
return new_page_desc
page_desc = find_page_desc(fpd.page_id)
page = instantiate_page(page_desc)
title = page.title(pctx, fpd.data)
def instantiate_page(page_desc):
from course.content import instantiate_flow_page
return instantiate_flow_page(
"course '%s', flow '%s', page '%s/%s'"
% (course_identifier, flow_session.flow_id,
grp.id, page_desc.id),
repo, page_desc, commit_sha)
if fpd.title != title:
fpd.title = title
fpd.save()
def create_fpd(new_page_desc):
page = instantiate_page(new_page_desc)
ordinal[0] += 1
available_page_ids.remove(fpd.page_id)
data = page.initialize_page_data(pctx)
return FlowPageData(
flow_session=flow_session,
ordinal=None,
page_type=new_page_desc.type,
group_id=grp.id,
page_id=new_page_desc.id,
data=data,
title=page.title(pctx, data))
group_pages.append(fpd)
def add_page(fpd):
if fpd.ordinal != ordinal[0]:
fpd.ordinal = ordinal[0]
fpd.save()
# }}}
page_desc = find_page_desc(fpd.page_id)
page = instantiate_page(page_desc)
title = page.title(pctx, fpd.data)
ordinal = [0]
for grp in flow_desc.groups:
desc_group_ids.append(grp.id)
if fpd.title != title:
fpd.title = title
fpd.save()
shuffle = getattr(grp, "shuffle", False)
max_page_count = getattr(grp, "max_page_count", None)
ordinal[0] += 1
available_page_ids.remove(fpd.page_id)
available_page_ids = [page_desc.id for page_desc in grp.pages]
group_pages.append(fpd)
if max_page_count is None:
max_page_count = len(available_page_ids)
# }}}
group_pages: list[FlowPageData] = []
if shuffle:
# maintain order of existing pages as much as possible
# {{{ maintain order of existing pages as much as possible
for fpd in (FlowPageData.objects
.filter(
flow_session=flow_session,
group_id=grp.id,
ordinal__isnull=False)
.order_by("ordinal")):
page_ordinal__isnull=False)
.order_by("page_ordinal")):
if (fpd.page_id in available_page_ids
and len(group_pages) < max_page_count):
......@@ -216,9 +222,12 @@ def _adjust_flow_session_page_data_inner(repo, flow_session,
assert len(group_pages) <= max_page_count
# }}}
# {{{ then add randomly chosen new pages
from random import choice
# then add randomly chosen new pages
while len(group_pages) < max_page_count and available_page_ids:
new_page_id = choice(available_page_ids)
......@@ -241,13 +250,16 @@ def _adjust_flow_session_page_data_inner(repo, flow_session,
add_page(new_page_fpd)
# }}}
else:
# reorder pages to order in flow
id_to_fpd = dict(
((fpd.group_id, fpd.page_id), fpd)
# {{{ reorder pages to order in flow
id_to_fpd = {
(fpd.group_id, fpd.page_id): fpd
for fpd in FlowPageData.objects.filter(
flow_session=flow_session,
group_id=grp.id))
group_id=grp.id)}
for page_desc in grp.pages:
key = (grp.id, page_desc.id)
......@@ -263,13 +275,15 @@ def _adjust_flow_session_page_data_inner(repo, flow_session,
for fpd in id_to_fpd.values():
remove_page(fpd)
# }}}
# {{{ remove pages orphaned because of group renames
for fpd in (
FlowPageData.objects
.filter(
flow_session=flow_session,
ordinal__isnull=False)
page_ordinal__isnull=False)
.exclude(group_id__in=desc_group_ids)
):
remove_page(fpd)
......@@ -279,9 +293,9 @@ def _adjust_flow_session_page_data_inner(repo, flow_session,
return ordinal[0] # new page count
def adjust_flow_session_page_data(repo, flow_session,
course_identifier, flow_desc=None, respect_preview=True):
# type: (Repo_ish, FlowSession, Text, Optional[FlowDesc], bool) -> None
def adjust_flow_session_page_data(repo: Repo_ish, flow_session: FlowSession,
course_identifier: str, flow_desc: FlowDesc | None = None,
respect_preview: bool = True) -> None:
"""
The caller may *not* be in a transaction that has a weaker isolation
......@@ -316,9 +330,9 @@ def adjust_flow_session_page_data(repo, flow_session,
# {{{ grade page visit
def grade_page_visit(visit, visit_grade_model=FlowPageVisitGrade,
grade_data=None, respect_preview=True):
# type: (FlowPageVisit, type, Any, bool) -> None
def grade_page_visit(visit: FlowPageVisit,
visit_grade_model: type = FlowPageVisitGrade,
grade_data: Any = None, respect_preview: bool = True) -> None:
if not visit.is_submitted_answer:
raise RuntimeError(_("cannot grade ungraded answer"))
......@@ -326,66 +340,66 @@ def grade_page_visit(visit, visit_grade_model=FlowPageVisitGrade,
course = flow_session.course
page_data = visit.page_data
most_recent_grade = visit.get_most_recent_grade() # type: Optional[FlowPageVisitGrade] # noqa
most_recent_grade: FlowPageVisitGrade | None = visit.get_most_recent_grade()
if most_recent_grade is not None and grade_data is None:
grade_data = most_recent_grade.grade_data
from course.content import (
get_course_repo,
get_course_commit_sha,
get_flow_desc,
get_flow_page_desc,
instantiate_flow_page)
repo = get_course_repo(course)
get_course_commit_sha,
get_course_repo,
get_flow_desc,
get_flow_page_desc,
instantiate_flow_page,
)
course_commit_sha = get_course_commit_sha(
course, flow_session.participation if respect_preview else None)
with get_course_repo(course) as repo:
course_commit_sha = get_course_commit_sha(
course, flow_session.participation if respect_preview else None)
flow_desc = get_flow_desc(repo, course,
flow_session.flow_id, course_commit_sha)
flow_desc = get_flow_desc(repo, course,
flow_session.flow_id, course_commit_sha)
page_desc = get_flow_page_desc(
flow_session.flow_id,
flow_desc,
page_data.group_id, page_data.page_id)
page_desc = get_flow_page_desc(
flow_session.flow_id,
flow_desc,
page_data.group_id, page_data.page_id)
page = instantiate_flow_page(
location="flow '%s', group, '%s', page '%s'"
% (flow_session.flow_id, page_data.group_id, page_data.page_id),
repo=repo, page_desc=page_desc,
commit_sha=course_commit_sha)
page = instantiate_flow_page(
location=(f"flow '{flow_session.flow_id}', "
f"group, '{page_data.group_id}', page '{page_data.page_id}'"),
repo=repo, page_desc=page_desc,
commit_sha=course_commit_sha)
assert page.expects_answer()
if not page.is_answer_gradable():
return
assert page.expects_answer()
if not page.is_answer_gradable():
return
from course.page import PageContext
grading_page_context = PageContext(
course=course,
repo=repo,
commit_sha=course_commit_sha,
flow_session=flow_session)
from course.page import PageContext
grading_page_context = PageContext(
course=course,
repo=repo,
commit_sha=course_commit_sha,
flow_session=flow_session)
with translation.override(settings.RELATE_ADMIN_EMAIL_LOCALE):
answer_feedback = page.grade(
grading_page_context, visit.page_data.data,
visit.answer, grade_data=grade_data)
with LanguageOverride(course=course):
answer_feedback = page.grade(
grading_page_context, visit.page_data.data,
visit.answer, grade_data=grade_data)
grade = visit_grade_model()
grade.visit = visit
grade.grade_data = grade_data
grade.max_points = page.max_points(visit.page_data)
grade.graded_at_git_commit_sha = course_commit_sha.decode()
grade = visit_grade_model()
grade.visit = visit
grade.grade_data = grade_data
grade.max_points = page.max_points(visit.page_data)
grade.graded_at_git_commit_sha = course_commit_sha.decode()
bulk_feedback_json = None
if answer_feedback is not None:
grade.correctness = answer_feedback.correctness
grade.feedback, bulk_feedback_json = answer_feedback.as_json()
bulk_feedback_json = None
if answer_feedback is not None:
grade.correctness = answer_feedback.correctness
grade.feedback, bulk_feedback_json = answer_feedback.as_json()
grade.save()
grade.save()
update_bulk_feedback(page_data, grade, bulk_feedback_json)
update_bulk_feedback(page_data, grade, bulk_feedback_json)
# }}}
......@@ -393,16 +407,15 @@ def grade_page_visit(visit, visit_grade_model=FlowPageVisitGrade,
# {{{ start flow
def start_flow(
repo, # type: Repo_ish
course, # type: Course
participation, # type: Optional[Participation]
user, # type: Any
flow_id, # type: Text
flow_desc, # type: FlowDesc
session_start_rule, # type: FlowSessionStartRule
now_datetime, # type: datetime.datetime
):
# type: (...) -> FlowSession
repo: Repo_ish,
course: Course,
participation: Participation | None,
user: Any,
flow_id: str,
flow_desc: FlowDesc,
session_start_rule: FlowSessionStartRule,
now_datetime: datetime.datetime,
) -> FlowSession:
# This function does not need to be transactionally atomic.
# The only essential part is the creation of the session.
......@@ -427,6 +440,7 @@ def start_flow(
user=user,
active_git_commit_sha=course_commit_sha.decode(),
flow_id=flow_id,
start_time=now_datetime,
in_progress=True,
expiration_mode=exp_mode,
access_rules_tag=session_start_rule.tag_session)
......@@ -458,8 +472,8 @@ def start_flow(
# {{{ finish flow
def get_multiple_flow_session_graded_answers_qset(flow_sessions):
# type: (List[FlowSession]) -> query.QuerySet
def get_multiple_flow_session_graded_answers_qset(
flow_sessions: list[FlowSession]) -> query.QuerySet:
from django.db.models import Q
qset = (FlowPageVisit.objects
......@@ -477,31 +491,33 @@ def get_multiple_flow_session_graded_answers_qset(flow_sessions):
return qset
def get_flow_session_graded_answers_qset(flow_session):
# type: (FlowSession) -> query.QuerySet
def get_flow_session_graded_answers_qset(
flow_session: FlowSession) -> query.QuerySet:
return get_multiple_flow_session_graded_answers_qset([flow_session])
def get_prev_answer_visits_qset(page_data):
# type: (FlowPageData) -> query.QuerySet
def get_prev_answer_visits_qset(page_data: FlowPageData) -> query.QuerySet:
return (
get_flow_session_graded_answers_qset(page_data.flow_session)
.filter(page_data=page_data)
.order_by("-visit_time"))
def get_prev_answer_visit(page_data):
previous_answer_visits = get_prev_answer_visits_qset(page_data)
for prev_visit in previous_answer_visits[:1]:
return prev_visit
def get_first_from_qset(qset: query.QuerySet) -> Any | None:
for item in qset[:1]:
return item
return None
def assemble_page_grades(flow_sessions):
# type: (List[FlowSession]) -> List[List[Optional[FlowPageVisitGrade]]]
def get_prev_answer_visit(page_data):
return get_first_from_qset(get_prev_answer_visits_qset(page_data))
def assemble_page_grades(
flow_sessions: list[FlowSession]
) -> list[list[FlowPageVisitGrade | None]]:
"""
Given a list of flow sessions, return a list of lists of FlowPageVisitGrade
objects corresponding to the most recent page grades for each page of the
......@@ -511,23 +527,23 @@ def assemble_page_grades(flow_sessions):
of the lists may vary since the flow page count may vary per session.
"""
id_to_fsess_idx = {fsess.id: i for i, fsess in enumerate(flow_sessions)}
answer_visit_ids = [
[None] * fsess.page_count for fsess in flow_sessions
] # type: List[List[Optional[int]]]
answer_visit_ids: list[list[int | None]] = [
[None] * not_none(fsess.page_count) for fsess in flow_sessions
]
# Get all answer visits corresponding to the sessions. The query result is
# typically very large.
all_answer_visits = (
get_multiple_flow_session_graded_answers_qset(flow_sessions)
.order_by("visit_time")
.values("id", "flow_session_id", "page_data__ordinal",
.values("id", "flow_session_id", "page_data__page_ordinal",
"is_submitted_answer"))
for answer_visit in all_answer_visits:
fsess_idx = id_to_fsess_idx[answer_visit["flow_session_id"]]
ordinal = answer_visit["page_data__ordinal"]
if ordinal is not None:
answer_visit_ids[fsess_idx][ordinal] = answer_visit["id"]
page_ordinal = answer_visit["page_data__page_ordinal"]
if page_ordinal is not None:
answer_visit_ids[fsess_idx][page_ordinal] = answer_visit["id"]
if not flow_sessions[fsess_idx].in_progress:
assert answer_visit["is_submitted_answer"] is True
......@@ -544,12 +560,13 @@ def assemble_page_grades(flow_sessions):
.order_by("visit__id")
.order_by("grade_time"))
grades_by_answer_visit = {}
grades_by_answer_visit: dict[int | None, FlowPageVisitGrade] = {}
for grade in grades:
grades_by_answer_visit[grade.visit_id] = grade
def get_grades_for_visit_group(visit_group):
# type: (List[Optional[int]]) -> List[Optional[FlowPageVisit]]
def get_grades_for_visit_group(
visit_group: list[int | None]
) -> list[FlowPageVisitGrade | None]:
return [grades_by_answer_visit.get(visit_id)
for visit_id in visit_group]
......@@ -557,18 +574,19 @@ def assemble_page_grades(flow_sessions):
return [get_grades_for_visit_group(group) for group in answer_visit_ids]
def assemble_answer_visits(flow_session):
# type: (FlowSession) -> List[Optional[FlowPageVisit]]
def assemble_answer_visits(
flow_session: FlowSession) -> list[FlowPageVisit | None]:
answer_visits = [None] * flow_session.page_count # type: List[Optional[FlowPageVisit]] # noqa
answer_visits: list[FlowPageVisit | None] = [
cast(FlowPageVisit | None, None)] * not_none(flow_session.page_count)
answer_page_visits = (
get_flow_session_graded_answers_qset(flow_session)
.order_by("visit_time"))
for page_visit in answer_page_visits:
if page_visit.page_data.ordinal is not None:
answer_visits[page_visit.page_data.ordinal] = page_visit
if page_visit.page_data.page_ordinal is not None:
answer_visits[page_visit.page_data.page_ordinal] = page_visit
if not flow_session.in_progress:
assert page_visit.is_submitted_answer is True
......@@ -576,55 +594,59 @@ def assemble_answer_visits(flow_session):
return answer_visits
def get_all_page_data(flow_session):
# type: (FlowSession) -> Iterable[FlowPageData]
def get_all_page_data(flow_session: FlowSession) -> Iterable[FlowPageData]:
return (FlowPageData.objects
.filter(
flow_session=flow_session,
ordinal__isnull=False)
.order_by("ordinal"))
page_ordinal__isnull=False)
.order_by("page_ordinal"))
def get_interaction_kind(
fctx, # type: FlowContext
flow_session, # type: FlowSession
flow_generates_grade, # type: bool
all_page_data, # type: Iterable[FlowPageData]
):
# type: (...) -> Text
fctx: FlowContext,
flow_session: FlowSession,
flow_generates_grade: bool,
all_page_data: Iterable[FlowPageData],
) -> str:
ikind = flow_session_interaction_kind.noninteractive
has_interactive = False
has_gradable = False
for i, page_data in enumerate(all_page_data):
assert i == page_data.ordinal
assert i == page_data.page_ordinal
page = instantiate_flow_page_with_ctx(fctx, page_data)
if page.expects_answer():
has_interactive = True
if page.is_answer_gradable():
if flow_generates_grade:
return flow_session_interaction_kind.permanent_grade
else:
return flow_session_interaction_kind.practice_grade
else:
ikind = flow_session_interaction_kind.ungraded
has_gradable = True
return ikind
if has_interactive:
if has_gradable:
if flow_generates_grade:
return flow_session_interaction_kind.permanent_grade
else:
return flow_session_interaction_kind.practice_grade
else:
return flow_session_interaction_kind.ungraded
else:
return flow_session_interaction_kind.noninteractive
def get_session_answered_page_data(
fctx, # type: FlowContext
flow_session, # type: FlowSession
answer_visits # type: List[Optional[FlowPageVisit]]
):
# type: (...) -> Tuple[List[FlowPageData], List[FlowPageData]]
fctx: FlowContext,
flow_session: FlowSession,
answer_visits: list[FlowPageVisit | None]
) -> tuple[list[FlowPageData], list[FlowPageData], bool]:
all_page_data = get_all_page_data(flow_session)
answered_page_data_list = [] # type: List[FlowPageData]
unanswered_page_data_list = [] # type: List[FlowPageData]
answered_page_data_list: list[FlowPageData] = []
unanswered_page_data_list: list[FlowPageData] = []
is_interactive_flow: bool = False
for i, page_data in enumerate(all_page_data):
assert i == page_data.ordinal
assert i == page_data.page_ordinal
avisit = answer_visits[i]
if avisit is not None:
......@@ -634,35 +656,51 @@ def get_session_answered_page_data(
page = instantiate_flow_page_with_ctx(fctx, page_data)
if page.expects_answer():
if answer_data is None:
unanswered_page_data_list.append(page_data)
else:
answered_page_data_list.append(page_data)
is_interactive_flow = True
if not page.is_optional_page:
if answer_data is None:
unanswered_page_data_list.append(page_data)
else:
answered_page_data_list.append(page_data)
return (answered_page_data_list, unanswered_page_data_list)
return (answered_page_data_list, unanswered_page_data_list, is_interactive_flow)
class GradeInfo(object):
"""An object to hold a tally of points and page counts of various types in a flow.
class GradeInfo:
"""An object to hold a tally of points and page counts of various types in
a flow.
.. attribute:: points
The final grade, in points. May be *None* if the grade is not yet
final.
.. attribute:: provisional_points
The number of points still attainable for this flow if all points
were awarded for as yet ungraded (e.g. human-graded) pages.
.. attribute:: max_reachable_points
The maximum number of actually attainable points on the flow, subject
to :attr:`FlowSessionGradingRule.max_points_enforced_cap`.
"""
def __init__(
self,
points, # type: Optional[float]
provisional_points, # type: Optional[float]
max_points, # type: Optional[float]
max_reachable_points, # type: Optional[float]
fully_correct_count, # type: int
partially_correct_count, # type: int
incorrect_count, # type: int
unknown_count, # type: int
):
# type: (...) -> None
points: float | None,
provisional_points: float | None,
max_points: float | None,
max_reachable_points: float | None,
fully_correct_count: int,
partially_correct_count: int,
incorrect_count: int,
unknown_count: int,
optional_fully_correct_count: int = 0,
optional_partially_correct_count: int = 0,
optional_incorrect_count: int = 0,
optional_unknown_count: int = 0,
) -> None:
self.points = points
self.provisional_points = provisional_points
self.max_points = max_points
......@@ -671,6 +709,10 @@ class GradeInfo(object):
self.partially_correct_count = partially_correct_count
self.incorrect_count = incorrect_count
self.unknown_count = unknown_count
self.optional_fully_correct_count = optional_fully_correct_count
self.optional_partially_correct_count = optional_partially_correct_count
self.optional_incorrect_count = optional_incorrect_count
self.optional_unknown_count = optional_unknown_count
# Rounding to larger than 100% will break the percent bars on the
# flow results page.
......@@ -715,48 +757,69 @@ class GradeInfo(object):
# }}}
# {{{ page counts
# {{{ page counts / percentages
def total_count(self):
def total_count(self) -> int:
return (self.fully_correct_count
+ self.partially_correct_count
+ self.incorrect_count
+ self.unknown_count)
def fully_correct_percent(self):
def fully_correct_percent(self) -> float:
"""Only to be used for visualization purposes."""
return self.FULL_PERCENT*self.fully_correct_count/self.total_count()
def partially_correct_percent(self):
def partially_correct_percent(self) -> float:
"""Only to be used for visualization purposes."""
return self.FULL_PERCENT*self.partially_correct_count/self.total_count()
def incorrect_percent(self):
def incorrect_percent(self) -> float:
"""Only to be used for visualization purposes."""
return self.FULL_PERCENT*self.incorrect_count/self.total_count()
def unknown_percent(self):
def unknown_percent(self) -> float:
"""Only to be used for visualization purposes."""
return self.FULL_PERCENT*self.unknown_count/self.total_count()
def optional_total_count(self) -> int:
return (self.optional_fully_correct_count
+ self.optional_partially_correct_count
+ self.optional_incorrect_count
+ self.optional_unknown_count)
def optional_fully_correct_percent(self) -> float:
"""Only to be used for visualization purposes."""
return self.FULL_PERCENT * self.optional_fully_correct_count\
/ self.optional_total_count()
def optional_partially_correct_percent(self) -> float:
"""Only to be used for visualization purposes."""
return self.FULL_PERCENT * self.optional_partially_correct_count\
/ self.optional_total_count()
def optional_incorrect_percent(self) -> float:
"""Only to be used for visualization purposes."""
return self.FULL_PERCENT * self.optional_incorrect_count\
/ self.optional_total_count()
def optional_unknown_percent(self) -> float:
"""Only to be used for visualization purposes."""
return self.FULL_PERCENT * self.optional_unknown_count\
/ self.optional_total_count()
# }}}
def gather_grade_info(
fctx, # type: FlowContext
flow_session, # type: FlowSession
grading_rule, # type: FlowSessionGradingRule
answer_visits, # type: List[Optional[FlowPageVisit]]
):
# type: (...) -> GradeInfo
"""
:returns: a :class:`GradeInfo`
"""
fctx: FlowContext,
flow_session: FlowSession,
grading_rule: FlowSessionGradingRule,
answer_visits: list[FlowPageVisit | None],
) -> GradeInfo:
all_page_data = get_all_page_data(flow_session)
bonus_points = grading_rule.bonus_points
points = bonus_points
points: float | None = bonus_points
provisional_points = bonus_points
max_points = bonus_points
max_reachable_points = bonus_points
......@@ -766,10 +829,15 @@ def gather_grade_info(
incorrect_count = 0
unknown_count = 0
optional_fully_correct_count = 0
optional_partially_correct_count = 0
optional_incorrect_count = 0
optional_unknown_count = 0
for i, page_data in enumerate(all_page_data):
page = instantiate_flow_page_with_ctx(fctx, page_data)
assert i == page_data.ordinal
assert i == page_data.page_ordinal
av = answer_visits[i]
......@@ -786,32 +854,46 @@ def gather_grade_info(
grade = av.get_most_recent_grade()
assert grade is not None
assert grade.max_points is not None
feedback = get_feedback_for_grade(grade)
max_points += grade.max_points
if page.is_optional_page:
if feedback is None or feedback.correctness is None:
optional_unknown_count += 1
continue
if feedback is None or feedback.correctness is None:
unknown_count += 1
points = None
continue
if feedback.correctness == 1:
optional_fully_correct_count += 1
elif feedback.correctness == 0:
optional_incorrect_count += 1
else:
optional_partially_correct_count += 1
else:
max_points += grade.max_points
max_reachable_points += grade.max_points
if feedback is None or feedback.correctness is None:
unknown_count += 1
points = None
continue
page_points = grade.max_points*feedback.correctness
max_reachable_points += grade.max_points
if points is not None:
points += page_points
page_points = grade.max_points*feedback.correctness
provisional_points += page_points
if points is not None:
points += page_points
if grade.max_points > 0:
if feedback.correctness == 1:
fully_correct_count += 1
elif feedback.correctness == 0:
incorrect_count += 1
else:
partially_correct_count += 1
provisional_points += page_points
if grade.max_points > 0:
if feedback.correctness == 1:
fully_correct_count += 1
elif feedback.correctness == 0:
incorrect_count += 1
else:
partially_correct_count += 1
# {{{ adjust max_points if requested
......@@ -825,8 +907,10 @@ def gather_grade_info(
if grading_rule.max_points_enforced_cap is not None:
max_reachable_points = min(
max_reachable_points, grading_rule.max_points_enforced_cap)
points = min(
points, grading_rule.max_points_enforced_cap)
if points is not None:
points = min(
points, grading_rule.max_points_enforced_cap)
assert provisional_points is not None
provisional_points = min(
provisional_points, grading_rule.max_points_enforced_cap)
......@@ -841,18 +925,22 @@ def gather_grade_info(
fully_correct_count=fully_correct_count,
partially_correct_count=partially_correct_count,
incorrect_count=incorrect_count,
unknown_count=unknown_count)
unknown_count=unknown_count,
optional_fully_correct_count=optional_fully_correct_count,
optional_partially_correct_count=optional_partially_correct_count,
optional_incorrect_count=optional_incorrect_count,
optional_unknown_count=optional_unknown_count)
@transaction.atomic
def grade_page_visits(
fctx, # type: FlowContext
flow_session, # type: FlowSession
answer_visits, # type: List[Optional[FlowPageVisit]]
force_regrade=False, # type: bool
respect_preview=True, # type: bool
):
# type: (...) -> None
fctx: FlowContext,
flow_session: FlowSession,
answer_visits: list[FlowPageVisit | None],
force_regrade: bool = False,
respect_preview: bool = True,
) -> None:
for i in range(len(answer_visits)):
answer_visit = answer_visits[i]
......@@ -861,7 +949,7 @@ def grade_page_visits(
answer_visit.save()
else:
page_data = flow_session.page_data.get(ordinal=i)
page_data = flow_session.page_data.get(page_ordinal=i)
page = instantiate_flow_page_with_ctx(fctx, page_data)
if not page.expects_answer():
......@@ -882,9 +970,9 @@ def grade_page_visits(
if not page.is_answer_gradable():
continue
if answer_visit is not None:
if not answer_visit.grades.count() or force_regrade: # type: ignore
grade_page_visit(answer_visit, respect_preview=respect_preview)
assert answer_visit is not None
if not answer_visit.grades.count() or force_regrade: # type: ignore
grade_page_visit(answer_visit, respect_preview=respect_preview)
@retry_transaction_decorator()
......@@ -933,13 +1021,12 @@ def finish_flow_session(fctx, flow_session, grading_rule,
def expire_flow_session(
fctx, # type: FlowContext
flow_session, # type: FlowSession
grading_rule, # type: FlowSessionGradingRule
now_datetime, # type: datetime.datetime
past_due_only=False, # type:bool
):
# type: (...) -> bool
fctx: FlowContext,
flow_session: FlowSession,
grading_rule: FlowSessionGradingRule,
now_datetime: datetime.datetime,
past_due_only: bool = False,
) -> bool:
# This function does not need to be transactionally atomic.
# It only does one atomic 'thing' in each execution path.
......@@ -951,10 +1038,11 @@ def expire_flow_session(
assert isinstance(grading_rule, FlowSessionGradingRule)
if (past_due_only
and grading_rule.due is not None
and now_datetime < grading_rule.due):
return False
if past_due_only:
if grading_rule.due is None:
return False
elif now_datetime < grading_rule.due:
return False
adjust_flow_session_page_data(fctx.repo, flow_session,
flow_session.course.identifier, fctx.flow_desc,
......@@ -968,8 +1056,9 @@ def expire_flow_session(
if not session_start_rule.may_start_new_session:
# No new session allowed: finish.
return finish_flow_session(fctx, flow_session, grading_rule,
now_datetime=now_datetime, respect_preview=False)
finish_flow_session(fctx, flow_session, grading_rule,
now_datetime=now_datetime, respect_preview=False)
return True
else:
flow_session.access_rules_tag = session_start_rule.tag_session
......@@ -994,23 +1083,27 @@ def expire_flow_session(
return True
elif flow_session.expiration_mode == flow_session_expiration_mode.end:
return finish_flow_session(fctx, flow_session, grading_rule,
now_datetime=now_datetime, respect_preview=False)
finish_flow_session(fctx, flow_session, grading_rule,
now_datetime=now_datetime, respect_preview=False)
return True
else:
raise ValueError(
_("invalid expiration mode '%(mode)s' on flow session ID "
"%(session_id)d") % {
'mode': flow_session.expiration_mode,
'session_id': flow_session.id})
"mode": flow_session.expiration_mode,
"session_id": flow_session.id})
def get_flow_session_attempt_id(flow_session: FlowSession) -> str:
return "flow-session-%d" % flow_session.id
def grade_flow_session(
fctx, # type: FlowContext
flow_session, # type: FlowSession
grading_rule, # type: FlowSessionGradingRule
answer_visits=None, # type: Optional[List[Optional[FlowPageVisit]]]
):
# type: (...) -> GradeInfo
fctx: FlowContext,
flow_session: FlowSession,
grading_rule: FlowSessionGradingRule,
answer_visits: list[FlowPageVisit | None] | None = None,
) -> GradeInfo:
"""Updates the grade on an existing flow session and logs a
grade change with the grade records subsystem.
......@@ -1031,8 +1124,8 @@ def grade_flow_session(
comment = (
# Translators: grade flow: calculating grade.
_("Counted at %(percent).1f%% of %(point).1f points") % {
'percent': grading_rule.credit_percent,
'point': points})
"percent": grading_rule.credit_percent,
"point": points})
points = points * grading_rule.credit_percent / 100
flow_session.points = points
......@@ -1058,9 +1151,9 @@ def grade_flow_session(
gchange.opportunity = gopp
gchange.participation = flow_session.participation
gchange.state = grade_state_change_types.graded
gchange.attempt_id = "flow-session-%d" % flow_session.id
gchange.attempt_id = get_flow_session_attempt_id(flow_session)
gchange.points = points
gchange.max_points = grade_info.max_points
gchange.max_points = not_none(grade_info.max_points)
# creator left as NULL
gchange.flow_session = flow_session
gchange.comment = comment
......@@ -1069,7 +1162,6 @@ def grade_flow_session(
.filter(
opportunity=gchange.opportunity,
participation=gchange.participation,
state=gchange.state,
attempt_id=gchange.attempt_id,
flow_session=gchange.flow_session)
.order_by("-grade_time")
......@@ -1081,6 +1173,7 @@ def grade_flow_session(
previous_grade_change, = previous_grade_changes
if (previous_grade_change.points == gchange.points
and previous_grade_change.max_points == gchange.max_points
and previous_grade_change.state == gchange.state
and previous_grade_change.comment == gchange.comment):
do_save = False
else:
......@@ -1094,44 +1187,69 @@ def grade_flow_session(
return grade_info
def unsubmit_page(
prev_answer_visit: FlowPageVisit, now_datetime: datetime.datetime) -> None:
prev_answer_visit.id = None
prev_answer_visit.visit_time = now_datetime
prev_answer_visit.remote_address = None
prev_answer_visit.user = None
prev_answer_visit.is_synthetic = True
assert prev_answer_visit.is_submitted_answer
prev_answer_visit.is_submitted_answer = False
prev_answer_visit.save()
def reopen_session(
session, force=False, suppress_log=False):
# type: (FlowSession, bool, bool) -> None
now_datetime: datetime.datetime,
session: FlowSession,
suppress_log: bool = False,
unsubmit_pages: bool = False,
) -> None:
if session.in_progress:
raise RuntimeError(
_("Cannot reopen a session that's already in progress"))
if session.participation is None:
raise RuntimeError(
_("Cannot reopen anonymous sessions"))
with transaction.atomic():
if session.in_progress:
raise RuntimeError(
_("Cannot reopen a session that's already in progress"))
if session.participation is None:
raise RuntimeError(
_("Cannot reopen anonymous sessions"))
session.in_progress = True
session.points = None
session.max_points = None
if not suppress_log:
session.append_comment(
_("Session reopened at %(now)s, previous completion time "
"was '%(complete_time)s'.") % {
"now": format_datetime_local(now_datetime),
"complete_time": format_datetime_local(
as_local_time(not_none(session.completion_time)))
})
session.in_progress = True
session.points = None
session.max_points = None
session.completion_time = None
session.save()
if not suppress_log:
session.append_comment(
_("Session reopened at %(now)s, previous completion time "
"was '%(complete_time)s'.") % {
'now': format_datetime_local(local_now()),
'complete_time': format_datetime_local(
as_local_time(session.completion_time))
})
if unsubmit_pages:
answer_visits = assemble_answer_visits(session)
session.completion_time = None
session.save()
for visit in answer_visits:
if visit is not None:
unsubmit_page(visit, now_datetime)
def finish_flow_session_standalone(
repo, # type: Repo_ish
course, # type: Course
session, # type: FlowSession
force_regrade=False, # type: bool
now_datetime=None, # type: Optional[datetime.datetime]
past_due_only=False, # type: bool
respect_preview=True, # type:bool
):
# type: (...) -> bool
repo: Repo_ish,
course: Course,
session: FlowSession,
force_regrade: bool = False,
now_datetime: datetime.datetime | None = None,
past_due_only: bool = False,
respect_preview: bool = True,
) -> bool:
# Do not be tempted to call adjust_flow_session_page_data in here.
# This function may be called from within a transaction.
......@@ -1149,10 +1267,10 @@ def finish_flow_session_standalone(
grading_rule = get_session_grading_rule(session, fctx.flow_desc,
now_datetime_filled)
if grading_rule.due is not None:
if (
past_due_only
and now_datetime_filled < grading_rule.due):
if past_due_only:
if grading_rule.due is None:
return False
elif now_datetime_filled < grading_rule.due:
return False
finish_flow_session(fctx, session, grading_rule,
......@@ -1164,13 +1282,12 @@ def finish_flow_session_standalone(
def expire_flow_session_standalone(
repo, # type: Any
course, # type: Course
session, # type: FlowSession
now_datetime, # type: datetime.datetime
past_due_only=False, # type: bool
):
# type: (...) -> bool
repo: Any,
course: Course,
session: FlowSession,
now_datetime: datetime.datetime,
past_due_only: bool = False,
) -> bool:
assert session.participation is not None
fctx = FlowContext(repo, course, session.flow_id)
......@@ -1182,17 +1299,16 @@ def expire_flow_session_standalone(
def regrade_session(
repo, # type: Repo_ish
course, # type: Course
session, # type: FlowSession
):
# type: (...) -> None
repo: Repo_ish,
course: Course,
session: FlowSession,
) -> None:
adjust_flow_session_page_data(repo, session, course.identifier,
respect_preview=False)
if session.in_progress:
with transaction.atomic():
answer_visits = assemble_answer_visits(session) # type: List[Optional[FlowPageVisit]] # noqa
answer_visits: list[FlowPageVisit | None] = assemble_answer_visits(session)
for i in range(len(answer_visits)):
answer_visit = answer_visits[i]
......@@ -1204,23 +1320,23 @@ def regrade_session(
else:
prev_completion_time = session.completion_time
now_datetime = local_now()
with transaction.atomic():
session.append_comment(
_("Session regraded at %(time)s.") % {
'time': format_datetime_local(local_now())
"time": format_datetime_local(now_datetime)
})
session.save()
reopen_session(session, force=True, suppress_log=True)
reopen_session(now_datetime, session, suppress_log=True)
finish_flow_session_standalone(
repo, course, session, force_regrade=True,
now_datetime=prev_completion_time,
respect_preview=False)
def recalculate_session_grade(repo, course, session):
# type: (Repo_ish, Course, FlowSession) -> None
def recalculate_session_grade(
repo: Repo_ish, course: Course, session: FlowSession) -> None:
"""Only redoes the final grade determination without regrading
individual pages.
"""
......@@ -1234,13 +1350,14 @@ def recalculate_session_grade(repo, course, session):
respect_preview=False)
with transaction.atomic():
now_datetime = local_now()
session.append_comment(
_("Session grade recomputed at %(time)s.") % {
'time': format_datetime_local(local_now())
"time": format_datetime_local(now_datetime)
})
session.save()
reopen_session(session, force=True, suppress_log=True)
reopen_session(now_datetime, session, suppress_log=True)
finish_flow_session_standalone(
repo, course, session, force_regrade=False,
now_datetime=prev_completion_time,
......@@ -1250,79 +1367,81 @@ def recalculate_session_grade(repo, course, session):
def lock_down_if_needed(
request, # type: http.HttpRequest
permissions, # type: frozenset[Text]
flow_session, # type: FlowSession
):
# type: (...) -> None
request: http.HttpRequest,
permissions: frozenset[str],
flow_session: FlowSession,
) -> None:
if flow_permission.lock_down_as_exam_session in permissions:
request.session[
"relate_session_locked_to_exam_flow_session_pk"] = \
flow_session.pk
request.session[SESSION_LOCKED_TO_FLOW_PK] = flow_session.pk
# {{{ view: start flow
@course_view
def view_start_flow(pctx, flow_id):
# type: (CoursePageContext, Text) -> http.HttpResponse
def view_start_flow(pctx: CoursePageContext, flow_id: str) -> http.HttpResponse:
request = pctx.request
login_exam_ticket = get_login_exam_ticket(pctx.request)
now_datetime = get_now_or_fake_time(request)
fctx = FlowContext(pctx.repo, pctx.course, flow_id,
participation=pctx.participation)
if request.method == "POST":
return post_start_flow(pctx, fctx, flow_id)
else:
session_start_rule = get_session_start_rule(
pctx.course, pctx.participation,
flow_id, fctx.flow_desc, now_datetime,
facilities=pctx.request.relate_facilities,
login_exam_ticket=login_exam_ticket)
if session_start_rule.may_list_existing_sessions:
past_sessions = (FlowSession.objects
.filter(
participation=pctx.participation,
flow_id=fctx.flow_id,
participation__isnull=False)
.order_by("start_time"))
from collections import namedtuple
SessionProperties = namedtuple("SessionProperties", # noqa
["may_view", "may_modify", "due", "grade_description",
"grade_shown"])
past_sessions_and_properties = []
for session in past_sessions:
access_rule = get_session_access_rule(
session, fctx.flow_desc, now_datetime,
facilities=pctx.request.relate_facilities,
login_exam_ticket=login_exam_ticket)
grading_rule = get_session_grading_rule(
session, fctx.flow_desc, now_datetime)
session_properties = SessionProperties(
may_view=flow_permission.view in access_rule.permissions,
may_modify=(
flow_permission.submit_answer in access_rule.permissions
or
flow_permission.end_session in access_rule.permissions
),
due=grading_rule.due,
grade_description=grading_rule.description,
grade_shown=(
flow_permission.cannot_see_flow_result
not in access_rule.permissions))
past_sessions_and_properties.append((session, session_properties))
else:
past_sessions_and_properties = []
login_exam_ticket = get_login_exam_ticket(pctx.request)
now_datetime = get_now_or_fake_time(request)
session_start_rule = get_session_start_rule(
pctx.course, pctx.participation,
flow_id, fctx.flow_desc, now_datetime,
facilities=pctx.request.relate_facilities,
login_exam_ticket=login_exam_ticket,
remote_ip_address=remote_address_from_request(pctx.request))
may_start = session_start_rule.may_start_new_session
if session_start_rule.may_list_existing_sessions:
past_sessions = (FlowSession.objects
.filter(
participation=pctx.participation,
flow_id=fctx.flow_id,
participation__isnull=False)
.order_by("start_time"))
from collections import namedtuple
SessionProperties = namedtuple("SessionProperties",
["may_view", "may_modify", "due", "grade_description",
"grade_shown"])
past_sessions_and_properties = []
for session in past_sessions:
access_rule = get_session_access_rule(
session, fctx.flow_desc, now_datetime,
facilities=pctx.request.relate_facilities,
login_exam_ticket=login_exam_ticket,
remote_ip_address=remote_address_from_request(pctx.request))
grading_rule = get_session_grading_rule(
session, fctx.flow_desc, now_datetime)
session_properties = SessionProperties(
may_view=flow_permission.view in access_rule.permissions,
may_modify=(
flow_permission.submit_answer in access_rule.permissions
or flow_permission.end_session in access_rule.permissions
),
due=grading_rule.due,
grade_description=grading_rule.description,
grade_shown=(
flow_permission.cannot_see_flow_result
not in access_rule.permissions))
past_sessions_and_properties.append((session, session_properties))
else:
past_sessions_and_properties = []
may_start = session_start_rule.may_start_new_session
new_session_grading_rule = None
start_may_decrease_grade = False
grade_aggregation_strategy_descr = None
if may_start:
potential_session = FlowSession(
course=pctx.course,
participation=pctx.participation,
......@@ -1339,34 +1458,38 @@ def view_start_flow(pctx, flow_id):
start_may_decrease_grade = (
bool(past_sessions_and_properties)
and
new_session_grading_rule.grade_aggregation_strategy not in
[
and new_session_grading_rule.grade_aggregation_strategy
not in [
None,
grade_aggregation_strategy.max_grade,
grade_aggregation_strategy.use_earliest])
return render_course_page(pctx, "course/flow-start.html", {
"flow_desc": fctx.flow_desc,
"flow_identifier": flow_id,
"now": now_datetime,
"may_start": may_start,
"new_session_grading_rule": new_session_grading_rule,
"grade_aggregation_strategy_descr": (
dict(GRADE_AGGREGATION_STRATEGY_CHOICES).get(
new_session_grading_rule.grade_aggregation_strategy)),
"start_may_decrease_grade": start_may_decrease_grade,
"past_sessions_and_properties": past_sessions_and_properties,
},
allow_instant_flow_requests=False)
grade_aggregation_strategy_descr = (
dict(GRADE_AGGREGATION_STRATEGY_CHOICES).get(
new_session_grading_rule.grade_aggregation_strategy))
from course.content import markup_to_html
return render_course_page(pctx, "course/flow-start.html", {
"flow_desc": fctx.flow_desc,
"flow_identifier": flow_id,
"flow_description_html": markup_to_html(
fctx.course, fctx.repo, fctx.course_commit_sha,
getattr(fctx.flow_desc, "description", "")),
"now": now_datetime,
"may_start": may_start,
"new_session_grading_rule": new_session_grading_rule,
"grade_aggregation_strategy_descr": grade_aggregation_strategy_descr,
"start_may_decrease_grade": start_may_decrease_grade,
"past_sessions_and_properties": past_sessions_and_properties,
},
allow_instant_flow_requests=False)
@retry_transaction_decorator(serializable=True)
def post_start_flow(pctx, fctx, flow_id):
# type: (CoursePageContext, FlowContext, Text) -> http.HttpResponse
def post_start_flow(
pctx: CoursePageContext, fctx: FlowContext, flow_id: str
) -> http.HttpResponse:
now_datetime = get_now_or_fake_time(pctx.request)
login_exam_ticket = get_login_exam_ticket(pctx.request)
......@@ -1395,14 +1518,16 @@ def post_start_flow(pctx, fctx, flow_id):
pctx.course, pctx.participation,
flow_id, fctx.flow_desc, now_datetime,
facilities=pctx.request.relate_facilities,
login_exam_ticket=login_exam_ticket)
login_exam_ticket=login_exam_ticket,
remote_ip_address=remote_address_from_request(pctx.request))
if not session_start_rule.may_start_new_session:
raise PermissionDenied(_("new session not allowed"))
flow_user = pctx.request.user
if not flow_user.is_authenticated:
flow_user = None
if not pctx.request.user.is_authenticated:
flow_user: User | None = None
else:
flow_user = pctx.request.user
session = start_flow(
pctx.repo, pctx.course, pctx.participation,
......@@ -1414,7 +1539,8 @@ def post_start_flow(pctx, fctx, flow_id):
access_rule = get_session_access_rule(
session, fctx.flow_desc, now_datetime,
facilities=pctx.request.relate_facilities,
login_exam_ticket=login_exam_ticket)
login_exam_ticket=login_exam_ticket,
remote_ip_address=remote_address_from_request(pctx.request))
lock_down_if_needed(pctx.request, access_rule.permissions, session)
......@@ -1431,9 +1557,8 @@ def post_start_flow(pctx, fctx, flow_id):
# middleware will refuse access to flow pages in a locked-down facility.
@course_view
def view_resume_flow(pctx, flow_session_id):
# type: (CoursePageContext, int) -> http.HttpResponse
def view_resume_flow(
pctx: CoursePageContext, flow_session_id: int) -> http.HttpResponse:
now_datetime = get_now_or_fake_time(pctx.request)
flow_session = get_and_check_flow_session(pctx, int(flow_session_id))
......@@ -1446,7 +1571,8 @@ def view_resume_flow(pctx, flow_session_id):
access_rule = get_session_access_rule(
flow_session, fctx.flow_desc, now_datetime,
facilities=pctx.request.relate_facilities,
login_exam_ticket=login_exam_ticket)
login_exam_ticket=login_exam_ticket,
remote_ip_address=remote_address_from_request(pctx.request))
lock_down_if_needed(pctx.request, access_rule.permissions,
flow_session)
......@@ -1460,9 +1586,8 @@ def view_resume_flow(pctx, flow_session_id):
# {{{ view: flow page
def get_and_check_flow_session(pctx, flow_session_id):
# type: (CoursePageContext, int) -> FlowSession
def get_and_check_flow_session(
pctx: CoursePageContext, flow_session_id: int) -> FlowSession:
try:
flow_session = (FlowSession.objects
.select_related("participation")
......@@ -1475,8 +1600,7 @@ def get_and_check_flow_session(pctx, flow_session_id):
my_session = (
pctx.participation == flow_session.participation
or
(
or (
# anonymous by participation
flow_session.participation is None
and (
......@@ -1510,46 +1634,53 @@ def get_and_check_flow_session(pctx, flow_session_id):
return flow_session
def will_receive_feedback(permissions):
# type: (frozenset[Text]) -> bool
def will_receive_feedback(permissions: frozenset[str]) -> bool:
return (
flow_permission.see_correctness in permissions
or flow_permission.see_answer_after_submission in permissions)
def may_send_email_about_flow_page(permissions):
# type: (frozenset[Text]) -> bool
return flow_permission.send_email_about_flow_page in permissions
def may_send_email_about_flow_page(
flow_session: FlowSession, permissions: frozenset[str]) -> bool:
return (
flow_session.participation is not None
and flow_session.user is not None
and flow_permission.send_email_about_flow_page in permissions)
def get_page_behavior(
page, # type: PageBase
permissions, # type: frozenset[Text]
session_in_progress, # type: bool
answer_was_graded, # type: bool
generates_grade, # type: bool
is_unenrolled_session, # type: bool
viewing_prior_version=False, # type: bool
):
# type: (...) -> PageBehavior
page: PageBase,
permissions: frozenset[str],
session_in_progress: bool,
answer_was_graded: bool,
generates_grade: bool,
is_unenrolled_session: bool,
viewing_prior_version: bool = False,
) -> PageBehavior:
show_correctness = False
if page.expects_answer() and answer_was_graded:
show_correctness = flow_permission.see_correctness in permissions
if page.expects_answer():
if answer_was_graded:
show_correctness = flow_permission.see_correctness in permissions
show_answer = flow_permission.see_answer_after_submission in permissions
show_answer = flow_permission.see_answer_after_submission in permissions
elif page.expects_answer() and not answer_was_graded:
# Don't show answer yet
show_answer = (
flow_permission.see_answer_before_submission in permissions)
if session_in_progress:
# Don't reveal the answer if they can still change their mind
show_answer = (show_answer
and flow_permission.change_answer not in permissions)
show_answer = show_answer or (
flow_permission.see_answer_before_submission in permissions)
else:
# Don't show answer yet
show_answer = (
flow_permission.see_answer_before_submission in permissions)
else:
show_answer = (
flow_permission.see_answer_before_submission in permissions
or
flow_permission.see_answer_after_submission in permissions)
or flow_permission.see_answer_after_submission in permissions)
may_change_answer = (
not viewing_prior_version
......@@ -1562,11 +1693,11 @@ def get_page_behavior(
and (flow_permission.submit_answer in permissions)
and (generates_grade and not is_unenrolled_session
and ((generates_grade and not is_unenrolled_session)
or (not generates_grade))
)
from course.page.base import PageBehavior # noqa
from course.page.base import PageBehavior
return PageBehavior(
show_correctness=show_correctness,
show_answer=show_answer,
......@@ -1574,9 +1705,11 @@ def get_page_behavior(
)
def add_buttons_to_form(form, fpctx, flow_session, permissions):
# type: (StyledForm, FlowPageContext, FlowSession, frozenset[Text]) -> StyledForm
def add_buttons_to_form(
form: StyledForm,
fpctx: FlowPageContext,
flow_session: FlowSession,
permissions: frozenset[str]) -> StyledForm:
from crispy_forms.layout import Submit
show_save_button = getattr(form, "show_save_button", True)
if show_save_button:
......@@ -1597,10 +1730,11 @@ def add_buttons_to_form(form, fpctx, flow_session, permissions):
css_class="relate-save-button relate-submit-button"))
else:
# Only offer 'save and move on' if student will receive no feedback
if fpctx.page_data.ordinal + 1 < flow_session.page_count:
if (not_none(fpctx.page_data.page_ordinal) + 1
< not_none(flow_session.page_count)):
form.helper.add_input(
Submit("save_and_next",
mark_safe_lazy(
mark_safe(
string_concat(
_("Save answer and move on"),
" &raquo;")),
......@@ -1608,7 +1742,7 @@ def add_buttons_to_form(form, fpctx, flow_session, permissions):
else:
form.helper.add_input(
Submit("save_and_finish",
mark_safe_lazy(
mark_safe(
string_concat(
_("Save answer and finish"),
" &raquo;")),
......@@ -1617,9 +1751,10 @@ def add_buttons_to_form(form, fpctx, flow_session, permissions):
return form
def create_flow_page_visit(request, flow_session, page_data):
# type: (http.HttpRequest, FlowSession, FlowPageData) -> None
def create_flow_page_visit(
request: http.HttpRequest,
flow_session: FlowSession,
page_data: FlowPageData) -> None:
if request.user.is_authenticated:
# The access to 'is_authenticated' ought to wake up SimpleLazyObject.
user = request.user
......@@ -1629,7 +1764,7 @@ def create_flow_page_visit(request, flow_session, page_data):
visit = FlowPageVisit(
flow_session=flow_session,
page_data=page_data,
remote_address=request.META['REMOTE_ADDR'],
remote_address=request.META["REMOTE_ADDR"],
user=user,
is_submitted_answer=None)
......@@ -1640,40 +1775,35 @@ def create_flow_page_visit(request, flow_session, page_data):
@course_view
def view_flow_page(pctx, flow_session_id, ordinal):
# type: (CoursePageContext, int, int) -> http.HttpResponse
def view_flow_page(
pctx: CoursePageContext,
flow_session_id: int,
page_ordinal: int) -> http.HttpResponse:
request = pctx.request
login_exam_ticket = get_login_exam_ticket(request)
ordinal = int(ordinal)
page_ordinal = int(page_ordinal)
flow_session_id = int(flow_session_id)
flow_session = get_and_check_flow_session(pctx, flow_session_id)
flow_id = flow_session.flow_id
if flow_session is None:
messages.add_message(request, messages.WARNING,
_("No in-progress session record found for this flow. "
"Redirected to flow start page."))
assert flow_session is not None
return redirect("relate-view_start_flow",
pctx.course.identifier,
flow_id)
flow_id = flow_session.flow_id
adjust_flow_session_page_data(pctx.repo, flow_session, pctx.course.identifier,
respect_preview=True)
try:
fpctx = FlowPageContext(pctx.repo, pctx.course, flow_id, ordinal,
participation=pctx.participation,
flow_session=flow_session,
request=pctx.request)
fpctx = FlowPageContext(pctx.repo, pctx.course, flow_id, page_ordinal,
participation=pctx.participation,
flow_session=flow_session,
request=pctx.request)
except PageOrdinalOutOfRange:
return redirect("relate-view_flow_page",
pctx.course.identifier,
flow_session.id,
flow_session.page_count-1)
not_none(flow_session.page_count)-1)
if fpctx.page is None:
raise http.Http404()
......@@ -1685,14 +1815,14 @@ def view_flow_page(pctx, flow_session_id, ordinal):
access_rule = get_session_access_rule(
flow_session, fpctx.flow_desc, now_datetime,
facilities=pctx.request.relate_facilities,
login_exam_ticket=login_exam_ticket)
login_exam_ticket=login_exam_ticket,
remote_ip_address=remote_address_from_request(pctx.request))
grading_rule = get_session_grading_rule(
flow_session, fpctx.flow_desc, now_datetime)
generates_grade = (
grading_rule.grade_identifier is not None
and
grading_rule.generates_grade)
and grading_rule.generates_grade)
del grading_rule
permissions = fpctx.page.get_modified_permissions_for_page(
......@@ -1713,6 +1843,7 @@ def view_flow_page(pctx, flow_session_id, ordinal):
answer_visit = None
prev_visit_id = None
viewing_prior_version = False
if request.method == "POST":
if "finish" in request.POST:
......@@ -1734,6 +1865,9 @@ def view_flow_page(pctx, flow_session_id, ordinal):
answer_data,
answer_was_graded) = post_result
if prev_answer_visits:
prev_visit_id = prev_answer_visits[0].id
# continue at common flow page generation below
else:
......@@ -1751,7 +1885,6 @@ def view_flow_page(pctx, flow_session_id, ordinal):
except ValueError:
raise SuspiciousOperation("non-integer passed for 'visit_id'")
viewing_prior_version = False
if prev_answer_visits and prev_visit_id is not None:
answer_visit = prev_answer_visits[0]
......@@ -1765,14 +1898,15 @@ def view_flow_page(pctx, flow_session_id, ordinal):
if viewing_prior_version:
from django.template import defaultfilters
from relate.utils import as_local_time
messages.add_message(request, messages.INFO,
_("Viewing prior submission dated %(date)s.")
messages.add_message(request, messages.INFO, (
_("Viewing prior submission dated %(date)s. ")
% {
"date": defaultfilters.date(
as_local_time(pvisit.visit_time),
as_local_time(answer_visit.visit_time),
"DATETIME_FORMAT"),
})
}
+ '<a class="btn btn-secondary btn-sm" href="?" '
'role="button">&laquo; {}</a>'.format(_("Go back"))))
prev_visit_id = answer_visit.id
......@@ -1820,14 +1954,14 @@ def view_flow_page(pctx, flow_session_id, ordinal):
answer_data, page_behavior)
except InvalidPageData as e:
messages.add_message(request, messages.ERROR,
ugettext(
gettext(
"The page data stored in the database was found "
"to be invalid for the page as given in the "
"course content. Likely the course content was "
"changed in an incompatible way (say, by adding "
"an option to a choice question) without changing "
"the question ID. The precise error encountered "
"was the following: "+str(e)))
"was the following: ")+str(e))
return render_course_page(pctx, "course/course-base.html", {})
......@@ -1886,35 +2020,39 @@ def view_flow_page(pctx, flow_session_id, ordinal):
expiration_mode_choices.append((key, descr))
session_minutes = None
time_factor = 1
time_factor: float = 1
if flow_permission.see_session_time in permissions:
if not flow_session.in_progress:
end_time = as_local_time(not_none(flow_session.completion_time))
else:
end_time = now_datetime
session_minutes = (
now_datetime - flow_session.start_time).total_seconds() / 60
end_time - flow_session.start_time).total_seconds() / 60
if flow_session.participation is not None:
time_factor = flow_session.participation.time_factor
time_factor = float(flow_session.participation.time_factor)
all_page_data = get_all_page_data(flow_session)
from django.db import connection
with connection.cursor() as c:
c.execute(
"SELECT DISTINCT course_flowpagedata.ordinal "
"SELECT DISTINCT course_flowpagedata.page_ordinal "
"FROM course_flowpagevisit "
"INNER JOIN course_flowpagedata "
"ON course_flowpagedata.id = course_flowpagevisit.page_data_id "
"WHERE course_flowpagedata.flow_session_id = %s "
"AND course_flowpagevisit.answer IS NOT NULL "
"ORDER BY course_flowpagedata.ordinal",
"ORDER BY course_flowpagedata.page_ordinal",
[flow_session.id])
flow_page_ordinals_with_answers = set(row[0] for row in c.fetchall())
flow_page_ordinals_with_answers = {row[0] for row in c.fetchall()}
args = {
"flow_identifier": fpctx.flow_id,
"flow_desc": fpctx.flow_desc,
"ordinal": fpctx.ordinal,
"page_ordinal": fpctx.page_ordinal,
"page_data": fpctx.page_data,
"percentage": int(100*(fpctx.ordinal+1) / flow_session.page_count),
"percentage": int(100 * (fpctx.page_ordinal+1) / flow_session.page_count),
"flow_session": flow_session,
"all_page_data": all_page_data,
"flow_page_ordinals_with_answers": flow_page_ordinals_with_answers,
......@@ -1930,12 +2068,13 @@ def view_flow_page(pctx, flow_session_id, ordinal):
"may_change_answer": page_behavior.may_change_answer,
"may_change_graded_answer": (
page_behavior.may_change_answer
and
(flow_permission.change_answer in permissions)),
and (flow_permission.change_answer in permissions)),
"will_receive_feedback": will_receive_feedback(permissions),
"show_answer": page_behavior.show_answer,
"may_send_email_about_flow_page":
may_send_email_about_flow_page(permissions),
may_send_email_about_flow_page(flow_session, permissions),
"hide_point_count": flow_permission.hide_point_count in permissions,
"expects_answer": fpctx.page.expects_answer(),
"session_minutes": session_minutes,
"time_factor": time_factor,
......@@ -1948,6 +2087,7 @@ def view_flow_page(pctx, flow_session_id, ordinal):
"interaction_kind": get_interaction_kind(
fpctx, flow_session, generates_grade, all_page_data),
"viewing_prior_version": viewing_prior_version,
"prev_answer_visits": prev_answer_visits,
"prev_visit_id": prev_visit_id,
}
......@@ -1956,6 +2096,10 @@ def view_flow_page(pctx, flow_session_id, ordinal):
args["max_points"] = fpctx.page.max_points(fpctx.page_data)
args["page_expect_answer_and_gradable"] = True
if fpctx.page.is_optional_page:
assert not getattr(args, "max_points", None)
args["is_optional_page"] = True
return render_course_page(
pctx, "course/flow-page.html", args,
allow_instant_flow_requests=False)
......@@ -1963,9 +2107,72 @@ def view_flow_page(pctx, flow_session_id, ordinal):
# }}}
def get_pressed_button(form):
# type: (StyledForm) -> Text
@course_view
def view_flow_page_with_ext_resource_tabs(
pctx: CoursePageContext,
flow_session_id: int,
page_ordinal: int) -> http.HttpResponse:
request = pctx.request
flow_session_id = int(flow_session_id)
flow_session = get_and_check_flow_session(pctx, flow_session_id)
assert flow_session is not None
flow_id = flow_session.flow_id
adjust_flow_session_page_data(pctx.repo, flow_session, pctx.course.identifier,
respect_preview=True)
fctx = FlowContext(pctx.repo, pctx.course, flow_id,
participation=pctx.participation)
assert fctx.flow_desc.external_resources is not None
target_url = reverse(
"relate-view_flow_page",
args=[pctx.course.identifier, flow_session_id, page_ordinal],
)
return render(
request,
"course/tabbed-page.html",
{"tabs": [
TabDesc(str(_("Relate")), target_url),
*fctx.flow_desc.external_resources
]},
)
@course_view
def get_prev_answer_visits_dropdown_content(
pctx, flow_session_id, page_ordinal, prev_visit_id):
"""
:return: serialized prev_answer_visits items for past-submission-dropdown
"""
request = pctx.request
if request.method != "GET":
raise PermissionDenied()
page_ordinal = int(page_ordinal)
flow_session_id = int(flow_session_id)
flow_session = get_and_check_flow_session(pctx, flow_session_id)
page_data = get_object_or_404(
FlowPageData, flow_session=flow_session, page_ordinal=page_ordinal)
prev_answer_visits = get_prev_answer_visits_qset(page_data)
return render(request, "course/flow-page-prev-visits.html", {
"prev_answer_visits": prev_answer_visits,
"prev_visit_id": (
None
if prev_visit_id == "None" else
int(prev_visit_id)),
})
def get_pressed_button(form: forms.Form) -> str:
buttons = ["save", "save_and_next", "save_and_finish", "submit"]
for button in buttons:
if button in form.data:
......@@ -1976,21 +2183,18 @@ def get_pressed_button(form):
@retry_transaction_decorator()
def post_flow_page(
flow_session, # type: FlowSession
fpctx, # type: FlowPageContext
request, # type: http.HttpRequest
permissions, # type: frozenset[Text]
generates_grade, # type: bool
):
# type: (...) -> Tuple[PageBehavior, List[FlowPageVisit], forms.Form, Optional[AnswerFeedback], Any, bool] # noqa
flow_session: FlowSession,
fpctx: FlowPageContext,
request: http.HttpRequest,
permissions: frozenset[str],
generates_grade: bool,
) -> tuple[PageBehavior, list[FlowPageVisit],
forms.Form, AnswerFeedback | None, Any, bool] | http.HttpResponse:
page_context = fpctx.page_context
page_data = fpctx.page_data
assert page_context is not None
prev_answer_visits = list(
get_prev_answer_visits_qset(fpctx.page_data))
submission_allowed = True
assert fpctx.page is not None
......@@ -2001,6 +2205,9 @@ def post_flow_page(
_("Answer submission not allowed."))
submission_allowed = False
prev_answer_visits = list(
get_prev_answer_visits_qset(fpctx.page_data))
# reject if previous answer was final
if (prev_answer_visits
and prev_answer_visits[0].is_submitted_answer
......@@ -2034,7 +2241,7 @@ def post_flow_page(
answer_visit = FlowPageVisit()
answer_visit.flow_session = flow_session
answer_visit.page_data = fpctx.page_data
answer_visit.remote_address = request.META['REMOTE_ADDR']
answer_visit.remote_address = request.META["REMOTE_ADDR"]
answer_data = answer_visit.answer = fpctx.page.answer_data(
page_context, fpctx.page_data.data,
......@@ -2058,16 +2265,16 @@ def post_flow_page(
is_unenrolled_session=flow_session.participation is None)
if fpctx.page.is_answer_gradable():
with translation.override(settings.RELATE_ADMIN_EMAIL_LOCALE):
feedback = fpctx.page.grade(
with LanguageOverride(course=fpctx.course):
feedback: AnswerFeedback | None = fpctx.page.grade(
page_context, page_data.data, answer_visit.answer,
grade_data=None) # type: Optional[AnswerFeedback]
grade_data=None)
if answer_visit.is_submitted_answer:
grade = FlowPageVisitGrade()
grade.visit = answer_visit
grade.max_points = fpctx.page.max_points(page_data.data)
grade.graded_at_git_commit_sha = fpctx.course_commit_sha
grade.graded_at_git_commit_sha = fpctx.course_commit_sha.decode()
bulk_feedback_json = None
if feedback is not None:
......@@ -2085,9 +2292,9 @@ def post_flow_page(
if (pressed_button == "save_and_next"
and not will_receive_feedback(permissions)):
return redirect("relate-view_flow_page",
fpctx.course.identifier,
flow_session.id,
fpctx.ordinal + 1)
fpctx.course.identifier,
flow_session.id,
fpctx.page_ordinal + 1)
elif (pressed_button == "save_and_finish"
and not will_receive_feedback(permissions)):
return redirect("relate-finish_flow_session_view",
......@@ -2134,16 +2341,19 @@ def post_flow_page(
# {{{ view: send interaction email to course staffs in flow pages
@course_view
def send_email_about_flow_page(pctx, flow_session_id, ordinal):
def send_email_about_flow_page(pctx, flow_session_id, page_ordinal):
# {{{ check if interaction email is allowed for this page.
ordinal = int(ordinal)
page_ordinal = int(page_ordinal)
flow_session_id = int(flow_session_id)
flow_session = get_and_check_flow_session(pctx, flow_session_id)
flow_id = flow_session.flow_id
fpctx = FlowPageContext(pctx.repo, pctx.course, flow_id, ordinal,
adjust_flow_session_page_data(pctx.repo, flow_session, pctx.course.identifier,
respect_preview=True)
fpctx = FlowPageContext(pctx.repo, pctx.course, flow_id, page_ordinal,
participation=pctx.participation,
flow_session=flow_session,
request=pctx.request)
......@@ -2157,38 +2367,28 @@ def send_email_about_flow_page(pctx, flow_session_id, ordinal):
access_rule = get_session_access_rule(
flow_session, fpctx.flow_desc, now_datetime,
facilities=pctx.request.relate_facilities,
login_exam_ticket=login_exam_ticket)
login_exam_ticket=login_exam_ticket,
remote_ip_address=remote_address_from_request(pctx.request))
permissions = fpctx.page.get_modified_permissions_for_page(
access_rule.permissions)
if not may_send_email_about_flow_page(permissions):
if not may_send_email_about_flow_page(flow_session, permissions):
raise http.Http404()
# }}}
from django.conf import settings
from course.models import FlowSession
flow_session = get_object_or_404(
FlowSession, id=int(flow_session_id))
from course.models import FlowPageData
page_id = FlowPageData.objects.get(
flow_session=flow_session_id, ordinal=ordinal).page_id
from django.core.urlresolvers import reverse
review_url = reverse(
"relate-view_flow_page",
kwargs={'course_identifier': pctx.course.identifier,
'flow_session_id': flow_session_id,
'ordinal': ordinal
kwargs={"course_identifier": pctx.course.identifier,
"flow_session_id": flow_session_id,
"page_ordinal": page_ordinal
}
)
from six.moves.urllib.parse import urljoin
from urllib.parse import urljoin
review_uri = urljoin(getattr(settings, "RELATE_BASE_URL"),
review_url)
review_uri = urljoin(settings.RELATE_BASE_URL, review_url)
if request.method == "POST":
form = FlowPageInteractionEmailForm(review_uri, request.POST)
......@@ -2201,36 +2401,47 @@ def send_email_about_flow_page(pctx, flow_session_id, ordinal):
settings.ROBOT_EMAIL_FROM)
student_email = flow_session.participation.user.email
from course.constants import (
participation_permission as pperm)
from course.constants import participation_status
ta_email_list = Participation.objects.filter(
course=pctx.course,
roles__permissions__permission=pperm.assign_grade,
roles__identifier="ta",
status=participation_status.active
).values_list("user__email", flat=True)
instructor_email_list = Participation.objects.filter(
recipient_list = ta_email_list
if not recipient_list:
# instructors to receive the email
recipient_list = Participation.objects.filter(
course=pctx.course,
roles__permissions__permission=pperm.assign_grade,
roles__identifier="instructor"
).values_list("user__email", flat=True)
).values_list("user__email", flat=True)
recipient_list = ta_email_list
if not recipient_list:
recipient_list = instructor_email_list
with LanguageOverride(course=pctx.course):
from course.utils import will_use_masked_profile_for_email
if will_use_masked_profile_for_email(recipient_list):
username = pctx.participation.user.get_masked_profile()
else:
username = pctx.participation.user.get_full_name()
page_id = FlowPageData.objects.get(
flow_session=flow_session_id, page_ordinal=page_ordinal).page_id
from relate.utils import render_email_template
from django.utils import translation
with translation.override(settings.RELATE_ADMIN_EMAIL_LOCALE):
from django.template.loader import render_to_string
message = render_to_string(
message = render_email_template(
"course/flow-page-interaction-email.txt", {
"page_id": page_id,
"flow_session_id": flow_session_id,
"course": pctx.course,
"question_text": form.cleaned_data["message"],
"review_uri": review_uri,
"username": pctx.participation.user.get_full_name()
"username": username
})
from django.core.mail import EmailMessage
......@@ -2239,10 +2450,10 @@ def send_email_about_flow_page(pctx, flow_session_id, ordinal):
"[%(identifier)s:%(flow_id)s--%(page_id)s] ",
_("Interaction request from %(username)s"))
% {
'identifier': pctx.course_identifier,
'flow_id': flow_session_id,
'page_id': page_id,
'username': pctx.participation.user.get_full_name()
"identifier": pctx.course_identifier,
"flow_id": flow_session_id,
"page_id": page_id,
"username": username
},
body=message,
from_email=from_email,
......@@ -2263,7 +2474,7 @@ def send_email_about_flow_page(pctx, flow_session_id, ordinal):
"also receive a copy of the email."))
return redirect("relate-view_flow_page",
pctx.course.identifier, flow_session_id, ordinal)
pctx.course.identifier, flow_session_id, page_ordinal)
else:
form = FlowPageInteractionEmailForm(review_uri)
......@@ -2277,11 +2488,11 @@ def send_email_about_flow_page(pctx, flow_session_id, ordinal):
class FlowPageInteractionEmailForm(StyledForm):
def __init__(self, review_uri, *args, **kwargs):
super(FlowPageInteractionEmailForm, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
self.fields["message"] = forms.CharField(
required=True,
widget=forms.Textarea,
help_text= string_concat(
help_text=string_concat(
_("Your questions about page %s . ") % review_uri,
_("Notice that <strong>only</strong> questions "
"for that page will be answered."),
......@@ -2293,7 +2504,7 @@ class FlowPageInteractionEmailForm(StyledForm):
css_class="relate-submit-button"))
def clean_message(self):
cleaned_data = super(FlowPageInteractionEmailForm, self).clean()
cleaned_data = super().clean()
message = cleaned_data.get("message")
if len(message) < 20:
raise forms.ValidationError(
......@@ -2306,7 +2517,7 @@ class FlowPageInteractionEmailForm(StyledForm):
# {{{ view: update page bookmark state
@course_view
def update_page_bookmark_state(pctx, flow_session_id, ordinal):
def update_page_bookmark_state(pctx, flow_session_id, page_ordinal):
if pctx.request.method != "POST":
raise SuspiciousOperation(_("only POST allowed"))
......@@ -2323,8 +2534,8 @@ def update_page_bookmark_state(pctx, flow_session_id, ordinal):
bookmark_state = bookmark_state == "1"
fpd = get_object_or_404(FlowPageData.objects,
flow_session=flow_session,
ordinal=ordinal)
flow_session=flow_session,
page_ordinal=page_ordinal)
fpd.bookmarked = bookmark_state
fpd.save()
......@@ -2337,9 +2548,8 @@ def update_page_bookmark_state(pctx, flow_session_id, ordinal):
# {{{ view: update expiration mode
@course_view
def update_expiration_mode(pctx, flow_session_id):
# type: (CoursePageContext, int) -> http.HttpRequest
def update_expiration_mode(
pctx: CoursePageContext, flow_session_id: int) -> http.HttpResponse:
if pctx.request.method != "POST":
raise SuspiciousOperation(_("only POST allowed"))
......@@ -2358,6 +2568,7 @@ def update_expiration_mode(pctx, flow_session_id):
if not any(expmode == em_key
for em_key, _ in FLOW_SESSION_EXPIRATION_MODE_CHOICES):
raise SuspiciousOperation(_("invalid expiration mode"))
assert expmode is not None
fctx = FlowContext(pctx.repo, pctx.course, flow_session.flow_id,
participation=pctx.participation)
......@@ -2366,7 +2577,8 @@ def update_expiration_mode(pctx, flow_session_id):
flow_session, fctx.flow_desc,
get_now_or_fake_time(pctx.request),
facilities=pctx.request.relate_facilities,
login_exam_ticket=login_exam_ticket)
login_exam_ticket=login_exam_ticket,
remote_ip_address=remote_address_from_request(pctx.request))
if is_expiration_mode_allowed(expmode, access_rule.permissions):
flow_session.expiration_mode = expmode
......@@ -2382,9 +2594,8 @@ def update_expiration_mode(pctx, flow_session_id):
# {{{ view: finish flow
@course_view
def finish_flow_session_view(pctx, flow_session_id):
# type: (CoursePageContext, int) -> http.HttpResponse
def finish_flow_session_view(
pctx: CoursePageContext, flow_session_id: int) -> http.HttpResponse:
# Does not need to be atomic: All writing to the db
# is done in 'finish_flow_session' below.
......@@ -2404,7 +2615,8 @@ def finish_flow_session_view(pctx, flow_session_id):
access_rule = get_session_access_rule(
flow_session, fctx.flow_desc, now_datetime,
facilities=pctx.request.relate_facilities,
login_exam_ticket=login_exam_ticket)
login_exam_ticket=login_exam_ticket,
remote_ip_address=remote_address_from_request(pctx.request))
from course.content import markup_to_html
completion_text = markup_to_html(
......@@ -2414,22 +2626,16 @@ def finish_flow_session_view(pctx, flow_session_id):
adjust_flow_session_page_data(pctx.repo, flow_session, pctx.course.identifier,
fctx.flow_desc, respect_preview=True)
answer_visits = assemble_answer_visits(flow_session) # type: List[Optional[FlowPageVisit]] # noqa
answer_visits: list[FlowPageVisit | None] = assemble_answer_visits(flow_session)
(answered_page_data_list, unanswered_page_data_list) =\
(answered_page_data_list, unanswered_page_data_list, is_interactive_flow) =\
get_session_answered_page_data(
fctx, flow_session, answer_visits)
answered_count = len(answered_page_data_list)
unanswered_count = len(unanswered_page_data_list)
is_interactive_flow = bool(answered_count + unanswered_count) # type: bool
if flow_permission.view not in access_rule.permissions:
raise PermissionDenied()
def render_finish_response(template, **kwargs):
# type: (...) -> http.HttpResponse
def render_finish_response(template, **kwargs) -> http.HttpResponse:
render_args = {
"flow_identifier": fctx.flow_id,
"flow_desc": fctx.flow_desc,
......@@ -2463,6 +2669,16 @@ def finish_flow_session_view(pctx, flow_session_id):
if (hasattr(fctx.flow_desc, "notify_on_submit")
and fctx.flow_desc.notify_on_submit):
staff_email = (
[*fctx.flow_desc.notify_on_submit, fctx.course.notify_email])
from course.utils import will_use_masked_profile_for_email
use_masked_profile = will_use_masked_profile_for_email(staff_email)
if flow_session.participation is None or flow_session.user is None:
# because Anonymous doesn't have get_masked_profile() method
use_masked_profile = False
if (grading_rule.grade_identifier
and flow_session.participation is not None):
from course.models import get_flow_grading_opportunity
......@@ -2481,21 +2697,35 @@ def finish_flow_session_view(pctx, flow_session_id):
flow_session.id,
0))
with translation.override(settings.RELATE_ADMIN_EMAIL_LOCALE):
from django.template.loader import render_to_string
message = render_to_string("course/submit-notify.txt", {
with LanguageOverride(course=pctx.course):
from relate.utils import render_email_template
participation = flow_session.participation
message = render_email_template("course/submit-notify.txt", {
"course": fctx.course,
"flow_session": flow_session,
"use_masked_profile": use_masked_profile,
"review_uri": pctx.request.build_absolute_uri(review_uri)
})
participation_desc = repr(participation)
if use_masked_profile:
assert participation is not None
participation_desc = _(
"%(user)s in %(course)s as %(role)s") % {
"user": participation.user.get_masked_profile(),
"course": flow_session.course,
"role": "/".join(
role.identifier
for role in participation.roles.all())
}
from django.core.mail import EmailMessage
msg = EmailMessage(
string_concat("[%(identifier)s:%(flow_id)s] ",
_("Submission by %(participation)s"))
% {'participation': flow_session.participation,
'identifier': fctx.course.identifier,
'flow_id': flow_session.flow_id},
_("Submission by %(participation_desc)s"))
% {"participation_desc": participation_desc,
"identifier": fctx.course.identifier,
"flow_id": flow_session.flow_id},
message,
getattr(settings, "NOTIFICATION_EMAIL_FROM",
settings.ROBOT_EMAIL_FROM),
......@@ -2528,14 +2758,13 @@ def finish_flow_session_view(pctx, flow_session_id):
completion_text=completion_text)
if (not is_interactive_flow
or
(flow_session.in_progress
or (flow_session.in_progress
and flow_permission.end_session not in access_rule.permissions)):
# No ability to end--just show completion page.
return render_finish_response(
"course/flow-completion.html",
last_page_nr=flow_session.page_count-1,
last_page_nr=not_none(flow_session.page_count)-1,
flow_session=flow_session,
completion_text=completion_text)
......@@ -2554,14 +2783,20 @@ def finish_flow_session_view(pctx, flow_session_id):
else:
# confirm ending flow
answered_count = len(answered_page_data_list)
unanswered_count = len(unanswered_page_data_list)
required_count = answered_count + unanswered_count
session_may_generate_grade = (
grading_rule.generates_grade and required_count)
return render_finish_response(
"course/flow-confirm-completion.html",
last_page_nr=flow_session.page_count-1,
last_page_nr=not_none(flow_session.page_count)-1,
flow_session=flow_session,
answered_count=answered_count,
unanswered_count=unanswered_count,
unanswered_page_data_list=unanswered_page_data_list,
total_count=answered_count+unanswered_count)
required_count=required_count,
session_may_generate_grade=session_may_generate_grade)
# }}}
......@@ -2569,9 +2804,8 @@ def finish_flow_session_view(pctx, flow_session_id):
# {{{ view: regrade flow
class RegradeFlowForm(StyledForm):
def __init__(self, flow_ids, *args, **kwargs):
# type: (List[Text], *Any, **Any) -> None
super(RegradeFlowForm, self).__init__(*args, **kwargs)
def __init__(self, flow_ids: list[str], *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self.fields["flow_id"] = forms.ChoiceField(
choices=[(fid, fid) for fid in flow_ids],
......@@ -2599,8 +2833,7 @@ class RegradeFlowForm(StyledForm):
@course_view
def regrade_flows_view(pctx):
# type: (CoursePageContext) -> http.HttpResponse
def regrade_flows_view(pctx: CoursePageContext) -> http.HttpResponse:
if not pctx.has_permission(pperm.batch_regrade_flow_session):
raise PermissionDenied(_("may not batch-regrade flows"))
......@@ -2643,4 +2876,126 @@ def regrade_flows_view(pctx):
# }}}
# {{{ view: unsubmit flow page
class UnsubmitFlowPageForm(forms.Form):
def __init__(self, *args: Any, **kwargs: Any) -> None:
self.helper = FormHelper()
super().__init__(*args, **kwargs)
self.helper.add_input(Submit("submit", _("Re-allow changes")))
self.helper.add_input(Submit("cancel", _("Cancel")))
@course_view
def view_unsubmit_flow_page(
pctx: CoursePageContext, flow_session_id: int, page_ordinal: int
) -> http.HttpResponse:
if pctx.participation is None:
raise PermissionDenied()
if not pctx.has_permission(pperm.reopen_flow_session):
raise PermissionDenied()
request = pctx.request
now_datetime = get_now_or_fake_time(request)
page_ordinal = int(page_ordinal)
flow_session_id = int(flow_session_id)
flow_session = get_and_check_flow_session(pctx, flow_session_id)
adjust_flow_session_page_data(pctx.repo, flow_session, pctx.course.identifier,
respect_preview=True)
page_data = get_object_or_404(
FlowPageData, flow_session=flow_session, page_ordinal=page_ordinal)
visit = get_first_from_qset(
get_prev_answer_visits_qset(page_data)
.filter(is_submitted_answer=True))
if visit is None:
messages.add_message(request, messages.INFO,
_("No prior answers found that could be un-submitted."))
return redirect("relate-view_flow_page",
pctx.course.identifier, flow_session_id, page_ordinal)
if request.method == "POST":
form = UnsubmitFlowPageForm(request.POST)
if form.is_valid():
if "submit" in request.POST:
unsubmit_page(visit, now_datetime)
messages.add_message(request, messages.INFO,
_("Flow page changes reallowed. "))
return redirect("relate-view_flow_page",
pctx.course.identifier, flow_session_id, page_ordinal)
else:
form = UnsubmitFlowPageForm()
return render_course_page(pctx, "course/generic-course-form.html", {
"form_description": _("Re-allow Changes to Flow Page"),
"form": form
})
# }}}
# {{{ purge page view data
def get_pv_purgeable_courses_for_user_qs(user: User) -> query.QuerySet:
course_qs = Course.objects.all()
if user.is_superuser:
# do not filter queryset
pass
else:
course_qs = course_qs.filter(
participations__user=user,
participations__roles__permissions__permission=(
pperm.use_admin_interface))
return course_qs
class PurgePageViewData(StyledForm):
def __init__(self, user: User, *args: Any, **kwargs: Any) -> None:
self.helper = FormHelper()
super().__init__(*args, **kwargs)
self.fields["course"] = forms.ModelChoiceField(
queryset=get_pv_purgeable_courses_for_user_qs(user),
required=True)
self.helper.add_input(
Submit("submit", _("Purge Page View Data"),
css_class="btn btn-danger"))
@login_required
def purge_page_view_data(request):
purgeable_courses = get_pv_purgeable_courses_for_user_qs(request.user)
if not purgeable_courses.count():
raise PermissionDenied()
if request.method == "POST":
form = PurgePageViewData(request.user, request.POST)
if form.is_valid():
if "submit" in request.POST:
course = form.cleaned_data["course"]
from course.tasks import purge_page_view_data
async_res = purge_page_view_data.delay(course.id)
return redirect("relate-monitor_task", async_res.id)
else:
form = PurgePageViewData(request.user)
return render(request, "generic-form.html", {
"form_description": _("Purge Page View Data"),
"form": form
})
# }}}
# vim: foldmethod=marker
# -*- coding: utf-8 -*-
from __future__ import annotations
from __future__ import division
__copyright__ = "Copyright (C) 2014 Andreas Kloeckner"
......@@ -25,44 +24,56 @@ THE SOFTWARE.
"""
import re
import six
from decimal import Decimal
from typing import (
TYPE_CHECKING,
Any,
cast,
)
from django.utils.translation import (
ugettext_lazy as _, pgettext_lazy, ugettext, string_concat)
from django.shortcuts import ( # noqa
render, redirect, get_object_or_404)
from django.contrib import messages # noqa
from crispy_forms.layout import Submit
from django import forms, http
from django.contrib import messages
from django.core.exceptions import (
PermissionDenied, SuspiciousOperation, ObjectDoesNotExist)
from django.db import connection
from django import forms
from django.db import transaction
from django.utils.timezone import now
from django import http
ObjectDoesNotExist,
PermissionDenied,
SuspiciousOperation,
)
from django.db import connection, transaction
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from relate.utils import StyledForm, StyledModelForm
from crispy_forms.layout import Submit
from bootstrap3_datetime.widgets import DateTimePicker
from django.utils.timezone import now
from django.utils.translation import gettext, gettext_lazy as _, pgettext_lazy
from course.utils import course_view, render_course_page
from course.models import (
Participation, participation_status,
GradingOpportunity, GradeChange, GradeStateMachine,
grade_state_change_types,
FlowSession, FlowPageVisit)
from course.constants import participation_permission as pperm
from course.flow import adjust_flow_session_page_data
from course.models import (
FlowPageVisit,
FlowSession,
GradeChange,
GradeStateMachine,
GradingOpportunity,
Participation,
grade_state_change_types,
participation_status,
)
from course.utils import course_view, render_course_page
from course.views import get_now_or_fake_time
from course.constants import (
participation_permission as pperm,
)
from relate.utils import (
HTML5DateTimeInput,
StyledForm,
StyledModelForm,
not_none,
string_concat,
)
# {{{ for mypy
from typing import cast, Tuple, Text, Optional, Any, Iterable # noqa
from course.utils import CoursePageContext # noqa
from course.content import FlowDesc # noqa
from course.models import Course, FlowPageVisitGrade # noqa
if TYPE_CHECKING:
from course.content import FlowDesc
from course.models import Course, FlowPageVisitGrade
from course.utils import CoursePageContext
# }}}
......@@ -87,17 +98,23 @@ def view_participant_grades(pctx, participation_id=None):
# NOTE: It's important that these two queries are sorted consistently,
# also consistently with the code below.
grading_opps = list((GradingOpportunity.objects
gopp_extra_filter_kwargs = {}
if not is_privileged_view:
gopp_extra_filter_kwargs = {"shown_in_participant_grade_book": True}
grading_opps = list(GradingOpportunity.objects
.filter(
course=pctx.course,
shown_in_grade_book=True,
**gopp_extra_filter_kwargs
)
.order_by("identifier")))
.order_by("identifier"))
grade_changes = list(GradeChange.objects
.filter(
participation=grade_participation,
opportunity__course=pctx.course,
opportunity__pk__in=[gopp.pk for gopp in grading_opps],
opportunity__shown_in_grade_book=True)
.order_by(
"participation__id",
......@@ -178,11 +195,11 @@ def view_grading_opportunity_list(pctx):
if not pctx.has_permission(pperm.view_gradebook):
raise PermissionDenied(_("may not view grade book"))
grading_opps = list((GradingOpportunity.objects
grading_opps = list(GradingOpportunity.objects
.filter(
course=pctx.course,
)
.order_by("identifier")))
.order_by("identifier"))
return render_course_page(pctx, "course/gradebook-opp-list.html", {
"grading_opps": grading_opps,
......@@ -194,23 +211,23 @@ def view_grading_opportunity_list(pctx):
# {{{ teacher grade book
class GradeInfo:
def __init__(self, opportunity, grade_state_machine):
# type: (GradingOpportunity, GradeStateMachine) -> None
def __init__(self, opportunity: GradingOpportunity,
grade_state_machine: GradeStateMachine) -> None:
self.opportunity = opportunity
self.grade_state_machine = grade_state_machine
def get_grade_table(course):
# type: (Course) -> Tuple[List[Participation], List[GradingOpportunity], List[List[GradeInfo]]] # noqa
def get_grade_table(course: Course) -> tuple[
list[Participation], list[GradingOpportunity], list[list[GradeInfo]]]:
# NOTE: It's important that these queries are sorted consistently,
# also consistently with the code below.
grading_opps = list((GradingOpportunity.objects
grading_opps = list(GradingOpportunity.objects
.filter(
course=course,
shown_in_grade_book=True,
)
.order_by("identifier")))
.order_by("identifier"))
participations = list(Participation.objects
.filter(
......@@ -278,11 +295,11 @@ def view_gradebook(pctx):
participations, grading_opps, grade_table = get_grade_table(pctx.course)
def grade_key(entry):
(participation, grades) = entry
(participation, _grades) = entry
return (participation.user.last_name.lower(),
participation.user.first_name.lower())
grade_table = sorted(zip(participations, grade_table), key=grade_key)
grade_table = sorted(zip(participations, grade_table, strict=True), key=grade_key)
return render_course_page(pctx, "course/gradebook.html", {
"grade_table": grade_table,
......@@ -299,22 +316,19 @@ def export_gradebook_csv(pctx):
participations, grading_opps, grade_table = get_grade_table(pctx.course)
from six import StringIO
from io import StringIO
csvfile = StringIO()
if six.PY2:
import unicodecsv as csv
else:
import csv
import csv
fieldnames = ['user_name', 'last_name', 'first_name'] + [
fieldnames = ["user_name", "last_name", "first_name"] + [
gopp.identifier for gopp in grading_opps]
writer = csv.writer(csvfile)
writer.writerow(fieldnames)
for participation, grades in zip(participations, grade_table):
for participation, grades in zip(participations, grade_table, strict=True):
writer.writerow([
participation.user.username,
participation.user.last_name,
......@@ -325,9 +339,8 @@ def export_gradebook_csv(pctx):
response = http.HttpResponse(
csvfile.getvalue().encode("utf-8"),
content_type="text/plain; charset=utf-8")
response['Content-Disposition'] = (
'attachment; filename="grades-%s.csv"'
% pctx.course.identifier)
response["Content-Disposition"] = (
f'attachment; filename="grades-{pctx.course.identifier}.csv"')
return response
# }}}
......@@ -335,20 +348,39 @@ def export_gradebook_csv(pctx):
# {{{ grades by grading opportunity
class OpportunitySessionGradeInfo(object):
def __init__(self, grade_state_machine, flow_session, grades=None):
# type: (GradeStateMachine, Optional[FlowSession], Optional[Any]) -> None
class OpportunitySessionGradeInfo:
def __init__(self,
grade_state_machine, # Optional[GradeStateMachine]
flow_session, # Optional[FlowSession]
flow_id=None, # Optional[Text]
grades=None, # Optional[Any]
has_finished_session=False, # bool
) -> None:
"""
:param grade_state_machine: a :class:`GradeStateMachine:` or None.
:param flow_session: a :class:`FlowSession:` or None.
:param flow_id: optional, a :class:`str:` or None. This is used to determine
whether the instance is created by a flow-session-related opportunity.
:param grades: optional, a :class:`list:` of float or None, representing the
percentage grades of each page in the flow session.
:param has_finished_session: a :class:`bool:`, represent whether
the related participation has finished a flow-session, if the opportunity
is a flow-session related one. This is used to correctly order flow state,
if the participation has at least one finished flow session, the in-progress
flow session won't be ordered to top of the session state column.
"""
self.grade_state_machine = grade_state_machine
self.flow_session = flow_session
self.flow_id = flow_id
self.grades = grades
self.has_finished_session = has_finished_session
class ModifySessionsForm(StyledForm):
def __init__(self, session_rule_tags, *args, **kwargs):
# type: (List[Text], *Any, **Any) -> None
def __init__(self, session_rule_tags: list[str],
*args: Any, **kwargs: Any) -> None:
super(ModifySessionsForm, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
self.fields["rule_tag"] = forms.ChoiceField(
choices=tuple(
......@@ -376,8 +408,7 @@ class ModifySessionsForm(StyledForm):
RULE_TAG_NONE_STRING = "<<<NONE>>>"
def mangle_session_access_rule_tag(rule_tag):
# type: (Optional[Text]) -> Text
def mangle_session_access_rule_tag(rule_tag: str | None) -> str:
if rule_tag is None:
return RULE_TAG_NONE_STRING
else:
......@@ -385,8 +416,8 @@ def mangle_session_access_rule_tag(rule_tag):
@course_view
def view_grades_by_opportunity(pctx, opp_id):
# type: (CoursePageContext, Text) -> http.HttpResponse
def view_grades_by_opportunity(
pctx: CoursePageContext, opp_id: str) -> http.HttpResponse:
from course.views import get_now_or_fake_time
now_datetime = get_now_or_fake_time(pctx.request)
......@@ -408,7 +439,7 @@ def view_grades_by_opportunity(pctx, opp_id):
or pctx.has_permission(pperm.batch_recalculate_flow_session_grade)
)
batch_session_ops_form = None # type: Optional[ModifySessionsForm]
batch_session_ops_form: ModifySessionsForm | None = None
if batch_ops_allowed and opportunity.flow_id:
cursor = connection.cursor()
cursor.execute("select distinct access_rules_tag from course_flowsession "
......@@ -456,10 +487,11 @@ def view_grades_by_opportunity(pctx, opp_id):
rule_tag = None
from course.tasks import (
expire_in_progress_sessions,
finish_in_progress_sessions,
regrade_flow_sessions,
recalculate_ended_sessions)
expire_in_progress_sessions,
finish_in_progress_sessions,
recalculate_ended_sessions,
regrade_flow_sessions,
)
if op == "expire":
async_res = expire_in_progress_sessions.delay(
......@@ -484,16 +516,14 @@ def view_grades_by_opportunity(pctx, opp_id):
return redirect("relate-monitor_task", async_res.id)
elif op == "recalculate":
else:
assert op == "recalculate"
async_res = recalculate_ended_sessions.delay(
pctx.course.id, opportunity.flow_id,
rule_tag)
return redirect("relate-monitor_task", async_res.id)
else:
raise SuspiciousOperation("invalid operation")
else:
batch_session_ops_form = ModifySessionsForm(session_rule_tags)
......@@ -519,14 +549,14 @@ def view_grades_by_opportunity(pctx, opp_id):
.select_related("opportunity"))
if opportunity.flow_id:
flow_sessions = list(FlowSession.objects
flow_sessions: list[FlowSession] | None = list(FlowSession.objects
.filter(
flow_id=opportunity.flow_id,
)
.order_by(
"participation__id",
"start_time"
)) # type: Optional[List[FlowSession]]
))
else:
flow_sessions = None
......@@ -538,8 +568,8 @@ def view_grades_by_opportunity(pctx, opp_id):
finished_sessions = 0
total_sessions = 0
grade_table = [] # type: List[Tuple[Participation, OpportunitySessionGradeInfo]]
for idx, participation in enumerate(participations):
grade_table: list[tuple[Participation, OpportunitySessionGradeInfo]] = []
for participation in participations:
# Advance in grade change list
while (
gchng_idx < len(grade_changes)
......@@ -564,24 +594,38 @@ def view_grades_by_opportunity(pctx, opp_id):
flow_session=None)))
else:
while (
fsess_idx < len(flow_sessions) and (
flow_sessions[fsess_idx].participation is None or
flow_sessions[fsess_idx].participation.pk < participation.pk)):
fsess_idx < len(flow_sessions)
and (
flow_sessions[fsess_idx].participation is None
or (
not_none(flow_sessions[fsess_idx].participation).pk
< participation.pk))):
fsess_idx += 1
my_flow_sessions = []
while (
fsess_idx < len(flow_sessions) and
flow_sessions[fsess_idx].participation is not None and
flow_sessions[fsess_idx].participation.pk == participation.pk):
fsess_idx < len(flow_sessions)
and flow_sessions[fsess_idx].participation is not None
and (
not_none(flow_sessions[fsess_idx].participation).pk
== participation.pk)):
my_flow_sessions.append(flow_sessions[fsess_idx])
fsess_idx += 1
# When view_page_grades, participations with no started flow sessions
# should not be included
if not my_flow_sessions and not view_page_grades:
grade_table.append(
(participation, OpportunitySessionGradeInfo(
grade_state_machine=None,
flow_id=opportunity.flow_id,
flow_session=None)))
for fsession in my_flow_sessions:
total_sessions += 1
if fsession is None:
continue
assert fsession is not None
if not fsession.in_progress:
finished_sessions += 1
......@@ -589,23 +633,29 @@ def view_grades_by_opportunity(pctx, opp_id):
grade_table.append(
(participation, OpportunitySessionGradeInfo(
grade_state_machine=state_machine,
flow_session=fsession)))
flow_id=opportunity.flow_id,
flow_session=fsession,
has_finished_session=bool(finished_sessions))))
if view_page_grades and len(grade_table) > 0 and all(
info.flow_session is not None for _dummy1, info in grade_table):
if view_page_grades and len(grade_table) > 0 and opportunity.flow_id is not None:
# Query grades for flow pages
all_flow_sessions = [
cast(FlowSession, info.flow_session)
for _dummy1, info in grade_table]
max_page_count = max(fsess.page_count for fsess in all_flow_sessions)
assert all(all_flow_sessions)
max_page_count = max(not_none(fsess.page_count) for fsess in all_flow_sessions)
page_numbers = list(range(1, 1 + max_page_count))
from course.flow import assemble_page_grades
page_grades = assemble_page_grades(all_flow_sessions) # type: List[List[Optional[FlowPageVisitGrade]]] # noqa
page_grades: list[list[FlowPageVisitGrade | None]] \
= assemble_page_grades(all_flow_sessions)
for (_dummy2, grade_info), grade_list in zip(grade_table, page_grades): # type: ignore # noqa
for (_dummy2, grade_info), grade_list in \
zip(grade_table, page_grades, strict=True): # type: ignore
# Not all pages exist in all sessions
grades = list(enumerate(grade_list)) # type: List[Tuple[Optional[int], Optional[FlowPageVisitGrade]]] # noqa
grades: list[tuple[int | None, FlowPageVisitGrade | None]] \
= list(enumerate(grade_list))
if len(grades) < max_page_count:
grades.extend([(None, None)] * (max_page_count - len(grades)))
grade_info.grades = grades
......@@ -632,26 +682,31 @@ def view_grades_by_opportunity(pctx, opp_id):
# {{{ reopen session UI
NONE_SESSION_TAG = "<<<NONE>>>" # noqa
NONE_SESSION_TAG = "<<<NONE>>>"
class ReopenSessionForm(StyledForm):
def __init__(self, flow_desc, current_tag, *args, **kwargs):
# type: (FlowDesc, Text, *Any, **Any) -> None
def __init__(self, flow_desc: FlowDesc, current_tag: str | None,
*args: Any, **kwargs: Any) -> None:
super(ReopenSessionForm, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
rules = getattr(flow_desc, "rules", object())
tags = getattr(rules, "tags", [])
tags = [NONE_SESSION_TAG] + tags
tags = [NONE_SESSION_TAG, *tags]
self.fields["set_access_rules_tag"] = forms.ChoiceField(
[(tag, tag) for tag in tags],
choices=[(tag, tag) for tag in tags],
initial=(current_tag
if current_tag is not None
else NONE_SESSION_TAG),
label=_("Set access rules tag"))
self.fields["unsubmit_pages"] = forms.BooleanField(
initial=True,
required=False,
label=_("Re-allow changes to already-submitted questions"))
self.fields["comment"] = forms.CharField(
widget=forms.Textarea, required=True,
label=_("Comment"))
......@@ -663,8 +718,8 @@ class ReopenSessionForm(StyledForm):
@course_view
@transaction.atomic
def view_reopen_session(pctx, flow_session_id, opportunity_id):
# type: (CoursePageContext, Text, Text) -> http.HttpResponse
def view_reopen_session(pctx: CoursePageContext, flow_session_id: str,
opportunity_id: str) -> http.HttpResponse:
if not pctx.has_permission(pperm.reopen_flow_session):
raise PermissionDenied(_("may not reopen session"))
......@@ -697,23 +752,27 @@ def view_reopen_session(pctx, flow_session_id, opportunity_id):
session.access_rules_tag = new_access_rules_tag
from relate.utils import (
local_now, as_local_time,
format_datetime_local)
from relate.utils import as_local_time, format_datetime_local, local_now
now_datetime = local_now()
session.append_comment(
ugettext("Session reopened at %(now)s by %(user)s, "
gettext("Session reopened at %(now)s by %(user)s, "
"previous completion time was '%(completion_time)s': "
"%(comment)s.") % {
"now": format_datetime_local(local_now()),
"now": format_datetime_local(now_datetime),
"user": pctx.request.user,
"completion_time": format_datetime_local(
as_local_time(session.completion_time)),
as_local_time(not_none(session.completion_time))),
"comment": form.cleaned_data["comment"]
})
session.save()
from course.flow import reopen_session
reopen_session(session, suppress_log=True)
reopen_session(now_datetime, session, suppress_log=True,
unsubmit_pages=form.cleaned_data["unsubmit_pages"])
# anonymous sessions do not appear in the grading interface
assert session.participation is not None
return redirect("relate-view_single_grade",
pctx.course.identifier,
......@@ -733,8 +792,9 @@ def view_reopen_session(pctx, flow_session_id, opportunity_id):
# {{{ view single grade
def average_grade(opportunity):
# type: (GradingOpportunity) -> Tuple[Optional[float], int]
def average_grade(
opportunity: GradingOpportunity
) -> tuple[float | Decimal | None, int]:
grade_changes = (GradeChange.objects
.filter(
......@@ -748,10 +808,9 @@ def average_grade(opportunity):
.select_related("opportunity"))
grades = []
my_grade_changes = [] # type: List[GradeChange]
my_grade_changes: list[GradeChange] = []
def finalize():
# type: () -> None
def finalize() -> None:
if not my_grade_changes:
return
......@@ -781,9 +840,29 @@ def average_grade(opportunity):
return None, 0
def get_single_grade_changes_and_state_machine(opportunity: GradingOpportunity,
participation: Participation) -> tuple[list[GradeChange], GradeStateMachine]:
grade_changes = list(
GradeChange.objects.filter(
opportunity=opportunity,
participation=participation)
.order_by("grade_time")
.select_related("participation")
.select_related("participation__user")
.select_related("creator")
.select_related("flow_session")
.select_related("opportunity"))
state_machine = GradeStateMachine()
state_machine.consume(grade_changes, set_is_superseded=True)
return grade_changes, state_machine
@course_view
def view_single_grade(pctx, participation_id, opportunity_id):
# type: (CoursePageContext, Text, Text) -> http.HttpResponse
def view_single_grade(pctx: CoursePageContext, participation_id: str,
opportunity_id: str) -> http.HttpResponse:
now_datetime = get_now_or_fake_time(pctx.request)
......@@ -795,6 +874,9 @@ def view_single_grade(pctx, participation_id, opportunity_id):
opportunity = get_object_or_404(GradingOpportunity, id=int(opportunity_id))
if pctx.course != opportunity.course:
raise SuspiciousOperation(_("opportunity from wrong course"))
my_grade = participation == pctx.participation
is_privileged_view = pctx.has_permission(pperm.view_gradebook)
......@@ -822,7 +904,8 @@ def view_single_grade(pctx, participation_id, opportunity_id):
request = pctx.request
if pctx.request.method == "POST":
action_re = re.compile("^([a-z]+)_([0-9]+)$")
action_re = re.compile(r"^([a-z]+)_([0-9]+)$")
action_match = None
for key in request.POST.keys():
action_match = action_re.match(key)
if action_match:
......@@ -839,10 +922,11 @@ def view_single_grade(pctx, participation_id, opportunity_id):
respect_preview=False)
from course.flow import (
regrade_session,
recalculate_session_grade,
expire_flow_session_standalone,
finish_flow_session_standalone)
expire_flow_session_standalone,
finish_flow_session_standalone,
recalculate_session_grade,
regrade_session,
)
try:
if op == "imposedl":
......@@ -897,18 +981,8 @@ def view_single_grade(pctx, participation_id, opportunity_id):
# }}}
grade_changes = list(GradeChange.objects
.filter(
opportunity=opportunity,
participation=participation)
.order_by("grade_time")
.select_related("participation")
.select_related("participation__user")
.select_related("creator")
.select_related("opportunity"))
state_machine = GradeStateMachine()
state_machine.consume(grade_changes, set_is_superseded=True)
grade_changes, state_machine = (
get_single_grade_changes_and_state_machine(opportunity, participation))
if opportunity.flow_id:
flow_sessions = list(FlowSession.objects
......@@ -919,18 +993,19 @@ def view_single_grade(pctx, participation_id, opportunity_id):
.order_by("start_time"))
from collections import namedtuple
SessionProperties = namedtuple( # noqa
SessionProperties = namedtuple(
"SessionProperties",
["due", "grade_description"])
from course.utils import get_session_grading_rule
from course.content import get_flow_desc
from course.utils import get_session_grading_rule
try:
flow_desc = get_flow_desc(pctx.repo, pctx.course,
opportunity.flow_id, pctx.course_commit_sha)
except ObjectDoesNotExist:
flow_sessions_and_session_properties = None
flow_sessions_and_session_properties: \
list[tuple[Any, SessionProperties]] | None = None
else:
flow_sessions_and_session_properties = []
for session in flow_sessions:
......@@ -959,13 +1034,13 @@ def view_single_grade(pctx, participation_id, opportunity_id):
# {{{ filter out pre-public grade changes
if (not show_privileged_info and
opportunity.hide_superseded_grade_history_before is not None):
if (not show_privileged_info
and opportunity.hide_superseded_grade_history_before is not None):
grade_changes = [gchange
for gchange in grade_changes
if not gchange.is_superseded
or gchange.grade_time >=
opportunity.hide_superseded_grade_history_before]
or gchange.grade_time
>= opportunity.hide_superseded_grade_history_before]
# }}}
......@@ -994,7 +1069,7 @@ def view_single_grade(pctx, participation_id, opportunity_id):
class ImportGradesForm(StyledForm):
def __init__(self, course, *args, **kwargs):
super(ImportGradesForm, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
self.fields["grading_opportunity"] = forms.ModelChoiceField(
queryset=(GradingOpportunity.objects
......@@ -1023,7 +1098,8 @@ class ImportGradesForm(StyledForm):
self.fields["attr_type"] = forms.ChoiceField(
choices=(
("email_or_id", _("Email or NetID")),
("inst_id", _("Institutional ID")),
("institutional_id", _("Institutional ID")),
("username", _("Username")),
),
label=_("User attribute"))
......@@ -1051,7 +1127,16 @@ class ImportGradesForm(StyledForm):
self.helper.add_input(Submit("import", _("Import")))
def clean(self):
data = super(ImportGradesForm, self).clean()
data = super().clean()
attempt_id = data.get("attempt_id")
if attempt_id:
attempt_id = attempt_id.strip()
flow_session_specific_attempt_id_prefix = "flow-session-"
if attempt_id.startswith(flow_session_specific_attempt_id_prefix):
self.add_error("attempt_id",
_('"%s" as a prefix is not allowed')
% flow_session_specific_attempt_id_prefix)
file_contents = data.get("file")
if file_contents:
column_idx_list = [
......@@ -1062,44 +1147,54 @@ class ImportGradesForm(StyledForm):
has_header = data["format"] == "csvhead"
header_count = 1 if has_header else 0
from course.utils import csv_data_importable
import io
from course.utils import csv_data_importable
importable, err_msg = csv_data_importable(
six.StringIO(
io.StringIO(
file_contents.read().decode("utf-8", errors="replace")),
column_idx_list,
header_count)
if not importable:
self.add_error('file', err_msg)
self.add_error("file", err_msg)
class ParticipantNotFound(ValueError):
pass
def find_participant_from_inst_id(course, inst_id_str):
inst_id_str = inst_id_str.strip()
def find_participant_from_user_attr(course, attr_type, attr_str):
attr_str = attr_str.strip()
exact_mode = "exact"
if attr_type == "institutional_id":
exact_mode = "iexact"
kwargs = {f"user__{attr_type}__{exact_mode}": attr_str}
matches = (Participation.objects
.filter(
course=course,
status=participation_status.active,
user__institutional_id__exact=inst_id_str)
**kwargs)
.select_related("user"))
if not matches:
raise ParticipantNotFound(
# Translators: use institutional_id_string to find user
# (participant).
_("no participant found with institutional ID "
"'%(inst_id_string)s'") % {
"inst_id_string": inst_id_str})
if len(matches) > 1:
matches_count = matches.count()
if not matches_count or matches_count > 1:
from django.contrib.auth import get_user_model
from django.utils.encoding import force_str
attr_verbose_name = force_str(
get_user_model()._meta.get_field(attr_type).verbose_name)
map_dict = {"user_attr": attr_verbose_name, "user_attr_str": attr_str}
if not matches_count:
raise ParticipantNotFound(
_("no participant found with %(user_attr)s "
"'%(user_attr_str)s'") % map_dict)
raise ParticipantNotFound(
_("more than one participant found with institutional ID "
"'%(inst_id_string)s'") % {
"inst_id_string": inst_id_str})
_("more than one participant found with %(user_attr)s "
"'%(user_attr_str)s'") % map_dict)
return matches[0]
......@@ -1154,18 +1249,47 @@ def fix_decimal(s):
return s
def points_equal(num: Decimal | None, other: Decimal | None) -> bool:
if num is None and other is None:
return True
if ((num is None and other is not None)
or (num is not None and other is None)):
return False
assert num is not None
assert other is not None
return abs(num - other) < Decimal("0.01")
def csv_to_grade_changes(
log_lines,
course, grading_opportunity, attempt_id, file_contents,
attr_type, attr_column, points_column, feedback_column,
max_points, creator, grade_time, has_header):
max_points, creator, grade_time, has_header,
# Row count limited to avoid out-of-memory situation.
# https://github.com/inducer/relate/issues/849
max_rows=10_000):
result = []
import csv
total_count = 0
spamreader = csv.reader(file_contents)
for row in spamreader:
from course.utils import get_col_contents_or_empty
gchange_count = 0
row_count = 0
for row in csv.reader(file_contents):
row_count += 1
if row_count % 30 == 0:
print(row_count)
if row_count >= max_rows:
raise ValueError(_(
"Too many rows. "
"Aborted processing after %d rows. "
"Please split your file into smaller pieces.")
% max_rows)
if has_header:
has_header = False
continue
......@@ -1175,14 +1299,13 @@ def csv_to_grade_changes(
try:
if attr_type == "email_or_id":
gchange.participation = find_participant_from_id(
course, row[attr_column-1])
elif attr_type == "inst_id":
gchange.participation = find_participant_from_inst_id(
course, row[attr_column-1])
course, get_col_contents_or_empty(row, attr_column-1))
elif attr_type in ["institutional_id", "username"]:
gchange.participation = find_participant_from_user_attr(
course, attr_type,
get_col_contents_or_empty(row, attr_column-1))
else:
raise ParticipantNotFound(
_("Unknown user attribute '%(attr_type)s'") % {
"attr_type": attr_type})
raise NotImplementedError()
except ParticipantNotFound as e:
log_lines.append(e)
continue
......@@ -1190,16 +1313,16 @@ def csv_to_grade_changes(
gchange.state = grade_state_change_types.graded
gchange.attempt_id = attempt_id
points_str = row[points_column-1].strip()
points_str = get_col_contents_or_empty(row, points_column-1).strip()
# Moodle's "NULL" grades look like this.
if points_str in ["-", ""]:
gchange.points = None
else:
gchange.points = float(fix_decimal(points_str))
gchange.points = Decimal(fix_decimal(points_str))
gchange.max_points = max_points
if feedback_column is not None:
gchange.comment = row[feedback_column-1]
gchange.comment = get_col_contents_or_empty(row, feedback_column-1)
gchange.creator = creator
gchange.grade_time = grade_time
......@@ -1215,14 +1338,13 @@ def csv_to_grade_changes(
last_grade, = last_grades
if last_grade.state == grade_state_change_types.graded:
updated = []
if last_grade.points != gchange.points:
updated.append(ugettext("points"))
if last_grade.max_points != gchange.max_points:
updated.append(ugettext("max_points"))
if not points_equal(last_grade.points, gchange.points):
updated.append(gettext("points"))
if not points_equal(last_grade.max_points, gchange.max_points):
updated.append(gettext("max_points"))
if last_grade.comment != gchange.comment:
updated.append(ugettext("comment"))
updated.append(gettext("comment"))
if updated:
log_lines.append(
......@@ -1230,8 +1352,8 @@ def csv_to_grade_changes(
"%(participation)s: %(updated)s ",
_("updated")
) % {
'participation': gchange.participation,
'updated': ", ".join(updated)})
"participation": gchange.participation,
"updated": ", ".join(updated)})
result.append(gchange)
else:
......@@ -1240,9 +1362,9 @@ def csv_to_grade_changes(
else:
result.append(gchange)
total_count += 1
gchange_count += 1
return total_count, result
return gchange_count, result
@course_view
......@@ -1255,6 +1377,7 @@ def import_grades(pctx):
log_lines = []
import io
request = pctx.request
if request.method == "POST":
form = ImportGradesForm(
......@@ -1271,7 +1394,7 @@ def import_grades(pctx):
course=pctx.course,
grading_opportunity=form.cleaned_data["grading_opportunity"],
attempt_id=form.cleaned_data["attempt_id"],
file_contents=six.StringIO(data),
file_contents=io.StringIO(data),
attr_type=form.cleaned_data["attr_type"],
attr_column=form.cleaned_data["attr_column"],
points_column=form.cleaned_data["points_column"],
......@@ -1292,9 +1415,9 @@ def import_grades(pctx):
else:
if total_count != len(grade_changes):
messages.add_message(pctx.request, messages.INFO,
_("%(total)d grades found, %(unchaged)d unchanged.")
% {'total': total_count,
'unchaged': total_count - len(grade_changes)})
_("%(total)d grades found, %(unchanged)d unchanged.")
% {"total": total_count,
"unchanged": total_count - len(grade_changes)})
from django.template.loader import render_to_string
......@@ -1331,7 +1454,7 @@ def import_grades(pctx):
class DownloadAllSubmissionsForm(StyledForm):
def __init__(self, page_ids, session_tag_choices, *args, **kwargs):
super(DownloadAllSubmissionsForm, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
self.fields["page_id"] = forms.ChoiceField(
choices=tuple(
......@@ -1402,7 +1525,7 @@ def download_all_submissions(pctx, flow_id):
# }}}
page_ids = [
"%s/%s" % (group_desc.id, page_desc.id)
f"{group_desc.id}/{page_desc.id}"
for group_desc in flow_desc.groups
for page_desc in group_desc.pages]
......@@ -1482,12 +1605,12 @@ def download_all_submissions(pctx, flow_id):
submissions[key] = (
bytes_answer, list(visit.grades.all()))
from six import BytesIO
from io import BytesIO
from zipfile import ZipFile
bio = BytesIO()
with ZipFile(bio, "w") as subm_zip:
for key, ((extension, bytes_answer), visit_grades) in \
six.iteritems(submissions):
submissions.items():
basename = "-".join(key)
subm_zip.writestr(
basename + extension,
......@@ -1497,8 +1620,7 @@ def download_all_submissions(pctx, flow_id):
feedback_lines = []
feedback_lines.append(
"scores: %s" % (
", ".join(
"scores: {}".format(", ".join(
str(g.correctness)
for g in visit_grades)))
......@@ -1521,10 +1643,10 @@ def download_all_submissions(pctx, flow_id):
response = http.HttpResponse(
bio.getvalue(),
content_type="application/zip")
response['Content-Disposition'] = (
'attachment; filename="submissions_%s_%s_%s_%s_%s.zip"'
% (pctx.course.identifier, flow_id, group_id, page_id,
now().date().strftime("%Y-%m-%d")))
response["Content-Disposition"] = (
f'attachment; filename="submissions_{pctx.course.identifier}_'
f'{flow_id}_{group_id}_{page_id}_'
f'{now().date().strftime("%Y-%m-%d")}.zip"')
return response
else:
......@@ -1541,13 +1663,13 @@ def download_all_submissions(pctx, flow_id):
# {{{ edit_grading_opportunity
class EditGradingOpportunityForm(StyledModelForm):
def __init__(self, add_new, *args, **kwargs):
# type: (bool, *Any, **Any) -> None
super(EditGradingOpportunityForm, self).__init__(*args, **kwargs)
def __init__(self, add_new: bool, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
if not add_new:
self.fields["identifier"].disabled = True
self.fields["course"].disabled = True
self.fields["flow_id"].disabled = True
self.fields["creation_time"].disabled = True
......@@ -1557,20 +1679,18 @@ class EditGradingOpportunityForm(StyledModelForm):
class Meta:
model = GradingOpportunity
exclude = (
"course",
# do not exclude 'course', used in unique_together checking
# not used
"due_time",
)
widgets = {
"hide_superseded_grade_history_before":
DateTimePicker(
options={"format": "YYYY-MM-DD HH:mm", "sideBySide": True}),
"hide_superseded_grade_history_before": HTML5DateTimeInput(),
}
@course_view
def edit_grading_opportunity(pctx, opportunity_id):
# type: (CoursePageContext, int) -> http.HttpResponse
def edit_grading_opportunity(pctx: CoursePageContext,
opportunity_id: int) -> http.HttpResponse:
if not pctx.has_permission(pperm.edit_grading_opportunity):
raise PermissionDenied()
......@@ -1588,7 +1708,7 @@ def edit_grading_opportunity(pctx, opportunity_id):
raise SuspiciousOperation(
"may not edit grading opportunity in different course")
if request.method == 'POST':
if request.method == "POST":
form = EditGradingOpportunityForm(add_new, request.POST, instance=gopp)
if form.is_valid():
......
# -*- coding: utf-8 -*-
from __future__ import annotations
from __future__ import division
__copyright__ = "Copyright (C) 2014 Andreas Kloeckner"
......@@ -25,63 +24,128 @@ THE SOFTWARE.
"""
from django.utils.translation import ugettext as _
from django.shortcuts import ( # noqa
get_object_or_404, redirect)
from relate.utils import retry_transaction_decorator
from django.core.exceptions import ( # noqa
PermissionDenied, SuspiciousOperation,
ObjectDoesNotExist)
from django import http
from typing import TYPE_CHECKING, Any
from django import http
from django.contrib import messages
from django.core.exceptions import (
ObjectDoesNotExist,
PermissionDenied,
SuspiciousOperation,
)
from django.shortcuts import get_object_or_404, redirect, render # noqa
from django.utils.translation import gettext as _
from course.constants import participation_permission as pperm
from course.models import (
FlowSession, FlowPageVisitGrade,
get_flow_grading_opportunity,
get_feedback_for_grade,
update_bulk_feedback)
FlowPageVisitGrade,
FlowSession,
get_feedback_for_grade,
get_flow_grading_opportunity,
update_bulk_feedback,
)
from course.page import InvalidPageData
from course.utils import (
course_view, render_course_page,
get_session_grading_rule,
FlowPageContext)
FlowPageContext,
course_view,
get_session_grading_rule,
render_course_page,
)
from course.views import get_now_or_fake_time
from django.conf import settings
from django.utils import translation
from course.constants import (
participation_permission as pperm,
)
from relate.utils import (
StyledForm,
retry_transaction_decorator,
)
# {{{ for mypy
from typing import Text, Any, Optional # noqa
from course.models import ( # noqa
GradingOpportunity)
from course.utils import ( # noqa
CoursePageContext)
import datetime # noqa
if TYPE_CHECKING:
import datetime
from django.db.models import query
from course.models import GradingOpportunity
from course.utils import CoursePageContext
# }}}
def get_prev_visit_grades(
course_identifier: str,
flow_session_id: int,
page_ordinal: int,
reversed_on_visit_time_and_grade_time: bool | None = False
) -> query.QuerySet:
order_by_args: list[str] = []
if reversed_on_visit_time_and_grade_time:
order_by_args = ["-visit__visit_time", "-grade_time"]
return (FlowPageVisitGrade.objects
.filter(
visit__flow_session_id=flow_session_id,
visit__page_data__page_ordinal=page_ordinal,
visit__is_submitted_answer=True,
visit__flow_session__course__identifier=course_identifier)
.order_by(*order_by_args)
.select_related("visit"))
@course_view
def get_prev_grades_dropdown_content(pctx, flow_session_id, page_ordinal,
prev_grade_id):
"""
:return: serialized prev_grades items for rendering past-grades-dropdown
"""
request = pctx.request
if request.method != "GET":
raise PermissionDenied()
if not pctx.participation:
raise PermissionDenied(_("may not view grade book"))
if not pctx.participation.has_permission(pperm.view_gradebook):
raise PermissionDenied(_("may not view grade book"))
page_ordinal = int(page_ordinal)
flow_session_id = int(flow_session_id)
prev_grades = get_prev_visit_grades(pctx.course_identifier,
flow_session_id, page_ordinal, True)
return render(pctx.request, "course/prev-grades-dropdown.html", {
"prev_grades": prev_grades,
"prev_grade_id": (
None
if prev_grade_id == "None" else
int(prev_grade_id)),
})
# {{{ grading driver
@course_view
def grade_flow_page(pctx, flow_session_id, page_ordinal):
# type: (CoursePageContext, int, int) -> http.HttpResponse
def grade_flow_page(
pctx: CoursePageContext,
flow_session_id: int,
page_ordinal: int
) -> http.HttpResponse:
now_datetime = get_now_or_fake_time(pctx.request)
page_ordinal = int(page_ordinal)
viewing_prev_grade = False
prev_grade_id = pctx.request.GET.get("grade_id")
if prev_grade_id is not None:
prev_grade_id_str = pctx.request.GET.get("grade_id")
if prev_grade_id_str is not None:
try:
prev_grade_id = int(prev_grade_id)
prev_grade_id = int(prev_grade_id_str)
viewing_prev_grade = True
except ValueError:
raise SuspiciousOperation("non-integer passed for 'grade_id'")
else:
prev_grade_id = None
if not pctx.has_permission(pperm.view_gradebook):
raise PermissionDenied(_("may not view grade book"))
assert pctx.request.user.is_authenticated
flow_session = get_object_or_404(FlowSession, id=int(flow_session_id))
......@@ -92,9 +156,13 @@ def grade_flow_page(pctx, flow_session_id, page_ordinal):
raise SuspiciousOperation(
_("Cannot grade anonymous session"))
from course.flow import adjust_flow_session_page_data
adjust_flow_session_page_data(pctx.repo, flow_session,
pctx.course.identifier, respect_preview=False)
fpctx = FlowPageContext(pctx.repo, pctx.course, flow_session.flow_id,
page_ordinal, participation=flow_session.participation,
flow_session=flow_session, request=pctx.request)
page_ordinal, participation=flow_session.participation,
flow_session=flow_session, request=pctx.request)
if fpctx.page_desc is None:
raise http.Http404()
......@@ -102,11 +170,6 @@ def grade_flow_page(pctx, flow_session_id, page_ordinal):
assert fpctx.page is not None
assert fpctx.page_context is not None
from course.flow import adjust_flow_session_page_data
adjust_flow_session_page_data(pctx.repo, flow_session,
pctx.course.identifier, fpctx.flow_desc,
respect_preview=True)
# {{{ enable flow session zapping
all_flow_sessions = list(FlowSession.objects
......@@ -133,13 +196,8 @@ def grade_flow_page(pctx, flow_session_id, page_ordinal):
# }}}
prev_grades = (FlowPageVisitGrade.objects
.filter(
visit__flow_session=flow_session,
visit__page_data__ordinal=page_ordinal,
visit__is_submitted_answer=True)
.order_by("-visit__visit_time", "-grade_time")
.select_related("visit"))
prev_grades = get_prev_visit_grades(pctx.course_identifier, flow_session_id,
page_ordinal)
# {{{ reproduce student view
......@@ -149,7 +207,9 @@ def grade_flow_page(pctx, flow_session_id, page_ordinal):
grade_data = None
shown_grade = None
if fpctx.page.expects_answer():
page_expects_answer = fpctx.page.expects_answer()
if page_expects_answer:
if fpctx.prev_answer_visit is not None and prev_grade_id is None:
answer_data = fpctx.prev_answer_visit.answer
......@@ -183,9 +243,22 @@ def grade_flow_page(pctx, flow_session_id, page_ordinal):
show_answer=False,
may_change_answer=False)
form = fpctx.page.make_form(
fpctx.page_context, fpctx.page_data.data,
answer_data, page_behavior)
try:
form = fpctx.page.make_form(
fpctx.page_context, fpctx.page_data.data,
answer_data, page_behavior)
except InvalidPageData as e:
messages.add_message(pctx.request, messages.ERROR,
_(
"The page data stored in the database was found "
"to be invalid for the page as given in the "
"course content. Likely the course content was "
"changed in an incompatible way (say, by adding "
"an option to a choice question) without changing "
"the question ID. The precise error encountered "
"was the following: ")+str(e))
return render_course_page(pctx, "course/course-base.html", {})
if form is not None:
form_html = fpctx.page.form_to_html(
......@@ -197,7 +270,9 @@ def grade_flow_page(pctx, flow_session_id, page_ordinal):
# {{{ grading form
if (fpctx.page.expects_answer()
grading_form: StyledForm | None = None
if (page_expects_answer
and fpctx.page.is_answer_gradable()
and fpctx.prev_answer_visit is not None
and not flow_session.in_progress
......@@ -215,10 +290,10 @@ def grade_flow_page(pctx, flow_session_id, page_ordinal):
request,
fpctx.page_context, fpctx.page_data, grade_data,
grading_form, request.FILES)
with translation.override(settings.RELATE_ADMIN_EMAIL_LOCALE):
from course.utils import LanguageOverride
with LanguageOverride(pctx.course):
feedback = fpctx.page.grade(
fpctx.page_context, fpctx.page_data,
fpctx.page_context, fpctx.page_data.data,
answer_data, grade_data)
if feedback is not None:
......@@ -226,8 +301,8 @@ def grade_flow_page(pctx, flow_session_id, page_ordinal):
else:
correctness = None
feedback_json = None # type: Optional[Dict[Text, Any]]
bulk_feedback_json = None # type: Optional[Dict[Text, Any]]
feedback_json: dict[str, Any] | None = None
bulk_feedback_json: dict[str, Any] | None = None
if feedback is not None:
feedback_json, bulk_feedback_json = feedback.as_json()
......@@ -237,7 +312,7 @@ def grade_flow_page(pctx, flow_session_id, page_ordinal):
most_recent_grade = FlowPageVisitGrade(
visit=fpctx.prev_answer_visit,
grader=pctx.request.user,
graded_at_git_commit_sha=pctx.course_commit_sha,
graded_at_git_commit_sha=pctx.course_commit_sha.decode(),
grade_data=grade_data,
......@@ -245,7 +320,7 @@ def grade_flow_page(pctx, flow_session_id, page_ordinal):
correctness=correctness,
feedback=feedback_json)
_save_grade(fpctx, flow_session, most_recent_grade,
prev_grade_id = _save_grade(fpctx, flow_session, most_recent_grade,
bulk_feedback_json, now_datetime)
else:
grading_form = fpctx.page.make_grading_form(
......@@ -254,28 +329,26 @@ def grade_flow_page(pctx, flow_session_id, page_ordinal):
else:
grading_form = None
grading_form_html: str | None = None
if grading_form is not None:
from crispy_forms.layout import Submit
grading_form.helper.form_class += " relate-grading-form"
grading_form.helper.add_input(
Submit(
"submit", _("Submit"),
accesskey="s",
css_class="relate-grading-save-button"))
grading_form_html = fpctx.page.grading_form_to_html(
pctx.request, fpctx.page_context, grading_form, grade_data)
else:
grading_form_html = None
# }}}
# {{{ compute points_awarded
max_points = None
points_awarded = None
if (fpctx.page.expects_answer()
if (page_expects_answer
and fpctx.page.is_answer_gradable()):
max_points = fpctx.page.max_points(fpctx.page_data)
if feedback is not None and feedback.correctness is not None:
......@@ -287,11 +360,11 @@ def grade_flow_page(pctx, flow_session_id, page_ordinal):
flow_session, fpctx.flow_desc, get_now_or_fake_time(pctx.request))
if grading_rule.grade_identifier is not None:
grading_opportunity = get_flow_grading_opportunity(
pctx.course, flow_session.flow_id, fpctx.flow_desc,
grading_rule.grade_identifier,
grading_rule.grade_aggregation_strategy
) # type: Optional[GradingOpportunity]
grading_opportunity: GradingOpportunity | None = \
get_flow_grading_opportunity(
pctx.course, flow_session.flow_id, fpctx.flow_desc,
grading_rule.grade_identifier,
grading_rule.grade_aggregation_strategy)
else:
grading_opportunity = None
......@@ -302,7 +375,7 @@ def grade_flow_page(pctx, flow_session_id, page_ordinal):
"flow_identifier": fpctx.flow_id,
"flow_session": flow_session,
"flow_desc": fpctx.flow_desc,
"ordinal": fpctx.ordinal,
"page_ordinal": fpctx.page_ordinal,
"page_data": fpctx.page_data,
"body": fpctx.page.body(
......@@ -313,8 +386,8 @@ def grade_flow_page(pctx, flow_session_id, page_ordinal):
"max_points": max_points,
"points_awarded": points_awarded,
"shown_grade": shown_grade,
"prev_grades": prev_grades,
"prev_grade_id": prev_grade_id,
"expects_answer": page_expects_answer,
"grading_opportunity": grading_opportunity,
......@@ -331,14 +404,14 @@ def grade_flow_page(pctx, flow_session_id, page_ordinal):
@retry_transaction_decorator()
def _save_grade(
fpctx, # type: FlowPageContext
flow_session, # type: FlowSession
most_recent_grade, # type: FlowPageVisitGrade
bulk_feedback_json, # type: Any
now_datetime, # type: datetime.datetime
):
# type: (...) -> None
fpctx: FlowPageContext,
flow_session: FlowSession,
most_recent_grade: FlowPageVisitGrade,
bulk_feedback_json: Any,
now_datetime: datetime.datetime,
) -> int:
most_recent_grade.save()
most_recent_grade_id = most_recent_grade.id
update_bulk_feedback(
fpctx.prev_answer_visit.page_data,
......@@ -351,6 +424,8 @@ def _save_grade(
from course.flow import grade_flow_session
grade_flow_session(fpctx, flow_session, grading_rule)
return most_recent_grade_id
# }}}
......@@ -378,7 +453,7 @@ def show_grader_statistics(pctx, flow_id):
graders = set()
# tuples: (ordinal, id)
# tuples: (page_ordinal, id)
pages = set()
counts = {}
......@@ -387,7 +462,7 @@ def show_grader_statistics(pctx, flow_id):
def commit_grade_info(grade):
grader = grade.grader
page = (grade.visit.page_data.ordinal,
page = (grade.visit.page_data.page_ordinal,
grade.visit.page_data.group_id + "/" + grade.visit.page_data.page_id)
graders.add(grader)
......@@ -437,7 +512,8 @@ def show_grader_statistics(pctx, flow_id):
"flow_id": flow_id,
"pages": pages,
"graders": graders,
"pages_stats_counts": list(zip(pages, stats_table, page_counts)),
"pages_stats_counts":
list(zip(pages, stats_table, page_counts, strict=True)),
"grader_counts": grader_counts,
})
......
# -*- coding: utf-8 -*-
from __future__ import annotations
from __future__ import division
__copyright__ = "Copyright (C) 2014 Andreas Kloeckner"
......@@ -24,28 +23,23 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
"""
from django.utils.translation import (
ugettext as _, pgettext_lazy)
from django.shortcuts import ( # noqa
render, get_object_or_404, redirect)
from django.contrib import messages # noqa
from django.core.exceptions import PermissionDenied
import django.forms as forms
import slixmpp
from asgiref.sync import async_to_sync
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Submit
from course.models import InstantMessage
from course.constants import (
participation_permission as pperm,
)
from course.models import Course # noqa
from django.contrib import messages
from django.core.exceptions import PermissionDenied
from django.shortcuts import get_object_or_404, redirect, render # noqa
from django.utils.translation import gettext as _, pgettext_lazy
from course.constants import participation_permission as pperm
from course.models import (
Course, # noqa
InstantMessage,
)
from course.utils import course_view, render_course_page
import sleekxmpp
import threading
# {{{ instant message
......@@ -65,98 +59,70 @@ class InstantMessageForm(forms.Form):
# the instant messaging function.
pgettext_lazy("Send instant messages", "Send")))
super(InstantMessageForm, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
_xmpp_connections = {} # type: Dict[int, CourseXMPP]
_disconnectors = [] # type: List[Disconnector]
# based on https://slixmpp.readthedocs.io/en/latest/getting_started/sendlogout.html
class SendMsgBot(slixmpp.ClientXMPP):
"""
A basic Slixmpp bot that will log in, send a message,
and then log out.
"""
def __init__(self, jid, password, recipient, message):
slixmpp.ClientXMPP.__init__(self, jid, password)
class CourseXMPP(sleekxmpp.ClientXMPP):
def __init__(self, jid, password, recipient_jid):
sleekxmpp.ClientXMPP.__init__(self, jid, password)
self.recipient_jid = recipient_jid
# The message we wish to send, and the JID that
# will receive it.
self.recipient = recipient
self.msg = message
# The session_start event will be triggered when
# the bot establishes its connection with the server
# and the XML streams are ready for use. We want to
# listen for this event so that we we can initialize
# our roster.
self.add_event_handler("session_start", self.start)
self.add_event_handler("changed_status", self.wait_for_presences)
self.received = set()
self.presences_received = threading.Event()
def start(self, event):
self.send_presence()
self.get_roster()
def is_recipient_online(self):
groups = self.client_roster.groups()
for group in groups:
for jid in groups[group]:
if jid != self.recipient_jid:
continue
connections = self.client_roster.presence(jid)
for res, pres in connections.items():
return True
return False
def wait_for_presences(self, pres):
"""
Track how many roster entries have received presence updates.
async def start(self, event):
"""
self.received.add(pres['from'].bare)
if len(self.received) >= len(self.client_roster.keys()):
self.presences_received.set()
else:
self.presences_received.clear()
class Disconnector(object):
def __init__(self, xmpp, course):
# type: (CourseXMPP, Course) -> None
self.timer = None
self.xmpp = xmpp
self.course = course
self.timer = threading.Timer(60, self) # type: ignore
self.timer.start()
Process the session_start event.
def __call__(self):
# type: () -> None
# print "EXPIRING XMPP", self.course.pk
del _xmpp_connections[self.course.pk]
self.xmpp.disconnect(wait=True)
_disconnectors.remove(self)
Typical actions for the session_start event are
requesting the roster and broadcasting an initial
presence stanza.
Arguments:
event -- An empty dictionary. The session_start
event does not provide any additional
data.
"""
self.send_presence()
await self.get_roster()
def get_xmpp_connection(course):
try:
return _xmpp_connections[course.pk]
except KeyError:
xmpp = CourseXMPP(
course.course_xmpp_id,
course.course_xmpp_password,
course.recipient_xmpp_id)
if xmpp.connect():
xmpp.process()
else:
raise RuntimeError(_("unable to connect"))
self.send_message(mto=self.recipient,
mbody=self.msg,
mtype="chat")
_xmpp_connections[course.pk] = xmpp
self.disconnect()
xmpp.presences_received.wait(5)
xmpp.is_recipient_online()
_disconnectors.append(Disconnector(xmpp, course))
@async_to_sync
async def _send_xmpp_msg(xmpp_id, password, recipient_xmpp_id, message):
xmpp = SendMsgBot(
xmpp_id, password, recipient_xmpp_id, message)
xmpp.register_plugin("xep_0030") # Service Discovery
xmpp.register_plugin("xep_0199") # XMPP Ping
return xmpp
# Connect to the XMPP server and start processing XMPP stanzas.
xmpp.connect()
await xmpp.disconnected
@course_view
def send_instant_message(pctx):
if not pctx.has_permission(pperm.send_instant_message):
raise PermissionDenied(_("may not batch-download submissions"))
raise PermissionDenied(_("may not send instant message"))
request = pctx.request
course = pctx.course
......@@ -167,15 +133,6 @@ def send_instant_message(pctx):
return redirect("relate-course_page", pctx.course_identifier)
xmpp = get_xmpp_connection(pctx.course)
if xmpp.is_recipient_online():
form_text = _("Recipient is <span class='label label-success'>"
"Online</span>.")
else:
form_text = _("Recipient is <span class='label label-danger'>"
"Offline</span>.")
form_text = "<div class='well'>%s</div>" % form_text
if request.method == "POST":
form = InstantMessageForm(request.POST, request.FILES)
if form.is_valid():
......@@ -191,11 +148,11 @@ def send_instant_message(pctx):
if not course.course_xmpp_password:
raise RuntimeError(_("no XMPP password"))
xmpp.send_message(
mto=course.recipient_xmpp_id,
mbody=form.cleaned_data["message"],
mtype='chat')
_send_xmpp_msg(
xmpp_id=course.course_xmpp_id,
password=course.course_xmpp_password,
recipient_xmpp_id=course.recipient_xmpp_id,
message=form.cleaned_data["message"])
except Exception:
from traceback import print_exc
print_exc()
......@@ -213,7 +170,6 @@ def send_instant_message(pctx):
return render_course_page(pctx, "course/generic-course-form.html", {
"form": form,
"form_text": form_text,
"form_description": _("Send instant message"),
})
......
from __future__ import annotations
from django.core.exceptions import ObjectDoesNotExist
from django.core.management.base import BaseCommand, CommandError # noqa
from django.db import transaction
from django.db.models import Q
from django.db.models.functions import Length
from course.models import FlowPageBulkFeedback, FlowPageVisit
def convert_flow_page_visit(stderr, fpv):
course = fpv.flow_session.course
from course.content import (
get_course_repo,
get_flow_desc,
get_flow_page_desc,
instantiate_flow_page,
)
repo = get_course_repo(course)
flow_id = fpv.flow_session.flow_id
commit_sha = course.active_git_commit_sha.encode()
try:
flow_desc = get_flow_desc(repo, course,
flow_id, commit_sha, tolerate_tabs=True)
except ObjectDoesNotExist:
stderr.write("warning: no flow yaml file found for "
f"'{flow_id}' in '{course.identifier}'")
return
try:
page_desc = get_flow_page_desc(
fpv.flow_session.flow_id, flow_desc,
fpv.page_data.group_id, fpv.page_data.page_id)
except ObjectDoesNotExist:
stderr.write(f"warning: flow page visit {fpv.id}: no page yaml desc "
"found for "
f"'{flow_id}:{fpv.page_data.group_id}/{fpv.page_data.page_id}' "
f"in '{course.identifier}'")
return
page = instantiate_flow_page(
location=(f"flow '{flow_id}', "
f"group '{fpv.page_data.group_id}', page '{fpv.page_data.page_id}'"),
repo=repo, page_desc=page_desc,
commit_sha=commit_sha)
from course.page.base import PageContext
pctx = PageContext(
course=course,
repo=repo,
commit_sha=commit_sha,
flow_session=fpv.flow_session,
page_uri=None)
from course.page.code import CodeQuestion
from course.page.upload import FileUploadQuestion
if isinstance(page, FileUploadQuestion):
content, mime_type = page.get_content_from_answer_data(
fpv.answer)
from django.core.files.base import ContentFile
answer_data = page.file_to_answer_data(
pctx, ContentFile(content), mime_type)
fpv.answer = answer_data
fpv.save()
return True
elif isinstance(page, CodeQuestion):
code = page.get_code_from_answer_data(fpv.answer)
answer_data = page.code_to_answer_data(pctx, code)
fpv.answer = answer_data
fpv.save()
return True
else:
return False
raise AssertionError()
def convert_flow_page_visits(stdout, stderr):
fpv_pk_qset = (FlowPageVisit
.objects
.annotate(answer_len=Length("answer"))
.filter(
Q(answer__contains="base64_data")
| (
# code questions with long answer_data
Q(answer__contains="answer")
& Q(answer_len__gte=128))
)
.values("pk"))
fpv_pk_qset_iterator = iter(fpv_pk_qset)
quit = False
total_count = 0
while not quit:
with transaction.atomic():
for _i in range(200):
try:
fpv_pk = next(fpv_pk_qset_iterator)
except StopIteration:
quit = True
break
fpv = (FlowPageVisit
.objects
.select_related(
"flow_session",
"flow_session__course",
"flow_session__participation",
"flow_session__participation__user",
"page_data")
.get(pk=fpv_pk["pk"]))
if convert_flow_page_visit(stderr, fpv):
total_count += 1
stdout.write("converted %d page visits..." % total_count)
stdout.write("done with visits!")
def convert_bulk_feedback(stdout, stderr):
from course.models import BULK_FEEDBACK_FILENAME_KEY, update_bulk_feedback
fbf_pk_qset = (FlowPageBulkFeedback
.objects
.annotate(bf_len=Length("bulk_feedback"))
.filter(
~Q(bulk_feedback__contains=BULK_FEEDBACK_FILENAME_KEY)
& Q(bf_len__gte=256))
.values("pk"))
fbf_pk_qset_iterator = iter(fbf_pk_qset)
quit = False
total_count = 0
while not quit:
with transaction.atomic():
for _i in range(200):
try:
fbf_pk = next(fbf_pk_qset_iterator)
except StopIteration:
quit = True
break
fbf = (FlowPageBulkFeedback
.objects
.select_related(
"page_data",
"page_data__flow_session",
"page_data__flow_session__participation",
"page_data__flow_session__participation__user")
.get(pk=fbf_pk["pk"]))
update_bulk_feedback(fbf.page_data, fbf.grade, fbf.bulk_feedback)
total_count += 1
stdout.write("converted %d items of bulk feedback..." % total_count)
stdout.write("done with bulk feedback!")
class Command(BaseCommand):
help = (
"Migrates bulk data (e.g. file upload submissions) out of the database "
"and into the storage given by RELATE_BULK_STORAGE. This command may "
"safely be interrupted and will pick up where it left off.")
def handle(self, *args, **options):
convert_bulk_feedback(self.stdout, self.stderr)
convert_flow_page_visits(self.stdout, self.stderr)
# vim: foldmethod=marker
from __future__ import annotations
from django.core.management.commands.test import Command as DjangoTestCommand
class Command(DjangoTestCommand):
def add_arguments(self, parser):
super().add_arguments(parser)
parser.add_argument(
"--local_test_settings", action="store",
dest="local_test_settings",
help=("Overrides the default local test setting file path. "
"The default value is 'local_settings_example.py' in "
"project root. Note that local settings for production "
'("local_settings.py") is not allowed to be used '
"for unit tests for security reason.")
)
def handle(self, *test_labels, **options):
del options["local_test_settings"]
super().handle(*test_labels, **options)
# Downloaded from https://github.com/mayoff/python-markdown-mathjax/issues/3
from __future__ import annotations
import markdown
from markdown.inlinepatterns import Pattern
from markdown.postprocessors import Postprocessor
class MathJaxPattern(markdown.inlinepatterns.Pattern):
class MathJaxPattern(Pattern):
def __init__(self):
markdown.inlinepatterns.Pattern.__init__(self, r'(?<!\\)(\$\$?)(.+?)\2')
super().__init__(r"(?<!\\)(\$\$?)(.+?)\2")
def handleMatch(self, m):
node = markdown.util.etree.Element('mathjax')
node.text = markdown.util.AtomicString(m.group(2) + m.group(3) + m.group(2))
from xml.etree.ElementTree import Element
node = Element("mathjax")
from markdown.util import AtomicString
node.text = AtomicString(m.group(2) + m.group(3) + m.group(2))
return node
class MathJaxPostprocessor(Postprocessor):
def run(self, text):
text = text.replace('<mathjax>', '')
text = text.replace('</mathjax>', '')
text = text.replace("<mathjax>", "")
text = text.replace("</mathjax>", "")
return text
class MathJaxExtension(markdown.Extension):
def extendMarkdown(self, md, md_globals):
def extendMarkdown(self, md):
# Needs to come before escape matching because \ is pretty important in LaTeX
md.inlinePatterns.add('mathjax', MathJaxPattern(), '<escape')
md.postprocessors['mathjax'] = MathJaxPostprocessor(md)
# inlinepatterns in Python-Markdown seem to top out at 200-ish?
# https://github.com/Python-Markdown/markdown/blob/0b5e80efbb83f119e0e38801bf5b5b5864c67cd0/markdown/inlinepatterns.py#L53-L95
md.inlinePatterns.register(MathJaxPattern(), "mathjax", 1000)
md.postprocessors.register(MathJaxPostprocessor(md), "mathjax", 0)
def makeExtension(configs=None):
......
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
import jsonfield.fields
import django.utils.timezone
import jsonfield.fields
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
......@@ -18,15 +15,15 @@ class Migration(migrations.Migration):
name='Course',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('identifier', models.CharField(help_text=b"A course identifier. Alphanumeric with dashes, no spaces. This is visible in URLs and determines the location on your file system where the course's git repository lives.", unique=True, max_length=200, db_index=True)),
('identifier', models.CharField(help_text="A course identifier. Alphanumeric with dashes, no spaces. This is visible in URLs and determines the location on your file system where the course's git repository lives.", unique=True, max_length=200, db_index=True)),
('hidden', models.BooleanField(default=True, help_text='Is the course only visible to course staff?')),
('valid', models.BooleanField(default=True, help_text='Whether the course content has passed validation.')),
('git_source', models.CharField(help_text=b"A Git URL from which to pull course updates. If you're just starting out, enter <tt>git://github.com/inducer/relate-sample</tt> to get some sample content.", max_length=200, blank=True)),
('git_source', models.CharField(help_text="A Git URL from which to pull course updates. If you're just starting out, enter <tt>git://github.com/inducer/relate-sample</tt> to get some sample content.", max_length=200, blank=True)),
('ssh_private_key', models.TextField(help_text='An SSH private key to use for Git authentication', blank=True)),
('course_file', models.CharField(default='course.yml', help_text='Name of a YAML file in the git repository that contains the root course descriptor.', max_length=200)),
('enrollment_approval_required', models.BooleanField(default=False, help_text='If set, each enrolling student must be individually approved.')),
('enrollment_required_email_suffix', models.CharField(help_text=b"Enrollee's email addresses must end in the specified suffix, such as '@illinois.edu'.", max_length=200, null=True, blank=True)),
('email', models.EmailField(help_text=b"This email address will be used in the 'From' line of automated emails sent by RELATE. It will also receive notifications about required approvals.", max_length=75)),
('enrollment_required_email_suffix', models.CharField(help_text="Enrollee's email addresses must end in the specified suffix, such as '@illinois.edu'.", max_length=200, null=True, blank=True)),
('email', models.EmailField(help_text="This email address will be used in the 'From' line of automated emails sent by RELATE. It will also receive notifications about required approvals.", max_length=75)),
('course_xmpp_id', models.CharField(max_length=200, blank=True)),
('course_xmpp_password', models.CharField(max_length=200, blank=True)),
('active_git_commit_sha', models.CharField(max_length=200)),
......@@ -116,7 +113,7 @@ class Migration(migrations.Migration):
),
migrations.AlterUniqueTogether(
name='flowpagevisit',
unique_together=set([('page_data', 'visit_time')]),
unique_together={('page_data', 'visit_time')},
),
migrations.AddField(
model_name='flowpagedata',
......@@ -126,7 +123,7 @@ class Migration(migrations.Migration):
),
migrations.AlterUniqueTogether(
name='flowpagedata',
unique_together=set([('flow_session', 'ordinal')]),
unique_together={('flow_session', 'ordinal')},
),
migrations.CreateModel(
name='GradeChange',
......@@ -171,7 +168,7 @@ class Migration(migrations.Migration):
),
migrations.AlterUniqueTogether(
name='gradingopportunity',
unique_together=set([('course', 'identifier')]),
unique_together={('course', 'identifier')},
),
migrations.CreateModel(
name='InstantFlowRequest',
......@@ -240,7 +237,7 @@ class Migration(migrations.Migration):
),
migrations.AlterUniqueTogether(
name='participation',
unique_together=set([('user', 'course')]),
unique_together={('user', 'course')},
),
migrations.CreateModel(
name='TimeLabel',
......@@ -258,7 +255,7 @@ class Migration(migrations.Migration):
),
migrations.AlterUniqueTogether(
name='timelabel',
unique_together=set([('course', 'kind', 'ordinal')]),
unique_together={('course', 'kind', 'ordinal')},
),
migrations.CreateModel(
name='UserStatus',
......
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
from django.db import migrations, models
class Migration(migrations.Migration):
......@@ -19,6 +16,6 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='flowaccessexceptionentry',
name='permission',
field=models.CharField(max_length=50, choices=[(b'view', b'View flow'), (b'view_past', b'Review past attempts'), (b'start_credit', b'Start for-credit session'), (b'start_no_credit', b'Start not-for-credit session'), (b'change_answer', b'Change already-graded answer'), (b'see_correctness', b'See whether answer is correct'), (b'see_answer', b'See the correct answer')]),
field=models.CharField(max_length=50, choices=[('view', 'View flow'), ('view_past', 'Review past attempts'), ('start_credit', 'Start for-credit session'), ('start_no_credit', 'Start not-for-credit session'), ('change_answer', 'Change already-graded answer'), ('see_correctness', 'See whether answer is correct'), ('see_answer', 'See the correct answer')]),
),
]
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
......@@ -21,24 +18,24 @@ class Migration(migrations.Migration):
('participation', models.ForeignKey(to='course.Participation', on_delete=models.CASCADE)),
],
options={
'ordering': (b'participation__course', b'time'),
'ordering': ('participation__course', 'time'),
},
bases=(models.Model,),
),
migrations.AddField(
model_name='course',
name='recipient_xmpp_id',
field=models.CharField(help_text=b'(Required only if the instant message feature is desired.) The JID to which instant messages will be sent.', max_length=200, null=True, blank=True),
field=models.CharField(help_text='(Required only if the instant message feature is desired.) The JID to which instant messages will be sent.', max_length=200, null=True, blank=True),
preserve_default=True,
),
migrations.AlterField(
model_name='course',
name='course_xmpp_id',
field=models.CharField(help_text=b'(Required only if the instant message feature is desired.) The Jabber/XMPP ID (JID) the course will use to sign in to an XMPP server.', max_length=200, null=True, blank=True),
field=models.CharField(help_text='(Required only if the instant message feature is desired.) The Jabber/XMPP ID (JID) the course will use to sign in to an XMPP server.', max_length=200, null=True, blank=True),
),
migrations.AlterField(
model_name='course',
name='course_xmpp_password',
field=models.CharField(help_text=b'(Required only if the instant message feature is desired.) The password to go with the JID above.', max_length=200, null=True, blank=True),
field=models.CharField(help_text='(Required only if the instant message feature is desired.) The password to go with the JID above.', max_length=200, null=True, blank=True),
),
]
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
from django.db import migrations, models
def set_course(apps, schema_editor):
......
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
from django.db import migrations, models
class Migration(migrations.Migration):
......
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
from django.db import migrations, models
class Migration(migrations.Migration):
......
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
......@@ -31,6 +28,6 @@ class Migration(migrations.Migration):
),
migrations.AlterUniqueTogether(
name='participationpreapproval',
unique_together=set([('course', 'email')]),
unique_together={('course', 'email')},
),
]
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
import django.utils.timezone
import jsonfield.fields
from django.conf import settings
import django.utils.timezone
from django.db import migrations, models
def store_grading_results(apps, schema_editor):
......@@ -70,7 +67,7 @@ class Migration(migrations.Migration):
),
migrations.AlterUniqueTogether(
name='flowpagevisitgrade',
unique_together=set([('visit', 'grade_time')]),
unique_together={('visit', 'grade_time')},
),
migrations.RunPython(store_grading_results),
migrations.RemoveField(
......
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
......@@ -15,26 +12,26 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='flowaccessexceptionentry',
name='exception',
field=models.ForeignKey(related_name=b'entries', to='course.FlowAccessException', on_delete=models.CASCADE),
field=models.ForeignKey(related_name='entries', to='course.FlowAccessException', on_delete=models.CASCADE),
),
migrations.AlterField(
model_name='flowpagedata',
name='flow_session',
field=models.ForeignKey(related_name=b'page_data', to='course.FlowSession', on_delete=models.CASCADE),
field=models.ForeignKey(related_name='page_data', to='course.FlowSession', on_delete=models.CASCADE),
),
migrations.AlterField(
model_name='flowpagevisitgrade',
name='visit',
field=models.ForeignKey(related_name=b'grades', to='course.FlowPageVisit', on_delete=models.CASCADE),
field=models.ForeignKey(related_name='grades', to='course.FlowPageVisit', on_delete=models.CASCADE),
),
migrations.AlterField(
model_name='gradechange',
name='flow_session',
field=models.ForeignKey(related_name=b'grade_changes', blank=True, to='course.FlowSession', null=True, on_delete=models.CASCADE),
field=models.ForeignKey(related_name='grade_changes', blank=True, to='course.FlowSession', null=True, on_delete=models.CASCADE),
),
migrations.AlterField(
model_name='userstatus',
name='user',
field=models.OneToOneField(related_name=b'user_status', to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE),
field=models.OneToOneField(related_name='user_status', to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE),
),
]