valentina-python-client 2.1.0__tar.gz → 2.3.0__tar.gz

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 (84) hide show
  1. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/PKG-INFO +1 -1
  2. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/pyproject.toml +1 -1
  3. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/__init__.py +1 -1
  4. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/_sync/registry.py +1 -1
  5. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/_sync/services/base.py +9 -2
  6. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/_sync/services/global_admin.py +262 -22
  7. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/constants.py +8 -1
  8. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/endpoints.py +4 -0
  9. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/models/__init__.py +10 -0
  10. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/models/characters.py +6 -2
  11. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/models/companies.py +4 -0
  12. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/models/global_admin.py +33 -1
  13. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/models/users.py +35 -0
  14. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/registry.py +1 -1
  15. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/services/base.py +3 -1
  16. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/services/global_admin.py +277 -19
  17. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/testing/__init__.py +4 -0
  18. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/testing/_factories.py +15 -0
  19. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/testing/_router.py +15 -2
  20. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/testing/_routes.py +14 -2
  21. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/validate_constants.py +1 -0
  22. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/LICENSE +0 -0
  23. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/README.md +0 -0
  24. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/_codegen.py +0 -0
  25. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/_sync/__init__.py +0 -0
  26. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/_sync/client.py +0 -0
  27. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/_sync/services/__init__.py +0 -0
  28. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/_sync/services/_audit_params.py +0 -0
  29. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/_sync/services/campaign_book_chapters.py +0 -0
  30. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/_sync/services/campaign_books.py +0 -0
  31. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/_sync/services/campaigns.py +0 -0
  32. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/_sync/services/character_autogen.py +0 -0
  33. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/_sync/services/character_blueprint.py +0 -0
  34. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/_sync/services/character_traits.py +0 -0
  35. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/_sync/services/characters.py +0 -0
  36. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/_sync/services/companies.py +0 -0
  37. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/_sync/services/developers.py +0 -0
  38. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/_sync/services/dicerolls.py +0 -0
  39. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/_sync/services/dictionary.py +0 -0
  40. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/_sync/services/options.py +0 -0
  41. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/_sync/services/system.py +0 -0
  42. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/_sync/services/user_lookup.py +0 -0
  43. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/_sync/services/user_self_registration.py +0 -0
  44. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/_sync/services/users.py +0 -0
  45. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/_sync/testing/__init__.py +0 -0
  46. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/_sync/testing/_client.py +0 -0
  47. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/client.py +0 -0
  48. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/config.py +0 -0
  49. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/exceptions.py +0 -0
  50. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/models/audit_logs.py +0 -0
  51. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/models/books.py +0 -0
  52. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/models/campaigns.py +0 -0
  53. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/models/chapters.py +0 -0
  54. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/models/character_autogen.py +0 -0
  55. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/models/character_blueprint.py +0 -0
  56. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/models/character_trait.py +0 -0
  57. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/models/developers.py +0 -0
  58. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/models/diceroll.py +0 -0
  59. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/models/dictionary.py +0 -0
  60. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/models/full_sheet.py +0 -0
  61. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/models/pagination.py +0 -0
  62. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/models/shared.py +0 -0
  63. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/models/system.py +0 -0
  64. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/models/user_lookup.py +0 -0
  65. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/py.typed +0 -0
  66. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/services/__init__.py +0 -0
  67. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/services/_audit_params.py +0 -0
  68. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/services/campaign_book_chapters.py +0 -0
  69. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/services/campaign_books.py +0 -0
  70. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/services/campaigns.py +0 -0
  71. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/services/character_autogen.py +0 -0
  72. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/services/character_blueprint.py +0 -0
  73. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/services/character_traits.py +0 -0
  74. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/services/characters.py +0 -0
  75. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/services/companies.py +0 -0
  76. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/services/developers.py +0 -0
  77. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/services/dicerolls.py +0 -0
  78. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/services/dictionary.py +0 -0
  79. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/services/options.py +0 -0
  80. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/services/system.py +0 -0
  81. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/services/user_lookup.py +0 -0
  82. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/services/user_self_registration.py +0 -0
  83. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/services/users.py +0 -0
  84. {valentina_python_client-2.1.0 → valentina_python_client-2.3.0}/src/vclient/testing/_client.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: valentina-python-client
