valentina-python-client 1.20.0__tar.gz → 1.21.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 (79) hide show
  1. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/PKG-INFO +1 -1
  2. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/pyproject.toml +1 -1
  3. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/__init__.py +5 -1
  4. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/_codegen.py +5 -0
  5. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/_sync/__init__.py +2 -0
  6. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/_sync/client.py +15 -0
  7. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/_sync/registry.py +24 -0
  8. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/_sync/services/__init__.py +2 -0
  9. valentina_python_client-1.21.0/src/vclient/_sync/services/user_lookup.py +68 -0
  10. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/_sync/services/users.py +38 -8
  11. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/client.py +15 -0
  12. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/constants.py +1 -1
  13. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/endpoints.py +3 -0
  14. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/models/__init__.py +4 -0
  15. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/models/characters.py +2 -6
  16. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/models/companies.py +1 -1
  17. valentina_python_client-1.21.0/src/vclient/models/user_lookup.py +22 -0
  18. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/registry.py +24 -0
  19. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/services/__init__.py +2 -0
  20. valentina_python_client-1.21.0/src/vclient/services/user_lookup.py +67 -0
  21. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/services/users.py +32 -4
  22. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/testing/__init__.py +2 -0
  23. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/testing/_factories.py +8 -0
  24. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/testing/_router.py +3 -0
  25. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/testing/_routes.py +3 -0
  26. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/LICENSE +0 -0
  27. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/README.md +0 -0
  28. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/_sync/services/base.py +0 -0
  29. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/_sync/services/campaign_book_chapters.py +0 -0
  30. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/_sync/services/campaign_books.py +0 -0
  31. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/_sync/services/campaigns.py +0 -0
  32. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/_sync/services/character_autogen.py +0 -0
  33. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/_sync/services/character_blueprint.py +0 -0
  34. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/_sync/services/character_traits.py +0 -0
  35. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/_sync/services/characters.py +0 -0
  36. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/_sync/services/companies.py +0 -0
  37. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/_sync/services/developers.py +0 -0
  38. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/_sync/services/dicerolls.py +0 -0
  39. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/_sync/services/dictionary.py +0 -0
  40. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/_sync/services/global_admin.py +0 -0
  41. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/_sync/services/options.py +0 -0
  42. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/_sync/services/system.py +0 -0
  43. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/_sync/testing/__init__.py +0 -0
  44. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/_sync/testing/_client.py +0 -0
  45. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/config.py +0 -0
  46. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/exceptions.py +0 -0
  47. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/models/books.py +0 -0
  48. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/models/campaigns.py +0 -0
  49. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/models/chapters.py +0 -0
  50. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/models/character_autogen.py +0 -0
  51. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/models/character_blueprint.py +0 -0
  52. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/models/character_trait.py +0 -0
  53. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/models/developers.py +0 -0
  54. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/models/diceroll.py +0 -0
  55. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/models/dictionary.py +0 -0
  56. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/models/full_sheet.py +0 -0
  57. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/models/global_admin.py +0 -0
  58. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/models/pagination.py +0 -0
  59. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/models/shared.py +0 -0
  60. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/models/system.py +0 -0
  61. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/models/users.py +0 -0
  62. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/py.typed +0 -0
  63. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/services/base.py +0 -0
  64. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/services/campaign_book_chapters.py +0 -0
  65. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/services/campaign_books.py +0 -0
  66. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/services/campaigns.py +0 -0
  67. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/services/character_autogen.py +0 -0
  68. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/services/character_blueprint.py +0 -0
  69. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/services/character_traits.py +0 -0
  70. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/services/characters.py +0 -0
  71. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/services/companies.py +0 -0
  72. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/services/developers.py +0 -0
  73. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/services/dicerolls.py +0 -0
  74. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/services/dictionary.py +0 -0
  75. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/services/global_admin.py +0 -0
  76. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/services/options.py +0 -0
  77. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/services/system.py +0 -0
  78. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/testing/_client.py +0 -0
  79. {valentina_python_client-1.20.0 → valentina_python_client-1.21.0}/src/vclient/validate_constants.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: valentina-python-client
3
- Version: 1.20.0
3
+ Version: 1.21.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 = "1.20.0"
19
+ version = "1.21.0"
20
20
 
21
21
  [project.optional-dependencies]
22
22
  testing = ["polyfactory>=3.3.0"]
@@ -49,6 +49,7 @@ from vclient._sync import ( # noqa: E402
49
49
  sync_global_admin_service,
50
50
  sync_options_service,
51
51
  sync_system_service,
52
+ sync_user_lookup_service,
52
53
  sync_users_service,
53
54
  )
54
55
  from vclient.client import VClient # noqa: E402
@@ -67,6 +68,7 @@ from vclient.registry import ( # noqa: E402
67
68
  global_admin_service,
68
69
  options_service,
69
70
  system_service,
71
+ user_lookup_service,
70
72
  users_service,
71
73
  )
72
74
 
