ontrack-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """ontrack-cli package."""
2
+
3
+ __version__ = "0.1.0"
ontrack_cli/auth.py ADDED
@@ -0,0 +1,200 @@
1
+ """Authentication helpers that mirror moodle-cli's browser-first flow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import glob
6
+ import logging
7
+ import os
8
+ import sys
9
+ from urllib.parse import urlparse
10
+
11
+ import requests
12
+
13
+ from ontrack_cli.constants import DEFAULT_TIMEOUT
14
+ from ontrack_cli.exceptions import AuthError
15
+ from ontrack_cli.models import CachedUser
16
+
17
+ log = logging.getLogger(__name__)
18
+
19
+
20
+ def _glob_paths(patterns: list[str]) -> list[str]:
21
+ """Expand filesystem glob patterns in a stable order."""
22
+ paths: list[str] = []
23
+ for pattern in patterns:
24
+ paths.extend(sorted(glob.glob(os.path.expanduser(pattern))))
25
+ return paths
26
+
27
+
28
+ def _chromium_cookie_files(browser: str) -> list[str]:
29
+ """Return candidate cookie DB files for Chromium-based browsers."""
30
+ platform_patterns = {
31
+ "darwin": {
32
+ "Chrome": [
33
+ "~/Library/Application Support/Google/Chrome/Default/Cookies",
34
+ "~/Library/Application Support/Google/Chrome/Guest Profile/Cookies",
35
+ "~/Library/Application Support/Google/Chrome/Profile */Cookies",
36
+ ],
37
+ "Brave": [
38
+ "~/Library/Application Support/BraveSoftware/Brave-Browser/Default/Cookies",
39
+ "~/Library/Application Support/BraveSoftware/Brave-Browser/Guest Profile/Cookies",
40
+ "~/Library/Application Support/BraveSoftware/Brave-Browser/Profile */Cookies",
41
+ ],
42
+ "Edge": [
43
+ "~/Library/Application Support/Microsoft Edge/Default/Cookies",
44
+ "~/Library/Application Support/Microsoft Edge/Guest Profile/Cookies",
45
+ "~/Library/Application Support/Microsoft Edge/Profile */Cookies",
46
+ ],
47
+ },
48
+ "linux": {
49
+ "Chrome": [
50
+ "~/.config/google-chrome/Default/Cookies",
51
+ "~/.config/google-chrome/Profile */Cookies",
52
+ "~/.var/app/com.google.Chrome/config/google-chrome/Default/Cookies",
53
+ "~/.var/app/com.google.Chrome/config/google-chrome/Profile */Cookies",
54
+ ],
55
+ "Brave": [
56
+ "~/.config/BraveSoftware/Brave-Browser/Default/Cookies",
57
+ "~/.config/BraveSoftware/Brave-Browser/Profile */Cookies",
58
+ "~/.var/app/com.brave.Browser/config/BraveSoftware/Brave-Browser/Default/Cookies",
59
+ "~/.var/app/com.brave.Browser/config/BraveSoftware/Brave-Browser/Profile */Cookies",
60
+ ],
61
+ "Edge": [
62
+ "~/.config/microsoft-edge/Default/Cookies",
63
+ "~/.config/microsoft-edge/Profile */Cookies",
64
+ ],
65
+ },
66
+ "win32": {
67
+ "Chrome": [
68
+ "~/AppData/Local/Google/Chrome/User Data/Default/Cookies",
69
+ "~/AppData/Local/Google/Chrome/User Data/Default/Network/Cookies",
70
+ "~/AppData/Local/Google/Chrome/User Data/Profile */Cookies",
71
+ "~/AppData/Local/Google/Chrome/User Data/Profile */Network/Cookies",
72
+ ],
73
+ "Brave": [
74
+ "~/AppData/Local/BraveSoftware/Brave-Browser/User Data/Default/Cookies",
75
+ "~/AppData/Local/BraveSoftware/Brave-Browser/User Data/Default/Network/Cookies",
76
+ "~/AppData/Local/BraveSoftware/Brave-Browser/User Data/Profile */Cookies",
77
+ "~/AppData/Local/BraveSoftware/Brave-Browser/User Data/Profile */Network/Cookies",
78
+ ],
79
+ "Edge": [
80
+ "~/AppData/Local/Microsoft/Edge/User Data/Default/Cookies",
81
+ "~/AppData/Local/Microsoft/Edge/User Data/Default/Network/Cookies",
82
+ "~/AppData/Local/Microsoft/Edge/User Data/Profile */Cookies",
83
+ "~/AppData/Local/Microsoft/Edge/User Data/Profile */Network/Cookies",
84
+ ],
85
+ },
86
+ }
87
+ return _glob_paths(platform_patterns.get(sys.platform, {}).get(browser, []))
88
+
89
+
90
+ def _iter_browser_cookie_sets(domain: str):
91
+ """Yield supported browser cookie jars profile by profile."""
92
+ try:
93
+ import browser_cookie3
94
+ except ImportError as exc:
95
+ raise AuthError(
96
+ "browser-cookie3 is not installed, so automatic browser auth is unavailable."
97
+ ) from exc
98
+
99
+ loaders = [
100
+ ("Chrome", browser_cookie3.chrome, _chromium_cookie_files("Chrome")),
101
+ ("Firefox", browser_cookie3.firefox, [None]),
102
+ ("Brave", browser_cookie3.brave, _chromium_cookie_files("Brave")),
103
+ ("Edge", browser_cookie3.edge, _chromium_cookie_files("Edge")),
104
+ ]
105
+
106
+ for name, loader, cookie_files in loaders:
107
+ attempts = cookie_files or [None]
108
+ for cookie_file in attempts:
109
+ try:
110
+ kwargs = {"domain_name": domain}
111
+ if cookie_file is not None:
112
+ kwargs["cookie_file"] = cookie_file
113
+ yield name, cookie_file or "default profile", loader(**kwargs)
114
+ except Exception as exc:
115
+ log.debug("Could not read cookies from %s (%s): %s", name, cookie_file or "default profile", exc)
116
+
117
+
118
+ def _cookie_value(cookie_jar, domain: str, name: str) -> str | None:
119
+ """Read a named cookie from the jar."""
120
+ for cookie in cookie_jar:
121
+ if cookie.name != name or not cookie.value:
122
+ continue
123
+ if domain in (cookie.domain or ""):
124
+ return cookie.value
125
+ return None
126
+
127
+
128
+ def _exchange_refresh_token(base_url: str, cookie_jar, domain: str) -> tuple[str, CachedUser] | None:
129
+ """Exchange browser refresh_token cookie for an auth token."""
130
+ username = _cookie_value(cookie_jar, domain, "username")
131
+ refresh_token = _cookie_value(cookie_jar, domain, "refresh_token")
132
+ if not username or not refresh_token:
133
+ return None
134
+
135
+ session = requests.Session()
136
+ for cookie in cookie_jar:
137
+ if domain not in (cookie.domain or ""):
138
+ continue
139
+ session.cookies.set(cookie.name, cookie.value, domain=cookie.domain, path=cookie.path)
140
+
141
+ response = session.post(
142
+ f"{base_url}/api/auth/access-token",
143
+ json={"delete_auth_token": False},
144
+ timeout=DEFAULT_TIMEOUT,
145
+ )
146
+ if response.status_code >= 400:
147
+ log.debug("Refresh-token exchange failed with %s", response.status_code)
148
+ return None
149
+
150
+ try:
151
+ payload = response.json()
152
+ except ValueError:
153
+ return None
154
+
155
+ if not isinstance(payload, dict):
156
+ return None
157
+
158
+ auth_token = payload.get("auth_token") or payload.get("access_token") or payload.get("token")
159
+ user_data = payload.get("user") or {}
160
+ if not auth_token or not isinstance(user_data, dict):
161
+ return None
162
+
163
+ user = CachedUser(
164
+ id=user_data.get("id"),
165
+ username=user_data.get("username") or username,
166
+ authentication_token=auth_token,
167
+ first_name=user_data.get("first_name") or user_data.get("firstName"),
168
+ last_name=user_data.get("last_name") or user_data.get("lastName"),
169
+ email=user_data.get("email"),
170
+ nickname=user_data.get("nickname"),
171
+ )
172
+ return auth_token, user
173
+
174
+
175
+ def _is_valid_token(base_url: str, username: str, auth_token: str) -> bool:
176
+ """Check whether the candidate token can access projects."""
177
+ response = requests.get(
178
+ f"{base_url}/api/projects",
179
+ headers={"Username": username, "Auth-Token": auth_token, "Accept": "application/json"},
180
+ params={"include_inactive": True},
181
+ timeout=DEFAULT_TIMEOUT,
182
+ )
183
+ return response.status_code == 200
184
+
185
+
186
+ def get_browser_auth(base_url: str) -> tuple[str, str, CachedUser] | None:
187
+ """Try to resolve OnTrack auth from supported browsers."""
188
+ domain = urlparse(base_url).hostname or ""
189
+ for browser_name, source, cookie_jar in _iter_browser_cookie_sets(domain):
190
+ exchanged = _exchange_refresh_token(base_url, cookie_jar, domain)
191
+ if exchanged is None:
192
+ continue
193
+ auth_token, user = exchanged
194
+ username = user.username or _cookie_value(cookie_jar, domain, "username")
195
+ if not username:
196
+ continue
197
+ if _is_valid_token(base_url, username, auth_token):
198
+ log.debug("Using browser auth from %s (%s)", browser_name, source)
199
+ return username, auth_token, user
200
+ return None
ontrack_cli/cli.py ADDED
@@ -0,0 +1,228 @@
1
+ """CLI entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ import click
8
+ from rich.console import Console
9
+
10
+ from ontrack_cli import __version__
11
+ from ontrack_cli.client import OnTrackClient
12
+ from ontrack_cli.config import load_auth_config
13
+ from ontrack_cli.exceptions import AuthError, ConfigError, OnTrackAPIError, OnTrackCLIError
14
+ from ontrack_cli.formatter import build_task_rows, print_project_detail, print_projects, print_roles, print_task_rows
15
+ from ontrack_cli.output import output_json, output_yaml
16
+
17
+ stdout_console = Console()
18
+ stderr_console = Console(stderr=True)
19
+
20
+
21
+ def _emit(data: object, *, as_json: bool, as_yaml: bool, printer) -> None:
22
+ """Emit structured output or rich tables."""
23
+ if as_json:
24
+ output_json(data)
25
+ elif as_yaml:
26
+ output_yaml(data)
27
+ else:
28
+ printer()
29
+
30
+
31
+ @click.group()
32
+ @click.version_option(version=__version__)
33
+ @click.pass_context
34
+ def cli(ctx: click.Context) -> None:
35
+ """Terminal-first CLI for OnTrack."""
36
+ ctx.ensure_object(dict)
37
+ ctx.obj["_auth"] = None
38
+ ctx.obj["_client"] = None
39
+
40
+ def get_auth():
41
+ if ctx.obj["_auth"] is None:
42
+ ctx.obj["_auth"] = load_auth_config()
43
+ return ctx.obj["_auth"]
44
+
45
+ def get_client() -> OnTrackClient:
46
+ if ctx.obj["_client"] is None:
47
+ ctx.obj["_client"] = OnTrackClient(get_auth())
48
+ return ctx.obj["_client"]
49
+
50
+ ctx.obj["get_auth"] = get_auth
51
+ ctx.obj["get_client"] = get_client
52
+
53
+
54
+ @cli.group()
55
+ def auth() -> None:
56
+ """Authentication helpers."""
57
+
58
+
59
+ @auth.command("check")
60
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
61
+ @click.option("--yaml", "as_yaml", is_flag=True, help="Output as YAML.")
62
+ @click.pass_context
63
+ def auth_check(ctx: click.Context, as_json: bool, as_yaml: bool) -> None:
64
+ """Validate current credentials."""
65
+ client = ctx.obj["get_client"]()
66
+ info = client.check_access()
67
+
68
+ _emit(
69
+ info,
70
+ as_json=as_json,
71
+ as_yaml=as_yaml,
72
+ printer=lambda: stdout_console.print(
73
+ "\n".join(
74
+ [
75
+ f"Base URL: {info['base_url']}",
76
+ f"Username: {info['username']}",
77
+ f"Auth method: {info['auth_method'] or 'unknown'}",
78
+ f"Projects: {info['projects']}",
79
+ f"Unit roles: {info['unit_roles']}",
80
+ ]
81
+ )
82
+ ),
83
+ )
84
+
85
+
86
+ @cli.command()
87
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
88
+ @click.option("--yaml", "as_yaml", is_flag=True, help="Output as YAML.")
89
+ @click.pass_context
90
+ def user(ctx: click.Context, as_json: bool, as_yaml: bool) -> None:
91
+ """Show the resolved signed-in user."""
92
+ auth = ctx.obj["get_auth"]()
93
+ client = ctx.obj["get_client"]()
94
+ info = client.check_access()
95
+ payload = (
96
+ {
97
+ "id": auth.cached_user.id,
98
+ "username": auth.cached_user.username,
99
+ "first_name": auth.cached_user.first_name,
100
+ "last_name": auth.cached_user.last_name,
101
+ "email": auth.cached_user.email,
102
+ "nickname": auth.cached_user.nickname,
103
+ }
104
+ if auth.cached_user
105
+ else {"username": auth.username}
106
+ )
107
+ payload["base_url"] = auth.base_url
108
+ payload["auth_method"] = info.get("auth_method")
109
+
110
+ _emit(
111
+ payload,
112
+ as_json=as_json,
113
+ as_yaml=as_yaml,
114
+ printer=lambda: stdout_console.print(
115
+ "\n".join(
116
+ [
117
+ f"User: {payload.get('username')}",
118
+ f"Name: {' '.join(part for part in [payload.get('first_name'), payload.get('last_name')] if part) or payload.get('nickname') or '-'}",
119
+ f"Email: {payload.get('email') or '-'}",
120
+ f"Base URL: {payload['base_url']}",
121
+ f"Auth method: {payload.get('auth_method') or 'unknown'}",
122
+ ]
123
+ )
124
+ ),
125
+ )
126
+
127
+
128
+ @cli.command()
129
+ @click.option("--include-inactive", is_flag=True, help="Include inactive projects.")
130
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
131
+ @click.option("--yaml", "as_yaml", is_flag=True, help="Output as YAML.")
132
+ @click.pass_context
133
+ def projects(ctx: click.Context, include_inactive: bool, as_json: bool, as_yaml: bool) -> None:
134
+ """List the current user's projects."""
135
+ client = ctx.obj["get_client"]()
136
+ items = client.get_projects(include_inactive=include_inactive)
137
+ payload = [item.to_dict() for item in items]
138
+
139
+ _emit(payload, as_json=as_json, as_yaml=as_yaml, printer=lambda: print_projects(items))
140
+
141
+
142
+ @cli.command()
143
+ @click.argument("project_id", type=int)
144
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
145
+ @click.option("--yaml", "as_yaml", is_flag=True, help="Output as YAML.")
146
+ @click.pass_context
147
+ def project(ctx: click.Context, project_id: int, as_json: bool, as_yaml: bool) -> None:
148
+ """Show a project with merged task definitions."""
149
+ client = ctx.obj["get_client"]()
150
+ item = client.get_project(project_id)
151
+ unit = client.get_unit(item.unit.id)
152
+ task_rows = build_task_rows(item, unit)
153
+ payload = {
154
+ "project": item.to_dict(),
155
+ "unit": unit.to_dict(),
156
+ "tasks": task_rows,
157
+ }
158
+
159
+ _emit(payload, as_json=as_json, as_yaml=as_yaml, printer=lambda: print_project_detail(item, unit))
160
+
161
+
162
+ @cli.command()
163
+ @click.argument("project_id", type=int)
164
+ @click.option("--status", "statuses", multiple=True, help="Filter by raw status key.")
165
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
166
+ @click.option("--yaml", "as_yaml", is_flag=True, help="Output as YAML.")
167
+ @click.pass_context
168
+ def tasks(
169
+ ctx: click.Context,
170
+ project_id: int,
171
+ statuses: tuple[str, ...],
172
+ as_json: bool,
173
+ as_yaml: bool,
174
+ ) -> None:
175
+ """Show task rows for a project."""
176
+ client = ctx.obj["get_client"]()
177
+ item = client.get_project(project_id)
178
+ unit = client.get_unit(item.unit.id)
179
+ rows = build_task_rows(item, unit)
180
+
181
+ if statuses:
182
+ allowed = set(statuses)
183
+ rows = [row for row in rows if row["status"] in allowed]
184
+
185
+ _emit(rows, as_json=as_json, as_yaml=as_yaml, printer=lambda: print_task_rows(rows))
186
+
187
+
188
+ @cli.command()
189
+ @click.option("--all", "show_all", is_flag=True, help="Include inactive roles.")
190
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
191
+ @click.option("--yaml", "as_yaml", is_flag=True, help="Output as YAML.")
192
+ @click.pass_context
193
+ def roles(ctx: click.Context, show_all: bool, as_json: bool, as_yaml: bool) -> None:
194
+ """List unit roles for the current user."""
195
+ client = ctx.obj["get_client"]()
196
+ items = client.get_unit_roles(active_only=not show_all)
197
+ payload = [item.to_dict() for item in items]
198
+
199
+ _emit(payload, as_json=as_json, as_yaml=as_yaml, printer=lambda: print_roles(items))
200
+
201
+
202
+ def main() -> None:
203
+ """CLI entry point with consistent error handling."""
204
+ try:
205
+ cli(standalone_mode=False)
206
+ except click.exceptions.Abort:
207
+ sys.exit(130)
208
+ except click.exceptions.Exit as exc:
209
+ sys.exit(exc.exit_code)
210
+ except click.ClickException as exc:
211
+ exc.show()
212
+ sys.exit(exc.exit_code)
213
+ except ConfigError as exc:
214
+ stderr_console.print(f"[bold red]Config error:[/] {exc}")
215
+ sys.exit(1)
216
+ except AuthError as exc:
217
+ stderr_console.print(f"[bold red]Auth error:[/] {exc}")
218
+ sys.exit(1)
219
+ except OnTrackAPIError as exc:
220
+ stderr_console.print(f"[bold red]API error:[/] {exc}")
221
+ sys.exit(1)
222
+ except OnTrackCLIError as exc:
223
+ stderr_console.print(f"[bold red]Error:[/] {exc}")
224
+ sys.exit(1)
225
+
226
+
227
+ if __name__ == "__main__":
228
+ main()
ontrack_cli/client.py ADDED
@@ -0,0 +1,226 @@
1
+ """Minimal client for the Doubtfire API used by OnTrack."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import requests
8
+
9
+ from ontrack_cli.constants import DEFAULT_TIMEOUT
10
+ from ontrack_cli.exceptions import AuthError, OnTrackAPIError
11
+ from ontrack_cli.models import (
12
+ AuthConfig,
13
+ CachedUser,
14
+ ProjectDetail,
15
+ ProjectSummary,
16
+ Task,
17
+ TaskDefinition,
18
+ UnitDetail,
19
+ UnitRole,
20
+ UnitSummary,
21
+ )
22
+
23
+
24
+ def _unit_from_payload(data: dict[str, Any]) -> UnitSummary:
25
+ """Map a minimal unit payload."""
26
+ return UnitSummary(
27
+ id=data["id"],
28
+ code=data["code"],
29
+ name=data["name"],
30
+ my_role=data.get("my_role"),
31
+ start_date=data.get("start_date"),
32
+ end_date=data.get("end_date"),
33
+ active=data.get("active"),
34
+ )
35
+
36
+
37
+ def _task_from_payload(data: dict[str, Any]) -> Task:
38
+ """Map a task payload."""
39
+ return Task(
40
+ id=data["id"],
41
+ task_definition_id=data["task_definition_id"],
42
+ status=data["status"],
43
+ due_date=data.get("due_date"),
44
+ submission_date=data.get("submission_date"),
45
+ completion_date=data.get("completion_date"),
46
+ extensions=data.get("extensions"),
47
+ times_assessed=data.get("times_assessed"),
48
+ grade=data.get("grade"),
49
+ quality_pts=data.get("quality_pts"),
50
+ include_in_portfolio=data.get("include_in_portfolio"),
51
+ )
52
+
53
+
54
+ def _task_definition_from_payload(data: dict[str, Any]) -> TaskDefinition:
55
+ """Map a task definition payload."""
56
+ return TaskDefinition(
57
+ id=data["id"],
58
+ abbreviation=data["abbreviation"],
59
+ name=data["name"],
60
+ description=data.get("description"),
61
+ target_grade=data.get("target_grade"),
62
+ start_date=data.get("start_date"),
63
+ target_date=data.get("target_date"),
64
+ due_date=data.get("due_date"),
65
+ is_graded=data.get("is_graded"),
66
+ max_quality_pts=data.get("max_quality_pts"),
67
+ )
68
+
69
+
70
+ class OnTrackClient:
71
+ """Small HTTP client for the OnTrack API."""
72
+
73
+ def __init__(self, auth: AuthConfig) -> None:
74
+ self.auth = auth
75
+ self.session = requests.Session()
76
+ self.session.headers.update(
77
+ {
78
+ "Accept": "application/json",
79
+ "Username": auth.username,
80
+ "Auth-Token": auth.auth_token,
81
+ }
82
+ )
83
+
84
+ def _request(
85
+ self,
86
+ method: str,
87
+ path: str,
88
+ *,
89
+ params: dict[str, Any] | None = None,
90
+ json_body: dict[str, Any] | None = None,
91
+ ) -> Any:
92
+ """Perform a request and return parsed JSON."""
93
+ url = f"{self.auth.base_url}{path}"
94
+ response = self.session.request(
95
+ method,
96
+ url,
97
+ params=params,
98
+ json=json_body,
99
+ timeout=DEFAULT_TIMEOUT,
100
+ )
101
+
102
+ if response.status_code in (401, 419):
103
+ try:
104
+ payload = response.json()
105
+ message = payload.get("error") or payload.get("message") or response.text
106
+ except ValueError:
107
+ message = response.text or "Authentication failed."
108
+ raise AuthError(message.strip() or "Authentication failed.")
109
+
110
+ if response.status_code >= 400:
111
+ try:
112
+ payload = response.json()
113
+ message = payload.get("error") or payload.get("message") or response.text
114
+ except ValueError:
115
+ message = response.text or f"HTTP {response.status_code}"
116
+ raise OnTrackAPIError(message.strip() or f"HTTP {response.status_code}", response.status_code)
117
+
118
+ if response.status_code == 204 or not response.content:
119
+ return None
120
+
121
+ try:
122
+ return response.json()
123
+ except ValueError as exc:
124
+ raise OnTrackAPIError("The API returned invalid JSON.", response.status_code) from exc
125
+
126
+ def get_auth_method(self) -> dict[str, Any]:
127
+ """Read the public auth method configuration."""
128
+ return self._request("GET", "/api/auth/method")
129
+
130
+ def get_projects(self, include_inactive: bool = False) -> list[ProjectSummary]:
131
+ """Fetch the current user's projects."""
132
+ data = self._request("GET", "/api/projects", params={"include_inactive": include_inactive})
133
+ if not isinstance(data, list):
134
+ return []
135
+ return [
136
+ ProjectSummary(
137
+ id=item["id"],
138
+ unit=_unit_from_payload(item["unit"]),
139
+ target_grade=item.get("target_grade"),
140
+ portfolio_available=item.get("portfolio_available"),
141
+ user_id=item.get("user_id"),
142
+ unit_id=item.get("unit_id"),
143
+ )
144
+ for item in data
145
+ ]
146
+
147
+ def get_project(self, project_id: int) -> ProjectDetail:
148
+ """Fetch a single project with task status data."""
149
+ data = self._request("GET", f"/api/projects/{project_id}")
150
+ if not isinstance(data, dict):
151
+ raise OnTrackAPIError("Project response was not an object.")
152
+ tasks = [_task_from_payload(item) for item in data.get("tasks", [])]
153
+ return ProjectDetail(
154
+ id=data["id"],
155
+ unit=_unit_from_payload(data["unit"]),
156
+ target_grade=data.get("target_grade"),
157
+ submitted_grade=data.get("submitted_grade"),
158
+ compile_portfolio=data.get("compile_portfolio"),
159
+ portfolio_available=data.get("portfolio_available"),
160
+ uses_draft_learning_summary=data.get("uses_draft_learning_summary"),
161
+ tasks=tasks,
162
+ )
163
+
164
+ def get_unit(self, unit_id: int) -> UnitDetail:
165
+ """Fetch a unit to resolve task definitions."""
166
+ data = self._request("GET", f"/api/units/{unit_id}")
167
+ if not isinstance(data, dict):
168
+ raise OnTrackAPIError("Unit response was not an object.")
169
+ summary = _unit_from_payload(data)
170
+ task_definitions = [_task_definition_from_payload(item) for item in data.get("task_definitions", [])]
171
+ return UnitDetail(
172
+ summary=summary,
173
+ description=data.get("description"),
174
+ task_definitions=task_definitions,
175
+ )
176
+
177
+ def get_unit_roles(self, active_only: bool = True) -> list[UnitRole]:
178
+ """Fetch teaching roles for the current user."""
179
+ data = self._request("GET", "/api/unit_roles", params={"active_only": active_only})
180
+ if not isinstance(data, list):
181
+ return []
182
+ roles: list[UnitRole] = []
183
+ for item in data:
184
+ user_data = item.get("user") or {}
185
+ user = CachedUser(
186
+ id=user_data.get("id"),
187
+ username=user_data.get("username"),
188
+ first_name=user_data.get("first_name"),
189
+ last_name=user_data.get("last_name"),
190
+ email=user_data.get("email"),
191
+ nickname=user_data.get("nickname"),
192
+ )
193
+ roles.append(
194
+ UnitRole(
195
+ id=item["id"],
196
+ role=item["role"],
197
+ unit=_unit_from_payload(item["unit"]),
198
+ user=user,
199
+ )
200
+ )
201
+ return roles
202
+
203
+ def check_access(self) -> dict[str, Any]:
204
+ """Run a light auth check across the main student and staff entry points."""
205
+ auth_method = self.get_auth_method()
206
+ projects = self.get_projects(include_inactive=True)
207
+ roles = self.get_unit_roles(active_only=False)
208
+ return {
209
+ "base_url": self.auth.base_url,
210
+ "username": self.auth.username,
211
+ "auth_method": auth_method.get("method"),
212
+ "projects": len(projects),
213
+ "unit_roles": len(roles),
214
+ "cached_user": (
215
+ {
216
+ "id": self.auth.cached_user.id,
217
+ "username": self.auth.cached_user.username,
218
+ "first_name": self.auth.cached_user.first_name,
219
+ "last_name": self.auth.cached_user.last_name,
220
+ "email": self.auth.cached_user.email,
221
+ "nickname": self.auth.cached_user.nickname,
222
+ }
223
+ if self.auth.cached_user
224
+ else None
225
+ ),
226
+ }