Skip to content
#! /usr/bin/env python
from __future__ import print_function
import os
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "relate.settings")
import django
django.setup()
from course.models import FlowPageData
import sys
from course.models import FlowPageData
course_identifier = sys.argv[1]
flow_id = sys.argv[2]
group_id = sys.argv[3]
......@@ -32,6 +36,7 @@ print(fpages.count(), "pages total")
raw_input("[Enter to continue]")
from django.db import transaction
with transaction.atomic():
for fpd in fpages:
assert fpd.page_type == old_type
......
#! /bin/bash
find node_modules/mathjax/es5 -name '*.js' -exec sed -i /sourceMappingURL=/d '{}' \;
poetry run python manage.py collectstatic
......@@ -2,12 +2,124 @@
from __future__ import print_function
from os.path import join, basename
from urllib.parse import quote_plus
from glob import glob
import re
section_nr = [1]
def rewrite_org_element(args, org_el):
el_type = org_el[0]
org_props = org_el[1]
assert org_props is None or isinstance(org_props, dict)
contents = org_el[2:]
had_relate_properties = False
promote_to_parent_level = False
node_props = {}
if el_type == "org-data":
title, = [
subel[1]["key"]
for el in contents
if el[0] == "section"
for subel in el[2:]
if subel[0] == "keyword"
if subel[1]["key"] == "TITLE"]
node_props["text"] = title
elif el_type == "headline":
node_props["text"] = org_props["raw-value"]
prop_container = [
subel[2:]
for el in contents
if el[0] == "section"
for subel in el[2:]
if subel[0] == "property-drawer"]
if prop_container:
prop_kws = prop_container[0]
else:
prop_kws = []
properties = {
ch[1]["key"]: ch[1]["value"]
for ch in prop_kws
if ch[0] == "node-property"
}
had_relate_properties = had_relate_properties or any(
key.startswith("RELATE_") for key in properties)
if "RELATE_TREE_SECTION_NAME" in properties:
node_props["section"] = properties["RELATE_TREE_SECTION_NAME"]
if "RELATE_TREE_SECTION_OPENED" in properties:
node_props["opened"] = properties["RELATE_TREE_SECTION_OPENED"]
if "RELATE_TREE_ICON" in properties:
node_props["icon"] = properties["RELATE_TREE_ICON"]
if "RELATE_TREE_LINK" in properties:
node_props["link"] = properties["RELATE_TREE_LINK"]
if "RELATE_PROMOTE_TO_PARENT_LEVEL" in properties:
promote_to_parent_level = True
if el_type == "org-data":
level = 0
else:
level = org_props.get("level")
if ("text" in node_props
and level is not None
and level < len(args.org_level_icon)):
node_props["icon"] = args.org_level_icon[level]
siblings = []
children = []
node_props["nodes"] = children
for ch in contents:
if isinstance(ch, list):
child_props, child_siblings, promote_child_to_parent_level = \
rewrite_org_element(args, ch)
if "text" in child_props:
if promote_child_to_parent_level:
siblings.append(child_props)
else:
children.append(child_props)
children.extend(child_siblings)
if args.org_stop_level:
if (level is not None
and level > args.org_stop_level
and not had_relate_properties):
node_props = {}
if el_type == "headline" and org_props["todo-keyword"] is not None:
node_props = {}
if (el_type == "headline"
and org_props["tags"] is not None
and "noexport" in org_props["tags"]):
node_props = {}
return node_props, siblings, promote_to_parent_level
def load_org_json(args, infile_name):
# https://github.com/ludios/org-to-json
from json import loads
with open(infile_name, "rb") as inf:
json = loads(inf.read())
node_props, siblings, promote_to_parent_level = \
rewrite_org_element(args, json)
assert not siblings
assert not promote_to_parent_level
return node_props
def normalize_nodes(nodes, text_icon):
for i in range(len(nodes)):
node = nodes[i]
......@@ -45,9 +157,16 @@ def find_section_nodes(section_dict, node):
class RenderSettings:
def __init__(self, default_icon, number_sections):
def __init__(self, default_icon, number_sections, tree_replacements):
self.default_icon = default_icon
self.number_sections = number_sections
self.tree_replacements = tree_replacements
def apply_replacements(self, s):
for key, val in self.tree_replacements.items():
s = s.replace(key, val)
return s
def render(settings, outf, node, indent=0, skip=1):
......@@ -79,12 +198,14 @@ def render(settings, outf, node, indent=0, skip=1):
if "link" in node:
print(
indent * " ",
"<a href=\"%s\">%s</a>" % (
node["link"],
text),
'<a href="%s">%s</a>' % (
settings.apply_replacements(node["link"]),
settings.apply_replacements(text)),
file=outf, sep="")
else:
print(indent * " ", text, file=outf, sep="")
print(
indent * " ",
settings.apply_replacements(text), file=outf, sep="")
subnodes = node.get("nodes", [])
if subnodes:
......@@ -99,6 +220,7 @@ def render(settings, outf, node, indent=0, skip=1):
indent -= 2
print(indent * " ", "</li>", file=outf, sep="")
FN_REGEX = re.compile(r"^([0-9]+)-(.*)(\.[a-z]+)$")
......@@ -127,12 +249,19 @@ def get_section_id_and_display_name(trunk, include_extension):
return section_id, display_name
def blacklisted_glob(basedir, pattern, blacklist_regexps):
return sorted(
name
for name in glob(join(basedir, pattern))
if not any(bl_re.match(name[len(basedir)+1:])
for bl_re in blacklist_regexps))
def main():
import argparse
parser = argparse.ArgumentParser(
description='Turn a YAML file into a jsTree-compatible data file')
description="Turn a YAML file into a jsTree-compatible data file")
parser.add_argument("-o", "--output-file", metavar="FILE", required=True)
......@@ -142,37 +271,74 @@ def main():
parser.add_argument("--ipynb-as-py", action="store_true")
parser.add_argument("--ipynb-as-ipynb", action="store_true")
parser.add_argument("--py-dir", metavar="DIRECTORY")
parser.add_argument("--py-urlroot", metavar="URL",
parser.add_argument("--interactive-nb-urlroot", metavar="URL",
help="(without the trailing slash)")
parser.add_argument("--interactive-nb-cleared-urlroot", metavar="URL",
help="(without the trailing slash)")
parser.add_argument("--ipynb-main-link",
choices=["static", "interactive", "interactive-cleared"],
default="static")
parser.add_argument("--source-dir", metavar="DIRECTORY")
parser.add_argument("--source-urlroot", metavar="URL",
help="(without the trailing slash)")
parser.add_argument("--source-wildcard", metavar="WILDCARD", nargs="*")
parser.add_argument("--pdf-dir", metavar="DIRECTORY")
parser.add_argument("--pdf-urlroot", metavar="URL",
help="(without the trailing slash)")
parser.add_argument("--default-icon", metavar="ICON_STR",
default="fa fa-file-o")
default="bi bi-file-earmark")
parser.add_argument("--text-icon", metavar="ICON_STR",
default="fa fa-file-o")
default="bi bi-file-earmark")
parser.add_argument("--number-sections", action="store_true")
parser.add_argument("--blacklist-file", metavar="PATTERN_FILE")
parser.add_argument("--org-stop-level", metavar="INT", type=int)
parser.add_argument("--org-level-icon", metavar="CSS_CLASSES", nargs="*")
parser.add_argument("--tree-replacement", metavar="KEY=VALUE", nargs="*")
parser.add_argument("input_file", metavar="FILE")
args = parser.parse_args()
from yaml import load
with open(args.input_file, "rb") as inf:
root_node = load(inf)
blacklist_regexps = []
if args.blacklist_file is not None:
with open(args.blacklist_file, "rt") as bl_file:
import fnmatch
for pattern in bl_file:
blacklist_regexps.append(
re.compile(fnmatch.translate(pattern.strip())))
if args.input_file.endswith(".yml") or args.input_file.endswith(".yml"):
from yaml import safe_load
with open(args.input_file, "rb") as inf:
root_node = safe_load(inf)
elif args.input_file.endswith(".org.json"):
root_node = load_org_json(args, args.input_file)
else:
raise ValueError("unknown extension of input file: %s" % args.input_file)
normalize_nodes([root_node], args.text_icon)
tree_replacements = {}
if args.tree_replacement is not None:
for tr in args.tree_replacement:
eq_ind = tr.find("=")
if eq_ind < 0:
raise ValueError(f"tree replacement '{tr}' contains no equal sign")
tree_replacements[tr[:eq_ind]] = tr[eq_ind+1:]
section_dict = {}
find_section_nodes(section_dict, root_node)
# {{{ demos
if args.ipynb_dir is not None:
for fn in sorted(glob(join(args.ipynb_dir, "*", "*.ipynb"))):
for fn in blacklisted_glob(args.ipynb_dir, join("*", "*.ipynb"),
blacklist_regexps):
trunk = fn[len(args.ipynb_dir)+1:]
section_id, display_name = get_section_id_and_display_name(
......@@ -180,60 +346,89 @@ def main():
link_ipynb = args.ipynb_urlroot + "/" + trunk
link_html = link_ipynb.replace(".ipynb", ".html")
main_link = link_html
sub_nodes = [{
"text": "View on the web",
"link": link_html,
"icon": "fa fa-newspaper-o",
"icon": "bi bi-newspaper",
}]
if args.interactive_nb_urlroot:
interactive_nb_url = args.interactive_nb_urlroot + quote_plus(trunk)
sub_nodes.append({
"text": "Run interactively",
"link": interactive_nb_url,
"icon": "bi bi-keyboard",
})
if args.ipynb_main_link == "interactive":
main_link = interactive_nb_url
if args.interactive_nb_cleared_urlroot:
interactive_nb_url = (args.interactive_nb_cleared_urlroot
+ quote_plus(trunk))
sub_nodes.append({
"text": "Run interactively with cleared input",
"link": interactive_nb_url,
"icon": "bi bi-keyboard",
})
if args.ipynb_main_link == "interactive-cleared":
main_link = interactive_nb_url
if args.ipynb_as_py:
link_py = link_ipynb.replace(".ipynb", ".py")
sub_nodes.append({
"text": "Download Python script",
"link": link_py,
"icon": "fa fa-terminal",
"icon": "bi bi-terminal",
})
if args.ipynb_as_ipynb:
sub_nodes.append({
"text": "Download Jupyter notebook",
"link": link_ipynb,
"icon": "fa fa-download",
"icon": "bi bi-download",
})
demo_node = {
"text": "Demo: " + display_name,
"link": link_html,
"icon": "fa fa-keyboard-o",
"link": main_link,
"icon": "bi bi-keyboard",
"nodes": sub_nodes,
}
section_dict[section_id]["nodes"].append(demo_node)
if section_id in section_dict:
section_dict[section_id]["nodes"].append(demo_node)
# }}}
# {{{ python source
# {{{ general source files
if args.py_dir is not None:
for fn in sorted(glob(join(args.py_dir, "*", "*.py"))):
trunk = fn[len(args.py_dir)+1:]
section_id, display_name = get_section_id_and_display_name(
trunk, include_extension=True)
if args.source_dir is not None:
for source_wildcard in args.source_wildcard:
for fn in blacklisted_glob(args.source_dir, join("*", source_wildcard),
blacklist_regexps):
trunk = fn[len(args.source_dir)+1:]
section_id, display_name = get_section_id_and_display_name(
trunk, include_extension=True)
src_node = {
"text": "Code: " + display_name,
"link": args.py_urlroot + "/" + trunk,
"icon": "fa fa-file-text-o",
}
section_dict[section_id]["nodes"].append(src_node)
src_node = {
"text": "Code: " + display_name,
"link": args.source_urlroot + "/" + trunk,
"icon": "bi bi-file-earmark-text",
}
if section_id in section_dict:
section_dict[section_id]["nodes"].append(src_node)
# }}}
# {{{ notes
if args.pdf_dir is not None:
for fn in sorted(glob(join(args.pdf_dir, "*.pdf"))):
for fn in blacklisted_glob(args.pdf_dir, join("*.pdf"),
blacklist_regexps):
if "autosave" in fn:
continue
......@@ -244,17 +439,20 @@ def main():
notes_node = {
"text": "PDF: " + basename(display_name),
"link": args.pdf_urlroot + "/" + trunk,
"icon": "fa fa-book",
"icon": "bi bi-book",
}
section_dict[section_id]["nodes"].insert(0, notes_node)
if section_id in section_dict:
section_dict[section_id]["nodes"].insert(0, notes_node)
# }}}
with open(args.output_file, "wt") as outf:
with open(args.output_file, "wt", encoding="utf-8") as outf:
render(
RenderSettings(
default_icon=args.default_icon,
number_sections=args.number_sections),
number_sections=args.number_sections,
tree_replacements=tree_replacements),
outf, root_node)
......
......@@ -4,25 +4,25 @@ text: "CS 123"
nodes:
- text: "Introduction"
icon: "fa fa-cube"
icon: "bi bi-box"
opened: true
section: 0
nodes:
- text: "Notes"
icon: "fa fa-book" # see http://fontawesome.io/icons/
icon: "bi bi-book" # see http://fontawesome.io/icons/
link: "http://andreask.cs.illinois.edu/cs598apk-f15/notes/notes.pdf"
- text: "Dense Matrices and Computation"
section: 1
icon: "fa fa-cube"
icon: "bi bi-box"
opened: true
nodes:
- text: "Notes"
icon: "fa fa-book"
icon: "bi bi-book"
link: "http://andreask.cs.illinois.edu/cs598apk-f15/notes/notes.pdf#page=10"
- text: "Finding structure with randomness: Probabilistic algorithms for constructing approximate matrix decompositions by Halko/Martinsson/Tropp"
icon: "fa fa-paperclip"
icon: "bi bi-paperclip"
link: http://arxiv.org/abs/0909.4061
- text: "Sources and Targets"
......
......@@ -22,6 +22,7 @@ TEMPLATE = Template(r"""
\usepackage{examtron}
\pagestyle{empty}
\usepackage{longtable}
\usepackage{titlesec}
\usepackage{tikz}
\titleformat{\section}
......@@ -42,14 +43,22 @@ TEMPLATE = Template(r"""
INCLUDE_GRAPHICS_MEDIA_RE = re.compile(r"\\includegraphics\{media:(.*)\}")
INCLUDE_GRAPHICS_REPO_RE = re.compile(r"\\includegraphics\{repo:(.*)\}")
FORMULA_ALIGN_RE = re.compile(
r"\\\["
r"\s*(\\begin\{align\*?\})"
r"(.*?)"
r"(\\end\{align\*?\})\s*"
r"\\\]", re.DOTALL)
def convert_markup(s):
result = pypandoc.convert(s, 'latex', format='markdown')
result, _ = INCLUDE_GRAPHICS_MEDIA_RE.subn(
r"\includegraphics[height=4cm]{media/\1}", result)
r"\\includegraphics[height=4cm]{media/\1}", result)
result, _ = INCLUDE_GRAPHICS_REPO_RE.subn(
r"\includegraphics[height=4cm]{\1}", result)
r"\\includegraphics[height=4cm]{\1}", result)
result, _ = FORMULA_ALIGN_RE.subn(
r"\1\2\3", result)
return result
......@@ -88,6 +97,9 @@ def convert_page_inner(page):
"SurveyChoiceQuestion"]:
prompt = convert_markup(page.prompt)
if page.type == "MultipleChoiceQuestion":
prompt += "\n\n(Select all that apply.)"
choices = [
"\item "
+
......
files
env
pack
_output
.cache
.jupyterlite.doit.db
mamba-root
micromamba
name: build-env
channels:
- conda-forge
dependencies:
- python
- pip
- jupyter_server
- jupyterlite-core >=0.1.0,<0.2.0
- jupyterlite-xeus-python >=0.9.2,<0.10.0
#! /bin/bash
set -eo pipefail
EXAM=0
if test "$1" == "--exam"; then
echo "BUILDING IN EXAM MODE"
EXAM=1
fi
MATHJAX_VER=2.7.7
./cleanup.sh
EXTRA_BUILD_FLAGS=()
if false; then
python3 -m venv env
source env/bin/activate
pip install jupyterlite-core
# update the pyodide kernel and pyodide in lockstep
pip install 'jupyterlite-pyodide-kernel==0.1.2'
EXTRA_BUILD_FLAGS=(--pyodide https://github.com/pyodide/pyodide/releases/download/0.24.0/pyodide-0.24.0.tar.bz2)
else
curl -Ls https://micro.mamba.pm/api/micromamba/linux-64/latest | tar -xvj --strip=1 bin/micromamba
./micromamba create -y -f build-environment.yml -p ./mamba-root
eval "$(./micromamba shell hook --shell bash)"
micromamba activate ./mamba-root
fi
# jupyter-server appears to be needed for indexing contents
pip install jupyter-server libarchive-c
mkdir -p pack
if [[ "$EXAM" = 0 ]]; then
mkdir -p files/{cs450,cs555,cs598apk}-kloeckner
git clone https://github.com/inducer/numerics-notes pack/numerics-notes
git clone https://github.com/inducer/numpde-notes pack/numpde-notes
git clone https://github.com/inducer/fast-alg-ie-notes pack/fast-alg-ie-notes
cp -R pack/numerics-notes/demos files/cs450-kloeckner/demos
cp -R pack/numerics-notes/cleared-demos files/cs450-kloeckner/cleared
cp -R pack/numpde-notes/demos files/cs555-kloeckner/demos
cp -R pack/numpde-notes/cleared-demos files/cs555-kloeckner/cleared
cp -R pack/fast-alg-ie-notes/demos files/cs598apk-kloeckner/demos
cp -R pack/fast-alg-ie-notes/cleared-demos files/cs598apk-kloeckner/cleared
fi
curl -L "https://github.com/mathjax/MathJax/archive/$MATHJAX_VER.zip" \
-o "pack/mathjax-$MATHJAX_VER.zip"
(cd pack; unzip -q mathjax-$MATHJAX_VER.zip)
jupyter lite init
jupyter lite build \
--mathjax-dir "pack/MathJax-$MATHJAX_VER" \
"${EXTRA_BUILD_FLAGS[@]}"
# vim: sw=4
#! /bin/bash
set -e
rm -Rf .cache env .jupyterlite.doit.db _output pack micromamba mamba-root files
name: xeus-python-kernel
channels:
- https://repo.mamba.pm/emscripten-forge
- https://repo.mamba.pm/conda-forge
dependencies:
- numpy
- scipy
- matplotlib
- sympy
#! /bin/bash
if test -d files/cs450-kloeckner; then
echo "Uploading in normal mode..."
rsync --archive --delete -v _output/ rl:/web/jupyterlite/main/
else
echo "Uploading in exam mode..."
rsync --archive --delete -v _output/ rl:/web/jupyterlite/exam/
fi
default_app_config = 'course.apps.CourseConfig'
\ No newline at end of file
# (empty)
This diff is collapsed.
# -*- coding: utf-8 -*-
from __future__ import annotations
from __future__ import division
__copyright__ = "Copyright (C) 2014 Andreas Kloeckner"
......@@ -25,29 +24,19 @@ THE SOFTWARE.
"""
import six
from django.utils.translation import ugettext as _, pgettext, string_concat
from django.shortcuts import ( # noqa
render, get_object_or_404, redirect)
from django import http
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.db import connection
from django.shortcuts import get_object_or_404, redirect, render # noqa
from django.urls import reverse
from django.core.exceptions import ObjectDoesNotExist
from django import http
from django.contrib import messages
from course.utils import course_view, render_course_page, PageInstanceCache
from course.models import (
FlowSession,
FlowPageVisit,
flow_permission)
from course.constants import (
participation_permission as pperm,
)
from django.utils.translation import gettext as _, pgettext
from course.constants import participation_permission as pperm
from course.content import get_flow_desc
from course.models import FlowPageVisit, FlowSession, flow_permission
from course.utils import PageInstanceCache, course_view, render_course_page
# {{{ flow list
......@@ -74,7 +63,7 @@ def flow_list(pctx):
# {{{ histogram tool
class BinInfo(object):
class BinInfo:
def __init__(self, title, raw_weight, percentage, url=None):
self.title = title
self.raw_weight = raw_weight
......@@ -82,7 +71,7 @@ class BinInfo(object):
self.url = url
class Histogram(object):
class Histogram:
def __init__(self, num_bin_count=10, num_bin_starts=None,
num_min_value=None, num_max_value=None,
num_enforce_bounds=False, num_log_bins=False,
......@@ -97,7 +86,7 @@ class Histogram(object):
self.num_bin_title_formatter = num_bin_title_formatter
def add_data_point(self, value, weight=1):
if isinstance(value, six.string_types):
if isinstance(value, str):
self.string_weights[value] = \
self.string_weights.get(value, 0) + weight
elif value is None:
......@@ -130,7 +119,7 @@ class Histogram(object):
def total_weight(self):
return (
sum(weight for val, weight in self.num_values)
+ sum(six.itervalues(self.string_weights)))
+ sum(self.string_weights.values()))
def get_bin_info_list(self):
min_value = self.num_min_value
......@@ -151,11 +140,17 @@ class Histogram(object):
max_value = 1
if self.num_log_bins:
from math import log, exp
min_value = max(min_value, 1e-15)
max_value = max(max_value, 1.01*min_value)
from math import exp, log
bin_width = (log(max_value) - log(min_value))/self.num_bin_count
num_bin_starts = [
exp(log(min_value)+bin_width*i)
for i in range(self.num_bin_count)]
# Rounding error means exp(log(min_value)) may be greater
# than min_value, so set start of first bin to min_value
num_bin_starts[0] = min_value
else:
bin_width = (max_value - min_value)/self.num_bin_count
num_bin_starts = [
......@@ -189,14 +184,14 @@ class Histogram(object):
100*weight/total_weight
if total_weight
else None))
for start, weight in zip(num_bin_starts, bins)]
for start, weight in zip(num_bin_starts, bins, strict=True)]
str_bin_info = [
BinInfo(
title=key,
raw_weight=temp_string_weights[key],
percentage=100*temp_string_weights[key]/total_weight)
for key in sorted(six.iterkeys(temp_string_weights))]
for key in sorted(temp_string_weights)]
return num_bin_info + str_bin_info
......@@ -207,12 +202,12 @@ class Histogram(object):
if max_len < 20:
from django.template.loader import render_to_string
return render_to_string("course/histogram-wide.html", {
"bin_info_list": self.get_bin_info_list(),
"bin_info_list": bin_info_list,
})
else:
from django.template.loader import render_to_string
return render_to_string("course/histogram.html", {
"bin_info_list": self.get_bin_info_list(),
"bin_info_list": bin_info_list,
})
# }}}
......@@ -276,7 +271,7 @@ def make_grade_histogram(pctx, flow_id):
return hist
class PageAnswerStats(object):
class PageAnswerStats:
def __init__(self, group_id, page_id, title, average_correctness,
average_emptiness, answer_count, total_count, url=None):
self.group_id = group_id
......@@ -405,6 +400,7 @@ def make_time_histogram(pctx, flow_id):
course=pctx.course,
flow_id=flow_id)
from relate.utils import string_concat
hist = Histogram(
num_log_bins=True,
num_bin_title_formatter=(
......@@ -472,7 +468,7 @@ def flow_analytics(pctx, flow_id):
# {{{ page analytics
class AnswerStats(object):
class AnswerStats:
def __init__(self, normalized_answer, correctness, count,
percentage):
self.normalized_answer = normalized_answer
......@@ -493,8 +489,6 @@ def page_analytics(pctx, flow_id, group_id, page_id):
restrict_to_first_attempt = int(
bool(pctx.request.GET.get("restrict_to_first_attempt") == "1"))
is_multiple_submit = is_flow_multiple_submit(flow_desc)
page_cache = PageInstanceCache(pctx.repo, pctx.course, flow_id)
visits = (FlowPageVisit.objects
......@@ -509,6 +503,9 @@ def page_analytics(pctx, flow_id, group_id, page_id):
))
if connection.features.can_distinct_on_fields:
is_multiple_submit = is_flow_multiple_submit(flow_desc)
if restrict_to_first_attempt:
visits = (visits
.distinct("flow_session__participation__id")
......@@ -540,9 +537,15 @@ def page_analytics(pctx, flow_id, group_id, page_id):
flow_session=visit.flow_session)
title = page.title(grading_page_context, visit.page_data.data)
body = page.body(grading_page_context, visit.page_data.data)
body = page.analytic_view_body(grading_page_context, visit.page_data.data)
normalized_answer = page.normalized_answer(
grading_page_context, visit.page_data.data, visit.answer)
grading_page_context, visit.page_data.data, visit.answer)
if normalized_answer is None:
normalized_answer = _("(No answer)")
else:
import bleach
normalized_answer = bleach.clean(normalized_answer)
answer_feedback = visit.get_most_recent_feedback()
......@@ -560,7 +563,7 @@ def page_analytics(pctx, flow_id, group_id, page_id):
answer_stats = []
for (normalized_answer, correctness), count in \
six.iteritems(normalized_answer_and_correctness_to_count):
normalized_answer_and_correctness_to_count.items():
answer_stats.append(
AnswerStats(
normalized_answer=normalized_answer,
......
from __future__ import annotations
__copyright__ = "Copyright (C) 2017 Andreas Kloeckner"
__license__ = """
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
"""
from typing import TYPE_CHECKING, Any
from django import http
from django.core.exceptions import PermissionDenied
from django.shortcuts import get_object_or_404
from course.auth import APIError, with_course_api_auth
from course.constants import participation_permission as pperm
from course.models import FlowSession
# {{{ mypy
if TYPE_CHECKING:
from course.auth import APIContext
# }}}
def flow_session_to_json(sess: FlowSession) -> Any:
last_activity = sess.last_activity()
return {
"id": sess.id,
"participation_username": (
sess.participation.user.username
if sess.participation is not None
else None),
"participation_institutional_id": (
sess.participation.user.institutional_id
if sess.participation is not None
else None),
"active_git_commit_sha": sess.active_git_commit_sha,
"flow_id": sess.flow_id,
"start_time": sess.start_time.isoformat(),
"completion_time": sess.completion_time,
"last_activity_time": (
last_activity.isoformat()
if last_activity is not None
else None),
"page_count": sess.page_count,
"in_progress": sess.in_progress,
"access_rules_tag": sess.access_rules_tag,
"expiration_mode": sess.expiration_mode,
"points": sess.points,
"max_points": sess.max_points,
"result_comment": sess.result_comment,
}
@with_course_api_auth("Token")
def get_flow_sessions(
api_ctx: APIContext, course_identifier: str) -> http.HttpResponse:
if not api_ctx.has_permission(pperm.view_gradebook):
raise PermissionDenied("token role does not have required permissions")
try:
flow_id = api_ctx.request.GET["flow_id"]
except KeyError:
raise APIError("must specify flow_id GET parameter")
sessions = FlowSession.objects.filter(
course=api_ctx.course,
flow_id=flow_id)
result = [flow_session_to_json(sess) for sess in sessions]
return http.JsonResponse(result, safe=False)
@with_course_api_auth("Token")
def get_flow_session_content(
api_ctx: APIContext, course_identifier: str) -> http.HttpResponse:
if not api_ctx.has_permission(pperm.view_gradebook):
raise PermissionDenied("token role does not have required permissions")
try:
session_id_str = api_ctx.request.GET["flow_session_id"]
except KeyError:
raise APIError("must specify flow_id GET parameter")
session_id = int(session_id_str)
flow_session = get_object_or_404(FlowSession, id=session_id)
if flow_session.course != api_ctx.course:
raise PermissionDenied(
"session's course does not match auth context")
from course.content import get_course_repo
from course.flow import adjust_flow_session_page_data, assemble_answer_visits
with get_course_repo(api_ctx.course) as repo:
from course.utils import FlowContext, instantiate_flow_page_with_ctx
fctx = FlowContext(repo, api_ctx.course, flow_session.flow_id)
adjust_flow_session_page_data(repo, flow_session, api_ctx.course.identifier,
fctx.flow_desc)
from course.flow import get_all_page_data
all_page_data = get_all_page_data(flow_session)
answer_visits = assemble_answer_visits(flow_session)
pages = []
for i, page_data in enumerate(all_page_data):
page = instantiate_flow_page_with_ctx(fctx, page_data)
assert i == page_data.page_ordinal
page_data_json = {
"ordinal": i,
"page_type": page_data.page_type,
"group_id": page_data.group_id,
"page_id": page_data.page_id,
"page_data": page_data.data,
"title": page_data.title,
"bookmarked": page_data.bookmarked,
}
answer_json = None
grade_json = None
visit = answer_visits[i]
if visit is not None:
from course.page.base import PageContext
pctx = PageContext(api_ctx.course, repo, fctx.course_commit_sha,
flow_session)
norm_bytes_answer_tup = page.normalized_bytes_answer(
pctx, page_data.data, visit.answer)
# norm_answer needs to be JSON-encodable
norm_answer: Any = None
if norm_bytes_answer_tup is not None:
answer_file_ext, norm_bytes_answer = norm_bytes_answer_tup
if answer_file_ext in [".txt", ".py"]:
norm_answer = norm_bytes_answer.decode("utf-8")
elif answer_file_ext == ".json":
import json
norm_answer = json.loads(norm_bytes_answer)
else:
from base64 import b64encode
norm_answer = [answer_file_ext,
b64encode(norm_bytes_answer).decode("utf-8")]
answer_json = {
"visit_time": visit.visit_time.isoformat(),
"remote_address": repr(visit.remote_address),
"user": (
visit.user.username if visit.user is not None else None),
"impersonated_by": (
visit.impersonated_by.username
if visit.impersonated_by is not None else None),
"is_synthetic_visit": visit.is_synthetic,
"answer_data": visit.answer,
"answer": norm_answer,
}
grade = visit.get_most_recent_grade()
if grade is not None:
grade_json = {
"grader": (grade.grader.username
if grade.grader is not None else None),
"grade_time": grade.grade_time.isoformat(),
"graded_at_git_commit_sha": (
grade.graded_at_git_commit_sha),
"max_points": grade.max_points,
"correctness": grade.correctness,
"feedback": grade.feedback}
pages.append({
"page": page_data_json,
"answer": answer_json,
"grade": grade_json,
})
result = {
"session": flow_session_to_json(flow_session),
"pages": pages,
}
return http.JsonResponse(result, safe=False)
# vim: foldmethod=marker
from __future__ import annotations
from django.apps import AppConfig
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import gettext_lazy as _
from relate.checks import register_startup_checks, register_startup_checks_extra
class CourseConfig(AppConfig):
name = 'course'
name = "course"
# for translation of the name of "Course" app displayed in admin.
verbose_name = _("Course module")
default_auto_field = "django.db.models.BigAutoField"
def ready(self):
import course.receivers
\ No newline at end of file
import course.receivers # noqa
# register all checks
register_startup_checks()
register_startup_checks_extra()
This diff is collapsed.
# -*- coding: utf-8 -*-
from __future__ import annotations
from __future__ import division
__copyright__ = "Copyright (C) 2014 Andreas Kloeckner"
......@@ -24,28 +23,44 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
"""
import six
from six.moves import range
import datetime
from django.utils.translation import (
ugettext_lazy as _, pgettext_lazy, string_concat)
import django.forms as forms
from crispy_forms.layout import Submit
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.exceptions import (
ObjectDoesNotExist,
PermissionDenied,
ValidationError,
)
from django.db import transaction
from django.utils.safestring import mark_safe
from django.utils.translation import get_language, gettext_lazy as _, pgettext_lazy
from course.constants import participation_permission as pperm
from course.models import Event
from course.utils import course_view, render_course_page
from django.core.exceptions import PermissionDenied, ObjectDoesNotExist
from django.db import transaction, IntegrityError
from django.contrib import messages # noqa
import django.forms as forms
from relate.utils import HTML5DateTimeInput, StyledForm, as_local_time, string_concat
from crispy_forms.layout import Submit
import datetime
from bootstrap3_datetime.widgets import DateTimePicker
class ListTextWidget(forms.TextInput):
# Widget which allow free text and choices for CharField
def __init__(self, data_list, name, *args, **kwargs):
super().__init__(*args, **kwargs)
self._name = name
self._list = data_list
self.attrs.update({"list": f"list__{self._name}"})
from relate.utils import StyledForm, as_local_time
from course.constants import (
participation_permission as pperm,
)
from course.models import Event
def render(self, name, value, attrs=None, renderer=None):
text_html = super().render(
name, value, attrs=attrs, renderer=renderer)
data_list = f'<datalist id="list__{self._name}">'
for item in self._list:
data_list += f'<option value="{item[0]}">{item[1]}</option>'
data_list += "</datalist>"
return mark_safe(text_html + data_list)
# {{{ creation
......@@ -56,11 +71,21 @@ class RecurringEventForm(StyledForm):
"allowed."),
label=pgettext_lazy("Kind of event", "Kind of event"))
time = forms.DateTimeField(
widget=DateTimePicker(
options={"format": "YYYY-MM-DD HH:mm", "sideBySide": True}),
widget=HTML5DateTimeInput(),
label=pgettext_lazy("Starting time of event", "Starting time"))
duration_in_minutes = forms.FloatField(required=False,
min_value=0,
label=_("Duration in minutes"))
all_day = forms.BooleanField(
required=False,
initial=False,
label=_("All-day event"),
help_text=_("Only affects the rendering in the class calendar, "
"in that a start time is not shown"))
shown_in_calendar = forms.BooleanField(
required=False,
initial=True,
label=_("Shown in calendar"))
interval = forms.ChoiceField(required=True,
choices=(
("weekly", _("Weekly")),
......@@ -71,10 +96,19 @@ class RecurringEventForm(StyledForm):
label=pgettext_lazy(
"Starting ordinal of recurring events", "Starting ordinal"))
count = forms.IntegerField(required=True,
min_value=0,
label=pgettext_lazy("Count of recurring events", "Count"))
def __init__(self, *args, **kwargs):
super(RecurringEventForm, self).__init__(*args, **kwargs)
def __init__(self, course_identifier, *args, **kwargs):
super().__init__(*args, **kwargs)
self.course_identifier = course_identifier
exist_event_choices = [(choice, choice) for choice in set(
Event.objects.filter(
course__identifier=course_identifier)
.values_list("kind", flat=True))]
self.fields["kind"].widget = ListTextWidget(data_list=exist_event_choices,
name="event_choices")
self.helper.add_input(
Submit("submit", _("Create")))
......@@ -86,27 +120,31 @@ class EventAlreadyExists(Exception):
@transaction.atomic
def _create_recurring_events_backend(course, time, kind, starting_ordinal, interval,
count, duration_in_minutes):
count, duration_in_minutes, all_day, shown_in_calendar):
ordinal = starting_ordinal
assert ordinal is not None
import datetime
for i in range(count):
for _i in range(count):
evt = Event()
evt.course = course
evt.kind = kind
evt.ordinal = ordinal
evt.time = time
evt.all_day = all_day
evt.shown_in_calendar = shown_in_calendar
if duration_in_minutes:
evt.end_time = evt.time + datetime.timedelta(
minutes=duration_in_minutes)
try:
evt.save()
except IntegrityError:
if Event.objects.filter(course=course, kind=kind, ordinal=ordinal).count():
raise EventAlreadyExists(
_("'%(event_kind)s %(event_ordinal)d' already exists") %
{'event_kind': kind, 'event_ordinal': ordinal})
_("'%(exist_event)s' already exists")
% {"exist_event": evt})
evt.save()
date = time.date()
if interval == "weekly":
......@@ -114,17 +152,10 @@ def _create_recurring_events_backend(course, time, kind, starting_ordinal, inter
elif interval == "biweekly":
date += datetime.timedelta(weeks=2)
else:
raise ValueError(
string_concat(
pgettext_lazy(
"Unkown time interval",
"unknown interval"),
": %s")
% interval)
time = time.tzinfo.localize(
datetime.datetime(date.year, date.month, date.day,
time.hour, time.minute, time.second))
raise NotImplementedError()
time = datetime.datetime(date.year, date.month, date.day,
time.hour, time.minute, time.second, tzinfo=time.tzinfo)
del date
ordinal += 1
......@@ -137,9 +168,12 @@ def create_recurring_events(pctx):
raise PermissionDenied(_("may not edit events"))
request = pctx.request
message = None
message_level = None
if request.method == "POST":
form = RecurringEventForm(request.POST, request.FILES)
form = RecurringEventForm(
pctx.course.identifier, request.POST, request.FILES)
if form.is_valid():
if form.cleaned_data["starting_ordinal"] is not None:
starting_ordinal = form.cleaned_data["starting_ordinal"]
......@@ -158,36 +192,53 @@ def create_recurring_events(pctx):
interval=form.cleaned_data["interval"],
count=form.cleaned_data["count"],
duration_in_minutes=(
form.cleaned_data["duration_in_minutes"]))
form.cleaned_data["duration_in_minutes"]),
all_day=form.cleaned_data["all_day"],
shown_in_calendar=(
form.cleaned_data["shown_in_calendar"])
)
message = _("Events created.")
message_level = messages.SUCCESS
except EventAlreadyExists as e:
if starting_ordinal_specified:
messages.add_message(request, messages.ERROR,
message = (
string_concat(
"%(err_type)s: %(err_str)s. ",
_("No events created."))
% {
"err_type": type(e).__name__,
"err_str": str(e)})
message_level = messages.ERROR
else:
starting_ordinal += 10
continue
except Exception as e:
messages.add_message(request, messages.ERROR,
string_concat(
"%(err_type)s: %(err_str)s. ",
_("No events created."))
% {
"err_type": type(e).__name__,
"err_str": str(e)})
else:
messages.add_message(request, messages.SUCCESS,
_("Events created."))
if isinstance(e, ValidationError):
for field, error in e.error_dict.items():
try:
form.add_error(field, error)
except ValueError:
# This happens when ValidationError were
# raised for fields which don't exist in
# RecurringEventForm
form.add_error(
"__all__", f"'{field}': {error}")
else:
message = (
string_concat(
"%(err_type)s: %(err_str)s. ",
_("No events created."))
% {
"err_type": type(e).__name__,
"err_str": str(e)})
message_level = messages.ERROR
break
else:
form = RecurringEventForm()
form = RecurringEventForm(pctx.course.identifier)
if message and message_level:
messages.add_message(request, message_level, message)
return render_course_page(pctx, "course/generic-course-form.html", {
"form": form,
"form_description": _("Create recurring events"),
......@@ -195,16 +246,30 @@ def create_recurring_events(pctx):
class RenumberEventsForm(StyledForm):
kind = forms.CharField(required=True,
kind = forms.ChoiceField(required=True,
help_text=_("Should be lower_case_with_underscores, no spaces "
"allowed."),
label=pgettext_lazy("Kind of event", "Kind of event"))
starting_ordinal = forms.IntegerField(required=True, initial=1,
help_text=_("The starting ordinal of this kind of events"),
label=pgettext_lazy(
"Starting ordinal of recurring events", "Starting ordinal"))
def __init__(self, *args, **kwargs):
super(RenumberEventsForm, self).__init__(*args, **kwargs)
preserve_ordinal_order = forms.BooleanField(
required=False,
initial=False,
help_text=_("Tick to preserve the order of ordinals of "
"existing events."),
label=_("Preserve ordinal order"))
def __init__(self, course_identifier, *args, **kwargs):
super().__init__(*args, **kwargs)
self.course_identifier = course_identifier
renumberable_event_kinds = set(Event.objects.filter(
course__identifier=self.course_identifier,
ordinal__isnull=False).values_list("kind", flat=True))
self.fields["kind"].choices = tuple(
(kind, kind) for kind in renumberable_event_kinds)
self.helper.add_input(
Submit("submit", _("Renumber")))
......@@ -219,42 +284,58 @@ def renumber_events(pctx):
request = pctx.request
message = None
message_level = None
if request.method == "POST":
form = RenumberEventsForm(request.POST, request.FILES)
form = RenumberEventsForm(
pctx.course.identifier, request.POST, request.FILES)
if form.is_valid():
events = list(Event.objects
.filter(course=pctx.course, kind=form.cleaned_data["kind"])
.order_by('time'))
if events:
queryset = (Event.objects
.filter(course=pctx.course, kind=form.cleaned_data["kind"]))
queryset.delete()
ordinal = form.cleaned_data["starting_ordinal"]
for event in events:
new_event = Event()
new_event.course = pctx.course
new_event.kind = form.cleaned_data["kind"]
new_event.ordinal = ordinal
new_event.time = event.time
new_event.end_time = event.end_time
new_event.all_day = event.all_day
new_event.shown_in_calendar = event.shown_in_calendar
new_event.save()
ordinal += 1
messages.add_message(request, messages.SUCCESS,
_("Events renumbered."))
else:
messages.add_message(request, messages.ERROR,
_("No events found."))
kind = form.cleaned_data["kind"]
order_field = "time"
if form.cleaned_data["preserve_ordinal_order"]:
order_field = "ordinal"
events = list(
Event.objects.filter(
course=pctx.course, kind=kind,
# there might be event with the same kind but no ordinal,
# we don't renumber that
ordinal__isnull=False)
.order_by(order_field))
assert events
queryset = (Event.objects.filter(
course=pctx.course, kind=kind,
# there might be event with the same kind but no ordinal,
# we don't renumber that
ordinal__isnull=False))
queryset.delete()
ordinal = form.cleaned_data["starting_ordinal"]
for event in events:
new_event = Event()
new_event.course = pctx.course
new_event.kind = kind
new_event.ordinal = ordinal
new_event.time = event.time
new_event.end_time = event.end_time
new_event.all_day = event.all_day
new_event.shown_in_calendar = event.shown_in_calendar
new_event.save()
ordinal += 1
message = _("Events renumbered.")
message_level = messages.SUCCESS
else:
form = RenumberEventsForm()
form = RenumberEventsForm(pctx.course.identifier)
if messages and message_level:
messages.add_message(request, message_level, message)
return render_course_page(pctx, "course/generic-course-form.html", {
"form": form,
"form_description": _("Renumber events"),
......@@ -265,7 +346,7 @@ def renumber_events(pctx):
# {{{ calendar
class EventInfo(object):
class EventInfo:
def __init__(self, id, human_title, start_time, end_time, description):
self.id = id
self.human_title = human_title
......@@ -274,19 +355,34 @@ class EventInfo(object):
self.description = description
def _fullcalendar_lang_code() -> str:
"""
Return the fallback lang name for js files.
"""
lang_name = get_language()
known_fallback_mapping = {
"zh-hans": "zh-cn",
"zh-hant": "zh-tw"}
return known_fallback_mapping.get(lang_name.lower(), lang_name).lower()
@course_view
def view_calendar(pctx):
from course.content import markup_to_html, parse_date_spec
if not pctx.has_permission(pperm.view_calendar):
raise PermissionDenied(_("may not view calendar"))
# must import locally for mock to work
from course.views import get_now_or_fake_time
now = get_now_or_fake_time(pctx.request)
if not pctx.has_permission(pperm.view_calendar):
raise PermissionDenied(_("may not view calendar"))
events_json = []
from course.content import get_raw_yaml_from_repo
from course.content import (
get_raw_yaml_from_repo,
markup_to_html,
parse_date_spec,
)
try:
event_descr = get_raw_yaml_from_repo(pctx.repo,
pctx.course.events_file, pctx.course_commit_sha)
......@@ -298,14 +394,19 @@ def view_calendar(pctx):
event_info_list = []
for event in (Event.objects
events = sorted(
Event.objects
.filter(
course=pctx.course,
shown_in_calendar=True)
.order_by("-time")):
shown_in_calendar=True),
key=lambda evt: (
-evt.time.year, -evt.time.month, -evt.time.day,
evt.time.hour, evt.time.minute, evt.time.second))
for event in events:
kind_desc = event_kinds_desc.get(event.kind)
human_title = six.text_type(event)
human_title = str(event)
event_json = {
"id": event.id,
......@@ -322,11 +423,11 @@ def view_calendar(pctx):
if event.ordinal is not None:
human_title = kind_desc["title"].format(nr=event.ordinal)
else:
human_title = kind_desc["title"]
human_title = kind_desc["title"].rstrip("{nr}").strip()
description = None
show_description = True
event_desc = event_info_desc.get(six.text_type(event))
event_desc = event_info_desc.get(str(event))
if event_desc is not None:
if "description" in event_desc:
description = markup_to_html(
......@@ -361,12 +462,13 @@ def view_calendar(pctx):
if event.all_day:
start_time = start_time.date()
local_end_time = as_local_time(end_time)
end_midnight = datetime.time(tzinfo=local_end_time.tzinfo)
if local_end_time.time() == end_midnight:
end_time = (end_time - datetime.timedelta(days=1)).date()
else:
end_time = end_time.date()
if end_time is not None:
local_end_time = as_local_time(end_time)
end_midnight = datetime.time(tzinfo=local_end_time.tzinfo)
if local_end_time.time() == end_midnight:
end_time = (end_time - datetime.timedelta(days=1)).date()
else:
end_time = end_time.date()
event_info_list.append(
EventInfo(
......@@ -388,6 +490,7 @@ def view_calendar(pctx):
"events_json": dumps(events_json),
"event_info_list": event_info_list,
"default_date": default_date.isoformat(),
"fullcalendar_lang_code": _fullcalendar_lang_code()
})
# }}}
......
# -*- coding: utf-8 -*-
from __future__ import annotations
from __future__ import division, unicode_literals
__copyright__ = "Copyright (C) 2014 Andreas Kloeckner"
......@@ -25,22 +24,28 @@ THE SOFTWARE.
"""
from django.utils.translation import pgettext_lazy, ugettext
from django.utils.translation import gettext, pgettext_lazy
# Allow 10x extra credit at the very most.
MAX_EXTRA_CREDIT_FACTOR = 10
DEFAULT_EMAIL_APPELLATION_PRIORITY_LIST = [
"first_name", "email", "username", "full_name"]
COURSE_ID_REGEX = "(?P<course_identifier>[-a-zA-Z0-9]+)"
EVENT_KIND_REGEX = "(?P<event_kind>[_a-z0-9]+)"
FLOW_ID_REGEX = "(?P<flow_id>[-_a-zA-Z0-9]+)"
GRADING_OPP_ID_REGEX = "(?P<grading_opp_id>[-_a-zA-Z0-9]+)"
# FIXME : Support page hierarchy. Add '/' here, fix validation code.
STATICPAGE_PATH_REGEX = "(?P<page_path>[-\w]+)"
STATICPAGE_PATH_REGEX = r"(?P<page_path>[-\w]+)"
class user_status: # noqa
unconfirmed = "unconfirmed"
active = "active"
USER_STATUS_CHOICES = (
(user_status.unconfirmed, pgettext_lazy("User status", "Unconfirmed")),
(user_status.active, pgettext_lazy("User status", "Active")),
......@@ -72,14 +77,13 @@ PARTICIPATION_STATUS_CHOICES = (
# {{{ participation permission
class participation_permission:
class participation_permission: # noqa
edit_course = "edit_course"
use_admin_interface = "use_admin_interface"
manage_authentication_tokens = "manage_authentication_tokens"
impersonate_role = "impersonate_role"
# FIXME: Not yet used
set_fake_time = "set_fake_time"
# FIXME: Not yet used
set_pretend_facility = "set_pretend_facility"
edit_course_permissions = "edit_course_permissions"
......@@ -88,11 +92,13 @@ class participation_permission:
send_instant_message = "send_instant_message"
access_files_for = "access_files_for"
included_in_grade_statistics = "included_in_grade_statistics"
skip_during_manual_grading = "skip_during_manual_grading"
edit_exam = "edit_exam"
issue_exam_ticket = "issue_exam_ticket"
batch_issue_exam_ticket = "batch_issue_exam_ticket"
view_participant_masked_profile = "view_participant_masked_profile"
view_flow_sessions_from_role = "view_flow_sessions_from_role"
view_gradebook = "view_gradebook"
edit_grading_opportunity = "edit_grading_opportunity"
......@@ -117,6 +123,7 @@ class participation_permission:
preview_content = "preview_content"
update_content = "update_content"
use_git_endpoint = "use_git_endpoint"
use_markup_sandbox = "use_markup_sandbox"
use_page_sandbox = "use_page_sandbox"
test_flow = "test_flow"
......@@ -135,6 +142,10 @@ PARTICIPATION_PERMISSION_CHOICES = (
pgettext_lazy("Participation permission", "Edit course")),
(participation_permission.use_admin_interface,
pgettext_lazy("Participation permission", "Use admin interface")),
(participation_permission.manage_authentication_tokens,
pgettext_lazy("Participation permission",
"Manage authentication tokens")),
(participation_permission.impersonate_role,
pgettext_lazy("Participation permission", "Impersonate role")),
(participation_permission.set_fake_time,
......@@ -154,6 +165,9 @@ PARTICIPATION_PERMISSION_CHOICES = (
(participation_permission.included_in_grade_statistics,
pgettext_lazy("Participation permission",
"Included in grade statistics")),
(participation_permission.skip_during_manual_grading,
pgettext_lazy("Participation permission",
"Skip during manual grading")),
(participation_permission.edit_exam,
pgettext_lazy("Participation permission", "Edit exam")),
......@@ -162,9 +176,12 @@ PARTICIPATION_PERMISSION_CHOICES = (
(participation_permission.batch_issue_exam_ticket,
pgettext_lazy("Participation permission", "Batch issue exam ticket")),
(participation_permission.view_participant_masked_profile,
pgettext_lazy("Participation permission",
"View participants' masked profile only")),
(participation_permission.view_flow_sessions_from_role,
pgettext_lazy("Participation permission",
"View flow sessions from role ")),
"View flow sessions from role")),
(participation_permission.view_gradebook,
pgettext_lazy("Participation permission", "View gradebook")),
(participation_permission.edit_grading_opportunity,
......@@ -200,7 +217,7 @@ PARTICIPATION_PERMISSION_CHOICES = (
"Recalculate flow session grade")),
(participation_permission.batch_recalculate_flow_session_grade,
pgettext_lazy("Participation permission",
"Batch-recalculate flow sesssion grades")),
"Batch-recalculate flow session grades")),
(participation_permission.reopen_flow_session,
pgettext_lazy("Participation permission", "Reopen flow session")),
......@@ -213,6 +230,8 @@ PARTICIPATION_PERMISSION_CHOICES = (
pgettext_lazy("Participation permission", "Preview content")),
(participation_permission.update_content,
pgettext_lazy("Participation permission", "Update content")),
(participation_permission.use_git_endpoint,
pgettext_lazy("Participation permission", "Use direct git endpoint")),
(participation_permission.use_markup_sandbox,
pgettext_lazy("Participation permission", "Use markup sandbox")),
(participation_permission.use_page_sandbox,
......@@ -268,6 +287,7 @@ class flow_session_expiration_mode: # noqa
# allowed by special permission below
roll_over = "roll_over"
FLOW_SESSION_EXPIRATION_MODE_CHOICES = (
(flow_session_expiration_mode.end,
pgettext_lazy("Flow expiration mode", "Submit session for grading")),
......@@ -277,8 +297,9 @@ FLOW_SESSION_EXPIRATION_MODE_CHOICES = (
)
def is_expiration_mode_allowed(expmode, permissions):
# type: (str, frozenset[str]) -> bool
def is_expiration_mode_allowed(
expmode: str, permissions: frozenset[str]
) -> bool:
if expmode == flow_session_expiration_mode.roll_over:
if (flow_permission.set_roll_over_expiration_mode
in permissions):
......@@ -286,7 +307,7 @@ def is_expiration_mode_allowed(expmode, permissions):
elif expmode == flow_session_expiration_mode.end:
return True
else:
raise ValueError(ugettext("unknown expiration mode"))
raise ValueError(gettext("unknown expiration mode"))
return False
......@@ -332,7 +353,7 @@ class flow_permission: # noqa
.. attribute:: see_answer_after_submission
If present, shows the correct answer to the participant after they have
submitted an answer of their own.
submitted an answer of their own (and are no longer able to change it).
.. attribute:: cannot_see_flow_result
......@@ -354,13 +375,18 @@ class flow_permission: # noqa
.. attribute:: lock_down_as_exam_session
(Optional) Once any page of the flow has been viewed, access to all content
Once any page of the flow has been viewed, access to all content
except for this session on this RELATE instance will be denied.
.. attribute:: send_email_about_flow_page
(Optional) If present, the participant can send interaction emails to
course staffs for questions for each page with that permission.
If present, the participant can send interaction emails to
course staff for questions for each page with that permission.
.. attribute:: hide_point_count
If present, the point count for the page will not be shown to
the participant.
"""
view = "view"
end_session = "end_session"
......@@ -374,6 +400,8 @@ class flow_permission: # noqa
see_session_time = "see_session_time"
lock_down_as_exam_session = "lock_down_as_exam_session"
send_email_about_flow_page = "send_email_about_flow_page"
hide_point_count = "hide_point_count"
FLOW_PERMISSION_CHOICES = (
(flow_permission.view,
......@@ -406,6 +434,9 @@ FLOW_PERMISSION_CHOICES = (
(flow_permission.send_email_about_flow_page,
pgettext_lazy("Flow permission",
"Send emails about the flow page to course staff")),
(flow_permission.hide_point_count,
pgettext_lazy("Flow permission",
"Hide point count")),
)
# }}}
......@@ -533,5 +564,14 @@ EXAM_TICKET_STATE_CHOICES = (
ATTRIBUTES_FILENAME = ".attributes.yml"
DEFAULT_ACCESS_KINDS = ["public", "in_exam", "student", "ta",
"unenrolled", "instructor"]
# {{{ session attributes
SESSION_LOCKED_TO_FLOW_PK = "relate_session_locked_to_exam_flow_session_pk"
# }}}
# vim: foldmethod=marker
This diff is collapsed.