Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .secrets.baseline

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 39 additions & 1 deletion lighthouse_cli/assessment_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from __future__ import annotations

import math
from datetime import datetime, timezone
from typing import Any

import requests
Expand All @@ -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."""

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Usable message depends on every caller special-casing this type

Only _run in assessment_commands.py currently maps AssignmentClosedError to str(exc); any future caller that routes it through format_user_error instead degrades to "Permission denied (HTTP 403)." (the status pattern in the message still matches), silently losing the closure explanation and the lighthouse grades hint. Safe today since submissions(mine=True) has a single caller, but worth a note so the coupling to _run is discoverable.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.



def positive_id(value: object) -> int:
return _require_positive_endpoint_id(value, "identifier")

Expand Down Expand Up @@ -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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Broad except Exception hides non-transport bugs during the probe

The fallback intent (any unreadable folder keeps the original 403 message) is sound, but this also swallows programming errors in the read path - e.g. a future refactor regression raising TypeError/AttributeError would silently surface as the generic "Permission denied (HTTP 403)." instead of failing loudly in tests. Consider narrowing to the failure types this probe can actually hit, e.g. except (requests.RequestException, NetworkError, SessionExpiredError):.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

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"}:
Expand Down
8 changes: 7 additions & 1 deletion lighthouse_cli/assessment_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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})
Expand Down
76 changes: 76 additions & 0 deletions tests/test_assessment_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}])
Expand Down
Loading