@@ -100,9 +102,11 @@ __all__ = (
100
102
  "sync_global_admin_service",
101
103
  "sync_options_service",
102
104
  "sync_system_service",
105
+ "sync_user_lookup_service",
103
106
  "sync_users_service",
104
107
  "system_service",
108
+ "user_lookup_service",
105
109
  "users_service",
106
110
  )
107
111
 
108
- __version__ = "1.20.0"
112
+ __version__ = "1.21.0"
@@ -28,6 +28,7 @@ RENAME_CLASSES: dict[str, str] = {
28
28
  "GlobalAdminService": "SyncGlobalAdminService",
29
29
  "OptionsService": "SyncOptionsService",
30
30
  "SystemService": "SyncSystemService",
31
+ "UserLookupService": "SyncUserLookupService",
31
32
  "UsersService": "SyncUsersService",
32
33
  "VClient": "SyncVClient",
33
34
  "FakeVClient": "SyncFakeVClient",
@@ -49,6 +50,7 @@ FACTORY_RENAMES: dict[str, str] = {
49
50
  "dicerolls_service": "sync_dicerolls_service",
50
51
  "options_service": "sync_options_service",
51
52
  "character_autogen_service": "sync_character_autogen_service",
53
+ "user_lookup_service": "sync_user_lookup_service",
52
54
  "configure_default_client": "sync_configure_default_client",
53
55
  "clear_default_client": "sync_clear_default_client",
54
56
  "default_client": "sync_default_client",
@@ -73,6 +75,7 @@ IMPORT_REWRITES: dict[str, str] = {
73
75
  "vclient.services.dicerolls": "vclient._sync.services.dicerolls",
74
76
  "vclient.services.options": "vclient._sync.services.options",
75
77
  "vclient.services.character_autogen": "vclient._sync.services.character_autogen",
78
+ "vclient.services.user_lookup": "vclient._sync.services.user_lookup",
76
79
  "vclient.registry": "vclient._sync.registry",
77
80
  "vclient.testing._client": "vclient._sync.testing._client",
78
81
  }
@@ -307,6 +310,7 @@ def _write_sync_init(path: Path) -> None:
307
310
  " sync_global_admin_service,",
308
311
  " sync_options_service,",
309
312
  " sync_system_service,",
313
+ " sync_user_lookup_service,",
310
314
  " sync_users_service,",
311
315
  ")",
312
316
  "",
@@ -329,6 +333,7 @@ def _write_sync_init(path: Path) -> None:
329
333
  ' "sync_global_admin_service",',
330
334
  ' "sync_options_service",',
331
335
  ' "sync_system_service",',
336
+ ' "sync_user_lookup_service",',
332
337
  ' "sync_users_service",',
333
338
  "]",
334
339
  "",
@@ -19,6 +19,7 @@ from vclient._sync.registry import (
19
19
  sync_global_admin_service,
20
20
  sync_options_service,
21
21
  sync_system_service,
22
+ sync_user_lookup_service,
22
23
  sync_users_service,
23
24
  )
24
25
 
@@ -41,5 +42,6 @@ __all__ = [
41
42
  "sync_global_admin_service",
42
43
  "sync_options_service",
43
44
  "sync_system_service",
45
+ "sync_user_lookup_service",
44
46
  "sync_users_service",
45
47
  ]
@@ -37,6 +37,7 @@ if TYPE_CHECKING:
37
37
  SyncGlobalAdminService,
38
38
  SyncOptionsService,
39
39
  SyncSystemService,
40
+ SyncUserLookupService,
40
41
  SyncUsersService,
41
42
  )
42
43
 
@@ -147,6 +148,7 @@ class SyncVClient:
147
148
  self._developer: SyncDeveloperService | None = None
148
149
  self._global_admin: SyncGlobalAdminService | None = None
149
150
  self._system: SyncSystemService | None = None
151
+ self._user_lookup: SyncUserLookupService | None = None
150
152
  if set_as_default:
151
153
  from vclient._sync.registry import sync_configure_default_client
152
154
 
@@ -278,6 +280,19 @@ class SyncVClient:
278
280
  self._system = SyncSystemService(self)
279
281
  return self._system
280
282
 
283
+ @property
284
+ def user_lookup(self) -> "SyncUserLookupService":
285
+ """Access the User Lookup service for cross-company user discovery.
286
+
287
+ Returns:
288
+ The SyncUserLookupService instance for user lookup operations.
289
+ """
290
+ if self._user_lookup is None:
291
+ from vclient._sync.services.user_lookup import SyncUserLookupService
292
+
293
+ self._user_lookup = SyncUserLookupService(self)
294
+ return self._user_lookup
295
+
281
296
  def users(self, company_id: str | None = None) -> "SyncUsersService":
282
297
  """Get a SyncUsersService scoped to a specific company.
283
298
 
@@ -38,6 +38,7 @@ if TYPE_CHECKING:
38
38
  SyncGlobalAdminService,
39
39
  SyncOptionsService,
40
40
  SyncSystemService,
41
+ SyncUserLookupService,
41
42
  SyncUsersService,
42
43
  )
43
44
  _default_client: "SyncVClient | None" = None
@@ -189,6 +190,29 @@ def sync_system_service() -> "SyncSystemService":
189
190
  return SyncSystemService(sync_default_client())
190
191
 
191
192
 
193
+ def sync_user_lookup_service() -> "SyncUserLookupService":
194
+ """Create a SyncUserLookupService using the default client.
195
+
196
+ Discover which companies a person has a user record in by searching
197
+ via email, Discord ID, Google ID, or GitHub ID.
198
+
199
+ Returns:
200
+ SyncUserLookupService: A service instance for cross-company user lookup.
201
+
202
+ Raises:
203
+ RuntimeError: If no default client has been configured.
204
+
205
+ Example:
206
+ ```python
207
+ lookup = sync_user_lookup_service()
208
+ results = await lookup.by_email("alice@example.com")
209
+ ```
210
+ """
211
+ from vclient._sync.services.user_lookup import SyncUserLookupService
212
+
213
+ return SyncUserLookupService(sync_default_client())
214
+
215
+
192
216
  def sync_users_service(company_id: str | None = None) -> "SyncUsersService":
193
217
  """Create a SyncUsersService scoped to a specific company using the default client.
194
218
 
@@ -16,6 +16,7 @@ from .dictionary import SyncDictionaryService
16
16
  from .global_admin import SyncGlobalAdminService
17
17
  from .options import SyncOptionsService
18
18
  from .system import SyncSystemService
19
+ from .user_lookup import SyncUserLookupService
19
20
  from .users import SyncUsersService
20
21
 
21
22
  __all__ = [
@@ -34,5 +35,6 @@ __all__ = [
34
35
  "SyncGlobalAdminService",
35
36
  "SyncOptionsService",
36
37
  "SyncSystemService",
38
+ "SyncUserLookupService",
37
39
  "SyncUsersService",
38
40
  ]
@@ -0,0 +1,68 @@
1
+ # AUTO-GENERATED — do not edit. Run 'uv run duty generate_sync' to regenerate.
2
+ """Service for cross-company user lookup."""
3
+
4
+ from vclient._sync.services.base import SyncBaseService
5
+ from vclient.endpoints import Endpoints
6
+ from vclient.models import UserLookupResult
7
+
8
+
9
+ class SyncUserLookupService(SyncBaseService):
10
+ """Service for looking up users across companies.
11
+
12
+ Discover which companies a person has a user record in by searching
13
+ via email, Discord ID, Google ID, or GitHub ID.
14
+
15
+ Example:
16
+ >>> async with SyncVClient() as client:
17
+ ... results = await client.user_lookup.by_email("alice@example.com")
18
+ ... for r in results:
19
+ ... print(f"{r.company_name}: {r.role}")
20
+ """
21
+
22
+ def by_email(self, email: str) -> list[UserLookupResult]:
23
+ """Look up a user by email address.
24
+
25
+ Args:
26
+ email: Exact email address to search for.
27
+
28
+ Returns:
29
+ List of companies where a matching user was found. Empty list if no matches.
30
+ """
31
+ response = self._get(Endpoints.USERS_LOOKUP, params={"email": email})
32
+ return [UserLookupResult.model_validate(item) for item in response.json()]
33
+
34
+ def by_discord_id(self, discord_id: str) -> list[UserLookupResult]:
35
+ """Look up a user by Discord profile ID.
36
+
37
+ Args:
38
+ discord_id: Discord profile ID to search for.
39
+
40
+ Returns:
41
+ List of companies where a matching user was found. Empty list if no matches.
42
+ """
43
+ response = self._get(Endpoints.USERS_LOOKUP, params={"discord_id": discord_id})
44
+ return [UserLookupResult.model_validate(item) for item in response.json()]
45
+
46
+ def by_google_id(self, google_id: str) -> list[UserLookupResult]:
47
+ """Look up a user by Google profile ID.
48
+
49
+ Args:
50
+ google_id: Google profile ID to search for.
51
+
52
+ Returns:
53
+ List of companies where a matching user was found. Empty list if no matches.
54
+ """
55
+ response = self._get(Endpoints.USERS_LOOKUP, params={"google_id": google_id})
56
+ return [UserLookupResult.model_validate(item) for item in response.json()]
57
+
58
+ def by_github_id(self, github_id: str) -> list[UserLookupResult]:
59
+ """Look up a user by GitHub profile ID.
60
+
61
+ Args:
62
+ github_id: GitHub profile ID to search for.
63
+
64
+ Returns:
65
+ List of companies where a matching user was found. Empty list if no matches.
66
+ """
67
+ response = self._get(Endpoints.USERS_LOOKUP, params={"github_id": github_id})
68
+ return [UserLookupResult.model_validate(item) for item in response.json()]
@@ -65,13 +65,14 @@ class SyncUsersService(SyncBaseService):
65
65
  return endpoint.format(company_id=self._company_id, **kwargs)
66
66
 
67
67
  def get_unapproved_page(
68
- self, *, limit: int = DEFAULT_PAGE_LIMIT, offset: int = 0
68
+ self, requesting_user_id: str, *, limit: int = DEFAULT_PAGE_LIMIT, offset: int = 0
69
69
  ) -> PaginatedResponse[User]:
70
70
  """Retrieve a paginated page of unapproved users within a company.
71
71
 
72
72
  Unapproved users have registered but have not yet been approved by an admin.
73
73
 
74
74
  Args:
75
+ requesting_user_id: ID of the user making the request (for permissions).
75
76
  limit: Maximum number of items to return (0-100, default 10).
76
77
  offset: Number of items to skip from the beginning (default 0).
77
78
 
@@ -79,40 +80,54 @@ class SyncUsersService(SyncBaseService):
79
80
  A PaginatedResponse containing User objects and pagination metadata.
80
81
  """
81
82
  return self._get_paginated_as(
82
- self._format_endpoint(Endpoints.USERS_UNAPPROVED_LIST), User, limit=limit, offset=offset
83
+ self._format_endpoint(Endpoints.USERS_UNAPPROVED_LIST),
84
+ User,
85
+ limit=limit,
86
+ offset=offset,
87
+ params=self._build_params(requesting_user_id=requesting_user_id),
83
88
  )
84
89
 
85
- def list_all_unapproved(self) -> list[User]:
90
+ def list_all_unapproved(self, requesting_user_id: str) -> list[User]:
86
91
  """Retrieve all unapproved users within a company.
87
92
 
88
93
  Automatically paginates through all results. Use `get_unapproved_page()` for
89
94
  paginated access or `iter_all_unapproved()` for memory-efficient streaming.
90
95
 
96
+ Args:
97
+ requesting_user_id: ID of the user making the request (for permissions).
98
+
91
99
  Returns:
92
100
  A list of all unapproved User objects.
93
101
  """
94
- return [user for user in self.iter_all_unapproved()]
102
+ return [user for user in self.iter_all_unapproved(requesting_user_id)]
95
103
 
96
- def iter_all_unapproved(self, *, limit: int = 100) -> Iterator[User]:
104
+ def iter_all_unapproved(self, requesting_user_id: str, *, limit: int = 100) -> Iterator[User]:
97
105
  """Iterate through all unapproved users within a company.
98
106
 
99
107
  Yields individual unapproved users, automatically fetching subsequent pages
100
108
  until all items have been retrieved.
101
109
 
102
110
  Args:
111
+ requesting_user_id: ID of the user making the request (for permissions).
103
112
  limit: Items per page (default 100 for efficiency).
104
113
 
105
114
  Yields:
106
115
  Individual User objects.
107
116
  """
108
117
  for item in self._iter_all_pages(
109
- self._format_endpoint(Endpoints.USERS_UNAPPROVED_LIST), limit=limit
118
+ self._format_endpoint(Endpoints.USERS_UNAPPROVED_LIST),
119
+ limit=limit,
120
+ params=self._build_params(requesting_user_id=requesting_user_id),
110
121
  ):
111
122
  yield User.model_validate(item)
112
123
 
113
124
  def approve_user(self, user_id: str, role: UserRole, requesting_user_id: str) -> User:
114
125
  """Approve an unapproved user and assign them a role.
115
126
 
127
+ The assigned ``role`` is validated through the server-side role-assignment
128
+ hierarchy — for example, a STORYTELLER cannot approve a user directly to
129
+ ADMIN, and only ADMIN may approve a user to ADMIN or DEACTIVATED.
130
+
116
131
  Args:
117
132
  user_id: The ID of the unapproved user to approve.
118
133
  role: The role to assign to the approved user.
@@ -123,7 +138,8 @@ class SyncUsersService(SyncBaseService):
123
138
 
124
139
  Raises:
125
140
  NotFoundError: If the user does not exist.
126
- AuthorizationError: If you don't have appropriate access.
141
+ AuthorizationError: If the requesting user lacks permission to assign
142
+ the requested role under the hierarchy.
127
143
  """
128
144
  body = UserApproveDTO(role=role, requesting_user_id=requesting_user_id)
129
145
  response = self._post(
@@ -285,6 +301,10 @@ class SyncUsersService(SyncBaseService):
285
301
  is optional and is not used for authentication but is included for Discord bot
286
302
  integration.
287
303
 
304
+ The initial ``role`` cannot be ``UNAPPROVED`` (use :meth:`register` for SSO
305
+ onboarding) or ``DEACTIVATED`` (not a creation path); either will surface as
306
+ ``ValidationError``.
307
+
288
308
  Args:
289
309
  request: A UserCreate model, OR pass fields as keyword arguments.
290
310
  **kwargs: Fields for UserCreate if request is not provided.
@@ -341,6 +361,14 @@ class SyncUsersService(SyncBaseService):
341
361
 
342
362
  Only include fields that need to be changed; omitted fields remain unchanged.
343
363
 
364
+ Setting ``role="DEACTIVATED"`` is the canonical way to deactivate a user:
365
+ the account can no longer log in or act, but their characters, assets, XP,
366
+ and notes remain intact and manageable by other users. Reactivate by calling
367
+ this same endpoint with any other valid role. Role changes are subject to a
368
+ server-side role-assignment hierarchy (e.g. only ADMIN may assign or remove
369
+ ADMIN/DEACTIVATED) and the server refuses to demote or deactivate the last
370
+ remaining active admin.
371
+
344
372
  Args:
345
373
  user_id: The ID of the user to update.
346
374
  request: A UserUpdate model, OR pass fields as keyword arguments.
@@ -354,7 +382,9 @@ class SyncUsersService(SyncBaseService):
354
382
 
355
383
  Raises:
356
384
  NotFoundError: If the user does not exist.
357
- AuthorizationError: If you don't have appropriate access.
385
+ AuthorizationError: If the requesting user lacks permission to make
386
+ the change or to assign the requested role under the hierarchy.
387
+ ConflictError: If the change would remove the last active admin.
358
388
  RequestValidationError: If the input parameters fail client-side validation.
359
389
  ValidationError: If the request data is invalid.
360
390
  """
@@ -36,6 +36,7 @@ if TYPE_CHECKING:
36
36
  GlobalAdminService,
37
37
  OptionsService,
38
38
  SystemService,
39
+ UserLookupService,
39
40
  UsersService,
40
41
  )
41
42
 
@@ -150,6 +151,7 @@ class VClient:
150
151
  self._developer: DeveloperService | None = None
151
152
  self._global_admin: GlobalAdminService | None = None
152
153
  self._system: SystemService | None = None
154
+ self._user_lookup: UserLookupService | None = None
153
155
 
154
156
  if set_as_default:
155
157
  from vclient.registry import configure_default_client
@@ -290,6 +292,19 @@ class VClient:
290
292
  self._system = SystemService(self)
291
293
  return self._system
292
294
 
295
+ @property
296
+ def user_lookup(self) -> "UserLookupService":
297
+ """Access the User Lookup service for cross-company user discovery.
298
+
299
+ Returns:
300
+ The UserLookupService instance for user lookup operations.
301
+ """
302
+ if self._user_lookup is None:
303
+ from vclient.services.user_lookup import UserLookupService
304
+
305
+ self._user_lookup = UserLookupService(self)
306
+ return self._user_lookup
307
+
293
308
  def users(self, company_id: str | None = None) -> "UsersService":
294
309
  """Get a UsersService scoped to a specific company.
295
310
 
@@ -63,7 +63,7 @@ RecoupXPPermission = Literal["UNRESTRICTED", "DENIED", "WITHIN_SESSION"]
63
63
  RollResultType = Literal["SUCCESS", "FAILURE", "BOTCH", "CRITICAL", "OTHER"]
64
64
  AssetType = Literal["image", "text", "audio", "video", "document", "archive", "other"]
65
65
  SpecialtyType = Literal["ACTION", "OTHER", "PASSIVE", "RITUAL", "SPELL"]
66
- UserRole = Literal["ADMIN", "STORYTELLER", "PLAYER", "UNAPPROVED"]
66
+ UserRole = Literal["ADMIN", "STORYTELLER", "PLAYER", "UNAPPROVED", "DEACTIVATED"]
67
67
  WerewolfRenown = Literal["HONOR", "GLORY", "WISDOM"]
68
68
  BlueprintTraitOrderBy = Literal["NAME", "SHEET"]
69
69
  TraitModifyCurrency = Literal["XP", "STARTING_POINTS", "NO_COST"]
@@ -19,6 +19,9 @@ class Endpoints:
19
19
  # System endpoints
20
20
  HEALTH = f"{_BASE}/health"
21
21
 
22
+ # User lookup (cross-company, not scoped)
23
+ USERS_LOOKUP = f"{_BASE}/users/lookup"
24
+
22
25
  # Global Admin endpoints
23
26
  ADMIN_DEVELOPERS = f"{_BASE}/admin/developers"
24
27
  ADMIN_DEVELOPER = f"{_BASE}/admin/developers/{{developer_id}}"
@@ -115,6 +115,9 @@ from .shared import (
115
115
  Trait,
116
116
  )
117
117
  from .system import SystemHealth
118
+ from .user_lookup import (
119
+ UserLookupResult,
120
+ )
118
121
  from .users import (
119
122
  CampaignExperience,
120
123
  DiscordProfile,
@@ -221,6 +224,7 @@ __all__ = [
221
224
  "UserCreate",
222
225
  "UserDenyDTO",
223
226
  "UserDetail",
227
+ "UserLookupResult",
224
228
  "UserMergeDTO",
225
229
  "UserRegisterDTO",
226
230
  "UserUpdate",
@@ -152,12 +152,8 @@ class Character(BaseModel):
152
152
  """
153
153
 
154
154
  id: str = Field(..., description="MongoDB document ObjectID.")
155
- date_created: datetime | None = Field(
156
- default=None, description="Timestamp when the character was created."
157
- )
158
- date_modified: datetime | None = Field(
159
- default=None, description="Timestamp when the character was last modified."
160
- )
155
+ date_created: datetime
156
+ date_modified: datetime
161
157
  date_killed: datetime | None = Field(
162
158
  default=None, description="Timestamp when the character was killed."
163
159
  )
@@ -43,7 +43,7 @@ class Company(BaseModel):
43
43
  description: str | None
44
44
  email: str
45
45
  resources_modified_at: datetime
46
- settings: CompanySettings | None
46
+ settings: CompanySettings
47
47
 
48
48
 
49
49
  class CompanyPermissions(BaseModel):
@@ -0,0 +1,22 @@
1
+ """Pydantic models for User Lookup API responses."""
2
+
3
+ from pydantic import BaseModel
4
+
5
+ from vclient.constants import UserRole
6
+
7
+
8
+ class UserLookupResult(BaseModel):
9
+ """A single result from a cross-company user lookup.
10
+
11
+ Each result represents a company where the looked-up person has a user record.
12
+ """
13
+
14
+ company_id: str
15
+ company_name: str
16
+ user_id: str
17
+ role: UserRole
18
+
19
+
20
+ __all__ = [
21
+ "UserLookupResult",
22
+ ]
@@ -37,6 +37,7 @@ if TYPE_CHECKING:
37
37
  GlobalAdminService,
38
38
  OptionsService,
39
39
  SystemService,
40
+ UserLookupService,
40
41
  UsersService,
41
42
  )
42
43
 
@@ -189,6 +190,29 @@ def system_service() -> "SystemService":
189
190
  return SystemService(default_client())
190
191
 
191
192
 
193
+ def user_lookup_service() -> "UserLookupService":
194
+ """Create a UserLookupService using the default client.
195
+
196
+ Discover which companies a person has a user record in by searching
197
+ via email, Discord ID, Google ID, or GitHub ID.
198
+
199
+ Returns:
200
+ UserLookupService: A service instance for cross-company user lookup.
201
+
202
+ Raises:
203
+ RuntimeError: If no default client has been configured.
204
+
205
+ Example:
206
+ ```python
207
+ lookup = user_lookup_service()
208
+ results = await lookup.by_email("alice@example.com")
209
+ ```
210
+ """
211
+ from vclient.services.user_lookup import UserLookupService
212
+
213
+ return UserLookupService(default_client())
214
+
215
+
192
216
  def users_service(company_id: str | None = None) -> "UsersService":
193
217
  """Create a UsersService scoped to a specific company using the default client.
194
218
 
@@ -15,6 +15,7 @@ from .dictionary import DictionaryService
15
15
  from .global_admin import GlobalAdminService
16
16
  from .options import OptionsService
17
17
  from .system import SystemService
18
+ from .user_lookup import UserLookupService
18
19
  from .users import UsersService
19
20
 
20
21
  __all__ = [
@@ -33,5 +34,6 @@ __all__ = [
33
34
  "GlobalAdminService",
34
35
  "OptionsService",
35
36
  "SystemService",
37
+ "UserLookupService",
36
38
  "UsersService",
37
39
  ]
@@ -0,0 +1,67 @@
1
+ """Service for cross-company user lookup."""
2
+
3
+ from vclient.endpoints import Endpoints
4
+ from vclient.models import UserLookupResult
5
+ from vclient.services.base import BaseService
6
+
7
+
8
+ class UserLookupService(BaseService):
9
+ """Service for looking up users across companies.
10
+
11
+ Discover which companies a person has a user record in by searching
12
+ via email, Discord ID, Google ID, or GitHub ID.
13
+
14
+ Example:
15
+ >>> async with VClient() as client:
16
+ ... results = await client.user_lookup.by_email("alice@example.com")
17
+ ... for r in results:
18
+ ... print(f"{r.company_name}: {r.role}")
19
+ """
20
+
21
+ async def by_email(self, email: str) -> list[UserLookupResult]:
22
+ """Look up a user by email address.
23
+
24
+ Args:
25
+ email: Exact email address to search for.
26
+
27
+ Returns:
28
+ List of companies where a matching user was found. Empty list if no matches.
29
+ """
30
+ response = await self._get(Endpoints.USERS_LOOKUP, params={"email": email})
31
+ return [UserLookupResult.model_validate(item) for item in response.json()]
32
+
33
+ async def by_discord_id(self, discord_id: str) -> list[UserLookupResult]:
34
+ """Look up a user by Discord profile ID.
35
+
36
+ Args:
37
+ discord_id: Discord profile ID to search for.
38
+
39
+ Returns:
40
+ List of companies where a matching user was found. Empty list if no matches.
41
+ """
42
+ response = await self._get(Endpoints.USERS_LOOKUP, params={"discord_id": discord_id})
43
+ return [UserLookupResult.model_validate(item) for item in response.json()]
44
+
45
+ async def by_google_id(self, google_id: str) -> list[UserLookupResult]:
46
+ """Look up a user by Google profile ID.
47
+
48
+ Args:
49
+ google_id: Google profile ID to search for.
50
+
51
+ Returns:
52
+ List of companies where a matching user was found. Empty list if no matches.
53
+ """
54
+ response = await self._get(Endpoints.USERS_LOOKUP, params={"google_id": google_id})
55
+ return [UserLookupResult.model_validate(item) for item in response.json()]
56
+
57
+ async def by_github_id(self, github_id: str) -> list[UserLookupResult]:
58
+ """Look up a user by GitHub profile ID.
59
+
60
+ Args:
61
+ github_id: GitHub profile ID to search for.
62
+
63
+ Returns:
64
+ List of companies where a matching user was found. Empty list if no matches.
65
+ """
66
+ response = await self._get(Endpoints.USERS_LOOKUP, params={"github_id": github_id})
67
+ return [UserLookupResult.model_validate(item) for item in response.json()]
@@ -69,6 +69,7 @@ class UsersService(BaseService):
69
69
 
70
70
  async def get_unapproved_page(
71
71
  self,
72
+ requesting_user_id: str,
72
73
  *,
73
74
  limit: int = DEFAULT_PAGE_LIMIT,
74
75
  offset: int = 0,
@@ -78,6 +79,7 @@ class UsersService(BaseService):
78
79
  Unapproved users have registered but have not yet been approved by an admin.
79
80
 
80
81
  Args:
82
+ requesting_user_id: ID of the user making the request (for permissions).
81
83
  limit: Maximum number of items to return (0-100, default 10).
82
84
  offset: Number of items to skip from the beginning (default 0).
83
85
 
@@ -89,21 +91,26 @@ class UsersService(BaseService):
89
91
  User,
90
92
  limit=limit,
91
93
  offset=offset,
94
+ params=self._build_params(requesting_user_id=requesting_user_id),
92
95
  )
93
96
 
94
- async def list_all_unapproved(self) -> list[User]:
97
+ async def list_all_unapproved(self, requesting_user_id: str) -> list[User]:
95
98
  """Retrieve all unapproved users within a company.
96
99
 
97
100
  Automatically paginates through all results. Use `get_unapproved_page()` for
98
101
  paginated access or `iter_all_unapproved()` for memory-efficient streaming.
99
102
 
103
+ Args:
104
+ requesting_user_id: ID of the user making the request (for permissions).
105
+
100
106
  Returns:
101
107
  A list of all unapproved User objects.
102
108
  """
103
- return [user async for user in self.iter_all_unapproved()]
109
+ return [user async for user in self.iter_all_unapproved(requesting_user_id)]
104
110
 
105
111
  async def iter_all_unapproved(
106
112
  self,
113
+ requesting_user_id: str,
107
114
  *,
108
115
  limit: int = 100,
109
116
  ) -> AsyncIterator[User]:
@@ -113,6 +120,7 @@ class UsersService(BaseService):
113
120
  until all items have been retrieved.
114
121
 
115
122
  Args:
123
+ requesting_user_id: ID of the user making the request (for permissions).
116
124
  limit: Items per page (default 100 for efficiency).
117
125
 
118
126
  Yields:
@@ -121,6 +129,7 @@ class UsersService(BaseService):
121
129
  async for item in self._iter_all_pages(
122
130
  self._format_endpoint(Endpoints.USERS_UNAPPROVED_LIST),
123
131
  limit=limit,
132
+ params=self._build_params(requesting_user_id=requesting_user_id),
124
133
  ):
125
134
  yield User.model_validate(item)
126
135
 
@@ -132,6 +141,10 @@ class UsersService(BaseService):
132
141
  ) -> User:
133
142
  """Approve an unapproved user and assign them a role.
134
143
 
144
+ The assigned ``role`` is validated through the server-side role-assignment
145
+ hierarchy — for example, a STORYTELLER cannot approve a user directly to
146
+ ADMIN, and only ADMIN may approve a user to ADMIN or DEACTIVATED.
147
+
135
148
  Args:
136
149
  user_id: The ID of the unapproved user to approve.
137
150
  role: The role to assign to the approved user.
@@ -142,7 +155,8 @@ class UsersService(BaseService):
142
155
 
143
156
  Raises:
144
157
  NotFoundError: If the user does not exist.
145
- AuthorizationError: If you don't have appropriate access.
158
+ AuthorizationError: If the requesting user lacks permission to assign
159
+ the requested role under the hierarchy.
146
160
  """
147
161
  body = UserApproveDTO(role=role, requesting_user_id=requesting_user_id)
148
162
  response = await self._post(
@@ -333,6 +347,10 @@ class UsersService(BaseService):
333
347
  is optional and is not used for authentication but is included for Discord bot
334
348
  integration.
335
349
 
350
+ The initial ``role`` cannot be ``UNAPPROVED`` (use :meth:`register` for SSO
351
+ onboarding) or ``DEACTIVATED`` (not a creation path); either will surface as
352
+ ``ValidationError``.
353
+
336
354
  Args:
337
355
  request: A UserCreate model, OR pass fields as keyword arguments.
338
356
  **kwargs: Fields for UserCreate if request is not provided.
@@ -398,6 +416,14 @@ class UsersService(BaseService):
398
416
 
399
417
  Only include fields that need to be changed; omitted fields remain unchanged.
400
418
 
419
+ Setting ``role="DEACTIVATED"`` is the canonical way to deactivate a user:
420
+ the account can no longer log in or act, but their characters, assets, XP,
421
+ and notes remain intact and manageable by other users. Reactivate by calling
422
+ this same endpoint with any other valid role. Role changes are subject to a
423
+ server-side role-assignment hierarchy (e.g. only ADMIN may assign or remove
424
+ ADMIN/DEACTIVATED) and the server refuses to demote or deactivate the last
425
+ remaining active admin.
426
+
401
427
  Args:
402
428
  user_id: The ID of the user to update.
403
429
  request: A UserUpdate model, OR pass fields as keyword arguments.
@@ -411,7 +437,9 @@ class UsersService(BaseService):
411
437
 
412
438
  Raises:
413
439
  NotFoundError: If the user does not exist.
414
- AuthorizationError: If you don't have appropriate access.
440
+ AuthorizationError: If the requesting user lacks permission to make
441
+ the change or to assign the requested role under the hierarchy.
442
+ ConflictError: If the change would remove the last active admin.
415
443
  RequestValidationError: If the input parameters fail client-side validation.
416
444
  ValidationError: If the request data is invalid.
417
445
  """
@@ -57,6 +57,7 @@ from vclient.testing._factories import (
57
57
  TraitSubcategoryFactory,
58
58
  UserDetailFactory,
59
59
  UserFactory,
60
+ UserLookupResultFactory,
60
61
  VampireClanFactory,
61
62
  WerewolfAuspiceFactory,
62
63
  WerewolfTribeFactory,
@@ -110,6 +111,7 @@ __all__ = [
110
111
  "TraitSubcategoryFactory",
111
112
  "UserDetailFactory",
112
113
  "UserFactory",
114
+ "UserLookupResultFactory",
113
115
  "VampireClanFactory",
114
116
  "WerewolfAuspiceFactory",
115
117
  "WerewolfTribeFactory",
@@ -43,6 +43,7 @@ from vclient.models import (
43
43
  TraitSubcategory,
44
44
  User,
45
45
  UserDetail,
46
+ UserLookupResult,
46
47
  VampireClan,
47
48
  WerewolfAuspice,
48
49
  WerewolfTribe,
@@ -259,6 +260,12 @@ class UserFactory(ModelFactory[User]):
259
260
  role = "PLAYER"
260
261
 
261
262
 
263
+ class UserLookupResultFactory(ModelFactory[UserLookupResult]):
264
+ __model__ = UserLookupResult
265
+ __use_defaults__ = True
266
+ role = "PLAYER"
267
+
268
+
262
269
  class UserDetailFactory(ModelFactory[UserDetail]):
263
270
  __model__ = UserDetail
264
271
  __use_defaults__ = True
@@ -323,6 +330,7 @@ __all__ = [
323
330
  "TraitSubcategoryFactory",
324
331
  "UserDetailFactory",
325
332
  "UserFactory",
333
+ "UserLookupResultFactory",
326
334
  "VampireClanFactory",
327
335
  "WerewolfAuspiceFactory",
328
336
  "WerewolfTribeFactory",
@@ -48,6 +48,7 @@ from vclient.models import (
48
48
  TraitSubcategory,
49
49
  User,
50
50
  UserDetail,
51
+ UserLookupResult,
51
52
  VampireClan,
52
53
  WerewolfAuspice,
53
54
  WerewolfTribe,
@@ -91,6 +92,7 @@ from vclient.testing._factories import (
91
92
  TraitSubcategoryFactory,
92
93
  UserDetailFactory,
93
94
  UserFactory,
95
+ UserLookupResultFactory,
94
96
  VampireClanFactory,
95
97
  WerewolfAuspiceFactory,
96
98
  WerewolfTribeFactory,
@@ -133,6 +135,7 @@ _FACTORY_MAP: dict[type, type[ModelFactory]] = {
133
135
  TraitCategory: TraitCategoryFactory,
134
136
  TraitSubcategory: TraitSubcategoryFactory,
135
137
  User: UserFactory,
138
+ UserLookupResult: UserLookupResultFactory,
136
139
  UserDetail: UserDetailFactory,
137
140
  VampireClan: VampireClanFactory,
138
141
  WerewolfAuspice: WerewolfAuspiceFactory,
@@ -40,6 +40,7 @@ from vclient.models import (
40
40
  TraitSubcategory,
41
41
  User,
42
42
  UserDetail,
43
+ UserLookupResult,
43
44
  VampireClan,
44
45
  WerewolfAuspice,
45
46
  WerewolfTribe,
@@ -131,6 +132,8 @@ class Routes:
131
132
  USERS_REGISTER = RouteSpec("POST", Endpoints.USER_REGISTER, SINGLE, User)
132
133
  USERS_MERGE = RouteSpec("POST", Endpoints.USER_MERGE, SINGLE, User)
133
134
  USERS_STATISTICS = RouteSpec("GET", Endpoints.USER_STATISTICS, SINGLE, RollStatistics)
135
+ # User lookup (cross-company)
136
+ USERS_LOOKUP = RouteSpec("GET", Endpoints.USERS_LOOKUP, LIST, UserLookupResult)
134
137
 
135
138
  # User assets
136
139
  USERS_ASSETS_LIST = RouteSpec("GET", Endpoints.USER_ASSETS, PAGINATED, Asset)