From 3f8b7111afb3fddc0a9476acbe4dd445b9b59e2d Mon Sep 17 00:00:00 2001 From: rabesss <26330469+rabesss@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:42:28 +0530 Subject: [PATCH 1/2] fix: say why a closed assignment's history is refused Brightspace answers a learner's `mysubmissions` with 403 once an assignment's end date has passed and access is restricted after it, so `student assignment-history` printed only "Permission denied (HTTP 403)." for every closed assignment, as if the session or role were wrong. On that 403 the CLI now reads the folder once. If its end date has passed and EndDateAvailabilityType is 0 (access restricted), it says when the assignment closed, in UTC, and to check released grades with `lighthouse grades COURSE_ID`. Any other availability, an unreadable folder or date, any other status and the instructor `submissions` read keep the original message. The README notes this; its audited .secrets.baseline entry moved three lines. --- .secrets.baseline | 6 +-- README.md | 3 ++ lighthouse_cli/assessment_api.py | 40 +++++++++++++++- lighthouse_cli/assessment_commands.py | 8 +++- tests/test_assessment_workflows.py | 67 +++++++++++++++++++++++++++ 5 files changed, 119 insertions(+), 5 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 7571f23..e94fdea 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 } ], @@ -179,7 +179,7 @@ "filename": "tests/test_assessment_workflows.py", "hashed_secret": "f714a8256826794cf5e9c3aa49c17d9ea53b8bd5", "is_verified": false, - "line_number": 156, + "line_number": 157, "is_secret": false } ], @@ -392,5 +392,5 @@ } ] }, - "generated_at": "2026-10-09T14:22:07Z" + "generated_at": "2026-10-09T17:09:19Z" } 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..eef681b 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 + # AvailabilityDateType 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")) + except ValueError: + return None + if closed.tzinfo is None or closed >= datetime.now(timezone.utc): + return None + return closed.astimezone(timezone.utc) 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..5a4071c 100644 --- a/tests/test_assessment_workflows.py +++ b/tests/test_assessment_workflows.py @@ -25,6 +25,7 @@ from lighthouse_cli.config import COOKIE_NAMES from lighthouse_cli.connection import LIGHTHOUSE, Connection, active_connection from lighthouse_cli.credential_store import CredentialStore +from lighthouse_cli.display import format_user_error from lighthouse_cli.quiz_rules import navigation_rules @@ -167,6 +168,72 @@ 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 {"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" + + +@pytest.mark.parametrize(("history", "folder", "closed", "reads"), [ + # Closed with access restricted after the end date: say so, in UTC, and where grades are. + (_http_error(403), _closed(PAST), True, 2), + (_http_error(403), _closed("2026-03-05T23:59:00+05:30"), True, 2), + # Any other availability, or an unreadable folder: the original 403. + (_http_error(403), _closed(PAST, None), False, 2), + (_http_error(403), {"Availability": {"EndDate": PAST}}, False, 2), + (_http_error(403), _closed(PAST, 1), False, 2), + (_http_error(403), _closed(PAST, 2), False, 2), + (_http_error(403), _closed(PAST, "0"), False, 2), + (_http_error(403), _closed(PAST, False), False, 2), + (_http_error(403), _closed("2999-01-01T00:00:00Z"), False, 2), + (_http_error(403), _closed(None), False, 2), + (_http_error(403), _closed("2026-03-05T18:29:00"), False, 2), + (_http_error(403), _closed("2026-03-05"), False, 2), + (_http_error(403), _closed("cookie=NEVER_PRINT"), False, 2), + (_http_error(403), _closed(PAST + " " * 40), False, 2), + (_http_error(403), {"Availability": None}, False, 2), + (_http_error(403), {"Availability": "x"}, False, 2), + (_http_error(403), [PAST], False, 2), + (_http_error(403), _http_error(403), False, 2), + (_http_error(403), requests.ConnectionError("cookie=NEVER_PRINT"), False, 2), + # Any other refusal reads nothing more. + (requests.HTTPError("403 Client Error"), _closed(PAST), False, 1), + (_http_error(404), _closed(PAST), False, 1), + (NetworkError("cookie=NEVER_PRINT"), _closed(PAST), False, 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", "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, closed, 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": CLOSED if closed else format_user_error(history)} + 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_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"] == "Permission denied (HTTP 403)." + 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}]) From 6c44cab760361db0743f509e0b4f126cc5aa6b5d Mon Sep 17 00:00:00 2001 From: rabesss <26330469+rabesss@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:01:05 +0530 Subject: [PATCH 2/2] fix: keep the 403 when a closed folder's end date overflows An aware end date at the edge of the datetime range, such as "0001-01-01T00:00:00+01:00" with access restricted, parsed and passed the checks, then overflowed when converted to UTC, so the CLI printed "Command failed." instead of the original "Permission denied (HTTP 403)." (GPT-6.1 Sol, Grok 4.7). The comparison and the conversion now fall back like any unreadable date; min and max dates are pinned. Tests now pin each fallback's literal message instead of recomputing it with format_user_error (Kilo Code Review), give the closed fixtures a secret-bearing folder name (Muse Spark 1.3), and pin the closed message on stderr with empty stdout without --json (MiMo v2.6 Flash). The code comment names EndDateAvailabilityType (Kilo Code Review). The test file's audited .secrets.baseline entry is back on its original line. --- .secrets.baseline | 4 +- lighthouse_cli/assessment_api.py | 10 ++--- tests/test_assessment_workflows.py | 71 +++++++++++++++++------------- 3 files changed, 47 insertions(+), 38 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index e94fdea..adab5a0 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -179,7 +179,7 @@ "filename": "tests/test_assessment_workflows.py", "hashed_secret": "f714a8256826794cf5e9c3aa49c17d9ea53b8bd5", "is_verified": false, - "line_number": 157, + "line_number": 156, "is_secret": false } ], @@ -392,5 +392,5 @@ } ] }, - "generated_at": "2026-10-09T17:09:19Z" + "generated_at": "2026-10-09T17:28:09Z" } diff --git a/lighthouse_cli/assessment_api.py b/lighthouse_cli/assessment_api.py index eef681b..4ed1d88 100644 --- a/lighthouse_cli/assessment_api.py +++ b/lighthouse_cli/assessment_api.py @@ -199,17 +199,17 @@ def _closed_at(self, folder_id: int) -> datetime | None: availability = folder.get("Availability") if isinstance(folder, dict) else None if not isinstance(availability, dict): return None - # AvailabilityDateType 0 restricts access after the end date (1 restricts only submission). + # 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")) - except ValueError: - return None - if closed.tzinfo is None or closed >= datetime.now(timezone.utc): + 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 - return closed.astimezone(timezone.utc) 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/tests/test_assessment_workflows.py b/tests/test_assessment_workflows.py index 5a4071c..524773c 100644 --- a/tests/test_assessment_workflows.py +++ b/tests/test_assessment_workflows.py @@ -25,7 +25,6 @@ from lighthouse_cli.config import COOKIE_NAMES from lighthouse_cli.connection import LIGHTHOUSE, Connection, active_connection from lighthouse_cli.credential_store import CredentialStore -from lighthouse_cli.display import format_user_error from lighthouse_cli.quiz_rules import navigation_rules @@ -175,62 +174,72 @@ def _http_error(status): def _closed(end, restriction=0): - return {"Availability": {"EndDate": end, "EndDateAvailabilityType": restriction}} + 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", "closed", "reads"), [ +@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), True, 2), - (_http_error(403), _closed("2026-03-05T23:59:00+05:30"), True, 2), + (_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), False, 2), - (_http_error(403), {"Availability": {"EndDate": PAST}}, False, 2), - (_http_error(403), _closed(PAST, 1), False, 2), - (_http_error(403), _closed(PAST, 2), False, 2), - (_http_error(403), _closed(PAST, "0"), False, 2), - (_http_error(403), _closed(PAST, False), False, 2), - (_http_error(403), _closed("2999-01-01T00:00:00Z"), False, 2), - (_http_error(403), _closed(None), False, 2), - (_http_error(403), _closed("2026-03-05T18:29:00"), False, 2), - (_http_error(403), _closed("2026-03-05"), False, 2), - (_http_error(403), _closed("cookie=NEVER_PRINT"), False, 2), - (_http_error(403), _closed(PAST + " " * 40), False, 2), - (_http_error(403), {"Availability": None}, False, 2), - (_http_error(403), {"Availability": "x"}, False, 2), - (_http_error(403), [PAST], False, 2), - (_http_error(403), _http_error(403), False, 2), - (_http_error(403), requests.ConnectionError("cookie=NEVER_PRINT"), False, 2), + (_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), False, 1), - (_http_error(404), _closed(PAST), False, 1), - (NetworkError("cookie=NEVER_PRINT"), _closed(PAST), False, 1), + (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", "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, closed, reads): + "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": CLOSED if closed else format_user_error(history)} + 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"] == "Permission denied (HTTP 403)." + assert json.loads(result.stdout)["error"] == DENIED client.return_value.get_json.assert_called_once_with("/12/dropbox/folders/34/submissions/")