3
- Version: 2.1.0
3
+ Version: 2.3.0
4
4
  Summary: Async Python client library for the Valentina Noir API
5
5
  Author: Nate Landau
6
6
  Author-email: Nate Landau <github@natenate.org>
@@ -16,7 +16,7 @@
16
16
  name = "valentina-python-client"
17
17
  readme = "README.md"
18
18
  requires-python = ">=3.13"
19
- version = "2.1.0"
19
+ version = "2.3.0"
20
20
 
21
21
  [project.optional-dependencies]
22
22
  testing = ["polyfactory>=3.3.0"]
@@ -113,4 +113,4 @@ __all__ = (
113
113
  "users_service",
114
114
  )
115
115
 
116
- __version__ = "2.1.0"
116
+ __version__ = "2.3.0"
@@ -160,7 +160,7 @@ def sync_global_admin_service() -> "SyncGlobalAdminService":
160
160
  Example:
161
161
  ```python
162
162
  admins = sync_global_admin_service()
163
- developers = await admins.list_all()
163
+ developers = await admins.list_all_developers()
164
164
  ```
165
165
  """
166
166
  from vclient._sync.services.global_admin import SyncGlobalAdminService
@@ -381,17 +381,24 @@ class SyncBaseService:
381
381
  return None
382
382
  return SyncBaseService._parse_rate_limit_header_value(rate_limit_header, "r")
383
383
 
384
- def _get(self, path: str, *, params: dict[str, Any] | None = None) -> httpx.Response:
384
+ def _get(
385
+ self,
386
+ path: str,
387
+ *,
388
+ params: dict[str, Any] | None = None,
389
+ headers: dict[str, str] | None = None,
390
+ ) -> httpx.Response:
385
391
  """Make a GET request.
386
392
 
387
393
  Args:
388
394
  path: API endpoint path.
389
395
  params: Query parameters.
396
+ headers: Additional headers (e.g. an Accept override for binary downloads).
390
397
 
391
398
  Returns:
392
399
  The HTTP response.
393
400
  """
394
- return self._request("GET", path, params=params)
401
+ return self._request("GET", path, params=params, headers=headers)
395
402
 
396
403
  def _merge_on_behalf_of_header(self, headers: dict[str, str] | None) -> dict[str, str] | None:
397
404
  """Merge the On-Behalf-Of header into headers when _on_behalf_of is set.
@@ -1,13 +1,27 @@
1
1
  # AUTO-GENERATED — do not edit. Run 'uv run duty generate_sync' to regenerate.
2
2
  """Service for interacting with the Global Admin API."""
3
3
 
4
+ import re
4
5
  from collections.abc import Iterator, Sequence
5
6
  from datetime import datetime
6
7
 
7
8
  from vclient._sync.services.base import SyncBaseService
8
- from vclient.constants import DEFAULT_PAGE_LIMIT, AuditEntityType, AuditLogInclude, AuditOperation
9
+ from vclient.constants import (
10
+ DEFAULT_LOG_TAIL_LIMIT,
11
+ DEFAULT_PAGE_LIMIT,
12
+ MAX_LOG_TAIL_LIMIT,
13
+ MIN_LOG_TAIL_LIMIT,
14
+ AuditEntityType,
15
+ AuditLogInclude,
16
+ AuditOperation,
17
+ LogLevel,
18
+ UserRole,
19
+ )
9
20
  from vclient.endpoints import Endpoints
