arize-phoenix 10.0.4__py3-none-any.whl → 12.28.1__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 (276) hide show
  1. {arize_phoenix-10.0.4.dist-info → arize_phoenix-12.28.1.dist-info}/METADATA +124 -72
  2. arize_phoenix-12.28.1.dist-info/RECORD +499 -0
  3. {arize_phoenix-10.0.4.dist-info → arize_phoenix-12.28.1.dist-info}/WHEEL +1 -1
  4. {arize_phoenix-10.0.4.dist-info → arize_phoenix-12.28.1.dist-info}/licenses/IP_NOTICE +1 -1
  5. phoenix/__generated__/__init__.py +0 -0
  6. phoenix/__generated__/classification_evaluator_configs/__init__.py +20 -0
  7. phoenix/__generated__/classification_evaluator_configs/_document_relevance_classification_evaluator_config.py +17 -0
  8. phoenix/__generated__/classification_evaluator_configs/_hallucination_classification_evaluator_config.py +17 -0
  9. phoenix/__generated__/classification_evaluator_configs/_models.py +18 -0
  10. phoenix/__generated__/classification_evaluator_configs/_tool_selection_classification_evaluator_config.py +17 -0
  11. phoenix/__init__.py +5 -4
  12. phoenix/auth.py +39 -2
  13. phoenix/config.py +1763 -91
  14. phoenix/datetime_utils.py +120 -2
  15. phoenix/db/README.md +595 -25
  16. phoenix/db/bulk_inserter.py +145 -103
  17. phoenix/db/engines.py +140 -33
  18. phoenix/db/enums.py +3 -12
  19. phoenix/db/facilitator.py +302 -35
  20. phoenix/db/helpers.py +1000 -65
  21. phoenix/db/iam_auth.py +64 -0
  22. phoenix/db/insertion/dataset.py +135 -2
  23. phoenix/db/insertion/document_annotation.py +9 -6
  24. phoenix/db/insertion/evaluation.py +2 -3
  25. phoenix/db/insertion/helpers.py +17 -2
  26. phoenix/db/insertion/session_annotation.py +176 -0
  27. phoenix/db/insertion/span.py +15 -11
  28. phoenix/db/insertion/span_annotation.py +3 -4
  29. phoenix/db/insertion/trace_annotation.py +3 -4
  30. phoenix/db/insertion/types.py +50 -20
  31. phoenix/db/migrations/versions/01a8342c9cdf_add_user_id_on_datasets.py +40 -0
  32. phoenix/db/migrations/versions/0df286449799_add_session_annotations_table.py +105 -0
  33. phoenix/db/migrations/versions/272b66ff50f8_drop_single_indices.py +119 -0
  34. phoenix/db/migrations/versions/58228d933c91_dataset_labels.py +67 -0
  35. phoenix/db/migrations/versions/699f655af132_experiment_tags.py +57 -0
  36. phoenix/db/migrations/versions/735d3d93c33e_add_composite_indices.py +41 -0
  37. phoenix/db/migrations/versions/a20694b15f82_cost.py +196 -0
  38. phoenix/db/migrations/versions/ab513d89518b_add_user_id_on_dataset_versions.py +40 -0
  39. phoenix/db/migrations/versions/d0690a79ea51_users_on_experiments.py +40 -0
  40. phoenix/db/migrations/versions/deb2c81c0bb2_dataset_splits.py +139 -0
  41. phoenix/db/migrations/versions/e76cbd66ffc3_add_experiments_dataset_examples.py +87 -0
  42. phoenix/db/models.py +669 -56
  43. phoenix/db/pg_config.py +10 -0
  44. phoenix/db/types/model_provider.py +4 -0
  45. phoenix/db/types/token_price_customization.py +29 -0
  46. phoenix/db/types/trace_retention.py +23 -15
  47. phoenix/experiments/evaluators/utils.py +3 -3
  48. phoenix/experiments/functions.py +160 -52
  49. phoenix/experiments/tracing.py +2 -2
  50. phoenix/experiments/types.py +1 -1
  51. phoenix/inferences/inferences.py +1 -2
  52. phoenix/server/api/auth.py +38 -7
  53. phoenix/server/api/auth_messages.py +46 -0
  54. phoenix/server/api/context.py +100 -4
  55. phoenix/server/api/dataloaders/__init__.py +79 -5
  56. phoenix/server/api/dataloaders/annotation_configs_by_project.py +31 -0
  57. phoenix/server/api/dataloaders/annotation_summaries.py +60 -8
  58. phoenix/server/api/dataloaders/average_experiment_repeated_run_group_latency.py +50 -0
  59. phoenix/server/api/dataloaders/average_experiment_run_latency.py +17 -24
  60. phoenix/server/api/dataloaders/cache/two_tier_cache.py +1 -2
  61. phoenix/server/api/dataloaders/dataset_dataset_splits.py +52 -0
  62. phoenix/server/api/dataloaders/dataset_example_revisions.py +0 -1
  63. phoenix/server/api/dataloaders/dataset_example_splits.py +40 -0
  64. phoenix/server/api/dataloaders/dataset_examples_and_versions_by_experiment_run.py +47 -0
  65. phoenix/server/api/dataloaders/dataset_labels.py +36 -0
  66. phoenix/server/api/dataloaders/document_evaluation_summaries.py +2 -2
  67. phoenix/server/api/dataloaders/document_evaluations.py +6 -9
  68. phoenix/server/api/dataloaders/experiment_annotation_summaries.py +88 -34
  69. phoenix/server/api/dataloaders/experiment_dataset_splits.py +43 -0
  70. phoenix/server/api/dataloaders/experiment_error_rates.py +21 -28
  71. phoenix/server/api/dataloaders/experiment_repeated_run_group_annotation_summaries.py +77 -0
  72. phoenix/server/api/dataloaders/experiment_repeated_run_groups.py +57 -0
  73. phoenix/server/api/dataloaders/experiment_runs_by_experiment_and_example.py +44 -0
  74. phoenix/server/api/dataloaders/last_used_times_by_generative_model_id.py +35 -0
  75. phoenix/server/api/dataloaders/latency_ms_quantile.py +40 -8
  76. phoenix/server/api/dataloaders/record_counts.py +37 -10
  77. phoenix/server/api/dataloaders/session_annotations_by_session.py +29 -0
  78. phoenix/server/api/dataloaders/span_cost_by_span.py +24 -0
  79. phoenix/server/api/dataloaders/span_cost_detail_summary_entries_by_generative_model.py +56 -0
  80. phoenix/server/api/dataloaders/span_cost_detail_summary_entries_by_project_session.py +57 -0
  81. phoenix/server/api/dataloaders/span_cost_detail_summary_entries_by_span.py +43 -0
  82. phoenix/server/api/dataloaders/span_cost_detail_summary_entries_by_trace.py +56 -0
  83. phoenix/server/api/dataloaders/span_cost_details_by_span_cost.py +27 -0
  84. phoenix/server/api/dataloaders/span_cost_summary_by_experiment.py +57 -0
  85. phoenix/server/api/dataloaders/span_cost_summary_by_experiment_repeated_run_group.py +64 -0
  86. phoenix/server/api/dataloaders/span_cost_summary_by_experiment_run.py +58 -0
  87. phoenix/server/api/dataloaders/span_cost_summary_by_generative_model.py +55 -0
  88. phoenix/server/api/dataloaders/span_cost_summary_by_project.py +152 -0
  89. phoenix/server/api/dataloaders/span_cost_summary_by_project_session.py +56 -0
  90. phoenix/server/api/dataloaders/span_cost_summary_by_trace.py +55 -0
  91. phoenix/server/api/dataloaders/span_costs.py +29 -0
  92. phoenix/server/api/dataloaders/table_fields.py +2 -2
  93. phoenix/server/api/dataloaders/token_prices_by_model.py +30 -0
  94. phoenix/server/api/dataloaders/trace_annotations_by_trace.py +27 -0
  95. phoenix/server/api/dataloaders/types.py +29 -0
  96. phoenix/server/api/exceptions.py +11 -1
  97. phoenix/server/api/helpers/dataset_helpers.py +5 -1
  98. phoenix/server/api/helpers/playground_clients.py +1243 -292
  99. phoenix/server/api/helpers/playground_registry.py +2 -2
  100. phoenix/server/api/helpers/playground_spans.py +8 -4
  101. phoenix/server/api/helpers/playground_users.py +26 -0
  102. phoenix/server/api/helpers/prompts/conversions/aws.py +83 -0
  103. phoenix/server/api/helpers/prompts/conversions/google.py +103 -0
  104. phoenix/server/api/helpers/prompts/models.py +205 -22
  105. phoenix/server/api/input_types/{SpanAnnotationFilter.py → AnnotationFilter.py} +22 -14
  106. phoenix/server/api/input_types/ChatCompletionInput.py +6 -2
  107. phoenix/server/api/input_types/CreateProjectInput.py +27 -0
  108. phoenix/server/api/input_types/CreateProjectSessionAnnotationInput.py +37 -0
  109. phoenix/server/api/input_types/DatasetFilter.py +17 -0
  110. phoenix/server/api/input_types/ExperimentRunSort.py +237 -0
  111. phoenix/server/api/input_types/GenerativeCredentialInput.py +9 -0
  112. phoenix/server/api/input_types/GenerativeModelInput.py +5 -0
  113. phoenix/server/api/input_types/ProjectSessionSort.py +161 -1
  114. phoenix/server/api/input_types/PromptFilter.py +14 -0
  115. phoenix/server/api/input_types/PromptVersionInput.py +52 -1
  116. phoenix/server/api/input_types/SpanSort.py +44 -7
  117. phoenix/server/api/input_types/TimeBinConfig.py +23 -0
  118. phoenix/server/api/input_types/UpdateAnnotationInput.py +34 -0
  119. phoenix/server/api/input_types/UserRoleInput.py +1 -0
  120. phoenix/server/api/mutations/__init__.py +10 -0
  121. phoenix/server/api/mutations/annotation_config_mutations.py +8 -8
  122. phoenix/server/api/mutations/api_key_mutations.py +19 -23
  123. phoenix/server/api/mutations/chat_mutations.py +154 -47
  124. phoenix/server/api/mutations/dataset_label_mutations.py +243 -0
  125. phoenix/server/api/mutations/dataset_mutations.py +21 -16
  126. phoenix/server/api/mutations/dataset_split_mutations.py +351 -0
  127. phoenix/server/api/mutations/experiment_mutations.py +2 -2
  128. phoenix/server/api/mutations/export_events_mutations.py +3 -3
  129. phoenix/server/api/mutations/model_mutations.py +210 -0
  130. phoenix/server/api/mutations/project_mutations.py +49 -10
  131. phoenix/server/api/mutations/project_session_annotations_mutations.py +158 -0
  132. phoenix/server/api/mutations/project_trace_retention_policy_mutations.py +8 -4
  133. phoenix/server/api/mutations/prompt_label_mutations.py +74 -65
  134. phoenix/server/api/mutations/prompt_mutations.py +65 -129
  135. phoenix/server/api/mutations/prompt_version_tag_mutations.py +11 -8
  136. phoenix/server/api/mutations/span_annotations_mutations.py +15 -10
  137. phoenix/server/api/mutations/trace_annotations_mutations.py +14 -10
  138. phoenix/server/api/mutations/trace_mutations.py +47 -3
  139. phoenix/server/api/mutations/user_mutations.py +66 -41
  140. phoenix/server/api/queries.py +768 -293
  141. phoenix/server/api/routers/__init__.py +2 -2
  142. phoenix/server/api/routers/auth.py +154 -88
  143. phoenix/server/api/routers/ldap.py +229 -0
  144. phoenix/server/api/routers/oauth2.py +369 -106
  145. phoenix/server/api/routers/v1/__init__.py +24 -4
  146. phoenix/server/api/routers/v1/annotation_configs.py +23 -31
  147. phoenix/server/api/routers/v1/annotations.py +481 -17
  148. phoenix/server/api/routers/v1/datasets.py +395 -81
  149. phoenix/server/api/routers/v1/documents.py +142 -0
  150. phoenix/server/api/routers/v1/evaluations.py +24 -31
  151. phoenix/server/api/routers/v1/experiment_evaluations.py +19 -8
  152. phoenix/server/api/routers/v1/experiment_runs.py +337 -59
  153. phoenix/server/api/routers/v1/experiments.py +479 -48
  154. phoenix/server/api/routers/v1/models.py +7 -0
  155. phoenix/server/api/routers/v1/projects.py +18 -49
  156. phoenix/server/api/routers/v1/prompts.py +54 -40
  157. phoenix/server/api/routers/v1/sessions.py +108 -0
  158. phoenix/server/api/routers/v1/spans.py +1091 -81
  159. phoenix/server/api/routers/v1/traces.py +132 -78
  160. phoenix/server/api/routers/v1/users.py +389 -0
  161. phoenix/server/api/routers/v1/utils.py +3 -7
  162. phoenix/server/api/subscriptions.py +305 -88
  163. phoenix/server/api/types/Annotation.py +90 -23
  164. phoenix/server/api/types/ApiKey.py +13 -17
  165. phoenix/server/api/types/AuthMethod.py +1 -0
  166. phoenix/server/api/types/ChatCompletionSubscriptionPayload.py +1 -0
  167. phoenix/server/api/types/CostBreakdown.py +12 -0
  168. phoenix/server/api/types/Dataset.py +226 -72
  169. phoenix/server/api/types/DatasetExample.py +88 -18
  170. phoenix/server/api/types/DatasetExperimentAnnotationSummary.py +10 -0
  171. phoenix/server/api/types/DatasetLabel.py +57 -0
  172. phoenix/server/api/types/DatasetSplit.py +98 -0
  173. phoenix/server/api/types/DatasetVersion.py +49 -4
  174. phoenix/server/api/types/DocumentAnnotation.py +212 -0
  175. phoenix/server/api/types/Experiment.py +264 -59
  176. phoenix/server/api/types/ExperimentComparison.py +5 -10
  177. phoenix/server/api/types/ExperimentRepeatedRunGroup.py +155 -0
  178. phoenix/server/api/types/ExperimentRepeatedRunGroupAnnotationSummary.py +9 -0
  179. phoenix/server/api/types/ExperimentRun.py +169 -65
  180. phoenix/server/api/types/ExperimentRunAnnotation.py +158 -39
  181. phoenix/server/api/types/GenerativeModel.py +245 -3
  182. phoenix/server/api/types/GenerativeProvider.py +70 -11
  183. phoenix/server/api/types/{Model.py → InferenceModel.py} +1 -1
  184. phoenix/server/api/types/ModelInterface.py +16 -0
  185. phoenix/server/api/types/PlaygroundModel.py +20 -0
  186. phoenix/server/api/types/Project.py +1278 -216
  187. phoenix/server/api/types/ProjectSession.py +188 -28
  188. phoenix/server/api/types/ProjectSessionAnnotation.py +187 -0
  189. phoenix/server/api/types/ProjectTraceRetentionPolicy.py +1 -1
  190. phoenix/server/api/types/Prompt.py +119 -39
  191. phoenix/server/api/types/PromptLabel.py +42 -25
  192. phoenix/server/api/types/PromptVersion.py +11 -8
  193. phoenix/server/api/types/PromptVersionTag.py +65 -25
  194. phoenix/server/api/types/ServerStatus.py +6 -0
  195. phoenix/server/api/types/Span.py +167 -123
  196. phoenix/server/api/types/SpanAnnotation.py +189 -42
  197. phoenix/server/api/types/SpanCostDetailSummaryEntry.py +10 -0
  198. phoenix/server/api/types/SpanCostSummary.py +10 -0
  199. phoenix/server/api/types/SystemApiKey.py +65 -1
  200. phoenix/server/api/types/TokenPrice.py +16 -0
  201. phoenix/server/api/types/TokenUsage.py +3 -3
  202. phoenix/server/api/types/Trace.py +223 -51
  203. phoenix/server/api/types/TraceAnnotation.py +149 -50
  204. phoenix/server/api/types/User.py +137 -32
  205. phoenix/server/api/types/UserApiKey.py +73 -26
  206. phoenix/server/api/types/node.py +10 -0
  207. phoenix/server/api/types/pagination.py +11 -2
  208. phoenix/server/app.py +290 -45
  209. phoenix/server/authorization.py +38 -3
  210. phoenix/server/bearer_auth.py +34 -24
  211. phoenix/server/cost_tracking/cost_details_calculator.py +196 -0
  212. phoenix/server/cost_tracking/cost_model_lookup.py +179 -0
  213. phoenix/server/cost_tracking/helpers.py +68 -0
  214. phoenix/server/cost_tracking/model_cost_manifest.json +3657 -830
  215. phoenix/server/cost_tracking/regex_specificity.py +397 -0
  216. phoenix/server/cost_tracking/token_cost_calculator.py +57 -0
  217. phoenix/server/daemons/__init__.py +0 -0
  218. phoenix/server/daemons/db_disk_usage_monitor.py +214 -0
  219. phoenix/server/daemons/generative_model_store.py +103 -0
  220. phoenix/server/daemons/span_cost_calculator.py +99 -0
  221. phoenix/server/dml_event.py +17 -0
  222. phoenix/server/dml_event_handler.py +5 -0
  223. phoenix/server/email/sender.py +56 -3
  224. phoenix/server/email/templates/db_disk_usage_notification.html +19 -0
  225. phoenix/server/email/types.py +11 -0
  226. phoenix/server/experiments/__init__.py +0 -0
  227. phoenix/server/experiments/utils.py +14 -0
  228. phoenix/server/grpc_server.py +11 -11
  229. phoenix/server/jwt_store.py +17 -15
  230. phoenix/server/ldap.py +1449 -0
  231. phoenix/server/main.py +26 -10
  232. phoenix/server/oauth2.py +330 -12
  233. phoenix/server/prometheus.py +66 -6
  234. phoenix/server/rate_limiters.py +4 -9
  235. phoenix/server/retention.py +33 -20
  236. phoenix/server/session_filters.py +49 -0
  237. phoenix/server/static/.vite/manifest.json +55 -51
  238. phoenix/server/static/assets/components-BreFUQQa.js +6702 -0
  239. phoenix/server/static/assets/{index-E0M82BdE.js → index-CTQoemZv.js} +140 -56
  240. phoenix/server/static/assets/pages-DBE5iYM3.js +9524 -0
  241. phoenix/server/static/assets/vendor-BGzfc4EU.css +1 -0
  242. phoenix/server/static/assets/vendor-DCE4v-Ot.js +920 -0
  243. phoenix/server/static/assets/vendor-codemirror-D5f205eT.js +25 -0
  244. phoenix/server/static/assets/vendor-recharts-V9cwpXsm.js +37 -0
  245. phoenix/server/static/assets/vendor-shiki-Do--csgv.js +5 -0
  246. phoenix/server/static/assets/vendor-three-CmB8bl_y.js +3840 -0
  247. phoenix/server/templates/index.html +40 -6
  248. phoenix/server/thread_server.py +1 -2
  249. phoenix/server/types.py +14 -4
  250. phoenix/server/utils.py +74 -0
  251. phoenix/session/client.py +56 -3
  252. phoenix/session/data_extractor.py +5 -0
  253. phoenix/session/evaluation.py +14 -5
  254. phoenix/session/session.py +45 -9
  255. phoenix/settings.py +5 -0
  256. phoenix/trace/attributes.py +80 -13
  257. phoenix/trace/dsl/helpers.py +90 -1
  258. phoenix/trace/dsl/query.py +8 -6
  259. phoenix/trace/projects.py +5 -0
  260. phoenix/utilities/template_formatters.py +1 -1
  261. phoenix/version.py +1 -1
  262. arize_phoenix-10.0.4.dist-info/RECORD +0 -405
  263. phoenix/server/api/types/Evaluation.py +0 -39
  264. phoenix/server/cost_tracking/cost_lookup.py +0 -255
  265. phoenix/server/static/assets/components-DULKeDfL.js +0 -4365
  266. phoenix/server/static/assets/pages-Cl0A-0U2.js +0 -7430
  267. phoenix/server/static/assets/vendor-WIZid84E.css +0 -1
  268. phoenix/server/static/assets/vendor-arizeai-Dy-0mSNw.js +0 -649
  269. phoenix/server/static/assets/vendor-codemirror-DBtifKNr.js +0 -33
  270. phoenix/server/static/assets/vendor-oB4u9zuV.js +0 -905
  271. phoenix/server/static/assets/vendor-recharts-D-T4KPz2.js +0 -59
  272. phoenix/server/static/assets/vendor-shiki-BMn4O_9F.js +0 -5
  273. phoenix/server/static/assets/vendor-three-C5WAXd5r.js +0 -2998
  274. phoenix/utilities/deprecation.py +0 -31
  275. {arize_phoenix-10.0.4.dist-info → arize_phoenix-12.28.1.dist-info}/entry_points.txt +0 -0
  276. {arize_phoenix-10.0.4.dist-info → arize_phoenix-12.28.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,389 @@
1
+ import asyncio
2
+ import logging
3
+ import secrets
4
+ from datetime import datetime
5
+ from functools import partial
6
+ from typing import Annotated, Literal, Union
7
+
8
+ from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request
9
+ from pydantic import Field
10
+ from sqlalchemy import select
11
+ from sqlalchemy.exc import IntegrityError as PostgreSQLIntegrityError
12
+ from sqlalchemy.orm import joinedload
13
+ from sqlean.dbapi2 import IntegrityError as SQLiteIntegrityError # type: ignore[import-untyped]
14
+ from starlette.datastructures import Secret
15
+ from strawberry.relay import GlobalID
16
+ from typing_extensions import TypeAlias, assert_never
17
+
18
+ from phoenix.auth import (
19
+ DEFAULT_ADMIN_EMAIL,
20
+ DEFAULT_ADMIN_USERNAME,
21
+ DEFAULT_SECRET_LENGTH,
22
+ DEFAULT_SYSTEM_EMAIL,
23
+ DEFAULT_SYSTEM_USERNAME,
24
+ compute_password_hash,
25
+ sanitize_email,
26
+ validate_email_format,
27
+ validate_password_format,
28
+ )
29
+ from phoenix.db import models
30
+ from phoenix.db.types.db_models import UNDEFINED
31
+ from phoenix.server.api.routers.v1.models import V1RoutesBaseModel
32
+ from phoenix.server.api.routers.v1.utils import (
33
+ PaginatedResponseBody,
34
+ ResponseBody,
35
+ add_errors_to_responses,
36
+ )
37
+ from phoenix.server.api.types.node import from_global_id_with_expected_type
38
+ from phoenix.server.authorization import is_not_locked, require_admin
39
+ from phoenix.server.ldap import is_ldap_user, is_null_email_marker
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+ router = APIRouter(tags=["users"])
44
+
45
+
46
+ class UserData(V1RoutesBaseModel):
47
+ email: str
48
+ username: str
49
+ role: models.UserRoleName
50
+
51
+
52
+ class LocalUserData(UserData):
53
+ auth_method: Literal["LOCAL"]
54
+ password: str = UNDEFINED
55
+
56
+
57
+ class OAuth2UserData(UserData):
58
+ auth_method: Literal["OAUTH2"]
59
+ oauth2_client_id: str = UNDEFINED
60
+ oauth2_user_id: str = UNDEFINED
61
+
62
+
63
+ class LDAPUserData(UserData):
64
+ auth_method: Literal["LDAP"]
65
+
66
+
67
+ class DbUser(V1RoutesBaseModel):
68
+ id: str
69
+ created_at: datetime
70
+ updated_at: datetime
71
+
72
+
73
+ class LocalUser(LocalUserData, DbUser):
74
+ password_needs_reset: bool
75
+
76
+
77
+ class OAuth2User(OAuth2UserData, DbUser):
78
+ profile_picture_url: str = UNDEFINED
79
+
80
+
81
+ class LDAPUser(LDAPUserData, DbUser):
82
+ pass
83
+
84
+
85
+ User: TypeAlias = Annotated[
86
+ Union[LocalUser, OAuth2User, LDAPUser], Field(..., discriminator="auth_method")
87
+ ]
88
+
89
+
90
+ class GetUsersResponseBody(PaginatedResponseBody[User]):
91
+ pass
92
+
93
+
94
+ class GetUserResponseBody(ResponseBody[User]):
95
+ pass
96
+
97
+
98
+ class CreateUserRequestBody(V1RoutesBaseModel):
99
+ user: Annotated[
100
+ Union[LocalUserData, OAuth2UserData, LDAPUserData], Field(..., discriminator="auth_method")
101
+ ]
102
+ send_welcome_email: bool = True
103
+
104
+
105
+ class CreateUserResponseBody(ResponseBody[User]):
106
+ pass
107
+
108
+
109
+ DEFAULT_PAGINATION_PAGE_LIMIT = 100
110
+
111
+
112
+ @router.get(
113
+ "/users",
114
+ operation_id="getUsers",
115
+ summary="List all users",
116
+ description="Retrieve a paginated list of all users in the system.",
117
+ response_description="A list of users.",
118
+ responses=add_errors_to_responses(
119
+ [
120
+ 422,
121
+ ],
122
+ ),
123
+ dependencies=[Depends(require_admin)],
124
+ response_model_by_alias=True,
125
+ response_model_exclude_unset=True,
126
+ response_model_exclude_defaults=True,
127
+ )
128
+ async def list_users(
129
+ request: Request,
130
+ cursor: str = Query(default=None, description="Cursor for pagination (base64-encoded user ID)"),
131
+ limit: int = Query(
132
+ default=DEFAULT_PAGINATION_PAGE_LIMIT,
133
+ description="The max number of users to return at a time.",
134
+ gt=0,
135
+ ),
136
+ ) -> GetUsersResponseBody:
137
+ stmt = select(models.User).options(joinedload(models.User.role)).order_by(models.User.id.desc())
138
+ if cursor:
139
+ try:
140
+ cursor_id = GlobalID.from_id(cursor).node_id
141
+ except Exception:
142
+ raise HTTPException(status_code=422, detail=f"Invalid cursor format: {cursor}")
143
+ else:
144
+ stmt = stmt.where(models.User.id <= int(cursor_id))
145
+ stmt = stmt.limit(limit + 1)
146
+ async with request.app.state.db() as session:
147
+ result = (await session.scalars(stmt)).all()
148
+ next_cursor = None
149
+ if len(result) == limit + 1:
150
+ last_user = result[-1]
151
+ next_cursor = str(GlobalID("User", str(last_user.id)))
152
+ result = result[:-1]
153
+ data: list[User] = []
154
+ for user in result:
155
+ if isinstance(user, models.LocalUser):
156
+ data.append(
157
+ LocalUser(
158
+ id=str(GlobalID("User", str(user.id))),
159
+ username=user.username,
160
+ email=user.email,
161
+ role=user.role.name,
162
+ created_at=user.created_at,
163
+ updated_at=user.updated_at,
164
+ auth_method="LOCAL",
165
+ password_needs_reset=user.reset_password,
166
+ )
167
+ )
168
+ elif isinstance(user, models.OAuth2User) and is_ldap_user(user.oauth2_client_id):
169
+ # Check if this is an LDAP user (identified by special marker)
170
+ data.append(
171
+ LDAPUser(
172
+ id=str(GlobalID("User", str(user.id))),
173
+ username=user.username,
174
+ email="" if is_null_email_marker(user.email) else user.email,
175
+ role=user.role.name,
176
+ created_at=user.created_at,
177
+ updated_at=user.updated_at,
178
+ auth_method="LDAP",
179
+ )
180
+ )
181
+ elif isinstance(user, models.OAuth2User):
182
+ oauth2_user = OAuth2User(
183
+ id=str(GlobalID("User", str(user.id))),
184
+ username=user.username,
185
+ email=user.email,
186
+ role=user.role.name,
187
+ created_at=user.created_at,
188
+ updated_at=user.updated_at,
189
+ auth_method="OAUTH2",
190
+ )
191
+ if user.oauth2_client_id:
192
+ oauth2_user.oauth2_client_id = user.oauth2_client_id
193
+ if user.oauth2_user_id:
194
+ oauth2_user.oauth2_user_id = user.oauth2_user_id
195
+ if user.profile_picture_url:
196
+ oauth2_user.profile_picture_url = user.profile_picture_url
197
+ data.append(oauth2_user)
198
+ return GetUsersResponseBody(next_cursor=next_cursor, data=data)
199
+
200
+
201
+ @router.post(
202
+ "/users",
203
+ operation_id="createUser",
204
+ summary="Create a new user",
205
+ description="Create a new user with the specified configuration.",
206
+ response_description="The newly created user.",
207
+ status_code=201,
208
+ responses=add_errors_to_responses(
209
+ [
210
+ {"status_code": 400, "description": "Role not found."},
211
+ {"status_code": 409, "description": "Username or email already exists."},
212
+ 422,
213
+ ]
214
+ ),
215
+ dependencies=[Depends(require_admin), Depends(is_not_locked)],
216
+ response_model_by_alias=True,
217
+ response_model_exclude_unset=True,
218
+ response_model_exclude_defaults=True,
219
+ )
220
+ async def create_user(
221
+ request: Request,
222
+ request_body: CreateUserRequestBody,
223
+ ) -> CreateUserResponseBody:
224
+ user_data = request_body.user
225
+ email, username, role = user_data.email, user_data.username, user_data.role
226
+ # Sanitize email by trimming and lowercasing
227
+ email = sanitize_email(email)
228
+ validate_email_format(email)
229
+
230
+ # Prevent creation of SYSTEM users
231
+ if role == "SYSTEM":
232
+ raise HTTPException(
233
+ status_code=400,
234
+ detail="Cannot create users with SYSTEM role",
235
+ )
236
+
237
+ # Prevent OAuth2 users from using the LDAP marker or any variation
238
+ if isinstance(user_data, OAuth2UserData):
239
+ if is_ldap_user(user_data.oauth2_client_id):
240
+ raise HTTPException(
241
+ status_code=400,
242
+ detail="Cannot create OAuth2 users with reserved LDAP identifier",
243
+ )
244
+
245
+ user: models.User
246
+ if isinstance(user_data, LocalUserData):
247
+ password = (user_data.password or secrets.token_hex()).strip()
248
+ validate_password_format(password)
249
+
250
+ # Generate salt and hash password using the same method as in context.py
251
+ salt = secrets.token_bytes(DEFAULT_SECRET_LENGTH)
252
+ compute = partial(compute_password_hash, password=Secret(password), salt=salt)
253
+ password_hash = await asyncio.get_running_loop().run_in_executor(None, compute)
254
+
255
+ user = models.LocalUser(
256
+ email=email,
257
+ username=username,
258
+ password_hash=password_hash,
259
+ password_salt=salt,
260
+ reset_password=True,
261
+ )
262
+ elif isinstance(user_data, OAuth2UserData):
263
+ user = models.OAuth2User(
264
+ email=email,
265
+ username=username,
266
+ oauth2_client_id=user_data.oauth2_client_id or None,
267
+ oauth2_user_id=user_data.oauth2_user_id or None,
268
+ )
269
+ elif isinstance(user_data, LDAPUserData):
270
+ user = models.LDAPUser(
271
+ email=email,
272
+ username=username,
273
+ )
274
+ else:
275
+ assert_never(user_data)
276
+ try:
277
+ async with request.app.state.db() as session:
278
+ user_role_id = await session.scalar(select(models.UserRole.id).filter_by(name=role))
279
+ if user_role_id is None:
280
+ raise HTTPException(status_code=400, detail=f"Role '{role}' not found")
281
+ user.user_role_id = user_role_id
282
+ session.add(user)
283
+ except (PostgreSQLIntegrityError, SQLiteIntegrityError) as e:
284
+ if "users.username" in str(e):
285
+ raise HTTPException(status_code=409, detail="Username already exists")
286
+ elif "users.email" in str(e):
287
+ raise HTTPException(status_code=409, detail="Email already exists")
288
+ else:
289
+ raise HTTPException(
290
+ status_code=409,
291
+ detail="Failed to create user due to a conflict with existing data",
292
+ )
293
+ id_ = str(GlobalID("User", str(user.id)))
294
+ data: User
295
+ if isinstance(user_data, LocalUserData):
296
+ data = LocalUser(
297
+ id=id_,
298
+ email=email,
299
+ username=username,
300
+ auth_method="LOCAL",
301
+ role=user_data.role,
302
+ created_at=user.created_at,
303
+ updated_at=user.updated_at,
304
+ password_needs_reset=user.reset_password,
305
+ )
306
+ elif isinstance(user_data, OAuth2UserData):
307
+ data = OAuth2User(
308
+ id=id_,
309
+ email=email,
310
+ username=username,
311
+ auth_method="OAUTH2",
312
+ role=user_data.role,
313
+ created_at=user.created_at,
314
+ updated_at=user.updated_at,
315
+ )
316
+ if user.oauth2_client_id:
317
+ data.oauth2_client_id = user.oauth2_client_id
318
+ if user.oauth2_user_id:
319
+ data.oauth2_user_id = user.oauth2_user_id
320
+ if user.profile_picture_url:
321
+ data.profile_picture_url = user.profile_picture_url
322
+ elif isinstance(user_data, LDAPUserData):
323
+ data = LDAPUser(
324
+ id=id_,
325
+ email=email,
326
+ username=username,
327
+ auth_method="LDAP",
328
+ role=user_data.role,
329
+ created_at=user.created_at,
330
+ updated_at=user.updated_at,
331
+ )
332
+ else:
333
+ assert_never(user_data)
334
+ # Send welcome email if requested
335
+ if request_body.send_welcome_email and request.app.state.email_sender is not None:
336
+ try:
337
+ await request.app.state.email_sender.send_welcome_email(user.email, user.username)
338
+ except Exception as error:
339
+ # Log the error but do not raise it
340
+ logger.error(f"Failed to send welcome email: {error}")
341
+ return CreateUserResponseBody(data=data)
342
+
343
+
344
+ @router.delete(
345
+ "/users/{user_id}",
346
+ operation_id="deleteUser",
347
+ summary="Delete a user by ID",
348
+ description="Delete an existing user by their unique GlobalID.",
349
+ response_description="No content returned on successful deletion.",
350
+ status_code=204,
351
+ responses=add_errors_to_responses(
352
+ [
353
+ {"status_code": 404, "description": "User not found."},
354
+ 422,
355
+ {
356
+ "status_code": 403,
357
+ "description": "Cannot delete the default admin or system user",
358
+ },
359
+ ]
360
+ ),
361
+ dependencies=[Depends(require_admin)],
362
+ response_model_by_alias=True,
363
+ response_model_exclude_unset=True,
364
+ response_model_exclude_defaults=True,
365
+ )
366
+ async def delete_user(
367
+ request: Request,
368
+ user_id: str = Path(..., description="The GlobalID of the user (e.g. 'VXNlcjox')."),
369
+ ) -> None:
370
+ try:
371
+ id_ = from_global_id_with_expected_type(GlobalID.from_id(user_id), "User")
372
+ except Exception:
373
+ raise HTTPException(status_code=422, detail=f"Invalid User GlobalID format: {user_id}")
374
+ async with request.app.state.db() as session:
375
+ user = await session.get(models.User, id_)
376
+ if not user:
377
+ raise HTTPException(status_code=404, detail="User not found")
378
+ # Prevent deletion of system and default admin users
379
+ if (
380
+ user.email == DEFAULT_ADMIN_EMAIL
381
+ or user.email == DEFAULT_SYSTEM_EMAIL
382
+ or user.username == DEFAULT_ADMIN_USERNAME
383
+ or user.username == DEFAULT_SYSTEM_USERNAME
384
+ ):
385
+ raise HTTPException(
386
+ status_code=403, detail="Cannot delete the default admin or system user"
387
+ )
388
+ await session.delete(user)
389
+ return None
@@ -3,10 +3,6 @@ from typing import Any, Generic, Optional, TypedDict, TypeVar, Union
3
3
  from fastapi import HTTPException
4
4
  from sqlalchemy import select
5
5
  from sqlalchemy.ext.asyncio import AsyncSession
6
- from starlette.status import (
7
- HTTP_404_NOT_FOUND,
8
- HTTP_422_UNPROCESSABLE_ENTITY,
9
- )
10
6
  from strawberry.relay import GlobalID
11
7
  from typing_extensions import TypeAlias, assert_never
12
8
 
@@ -135,21 +131,21 @@ async def _get_project_by_identifier(
135
131
  name = project_identifier
136
132
  except HTTPException:
137
133
  raise HTTPException(
138
- status_code=HTTP_422_UNPROCESSABLE_ENTITY,
134
+ status_code=422,
139
135
  detail=f"Invalid project identifier format: {project_identifier}",
140
136
  )
141
137
  stmt = select(models.Project).filter_by(name=name)
142
138
  project = await session.scalar(stmt)
143
139
  if project is None:
144
140
  raise HTTPException(
145
- status_code=HTTP_404_NOT_FOUND,
141
+ status_code=404,
146
142
  detail=f"Project with name {name} not found",
147
143
  )
148
144
  else:
149
145
  project = await session.get(models.Project, id_)
150
146
  if project is None:
151
147
  raise HTTPException(
152
- status_code=HTTP_404_NOT_FOUND,
148
+ status_code=404,
153
149
  detail=f"Project with ID {project_identifier} not found",
154
150
  )
155
151
  return project