deeptutor-server 1.7.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.
- deeptutor/api/__init__.py +0 -0
- deeptutor/api/contracts/__init__.py +21 -0
- deeptutor/api/contracts/export.py +110 -0
- deeptutor/api/contracts/turn_protocol.py +313 -0
- deeptutor/api/main.py +547 -0
- deeptutor/api/module_routes.py +42 -0
- deeptutor/api/router_index.json +5752 -0
- deeptutor/api/router_loader.py +139 -0
- deeptutor/api/routers/__init__.py +1 -0
- deeptutor/api/routers/agent_config.py +59 -0
- deeptutor/api/routers/attachments.py +79 -0
- deeptutor/api/routers/auth.py +1937 -0
- deeptutor/api/routers/capabilities.py +44 -0
- deeptutor/api/routers/capabilities_settings.py +38 -0
- deeptutor/api/routers/dashboard.py +292 -0
- deeptutor/api/routers/file_library.py +169 -0
- deeptutor/api/routers/file_preview.py +205 -0
- deeptutor/api/routers/imports.py +146 -0
- deeptutor/api/routers/learning_journal.py +18 -0
- deeptutor/api/routers/mcp_settings.py +133 -0
- deeptutor/api/routers/modules.py +12 -0
- deeptutor/api/routers/multi_user.py +943 -0
- deeptutor/api/routers/outputs.py +97 -0
- deeptutor/api/routers/personas.py +154 -0
- deeptutor/api/routers/plugins.py +416 -0
- deeptutor/api/routers/quiz_judge.py +464 -0
- deeptutor/api/routers/sessions.py +801 -0
- deeptutor/api/routers/settings.py +2503 -0
- deeptutor/api/routers/skills.py +315 -0
- deeptutor/api/routers/space_cli_apps.py +222 -0
- deeptutor/api/routers/space_mcp.py +460 -0
- deeptutor/api/routers/subagents.py +306 -0
- deeptutor/api/routers/system.py +633 -0
- deeptutor/api/routers/task_board.py +139 -0
- deeptutor/api/routers/tools.py +268 -0
- deeptutor/api/routers/unified_ws.py +385 -0
- deeptutor/api/routers/workspace.py +371 -0
- deeptutor/api/run_server.py +99 -0
- deeptutor/api/security_guard.py +91 -0
- deeptutor/api/utils/http_headers.py +27 -0
- deeptutor/api/utils/progress_broadcaster.py +73 -0
- deeptutor/api/utils/task_id_manager.py +120 -0
- deeptutor/api/utils/task_log_stream.py +362 -0
- deeptutor/api/utils/tool_options.py +133 -0
- deeptutor_server-1.7.0.dist-info/METADATA +14 -0
- deeptutor_server-1.7.0.dist-info/RECORD +48 -0
- deeptutor_server-1.7.0.dist-info/WHEEL +5 -0
- deeptutor_server-1.7.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,943 @@
|
|
|
1
|
+
"""Admin APIs for the optional multi-user layer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from copy import deepcopy
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import shutil
|
|
10
|
+
from typing import Any
|
|
11
|
+
import uuid
|
|
12
|
+
|
|
13
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
14
|
+
from pydantic import BaseModel, Field, StrictBool, field_validator
|
|
15
|
+
|
|
16
|
+
from deeptutor.api.routers.auth import require_admin, require_auth
|
|
17
|
+
from deeptutor.modules.catalog import module_enabled, require_module
|
|
18
|
+
from deeptutor.multi_user.audit import log_admin_action, log_guardian_action
|
|
19
|
+
from deeptutor.multi_user.book_permission import (
|
|
20
|
+
BookDefaultLevel,
|
|
21
|
+
BookPermission,
|
|
22
|
+
BookPermissionLevel,
|
|
23
|
+
)
|
|
24
|
+
from deeptutor.multi_user.context import get_current_user
|
|
25
|
+
from deeptutor.multi_user.device_credentials import revoke_device_credentials_for_user
|
|
26
|
+
from deeptutor.multi_user.grants import (
|
|
27
|
+
LEARNING_AGE_BANDS,
|
|
28
|
+
LEARNING_SURFACES,
|
|
29
|
+
learner_grant,
|
|
30
|
+
load_grant,
|
|
31
|
+
normalize_grant,
|
|
32
|
+
restore_grant_if_unchanged,
|
|
33
|
+
save_grant,
|
|
34
|
+
save_grant_with_receipt,
|
|
35
|
+
validate_grant,
|
|
36
|
+
)
|
|
37
|
+
from deeptutor.multi_user.guardians import (
|
|
38
|
+
GUARDIAN_PERMISSIONS,
|
|
39
|
+
authorize_guardian,
|
|
40
|
+
guardian_can_access,
|
|
41
|
+
list_relationships,
|
|
42
|
+
relationship_by_id,
|
|
43
|
+
revoke_guardian,
|
|
44
|
+
)
|
|
45
|
+
from deeptutor.multi_user.identity import (
|
|
46
|
+
get_user_by_id,
|
|
47
|
+
list_user_info,
|
|
48
|
+
set_book_permission,
|
|
49
|
+
set_password,
|
|
50
|
+
set_preset,
|
|
51
|
+
)
|
|
52
|
+
from deeptutor.multi_user.knowledge_access import admin_kb_base_dir
|
|
53
|
+
from deeptutor.multi_user.model_access import is_owner_bound
|
|
54
|
+
from deeptutor.multi_user.paths import (
|
|
55
|
+
get_admin_path_service,
|
|
56
|
+
get_path_service_for_scope,
|
|
57
|
+
scope_for_user,
|
|
58
|
+
)
|
|
59
|
+
from deeptutor.services.auth import POCKETBASE_ENABLED, hash_password
|
|
60
|
+
from deeptutor.services.config.model_catalog import ModelCatalogService
|
|
61
|
+
|
|
62
|
+
router = APIRouter()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class GrantPayload(BaseModel):
|
|
66
|
+
grant: dict[str, Any]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class BookPermissionPayload(BaseModel):
|
|
70
|
+
create: StrictBool = True
|
|
71
|
+
default: BookDefaultLevel = "none"
|
|
72
|
+
books: dict[str, BookPermissionLevel] = Field(default_factory=dict)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class GuardianAuthorizationPayload(BaseModel):
|
|
76
|
+
guardian_user_id: str = Field(min_length=1, max_length=64)
|
|
77
|
+
learner_user_id: str = Field(min_length=1, max_length=64)
|
|
78
|
+
permissions: list[str] = Field(default_factory=lambda: sorted(GUARDIAN_PERMISSIONS))
|
|
79
|
+
|
|
80
|
+
@field_validator("permissions")
|
|
81
|
+
@classmethod
|
|
82
|
+
def permissions_valid(cls, value: list[str]) -> list[str]:
|
|
83
|
+
permissions = sorted(set(value))
|
|
84
|
+
if not permissions or not set(permissions).issubset(GUARDIAN_PERMISSIONS):
|
|
85
|
+
raise ValueError("Unknown guardian permission")
|
|
86
|
+
return permissions
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class GuardianMaterialsPayload(BaseModel):
|
|
90
|
+
book_ids: list[str]
|
|
91
|
+
|
|
92
|
+
@field_validator("book_ids")
|
|
93
|
+
@classmethod
|
|
94
|
+
def book_ids_valid(cls, value: list[str]) -> list[str]:
|
|
95
|
+
book_ids: list[str] = []
|
|
96
|
+
for raw_id in value:
|
|
97
|
+
book_id = raw_id.strip()
|
|
98
|
+
if not book_id:
|
|
99
|
+
raise ValueError("Book ids cannot be empty")
|
|
100
|
+
if book_id not in book_ids:
|
|
101
|
+
book_ids.append(book_id)
|
|
102
|
+
return book_ids
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class GuardianRestrictionsPayload(BaseModel):
|
|
106
|
+
age_band: str
|
|
107
|
+
allow_upload: StrictBool
|
|
108
|
+
allowed_surfaces: list[str]
|
|
109
|
+
extensions: list[str]
|
|
110
|
+
|
|
111
|
+
@field_validator("age_band")
|
|
112
|
+
@classmethod
|
|
113
|
+
def age_band_valid(cls, value: str) -> str:
|
|
114
|
+
if value not in LEARNING_AGE_BANDS:
|
|
115
|
+
raise ValueError("Unknown learning age band")
|
|
116
|
+
return value
|
|
117
|
+
|
|
118
|
+
@field_validator("allowed_surfaces")
|
|
119
|
+
@classmethod
|
|
120
|
+
def surfaces_valid(cls, value: list[str]) -> list[str]:
|
|
121
|
+
surfaces = list(dict.fromkeys(value))
|
|
122
|
+
if not surfaces or not set(surfaces).issubset(LEARNING_SURFACES):
|
|
123
|
+
raise ValueError("Unknown or empty learning surface")
|
|
124
|
+
return surfaces
|
|
125
|
+
|
|
126
|
+
@field_validator("extensions")
|
|
127
|
+
@classmethod
|
|
128
|
+
def extensions_valid(cls, value: list[str]) -> list[str]:
|
|
129
|
+
extensions: list[str] = []
|
|
130
|
+
for raw_id in value:
|
|
131
|
+
extension_id = raw_id.strip()
|
|
132
|
+
if not extension_id:
|
|
133
|
+
raise ValueError("Reading extension ids cannot be empty")
|
|
134
|
+
if extension_id not in extensions:
|
|
135
|
+
extensions.append(extension_id)
|
|
136
|
+
return extensions
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class GuardianCredentialResetPayload(BaseModel):
|
|
140
|
+
new_password: str = Field(min_length=8, max_length=128)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class SkillInstallPayload(BaseModel):
|
|
144
|
+
ref: str
|
|
145
|
+
name: str | None = None
|
|
146
|
+
force: bool = False
|
|
147
|
+
allow_unverified: bool = False
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _admin_catalog_summary() -> dict[str, list[dict[str, Any]]]:
|
|
151
|
+
catalog = ModelCatalogService(
|
|
152
|
+
path=get_admin_path_service().get_settings_file("model_catalog")
|
|
153
|
+
).load()
|
|
154
|
+
out: dict[str, list[dict[str, Any]]] = {"llm": []}
|
|
155
|
+
for service, state in (catalog.get("services") or {}).items():
|
|
156
|
+
if service not in out:
|
|
157
|
+
continue
|
|
158
|
+
for profile in state.get("profiles", []) or []:
|
|
159
|
+
if is_owner_bound(profile):
|
|
160
|
+
# Bound to one person's OAuth identity, so it is not assignable.
|
|
161
|
+
# Listing it here would offer admins a grant the server drops.
|
|
162
|
+
continue
|
|
163
|
+
profile_id = str(profile.get("id") or "")
|
|
164
|
+
models = []
|
|
165
|
+
for model in profile.get("models", []) or []:
|
|
166
|
+
from deeptutor.services.config.provider_links import resolve_profile_provider
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
effective = resolve_profile_provider(catalog, service, profile, model)
|
|
170
|
+
except ValueError:
|
|
171
|
+
continue
|
|
172
|
+
if is_owner_bound(effective):
|
|
173
|
+
continue
|
|
174
|
+
models.append(
|
|
175
|
+
{
|
|
176
|
+
"model_id": model.get("id", ""),
|
|
177
|
+
"name": model.get("name") or model.get("model") or model.get("id"),
|
|
178
|
+
"model": model.get("model", ""),
|
|
179
|
+
}
|
|
180
|
+
)
|
|
181
|
+
if profile.get("models") and not models:
|
|
182
|
+
continue
|
|
183
|
+
out[service].append(
|
|
184
|
+
{
|
|
185
|
+
"profile_id": profile_id,
|
|
186
|
+
"name": profile.get("name") or profile_id,
|
|
187
|
+
"models": models,
|
|
188
|
+
}
|
|
189
|
+
)
|
|
190
|
+
return out
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _admin_kb_summary() -> list[dict[str, Any]]:
|
|
194
|
+
if not module_enabled("knowledge"):
|
|
195
|
+
return []
|
|
196
|
+
from deeptutor.knowledge.manager import KnowledgeBaseManager
|
|
197
|
+
|
|
198
|
+
manager = KnowledgeBaseManager(base_dir=str(admin_kb_base_dir()))
|
|
199
|
+
return [
|
|
200
|
+
{
|
|
201
|
+
"resource_id": f"admin:kb:{name}",
|
|
202
|
+
"name": name,
|
|
203
|
+
"source": "admin",
|
|
204
|
+
}
|
|
205
|
+
for name in manager.list_knowledge_bases()
|
|
206
|
+
]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _admin_skill_summary() -> list[dict[str, Any]]:
|
|
210
|
+
from deeptutor.services.skill.service import get_admin_skill_service
|
|
211
|
+
|
|
212
|
+
service = get_admin_skill_service()
|
|
213
|
+
return [item.to_dict() for item in service.list_skills()]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _admin_partner_summary() -> list[dict[str, Any]]:
|
|
217
|
+
"""The partners an admin can hand to someone else.
|
|
218
|
+
|
|
219
|
+
Admin-managed partners only — the ones with no owner, or that the admin
|
|
220
|
+
created. A partner someone built for themselves is theirs to share or not;
|
|
221
|
+
listing it here would let an admin lend out a private companion (and its
|
|
222
|
+
soul, which people write personally) by a single click. Identity only: no
|
|
223
|
+
channel wiring or model selection leaks into the assignable summary.
|
|
224
|
+
"""
|
|
225
|
+
if not module_enabled("partners"):
|
|
226
|
+
return []
|
|
227
|
+
from deeptutor.services.partners import get_partner_manager
|
|
228
|
+
|
|
229
|
+
admin_id = get_current_user().id
|
|
230
|
+
return [
|
|
231
|
+
{
|
|
232
|
+
"partner_id": str(item.get("partner_id") or ""),
|
|
233
|
+
"name": item.get("name") or item.get("partner_id") or "",
|
|
234
|
+
"description": item.get("description") or "",
|
|
235
|
+
"emoji": item.get("emoji") or "",
|
|
236
|
+
}
|
|
237
|
+
for item in get_partner_manager().list_partners()
|
|
238
|
+
if str(item.get("owner_id") or "") in ("", admin_id)
|
|
239
|
+
]
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _reading_root(service: Any) -> Path:
|
|
243
|
+
return service.get_workspace_feature_dir("reading")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _admin_reading_summary() -> list[dict[str, Any]]:
|
|
247
|
+
if not module_enabled("reading"):
|
|
248
|
+
return []
|
|
249
|
+
from deeptutor.reading import ReadingStore
|
|
250
|
+
|
|
251
|
+
store = ReadingStore(_reading_root(get_admin_path_service()))
|
|
252
|
+
return [manifest.to_dict() for manifest in store.list_materials()]
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _stage_assigned_materials(user_id: str, grant: dict[str, Any]) -> None:
|
|
256
|
+
"""Copy newly assigned admin books without touching learner-owned state."""
|
|
257
|
+
policy = grant.get("learning_policy")
|
|
258
|
+
reading = policy.get("reading") if isinstance(policy, dict) else None
|
|
259
|
+
if not isinstance(reading, dict):
|
|
260
|
+
return
|
|
261
|
+
material_ids = set(reading.get("material_ids") or [])
|
|
262
|
+
material_ids.discard("*")
|
|
263
|
+
if not material_ids:
|
|
264
|
+
return
|
|
265
|
+
|
|
266
|
+
require_module("reading")
|
|
267
|
+
from deeptutor.reading import ReadingStore
|
|
268
|
+
|
|
269
|
+
admin_root = _reading_root(get_admin_path_service())
|
|
270
|
+
admin_store = ReadingStore(admin_root)
|
|
271
|
+
user_service = get_path_service_for_scope(scope_for_user(user_id, is_admin=False))
|
|
272
|
+
target_root = _reading_root(user_service)
|
|
273
|
+
target_root.mkdir(parents=True, exist_ok=True)
|
|
274
|
+
for material_id in sorted(material_ids):
|
|
275
|
+
try:
|
|
276
|
+
admin_store.manifest(material_id)
|
|
277
|
+
except Exception as exc:
|
|
278
|
+
raise ValueError(f"Unknown admin reading material: {material_id}") from exc
|
|
279
|
+
target = target_root / material_id
|
|
280
|
+
if target.exists():
|
|
281
|
+
continue
|
|
282
|
+
stage = target_root / f".{material_id}.{uuid.uuid4().hex[:8]}.staging"
|
|
283
|
+
try:
|
|
284
|
+
shutil.copytree(admin_root / material_id, stage)
|
|
285
|
+
os.replace(stage, target)
|
|
286
|
+
finally:
|
|
287
|
+
shutil.rmtree(stage, ignore_errors=True)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _validate_reading_policy(grant: dict[str, Any]) -> None:
|
|
291
|
+
policy = grant.get("learning_policy")
|
|
292
|
+
reading = policy.get("reading") if isinstance(policy, dict) else None
|
|
293
|
+
if not isinstance(reading, dict):
|
|
294
|
+
return
|
|
295
|
+
allowed_extensions = {extension.manifest.id for extension in _reading_extensions()}
|
|
296
|
+
unknown = sorted(set(reading.get("extensions") or []) - allowed_extensions)
|
|
297
|
+
if unknown:
|
|
298
|
+
raise ValueError(f"Unknown reading extensions: {', '.join(unknown)}")
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _require_assignable_user(user_id: str) -> tuple[str, dict[str, Any]]:
|
|
302
|
+
user_record = get_user_by_id(user_id)
|
|
303
|
+
if user_record is None:
|
|
304
|
+
raise HTTPException(status_code=404, detail="User not found")
|
|
305
|
+
username, record = user_record
|
|
306
|
+
if str(record.get("role") or "user") == "admin":
|
|
307
|
+
raise HTTPException(
|
|
308
|
+
status_code=403,
|
|
309
|
+
detail="Admin users use the main workspace and cannot receive assignments.",
|
|
310
|
+
)
|
|
311
|
+
return username, record
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _users_by_id() -> dict[str, dict[str, Any]]:
|
|
315
|
+
return {str(user.get("id") or ""): user for user in list_user_info()}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _relationship_view(
|
|
319
|
+
relationship: dict[str, Any], users: dict[str, dict[str, Any]]
|
|
320
|
+
) -> dict[str, Any]:
|
|
321
|
+
guardian = users.get(relationship["guardian_user_id"], {})
|
|
322
|
+
learner = users.get(relationship["learner_user_id"], {})
|
|
323
|
+
return {
|
|
324
|
+
**relationship,
|
|
325
|
+
"guardian_username": str(guardian.get("username") or ""),
|
|
326
|
+
"learner_username": str(learner.get("username") or ""),
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _require_guardian_access(
|
|
331
|
+
current: object, learner_user_id: str, permission: str
|
|
332
|
+
) -> tuple[str, dict[str, Any], str, bool]:
|
|
333
|
+
actor_user_id = str(getattr(current, "user_id", "") or "")
|
|
334
|
+
learner_username, learner_record = _require_assignable_user(learner_user_id)
|
|
335
|
+
is_admin = str(getattr(current, "role", "") or "") == "admin"
|
|
336
|
+
if is_admin:
|
|
337
|
+
return learner_username, learner_record, actor_user_id, True
|
|
338
|
+
_guardian_username, _guardian_record = _require_assignable_user(actor_user_id)
|
|
339
|
+
if not guardian_can_access(actor_user_id, learner_user_id, permission):
|
|
340
|
+
raise HTTPException(status_code=403, detail="Guardian authorization required")
|
|
341
|
+
return learner_username, learner_record, actor_user_id, False
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _log_supervisor_action(
|
|
345
|
+
action: str,
|
|
346
|
+
*,
|
|
347
|
+
actor_user_id: str,
|
|
348
|
+
learner_user_id: str,
|
|
349
|
+
is_admin: bool,
|
|
350
|
+
summary: dict[str, Any] | None = None,
|
|
351
|
+
) -> None:
|
|
352
|
+
if is_admin:
|
|
353
|
+
log_admin_action(action, target_user_id=learner_user_id, summary=summary)
|
|
354
|
+
return
|
|
355
|
+
log_guardian_action(
|
|
356
|
+
action,
|
|
357
|
+
guardian_user_id=actor_user_id,
|
|
358
|
+
learner_user_id=learner_user_id,
|
|
359
|
+
summary=summary,
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@router.get("/admin/resources")
|
|
364
|
+
async def admin_resources(_: object = Depends(require_admin)) -> dict[str, Any]:
|
|
365
|
+
"""Everything an admin can assign to a user: models, KBs, skills, and
|
|
366
|
+
the tool surface (system tools + MCP tools, same pool partners use)."""
|
|
367
|
+
from deeptutor.api.utils.tool_options import build_tool_options
|
|
368
|
+
|
|
369
|
+
tool_options = await build_tool_options()
|
|
370
|
+
return {
|
|
371
|
+
"models": _admin_catalog_summary(),
|
|
372
|
+
"knowledge_bases": _admin_kb_summary(),
|
|
373
|
+
"skills": _admin_skill_summary(),
|
|
374
|
+
"partners": _admin_partner_summary(),
|
|
375
|
+
"reading_materials": _admin_reading_summary(),
|
|
376
|
+
"reading_extensions": [
|
|
377
|
+
extension.manifest.model_dump() for extension in _reading_extensions()
|
|
378
|
+
],
|
|
379
|
+
"tools": tool_options["tools"],
|
|
380
|
+
"mcp_tools": tool_options["mcp_tools"],
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
@router.get("/admin/books")
|
|
385
|
+
async def admin_books(_: object = Depends(require_admin)) -> dict[str, Any]:
|
|
386
|
+
from deeptutor.multi_user.book_access import admin_book_catalog
|
|
387
|
+
|
|
388
|
+
return {"books": admin_book_catalog()}
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
@router.get("/guardians")
|
|
392
|
+
async def list_guardian_relationships(
|
|
393
|
+
include_revoked: bool = False,
|
|
394
|
+
_: object = Depends(require_admin),
|
|
395
|
+
) -> dict[str, Any]:
|
|
396
|
+
users = _users_by_id()
|
|
397
|
+
relationships = [
|
|
398
|
+
_relationship_view(relationship, users)
|
|
399
|
+
for relationship in list_relationships(include_revoked=include_revoked)
|
|
400
|
+
]
|
|
401
|
+
return {"relationships": relationships}
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
@router.post("/guardians", status_code=201)
|
|
405
|
+
async def authorize_guardian_relationship(
|
|
406
|
+
payload: GuardianAuthorizationPayload,
|
|
407
|
+
current: object = Depends(require_admin),
|
|
408
|
+
) -> dict[str, Any]:
|
|
409
|
+
_require_assignable_user(payload.guardian_user_id)
|
|
410
|
+
_require_assignable_user(payload.learner_user_id)
|
|
411
|
+
try:
|
|
412
|
+
relationship = authorize_guardian(
|
|
413
|
+
payload.guardian_user_id,
|
|
414
|
+
payload.learner_user_id,
|
|
415
|
+
payload.permissions,
|
|
416
|
+
)
|
|
417
|
+
except ValueError as exc:
|
|
418
|
+
message = str(exc)
|
|
419
|
+
status_code = 409 if "already authorized" in message else 400
|
|
420
|
+
raise HTTPException(status_code=status_code, detail=message) from exc
|
|
421
|
+
log_admin_action(
|
|
422
|
+
"guardian_authorize",
|
|
423
|
+
target_user_id=payload.learner_user_id,
|
|
424
|
+
summary={
|
|
425
|
+
"relationship_id": relationship["id"],
|
|
426
|
+
"guardian_user_id": payload.guardian_user_id,
|
|
427
|
+
"permissions": relationship["permissions"],
|
|
428
|
+
},
|
|
429
|
+
)
|
|
430
|
+
return {
|
|
431
|
+
"relationship": _relationship_view(relationship, _users_by_id()),
|
|
432
|
+
"actor_username": str(getattr(current, "username", "") or ""),
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
@router.delete("/guardians/{relationship_id}")
|
|
437
|
+
async def revoke_guardian_relationship(
|
|
438
|
+
relationship_id: str,
|
|
439
|
+
current: object = Depends(require_admin),
|
|
440
|
+
) -> dict[str, Any]:
|
|
441
|
+
revoked_by = str(getattr(current, "user_id", "") or "")
|
|
442
|
+
relationship = revoke_guardian(relationship_id, revoked_by=revoked_by)
|
|
443
|
+
if relationship is None:
|
|
444
|
+
raise HTTPException(status_code=404, detail="Guardian relationship not found")
|
|
445
|
+
log_admin_action(
|
|
446
|
+
"guardian_revoke",
|
|
447
|
+
target_user_id=relationship["learner_user_id"],
|
|
448
|
+
summary={
|
|
449
|
+
"relationship_id": relationship["id"],
|
|
450
|
+
"guardian_user_id": relationship["guardian_user_id"],
|
|
451
|
+
},
|
|
452
|
+
)
|
|
453
|
+
return {
|
|
454
|
+
"relationship": _relationship_view(relationship, _users_by_id()),
|
|
455
|
+
"ok": True,
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
@router.get("/me/guardianships")
|
|
460
|
+
async def my_guardianships(
|
|
461
|
+
current: object = Depends(require_auth),
|
|
462
|
+
) -> dict[str, Any]:
|
|
463
|
+
guardian_user_id = str(getattr(current, "user_id", "") or "")
|
|
464
|
+
_require_assignable_user(guardian_user_id)
|
|
465
|
+
users = _users_by_id()
|
|
466
|
+
relationships = [
|
|
467
|
+
_relationship_view(relationship, users)
|
|
468
|
+
for relationship in list_relationships(guardian_user_id=guardian_user_id)
|
|
469
|
+
]
|
|
470
|
+
return {"relationships": relationships}
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
@router.delete("/me/guardianships/{relationship_id}")
|
|
474
|
+
async def revoke_my_guardianship(
|
|
475
|
+
relationship_id: str,
|
|
476
|
+
current: object = Depends(require_auth),
|
|
477
|
+
) -> dict[str, Any]:
|
|
478
|
+
guardian_user_id = str(getattr(current, "user_id", "") or "")
|
|
479
|
+
_require_assignable_user(guardian_user_id)
|
|
480
|
+
relationship = relationship_by_id(relationship_id)
|
|
481
|
+
if (
|
|
482
|
+
relationship is None
|
|
483
|
+
or relationship["guardian_user_id"] != guardian_user_id
|
|
484
|
+
or relationship["revoked_at"] is not None
|
|
485
|
+
):
|
|
486
|
+
raise HTTPException(status_code=404, detail="Active guardian relationship not found")
|
|
487
|
+
revoked = revoke_guardian(relationship_id, revoked_by=guardian_user_id, reason="self_revoked")
|
|
488
|
+
assert revoked is not None
|
|
489
|
+
log_guardian_action(
|
|
490
|
+
"guardian_self_revoke",
|
|
491
|
+
guardian_user_id=guardian_user_id,
|
|
492
|
+
learner_user_id=relationship["learner_user_id"],
|
|
493
|
+
summary={"relationship_id": relationship_id},
|
|
494
|
+
)
|
|
495
|
+
return {"relationship": _relationship_view(revoked, _users_by_id()), "ok": True}
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
@router.get("/learners/{learner_user_id}/guardian-report")
|
|
499
|
+
async def guardian_report(
|
|
500
|
+
learner_user_id: str,
|
|
501
|
+
current: object = Depends(require_auth),
|
|
502
|
+
) -> dict[str, Any]:
|
|
503
|
+
learner_username, learner_record, actor_user_id, is_admin = _require_guardian_access(
|
|
504
|
+
current, learner_user_id, "view_reports"
|
|
505
|
+
)
|
|
506
|
+
from deeptutor.multi_user.book_access import admin_book_catalog
|
|
507
|
+
from deeptutor.multi_user.book_permission import (
|
|
508
|
+
normalize_book_permission,
|
|
509
|
+
public_permission_dict,
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
permission = normalize_book_permission(learner_record.get("book_permission"))
|
|
513
|
+
permission_dict = public_permission_dict(permission)
|
|
514
|
+
assigned_materials = [
|
|
515
|
+
{**book, "permission": permission.level_for(book["book_id"])}
|
|
516
|
+
for book in admin_book_catalog()
|
|
517
|
+
if permission.level_for(book["book_id"]) != "none"
|
|
518
|
+
]
|
|
519
|
+
grant = load_grant(learner_user_id)
|
|
520
|
+
_log_supervisor_action(
|
|
521
|
+
"guardian_report_view",
|
|
522
|
+
actor_user_id=actor_user_id,
|
|
523
|
+
learner_user_id=learner_user_id,
|
|
524
|
+
is_admin=is_admin,
|
|
525
|
+
summary={"assigned_material_count": len(assigned_materials)},
|
|
526
|
+
)
|
|
527
|
+
return {
|
|
528
|
+
"learner": {
|
|
529
|
+
"id": learner_user_id,
|
|
530
|
+
"username": learner_username,
|
|
531
|
+
"disabled": bool(learner_record.get("disabled", False)),
|
|
532
|
+
},
|
|
533
|
+
"book_permission": permission_dict,
|
|
534
|
+
"assigned_materials": assigned_materials,
|
|
535
|
+
"grant_summary": {
|
|
536
|
+
"model_count": len(grant.get("models", {}).get("llm", []) or []),
|
|
537
|
+
"knowledge_base_count": len(grant.get("knowledge_bases") or []),
|
|
538
|
+
"skill_count": len(grant.get("skills") or []),
|
|
539
|
+
"enabled_tools": grant.get("enabled_tools"),
|
|
540
|
+
},
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
@router.get("/learners/{learner_user_id}/materials")
|
|
545
|
+
async def guardian_material_catalog(
|
|
546
|
+
learner_user_id: str,
|
|
547
|
+
current: object = Depends(require_auth),
|
|
548
|
+
) -> dict[str, Any]:
|
|
549
|
+
_learner_username, learner_record, actor_user_id, is_admin = _require_guardian_access(
|
|
550
|
+
current, learner_user_id, "assign_materials"
|
|
551
|
+
)
|
|
552
|
+
from deeptutor.multi_user.book_access import admin_book_catalog
|
|
553
|
+
from deeptutor.multi_user.book_permission import normalize_book_permission
|
|
554
|
+
|
|
555
|
+
permission = normalize_book_permission(learner_record.get("book_permission"))
|
|
556
|
+
materials = [
|
|
557
|
+
{
|
|
558
|
+
**book,
|
|
559
|
+
"assigned": permission.level_for(book["book_id"]) != "none",
|
|
560
|
+
"permission": permission.level_for(book["book_id"]),
|
|
561
|
+
}
|
|
562
|
+
for book in admin_book_catalog()
|
|
563
|
+
]
|
|
564
|
+
_log_supervisor_action(
|
|
565
|
+
"guardian_material_catalog_view",
|
|
566
|
+
actor_user_id=actor_user_id,
|
|
567
|
+
learner_user_id=learner_user_id,
|
|
568
|
+
is_admin=is_admin,
|
|
569
|
+
summary={"material_count": len(materials)},
|
|
570
|
+
)
|
|
571
|
+
return {"materials": materials}
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
@router.put("/learners/{learner_user_id}/materials")
|
|
575
|
+
async def assign_guardian_materials(
|
|
576
|
+
learner_user_id: str,
|
|
577
|
+
payload: GuardianMaterialsPayload,
|
|
578
|
+
current: object = Depends(require_auth),
|
|
579
|
+
) -> dict[str, Any]:
|
|
580
|
+
learner_username, learner_record, actor_user_id, is_admin = _require_guardian_access(
|
|
581
|
+
current, learner_user_id, "assign_materials"
|
|
582
|
+
)
|
|
583
|
+
from deeptutor.multi_user.book_access import admin_book_catalog
|
|
584
|
+
from deeptutor.multi_user.book_permission import (
|
|
585
|
+
BookPermission,
|
|
586
|
+
normalize_book_permission,
|
|
587
|
+
public_permission_dict,
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
catalog = admin_book_catalog()
|
|
591
|
+
catalog_ids = {book["book_id"] for book in catalog}
|
|
592
|
+
unknown = next((book_id for book_id in payload.book_ids if book_id not in catalog_ids), None)
|
|
593
|
+
if unknown:
|
|
594
|
+
raise HTTPException(status_code=400, detail=f"Unknown approved book id: {unknown}")
|
|
595
|
+
|
|
596
|
+
# Guardians hand out the admin-approved catalogue as read-only material.
|
|
597
|
+
# Admins remain the only source of edit/create capability.
|
|
598
|
+
existing = normalize_book_permission(learner_record.get("book_permission"))
|
|
599
|
+
selected_ids = set(payload.book_ids)
|
|
600
|
+
permission = BookPermission(
|
|
601
|
+
create=existing.create,
|
|
602
|
+
default=existing.default,
|
|
603
|
+
books=tuple(
|
|
604
|
+
(book["book_id"], "read" if book["book_id"] in selected_ids else "none")
|
|
605
|
+
for book in catalog
|
|
606
|
+
if (book["book_id"] in selected_ids) != (existing.default == "read")
|
|
607
|
+
),
|
|
608
|
+
)
|
|
609
|
+
if not set_book_permission(learner_username, permission):
|
|
610
|
+
raise HTTPException(status_code=404, detail="User not found")
|
|
611
|
+
result = public_permission_dict(permission)
|
|
612
|
+
_log_supervisor_action(
|
|
613
|
+
"guardian_material_assign",
|
|
614
|
+
actor_user_id=actor_user_id,
|
|
615
|
+
learner_user_id=learner_user_id,
|
|
616
|
+
is_admin=is_admin,
|
|
617
|
+
summary={"book_ids": payload.book_ids, "read_only": True},
|
|
618
|
+
)
|
|
619
|
+
return {"book_permission": result}
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _guardian_restrictions(grant: dict[str, Any]) -> dict[str, Any]:
|
|
623
|
+
policy = grant.get("learning_policy")
|
|
624
|
+
if not isinstance(policy, dict):
|
|
625
|
+
raise HTTPException(status_code=409, detail="Learner account has no learning policy")
|
|
626
|
+
reading = policy.get("reading") if isinstance(policy.get("reading"), dict) else {}
|
|
627
|
+
return {
|
|
628
|
+
"age_band": policy.get("age_band"),
|
|
629
|
+
"allow_upload": bool(reading.get("allow_upload", False)),
|
|
630
|
+
"allowed_surfaces": list(policy.get("allowed_surfaces") or ["chat", "reading"]),
|
|
631
|
+
"extensions": list(reading.get("extensions") or []),
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _restriction_grant(learner_user_id: str, learner_record: dict[str, Any]) -> dict[str, Any]:
|
|
636
|
+
grant = load_grant(learner_user_id)
|
|
637
|
+
if grant.get("learning_policy") is None and learner_record.get("preset") == "learner":
|
|
638
|
+
return learner_grant(learner_user_id)
|
|
639
|
+
return grant
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
@router.get("/learners/{learner_user_id}/restrictions")
|
|
643
|
+
async def get_guardian_restrictions(
|
|
644
|
+
learner_user_id: str,
|
|
645
|
+
current: object = Depends(require_auth),
|
|
646
|
+
) -> dict[str, Any]:
|
|
647
|
+
_learner_username, learner_record, actor_user_id, is_admin = _require_guardian_access(
|
|
648
|
+
current, learner_user_id, "manage_restrictions"
|
|
649
|
+
)
|
|
650
|
+
restrictions = _guardian_restrictions(_restriction_grant(learner_user_id, learner_record))
|
|
651
|
+
_log_supervisor_action(
|
|
652
|
+
"guardian_restrictions_view",
|
|
653
|
+
actor_user_id=actor_user_id,
|
|
654
|
+
learner_user_id=learner_user_id,
|
|
655
|
+
is_admin=is_admin,
|
|
656
|
+
)
|
|
657
|
+
return {
|
|
658
|
+
"restrictions": restrictions,
|
|
659
|
+
"available_extensions": [
|
|
660
|
+
extension.manifest.model_dump() for extension in _reading_extensions()
|
|
661
|
+
],
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
@router.put("/learners/{learner_user_id}/restrictions")
|
|
666
|
+
async def put_guardian_restrictions(
|
|
667
|
+
learner_user_id: str,
|
|
668
|
+
payload: GuardianRestrictionsPayload,
|
|
669
|
+
current: object = Depends(require_auth),
|
|
670
|
+
) -> dict[str, Any]:
|
|
671
|
+
learner_username, learner_record, actor_user_id, is_admin = _require_guardian_access(
|
|
672
|
+
current, learner_user_id, "manage_restrictions"
|
|
673
|
+
)
|
|
674
|
+
available_extensions = {extension.manifest.id for extension in _reading_extensions()}
|
|
675
|
+
unknown_extensions = sorted(set(payload.extensions) - available_extensions)
|
|
676
|
+
if unknown_extensions:
|
|
677
|
+
raise HTTPException(
|
|
678
|
+
status_code=400,
|
|
679
|
+
detail=f"Unknown reading extensions: {', '.join(unknown_extensions)}",
|
|
680
|
+
)
|
|
681
|
+
grant = deepcopy(_restriction_grant(learner_user_id, learner_record))
|
|
682
|
+
policy = grant.get("learning_policy")
|
|
683
|
+
if not isinstance(policy, dict):
|
|
684
|
+
# Assigning guardian restrictions is what makes an account a learning
|
|
685
|
+
# account. Seed the default learning policy; the preset is switched
|
|
686
|
+
# only after the updated grant has passed validation and been saved.
|
|
687
|
+
grant = deepcopy(learner_grant(learner_user_id))
|
|
688
|
+
policy = grant.get("learning_policy")
|
|
689
|
+
if not isinstance(policy, dict):
|
|
690
|
+
raise HTTPException(status_code=409, detail="Learner account has no learning policy")
|
|
691
|
+
reading = policy.get("reading")
|
|
692
|
+
if not isinstance(reading, dict):
|
|
693
|
+
reading = {}
|
|
694
|
+
policy["reading"] = reading
|
|
695
|
+
policy["age_band"] = payload.age_band
|
|
696
|
+
policy["allowed_surfaces"] = payload.allowed_surfaces
|
|
697
|
+
reading["allow_upload"] = payload.allow_upload
|
|
698
|
+
reading["extensions"] = payload.extensions
|
|
699
|
+
needs_preset_update = learner_record.get("preset") != "learner"
|
|
700
|
+
try:
|
|
701
|
+
grant = normalize_grant(learner_user_id, grant)
|
|
702
|
+
validate_grant(grant)
|
|
703
|
+
_validate_reading_policy(grant)
|
|
704
|
+
if needs_preset_update:
|
|
705
|
+
grant, receipt = save_grant_with_receipt(learner_user_id, grant)
|
|
706
|
+
else:
|
|
707
|
+
grant = save_grant(learner_user_id, grant)
|
|
708
|
+
except ValueError as exc:
|
|
709
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
710
|
+
except OSError as exc:
|
|
711
|
+
raise HTTPException(status_code=500, detail="Learner grant could not be saved") from exc
|
|
712
|
+
if needs_preset_update:
|
|
713
|
+
preset_error: Exception | None = None
|
|
714
|
+
try:
|
|
715
|
+
preset_saved = set_preset(learner_username, "learner", expected_user_id=learner_user_id)
|
|
716
|
+
except Exception as exc:
|
|
717
|
+
preset_saved = False
|
|
718
|
+
preset_error = exc
|
|
719
|
+
if not preset_saved:
|
|
720
|
+
try:
|
|
721
|
+
restored = restore_grant_if_unchanged(learner_user_id, receipt)
|
|
722
|
+
except OSError as exc:
|
|
723
|
+
raise HTTPException(
|
|
724
|
+
status_code=500,
|
|
725
|
+
detail="Learner preset update failed and the prior grant could not be restored",
|
|
726
|
+
) from exc
|
|
727
|
+
if not restored:
|
|
728
|
+
raise HTTPException(
|
|
729
|
+
status_code=409,
|
|
730
|
+
detail="Learner preset update failed; a later grant change was preserved",
|
|
731
|
+
) from preset_error
|
|
732
|
+
raise HTTPException(
|
|
733
|
+
status_code=500 if preset_error is not None else 409,
|
|
734
|
+
detail="Learner preset update failed; the prior grant was restored",
|
|
735
|
+
) from preset_error
|
|
736
|
+
restrictions = _guardian_restrictions(grant)
|
|
737
|
+
_log_supervisor_action(
|
|
738
|
+
"guardian_restrictions_set",
|
|
739
|
+
actor_user_id=actor_user_id,
|
|
740
|
+
learner_user_id=learner_user_id,
|
|
741
|
+
is_admin=is_admin,
|
|
742
|
+
summary=restrictions,
|
|
743
|
+
)
|
|
744
|
+
return {"restrictions": restrictions}
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
@router.post("/learners/{learner_user_id}/credentials/reset")
|
|
748
|
+
async def reset_learner_credentials(
|
|
749
|
+
learner_user_id: str,
|
|
750
|
+
payload: GuardianCredentialResetPayload,
|
|
751
|
+
current: object = Depends(require_auth),
|
|
752
|
+
) -> dict[str, Any]:
|
|
753
|
+
learner_username, _learner_record, actor_user_id, is_admin = _require_guardian_access(
|
|
754
|
+
current, learner_user_id, "reset_credentials"
|
|
755
|
+
)
|
|
756
|
+
if POCKETBASE_ENABLED:
|
|
757
|
+
raise HTTPException(
|
|
758
|
+
status_code=400,
|
|
759
|
+
detail="Guardian credential reset requires built-in local authentication.",
|
|
760
|
+
)
|
|
761
|
+
revoked_devices = revoke_device_credentials_for_user(
|
|
762
|
+
learner_user_id,
|
|
763
|
+
revoked_by=actor_user_id,
|
|
764
|
+
)
|
|
765
|
+
if set_password(learner_username, hash_password(payload.new_password)) is None:
|
|
766
|
+
raise HTTPException(status_code=404, detail="User not found")
|
|
767
|
+
_log_supervisor_action(
|
|
768
|
+
"guardian_credential_reset",
|
|
769
|
+
actor_user_id=actor_user_id,
|
|
770
|
+
learner_user_id=learner_user_id,
|
|
771
|
+
is_admin=is_admin,
|
|
772
|
+
summary={
|
|
773
|
+
"credential_reset": True,
|
|
774
|
+
"device_credentials_revoked": revoked_devices,
|
|
775
|
+
},
|
|
776
|
+
)
|
|
777
|
+
return {"ok": True, "device_credentials_revoked": revoked_devices}
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
@router.get("/users/{user_id}/grants")
|
|
781
|
+
async def get_user_grants(user_id: str, _: object = Depends(require_admin)) -> dict[str, Any]:
|
|
782
|
+
_require_assignable_user(user_id)
|
|
783
|
+
return {"grant": load_grant(user_id)}
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
@router.put("/users/{user_id}/grants")
|
|
787
|
+
async def put_user_grants(
|
|
788
|
+
user_id: str,
|
|
789
|
+
payload: GrantPayload,
|
|
790
|
+
_: object = Depends(require_admin),
|
|
791
|
+
) -> dict[str, Any]:
|
|
792
|
+
user_record = _require_assignable_user(user_id)
|
|
793
|
+
try:
|
|
794
|
+
grant = normalize_grant(user_id, payload.grant)
|
|
795
|
+
if (
|
|
796
|
+
str(user_record[1].get("preset") or "standard") == "learner"
|
|
797
|
+
and grant.get("learning_policy") is None
|
|
798
|
+
):
|
|
799
|
+
raise ValueError("Learner accounts must retain a learning policy.")
|
|
800
|
+
validate_grant(grant)
|
|
801
|
+
_validate_reading_policy(grant)
|
|
802
|
+
_stage_assigned_materials(user_id, grant)
|
|
803
|
+
grant = save_grant(user_id, grant)
|
|
804
|
+
except ValueError as exc:
|
|
805
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
806
|
+
log_admin_action(
|
|
807
|
+
"grant_set",
|
|
808
|
+
target_user_id=user_id,
|
|
809
|
+
summary={
|
|
810
|
+
"model_count": len(grant.get("models", {}).get("llm", []) or []),
|
|
811
|
+
"kb_count": len(grant.get("knowledge_bases", []) or []),
|
|
812
|
+
"skill_count": len(grant.get("skills", []) or []),
|
|
813
|
+
"partner_count": len(grant.get("partners", []) or []),
|
|
814
|
+
"enabled_tools": grant.get("enabled_tools"),
|
|
815
|
+
"mcp_tool_count": (
|
|
816
|
+
None if grant.get("mcp_tools") is None else len(grant.get("mcp_tools") or [])
|
|
817
|
+
),
|
|
818
|
+
"exec_enabled": grant.get("exec_enabled"),
|
|
819
|
+
"learning_policy": grant.get("learning_policy"),
|
|
820
|
+
},
|
|
821
|
+
)
|
|
822
|
+
return {"grant": grant}
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
@router.get("/users/{user_id}/book-permission")
|
|
826
|
+
async def get_user_book_permission(
|
|
827
|
+
user_id: str,
|
|
828
|
+
_: object = Depends(require_admin),
|
|
829
|
+
) -> dict[str, Any]:
|
|
830
|
+
from deeptutor.multi_user.book_permission import (
|
|
831
|
+
normalize_book_permission,
|
|
832
|
+
public_permission_dict,
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
_, record = _require_assignable_user(user_id)
|
|
836
|
+
return {
|
|
837
|
+
"permission": public_permission_dict(
|
|
838
|
+
normalize_book_permission(record.get("book_permission"))
|
|
839
|
+
)
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
@router.put("/users/{user_id}/book-permission")
|
|
844
|
+
async def put_user_book_permission(
|
|
845
|
+
user_id: str,
|
|
846
|
+
payload: BookPermissionPayload,
|
|
847
|
+
_: object = Depends(require_admin),
|
|
848
|
+
) -> dict[str, Any]:
|
|
849
|
+
from deeptutor.multi_user.book_access import shared_book_exists
|
|
850
|
+
from deeptutor.multi_user.book_permission import public_permission_dict
|
|
851
|
+
|
|
852
|
+
username, _record = _require_assignable_user(user_id)
|
|
853
|
+
unknown = sorted(book_id for book_id in payload.books if not shared_book_exists(book_id))
|
|
854
|
+
if unknown:
|
|
855
|
+
raise HTTPException(status_code=400, detail=f"Unknown book id: {unknown[0]}")
|
|
856
|
+
permission = BookPermission(
|
|
857
|
+
create=bool(payload.create),
|
|
858
|
+
default=payload.default,
|
|
859
|
+
books=tuple(payload.books.items()),
|
|
860
|
+
)
|
|
861
|
+
if not set_book_permission(username, permission):
|
|
862
|
+
raise HTTPException(status_code=404, detail="User not found")
|
|
863
|
+
result = public_permission_dict(permission)
|
|
864
|
+
log_admin_action(
|
|
865
|
+
"book_permission_set",
|
|
866
|
+
target_user_id=user_id,
|
|
867
|
+
summary={
|
|
868
|
+
"create": permission.create,
|
|
869
|
+
"default": permission.default,
|
|
870
|
+
"book_count": len(permission.books),
|
|
871
|
+
},
|
|
872
|
+
)
|
|
873
|
+
return {"permission": result}
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
@router.post("/admin/skills/install")
|
|
877
|
+
async def admin_install_skill(
|
|
878
|
+
payload: SkillInstallPayload,
|
|
879
|
+
_: object = Depends(require_admin),
|
|
880
|
+
) -> dict[str, Any]:
|
|
881
|
+
"""Install a hub skill into the admin catalog (``<hub>:<slug>[@version]``).
|
|
882
|
+
|
|
883
|
+
The skill lands in the admin workspace — the same pool ``/admin/resources``
|
|
884
|
+
lists — so it stays invisible to non-admin users until a grant assigns it.
|
|
885
|
+
The install pipeline (verdict gate, safe extraction, ``always`` stripping)
|
|
886
|
+
lives in :func:`deeptutor.services.skill.hub.install_from_hub`; this
|
|
887
|
+
endpoint only chooses the target root and audits the action.
|
|
888
|
+
"""
|
|
889
|
+
from deeptutor.services.skill.hub import HubError, install_from_hub
|
|
890
|
+
from deeptutor.services.skill.service import (
|
|
891
|
+
InvalidSkillNameError,
|
|
892
|
+
SkillExistsError,
|
|
893
|
+
SkillImportError,
|
|
894
|
+
get_admin_skill_service,
|
|
895
|
+
)
|
|
896
|
+
|
|
897
|
+
service = get_admin_skill_service()
|
|
898
|
+
try:
|
|
899
|
+
outcome = await asyncio.to_thread(
|
|
900
|
+
install_from_hub,
|
|
901
|
+
payload.ref,
|
|
902
|
+
service=service,
|
|
903
|
+
rename_to=payload.name,
|
|
904
|
+
force=payload.force,
|
|
905
|
+
allow_unverified=payload.allow_unverified,
|
|
906
|
+
)
|
|
907
|
+
except SkillExistsError as exc:
|
|
908
|
+
raise HTTPException(status_code=409, detail=f"Skill already exists: {exc}") from exc
|
|
909
|
+
except (SkillImportError, InvalidSkillNameError) as exc:
|
|
910
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
911
|
+
except HubError as exc:
|
|
912
|
+
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
|
913
|
+
|
|
914
|
+
log_admin_action(
|
|
915
|
+
"skill_hub_install",
|
|
916
|
+
summary={
|
|
917
|
+
"ref": payload.ref,
|
|
918
|
+
"installed_as": outcome.result.info.name,
|
|
919
|
+
"version": outcome.ref.version,
|
|
920
|
+
"verdict": outcome.verdict.status,
|
|
921
|
+
"forced": payload.force,
|
|
922
|
+
"allow_unverified": payload.allow_unverified,
|
|
923
|
+
},
|
|
924
|
+
)
|
|
925
|
+
return {
|
|
926
|
+
"skill": outcome.result.info.to_dict(),
|
|
927
|
+
"verdict": {"status": outcome.verdict.status, "detail": outcome.verdict.detail},
|
|
928
|
+
"version": outcome.ref.version,
|
|
929
|
+
"skipped": [{"path": rel, "reason": reason} for rel, reason in outcome.result.skipped],
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
@router.get("/users")
|
|
934
|
+
async def multi_user_list_users(_: object = Depends(require_admin)) -> dict[str, Any]:
|
|
935
|
+
return {"users": list_user_info()}
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def _reading_extensions():
|
|
939
|
+
if not module_enabled("reading"):
|
|
940
|
+
return ()
|
|
941
|
+
from deeptutor.reading.extensions import get_reading_extension_registry
|
|
942
|
+
|
|
943
|
+
return get_reading_extension_registry().all()
|