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.
Files changed (48) hide show
  1. deeptutor/api/__init__.py +0 -0
  2. deeptutor/api/contracts/__init__.py +21 -0
  3. deeptutor/api/contracts/export.py +110 -0
  4. deeptutor/api/contracts/turn_protocol.py +313 -0
  5. deeptutor/api/main.py +547 -0
  6. deeptutor/api/module_routes.py +42 -0
  7. deeptutor/api/router_index.json +5752 -0
  8. deeptutor/api/router_loader.py +139 -0
  9. deeptutor/api/routers/__init__.py +1 -0
  10. deeptutor/api/routers/agent_config.py +59 -0
  11. deeptutor/api/routers/attachments.py +79 -0
  12. deeptutor/api/routers/auth.py +1937 -0
  13. deeptutor/api/routers/capabilities.py +44 -0
  14. deeptutor/api/routers/capabilities_settings.py +38 -0
  15. deeptutor/api/routers/dashboard.py +292 -0
  16. deeptutor/api/routers/file_library.py +169 -0
  17. deeptutor/api/routers/file_preview.py +205 -0
  18. deeptutor/api/routers/imports.py +146 -0
  19. deeptutor/api/routers/learning_journal.py +18 -0
  20. deeptutor/api/routers/mcp_settings.py +133 -0
  21. deeptutor/api/routers/modules.py +12 -0
  22. deeptutor/api/routers/multi_user.py +943 -0
  23. deeptutor/api/routers/outputs.py +97 -0
  24. deeptutor/api/routers/personas.py +154 -0
  25. deeptutor/api/routers/plugins.py +416 -0
  26. deeptutor/api/routers/quiz_judge.py +464 -0
  27. deeptutor/api/routers/sessions.py +801 -0
  28. deeptutor/api/routers/settings.py +2503 -0
  29. deeptutor/api/routers/skills.py +315 -0
  30. deeptutor/api/routers/space_cli_apps.py +222 -0
  31. deeptutor/api/routers/space_mcp.py +460 -0
  32. deeptutor/api/routers/subagents.py +306 -0
  33. deeptutor/api/routers/system.py +633 -0
  34. deeptutor/api/routers/task_board.py +139 -0
  35. deeptutor/api/routers/tools.py +268 -0
  36. deeptutor/api/routers/unified_ws.py +385 -0
  37. deeptutor/api/routers/workspace.py +371 -0
  38. deeptutor/api/run_server.py +99 -0
  39. deeptutor/api/security_guard.py +91 -0
  40. deeptutor/api/utils/http_headers.py +27 -0
  41. deeptutor/api/utils/progress_broadcaster.py +73 -0
  42. deeptutor/api/utils/task_id_manager.py +120 -0
  43. deeptutor/api/utils/task_log_stream.py +362 -0
  44. deeptutor/api/utils/tool_options.py +133 -0
  45. deeptutor_server-1.7.0.dist-info/METADATA +14 -0
  46. deeptutor_server-1.7.0.dist-info/RECORD +48 -0
  47. deeptutor_server-1.7.0.dist-info/WHEEL +5 -0
  48. 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()