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.
- {arize_phoenix-10.0.4.dist-info → arize_phoenix-12.28.1.dist-info}/METADATA +124 -72
- arize_phoenix-12.28.1.dist-info/RECORD +499 -0
- {arize_phoenix-10.0.4.dist-info → arize_phoenix-12.28.1.dist-info}/WHEEL +1 -1
- {arize_phoenix-10.0.4.dist-info → arize_phoenix-12.28.1.dist-info}/licenses/IP_NOTICE +1 -1
- phoenix/__generated__/__init__.py +0 -0
- phoenix/__generated__/classification_evaluator_configs/__init__.py +20 -0
- phoenix/__generated__/classification_evaluator_configs/_document_relevance_classification_evaluator_config.py +17 -0
- phoenix/__generated__/classification_evaluator_configs/_hallucination_classification_evaluator_config.py +17 -0
- phoenix/__generated__/classification_evaluator_configs/_models.py +18 -0
- phoenix/__generated__/classification_evaluator_configs/_tool_selection_classification_evaluator_config.py +17 -0
- phoenix/__init__.py +5 -4
- phoenix/auth.py +39 -2
- phoenix/config.py +1763 -91
- phoenix/datetime_utils.py +120 -2
- phoenix/db/README.md +595 -25
- phoenix/db/bulk_inserter.py +145 -103
- phoenix/db/engines.py +140 -33
- phoenix/db/enums.py +3 -12
- phoenix/db/facilitator.py +302 -35
- phoenix/db/helpers.py +1000 -65
- phoenix/db/iam_auth.py +64 -0
- phoenix/db/insertion/dataset.py +135 -2
- phoenix/db/insertion/document_annotation.py +9 -6
- phoenix/db/insertion/evaluation.py +2 -3
- phoenix/db/insertion/helpers.py +17 -2
- phoenix/db/insertion/session_annotation.py +176 -0
- phoenix/db/insertion/span.py +15 -11
- phoenix/db/insertion/span_annotation.py +3 -4
- phoenix/db/insertion/trace_annotation.py +3 -4
- phoenix/db/insertion/types.py +50 -20
- phoenix/db/migrations/versions/01a8342c9cdf_add_user_id_on_datasets.py +40 -0
- phoenix/db/migrations/versions/0df286449799_add_session_annotations_table.py +105 -0
- phoenix/db/migrations/versions/272b66ff50f8_drop_single_indices.py +119 -0
- phoenix/db/migrations/versions/58228d933c91_dataset_labels.py +67 -0
- phoenix/db/migrations/versions/699f655af132_experiment_tags.py +57 -0
- phoenix/db/migrations/versions/735d3d93c33e_add_composite_indices.py +41 -0
- phoenix/db/migrations/versions/a20694b15f82_cost.py +196 -0
- phoenix/db/migrations/versions/ab513d89518b_add_user_id_on_dataset_versions.py +40 -0
- phoenix/db/migrations/versions/d0690a79ea51_users_on_experiments.py +40 -0
- phoenix/db/migrations/versions/deb2c81c0bb2_dataset_splits.py +139 -0
- phoenix/db/migrations/versions/e76cbd66ffc3_add_experiments_dataset_examples.py +87 -0
- phoenix/db/models.py +669 -56
- phoenix/db/pg_config.py +10 -0
- phoenix/db/types/model_provider.py +4 -0
- phoenix/db/types/token_price_customization.py +29 -0
- phoenix/db/types/trace_retention.py +23 -15
- phoenix/experiments/evaluators/utils.py +3 -3
- phoenix/experiments/functions.py +160 -52
- phoenix/experiments/tracing.py +2 -2
- phoenix/experiments/types.py +1 -1
- phoenix/inferences/inferences.py +1 -2
- phoenix/server/api/auth.py +38 -7
- phoenix/server/api/auth_messages.py +46 -0
- phoenix/server/api/context.py +100 -4
- phoenix/server/api/dataloaders/__init__.py +79 -5
- phoenix/server/api/dataloaders/annotation_configs_by_project.py +31 -0
- phoenix/server/api/dataloaders/annotation_summaries.py +60 -8
- phoenix/server/api/dataloaders/average_experiment_repeated_run_group_latency.py +50 -0
- phoenix/server/api/dataloaders/average_experiment_run_latency.py +17 -24
- phoenix/server/api/dataloaders/cache/two_tier_cache.py +1 -2
- phoenix/server/api/dataloaders/dataset_dataset_splits.py +52 -0
- phoenix/server/api/dataloaders/dataset_example_revisions.py +0 -1
- phoenix/server/api/dataloaders/dataset_example_splits.py +40 -0
- phoenix/server/api/dataloaders/dataset_examples_and_versions_by_experiment_run.py +47 -0
- phoenix/server/api/dataloaders/dataset_labels.py +36 -0
- phoenix/server/api/dataloaders/document_evaluation_summaries.py +2 -2
- phoenix/server/api/dataloaders/document_evaluations.py +6 -9
- phoenix/server/api/dataloaders/experiment_annotation_summaries.py +88 -34
- phoenix/server/api/dataloaders/experiment_dataset_splits.py +43 -0
- phoenix/server/api/dataloaders/experiment_error_rates.py +21 -28
- phoenix/server/api/dataloaders/experiment_repeated_run_group_annotation_summaries.py +77 -0
- phoenix/server/api/dataloaders/experiment_repeated_run_groups.py +57 -0
- phoenix/server/api/dataloaders/experiment_runs_by_experiment_and_example.py +44 -0
- phoenix/server/api/dataloaders/last_used_times_by_generative_model_id.py +35 -0
- phoenix/server/api/dataloaders/latency_ms_quantile.py +40 -8
- phoenix/server/api/dataloaders/record_counts.py +37 -10
- phoenix/server/api/dataloaders/session_annotations_by_session.py +29 -0
- phoenix/server/api/dataloaders/span_cost_by_span.py +24 -0
- phoenix/server/api/dataloaders/span_cost_detail_summary_entries_by_generative_model.py +56 -0
- phoenix/server/api/dataloaders/span_cost_detail_summary_entries_by_project_session.py +57 -0
- phoenix/server/api/dataloaders/span_cost_detail_summary_entries_by_span.py +43 -0
- phoenix/server/api/dataloaders/span_cost_detail_summary_entries_by_trace.py +56 -0
- phoenix/server/api/dataloaders/span_cost_details_by_span_cost.py +27 -0
- phoenix/server/api/dataloaders/span_cost_summary_by_experiment.py +57 -0
- phoenix/server/api/dataloaders/span_cost_summary_by_experiment_repeated_run_group.py +64 -0
- phoenix/server/api/dataloaders/span_cost_summary_by_experiment_run.py +58 -0
- phoenix/server/api/dataloaders/span_cost_summary_by_generative_model.py +55 -0
- phoenix/server/api/dataloaders/span_cost_summary_by_project.py +152 -0
- phoenix/server/api/dataloaders/span_cost_summary_by_project_session.py +56 -0
- phoenix/server/api/dataloaders/span_cost_summary_by_trace.py +55 -0
- phoenix/server/api/dataloaders/span_costs.py +29 -0
- phoenix/server/api/dataloaders/table_fields.py +2 -2
- phoenix/server/api/dataloaders/token_prices_by_model.py +30 -0
- phoenix/server/api/dataloaders/trace_annotations_by_trace.py +27 -0
- phoenix/server/api/dataloaders/types.py +29 -0
- phoenix/server/api/exceptions.py +11 -1
- phoenix/server/api/helpers/dataset_helpers.py +5 -1
- phoenix/server/api/helpers/playground_clients.py +1243 -292
- phoenix/server/api/helpers/playground_registry.py +2 -2
- phoenix/server/api/helpers/playground_spans.py +8 -4
- phoenix/server/api/helpers/playground_users.py +26 -0
- phoenix/server/api/helpers/prompts/conversions/aws.py +83 -0
- phoenix/server/api/helpers/prompts/conversions/google.py +103 -0
- phoenix/server/api/helpers/prompts/models.py +205 -22
- phoenix/server/api/input_types/{SpanAnnotationFilter.py → AnnotationFilter.py} +22 -14
- phoenix/server/api/input_types/ChatCompletionInput.py +6 -2
- phoenix/server/api/input_types/CreateProjectInput.py +27 -0
- phoenix/server/api/input_types/CreateProjectSessionAnnotationInput.py +37 -0
- phoenix/server/api/input_types/DatasetFilter.py +17 -0
- phoenix/server/api/input_types/ExperimentRunSort.py +237 -0
- phoenix/server/api/input_types/GenerativeCredentialInput.py +9 -0
- phoenix/server/api/input_types/GenerativeModelInput.py +5 -0
- phoenix/server/api/input_types/ProjectSessionSort.py +161 -1
- phoenix/server/api/input_types/PromptFilter.py +14 -0
- phoenix/server/api/input_types/PromptVersionInput.py +52 -1
- phoenix/server/api/input_types/SpanSort.py +44 -7
- phoenix/server/api/input_types/TimeBinConfig.py +23 -0
- phoenix/server/api/input_types/UpdateAnnotationInput.py +34 -0
- phoenix/server/api/input_types/UserRoleInput.py +1 -0
- phoenix/server/api/mutations/__init__.py +10 -0
- phoenix/server/api/mutations/annotation_config_mutations.py +8 -8
- phoenix/server/api/mutations/api_key_mutations.py +19 -23
- phoenix/server/api/mutations/chat_mutations.py +154 -47
- phoenix/server/api/mutations/dataset_label_mutations.py +243 -0
- phoenix/server/api/mutations/dataset_mutations.py +21 -16
- phoenix/server/api/mutations/dataset_split_mutations.py +351 -0
- phoenix/server/api/mutations/experiment_mutations.py +2 -2
- phoenix/server/api/mutations/export_events_mutations.py +3 -3
- phoenix/server/api/mutations/model_mutations.py +210 -0
- phoenix/server/api/mutations/project_mutations.py +49 -10
- phoenix/server/api/mutations/project_session_annotations_mutations.py +158 -0
- phoenix/server/api/mutations/project_trace_retention_policy_mutations.py +8 -4
- phoenix/server/api/mutations/prompt_label_mutations.py +74 -65
- phoenix/server/api/mutations/prompt_mutations.py +65 -129
- phoenix/server/api/mutations/prompt_version_tag_mutations.py +11 -8
- phoenix/server/api/mutations/span_annotations_mutations.py +15 -10
- phoenix/server/api/mutations/trace_annotations_mutations.py +14 -10
- phoenix/server/api/mutations/trace_mutations.py +47 -3
- phoenix/server/api/mutations/user_mutations.py +66 -41
- phoenix/server/api/queries.py +768 -293
- phoenix/server/api/routers/__init__.py +2 -2
- phoenix/server/api/routers/auth.py +154 -88
- phoenix/server/api/routers/ldap.py +229 -0
- phoenix/server/api/routers/oauth2.py +369 -106
- phoenix/server/api/routers/v1/__init__.py +24 -4
- phoenix/server/api/routers/v1/annotation_configs.py +23 -31
- phoenix/server/api/routers/v1/annotations.py +481 -17
- phoenix/server/api/routers/v1/datasets.py +395 -81
- phoenix/server/api/routers/v1/documents.py +142 -0
- phoenix/server/api/routers/v1/evaluations.py +24 -31
- phoenix/server/api/routers/v1/experiment_evaluations.py +19 -8
- phoenix/server/api/routers/v1/experiment_runs.py +337 -59
- phoenix/server/api/routers/v1/experiments.py +479 -48
- phoenix/server/api/routers/v1/models.py +7 -0
- phoenix/server/api/routers/v1/projects.py +18 -49
- phoenix/server/api/routers/v1/prompts.py +54 -40
- phoenix/server/api/routers/v1/sessions.py +108 -0
- phoenix/server/api/routers/v1/spans.py +1091 -81
- phoenix/server/api/routers/v1/traces.py +132 -78
- phoenix/server/api/routers/v1/users.py +389 -0
- phoenix/server/api/routers/v1/utils.py +3 -7
- phoenix/server/api/subscriptions.py +305 -88
- phoenix/server/api/types/Annotation.py +90 -23
- phoenix/server/api/types/ApiKey.py +13 -17
- phoenix/server/api/types/AuthMethod.py +1 -0
- phoenix/server/api/types/ChatCompletionSubscriptionPayload.py +1 -0
- phoenix/server/api/types/CostBreakdown.py +12 -0
- phoenix/server/api/types/Dataset.py +226 -72
- phoenix/server/api/types/DatasetExample.py +88 -18
- phoenix/server/api/types/DatasetExperimentAnnotationSummary.py +10 -0
- phoenix/server/api/types/DatasetLabel.py +57 -0
- phoenix/server/api/types/DatasetSplit.py +98 -0
- phoenix/server/api/types/DatasetVersion.py +49 -4
- phoenix/server/api/types/DocumentAnnotation.py +212 -0
- phoenix/server/api/types/Experiment.py +264 -59
- phoenix/server/api/types/ExperimentComparison.py +5 -10
- phoenix/server/api/types/ExperimentRepeatedRunGroup.py +155 -0
- phoenix/server/api/types/ExperimentRepeatedRunGroupAnnotationSummary.py +9 -0
- phoenix/server/api/types/ExperimentRun.py +169 -65
- phoenix/server/api/types/ExperimentRunAnnotation.py +158 -39
- phoenix/server/api/types/GenerativeModel.py +245 -3
- phoenix/server/api/types/GenerativeProvider.py +70 -11
- phoenix/server/api/types/{Model.py → InferenceModel.py} +1 -1
- phoenix/server/api/types/ModelInterface.py +16 -0
- phoenix/server/api/types/PlaygroundModel.py +20 -0
- phoenix/server/api/types/Project.py +1278 -216
- phoenix/server/api/types/ProjectSession.py +188 -28
- phoenix/server/api/types/ProjectSessionAnnotation.py +187 -0
- phoenix/server/api/types/ProjectTraceRetentionPolicy.py +1 -1
- phoenix/server/api/types/Prompt.py +119 -39
- phoenix/server/api/types/PromptLabel.py +42 -25
- phoenix/server/api/types/PromptVersion.py +11 -8
- phoenix/server/api/types/PromptVersionTag.py +65 -25
- phoenix/server/api/types/ServerStatus.py +6 -0
- phoenix/server/api/types/Span.py +167 -123
- phoenix/server/api/types/SpanAnnotation.py +189 -42
- phoenix/server/api/types/SpanCostDetailSummaryEntry.py +10 -0
- phoenix/server/api/types/SpanCostSummary.py +10 -0
- phoenix/server/api/types/SystemApiKey.py +65 -1
- phoenix/server/api/types/TokenPrice.py +16 -0
- phoenix/server/api/types/TokenUsage.py +3 -3
- phoenix/server/api/types/Trace.py +223 -51
- phoenix/server/api/types/TraceAnnotation.py +149 -50
- phoenix/server/api/types/User.py +137 -32
- phoenix/server/api/types/UserApiKey.py +73 -26
- phoenix/server/api/types/node.py +10 -0
- phoenix/server/api/types/pagination.py +11 -2
- phoenix/server/app.py +290 -45
- phoenix/server/authorization.py +38 -3
- phoenix/server/bearer_auth.py +34 -24
- phoenix/server/cost_tracking/cost_details_calculator.py +196 -0
- phoenix/server/cost_tracking/cost_model_lookup.py +179 -0
- phoenix/server/cost_tracking/helpers.py +68 -0
- phoenix/server/cost_tracking/model_cost_manifest.json +3657 -830
- phoenix/server/cost_tracking/regex_specificity.py +397 -0
- phoenix/server/cost_tracking/token_cost_calculator.py +57 -0
- phoenix/server/daemons/__init__.py +0 -0
- phoenix/server/daemons/db_disk_usage_monitor.py +214 -0
- phoenix/server/daemons/generative_model_store.py +103 -0
- phoenix/server/daemons/span_cost_calculator.py +99 -0
- phoenix/server/dml_event.py +17 -0
- phoenix/server/dml_event_handler.py +5 -0
- phoenix/server/email/sender.py +56 -3
- phoenix/server/email/templates/db_disk_usage_notification.html +19 -0
- phoenix/server/email/types.py +11 -0
- phoenix/server/experiments/__init__.py +0 -0
- phoenix/server/experiments/utils.py +14 -0
- phoenix/server/grpc_server.py +11 -11
- phoenix/server/jwt_store.py +17 -15
- phoenix/server/ldap.py +1449 -0
- phoenix/server/main.py +26 -10
- phoenix/server/oauth2.py +330 -12
- phoenix/server/prometheus.py +66 -6
- phoenix/server/rate_limiters.py +4 -9
- phoenix/server/retention.py +33 -20
- phoenix/server/session_filters.py +49 -0
- phoenix/server/static/.vite/manifest.json +55 -51
- phoenix/server/static/assets/components-BreFUQQa.js +6702 -0
- phoenix/server/static/assets/{index-E0M82BdE.js → index-CTQoemZv.js} +140 -56
- phoenix/server/static/assets/pages-DBE5iYM3.js +9524 -0
- phoenix/server/static/assets/vendor-BGzfc4EU.css +1 -0
- phoenix/server/static/assets/vendor-DCE4v-Ot.js +920 -0
- phoenix/server/static/assets/vendor-codemirror-D5f205eT.js +25 -0
- phoenix/server/static/assets/vendor-recharts-V9cwpXsm.js +37 -0
- phoenix/server/static/assets/vendor-shiki-Do--csgv.js +5 -0
- phoenix/server/static/assets/vendor-three-CmB8bl_y.js +3840 -0
- phoenix/server/templates/index.html +40 -6
- phoenix/server/thread_server.py +1 -2
- phoenix/server/types.py +14 -4
- phoenix/server/utils.py +74 -0
- phoenix/session/client.py +56 -3
- phoenix/session/data_extractor.py +5 -0
- phoenix/session/evaluation.py +14 -5
- phoenix/session/session.py +45 -9
- phoenix/settings.py +5 -0
- phoenix/trace/attributes.py +80 -13
- phoenix/trace/dsl/helpers.py +90 -1
- phoenix/trace/dsl/query.py +8 -6
- phoenix/trace/projects.py +5 -0
- phoenix/utilities/template_formatters.py +1 -1
- phoenix/version.py +1 -1
- arize_phoenix-10.0.4.dist-info/RECORD +0 -405
- phoenix/server/api/types/Evaluation.py +0 -39
- phoenix/server/cost_tracking/cost_lookup.py +0 -255
- phoenix/server/static/assets/components-DULKeDfL.js +0 -4365
- phoenix/server/static/assets/pages-Cl0A-0U2.js +0 -7430
- phoenix/server/static/assets/vendor-WIZid84E.css +0 -1
- phoenix/server/static/assets/vendor-arizeai-Dy-0mSNw.js +0 -649
- phoenix/server/static/assets/vendor-codemirror-DBtifKNr.js +0 -33
- phoenix/server/static/assets/vendor-oB4u9zuV.js +0 -905
- phoenix/server/static/assets/vendor-recharts-D-T4KPz2.js +0 -59
- phoenix/server/static/assets/vendor-shiki-BMn4O_9F.js +0 -5
- phoenix/server/static/assets/vendor-three-C5WAXd5r.js +0 -2998
- phoenix/utilities/deprecation.py +0 -31
- {arize_phoenix-10.0.4.dist-info → arize_phoenix-12.28.1.dist-info}/entry_points.txt +0 -0
- {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=
|
|
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=
|
|
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=
|
|
148
|
+
status_code=404,
|
|
153
149
|
detail=f"Project with ID {project_identifier} not found",
|
|
154
150
|
)
|
|
155
151
|
return project
|