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.
- ontrack_cli/__init__.py +3 -0
- ontrack_cli/auth.py +200 -0
- ontrack_cli/cli.py +228 -0
- ontrack_cli/client.py +226 -0
- ontrack_cli/config.py +254 -0
- ontrack_cli/constants.py +69 -0
- ontrack_cli/exceptions.py +21 -0
- ontrack_cli/formatter.py +153 -0
- ontrack_cli/models.py +214 -0
- ontrack_cli/output.py +19 -0
- ontrack_cli-0.1.0.dist-info/METADATA +183 -0
- ontrack_cli-0.1.0.dist-info/RECORD +15 -0
- ontrack_cli-0.1.0.dist-info/WHEEL +4 -0
- ontrack_cli-0.1.0.dist-info/entry_points.txt +2 -0
- ontrack_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
ontrack_cli/__init__.py
ADDED
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
|
+
}
|