diff --git a/.secrets.baseline b/.secrets.baseline index 7571f23..adab5a0 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -149,7 +149,7 @@ "filename": "README.md", "hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6", "is_verified": false, - "line_number": 340, + "line_number": 343, "is_secret": false } ], @@ -392,5 +392,5 @@ } ] }, - "generated_at": "2026-10-09T14:22:07Z" + "generated_at": "2026-10-09T17:28:09Z" } diff --git a/README.md b/README.md index 7d86a8b..72f2614 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,9 @@ the shared course reads above. Their JSON envelope is `{"course_id": 123, "data": ...}`. Responses use a bounded field allowlist; unknown fields and credential-bearing properties are not returned. Classlists currently omit email and login identifiers. +Brightspace refuses `assignment-history` (403) once an assignment's end date +has passed with access restricted; the CLI then says when it closed and to +check released grades with `lighthouse grades COURSE_ID`. Quiz creation makes a **hidden shell without questions or a gradebook link**. `--layout all` displays questions together; `--layout one-way` selects one diff --git a/lighthouse_cli/assessment_api.py b/lighthouse_cli/assessment_api.py index c80dd0a..4ed1d88 100644 --- a/lighthouse_cli/assessment_api.py +++ b/lighthouse_cli/assessment_api.py @@ -7,6 +7,7 @@ from __future__ import annotations import math +from datetime import datetime, timezone from typing import Any import requests @@ -28,6 +29,10 @@ class AssessmentWriteUnknownError(NetworkError): _WRITE_UNKNOWN = "Write outcome unknown. Inspect the assessment before retrying." +class AssignmentClosedError(ValueError): + """Brightspace refused a learner's own submissions after the folder closed; fixed local message only.""" + + def positive_id(value: object) -> int: return _require_positive_endpoint_id(value, "identifier") @@ -171,7 +176,40 @@ def attempts(self, quiz_id: int) -> list[dict[str, Any]]: def submissions(self, folder_id: int, *, mine: bool) -> Any: path = self.path("assignment", folder_id) + "/submissions/" - return self.client.get_json(path + "mysubmissions/" if mine else path) + if not mine: + return self.client.get_json(path) + try: + return self.client.get_json(path + "mysubmissions/") + except requests.HTTPError as e: + # Brightspace refuses a learner's own history (403) once an access-restricted folder has closed. + closed = self._closed_at(folder_id) if e.response is not None and e.response.status_code == 403 else None + if closed is None: + raise + raise AssignmentClosedError( + f"Brightspace refused this (HTTP 403): the assignment closed on {closed:%Y-%m-%d %H:%M} UTC and " + "restricts access after that, so its submissions and feedback cannot be read here. " + f"Check released grades with: lighthouse grades {self.course_id}") from None + + def _closed_at(self, folder_id: int) -> datetime | None: + """When this folder closed, if its end date has passed with access restricted after it.""" + try: + folder = self.read("assignment", folder_id) + except Exception: + return None + availability = folder.get("Availability") if isinstance(folder, dict) else None + if not isinstance(availability, dict): + return None + # EndDateAvailabilityType 0 restricts access after the end date (1 restricts only submission). + restriction, end = availability.get("EndDateAvailabilityType"), availability.get("EndDate") + if type(restriction) is not int or restriction != 0 or not isinstance(end, str) or len(end) > 40: + return None + try: + closed = datetime.fromisoformat(end.replace("Z", "+00:00")) + if closed.tzinfo is None or closed >= datetime.now(timezone.utc): + return None + return closed.astimezone(timezone.utc) + except (ValueError, OverflowError): # unparseable, or out of range once in UTC + return None def write(self, method: str, resource: str, data: dict[str, Any], identifier: int | None = None) -> Any: if method not in {"POST", "PUT"}: diff --git a/lighthouse_cli/assessment_commands.py b/lighthouse_cli/assessment_commands.py index 19b4c67..318906c 100644 --- a/lighthouse_cli/assessment_commands.py +++ b/lighthouse_cli/assessment_commands.py @@ -25,7 +25,12 @@ def _run(course_id: int, json_output: bool, action: Callable[[AssessmentAPI], Any]) -> None: # Imported here, not at module level, so role --help skips the HTTP stack. from .api import LighthouseClient - from .assessment_api import AssessmentAPI, AssessmentWriteUnknownError, project + from .assessment_api import ( + AssessmentAPI, + AssessmentWriteUnknownError, + AssignmentClosedError, + project, + ) client = None try: @@ -36,6 +41,7 @@ def _run(course_id: int, json_output: bool, action: Callable[[AssessmentAPI], An message = ( "Write outcome unknown. Inspect the assessment before retrying." if isinstance(exc, AssessmentWriteUnknownError) + else str(exc) if isinstance(exc, AssignmentClosedError) else format_user_error(exc) ) fail(message, json_output, {"course_id": course_id, "data": None, "error": message}) diff --git a/tests/test_assessment_workflows.py b/tests/test_assessment_workflows.py index 9503ce9..524773c 100644 --- a/tests/test_assessment_workflows.py +++ b/tests/test_assessment_workflows.py @@ -167,6 +167,82 @@ def test_learner_history_uses_my_submissions_and_retains_feedback(): client.return_value._session.close.assert_called_once() +def _http_error(status): + response = requests.Response() + response.status_code = status + return requests.HTTPError(f"{status} Client Error", response=response) + + +def _closed(end, restriction=0): + return {"Name": "cookie=NEVER_PRINT", "Availability": {"EndDate": end, "EndDateAvailabilityType": restriction}} + + +CLOSED = ("Brightspace refused this (HTTP 403): the assignment closed on 2026-03-05 18:29 UTC and restricts access " + "after that, so its submissions and feedback cannot be read here. " + "Check released grades with: lighthouse grades 12") +PAST = "2026-03-05T18:29:00.000Z" +DENIED = "Permission denied (HTTP 403)." + + +@pytest.mark.parametrize(("history", "folder", "error", "reads"), [ + # Closed with access restricted after the end date: say so, in UTC, and where grades are. + (_http_error(403), _closed(PAST), CLOSED, 2), + (_http_error(403), _closed("2026-03-05T23:59:00+05:30"), CLOSED, 2), + # Any other availability, or an unreadable folder: the original 403. + (_http_error(403), _closed(PAST, None), DENIED, 2), + (_http_error(403), {"Availability": {"EndDate": PAST}}, DENIED, 2), + (_http_error(403), _closed(PAST, 1), DENIED, 2), + (_http_error(403), _closed(PAST, 2), DENIED, 2), + (_http_error(403), _closed(PAST, "0"), DENIED, 2), + (_http_error(403), _closed(PAST, False), DENIED, 2), + (_http_error(403), _closed("2999-01-01T00:00:00Z"), DENIED, 2), + (_http_error(403), _closed(None), DENIED, 2), + (_http_error(403), _closed("2026-03-05T18:29:00"), DENIED, 2), + (_http_error(403), _closed("2026-03-05"), DENIED, 2), + (_http_error(403), _closed("cookie=NEVER_PRINT"), DENIED, 2), + (_http_error(403), _closed(PAST + " " * 40), DENIED, 2), + (_http_error(403), _closed("0001-01-01T00:00:00+01:00"), DENIED, 2), + (_http_error(403), _closed("9999-12-31T23:59:00-01:00"), DENIED, 2), + (_http_error(403), {"Availability": None}, DENIED, 2), + (_http_error(403), {"Availability": "x"}, DENIED, 2), + (_http_error(403), [PAST], DENIED, 2), + (_http_error(403), _http_error(403), DENIED, 2), + (_http_error(403), requests.ConnectionError("cookie=NEVER_PRINT"), DENIED, 2), + # Any other refusal reads nothing more. + (requests.HTTPError("403 Client Error"), _closed(PAST), DENIED, 1), + (_http_error(404), _closed(PAST), "Not found (HTTP 404).", 1), + (NetworkError("cookie=NEVER_PRINT"), _closed(PAST), "Network error. Check your connection and try again.", 1), +], ids=["closed", "closed-offset", "null-type", "no-type", "submission-restricted", "hidden", "string-type", + "false-type", "open", "null-date", "naive-date", "date-only", "secret-date", "long-date", "min-date", + "max-date", "no-availability", "bad-availability", "bad-folder", "folder-403", "folder-network", + "no-response", "404", "network"]) +def test_learner_history_says_why_a_closed_assignment_is_refused(history, folder, error, reads): + with patch("lighthouse_cli.api.LighthouseClient") as client: + client.return_value.get_json.side_effect = [history, folder] + result = CliRunner().invoke(cli, ["student", "assignment-history", "12", "34", "--json"]) + assert result.exit_code == 1 + assert json.loads(result.stdout) == {"course_id": 12, "data": None, "error": error} + assert [c.args for c in client.return_value.get_json.call_args_list] == [ + ("/12/dropbox/folders/34/submissions/mysubmissions/",), ("/12/dropbox/folders/34",)][:reads] + assert "NEVER_PRINT" not in result.output + + +def test_learner_history_closed_message_goes_to_stderr(): + with patch("lighthouse_cli.api.LighthouseClient") as client: + client.return_value.get_json.side_effect = [_http_error(403), _closed(PAST)] + result = CliRunner().invoke(cli, ["student", "assignment-history", "12", "34"]) + assert (result.exit_code, result.stdout, result.stderr.strip()) == (1, "", CLOSED) + + +def test_instructor_submissions_refusal_reads_nothing_more(): + with patch("lighthouse_cli.api.LighthouseClient") as client: + client.return_value.get_json.side_effect = [_http_error(403), _closed(PAST)] + result = CliRunner().invoke(cli, ["instructor", "submissions", "12", "34", "--json"]) + assert result.exit_code == 1 + assert json.loads(result.stdout)["error"] == DENIED + client.return_value.get_json.assert_called_once_with("/12/dropbox/folders/34/submissions/") + + def test_teacher_questions_follow_pagination(): client = LighthouseClient() client.get_json = Mock(side_effect=[{"Objects": [{"QuestionId": 1}], "Next": "?page=2"}, {"Objects": [{"QuestionId": 2}], "Next": None}])