Skip to content
from __future__ import annotations
__copyright__ = "Copyright (C) 2024 University of Illinois Board of Trustees"
__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 django.core.validators import RegexValidator
from django.db import models, transaction
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from course.models import Course
FACILITY_ID_REGEX = "(?P<facility_id>[a-zA-Z][a-zA-Z0-9_]*)"
class Facility(models.Model):
id = models.BigAutoField(primary_key=True)
course = models.ForeignKey(Course, on_delete=models.CASCADE)
identifier = models.CharField(max_length=200, unique=True,
validators=[RegexValidator(f"^{FACILITY_ID_REGEX}$")],
db_index=True)
description = models.TextField(blank=True, null=True)
secret = models.CharField(max_length=220)
class Meta:
indexes = [
models.Index(fields=["course", "identifier"]),
]
verbose_name_plural = _("Facilities")
def __str__(self) -> str:
return f"PrairieTest Facility '{self.identifier}' in {self.course.identifier}"
class Event(models.Model):
id = models.BigAutoField(primary_key=True)
facility = models.ForeignKey(Facility, on_delete=models.CASCADE)
event_id = models.UUIDField()
created = models.DateTimeField(verbose_name=_("Created time"))
received_time = models.DateTimeField(default=now,
verbose_name=_("Received time"))
class Meta:
abstract = True
indexes = [
models.Index(fields=["event_id"]),
models.Index(fields=["created"]),
]
class AllowEvent(Event):
user_uid = models.CharField(max_length=200)
user_uin = models.CharField(max_length=200)
exam_uuid = models.UUIDField()
start = models.DateTimeField(verbose_name=_("Start time"))
end = models.DateTimeField(verbose_name=_("End time"))
cidr_blocks = models.JSONField()
def __str__(self) -> str:
return f"PrairieTest allow event {self.event_id} for {self.user_uid}"
class Meta:
indexes = [
models.Index(fields=["user_uid", "exam_uuid", "start"]),
models.Index(fields=["user_uid", "exam_uuid", "end"]),
]
class DenyEvent(Event):
deny_uuid = models.UUIDField()
start = models.DateTimeField(verbose_name=_("Start time"))
end = models.DateTimeField(verbose_name=_("End time"))
cidr_blocks = models.JSONField()
def __str__(self) -> str:
return f"PrairieTest deny event {self.event_id} with {self.deny_uuid}"
class Meta:
indexes = [
models.Index(fields=["deny_uuid", "created"]),
models.Index(fields=["deny_uuid", "start"]),
models.Index(fields=["deny_uuid", "end"]),
]
class MostRecentDenyEvent(models.Model):
id = models.BigAutoField(primary_key=True)
deny_uuid = models.UUIDField(unique=True)
end = models.DateTimeField(verbose_name=_("End time"))
event = models.ForeignKey(DenyEvent, on_delete=models.CASCADE)
class Meta:
indexes = [
models.Index(fields=["end"]),
]
def __str__(self) -> str:
return f"PrairieTest current deny event with {self.deny_uuid}"
def save_deny_event(devt: DenyEvent) -> None:
with transaction.atomic():
devt.save()
try:
mrde = (MostRecentDenyEvent
.objects.select_for_update().prefetch_related("event")
.get(deny_uuid=devt.deny_uuid))
except MostRecentDenyEvent.DoesNotExist:
mrde = MostRecentDenyEvent(
deny_uuid=devt.deny_uuid,
end=devt.end,
event=devt)
mrde.save()
if mrde.event.created < devt.created:
mrde.end = devt.end
mrde.event = devt
mrde.save()
from __future__ import annotations
__copyright__ = "Copyright (C) 2024 University of Illinois Board of Trustees"
__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 django.urls import re_path
from course.constants import COURSE_ID_REGEX
from prairietest.models import FACILITY_ID_REGEX
from prairietest.views import webhook
app_name = "prairietest"
urlpatterns = [
re_path(
r"^course"
"/" + COURSE_ID_REGEX
+ "/webhook"
"/" + FACILITY_ID_REGEX
+ "/",
webhook, name="webhook")
]
from __future__ import annotations
__copyright__ = """
Copyright (C) 2024 University of Illinois Board of Trustees
Copyright (C) 2024 PrairieTest, Inc.
"""
__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.
The following license applies to the PrairieTest-derived code below:
Example Python code snippets on this page are provided by PrairieLearn, Inc. and are
released under the [CC0 1.0 Universal (CC0 1.0) Public Domain Dedication](
https://creativecommons.org/publicdomain/zero/1.0/). You may copy, modify, and
distribute these code snippets, even for commercial purposes, all without asking
permission and without attribution. The code snippets are provided as-is, without
any warranty, and PrairieLearn, Inc. disclaims all liability for any damages
resulting from their use.
(via a response from Matt West on the PrairieLearn Slack space.)
"""
import hashlib
import hmac
import time
from collections.abc import Collection, Mapping, Sequence
from datetime import datetime
from functools import lru_cache
from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network, ip_network
from zoneinfo import ZoneInfo
from django.db.models import Q
from course.models import Course
from prairietest.models import AllowEvent, DenyEvent, MostRecentDenyEvent
# {{{ begin code copied from PrairieTest docs
# Source:
# https://us.prairietest.com/pt/docs/api/exam-access
# Retrieved Sep 10, 2024
# included with light modifications (type annotation, linter conformance)
def check_signature(
headers: Mapping[str, str],
body: bytes,
secret: str,
*, now_timestamp: float | None = None,
) -> tuple[bool, str]:
"""Check the signature of a webhook event.
Arguments:
headers -- a dictionary of HTTP headers from the webhook request
body -- the body of the webhook request (as a bytes object)
secret -- the shared secret string
Returns:
A tuple (signature_ok, message) where signature_ok is True if the signature
is valid, False otherwise,
and message is a string describing the reason for the failure (if any).
"""
if "PrairieTest-Signature" not in headers:
return False, "Missing PrairieTest-Signature header"
prairietest_signature = headers["PrairieTest-Signature"]
# get the timestamp
timestamp = None
for block in prairietest_signature.split(","):
if block.startswith("t="):
timestamp = block[2:]
break
if timestamp is None:
return False, "Missing timestamp in PrairieTest-Signature"
# check the timestamp
try:
timestamp_val = int(timestamp)
except ValueError:
return False, "Invalid timestamp in PrairieTest-Signature"
if now_timestamp is None:
now_timestamp = time.time()
if abs(timestamp_val - now_timestamp) > 3000:
return False, "Timestamp in PrairieTest-Signature is too old or too new"
# get the signature
signature = None
for block in prairietest_signature.split(","):
if block.startswith("v1="):
signature = block[3:]
break
if signature is None:
return False, "Missing v1 signature in PrairieTest-Signature"
# check the signature
signed_payload = bytes(timestamp, "ascii") + b"." + body
expected_signature = hmac.new(
secret.encode("utf-8"), signed_payload, hashlib.sha256).digest().hex()
if signature != expected_signature:
return False, "Incorrect v1 signature in PrairieTest-Signature"
# everything checks out
return True, ""
# }}}
def has_access_to_exam(
course: Course,
user_uid: str,
exam_uuid: str,
now: datetime,
ip_address: IPv4Address | IPv6Address
) -> AllowEvent | None:
facility_id_to_most_recent_allow_event: dict[int, AllowEvent] = {}
for allow_event in AllowEvent.objects.filter(
facility__course=course,
user_uid=user_uid,
exam_uuid=exam_uuid
).order_by("created").prefetch_related("facility"):
facility_id_to_most_recent_allow_event[
allow_event.facility.id] = allow_event
for allow_event in facility_id_to_most_recent_allow_event.values():
if now < allow_event.start or allow_event.end < now:
return None
if any(
ip_address in ip_network(cidr_block)
for cidr_block in allow_event.cidr_blocks):
return allow_event
return None
def denials_at(
now: datetime,
course: Course | None = None,
) -> Sequence[DenyEvent]:
qs = MostRecentDenyEvent.objects.all()
if course is not None:
qs = qs.filter(event__facility__course=course)
return [
mrde.event
for mrde in qs.filter(
Q(end__gte=now) & Q(event__start__lte=now)
).prefetch_related(
"event",
"event__facility",
"event__facility__course")
]
def _get_denials_at(
now_bucket: int,
course_id: int | None,
) -> Mapping[tuple[int, str], Collection[IPv6Network | IPv4Network]]:
from django.conf import settings
tz = ZoneInfo(settings.TIME_ZONE)
deny_events = denials_at(
datetime.fromtimestamp(now_bucket, tz=tz),
Course.objects.get(id=course_id) if course_id is not None else None,
)
result: dict[tuple[int, str], set[IPv6Network | IPv4Network]] = {}
for devt in deny_events:
result.setdefault(
(devt.facility.course.id, devt.facility.identifier),
set()
).update(ip_network(cidr_block) for cidr_block in devt.cidr_blocks)
return result
_get_denials_at_cached = lru_cache(10)(_get_denials_at)
def denied_ip_networks_at(
now: datetime | None = None,
course: Course | None = None,
cache: bool = True,
) -> Mapping[tuple[int, str], Collection[IPv6Network | IPv4Network]]:
"""
:returns: a mapping from (course_id, test_facility_id) to a collection of
networks.
"""
if now is None:
from django.utils import timezone
now = timezone.now()
if not cache:
get_denials = _get_denials_at
else:
get_denials = _get_denials_at_cached
return get_denials(
int(now.timestamp() // 60) * 60,
course.id if course else None,
)
from __future__ import annotations
__copyright__ = "Copyright (C) 2024 University of Illinois Board of Trustees"
__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.
"""
import json
from datetime import datetime
from django import http
from django.core.exceptions import BadRequest, SuspiciousOperation
from django.shortcuts import get_object_or_404
from django.views.decorators.csrf import csrf_exempt
from prairietest.models import (
AllowEvent,
DenyEvent,
Facility,
save_deny_event,
)
from prairietest.utils import check_signature
@csrf_exempt
def webhook(
request: http.HttpRequest,
course_identifier: str,
facility_id: str,
) -> http.HttpResponse:
body = request.body
facility = get_object_or_404(
Facility,
course__identifier=course_identifier,
identifier=facility_id,
)
sig_valid, msg = check_signature(request.headers, body, facility.secret)
if not sig_valid:
raise SuspiciousOperation(f"Invalid PrairieTest signature: {msg}")
event = json.loads(body)
api_ver: str = event["api_version"]
if api_ver != "2023-07-18":
raise BadRequest(f"Unknown PrairieTest API version: {api_ver}")
event_id: str = event["id"]
if (
AllowEvent.objects.filter(
facility=facility, event_id=event_id).exists()
or DenyEvent.objects.filter(
facility=facility, event_id=event_id).exists()
):
return http.HttpResponse(b"OK", content_type="text/plain", status=200)
evt_type: str = event["type"]
created = datetime.fromisoformat(event["created"])
data = event["data"]
del event
if evt_type == "allow_access":
user_uid: str = data["user_uid"]
exam_uuid: str = data["exam_uuid"]
has_newer_allows = AllowEvent.objects.filter(
facility=facility,
user_uid=user_uid,
exam_uuid=exam_uuid,
created__gte=created,
).exists()
if not has_newer_allows:
allow_evt = AllowEvent(
facility=facility,
event_id=event_id,
created=created,
user_uid=user_uid,
user_uin=data["user_uin"],
exam_uuid=exam_uuid,
start=datetime.fromisoformat(data["start"]),
end=datetime.fromisoformat(data["end"]),
cidr_blocks=data["cidr_blocks"],
)
allow_evt.save()
return http.HttpResponse(b"OK", content_type="text/plain", status=200)
elif evt_type == "deny_access":
deny_uuid = data["deny_uuid"]
has_newer_denies = DenyEvent.objects.filter(
facility=facility,
deny_uuid=deny_uuid,
created__gte=created,
).exists()
if not has_newer_denies:
deny_evt = DenyEvent(
facility=facility,
event_id=event_id,
created=created,
deny_uuid=deny_uuid,
start=datetime.fromisoformat(data["start"]),
end=datetime.fromisoformat(data["end"]),
cidr_blocks=data["cidr_blocks"],
)
save_deny_event(deny_evt)
return http.HttpResponse(b"OK", content_type="text/plain", status=200)
else:
raise BadRequest(f"Unknown PrairieTest event type: {evt_type}")
[build-system]
# Works around a setuptools 50 issue
# AK, 2020-12-11
# taken from https://github.com/AnalogJ/lexicon/pull/629/files
requires = [
"setuptools!=50",
"poetry>=0.12",
]
build-backend = "poetry.masonry.api"
[tool.poetry]
name = "relate-courseware"
version = "2024.1"
description = "RELATE courseware"
readme = "README.rst"
repository = "https://github.com/inducer/relate"
documentation = "https://documen.tician.de/relate/"
authors = ["Andreas Kloeckner <inform@tiker.net>"]
license = "MIT"
packages = [
{ include = "course" },
{ include = "accounts" },
{ include = "relate" },
# { include = "bin" },
]
[tool.poetry.scripts]
relate = "relate.bin.relate:main"
[tool.poetry.dependencies]
# keep consistent with ruff target version below
python = ">=3.10,<4"
django = "^5.1.4"
# Automatically renders Django forms in a pretty, Bootstrap-compatible way.
django-crispy-forms = ">=1.13.0"
# 0.6 has broken checkbox rendering
# https://github.com/django-crispy-forms/crispy-bootstrap5/commit/8bccd516aa9ce336a1069b6c9346e0c4c9e8270f
crispy-bootstrap5 = "^2024.10"
# Page data, answer data, ... all represented in JSON. This makes that editable in the Django admin.
jsonfield = ">=1.4.0"
# /!\ Upstream is dead, using branch for Django 3.0 support
django-yamlfield = {git = "https://github.com/bakatrouble/django-yamlfield.git", rev = "c92d0373d12a02d1e52fb09b44010f156111d7ea"}
# For easy content formatting:
markdown = "^3.7"
minijinja= "^2.5.0"
# For math/symbolic questions
pymbolic = "*"
sympy = "*"
# Course content is stored in YAML.
pyyaml = "*"
# Dulwich git integration
dulwich = "^0.22.6"
paramiko = "*"
# https://github.com/poezio/slixmpp/commit/53dc9847e2a4110be85ad16af9b427f9a280aaee#commitcomment-69121492
slixmpp = "^1.8.3"
# For code isolation in code questions
# 7.1 bumps the minimum API version to 1.24, which is unsupported by podman
docker = "^7.1.0"
# For code highlighting, required via the CodeHilite extension to Python-Markdown
pygments = "^2.6.1"
# For grade export
unicodecsv = "^0.14.1"
# {{{ For interoperation with SAML2/Shibboleth
pysaml2 = "^7.4.2"
djangosaml2 = "^v1.7.0"
# }}}
# Try to avoid https://github.com/Julian/jsonschema/issues/449
attrs = ">=19"
python-memcached = "^1.59"
# {{{ celery and related
# A task queue, used to execute long-running tasks
celery = "^5.2.2"
kombu = "*"
# Avoid 2.3.0 because of https://github.com/celery/django-celery-results/issues/293
django-celery-results = "^2.4.0"
# }}}
# For searchable select widgets
django_select2 = "^8.2.1"
# To sanitize HTML generated by user code
bleach = "^6.2"
# bleach is based on html5lib, but they vendor it. I don't think we should fish the
# vendored bits out of bleach, so we'll introduce our own dependency for data-URI
# sanitization.
html5lib = "^1.1"
# For query lexing
pytools = ">=2024.1.8"
# For relate script
colorama = "*"
# not a direct dependency
# version constraint is here because of CVE-2020-25659
cryptography = ">=3.2.1"
social-auth-app-django = "^5.4.1"
psycopg2 = { version = "^2.9.10", optional = true }
pylibmc = { version = "^1.6.0", optional = true }
# urllib3 2.x seems incompatible with dulwich?
# "urllib3.exceptions.LocationValueError: No host specified."
urllib3 = "^2.3.0"
[tool.poetry.dev-dependencies]
factory_boy = "^3.3.1"
ruff = "^0.8"
django-stubs = { version ="5.1.*", extras = ["compatible-mypy"] }
pytest = "^8"
pytest-django = "^4.5.2"
pytest-factoryboy = "^2.6.0"
pytest-pudb = "^0.7"
safety = "^3.2.11"
sphinx = "^8.1.3"
furo = "^2024.8.6"
sphinx-copybutton = "^0.5.2"
django-upgrade = "^1.22.1"
pyright = "^1"
types-bleach = "^6.2.0"
types-paramiko = "^3.5.0"
types-Markdown = "^3.7"
types-PyYAML = "^6.0.12"
# tests for code questions use question code that has numpy
numpy = "^2.1"
yamllint = "^1.32.0"
# enable with "-E postgres"
[tool.poetry.extras]
postgres = ["psycopg2"]
memcache = ["pylibmc"]
[tool.ruff]
preview = true
target-version = "py310"
exclude = ["contrib/jupyterlite"]
[tool.ruff.lint]
extend-select = [
"B", # flake8-bugbear
"C", # flake8-comprehensions
"E", # pycodestyle
"F", # pyflakes
"I", # flake8-isort
"N", # pep8-naming
"NPY", # numpy
"Q", # flake8-quotes
"W", # pycodestyle
"RUF",
"UP",
# "DJ",
]
extend-ignore = [
"C90", # McCabe complexity
"E221", # multiple spaces before operator
"E226", # missing whitespace around arithmetic operator
"E241", # multiple spaces after comma
"E242", # tab after comma
"E402", # module level import not at the top of file
"N818", # error suffix in exception names
# TODO
"B904", # raise in except needs from
"B028", # stacklevel
"RUF012", # mutable class atttributes
"UP031", # %-format
]
allowed-confusables = ["‐", "–"]
[tool.ruff.lint.flake8-quotes]
docstring-quotes = "double"
inline-quotes = "double"
multiline-quotes = "double"
[tool.ruff.lint.per-file-ignores]
"course/mdx_mathjax.py" = ["N802"]
# config file, no type annotations, avoid scaring users
"local_settings_example.py" = ["I002"]
# copy-pasted from elsewhere, not our problem
"saml-config/attribute-maps/*.py" = ["Q", "E231", "W292", "W291", "E501", "I002"]
# mostly generated
"*/migrations/*.py" = ["Q", "E501", "RUF012", "F401", "I002", "UP"]
# TODO
"tests/**/*.py" = ["F841", "RUF012"]
[tool.ruff.lint.isort]
combine-as-imports = true
lines-after-imports = 2
required-imports = ["from __future__ import annotations"]
[tool.mypy]
plugins = ["mypy_django_plugin.main"]
strict_optional = true
ignore_missing_imports = true
disallow_untyped_calls = "true"
# wishful thinking :)
# disallow_untyped_defs= "true"
show_error_codes = true
untyped_calls_exclude = [
"dulwich",
]
[tool.django-stubs]
django_settings_module = "relate.settings"
strict_settings = false
[[tool.mypy.overrides]]
module = [
"course.migrations.*",
]
ignore_errors = true
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "relate.settings"
python_files = [
"tests.py",
"test_*.py",
"*_tests.py",
]
markers = [
"slow: marks tests as slow (run with '--slow')",
"postgres: mark test as postgres specific",
]
[tool.typos.default]
extend-ignore-re = [
"(?Rm)^.*(#|//)\\s*spellchecker:\\s*disable-line"
]
[tool.typos.default.extend-words]
# opps as in 'grading opportunities'
opps = "opps"
# fre, short for flow rule exception
fre = "fre"
# short for 'stipulation'
stip = "stip"
# as in documen.tician.de
documen = "documen"
# like sur_name in SAML
sur = "sur"
[tool.typos.files]
extend-exclude = [
"locale/**/*.po",
# migrations reflect the past, don't attempt to alter them.
"*/migrations/**/*.py",
# not ours
"saml-config/**/*.py",
"contrib/jupyterlite/mamba-root",
]
# vim: foldmethod=marker
from __future__ import absolute_import
from __future__ import annotations
# This will make sure the app is always imported when
# Django starts so that shared_task will use this app.
from .celery import app as celery_app # noqa
import os
if "RELATE_COMMAND_LINE" not in os.environ:
# This will make sure the app is always imported when
# Django starts so that shared_task will use this app.
from .celery import app as celery_app # noqa
from __future__ import annotations
if __name__ == "__main__":
from relate.bin.relate import main
main()
#! /usr/bin/env python3
from __future__ import annotations
import sys
import io
import sys
from typing import Any
# {{{ validate_course
def validate(args):
def validate_course(args):
from django.conf import settings
settings.configure(DEBUG=True)
......@@ -16,11 +20,53 @@ def validate(args):
course_file=args.course_file,
events_file=args.events_file)
if has_warnings:
if has_warnings and args.warn_error:
return 1
else:
return 0
# }}}
# {{{ validate_pages
def validate_pages(args):
from django.conf import settings
settings.configure(DEBUG=True)
import django
django.setup()
from course.validation import (
FileSystemFakeRepo,
ValidationContext,
get_yaml_from_repo_safely,
validate_flow_page,
)
fake_repo = FileSystemFakeRepo(args.REPO_ROOT.encode("utf-8"))
vctx = ValidationContext(
repo=fake_repo,
commit_sha=fake_repo,
course=None)
for yaml_filename in args.PROBLEM_YMLS:
page_desc = get_yaml_from_repo_safely(fake_repo, yaml_filename,
commit_sha=fake_repo)
validate_flow_page(vctx, yaml_filename, page_desc)
if vctx.warnings:
print("WARNINGS: ")
for w in vctx.warnings:
print("***", w.location, w.text)
if vctx.warnings and args.warn_error:
return 1
else:
return 0
# }}}
# {{{ expand YAML
......@@ -28,32 +74,75 @@ def expand_yaml(yml_file, repo_root):
if yml_file == "-":
data = sys.stdin.read()
else:
with open(yml_file, "r") as inf:
with open(yml_file) as inf:
data = inf.read()
from course.content import (
process_yaml_for_expansion, YamlBlockEscapingFileSystemLoader)
YamlBlockEscapingFileSystemLoader,
process_yaml_for_expansion,
)
data = process_yaml_for_expansion(data)
from jinja2 import Environment, StrictUndefined
from minijinja import Environment
jinja_env = Environment(
loader=YamlBlockEscapingFileSystemLoader(repo_root),
undefined=StrictUndefined)
template = jinja_env.from_string(data)
data = template.render()
undefined_behavior="strict",
auto_escape_callback=lambda fn: False,
)
return data
return jinja_env.render_str(data)
# }}}
# {{{ code test
# {{{ lint YAML
def lint_yaml(args):
import os
from yamllint import linter
from yamllint.cli import show_problems
from yamllint.config import YamlLintConfig
conf = YamlLintConfig(file=args.config_file)
def test_code_question(page_desc, repo_root):
had_problems = False
def check_file(name):
nonlocal had_problems
# expanded yaml is missing a newline at the end of the
# file which causes the linter to complain, so we add a
# newline :)
expanded_yaml = expand_yaml(name, args.repo_root) + "\n"
problems = list(linter.run(expanded_yaml, conf))
show_problems(problems, name, "auto", None)
had_problems = had_problems or bool(problems)
for item in args.files:
if os.path.isdir(item):
for root, _, filenames in os.walk(item):
for f in filenames:
filepath = os.path.join(root, f)
if not conf.is_file_ignored(f) and conf.is_yaml_file(f):
check_file(filepath)
else:
check_file(item)
print(f"{had_problems=}")
return int(had_problems)
# }}}
# {{{ code test
def test_code_question(page_desc, repo_root) -> bool:
if page_desc.type not in [
"PythonCodeQuestion",
"PythonCodeQuestionWithHumanTextFeedback"]:
return
return True
print(75*"-")
print("TESTING", page_desc.id, "...", end=" ")
......@@ -64,11 +153,12 @@ def test_code_question(page_desc, repo_root):
correct_code = getattr(page_desc, "correct_code", "")
from course.page.code_runpy_backend import \
substitute_correct_code_into_test_code
from course.page.code_run_backend import (
substitute_correct_code_into_test_code,
)
test_code = substitute_correct_code_into_test_code(test_code, correct_code)
from course.page.code_runpy_backend import run_code, package_exception
from course.page.code_run_backend import package_exception, run_code
data_files = {}
......@@ -80,17 +170,19 @@ def test_code_question(page_desc, repo_root):
run_req = {
"setup_code": getattr(page_desc, "setup_code", ""),
"names_for_user": getattr(page_desc, "names_for_user", []),
"user_code": getattr(page_desc, "correct_code", ""),
"user_code": (
getattr(page_desc, "check_user_code", "")
or getattr(page_desc, "correct_code", "")),
"names_from_user": getattr(page_desc, "names_from_user", []),
"test_code": test_code,
"data_files": data_files,
}
response = {}
response: dict[str, Any] = {}
prev_stdin = sys.stdin # noqa
prev_stdout = sys.stdout # noqa
prev_stderr = sys.stderr # noqa
prev_stdin = sys.stdin
prev_stdout = sys.stdout
prev_stderr = sys.stderr
stdout = io.StringIO()
stderr = io.StringIO()
......@@ -99,17 +191,17 @@ def test_code_question(page_desc, repo_root):
start = time()
try:
sys.stdin = None
sys.stdin = None # type: ignore[assignment]
sys.stdout = stdout
sys.stderr = stderr
from relate.utils import Struct
run_code(response, Struct(run_req))
run_code(response, Struct(run_req)) # type: ignore[no-untyped-call]
response["stdout"] = stdout.getvalue()
response["stderr"] = stderr.getvalue()
except:
except Exception:
response = {}
package_exception(response, "uncaught_error")
......@@ -120,9 +212,8 @@ def test_code_question(page_desc, repo_root):
stop = time()
response["timeout"] = (
"Execution took %.1f seconds. "
"(Timeout is %.1f seconds.)"
% (stop-start, page_desc.timeout))
f"Execution took {stop-start:.1f} seconds. "
f"(Timeout is {page_desc.timeout:.1f} seconds.)")
from colorama import Fore, Style
if response["result"] == "success":
......@@ -131,17 +222,21 @@ def test_code_question(page_desc, repo_root):
print(Fore.RED
+ "FAIL: no points value recorded"
+ Style.RESET_ALL)
success = False
elif points < 1:
print(Fore.RED
+ "FAIL: correct_code did not pass test"
+ "FAIL: code did not pass test"
+ Style.RESET_ALL)
success = False
else:
print(Fore.GREEN+response["result"].upper()+Style.RESET_ALL)
success = True
else:
print(Style.BRIGHT+Fore.RED
+ response["result"].upper()+Style.RESET_ALL)
success = False
def print_response_aspect(s):
def print_response_aspect(s: str) -> None:
if s not in response:
return
......@@ -164,16 +259,19 @@ def test_code_question(page_desc, repo_root):
print_response_aspect("stderr")
print_response_aspect("timeout")
return success
def test_code_yml(yml_file, repo_root):
data = expand_yaml(yml_file, repo_root)
from yaml import load
from yaml import safe_load
from relate.utils import dict_to_struct
data = dict_to_struct(load(data))
data = dict_to_struct(safe_load(data))
if hasattr(data, "id") and hasattr(data, "type"):
test_code_question(data, repo_root)
return test_code_question(data, repo_root)
else:
if hasattr(data, "groups"):
......@@ -186,20 +284,24 @@ def test_code_yml(yml_file, repo_root):
else:
from colorama import Fore, Style
print(Fore.RED + Style.BRIGHT
+ "'%s' does not look like a valid flow or page file"
% yml_file
+ f"'{yml_file}' does not look like a valid flow or page file"
+ Style.RESET_ALL)
return
for page in pages:
test_code_question(page, repo_root)
res = test_code_question(page, repo_root)
if not res:
return False
return True
def test_code(args):
for yml_file in args.FLOW_OR_PROBLEM_YMLS:
print(75*"=")
print("EXAMINING", yml_file)
test_code_yml(yml_file, repo_root=args.repo_root)
if not test_code_yml(yml_file, repo_root=args.repo_root):
return 1
return 0
......@@ -210,30 +312,50 @@ def expand_yaml_ui(args):
print(expand_yaml(args.YAML_FILE, args.repo_root))
def main():
def main() -> None:
pass
import os
import argparse
import os
os.environ["RELATE_COMMAND_LINE"] = "1"
parser = argparse.ArgumentParser(
description='RELATE course content command line tool')
description="RELATE course content command line tool")
subp = parser.add_subparsers()
parser_validate = subp.add_parser("validate")
parser_validate.add_argument("--course-file", default="course.yml")
parser_validate.add_argument("--events-file", default="events.yml")
parser_validate.add_argument('REPO_ROOT', default=os.getcwd())
parser_validate.set_defaults(func=validate)
parser_validate_course = subp.add_parser("validate")
parser_validate_course.add_argument("--course-file", default="course.yml")
parser_validate_course.add_argument("--events-file", default="events.yml")
parser_validate_course.add_argument("--warn-error", action="store_true",
help="Treat warnings as errors")
parser_validate_course.add_argument("REPO_ROOT", default=os.getcwd())
parser_validate_course.set_defaults(func=validate_course)
parser_validate_page = subp.add_parser("validate-page")
parser_validate_page.add_argument("--warn-error", action="store_true",
help="Treat warnings as errors")
parser_validate_page.add_argument("REPO_ROOT", default=os.getcwd())
parser_validate_page.add_argument("PROBLEM_YMLS", nargs="+")
parser_validate_page.set_defaults(func=validate_pages)
parser_test_code = subp.add_parser("test-code")
parser_test_code.add_argument('--repo-root', default=os.getcwd())
parser_test_code.add_argument('FLOW_OR_PROBLEM_YMLS', nargs="+")
parser_test_code.add_argument("--repo-root", default=os.getcwd())
parser_test_code.add_argument("FLOW_OR_PROBLEM_YMLS", nargs="+")
parser_test_code.set_defaults(func=test_code)
parser_expand_yaml = subp.add_parser("expand-yaml")
parser_expand_yaml.add_argument('--repo-root', default=os.getcwd())
parser_expand_yaml.add_argument('YAML_FILE')
parser_expand_yaml.add_argument("--repo-root", default=os.getcwd())
parser_expand_yaml.add_argument("YAML_FILE")
parser_expand_yaml.set_defaults(func=expand_yaml_ui)
parser_lint_yaml = subp.add_parser("lint-yaml")
parser_lint_yaml.add_argument("--repo-root", default=os.getcwd())
parser_lint_yaml.add_argument("--config-file", default="./.yamllint")
parser_lint_yaml.add_argument("files", metavar="FILES",
nargs="+",
help="List of directories or files to lint")
parser_lint_yaml.set_defaults(func=lint_yaml)
args = parser.parse_args()
if not hasattr(args, "func"):
......@@ -247,3 +369,5 @@ def main():
if __name__ == "__main__":
main()
# vim: foldmethod=marker
from __future__ import absolute_import
from __future__ import annotations
import os
from celery import Celery
# set the default Django settings module for the 'celery' program.
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'relate.settings')
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "relate.settings")
from django.conf import settings
app = Celery('relate')
app = Celery("relate")
# Using a string here means the worker will not have to
# pickle the object when using Windows.
app.config_from_object('django.conf:settings')
app.config_from_object("django.conf:settings")
app.autodiscover_tasks(lambda: settings.INSTALLED_APPS)
@app.task(bind=True)
def debug_task(self):
print('Request: {0!r}'.format(self.request))
def debug_task(self): # pragma: no cover
print(f"Request: {self.request!r}")
from __future__ import annotations
__copyright__ = "Copyright (C) 2017 Dong Zhuang"
__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.
"""
import os
from collections.abc import Iterable
from django.conf import settings
from django.core.checks import Critical, Warning, register
from django.core.exceptions import ImproperlyConfigured
from django.utils.module_loading import import_string
REQUIRED_CONF_ERROR_PATTERN = (
"You must configure %(location)s for RELATE to run properly.")
INSTANCE_ERROR_PATTERN = "%(location)s must be an instance of %(types)s."
GENERIC_ERROR_PATTERN = "Error in '%(location)s': %(error_type)s: %(error_str)s"
USE_I18N = "USE_I18N"
LANGUAGES = "LANGUAGES"
RELATE_SITE_NAME = "RELATE_SITE_NAME"
RELATE_CUTOMIZED_SITE_NAME = "RELATE_CUTOMIZED_SITE_NAME"
RELATE_OVERRIDE_TEMPLATES_DIRS = "RELATE_OVERRIDE_TEMPLATES_DIRS"
EMAIL_CONNECTIONS = "EMAIL_CONNECTIONS"
RELATE_BASE_URL = "RELATE_BASE_URL"
RELATE_FACILITIES = "RELATE_FACILITIES"
RELATE_MAINTENANCE_MODE_EXCEPTIONS = "RELATE_MAINTENANCE_MODE_EXCEPTIONS"
RELATE_SESSION_RESTART_COOLDOWN_SECONDS = "RELATE_SESSION_RESTART_COOLDOWN_SECONDS"
RELATE_TICKET_MINUTES_VALID_AFTER_USE = "RELATE_TICKET_MINUTES_VALID_AFTER_USE"
GIT_ROOT = "GIT_ROOT"
RELATE_BULK_STORAGE = "RELATE_BULK_STORAGE"
RELATE_STARTUP_CHECKS = "RELATE_STARTUP_CHECKS"
RELATE_STARTUP_CHECKS_EXTRA = "RELATE_STARTUP_CHECKS_EXTRA"
RELATE_STARTUP_CHECKS_TAG = "start_up_check"
RELATE_STARTUP_CHECKS_EXTRA_TAG = "startup_checks_extra"
RELATE_DISABLE_CODEHILITE_MARKDOWN_EXTENSION = (
"RELATE_DISABLE_CODEHILITE_MARKDOWN_EXTENSION")
RELATE_CUSTOM_PAGE_TYPES_REMOVED_DEADLINE = (
"RELATE_CUSTOM_PAGE_TYPES_REMOVED_DEADLINE")
class RelateCriticalCheckMessage(Critical):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.obj = self.obj or ImproperlyConfigured.__name__
class DeprecatedException(Exception):
pass
def get_ip_network(ip_range):
import ipaddress
return ipaddress.ip_network(str(ip_range))
def check_relate_settings(app_configs, **kwargs):
errors = []
# {{{ check RELATE_BASE_URL
relate_base_url = getattr(settings, RELATE_BASE_URL, None)
if relate_base_url is None:
errors.append(RelateCriticalCheckMessage(
msg=REQUIRED_CONF_ERROR_PATTERN % {"location": RELATE_BASE_URL},
id="relate_base_url.E001"
))
elif not isinstance(relate_base_url, str):
errors.append(RelateCriticalCheckMessage(
msg=(INSTANCE_ERROR_PATTERN
% {"location": RELATE_BASE_URL, "types": "str"}),
id="relate_base_url.E002"
))
elif not relate_base_url.strip():
errors.append(RelateCriticalCheckMessage(
msg=f"{RELATE_BASE_URL} should not be an empty string",
id="relate_base_url.E003"
))
# }}}
from accounts.utils import relate_user_method_settings
# check RELATE_EMAIL_APPELLATION_PRIORITY_LIST
errors.extend(
relate_user_method_settings.check_email_appellation_priority_list())
# check RELATE_CSV_SETTINGS
errors.extend(relate_user_method_settings.check_custom_full_name_method())
# check RELATE_USER_PROFILE_MASK_METHOD
errors.extend(relate_user_method_settings.check_user_profile_mask_method())
# {{{ check EMAIL_CONNECTIONS
email_connections = getattr(settings, EMAIL_CONNECTIONS, None)
if email_connections is not None:
if not isinstance(email_connections, dict):
errors.append(RelateCriticalCheckMessage(
msg=(
INSTANCE_ERROR_PATTERN
% {"location": EMAIL_CONNECTIONS,
"types": "dict"}),
id="email_connections.E001"
))
else:
for label, c in email_connections.items():
if not isinstance(c, dict):
errors.append(RelateCriticalCheckMessage(
msg=(
INSTANCE_ERROR_PATTERN
% {"location": f"'{label}' in '{EMAIL_CONNECTIONS}'",
"types": "dict"}),
id="email_connections.E002"
))
else:
if "backend" in c:
try:
import_string(c["backend"])
except ImportError as e:
errors.append(RelateCriticalCheckMessage(
msg=(
GENERIC_ERROR_PATTERN
% {
"location":
f"'{label}' in {RELATE_FACILITIES}",
"error_type": type(e).__name__,
"error_str": str(e)
}),
id="email_connections.E003")
)
# }}}
# {{{ check RELATE_FACILITIES
relate_facilities_conf = getattr(settings, RELATE_FACILITIES, None)
if relate_facilities_conf is not None:
from course.utils import get_facilities_config
try:
facilities = get_facilities_config()
except Exception as e:
errors.append(RelateCriticalCheckMessage(
msg=(
GENERIC_ERROR_PATTERN
% {
"location": RELATE_FACILITIES,
"error_type": type(e).__name__,
"error_str": str(e)
}),
id="relate_facilities.E001")
)
else:
if not isinstance(facilities, dict):
errors.append(RelateCriticalCheckMessage(
msg=(
f"'{RELATE_FACILITIES}' must either be or return a dictionary"),
id="relate_facilities.E002")
)
else:
for facility, conf in facilities.items():
if not isinstance(conf, dict):
errors.append(RelateCriticalCheckMessage(
msg=(
INSTANCE_ERROR_PATTERN
% {"location":
f"Facility `{facility}` in {RELATE_FACILITIES}",
"types": "dict"}),
id="relate_facilities.E003")
)
else:
ip_ranges = conf.get("ip_ranges", [])
if ip_ranges:
if not isinstance(ip_ranges, list | tuple):
errors.append(RelateCriticalCheckMessage(
msg=(
INSTANCE_ERROR_PATTERN
% {"location":
f"'ip_ranges' in facility `{facilities}` in {RELATE_FACILITIES}", # noqa: E501
"types": "list or tuple"}),
id="relate_facilities.E004")
)
else:
for ip_range in ip_ranges:
try:
get_ip_network(ip_range)
except Exception as e:
errors.append(RelateCriticalCheckMessage(
msg=(
GENERIC_ERROR_PATTERN
% {
"location":
"'ip_ranges' in "
f"facility `{facility}` in {RELATE_FACILITIES}", # noqa: E501
"error_type": type(e).__name__,
"error_str": str(e)
}),
id="relate_facilities.E005")
)
else:
if not callable(relate_facilities_conf):
errors.append(Warning(
msg=(
f"Faclity `{facility}` in {RELATE_FACILITIES} is an open facility " # noqa: E501
"as it has no configured `ip_ranges`"
),
id="relate_facilities.W001"
))
# }}}
# {{{ check RELATE_MAINTENANCE_MODE_EXCEPTIONS
relate_maintenance_mode_exceptions = getattr(
settings, RELATE_MAINTENANCE_MODE_EXCEPTIONS, None)
if relate_maintenance_mode_exceptions is not None:
if not isinstance(relate_maintenance_mode_exceptions, list | tuple):
errors.append(RelateCriticalCheckMessage(
msg=(INSTANCE_ERROR_PATTERN
% {"location": RELATE_MAINTENANCE_MODE_EXCEPTIONS,
"types": "list or tuple"}),
id="relate_maintenance_mode_exceptions.E001")
)
else:
for ip in relate_maintenance_mode_exceptions:
try:
get_ip_network(ip)
except Exception as e:
errors.append(RelateCriticalCheckMessage(
msg=(
GENERIC_ERROR_PATTERN
% {"location":
f"ip/ip_ranges '{ip}' in {RELATE_FACILITIES}",
"error_type": type(e).__name__,
"error_str": str(e)
}),
id="relate_maintenance_mode_exceptions.E002")
)
# }}}
# {{{ check RELATE_SESSION_RESTART_COOLDOWN_SECONDS
relate_session_restart_cooldown_seconds = getattr(
settings, RELATE_SESSION_RESTART_COOLDOWN_SECONDS, None)
if relate_session_restart_cooldown_seconds is not None:
if not isinstance(relate_session_restart_cooldown_seconds, int | float):
errors.append(RelateCriticalCheckMessage(
msg=(INSTANCE_ERROR_PATTERN
% {"location": RELATE_SESSION_RESTART_COOLDOWN_SECONDS,
"types": "int or float"}),
id="relate_session_restart_cooldown_seconds.E001")
)
else:
if relate_session_restart_cooldown_seconds < 0:
errors.append(RelateCriticalCheckMessage(
msg=(
f"{RELATE_SESSION_RESTART_COOLDOWN_SECONDS} must be a positive number, " # noqa: E501
f"got {relate_session_restart_cooldown_seconds} instead"),
id="relate_session_restart_cooldown_seconds.E002")
)
# }}}
# {{{ check RELATE_TICKET_MINUTES_VALID_AFTER_USE
relate_ticket_minutes_valid_after_use = getattr(
settings, RELATE_TICKET_MINUTES_VALID_AFTER_USE, None)
if relate_ticket_minutes_valid_after_use is not None:
if not isinstance(relate_ticket_minutes_valid_after_use, int | float):
errors.append(RelateCriticalCheckMessage(
msg=(INSTANCE_ERROR_PATTERN
% {"location": RELATE_TICKET_MINUTES_VALID_AFTER_USE,
"types": "int or float"}),
id="relate_ticket_minutes_valid_after_use.E001")
)
else:
if relate_ticket_minutes_valid_after_use < 0:
errors.append(RelateCriticalCheckMessage(
msg=(
f"{RELATE_TICKET_MINUTES_VALID_AFTER_USE} must be a positive number, " # noqa: E501
f"got {relate_ticket_minutes_valid_after_use} instead"),
id="relate_ticket_minutes_valid_after_use.E002")
)
# }}}
# {{{ check GIT_ROOT
git_root = getattr(settings, GIT_ROOT, None)
if git_root is None:
errors.append(RelateCriticalCheckMessage(
msg=REQUIRED_CONF_ERROR_PATTERN % {"location": GIT_ROOT},
id="git_root.E001"
))
elif not isinstance(git_root, str):
errors.append(RelateCriticalCheckMessage(
msg=INSTANCE_ERROR_PATTERN % {"location": GIT_ROOT, "types": "str"},
id="git_root.E002"
))
else:
if not os.path.isdir(git_root):
errors.append(RelateCriticalCheckMessage(
msg=(f"`{git_root}` configured in {GIT_ROOT} is not a valid path"),
id="git_root.E003"
))
else:
if not os.access(git_root, os.W_OK):
errors.append(RelateCriticalCheckMessage(
msg=(f"`{git_root}` configured in {GIT_ROOT} is not writable "
"by RELATE"),
id="git_root.E004"
))
if not os.access(git_root, os.R_OK):
errors.append(RelateCriticalCheckMessage(
msg=(f"`{git_root}` configured in {GIT_ROOT} is not readable "
"by RELATE"),
id="git_root.E005"
))
# }}}
# {{{ check RELATE_BULK_STORAGE
bulk_storage = getattr(settings, RELATE_BULK_STORAGE, None)
from django.core.files.storage import Storage
if bulk_storage is None:
errors.append(RelateCriticalCheckMessage(
msg=REQUIRED_CONF_ERROR_PATTERN % {
"location": RELATE_BULK_STORAGE},
id="bulk_storage.E001"
))
elif not isinstance(bulk_storage, Storage):
errors.append(RelateCriticalCheckMessage(
msg=INSTANCE_ERROR_PATTERN % {
"location": RELATE_BULK_STORAGE, "types": "Storage"},
id="bulk_storage.E002"
))
# }}}
# {{{ check RELATE_DISABLE_CODEHILITE_MARKDOWN_EXTENSION
relate_disable_codehilite_markdown_extension = getattr(
settings, RELATE_DISABLE_CODEHILITE_MARKDOWN_EXTENSION, None)
if relate_disable_codehilite_markdown_extension is not None:
if not isinstance(relate_disable_codehilite_markdown_extension, bool):
errors.append(
Warning(
msg=f"{RELATE_DISABLE_CODEHILITE_MARKDOWN_EXTENSION} is not a Boolean value: `{relate_disable_codehilite_markdown_extension!r}`, " # noqa: E501
"assuming True",
id="relate_disable_codehilite_markdown_extension.W001"))
elif not relate_disable_codehilite_markdown_extension:
errors.append(
Warning(
msg="%(location)s is set to False "
"(with 'markdown.extensions.codehilite' enabled'), "
"noticing that some pages with code fence markdown "
"might crash"
% {"location":
RELATE_DISABLE_CODEHILITE_MARKDOWN_EXTENSION,
},
id="relate_disable_codehilite_markdown_extension.W002"))
# }}}
# {{{ check LANGUAGES, why this is not done in django?
languages = settings.LANGUAGES
if (isinstance(languages, str)
or not isinstance(languages, Iterable)):
errors.append(RelateCriticalCheckMessage(
msg=(INSTANCE_ERROR_PATTERN
% {"location": LANGUAGES,
"types": "an iterable (e.g., a list or tuple)."}),
id="relate_languages.E001")
)
else:
if any(isinstance(choice, str)
or not isinstance(choice, Iterable) or len(choice) != 2
for choice in languages):
errors.append(RelateCriticalCheckMessage(
msg=(f"'{LANGUAGES}' must be an iterable containing "
"(language code, language description) tuples, just "
"like the format of LANGUAGES setting ("
"https://docs.djangoproject.com/en/dev/ref/settings/"
"#languages)"),
id="relate_languages.E002")
)
else:
from collections import OrderedDict
options_dict = OrderedDict(tuple(settings.LANGUAGES))
all_lang_codes = [lang_code for lang_code, lang_descr
in tuple(settings.LANGUAGES)]
for lang_code in options_dict.keys():
if all_lang_codes.count(lang_code) > 1:
errors.append(Warning(
msg=(
"Duplicate language entries were found in "
f"settings.LANGUAGES for '{lang_code}', '{options_dict[lang_code]}' will be used " # noqa: E501
"as its language_description"),
id="relate_languages.W001"
))
# }}}
# {{{ check RELATE_SITE_NAME
try:
site_name = settings.RELATE_SITE_NAME
if site_name is None:
errors.append(
RelateCriticalCheckMessage(
msg=(f"{RELATE_SITE_NAME} must not be None"),
id="relate_site_name.E002")
)
else:
if not isinstance(site_name, str):
errors.append(RelateCriticalCheckMessage(
msg=(INSTANCE_ERROR_PATTERN
% {"location": f"{RELATE_SITE_NAME}/{RELATE_CUTOMIZED_SITE_NAME}", # noqa: E501
"types": "string"}),
id="relate_site_name.E003"))
elif not site_name.strip():
errors.append(RelateCriticalCheckMessage(
msg=(f"{RELATE_SITE_NAME} must not be an empty string"),
id="relate_site_name.E004"))
except AttributeError:
# This happens when RELATE_SITE_NAME is DELETED from settings.
errors.append(
RelateCriticalCheckMessage(
msg=(REQUIRED_CONF_ERROR_PATTERN
% {"location": RELATE_SITE_NAME}),
id="relate_site_name.E001")
)
# }}}
# {{{ check RELATE_OVERRIDE_TEMPLATES_DIRS
relate_override_templates_dirs = getattr(settings,
RELATE_OVERRIDE_TEMPLATES_DIRS, None)
if relate_override_templates_dirs is not None:
if (isinstance(relate_override_templates_dirs, str)
or not isinstance(relate_override_templates_dirs, Iterable)):
errors.append(RelateCriticalCheckMessage(
msg=(INSTANCE_ERROR_PATTERN
% {"location": RELATE_OVERRIDE_TEMPLATES_DIRS,
"types": "an iterable (e.g., a list or tuple)."}),
id="relate_override_templates_dirs.E001"))
else:
if any(not isinstance(directory, str)
for directory in relate_override_templates_dirs):
errors.append(RelateCriticalCheckMessage(
msg=(f"'{RELATE_OVERRIDE_TEMPLATES_DIRS}' must contain only string of paths."), # noqa: E501
id="relate_override_templates_dirs.E002"))
else:
for directory in relate_override_templates_dirs:
if not os.path.isdir(directory):
errors.append(
Warning(
msg=(
f"Invalid Templates Dirs item '{directory}' in '{RELATE_OVERRIDE_TEMPLATES_DIRS}', " # noqa: E501
"it will be ignored."),
id="relate_override_templates_dirs.W001"
))
# }}}
# {{{ check RELATE_CUSTOM_PAGE_TYPES_REMOVED_DEADLINE
relate_custom_page_types_removed_deadline = getattr(
settings, RELATE_CUSTOM_PAGE_TYPES_REMOVED_DEADLINE, None)
if relate_custom_page_types_removed_deadline is not None:
from datetime import datetime
if not isinstance(relate_custom_page_types_removed_deadline, datetime):
errors.append(RelateCriticalCheckMessage(
msg=(INSTANCE_ERROR_PATTERN
% {"location": RELATE_CUSTOM_PAGE_TYPES_REMOVED_DEADLINE,
"types": "datetime.datetime"}),
id="relate_custom_page_types_removed_deadline.E001"))
# }}}
return errors
def register_startup_checks():
register(check_relate_settings, RELATE_STARTUP_CHECKS_TAG)
def register_startup_checks_extra():
"""
Register extra checks provided by user.
Here we will have to raise error for Exceptions, as that can not be done
via check: all checks, including check_relate_settings, will only be
executed after AppConfig.ready() is done.
"""
startup_checks_extra = getattr(settings, RELATE_STARTUP_CHECKS_EXTRA, None)
if startup_checks_extra is not None:
if not isinstance(startup_checks_extra, list | tuple):
raise ImproperlyConfigured(
INSTANCE_ERROR_PATTERN
% {"location": RELATE_STARTUP_CHECKS_EXTRA,
"types": "list or tuple"
}
)
for c in startup_checks_extra:
try:
check_item = import_string(c)
except Exception as e:
raise ImproperlyConfigured(
GENERIC_ERROR_PATTERN
% {
"location": RELATE_STARTUP_CHECKS_EXTRA,
"error_type": type(e).__name__,
"error_str": str(e)
})
else:
register(check_item, RELATE_STARTUP_CHECKS_EXTRA_TAG)
# vim: foldmethod=marker
from __future__ import absolute_import
"""
Django settings for RELATE.
"""
from __future__ import annotations
import os
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
import sys
from collections.abc import Callable
from os.path import join
from typing import Any
from django.conf.global_settings import STORAGES
from django.utils.translation import gettext_noop
if False:
# for mypy
from typing import Callable, Any, Union # noqa
# Do not change this file. All these settings can be overridden in
# local_settings.py.
from django.conf.global_settings import STATICFILES_FINDERS
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
import os
from os.path import join
BASE_DIR = os.path.dirname(os.path.dirname(__file__))
RELATE_EMAIL_SMTP_ALLOW_NONAUTHORIZED_SENDER = True
_local_settings_file = join(BASE_DIR, "local_settings.py")
local_settings = {
"__file__": _local_settings_file,
}
try:
with open(_local_settings_file) as inf:
local_settings_contents = inf.read()
except IOError:
pass
else:
exec(compile(local_settings_contents, "local_settings.py", "exec"),
local_settings)
if os.environ.get("RELATE_LOCAL_TEST_SETTINGS", None):
# This is to make sure local_settings.py is not used for unit tests.
assert _local_settings_file != os.environ["RELATE_LOCAL_TEST_SETTINGS"]
_local_settings_file = os.environ["RELATE_LOCAL_TEST_SETTINGS"]
if not os.path.isfile(_local_settings_file):
raise RuntimeError(
f"Management command '{sys.argv[1]}' failed to run "
f"because '{_local_settings_file}' is missing.")
local_settings_module_name, ext = (
os.path.splitext(os.path.split(_local_settings_file)[-1]))
assert ext == ".py"
exec(f"import {local_settings_module_name} as local_settings_module")
local_settings = local_settings_module.__dict__ # type: ignore # noqa
# {{{ django: apps
......@@ -42,22 +51,29 @@ INSTALLED_APPS = (
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"social_django",
"crispy_forms",
"crispy_bootstrap5",
"jsonfield",
"bootstrap3_datetime",
"djangobower",
"django_select2",
# message queue
"djcelery",
"kombu.transport.django",
"django_celery_results",
"accounts",
"course",
"prairietest",
)
if local_settings["RELATE_SIGN_IN_BY_SAML2_ENABLED"]:
INSTALLED_APPS = INSTALLED_APPS + ("djangosaml2",) # type: ignore
if local_settings.get("RELATE_SIGN_IN_BY_SAML2_ENABLED"):
INSTALLED_APPS = (*INSTALLED_APPS, "djangosaml2",) # type: ignore
SOCIAL_AUTH_POSTGRES_JSONFIELD = (
"DATABASES" in local_settings
and local_settings["DATABASES"]["default"]["ENGINE"]
== "django.db.backends.postgresql")
# }}}
......@@ -69,7 +85,6 @@ MIDDLEWARE = (
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.auth.middleware.SessionAuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"course.auth.ImpersonateMiddleware",
......@@ -77,62 +92,68 @@ MIDDLEWARE = (
"course.exam.ExamFacilityMiddleware",
"course.exam.ExamLockdownMiddleware",
"relate.utils.MaintenanceMiddleware",
"social_django.middleware.SocialAuthExceptionMiddleware",
)
if local_settings.get("RELATE_SIGN_IN_BY_SAML2_ENABLED"):
MIDDLEWARE = (*MIDDLEWARE, # type: ignore
"djangosaml2.middleware.SamlSessionMiddleware",)
CSRF_COOKIE_NAME = "relate_csrftoken"
# }}}
# {{{ django: auth
AUTH_USER_MODEL = "accounts.User"
AUTHENTICATION_BACKENDS = (
"course.auth.TokenBackend",
"course.auth.EmailedTokenBackend",
"course.auth.APIBearerTokenBackend",
"course.exam.ExamTicketBackend",
"django.contrib.auth.backends.ModelBackend",
)
if local_settings["RELATE_SIGN_IN_BY_SAML2_ENABLED"]:
AUTHENTICATION_BACKENDS = AUTHENTICATION_BACKENDS + ( # type: ignore
'course.auth.Saml2Backend',
if local_settings.get("RELATE_SIGN_IN_BY_SAML2_ENABLED"):
AUTHENTICATION_BACKENDS = (*AUTHENTICATION_BACKENDS, # type: ignore
"course.auth.RelateSaml2Backend",
)
AUTH_USER_MODEL = 'accounts.User'
if local_settings.get("RELATE_SOCIAL_AUTH_BACKENDS"):
AUTHENTICATION_BACKENDS = (
AUTHENTICATION_BACKENDS
+ local_settings["RELATE_SOCIAL_AUTH_BACKENDS"])
# }}}
SOCIAL_AUTH_PIPELINE = (
"social_core.pipeline.social_auth.social_details",
"social_core.pipeline.social_auth.social_uid",
"social_core.pipeline.social_auth.social_user",
"social_core.pipeline.user.get_username",
# {{{ bower packages
"course.auth.social_auth_check_domain_against_blacklist",
BOWER_COMPONENTS_ROOT = os.path.join(BASE_DIR, "components")
# /!\ Assumes that providers only provide verified emails
"social_core.pipeline.social_auth.associate_by_email",
STATICFILES_FINDERS = tuple(STATICFILES_FINDERS) + (
"djangobower.finders.BowerFinder",
)
"social_core.pipeline.user.create_user",
"social_core.pipeline.social_auth.associate_user",
"social_core.pipeline.social_auth.load_extra_data",
"social_core.pipeline.user.user_details",
BOWER_INSTALLED_APPS = (
"bootstrap#3.3.4",
"fontawesome#4.4.0",
"videojs#5.6.0",
"MathJax",
"codemirror#5.2.0",
"fullcalendar#2.3.1",
"jqueryui",
"datatables.net",
"datatables-i18n",
"datatables.net-bs",
"datatables.net-fixedcolumns",
"datatables.net-fixedcolumns-bs",
"jstree#3.2.1",
"select2#4.0.1",
"select2-bootstrap-css",
)
"course.auth.social_set_user_email_verified",
)
CODEMIRROR_PATH = "codemirror"
SOCIAL_AUTH_ADMIN_USER_SEARCH_FIELDS = [
"username", "first_name", "last_name", "email"]
LOGIN_ERROR_URL = "/"
# }}}
ROOT_URLCONF = 'relate.urls'
ROOT_URLCONF = "relate.urls"
WSGI_APPLICATION = 'relate.wsgi.application'
CRISPY_FAIL_SILENTLY = False
# {{{ templates
WSGI_APPLICATION = "relate.wsgi.application"
# {{{ context processors
......@@ -142,11 +163,17 @@ RELATE_EXTRA_CONTEXT_PROCESSORS = (
"course.views.fake_time_context_processor",
"course.views.pretend_facilities_context_processor",
"course.exam.exam_lockdown_context_processor",
"social_django.context_processors.backends",
"social_django.context_processors.login_redirect",
)
# }}}
CRISPY_TEMPLATE_PACK = "bootstrap3"
# {{{ templates
CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5"
CRISPY_TEMPLATE_PACK = "bootstrap5"
TEMPLATES = [
{
......@@ -160,25 +187,32 @@ TEMPLATES = [
"django.contrib.auth.context_processors.auth",
"django.template.context_processors.debug",
"django.template.context_processors.i18n",
"django.template.context_processors.media",
"django.template.context_processors.static",
"django.template.context_processors.tz",
"django.template.context_processors.request",
"django.contrib.messages.context_processors.messages",
) + RELATE_EXTRA_CONTEXT_PROCESSORS,
"django.template.context_processors.media",
"django.template.context_processors.static",
"django.template.context_processors.tz",
"django.template.context_processors.request",
"django.contrib.messages.context_processors.messages",
*RELATE_EXTRA_CONTEXT_PROCESSORS),
"builtins": ["course.templatetags.coursetags"],
}
},
]
RELATE_OVERRIDE_TEMPLATES_DIRS = (
local_settings.get("RELATE_OVERRIDE_TEMPLATES_DIRS", []))
if RELATE_OVERRIDE_TEMPLATES_DIRS:
TEMPLATES[0]["DIRS"] = (
tuple(RELATE_OVERRIDE_TEMPLATES_DIRS) + TEMPLATES[0]["DIRS"]) # type: ignore
# }}}
# {{{ database
# default, likely overriden by local_settings.py
# default, likely overridden by local_settings.py
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": os.path.join(BASE_DIR, "db.sqlite3"),
}
}
......@@ -186,17 +220,19 @@ DATABASES = {
# {{{ internationalization
LANGUAGE_CODE = 'en-us'
LANGUAGE_CODE = "en-us"
USE_I18N = True
USE_L10N = True
USE_TZ = True
# }}}
LOGIN_URL = "relate-sign_in_choice"
# Do not remove this setting. It is used by djangosaml2 to determine where to
# redirect after a successful login.
LOGIN_REDIRECT_URL = "/"
# Static files (CSS, JavaScript, Images)
......@@ -206,51 +242,81 @@ LOGIN_REDIRECT_URL = "/"
STATICFILES_DIRS = (
join(BASE_DIR, "relate", "static"),
join(BASE_DIR, "node_modules", "mathjax", "es5"),
join(BASE_DIR, "frontend-dist"),
)
STATIC_URL = '/static/'
STATIC_URL = "/static/"
STATIC_ROOT = join(BASE_DIR, "static")
# local select2 'static' resources instead of from CDN
# https://goo.gl/dY6xf7
SELECT2_JS = 'select2/dist/js/select2.min.js'
SELECT2_CSS = 'select2/dist/css/select2.css'
STORAGES = {
**STORAGES,
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.ManifestStaticFilesStorage",
},
}
# bundled select2 "static" resources instead of from CDN
SELECT2_JS = ""
SELECT2_CSS = ""
# }}}
SESSION_COOKIE_NAME = 'relate_sessionid'
SESSION_COOKIE_NAME = "relate_sessionid"
SESSION_COOKIE_AGE = 12096000 # 20 weeks
# {{{ app defaults
RELATE_FACILITIES = {} # type: Union[None,Dict[str, Dict[str, Any]], Callable[..., Dict[str, Dict[str, Any]]], ] # noqa
RELATE_FACILITIES: (
dict[str, dict[str, Any]]
| Callable[..., dict[str, dict[str, Any]]]
| None
) = {}
RELATE_TICKET_MINUTES_VALID_AFTER_USE = 0
RELATE_CACHE_MAX_BYTES = 32768
RELATE_ADMIN_EMAIL_LOCALE = "en_US"
RELATE_ADMIN_EMAIL_LOCALE = "en-us"
RELATE_EDITABLE_INST_ID_BEFORE_VERIFICATION = True
RELATE_SIGN_IN_BY_USERNAME_ENABLED = True
RELATE_SHOW_INST_ID_FORM = True
RELATE_SHOW_EDITOR_FORM = True
# }}}
for name, val in local_settings.items():
if not name.startswith("_"):
globals()[name] = val
RELATE_SITE_NAME = gettext_noop("RELATE")
RELATE_CUTOMIZED_SITE_NAME = local_settings.get("RELATE_CUTOMIZED_SITE_NAME")
if RELATE_CUTOMIZED_SITE_NAME is not None and RELATE_CUTOMIZED_SITE_NAME.strip():
RELATE_SITE_NAME = RELATE_CUTOMIZED_SITE_NAME
# {{{ celery config
BROKER_URL = 'django://'
if "CELERY_BROKER_URL" not in globals():
from warnings import warn
warn("CELERY_BROKER_URL not set in local_settings.py: defaulting to amqp://. "
"If there is no queue server installed, long-running tasks will "
"appear to hang.")
CELERY_ACCEPT_CONTENT = ['pickle']
CELERY_TASK_SERIALIZER = 'pickle'
CELERY_RESULT_SERIALIZER = 'pickle'
CELERY_BROKER_URL = "amqp://"
CELERY_ACCEPT_CONTENT = ["pickle", "json"]
CELERY_TASK_SERIALIZER = "pickle"
# (pickle is buggy in django-celery-results 1.0.1)
# https://github.com/celery/django-celery-results/issues/50
CELERY_RESULT_SERIALIZER = "json"
CELERY_TRACK_STARTED = True
if "CELERY_RESULT_BACKEND" not in globals():
if ("CACHES" in globals()
if (
"CACHES" in globals()
and "LocMem" not in CACHES["default"]["BACKEND"] # type:ignore # noqa
and "Dummy" not in CACHES["default"]["BACKEND"] # type:ignore # noqa
):
......@@ -260,33 +326,27 @@ if "CELERY_RESULT_BACKEND" not in globals():
# transaction. But if we're using the in-memory cache, using
# cache as a results backend doesn't make much sense.
CELERY_RESULT_BACKEND = 'djcelery.backends.cache:CacheBackend'
CELERY_RESULT_BACKEND = "django-cache"
else:
CELERY_RESULT_BACKEND = 'djcelery.backends.database:DatabaseBackend'
CELERY_RESULT_BACKEND = "django-db"
# }}}
LOCALE_PATHS = (
BASE_DIR + '/locale',
BASE_DIR + "/locale",
)
# {{{ saml2
# This makes SAML2 logins compatible with (and usable at the same time as)
# email-based logins.
SAML_DJANGO_USER_MAIN_ATTRIBUTE = 'username'
SAML_DJANGO_USER_MAIN_ATTRIBUTE = "username"
SAML_CREATE_UNKNOWN_USER = True
# }}}
# This makes sure the RELATE_BASE_URL is configured.
assert local_settings["RELATE_BASE_URL"]
SAML_SESSION_COOKIE_NAME = "relate_saml_session"
# This makes sure RELATE_EMAIL_APPELATION_PRIORITY_LIST is a list
if "RELATE_EMAIL_APPELATION_PRIORITY_LIST" in local_settings:
assert isinstance(
local_settings["RELATE_EMAIL_APPELATION_PRIORITY_LIST"], list)
# }}}
# vim: foldmethod=marker
function rm_tag(s) {
return s.replace(/(<([^>]+)>)/g, '');
}
jQuery.extend(jQuery.fn.dataTableExt.oSort, {
"name-asc" : function (s1, s2) {
return rm_tag(s1).localeCompare(rm_tag(s2));
},
"name-desc" : function (s1, s2) {
return rm_tag(s2).localeCompare(rm_tag(s1));
}
});
\ No newline at end of file
......@@ -16,7 +16,7 @@
{% url "relate-home" as relate-home %}
{% blocktrans trimmed %}
You're not currently signed in. Access to the resource is restricted,
and since the site has no way of knowing who you are, it denied you accesss.
and since the site has no way of knowing who you are, it denied you access.
Once you're signed in, navigate back to your course from the
<a href="{{ relate-home }}"> home page </a> and retry your last
action.
......
......@@ -2,6 +2,5 @@
{% load i18n %}
{% block branding %}
{% trans "RELATE" as RELATE %}
<h1 id="site-name">{% blocktrans %}{{ RELATE }} Administration{% endblocktrans %}</h1>
<h1 id="site-name">{% blocktrans with RELATE=relate_site_name %}{{ RELATE }} Administration{% endblocktrans %}</h1>
{% endblock %}
......@@ -10,20 +10,22 @@
<div class="alert
{% if message.tags == "error" %}
alert-danger
{% elif message.tags|startswith:"social-auth" %}
alert-danger
{% else %}
alert-{{ message.tags }}
{% endif %}
">
{% if message.tags == "error" %}
<i class="fa fa-ban"></i>
<i class="bi bi-x-circle"></i>
{% elif message.tags == "success" %}
<i class="fa fa-check"></i>
<i class="bi bi-check"></i>
{% elif message.tags == "info" %}
<i class="fa fa-info-circle"></i>
<i class="bi bi-info-circle"></i>
{% elif message.tags == "warning" %}
<i class="fa fa-warning"></i>
<i class="bi bi-exclamation-triangle"></i>
{% elif message.tags == "danger warning" %}
<i class="fa fa-warning"></i>
<i class="bi bi-exclamation-triangle"></i>
{% endif %}
{{ message|safe }}
</div>
......@@ -31,13 +33,13 @@
{% if fake_time %}
<div class="alert alert-info">
<i class="fa fa-info-circle"></i>
<i class="bi bi-info-circle"></i>
{% blocktrans trimmed %}
You are currently seeing a preview of what the site
would look like at {{ fake_time }}.
{% endblocktrans %}
{% if user.is_staff %}
<a class="btn btn-default btn-sm" href="{% url "relate-set_fake_time" %}" role="button" target="_blank">
<a class="btn btn-secondary btn-sm" href="{% url "relate-set_fake_time" %}" role="button" target="_blank">
{% trans "Set fake time" %} &raquo;</a>
{% endif %}
</div>
......@@ -45,13 +47,13 @@
{% if pretend_facilities and add_pretend_facilities_header %}
<div class="alert alert-info">
<i class="fa fa-info-circle"></i>
<i class="bi bi-info-circle"></i>
{% blocktrans trimmed with facilities=pretend_facilities|join:", " %}
You are currently seeing a preview of what the site
would look like from inside the facilities <b>{{ facilities }}</b>.
{% endblocktrans %}
{% if user.is_staff %}
<a class="btn btn-default btn-sm" href="{% url "relate-set_pretend_facilities" %}" role="button" target="_blank">
<a class="btn btn-secondary btn-sm" href="{% url "relate-set_pretend_facilities" %}" role="button" target="_blank">
{% trans "Pretend to be in facilities" %} &raquo;</a>
{% endif %}
</div>
......@@ -59,8 +61,8 @@
{% if currently_impersonating and add_impersonation_header %}
<div class="alert alert-info">
<i class="fa fa-info-circle"></i>
<i class="bi bi-info-circle"></i>
{% trans "Now impersonating" %} {{ user.get_full_name }} ({{ user.username }} - {{ user.email }}).
<a class="btn btn-default btn-sm" href="{% url "relate-stop_impersonating" %}" role="button" target="_blank">{% trans "Stop impersonating" %} &raquo;</a>
<a class="btn btn-secondary btn-sm" onclick=stopImpersonate() role="button" target="_blank">{% trans "Stop impersonating" %} &raquo;</a>
</div>
{% endif %}
......@@ -7,149 +7,185 @@
<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 %}{% trans "RELATE" %}{% endblock %}</title>
<title>{% block title %}{{ relate_site_name }}{% endblock %}</title>
<link href="{% static "bootstrap/dist/css/bootstrap.css" %}" rel="stylesheet">
<link href="{% static "jqueryui/themes/smoothness/jquery-ui.css" %}" rel="stylesheet">
{% block bundle_loads %}
<script src="{% static 'bundle-base.js' %}"></script>
{% endblock %}
<link rel="stylesheet" href="{% static "fontawesome/css/font-awesome.css" %}">
<link rel="stylesheet" href="{% static "jstree/dist/themes/default/style.min.css" %}" />
<link rel="stylesheet" href="/static/css/style.css" >
{% block head_assets_form_media %}
{{ form.media }}
{% endblock %}
{# Don't be tempted to move all this JS stuff down to the end. #}
{# The datepicker generates inline JS that relies on this being loaded. #}
<script src="{% static "jquery/dist/jquery.js" %}"></script>
<script src="{% static "jqueryui/jquery-ui.js" %}"></script>
<script src="{% static "bootstrap/dist/js/bootstrap.js" %}"></script>
<script src="{% static "jstree/dist/jstree.min.js" %}"></script>
{% block header_extra %}{% endblock %}
</head>
{{ form.media }}
<body class="mathjax_ignore">
{% block fixed_navbar %}
<nav class="navbar navbar-expand-lg secondary-color bg-secondary-subtle mb-4 border-bottom shadow-sm">
<div class="container">
<a class="navbar-brand"
href="{% block brand_link %}{% url 'relate-home' %}{% endblock %}" >
{%block brand %}{{ relate_site_name }}{% endblock %}
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse"
data-bs-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent"
aria-expanded="false"
aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
{%block header_extra %}{% endblock %}
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav flex-grow-1">
{% if not maintenance_mode %}
{% block navbar %}{% endblock %}
{% block navbar_dropdown_staff %}
{% if user.is_staff or currently_impersonating or perms.course.can_issue_exam_tickets or pperm.impersonate_role or pperm.set_fake_time or pperm.set_pretend_facility or request.relate_impersonate_original_user|may_set_fake_time or request.relate_impersonate_original_user|may_set_pretend_facility %}
<li class="nav-item relate-dropdown-menu">
<a href="#" id="dropdownStaffMenu" data-bs-toggle="dropdown" aria-expanded="false">
{% trans "Staff" %}
</a>
<ul class="dropdown-menu" aria-labelledby="dropdownStaffMenu">
{% block navbar_dropdown_staff_menu_admin %}
{% if user.is_staff %}
<li><a class="dropdown-item" href="{% url 'admin:index' %}" target="_blank">{% trans "Admin site" %}</a></li>
{% endif %}
{% endblock %}
{% block navbar_dropdown_staff_menu_purge_page_view_data %}
{% if user.is_superuser or pperm.use_admin_interface %}
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{% url 'relate-purge_page_view_data' %}">{% trans "Purge page view data" %}</a></li>
{% endif %}
{% endblock %}
{% block navbar_dropdown_staff_menu_exam %}
{% if perms.course.can_issue_exam_tickets %}
<li><hr class="dropdown-divider"></li>
<li><h6 class="dropdown-header">{% trans "Exams" %}</h6></li>
<li><a class="dropdown-item" href="{% url 'relate-issue_exam_ticket' %}">{% trans "Issue exam tickets" %}</a></li>
<li><a class="dropdown-item" href="{% url 'relate-check_in_for_exam' %}">{% trans "Exam check-in" %}</a></li>
{% endif %}
{% endblock %}
{% block navbar_dropdown_staff_menu_trouble_shoot %}
{% if currently_impersonating or pperm.impersonate_role or pperm.set_fake_time or request.relate_impersonate_original_user|may_set_fake_time or request.relate_impersonate_original_user|may_set_pretend_facility %}
<li class="dropdown-divider"></li>
<li><h6 class="dropdown-header">{% trans "Test/Troubleshoot" %}</h6></li>
{% if currently_impersonating %}
<li><a class="dropdown-item" onclick="stopImpersonate()">{% trans "Stop impersonating" %}</a></li>
{% else %}
<li><a class="dropdown-item" href="{% url 'relate-impersonate' %}">{% trans "Impersonate user" %}</a></li>
{% endif %}
{% if pperm.set_fake_time or request.relate_impersonate_original_user|may_set_fake_time %}
<li><a class="dropdown-item" href="{% url 'relate-set_fake_time' %}">{% trans "Set fake time" %}</a></li>
{% endif %}
{% if pperm.set_pretend_facility or request.relate_impersonate_original_user|may_set_pretend_facility %}
<li><a class="dropdown-item" href="{% url 'relate-set_pretend_facilities' %}">{% trans "Pretend to be in facilities" %}</a></li>
{% endif %}
{% endif %}
{% endblock %}
</ul>
</li>
{% endif %}
{% endblock %}
<li class="ms-auto">
{% block preview_status %}
{% endblock %}
{% if user.is_authenticated %}
<a class="link-secondary" href="{% url 'relate-user_profile' %}">
{% blocktrans trimmed with username=user.username %}
Signed in as {{ username }}
{% endblocktrans %}</a>
{% if currently_impersonating %}
{% trans "(impersonated)" %}
{% endif %}
{% else %}
{# Error pages don't seem to run template context processors, so #}
{# this variable might not be available... #}
{% if student_sign_in_view %}
<a class="link-secondary" href="{% url student_sign_in_view %}">{% trans "Sign in" %}</a>
{% endif %}
{% endif %}
</li>
<!-- HTML5 shim and Respond.js IE8 support of HTML5 elements and media queries -->
<!--[if lt IE 9]>
<script src="https://oss.maxcdn.com/libs/html5shiv/3.7.0/html5shiv.js"></script>
<script src="https://oss.maxcdn.com/libs/respond.js/1.4.2/respond.min.js"></script>
<![endif]-->
{% endif %} {# not maintenance_mode #}
<link href="{% static "videojs/dist/video-js.min.css" %}" rel="stylesheet">
<script src="{% static "videojs/dist/video.min.js" %}"></script>
<script type="text/javascript">
videojs.options.flash.swf = "{% static "videojs/dist/video-js/video-js.swf" %}"
</script>
</head>
</ul>
</div>
</div>
</nav>
{% endblock %}
<body>
{% block pre_root_container %}{% endblock %}
<!-- Fixed navbar -->
<div class="navbar navbar-default" role="navigation">
{% block root_container %}
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="{%block brand_link %}/{% endblock %}">{%block brand %}{% trans "RELATE" %}{% endblock %}</a>
</div>
<div class="navbar-collapse collapse">
{% if not maintenance_mode %}
<ul class="nav navbar-nav">
{%block navbar %}{% endblock %}
{% if user.is_staff or currently_impersonating or perms.course.can_issue_exam_tickets %}
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown">{% trans "Staff" %}<b class="caret"></b></a>
<ul class="dropdown-menu">
{% if user.is_staff or currently_impersonating %}
<li><a href="/admin" target="_blank">{% trans "Admin site" %}</a></li>
<li role="presentation" class="divider"></li>
<li role="presentation" class="dropdown-header">{% trans "Test/Troubleshoot" %}</li>
{% if currently_impersonating %}
<li><a href="{% url "relate-stop_impersonating" %}" target="_blank">{% trans "Stop impersonating" %}</a></li>
{% else %}
<li><a href="{% url "relate-impersonate" %}" target="_blank">{% trans "Impersonate user" %}</a></li>
{% endif %}
<li><a href="{% url "relate-set_fake_time" %}" target="_blank">{% trans "Set fake time" %}</a></li>
<li><a href="{% url "relate-set_pretend_facilities" %}" target="_blank">{% trans "Pretend to be in facilities" %}</a></li>
{% endif %}
{% if perms.course.can_issue_exam_tickets %}
{% if user.is_staff or currently_impersonating %}
<li role="presentation" class="divider"></li>
{% endif %}
<li role="presentation" class="dropdown-header">{% trans "Exams" %}</li>
<li><a href="{% url "relate-issue_exam_ticket" %}">{% trans "Issue exam tickets" %}</a></li>
<li><a href="{% url "relate-check_in_for_exam" %}">{% trans "Exam check-in" %}</a></li>
{% endif %}
</ul>
</li>
{% endif %}
</ul>
<ul class="nav navbar-nav navbar-right">
{% block preview_status %}
{% endblock %}
{% if user.is_authenticated %}
<li>
<a href="{% url "relate-user_profile" %}">
{% blocktrans trimmed with username=user.username %}
Signed in as {{username}}
{% endblocktrans %} </a>
</li>
{% if currently_impersonating %}
<li>
<p class="navbar-text">
<b>{% trans "(impersonated)" %}</b>
</p>
</li>
{% endif %}
{% else %}
{# Error pages don't seem to run template context processors, so #}
{# this variable might not be available... #}
{% if student_sign_in_view %}
<li><a href="{% url student_sign_in_view %}">{% trans "Sign in" %}</a></li>
{% endif %}
{% endif %}
</ul>
{% endif %}{# not maintenance_mode #}
</div><!--/.nav-collapse -->
<main>
{% block base_page_top %}{% include "base-page-top.html" %}{% endblock %}
{% block nav_recommendations %}
{% endblock %}
{% block content %}
{% endblock %}
</main>
</div>
</div>
{% endblock %}
{% block root_container %}
<div class="container">
{% include "base-page-top.html" %}
{% block post_root_container %}{% endblock %}
{% block footer %}{% endblock %}
{% block page_bottom_javascript_extra %}{% endblock %}
{% block page_bottom_javascript %}
{% if currently_impersonating and add_impersonation_header %}
<script type="text/javascript">
function stopImpersonate() {
$("#stop-impersonate")
.html(
'<img src="{% static "images/busy.gif" %}" alt="Busy indicator">')
.show();
var jqxhr = $.ajax(
"{% url "relate-stop_impersonating" %}",
{
type: "POST",
data: {"stop_impersonating": true},
beforeSend: function (xhr, settings) {
var csrftoken = rlUtils.getCookie('relate_csrftoken');
xhr.setRequestHeader("X-CSRFToken", csrftoken);
}
})
.done(function () {
window.location.reload();
})
}
</script>
{% endif %}
{% block nav_recommendations %}
{% endblock %}
{% endblock %}
{% block content %}
{% endblock %}
{# {{{ toast #}
<div class="position-fixed bottom-0 end-0 p-3" style="z-index: 11">
<div id="relate-ui-toast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header">
<strong class="me-auto text-danger" id="relate-ui-toast-title">{% trans "Error" %}</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
<div id="relate-ui-toast-body" class="toast-body">
</div>
</div>
</div>
{% endblock %}
<!-- JavaScript
================================================== -->
<!-- Placed at the end of the document so the pages load faster -->
<script type="text/javascript"
src="{% static "MathJax/MathJax.js" %}?config=TeX-AMS-MML_HTMLorMML">
</script>
<script type="text/x-mathjax-config">
MathJax.Hub.Config({
extensions: ["tex2jax.js", "TeX/boldsymbol.js"],
messageStyle: "none",
// jax: ["input/TeX", "output/HTML-CSS"],
tex2jax: {
inlineMath: [ ['$','$'], ["\\(","\\)"] ],
displayMath: [ ['$$','$$'], ["\\[","\\]"] ],
processEscapes: true
},
});
</script>
{# }}} #}
</body>
</html>
......@@ -8,7 +8,7 @@
<pre>{{ log }}</pre>
{% if was_successful %}
<div class="alert alert-success">
<i class="fa fa-hand-scissors-o"></i>
<i class="bi bi-check"></i>
{{ status|safe }}
</div>
{% else %}
......
{% load static %}
{# the min.js version contains all dependencies #}
<script src="{% static "datatables.net/js/jquery.dataTables.min.js" %}"></script>
<script src="{% static "datatables.net-bs/js/dataTables.bootstrap.min.js" %}"></script>
<link href="{% static "datatables.net-bs/css/dataTables.bootstrap.min.css" %}" rel="stylesheet">
<script src="{% static "datatables.net-fixedcolumns/js/dataTables.fixedColumns.js" %}"></script>
<link href="{% static "datatables.net-fixedcolumns-bs/css/fixedColumns.bootstrap.min.css" %}" rel="stylesheet">
{# allow sorting fields using localeCompare method #}
<script src="{% static "js/dataTables.relate.sort.plugin.js" %}"></script>
\ No newline at end of file
{% extends "base.html" %}
{% load i18n %}
{% load static %}
{% load crispy_forms_tags %}
{% block title %}
{{ form_description }} - {{ relate_site_name }}
{% endblock %}
{# keep this in sync with course/templates/course/generic-course-form.html #}
{# keep this in sync with course/templates/course/manage-auth-tokens.html #}
{% block head_assets_form_media %}
{{ form.media }}
{% endblock %}
{% block content %}
{% block form_description %}
{% if form_description %}
<h1>{{ form_description }}</h1>
{% endif %}
{{ form_text|safe }}
<div class="well">
{% endif %}
{% endblock %}
{% block form %}
<div class="relate-well">
{% crispy form %}
</div>
{% endblock %}
{% endblock %}