10
21
  from vclient.models import (
22
+ AdminUser,
23
+ AdminUserCreate,
24
+ AdminUserUpdate,
11
25
  AuditLog,
12
26
  AuditLogDetail,
13
27
  Developer,
@@ -15,23 +29,48 @@ from vclient.models import (
15
29
  DeveloperUpdate,
16
30
  DeveloperWithApiKey,
17
31
  PaginatedResponse,
32
+ ServerLogArchive,
33
+ ServerLogEntry,
18
34
  )
19
35
  from vclient.services._audit_params import _build_audit_params
20
36
 
37
+ _CONTENT_DISPOSITION_FILENAME = re.compile('filename=(?:"([^"]+)"|([^;]+))', re.IGNORECASE)
38
+
39
+
40
+ def _filename_from_content_disposition(header: str | None, *, fallback: str) -> str:
41
+ """Extract the attachment filename from a Content-Disposition header.
42
+
43
+ Return ``fallback`` when the header is absent or contains no filename so callers
44
+ always get a usable name for the downloaded archive.
45
+
46
+ Args:
47
+ header: The raw Content-Disposition header value, or None.
48
+ fallback: Filename to return when none can be parsed.
49
+
50
+ Returns:
51
+ The parsed filename, or the fallback.
52
+ """
53
+ if not header:
54
+ return fallback
55
+ match = _CONTENT_DISPOSITION_FILENAME.search(header)
56
+ if not match:
57
+ return fallback
58
+ return (match.group(1) or match.group(2)).strip()
59
+
21
60
 
22
61
  class SyncGlobalAdminService(SyncBaseService):
23
62
  """Service for global admin operations in the Valentina API.
24
63
 
25
- Provides methods to create, retrieve, update, and delete developer accounts,
26
- as well as manage API keys. Requires global admin privileges.
64
+ Provides cross-company management of developer accounts and their API keys,
65
+ plus server log access. Requires global admin privileges.
27
66
 
28
67
  Example:
29
68
  >>> async with SyncVClient() as client:
30
- ... developers = await client.global_admin.list_all()
31
- ... developer = await client.global_admin.get("developer_id")
69
+ ... developers = await client.global_admin.list_all_developers()
70
+ ... developer = await client.global_admin.get_developer("developer_id")
32
71
  """
33
72
 
34
- def get_page(
73
+ def get_developer_page(
35
74
  self,
36
75
  *,
37
76
  limit: int = DEFAULT_PAGE_LIMIT,
@@ -55,11 +94,11 @@ class SyncGlobalAdminService(SyncBaseService):
55
94
  Endpoints.ADMIN_DEVELOPERS, Developer, limit=limit, offset=offset, params=params
56
95
  )
57
96
 
58
- def list_all(self, *, is_global_admin: bool | None = None) -> list[Developer]:
97
+ def list_all_developers(self, *, is_global_admin: bool | None = None) -> list[Developer]:
59
98
  """Retrieve all developer accounts.
60
99
 
61
- Automatically paginates through all results. Use `get_page()` for paginated access
62
- or `iter_all()` for memory-efficient streaming of large datasets.
100
+ Automatically paginates through all results. Use `get_developer_page()` for paginated
101
+ access or `iter_all_developers()` for memory-efficient streaming of large datasets.
63
102
 
64
103
  Args:
65
104
  is_global_admin: Optional filter by global admin status.
@@ -67,9 +106,11 @@ class SyncGlobalAdminService(SyncBaseService):
67
106
  Returns:
68
107
  A list of all Developer objects.
69
108
  """
70
- return [developer for developer in self.iter_all(is_global_admin=is_global_admin)]
109
+ return [
110
+ developer for developer in self.iter_all_developers(is_global_admin=is_global_admin)
111
+ ]
71
112
 
72
- def iter_all(
113
+ def iter_all_developers(
73
114
  self, *, limit: int = 100, is_global_admin: bool | None = None
74
115
  ) -> Iterator[Developer]:
75
116
  """Iterate through all developer accounts.
@@ -85,18 +126,14 @@ class SyncGlobalAdminService(SyncBaseService):
85
126
  Individual Developer objects.
86
127
 
87
128
  Example:
88
- >>> async for developer in client.global_admin.iter_all():
129
+ >>> async for developer in client.global_admin.iter_all_developers():
89
130
  ... print(developer.username)
90
131
  """
91
- params = {}
92
- if is_global_admin is not None:
93
- params["is_global_admin"] = is_global_admin
94
- for item in self._iter_all_pages(
95
- Endpoints.ADMIN_DEVELOPERS, limit=limit, params=params or None
96
- ):
132
+ params = self._build_params(is_global_admin=is_global_admin)
133
+ for item in self._iter_all_pages(Endpoints.ADMIN_DEVELOPERS, limit=limit, params=params):
97
134
  yield Developer.model_validate(item)
98
135
 
99
- def get(self, developer_id: str) -> Developer:
136
+ def get_developer(self, developer_id: str) -> Developer:
100
137
  """Retrieve detailed information about a specific developer.
101
138
 
102
139
  Args:
@@ -112,7 +149,7 @@ class SyncGlobalAdminService(SyncBaseService):
112
149
  response = self._get(Endpoints.ADMIN_DEVELOPER.format(developer_id=developer_id))
113
150
  return Developer.model_validate(response.json())
114
151
 
115
- def create(self, request: DeveloperCreate | None = None, **kwargs) -> Developer:
152
+ def create_developer(self, request: DeveloperCreate | None = None, **kwargs) -> Developer:
116
153
  """Create a new developer account.
117
154
 
118
155
  This creates the account but does not create an API key or grant access to any
@@ -139,7 +176,7 @@ class SyncGlobalAdminService(SyncBaseService):
139
176
  )
140
177
  return Developer.model_validate(response.json())
141
178
 
142
- def update(
179
+ def update_developer(
143
180
  self, developer_id: str, request: DeveloperUpdate | None = None, **kwargs
144
181
  ) -> Developer:
145
182
  """Modify a developer account's properties.
@@ -169,7 +206,7 @@ class SyncGlobalAdminService(SyncBaseService):
169
206
  )
170
207
  return Developer.model_validate(response.json())
171
208
 
172
- def delete(self, developer_id: str) -> None:
209
+ def delete_developer(self, developer_id: str) -> None:
173
210
  """Remove a developer account from the system.
174
211
 
175
212
  The developer's API key will be invalidated immediately.
@@ -183,6 +220,165 @@ class SyncGlobalAdminService(SyncBaseService):
183
220
  """
184
221
  self._delete(Endpoints.ADMIN_DEVELOPER.format(developer_id=developer_id))
185
222
 
223
+ def get_user_page(
224
+ self,
225
+ *,
226
+ company_id: str | None = None,
227
+ role: UserRole | None = None,
228
+ email: str | None = None,
229
+ is_archived: bool | None = None,
230
+ limit: int = DEFAULT_PAGE_LIMIT,
231
+ offset: int = 0,
232
+ ) -> PaginatedResponse[AdminUser]:
233
+ """Retrieve a paginated page of users across all companies.
234
+
235
+ Authenticates with the global-admin API key only; no On-Behalf-Of header
236
+ is sent. Archived (soft-deleted) users are included when
237
+ ``is_archived=True``.
238
+
239
+ Args:
240
+ company_id: Filter to a single company.
241
+ role: Filter by user role.
242
+ email: Filter by exact email match.
243
+ is_archived: Filter by archived state.
244
+ limit: Maximum number of items to return (0-100, default 10).
245
+ offset: Number of items to skip from the beginning (default 0).
246
+
247
+ Returns:
248
+ A PaginatedResponse containing AdminUser objects and pagination metadata.
249
+ """
250
+ params = self._build_params(
251
+ company_id=company_id, role=role, email=email, is_archived=is_archived
252
+ )
253
+ return self._get_paginated_as(
254
+ Endpoints.ADMIN_USERS, AdminUser, limit=limit, offset=offset, params=params
255
+ )
256
+
257
+ def list_all_users(
258
+ self,
259
+ *,
260
+ company_id: str | None = None,
261
+ role: UserRole | None = None,
262
+ email: str | None = None,
263
+ is_archived: bool | None = None,
264
+ ) -> list[AdminUser]:
265
+ """Retrieve all users across all companies.
266
+
267
+ Automatically paginates through all results. Use ``get_user_page()`` for
268
+ paginated access or ``iter_all_users()`` for memory-efficient streaming.
269
+
270
+ Args:
271
+ company_id: Filter to a single company.
272
+ role: Filter by user role.
273
+ email: Filter by exact email match.
274
+ is_archived: Filter by archived state.
275
+
276
+ Returns:
277
+ A list of all matching AdminUser objects.
278
+ """
279
+ return [
280
+ user
281
+ for user in self.iter_all_users(
282
+ company_id=company_id, role=role, email=email, is_archived=is_archived
283
+ )
284
+ ]
285
+
286
+ def iter_all_users(
287
+ self,
288
+ *,
289
+ limit: int = 100,
290
+ company_id: str | None = None,
291
+ role: UserRole | None = None,
292
+ email: str | None = None,
293
+ is_archived: bool | None = None,
294
+ ) -> Iterator[AdminUser]:
295
+ """Iterate through all users across all companies.
296
+
297
+ Yields individual users, automatically fetching subsequent pages until all
298
+ matching users have been retrieved.
299
+
300
+ Args:
301
+ limit: Items per page (default 100 for efficiency).
302
+ company_id: Filter to a single company.
303
+ role: Filter by user role.
304
+ email: Filter by exact email match.
305
+ is_archived: Filter by archived state.
306
+
307
+ Yields:
308
+ Individual AdminUser objects.
309
+ """
310
+ params = self._build_params(
311
+ company_id=company_id, role=role, email=email, is_archived=is_archived
312
+ )
313
+ for item in self._iter_all_pages(Endpoints.ADMIN_USERS, limit=limit, params=params):
314
+ yield AdminUser.model_validate(item)
315
+
316
+ def get_user(self, user_id: str) -> AdminUser:
317
+ """Retrieve a single user by ID, including archived users.
318
+
319
+ Args:
320
+ user_id: The ID of the user to retrieve.
321
+
322
+ Returns:
323
+ The AdminUser object, with ``is_archived`` reflecting soft-delete state.
324
+ """
325
+ response = self._get(Endpoints.ADMIN_USER.format(user_id=user_id))
326
+ return AdminUser.model_validate(response.json())
327
+
328
+ def create_user(self, request: AdminUserCreate | None = None, **kwargs) -> AdminUser:
329
+ """Create a user in a target company.
330
+
331
+ The role assignment matrix enforced on company-scoped endpoints does not
332
+ apply here, but the server still rejects ``UNAPPROVED``/``DEACTIVATED`` on
333
+ create.
334
+
335
+ Args:
336
+ request: An AdminUserCreate model, OR pass fields as keyword arguments.
337
+ **kwargs: Fields for AdminUserCreate if request is not provided.
338
+ Requires: company_id, username, email, role.
339
+
340
+ Returns:
341
+ The newly created AdminUser object.
342
+ """
343
+ body = request if request is not None else self._validate_request(AdminUserCreate, **kwargs)
344
+ response = self._post(
345
+ Endpoints.ADMIN_USERS,
346
+ json=body.model_dump(exclude_none=True, exclude_unset=True, mode="json"),
347
+ )
348
+ return AdminUser.model_validate(response.json())
349
+
350
+ def update_user(
351
+ self, user_id: str, request: AdminUserUpdate | None = None, **kwargs
352
+ ) -> AdminUser:
353
+ """Update any user by ID, bypassing the company-scoped role hierarchy.
354
+
355
+ Set ``is_archived=False`` to restore a soft-deleted user.
356
+
357
+ Args:
358
+ user_id: The ID of the user to update.
359
+ request: An AdminUserUpdate model, OR pass fields as keyword arguments.
360
+ **kwargs: Fields for AdminUserUpdate if request is not provided.
361
+
362
+ Returns:
363
+ The updated AdminUser object.
364
+ """
365
+ body = request if request is not None else self._validate_request(AdminUserUpdate, **kwargs)
366
+ response = self._patch(
367
+ Endpoints.ADMIN_USER.format(user_id=user_id),
368
+ json=body.model_dump(exclude_none=True, exclude_unset=True, mode="json"),
369
+ )
370
+ return AdminUser.model_validate(response.json())
371
+
372
+ def delete_user(self, user_id: str) -> None:
373
+ """Soft-delete a user by ID.
374
+
375
+ The deletion is reversible via ``update_user(user_id, is_archived=False)``.
376
+
377
+ Args:
378
+ user_id: The ID of the user to soft-delete.
379
+ """
380
+ self._delete(Endpoints.ADMIN_USER.format(user_id=user_id))
381
+
186
382
  def create_api_key(self, developer_id: str) -> DeveloperWithApiKey:
187
383
  """Generate a new API key for a developer.
188
384
 
@@ -404,3 +600,47 @@ class SyncGlobalAdminService(SyncBaseService):
404
600
  params=params,
405
601
  ):
406
602
  yield model.model_validate(item)
603
+
604
+ def tail_logs(
605
+ self, *, level: LogLevel | None = None, limit: int = DEFAULT_LOG_TAIL_LIMIT
606
+ ) -> list[ServerLogEntry]:
607
+ """Tail the most recent server log entries, newest first.
608
+
609
+ Inspect on-disk server logs without shelling into the host. Requires global
610
+ admin privileges and that file logging is enabled on the server.
611
+
612
+ Args:
613
+ level: Minimum log level to include. Defaults to the server's configured
614
+ level when omitted.
615
+ limit: Maximum number of entries to return. Clamped to 1-500 (default 100).
616
+
617
+ Returns:
618
+ A list of ServerLogEntry objects, newest first.
619
+
620
+ Raises:
621
+ AuthorizationError: If you don't have global admin privileges.
622
+ ConflictError: If file logging is not enabled on the server.
623
+ """
624
+ clamped_limit = min(max(limit, MIN_LOG_TAIL_LIMIT), MAX_LOG_TAIL_LIMIT)
625
+ params = self._build_params(level=level, limit=clamped_limit)
626
+ response = self._get(Endpoints.ADMIN_LOGS, params=params)
627
+ return [ServerLogEntry.model_validate(item) for item in response.json()]
628
+
629
+ def download_logs(self) -> ServerLogArchive:
630
+ """Download a zip archive of the server log files.
631
+
632
+ Stream the active log file plus rotated backups as a single zip. Requires
633
+ global admin privileges and that file logging is enabled on the server.
634
+
635
+ Returns:
636
+ A ServerLogArchive with the server-provided filename and raw zip bytes.
637
+
638
+ Raises:
639
+ AuthorizationError: If you don't have global admin privileges.
640
+ ConflictError: If file logging is not enabled or no log files exist.
641
+ """
642
+ response = self._get(Endpoints.ADMIN_LOGS_DOWNLOAD, headers={"Accept": "application/zip"})
643
+ filename = _filename_from_content_disposition(
644
+ response.headers.get("Content-Disposition"), fallback="vapi-logs.zip"
645
+ )
646
+ return ServerLogArchive(filename=filename, content=response.content)
@@ -21,6 +21,11 @@ IDEMPOTENT_HTTP_METHODS: frozenset[str] = frozenset({"GET", "PUT", "DELETE"})
21
21
  DEFAULT_PAGE_LIMIT = 10
22
22
  MAX_PAGE_LIMIT = 100
23
23
 
24
+ # Server log tail defaults
25
+ DEFAULT_LOG_TAIL_LIMIT = 100
26
+ MIN_LOG_TAIL_LIMIT = 1
27
+ MAX_LOG_TAIL_LIMIT = 500
28
+
24
29
  # HTTP Status Code Ranges (5xx Server Errors)
25
30
  HTTP_500_INTERNAL_SERVER_ERROR = 500
26
31
  HTTP_600_UPPER_BOUND = 600
@@ -70,14 +75,16 @@ CharacterInventoryType = Literal[
70
75
  "WEAPON",
71
76
  ]
72
77
  CharacterStatus = Literal["ALIVE", "DEAD"]
73
- CharacterType = Literal["PLAYER", "NPC", "STORYTELLER", "DEVELOPER"]
78
+ CharacterType = Literal["PLAYER", "NPC", "STORYTELLER"]
74
79
  DiceSize = Literal[4, 6, 8, 10, 20, 100]
75
80
  FreeTraitChangesPermission = Literal["UNRESTRICTED", "WITHIN_24_HOURS", "STORYTELLER"]
76
81
  GameVersion = Literal["V4", "V5"]
77
82
  GrantXPPermission = Literal["UNRESTRICTED", "PLAYER", "STORYTELLER"]
78
83
  HunterCreed = Literal["ENTREPRENEURIAL", "FAITHFUL", "INQUISITIVE", "MARTIAL", "UNDERGROUND"]
79
84
  HunterEdgeType = Literal["ASSETS", "APTITUDES", "ENDOWMENTS"]
85
+ LogLevel = Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
80
86
  ManageCampaignPermission = Literal["UNRESTRICTED", "STORYTELLER"]
87
+ ManageNPCPermission = Literal["UNRESTRICTED", "STORYTELLER"]
81
88
  PermissionLevel = Literal["USER", "ADMIN", "OWNER", "REVOKE"]
82
89
  RecoupXPPermission = Literal["UNRESTRICTED", "DENIED", "WITHIN_SESSION"]
83
90
  RollResultType = Literal["SUCCESS", "FAILURE", "BOTCH", "CRITICAL", "OTHER"]
@@ -27,6 +27,10 @@ class Endpoints:
27
27
  ADMIN_DEVELOPER = f"{_BASE}/admin/developers/{{developer_id}}"
28
28
  ADMIN_DEVELOPER_NEW_KEY = f"{_BASE}/admin/developers/{{developer_id}}/new-key"
29
29
  ADMIN_DEVELOPER_AUDIT_LOGS = f"{ADMIN_DEVELOPER}/audit-logs"
30
+ ADMIN_LOGS = f"{_BASE}/admin/logs"
31
+ ADMIN_LOGS_DOWNLOAD = f"{ADMIN_LOGS}/download"
32
+ ADMIN_USERS = f"{_BASE}/admin/users"
33
+ ADMIN_USER = f"{_BASE}/admin/users/{{user_id}}"
30
34
 
31
35
  # Developer endpoints (self-service)
32
36
  DEVELOPER_ME = f"{_BASE}/developers/me"
@@ -107,6 +107,8 @@ from .global_admin import (
107
107
  DeveloperCreate,
108
108
  DeveloperUpdate,
109
109
  DeveloperWithApiKey,
110
+ ServerLogArchive,
111
+ ServerLogEntry,
110
112
  )
111
113
  from .pagination import PaginatedResponse
112
114
  from .shared import (
@@ -125,6 +127,9 @@ from .user_lookup import (
125
127
  UserLookupResult,
126
128
  )
127
129
  from .users import (
130
+ AdminUser,
131
+ AdminUserCreate,
132
+ AdminUserUpdate,
128
133
  CampaignExperience,
129
134
  DiscordProfile,
130
135
  DiscordProfileUpdate,
@@ -144,6 +149,9 @@ from .users import (
144
149
  )
145
150
 
146
151
  __all__ = [
152
+ "AdminUser",
153
+ "AdminUserCreate",
154
+ "AdminUserUpdate",
147
155
  "Asset",
148
156
  "AuditLog",
149
157
  "AuditLogDetail",
@@ -222,6 +230,8 @@ __all__ = [
222
230
  "QuickrollCreate",
223
231
  "QuickrollUpdate",
224
232
  "RollStatistics",
233
+ "ServerLogArchive",
234
+ "ServerLogEntry",
225
235
  "SheetSection",
226
236
  "SystemHealth",
227
237
  "Trait",
@@ -190,8 +190,12 @@ class Character(BaseModel):
190
190
  concept_name: str | None = Field(default=None, description="Name of the character concept.")
191
191
 
192
192
  # Relationships
193
- user_creator_id: str = Field(..., description="ID of the user who created the character.")
194
- user_player_id: str = Field(..., description="ID of the user who plays the character.")
193
+ user_creator_id: str | None = Field(
194
+ default=None, description="ID of the user who created the character."
195
+ )
196
+ user_player_id: str | None = Field(
197
+ default=None, description="ID of the user who plays the character."
198
+ )
195
199
  company_id: str = Field(..., description="ID of the company.")
196
200
  campaign_id: str = Field(..., description="ID of the campaign.")
197
201
 
@@ -9,6 +9,7 @@ from vclient.constants import (
9
9
  FreeTraitChangesPermission,
10
10
  GrantXPPermission,
11
11
  ManageCampaignPermission,
12
+ ManageNPCPermission,
12
13
  PermissionLevel,
13
14
  RecoupXPPermission,
14
15
  )
@@ -27,6 +28,7 @@ class CompanySettings(BaseModel):
27
28
  character_autogen_num_choices: int
28
29
  character_autogen_starting_points: int
29
30
  permission_manage_campaign: ManageCampaignPermission
31
+ permission_manage_npc: ManageNPCPermission
30
32
  permission_grant_xp: GrantXPPermission
31
33
  permission_free_trait_changes: FreeTraitChangesPermission
32
34
  permission_recoup_xp: RecoupXPPermission
@@ -42,6 +44,7 @@ class CompanySettingsCreate(BaseModel):
42
44
  character_autogen_num_choices: int | None = None
43
45
  character_autogen_starting_points: int | None = None
44
46
  permission_manage_campaign: ManageCampaignPermission | None = None
47
+ permission_manage_npc: ManageNPCPermission | None = None
45
48
  permission_grant_xp: GrantXPPermission | None = None
46
49
  permission_free_trait_changes: FreeTraitChangesPermission | None = None
47
50
  permission_recoup_xp: RecoupXPPermission | None = None
@@ -57,6 +60,7 @@ class CompanySettingsUpdate(BaseModel):
57
60
  character_autogen_num_choices: int | None = None
58
61
  character_autogen_starting_points: int | None = None
59
62
  permission_manage_campaign: ManageCampaignPermission | None = None
63
+ permission_manage_npc: ManageNPCPermission | None = None
60
64
  permission_grant_xp: GrantXPPermission | None = None
61
65
  permission_free_trait_changes: FreeTraitChangesPermission | None = None
62
66
  permission_recoup_xp: RecoupXPPermission | None = None
@@ -1,8 +1,10 @@
1
1
  """Pydantic models for Global Admin API responses."""
2
2
 
3
+ from dataclasses import dataclass
3
4
  from datetime import datetime
5
+ from typing import Any
4
6
 
5
- from pydantic import BaseModel
7
+ from pydantic import BaseModel, Field
6
8
 
7
9
  from vclient.constants import PermissionLevel
8
10
 
@@ -70,10 +72,40 @@ class DeveloperUpdate(BaseModel):
70
72
  is_global_admin: bool | None = None
71
73
 
72
74
 
75
+ class ServerLogEntry(BaseModel):
76
+ """A single parsed server log entry from the admin logs tail endpoint.
77
+
78
+ Every field is nullable because individual log lines may omit values or fail
79
+ to parse as structured JSON (in which case ``raw`` holds the original line).
80
+ """
81
+
82
+ timestamp: str | None = None
83
+ level: str | None = None
84
+ name: str | None = None
85
+ message: str | None = None
86
+ exception: str | None = None
87
+ extra: dict[str, Any] = Field(default_factory=dict)
88
+ raw: str | None = None
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class ServerLogArchive:
93
+ """A downloaded server-log zip archive.
94
+
95
+ Pairs the server-provided ``Content-Disposition`` filename with the raw zip
96
+ bytes so callers can write the archive straight to disk.
97
+ """
98
+
99
+ filename: str
100
+ content: bytes
101
+
102
+
73
103
  __all__ = [
74
104
  "Developer",
75
105
  "DeveloperCompanyPermission",
76
106
  "DeveloperCreate",
77
107
  "DeveloperUpdate",
78
108
  "DeveloperWithApiKey",
109
+ "ServerLogArchive",
110
+ "ServerLogEntry",
79
111
  ]
@@ -122,6 +122,17 @@ class UserDetail(User):
122
122
  )
123
123
 
124
124
 
125
+ class AdminUser(User):
126
+ """Response model for a user returned by the global-admin user endpoints.
127
+
128
+ Extends the tenant-scoped ``User`` with ``is_archived``, which is always
129
+ present on the admin endpoints so callers can identify soft-deleted users
130
+ directly from the response body.
131
+ """
132
+
133
+ is_archived: bool
134
+
135
+
125
136
  # -----------------------------------------------------------------------------
126
137
  # User Request Models
127
138
  # -----------------------------------------------------------------------------
@@ -194,6 +205,27 @@ class UserUpdate(BaseModel):
194
205
  github_profile: GitHubProfile | None = None
195
206
 
196
207
 
208
+ class AdminUserCreate(UserCreate):
209
+ """Request body for creating a user as a global admin.
210
+
211
+ Extends the tenant-scoped ``UserCreate`` with an explicit ``company_id`` for
212
+ the target company. The server rejects ``UNAPPROVED``/``DEACTIVATED`` roles
213
+ on create, so no client-side role restriction is applied here.
214
+ """
215
+
216
+ company_id: str
217
+
218
+
219
+ class AdminUserUpdate(UserUpdate):
220
+ """Request body for updating any user as a global admin.
221
+
222
+ Extends the tenant-scoped ``UserUpdate`` with ``is_archived``. Set it to
223
+ ``False`` to restore a soft-deleted user.
224
+ """
225
+
226
+ is_archived: bool | None = None
227
+
228
+
197
229
  class UserApproveDTO(BaseModel):
198
230
  """Approve an unapproved user and assign a role."""
199
231
 
@@ -262,6 +294,9 @@ class _ExperienceAddRemove(BaseModel):
262
294
 
263
295
 
264
296
  __all__ = [
297
+ "AdminUser",
298
+ "AdminUserCreate",
299
+ "AdminUserUpdate",
265
300
  "CampaignExperience",
266
301
  "DiscordProfile",
267
302
  "GitHubProfile",