From da724b2c802c217ce788c14f176a17eeef1717ec Mon Sep 17 00:00:00 2001 From: Andreas Kloeckner Date: Tue, 25 Nov 2025 12:24:04 -0600 Subject: [PATCH 1/9] Code q failure: his or her -> their --- course/templates/course/broken-code-question-email.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/course/templates/course/broken-code-question-email.txt b/course/templates/course/broken-code-question-email.txt index 1f4680cfb..bbe02ad79 100644 --- a/course/templates/course/broken-code-question-email.txt +++ b/course/templates/course/broken-code-question-email.txt @@ -1,6 +1,6 @@ {% load i18n %}{% blocktrans with page_id=page_id course_identifier=course.identifier error_message=error_message|safe %}Hi there, This message was sent from {{ site }} at {{ time }}. -Bad news! A code question with ID '{{ page_id }}' in '{{ course_identifier }}' has just failed while a user was trying to get his or her code graded. +Bad news! A code question with ID '{{ page_id }}' in '{{ course_identifier }}' has just failed while a user was trying to get their code graded. Details of the problem are below: {{ error_message }} {% endblocktrans %} From d38cce6bb8a3c2cb0008a306d17782e206436d7b Mon Sep 17 00:00:00 2001 From: Andreas Kloeckner Date: Tue, 25 Nov 2025 17:36:17 -0600 Subject: [PATCH 2/9] Complain on flow page POST with prev_visit_id --- course/flow.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/course/flow.py b/course/flow.py index 1f13f00d0..eea657176 100644 --- a/course/flow.py +++ b/course/flow.py @@ -1834,11 +1834,23 @@ def view_flow_page( prev_visit_id = None viewing_prior_version = False + prev_visit_id_str = pctx.request.GET.get("visit_id") + if prev_visit_id_str is not None: + try: + prev_visit_id = int(prev_visit_id_str) + except ValueError: + raise SuspiciousOperation("non-integer passed for 'visit_id'") + else: + prev_visit_id = prev_visit_id_str + if request.method == "POST": if "finish" in request.POST: return redirect("relate-finish_flow_session_view", pctx.course.identifier, flow_session_id) else: + if prev_visit_id is not None: + raise SuspiciousOperation("POST to previous visit") + post_result = post_flow_page( flow_session, fpctx, request, permissions, generates_grade) @@ -1867,15 +1879,6 @@ def view_flow_page( # {{{ fish out previous answer_visit - prev_visit_id_str = pctx.request.GET.get("visit_id") - if prev_visit_id_str is not None: - try: - prev_visit_id = int(prev_visit_id_str) - except ValueError: - raise SuspiciousOperation("non-integer passed for 'visit_id'") - else: - prev_visit_id = prev_visit_id_str - if prev_answer_visits and prev_visit_id is not None: answer_visit = prev_answer_visits[0] From ef5f6ca8371173eca9027c1f795a1e7f53df0ddf Mon Sep 17 00:00:00 2001 From: Andreas Kloeckner Date: Thu, 4 Dec 2025 18:17:30 -0600 Subject: [PATCH 3/9] Strongly limit what per-page permissions can do --- course/page/base.py | 29 +++++++++++++-- tests/test_constants.py | 78 ----------------------------------------- 2 files changed, 27 insertions(+), 80 deletions(-) delete mode 100644 tests/test_constants.py diff --git a/course/page/base.py b/course/page/base.py index 68966b206..13da76178 100644 --- a/course/page/base.py +++ b/course/page/base.py @@ -38,7 +38,7 @@ ) import django.http -from annotated_types import Ge +from annotated_types import Ge, Len from django import forms from django.conf import settings from django.forms import ValidationError as FormValidationError @@ -363,7 +363,21 @@ class PageAccessRules: """ add_permissions: list[FlowPermission] = Field(default_factory=list) - remove_permissions: list[FlowPermission] = Field(default_factory=list) + remove_permissions: Annotated[list[FlowPermission], Len(max_length=0)] = \ + Field(default_factory=list) + + @model_validator(mode="after") + def limit_specified_rules(self) -> Self: + if not set(self.add_permissions) <= { + FlowPermission.change_answer, + FlowPermission.see_correctness, + FlowPermission.send_email_about_flow_page, + }: + raise ValueError(_("'add_permissions' may only contain " + "'change_answer', 'see_correctness', " + "'send_email_about_flow_page'")) + + return self class PageBase(BaseModel, ABC): # pyright: ignore[reportUnsafeMultipleInheritance] @@ -453,6 +467,17 @@ def __pydantic_init_subclass__(cls, **kwargs: object): # }} + @model_validator(mode="after") + def warn_about_deprecated_per_page_permissions(self, info: ValidationInfo) -> Self: + vctx = get_validation_context(info).with_location(f"page '{self.id}'") + if self.access_rules is not None and type(self): + vctx.add_warning(gettext( + "per-page 'access_rules' are deprecated " + "and will stop having an effect in 2027. " + "Use Starlark code for rules instead.")) + + return self + def get_modified_permissions_for_page( self, permissions: AbstractSet[FlowPermission] ) -> AbstractSet[FlowPermission]: diff --git a/tests/test_constants.py b/tests/test_constants.py deleted file mode 100644 index 699359bd3..000000000 --- a/tests/test_constants.py +++ /dev/null @@ -1,78 +0,0 @@ -from __future__ import annotations - - -__copyright__ = "Copyright (C) 2018 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 unittest - -from course import constants - - -class IsExpirationModeAllowedTest(unittest.TestCase): - # test course.constants.is_expiration_mode_allowed - def test_roll_over(self): - expmode = constants.FlowSessionExpirationMode.roll_over - permissions = frozenset([]) - self.assertFalse( - constants.is_expiration_mode_allowed(expmode, permissions)) - - permissions = frozenset([ - constants.FlowPermission.set_roll_over_expiration_mode - ]) - self.assertTrue( - constants.is_expiration_mode_allowed(expmode, permissions)) - - def test_end(self): - expmode = constants.FlowSessionExpirationMode.end - permissions = frozenset([constants.FlowPermission.end_session]) - self.assertTrue( - constants.is_expiration_mode_allowed(expmode, permissions)) - - permissions = frozenset([ - constants.FlowPermission.set_roll_over_expiration_mode - ]) - self.assertTrue( - constants.is_expiration_mode_allowed(expmode, permissions)) - - def test_unknown_mode(self): - expmode = "unknown_mode" - permissions = frozenset([]) - - expected_error_msg = "unknown expiration mode" - - with self.assertRaises(ValueError) as cm: - self.assertTrue( - constants.is_expiration_mode_allowed(expmode, permissions)) - - self.assertEqual(expected_error_msg, str(cm.exception)) - - permissions = frozenset([ - constants.FlowPermission.set_roll_over_expiration_mode, - constants.FlowPermission.end_session - ]) - - with self.assertRaises(ValueError) as cm: - self.assertTrue( - constants.is_expiration_mode_allowed(expmode, permissions)) - - self.assertEqual(expected_error_msg, str(cm.exception)) From 8f5dad1dbaaac3f8eec913650e8403062926bc01 Mon Sep 17 00:00:00 2001 From: Andreas Kloeckner Date: Tue, 9 Dec 2025 07:39:35 -0600 Subject: [PATCH 4/9] Minor tweaks to FileSystemFakeRepo: --- course/repo.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/course/repo.py b/course/repo.py index 27ee0db05..aeb1edbb0 100644 --- a/course/repo.py +++ b/course/repo.py @@ -169,7 +169,7 @@ def __getitem__(self, @override def __str__(self): - return f"" + return f"" def decode(self): return self @@ -188,7 +188,7 @@ def __exit__(self, self.close() -@dataclass +@dataclass(frozen=True) class FileSystemFakeRepoTreeEntry: # pragma: no cover path: bytes mode: int From 5c1bcb2a0231fde2c5bf6f3097ce26f9ee45fceb Mon Sep 17 00:00:00 2001 From: Andreas Kloeckner Date: Mon, 15 Dec 2025 09:51:59 -0600 Subject: [PATCH 5/9] pytest-django: fail on invalid template vars --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 3f6039b9f..20c8dc1dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -257,6 +257,7 @@ ignore_errors = true [tool.pytest.ini_options] DJANGO_SETTINGS_MODULE = "relate.settings" +FAIL_INVALID_TEMPLATE_VARS = true python_files = [ "tests.py", "test_*.py", From 5fd8f98ec4d86c015f0d48a6a685e3697adb7a17 Mon Sep 17 00:00:00 2001 From: Andreas Kloeckner Date: Tue, 9 Dec 2025 07:40:04 -0600 Subject: [PATCH 6/9] WIP Python-class fake repo for testing --- course/content.py | 8 +++ course/repo.py | 152 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 158 insertions(+), 2 deletions(-) diff --git a/course/content.py b/course/content.py index a5096767d..39a91fad3 100644 --- a/course/content.py +++ b/course/content.py @@ -75,7 +75,10 @@ from course.page.base import PageBase # noqa: TC001 from course.repo import ( CACHE_KEY_ROOT, + PYTHON_CLASS_REPO_PREFIX, + PYTHON_CLASS_REPO_REGISTRY, FileSystemFakeRepo, + PythonClassFakeRepo, RevisionID_ish, SubdirRepoWrapper, get_repo_blob_data_cached, @@ -942,6 +945,11 @@ def get_course_repo_path(course: Course) -> Path: def get_course_repo(course: Course) -> Repo_ish: + if course.git_source.startswith(PYTHON_CLASS_REPO_PREFIX): + return PythonClassFakeRepo( + PYTHON_CLASS_REPO_REGISTRY[ + course.git_source[len(PYTHON_CLASS_REPO_PREFIX):]]) + from dulwich.repo import Repo repo = Repo(get_course_repo_path(course)) diff --git a/course/repo.py b/course/repo.py index aeb1edbb0..053c77ac3 100644 --- a/course/repo.py +++ b/course/repo.py @@ -28,7 +28,7 @@ from dataclasses import dataclass from hashlib import sha256 from pathlib import Path -from typing import TYPE_CHECKING, TypeAlias, cast +from typing import TYPE_CHECKING, ClassVar, Protocol, TypeAlias, cast import dulwich.objects import dulwich.repo @@ -38,7 +38,7 @@ if TYPE_CHECKING: - from collections.abc import Mapping + from collections.abc import Mapping, Sequence from types import TracebackType @@ -146,6 +146,151 @@ def __exit__(self, self.close() +# {{{ python class fake repo + +def attr_name_to_file_name(name: str): + # 'Subdirectories' aka inner classes are upper case because of PEP8. + # We can get by without upper case letters in filenames, I guess. + name = name.lower() + + chars: list[str] = [] + i = 0 + while i < len(name): + ch = name[i] + if ch == "_": + next_underscore = name.find("_", i+1) + if next_underscore == -1: + raise ValueError("lone underscore found") + + key = name[i+1:next_underscore] + i = next_underscore+1 + + if key == "dot": + chars.append(".") + else: + raise ValueError() + else: + chars.append(ch) + i += 1 + + return "".join(chars) + + +class ProcessedRepoClass(Protocol): + _file_name_to_attr_name: ClassVar[dict[str, str]] + + +class ProcessedRepoRootClass(ProcessedRepoClass, Protocol): + _registry_url: ClassVar[str] + + +@dataclass(frozen=True) +class PythonClassFakeRepo: + cls: type[ProcessedRepoRootClass] + + def close(self): + pass + + @property + def tree(self): + return PythonClassFakeRepoTree(self.cls) + + def controldir(self): + return f"{self.cls.__module__}:{self.cls.__qualname__}" + + def __enter__(self): + return self + + def __exit__(self, + exc_type: type[Exception], + exc_val: Exception, + exc_tb: TracebackType) -> None: + pass + +@dataclass(frozen=True) +class PythonClassFakeRepoTreeEntry: + path: bytes + mode: int + + +@dataclass(frozen=True) +class PythonClassFakeRepoTree: + cls: type[ProcessedRepoClass] + + MODE_DIR: ClassVar[int] = 0o0040777 + MODE_FILE: ClassVar[int] = 0o666 + + def __getitem__(self, name: bytes): + decoded_name = name.decode("utf-8") + attr_name = self.cls._file_name_to_attr_name.get(decoded_name) # pyright: ignore[reportPrivateUsage] + if attr_name is None: + raise ObjectDoesNotExist(name) + + entry = getattr(self.cls, attr_name) + if isinstance(entry, type): + return self.MODE_DIR, PythonClassFakeRepoTree(entry) + else: + return self.MODE_FILE, PythonClassFakeRepoFile(entry) + + def items(self) -> Sequence[PythonClassFakeRepoTreeEntry]: + return [ + PythonClassFakeRepoTreeEntry( + path=name.encode("utf-8"), + mode=( + self.MODE_DIR + if isinstance(getattr(self.cls, attr_name), type) + else self.MODE_FILE), + ) + for name, attr_name in self.cls._file_name_to_attr_name.items() # pyright: ignore[reportPrivateUsage] + ] + + +@dataclass(frozen=True) +class PythonClassFakeRepoFile: + obj: object + + def data(self): + assert isinstance(self.obj, str) + return self.obj.encode("utf-8") + + +PYTHON_CLASS_REPO_PREFIX = "pyclass://" + +PYTHON_CLASS_REPO_REGISTRY: dict[tuple[str, str], type[ProcessedRepoRootClass]] = {} + + +def _make_filename_map(cls: type) -> None: + file_name_to_attr_name: dict[str, str] = {} + for attr_name in dir(cls): + if not attr_name.startswith("__"): + file_name = attr_name_to_file_name(attr_name) + file_name_to_attr_name[file_name] = attr_name + + entry = getattr(cls, attr_name) + if isinstance(entry, type): + _make_filename_map(entry) + + cls._file_name_to_attr_name = file_name_to_attr_name + + +def python_repo_class(cls: type) -> type[ProcessedRepoClass]: + key = (cls.__module__, cls.__qualname__) + url = f"{PYTHON_CLASS_REPO_PREFIX}{key}" + existing_entry = PYTHON_CLASS_REPO_REGISTRY.get(key) + if existing_entry is not None: + assert cls is existing_entry + return cls + + PYTHON_CLASS_REPO_REGISTRY[key] = cls + _make_filename_map(cls) + cls._registry_url = url + return cls + +# }}} + + +# {{{ file system repo + class FileSystemFakeRepo: root: Path @@ -246,9 +391,12 @@ def data(self): except FileNotFoundError as e: raise ObjectDoesNotExist(self.name) from e +# }}} + Repo_ish: TypeAlias = (dulwich.repo.Repo | SubdirRepoWrapper + | PythonClassFakeRepo | FileSystemFakeRepo | EmptyRepo) Blob_ish: TypeAlias = dulwich.objects.Blob | FileSystemFakeRepoFile From 96984452195074c8c22c238cc6daa809d6ea99cd Mon Sep 17 00:00:00 2001 From: Andreas Kloeckner Date: Sat, 18 Oct 2025 22:53:16 -0500 Subject: [PATCH 7/9] WIP Starlark rules --- course/analytics.py | 37 +- course/constants.py | 22 +- course/content.py | 192 ++++++---- course/datespec.py | 19 +- course/flow.py | 354 +++++++++---------- course/grades.py | 2 +- course/grading.py | 5 +- course/page/base.py | 29 +- course/page/choice.py | 12 - course/page/code.py | 102 +----- course/page/inline.py | 4 - course/page/static.py | 4 - course/page/text.py | 20 -- course/page/upload.py | 4 - course/sandbox.py | 1 + course/starlark/builtin.py | 169 +++++++++ course/starlark/data.py | 237 +++++++++++++ course/starlark/dataclasses.py | 167 +++++++++ course/starlark/lib/_core_types.star | 3 + course/starlark/lib/core.star | 20 ++ course/starlark/lib/course.star | 14 + course/starlark/lib/rules.star | 6 + course/starlark/module.py | 283 +++++++++++++++ course/starlark/use_case/__init__.py | 75 ++++ course/starlark/use_case/rules.py | 219 ++++++++++++ course/templates/course/broken-starlark.txt | 38 ++ course/templates/course/flow-page.html | 6 +- course/utils.py | 367 ++++++++++++++++---- course/validation.py | 1 - course/views.py | 32 +- doc/conf.py | 2 + doc/flow.rst | 36 +- doc/index.rst | 1 + doc/page-types.rst | 8 +- doc/starlark.rst | 128 +++++++ pyproject.toml | 3 + tests/base_test_mixins.py | 2 +- tests/test_content.py | 2 +- tests/test_flow/test_flow.py | 111 +++--- tests/test_pages/test_base.py | 35 -- tests/test_starlark_rules.py | 40 +++ tests/test_utils.py | 20 +- uv.lock | 88 +++++ 43 files changed, 2242 insertions(+), 678 deletions(-) create mode 100644 course/starlark/builtin.py create mode 100644 course/starlark/data.py create mode 100644 course/starlark/dataclasses.py create mode 100644 course/starlark/lib/_core_types.star create mode 100644 course/starlark/lib/core.star create mode 100644 course/starlark/lib/course.star create mode 100644 course/starlark/lib/rules.star create mode 100644 course/starlark/module.py create mode 100644 course/starlark/use_case/__init__.py create mode 100644 course/starlark/use_case/rules.py create mode 100644 course/templates/course/broken-starlark.txt create mode 100644 doc/starlark.rst create mode 100644 tests/test_starlark_rules.py diff --git a/course/analytics.py b/course/analytics.py index bffe3df61..4cde31560 100644 --- a/course/analytics.py +++ b/course/analytics.py @@ -35,8 +35,8 @@ from django.utils.translation import gettext as _, pgettext from pytools import not_none -from course.constants import FlowPermission, ParticipationPermission as PPerm -from course.content import FlowDesc, get_flow_desc +from course.constants import ParticipationPermission as PPerm +from course.content import get_flow_desc from course.models import FlowPageVisit, FlowSession from course.utils import ( CoursePageContext, @@ -49,7 +49,6 @@ if TYPE_CHECKING: from collections.abc import Callable - from course.page.base import PageBase from course.utils import CoursePageContext @@ -235,29 +234,6 @@ def html(self): # }}} -def is_flow_multiple_submit(flow_desc: FlowDesc): - return any(FlowPermission.change_answer in rule.permissions - for rule in flow_desc.rules.access) - - -def is_page_multiple_submit(flow_desc: FlowDesc, page: PageBase): - result = is_flow_multiple_submit(flow_desc) - - page_rules = page.access_rules - if page_rules is None: - return result - - if result: - if FlowPermission.change_answer in page_rules.remove_permissions: - result = False - - else: - if FlowPermission.change_answer in page_rules.add_permissions: - result = True - - return result - - # {{{ flow analytics def make_grade_histogram(pctx: CoursePageContext, flow_id: str): @@ -370,7 +346,7 @@ def make_page_answer_stats_list( .distinct("flow_session__participation__id") .order_by("flow_session__participation__id", "visit_time")) - elif is_page_multiple_submit(flow_desc, page_desc): + else: visits = (visits .distinct("page_data__id") .order_by("page_data__id", "-visit_time")) @@ -528,9 +504,6 @@ def page_analytics(pctx: CoursePageContext, flow_id: str, group_id: str, page_id if not pctx.has_permission(PPerm.view_analytics): raise PermissionDenied(_("may not view analytics")) - flow_desc = get_flow_desc(pctx.repo, pctx.course, flow_id, - pctx.course_commit_sha) - restrict_to_first_attempt = int( bool(pctx.request.GET.get("restrict_to_first_attempt") == "1")) @@ -549,13 +522,11 @@ def page_analytics(pctx: CoursePageContext, flow_id: str, group_id: str, 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") .order_by("flow_session__participation__id", "visit_time")) - elif is_multiple_submit: + else: visits = (visits .distinct("page_data__id") .order_by("page_data__id", "-visit_time")) diff --git a/course/constants.py b/course/constants.py index b69fe2c3b..eb74cc565 100644 --- a/course/constants.py +++ b/course/constants.py @@ -25,13 +25,8 @@ from enum import StrEnum -from typing import TYPE_CHECKING -from django.utils.translation import gettext, pgettext_lazy - - -if TYPE_CHECKING: - from collections.abc import Set as AbstractSet +from django.utils.translation import pgettext_lazy # Allow 10x extra credit at the very most. @@ -303,21 +298,6 @@ class FlowSessionExpirationMode(StrEnum): "Do not submit session for grading")), ) - -def is_expiration_mode_allowed( - expmode: str, permissions: AbstractSet[FlowPermission] - ) -> bool: - if expmode == FlowSessionExpirationMode.roll_over: - if (FlowPermission.set_roll_over_expiration_mode - in permissions): - return True - elif expmode == FlowSessionExpirationMode.end: - return True - else: - raise ValueError(gettext("unknown expiration mode")) - - return False - # }}} diff --git a/course/content.py b/course/content.py index 39a91fad3..e40b9dbec 100644 --- a/course/content.py +++ b/course/content.py @@ -22,14 +22,13 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ - - import datetime import html.parser as html_parser import os import re from collections.abc import Set as AbstractSet from dataclasses import dataclass, field +from functools import partial from itertools import starmap from pathlib import Path from typing import ( @@ -38,8 +37,8 @@ Any, ClassVar, Self, + TypeAlias, TypeVar, - cast, ) from xml.etree.ElementTree import Element, tostring @@ -84,6 +83,12 @@ get_repo_blob_data_cached, get_repo_tree, ) +from course.starlark.use_case import validate_starlark_code +from course.starlark.use_case.rules import ( + FlowPageAccessRulesUseCase, + FlowSessionAccessRulesUseCase, + FlowStartRulesUseCase, +) from course.validation import ( DOMIdentifierStr, EventStr, @@ -274,15 +279,11 @@ class FlowRule: # {{{ flow start rule @dataclass(frozen=True, kw_only=True) -class FlowSessionStartMode: +class FlowSessionStartModeBase: may_start_new_session: bool """(Mandatory) A Boolean (True/False) value indicating whether, if the rule applies, the participant may start a new session.""" - may_list_existing_sessions: bool - """(Mandatory) A Boolean (True/False) value indicating whether, if the - rule applies, the participant may view a list of existing sessions.""" - tag_session: IdentifierStr | None = None """An identifier that will be applied to a newly-created session as a "tag". This can be used by @@ -297,7 +298,29 @@ class FlowSessionStartMode: @dataclass(frozen=True, kw_only=True) -class FlowSessionStartRuleDesc(FlowRule, FlowSessionStartMode): +class FlowSessionStartRuleOutcomeDesc(FlowSessionStartModeBase): + may_list_existing_sessions: bool + """(Mandatory) A Boolean (True/False) value indicating whether, if the + rule applies, the participant may view a list of existing sessions.""" + + +@dataclass(frozen=True, kw_only=True) +class FlowSessionStartMode(FlowSessionStartModeBase): + """ + .. autoattribute:: may_start_new_session + .. autoattribute:: tag_session + .. autoattribute:: lock_down_as_exam_session + .. autoattribute:: default_expiration_mode + .. autoattribute:: session_list_ids + """ + session_list_ids: list[int] + + +flow_session_start_mode_ta = TypeAdapter(FlowSessionStartMode) + + +@dataclass(frozen=True, kw_only=True) +class FlowSessionStartRuleDesc(FlowRule, FlowSessionStartRuleOutcomeDesc): """Rules that govern when a new session may be started and whether existing sessions may be listed. @@ -382,19 +405,36 @@ class FlowSessionStartRuleDesc(FlowRule, FlowSessionStartMode): start_rule_ta = TypeAdapter(FlowSessionStartRuleDesc) -# }}} +StartRuleCode: TypeAlias = Annotated[ + str, + AfterValidator(partial(validate_starlark_code, FlowStartRulesUseCase())) +] -# {{{ flow access rule @dataclass(frozen=True, kw_only=True) -class FlowSessionAccessMode(FlowRule): - permissions: AbstractSet[FlowPermission] - message: str | None = None +class FlowSessionStartRuleCode(FlowRule): + """ + .. attribute:: code + + :ref:`Starlark ` code specifying ability to list and + start flow sessions. + Must define a function ``rule`` that receives + :class:`~course.starlark.data.FlowSessionStartRuleArgs` + and returns a + :class:`~course.content.FlowSessionStartMode`. + """ + kind: ClassVar[FlowRuleKind] = FlowRuleKind.start + code: StartRuleCode + +# }}} + + +# {{{ flow access rule @dataclass(frozen=True, kw_only=True) -class FlowSessionAccessRuleDesc(FlowSessionAccessMode, FlowRule): +class FlowSessionAccessRuleDesc(FlowRule): """Rules that govern what a user may do with an existing session. Found in the ``access`` attribute of :class:`FlowRulesDesc`. @@ -417,7 +457,6 @@ class FlowSessionAccessRuleDesc(FlowSessionAccessMode, FlowRule): .. rubric:: Rules specified .. autoattribute:: permissions .. autoattribute:: message - """ kind: ClassVar[FlowRuleKind] = FlowRuleKind.access @@ -484,25 +523,53 @@ class FlowSessionAccessRuleDesc(FlowSessionAccessMode, FlowRule): if_has_prairietest_exam_access: str | None = None + permissions: Set[FlowPermission] + message: str | None = None + access_rule_ta = TypeAdapter(FlowSessionAccessRuleDesc) -# }}} +SessionAccessRuleCode: TypeAlias = Annotated[ + str, + AfterValidator(partial(validate_starlark_code, FlowSessionAccessRulesUseCase())) +] -FlowRuleT = TypeVar("FlowRuleT", bound=FlowRule) +PageAccessRuleCode: TypeAlias = Annotated[ + str, + AfterValidator(partial(validate_starlark_code, FlowPageAccessRulesUseCase())) +] -def get_rule_ta(tp: type[FlowRuleT]) -> TypeAdapter[FlowRuleT]: - if tp is FlowSessionStartRuleDesc: - return cast("TypeAdapter[FlowRuleT]", start_rule_ta) - elif tp is FlowSessionAccessRuleDesc: - return cast("TypeAdapter[FlowRuleT]", access_rule_ta) - elif tp is FlowSessionGradingRuleDesc: - return cast("TypeAdapter[FlowRuleT]", grading_rule_ta) - else: - raise AssertionError() +@dataclass(frozen=True, kw_only=True) +class FlowSessionAccessRuleCode(FlowRule): + """ + .. attribute:: session + + :ref:`Starlark ` code specifying access to a flow session. + Must define a function ``rule`` that receives + :class:`~course.starlark.data.FlowSessionAccessRuleArgs` + and returns a + :class:`~course.utils.FlowSessionAccessMode`. + + .. attribute:: page + + :ref:`Starlark ` code specifying access to a flow page. + Must define a function ``rule`` that receives + :class:`~course.starlark.data.FlowPageAccessRuleArgs` + and returns a + :class:`~course.utils.FlowPageAccessMode`. + """ + kind: ClassVar[FlowRuleKind] = FlowRuleKind.access + + session: SessionAccessRuleCode + page: PageAccessRuleCode + +# }}} + + +FlowRuleT = TypeVar("FlowRuleT", bound=FlowRule) # {{{ flow grading rule @@ -640,7 +707,7 @@ def has_conditionals(self): # {{{ flow rules -def default_start_rules(): +def default_start_rules() -> list[FlowSessionStartRuleDesc]: return [FlowSessionStartRuleDesc( may_start_new_session=True, may_list_existing_sessions=False)] @@ -677,15 +744,16 @@ class FlowRulesDesc: tags: list[IdentifierStr] = field(default_factory=list) - start: list[FlowSessionStartRuleDesc] = field(default_factory=default_start_rules) + start: list[FlowSessionStartRuleDesc] | FlowSessionStartRuleCode \ + = field(default_factory=default_start_rules) """Rules that govern when a new session may be started and whether existing sessions may be listed. Rules are tested from top to bottom. The first rule whose conditions apply determines the access.""" - access: list[FlowSessionAccessRuleDesc] = field( - default_factory=default_access_rules) + access: list[FlowSessionAccessRuleDesc] | FlowSessionAccessRuleCode \ + = field(default_factory=default_access_rules) """Rules that govern what a user may do while they are interacting with an existing session. @@ -736,25 +804,27 @@ def check_last_grading_rule_unconditional(self) -> Self: def check_tags_valid(self) -> Self: tags = set(self.tags) - if self.start: + if isinstance(self.start, list): for i, srule in enumerate(self.start): - if (srule.if_has_session_tagged is not None - and srule.if_has_session_tagged is not NotSpecified - and srule.if_has_session_tagged not in tags): - raise ValueError(f"access rule {i+1}: " - f"unknown session tag {srule.if_has_session_tagged}") - - if srule.tag_session is not None and srule.tag_session not in tags: - raise ValueError(f"access rule {i+1}: " - f"unknown session tag {srule.if_has_session_tagged}") - - if self.access: - for i, arule in enumerate(self.access): - if (arule.if_has_tag is not None - and arule.if_has_tag is not NotSpecified - and arule.if_has_tag not in tags): - raise ValueError(f"access rule {i+1}: " - f"unknown session tag {arule.if_has_tag}") + if isinstance(srule, FlowSessionStartRuleDesc): + if (srule.if_has_session_tagged is not None + and srule.if_has_session_tagged is not NotSpecified + and srule.if_has_session_tagged not in tags): + raise ValueError(f"access rule {i+1}: " + f"unknown session tag {srule.if_has_session_tagged}") + + if srule.tag_session is not None and srule.tag_session not in tags: + raise ValueError(f"access rule {i+1}: " + f"unknown session tag {srule.if_has_session_tagged}") + + if isinstance(self.access, list): + if self.access: + for i, arule in enumerate(self.access): + if (arule.if_has_tag is not None + and arule.if_has_tag is not NotSpecified + and arule.if_has_tag not in tags): + raise ValueError(f"access rule {i+1}: " + f"unknown session tag {arule.if_has_tag}") if self.grading: for i, grule in enumerate(self.grading): @@ -768,17 +838,19 @@ def check_tags_valid(self) -> Self: @model_validator(mode="after") def check_for_ignored_permissions(self) -> Self: - for i, arule in enumerate(self.access): - if arule.if_in_progress is False and ( - FlowPermission.submit_answer in arule.permissions - or FlowPermission.end_session in arule.permissions): - # pydantic dataclasses do not get context, and so we can't really - # warn here. This has been a warning for a while, so maybe that's OK? - raise ValueError( - _("Access Rule {} Rule specifies " - "'submit_answer' or 'end_session' " - "permissions for non-in-progress flow. These " - "permissions will be ignored.").format(i+1)) + if isinstance(self.access, list): + for i, arule in enumerate(self.access): + if arule.if_in_progress is False and ( + FlowPermission.submit_answer in arule.permissions + or FlowPermission.end_session in arule.permissions): + # pydantic dataclasses do not get context, and so we can't really + # warn here. This has been a warning for a while, so maybe that's + # OK? + raise ValueError( + _("Access Rule {} Rule specifies " + "'submit_answer' or 'end_session' " + "permissions for non-in-progress flow. These " + "permissions will be ignored.").format(i+1)) return self diff --git a/course/datespec.py b/course/datespec.py index 87198375a..c0ce4eed1 100644 --- a/course/datespec.py +++ b/course/datespec.py @@ -168,11 +168,11 @@ def apply(self, dtm: datetime.datetime): ] -def parse_date_spec( +def parse_date_spec_or_none( course: Course | None, datespec: str | datetime.date | datetime.datetime, vctx: ValidationContext | None = None, - ) -> datetime.datetime: + ) -> datetime.datetime | None: orig_datespec = datespec def localize_if_needed(d: datetime.datetime) -> datetime.datetime: @@ -247,7 +247,7 @@ def apply_postprocs(dtime: datetime.datetime) -> datetime.datetime: raise ValueError(_("expected an identifier, got: '{}'").format(event_kind)) if course is None: - return now() + return None from course.models import Event @@ -261,10 +261,10 @@ def apply_postprocs(dtime: datetime.datetime) -> datetime.datetime: if vctx is not None: vctx.add_warning( _("Unrecognized date/time specification: '%s' " - "(interpreted as 'now'). " + "(possibly interpreted as 'now'). " "You should add an event with this name.") % orig_datespec) - return now() + return None if is_end: if event_obj.end_time is not None: @@ -281,6 +281,15 @@ def apply_postprocs(dtime: datetime.datetime) -> datetime.datetime: return apply_postprocs(result) + +def parse_date_spec( + course: Course | None, + datespec: str | datetime.date | datetime.datetime, + vctx: ValidationContext | None = None, + ) -> datetime.datetime: + result = parse_date_spec_or_none(course, datespec, vctx) + return now() if result is None else result + # }}} diff --git a/course/flow.py b/course/flow.py index eea657176..6bbc3f758 100644 --- a/course/flow.py +++ b/course/flow.py @@ -51,12 +51,10 @@ FLOW_SESSION_EXPIRATION_MODE_CHOICES, GRADE_AGGREGATION_STRATEGY_CHOICES, SESSION_LOCKED_TO_FLOW_PK, - FlowPermission, FlowSessionExpirationMode, FlowSessionInteractionKind, GradeAggregationStrategy, ParticipationPermission as PPerm, - is_expiration_mode_allowed, ) from course.content import FlowSessionStartMode, TabDesc from course.exam import get_login_exam_ticket @@ -91,6 +89,8 @@ if TYPE_CHECKING: import datetime from collections.abc import Iterable, Set as AbstractSet + from collections.abc import Iterable, Set + from collections.abc import Iterable from django.db.models import query @@ -110,6 +110,21 @@ # }}} +def is_expiration_mode_allowed( + expmode: str, + mode: c_utils.FlowSessionAccessMode, + ) -> bool: + if expmode == FlowSessionExpirationMode.roll_over: + if mode.may_set_rollover_expiration_mode: + return True + elif expmode == FlowSessionExpirationMode.end: + return True + else: + raise ValueError(gettext("unknown expiration mode")) + + return False + + # {{{ page data wrangling @retry_transaction_decorator(serializable=True) @@ -1035,8 +1050,9 @@ def expire_flow_session( if flow_session.expiration_mode == FlowSessionExpirationMode.roll_over: session_start_rule = c_utils.get_session_start_mode( + fctx.repo, fctx.course_commit_sha, flow_session.course, flow_session.participation, - flow_session.flow_id, fctx.flow_desc, now_datetime, + flow_session.flow_id, fctx.flow_desc.rules, now_datetime, for_rollover=True) if not session_start_rule.may_start_new_session: @@ -1050,14 +1066,16 @@ def expire_flow_session( # {{{ FIXME: This is weird and should probably not exist. - access_rule = c_utils.get_session_access_mode( - flow_session, fctx.flow_desc, now_datetime) + access_mode = c_utils.get_session_access_mode( + fctx.repo, fctx.course_commit_sha, + flow_session, fctx.flow_desc.rules, now_datetime, + page_data=None) flow_session.expiration_mode = session_start_rule.default_expiration_mode if not is_expiration_mode_allowed( FlowSessionExpirationMode(flow_session.expiration_mode), - access_rule.permissions): + access_mode): flow_session.expiration_mode = FlowSessionExpirationMode.end # }}} @@ -1247,7 +1265,7 @@ def finish_flow_session_standalone( fctx = c_utils.FlowContext(repo, course, session.flow_id) - grading_rule = c_utils.get_session_grading_mode(session, fctx.flow_desc, + grading_rule = c_utils.get_session_grading_mode(session, fctx.flow_desc.rules, now_datetime_filled) if past_due_only: @@ -1275,7 +1293,7 @@ def expire_flow_session_standalone( fctx = c_utils.FlowContext(repo, course, session.flow_id) grading_rule = c_utils.get_session_grading_mode( - session, fctx.flow_desc, now_datetime) + session, fctx.flow_desc.rules, now_datetime) return expire_flow_session(fctx, session, grading_rule, now_datetime, past_due_only=past_due_only) @@ -1348,14 +1366,11 @@ def recalculate_session_grade( # }}} -def lock_down_if_needed( +def lock_session_to_flow( request: http.HttpRequest, - permissions: AbstractSet[str], flow_session: FlowSession, ) -> None: - - if FlowPermission.lock_down_as_exam_session in permissions: - request.session[SESSION_LOCKED_TO_FLOW_PK] = flow_session.pk + request.session[SESSION_LOCKED_TO_FLOW_PK] = flow_session.pk # {{{ view: start flow @@ -1382,48 +1397,54 @@ def view_start_flow(pctx: CoursePageContext, flow_id: str) -> http.HttpResponse: login_exam_ticket = get_login_exam_ticket(pctx.request) now_datetime = get_now_or_fake_time(request) - session_start_rule = c_utils.get_session_start_mode( + session_start_mode = c_utils.get_session_start_mode( + pctx.repo, pctx.course_commit_sha, pctx.course, pctx.participation, - flow_id, fctx.flow_desc, now_datetime, + flow_id, fctx.flow_desc.rules, now_datetime, facilities=pctx.request.relate_facilities, login_exam_ticket=login_exam_ticket, remote_ip_address=remote_address_from_request(pctx.request)) - if session_start_rule.may_list_existing_sessions: - past_sessions = (FlowSession.objects - .filter( - participation=pctx.participation, - flow_id=fctx.flow_id, - participation__isnull=False) - .order_by("start_time")) + id_to_past_session = { + sess.id: sess + for sess in FlowSession.objects + .filter( + participation=pctx.participation, + flow_id=fctx.flow_id, + participation__isnull=False) + } + + if session_start_mode.session_list_ids: + past_sessions = [ + sess + for sid in session_start_mode.session_list_ids + if (sess := id_to_past_session[sid]) + ] past_sessions_and_properties: list[tuple[FlowSession, SessionProperties]] = [] for session in past_sessions: - access_rule = c_utils.get_session_access_mode( - session, fctx.flow_desc, now_datetime, + access_mode = c_utils.get_session_access_mode( + pctx.repo, pctx.course_commit_sha, + session, fctx.flow_desc.rules, now_datetime, facilities=pctx.request.relate_facilities, login_exam_ticket=login_exam_ticket, - remote_ip_address=remote_address_from_request(pctx.request)) + remote_ip_address=remote_address_from_request(pctx.request), + page_data=None) grading_rule = c_utils.get_session_grading_mode( - session, fctx.flow_desc, now_datetime) + session, fctx.flow_desc.rules, now_datetime) session_properties = SessionProperties( - may_view=FlowPermission.view in access_rule.permissions, - may_modify=( - FlowPermission.submit_answer in access_rule.permissions - or FlowPermission.end_session in access_rule.permissions - ), + may_view=access_mode.may_view, + may_modify=access_mode.may_end, due=(grading_rule.due.eval(pctx.course) if grading_rule.due else None), grade_description=grading_rule.description, - grade_shown=( - FlowPermission.cannot_see_flow_result - not in access_rule.permissions)) + grade_shown=access_mode.show_flow_grade) past_sessions_and_properties.append((session, session_properties)) else: past_sessions_and_properties = [] - may_start = session_start_rule.may_start_new_session + may_start = session_start_mode.may_start_new_session new_session_grading_rule = None start_may_decrease_grade = False grade_aggregation_strategy_descr = None @@ -1438,13 +1459,13 @@ def view_start_flow(pctx: CoursePageContext, flow_id: str) -> http.HttpResponse: # default_expiration_mode ignored expiration_mode=FlowSessionExpirationMode.end, - access_rules_tag=session_start_rule.tag_session) + access_rules_tag=session_start_mode.tag_session) new_session_grading_rule = c_utils.get_session_grading_mode( - potential_session, fctx.flow_desc, now_datetime) + potential_session, fctx.flow_desc.rules, now_datetime) start_may_decrease_grade = ( - bool(past_sessions_and_properties) + bool(id_to_past_session) and new_session_grading_rule.grade_aggregation_strategy not in [ None, @@ -1505,8 +1526,9 @@ def post_start_flow( pctx.course.identifier, latest_session.id, 0) session_start_rule = c_utils.get_session_start_mode( + pctx.repo, pctx.course_commit_sha, pctx.course, pctx.participation, - flow_id, fctx.flow_desc, now_datetime, + flow_id, fctx.flow_desc.rules, now_datetime, facilities=pctx.request.relate_facilities, login_exam_ticket=login_exam_ticket, remote_ip_address=remote_address_from_request(pctx.request)) @@ -1527,12 +1549,16 @@ def post_start_flow( now_datetime=now_datetime) access_rule = c_utils.get_session_access_mode( - session, fctx.flow_desc, now_datetime, + pctx.repo, pctx.course_commit_sha, + session, fctx.flow_desc.rules, now_datetime, facilities=pctx.request.relate_facilities, login_exam_ticket=login_exam_ticket, - remote_ip_address=remote_address_from_request(pctx.request)) + remote_ip_address=remote_address_from_request(pctx.request), + page_data=None, + ) - lock_down_if_needed(pctx.request, access_rule.permissions, session) + if access_rule.lock_down_as_exam_session: + lock_session_to_flow(pctx.request, session) return redirect("relate-view_flow_page", pctx.course.identifier, session.id, 0) @@ -1558,14 +1584,17 @@ def view_resume_flow( login_exam_ticket = get_login_exam_ticket(pctx.request) - access_rule = c_utils.get_session_access_mode( - flow_session, fctx.flow_desc, now_datetime, + access_mode = c_utils.get_session_access_mode( + pctx.repo, pctx.course_commit_sha, + flow_session, fctx.flow_desc.rules, now_datetime, facilities=pctx.request.relate_facilities, login_exam_ticket=login_exam_ticket, - remote_ip_address=remote_address_from_request(pctx.request)) + remote_ip_address=remote_address_from_request(pctx.request), + page_data=None, + ) - lock_down_if_needed(pctx.request, access_rule.permissions, - flow_session) + if access_mode.lock_down_as_exam_session: + lock_session_to_flow(pctx.request, flow_session) return redirect("relate-view_flow_page", pctx.course.identifier, flow_session.id, 0) @@ -1624,108 +1653,73 @@ def get_and_check_flow_session( return flow_session -def will_receive_feedback(permissions: AbstractSet[FlowPermission]) -> bool: - return ( - FlowPermission.see_correctness in permissions - or FlowPermission.see_answer_after_submission in permissions) - - def may_send_email_about_flow_page( - flow_session: FlowSession, permissions: AbstractSet[FlowPermission]) -> bool: + flow_session: FlowSession, + mode: c_utils.FlowPageAccessMode) -> bool: return ( flow_session.participation is not None and flow_session.user is not None - and FlowPermission.send_email_about_flow_page in permissions) + and mode.may_send_email) def get_page_behavior( - page: PageBase, - permissions: AbstractSet[FlowPermission], + page_access_mode: c_utils.FlowPageAccessMode, session_in_progress: bool, answer_was_graded: bool, generates_grade: bool, is_unenrolled_session: bool, viewing_prior_version: bool = False, ) -> PageBehavior: - show_correctness = False - - if page.expects_answer(): - if answer_was_graded: - show_correctness = FlowPermission.see_correctness in permissions - - show_answer = FlowPermission.see_answer_after_submission in permissions - - if session_in_progress: - # Don't reveal the answer if they can still change their mind - show_answer = (show_answer - and FlowPermission.change_answer not in permissions) - - show_answer = show_answer or ( - FlowPermission.see_answer_before_submission in permissions) - else: - # Don't show answer yet - show_answer = ( - FlowPermission.see_answer_before_submission in permissions) - else: - show_answer = ( - FlowPermission.see_answer_before_submission in permissions - or FlowPermission.see_answer_after_submission in permissions) - - may_change_answer = ( + from course.page.base import PageBehavior + return PageBehavior( + show_correctness=answer_was_graded and page_access_mode.show_correctness, + show_feedback=answer_was_graded and page_access_mode.show_feedback, + show_answer=page_access_mode.show_answer, + may_change_answer=( not viewing_prior_version - and (not answer_was_graded - or (FlowPermission.change_answer in permissions)) + and page_access_mode.may_submit # can happen if no answer was ever saved and session_in_progress - and (FlowPermission.submit_answer in permissions) - and ((generates_grade and not is_unenrolled_session) or (not generates_grade)) - ) - - from course.page.base import PageBehavior - return PageBehavior( - show_correctness=show_correctness, - show_answer=show_answer, - may_change_answer=may_change_answer, - ) + ), + ) def add_buttons_to_form( form: StyledFormBase, fpctx: c_utils.FlowPageContext, flow_session: FlowSession, - permissions: AbstractSet[FlowPermission]) -> StyledFormBase: + page_access_mode: c_utils.FlowPageAccessMode) -> StyledFormBase: from crispy_forms.layout import Submit form.helper.add_input( Submit("save", _("Save answer"), css_class="relate-save-button")) - if will_receive_feedback(permissions): - if FlowPermission.change_answer in permissions: - form.helper.add_input( - Submit( - "submit", _("Submit answer for feedback"), - accesskey="g", - css_class="relate-save-button relate-submit-button")) - else: - form.helper.add_input( - Submit("submit", _("Submit final answer"), - css_class="relate-save-button relate-submit-button")) + is_not_last_page = (not_none(fpctx.page_data.page_ordinal) + 1 + < not_none(flow_session.page_count)) + if page_access_mode.will_receive_feedback: + form.helper.add_input( + Submit( + "submit", _("Submit answer"), + accesskey="g", + css_class="relate-save-button relate-submit-button", + title=page_access_mode.submit_message, + )) else: # Only offer 'save and move on' if student will receive no feedback - if (not_none(fpctx.page_data.page_ordinal) + 1 - < not_none(flow_session.page_count)): + if is_not_last_page: form.helper.add_input( Submit("save_and_next", mark_safe( string_concat( _("Save answer and move on"), " »")), - css_class="relate-save-button")) + css_class="relate-save-button", + )) else: form.helper.add_input( Submit("save_and_finish", @@ -1801,34 +1795,39 @@ def view_flow_page( assert fpctx.page_data is not None now_datetime = get_now_or_fake_time(request) - access_rule = c_utils.get_session_access_mode( - flow_session, fpctx.flow_desc, now_datetime, + session_access_mode, page_access_mode = c_utils.get_session_access_mode( + fpctx.repo, fpctx.course_commit_sha, + flow_session, fpctx.flow_desc.rules, now_datetime, facilities=pctx.request.relate_facilities, login_exam_ticket=login_exam_ticket, - remote_ip_address=remote_address_from_request(pctx.request)) + remote_ip_address=remote_address_from_request(pctx.request), + page_data=fpctx.page_data, + permission_modifier=fpctx.page.get_modified_permissions_for_page, + ) - grading_rule = c_utils.get_session_grading_mode( - flow_session, fpctx.flow_desc, now_datetime) + grading_mode = c_utils.get_session_grading_mode( + flow_session, fpctx.flow_desc.rules, now_datetime) generates_grade = ( - grading_rule.grade_identifier is not None - and grading_rule.generates_grade) - del grading_rule + grading_mode.grade_identifier is not None + and grading_mode.generates_grade) + del grading_mode - permissions = fpctx.page.get_modified_permissions_for_page( - access_rule.permissions) + if page_access_mode.message: + messages.add_message(request, messages.INFO, page_access_mode.message) - if access_rule.message: - messages.add_message(request, messages.INFO, access_rule.message) - - lock_down_if_needed(pctx.request, permissions, flow_session) + if session_access_mode.lock_down_as_exam_session: + lock_session_to_flow(pctx.request, flow_session) page_context = fpctx.page_context page_data = fpctx.page_data answer_data = None grade_data = None - if FlowPermission.view not in permissions: + if not session_access_mode.may_view: raise PermissionDenied(_("not allowed to view flow")) + if not page_access_mode.may_view: + # FIXME: Do something more nuanced here that allows navigation to other pages + raise PermissionDenied(_("not allowed to view page")) answer_visit = None prev_visit_id = None @@ -1852,7 +1851,7 @@ def view_flow_page( raise SuspiciousOperation("POST to previous visit") post_result = post_flow_page( - flow_session, fpctx, request, permissions, generates_grade) + flow_session, fpctx, request, page_access_mode, generates_grade) if not isinstance(post_result, tuple): # ought to be an HTTP response @@ -1919,8 +1918,7 @@ def view_flow_page( answer_was_graded = False page_behavior = get_page_behavior( - page=fpctx.page, - permissions=permissions, + page_access_mode=page_access_mode, session_in_progress=flow_session.in_progress, answer_was_graded=answer_was_graded, generates_grade=generates_grade, @@ -1971,7 +1969,7 @@ def view_flow_page( if form is not None and page_behavior.may_change_answer: form = add_buttons_to_form(form, fpctx, flow_session, - permissions) + page_access_mode) shown_feedback = None if (fpctx.page.expects_answer() and answer_was_graded @@ -1993,7 +1991,7 @@ def view_flow_page( if (generates_grade and flow_session.participation is None - and FlowPermission.submit_answer in permissions): + and page_access_mode.may_submit): messages.add_message(request, messages.INFO, _("Changes to this session are being prevented " "because this session yields a permanent grade, but " @@ -2008,15 +2006,14 @@ def view_flow_page( else: form_html = None - expiration_mode_choices = [] - + expiration_mode_choices: list[tuple[FlowSessionExpirationMode, str]] = [] for key, descr in FLOW_SESSION_EXPIRATION_MODE_CHOICES: - if is_expiration_mode_allowed(key, permissions): - expiration_mode_choices.append((key, descr)) + if is_expiration_mode_allowed(key, session_access_mode): + expiration_mode_choices.append((key, str(descr))) session_minutes = None time_factor: float = 1 - if FlowPermission.see_session_time in permissions: + if session_access_mode.show_session_time: if not flow_session.in_progress: end_time = as_local_time(not_none(flow_session.completion_time)) else: @@ -2060,15 +2057,14 @@ def view_flow_page( "correct_answer": correct_answer, "show_correctness": page_behavior.show_correctness, + "show_feedback": page_behavior.show_feedback, "may_change_answer": page_behavior.may_change_answer, - "may_change_graded_answer": ( - page_behavior.may_change_answer - and (FlowPermission.change_answer in permissions)), - "will_receive_feedback": will_receive_feedback(permissions), + "submit_message": page_access_mode.submit_message, + "will_receive_feedback": page_access_mode.will_receive_feedback, "show_answer": page_behavior.show_answer, "may_send_email_about_flow_page": - may_send_email_about_flow_page(flow_session, permissions), - "hide_point_count": FlowPermission.hide_point_count in permissions, + may_send_email_about_flow_page(flow_session, page_access_mode), + "hide_point_count": not page_access_mode.show_point_count, "expects_answer": fpctx.page.expects_answer(), "session_minutes": session_minutes, @@ -2186,7 +2182,7 @@ def post_flow_page( flow_session: FlowSession, fpctx: c_utils.FlowPageContext, request: http.HttpRequest, - permissions: AbstractSet[FlowPermission], + page_access_mode: c_utils.FlowPageAccessMode, generates_grade: bool, ) -> tuple[ PageBehavior, list[FlowPageVisit], @@ -2204,7 +2200,7 @@ def post_flow_page( assert fpctx.page is not None # reject answer update if permission not present - if FlowPermission.submit_answer not in permissions: + if not page_access_mode.may_submit: messages.add_message(request, messages.ERROR, _("Answer submission not allowed.")) submission_allowed = False @@ -2212,18 +2208,8 @@ def post_flow_page( prev_answer_visits = list( get_prev_answer_visits_qset(fpctx.page_data)) - # reject if previous answer was final - if (prev_answer_visits - and prev_answer_visits[0].is_submitted_answer - and FlowPermission.change_answer - not in permissions): - messages.add_message(request, messages.ERROR, - _("Already have final answer.")) - submission_allowed = False - page_behavior = get_page_behavior( - page=fpctx.page, - permissions=permissions, + page_access_mode=page_access_mode, session_in_progress=flow_session.in_progress, answer_was_graded=False, generates_grade=generates_grade, @@ -2261,8 +2247,7 @@ def post_flow_page( answer_was_graded = answer_visit.is_submitted_answer page_behavior = get_page_behavior( - page=fpctx.page, - permissions=permissions, + page_access_mode=page_access_mode, session_in_progress=flow_session.in_progress, answer_was_graded=answer_was_graded, generates_grade=generates_grade, @@ -2294,13 +2279,13 @@ def post_flow_page( feedback = None if (pressed_button == "save_and_next" - and not will_receive_feedback(permissions)): + and not page_access_mode.will_receive_feedback): return redirect("relate-view_flow_page", fpctx.course.identifier, flow_session.id, fpctx.page_ordinal + 1) elif (pressed_button == "save_and_finish" - and not will_receive_feedback(permissions)): + and not page_access_mode.will_receive_feedback): return redirect("relate-finish_flow_session_view", fpctx.course.identifier, flow_session.id) else: @@ -2376,17 +2361,20 @@ def send_email_about_flow_page( request = pctx.request now_datetime = get_now_or_fake_time(request) login_exam_ticket = get_login_exam_ticket(request) - access_rule = c_utils.get_session_access_mode( - flow_session, fpctx.flow_desc, now_datetime, + session_access_mode, page_access_mode = c_utils.get_session_access_mode( + pctx.repo, pctx.course_commit_sha, + flow_session, fpctx.flow_desc.rules, now_datetime, facilities=pctx.request.relate_facilities, login_exam_ticket=login_exam_ticket, - remote_ip_address=remote_address_from_request(pctx.request)) - - permissions = fpctx.page.get_modified_permissions_for_page( - access_rule.permissions) + remote_ip_address=remote_address_from_request(pctx.request), + page_data=fpctx.page_data, + permission_modifier=fpctx.page.get_modified_permissions_for_page, + ) + if not (session_access_mode.may_view and page_access_mode.may_view): + raise PermissionDenied() - if not may_send_email_about_flow_page(flow_session, permissions): - raise http.Http404() + if not may_send_email_about_flow_page(flow_session, page_access_mode): + raise PermissionDenied() # }}} @@ -2590,14 +2578,17 @@ def update_expiration_mode( fctx = c_utils.FlowContext(pctx.repo, pctx.course, flow_session.flow_id, participation=pctx.participation) - access_rule = c_utils.get_session_access_mode( - flow_session, fctx.flow_desc, + access_mode = c_utils.get_session_access_mode( + pctx.repo, pctx.course_commit_sha, + flow_session, fctx.flow_desc.rules, get_now_or_fake_time(pctx.request), facilities=pctx.request.relate_facilities, login_exam_ticket=login_exam_ticket, - remote_ip_address=remote_address_from_request(pctx.request)) + remote_ip_address=remote_address_from_request(pctx.request), + page_data=None, + ) - if is_expiration_mode_allowed(expmode, access_rule.permissions): + if is_expiration_mode_allowed(expmode, access_mode): flow_session.expiration_mode = expmode flow_session.save() @@ -2631,11 +2622,14 @@ def finish_flow_session_view( fctx = c_utils.FlowContext(pctx.repo, pctx.course, flow_id, participation=pctx.participation) - access_rule = c_utils.get_session_access_mode( - flow_session, fctx.flow_desc, now_datetime, + access_mode = c_utils.get_session_access_mode( + pctx.repo, pctx.course_commit_sha, + flow_session, fctx.flow_desc.rules, now_datetime, facilities=pctx.request.relate_facilities, login_exam_ticket=login_exam_ticket, - remote_ip_address=remote_address_from_request(pctx.request)) + remote_ip_address=remote_address_from_request(pctx.request), + page_data=None, + ) from course.content import markup_to_html completion_text = markup_to_html( @@ -2651,7 +2645,7 @@ def finish_flow_session_view( get_session_answered_page_data( fctx, flow_session, answer_visits) - if FlowPermission.view not in access_rule.permissions: + if not access_mode.may_view: raise PermissionDenied() def render_finish_response(template, **kwargs) -> http.HttpResponse: @@ -2666,7 +2660,7 @@ def render_finish_response(template, **kwargs) -> http.HttpResponse: allow_instant_flow_requests=False) grading_rule = c_utils.get_session_grading_mode( - flow_session, fctx.flow_desc, now_datetime) + flow_session, fctx.flow_desc.rules, now_datetime) if request.method == "POST": if "submit" not in request.POST: @@ -2676,9 +2670,8 @@ def render_finish_response(template, **kwargs) -> http.HttpResponse: messages.add_message(request, messages.ERROR, _("Cannot end a session that's already ended")) - if FlowPermission.end_session not in access_rule.permissions: - raise PermissionDenied( - _("not permitted to end session")) + if not access_mode.may_end: + raise PermissionDenied(_("not permitted to end session")) grade_info = finish_flow_session( fctx, flow_session, grading_rule, @@ -2760,7 +2753,7 @@ def render_finish_response(template, **kwargs) -> http.HttpResponse: # }}} if is_interactive_flow: - if FlowPermission.cannot_see_flow_result in access_rule.permissions: + if not access_mode.show_flow_grade: grade_info = None return render_finish_response( @@ -2776,8 +2769,7 @@ def render_finish_response(template, **kwargs) -> http.HttpResponse: completion_text=completion_text) if (not is_interactive_flow - or (flow_session.in_progress - and FlowPermission.end_session not in access_rule.permissions)): + or (flow_session.in_progress and not access_mode.may_end)): # No ability to end--just show completion page. return render_finish_response( @@ -2791,7 +2783,7 @@ def render_finish_response(template, **kwargs) -> http.HttpResponse: grade_info = gather_grade_info( fctx, flow_session, grading_rule, answer_visits) - if FlowPermission.cannot_see_flow_result in access_rule.permissions: + if access_mode.show_flow_grade: grade_info = None return render_finish_response( diff --git a/course/grades.py b/course/grades.py index c8b1d0f9b..b1a8f5a87 100644 --- a/course/grades.py +++ b/course/grades.py @@ -1015,7 +1015,7 @@ def view_single_grade(pctx: CoursePageContext, participation_id: str, respect_preview=False) grading_rule = get_session_grading_mode( - session, flow_desc, now_datetime) + session, flow_desc.rules, now_datetime) session_properties = SessionProperties( due=grading_rule.due, diff --git a/course/grading.py b/course/grading.py index 450ad4a1f..a7bd6f2bb 100644 --- a/course/grading.py +++ b/course/grading.py @@ -241,6 +241,7 @@ def grade_flow_page( from course.page.base import PageBehavior page_behavior = PageBehavior( show_correctness=True, + show_feedback=True, show_answer=False, may_change_answer=False) @@ -358,7 +359,7 @@ def grade_flow_page( # }}} grading_rule = get_session_grading_mode( - flow_session, fpctx.flow_desc, get_now_or_fake_time(pctx.request)) + flow_session, fpctx.flow_desc.rules, get_now_or_fake_time(pctx.request)) if grading_rule.grade_identifier is not None: grading_opportunity: GradingOpportunity | None = \ @@ -420,7 +421,7 @@ def _save_grade( bulk_feedback_json) grading_rule = get_session_grading_mode( - flow_session, fpctx.flow_desc, now_datetime) + flow_session, fpctx.flow_desc.rules, now_datetime) from course.flow import grade_flow_session grade_flow_session(fpctx, flow_session, grading_rule) diff --git a/course/page/base.py b/course/page/base.py index 13da76178..7dc4bd3de 100644 --- a/course/page/base.py +++ b/course/page/base.py @@ -93,10 +93,6 @@ See ``relate.utils.Repo_ish``. -.. class:: Course - - See ``course.models.Course``. - .. class:: FlowSession See ``course.models.FlowSession``. @@ -152,31 +148,18 @@ class PageContext: request: django.http.HttpRequest | None = None -@final +@dataclass(frozen=True) class PageBehavior: """ .. attribute:: show_correctness + .. attribute:: show_feedback .. attribute:: show_answer .. attribute:: may_change_answer """ - - def __init__( - self, - show_correctness: bool, - show_answer: bool, - may_change_answer: bool, - ) -> None: - - self.show_correctness = show_correctness - self.show_answer = show_answer - self.may_change_answer = may_change_answer - - def __bool__(self): - # This is for compatibility: page_behavior used to be a bool argument - # 'answer_is_final'. - return not self.may_change_answer - - __nonzero__ = __bool__ + show_correctness: bool + show_feedback: bool + show_answer: bool + may_change_answer: bool def markup_to_html( diff --git a/course/page/choice.py b/course/page/choice.py index 93610afb2..b924a8015 100644 --- a/course/page/choice.py +++ b/course/page/choice.py @@ -299,10 +299,6 @@ class ChoiceQuestion(ChoiceQuestionBase, PageBaseWithoutHumanGrading): |is-optional-page-attr| - .. attribute:: access_rules - - |access-rules-page-attr| - .. attribute:: title |title-page-attr| @@ -475,10 +471,6 @@ class MultipleChoiceQuestion(ChoiceQuestionBase, PageBaseWithoutHumanGrading): |is-optional-page-attr| - .. attribute:: access_rules - - |access-rules-page-attr| - .. attribute:: title |title-page-attr| @@ -729,10 +721,6 @@ class SurveyChoiceQuestion(PageBaseWithTitle, PageBaseUngraded): |is-optional-page-attr| - .. attribute:: access_rules - - |access-rules-page-attr| - .. attribute:: title |title-page-attr| diff --git a/course/page/code.py b/course/page/code.py index a8a92bc1a..a1b3ae72b 100644 --- a/course/page/code.py +++ b/course/page/code.py @@ -47,7 +47,6 @@ from pytools import not_none from typing_extensions import override -from course.constants import FlowPermission from course.page.base import ( AnswerData, AnswerFeedback, @@ -448,20 +447,6 @@ class CodeQuestion(PageBaseWithTitle, PageBaseWithValue, ABC): is in the specified language. This class should be treated as an interface and used only as a superclass. - If you are not including the - :attr:`course.constants.FlowPermission.change_answer` - permission for your entire flow, you likely want to - include this snippet in your question definition: - - .. code-block:: yaml - - access_rules: - add_permissions: - - change_answer - - This will allow participants multiple attempts at getting - the right answer. - .. attribute:: id |id-page-attr| @@ -474,10 +459,6 @@ class CodeQuestion(PageBaseWithTitle, PageBaseWithValue, ABC): |is-optional-page-attr| - .. attribute:: access_rules - - |access-rules-page-attr| - .. attribute:: title |title-page-attr| @@ -568,12 +549,6 @@ class CodeQuestion(PageBaseWithTitle, PageBaseWithValue, ABC): available to :attr:`setup_code` and :attr:`test_code` through the ``data_files`` dictionary. (see below) - .. attribute:: single_submission - - Optional, a Boolean. If the question does not allow multiple submissions - based on its :attr:`access_rules` (not the ones of the flow), a warning - is shown. Setting this attribute to True will silence the warning. - .. attribute:: docker_image Optional. @@ -616,35 +591,13 @@ class CodeQuestion(PageBaseWithTitle, PageBaseWithValue, ABC): # It is not considered anywhere else. check_user_code: str | None = None - @model_validator(mode="after") - def check_has_multi_submit(self, info: ValidationInfo) -> Self: - vctx = get_validation_context(info) - - if not self.single_submission: - is_multi_submit = False - - if self.access_rules is not None: - if FlowPermission.change_answer in self.access_rules.add_permissions: - is_multi_submit = True - - if not is_multi_submit: - vctx.add_warning(_("code question does not explicitly " - "allow multiple submission. Either add " - "access_rules/add_permissions/change_answer " - "or add 'single_submission: True' to confirm that you intend " - "for only a single submission to be allowed. " - "While you're at it, consider adding " - "access_rules/add_permissions/see_correctness.")) - - return self - @model_validator(mode="after") def check_check_user_code_without_file_repo(self, info: ValidationInfo) -> Self: vctx = get_validation_context(info) - # use this as a proxy for 'running in the CLI' (plus the test suite is - # allowed, too) if self.check_user_code is not None: + # use this as a proxy for 'running in the CLI' (plus the test suite is + # allowed, too) if (not isinstance(vctx.repo, FileSystemFakeRepo) and "PYTEST_CURRENT_TEST" not in os.environ): raise ValueError("check_user_code is not None while " @@ -652,6 +605,16 @@ def check_check_user_code_without_file_repo(self, info: ValidationInfo) -> Self: return self + @model_validator(mode="after") + def check_deprecate_single_submission(self, info: ValidationInfo) -> Self: + vctx = get_validation_context(info).with_location(f"page '{self.id}'") + + if self.single_submission: + vctx.add_warning("'single_submission' is deprecated and will stop " + "being accepted in 2H2026.") + + return self + def _initial_code(self): result = self.initial_code if result is not None: @@ -1163,9 +1126,6 @@ class PythonCodeQuestion(CodeQuestion, PageBaseWithoutHumanGrading): type: PythonCodeQuestion id: addition - access_rules: - add_permissions: - - change_answer value: 1 timeout: 10 prompt: | @@ -1192,20 +1152,6 @@ class PythonCodeQuestion(CodeQuestion, PageBaseWithoutHumanGrading): else: feedback.finish(0, "Your computed c was incorrect.") - If you are not including the - :attr:`course.constants.FlowPermission.change_answer` - permission for your entire flow, you likely want to - include this snippet in your question definition: - - .. code-block:: yaml - - access_rules: - add_permissions: - - change_answer - - This will allow participants multiple attempts at getting - the right answer. - .. attribute:: id |id-page-attr| @@ -1218,10 +1164,6 @@ class PythonCodeQuestion(CodeQuestion, PageBaseWithoutHumanGrading): |is-optional-page-attr| - .. attribute:: access_rules - - |access-rules-page-attr| - .. attribute:: title |title-page-attr| @@ -1312,12 +1254,6 @@ class PythonCodeQuestion(CodeQuestion, PageBaseWithoutHumanGrading): available to :attr:`setup_code` and :attr:`test_code` through the ``data_files`` dictionary. (see below) - .. attribute:: single_submission - - Optional, a Boolean. If the question does not allow multiple submissions - based on its :attr:`access_rules` (not the ones of the flow), a warning - is shown. Setting this attribute to True will silence the warning. - The following symbols are available in :attr:`setup_code` and :attr:`test_code`: * ``GradingComplete``: An exception class that can be raised to indicated @@ -1391,20 +1327,6 @@ class PythonCodeQuestionWithHumanTextFeedback( This page type allows both automatic grading and grading by a human grader. - If you are not including the - :attr:`course.constants.FlowPermission.change_answer` - permission for your entire flow, you likely want to - include this snippet in your question definition: - - .. code-block:: yaml - - access_rules: - add_permissions: - - change_answer - - This will allow participants multiple attempts at getting - the right answer. - Besides those defined in :class:`PythonCodeQuestion`, the following additional, allowed/required attribute are introduced: diff --git a/course/page/inline.py b/course/page/inline.py index b945ea5de..2cd50a292 100644 --- a/course/page/inline.py +++ b/course/page/inline.py @@ -420,10 +420,6 @@ class InlineMultiQuestion( |is-optional-page-attr| - .. attribute:: access_rules - - |access-rules-page-attr| - .. attribute:: title |title-page-attr| diff --git a/course/page/static.py b/course/page/static.py index 9c0d468bd..2b776cebf 100644 --- a/course/page/static.py +++ b/course/page/static.py @@ -57,10 +57,6 @@ class Page(PageBaseWithCorrectAnswer, PageBaseWithTitle, PageBaseUngraded): ``Page`` - .. attribute:: access_rules - - |access-rules-page-attr| - .. attribute:: title |title-page-attr| diff --git a/course/page/text.py b/course/page/text.py index b385ad75b..0f4181112 100644 --- a/course/page/text.py +++ b/course/page/text.py @@ -618,10 +618,6 @@ class TextQuestionBase(PageBaseWithTitle, ABC): |is-optional-page-attr| - .. attribute:: access_rules - - |access-rules-page-attr| - .. attribute:: title |title-page-attr| @@ -753,10 +749,6 @@ class SurveyTextQuestion(TextQuestionBase, PageBaseUngraded): |is-optional-page-attr| - .. attribute:: access_rules - - |access-rules-page-attr| - .. attribute:: title |title-page-attr| @@ -846,10 +838,6 @@ class TextQuestion(TextQuestionBase, PageBaseWithValue, PageBaseWithoutHumanGrad |is-optional-page-attr| - .. attribute:: access_rules - - |access-rules-page-attr| - .. attribute:: title |title-page-attr| @@ -1042,10 +1030,6 @@ class HumanGradedTextQuestion(TextQuestionBase, PageBaseWithValue, |is-optional-page-attr| - .. attribute:: access_rules - - |access-rules-page-attr| - .. attribute:: title |title-page-attr| @@ -1133,10 +1117,6 @@ class HumanGradedRichTextQuestion(PageBaseWithValue, PageBaseWithTitle, |is-optional-page-attr| - .. attribute:: access_rules - - |access-rules-page-attr| - .. attribute:: title |title-page-attr| diff --git a/course/page/upload.py b/course/page/upload.py index b5e32f371..984eb3798 100644 --- a/course/page/upload.py +++ b/course/page/upload.py @@ -122,10 +122,6 @@ class FileUploadQuestion(PageBaseWithTitle, PageBaseWithValue, |is-optional-page-attr| - .. attribute:: access_rules - - |access-rules-page-attr| - .. attribute:: title |title-page-attr| diff --git a/course/sandbox.py b/course/sandbox.py index ed47b8754..c382f9716 100644 --- a/course/sandbox.py +++ b/course/sandbox.py @@ -358,6 +358,7 @@ def make_form(data: Mapping[str, Any] | None = None) -> PageSandboxForm: from course.page.base import PageBehavior page_behavior = PageBehavior( show_correctness=True, + show_feedback=True, show_answer=True, may_change_answer=True) diff --git a/course/starlark/builtin.py b/course/starlark/builtin.py new file mode 100644 index 000000000..b825e9bc1 --- /dev/null +++ b/course/starlark/builtin.py @@ -0,0 +1,169 @@ +from __future__ import annotations + + +__copyright__ = "Copyright (C) 2025 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 ipaddress +from datetime import datetime +from importlib.resources import files +from typing import TYPE_CHECKING, Never + +from course.constants import ( + FlowSessionExpirationMode, + ParticipationStatus, +) +from course.content import FlowSessionStartMode +from course.starlark.data import ( + FlowPageAccessRuleArgs, + FlowPageAttempt, + FlowPageId, + FlowSession, + FlowSessionAccessRuleArgs, + FlowSessionStartRuleArgs, + Participation, +) +from course.starlark.dataclasses import dataclass_to_starlark +from course.utils import FlowPageAccessMode, FlowSessionAccessMode + + +if TYPE_CHECKING: + from collections.abc import Callable + from enum import StrEnum + + from course.models import Course + from course.starlark.module import StarlarkModule + + +_newline = "\n" +_classes: list[type] = [ + Participation, + FlowSession, + FlowPageAttempt, + FlowPageId, + FlowSessionStartRuleArgs, + FlowSessionStartMode, + FlowSessionAccessRuleArgs, + FlowSessionAccessMode, + FlowPageAccessRuleArgs, + FlowPageAccessMode, +] + +_enums: list[type[StrEnum]] = [ + ParticipationStatus, + FlowSessionExpirationMode, +] + + +def str_enum_to_starlark(enum_tp: type[StrEnum]) -> str: + name = enum_tp.__name__ + return f"{name} = enum({', '.join(repr(s) for s in enum_tp.__members__)})" + + +RELATE_GENERATED_STAR = f""" +load("relate/_core_types.star", "Timestamp") + +{_newline.join(str_enum_to_starlark(cls) for cls in _enums)} + +{_newline.join(dataclass_to_starlark(cls) for cls in _classes)} + +PY_TYPE_MAP = {{ + {", ".join(f"'{cls.__name__}': {cls.__name__}" for cls in _classes)} +}} +""" + +_RELATE_MODULE_CACHE: dict[str, StarlarkModule] = {} + + +class StarlarkError(RuntimeError): + pass + + +def error(message: str) -> Never: + raise StarlarkError(message) + + +def parse_date_spec(course: Course | None, datespec: str): + if course is not None: + from course.datespec import parse_date_spec_or_none + dt = parse_date_spec_or_none(course, datespec) + return dt.timestamp() if dt is not None else dt + else: + return None + + +def has_prairietest_access( + course: Course | None, + user_uid: str | None, + user_uin: str | None, + exam_uuid: str, + now: float, + ip_address: str, + ) -> bool: + if course is not None: + from prairietest.utils import has_access_to_exam + return bool(has_access_to_exam( + course, user_uid=user_uid, user_uin=user_uin, + exam_uuid=exam_uuid, + now=datetime.fromtimestamp(now), + ip_address=ipaddress.ip_address(ip_address), + )) + else: + return False + + +def get_relate_starlark_module(name: str) -> StarlarkModule: + try: + return _RELATE_MODULE_CACHE[name] + except KeyError: + pass + + extra_functions: dict[str, Callable[..., object]] = {} + if name == "relate/_builtins.star": + code = "" + extra_functions = { + "error": error, + "parse_date_spec": parse_date_spec, + "has_prairietest_access": has_prairietest_access, + } + elif name == "relate/_generated.star": + code = RELATE_GENERATED_STAR + else: + lib_file = files("course.starlark.lib") + parts = name.split("/") + if parts[0] != "relate": + raise FileNotFoundError(name) + + for part in parts[1:]: + if part == "..": + raise ValueError("'..' not allowed in library imports") + lib_file = lib_file / part + code = lib_file.read_text() + + def load_func(name: str): + return get_relate_starlark_module(name) + + from course.starlark.module import make_starlark_module + mod = make_starlark_module(name, code, load_func, + extra_functions=extra_functions) + + _RELATE_MODULE_CACHE[name] = mod + return mod diff --git a/course/starlark/data.py b/course/starlark/data.py new file mode 100644 index 000000000..e214fadc8 --- /dev/null +++ b/course/starlark/data.py @@ -0,0 +1,237 @@ +from __future__ import annotations + + +__copyright__ = "Copyright (C) 2025 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 dataclasses import dataclass +from datetime import datetime # noqa: TC003 +from typing import TYPE_CHECKING, Self, TypeAlias + +from pytools import not_none + +from course.constants import FlowSessionExpirationMode + + +if TYPE_CHECKING: + from _typeshed import ConvertibleToFloat + + from course.models import ( + FlowPageData, + FlowPageVisit, + FlowSession as FlowSessionModel, + Participation as ParticipationModel, + ) + + +Opaque: TypeAlias = object + + +def float_or_none(v: ConvertibleToFloat | None) -> float | None: + if v is None: + return v + else: + return float(v) + + +@dataclass(frozen=True, kw_only=True) +class Participation: + """ + .. autoattribute:: id + .. autoattribute:: username + .. autoattribute:: email + .. autoattribute:: institutional_id + .. autoattribute:: tags + .. autoattribute:: roles + .. autoattribute:: time_factor + """ + id: int + username: str + email: str + institutional_id: str | None + tags: list[str] + roles: list[str] + time_factor: float = 1.0 + + @classmethod + def from_relate(cls, part: ParticipationModel | Self): + if isinstance(part, Participation): + return part + + # Mypy can't figure this out on its own: + from course.models import Participation as ParticipationModel + assert isinstance(part, ParticipationModel) + + return cls( + id=part.id, + username=part.user.username, + email=part.user.email, + institutional_id=( + part.user.institutional_id + if part.user.institutional_id_verified else None + ), + tags=[tag.name for tag in part.tags.all()], + roles=[role.identifier for role in part.roles.all()], + time_factor=float(part.time_factor), + ) + + +@dataclass(frozen=True, kw_only=True) +class FlowSession: + """ + .. autoattribute:: id + .. autoattribute:: start_time + .. autoattribute:: completion_time + .. autoattribute:: expiration_mode + .. autoattribute:: access_rules_tag + .. autoattribute:: points + .. autoattribute:: max_points + """ + id: int + start_time: datetime + completion_time: datetime | None + expiration_mode: FlowSessionExpirationMode | None + access_rules_tag: str | None + points: float | None + max_points: float | None + + @classmethod + def from_relate(cls, sess: FlowSessionModel | Self): + if isinstance(sess, FlowSession): + return sess + + # Mypy can't figure this out on its own: + from course.models import FlowSession as FlowSessionModel + assert isinstance(sess, FlowSessionModel) + + return cls( + id=sess.id, + start_time=sess.start_time, + completion_time=sess.completion_time, + expiration_mode=FlowSessionExpirationMode(sess.expiration_mode) + if sess.expiration_mode is not None else None, + access_rules_tag=sess.access_rules_tag, + points=float_or_none(sess.points), + max_points=float_or_none(sess.max_points), + ) + + +@dataclass(frozen=True, kw_only=True) +class FlowSessionStartRuleArgs: + """ + .. autoattribute:: course + .. autoattribute:: now + .. autoattribute:: participation + .. autoattribute:: flow_id + .. autoattribute:: sessions + .. autoattribute:: facilities + .. autoattribute:: has_matching_exam_ticket + """ + course: Opaque + now: datetime + participation: Participation | None + flow_id: str + sessions: list[FlowSession] + facilities: list[str] + has_matching_exam_ticket: bool + + +@dataclass(frozen=True, kw_only=True) +class FlowPageAttempt: + """ + .. autoattribute:: time + """ + time: datetime + + @classmethod + def from_relate(cls, visit: FlowPageVisit | Self): + if isinstance(visit, FlowPageAttempt): + return visit + + # Mypy can't figure this out on its own: + from course.models import FlowPageVisit + assert isinstance(visit, FlowPageVisit) + + assert visit.is_submitted_answer + + return cls( + time=visit.visit_time, + ) + + +@dataclass(frozen=True, kw_only=True) +class FlowPageId: + """ + .. autoattribute:: type + .. autoattribute:: group_id + .. autoattribute:: page_id + """ + @classmethod + def from_relate(cls, page_data: FlowPageData | Self): + if isinstance(page_data, FlowPageId): + return page_data + + # Mypy can't figure this out on its own: + from course.models import FlowPageData + assert isinstance(page_data, FlowPageData) + + return cls( + type=not_none(page_data.page_type), + group_id=page_data.group_id, + page_id=page_data.page_id, + ) + + type: str + group_id: str + page_id: str + + +@dataclass(frozen=True, kw_only=True) +class FlowSessionAccessRuleArgs: + """ + .. autoattribute:: course + .. autoattribute:: now + .. autoattribute:: participation + .. autoattribute:: flow_id + .. autoattribute:: session + .. autoattribute:: facilities + .. autoattribute:: has_matching_exam_ticket + """ + course: Opaque + now: datetime + participation: Participation | None + flow_id: str + session: FlowSession + facilities: list[str] + has_matching_exam_ticket: bool + + +@dataclass(frozen=True, kw_only=True) +class FlowPageAccessRuleArgs(FlowSessionAccessRuleArgs): + """ + :show-inheritance: + + .. autoattribute:: page_id + .. autoattribute:: attempts + """ + page_id: FlowPageId | None + attempts: list[FlowPageAttempt] | None diff --git a/course/starlark/dataclasses.py b/course/starlark/dataclasses.py new file mode 100644 index 000000000..cef88fc70 --- /dev/null +++ b/course/starlark/dataclasses.py @@ -0,0 +1,167 @@ +from __future__ import annotations + + +__copyright__ = "Copyright (C) 2025 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 dataclasses +import re +from dataclasses import Field, dataclass, field, fields, is_dataclass +from datetime import datetime +from enum import StrEnum +from types import GenericAlias, NoneType, UnionType +from typing import ( + TYPE_CHECKING, + Any, + TypeVar, + Union, # pyright: ignore[reportDeprecated] + cast, + get_args, + get_origin, + get_type_hints, +) + +import starlark as sl +from django.db.models import Model + + +if TYPE_CHECKING: + from collections.abc import Callable, Sequence + + from _typeshed import DataclassInstance + + +def type_to_starlark(tp: type[object] | str | Any) -> str: + if tp == datetime: + return "Timestamp" + if isinstance(tp, type) and issubclass(tp, Model): + return "typing.Any" + + origin = get_origin(tp) + if isinstance(tp, GenericAlias): + args = ", ".join(type_to_starlark(arg) for arg in get_args(tp)) + return f"{type_to_starlark(get_origin(tp))}[{args}]" + elif origin is Union or origin is UnionType: # pyright: ignore[reportDeprecated] + return " | ".join(type_to_starlark(arg) for arg in get_args(tp)) + elif tp == NoneType: + return "None" + elif tp is object: + return "typing.Any" + elif isinstance(tp, type): + result = tp.__name__ + if result == "Set": + return "list" + else: + return str(tp) + + return (result + .replace("StarlarkTimestamp", "Timestamp") + .replace("IdentifierStr", "str") + ) + + +def camel_to_snake_case(s: str): + # https://stackoverflow.com/a/1176023 + return re.sub(r"(? str: + return dc.__name__ + + +def value_to_starlark(obj: object) -> str: + if isinstance(obj, StrEnum): + return f"{type(obj).__name__}({str(obj)!r})" + + return repr(obj) + + +def field_to_starlark(field: Field[object], type_hint: Any): + tp = type_to_starlark(type_hint) + if field.default is dataclasses.MISSING: + return tp + else: + return f"field({tp}, {value_to_starlark(field.default)})" + + +def dataclass_to_starlark(dc: type[DataclassInstance]) -> str: + type_hints = get_type_hints(dc) + name = dataclass_to_starlark_name(dc) + field_decls = [ + f" {fld.name}={field_to_starlark(fld, type_hint=type_hints[fld.name])}," + for fld in fields(dc) + ] + field_decls_str = "\n".join(field_decls) + "\n" + return f"{name} = record(\n{field_decls_str})" + + +T = TypeVar("T") + + +@dataclass(frozen=True) +class ToStarlarkConverter: + _type_to_converter: dict[ + type, + Callable[[ToStarlarkConverter, object], object] + ] = field(default_factory=dict) + + def __post_init__(self): + def convert_list( + conv: ToStarlarkConverter, + obj: Sequence[object], + ) -> list[object]: + return [conv(li) for li in obj] + self.register_type(list, convert_list) + self.register_type(tuple, convert_list) + + self.register_type(NoneType, lambda _conv, obj: obj) + self.register_type(str, lambda _conv, obj: obj) + self.register_type(int, lambda _conv, obj: obj) + self.register_type(float, lambda _conv, obj: obj) + self.register_type(datetime, lambda _conv, dt: dt.timestamp()) + self.register_type(Model, lambda _conv, obj: sl.OpaquePythonObject(obj)) + + def register_type(self, + tp: type[T], + converter: Callable[[ToStarlarkConverter, T], object]): + if tp in self._type_to_converter: + raise ValueError(f"converter for type '{tp}' already registered") + self._type_to_converter[tp] = cast( + "Callable[[ToStarlarkConverter, object], object]", + converter) + + def __call__(self, obj: object) -> object: + if is_dataclass(obj): + return { + "_record_type": type(obj).__name__, + "fields": {fld.name: self(getattr(obj, fld.name)) + for fld in fields(obj)} + } + for tp in type(obj).__mro__: + converter = self._type_to_converter.get(tp) + if converter is not None: + return converter(self, obj) + + raise ValueError(f"unable to convert {type(obj)}") + + +to_starlark = ToStarlarkConverter() diff --git a/course/starlark/lib/_core_types.star b/course/starlark/lib/_core_types.star new file mode 100644 index 000000000..da971130f --- /dev/null +++ b/course/starlark/lib/_core_types.star @@ -0,0 +1,3 @@ +load("relate/_builtins.star", "parse_date_spec") + +Timestamp = float diff --git a/course/starlark/lib/core.star b/course/starlark/lib/core.star new file mode 100644 index 000000000..ed52fda45 --- /dev/null +++ b/course/starlark/lib/core.star @@ -0,0 +1,20 @@ +load("relate/_core_types.star", "Timestamp") +load("relate/_builtins.star", "error") +load("relate/_generated.star", "PY_TYPE_MAP") + + +def from_py(type_map: dict[str, type], obj: typing.Any): + tp = type(obj) + if tp == "list": + return [from_py(type_map, li) for li in obj] + elif tp == "tuple": + return tuple([from_py(type_map, li) for li in obj]) + elif tp == "dict": + converted = {name: from_py(type_map, val) for name, val in obj["fields"].items()} + if "_record_type" in obj: + rec_constructor = type_map[obj["_record_type"]] + return rec_constructor(**converted) + return converted + else: + return obj + diff --git a/course/starlark/lib/course.star b/course/starlark/lib/course.star new file mode 100644 index 000000000..e6407bd3a --- /dev/null +++ b/course/starlark/lib/course.star @@ -0,0 +1,14 @@ +load("relate/_builtins.star", "parse_date_spec") +load("relate/_core_types.star", + "Timestamp", +) +load("relate/_generated.star", + "FlowSessionExpirationMode", + "ParticipationStatus", + + "Participation", + "FlowSession", + "FlowPageAttempt", + "FlowPageId", + "PY_TYPE_MAP" +) diff --git a/course/starlark/lib/rules.star b/course/starlark/lib/rules.star new file mode 100644 index 000000000..071d45444 --- /dev/null +++ b/course/starlark/lib/rules.star @@ -0,0 +1,6 @@ +load("relate/_generated.star", + "FlowSessionStartRuleArgs", "FlowSessionStartMode", + "FlowSessionAccessRuleArgs", "FlowSessionAccessMode", + "FlowPageAccessRuleArgs", "FlowPageAccessMode", + ) +load("relate/_builtins.star", "has_prairietest_access") diff --git a/course/starlark/module.py b/course/starlark/module.py new file mode 100644 index 000000000..854b45b2a --- /dev/null +++ b/course/starlark/module.py @@ -0,0 +1,283 @@ +from __future__ import annotations + + +__copyright__ = "Copyright (C) 2025 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 dataclasses import dataclass +from functools import partial +from pprint import pformat +from typing import TYPE_CHECKING, TypeVar + +import starlark as sl +from django.core.exceptions import ObjectDoesNotExist +from django.utils.translation import gettext as _ +from lru import LRU + +from course.repo import Repo_ish, RevisionID_ish, get_repo_blob +from course.starlark.builtin import get_relate_starlark_module +from relate.utils import format_datetime_local, local_now + + +if TYPE_CHECKING: + from collections.abc import Callable, Hashable, Mapping, Sequence + + from pydantic import TypeAdapter + + from course.models import Course + + +def relate_starlark_dialect(): + dialect = sl.Dialect.extended() + dialect.enable_types = sl.DialectTypes.ENABLE + dialect.enable_f_strings = True + # FIXME Enable once we depend on sl-rust 2025.2.6 + # dialect.enable_positional_only_arguments = True + dialect.enable_keyword_only_arguments = True + dialect.enable_load_reexport = True + return dialect + + +def relate_starlark_globals(): + return sl.Globals.standard().extended_by([ + sl.LibraryExtension.EnumType, + sl.LibraryExtension.RecordType, + sl.LibraryExtension.Partial, + sl.LibraryExtension.Typing, + ]) + + +@dataclass(frozen=True) +class StarlarkAst: + module: sl.AstModule + lint: Sequence[sl.Lint] + + +@dataclass(frozen=True) +class StarlarkModule: + module: sl.FrozenModule + interface: sl.Interface + lint: Sequence[sl.Lint] + load_lint: Mapping[str, Sequence[sl.Lint]] + + +@dataclass(frozen=True) +class StarlarkModuleWithSource(StarlarkModule): + source: str + + +def str_lint(lnt: sl.Lint): + return ( + f"{lnt.resolved_location.file}: {lnt.resolved_location.span.begin.line+1}: " + f"{lnt.severity} [{lnt.short_name}]: {lnt.problem}") + + +def parse_starlark(filename: str, code: str) -> StarlarkAst: + import starlark as sl + try: + ast = sl.parse(filename, code, relate_starlark_dialect()) + except Exception as e: + raise ValueError(f"unable to parse Starlark code:\n{e}") + lint = ast.lint() + warning_severities = [sl.EvalSeverity.Advice, sl.EvalSeverity.Disabled] + bad_lint = [ + lnt for lnt in lint + if lnt.severity not in warning_severities + ] + warn_lint = [ + lnt for lnt in lint + if lnt.severity in warning_severities + ] + + if bad_lint: + lint_str = "\n".join(str_lint(lnt) for lnt in bad_lint) + raise ValueError(f"has lint:\n{lint_str}") + + return StarlarkAst(ast, warn_lint) + + +def make_starlark_module( + name: str, + source: str, + load_func: Callable[[str], StarlarkModule], + extra_functions: Mapping[str, Callable[..., object]] | None = None, + ) -> StarlarkModule: + if extra_functions is None: + extra_functions = {} + + ast = parse_starlark(name, source) + loads = { + ld.module_id: load_func(ld.module_id) + for ld in ast.module.loads()} + + load_ifaces = {name: mod.interface for name, mod in loads.items()} + errs, iface, _ = ast.module.typecheck(relate_starlark_globals(), load_ifaces) + if errs: + err_str = "\n".join(f"{err.span}: {err}" for err in errs) + raise ValueError(f"has type errors:\n{err_str}") + + def fm_load_func(name: str): + return load_func(name).module + + mod = sl.Module() + for name, clbl in extra_functions.items(): + mod.add_callable(name, clbl) + + sl.eval(mod, ast.module, relate_starlark_globals(), sl.FileLoader(fm_load_func)) + + load_lint = {name: mod.lint for name, mod in loads.items()} + + return StarlarkModule(mod.freeze(), iface, lint=ast.lint, load_lint=load_lint) + + +_MODULE_FROM_REPO_CACHE: dict[Hashable, StarlarkModule] = {} + + +def _load_func(repo: Repo_ish, commit_sha: RevisionID_ish, name: str): + if name.startswith("relate/"): + return get_relate_starlark_module(name) + else: + return get_starlark_module_from_repo(repo, commit_sha, name) + + +def get_starlark_module_from_repo( + repo: Repo_ish, + commit_sha: RevisionID_ish, + name: str, + ) -> StarlarkModule: + key = (repo.controldir(), commit_sha, name) + try: + return _MODULE_FROM_REPO_CACHE[key] + except KeyError: + pass + + try: + blob: bytes = get_repo_blob(repo, name, commit_sha).data + except ObjectDoesNotExist as err: + raise FileNotFoundError(f"{name}: {err}") + mod = make_starlark_module( + name, blob.decode("utf-8"), + partial(_load_func, repo, commit_sha)) + + _MODULE_FROM_REPO_CACHE[key] = mod + return mod + + +_MODULE_FROM_SOURCE_CACHE: LRU[Hashable, StarlarkModuleWithSource] = LRU(500) + + +def get_starlark_module_from_source( + repo: Repo_ish, + commit_sha: RevisionID_ish, + name: str | None, + source: str, + extra_functions: Mapping[str, Callable[..., object]] | None = None, + ) -> StarlarkModuleWithSource: + use_cache = not extra_functions + + key = (repo.controldir(), commit_sha, source) + if use_cache: + try: + return _MODULE_FROM_SOURCE_CACHE[key] + except KeyError: + pass + + if name is None: + name = "" + mod = make_starlark_module( + name, source, + partial(_load_func, repo, commit_sha), + extra_functions) + + smod = StarlarkModuleWithSource( + module=mod.module, + interface=mod.interface, + lint=mod.lint, + load_lint=mod.load_lint, + source=source, + ) + + if use_cache: + _MODULE_FROM_SOURCE_CACHE[key] = smod + + return smod + + +ModelT = TypeVar("ModelT") + + +def notify_of_error( + course: Course, + source: str, + func_name: str, + args: tuple[object, ...], + kwargs: dict[str, object], + exc: Exception, + ) -> None: + from django.conf import settings + from django.core.mail import EmailMessage + + from relate.utils import render_email_template + + message = render_email_template( + "course/broken-starlark.txt", { + "site": settings.RELATE_BASE_URL, + "course": course, + "func_name": func_name, + "source": source, + "error_message": f"{type(exc).__name__}: {exc!s}", + "pprint_args": pformat(args), + "pprint_kwargs": pformat(kwargs), + "time": format_datetime_local(local_now()) + }) + msg = EmailMessage( + f"[{course.identifier}] {_('Starlark code failed')}", + message, + settings.ROBOT_EMAIL_FROM, + [course.notify_email]) + + from relate.utils import get_outbound_mail_connection + msg.connection = get_outbound_mail_connection("robot") + msg.send() + + +def call_and_notify_on_error( + course: Course | None, + smod: StarlarkModuleWithSource, + func_name: str, + retval_ta: TypeAdapter[ModelT], + *args: object, + **kwargs: object, + ) -> ModelT: + try: + retval = smod.module.call(func_name, *args, **kwargs) + except Exception as e: + if course is not None: + notify_of_error(course, smod.source, func_name, args, kwargs, e) + raise + + try: + return retval_ta.validate_python(retval) + except Exception as e: + if course is not None: + notify_of_error(course, smod.source, func_name, args, kwargs, e) + raise diff --git a/course/starlark/use_case/__init__.py b/course/starlark/use_case/__init__.py new file mode 100644 index 000000000..8a10b0eeb --- /dev/null +++ b/course/starlark/use_case/__init__.py @@ -0,0 +1,75 @@ +from __future__ import annotations + + +__copyright__ = "Copyright (C) 2025 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 abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +from course.constants import ( + FlowSessionExpirationMode as FlowSessionExpirationMode, + ParticipationStatus as ParticipationStatus, +) +from course.repo import ( + get_repo_blob as get_repo_blob, +) +from course.validation import IdentifierStr as IdentifierStr, get_validation_context + + +if TYPE_CHECKING: + + from pydantic import ValidationInfo + + from course.models import ( + Course, + FlowSession as FlowSession, + Participation as Participation, + ) + from course.repo import ( + Repo_ish as Repo_ish, + RevisionID_ish as RevisionID_ish, + ) + from course.starlark.module import StarlarkModuleWithSource + + +class StarlarkUseCase(ABC): + @abstractmethod + def get_module(self, + repo: Repo_ish, + commit_sha: RevisionID_ish, + location: str, + code: str) -> StarlarkModuleWithSource: + ... + + @abstractmethod + def run_tests(self, mod: StarlarkModuleWithSource, course: Course | None): + ... + + +def validate_starlark_code(use_case: StarlarkUseCase, code: str, info: ValidationInfo): + vctx = get_validation_context(info) + loc = str(vctx.with_location( + f"")._location) # pyright: ignore[reportPrivateUsage] + + mod = use_case.get_module(vctx.repo, vctx.commit_sha, loc, code) + use_case.run_tests(mod, vctx.course) diff --git a/course/starlark/use_case/rules.py b/course/starlark/use_case/rules.py new file mode 100644 index 000000000..22e5b570b --- /dev/null +++ b/course/starlark/use_case/rules.py @@ -0,0 +1,219 @@ +from __future__ import annotations + + +__copyright__ = "Copyright (C) 2025 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 abc import ABC +from datetime import datetime, timedelta +from typing import TYPE_CHECKING + +from typing_extensions import override + +from course.starlark.data import ( + FlowPageAccessRuleArgs, + FlowPageAttempt, + FlowPageId, + FlowSession as StarlarkFlowSession, + FlowSessionAccessRuleArgs, + FlowSessionStartRuleArgs, + Participation as StarlarkParticipation, +) +from course.starlark.dataclasses import to_starlark +from course.starlark.use_case import StarlarkUseCase + + +if TYPE_CHECKING: + from collections.abc import Collection, Sequence + + from course.models import ( + Course, + FlowPageData, + FlowPageVisit, + FlowSession, + Participation, + ) + from course.repo import Repo_ish, RevisionID_ish + from course.starlark.module import StarlarkModuleWithSource + + +RULES_LEADER = """ +load("relate/core.star", "Timestamp", + _from_py="from_py", _PY_TYPE_MAP="PY_TYPE_MAP") +load("relate/course.star", "FlowSessionExpirationMode", + "Participation", "FlowSession", "parse_date_spec") +load("relate/rules.star", + "FlowSessionStartMode", "FlowSessionStartRuleArgs", + "FlowSessionAccessRuleArgs", "FlowSessionAccessMode", + "FlowPageAccessRuleArgs", "FlowPageAccessMode", + "has_prairietest_access") +""" + + +RULES_TRAILER = """ +def wrap_rule(args: dict): + return rule(_from_py(_PY_TYPE_MAP, args)) +""" + + +class FlowRulesUseCaseBase(StarlarkUseCase, ABC): + @override + def get_module(self, + repo: Repo_ish, + commit_sha: RevisionID_ish, + location: str | None, + code: str, + ) -> StarlarkModuleWithSource: + wrapped_code = f"{RULES_LEADER}\n{code}\n{RULES_TRAILER}" + from course.starlark.module import get_starlark_module_from_source + return get_starlark_module_from_source( + repo, commit_sha, location, wrapped_code) + + +class FlowStartRulesUseCase(FlowRulesUseCaseBase): + def __call__(self, + mod: StarlarkModuleWithSource, + *, + course: Course | None, + now: datetime, + participation: Participation | StarlarkParticipation | None, + flow_id: str, + sessions: Sequence[FlowSession | StarlarkFlowSession], + facilities: Collection[str], + has_matching_exam_ticket: bool, + ): + from course.content import flow_session_start_mode_ta + from course.starlark.module import call_and_notify_on_error + return call_and_notify_on_error( + course, mod, "wrap_rule", + flow_session_start_mode_ta, + to_starlark(FlowSessionStartRuleArgs( + course=course, + now=now, + participation=StarlarkParticipation.from_relate(participation) + if participation is not None else None, + flow_id=flow_id, + sessions=[ + StarlarkFlowSession.from_relate(sess) + for sess in sessions], + facilities=list(facilities), + has_matching_exam_ticket=has_matching_exam_ticket))) + + @override + def run_tests(self, mod: StarlarkModuleWithSource, course: Course | None): + now = datetime.now() + hour = timedelta(hours=1) + + flow_id = "quiz-test" + + self(mod, course=None, now=now, participation=None, + flow_id=flow_id, sessions=[], + facilities=[], has_matching_exam_ticket=False) + if not course: + participation = StarlarkParticipation( + id=1, + username="johndoe@illinois.edu", + email="johndoe@illinois.edu", + institutional_id=None, + tags=["online"], + roles=["student"], + ) + self(mod, course=None, now=now, participation=participation, + flow_id=flow_id, sessions=[], + facilities=[], has_matching_exam_ticket=False) + self(mod, course=None, now=now, participation=participation, + flow_id=flow_id, sessions=[], + facilities=["cbtf"], has_matching_exam_ticket=False) + # sess_1 = StarlarkFlowSession( + # id=1, + # start_time=now - hour, + + # ) + + +class FlowSessionAccessRulesUseCase(FlowRulesUseCaseBase): + def __call__(self, + mod: StarlarkModuleWithSource, + *, + course: Course | None, + now: datetime, + participation: Participation | StarlarkParticipation | None, + flow_id: str, + session: FlowSession | StarlarkFlowSession, + facilities: Collection[str], + has_matching_exam_ticket: bool, + ): + from course.starlark.module import call_and_notify_on_error + from course.utils import session_access_mode_ta + return call_and_notify_on_error( + course, mod, "wrap_rule", + session_access_mode_ta, + to_starlark(FlowSessionAccessRuleArgs( + course=course, + now=now, + participation=StarlarkParticipation.from_relate(participation) + if participation is not None else None, + flow_id=flow_id, + session=StarlarkFlowSession.from_relate(session), + facilities=list(facilities), + has_matching_exam_ticket=has_matching_exam_ticket))) + + @override + def run_tests(self, mod: StarlarkModuleWithSource, course: Course | None): + pass + + +class FlowPageAccessRulesUseCase(FlowRulesUseCaseBase): + def __call__(self, + mod: StarlarkModuleWithSource, + *, + course: Course | None, + now: datetime, + participation: Participation | StarlarkParticipation | None, + flow_id: str, + session: FlowSession | StarlarkFlowSession, + page_data: FlowPageData, + attempts: Sequence[FlowPageVisit | FlowPageAttempt], + facilities: Collection[str], + has_matching_exam_ticket: bool, + ): + from course.starlark.module import call_and_notify_on_error + from course.utils import page_access_mode_ta + return call_and_notify_on_error( + course, mod, "wrap_rule", + page_access_mode_ta, + to_starlark(FlowPageAccessRuleArgs( + course=course, + now=now, + participation=StarlarkParticipation.from_relate(participation) + if participation is not None else None, + flow_id=flow_id, + session=StarlarkFlowSession.from_relate(session), + page_id=FlowPageId.from_relate(page_data), + attempts=[FlowPageAttempt.from_relate(vis) + for vis in attempts], + facilities=list(facilities), + has_matching_exam_ticket=has_matching_exam_ticket))) + + @override + def run_tests(self, mod: StarlarkModuleWithSource, course: Course | None): + pass diff --git a/course/templates/course/broken-starlark.txt b/course/templates/course/broken-starlark.txt new file mode 100644 index 000000000..e626fe562 --- /dev/null +++ b/course/templates/course/broken-starlark.txt @@ -0,0 +1,38 @@ +{% load i18n %} +{% blocktrans with + course_identifier=course.identifier + error_message=error_message|safe + func_name=func_name + source=source + pprint_args=pprint_args + pprint_kwargs=pprint_kwargs +%} +Hi there, + +This message was sent from {{ site }} at {{ time }}. + +Bad news! Starlark code in '{{ course_identifier }}' just failed to execute. + +Specifically, calling '{{ func_name }}' within the following code: +--------------------------------------------------------------------------- +{{ source }} +--------------------------------------------------------------------------- + +failed with the following error: +{{ error_message }} + +The following positional arguments were provided: +--------------------------------------------------------------------------- +{{ pprint_args }} +--------------------------------------------------------------------------- + +The following keyword arguments were provided: +--------------------------------------------------------------------------- +{{ pprint_kwargs }} +--------------------------------------------------------------------------- + +{% endblocktrans %} + +- {{ relate_site_name }} + + diff --git a/course/templates/course/flow-page.html b/course/templates/course/flow-page.html index 8e28df716..37eba5514 100644 --- a/course/templates/course/flow-page.html +++ b/course/templates/course/flow-page.html @@ -357,13 +357,13 @@
{{ form_html|safe }} - {% if may_change_graded_answer and will_receive_feedback %} + {% if submit_message %} {% if form.no_offset_labels %}
{% else %}
{% endif %} - {% trans "(You may still change your answer after you submit it.)" %} + {{ submit_message }}
{% endif %}
@@ -373,7 +373,7 @@ {# {{{ feedback #} - {% if show_correctness and feedback %} + {% if show_feedback and feedback %}
list[FlowRuleT]: - from course.content import ( - FlowSessionAccessRuleDesc, - FlowSessionGradingRuleDesc, - FlowSessionStartRuleDesc, - ) - - rules: list[FlowRuleT] = [] - if type is FlowSessionStartRuleDesc: - rules = cast("list[FlowRuleT]", flow_desc.rules.start) - elif type is FlowSessionAccessRuleDesc: - rules = cast("list[FlowRuleT]", flow_desc.rules.access) - elif type is FlowSessionGradingRuleDesc: - rules = cast("list[FlowRuleT]", flow_desc.rules.grading) - else: - raise AssertionError() - - rules = rules.copy() + ) -> Sequence[FlowRuleT | FlowRule2T]: + rules: list[FlowRuleT | FlowRule2T] = ( + [flow_desc_rules] + if isinstance(flow_desc_rules, FlowRule) else + list(flow_desc_rules) + ) from course.models import FlowRuleException if consider_exceptions and participation is not None: @@ -275,7 +281,7 @@ def get_flow_rules( .filter( participation=participation, active=True, - kind=type.kind, + kind=type_adapter._type.kind, # pyright: ignore[reportPrivateUsage] flow_id=flow_id) # rules created first will get inserted first, and show up last .order_by("creation_time")): @@ -283,16 +289,18 @@ def get_flow_rules( if exc.expiration is not None and now_datetime > exc.expiration: continue - rules.insert(0, get_rule_ta(type).validate_python(exc.rule, context=vctx)) + rules.insert(0, type_adapter.validate_python(exc.rule, context=vctx)) return rules def get_session_start_mode( + repo: Repo_ish, + commit_sha: RevisionID_ish, course: Course, participation: Participation | None, flow_id: str, - flow_desc: FlowDesc, + flow_rules: FlowRulesDesc, now_datetime: datetime.datetime, facilities: Collection[str] | None = None, for_rollover: bool = False, @@ -305,12 +313,33 @@ def get_session_start_mode( facilities = frozenset() rules = get_flow_rules( - flow_desc, FlowSessionStartRuleDesc, + flow_rules.start, start_rule_ta, participation, flow_id, now_datetime, ) from course.models import FlowSession + sessions = list(FlowSession.objects.filter( + participation=participation, + flow_id=flow_id, + participation__isnull=False, + ).order_by("start_time")) + for rule in rules: + if isinstance(rule, FlowSessionStartRuleCode): + use_case = FlowStartRulesUseCase() + mod = use_case.get_module( + repo, commit_sha, "", rule.code) + + return use_case(mod, + course=course, + now=now_datetime, + participation=participation, + flow_id=flow_id, + sessions=sessions, + facilities=facilities, + has_matching_exam_ticket=does_exam_ticket_match( + login_exam_ticket, participation, flow_id)) + if not _eval_generic_conditions(rule, course, participation, now_datetime, flow_id=flow_id, login_exam_ticket=login_exam_ticket, @@ -366,33 +395,167 @@ def get_session_start_mode( return FlowSessionStartMode( tag_session=rule.tag_session, may_start_new_session=rule.may_start_new_session, - may_list_existing_sessions=rule.may_list_existing_sessions, + session_list_ids=[s.id for s in FlowSession.objects.filter( + participation=participation, + flow_id=flow_id, + participation__isnull=False, + ).order_by("start_time")], default_expiration_mode=rule.default_expiration_mode, ) return FlowSessionStartMode( - may_list_existing_sessions=False, - may_start_new_session=False) + may_start_new_session=False, + session_list_ids=[], + ) + + +@dataclass(frozen=True, kw_only=True) +class FlowSessionAccessMode: + """ + .. autoattribute:: may_view + .. autoattribute:: may_end + .. autoattribute:: show_flow_grade + .. autoattribute:: may_set_rollover_expiration_mode + .. autoattribute:: lock_down_as_exam_session + """ + may_view: bool + may_end: bool + show_flow_grade: bool + may_set_rollover_expiration_mode: bool + lock_down_as_exam_session: bool + show_session_time: bool + + +session_access_mode_ta = TypeAdapter(FlowSessionAccessMode) + + +@dataclass(frozen=True, kw_only=True) +class FlowPageAccessMode: + """ + .. autoattribute:: may_view + .. autoattribute:: may_submit + .. autoattribute:: will_receive_feedback + .. autoattribute:: show_correctness + .. autoattribute:: show_feedback + .. autoattribute:: show_answer + .. autoattribute:: may_send_email + .. autoattribute:: show_point_count + + .. autoattribute:: message + """ + may_view: bool + may_submit: bool + will_receive_feedback: bool + show_correctness: bool + show_feedback: bool + show_answer: bool + may_send_email: bool + show_point_count: bool + + message: str | None = None + submit_message: str | None = None + """Message shown in the vicinity of the 'Submit' button.""" + +page_access_mode_ta = TypeAdapter(FlowPageAccessMode) + + +@overload def get_session_access_mode( - session: FlowSession, - flow_desc: FlowDesc, - now_datetime: datetime.datetime, - facilities: Collection[str] | None = None, - login_exam_ticket: ExamTicket | None = None, - *, - remote_ip_address: IPv4Address | IPv6Address | None = None, - ) -> FlowSessionAccessMode: + repo: Repo_ish, + commit_sha: RevisionID_ish, + session: FlowSession, + flow_rules: FlowRulesDesc, + now_datetime: datetime.datetime, + page_data: None, + facilities: Collection[str] | None = None, + login_exam_ticket: ExamTicket | None = None, + *, + permission_modifier: Callable[[Set[FPerm]], Set[FPerm]] | None = None, + remote_ip_address: IPv4Address | IPv6Address | None = None, + ) -> FlowSessionAccessMode: ... + + +@overload +def get_session_access_mode( + repo: Repo_ish, + commit_sha: RevisionID_ish, + session: FlowSession, + flow_rules: FlowRulesDesc, + now_datetime: datetime.datetime, + page_data: FlowPageData, + facilities: Collection[str] | None, + login_exam_ticket: ExamTicket | None = None, + *, + permission_modifier: Callable[[Set[FPerm]], Set[FPerm]], + remote_ip_address: IPv4Address | IPv6Address | None = None, + ) -> tuple[FlowSessionAccessMode, FlowPageAccessMode]: ... + + +def get_session_access_mode( + repo: Repo_ish, + commit_sha: RevisionID_ish, + session: FlowSession, + flow_rules: FlowRulesDesc, + now_datetime: datetime.datetime, + page_data: FlowPageData | None, + facilities: Collection[str] | None = None, + login_exam_ticket: ExamTicket | None = None, + *, + permission_modifier: Callable[[Set[FPerm]], Set[FPerm]] | None = None, + remote_ip_address: IPv4Address | IPv6Address | None = None, + ) -> FlowSessionAccessMode | tuple[FlowSessionAccessMode, FlowPageAccessMode]: if facilities is None: facilities = frozenset() - rules: list[FlowSessionAccessRuleDesc] = get_flow_rules( - flow_desc, FlowSessionAccessRuleDesc, + rules = get_flow_rules( + flow_rules.access, access_rule_ta, session.participation, session.flow_id, now_datetime) for rule in rules: + if isinstance(rule, FlowSessionAccessRuleCode): + session_use_case = FlowSessionAccessRulesUseCase() + session_mod = session_use_case.get_module( + repo, commit_sha, "", rule.session) + + from course.flow import get_prev_answer_visits_qset + attempts = ( + list(get_prev_answer_visits_qset(page_data)) + if page_data is not None else []) + + exam_ticket_matches = does_exam_ticket_match( + login_exam_ticket, session.participation, + session.flow_id) + + session_mode = session_use_case(session_mod, + course=session.course, + now=now_datetime, + participation=session.participation, + flow_id=session.flow_id, + session=session, + facilities=facilities, + has_matching_exam_ticket=exam_ticket_matches) + + if page_data is None: + return session_mode + else: + page_use_case = FlowPageAccessRulesUseCase() + page_mod = session_use_case.get_module( + repo, commit_sha, "", rule.page) + page_mode = page_use_case(page_mod, + course=session.course, + now=now_datetime, + participation=session.participation, + flow_id=session.flow_id, + session=session, + page_data=page_data, + attempts=attempts, + facilities=facilities, + has_matching_exam_ticket=exam_ticket_matches) + return session_mode, page_mode + if not _eval_generic_conditions( rule, session.course, session.participation, now_datetime, flow_id=session.flow_id, @@ -427,21 +590,87 @@ def get_session_access_mode( if duration_min > rule.if_session_duration_shorter_than_minutes: continue - permissions = set(rule.permissions) - - # Remove 'modify' permission from not-in-progress sessions - if not session.in_progress: - permissions.difference_update([ - FlowPermission.submit_answer, - FlowPermission.end_session, - ]) + perms = frozenset(rule.permissions) + if permission_modifier is not None: + perms = permission_modifier(perms) + + session_mode = FlowSessionAccessMode( + may_view=FPerm.view in perms, + may_end=FPerm.end_session in perms and session.in_progress, + show_flow_grade=FPerm.cannot_see_flow_result not in perms, + may_set_rollover_expiration_mode=( + FPerm.set_roll_over_expiration_mode in perms), + lock_down_as_exam_session=FPerm.lock_down_as_exam_session in perms, + show_session_time=FPerm.see_session_time in perms, + ) + if page_data is None: + return session_mode + else: + if permission_modifier is None: + # This is to make sure people don't forget to have per-page + # rules adequately accounted for. + raise ValueError("permission_modifier is required if page_data " + "is specified") + + from course.flow import get_prev_answer_visits_qset + attempts = list(get_prev_answer_visits_qset(page_data)) + return session_mode, FlowPageAccessMode( + # Note how FPerm.view is overloaded to mean both 'view page' + # and 'view session'. + may_view=FPerm.view in perms, + may_submit=session.in_progress and ( + ( + FPerm.change_answer in perms + and FPerm.submit_answer in perms + ) + if attempts else + (FPerm.submit_answer in perms) + ), + will_receive_feedback=( + FPerm.see_correctness in perms + or FPerm.see_answer_after_submission in perms + ), + show_correctness=FPerm.see_correctness in perms, + show_feedback=FPerm.see_correctness in perms, + show_answer=( + (FPerm.see_answer_after_submission in perms + # Don't reveal the answer if they can still change their mind + and FPerm.change_answer not in perms) + if attempts else + (FPerm.see_answer_before_submission in perms) + ), + may_send_email=FPerm.send_email_about_flow_page in perms, + show_point_count=FPerm.hide_point_count not in perms, - return FlowSessionAccessMode( - permissions=frozenset(permissions), message=rule.message, + submit_message=( + _("You may change your answer after submission.") + if FPerm.change_answer in perms and session.in_progress + else None ) + ) - return FlowSessionAccessMode(permissions=frozenset()) + session_mode = FlowSessionAccessMode( + may_view=False, + may_end=False, + show_flow_grade=True, + may_set_rollover_expiration_mode=False, + lock_down_as_exam_session=False, + show_session_time=False, + ) + if page_data is None: + return session_mode + else: + return session_mode, FlowPageAccessMode( + may_view=False, + may_submit=False, + will_receive_feedback=False, + show_correctness=False, + show_feedback=False, + show_answer=False, + may_send_email=False, + show_point_count=False, + ) @dataclass(frozen=True, kw_only=True) @@ -452,12 +681,12 @@ class FlowSessionGradingModeWithFlowLevelInfo(FlowSessionGradingMode): def get_session_grading_mode( session: FlowSession, - flow_desc: FlowDesc, + flow_rules: FlowRulesDesc, now_datetime: datetime.datetime ) -> FlowSessionGradingModeWithFlowLevelInfo: - rules: list[FlowSessionGradingRuleDesc] = get_flow_rules( - flow_desc, FlowSessionGradingRuleDesc, + rules = get_flow_rules( + flow_rules.grading, grading_rule_ta, session.participation, session.flow_id, now_datetime, ) @@ -494,8 +723,8 @@ def get_session_grading_mode( due = rule.due generates_grade = rule.generates_grade - grade_identifier = flow_desc.rules.grade_identifier - grade_aggregation_strategy = flow_desc.rules.grade_aggregation_strategy + grade_identifier = flow_rules.grade_identifier + grade_aggregation_strategy = flow_rules.grade_aggregation_strategy return FlowSessionGradingModeWithFlowLevelInfo( grade_identifier=grade_identifier, diff --git a/course/validation.py b/course/validation.py index 64d2d11e7..f19e562a4 100644 --- a/course/validation.py +++ b/course/validation.py @@ -84,7 +84,6 @@ Stub Docs ========= -.. class:: Course .. class:: Repo_ish """ diff --git a/course/views.py b/course/views.py index b9a54c211..28354d13b 100644 --- a/course/views.py +++ b/course/views.py @@ -908,8 +908,10 @@ def grant_exception_stage_2( access_rules_tags = flow_desc.rules.tags from course.utils import get_session_start_mode - session_start_rule = get_session_start_mode(pctx.course, participation, - flow_id, flow_desc, now_datetime) + session_start_rule = get_session_start_mode( + pctx.repo, pctx.course_commit_sha, + pctx.course, participation, + flow_id, flow_desc.rules, now_datetime) create_session_is_override = False if not session_start_rule.may_start_new_session: @@ -1159,8 +1161,11 @@ def grant_exception_stage_3( now_datetime = get_now_or_fake_time(pctx.request) from course.utils import get_session_access_mode, get_session_grading_mode - access_rule = get_session_access_mode(session, flow_desc, now_datetime) - grading_rule = get_session_grading_mode(session, flow_desc, now_datetime) + access_mode = get_session_access_mode( + pctx.repo, pctx.course_commit_sha, + session, flow_desc.rules, now_datetime, + page_data=None) + grading_rule = get_session_grading_mode(session, flow_desc.rules, now_datetime) request = pctx.request if request.method == "POST": @@ -1334,8 +1339,23 @@ def transfer_attr(name: str): "max_points": grading_rule.max_points, "max_points_enforced_cap": grading_rule.max_points_enforced_cap, } - for perm in access_rule.permissions: - data[perm] = True + + # We don't have the per-page permission data, so... + data["view"] = True + data["submit_answer"] = True + data["change_answer"] = False + data["see_correctness"] = False + data["see_answer_before_submission"] = False + data["see_answer_after_submission"] = False + data["hide_point_count"] = False + data["send_email_about_flow_page"] = False + + data["end_session"] = access_mode.may_end + data["cannot_see_flow_result"] = not access_mode.show_flow_grade + data["set_roll_over_expiration_mode"] = \ + access_mode.may_set_rollover_expiration_mode + data["see_session_time"] = access_mode.show_session_time + data["lock_down_as_exam_session"] = access_mode.lock_down_as_exam_session form = ExceptionStage3Form(data, flow_desc, session.access_rules_tag) diff --git a/doc/conf.py b/doc/conf.py index de8caf4d0..b4f60be43 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -50,6 +50,8 @@ ("py:class", "FileSystemFakeRepo"), ("py:class", "pydantic.functional_serializers.SerializeAsAny"), ("py:class", "Ge|Le|Gt|Lt|AllowInfNan"), + # deprecated, undocumented + ("py:class", "course.page.base.PageAccessRules"), ] copyright = "2014-25, Andreas Kloeckner" diff --git a/doc/flow.rst b/doc/flow.rst index 09d60b65c..8ae74e45e 100644 --- a/doc/flow.rst +++ b/doc/flow.rst @@ -208,11 +208,19 @@ Overall structure Rules for starting new sessions ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +.. autoclass:: FlowSessionStartRuleCode + +Alternatively, the older YAML-based rules specification remains available: + .. autoclass:: FlowSessionStartRuleDesc Rules about accessing and interacting with a flow ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +.. autoclass:: FlowSessionAccessRuleCode + +Alternatively, the older YAML-based rules specification remains available: + .. autoclass:: FlowSessionAccessRuleDesc .. _flow-permissions: @@ -230,6 +238,10 @@ Determining how final (overall) grades of flows are computed .. currentmodule:: course.content +.. autoclass:: FlowSessionGradingRuleCode + +Alternatively, the older YAML-based rules specification remains available: + .. autoclass:: FlowSessionGradingRuleDesc .. currentmodule:: course.constants @@ -268,30 +280,6 @@ Each group allows the following attributes: .. autoclass:: FlowPageGroupDesc -.. _page-permissions: - -Per-page permissions -^^^^^^^^^^^^^^^^^^^^ - -The granted access permissions for the entire flow (see -:class:`~course.content.FlowSessionAccessRuleDesc`) can be modified on a -per-page basis. This happens in the ``access_rules`` sub-block of each page, -e.g. in :attr:`course.page.ChoiceQuestion.access_rules`: - -.. currentmodule:: course.page.base - -.. autoclass:: PageAccessRules - -For example, to grant permission to revise an answer on a -:class:`course.page.PythonCodeQuestion`, one might type:: - - type: PythonCodeQuestion - id: addition - access_rules: - add_permissions: - - change_answer - value: 1 - .. _tabbed-page-view: Tabbed page view diff --git a/doc/index.rst b/doc/index.rst index 81dafc60b..dbe396da8 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -52,6 +52,7 @@ Table of Contents :maxdepth: 2 content + starlark flow page-types api diff --git a/doc/page-types.rst b/doc/page-types.rst index 94af5f24a..5cf5da4ae 100644 --- a/doc/page-types.rst +++ b/doc/page-types.rst @@ -1,5 +1,5 @@ -Predefined Page Types ---------------------- +Flow Page Types +--------------- .. currentmodule:: course.page @@ -38,10 +38,6 @@ The following page types are predefined: the first ten lines of the page body are searched for a Markdown heading (``# My title``) and this heading is used as a title. -.. |access-rules-page-attr| replace:: - - Optional. See :ref:`page-permissions`. - .. |value-page-attr| replace:: An integer or a floating point number, representing the diff --git a/doc/starlark.rst b/doc/starlark.rst new file mode 100644 index 000000000..1d0377205 --- /dev/null +++ b/doc/starlark.rst @@ -0,0 +1,128 @@ +.. _starlark: + +Controlling behavior with code +============================== + +Relate supports the use of `Starlark `__ to customize +various behaviors of the system, including as an alternative approach for +specifying flow start/access/grading rules. + +General notes on the language +----------------------------- + +While Starlark is quite similar to Python, although a number of key differences are +worth noting: + +- No support for classes. +- No support for exceptions, any error aborts execution. +- No support for control flow at the top level. +- No Python standard library, only a limited number of + `built-in symbols `__. +- Modules are immutable once loaded. +- The closest analog of Python's ``import`` statement is the ``load()`` statement. + + For example, the following ``import`` statement:: + + from mymod import myfunc, other_func as other + + would be written as:: + + load("mymod.star", "myfunc" other="other_func") + + ``load()`` statements may only occur at the topmost level, + i.e. not inside of functions. + + In Relate, any file in a course's git repository can be imported via a ``load()`` + statement, facilitating code reuse, except for those starting with + ``relate/``, see :ref:`starlark-lib`. + + +Relate's implementation of Starlark includes the following extensions +of the Starlark standard: + +- f-strings, with standard Python syntax. +- `record() `__ + for a rough analog of Python's dataclasses. +- `enum() `__ +- ``partial()``, a workalike of :func:`~functools.partial`. +- Type annotation, along with various symbols from :mod:`typing`, which + are always availble, e.g. :obj:`typing.Any`. + +.. _starlark-lib: + +Relate's Starlark library +------------------------- + +Certain functionality of the Relate system is available for use by Starlark code. +Module names starting with ``relate/`` refer to built-in functionality. + +Core functionality (``relate/core.star``) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +.. class:: Timestamp + + An alias of :class:`float`, a UNIX timestamp. + +.. function:: error(message: str) -> Never + + Abort execution with the given message. + +Course-related functionality (``relate/course.star``) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +(Disregard the module qualifiers given here, they are an implementation detail and +not relevant to Starlark.) + +.. class:: FlowSessionExpirationMode + + An ``enum``. See :class:`course.constants.FlowSessionExpirationMode`. + +.. class:: ParticipationStatus + + An ``enum``. One of "requested", "active", "dropped", "denied". + +.. class:: FlowPermission + :no-index: + + An ``enum``. See :class:`course.constants.FlowPermission`. + +.. autoclass:: course.starlark.data.Participation + +.. autoclass:: course.starlark.data.FlowPageId +.. +.. autoclass:: course.starlark.data.FlowPageAttempt +.. +.. autoclass:: course.starlark.data.FlowSession + +.. function:: parse_date_spec(course: Course | None, datespec: str) -> Timestamp | None + + Parses a :ref:`datespec ` in the context of the given *course*. + If no course is given or the *datespec* cannot be interpreted, + *None* is returned. + + If provided, *course* must be an opaque course handle such as that + given in :attr:`course.starlark.data.FlowSessionStartRuleArgs.course` + +.. _starlark-rules: + +Functionality for flow rules (``relate/rules.star``) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +(Disregard the module qualifiers given here, they are an implementation detail and +not relevant to Starlark.) + +.. autoclass:: course.starlark.data.FlowSessionStartRuleArgs + +.. autoclass:: course.content.FlowSessionStartMode + +.. autoclass:: course.starlark.data.FlowSessionAccessRuleArgs + +.. autoclass:: course.utils.FlowSessionAccessMode + +.. autoclass:: course.starlark.data.FlowPageAccessRuleArgs + +.. autoclass:: course.utils.FlowPageAccessMode + +.. function:: has_prairietest_access(course: Course | None, user_uid: str | None, user_uin: str | None, exam_uuid: str, now: float, ip_address: str, ) -> bool + + If provided, *course* must be an opaque course handle such as that + given in :attr:`course.starlark.data.FlowSessionStartRuleArgs.course` diff --git a/pyproject.toml b/pyproject.toml index 20c8dc1dd..b6f9782ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ dependencies = [ "social-auth-app-django>=5.4.1,<6", "urllib3>=2.3.0,<3", "typing_extensions>=4.14.1", + "lru-dict>=1.4.1", "pydantic~=2.12", @@ -51,6 +52,8 @@ dependencies = [ "email-validator~=2.3.0", "annotated-types~=0.7.0", + "starlark-pyo3>=2025.2.5", + "packaging>=25", # pysaml2 PR #1021 bumps its floor to >=25.3.0; match that here diff --git a/tests/base_test_mixins.py b/tests/base_test_mixins.py index c539fe059..dd08ce0dc 100644 --- a/tests/base_test_mixins.py +++ b/tests/base_test_mixins.py @@ -1712,7 +1712,7 @@ def get_flow_page_analytics(cls, client, # noqa: N805 default_session_start_rule = { "tag_session": None, "may_start_new_session": True, - "may_list_existing_sessions": True, + "session_list_ids": [], "default_expiration_mode": FlowSessionExpirationMode.end} def get_hacked_session_start_rule(self, **kwargs): diff --git a/tests/test_content.py b/tests/test_content.py index 46a4e19a7..90d74f370 100644 --- a/tests/test_content.py +++ b/tests/test_content.py @@ -579,7 +579,7 @@ def test_plus(self): parse_date_spec(self.course, datespec, vctx=self.vctx), self.mock_now_value) expected_warning_msg = (f"Unrecognized date/time specification: '{datespec}' " - "(interpreted as 'now'). " + "(possibly interpreted as 'now'). " "You should add an event with this name.") self.assertEqual(self.mock_add_warning.call_count, 1) diff --git a/tests/test_flow/test_flow.py b/tests/test_flow/test_flow.py index 61a1b532d..32801eb9e 100644 --- a/tests/test_flow/test_flow.py +++ b/tests/test_flow/test_flow.py @@ -1,7 +1,5 @@ from __future__ import annotations -from course.datespec import Datespec - __copyright__ = "Copyright (C) 2018 Dong Zhuang" @@ -48,6 +46,7 @@ FlowSessionStartMode, flow_desc_ta, ) +from course.datespec import Datespec from course.repo import EmptyRepo from course.utils import FlowSessionGradingModeWithFlowLevelInfo from course.validation import ValidationContext @@ -282,7 +281,7 @@ def test_start_flow_anonymous(self): session_start_rule = FlowSessionStartMode( may_start_new_session=True, - may_list_existing_sessions=True, + session_list_ids=[], tag_session="my_tag", default_expiration_mode=constants.FlowSessionExpirationMode.roll_over) @@ -330,7 +329,7 @@ def test_start_flow_with_no_rule(self): # no exp_mode session_start_rule = FlowSessionStartMode( may_start_new_session=True, - may_list_existing_sessions=True, + session_list_ids=[], ) # flow_desc no rules @@ -373,7 +372,7 @@ def test_start_flow_with_grade_identifier_null(self): # no exp_mode session_start_rule = FlowSessionStartMode( may_start_new_session=True, - may_list_existing_sessions=True, + session_list_ids=[], ) vctx = ValidationContext(EmptyRepo(), b"norev") @@ -1857,7 +1856,7 @@ def test_expiration_mode_rollover_not_may_start_new_session(self): FlowSessionStartMode( tag_session="roll_over_tag", may_start_new_session=False, - may_list_existing_sessions=False, + session_list_ids=[], )) grading_rule = FlowSessionGradingModeWithFlowLevelInfo( @@ -2966,14 +2965,14 @@ def remove_all_course(): def test_no_lock_down_as_exam_session_flow_permission(self): flow_permissions = ["other_flow_permission"] - flow.lock_down_if_needed(self.request, flow_permissions, self.flow_session) + flow.lock_session_to_flow(self.request, flow_permissions, self.flow_session) self.assertIsNone(self.request.session.get(SESSION_LOCKED_TO_FLOW_PK)) def test_has_lock_down_as_exam_session_flow_permission(self): flow_permissions = [FPerm.lock_down_as_exam_session, "other_flow_permission"] - flow.lock_down_if_needed(self.request, flow_permissions, self.flow_session) + flow.lock_session_to_flow(self.request, flow_permissions, self.flow_session) self.assertEqual( self.request.session.get(SESSION_LOCKED_TO_FLOW_PK), @@ -2997,8 +2996,16 @@ def setUp(self): self.mock_flow_context = fake_flow_context.start() self.fctx = mock.MagicMock() self.fctx.flow_id = self.flow_id - self.fctx.flow_desc = { - "title": "test page title", "description_html": "foo bar"} + vctx = ValidationContext(EmptyRepo(), b"norev") + self.fctx.flow_desc = flow_desc_ta.validate_python({ + "title": "", + "description": "", + "pages": [{"type": "Page", "id": "mypage", "content": "# Yo"}], + "rules": { + "grade_identifier": None, + "grade_aggregation_strategy": GAStrategy.use_earliest, + }, + }, context=vctx) self.mock_flow_context.return_value = self.fctx self.addCleanup(fake_flow_context.stop) @@ -3066,8 +3073,6 @@ def test_get_may_list_existing_sessions_but_no_session(self): self.assertEqual(len(past_sessions_and_properties), 0) def test_get_may_list_existing_sessions(self): - session_start_rule = self.get_hacked_session_start_rule() - # create 2 session with different access_rule and grading_rule fs1 = self.get_test_flow_session(in_progress=False, start_time=now() - timedelta(days=3)) @@ -3075,6 +3080,15 @@ def test_get_may_list_existing_sessions(self): start_time=now() - timedelta(days=2), completion_time=None) + session_start_rule = self.get_hacked_session_start_rule( + session_list_ids=[ + sess.id for sess in models.FlowSession.objects.filter( + participation=self.student_participation, + flow_id=self.flow_id, + ).order_by("start_time") + ] + ) + access_rule_for_session1 = self.get_hacked_session_access_rule( permissions=[FPerm.cannot_see_flow_result] ) @@ -3135,7 +3149,6 @@ def test_get_may_list_existing_sessions(self): def test_get_not_may_list_existing_sessions(self): session_start_rule = self.get_hacked_session_start_rule( may_start_new_session=False, - may_list_existing_sessions=False, ) # create 2 session with different access_rule and grading_rule @@ -3182,8 +3195,16 @@ def setUp(self): self.mock_flow_context = fake_flow_context.start() self.fctx = mock.MagicMock() self.fctx.flow_id = self.flow_id - self.fctx.flow_desc = { - "title": "test page title", "description_html": "foo bar"} + vctx = ValidationContext(EmptyRepo(), b"norev") + self.fctx.flow_desc = flow_desc_ta.validate_python({ + "title": "", + "description": "", + "pages": [{"type": "Page", "id": "mypage", "content": "# Yo"}], + "rules": { + "grade_identifier": None, + "grade_aggregation_strategy": GAStrategy.use_earliest, + }, + }, context=vctx) self.mock_flow_context.return_value = self.fctx self.addCleanup(fake_flow_context.stop) @@ -3433,40 +3454,6 @@ def test_view_student_session(self): student_session) -class WillReceiveFeedbackTest(unittest.TestCase): - # test flow.will_receive_feedback - def test_false(self): - combinations = [(frozenset([fp]), False) for fp in - get_flow_permissions_list( - excluded=[FPerm.see_correctness, - FPerm.see_answer_after_submission])] - combinations.append(([], False)) - - for permissions, will_receive in combinations: - with self.subTest(permissions=sorted(str(p) for p in permissions)): - self.assertEqual( - flow.will_receive_feedback(permissions), - will_receive) - - def test_true(self): - combinations = [ - (frozenset([fp, FPerm.see_correctness]), True) - for fp in get_flow_permissions_list( - excluded=[FPerm.see_correctness])] - - combinations2 = [ - (frozenset([fp, FPerm.see_answer_after_submission]), True) - for fp in get_flow_permissions_list( - excluded=[FPerm.see_answer_after_submission])] - combinations.extend(combinations2) - - for permissions, will_receive in combinations: - with self.subTest(permissions=sorted(str(p) for p in permissions)): - self.assertEqual( - flow.will_receive_feedback(permissions), - will_receive) - - @pytest.mark.django_db class MaySendEmailAboutFlowPageTest(TestCase): # test flow.may_send_email_about_flow_page @@ -3567,7 +3554,7 @@ def test_show_correctness1(self): permissions=sorted(str(p) for p in permissions)): behavior = flow.get_page_behavior( self.page, - permissions=permissions, + page_access_mode=permissions, session_in_progress=session_in_progress, answer_was_graded=answer_was_graded, generates_grade=generate_grade, @@ -3595,7 +3582,7 @@ def test_show_correctness2(self): with self.subTest(permissions=sorted(str(p) for p in permissions)): behavior = flow.get_page_behavior( self.page, - permissions=permissions, + page_access_mode=permissions, session_in_progress=session_in_progress, answer_was_graded=True, generates_grade=generate_grade, @@ -3622,7 +3609,7 @@ def test_show_correctness3(self): with self.subTest(permissions=sorted(str(p) for p in permissions)): behavior = flow.get_page_behavior( self.page, - permissions=permissions, + page_access_mode=permissions, session_in_progress=session_in_progress, answer_was_graded=False, generates_grade=generate_grade, @@ -3657,7 +3644,7 @@ def test_show_answer1(self): with self.subTest(permissions=sorted(str(p) for p in permissions)): behavior = flow.get_page_behavior( self.page, - permissions=permissions, + page_access_mode=permissions, session_in_progress=session_in_progress, answer_was_graded=answer_was_graded, generates_grade=generate_grade, @@ -3688,7 +3675,7 @@ def test_show_answer2(self): with self.subTest(permissions=sorted(str(p) for p in permissions)): behavior = flow.get_page_behavior( self.page, - permissions=permissions, + page_access_mode=permissions, session_in_progress=session_in_progress, answer_was_graded=False, generates_grade=generate_grade, @@ -3734,7 +3721,7 @@ def test_show_answer3(self): with self.subTest(permissions=sorted(str(p) for p in permissions)): behavior = flow.get_page_behavior( self.page, - permissions=permissions, + page_access_mode=permissions, session_in_progress=False, answer_was_graded=True, generates_grade=generate_grade, @@ -3780,7 +3767,7 @@ def test_show_answer4(self): with self.subTest(permissions=sorted(str(p) for p in permissions)): behavior = flow.get_page_behavior( self.page, - permissions=permissions, + page_access_mode=permissions, session_in_progress=True, answer_was_graded=True, generates_grade=generate_grade, @@ -3807,7 +3794,7 @@ def test_may_change_answer1(self): with self.subTest(permissions=sorted(str(p) for p in permissions)): behavior = flow.get_page_behavior( self.page, - permissions=permissions, + page_access_mode=permissions, session_in_progress=session_in_progress, answer_was_graded=answer_was_graded, generates_grade=generates_grade, @@ -3833,7 +3820,7 @@ def test_may_change_answer2(self): with self.subTest(permissions=sorted(str(p) for p in permissions)): behavior = flow.get_page_behavior( self.page, - permissions=permissions, + page_access_mode=permissions, session_in_progress=False, answer_was_graded=answer_was_graded, generates_grade=generates_grade, @@ -3860,7 +3847,7 @@ def test_may_change_answer3(self): with self.subTest(permissions=sorted(str(p) for p in permissions)): behavior = flow.get_page_behavior( self.page, - permissions=permissions, + page_access_mode=permissions, session_in_progress=session_in_progress, answer_was_graded=answer_was_graded, generates_grade=generates_grade, @@ -3885,7 +3872,7 @@ def test_may_change_answer4(self): with self.subTest(permissions=sorted(str(p) for p in permissions)): behavior = flow.get_page_behavior( self.page, - permissions=permissions, + page_access_mode=permissions, session_in_progress=session_in_progress, answer_was_graded=True, generates_grade=generates_grade, @@ -3909,7 +3896,7 @@ def test_may_change_answer6(self): with self.subTest(permissions=sorted(str(p) for p in permissions)): behavior = flow.get_page_behavior( self.page, - permissions=permissions, + page_access_mode=permissions, session_in_progress=True, answer_was_graded=False, generates_grade=True, @@ -3960,7 +3947,7 @@ def test_may_change_answer7(self): is_unenrolled_session=conf.is_unenrolled_session): behavior = flow.get_page_behavior( self.page, - permissions=permissions, + page_access_mode=permissions, session_in_progress=True, answer_was_graded=conf.answer_was_graded, generates_grade=conf.generates_grade, diff --git a/tests/test_pages/test_base.py b/tests/test_pages/test_base.py index 5935b3aec..5b33f8b60 100644 --- a/tests/test_pages/test_base.py +++ b/tests/test_pages/test_base.py @@ -31,7 +31,6 @@ from course.page.base import ( HumanTextFeedbackForm, - PageBehavior, create_default_point_scale, get_editor_interaction_mode, ) @@ -308,21 +307,6 @@ def test_get_modified_permissions_for_page(self): with pytest.raises(PdValidationError): page = Page.model_validate(page_desc, context=vctx) - with self.subTest(access_rules={ - "remove_permissions": [access_rule_permissions_list[0]]}): - page_desc = ( - { - **page_base_desc, - "access_rules": { - "remove_permissions": [access_rule_permissions_list[0]]} - } - ) - page = Page.model_validate(page_desc, context=vctx) - - self.assertSetEqual( - page.get_modified_permissions_for_page(access_rule_permissions), - frozenset(access_rule_permissions_list[1:])) - def human_text_feedback_form_clean_side_effect(self): from relate.utils import StyledFormBase @@ -812,23 +796,4 @@ def test_get_editor_interaction_mode_participation_not_none(self): self.assertEqual(get_editor_interaction_mode(page_context), "some_mode") -class PageBehaviorTest(unittest.TestCase): - def test_page_behavior_backward_compatibility(self): - answer_is_final = PageBehavior(show_correctness=False, show_answer=False, - may_change_answer=False) - if not answer_is_final: - self.fail( - "PageBehavior object expected to be True " - "when may_change_answer is False for backward " - "compatibility") - - answer_is_final = PageBehavior(show_correctness=False, show_answer=False, - may_change_answer=True) - - if answer_is_final: - self.fail( - "PageBehavior object expected to be False " - "when may_change_answer is True for backward " - "compatibility") - # vim: fdm=marker diff --git a/tests/test_starlark_rules.py b/tests/test_starlark_rules.py new file mode 100644 index 000000000..136c02d32 --- /dev/null +++ b/tests/test_starlark_rules.py @@ -0,0 +1,40 @@ +# pyright: reportUnannotatedClassAttribute=false + +from __future__ import annotations + + +__copyright__ = "Copyright (C) 2025 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 course.repo import python_repo_class + + +@python_repo_class +class Repo: + course_dot_yml = """ + """ + + class Flows: + pass + + +print(Repo._file_name_to_attr_name) diff --git a/tests/test_utils.py b/tests/test_utils.py index 58038803d..9e4970739 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -41,11 +41,11 @@ ) from course.constants import FlowPermission as FPerm from course.content import ( - FlowSessionAccessMode, FlowSessionAccessRuleDesc, FlowSessionGradingMode, FlowSessionStartMode, FlowSessionStartRuleDesc, + start_rule_ta, ) from course.datespec import Datespec, parse_date_spec from tests import factories @@ -618,7 +618,7 @@ def test_not_consider_exist_exceptions(self): ) result = utils.get_flow_rules( - flow_desc, FlowSessionStartRuleDesc, + flow_desc.rules.start, start_rule_ta, self.student_participation, self.flow_id, now(), @@ -653,7 +653,7 @@ def test_consider_exist_exceptions_is_default_to_true(self): # consider_exceptions not specified result = utils.get_flow_rules( - flow_desc, FlowSessionStartRuleDesc, + flow_desc.rules.start, start_rule_ta, self.student_participation, self.flow_id, now(), @@ -693,7 +693,7 @@ def test_consider_exist_exceptions(self): ) result = utils.get_flow_rules( - flow_desc, FlowSessionStartRuleDesc, + flow_desc.rules.start, start_rule_ta, self.student_participation, self.flow_id, now(), @@ -745,7 +745,7 @@ def test_consider_exist_exceptions_rule_expiration(self): now_datetime = now() - timedelta(days=3) result = utils.get_flow_rules( - flow_desc, FlowSessionStartRuleDesc, + flow_desc.rules.start, start_rule_ta, self.student_participation, self.flow_id, now_datetime, @@ -763,7 +763,7 @@ def test_consider_exist_exceptions_rule_expiration(self): now_datetime = now() result = utils.get_flow_rules( - flow_desc, FlowSessionStartRuleDesc, + flow_desc.rules.start, start_rule_ta, self.student_participation, self.flow_id, now_datetime, @@ -781,7 +781,7 @@ def test_consider_exist_exceptions_rule_expiration(self): now_datetime = now() + timedelta(days=5) result = utils.get_flow_rules( - flow_desc, FlowSessionStartRuleDesc, + flow_desc.rules.start, start_rule_ta, self.student_participation, self.flow_id, now_datetime, @@ -894,7 +894,7 @@ class GetSessionStartRuleTest(GetSessionRuleMixin, SingleCourseTestMixin, TestCa rule_klass = FlowSessionStartMode fallback_rule = FlowSessionStartMode( - may_list_existing_sessions=False, + session_list_ids=[], may_start_new_session=False) @property @@ -1095,9 +1095,9 @@ def test_get_expected_rule(self): class GetSessionAccessRuleTest(GetSessionRuleMixin, SingleCourseTestMixin, TestCase): # test utils.get_session_access_rule call_func = utils.get_session_access_mode - rule_klass = FlowSessionAccessMode + rule_klass = utils.FlowSessionAccessMode - fallback_rule = FlowSessionAccessMode(permissions=frozenset()) + # fallback_rule = utils.FlowSessionAccessMode(permissions=frozenset()) default_permissions = [FPerm.view] @property diff --git a/uv.lock b/uv.lock index 24abd64e9..d5056924e 100644 --- a/uv.lock +++ b/uv.lock @@ -1107,6 +1107,66 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" }, ] +[[package]] +name = "lru-dict" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/0a/dec86efe38b350314c49a8d39ef01ba7cf8bbbef1d177646320eedea7159/lru_dict-1.4.1.tar.gz", hash = "sha256:cc518ff2d38cc7a8ab56f9a6ae557f91e2e1524b57ed8e598e97f45a2bd708fc", size = 13439, upload-time = "2025-11-02T10:02:13.548Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/a8/89e4c26e0e751321b41b0a3007384f97d9eae7a863c49af1c68c43005ca3/lru_dict-1.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7fa342c6e6bc811ee6a17eb569d37b149340d5aa5a637a53438e316a95783838", size = 16683, upload-time = "2025-11-02T10:01:15.891Z" }, + { url = "https://files.pythonhosted.org/packages/f1/34/b3c6fdd120af68b6eeb524d0de3293ff27918ec57f45eed6bef1789fd085/lru_dict-1.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bd86bd202a7c1585d9dc7e5b0c3d52cf76dc56b261b4bbecfeefbbae31a5c97d", size = 11216, upload-time = "2025-11-02T10:01:16.867Z" }, + { url = "https://files.pythonhosted.org/packages/e9/7e/280267ae23f1ec1074ddaab787c5e041e090220e8e37828d51ff4e681dfd/lru_dict-1.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4617554f3e42a8f520c8494842c23b98f5b7f4d5e0410e91a4c3ad0ea5f7e094", size = 11687, upload-time = "2025-11-02T10:01:17.485Z" }, + { url = "https://files.pythonhosted.org/packages/ca/18/fec42416ceff98ae2760067ec72b0b9fc02840e729bbc18059c6a02cb01f/lru_dict-1.4.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:40927a6a4284d437047f547e652b15f6f0f40210deb6b9e5b77e556ff0faea0f", size = 31960, upload-time = "2025-11-02T10:01:18.158Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ef/38e7ee1a5d32b9b1629d045fa5a495375383aacfb2945f4d9535b9af9630/lru_dict-1.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e2c07ecb6d42494e45d00c2541e6b0ae7659fc3cf89681521ba94b15c682d4fe", size = 32882, upload-time = "2025-11-02T10:01:18.841Z" }, + { url = "https://files.pythonhosted.org/packages/72/82/d56653ca144c291ab37bea5f23c5078ffbe64f7f5b466f91d400590b9106/lru_dict-1.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:85b28aa2de7c5f1f6c68221857accd084438df98edbd4f57595795734225770c", size = 34268, upload-time = "2025-11-02T10:01:19.564Z" }, + { url = "https://files.pythonhosted.org/packages/94/ae/382651533d60f0b598757efda56dc87cad5ac311fba8e61f86fb916bf236/lru_dict-1.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cbbbb4b51e2529ccf7ee8a3c3b834052dbd54871a216cfd229dd2b1194ff293a", size = 32156, upload-time = "2025-11-02T10:01:20.22Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d1/d9df7e9272ccbc96f04c477dfb9abb91fa8fabde86b7fa190cb7b3c7a024/lru_dict-1.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e47040421a13de8bc6404557b3700c33f1f2683cbcce22fe5cacec4c938ce54b", size = 33395, upload-time = "2025-11-02T10:01:20.901Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6e/dafe0f5943a7b3ab24d3429032ff85873acd626087934b8161b55340c13a/lru_dict-1.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:451f7249866cb9564bb40d73bec7ac865574dafd0a4cc91627bbf35be7e99291", size = 31591, upload-time = "2025-11-02T10:01:21.606Z" }, + { url = "https://files.pythonhosted.org/packages/a6/4d/9dd35444592bfb6805548e15971cfce821400966a51130b78dc021ee8f03/lru_dict-1.4.1-cp312-cp312-win32.whl", hash = "sha256:e8996f3f94870ecb236c55d280839390edae7f201858fee770267eac27b8b47d", size = 13119, upload-time = "2025-11-02T10:01:22.61Z" }, + { url = "https://files.pythonhosted.org/packages/8d/82/7e72e30d6c15d65466b3baca87cce15e20848ba6a488868aa54e901141a6/lru_dict-1.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:d90774db1b60c0d5c829cfa5d7fda6db96ed1519296f626575598f9f170cca37", size = 14109, upload-time = "2025-11-02T10:01:23.322Z" }, + { url = "https://files.pythonhosted.org/packages/85/95/ee171a68ae381ab988c50e3b7b136b1c598f5f683ba4a1e10c51e2480408/lru_dict-1.4.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2a5644bb1db0514abdad5e2f3d8f1beb6f7560c8cceb62079c40a4269de34b3c", size = 12248, upload-time = "2025-11-02T10:01:24.291Z" }, + { url = "https://files.pythonhosted.org/packages/a1/82/8de8e8fd96c44d46891415834ceb9f51c552840bda2d118394aca5e3153a/lru_dict-1.4.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:4209864be09ec20f6059fef8544697eb3d3729d63a983bf66457054bf3e40601", size = 12243, upload-time = "2025-11-02T10:01:25.254Z" }, + { url = "https://files.pythonhosted.org/packages/53/97/251cfb357c547a8fd06c2bc40db8a7f7eed7dbacef30d8d7e543522360e1/lru_dict-1.4.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8fef8dd72484b4280799c502c116acfdfcf0dedf3508bc9d0d19e684a6a23267", size = 10938, upload-time = "2025-11-02T10:01:25.89Z" }, + { url = "https://files.pythonhosted.org/packages/58/14/602791d219bc87197ae80f5fa0f77ca0af8e83e9a06c7cdb89db5575839e/lru_dict-1.4.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d64ddbe4c426fdc4cfc1abaea71d587d439397386a7b35d588f4fd64b695a83d", size = 11261, upload-time = "2025-11-02T10:01:26.879Z" }, + { url = "https://files.pythonhosted.org/packages/10/5d/a30a6fad150f20f084de8e243882a0488ad4929db41a2c8ce9be6cf56563/lru_dict-1.4.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:000ba9a2ab4dd1ad2d91764a6d5cce75a59de51534cdda478d1ddaa3cd8d5c48", size = 10801, upload-time = "2025-11-02T10:01:27.553Z" }, + { url = "https://files.pythonhosted.org/packages/64/4d/cee327e024d42972c598b7e0cd5063a1b1d7451efba31f7de7b6ca91e7d0/lru_dict-1.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ffad2758ce21d8fd6f0ae2628b31330732db8429a4b5994d2e107bed0ee11e68", size = 16688, upload-time = "2025-11-02T10:01:28.17Z" }, + { url = "https://files.pythonhosted.org/packages/58/c8/2f86a1e448c5257b31424b96bf1385e7f96ec7841c2376db02811bbd395f/lru_dict-1.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1671e8d92fe35dfb38d3505a56338792d3e225032f8e94888b6e95b323120380", size = 11214, upload-time = "2025-11-02T10:01:28.888Z" }, + { url = "https://files.pythonhosted.org/packages/06/41/507c615cffaba67c35affd77dec25d3183bb87f404b41c8bb2b3053481ac/lru_dict-1.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d5f01ada0cf0c1aa2bdc684e5ac0f6548be7eccc3ce8b4c0361db8445f867f04", size = 11689, upload-time = "2025-11-02T10:01:29.508Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c1/35aa1359f80174016b389f8be5fd48c4a5af0a04a73afb4906e5d4279f4a/lru_dict-1.4.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:74204239e30b8ec7976257c5b64565d7e3e8aea0cad0dd50a9b99e171aaf3898", size = 32034, upload-time = "2025-11-02T10:01:30.164Z" }, + { url = "https://files.pythonhosted.org/packages/c2/93/46301015bddd4552a1b76982ef788a7fb2a886efff83ad2c178cc7e68349/lru_dict-1.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a7da0e451faa4d6dcae21c0f2527c540000b2f23ed8326a0bc1d870130fd12b1", size = 32919, upload-time = "2025-11-02T10:01:30.971Z" }, + { url = "https://files.pythonhosted.org/packages/e6/cb/6d67145619d8ec3bba15fe145ff702ecf44991e33345d38c763501c1608a/lru_dict-1.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:071468a716768a9afca64659c390c1abb6d937b1897e07a0b70383f75637fce0", size = 34334, upload-time = "2025-11-02T10:01:31.663Z" }, + { url = "https://files.pythonhosted.org/packages/a5/44/50daaec6793ec2042079ed6a8b6a687b4be51b270b1d8ec5efd280116493/lru_dict-1.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e77d209bcd396eb236c197bf4c95fab6848c61e0c1a5031cdde7f5c787e209f4", size = 32211, upload-time = "2025-11-02T10:01:32.468Z" }, + { url = "https://files.pythonhosted.org/packages/bd/53/355397949215e6b77b6771b973ee1dbc21fdd9f955925e47dce50d9d4727/lru_dict-1.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b21688fd7ece56d04c0c13b42fd9f904d46fc9ff21e3de87d98f3f5a14c67f74", size = 33461, upload-time = "2025-11-02T10:01:33.2Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/d660fa63144f38a0fd5b437a140517e3cff482d955ef6b9b4cf7651b9d85/lru_dict-1.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:989ef7352b347c82e5d5047f3b7ddf34b5a938e3f7b08775cacc9f28e97dd2a8", size = 31651, upload-time = "2025-11-02T10:01:33.874Z" }, + { url = "https://files.pythonhosted.org/packages/2e/77/0fae8d0702f7546f436efe06a684b301aad5c8a167bb2df6e42b0f821de5/lru_dict-1.4.1-cp313-cp313-win32.whl", hash = "sha256:a36e6e95b5d474ef90d04a5e3ad81ca362b473ec9534ed964222f3c0444138b8", size = 13120, upload-time = "2025-11-02T10:01:34.595Z" }, + { url = "https://files.pythonhosted.org/packages/4a/20/56a3f0d74c8fe32c01d3978387f66c9fb180c7f15bfd9fcecaa01b4e7736/lru_dict-1.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8e73a1ec2d0f476d666ce7c91464b22854086951b319544d1850c508f5ce381f", size = 14112, upload-time = "2025-11-02T10:01:35.268Z" }, + { url = "https://files.pythonhosted.org/packages/98/02/8e04a8d744b466d4153502e2d92b453c2e5a549d49bf7fabfdca1621828a/lru_dict-1.4.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7b770c7db258625e57b6ea8e2e0503ba0fbbdcde374baacf9adb256eb9c5adfa", size = 11119, upload-time = "2025-11-02T10:01:36.239Z" }, + { url = "https://files.pythonhosted.org/packages/50/f8/ee96f30127ff47c29966603f040e0485700fe0ca0e7d7b1ecbc9bf999eea/lru_dict-1.4.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:45d4dc338237cedcbacedab1afd9707b8f9867d8b601ec04e0395ec73f57405c", size = 11435, upload-time = "2025-11-02T10:01:36.857Z" }, + { url = "https://files.pythonhosted.org/packages/a0/5a/897b33ba1974b6487848cafa5de7e93a7c4f5d9d3f43319ee010f6882830/lru_dict-1.4.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:5b31e9b6636f8945ad69c630c1891d810d62a91d99e792ef0b9ca865b6c26745", size = 10988, upload-time = "2025-11-02T10:01:37.531Z" }, + { url = "https://files.pythonhosted.org/packages/19/8e/b87d0f2bfcad0169afc00e23e014bad9af252206ec2cbc6079f12bece58e/lru_dict-1.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:f9335d46c83882a1b5deffed8098a2dd9ad66d2bd6263f416fc4c73f63e26904", size = 16733, upload-time = "2025-11-02T10:01:38.194Z" }, + { url = "https://files.pythonhosted.org/packages/ab/19/d2384266864b1e5b1cc20527ae468550d3b23a71636371b40e4663276294/lru_dict-1.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:17844b4f8dd996144d53380395d73832e2508159ad49ed4fbcb62f1787a5feaf", size = 11222, upload-time = "2025-11-02T10:01:39.093Z" }, + { url = "https://files.pythonhosted.org/packages/66/8a/94dec42ae6b5c8bdc53a86867924fa22634516434f129dca187ccc0853b8/lru_dict-1.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2b569c7813adb753b7b631097c34e6dbc194cb1814f22299c2d2a94894779877", size = 11733, upload-time = "2025-11-02T10:01:39.739Z" }, + { url = "https://files.pythonhosted.org/packages/3f/19/0b6de1db804cf094e201c5541d58e6a96359eb5beed048fa64d0589b6520/lru_dict-1.4.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:33cf1eb368d3989b8f00945937cfbfc2095d8ad2b1d2274ce1bde0af6f6d1e66", size = 32251, upload-time = "2025-11-02T10:01:40.421Z" }, + { url = "https://files.pythonhosted.org/packages/97/38/89d9425dde436b9bd894234171988289b259aeeab5965bd2c21d5104cb41/lru_dict-1.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22d5879ec5d5955f9dde105997bdf7ec9e0522bf99612a80b55b09f356a08368", size = 33405, upload-time = "2025-11-02T10:01:41.917Z" }, + { url = "https://files.pythonhosted.org/packages/0e/24/f1a189399ee107a64f955c9d6c84d3b0aee9b64b31fc5684b1eaeb3a6fc0/lru_dict-1.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2084363e4488aa5b4f8b26bd3cc148d70a15be92e3d347621a5b830b2b1e0a82", size = 35135, upload-time = "2025-11-02T10:01:42.935Z" }, + { url = "https://files.pythonhosted.org/packages/f8/57/58e9dcf0853d639e2995e5d9f84649ff8d6792a04a418628672a130137f4/lru_dict-1.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8198ab8ad7cc81b86340243ddd5cca882ead87daed0c9fa6cce377a10a7f2e47", size = 32620, upload-time = "2025-11-02T10:01:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/a9/bb/664922f0cf076b1e3c2e43e8258582d507b07c19bd441a72dd5547a483e9/lru_dict-1.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f1f4ae6967d5873e684ce8b986e2e43985d0a1be735b09584737ad5634ff48f3", size = 34077, upload-time = "2025-11-02T10:01:44.694Z" }, + { url = "https://files.pythonhosted.org/packages/58/38/b7a6fa85b150232cada26a50c89dc4bcf9acd6ada00e987b074c3b4e57f2/lru_dict-1.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a9bb130b5eaddd6453ca3dc38ce4a75f743512ad135b6f3994999dde0680bd79", size = 31905, upload-time = "2025-11-02T10:01:45.668Z" }, + { url = "https://files.pythonhosted.org/packages/86/7d/9c86393946d621f4aec852d543df4023241d85106e9e1e2a0e4057861f71/lru_dict-1.4.1-cp314-cp314-win32.whl", hash = "sha256:5534c69a52add5757714456d08ce3831d36b86c98972394ba900493bb0bd97f8", size = 13435, upload-time = "2025-11-02T10:01:46.397Z" }, + { url = "https://files.pythonhosted.org/packages/20/3f/b017cbeea55a8a1d18037840a8a9c9cdae29554e9985b55d4e8694305035/lru_dict-1.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:96fd677b6d912229f2d02ba61a5a1210176963c4770c1bb765b8da937cec3834", size = 14423, upload-time = "2025-11-02T10:01:47.473Z" }, + { url = "https://files.pythonhosted.org/packages/aa/73/13132af7a5155edde66979b53eb509465304e6e5a2b00769246448479c73/lru_dict-1.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6699bfebbf11dd9ff1387be7996fac6d1009fe6a6f48091ef6e069e6f19c7bce", size = 17184, upload-time = "2025-11-02T10:01:48.161Z" }, + { url = "https://files.pythonhosted.org/packages/ef/82/094985beb3e49461bf65a3c40df8de2018b8484e4ef129295090460ca5d9/lru_dict-1.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a276f8f6f43861c3f05986824741d00e3133a973c3396598375310129535382d", size = 11459, upload-time = "2025-11-02T10:01:48.823Z" }, + { url = "https://files.pythonhosted.org/packages/14/29/836abc49f8c2b6c2efccd2ac2b2c0ad3e55b7d75a05a20cc061f17871e39/lru_dict-1.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:090c7b6a3d54fa7f3d69ba4802abe2f33c9583b16b33f52bcb521c701f7ea46c", size = 11938, upload-time = "2025-11-02T10:01:49.448Z" }, + { url = "https://files.pythonhosted.org/packages/c2/83/1bb4e8fbc0b753fea825564d9d96180813a71715d46a9b6bb30a6dea4ce0/lru_dict-1.4.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b21d06dec64fb1952385262d9fcefaec147921dc0b55210007091a79da440d93", size = 36389, upload-time = "2025-11-02T10:01:50.49Z" }, + { url = "https://files.pythonhosted.org/packages/54/f2/7df3b6d0dbc66f3be9aa6261750967cdc5619c89a563420c52200d2dd547/lru_dict-1.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9613908a38cf8aa47f6c138ba031a8ac4ed38460299e84a2b07dba7b3b45aae", size = 38706, upload-time = "2025-11-02T10:01:51.197Z" }, + { url = "https://files.pythonhosted.org/packages/97/5f/e3ba3eeb9b864a09b92e24fbf179aef4ef48588e763a9d8d2bc10bd2c6f8/lru_dict-1.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7558302ce8bbfcd29f08e695e07bf7a0d799c2979636d6a6a0b4e207f840969f", size = 38892, upload-time = "2025-11-02T10:01:51.899Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e3/12e0888aab0bf3ab9ce35e9849f239994a0feff6fe49380859bf57124a17/lru_dict-1.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3910396142322fb2718546115bb2a56f50ebc9144b5140327053cca084e0d375", size = 37254, upload-time = "2025-11-02T10:01:52.587Z" }, + { url = "https://files.pythonhosted.org/packages/cc/dc/06cd981718d039eb07a9c03263094aca6269721c470f765a292b24381a20/lru_dict-1.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f3f4fad5c4a9458954b275de6a6e31c67a26fbef7037c6a7354e22523a77db26", size = 37422, upload-time = "2025-11-02T10:01:53.271Z" }, + { url = "https://files.pythonhosted.org/packages/e8/82/ea88e618f39d78ff3c15b71f01a0b1a6c6ac2034ce5c6428ae41b1c30ea5/lru_dict-1.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85fc29363e2d3ba0a5f87b5e17f54b1078aea6d24c6dfc792725854b9d0f8d17", size = 35909, upload-time = "2025-11-02T10:01:54.356Z" }, + { url = "https://files.pythonhosted.org/packages/89/36/1dd91c602f623839cec24d6c77fa3fd1a8878bf2d716871197cd3bf084dc/lru_dict-1.4.1-cp314-cp314t-win32.whl", hash = "sha256:b3853518dfa50f28af0d6e2dcf8bb8b0a1687c5f4eb913c0b35b0da5c6d276ce", size = 13816, upload-time = "2025-11-02T10:01:55.417Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a3/113410f7b2e61e9d6f13f1f17c584dbd08b5796e65d772ecd5b063fab3af/lru_dict-1.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ff3af42922205620fdc920dcdf580c4c16b32c84a537a03b04b523e5c641a8a9", size = 15204, upload-time = "2025-11-02T10:01:56.06Z" }, +] + [[package]] name = "markdown" version = "3.10.2" @@ -2042,6 +2102,7 @@ dependencies = [ { name = "html5lib" }, { name = "jsonfield" }, { name = "kombu" }, + { name = "lru-dict" }, { name = "markdown" }, { name = "minijinja" }, { name = "packaging" }, @@ -2055,6 +2116,7 @@ dependencies = [ { name = "pyyaml" }, { name = "slixmpp" }, { name = "social-auth-app-django" }, + { name = "starlark-pyo3" }, { name = "sympy" }, { name = "typing-extensions" }, { name = "unicodecsv" }, @@ -2134,6 +2196,7 @@ requires-dist = [ { name = "html5lib", specifier = "~=1.1" }, { name = "jsonfield", specifier = ">=1.4.0" }, { name = "kombu", specifier = ">=5.4.2,<6" }, + { name = "lru-dict", specifier = ">=1.4.1" }, { name = "markdown", specifier = "~=3.7" }, { name = "minijinja", specifier = ">=2.7.0,<3" }, { name = "packaging", specifier = ">=25" }, @@ -2149,6 +2212,7 @@ requires-dist = [ { name = "pyyaml", specifier = ">=6.0.2" }, { name = "slixmpp", specifier = ">=1.8.3,<2" }, { name = "social-auth-app-django", specifier = ">=5.4.1,<6" }, + { name = "starlark-pyo3", specifier = ">=2025.2.5" }, { name = "sympy", specifier = ">=1.13.3" }, { name = "typing-extensions", specifier = ">=4.14.1" }, { name = "unicodecsv", specifier = ">=0.14.1,<0.15" }, @@ -2582,6 +2646,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, ] +[[package]] +name = "starlark-pyo3" +version = "2025.2.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/1d/baa48161b66b6664e891a5c76c23fd0fdf1ca53fa643b6429287b9de62cd/starlark_pyo3-2025.2.5.tar.gz", hash = "sha256:ac14b42364cc0e116e0e573a60858395f321489a74b65630641c243c772d1e1d", size = 42254, upload-time = "2025-11-20T18:38:49.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/55/51be13e38a81a9b94cf3d33137652bf813d799c4b87dd373efbc48c920c6/starlark_pyo3-2025.2.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c39f97a0c1f70984421bde53bfe4fec4bd86a5a6d26de81444b564a5d20e8cdc", size = 3119233, upload-time = "2025-11-20T18:38:22.658Z" }, + { url = "https://files.pythonhosted.org/packages/f1/09/afd93f47c15672bc6aebe85112b43e0ec783a42e7425b4bcf6b53819bd54/starlark_pyo3-2025.2.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a629b6fd4881930b2b581525a3d998e88076ae0d27c65d6bad916d47eaf8da6a", size = 3656743, upload-time = "2025-11-20T18:38:24.185Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a0/8f061eb916895a39797d16c3283e2359339088d8ef47b129be491a021685/starlark_pyo3-2025.2.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b94cd453d33d6ac1affefbd92bfd968858807070be11ff600f2ec86a49f5e76e", size = 3418311, upload-time = "2025-11-20T18:38:25.392Z" }, + { url = "https://files.pythonhosted.org/packages/b5/48/5922590f308ab4e566cfdb466bcb9d18508916557997ccc311fe327ec06c/starlark_pyo3-2025.2.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:082d2869aae5b3a253b3af4a3a14dc73a3ab3261717e0cce8d34bb4d2ac05643", size = 3547391, upload-time = "2025-11-20T18:38:26.662Z" }, + { url = "https://files.pythonhosted.org/packages/98/9e/72a694d25d5cc8546d275f3110b392a721cff5b260f3ab2c6fe13789c909/starlark_pyo3-2025.2.5-cp312-cp312-win_amd64.whl", hash = "sha256:da6700b6031e288f78c9f53851a361b504083c56acd7dd3d7e46cc1cb432407b", size = 2694850, upload-time = "2025-11-20T18:38:28.078Z" }, + { url = "https://files.pythonhosted.org/packages/33/da/8f5d99a263998280fca11d1a7a29fd3161c242d015366071d26f48459604/starlark_pyo3-2025.2.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a50917b0606795adf4b050c124b91df4dca05f5ee2afac93dcf2e33c4f3ee3ca", size = 3119409, upload-time = "2025-11-20T18:38:29.286Z" }, + { url = "https://files.pythonhosted.org/packages/4a/aa/d1a2ac67a3785c893baea3ed919a394e038d466ac54e57bb8ceadf1766e8/starlark_pyo3-2025.2.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7222506272bfd9eb48d10ba79914cf2fc5a4a81a4217fa718f145cd658fcadbe", size = 3656512, upload-time = "2025-11-20T18:38:31.117Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5a/7c7930982c7c1ea46ccaa2ebf1f61e44b41586099e2498bdc37b11938851/starlark_pyo3-2025.2.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3ed535de786b5af43f2315ad38065c0b85c0bdd4b6511ca5e5a1a3f4ed2f4dd7", size = 3418458, upload-time = "2025-11-20T18:38:32.374Z" }, + { url = "https://files.pythonhosted.org/packages/67/be/a658fadbee8db86e8d7fc33bb4d58c13229665d4b7362057950dc14794df/starlark_pyo3-2025.2.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1583ad6a649ec4d9393351e2cce34f77be6b389c7c2fb252bcd0e79154dd059b", size = 3547048, upload-time = "2025-11-20T18:38:34.24Z" }, + { url = "https://files.pythonhosted.org/packages/b1/d2/0e0bfb2fa96800647373c791bf969c2e5bc0bf0c8ab90673c2ef1900ade9/starlark_pyo3-2025.2.5-cp313-cp313-win_amd64.whl", hash = "sha256:bbdc20ce466ed05a7e321608fd8edde8cb13b4baacc2cbfc234b3828bd8050a8", size = 2694147, upload-time = "2025-11-20T18:38:35.313Z" }, + { url = "https://files.pythonhosted.org/packages/ec/62/60684da6c7b7ec77ae9341bd2b94fd0eb2464b3396d62712f2f4d51650c5/starlark_pyo3-2025.2.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c8b7de07f9723aab59a76e775d9650871f4234625eabe6a86ab8b7ac0c5f62f9", size = 3120594, upload-time = "2025-11-20T18:38:36.699Z" }, + { url = "https://files.pythonhosted.org/packages/44/28/497e8a62919e741b8c05019410f9c33b8656fefbda428069204741648118/starlark_pyo3-2025.2.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e2ea81cd468242e987c06a571dbbe4c4ab13c12de2252b015ec13054c5c7800", size = 3546441, upload-time = "2025-11-20T18:38:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/b8/f6/5e01ce21d221445586cc0d34c6a966392492a522113fd9a222f047d06027/starlark_pyo3-2025.2.5-cp314-cp314-win_amd64.whl", hash = "sha256:088f3bee6ecb6df809754e46dff8cfd6b9ad358596995d71d99f7a3aabb32efd", size = 2694191, upload-time = "2025-11-20T18:38:39.357Z" }, +] + [[package]] name = "sympy" version = "1.14.0" From 7fabe5bd665492f913dbe2bc239f8a196a4b5d40 Mon Sep 17 00:00:00 2001 From: Andreas Kloeckner Date: Mon, 12 Jan 2026 11:26:47 -0600 Subject: [PATCH 8/9] Make GradeInfo a dataclass --- course/flow.py | 40 +++++++++++++--------------------------- 1 file changed, 13 insertions(+), 27 deletions(-) diff --git a/course/flow.py b/course/flow.py index 6bbc3f758..24df27aa1 100644 --- a/course/flow.py +++ b/course/flow.py @@ -666,6 +666,7 @@ def get_session_answered_page_data( return (answered_page_data_list, unanswered_page_data_list, is_interactive_flow) +@dataclass(frozen=True) class GradeInfo: """An object to hold a tally of points and page counts of various types in a flow. @@ -686,33 +687,18 @@ class GradeInfo: to :attr:`FlowSessionGradingRule.max_points_enforced_cap`. """ - def __init__( - self, - points: float | None, - provisional_points: float | None, - max_points: float | None, - max_reachable_points: float | None, - fully_correct_count: int, - partially_correct_count: int, - incorrect_count: int, - unknown_count: int, - optional_fully_correct_count: int = 0, - optional_partially_correct_count: int = 0, - optional_incorrect_count: int = 0, - optional_unknown_count: int = 0, - ) -> None: - self.points = points - self.provisional_points = provisional_points - self.max_points = max_points - self.max_reachable_points = max_reachable_points - self.fully_correct_count = fully_correct_count - self.partially_correct_count = partially_correct_count - self.incorrect_count = incorrect_count - self.unknown_count = unknown_count - self.optional_fully_correct_count = optional_fully_correct_count - self.optional_partially_correct_count = optional_partially_correct_count - self.optional_incorrect_count = optional_incorrect_count - self.optional_unknown_count = optional_unknown_count + points: float | None + provisional_points: float | None + max_points: float | None + max_reachable_points: float | None + fully_correct_count: int + partially_correct_count: int + incorrect_count: int + unknown_count: int + optional_fully_correct_count: int = 0 + optional_partially_correct_count: int = 0 + optional_incorrect_count: int = 0 + optional_unknown_count: int = 0 # Rounding to larger than 100% will break the percent bars on the # flow results page. From deb6ebf03ab88e2dcd6be3445fdc6949a87468a3 Mon Sep 17 00:00:00 2001 From: Andreas Kloeckner Date: Mon, 12 Jan 2026 11:27:20 -0600 Subject: [PATCH 9/9] WIP grading rules starlark --- course/starlark/data.py | 64 ++++++++++++++++++++++++++++++- course/starlark/use_case/rules.py | 19 +++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/course/starlark/data.py b/course/starlark/data.py index e214fadc8..317934f46 100644 --- a/course/starlark/data.py +++ b/course/starlark/data.py @@ -1,5 +1,10 @@ from __future__ import annotations +from annotated_types import Ge +from pydantic import AllowInfNan + +from course.validation import PointCount + __copyright__ = "Copyright (C) 2025 University of Illinois Board of Trustees" @@ -25,7 +30,7 @@ from dataclasses import dataclass from datetime import datetime # noqa: TC003 -from typing import TYPE_CHECKING, Self, TypeAlias +from typing import TYPE_CHECKING, Annotated, Self, TypeAlias from pytools import not_none @@ -101,6 +106,7 @@ class FlowSession: .. autoattribute:: id .. autoattribute:: start_time .. autoattribute:: completion_time + .. autoattribute:: last_activity .. autoattribute:: expiration_mode .. autoattribute:: access_rules_tag .. autoattribute:: points @@ -109,6 +115,7 @@ class FlowSession: id: int start_time: datetime completion_time: datetime | None + last_activity: datetime expiration_mode: FlowSessionExpirationMode | None access_rules_tag: str | None points: float | None @@ -123,10 +130,15 @@ def from_relate(cls, sess: FlowSessionModel | Self): from course.models import FlowSession as FlowSessionModel assert isinstance(sess, FlowSessionModel) + last_activity = sess.last_activity() + if last_activity is None: + last_activity = sess.start_time + return cls( id=sess.id, start_time=sess.start_time, completion_time=sess.completion_time, + last_activity=last_activity, expiration_mode=FlowSessionExpirationMode(sess.expiration_mode) if sess.expiration_mode is not None else None, access_rules_tag=sess.access_rules_tag, @@ -235,3 +247,53 @@ class FlowPageAccessRuleArgs(FlowSessionAccessRuleArgs): """ page_id: FlowPageId | None attempts: list[FlowPageAttempt] | None + + +@dataclass(frozen=True, kw_only=True) +class FlowPageGrade: + """ + .. autoattribute:: grade + .. autoattribute:: message + """ + grade: PointCount | None + message: str | None + + +SessionPointCount = Annotated[ + float, + AllowInfNan(False), + Ge(0)] + + +@dataclass(frozen=True, kw_only=True) +class FlowSessionGrade: + """ + .. autoattribute:: points + .. autoattribute:: certain_points + .. autoattribute:: possible_points + .. autoattribute:: max_reachable_points + .. autoattribute:: message + """ + points: SessionPointCount | None + """Non-None only if the final grade is available.""" + + certain_points: SessionPointCount | None + + possible_points: SessionPointCount | None + + max_reachable_points: SessionPointCount | None + """The maximum number of actually attainable points on the flow, subject + to the grading rules, but independent of the particular page results. + """ + + message: str | None + + +@dataclass(frozen=True, kw_only=True) +class FlowGrade: + """ + .. autoattribute:: points + .. autoattribute:: message + """ + points: PointCount | None + message: str | None diff --git a/course/starlark/use_case/rules.py b/course/starlark/use_case/rules.py index 22e5b570b..5772abba2 100644 --- a/course/starlark/use_case/rules.py +++ b/course/starlark/use_case/rules.py @@ -217,3 +217,22 @@ def __call__(self, @override def run_tests(self, mod: StarlarkModuleWithSource, course: Course | None): pass + + +class FlowPageGradingRulesUseCase(FlowRulesUseCaseBase): + def __call__(self, + mod: StarlarkModuleWithSource, + *, + course: Course | None, + now: datetime, + participation: Participation | StarlarkParticipation | None, + flow_id: str, + session: FlowSession | StarlarkFlowSession, + page_data: FlowPageData, + attempts: Sequence[FlowPageVisit | FlowPageAttempt] + ) -> FlowPageGrade: + pass + + @override + def run_tests(self, mod: StarlarkModuleWithSource, course: Course | None): + pass