Skip to content
{% extends "course/course-base.html" %}
{% extends "course/course-base-with-markup.html" %}
{% load i18n %}
{% load crispy_forms_tags %}
......@@ -7,14 +7,14 @@
{% block title %}
{% if title %}
{% comment %} Translators: "[SB]" is abbreviation for "Sandbox" {% endcomment %}
{% blocktrans trimmed %}
{% blocktrans trimmed with title=title|safe %}
[SB] {{ title }}
{% endblocktrans %}
{% else %}
{% blocktrans %}Page Sandbox{% endblocktrans %}
{% endif %}
-
{% trans "RELATE" %}
{{ relate_site_name }}
{% endblock %}
{% block root_container %}
......@@ -24,14 +24,14 @@
{% if page_errors %}
<div class="alert alert-danger">
<i class="fa fa-ban"></i>
<i class="bi bi-x-circle"></i>
{{ page_errors | safe }}
</div>
{% endif %}
{% if page_warnings %}
<div class="alert alert-warning">
<i class="fa fa-warning"></i>
<i class="bi bi-exclamation-triangle"></i>
{% blocktrans trimmed %} Warnings were encountered when validating the page: {% endblocktrans %}
<ul>
......@@ -42,7 +42,7 @@
</div>
{% endif %}
<div class="well">
<div class="relate-well">
{% crispy edit_form %}
</div>
</div>
......@@ -53,7 +53,7 @@
{{ body|safe }}
{% if page_form_html %}
<div class="well">
<div class="relate-well">
{{ page_form_html|safe }}
</div>
{% endif %}
......@@ -85,27 +85,7 @@
</div>
</div>
{# {{{ codemirror resizing #}
<script type="text/javascript">
$("div.CodeMirror")
.resizable({
resize: function (event, ui)
{
$("div.CodeMirror").each(
function ()
{
var cm = this.CodeMirror;
cm.refresh();
});
}
});
</script>
{# }}} #}
{# {{{ codemirror save -> preview#}
{# {{{ codemirror save -> preview #}
<script type="text/javascript">
function do_preview()
......
{% load i18n %}{% blocktrans trimmed with username=user.get_email_appellation %}Dear {{username}},{% endblocktrans %}
{% trans "RELATE" as RELATE %}{% blocktrans with sign_in_uri=sign_in_uri home_uri=home_uri %}
{% blocktrans with RELATE=relate_site_name sign_in_uri=sign_in_uri home_uri=home_uri %}
Welcome to {{ RELATE }}! Please click this link to sign in:
{{ sign_in_uri }}
......@@ -8,4 +8,4 @@ You have received this email because someone (maybe you) entered your email addr
If this was not you, it is safe to disregard this email.
{% endblocktrans %}
{% blocktrans %}- RELATE staff {% endblocktrans %}
- {% blocktrans %}{{ relate_site_name }} staff{% endblocktrans %}
{% extends "course/course-base.html" %}
{% extends "course/course-base-with-markup.html" %}
{% load i18n %}
{% block page_navbar %}
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown">{% trans "Jump to" %}<b class="caret"></b></a>
<ul class="dropdown-menu">
{% for chunk in chunks %}
{% if chunk.title %}
<li><a href="#{{chunk.id}}">{{chunk.title}}</a></li>
{% endif %}
{% endfor %}
<li class="nav-item relate-dropdown-menu">
<a href="#" id="dropdownJumpToMenu" data-bs-toggle="dropdown" aria-expanded="false">
{% trans "Jump to" %}
</a>
<ul class="dropdown-menu" aria-labelledby="dropdownJumpToMenu">
{% for chunk in chunks %}
{% if chunk.title %}
<li><a class="dropdown-item" href="#{{chunk.id}}">{{chunk.title}}</a></li>
{% endif %}
{% endfor %}
</ul>
</li>
{% endblock %}
......
{% load i18n %}{% blocktrans with course_identifier=course.identifier %}Dear course staff of {{ course_identifier }},{% endblocktrans %}
{% if flow_session.participation %}{% blocktrans with participant=flow_session.participation.user %}Participant '{{ participant }}'{% endblocktrans %}{% else %}{% trans "A participant" %}{% endif %}{% blocktrans with flow_id=flow_session.flow_id course_identifier=course.identifier %} has just submitted his/her work on '{{ flow_id }}'.
{% if flow_session.participation and not use_masked_profile %}{% blocktrans with participant=flow_session.participation.user %}Participant '{{ participant }}'{% endblocktrans %}{% else %}{% trans "A participant" %}{% endif %}{% blocktrans with flow_id=flow_session.flow_id course_identifier=course.identifier %} has just submitted his/her work on '{{ flow_id }}'.
Click here to review it:
{{ review_uri }}
{% endblocktrans %}
{% blocktrans %}- RELATE staff {% endblocktrans %}
- {% blocktrans %}{{ relate_site_name }} staff{% endblocktrans %}
<!DOCTYPE html>
{% load i18n %}
{% load static %}
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
{% block favicon %}{% endblock %}
<title>{% block title %}{{ relate_site_name }}{% endblock %}</title>
{% block bundle_loads %}
<script src="{% static 'bundle-base.js' %}"></script>
<script src="{% static 'bundle-base-with-markup.js' %}"></script>
{% endblock %}
<style>
html,
body {
height: 100%;
margin: 0;
}
.tab-content {
height: 100%;
display: flex;
flex-direction: column;
}
.tab-pane {
height: 100%;
}
iframe {
width: 100%;
height: 100%;
}
</style>
</head>
<ul class="nav nav-tabs" id="tab-bar" role="tablist">
{% for tab in tabs %}
<li class="nav-item" role="presentation">
<button class="nav-link {% if forloop.first %}active{% endif %}" id="{{ tab.title }}-tab" data-bs-toggle="tab"
data-bs-target="#{{ tab.title }}-tab-pane" type="button" role="tab" aria-controls="{{ tab.title }}-tab-pane"
aria-selected="true">
{{ tab.title }}
</button>
</li>
{% endfor %}
</ul>
<div class="tab-content" id="tab-content">
{% for tab in tabs %}
<div class="tab-pane {% if forloop.first %}show active{% endif %}" id="{{ tab.title }}-tab-pane" role="tabpanel"
aria-labelledby="{{ tab.title }}-tab" tabindex="0">
<iframe src="{{ tab.url }}" frameborder="0" allowfullscreen></iframe>
</div>
{% endfor %}
</div>
</html>
\ No newline at end of file
......@@ -37,11 +37,11 @@
<div class="progress">
<div class="progress-bar
{% if state == "FAILURE" %}
progress-bar-danger
bg-danger
{% elif state == "SUCCESS" %}
progress-bar-success
bg-success
{% else %}
progress-bar-striped active
progress-bar-striped progress-bar-anmiated
{% endif %}"
role="progressbar"
aria-valuenow="100" aria-valuemin="0" aria-valuemax="100"
......
# -*- coding: utf-8 -*-
from __future__ import annotations
__copyright__ = "Copyright (C) 2016 Dong Zhuang, Andreas Kloeckner"
......@@ -24,10 +25,11 @@ THE SOFTWARE.
from django.template import Library, Node, TemplateSyntaxError
from django.utils import translation
from relate.utils import to_js_lang_name
register = Library()
# {{{ get language_code in JS traditional naming format
class GetCurrentLanguageJsFmtNode(Node):
......@@ -35,18 +37,21 @@ class GetCurrentLanguageJsFmtNode(Node):
self.variable = variable
def render(self, context):
js_lang_name = to_js_lang_name(translation.get_language())
context[self.variable] = js_lang_name
return ''
lang_name = (
translation.to_locale(translation.get_language()).replace("_", "-"))
context[self.variable] = lang_name
return ""
@register.tag("get_current_js_lang_name")
def do_get_current_js_lang_name(parser, token):
"""
This will store the current language in the context, in js lang format.
This is different with built-in do_get_current_language, which returns
languange name like "en-us", "zh-cn", with the country code using lower
case. This method return lang name "en-US", "zh-CN", as most js packages
with i18n are providing translations using that naming format.
language name like "en-us", "zh-hans". This method return lang name
"en-US", "zh-Hans", with the country code capitallized if country code
has 2 characters, and capitalize first if country code has more than 2
characters.
Usage::
......@@ -58,9 +63,79 @@ def do_get_current_js_lang_name(parser, token):
# token.split_contents() isn't useful here because this tag doesn't
# accept variable as arguments
args = token.contents.split()
if len(args) != 3 or args[1] != 'as':
if len(args) != 3 or args[1] != "as":
raise TemplateSyntaxError("'get_current_js_lang_name' requires "
"'as variable' (got %r)" % args)
f"'as variable' (got {args!r})")
return GetCurrentLanguageJsFmtNode(args[2])
# }}}
# {{{ filter for participation.has_permission()
@register.filter(name="has_permission")
def has_permission(participation, arg):
"""
Check if a participation instance has specific permission.
:param participation: a :class:`participation:` instance
:param arg: String, with permission and arguments separated by comma
:return: a :class:`bool`
"""
has_pperm = False
try:
arg_list = [s.strip() for s in arg.split(",")]
perm = arg_list[0]
argument = None
if len(arg_list) > 1:
argument = arg_list[1]
has_pperm = participation.has_permission(perm, argument)
except Exception:
# fail silently
pass
return has_pperm
# }}}
@register.filter(name="may_set_fake_time")
def may_set_fake_time(user):
"""
Check if a user may set fake time.
:param user: a :class:`accounts.User:` instance
:return: a :class:`bool`
"""
from course.views import may_set_fake_time as msf
return msf(user)
@register.filter(name="may_set_pretend_facility")
def may_set_pretend_facility(user):
"""
Check if a user may set pretend_facility
:param user: a :class:`accounts.User:` instance
:return: a :class:`bool`
"""
from course.views import may_set_pretend_facility as mspf
return mspf(user)
@register.filter(name="commit_message_as_html")
def commit_message_as_html(commit_sha, repo):
from course.versioning import _get_commit_message_as_html
return _get_commit_message_as_html(repo, commit_sha)
@register.filter(name="get_item")
def get_item(dictionary, key):
return dictionary.get(key, None)
@register.filter(name="get_item_or_key")
def get_item_or_key(dictionary, key):
return dictionary.get(key, key)
@register.filter(name="startswith")
def startswith(s, arg):
return s.startswith(arg)
from django.test import TestCase
from django.test import TestCase # noqa
# Create your tests here.
# -*- coding: utf-8 -*-
from __future__ import annotations
from __future__ import division
__copyright__ = "Copyright (C) 2014 Andreas Kloeckner"
......@@ -24,50 +23,69 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
"""
import six
from typing import cast, Tuple, List, Text, Iterable, Any, Optional # noqa
import datetime # noqa
from django.shortcuts import ( # noqa
render, get_object_or_404)
from django import http
import datetime
from collections.abc import Collection, Iterable
from contextlib import ContextDecorator
from dataclasses import dataclass
from ipaddress import IPv4Address, IPv6Address
from typing import (
TYPE_CHECKING,
Any,
cast,
)
from django import forms, http
from django.core.exceptions import ObjectDoesNotExist
from django.utils.translation import (
ugettext as _, string_concat, pgettext_lazy)
from django.shortcuts import get_object_or_404, render
from django.utils import translation
from django.utils.safestring import SafeString, mark_safe
from django.utils.translation import gettext as _, pgettext_lazy
from course.constants import flow_permission, flow_rule_kind
from course.content import (
get_course_repo, get_flow_desc,
parse_date_spec, get_course_commit_sha)
from course.constants import (
flow_permission, flow_rule_kind)
import dulwich.repo
from course.content import ( # noqa
FlowDesc,
FlowPageDesc,
FlowSessionAccessRuleDesc
)
from course.page.base import ( # noqa
PageBase,
PageContext,
)
CourseCommitSHADoesNotExist,
FlowDesc,
FlowPageDesc,
FlowSessionAccessRuleDesc,
FlowSessionGradingRuleDesc,
FlowSessionStartRuleDesc,
get_course_commit_sha,
get_course_repo,
get_flow_desc,
parse_date_spec,
)
from course.page.base import PageBase, PageContext
from relate.utils import (
RelateHttpRequest,
not_none,
remote_address_from_request,
string_concat,
)
# {{{ mypy
if False:
if TYPE_CHECKING:
from course.content import Repo_ish
from course.models import (
Course,
ExamTicket,
FlowPageData,
FlowSession,
Participation,
)
from relate.utils import Repo_ish # noqa
from course.models import ( # noqa
Course,
Participation,
ExamTicket,
FlowSession,
FlowPageData,
)
# }}}
import re
def getattr_with_fallback(aggregates, attr_name, default=None):
# type: (Iterable[Any], Text, Any) -> Any
CODE_CELL_DIV_ATTRS_RE = re.compile(r'(<div class="[^>]*code_cell[^>"]*")(>)')
def getattr_with_fallback(
aggregates: Iterable[Any], attr_name: str, default: Any = None) -> Any:
for agg in aggregates:
result = getattr(agg, attr_name, None)
if result is not None:
......@@ -78,19 +96,18 @@ def getattr_with_fallback(aggregates, attr_name, default=None):
# {{{ flow permissions
class FlowSessionRuleBase(object):
class FlowSessionRuleBase:
pass
class FlowSessionStartRule(FlowSessionRuleBase):
def __init__(
self,
tag_session=None, # type: Optional[Text]
may_start_new_session=None, # type: Optional[bool]
may_list_existing_sessions=None, # type: Optional[bool]
default_expiration_mode=None, # type: Optional[Text]
):
# type: (...) -> None
tag_session: str | None = None,
may_start_new_session: bool | None = None,
may_list_existing_sessions: bool | None = None,
default_expiration_mode: str | None = None,
) -> None:
self.tag_session = tag_session
self.may_start_new_session = may_start_new_session
self.may_list_existing_sessions = may_list_existing_sessions
......@@ -100,10 +117,9 @@ class FlowSessionStartRule(FlowSessionRuleBase):
class FlowSessionAccessRule(FlowSessionRuleBase):
def __init__(
self,
permissions, # type: frozenset[Text]
message=None, # type: Optional[Text]
):
# type: (...) -> None
permissions: frozenset[str],
message: str | None = None,
) -> None:
self.permissions = permissions
self.message = message
......@@ -116,18 +132,17 @@ class FlowSessionAccessRule(FlowSessionRuleBase):
class FlowSessionGradingRule(FlowSessionRuleBase):
def __init__(
self,
grade_identifier, # type: Optional[Text]
grade_aggregation_strategy, # type: Text
due, # type: Optional[datetime.datetime]
generates_grade, # type: bool
description=None, # type: Optional[Text]
credit_percent=None, # type: Optional[float]
use_last_activity_as_completion_time=None, # type: Optional[bool]
max_points=None, # type: Optional[float]
max_points_enforced_cap=None, # type: Optional[float]
bonus_points=None, # type: Optional[float]
):
# type: (...) -> None
grade_identifier: str | None,
grade_aggregation_strategy: str,
due: datetime.datetime | None,
generates_grade: bool,
description: str | None = None,
credit_percent: float | None = None,
use_last_activity_as_completion_time: bool | None = None,
max_points: float | None = None,
max_points_enforced_cap: float | None = None,
bonus_points: float = 0,
) -> None:
self.grade_identifier = grade_identifier
self.grade_aggregation_strategy = grade_aggregation_strategy
......@@ -143,14 +158,15 @@ class FlowSessionGradingRule(FlowSessionRuleBase):
def _eval_generic_conditions(
rule, # type: Any
course, # type: Course
participation, # type: Optional[Participation]
now_datetime, # type: datetime.datetime
flow_id, # type: Text
login_exam_ticket, # type: Optional[ExamTicket]
):
# type: (...) -> bool
rule: Any,
course: Course,
participation: Participation | None,
now_datetime: datetime.datetime,
flow_id: str,
login_exam_ticket: ExamTicket | None,
*,
remote_ip_address: IPv4Address | IPv6Address | None = None,
) -> bool:
if hasattr(rule, "if_before"):
ds = parse_date_spec(course, rule.if_before)
......@@ -172,20 +188,35 @@ def _eval_generic_conditions(
and rule.if_signed_in_with_matching_exam_ticket):
if login_exam_ticket is None:
return False
if login_exam_ticket is None:
return False
if login_exam_ticket.exam.flow_id != flow_id:
return False
if login_exam_ticket.participation != participation:
return False
if hasattr(rule, "if_has_prairietest_exam_access"):
if remote_ip_address is None:
return False
if participation is None:
return False
from prairietest.utils import has_access_to_exam
if not has_access_to_exam(
course,
participation.user.email,
rule.if_has_prairietest_exam_access,
now_datetime,
remote_ip_address,
):
return False
return True
def _eval_generic_session_conditions(
rule, # type: Any
session, # type: FlowSession
now_datetime, # type: datetime.datetime
):
# type: (...) -> bool
rule: Any,
session: FlowSession,
now_datetime: datetime.datetime,
) -> bool:
if hasattr(rule, "if_has_tag"):
if session.access_rules_tag != rule.if_has_tag:
......@@ -199,16 +230,48 @@ def _eval_generic_session_conditions(
return True
def _eval_participation_tags_conditions(
rule: Any,
participation: Participation | None,
) -> bool:
participation_tags_any_set = (
set(getattr(rule, "if_has_participation_tags_any", [])))
participation_tags_all_set = (
set(getattr(rule, "if_has_participation_tags_all", [])))
if participation_tags_any_set or participation_tags_all_set:
if not participation:
# Return False for anonymous users if only
# if_has_participation_tags_any or if_has_participation_tags_all
# is not empty.
return False
ptag_set = set(participation.tags.all().values_list("name", flat=True))
if not ptag_set:
return False
if (
participation_tags_any_set
and not participation_tags_any_set & ptag_set):
return False
if (
participation_tags_all_set
and not participation_tags_all_set <= ptag_set):
return False
return True
def get_flow_rules(
flow_desc, # type: FlowDesc
kind, # type: Text
participation, # type: Optional[Participation]
flow_id, # type: Text
now_datetime, # type: datetime.datetime
consider_exceptions=True, # type: bool
default_rules_desc=[] # type: List[Any]
):
# type: (...) -> List[Any]
flow_desc: FlowDesc,
kind: str,
participation: Participation | None,
flow_id: str,
now_datetime: datetime.datetime,
consider_exceptions: bool = True,
default_rules_desc: list[Any] | None = None
) -> list[Any]:
if default_rules_desc is None:
default_rules_desc = []
if (not hasattr(flow_desc, "rules")
or not hasattr(flow_desc.rules, kind)):
......@@ -238,16 +301,17 @@ def get_flow_rules(
def get_session_start_rule(
course, # type: Course
participation, # type: Optional[Participation]
flow_id, # type: Text
flow_desc, # type: FlowDesc
now_datetime, # type: datetime.datetime
facilities=None, # type: Optional[frozenset[Text]]
for_rollover=False, # type: bool
login_exam_ticket=None, # type: Optional[ExamTicket]
):
# type: (...) -> FlowSessionStartRule
course: Course,
participation: Participation | None,
flow_id: str,
flow_desc: FlowDesc,
now_datetime: datetime.datetime,
facilities: Collection[str] | None = None,
for_rollover: bool = False,
login_exam_ticket: ExamTicket | None = None,
*,
remote_ip_address: IPv4Address | IPv6Address | None = None,
) -> FlowSessionStartRule:
"""Return a :class:`FlowSessionStartRule` if a new session is
permitted or *None* if no new session is allowed.
......@@ -257,18 +321,23 @@ def get_session_start_rule(
facilities = frozenset()
from relate.utils import dict_to_struct
rules = get_flow_rules(flow_desc, flow_rule_kind.start,
rules: list[FlowSessionStartRuleDesc] = get_flow_rules(
flow_desc, flow_rule_kind.start,
participation, flow_id, now_datetime,
default_rules_desc=[
dict_to_struct(dict(
may_start_new_session=True,
may_list_existing_sessions=False))])
dict_to_struct({
"may_start_new_session": True,
"may_list_existing_sessions": False})])
from course.models import FlowSession # noqa
from course.models import FlowSession
for rule in rules:
if not _eval_generic_conditions(rule, course, participation,
now_datetime, flow_id=flow_id,
login_exam_ticket=login_exam_ticket):
login_exam_ticket=login_exam_ticket,
remote_ip_address=remote_ip_address):
continue
if not _eval_participation_tags_conditions(rule, participation):
continue
if not for_rollover and hasattr(rule, "if_in_facility"):
......@@ -330,33 +399,37 @@ def get_session_start_rule(
def get_session_access_rule(
session, # type: FlowSession
flow_desc, # type: FlowDesc
now_datetime, # type: datetime.datetime
facilities=None, # type: Optional[frozenset[Text]]
login_exam_ticket=None, # type: Optional[ExamTicket]
):
# type: (...) -> FlowSessionAccessRule
"""Return a :class:`ExistingFlowSessionRule`` to describe
how a flow may be accessed.
"""
session: FlowSession,
flow_desc: FlowDesc,
now_datetime: datetime.datetime,
facilities: Collection[str] | None = None,
login_exam_ticket: ExamTicket | None = None,
*,
remote_ip_address: IPv4Address | IPv6Address | None = None,
) -> FlowSessionAccessRule:
if facilities is None:
facilities = frozenset()
from relate.utils import dict_to_struct
rules = get_flow_rules(flow_desc, flow_rule_kind.access,
rules: list[FlowSessionAccessRuleDesc] = get_flow_rules(
flow_desc, flow_rule_kind.access,
session.participation, session.flow_id, now_datetime,
default_rules_desc=[
dict_to_struct(dict(
permissions=[flow_permission.view],
))]) # type: List[FlowSessionAccessRuleDesc]
dict_to_struct({
"permissions": [flow_permission.view],
})])
for rule in rules:
if not _eval_generic_conditions(
rule, session.course, session.participation,
now_datetime, flow_id=session.flow_id,
login_exam_ticket=login_exam_ticket):
rule, session.course, session.participation,
now_datetime, flow_id=session.flow_id,
login_exam_ticket=login_exam_ticket,
remote_ip_address=remote_ip_address,
):
continue
if not _eval_participation_tags_conditions(rule, session.participation):
continue
if not _eval_generic_session_conditions(rule, session, now_datetime):
......@@ -418,21 +491,21 @@ def get_session_access_rule(
def get_session_grading_rule(
session, # type: FlowSession
flow_desc, # type: FlowDesc
now_datetime # type: datetime.datetime
):
# type: (...) -> FlowSessionGradingRule
session: FlowSession,
flow_desc: FlowDesc,
now_datetime: datetime.datetime
) -> FlowSessionGradingRule:
flow_desc_rules = getattr(flow_desc, "rules", None)
from relate.utils import dict_to_struct
rules = get_flow_rules(flow_desc, flow_rule_kind.grading,
rules: list[FlowSessionGradingRuleDesc] = get_flow_rules(
flow_desc, flow_rule_kind.grading,
session.participation, session.flow_id, now_datetime,
default_rules_desc=[
dict_to_struct(dict(
generates_grade=False,
))])
dict_to_struct({
"generates_grade": False,
})])
from course.enrollment import get_participation_role_identifiers
roles = get_participation_role_identifiers(session.course, session.participation)
......@@ -445,16 +518,38 @@ def get_session_grading_rule(
if not _eval_generic_session_conditions(rule, session, now_datetime):
continue
if not _eval_participation_tags_conditions(rule, session.participation):
continue
if hasattr(rule, "if_completed_before"):
ds = parse_date_spec(session.course, rule.if_completed_before)
if session.in_progress and now_datetime > ds:
continue
if not session.in_progress and session.completion_time > ds:
use_last_activity_as_completion_time = False
if hasattr(rule, "use_last_activity_as_completion_time"):
use_last_activity_as_completion_time = \
rule.use_last_activity_as_completion_time
if use_last_activity_as_completion_time:
last_activity = session.last_activity()
if last_activity is not None:
completion_time = last_activity
else:
completion_time = now_datetime
else:
if session.in_progress:
completion_time = now_datetime
else:
completion_time = not_none(session.completion_time)
if completion_time > ds:
continue
due = parse_date_spec(session.course, getattr(rule, "due", None))
if due is not None:
due_str = getattr(rule, "due", None)
if due_str is not None:
due = parse_date_spec(session.course, due_str)
assert due.tzinfo is not None
else:
due = None
generates_grade = getattr(rule, "generates_grade", True)
......@@ -470,6 +565,8 @@ def get_session_grading_rule(
max_points_enforced_cap = getattr_with_fallback(
(rule, flow_desc), "max_points_enforced_cap", None)
grade_aggregation_strategy = cast(str, grade_aggregation_strategy)
return FlowSessionGradingRule(
grade_identifier=grade_identifier,
grade_aggregation_strategy=grade_aggregation_strategy,
......@@ -493,16 +590,28 @@ def get_session_grading_rule(
# {{{ contexts
class CoursePageContext(object):
def __init__(self, request, course_identifier):
# type: (http.HttpRequest, Text) -> None
class AnyArgumentType:
pass
ANY_ARGUMENT = AnyArgumentType()
class CoursePageContext:
def __init__(self, request: http.HttpRequest, course_identifier: str) -> None:
# account for monkeypatching
self.request = cast(RelateHttpRequest, request)
self.request = request
self.course_identifier = course_identifier
self._permissions_cache = None # type: Optional[frozenset[Tuple[Text, Optional[Text]]]] # noqa
self._role_identifiers_cache = None # type: Optional[List[Text]]
self._permissions_cache: frozenset[tuple[str, str | None]] | None = None
self._role_identifiers_cache: list[str] | None = None
self.old_language: str | None = None
from course.models import Course # noqa
# using this to prevent nested using as context manager
self._is_in_context_manager = False
from course.models import Course
self.course = get_object_or_404(Course, identifier=course_identifier)
from course.enrollment import get_participation_for_request
......@@ -512,44 +621,22 @@ class CoursePageContext(object):
from course.views import check_course_state
check_course_state(self.course, self.participation)
self.course_commit_sha = get_course_commit_sha(
self.course, self.participation)
self.repo = get_course_repo(self.course)
# logic duplicated in course.content.get_course_commit_sha
sha = self.course.active_git_commit_sha.encode()
if self.participation is not None:
if self.participation.preview_git_commit_sha:
preview_sha = self.participation.preview_git_commit_sha.encode()
repo = get_course_repo(self.course)
from relate.utils import SubdirRepoWrapper
if isinstance(repo, SubdirRepoWrapper):
true_repo = repo.repo
else:
true_repo = cast(dulwich.repo.Repo, repo)
try:
true_repo[preview_sha]
except KeyError:
from django.contrib import messages
messages.add_message(request, messages.ERROR,
_("Preview revision '%s' does not exist--"
"showing active course content instead.")
% preview_sha.decode())
preview_sha = None
try:
sha = get_course_commit_sha(
self.course, self.participation,
repo=self.repo,
raise_on_nonexistent_preview_commit=True)
except CourseCommitSHADoesNotExist as e:
from django.contrib import messages
messages.add_message(request, messages.ERROR, str(e))
if preview_sha is not None:
sha = preview_sha
sha = self.course.active_git_commit_sha.encode()
self.course_commit_sha = sha
def role_identifiers(self):
# type: () -> List[Text]
def role_identifiers(self) -> list[str]:
if self._role_identifiers_cache is not None:
return self._role_identifiers_cache
......@@ -558,8 +645,7 @@ class CoursePageContext(object):
self.course, self.participation)
return self._role_identifiers_cache
def permissions(self):
# type: () -> frozenset[Tuple[Text, Optional[Text]]]
def permissions(self) -> frozenset[tuple[str, str | None]]:
if self.participation is None:
if self._permissions_cache is not None:
return self._permissions_cache
......@@ -573,15 +659,50 @@ class CoursePageContext(object):
else:
return self.participation.permissions()
def has_permission(self, perm, argument=None):
# type: (Text, Optional[Text]) -> bool
return (perm, argument) in self.permissions()
def has_permission(
self, perm: str, argument: str | AnyArgumentType | None = None
) -> bool:
if argument is ANY_ARGUMENT:
return any(perm == p
for p, arg in self.permissions())
else:
return (perm, argument) in self.permissions()
def _set_course_lang(self, action: str) -> None:
if self.course.force_lang and self.course.force_lang.strip():
if action == "activate":
self.old_language = translation.get_language()
translation.activate(self.course.force_lang)
else:
if self.old_language is None:
# This should be a rare case, but get_language() can be None.
# See django.utils.translation.override.__exit__()
translation.deactivate_all()
else:
translation.activate(self.old_language)
def __enter__(self):
if self._is_in_context_manager:
raise RuntimeError(
"Nested use of 'course_view' as context manager "
"is not allowed.")
self._is_in_context_manager = True
self._set_course_lang(action="activate")
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self._is_in_context_manager = False
self._set_course_lang(action="deactivate")
self.repo.close()
class FlowContext(object):
def __init__(self, repo, course, flow_id, participation=None):
# type: (Repo_ish, Course, Text, Optional[Participation]) -> None
class FlowContext:
def __init__(
self,
repo: Repo_ish,
course: Course,
flow_id: str,
participation: Participation | None = None) -> None:
"""*participation* and *flow_session* are not stored and only used
to figure out versioning of the flow content.
"""
......@@ -616,33 +737,32 @@ class FlowPageContext(FlowContext):
def __init__(
self,
repo, # type: Repo_ish
course, # type: Course
flow_id, # type: Text
ordinal, # type: int
participation, # type: Optional[Participation]
flow_session, # type: FlowSession
request=None, # type: Optional[http.HttpRequest]
):
# type: (...) -> None
super(FlowPageContext, self).__init__(repo, course, flow_id, participation)
if ordinal >= flow_session.page_count:
repo: Repo_ish,
course: Course,
flow_id: str,
page_ordinal: int,
participation: Participation | None,
flow_session: FlowSession,
request: http.HttpRequest | None = None,
) -> None:
super().__init__(repo, course, flow_id, participation)
if page_ordinal >= not_none(flow_session.page_count):
raise PageOrdinalOutOfRange()
from course.models import FlowPageData # noqa
from course.models import FlowPageData
page_data = self.page_data = get_object_or_404(
FlowPageData, flow_session=flow_session, ordinal=ordinal)
FlowPageData, flow_session=flow_session, page_ordinal=page_ordinal)
from course.content import get_flow_page_desc
try:
self.page_desc = get_flow_page_desc(
flow_session, self.flow_desc, page_data.group_id,
page_data.page_id) # type: Optional[FlowPageDesc]
self.page_desc: FlowPageDesc | None = get_flow_page_desc(
flow_session.flow_id, self.flow_desc, page_data.group_id,
page_data.page_id)
except ObjectDoesNotExist:
self.page_desc = None
self.page = None # type: Optional[PageBase]
self.page_context = None # type: Optional[PageContext]
self.page: PageBase | None = None
self.page_context: PageContext | None = None
else:
self.page = instantiate_flow_page_with_ctx(self, page_data)
......@@ -650,14 +770,16 @@ class FlowPageContext(FlowContext):
if request is not None:
from django.urls import reverse
page_uri = request.build_absolute_uri(
reverse("relate-view_flow_page",
args=(course.identifier, flow_session.id, ordinal)))
reverse(
"relate-view_flow_page",
args=(course.identifier, flow_session.id, page_ordinal)))
self.page_context = PageContext(
course=self.course, repo=self.repo,
commit_sha=self.course_commit_sha,
flow_session=flow_session,
page_uri=page_uri)
page_uri=page_uri,
request=request)
self._prev_answer_visit = False
......@@ -670,12 +792,12 @@ class FlowPageContext(FlowContext):
return self._prev_answer_visit
@property
def ordinal(self):
return self.page_data.ordinal
def page_ordinal(self):
return self.page_data.page_ordinal
def instantiate_flow_page_with_ctx(fctx, page_data):
# type: (FlowContext, FlowPageData) -> PageBase
def instantiate_flow_page_with_ctx(
fctx: FlowContext, page_data: FlowPageData) -> PageBase:
from course.content import get_flow_page_desc
page_desc = get_flow_page_desc(
......@@ -684,21 +806,21 @@ def instantiate_flow_page_with_ctx(fctx, page_data):
from course.content import instantiate_flow_page
return instantiate_flow_page(
"course '%s', flow '%s', page '%s/%s'"
% (fctx.course.identifier, fctx.flow_id,
page_data.group_id, page_data.page_id),
f"course '{fctx.course.identifier}', "
f"flow '{fctx.flow_id}', page '{page_data.group_id}/{page_data.page_id}'",
fctx.repo, page_desc, fctx.course_commit_sha)
# }}}
# {{{ utilties for course-based views
# {{{ utilities for course-based views
def course_view(f):
def wrapper(request, course_identifier, *args, **kwargs):
pctx = CoursePageContext(request, course_identifier)
response = f(pctx, *args, **kwargs)
pctx.repo.close()
return response
with CoursePageContext(request, course_identifier) as pctx:
response = f(pctx, *args, **kwargs)
pctx.repo.close()
return response
from functools import update_wrapper
update_wrapper(wrapper, f)
......@@ -706,29 +828,27 @@ def course_view(f):
return wrapper
class ParticipationPermissionWrapper(object):
def __init__(self, pctx):
# type: (CoursePageContext) -> None
class ParticipationPermissionWrapper:
def __init__(self, pctx: CoursePageContext) -> None:
self.pctx = pctx
def __getitem__(self, perm):
# type: (Text) -> bool
def __getitem__(self, perm: str) -> bool:
from course.constants import participation_permission
try:
getattr(participation_permission, perm)
except AttributeError:
raise ValueError("permission name '%s' not valid" % perm)
raise ValueError(f"permission name '{perm}' not valid")
return self.pctx.has_permission(perm)
return self.pctx.has_permission(perm, ANY_ARGUMENT)
def __iter__(self):
raise TypeError("ParticipationPermissionWrapper is not iterable.")
def render_course_page(pctx, template_name, args,
allow_instant_flow_requests=True):
# type: (CoursePageContext, Text, Dict[Text, Any], bool) -> http.HttpResponse
def render_course_page(
pctx: CoursePageContext, template_name: str, args: dict[str, Any],
allow_instant_flow_requests: bool = True) -> http.HttpResponse:
args = args.copy()
......@@ -737,13 +857,13 @@ def render_course_page(pctx, template_name, args,
if allow_instant_flow_requests:
from course.models import InstantFlowRequest
instant_flow_requests = list((InstantFlowRequest.objects
instant_flow_requests = list(InstantFlowRequest.objects
.filter(
course=pctx.course,
start_time__lte=now_datetime,
end_time__gte=now_datetime,
cancelled=False)
.order_by("start_time")))
.order_by("start_time"))
else:
instant_flow_requests = []
......@@ -763,7 +883,7 @@ def render_course_page(pctx, template_name, args,
# {{{ page cache
class PageInstanceCache(object):
class PageInstanceCache:
"""Caches instances of :class:`course.page.Page`."""
def __init__(self, repo, course, flow_id):
......@@ -795,8 +915,9 @@ class PageInstanceCache(object):
group_id, page_id)
page = instantiate_flow_page(
location="flow '%s', group, '%s', page '%s'"
% (self.flow_id, group_id, page_id),
location=(
f"flow '{self.flow_id}', "
f"group '{group_id}', page '{page_id}'"),
repo=self.repo, page_desc=page_desc,
commit_sha=commit_sha)
......@@ -808,94 +929,179 @@ class PageInstanceCache(object):
# {{{ codemirror config
def get_codemirror_widget(language_mode, interaction_mode,
config=None, addon_css=(), addon_js=(), dependencies=(),
read_only=False):
theme = "default"
if read_only:
theme += " relate-readonly"
@dataclass(frozen=True)
class JsLiteral:
js: str
def repr_js(obj: Any) -> str:
if isinstance(obj, list):
return "[{}]".format(", ".join(repr_js(ch) for ch in obj))
elif isinstance(obj, dict):
return "{{{}}}".format(", ".join(f"{k}: {repr_js(v)}" for k, v in obj.items()))
elif isinstance(obj, bool):
return repr(obj).lower()
elif isinstance(obj, int | float):
return repr(obj)
elif isinstance(obj, str):
return repr(obj)
elif isinstance(obj, JsLiteral):
return obj.js
else:
raise ValueError(f"unsupported object type: {type(obj)}")
class CodeMirrorTextarea(forms.Textarea):
@property
def media(self):
return forms.Media(js=["bundle-codemirror.js"])
def __init__(self, attrs=None,
*,
language_mode=None, interaction_mode,
indent_unit: int,
autofocus: bool,
additional_keys: dict[str, JsLiteral],
**kwargs):
super().__init__(attrs, **kwargs)
self.language_mode = language_mode
self.interaction_mode = interaction_mode
self.indent_unit = indent_unit
self.autofocus = autofocus
self.additional_keys = additional_keys
# TODO: Maybe add VSCode keymap?
# https://github.com/replit/codemirror-vscode-keymap
def render(self, name, value, attrs=None, renderer=None) -> SafeString:
# based on
# https://github.com/codemirror/basic-setup/blob/b3be7cd30496ee578005bd11b1fa6a8b21fcbece/src/codemirror.ts
extensions = [
JsLiteral(f"rlCodemirror.indentUnit.of({' ' * self.indent_unit !r})"),
]
if self.interaction_mode == "vim":
extensions.insert(0, JsLiteral("rlCodemirror.vim()"))
elif self.interaction_mode == "emacs":
extensions.insert(0, JsLiteral("rlCodemirror.emacs()"))
else:
pass
if self.language_mode is not None:
extensions.append(JsLiteral(f"rlCodemirror.{self.language_mode}()"))
from codemirror import CodeMirrorTextarea, CodeMirrorJavascript
additional_keys = [
{
"key": key,
"run": func,
}
for key, func in self.additional_keys.items()
]
output = [super().render(
name, value, attrs, renderer),
f"""
<script type="text/javascript">
rlCodemirror.editorFromTextArea(
document.getElementById('id_{name}'),
{repr_js(extensions)},
{repr_js(self.autofocus)},
{repr_js(additional_keys)}
)
</script>
"""]
return mark_safe("\n".join(output))
def get_codemirror_widget(
language_mode: str,
interaction_mode: str | None,
*,
autofocus: bool = False,
additional_keys: dict[str, JsLiteral] | None = None,
) -> tuple[CodeMirrorTextarea, str]:
if additional_keys is None:
additional_keys = {}
from django.urls import reverse
help_text = (_("Press F9 to toggle full-screen mode. ")
help_text = (_("Press Esc then Tab to leave the editor. ")
+ _("Set editor mode in <a href='%s'>user profile</a>.")
% reverse("relate-user_profile"))
actual_addon_css = (
"dialog/dialog",
"display/fullscreen",
) + addon_css
actual_addon_js = (
"search/searchcursor",
"dialog/dialog",
"search/search",
"comment/comment",
"edit/matchbrackets",
"display/fullscreen",
"selection/active-line",
"edit/trailingspace",
) + addon_js
if language_mode == "python":
if language_mode in ["python", "yaml"]:
indent_unit = 4
else:
indent_unit = 2
actual_config = {
"fixedGutter": True,
#"autofocus": True,
"matchBrackets": True,
"styleActiveLine": True,
"showTrailingSpace": True,
"indentUnit": indent_unit,
"readOnly": read_only,
"extraKeys": CodeMirrorJavascript("""
{
"Ctrl-/": "toggleComment",
"Tab": function(cm)
{
var spaces = \
Array(cm.getOption("indentUnit") + 1).join(" ");
cm.replaceSelection(spaces);
},
"F9": function(cm) {
cm.setOption("fullScreen",
!cm.getOption("fullScreen"));
}
}
""")
}
return CodeMirrorTextarea(
language_mode=language_mode,
interaction_mode=interaction_mode,
indent_unit=indent_unit,
autofocus=autofocus,
additional_keys=additional_keys,
), help_text
# }}}
if interaction_mode == "vim":
actual_config["vimMode"] = True
actual_addon_js += ('../keymap/vim',)
elif interaction_mode == "emacs":
actual_config["keyMap"] = "emacs"
actual_addon_js += ('../keymap/emacs',)
elif interaction_mode == "sublime":
actual_config["keyMap"] = "sublime"
actual_addon_js += ('../keymap/sublime',)
# every other interaction mode goes to default
if config is not None:
actual_config.update(config)
# {{{ prosemirror
return CodeMirrorTextarea(
mode=language_mode,
dependencies=dependencies,
theme=theme,
addon_css=actual_addon_css,
addon_js=actual_addon_js,
config=actual_config), help_text
class ProseMirrorTextarea(forms.Textarea):
@property
def media(self):
return forms.Media(js=["bundle-prosemirror.js"])
def render(self, name, value, attrs=None, renderer=None) -> SafeString:
output = [super().render(
name, value, attrs, renderer),
f"""
<script type="text/javascript">
rlProsemirror.editorFromTextArea(
document.getElementById('id_{name}'),
)
</script>
"""]
return mark_safe("\n".join(output))
math_help_text = mark_safe(r"""
See the <a href="https://katex.org/docs/supported.html"
>list of supported math commands</a>.
More tips for using this editor to type math:
<ul>
<li>
You may paste in Markdown-with-math (as accepted by
<a
href="https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/writing-mathematical-expressions"
>Github</a>,
<a href="https://pandoc.org/MANUAL.html#math">Pandoc</a>, or
<a href="https://meta.discourse.org/t/discourse-math/65770">Discourse</a>).
<li>
Inline math nodes are delimited with <code>$</code>.
After typing the closing dollar sign in
an expression like <code>$\int_a^b f(x) dx$</code>, a math node will appear.
</li>
<li>
To start a block math node, press Enter to create a blank line,
then type <code>$$</code> followed by Space. You can type multi-line math
expressions, and the result will render in display style.
</li>
<li>
Math nodes behave like regular text when using arrow keys or Backspace.
From within a math node, press Ctrl-Backspace to delete the entire node.
You can select, copy, and paste math nodes just like regular text!
</li>
</ul>
""")
# }}}
# {{{ facility processing
def get_facilities_config(request=None):
# type: (Optional[http.HttpRequest]) -> Dict[Text, Dict[Text, Any]]
def get_facilities_config(
request: http.HttpRequest | None = None
) -> dict[str, dict[str, Any]] | None:
from django.conf import settings
# This is called during offline validation, where Django isn't really set up.
......@@ -917,28 +1123,32 @@ def get_facilities_config(request=None):
return facilities
class FacilityFindingMiddleware(object):
class FacilityFindingMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
def __call__(self, request: http.HttpRequest) -> http.HttpResponse:
pretend_facilities = request.session.get("relate_pretend_facilities")
if pretend_facilities is not None:
facilities = pretend_facilities
else:
import ipaddress
remote_address = ipaddress.ip_address(
six.text_type(request.META['REMOTE_ADDR']))
remote_address = remote_address_from_request(request)
facilities = set()
for name, props in six.iteritems(get_facilities_config(request)):
facilities_config = get_facilities_config(request)
if facilities_config is None:
facilities_config = {}
from ipaddress import ip_network
for name, props in facilities_config.items():
ip_ranges = props.get("ip_ranges", [])
for ir in ip_ranges:
if remote_address in ipaddress.ip_network(six.text_type(ir)):
if remote_address in ip_network(str(ir)):
facilities.add(name)
request = cast(RelateHttpRequest, request)
request.relate_facilities = frozenset(facilities)
return self.get_response(request)
......@@ -946,18 +1156,53 @@ class FacilityFindingMiddleware(object):
# }}}
def get_col_contents_or_empty(row, index):
if index >= len(row):
return ""
else:
return row[index]
def csv_data_importable(file_contents, column_idx_list, header_count):
import csv
spamreader = csv.reader(file_contents)
n_header_row = 0
for row in spamreader:
try:
row0 = spamreader.__next__()
except Exception as e:
err_msg = type(e).__name__
err_str = str(e)
if err_msg == "Error":
err_msg = ""
else:
err_msg += ": "
err_msg += err_str
if "line contains NUL" in err_str:
err_msg = err_msg.rstrip(".") + ". "
# This message changed over time.
# Make the message uniform to please the tests.
err_msg = err_msg.replace("NULL byte", "NUL")
err_msg += _("Are you sure the file is a CSV file other "
"than a Microsoft Excel file?")
return False, (
string_concat(
pgettext_lazy("Starting of Error message", "Error"),
f": {err_msg}"))
from itertools import chain
for row in chain([row0], spamreader):
n_header_row += 1
if n_header_row <= header_count:
continue
try:
for column_idx in column_idx_list:
if column_idx is not None:
six.text_type(row[column_idx-1])
str(get_col_contents_or_empty(row, column_idx-1))
except UnicodeDecodeError:
return False, (
_("Error: Columns to be imported contain "
......@@ -978,4 +1223,115 @@ def csv_data_importable(file_contents, column_idx_list, header_count):
return True, ""
def will_use_masked_profile_for_email(recipient_email: str | list[str] | None) -> bool:
if not recipient_email:
return False
if not isinstance(recipient_email, list):
recipient_email = [recipient_email]
from course.models import Participation
recipient_participations = (
Participation.objects.filter(
user__email__in=recipient_email
))
from course.constants import participation_permission as pperm
for part in recipient_participations:
if part.has_permission(pperm.view_participant_masked_profile):
return True
return False
def get_course_specific_language_choices() -> tuple[tuple[str, Any], ...]:
from collections import OrderedDict
from django.conf import settings
all_options = ((settings.LANGUAGE_CODE, None), *tuple(settings.LANGUAGES))
filtered_options_dict = OrderedDict(all_options)
def get_default_option() -> tuple[str, str]:
# For the default language used, if USE_I18N is True, display
# "Disabled". Otherwise display its lang info.
if not settings.USE_I18N:
formatted_descr = (
get_formatted_options(settings.LANGUAGE_CODE, None)[1])
else:
formatted_descr = _("disabled (i.e., displayed language is "
"determined by user's browser preference)")
return "", string_concat("{}: ".format(_("Default")), formatted_descr)
def get_formatted_options(
lang_code: str, lang_descr: str | None) -> tuple[str, str]:
if lang_descr is None:
lang_descr = OrderedDict(settings.LANGUAGES).get(lang_code)
if lang_descr is None:
try:
lang_info = translation.get_language_info(lang_code)
lang_descr = lang_info["name_translated"]
except KeyError:
return (lang_code.strip(), lang_code)
return (lang_code.strip(),
string_concat(_(lang_descr), f" ({lang_code})"))
filtered_options = (
[get_default_option()]
+ [get_formatted_options(k, v)
for k, v in filtered_options_dict.items()])
# filtered_options[1] is the option for settings.LANGUAGE_CODE
# it's already displayed when settings.USE_I18N is False
if not settings.USE_I18N:
filtered_options.pop(1)
return tuple(filtered_options)
class LanguageOverride(ContextDecorator):
def __init__(self, course: Course, deactivate: bool = False) -> None:
self.course = course
self.deactivate = deactivate
if course.force_lang:
self.language = course.force_lang
else:
from django.conf import settings
self.language = settings.RELATE_ADMIN_EMAIL_LOCALE
def __enter__(self) -> None:
self.old_language = translation.get_language()
if self.language is not None:
translation.activate(self.language)
else:
translation.deactivate_all()
def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
if self.old_language is None:
translation.deactivate_all()
elif self.deactivate:
translation.deactivate()
else:
translation.activate(self.old_language)
class RelateJinjaMacroBase:
def __init__(
self,
course: Course | None,
repo: Repo_ish,
commit_sha: bytes) -> None:
self.course = course
self.repo = repo
self.commit_sha = commit_sha
@property
def name(self):
# The name of the method used in the template
raise NotImplementedError()
def __call__(self, *args: Any, **kwargs: Any) -> str:
raise NotImplementedError()
# vim: foldmethod=marker
# -*- coding: utf-8 -*-
from __future__ import annotations
from __future__ import division, print_function
__copyright__ = "Copyright (C) 2014 Andreas Kloeckner"
......@@ -24,33 +23,48 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
"""
import re
import datetime
import six
import re
import sys
from collections.abc import Sequence
from typing import TYPE_CHECKING, Any, Literal, TypeAlias
import dulwich.objects
from django.core.exceptions import ObjectDoesNotExist
from django.utils.html import escape
from django.utils.translation import (
ugettext_lazy as _, ugettext, string_concat)
from django.utils.translation import gettext as _
from course.constants import (
FLOW_SESSION_EXPIRATION_MODE_CHOICES,
ATTRIBUTES_FILENAME,
participation_permission as pperm)
ATTRIBUTES_FILENAME,
DEFAULT_ACCESS_KINDS,
FLOW_SESSION_EXPIRATION_MODE_CHOICES,
participation_permission as pperm,
)
from relate.utils import Struct, string_concat
from course.content import get_repo_blob
from relate.utils import Struct
# {{{ mypy
from typing import Any, Tuple, Optional, Text # noqa
if False:
from relate.utils import Repo_ish # noqa
from course.models import Course # noqa
if TYPE_CHECKING:
from course.models import Course
from relate.utils import Repo_ish
# }}}
__doc__ = """
.. autoclass:: ValidationContext
.. autofunction:: validate_struct
Stub Docs
=========
.. class:: Course
.. class:: Repo_ish
"""
# {{{ validation tools
class ValidationError(RuntimeError):
......@@ -60,16 +74,16 @@ class ValidationError(RuntimeError):
ID_RE = re.compile(r"^[\w]+$")
def validate_identifier(vctx, location, s, warning_only=False):
# type: (ValidationContext, Text, Text, bool) -> None
def validate_identifier(
vctx: ValidationContext, location: str, s: str, warning_only: bool = False
) -> None:
if not ID_RE.match(s):
if warning_only:
msg = (string_concat(
_("invalid identifier"),
" '%(string)s'")
% {'location': location, 'string': s})
% {"location": location, "string": s})
vctx.add_warning(location, msg)
else:
......@@ -77,13 +91,12 @@ def validate_identifier(vctx, location, s, warning_only=False):
"%(location)s: ",
_("invalid identifier"),
" '%(string)s'")
% {'location': location, 'string': s})
% {"location": location, "string": s})
raise ValidationError(msg)
def validate_role(vctx, location, role):
# type: (ValidationContext, Text, Text) -> None
def validate_role(vctx: ValidationContext, location: str, role: str) -> None:
if vctx.course is not None:
from course.models import ParticipationRole
......@@ -94,12 +107,11 @@ def validate_role(vctx, location, role):
raise ValidationError(
string_concat("%(location)s: ",
_("invalid role '%(role)s'"))
% {'location': location, 'role': role})
% {"location": location, "role": role})
def validate_facility(vctx, location, facility):
# type: (ValidationContext, Text, Text) -> None
def validate_facility(
vctx: ValidationContext, location: str, facility: str) -> None:
from course.utils import get_facilities_config
facilities = get_facilities_config()
if facilities is None:
......@@ -115,14 +127,41 @@ def validate_facility(vctx, location, facility):
})
def validate_participationtag(
vctx: ValidationContext, location: str, participationtag: str) -> None:
if vctx.course is not None:
from pytools import memoize_in
@memoize_in(vctx, "available_participation_tags")
def get_ptag_list(vctx: ValidationContext) -> list[str]:
from course.models import ParticipationTag
return list(
ParticipationTag.objects.filter(course=vctx.course)
.values_list("name", flat=True))
ptag_list = get_ptag_list(vctx)
if participationtag not in ptag_list:
vctx.add_warning(
location,
_(
"Name of participation tag not recognized: '%(ptag_name)s'. "
"Known participation tag names: '%(known_ptag_names)s'")
% {
"ptag_name": participationtag,
"known_ptag_names": ", ".join(ptag_list),
})
AttrSpec: TypeAlias = Sequence[tuple[str, type | tuple[type, ...] | Literal["markup"]]]
def validate_struct(
vctx, # type: ValidationContext
location, # type: Text
obj, # type: Any
required_attrs, # type: List[Tuple[Text, Any]]
allowed_attrs, # type: List[Tuple[Text, Any]]
):
# type: (...) -> None
vctx: ValidationContext,
location: str,
obj: Any,
required_attrs: AttrSpec,
allowed_attrs: AttrSpec,
) -> None:
"""
:arg required_attrs: an attribute validation list (see below)
......@@ -136,27 +175,21 @@ def validate_struct(
if not isinstance(obj, Struct):
raise ValidationError(
"%s: not a key-value map" % location)
f"{location}: not a key-value map")
present_attrs = set(name for name in dir(obj) if not name.startswith("_"))
present_attrs = {name for name in dir(obj) if not name.startswith("_")}
for required, attr_list in [
(True, required_attrs),
(False, allowed_attrs),
]:
for attr_rec in attr_list:
if isinstance(attr_rec, tuple):
attr, allowed_types = attr_rec
else:
attr = attr_rec
allowed_types = None
for attr, allowed_types in attr_list:
if attr not in present_attrs:
if required:
raise ValidationError(
string_concat("%(location)s: ",
_("attribute '%(attr)s' missing"))
% {'location': location, 'attr': attr})
% {"location": location, "attr": attr})
else:
present_attrs.remove(attr)
val = getattr(obj, attr)
......@@ -166,10 +199,6 @@ def validate_struct(
allowed_types = str
is_markup = True
if allowed_types == str:
# Love you, too, Python 2.
allowed_types = six.string_types
if not isinstance(val, allowed_types):
raise ValidationError(
string_concat("%(location)s: ",
......@@ -177,34 +206,33 @@ def validate_struct(
"wrong type: got '%(name)s', "
"expected '%(allowed)s'"))
% {
'location': location,
'attr': attr,
'name': type(val).__name__,
'allowed': escape(str(allowed_types))})
"location": location,
"attr": attr,
"name": type(val).__name__,
"allowed": escape(str(allowed_types))})
if is_markup:
validate_markup(vctx, "%s: attribute %s" % (location, attr), val)
validate_markup(vctx, f"{location}: attribute {attr}", val)
if present_attrs:
raise ValidationError(
string_concat("%(location)s: ",
_("extraneous attribute(s) '%(attr)s'"))
% {'location': location, 'attr': ",".join(present_attrs)})
% {"location": location, "attr": ",".join(present_attrs)})
datespec_types = (datetime.date, six.string_types, datetime.datetime)
datespec_types = (datetime.date, str, datetime.datetime)
# }}}
class ValidationWarning(object):
def __init__(self, location, text):
# type: (Optional[Text], Text) -> None
class ValidationWarning:
def __init__(self, location: str | None, text: str) -> None:
self.location = location
self.text = text
class ValidationContext(object):
class ValidationContext:
"""
.. attribute:: repo
.. attribute:: commit_sha
......@@ -214,33 +242,30 @@ class ValidationContext(object):
is currently available.
"""
course = None # type: Optional[Course]
def __init__(self, repo, commit_sha, course=None):
# type: (Repo_ish, bytes, Optional[Course]) -> None
course: Course | None = None
def __init__(
self, repo: Repo_ish, commit_sha: bytes, course: Course | None = None
) -> None:
self.repo = repo
self.commit_sha = commit_sha
self.course = course
self.warnings = [] # type: List[ValidationWarning]
self.warnings: list[ValidationWarning] = []
def encounter_datespec(self, location, datespec):
# type: (Text, Text) -> None
def encounter_datespec(self, location: str, datespec: str) -> None:
from course.content import parse_date_spec
parse_date_spec(self.course, datespec, vctx=self, location=location)
def add_warning(self, location, text):
# type: (Optional[Text], Text) -> None
def add_warning(self, location: str | None, text: str) -> None:
self.warnings.append(ValidationWarning(location, text))
# {{{ markup validation
def validate_markup(vctx, location, markup_str):
# type: (ValidationContext, Text, Text) -> None
def validate_markup(
vctx: ValidationContext, location: str, markup_str: str) -> None:
def reverse_func(*args, **kwargs):
pass
......@@ -253,7 +278,7 @@ def validate_markup(vctx, location, markup_str):
text=markup_str,
reverse_func=reverse_func,
validate_only=True)
except:
except Exception:
from traceback import print_exc
print_exc()
......@@ -262,10 +287,7 @@ def validate_markup(vctx, location, markup_str):
assert tp is not None
raise ValidationError(
"%(location)s: %(err_type)s: %(err_str)s" % {
'location': location,
"err_type": tp.__name__,
"err_str": str(e)})
f"{location}: {tp.__name__}: {e!s}")
# }}}
......@@ -285,6 +307,8 @@ def validate_chunk_rule(vctx, location, chunk_rule):
("if_before", datespec_types),
("if_in_facility", str),
("if_has_role", list),
("if_has_participation_tags_any", list),
("if_has_participation_tags_all", list),
("start", datespec_types),
("end", datespec_types),
......@@ -303,6 +327,14 @@ def validate_chunk_rule(vctx, location, chunk_rule):
for role in chunk_rule.if_has_role:
validate_role(vctx, location, role)
if hasattr(chunk_rule, "if_has_participation_tags_any"):
for ptag in chunk_rule.if_has_participation_tags_any:
validate_participationtag(vctx, location, ptag)
if hasattr(chunk_rule, "if_has_participation_tags_all"):
for ptag in chunk_rule.if_has_participation_tags_all:
validate_participationtag(vctx, location, ptag)
if hasattr(chunk_rule, "if_in_facility"):
validate_facility(vctx, location, chunk_rule.if_in_facility)
......@@ -354,7 +386,7 @@ def validate_page_chunk(vctx, location, chunk):
raise ValidationError(
string_concat("%(location)s: ",
_("no title present"))
% {'location': location})
% {"location": location})
if hasattr(chunk, "rules"):
for i, rule in enumerate(chunk.rules):
......@@ -362,6 +394,8 @@ def validate_page_chunk(vctx, location, chunk):
"%s, rule %d" % (location, i+1),
rule)
validate_markup(vctx, location, chunk.content)
def validate_staticpage_desc(vctx, location, page_desc):
validate_struct(
......@@ -380,12 +414,11 @@ def validate_staticpage_desc(vctx, location, page_desc):
if (
(not hasattr(page_desc, "chunks") and not hasattr(page_desc, "content"))
or
(hasattr(page_desc, "chunks") and hasattr(page_desc, "content"))):
or (hasattr(page_desc, "chunks") and hasattr(page_desc, "content"))):
raise ValidationError(
string_concat("%(location)s: ",
_("must have either 'chunks' or 'content'"))
% {'location': location})
% {"location": location})
# }}}
......@@ -412,7 +445,7 @@ def validate_staticpage_desc(vctx, location, page_desc):
string_concat(
"%(location)s: ",
_("chunk id '%(chunkid)s' not unique"))
% {'location': location, 'chunkid': chunk.id})
% {"location": location, "chunkid": chunk.id})
chunk_ids.add(chunk.id)
......@@ -423,12 +456,13 @@ def validate_staticpage_desc(vctx, location, page_desc):
# {{{ flow validation
def validate_flow_page(vctx, location, page_desc):
def validate_flow_page(
vctx: ValidationContext, location: str, page_desc: Any) -> None:
if not hasattr(page_desc, "id"):
raise ValidationError(
string_concat(
"%s: ",
ugettext("flow page has no ID"))
_("flow page has no ID"))
% location)
validate_identifier(vctx, location, page_desc.id)
......@@ -439,7 +473,7 @@ def validate_flow_page(vctx, location, page_desc):
class_(vctx, location, page_desc)
except ValidationError:
raise
except:
except Exception:
tp, e, __ = sys.exc_info()
from traceback import format_exc
......@@ -450,10 +484,10 @@ def validate_flow_page(vctx, location, page_desc):
": %(err_type)s: "
"%(err_str)s<br><pre>%(format_exc)s</pre>")
% {
'location': location,
"err_type": tp.__name__,
"location": location,
"err_type": tp.__name__, # type: ignore
"err_str": str(e),
'format_exc': format_exc()})
"format_exc": format_exc()})
def validate_flow_group(vctx, location, grp):
......@@ -471,6 +505,13 @@ def validate_flow_group(vctx, location, grp):
]
)
if len(grp.pages) == 0:
raise ValidationError(
string_concat(
"%(location)s, ",
_("group '%(group_id)s': group is empty"))
% {"location": location, "group_id": grp.id})
for i, page_desc in enumerate(grp.pages):
validate_flow_page(
vctx,
......@@ -478,20 +519,21 @@ def validate_flow_group(vctx, location, grp):
% (location, i+1, getattr(page_desc, "id", None)),
page_desc)
if len(grp.pages) == 0:
raise ValidationError(
string_concat(
"%(location)s, ",
_("group '%(group_id)s': group is empty"))
% {'location': location, 'group_id': grp.id})
if hasattr(grp, "max_page_count") and grp.max_page_count <= 0:
raise ValidationError(
if hasattr(grp, "max_page_count"):
if grp.max_page_count <= 0:
raise ValidationError(
string_concat(
"%(location)s, ",
_("group '%(group_id)s': "
"max_page_count is not positive"))
% {'location': location, 'group_id': grp.id})
% {"location": location, "group_id": grp.id})
elif not hasattr(grp, "shuffle") and grp.max_page_count < len(grp.pages):
vctx.add_warning(
_("%(location)s, group '%(group_id)s': ") % {
"location": location, "group_id": grp.id},
_("shuffle attribute will be required for groups with"
"max_page_count in a future version. set "
"'shuffle: False' to match current behavior."))
# {{{ check page id uniqueness
......@@ -503,7 +545,7 @@ def validate_flow_group(vctx, location, grp):
string_concat(
"%(location)s: ",
_("page id '%(page_desc_id)s' not unique"))
% {'location': location, 'page_desc_id': page_desc.id})
% {"location": location, "page_desc_id": page_desc.id})
page_ids.add(page_desc.id)
......@@ -514,7 +556,9 @@ def validate_flow_group(vctx, location, grp):
# {{{ flow rules
def validate_session_start_rule(vctx, location, nrule, tags):
def validate_session_start_rule(
vctx: ValidationContext, location: str, nrule: Any, tags: list[str]
) -> None:
validate_struct(
vctx, location, nrule,
required_attrs=[],
......@@ -522,13 +566,16 @@ def validate_session_start_rule(vctx, location, nrule, tags):
("if_after", datespec_types),
("if_before", datespec_types),
("if_has_role", list),
("if_has_participation_tags_any", list),
("if_has_participation_tags_all", list),
("if_in_facility", str),
("if_has_in_progress_session", bool),
("if_has_session_tagged", (six.string_types, type(None))),
("if_has_session_tagged", (str, type(None))),
("if_has_fewer_sessions_than", int),
("if_has_fewer_tagged_sessions_than", int),
("if_signed_in_with_matching_exam_ticket", bool),
("tag_session", (six.string_types, type(None))),
("if_has_prairietest_exam_access", str),
("tag_session", (str, type(None))),
("may_start_new_session", bool),
("may_list_existing_sessions", bool),
("lock_down_as_exam_session", bool),
......@@ -547,12 +594,20 @@ def validate_session_start_rule(vctx, location, nrule, tags):
"%s, role %d" % (location, j+1),
role)
if hasattr(nrule, "if_has_participation_tags_any"):
for ptag in nrule.if_has_participation_tags_any:
validate_participationtag(vctx, location, ptag)
if hasattr(nrule, "if_has_participation_tags_all"):
for ptag in nrule.if_has_participation_tags_all:
validate_participationtag(vctx, location, ptag)
if hasattr(nrule, "if_in_facility"):
validate_facility(vctx, location, nrule.if_in_facility)
if hasattr(nrule, "if_has_session_tagged"):
if nrule.if_has_session_tagged is not None:
validate_identifier(vctx, "%s: if_has_session_tagged" % location,
validate_identifier(vctx, f"{location}: if_has_session_tagged",
nrule.if_has_session_tagged)
if not hasattr(nrule, "may_start_new_session"):
......@@ -572,7 +627,7 @@ def validate_session_start_rule(vctx, location, nrule, tags):
if hasattr(nrule, "tag_session"):
if nrule.tag_session is not None:
validate_identifier(vctx, "%s: tag_session" % location,
validate_identifier(vctx, f"{location}: tag_session",
nrule.tag_session,
warning_only=True)
......@@ -581,7 +636,7 @@ def validate_session_start_rule(vctx, location, nrule, tags):
string_concat(
"%(location)s: ",
_("invalid tag '%(tag)s'"))
% {'location': location, 'tag': nrule.tag_session})
% {"location": location, "tag": nrule.tag_session})
if hasattr(nrule, "default_expiration_mode"):
from course.constants import FLOW_SESSION_EXPIRATION_MODE_CHOICES
......@@ -591,12 +646,13 @@ def validate_session_start_rule(vctx, location, nrule, tags):
string_concat("%(location)s: ",
_("invalid default expiration mode '%(expiremode)s'"))
% {
'location': location,
'expiremode': nrule.default_expiration_mode})
"location": location,
"expiremode": nrule.default_expiration_mode})
def validate_session_access_rule(vctx, location, arule, tags):
# type: (ValidationContext, Text, Any, List[Text]) -> None
def validate_session_access_rule(
vctx: ValidationContext, location: str, arule: Any, tags: list[str]
) -> None:
validate_struct(
vctx, location, arule,
required_attrs=[
......@@ -607,13 +663,16 @@ def validate_session_access_rule(vctx, location, arule, tags):
("if_before", datespec_types),
("if_started_before", datespec_types),
("if_has_role", list),
("if_has_participation_tags_any", list),
("if_has_participation_tags_all", list),
("if_in_facility", str),
("if_has_tag", (six.string_types, type(None))),
("if_has_tag", (str, type(None))),
("if_in_progress", bool),
("if_completed_before", datespec_types),
("if_expiration_mode", str),
("if_session_duration_shorter_than_minutes", (int, float)),
("if_signed_in_with_matching_exam_ticket", bool),
("if_has_prairietest_exam_access", str),
("message", str),
]
)
......@@ -632,16 +691,29 @@ def validate_session_access_rule(vctx, location, arule, tags):
"%s, role %d" % (location, j+1),
role)
if hasattr(arule, "if_has_participation_tags_any"):
for ptag in arule.if_has_participation_tags_any:
validate_participationtag(vctx, location, ptag)
if hasattr(arule, "if_has_participation_tags_all"):
for ptag in arule.if_has_participation_tags_all:
validate_participationtag(vctx, location, ptag)
if hasattr(arule, "if_in_facility"):
validate_facility(vctx, location, arule.if_in_facility)
if hasattr(arule, "if_has_tag"):
if arule.if_has_tag is not None:
validate_identifier(vctx, f"{location}: if_has_tag",
arule.if_has_tag,
warning_only=True)
if not (arule.if_has_tag is None or arule.if_has_tag in tags):
raise ValidationError(
string_concat(
"%(location)s: ",
_("invalid tag '%(tag)s'"))
% {'location': location, 'tag': arule.if_has_tag})
% {"location": location, "tag": arule.if_has_tag})
if hasattr(arule, "if_expiration_mode"):
if arule.if_expiration_mode not in dict(
......@@ -650,8 +722,8 @@ def validate_session_access_rule(vctx, location, arule, tags):
string_concat("%(location)s: ",
_("invalid expiration mode '%(expiremode)s'"))
% {
'location': location,
'expiremode': arule.if_expiration_mode})
"location": location,
"expiremode": arule.if_expiration_mode})
for j, perm in enumerate(arule.permissions):
validate_flow_permission(
......@@ -671,13 +743,12 @@ def validate_session_access_rule(vctx, location, arule, tags):
def validate_session_grading_rule(
vctx, # type: ValidationContext
location, # type: Text
grule, # type: Any
tags, # type: List[Text]
grade_identifier, # type: Optional[Text]
):
# type: (...) -> bool
vctx: ValidationContext,
location: str,
grule: Any,
tags: list[str],
grade_identifier: str | None,
) -> bool:
"""
:returns: whether the rule only applies conditionally
......@@ -689,7 +760,9 @@ def validate_session_grading_rule(
],
allowed_attrs=[
("if_has_role", list),
("if_has_tag", (six.string_types, type(None))),
("if_has_participation_tags_any", list),
("if_has_participation_tags_all", list),
("if_has_tag", (str, type(None))),
("if_started_before", datespec_types),
("if_completed_before", datespec_types),
......@@ -729,6 +802,10 @@ def validate_session_grading_rule(
has_conditionals = False
if hasattr(grule, "if_started_before"):
vctx.encounter_datespec(location, grule.if_started_before)
has_conditionals = True
if hasattr(grule, "if_completed_before"):
vctx.encounter_datespec(location, grule.if_completed_before)
has_conditionals = True
......@@ -741,13 +818,28 @@ def validate_session_grading_rule(
role)
has_conditionals = True
if hasattr(grule, "if_has_participation_tags_any"):
for ptag in grule.if_has_participation_tags_any:
validate_participationtag(vctx, location, ptag)
has_conditionals = True
if hasattr(grule, "if_has_participation_tags_all"):
for ptag in grule.if_has_participation_tags_all:
validate_participationtag(vctx, location, ptag)
has_conditionals = True
if hasattr(grule, "if_has_tag"):
if grule.if_has_tag is not None:
validate_identifier(vctx, f"{location}: if_has_tag",
grule.if_has_tag,
warning_only=True)
if not (grule.if_has_tag is None or grule.if_has_tag in tags):
raise ValidationError(
string_concat(
"%(location)s: ",
_("invalid tag '%(tag)s'"))
% {'location': location, 'tag': grule.if_has_tag})
% {"location": location, "tag": grule.if_has_tag})
has_conditionals = True
if hasattr(grule, "due"):
......@@ -784,13 +876,20 @@ def validate_flow_rules(vctx, location, rules):
)
if not hasattr(rules, "grade_identifier"):
error_msg = _("'rules' block does not have a grade_identifier "
"attribute.")
# for backward compatibility
if hasattr(rules, "grading"):
if hasattr(rules.grading, "grade_identifier"):
error_msg = string_concat(
error_msg,
_(" This attribute needs to be moved out of "
"the lower-level 'grading' rules block and into "
"the 'rules' block itself."))
raise ValidationError(
string_concat("%(location)s: ",
_("'rules' block does not have a grade_identifier "
"attribute. This attribute needs to be moved out of "
"the lower-level 'grading' rules block and into "
"the 'rules' block itself."))
% {'location': location})
string_concat("%(location)s: ", error_msg)
% {"location": location})
tags = getattr(rules, "tags", [])
......@@ -820,7 +919,7 @@ def validate_flow_rules(vctx, location, rules):
# {{{ grade_id
if rules.grade_identifier:
validate_identifier(vctx, "%s: grade_identifier" % location,
validate_identifier(vctx, f"{location}: grade_identifier",
rules.grade_identifier)
if not hasattr(rules, "grade_aggregation_strategy"):
raise ValidationError(
......@@ -830,18 +929,18 @@ def validate_flow_rules(vctx, location, rules):
"must have grading rules with a "
"grade_aggregation_strategy"))
% {
'location': location,
'identifier': rules.grade_identifier})
"location": location,
"identifier": rules.grade_identifier})
from course.constants import GRADE_AGGREGATION_STRATEGY_CHOICES
if (
hasattr(rules, "grade_aggregation_strategy")
and
rules.grade_aggregation_strategy not in
dict(GRADE_AGGREGATION_STRATEGY_CHOICES)):
and rules.grade_aggregation_strategy
not in dict(GRADE_AGGREGATION_STRATEGY_CHOICES)):
raise ValidationError(
string_concat("%s: ",
_("invalid grade aggregation strategy"))
_("invalid grade aggregation strategy"),
f": {rules.grade_aggregation_strategy}")
% location)
# }}}
......@@ -854,7 +953,7 @@ def validate_flow_rules(vctx, location, rules):
string_concat("%(location)s: ",
_("'grading' block is required if grade_identifier "
"is not null/None."))
% {'location': location})
% {"location": location})
else:
has_conditionals = None
......@@ -885,9 +984,8 @@ def validate_flow_rules(vctx, location, rules):
# }}}
def validate_flow_permission(vctx, location, permission):
# type: (ValidationContext, Text, Text) -> None
def validate_flow_permission(
vctx: ValidationContext, location: str, permission: str) -> None:
from course.constants import FLOW_PERMISSION_CHOICES
if permission == "modify":
vctx.add_warning(location, _("Uses deprecated 'modify' permission--"
......@@ -904,33 +1002,33 @@ def validate_flow_permission(vctx, location, permission):
raise ValidationError(
string_concat("%(location)s: ",
_("invalid flow permission '%(permission)s'"))
% {'location': location, 'permission': permission})
% {"location": location, "permission": permission})
# }}}
def validate_flow_desc(vctx, location, flow_desc):
validate_struct(
vctx,
location,
flow_desc,
required_attrs=[
("title", str),
("description", "markup"),
],
allowed_attrs=[
("completion_text", "markup"),
("rules", Struct),
("groups", list),
("pages", list),
("notify_on_submit", list),
# deprecated (moved to grading rule)
("max_points", (int, float)),
("max_points_enforced_cap", (int, float)),
("bonus_points", (int, float)),
]
)
vctx,
location,
flow_desc,
required_attrs=[
("title", str),
("description", "markup"),
],
allowed_attrs=[
("completion_text", "markup"),
("rules", Struct),
("groups", list),
("pages", list),
("notify_on_submit", list),
("external_resources", list),
# deprecated (moved to grading rule)
("max_points", (int, float)),
("max_points_enforced_cap", (int, float)),
("bonus_points", (int, float)),
],
)
if hasattr(flow_desc, "rules"):
validate_flow_rules(vctx, location, flow_desc.rules)
......@@ -939,12 +1037,11 @@ def validate_flow_desc(vctx, location, flow_desc):
if (
(not hasattr(flow_desc, "groups") and not hasattr(flow_desc, "pages"))
or
(hasattr(flow_desc, "groups") and hasattr(flow_desc, "pages"))):
or (hasattr(flow_desc, "groups") and hasattr(flow_desc, "pages"))):
raise ValidationError(
string_concat("%(location)s: ",
_("must have either 'groups' or 'pages'"))
% {'location': location})
% {"location": location})
# }}}
......@@ -955,24 +1052,18 @@ def validate_flow_desc(vctx, location, flow_desc):
assert not hasattr(flow_desc, "pages")
assert hasattr(flow_desc, "groups")
for i, grp in enumerate(flow_desc.groups):
validate_flow_group(vctx, "%s, group %d ('%s')"
% (location, i+1, getattr(grp, "id", "<unknown id>")),
grp)
# {{{ check for non-emptiness
flow_has_page = False
for i, grp in enumerate(flow_desc.groups):
group_has_page = False
if not isinstance(grp.pages, list):
raise ValidationError(
string_concat(
"%(location)s, ",
_("group %(group_index)d ('%(group_id)s'): "
"'pages' is not a list"))
% {
'location': location,
'group_index': i+1,
'group_id': grp.id})
for page in grp.pages:
for _page in grp.pages:
group_has_page = flow_has_page = True
break
......@@ -983,9 +1074,9 @@ def validate_flow_desc(vctx, location, flow_desc):
_("group %(group_index)d ('%(group_id)s'): "
"no pages found"))
% {
'location': location,
'group_index': i+1,
'group_id': grp.id})
"location": location,
"group_index": i+1,
"group_id": grp.id})
if not flow_has_page:
raise ValidationError(_("%s: no pages found")
......@@ -1002,24 +1093,19 @@ def validate_flow_desc(vctx, location, flow_desc):
raise ValidationError(
string_concat("%(location)s: ",
_("group id '%(group_id)s' not unique"))
% {'location': location, 'group_id': grp.id})
% {"location": location, "group_id": grp.id})
group_ids.add(grp.id)
# }}}
for i, grp in enumerate(flow_desc.groups):
validate_flow_group(vctx, "%s, group %d ('%s')"
% (location, i+1, grp.id),
grp)
validate_markup(vctx, location, flow_desc.description)
if hasattr(flow_desc, "completion_text"):
validate_markup(vctx, location, flow_desc.completion_text)
if hasattr(flow_desc, "notify_on_submit"):
for i, item in enumerate(flow_desc.notify_on_submit):
if not isinstance(item, six.string_types):
if not isinstance(item, str):
raise ValidationError(
string_concat(
"%s, ",
......@@ -1057,7 +1143,7 @@ def validate_calendar_desc_struct(vctx, location, events_desc):
validate_struct(
vctx,
"%s, event kind '%s'" % (location, event_kind_name),
f"{location}, event kind '{event_kind_name}'",
event_kind,
required_attrs=[
],
......@@ -1073,7 +1159,7 @@ def validate_calendar_desc_struct(vctx, location, events_desc):
validate_struct(
vctx,
"%s, event '%s'" % (location, event_name),
f"{location}, event '{event_name}'",
event_desc,
required_attrs=[
],
......@@ -1101,21 +1187,21 @@ def get_yaml_from_repo_safely(repo, full_name, commit_sha):
return get_yaml_from_repo(
repo=repo, full_name=full_name, commit_sha=commit_sha,
cached=False)
except:
except Exception:
from traceback import print_exc
print_exc()
tp, e, _ = sys.exc_info()
raise ValidationError(
"%(fullname)s: %(err_type)s: %(err_str)s" % {
'fullname': full_name,
"err_type": tp.__name__,
"err_str": six.text_type(e)})
f"{full_name}: {tp.__name__}: {e!s}")
def check_attributes_yml(vctx, repo, path, tree, access_kinds):
# type: (ValidationContext, Repo_ish, Text, Any, List[Text]) -> None
def check_attributes_yml(
vctx: ValidationContext,
repo: Repo_ish,
path: str, tree: Any,
access_kinds: list[str]) -> None:
"""
This function reads the .attributes.yml file and checks
that each item for each header is a string
......@@ -1141,15 +1227,20 @@ def check_attributes_yml(vctx, repo, path, tree, access_kinds):
# {{{ analyze attributes file
try:
dummy, attr_blob_sha = tree[ATTRIBUTES_FILENAME.encode()]
_dummy, attr_blob_sha = tree[ATTRIBUTES_FILENAME.encode()]
except KeyError:
# no .attributes.yml here
pass
except ValueError:
# the path root only contains a directory
pass
else:
from yaml import safe_load as load_yaml
from relate.utils import dict_to_struct
from yaml import load as load_yaml
att_yml = dict_to_struct(load_yaml(true_repo[attr_blob_sha].data))
yaml_data = load_yaml(true_repo[attr_blob_sha].data) # type: ignore
att_yml = dict_to_struct(yaml_data)
if path:
loc = path + "/" + ATTRIBUTES_FILENAME
......@@ -1173,8 +1264,8 @@ def check_attributes_yml(vctx, repo, path, tree, access_kinds):
for access_kind in access_kinds:
if hasattr(att_yml, access_kind):
for i, l in enumerate(getattr(att_yml, access_kind)):
if not isinstance(l, six.string_types):
for i, ln in enumerate(getattr(att_yml, access_kind)):
if not isinstance(ln, str):
raise ValidationError(
"%s: entry %d in '%s' is not a string"
% (loc, i+1, access_kind))
......@@ -1183,12 +1274,15 @@ def check_attributes_yml(vctx, repo, path, tree, access_kinds):
# {{{ analyze gitignore
gitignore_lines = [] # type: List[Text]
gitignore_lines: list[str] = []
try:
dummy, gitignore_sha = tree[b".gitignore"]
_dummy, gitignore_sha = tree[b".gitignore"]
except KeyError:
# no .attributes.yml here
# no .gitignore here
pass
except ValueError:
# the path root only contains a directory
pass
else:
gitignore_lines = true_repo[gitignore_sha].data.decode("utf-8").split("\n")
......@@ -1209,7 +1303,7 @@ def check_attributes_yml(vctx, repo, path, tree, access_kinds):
subpath = entry_name
if stat.S_ISDIR(entry.mode):
dummy, blob_sha = tree[entry.path]
_dummy, blob_sha = tree[entry.path]
subtree = true_repo[blob_sha]
check_attributes_yml(vctx, true_repo, subpath, subtree, access_kinds)
......@@ -1253,8 +1347,8 @@ def check_for_page_type_changes(vctx, location, course, flow_id, flow_desc):
n_flow_desc = normalize_flow_desc(flow_desc)
from course.models import FlowPageData
for grp in n_flow_desc.groups:
for page_desc in grp.pages:
for grp in n_flow_desc.groups: # pragma: no branch
for page_desc in grp.pages: # pragma: no branch
fpd_with_mismatched_page_types = list(
FlowPageData.objects
.filter(
......@@ -1271,7 +1365,9 @@ def check_for_page_type_changes(vctx, location, course, flow_id, flow_desc):
raise ValidationError(
_("%(loc)s, group '%(group)s', page '%(page)s': "
"page type ('%(type_new)s') differs from "
"type used in database ('%(type_old)s')")
"type used in database ('%(type_old)s'). "
"You must change the question ID if you change the "
"question type.")
% {"loc": location, "group": grp.id,
"page": page_desc.id,
"type_new": page_desc.type,
......@@ -1280,6 +1376,36 @@ def check_for_page_type_changes(vctx, location, course, flow_id, flow_desc):
# }}}
def validate_flow_id(vctx: ValidationContext, location: str, flow_id: str) -> None:
from course.constants import FLOW_ID_REGEX
match = re.match("^" + FLOW_ID_REGEX + "$", flow_id)
if match is None:
raise ValidationError(
string_concat("%s: ",
_("invalid flow name. "
"Flow names may only contain (roman) "
"letters, numbers, "
"dashes and underscores."))
% location)
def validate_static_page_name(
vctx: ValidationContext, location: str, page_name: str) -> None:
from course.constants import STATICPAGE_PATH_REGEX
match = re.match("^" + STATICPAGE_PATH_REGEX + "$", page_name)
if match is None:
raise ValidationError(
string_concat("%s: ",
_(
"invalid page name. "
"Page names may only contain "
"alphanumeric characters (any language) "
"and hyphens."
))
% location)
def validate_course_content(repo, course_file, events_file,
validate_sha, course=None):
vctx = ValidationContext(
......@@ -1311,8 +1437,9 @@ def validate_course_content(repo, course_file, events_file,
if vctx.course is not None:
from course.models import (
ParticipationPermission,
ParticipationRolePermission)
ParticipationPermission,
ParticipationRolePermission,
)
access_kinds = frozenset(
ParticipationPermission.objects
.filter(
......@@ -1326,23 +1453,27 @@ def validate_course_content(repo, course_file, events_file,
permission=pperm.access_files_for,
)
.values_list("argument", flat=True))
access_kinds = frozenset(k for k in access_kinds if k is not None)
else:
access_kinds = ["public", "in_exam", "student", "ta",
"unenrolled", "instructor"]
access_kinds = DEFAULT_ACCESS_KINDS
from course.content import get_repo_tree
check_attributes_yml(
vctx, repo, "",
get_repo_blob(repo, "", validate_sha),
get_repo_tree(repo, "", validate_sha),
access_kinds)
try:
flows_tree = get_repo_blob(repo, "media", validate_sha)
get_repo_tree(repo, "media", validate_sha)
except ObjectDoesNotExist:
# That's great--no media directory.
pass
else:
vctx.add_warning(
'media/', _(
"media/", _(
"Your course repository has a 'media/' directory. "
"Linking to media files using 'media:' is discouraged. "
"Use the 'repo:' and 'repocur:' linkng schemes instead."))
......@@ -1350,7 +1481,7 @@ def validate_course_content(repo, course_file, events_file,
# {{{ flows
try:
flows_tree = get_repo_blob(repo, "flows", validate_sha)
flows_tree = get_repo_tree(repo, "flows", validate_sha)
except ObjectDoesNotExist:
# That's OK--no flows yet.
pass
......@@ -1362,19 +1493,11 @@ def validate_course_content(repo, course_file, events_file,
if not entry_path.endswith(".yml"):
continue
from course.constants import FLOW_ID_REGEX
flow_id = entry_path[:-4]
match = re.match("^"+FLOW_ID_REGEX+"$", flow_id)
if match is None:
raise ValidationError(
string_concat("%s: ",
_("invalid flow name. "
"Flow names may only contain (roman) "
"letters, numbers, "
"dashes and underscores."))
% entry_path)
location = entry_path
validate_flow_id(vctx, location, flow_id)
location = "flows/%s" % entry_path
location = f"flows/{entry_path}"
flow_desc = get_yaml_from_repo_safely(repo, location,
commit_sha=validate_sha)
......@@ -1389,8 +1512,7 @@ def validate_course_content(repo, course_file, events_file,
if (
flow_grade_identifier is not None
and
set([flow_grade_identifier]) & used_grade_identifiers):
and {flow_grade_identifier} & used_grade_identifiers):
raise ValidationError(
string_concat("%s: ",
_("flow uses the same grade_identifier "
......@@ -1412,6 +1534,8 @@ def validate_course_content(repo, course_file, events_file,
# }}}
from course.content import get_repo_blob
# {{{ static pages
try:
......@@ -1425,25 +1549,15 @@ def validate_course_content(repo, course_file, events_file,
if not entry_path.endswith(".yml"):
continue
from course.constants import STATICPAGE_PATH_REGEX
page_name = entry_path[:-4]
match = re.match("^"+STATICPAGE_PATH_REGEX+"$", page_name)
if match is None:
raise ValidationError(
string_concat("%s: ",
_(
"invalid page name. "
"Page names may only contain "
"alphanumeric characters (any language) "
"and hyphens."
))
% entry_path)
location = entry_path
validate_static_page_name(vctx, location, page_name)
location = "staticpages/%s" % entry_path
page_desc = get_yaml_from_repo_safely(repo, location,
commit_sha=validate_sha)
location = f"staticpages/{entry_path}"
page_desc = get_yaml_from_repo_safely(repo, location,
commit_sha=validate_sha)
validate_staticpage_desc(vctx, location, page_desc)
validate_staticpage_desc(vctx, location, page_desc)
# }}}
......@@ -1452,10 +1566,10 @@ def validate_course_content(repo, course_file, events_file,
# {{{ validation script support
class FileSystemFakeRepo(object):
class FileSystemFakeRepo: # pragma: no cover
def __init__(self, root):
self.root = root
assert isinstance(self.root, six.binary_type)
assert isinstance(self.root, bytes)
def controldir(self):
return self.root
......@@ -1464,7 +1578,7 @@ class FileSystemFakeRepo(object):
return sha
def __str__(self):
return "<FAKEREPO:%s>" % self.root
return f"<FAKEREPO:{self.root}>"
def decode(self):
return self
......@@ -1474,34 +1588,37 @@ class FileSystemFakeRepo(object):
return FileSystemFakeRepoTree(self.root)
class FileSystemFakeRepoTreeEntry(object):
def __init__(self, path, mode):
class FileSystemFakeRepoTreeEntry: # pragma: no cover
def __init__(self, path: bytes, mode: int) -> None:
self.path = path
self.mode = mode
class FileSystemFakeRepoTree(object):
class FileSystemFakeRepoTree: # pragma: no cover
def __init__(self, root):
self.root = root
assert isinstance(self.root, six.binary_type)
assert isinstance(self.root, bytes)
def __getitem__(self, name):
if not name:
raise KeyError("<empty filename>")
from os.path import join, isdir, exists
from os.path import exists, join
name = join(self.root, name)
if not exists(name):
raise KeyError(name)
from os import stat
from stat import S_ISDIR
stat_result = stat(name)
# returns mode, "sha"
if isdir(name):
return None, FileSystemFakeRepoTree(name)
if S_ISDIR(stat_result.st_mode):
return stat_result.st_mode, FileSystemFakeRepoTree(name)
else:
return None, FileSystemFakeRepoFile(name)
return stat_result.st_mode, FileSystemFakeRepoFile(name)
def items(self):
def items(self) -> list[FileSystemFakeRepoTreeEntry]:
import os
return [
FileSystemFakeRepoTreeEntry(
......@@ -1510,7 +1627,7 @@ class FileSystemFakeRepoTree(object):
for n in os.listdir(self.root)]
class FileSystemFakeRepoFile(object):
class FileSystemFakeRepoFile: # pragma: no cover
def __init__(self, name):
self.name = name
......@@ -1520,8 +1637,12 @@ class FileSystemFakeRepoFile(object):
return inf.read()
Blob_ish = dulwich.objects.Blob | FileSystemFakeRepoFile
Tree_ish = dulwich.objects.Tree | FileSystemFakeRepoTree
def validate_course_on_filesystem(
root, course_file, events_file):
root, course_file, events_file): # pragma: no cover
fake_repo = FileSystemFakeRepo(root.encode("utf-8"))
warnings = validate_course_content(
fake_repo,
......
# -*- coding: utf-8 -*-
from __future__ import annotations
from __future__ import division
__copyright__ = "Copyright (C) 2014 Andreas Kloeckner"
__copyright__ = """
Copyright (C) 2014 Andreas Kloeckner
Copyright (c) 2016 Polyconseil SAS. (the WSGI wrapping bits)
Copyright (C) 2019 Isuru Fernando
"""
__license__ = """
Permission is hereby granted, free of charge, to any person obtaining a copy
......@@ -24,157 +27,109 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
"""
import six
import re
from typing import (
TYPE_CHECKING,
Any,
cast,
)
from django.shortcuts import ( # noqa
render, get_object_or_404, redirect)
from django.contrib import messages
import django.forms as forms
import dulwich.client
import paramiko
import paramiko.client
from crispy_forms.layout import Submit
from django import http
from django.contrib import messages
from django.contrib.auth.decorators import login_required, permission_required
from django.core.exceptions import PermissionDenied, SuspiciousOperation
from django.db import transaction
from django.shortcuts import get_object_or_404, redirect, render # noqa
from django.urls import reverse
from django.utils.translation import (
ugettext_lazy as _,
ugettext,
pgettext,
pgettext_lazy,
string_concat,
)
gettext,
gettext_lazy as _,
pgettext,
pgettext_lazy,
)
from django.views.decorators.csrf import csrf_exempt
from django_select2.forms import Select2Widget
from bootstrap3_datetime.widgets import DateTimePicker
from django.db import transaction
from relate.utils import StyledForm, StyledModelForm
from crispy_forms.layout import Submit
from course.models import (
Course,
Participation,
ParticipationRole)
from course.utils import course_view, render_course_page
import paramiko
import paramiko.client
from dulwich.repo import Repo
import dulwich.client # noqa
from course.constants import (
participation_status,
participation_permission as pperm,
)
from course.auth import with_course_api_auth
from course.constants import participation_permission as pperm, participation_status
from course.models import Course, Participation, ParticipationRole
from course.utils import (
course_view,
get_course_specific_language_choices,
render_course_page,
)
from relate.utils import (
HTML5DateInput,
Repo_ish,
StyledForm,
StyledModelForm,
string_concat,
)
# {{{ for mypy
from django import http # noqa
from typing import Tuple, List, Text, Any # noqa
from dulwich.client import GitClient # noqa
if TYPE_CHECKING:
import dulwich.web
from dulwich.client import GitClient # noqa
from dulwich.objects import Commit
# }}}
from course.auth import APIContext
class AutoAcceptPolicy(paramiko.client.MissingHostKeyPolicy):
def missing_host_key(self, client, hostname, key):
# simply accept the key
return
# }}}
def _remove_prefix(prefix, s):
# type: (bytes, bytes) -> bytes
def _remove_prefix(prefix: bytes, s: bytes) -> bytes:
assert s.startswith(prefix)
return s[len(prefix):]
def transfer_remote_refs(repo, remote_refs):
# type: (Repo, Dict[bytes, Text]) -> None
def transfer_remote_refs(
repo: Repo_ish, fetch_pack_result: dulwich.client.FetchPackResult) -> None:
valid_refs = []
if remote_refs is not None:
for ref, sha in six.iteritems(remote_refs):
if (ref.startswith(b"refs/heads/")
and not ref.startswith(b"refs/heads/origin/")):
new_ref = b"refs/remotes/origin/"+_remove_prefix(b"refs/heads/", ref)
valid_refs.append(new_ref)
repo[new_ref] = sha
for ref, sha in fetch_pack_result.refs.items():
if (ref.startswith(b"refs/heads/")
and not ref.startswith(b"refs/heads/origin/")):
new_ref = b"refs/remotes/origin/"+_remove_prefix(b"refs/heads/", ref)
valid_refs.append(new_ref)
repo[new_ref] = sha
for ref in repo.get_refs().keys():
if ref.startswith(b"refs/remotes/origin/") and ref not in valid_refs:
del repo[ref]
class DulwichParamikoSSHVendor(object):
def __init__(self, ssh_kwargs):
self.ssh_kwargs = ssh_kwargs
def run_command(self, host, command, username=None, port=None,
progress_stderr=None):
if not isinstance(command, bytes):
raise TypeError(command)
if port is None:
port = 22
client = paramiko.SSHClient()
client.set_missing_host_key_policy(AutoAcceptPolicy())
client.connect(host, username=username, port=port,
**self.ssh_kwargs)
channel = client.get_transport().open_session()
channel.exec_command(command)
def progress_stderr(s):
import sys
sys.stderr.write(s.decode("utf-8"))
sys.stderr.flush()
try:
from dulwich.client import ParamikoWrapper
except ImportError:
from dulwich.contrib.paramiko_vendor import (
_ParamikoWrapper as ParamikoWrapper)
return ParamikoWrapper(
client, channel, progress_stderr=progress_stderr)
def get_dulwich_client_and_remote_path_from_course(course):
# type: (Course) -> Tuple[dulwich.client.GitClient, bytes]
def get_dulwich_client_and_remote_path_from_course(
course: Course) -> tuple[
dulwich.client.GitClient | dulwich.client.SSHGitClient, str]:
ssh_kwargs = {}
if course.ssh_private_key:
from six import StringIO
from io import StringIO
key_file = StringIO(course.ssh_private_key)
ssh_kwargs["pkey"] = paramiko.RSAKey.from_private_key(key_file)
def get_dulwich_ssh_vendor():
vendor = DulwichParamikoSSHVendor(ssh_kwargs)
from dulwich.contrib.paramiko_vendor import ParamikoSSHVendor
vendor = ParamikoSSHVendor(**ssh_kwargs)
return vendor
# writing to another module's global variable: gross!
dulwich.client.get_ssh_vendor = get_dulwich_ssh_vendor
dulwich.client.get_ssh_vendor = get_dulwich_ssh_vendor # type: ignore[assignment]
from dulwich.client import get_transport_and_path
client, remote_path = get_transport_and_path(
course.git_source)
try:
# Work around
# https://bugs.launchpad.net/dulwich/+bug/1025886
client._fetch_capabilities.remove('thin-pack')
except KeyError:
pass
except AttributeError:
pass
if not isinstance(client, dulwich.client.LocalGitClient):
# LocalGitClient uses Py3 Unicode path names to refer to
# paths, so it doesn't want an encoded path.
remote_path = remote_path.encode("utf-8")
return client, remote_path
......@@ -196,34 +151,29 @@ class CourseCreationForm(StyledModelForm):
"course_file",
"events_file",
"enrollment_approval_required",
"preapproval_require_verified_inst_id",
"enrollment_required_email_suffix",
"from_email",
"notify_email",
"force_lang",
)
widgets = {
"start_date": DateTimePicker(options={"format": "YYYY-MM-DD"}),
"end_date": DateTimePicker(options={"format": "YYYY-MM-DD"}),
"start_date": HTML5DateInput(),
"end_date": HTML5DateInput(),
"force_lang": forms.Select(
choices=get_course_specific_language_choices()),
}
def __init__(self, *args, **kwargs):
# type: (*Any, **Any) -> None
def __init__(self, *args: Any, **kwargs: Any) -> None:
super(CourseCreationForm, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
self.helper.add_input(
Submit("submit", _("Validate and create")))
def clean_git_source(self):
if not self.cleaned_data["git_source"]:
from django.forms import ValidationError as FormValidationError
raise FormValidationError(_("Git source must be specified"))
return self.cleaned_data["git_source"]
@permission_required("course.add_course")
def set_up_new_course(request):
# type: (http.HttpRequest) -> http.HttpResponse
def set_up_new_course(request: http.HttpRequest) -> http.HttpResponse:
if request.method == "POST":
form = CourseCreationForm(request.POST)
......@@ -247,22 +197,23 @@ def set_up_new_course(request):
get_dulwich_client_and_remote_path_from_course(
new_course)
remote_refs = client.fetch(remote_path, repo)
if remote_refs is None:
fetch_pack_result = client.fetch(remote_path, repo)
if not fetch_pack_result.refs:
raise RuntimeError(_("No refs found in remote repository"
" (i.e. no master branch, no HEAD). "
"This looks very much like a blank repository. "
"Please create course.yml in the remote "
"repository before creating your course."))
transfer_remote_refs(repo, remote_refs)
new_sha = repo[b"HEAD"] = remote_refs[b"HEAD"]
transfer_remote_refs(repo, fetch_pack_result)
new_sha = repo[b"HEAD"] = fetch_pack_result.refs[b"HEAD"]
vrepo = repo
if new_course.course_root_path:
from course.content import SubdirRepoWrapper
vrepo = SubdirRepoWrapper(
vrepo, new_course.course_root_path)
vrepo: Repo_ish = SubdirRepoWrapper(
repo, new_course.course_root_path)
else:
vrepo = repo
from course.validation import validate_course_content
validate_course_content( # type: ignore
......@@ -276,6 +227,8 @@ def set_up_new_course(request):
# {{{ set up a participation for the course creator
assert request.user.is_authenticated
part = Participation()
part.user = request.user
part.course = new_course
......@@ -294,35 +247,31 @@ def set_up_new_course(request):
messages.add_message(request, messages.INFO,
_("Course content validated, creation "
"succeeded."))
except:
except Exception as e:
# Don't coalesce this handler with the one below. We only want
# to delete the directory if we created it. Trust me.
# Work around read-only files on Windows.
# https://docs.python.org/3.5/library/shutil.html#rmtree-example
import os
import stat
import shutil
# Make sure files opened for 'repo' above are actually closed.
if repo is not None: # noqa
repo.close() # noqa
if repo is not None:
repo.close()
def remove_readonly(func, path, _): # noqa
"Clear the readonly bit and reattempt the removal"
os.chmod(path, stat.S_IWRITE)
func(path)
from relate.utils import force_remove_path
try:
shutil.rmtree(repo_path, onerror=remove_readonly)
force_remove_path(repo_path)
except OSError:
messages.add_message(request, messages.WARNING,
ugettext("Failed to delete unused "
gettext("Failed to delete unused "
"repository directory '%s'.")
% repo_path)
raise
# We don't raise the OSError thrown by force_remove_path
# This is to ensure correct error msg for PY2.
raise e
else:
assert repo is not None
repo.close()
except Exception as e:
from traceback import print_exc
......@@ -352,12 +301,15 @@ def set_up_new_course(request):
# {{{ update
def is_parent_commit(repo, potential_parent, child, max_history_check_size=None):
def is_ancestor_commit(
repo: Repo, potential_ancestor: Commit, child: Commit,
max_history_check_size: int | None = None) -> bool:
queue = [repo[parent] for parent in child.parents]
while queue:
entry = queue.pop()
if entry == potential_parent:
if entry == potential_ancestor:
return True
if max_history_check_size is not None:
......@@ -371,28 +323,38 @@ def is_parent_commit(repo, potential_parent, child, max_history_check_size=None)
return False
ALLOWED_COURSE_REVISIOIN_COMMANDS = [
"fetch", "fetch_update", "update", "fetch_preview",
"preview", "end_preview"]
def run_course_update_command(
request, repo, content_repo, pctx, command, new_sha, may_update,
prevent_discarding_revisions):
if command not in ALLOWED_COURSE_REVISIOIN_COMMANDS:
raise RuntimeError(_("invalid command"))
if command.startswith("fetch"):
if command != "fetch":
command = command[6:]
if not pctx.course.git_source:
raise RuntimeError(_("no git source URL specified"))
client, remote_path = \
get_dulwich_client_and_remote_path_from_course(pctx.course)
remote_refs = client.fetch(remote_path, repo)
transfer_remote_refs(repo, remote_refs)
remote_head = remote_refs[b"HEAD"]
if (
prevent_discarding_revisions
and
is_parent_commit(repo, repo[remote_head], repo[b"HEAD"],
max_history_check_size=20)):
raise RuntimeError(_("fetch would discard commits, refusing"))
fetch_pack_result = client.fetch(remote_path, repo)
assert isinstance(fetch_pack_result, dulwich.client.FetchPackResult)
transfer_remote_refs(repo, fetch_pack_result)
remote_head = fetch_pack_result.refs[b"HEAD"]
if prevent_discarding_revisions:
# Guard against bad scenario:
# Local is not ancestor of remote, i.e. the branches have diverged.
if not is_ancestor_commit(repo, repo[b"HEAD"], repo[remote_head],
max_history_check_size=20) and \
repo[b"HEAD"] != repo[remote_head]:
raise RuntimeError(_("internal git repo has more commits. Fetch, "
"merge and push."))
repo[b"HEAD"] = remote_head
......@@ -414,14 +376,14 @@ def run_course_update_command(
# {{{ validate
from course.validation import validate_course_content, ValidationError
from course.validation import ValidationError, validate_course_content
try:
warnings = validate_course_content(
content_repo, pctx.course.course_file, pctx.course.events_file,
new_sha, course=pctx.course)
except ValidationError as e:
messages.add_message(request, messages.ERROR,
_("Course content did not validate successfully. (%s) "
_("Course content did not validate successfully: '%s' "
"Update not applied.") % str(e))
return
......@@ -435,8 +397,7 @@ def run_course_update_command(
_("Course content validated OK, with warnings: "),
"<ul>%s</ul>")
% ("".join(
"<li><i>%(location)s</i>: %(warningtext)s</li>"
% {'location': w.location, 'warningtext': w.text}
f"<li><i>{w.location}</i>: {w.text}</li>"
for w in warnings)))
# }}}
......@@ -448,7 +409,7 @@ def run_course_update_command(
pctx.participation.preview_git_commit_sha = new_sha.decode()
pctx.participation.save()
elif command == "update" and may_update:
elif command == "update" and may_update: # pragma: no branch
pctx.course.active_git_commit_sha = new_sha.decode()
pctx.course.save()
......@@ -462,20 +423,17 @@ def run_course_update_command(
messages.add_message(request, messages.SUCCESS,
_("Update applied. "))
else:
raise RuntimeError(_("invalid command"))
class GitUpdateForm(StyledForm):
def __init__(self, may_update, previewing, repo, *args, **kwargs):
super(GitUpdateForm, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
repo_refs = repo.get_refs()
commit_iter = repo.get_walker(list(repo_refs.values()))
def format_commit(commit):
return "%s - %s" % (
return "{} - {}".format(
commit.id[:8].decode(),
"".join(
commit.message
......@@ -488,14 +446,13 @@ class GitUpdateForm(StyledForm):
self.fields["new_sha"] = forms.ChoiceField(
choices=([
(repo_refs[ref],
"[%s] %s" % (
(repo_refs[ref].decode(),
"[{}] {}".format(
ref.decode("utf-8", errors="replace"),
format_sha(repo_refs[ref])))
for ref in repo_refs
] +
[
(entry.commit.id, format_commit(entry.commit))
] + [
(entry.commit.id.decode(), format_commit(entry.commit))
for entry in commit_iter
]),
required=True,
......@@ -526,12 +483,9 @@ class GitUpdateForm(StyledForm):
def _get_commit_message_as_html(repo, commit_sha):
if six.PY2:
from cgi import escape
else:
from html import escape
from html import escape
if isinstance(commit_sha, six.text_type):
if isinstance(commit_sha, str):
commit_sha = commit_sha.encode()
try:
......@@ -547,8 +501,7 @@ def _get_commit_message_as_html(repo, commit_sha):
def update_course(pctx):
if not (
pctx.has_permission(pperm.update_content)
or
pctx.has_permission(pperm.preview_content)):
or pctx.has_permission(pperm.preview_content)):
raise PermissionDenied()
course = pctx.course
......@@ -569,14 +522,13 @@ def update_course(pctx):
may_update = pctx.has_permission(pperm.update_content)
response_form = None
form = None
if request.method == "POST":
form = GitUpdateForm(may_update, previewing, repo, request.POST,
request.FILES)
commands = ["fetch", "fetch_update", "update", "fetch_preview",
"preview", "end_preview"]
command = None
for cmd in commands:
for cmd in ALLOWED_COURSE_REVISIOIN_COMMANDS:
if cmd in form.data:
command = cmd
break
......@@ -613,66 +565,137 @@ def update_course(pctx):
form = GitUpdateForm(may_update, previewing, repo,
{
"new_sha": repo.head(),
"new_sha": repo.head().decode(),
"prevent_discarding_revisions": True,
})
text_lines = [
"<table class='table'>",
string_concat(
"<tr><th>",
ugettext("Git Source URL"),
"</th><td><tt>%(git_source)s</tt></td></tr>")
% {'git_source': pctx.course.git_source},
string_concat(
"<tr><th>",
ugettext("Public active git SHA"),
"</th><td> %(commit)s (%(message)s)</td></tr>")
% {
'commit': course.active_git_commit_sha,
'message': _get_commit_message_as_html(
repo, course.active_git_commit_sha)
},
string_concat(
"<tr><th>",
ugettext("Current git HEAD"),
"</th><td>%(commit)s (%(message)s)</td></tr>")
% {
'commit': repo.head().decode(),
'message': _get_commit_message_as_html(repo, repo.head())},
]
if participation is not None and participation.preview_git_commit_sha:
text_lines.append(
string_concat(
"<tr><th>",
ugettext("Current preview git SHA"),
"</th><td>%(commit)s (%(message)s)</td></tr>")
% {
'commit': participation.preview_git_commit_sha,
'message': _get_commit_message_as_html(
repo, participation.preview_git_commit_sha),
})
else:
text_lines.append(
"".join([
"<tr><th>",
ugettext("Current preview git SHA"),
"</th><td>",
ugettext("None"),
"</td></tr>",
]))
from django.template.loader import render_to_string
form_text = render_to_string(
"course/git-sha-table.html", {
"participation": participation,
"is_previewing": previewing,
"course": course,
"repo": repo,
"current_git_head": repo.head().decode(),
"git_url": request.build_absolute_uri(
reverse("relate-git_endpoint",
args=(course.identifier, ""))),
"token_url": reverse("relate-manage_authentication_tokens",
args=(course.identifier,)),
})
text_lines.append("</table>")
assert form is not None
return render_course_page(pctx, "course/generic-course-form.html", {
"form": form,
"form_text": "".join(
"<p>%s</p>" % line
for line in text_lines
),
"form_description": ugettext("Update Course Revision"),
"form_text": form_text,
"form_description": gettext("Update Course Revision"),
})
# }}}
# {{{ git endpoint
# {{{ wsgi wrapping
# Nabbed from
# https://github.com/Polyconseil/django-viewsgi/blob/master/viewsgi.py
# (BSD-licensed)
def call_wsgi_app(
application: dulwich.web.LimitedInputFilter,
request: http.HttpRequest,
prefix: str,
) -> http.HttpResponse:
response = http.HttpResponse()
# request.environ and request.META are the same object, so changes
# to the headers by middlewares will be seen here.
assert request.environ is request.META # type: ignore[attr-defined]
environ = request.environ.copy() # type: ignore[attr-defined]
# if len(args) > 0:
assert environ["PATH_INFO"].startswith(prefix)
environ["SCRIPT_NAME"] += prefix
environ["PATH_INFO"] = environ["PATH_INFO"][len(prefix):]
headers_set: list[tuple[str, str]] = []
headers_sent: list[bool] = []
def write(data: str) -> None:
if not headers_set:
raise AssertionError("write() called before start_response()")
if not headers_sent:
# Send headers before the first output.
for k, v in headers_set:
response[k] = v
headers_sent[:] = [True]
response.write(data)
# We could call response.flush() here, but is actually a no-op.
def start_response(status, headers, exc_info=None):
# Let Django handle all errors.
if exc_info:
raise exc_info[1].with_traceback(exc_info[2])
if headers_set:
raise AssertionError("start_response() called again "
"without exc_info")
response.status_code = int(status.split(" ", 1)[0])
headers_set[:] = headers
# Django provides no way to set the reason phrase (#12747).
return write
result = application(environ, start_response)
try:
for data in result:
if data:
write(data)
if not headers_sent:
write("")
finally:
if hasattr(result, "close"):
result.close()
return response
# }}}
GIT_AUTH_DATA_RE = re.compile(r"^(\w+):([0-9]+)_([a-z0-9]+)$")
@csrf_exempt
@with_course_api_auth("Basic")
def git_endpoint(api_ctx: APIContext, course_identifier: str,
git_path: str) -> http.HttpResponse:
course = api_ctx.course
request = api_ctx.request
if not api_ctx.has_permission(pperm.use_git_endpoint):
raise PermissionDenied("insufficient privileges")
from course.content import get_course_repo
repo = get_course_repo(course)
from course.content import SubdirRepoWrapper
if isinstance(repo, SubdirRepoWrapper):
true_repo = repo.repo
else:
true_repo = cast(dulwich.repo.Repo, repo)
base_path = reverse(git_endpoint, args=(course_identifier, ""))
assert base_path.endswith("/")
base_path = base_path[:-1]
import dulwich.web as dweb
backend = dweb.DictBackend({"/": true_repo})
app = dweb.make_wsgi_chain(backend)
return call_wsgi_app(app, request, base_path)
# }}}
# vim: foldmethod=marker
# -*- coding: utf-8 -*-
from __future__ import annotations
from __future__ import division
__copyright__ = "Copyright (C) 2014 Andreas Kloeckner"
......@@ -25,79 +24,85 @@ THE SOFTWARE.
"""
import datetime
from typing import (
TYPE_CHECKING,
Any,
cast,
)
from django.shortcuts import ( # noqa
render, get_object_or_404, redirect)
from django.contrib import messages # noqa
from django.core.exceptions import (
PermissionDenied, ObjectDoesNotExist, SuspiciousOperation)
import django.forms as forms
import django.views.decorators.http as http_dec
from crispy_forms.layout import Div, Layout, Submit
from django import http
from django.utils.safestring import mark_safe
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.exceptions import (
ObjectDoesNotExist,
PermissionDenied,
SuspiciousOperation,
)
from django.db import transaction
from django.utils import six
from django.shortcuts import get_object_or_404, redirect, render
from django.utils.safestring import mark_safe
from django.utils.translation import (
ugettext_lazy as _,
ugettext,
string_concat,
pgettext,
pgettext_lazy,
)
from django.utils.functional import lazy
from django.contrib.auth.decorators import login_required
from django_select2.forms import Select2Widget
mark_safe_lazy = lazy(mark_safe, six.text_type)
gettext as _,
pgettext,
pgettext_lazy,
)
from django.views.decorators.cache import cache_control
from django_select2.forms import Select2Widget
from crispy_forms.layout import Submit, Layout, Div
from relate.utils import StyledForm, StyledModelForm
from bootstrap3_datetime.widgets import DateTimePicker
from course.enrollment import (
get_participation_for_request,
get_participation_permissions)
from course.auth import get_pre_impersonation_user
from course.constants import (
participation_permission as pperm,
participation_status,
FLOW_PERMISSION_CHOICES,
)
from course.models import (
Course,
InstantFlowRequest,
Participation,
FlowSession,
FlowRuleException)
FLOW_PERMISSION_CHOICES,
FLOW_RULE_KIND_CHOICES,
flow_rule_kind,
participation_permission as pperm,
participation_status,
)
from course.content import get_course_repo
from course.enrollment import (
get_participation_for_request,
get_participation_permissions,
)
from course.models import (
Course,
FlowRuleException,
FlowSession,
InstantFlowRequest,
Participation,
)
from course.utils import (
CoursePageContext,
course_view,
get_course_specific_language_choices,
render_course_page,
)
from relate.utils import (
HTML5DateInput,
HTML5DateTimeInput,
RelateHttpRequest,
StyledForm,
StyledModelForm,
string_concat,
)
from course.utils import ( # noqa
course_view,
render_course_page,
CoursePageContext)
# {{{ for mypy
from typing import Tuple, List, Text, Optional, Any, Iterable # noqa
from course.content import ( # noqa
FlowDesc,
)
if TYPE_CHECKING:
from accounts.models import User
from course.content import FlowDesc
# }}}
NONE_SESSION_TAG = "<<<NONE>>>" # noqa
NONE_SESSION_TAG = string_concat("<<<", _("NONE"), ">>>")
# {{{ home
def home(request):
# type: (http.HttpRequest) -> http.HttpResponse
def home(request: http.HttpRequest) -> http.HttpResponse:
now_datetime = get_now_or_fake_time(request)
current_courses = []
......@@ -111,9 +116,6 @@ def home(request):
if (pperm.view_hidden_course_page, None) not in perms:
show = False
if not course.listed:
show = False
if show:
if (course.end_date is None
or now_datetime.date() <= course.end_date):
......@@ -143,8 +145,8 @@ def home(request):
# {{{ pages
def check_course_state(course, participation):
# type: (Course, Optional[Participation]) -> None
def check_course_state(
course: Course, participation: Participation | None) -> None:
"""
Check to see if the course is hidden.
......@@ -158,9 +160,8 @@ def check_course_state(course, participation):
@course_view
def course_page(pctx):
# type: (CoursePageContext) -> http.HttpResponse
from course.content import get_processed_page_chunks, get_course_desc
def course_page(pctx: CoursePageContext) -> http.HttpResponse:
from course.content import get_course_desc, get_processed_page_chunks
page_desc = get_course_desc(pctx.repo, pctx.course, pctx.course_commit_sha)
chunks = get_processed_page_chunks(
......@@ -216,9 +217,8 @@ def course_page(pctx):
@course_view
def static_page(pctx, page_path):
# type: (CoursePageContext, Text) -> http.HttpResponse
from course.content import get_staticpage_desc, get_processed_page_chunks
def static_page(pctx: CoursePageContext, page_path: str) -> http.HttpResponse:
from course.content import get_processed_page_chunks, get_staticpage_desc
try:
page_desc = get_staticpage_desc(pctx.repo, pctx.course,
pctx.course_commit_sha, "staticpages/"+page_path+".yml")
......@@ -249,8 +249,9 @@ def media_etag_func(request, course_identifier, commit_sha, media_path):
def get_media(request, course_identifier, commit_sha, media_path):
course = get_object_or_404(Course, identifier=course_identifier)
repo = get_course_repo(course)
return get_repo_file_response(repo, "media/" + media_path, commit_sha.encode())
with get_course_repo(course) as repo:
return get_repo_file_response(
repo, "media/" + media_path, commit_sha.encode())
def repo_file_etag_func(request, course_identifier, commit_sha, path):
......@@ -270,8 +271,8 @@ def get_repo_file(request, course_identifier, commit_sha, path):
request, course, participation, commit_sha, path)
def current_repo_file_etag_func(request, course_identifier, path):
# type: (http.HttpRequest, str, str) -> str
def current_repo_file_etag_func(
request: http.HttpRequest, course_identifier: str, path: str) -> str:
course = get_object_or_404(Course, identifier=course_identifier)
participation = get_participation_for_request(request, course)
......@@ -284,9 +285,9 @@ def current_repo_file_etag_func(request, course_identifier, path):
@http_dec.condition(etag_func=current_repo_file_etag_func)
def get_current_repo_file(request, course_identifier, path):
# type: (http.HttpRequest, str, str) -> http.HttpResponse
def get_current_repo_file(
request: http.HttpRequest, course_identifier: str, path: str
) -> http.HttpResponse:
course = get_object_or_404(Course, identifier=course_identifier)
participation = get_participation_for_request(request, course)
......@@ -298,13 +299,12 @@ def get_current_repo_file(request, course_identifier, path):
def get_repo_file_backend(
request, # type: http.HttpRequest
course, # type: Course
participation, # type: Optional[Participation]
commit_sha, # type: bytes
path, # type: str
):
# type: (...) -> http.HttpResponse # noqa
request: http.HttpRequest,
course: Course,
participation: Participation | None,
commit_sha: bytes,
path: str,
) -> http.HttpResponse:
"""
Check if a file should be accessible. Then call for it if
the permission is not denied.
......@@ -314,12 +314,11 @@ def get_repo_file_backend(
Note: an access_role of "public" is equal to "unenrolled"
"""
request = cast(RelateHttpRequest, request)
# check to see if the course is hidden
check_course_state(course, participation)
# retrieve local path for the repo for the course
repo = get_course_repo(course)
# set access to public (or unenrolled), student, etc
if request.relate_exam_lockdown:
access_kinds = ["in_exam"]
......@@ -332,14 +331,18 @@ def get_repo_file_backend(
and arg is not None]
from course.content import is_repo_file_accessible_as
if not is_repo_file_accessible_as(access_kinds, repo, commit_sha, path):
raise PermissionDenied()
return get_repo_file_response(repo, path, commit_sha)
# retrieve local path for the repo for the course
with get_course_repo(course) as repo:
if not is_repo_file_accessible_as(access_kinds, repo, commit_sha, path):
raise PermissionDenied()
return get_repo_file_response(repo, path, commit_sha)
def get_repo_file_response(repo, path, commit_sha):
# type: (Any, str, bytes) -> http.HttpResponse
def get_repo_file_response(
repo: Any, path: str, commit_sha: bytes
) -> http.HttpResponse:
from course.content import get_repo_blob_data_cached
......@@ -349,7 +352,7 @@ def get_repo_file_response(repo, path, commit_sha):
raise http.Http404()
from mimetypes import guess_type
content_type, _ = guess_type(path)
content_type, __ = guess_type(path)
if content_type is None:
content_type = "application/octet-stream"
......@@ -363,12 +366,11 @@ def get_repo_file_response(repo, path, commit_sha):
class FakeTimeForm(StyledForm):
time = forms.DateTimeField(
widget=DateTimePicker(
options={"format": "YYYY-MM-DD HH:mm", "sideBySide": True}),
label=_('Time'))
widget=HTML5DateTimeInput(),
label=_("Time"))
def __init__(self, *args, **kwargs):
super(FakeTimeForm, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
self.helper.add_input(
# Translators: "set" fake time.
......@@ -378,23 +380,19 @@ class FakeTimeForm(StyledForm):
Submit("unset", _("Unset")))
def get_fake_time(request):
# type: (http.HttpRequest) -> Optional[datetime.datetime]
def get_fake_time(request: http.HttpRequest | None) -> datetime.datetime | None:
if request is not None and "relate_fake_time" in request.session:
from zoneinfo import ZoneInfo
from django.conf import settings
from pytz import timezone
tz = timezone(settings.TIME_ZONE)
return tz.localize(
datetime.datetime.fromtimestamp(
request.session["relate_fake_time"]))
tz = ZoneInfo(settings.TIME_ZONE)
return datetime.datetime.fromtimestamp(
request.session["relate_fake_time"], tz=tz)
else:
return None
def get_now_or_fake_time(request):
# type: (http.HttpRequest) -> datetime.datetime
def get_now_or_fake_time(request: http.HttpRequest | None) -> datetime.datetime:
fake_time = get_fake_time(request)
if fake_time is None:
from django.utils.timezone import now
......@@ -403,18 +401,26 @@ def get_now_or_fake_time(request):
return fake_time
def set_fake_time(request):
def may_set_fake_time(user: User | None) -> bool:
# allow staff to set fake time when impersonating
def is_original_user_staff(request):
is_impersonating = hasattr(
request, "relate_impersonate_original_user")
if is_impersonating:
return request.relate_impersonate_original_user.is_staff
if user is None:
return False
if not (request.user.is_staff or is_original_user_staff(request)):
raise PermissionDenied(_("only staff may set fake time"))
return Participation.objects.filter(
user=user,
roles__permissions__permission=pperm.set_fake_time
).count() > 0
@login_required
def set_fake_time(request):
# allow staff to set fake time when impersonating
pre_imp_user = get_pre_impersonation_user(request)
if not (
may_set_fake_time(request.user) or (
pre_imp_user is not None
and may_set_fake_time(pre_imp_user))):
raise PermissionDenied(_("may not set fake time"))
if request.method == "POST":
form = FakeTimeForm(request.POST, request.FILES)
......@@ -454,7 +460,7 @@ def fake_time_context_processor(request):
class FakeFacilityForm(StyledForm):
def __init__(self, *args, **kwargs):
super(FakeFacilityForm, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
from course.utils import get_facilities_config
self.fields["facilities"] = forms.MultipleChoiceField(
......@@ -488,9 +494,26 @@ class FakeFacilityForm(StyledForm):
Submit("unset", _("Unset")))
def may_set_pretend_facility(user: User | None) -> bool:
if user is None:
return False
return Participation.objects.filter(
user=user,
roles__permissions__permission=pperm.set_pretend_facility
).count() > 0
@login_required
def set_pretend_facilities(request):
if not request.user.is_staff:
raise PermissionDenied(_("only staff may set fake facility"))
# allow staff to set fake time when impersonating
pre_imp_user = get_pre_impersonation_user(request)
if not (
may_set_pretend_facility(request.user) or (
pre_imp_user is not None
and may_set_pretend_facility(pre_imp_user))):
raise PermissionDenied(_("may not pretend facilities"))
if request.method == "POST":
form = FakeFacilityForm(request.POST)
......@@ -543,7 +566,7 @@ def pretend_facilities_context_processor(request):
class InstantFlowRequestForm(StyledForm):
def __init__(self, flow_ids, *args, **kwargs):
super(InstantFlowRequestForm, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
self.fields["flow_id"] = forms.ChoiceField(
choices=[(fid, fid) for fid in flow_ids],
......@@ -598,7 +621,8 @@ def manage_instant_flow_requests(pctx):
minutes=form.cleaned_data["duration_in_minutes"]))
ifr.save()
elif op == "cancel":
else:
assert op == "cancel"
(InstantFlowRequest.objects
.filter(
course=pctx.course,
......@@ -607,8 +631,6 @@ def manage_instant_flow_requests(pctx):
cancelled=False)
.order_by("start_time")
.update(cancelled=True))
else:
raise SuspiciousOperation(_("invalid operation"))
else:
form = InstantFlowRequestForm(flow_ids)
......@@ -625,7 +647,7 @@ def manage_instant_flow_requests(pctx):
class FlowTestForm(StyledForm):
def __init__(self, flow_ids, *args, **kwargs):
super(FlowTestForm, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
self.fields["flow_id"] = forms.ChoiceField(
choices=[(fid, fid) for fid in flow_ids],
......@@ -636,7 +658,7 @@ class FlowTestForm(StyledForm):
self.helper.add_input(
Submit(
"test",
mark_safe_lazy(
mark_safe(
string_concat(
pgettext("Start an activity", "Go"),
" &raquo;")),
......@@ -679,16 +701,12 @@ class ParticipationChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj):
user = obj.user
return (
"%(user_email)s - %(user_fullname)s"
% {
"user_email": user.email,
"user_fullname": user.get_full_name()
})
f"{user.email} - {user.get_full_name()}")
class ExceptionStage1Form(StyledForm):
def __init__(self, course, flow_ids, *args, **kwargs):
super(ExceptionStage1Form, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
self.fields["participation"] = ParticipationChoiceField(
queryset=(Participation.objects
......@@ -710,7 +728,7 @@ class ExceptionStage1Form(StyledForm):
self.helper.add_input(
Submit(
"next",
mark_safe_lazy(
mark_safe(
string_concat(
pgettext("Next step", "Next"),
" &raquo;"))))
......@@ -743,26 +761,28 @@ def grant_exception(pctx):
})
def strify_session_for_exception(session):
# type: (FlowSession) -> str
def strify_session_for_exception(session: FlowSession) -> str:
from relate.utils import as_local_time, format_datetime_local
# Translators: %s is the string of the start time of a session.
result = (_("started at %s") % format_datetime_local(
as_local_time(session.start_time)))
if session.access_rules_tag:
result += " tagged '%s'" % session.access_rules_tag
result += _(" tagged '%s'") % session.access_rules_tag
return result
class CreateSessionForm(StyledForm):
def __init__(self, session_tag_choices, default_tag, create_session_is_override,
*args, **kwargs):
# type: (List[Tuple[Text, Text]], Optional[Text], bool, *Any, **Any) -> None
super(CreateSessionForm, self).__init__(*args, **kwargs)
def __init__(
self,
session_tag_choices: list[tuple[str, str]],
default_tag: str | None,
create_session_is_override: bool,
*args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self.fields["access_rules_tag_for_new_session"] = forms.ChoiceField(
choices=session_tag_choices,
......@@ -784,10 +804,9 @@ class CreateSessionForm(StyledForm):
class ExceptionStage2Form(StyledForm):
def __init__(self, sessions, *args, **kwargs):
# type: (List[FlowSession], *Any, **Any) -> None
super(ExceptionStage2Form, self).__init__(*args, **kwargs)
def __init__(
self, sessions: list[FlowSession], *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self.fields["session"] = forms.ChoiceField(
choices=(
......@@ -801,15 +820,16 @@ class ExceptionStage2Form(StyledForm):
self.helper.add_input(
Submit(
"next",
mark_safe_lazy(
mark_safe(
string_concat(
pgettext("Next step", "Next"),
" &raquo;"))))
@course_view
def grant_exception_stage_2(pctx, participation_id, flow_id):
# type: (CoursePageContext, Text, Text) -> http.HttpResponse
def grant_exception_stage_2(
pctx: CoursePageContext, participation_id: str, flow_id: str
) -> http.HttpResponse:
if not pctx.has_permission(pperm.grant_exception):
raise PermissionDenied(_("may not grant exceptions"))
......@@ -820,13 +840,13 @@ def grant_exception_stage_2(pctx, participation_id, flow_id):
form_text = (
string_concat(
"<div class='well'>",
ugettext("Granting exception to '%(participation)s' for "
"<div class='relate-well'>",
_("Granting exception to '%(participation)s' for "
"'%(flow_id)s'."),
"</div>")
% {
'participation': participation,
'flow_id': flow_id})
"participation": participation,
"flow_id": flow_id})
from course.content import get_flow_desc
try:
......@@ -842,12 +862,6 @@ def grant_exception_stage_2(pctx, participation_id, flow_id):
else:
access_rules_tags = []
NONE_SESSION_TAG = string_concat("<<<", _("NONE"), ">>>") # noqa
session_tag_choices = [
(tag, tag)
for tag in access_rules_tags] + [(NONE_SESSION_TAG,
string_concat("(", _("NONE"), ")"))]
from course.utils import get_session_start_rule
session_start_rule = get_session_start_rule(pctx.course, participation,
flow_id, flow_desc, now_datetime)
......@@ -855,21 +869,26 @@ def grant_exception_stage_2(pctx, participation_id, flow_id):
create_session_is_override = False
if not session_start_rule.may_start_new_session:
create_session_is_override = True
form_text += ("<div class='alert alert-info'>%s</div>" % (
string_concat(
"<i class='fa fa-info-circle'></i> ",
form_text += ("<div class='alert alert-info'>{}</div>".format(string_concat(
"<i class='bi bi-info-circle'></i> ",
_("Creating a new session is (technically) not allowed "
"by course rules. Clicking 'Create Session' anyway will "
"override this rule."))))
session_tag_choices = [
(tag, tag)
for tag in access_rules_tags] + [(NONE_SESSION_TAG, NONE_SESSION_TAG)]
default_tag = session_start_rule.tag_session
if default_tag is None:
default_tag = NONE_SESSION_TAG
else:
if default_tag not in access_rules_tags:
session_tag_choices.insert(0, (default_tag, default_tag))
# }}}
def find_sessions():
# type: () -> List[FlowSession]
def find_sessions() -> list[FlowSession]:
return list(FlowSession.objects
.filter(
......@@ -899,14 +918,28 @@ def grant_exception_stage_2(pctx, participation_id, flow_id):
if access_rules_tag == NONE_SESSION_TAG:
access_rules_tag = None
start_flow(pctx.repo, pctx.course, participation,
new_session = start_flow(pctx.repo, pctx.course, participation,
user=participation.user,
flow_id=flow_id,
flow_desc=flow_desc,
session_start_rule=session_start_rule,
now_datetime=now_datetime)
if access_rules_tag is not None:
new_session.access_rules_tag = access_rules_tag
new_session.save()
exception_form = None
messages.add_message(
pctx.request, messages.SUCCESS,
_("A new session%(tag)s was created for '%(participation)s' "
"for '%(flow_id)s'.")
% {
"tag":
_(" tagged '%s'") % access_rules_tag
if access_rules_tag is not None else "",
"participation": participation,
"flow_id": flow_id})
elif exception_form.is_valid() and "next" in request.POST: # type: ignore
return redirect(
......@@ -930,9 +963,13 @@ def grant_exception_stage_2(pctx, participation_id, flow_id):
class ExceptionStage3Form(StyledForm):
def __init__(self, default_data, flow_desc, base_session_tag, *args, **kwargs):
# type: (Dict, FlowDesc, str, *Any, **Any) -> None
super(ExceptionStage3Form, self).__init__(*args, **kwargs)
def __init__(
self,
default_data: dict,
flow_desc: FlowDesc,
base_session_tag: str | None,
*args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
rules = getattr(flow_desc, "rules", object())
tags = getattr(rules, "tags", [])
......@@ -940,9 +977,12 @@ class ExceptionStage3Form(StyledForm):
layout = []
if tags:
tags = [NONE_SESSION_TAG] + tags
tags = [NONE_SESSION_TAG, *tags]
if base_session_tag is not None and base_session_tag not in tags:
tags.append(base_session_tag)
self.fields["set_access_rules_tag"] = forms.ChoiceField(
[(tag, tag) for tag in tags],
choices=[(tag, tag) for tag in tags],
initial=(base_session_tag
if base_session_tag is not None
else NONE_SESSION_TAG),
......@@ -955,7 +995,7 @@ class ExceptionStage3Form(StyledForm):
layout.append(
Div("set_access_rules_tag", "restrict_to_same_tag",
css_class="well"))
css_class="relate-well"))
access_fields = ["create_access_exception", "access_expires"]
......@@ -965,9 +1005,7 @@ class ExceptionStage3Form(StyledForm):
label=_("Create access rule exception"))
self.fields["access_expires"] = forms.DateTimeField(
widget=DateTimePicker(
options={"format": "YYYY-MM-DD HH:mm", "sideBySide": True,
"showClear": True}),
widget=HTML5DateTimeInput(),
required=False,
label=pgettext_lazy("Time when access expires", "Access expires"),
help_text=_("At the specified time, the special access granted below "
......@@ -985,20 +1023,19 @@ class ExceptionStage3Form(StyledForm):
access_fields.append(key)
layout.append(Div(*access_fields, css_class="well"))
layout.append(Div(*access_fields, css_class="relate-well"))
self.fields["create_grading_exception"] = forms.BooleanField(
required=False, help_text=_("If set, an exception for the "
"grading rules will be created."), initial=True,
label=_("Create grading rule exception"))
self.fields["due_same_as_access_expiration"] = forms.BooleanField(
required=False, help_text=_("If set, the 'Due' field will be "
required=False, help_text=_("If set, the 'Due time' field will be "
"disregarded."),
initial=default_data.get("due_same_as_access_expiration") or False,
label=_("Due same as access expiration"))
self.fields["due"] = forms.DateTimeField(
widget=DateTimePicker(
options={"format": "YYYY-MM-DD HH:mm", "sideBySide": True}),
widget=HTML5DateTimeInput(),
required=False,
help_text=_("The due time shown to the student. Also, the "
"time after which "
......@@ -1006,6 +1043,9 @@ class ExceptionStage3Form(StyledForm):
initial=default_data.get("due"),
label=_("Due time"))
self.fields["generates_grade"] = forms.BooleanField(required=False,
initial=default_data.get("generates_grade", True),
label=_("Generates grade"))
self.fields["credit_percent"] = forms.FloatField(required=False,
initial=default_data.get("credit_percent"),
label=_("Credit percent"))
......@@ -1021,9 +1061,10 @@ class ExceptionStage3Form(StyledForm):
layout.append(Div("create_grading_exception",
"due_same_as_access_expiration", "due",
"generates_grade",
"credit_percent", "bonus_points", "max_points",
"max_points_enforced_cap",
css_class="well"))
css_class="relate-well"))
self.fields["comment"] = forms.CharField(
widget=forms.Textarea, required=True,
......@@ -1039,21 +1080,26 @@ class ExceptionStage3Form(StyledForm):
self.helper.layout = Layout(*layout)
def clean(self):
if (self.cleaned_data["access_expires"] is None
and self.cleaned_data["due_same_as_access_expiration"]):
from django.core.exceptions import ValidationError
raise ValidationError(
_("Must specify access expiration if 'due same "
"as access expiration' is set."))
access_expires = self.cleaned_data.get("access_expires")
due_same_as_access_expiration = self.cleaned_data.get(
"due_same_as_access_expiration")
if (not access_expires and due_same_as_access_expiration):
self.add_error(
"access_expires",
_("Must specify access expiration if 'due same "
"as access expiration' is set."))
@course_view
@transaction.atomic
def grant_exception_stage_3(pctx, participation_id, flow_id, session_id):
# type: (CoursePageContext, int, Text, int) -> http.HttpResponse
def grant_exception_stage_3(
pctx: CoursePageContext,
participation_id: int,
flow_id: str,
session_id: int) -> http.HttpResponse:
if not pctx.has_permission(pperm.grant_exception):
raise PermissionDenied(_("may not grant exceptions"))
assert pctx.request.user.is_authenticated
participation = get_object_or_404(Participation, id=participation_id)
......@@ -1067,9 +1113,7 @@ def grant_exception_stage_3(pctx, participation_id, flow_id, session_id):
session = FlowSession.objects.get(id=int(session_id))
now_datetime = get_now_or_fake_time(pctx.request)
from course.utils import (
get_session_access_rule,
get_session_grading_rule)
from course.utils import get_session_access_rule, get_session_grading_rule
access_rule = get_session_access_rule(session, flow_desc, now_datetime)
grading_rule = get_session_grading_rule(session, flow_desc, now_datetime)
......@@ -1078,18 +1122,17 @@ def grant_exception_stage_3(pctx, participation_id, flow_id, session_id):
form = ExceptionStage3Form(
{}, flow_desc, session.access_rules_tag, request.POST)
from course.constants import flow_rule_kind
if form.is_valid():
permissions = [
key
for key, _ in FLOW_PERMISSION_CHOICES
for key, __ in FLOW_PERMISSION_CHOICES
if form.cleaned_data[key]]
from course.validation import (
validate_session_access_rule,
validate_session_grading_rule,
ValidationContext)
ValidationContext,
validate_session_access_rule,
validate_session_grading_rule,
)
from relate.utils import dict_to_struct
vctx = ValidationContext(
repo=pctx.repo,
......@@ -1098,21 +1141,27 @@ def grant_exception_stage_3(pctx, participation_id, flow_id, session_id):
flow_desc = get_flow_desc(pctx.repo,
pctx.course,
flow_id, pctx.course_commit_sha)
tags = None
tags: list[str] = []
if hasattr(flow_desc, "rules"):
tags = getattr(flow_desc.rules, "tags", None)
tags = cast(list[str], getattr(flow_desc.rules, "tags", []))
exceptions_created = []
restricted_to_same_tag = bool(
form.cleaned_data.get("restrict_to_same_tag")
and session.access_rules_tag is not None)
# {{{ put together access rule
if form.cleaned_data["create_access_exception"]:
new_access_rule = {"permissions": permissions}
new_access_rule: dict[str, Any] = {"permissions": permissions}
if (form.cleaned_data.get("restrict_to_same_tag")
and session.access_rules_tag is not None):
if restricted_to_same_tag:
new_access_rule["if_has_tag"] = session.access_rules_tag
validate_session_access_rule(
vctx, ugettext("newly created exception"),
vctx, _("newly created exception"),
dict_to_struct(new_access_rule), tags)
fre_access = FlowRuleException(
......@@ -1124,16 +1173,30 @@ def grant_exception_stage_3(pctx, participation_id, flow_id, session_id):
kind=flow_rule_kind.access,
rule=new_access_rule)
fre_access.save()
exceptions_created.append(
dict(FLOW_RULE_KIND_CHOICES)[fre_access.kind])
# }}}
new_access_rules_tag = form.cleaned_data.get("set_access_rules_tag")
if new_access_rules_tag == NONE_SESSION_TAG:
new_access_rules_tag = None
session_access_rules_tag_changed = False
if not restricted_to_same_tag:
new_access_rules_tag = form.cleaned_data.get("set_access_rules_tag")
if new_access_rules_tag == NONE_SESSION_TAG:
new_access_rules_tag = None
if session.access_rules_tag != new_access_rules_tag:
session.access_rules_tag = new_access_rules_tag
session.save()
session_access_rules_tag_changed = True
if session.access_rules_tag != new_access_rules_tag:
session.access_rules_tag = new_access_rules_tag
session.save()
if new_access_rules_tag is not None:
msg = _("Access rules tag of the selected session "
"updated to '%s'.") % new_access_rules_tag
else:
msg = _(
"Removed access rules tag of the selected session.")
messages.add_message(pctx.request, messages.SUCCESS, msg)
# {{{ put together grading rule
......@@ -1142,9 +1205,9 @@ def grant_exception_stage_3(pctx, participation_id, flow_id, session_id):
if form.cleaned_data["due_same_as_access_expiration"]:
due = form.cleaned_data["access_expires"]
descr = ugettext("Granted excecption")
descr = _("Granted exception")
if form.cleaned_data["credit_percent"] is not None:
descr += string_concat(" (%.1f%% ", ugettext('credit'), ")") \
descr += string_concat(" (%.1f%% ", _("credit"), ")") \
% form.cleaned_data["credit_percent"]
due_local_naive = due
......@@ -1154,29 +1217,21 @@ def grant_exception_stage_3(pctx, participation_id, flow_id, session_id):
as_local_time(due_local_naive)
.replace(tzinfo=None))
new_grading_rule = {
"description": descr,
}
new_grading_rule: dict[str, Any] = {"description": descr}
if due_local_naive is not None:
new_grading_rule["due"] = due_local_naive
new_grading_rule["if_completed_before"] = due_local_naive
for attr_name in ["credit_percent", "bonus_points",
"max_points", "max_points_enforced_cap"]:
"max_points", "max_points_enforced_cap", "generates_grade"]:
if form.cleaned_data[attr_name] is not None:
new_grading_rule[attr_name] = form.cleaned_data[attr_name]
if (form.cleaned_data.get("restrict_to_same_tag")
and session.access_rules_tag is not None):
if restricted_to_same_tag:
new_grading_rule["if_has_tag"] = session.access_rules_tag
if hasattr(grading_rule, "generates_grade"):
new_grading_rule["generates_grade"] = \
grading_rule.generates_grade
validate_session_grading_rule(
vctx, ugettext("newly created exception"),
vctx, _("newly created exception"),
dict_to_struct(new_grading_rule), tags,
grading_rule.grade_identifier)
......@@ -1188,16 +1243,41 @@ def grant_exception_stage_3(pctx, participation_id, flow_id, session_id):
kind=flow_rule_kind.grading,
rule=new_grading_rule)
fre_grading.save()
exceptions_created.append(
dict(FLOW_RULE_KIND_CHOICES)[fre_grading.kind])
# }}}
messages.add_message(pctx.request, messages.SUCCESS,
ugettext(
"Exception granted to '%(participation)s' "
"for '%(flow_id)s'.")
% {
'participation': participation,
'flow_id': flow_id})
if exceptions_created:
for exc in exceptions_created:
messages.add_message(pctx.request, messages.SUCCESS,
_(
"'%(exception_type)s' exception granted to "
"'%(participation)s' for '%(flow_id)s'.")
% {
"exception_type": exc,
"participation": participation,
"flow_id": flow_id})
else:
if session_access_rules_tag_changed:
messages.add_message(
pctx.request, messages.WARNING,
_(
"No other exception granted to the given flow "
"session of '%(participation)s' "
"for '%(flow_id)s'.")
% {
"participation": participation,
"flow_id": flow_id})
else:
messages.add_message(pctx.request, messages.WARNING,
_(
"No exception granted to the given flow "
"session of '%(participation)s' "
"for '%(flow_id)s'.")
% {
"participation": participation,
"flow_id": flow_id})
return redirect(
"relate-grant_exception",
pctx.course.identifier)
......@@ -1205,9 +1285,13 @@ def grant_exception_stage_3(pctx, participation_id, flow_id, session_id):
else:
data = {
"restrict_to_same_tag": session.access_rules_tag is not None,
"credit_percent": grading_rule.credit_percent,
#"due_same_as_access_expiration": True,
# "due_same_as_access_expiration": True,
"due": grading_rule.due,
"generates_grade": grading_rule.generates_grade,
"credit_percent": grading_rule.credit_percent,
"bonus_points": grading_rule.bonus_points,
"max_points": grading_rule.max_points,
"max_points_enforced_cap": grading_rule.max_points_enforced_cap,
}
for perm in access_rule.permissions:
data[perm] = True
......@@ -1216,16 +1300,16 @@ def grant_exception_stage_3(pctx, participation_id, flow_id, session_id):
return render_course_page(pctx, "course/generic-course-form.html", {
"form": form,
"form_description": ugettext("Grant Exception"),
"form_description": _("Grant Exception"),
"form_text": string_concat(
"<div class='well'>",
ugettext("Granting exception to '%(participation)s' "
"<div class='relate-well'>",
_("Granting exception to '%(participation)s' "
"for '%(flow_id)s' (session %(session)s)."),
"</div>")
% {
'participation': participation,
'flow_id': flow_id,
'session': strify_session_for_exception(session)},
"participation": participation,
"flow_id": flow_id,
"session": strify_session_for_exception(session)},
})
# }}}
......@@ -1242,16 +1326,16 @@ def generate_ssh_keypair(request):
key_class = RSAKey
prv = key_class.generate(bits=2048)
import six
prv_bio = six.StringIO()
import io
prv_bio = io.StringIO()
prv.write_private_key(prv_bio)
prv_bio_read = six.StringIO(prv_bio.getvalue())
prv_bio_read = io.StringIO(prv_bio.getvalue())
pub = key_class.from_private_key(prv_bio_read)
pub_bio = six.StringIO()
pub_bio.write("%s %s relate-course-key" % (pub.get_name(), pub.get_base64()))
pub_bio = io.StringIO()
pub_bio.write(f"{pub.get_name()} {pub.get_base64()} relate-course-key")
return render(request, "course/keypair.html", {
"public_key": prv_bio.getvalue(),
......@@ -1263,7 +1347,9 @@ def generate_ssh_keypair(request):
# {{{ celery task monitoring
@login_required
def monitor_task(request, task_id):
from celery import states
from celery.result import AsyncResult
async_res = AsyncResult(task_id)
......@@ -1281,13 +1367,13 @@ def monitor_task(request, task_id):
_("%(current)d out of %(total)d items processed.")
% {"current": current, "total": total})
if async_res.state == "SUCCESS":
if async_res.state == states.SUCCESS:
if (isinstance(async_res.result, dict)
and "message" in async_res.result):
progress_statement = async_res.result["message"]
traceback = None
if request.user.is_staff and async_res.state == "FAILURE":
if request.user.is_staff and async_res.state == states.FAILURE:
traceback = async_res.traceback
return render(request, "course/task-monitor.html", {
......@@ -1304,7 +1390,7 @@ def monitor_task(request, task_id):
class EditCourseForm(StyledModelForm):
def __init__(self, *args, **kwargs):
super(EditCourseForm, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
self.fields["identifier"].disabled = True
self.fields["active_git_commit_sha"].disabled = True
......@@ -1315,10 +1401,13 @@ class EditCourseForm(StyledModelForm):
model = Course
exclude = (
"participants",
"trusted_for_markup",
)
widgets = {
"start_date": DateTimePicker(options={"format": "YYYY-MM-DD"}),
"end_date": DateTimePicker(options={"format": "YYYY-MM-DD"})
"start_date": HTML5DateInput(),
"end_date": HTML5DateInput(),
"force_lang": forms.Select(
choices=get_course_specific_language_choices()),
}
......@@ -1328,19 +1417,34 @@ def edit_course(pctx):
raise PermissionDenied()
request = pctx.request
instance = pctx.course
if request.method == 'POST':
if request.method == "POST":
form = EditCourseForm(request.POST, instance=pctx.course)
if form.is_valid():
form.save()
else:
form = EditCourseForm(instance=pctx.course)
if form.has_changed():
instance = form.save()
messages.add_message(
request, messages.SUCCESS,
_("Successfully updated course settings."))
else:
messages.add_message(
request, messages.INFO,
_("No change was made on the settings."))
return render_course_page(pctx, "course/generic-course-form.html", {
"form_description": _("Edit Course"),
"form": form
})
else:
messages.add_message(request, messages.ERROR,
_("Failed to update course settings."))
form = EditCourseForm(instance=instance)
# Render the page with course.force_lang, in case force_lang was updated
from course.utils import LanguageOverride
with LanguageOverride(instance):
return render_course_page(pctx, "course/generic-course-form.html", {
"form_description": _("Edit Course"),
"form": form
})
# }}}
......
doc_upload_ssh_config
......@@ -3,15 +3,10 @@
# You can set these variables from the command line.
SPHINXOPTS =
SPHINXBUILD = python `which sphinx-build`
SPHINXBUILD = python -m sphinx
PAPER =
BUILDDIR = _build
# User-friendly check for sphinx-build
ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1)
$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/)
endif
# Internal variables.
PAPEROPT_a4 = -D latex_paper_size=a4
PAPEROPT_letter = -D latex_paper_size=letter
......
REST API
========
Data in RELATE can be accessed remotely and programmatically through a REST
API. To access the API, create an API token using functionality the
"Participant" menu.
An HTTP request like the following then suffices to access data::
curl \
-H "Authorization: Token 7_23acf8e6235ff332b186d6bc7848ce3a47c26991" \
https://HOSTNAME/course/rsmp/api/v1/get-flow-sessions?flow_id=quiz-test
.. warning::
RELATE uses plain text tokens for API authentication. Like passwords,
transmitting tokens over plain HTTP is laughably insecure.
DO NOT use RELATE's REST API via plain, unencrypted HTTP.
The following API endpoints exist:
* ``https://HOSTNAME/course/COURSE_IDENTIFIER/api/v1/get-flow-sessions?flow_id=FLOW_ID``
Retrieves all flow sessions in a course for a given flow ID.
* ``https://HOSTNAME/course/COURSE_IDENTIFIER/api/v1/get-flow-session-content?flow_session_id=FSID``
Retrieves all pages with answer and grade data for a given flow session with a numerical
flow session ID ``FSID``. ``FSID`` can be obtained from ``get-flow-sessions``.
To see what data will be returned from these queries, examine the
`API source code <https://github.com/inducer/relate/blob/master/course/api.py>`_.
#! /bin/bash
set -e
# This whole script is being run inside of poetry, so no need to wrap move of
# it in poetry calls.
python -m pip install docutils sphinx
cp local_settings_example.py doc
cd doc
cat > doc_upload_ssh_config <<END
Host doc-upload
User doc
IdentityFile doc_upload_key
IdentitiesOnly yes
Hostname documen.tician.de
StrictHostKeyChecking false
Port 2222
END
make html SPHINXOPTS="-W --keep-going -n"
if test -n "${DOC_UPLOAD_KEY}" && test "$CI_COMMIT_REF_NAME" = "main"; then
echo "${DOC_UPLOAD_KEY}" > doc_upload_key
chmod 0600 doc_upload_key
RSYNC_RSH="ssh -F doc_upload_ssh_config" ./upload-docs.sh || { rm doc_upload_key; exit 1; }
rm doc_upload_key
else
echo "Skipping upload. DOC_UPLOAD_KEY was not provided."
fi
# -*- coding: utf-8 -*-
#
# relate documentation build configuration file, created by
# sphinx-quickstart on Thu Jun 26 18:41:17 2014.
#
# This file is execfile()d with the current directory set to its
# containing dir.
#
# Note that not all possible configuration values are present in this
# autogenerated file.
#
# All configuration values have a default; values that are commented out
# serve to show the default.
from __future__ import annotations
import sys
import os
import sys
from urllib.request import urlopen
_conf_url = \
"https://raw.githubusercontent.com/inducer/sphinxconfig/main/sphinxconfig.py"
with urlopen(_conf_url) as _inf:
exec(compile(_inf.read(), _conf_url, "exec"), globals())
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
sys.path.insert(0, os.path.abspath('..'))
sys.path.insert(0, os.path.abspath(".."))
os.environ["DJANGO_SETTINGS_MODULE"] = "relate.settings"
import django
django.setup()
# -- General configuration ------------------------------------------------
# If your documentation needs a minimal Sphinx version, state it here.
#needs_sphinx = '1.0'
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
'sphinx.ext.autodoc',
]
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
# The suffix of source filenames.
source_suffix = '.rst'
# The encoding of source files.
#source_encoding = 'utf-8-sig'
# The master toctree document.
master_doc = 'index'
# General information about the project.
project = u'RELATE'
copyright = u'2014, Andreas Kloeckner'
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The short X.Y version.
version = '2015.1'
# The full version, including alpha/beta/rc tags.
release = version
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
#language = None
# There are two options for replacing |today|: either, you set today to some
# non-false value, then it is used:
#today = ''
# Else, today_fmt is used as the format for a strftime call.
#today_fmt = '%B %d, %Y'
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
exclude_patterns = ['_build']
# The reST default role (used for this markup: `text`) to use for all
# documents.
#default_role = None
# If true, '()' will be appended to :func: etc. cross-reference text.
#add_function_parentheses = True
# If true, the current module name will be prepended to all description
# unit titles (such as .. function::).
#add_module_names = True
# If true, sectionauthor and moduleauthor directives will be shown in the
# output. They are ignored by default.
#show_authors = False
# The name of the Pygments (syntax highlighting) style to use.
pygments_style = 'sphinx'
# A list of ignored prefixes for module index sorting.
#modindex_common_prefix = []
# If true, keep warnings as "system message" paragraphs in the built documents.
#keep_warnings = False
# -- Options for HTML output ----------------------------------------------
html_theme = "alabaster"
html_theme_options = {
"extra_nav_links": {
"🚀 Github": "https://github.com/inducer/relate",
#"💾 Download Releases": "https://pypi.python.org/pypi/modepy",
}
}
html_sidebars = {
'**': [
'about.html',
'navigation.html',
'relations.html',
'searchbox.html',
]
}
# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
# documentation.
#html_theme_options = {}
# Add any paths that contain custom themes here, relative to this directory.
#html_theme_path = []
# The name for this set of Sphinx documents. If None, it defaults to
# "<project> v<release> documentation".
#html_title = None
# A shorter title for the navigation bar. Default is the same as html_title.
#html_short_title = None
# The name of an image file (relative to this directory) to place at the top
# of the sidebar.
#html_logo = None
# The name of an image file (within the static path) to use as favicon of the
# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
# pixels large.
#html_favicon = None
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']
# Add any extra paths that contain custom files (such as robots.txt or
# .htaccess) here, relative to this directory. These files are copied
# directly to the root of the documentation.
#html_extra_path = []
# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
# using the given strftime format.
#html_last_updated_fmt = '%b %d, %Y'
# If true, SmartyPants will be used to convert quotes and dashes to
# typographically correct entities.
#html_use_smartypants = True
# Custom sidebar templates, maps document names to template names.
#html_sidebars = {}
# Additional templates that should be rendered to pages, maps page names to
# template names.
#html_additional_pages = {}
# If false, no module index is generated.
#html_domain_indices = True
# If false, no index is generated.
#html_use_index = True
# If true, the index is split into individual pages for each letter.
#html_split_index = False
# If true, links to the reST sources are added to the pages.
#html_show_sourcelink = True
# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
#html_show_sphinx = True
# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
#html_show_copyright = True
# If true, an OpenSearch description file will be output, and all pages will
# contain a <link> tag referring to it. The value of this option must be the
# base URL from which the finished HTML is served.
#html_use_opensearch = ''
# This is the file name suffix for HTML files (e.g. ".xhtml").
#html_file_suffix = None
# Output file base name for HTML help builder.
htmlhelp_basename = 'relatedoc'
# -- Options for LaTeX output ---------------------------------------------
latex_elements = {
# The paper size ('letterpaper' or 'a4paper').
#'papersize': 'letterpaper',
# The font size ('10pt', '11pt' or '12pt').
#'pointsize': '10pt',
django.setup()
# Additional stuff for the LaTeX preamble.
#'preamble': '',
intersphinx_mapping = {
"python": ("https://docs.python.org/3/", None),
"numpy": ("https://numpy.org/doc/stable/", None),
"django": (
"https://docs.djangoproject.com/en/dev/",
"https://docs.djangoproject.com/en/dev/_objects/",
),
"sympy": ("https://docs.sympy.org/latest", None),
# https://github.com/dulwich/dulwich/issues/913 (a recurrence)
"dulwich": (
# "https://www.dulwich.io/docs/",
"https://tiker.net/pub/dulwich-docs-stopgap/",
None),
}
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title,
# author, documentclass [howto, manual, or own class]).
latex_documents = [
('index', 'relate.tex', u'RELATE Documentation',
u'Andreas Kloeckner', 'manual'),
]
# The name of an image file (relative to this directory) to place at the top of
# the title page.
#latex_logo = None
# For "manual" documents, if this is true, then toplevel headings are parts,
# not chapters.
#latex_use_parts = False
# If true, show page references after internal links.
#latex_show_pagerefs = False
# If true, show URL addresses after external links.
#latex_show_urls = False
# Documents to append as an appendix to all manuals.
#latex_appendices = []
# If false, no module index is generated.
#latex_domain_indices = True
# -- Options for manual page output ---------------------------------------
copyright = "2014-21, Andreas Kloeckner"
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
('index', 'relate', u'RELATE Documentation',
[u'Andreas Kloeckner'], 1)
]
# If true, show URL addresses after external links.
#man_show_urls = False
# -- Options for Texinfo output -------------------------------------------
# Grouping the document tree into Texinfo files. List of tuples
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
('index', 'relate', u'RELATE Documentation',
u'Andreas Kloeckner', 'relate', 'One line description of project.',
'Miscellaneous'),
]
# Documents to append as an appendix to all manuals.
#texinfo_appendices = []
# If false, no module index is generated.
#texinfo_domain_indices = True
# How to display URL addresses: 'footnote', 'no', or 'inline'.
#texinfo_show_urls = 'footnote'
# If true, do not generate a @detailmenu in the "Top" node's menu.
#texinfo_no_detailmenu = False
version = "2021.1"
release = version
......@@ -26,6 +26,14 @@ sufficient privileges) may be previewing a different version of their choosing.
(namely, so that line endings are represented in the 'UNIX' convention,
as a single newline character).
RELATE maintains a git repository for each course and can fetch from one
external git repository configured in the course page and update its
internal git repository from this external git repository. A user with
sufficient privileges can access this internal git repository by using
``git pull`` and ``git push`` with the HTTPS URL given on the
"Update Course Content" page, RELATE username as the username and RELATE
authentication token as the password.
.. _yaml-files:
YAML
......@@ -105,7 +113,7 @@ literals::
:ref:`markup` does its own Jinja expansion though, so such block literals
*can* use Jinja.
.. comment::
.. ::
(Let's keep this undocumented for now.)
......@@ -130,12 +138,13 @@ export :ref:`markup` to essentially any other markup format under the sun,
including LaTeX, HTML, MediaWiki, Microsoft Word, and many more.
Further, YAML files are quite easy to read and traverse in most programming languages,
facilitating automated coversion. `This example Python script
<https://github.com/inducer/relate/blob/master/contrib/flow-to-worksheet>`_
facilitating automated conversion. `This example Python script
<https://github.com/inducer/relate/blob/main/contrib/flow-to-worksheet>`_
provided as part of RELATE takes a flow and converts it to a paper-based
worksheet. To do so, it makes use of `pypandoc
<https://pypi.python.org/pypi/pypandoc>`_ and `PyYAML <http://pyyaml.org/>`_.
Validation
----------
......@@ -148,15 +157,8 @@ These rules are automatically checked as part of setting a new revision of the
This helps avoid mistakes and ensures that the students always see a working
site.
RELATE validation is also available as a stand-alone script :command:`relate-validate`.
This runs independently of git and the web site on the content developer's
computer and provides validation feedback without having to commit and
upload the content to a RELATE site. This script can be installed by running::
sudo pip install -r requirements.txt
sudo python setup.py install
in the root directory of the RELATE distribution.
See :ref:`cli` for how to use validation from the command line while
developing content.
.. _markup:
......@@ -166,11 +168,11 @@ RELATE markup
All bulk text in RELATE is written in Markdown, with a few extensions.
Here are a few resources on Markdown:
* `The basics <https://help.github.com/articles/markdown-basics/>`_ as
* `The basics <https://help.github.com/articles/markdown-basics/>`__ as
described by Github.com
* `A 10-minute tutorial <http://markdowntutorial.com/>`_
* `John Gruber's original definition <http://daringfireball.net/projects/markdown/>`_
* `Markdown extensions used by RELATE <https://pythonhosted.org/Markdown/extensions/extra.html>`_
* `A 10-minute tutorial <http://markdowntutorial.com/>`__
* `John Gruber's original definition <http://daringfireball.net/projects/markdown/>`__
* `Markdown extensions used by RELATE <https://python-markdown.github.io/extensions/extra/>`__
To allow easy experimentation with markup, RELATE has a "markup sandbox" in
the "Content" menu where the rendered form of any RELATE markup can
......@@ -179,6 +181,35 @@ be previewed.
In addition to standard Markdown, the following extensions are
supported:
Tables
^^^^^^
Using the following syntax::
First Header | Second Header
------------- | -------------
Content Cell | Content Cell
Content Cell | Content Cell
Markdown nested in HTML
^^^^^^^^^^^^^^^^^^^^^^^
Using the following syntax::
<div markdown="1">
This is a *Markdown* Paragraph.
</div>
"Fenced" code blocks
^^^^^^^^^^^^^^^^^^^^
Using the following syntax::
```python
def f(x):
return 5+x
```
Custom URLs
^^^^^^^^^^^
......@@ -282,21 +313,24 @@ in ``$$...$$``::
Symbols and Icons
^^^^^^^^^^^^^^^^^
RELATE includes `FontAwesome <http://fontawesome.io/>`_,
a comprehensive symbol set by Dave Gandy.
Symbols from `that set <http://fontawesome.io/icons/>`_ can be included as follows::
RELATE includes `Bootstrap Icons <https://icons.getbootstrap.com/>`_,
a comprehensive symbol set. Symbols from that set can be included as follows::
<i class="fa fa-heart"></i>
<i class="bi bi-heart"></i>
In-line HTML
^^^^^^^^^^^^
In addition to Markdown, HTML is also allowed and puts the
In addition to Markdown, HTML can also be allowed and puts the
full power of modern web technologies at the content author's disposal.
Markdown and HTML may also be mixed. For example, the following
creates a box with a recessed appearance around the content::
In order to use arbitrary HTML, the course must have the setting "may
present arbitrary HTML to participants" enabled. This setting is available
in the admin functionality.
When enabled, Markdown and HTML may also be mixed. For example, the
following creates a box with a border around the content::
<div class="well" markdown="1">
<div style="border: 1px solid black" markdown="1">
Exam 2 takes place **next week**. Make sure to [prepare early](flow:exam2-prep).
</div>
......@@ -319,6 +353,7 @@ The following snippet shows an interactive video viewer::
<p class="vjs-no-js">To view this video please enable JavaScript, and consider upgrading to a web browser that <a href="http://videojs.com/html5-video-support/" target="_blank">supports HTML5 video</a></p>
</video>
Macros
^^^^^^
......@@ -450,7 +485,7 @@ Here's an example:
.. attribute:: id
An identifer used as page anchors and for tracking. Not
An identifier used as page anchors and for tracking. Not
user-visible otherwise.
.. attribute:: rules
......@@ -470,34 +505,45 @@ Here's an example:
.. attribute:: weight
(required) An integer indicating how far up the page the block
(Required) An integer indicating how far up the page the block
will be shown. Blocks with identical weight retain the order
in which they are given in the course information file.
.. attribute:: if_after
A :ref:`datespec <datespec>` that determines a date/time after which this rule
(Optional) A :ref:`datespec <datespec>` that determines a date/time after which this rule
applies.
.. attribute:: if_before
A :ref:`datespec <datespec>` that determines a date/time before which this rule
(Optional) A :ref:`datespec <datespec>` that determines a date/time before which this rule
applies.
.. attribute:: if_has_role
A list of a subset of ``[unenrolled, ta, student, instructor]``.
(Optional) A list of a subset of the roles defined in the course, by
default ``unenrolled``, ``ta``, ``student``, ``instructor``.
.. attribute:: if_has_participation_tags_any
(Optional) A list of participation tags. Rule applies when the
participation has at least one tag in this list.
.. attribute:: if_has_participation_tags_all
(Optional) A list of participation tags. Rule applies if only the
participation's tags include all items in this list.
.. attribute:: if_in_facility
Name of a facility known to the RELATE web page. This rule allows
(Optional) Name of a facility known to the RELATE web page. This rule allows
(for example) showing chunks based on whether a user is physically
located in a computer-based testing center (which RELATE can
recognize based on IP ranges).
.. attribute:: shown
A boolean (``true`` or ``false``) indicating whether the chunk
(Optional) A boolean (``true`` or ``false``) indicating whether the chunk
should be shown.
......@@ -519,7 +565,7 @@ Events serve two purposes:
* They are (optionally) shown in the class calendar.
For example, to create contiguously numbered ``lecture`` events for a
lecture occuring on a Tuesday/Thursday schedule, perform the following
lecture occurring on a Tuesday/Thursday schedule, perform the following
sequence of steps:
* Create a recurring, weekly event for the Tuesday lectures, with a
......@@ -568,8 +614,8 @@ Multiple of these modifiers may occur. They are applied from left to right.
.. events_yml
The Calendear Information File: :file:`events.yml`
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
The Calendar Information File: :file:`events.yml`
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
The calendar information file, by default named :file:`events.yml`,
augments the calendar data in the database with descriptions and
......@@ -602,7 +648,7 @@ by the 'ordinal' of each event.
The secondsection, ``events``, can be used to provide a more verbose
description for each event that appears below the main calendar. Titles and
colors can also be overriden for each event specifically.
colors can also be overridden for each event specifically.
All attributes in each section (as well as the entire calendar information
file) are optional.
......
Frequently Asked Questions
==========================
What does the 'view' permission do?
------------------------------------
If you have it (the permission), you can see the pages in the flow. If
you don't have it, you can't.
##########################
Getting Started
===============
How do I get started with Relate?
---------------------------------
At the start of a course, there are a few steps required to get going.
We assume that the Relate server is already installed and that you have
a user account there. Your account will need to have sufficient
privileges to create a course. (You can tell whether that's the case by
checking that there is a 'Set up new course' button at the bottom of the main
Relate page. If you don't yet have sufficient privileges, ask your site admin.)
Getting everything set up
^^^^^^^^^^^^^^^^^^^^^^^^^
- Start by creating a Git repository on a hosting site (Github, Gitlab,
Bitbucket, or similar) for your course. Likely you will want this to be a
private repository, to prevent students from seeing solutions to your
assignments.
- If you have a course repository from a prior semester, you can start by
pushing its content to your new repository.
If you're starting from scratch, you can use the
`sample course <https://github.com/inducer/relate-sample>`__.
You can either create your own and use it as a guide, or use
it in its entirety and just make modifications.
- Now you are ready to click that 'Set up new course' button.
Fill in the form that pops up. For the 'Git source' field,
use the SSH clone URL provided by your Git host. It should look
like this::
git@hostingsite.com:yourusername/yourreponame.git
or like this::
ssh://git@hostingsite.com/yourusername/yourreponame.git
- To make sure Relate can access your course content, you will need
an SSH keypair. Below the 'SSH private key' box in the course creation
form, there is a link to a tool (built into Relate) to help you create one.
Open that link in a new browser tab. Copy the 'private key' bit into the
'SSH private key' box on the course creation form. Next, find the
"Deployment key" section in the settings of your Git hosting site, and add
the public key there. On Github, this is under "Setting/Deploy keys". On
Gitlab, it is under "Settings/Repository/Deploy keys". For the title of the
key, you may choose any description you like.
- Fill out the rest of the form. You will want to pay special attention
to whether you want your course listed on the main page, whether
you would like it open for enrollment right away, who is allowed to enroll,
and whether the site is restricted to staff. Nearly all of these settings can be
changed later, under "Content/Edit Course", so if you make a mistake,
it's note the end of the world. The only things that have to be correct
at this point are the SSH settings, the course identifier,
and the settings for 'course root' and 'course file'
(for these last two, in all likelihood, the defaults will be just fine).
.. note::
The course identifier is final and cannot be changed once the course
is created.
When choosing the course identifier, note that this will appear as
part of the URL when students browse your course, so it is best to
choose something that is easy to type and does not look out of place
there, such as by preferring lower to upper case. It also has to be
unique across the entire Relate site that you are using, so if the
course you are teaching is expected to run multiple times, the
identifier should likely include extra bits like the semester, and
maybe even the name of the section.
A common pattern is, e.g., ``cs450-f21`` for a course named `CS 450`
running in the fall semester of 2021.
- Once you hit 'Validate and create', Relate will download your
course content via Git and check it for validity. This may take a second.
Next, you should be greeted by your new course web page.
If something went wrong, Relate will show an error message that
explains what happened. If you are unable to figure out what it is trying to say,
contact the site admin for your installation of Relate.
What to do next
^^^^^^^^^^^^^^^
At this point, you are off to the races! Here are some ideas for things you may
want to take care of next:
- To update the course content, commit your changes, push them to your Git
hosting site, select "Content / Retrieve/preview new course revisions" and
click "Fetch and update" or "Fetch and preview".
- You may also want to add your course staff so that they can help you
get things set up. You can add them under "Grading / List of Participants",
making sure to choose an appropriate role for them. Try to avoid giving
one-off permissions. Instead, adjust the permissions of the role on
the admin site.
- If your course has controlled enrollment, you will likely want to
recheck the enrollment settings under "Content/Edit course".
- If you check "Enrollment approval required", you will receive an email
(at the "Notify email" you provided) whenever a student tries to register.
Approving these requests can be cumbersome. So you may want to create
"enrollment preapprovals" for the students in your course, for example
based on a class roster you have received. You can preapprove students
either by institutional ID/student ID or by their email address.
You can create those preapprovals under the "Instructor / Preapprove enrollments"
menu item.
- Key dates in your course will be different every time your teach it, so Relate
provides a notion of 'events' as symbolic names for specific points/periods in time.
(E.g. ``lecture 5``, ``quiz 3``, ``exam 2``). In addition to (optionally) being
shown on the class calendar, you can refer to them from your course content,
so that you don't have to manually change these dates every time you teach
the course. If your course content uses events, you will likely have
seen some warnings fly by about these being missing. (You can revisit those
warnings by going to "Content / Retrieve/preview new course revisions" and
clicking "Update" to rerun the validation.) Now might be a good time
to add those events. (using "Content / Edit events")
Note that events may be numbered, as in the example above. If you need to create
many events, note that there is a function "Content / Create recurring events"
which lets you do so efficiently. If you have events that occur (e.g.) multiple
times a week, create all (e.g. weekly) series separately. At this point, however, the
numbering will be off, with the second series numbered after the first.
You can fix that by using the "Content / Renumber events" function, which
adjusts the numbering so that it is in chronological order.
We hope you have a productive and fun course with Relate! If you have
ideas, comments, or suggestions, don't hesitate to `get in touch
<https://github.com/inducer/relate/issues/new>`__.
What does 'starting a session' mean?
------------------------------------
......@@ -64,6 +192,15 @@ The first one indicates whether a student is allowed to start a new session,
and the second one indicates whether a list of past sessions is shown
to resume or review.
Content Creation
================
What does the 'view' permission do?
------------------------------------
If you have it (the permission), you can see the pages in the flow. If
you don't have it, you can't.
Can flows be set up to branch somehow?
--------------------------------------
......@@ -79,16 +216,6 @@ permission, but by default, once an answer is "submitted", it cannot be
changed. (That is distinct from just "saving" an answer which makes the
system remember it but not consider it final.)
Some events happen twice or three times in a week. How can I create create recurring events for that circumstance?
------------------------------------------------------------------------------------------------------------------
What I do in that case is create two recurring (weekly) event series (or three) and then renumber the result.
Sometimes we need to postpone or put in advance all the following events, which belong or not belong to the same kind of events, by a specific interval of time. How do I avoid editing events one by one?
----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
"Delete one and renumber" might do the trick? That's what I do when, say, a class gets cancelled.
How do I have students realistically deal with data files in code questions?
----------------------------------------------------------------------------
......@@ -129,7 +256,55 @@ Here's an example page to give you an idea::
I wrote a Yes/No question, but RELATE shows "True/False" instead of "Yes/No"--why on earth would it do that?
------------------------------------------------------------------------------------------------------------
This is a bit of a misfeature in YAML (which relate uses), wich parses ``No`` as
This is a bit of a misfeature in YAML (which relate uses), which parses ``No`` as
a :class:`bool` instead of a literal string. Once that has happened, relate can't
recover the original string representation. To avoid that, just put quotes
around the ``"No"``.
Course Operations
=================
How do I launch an exam?
------------------------
An exam does not launch automatically when the header is changed. First, make
sure you have updated the course so the exam has the correct header in the public git revision.
Then, you must go to Grading -> Edit Exams, and activate the exam for the correct dates.
Most exam issues, like being unable to issue exam tickets, come from failing
to do one of the above two things.
How do I grant an extension for a particular student?
-----------------------------------------------------
Grant an exception (from say the gradebook or the grading menu) to the latest
session of the assignment you want to extend. Change the "Access Expires" to what you want it to be.
Make sure the correct access rules are checked. You will want it to generate a
grade (so check it), but make sure to set the credit percent to what you want
it to be.
Some events happen twice or three times in a week. How can I create create recurring events for that circumstance?
------------------------------------------------------------------------------------------------------------------
What I do in that case is create two recurring (weekly) event series (or three) and then renumber the result.
Sometimes we need to postpone or put in advance all the following events, which belong or not belong to the same kind of events, by a specific interval of time. How do I avoid editing events one by one?
----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
"Delete one and renumber" might do the trick? That's what I do when, say, a class gets cancelled.
How do I manually upload a file for a student, after the deadline has passed?
-----------------------------------------------------------------------------
Typically, you can reopen the session with the appropriate access rules (from say, the gradebook),
impersonate the student, upload the file, and then submit the session to close it.
The previous steps may not work though if the flow rules are too restrictive.
How do I adjust a particular student's grade up?
------------------------------------------------
An easy way is to grant an exception for that student's quiz/homework/exam and
give them some number of bonus points. Note that this will also change the
number of points that the assignment is out of. To compensate, you must also change
the "maximum number of points" to the appropriate value. Remember to not grant
an access exception.
Internals
=========
Flow Page Interface
-------------------
This describes the programming interface between Relate and a page in a flow.
.. automodule:: course.page.base
Validation
----------
.. automodule:: course.validation
Stub Docs
---------
.. currentmodule:: course.models
.. class:: Course
.. class:: FlowSession
.. currentmodule:: relate.utils
.. class:: SubdirRepoWrapper
.. currentmodule:: course.utils
.. class:: FlowPageContext
.. currentmodule:: relate.utils
.. class:: StyledForm
Canonicalization of Django Names
--------------------------------
.. currentmodule:: django.forms.forms
.. class:: Form
See :class:`django.forms.Form`.
.. currentmodule:: django.http.request
.. class:: HttpRequest
See :class:`django.http.HttpRequest`.