squirrels 0.5.0rc0__py3-none-any.whl → 0.5.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.

Potentially problematic release.


This version of squirrels might be problematic. Click here for more details.

Files changed (108) hide show
  1. dateutils/__init__.py +6 -0
  2. dateutils/_enums.py +25 -0
  3. squirrels/dateutils.py → dateutils/_implementation.py +58 -111
  4. dateutils/types.py +6 -0
  5. squirrels/__init__.py +10 -12
  6. squirrels/_api_routes/__init__.py +5 -0
  7. squirrels/_api_routes/auth.py +271 -0
  8. squirrels/_api_routes/base.py +171 -0
  9. squirrels/_api_routes/dashboards.py +158 -0
  10. squirrels/_api_routes/data_management.py +148 -0
  11. squirrels/_api_routes/datasets.py +265 -0
  12. squirrels/_api_routes/oauth2.py +298 -0
  13. squirrels/_api_routes/project.py +252 -0
  14. squirrels/_api_server.py +245 -781
  15. squirrels/_arguments/__init__.py +0 -0
  16. squirrels/{arguments → _arguments}/init_time_args.py +7 -2
  17. squirrels/{arguments → _arguments}/run_time_args.py +13 -35
  18. squirrels/_auth.py +720 -212
  19. squirrels/_command_line.py +81 -41
  20. squirrels/_compile_prompts.py +147 -0
  21. squirrels/_connection_set.py +16 -7
  22. squirrels/_constants.py +29 -9
  23. squirrels/{_dashboards_io.py → _dashboards.py} +87 -6
  24. squirrels/_data_sources.py +570 -0
  25. squirrels/{dataset_result.py → _dataset_types.py} +2 -4
  26. squirrels/_exceptions.py +9 -37
  27. squirrels/_initializer.py +83 -59
  28. squirrels/_logging.py +117 -0
  29. squirrels/_manifest.py +129 -62
  30. squirrels/_model_builder.py +10 -52
  31. squirrels/_model_configs.py +3 -3
  32. squirrels/_model_queries.py +1 -1
  33. squirrels/_models.py +249 -118
  34. squirrels/{package_data → _package_data}/base_project/.env +16 -4
  35. squirrels/{package_data → _package_data}/base_project/.env.example +15 -3
  36. squirrels/{package_data → _package_data}/base_project/connections.yml +4 -3
  37. squirrels/{package_data → _package_data}/base_project/dashboards/dashboard_example.py +4 -4
  38. squirrels/_package_data/base_project/dashboards/dashboard_example.yml +22 -0
  39. squirrels/{package_data → _package_data}/base_project/duckdb_init.sql +1 -0
  40. squirrels/_package_data/base_project/macros/macros_example.sql +17 -0
  41. squirrels/{package_data → _package_data}/base_project/models/builds/build_example.py +2 -2
  42. squirrels/{package_data → _package_data}/base_project/models/builds/build_example.sql +1 -1
  43. squirrels/{package_data → _package_data}/base_project/models/builds/build_example.yml +2 -0
  44. squirrels/_package_data/base_project/models/dbviews/dbview_example.sql +17 -0
  45. squirrels/_package_data/base_project/models/dbviews/dbview_example.yml +32 -0
  46. squirrels/_package_data/base_project/models/federates/federate_example.py +48 -0
  47. squirrels/_package_data/base_project/models/federates/federate_example.sql +21 -0
  48. squirrels/{package_data → _package_data}/base_project/models/federates/federate_example.yml +7 -7
  49. squirrels/{package_data → _package_data}/base_project/models/sources.yml +5 -6
  50. squirrels/{package_data → _package_data}/base_project/parameters.yml +32 -45
  51. squirrels/_package_data/base_project/pyconfigs/connections.py +18 -0
  52. squirrels/{package_data → _package_data}/base_project/pyconfigs/context.py +31 -22
  53. squirrels/_package_data/base_project/pyconfigs/parameters.py +141 -0
  54. squirrels/_package_data/base_project/pyconfigs/user.py +44 -0
  55. squirrels/{package_data → _package_data}/base_project/seeds/seed_categories.yml +1 -1
  56. squirrels/{package_data → _package_data}/base_project/seeds/seed_subcategories.yml +1 -1
  57. squirrels/_package_data/base_project/squirrels.yml.j2 +61 -0
  58. squirrels/_package_data/templates/dataset_results.html +112 -0
  59. squirrels/_package_data/templates/oauth_login.html +271 -0
  60. squirrels/_package_data/templates/squirrels_studio.html +20 -0
  61. squirrels/_parameter_configs.py +76 -55
  62. squirrels/_parameter_options.py +348 -0
  63. squirrels/_parameter_sets.py +53 -45
  64. squirrels/_parameters.py +1664 -0
  65. squirrels/_project.py +403 -242
  66. squirrels/_py_module.py +3 -2
  67. squirrels/_request_context.py +33 -0
  68. squirrels/_schemas/__init__.py +0 -0
  69. squirrels/_schemas/auth_models.py +167 -0
  70. squirrels/_schemas/query_param_models.py +75 -0
  71. squirrels/{_api_response_models.py → _schemas/response_models.py} +48 -18
  72. squirrels/_seeds.py +1 -1
  73. squirrels/_sources.py +23 -19
  74. squirrels/_utils.py +121 -39
  75. squirrels/_version.py +1 -1
  76. squirrels/arguments.py +7 -0
  77. squirrels/auth.py +4 -0
  78. squirrels/connections.py +3 -0
  79. squirrels/dashboards.py +2 -81
  80. squirrels/data_sources.py +14 -563
  81. squirrels/parameter_options.py +13 -348
  82. squirrels/parameters.py +14 -1266
  83. squirrels/types.py +16 -0
  84. {squirrels-0.5.0rc0.dist-info → squirrels-0.5.1.dist-info}/METADATA +42 -30
  85. squirrels-0.5.1.dist-info/RECORD +98 -0
  86. squirrels/package_data/base_project/dashboards/dashboard_example.yml +0 -22
  87. squirrels/package_data/base_project/macros/macros_example.sql +0 -15
  88. squirrels/package_data/base_project/models/dbviews/dbview_example.sql +0 -12
  89. squirrels/package_data/base_project/models/dbviews/dbview_example.yml +0 -26
  90. squirrels/package_data/base_project/models/federates/federate_example.py +0 -44
  91. squirrels/package_data/base_project/models/federates/federate_example.sql +0 -17
  92. squirrels/package_data/base_project/pyconfigs/connections.py +0 -14
  93. squirrels/package_data/base_project/pyconfigs/parameters.py +0 -93
  94. squirrels/package_data/base_project/pyconfigs/user.py +0 -23
  95. squirrels/package_data/base_project/squirrels.yml.j2 +0 -71
  96. squirrels-0.5.0rc0.dist-info/RECORD +0 -70
  97. /squirrels/{package_data → _package_data}/base_project/assets/expenses.db +0 -0
  98. /squirrels/{package_data → _package_data}/base_project/assets/weather.db +0 -0
  99. /squirrels/{package_data → _package_data}/base_project/docker/.dockerignore +0 -0
  100. /squirrels/{package_data → _package_data}/base_project/docker/Dockerfile +0 -0
  101. /squirrels/{package_data → _package_data}/base_project/docker/compose.yml +0 -0
  102. /squirrels/{package_data/base_project/.gitignore → _package_data/base_project/gitignore} +0 -0
  103. /squirrels/{package_data → _package_data}/base_project/seeds/seed_categories.csv +0 -0
  104. /squirrels/{package_data → _package_data}/base_project/seeds/seed_subcategories.csv +0 -0
  105. /squirrels/{package_data → _package_data}/base_project/tmp/.gitignore +0 -0
  106. {squirrels-0.5.0rc0.dist-info → squirrels-0.5.1.dist-info}/WHEEL +0 -0
  107. {squirrels-0.5.0rc0.dist-info → squirrels-0.5.1.dist-info}/entry_points.txt +0 -0
  108. {squirrels-0.5.0rc0.dist-info → squirrels-0.5.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,271 @@
1
+ """
2
+ Authentication and user management routes
3
+ """
4
+ from typing import Annotated, Literal
5
+ from fastapi import FastAPI, Depends, Request, Response, status, Form, APIRouter
6
+ from fastapi.responses import RedirectResponse
7
+ from fastapi.security import HTTPBearer
8
+ from pydantic import BaseModel, Field
9
+ from authlib.integrations.starlette_client import OAuth
10
+
11
+ from .._schemas import response_models as rm
12
+ from .._exceptions import InvalidInputError
13
+ from .._schemas.auth_models import AbstractUser, RegisteredUser, GuestUser
14
+ from .base import RouteBase
15
+
16
+
17
+ class AuthRoutes(RouteBase):
18
+ """Authentication and user management routes"""
19
+
20
+ def __init__(self, get_bearer_token: HTTPBearer, project, no_cache: bool = False):
21
+ super().__init__(get_bearer_token, project, no_cache)
22
+
23
+ def setup_routes(self, app: FastAPI, squirrels_version_path: str) -> None:
24
+ """Setup all authentication routes"""
25
+
26
+ auth_path = squirrels_version_path + "/auth"
27
+ auth_router = APIRouter(prefix=auth_path)
28
+ user_management_router = APIRouter(prefix=auth_path + "/user-management")
29
+
30
+ # Get expiry configuration
31
+ expiry_mins = self._get_access_token_expiry_minutes()
32
+
33
+ # Create user models
34
+ class UpdateUserModel(self.authenticator.CustomUserFields):
35
+ access_level: Literal["admin", "member"]
36
+
37
+ class UserInfoModel(UpdateUserModel):
38
+ username: str
39
+
40
+ class AddUserModel(UserInfoModel):
41
+ password: str
42
+
43
+ # Setup OAuth2 login providers
44
+ oauth = OAuth()
45
+
46
+ for provider in self.authenticator.auth_providers:
47
+ oauth.register(
48
+ name=provider.name,
49
+ server_metadata_url=provider.provider_configs.server_metadata_url,
50
+ client_id=provider.provider_configs.client_id,
51
+ client_secret=provider.provider_configs.client_secret,
52
+ client_kwargs=provider.provider_configs.client_kwargs
53
+ )
54
+
55
+ # User info endpoint
56
+ @auth_router.get("/userinfo", description="Get the authenticated user's fields", tags=["Authentication"])
57
+ async def get_userinfo(user: RegisteredUser | GuestUser = Depends(self.get_current_user)) -> UserInfoModel:
58
+ if isinstance(user, GuestUser):
59
+ raise InvalidInputError(401, "invalid_authorization_token", "Invalid authorization token, no user info found")
60
+ custom_fields = user.custom_fields.model_dump(mode='json')
61
+ return UserInfoModel(username=user.username, access_level=user.access_level, **custom_fields)
62
+
63
+ # Login helper
64
+ def login_helper(
65
+ request: Request, user: RegisteredUser, redirect_url: str | None, *,
66
+ redirect_status_code: int = status.HTTP_307_TEMPORARY_REDIRECT
67
+ ):
68
+ access_token, expiry = self.authenticator.create_access_token(user, expiry_minutes=expiry_mins)
69
+ request.session["access_token"] = access_token
70
+ request.session["access_token_expiry"] = expiry.timestamp()
71
+ return RedirectResponse(url=redirect_url, status_code=redirect_status_code) if redirect_url else user
72
+
73
+ # Login endpoints
74
+ @auth_router.post("/login", tags=["Authentication"], description="Authenticate with username and password. Returns user information if no redirect_url is provided, otherwise redirects to the specified URL.", responses={
75
+ 200: {"model": UserInfoModel, "description": "Login successful, returns user information"},
76
+ 302: {"description": "Redirect if redirect URL parameter is specified"},
77
+ })
78
+ async def login(request: Request, username: Annotated[str, Form()], password: Annotated[str, Form()], redirect_url: str | None = None):
79
+ if self.manifest_cfg.authentication.type.value == "external":
80
+ raise InvalidInputError(403, "forbidden_login", "Username/password login is disabled when authentication.type is 'external'")
81
+ user = self.authenticator.get_user(username, password)
82
+ return login_helper(request, user, redirect_url, redirect_status_code=status.HTTP_302_FOUND)
83
+
84
+ @auth_router.get("/login", tags=["Authentication"], description="Authenticate with an existing API key or session token. Returns user information if no redirect_url is provided, otherwise redirects to the specified URL.", responses={
85
+ 200: {"model": UserInfoModel, "description": "Login successful, returns user information"},
86
+ 307: {"description": "Redirect if redirect URL parameter is specified"},
87
+ })
88
+ async def login_with_api_key(
89
+ request: Request, redirect_url: str | None = None, user: RegisteredUser | GuestUser = Depends(self.get_current_user)
90
+ ):
91
+ if isinstance(user, GuestUser):
92
+ raise InvalidInputError(401, "invalid_authorization_token", "Invalid authorization token, no user info found")
93
+ return login_helper(request, user, redirect_url)
94
+
95
+ # Provider authentication endpoints
96
+ providers_path = '/providers'
97
+ provider_login_path = '/providers/{provider_name}/login'
98
+ provider_callback_path = '/providers/{provider_name}/callback'
99
+
100
+ @auth_router.get(providers_path, tags=["Authentication"])
101
+ async def get_providers(request: Request) -> list[rm.ProviderResponse]:
102
+ """Get list of available authentication providers"""
103
+ return [
104
+ rm.ProviderResponse(
105
+ name=provider.name,
106
+ label=provider.label,
107
+ icon=provider.icon,
108
+ login_url=str(request.url_for('provider_login', provider_name=provider.name))
109
+ )
110
+ for provider in self.authenticator.auth_providers
111
+ ]
112
+
113
+ @auth_router.get(provider_login_path, tags=["Authentication"])
114
+ async def provider_login(request: Request, provider_name: str, redirect_url: str | None = None) -> RedirectResponse:
115
+ """Redirect to the login URL for the OAuth provider"""
116
+ client = oauth.create_client(provider_name)
117
+ if client is None:
118
+ raise InvalidInputError(status_code=404, error="provider_not_found", error_description=f"Provider {provider_name} not found or configured.")
119
+
120
+ callback_uri = str(request.url_for('provider_callback', provider_name=provider_name))
121
+ request.session["redirect_url"] = redirect_url
122
+
123
+ return await client.authorize_redirect(request, callback_uri)
124
+
125
+ @auth_router.get(provider_callback_path, tags=["Authentication"], responses={
126
+ 200: {"model": UserInfoModel, "description": "Login successful, returns user information"},
127
+ 302: {"description": "Redirect if redirect_url is in session"},
128
+ })
129
+ async def provider_callback(request: Request, provider_name: str):
130
+ """Handle OAuth callback from provider"""
131
+ client = oauth.create_client(provider_name)
132
+ if client is None:
133
+ raise InvalidInputError(status_code=404, error="provider_not_found", error_description=f"Provider {provider_name} not found or configured.")
134
+
135
+ try:
136
+ token = await client.authorize_access_token(request)
137
+ except Exception as e:
138
+ raise InvalidInputError(status_code=400, error="provider_authorization_failed", error_description=f"Could not authorize with provider for access token: {str(e)}")
139
+
140
+ user_info: dict = {}
141
+ if token:
142
+ if 'userinfo' in token:
143
+ user_info = token['userinfo']
144
+ elif 'id_token' in token and isinstance(token['id_token'], dict) and 'sub' in token['id_token']:
145
+ user_info = token['id_token']
146
+ else:
147
+ raise InvalidInputError(status_code=400, error="invalid_provider_user_info", error_description=f"User information not found in token for {provider_name}")
148
+
149
+ user = self.authenticator.create_or_get_user_from_provider(provider_name, user_info)
150
+ access_token, expiry = self.authenticator.create_access_token(user, expiry_minutes=expiry_mins)
151
+ request.session["access_token"] = access_token
152
+ request.session["access_token_expiry"] = expiry.timestamp()
153
+
154
+ redirect_url = request.session.pop("redirect_url", None)
155
+ return RedirectResponse(url=redirect_url) if redirect_url else user
156
+
157
+ # Logout endpoint
158
+ logout_path = '/logout'
159
+
160
+ @auth_router.get(logout_path, tags=["Authentication"], responses={
161
+ 200: {"description": "Logout successful"},
162
+ 302: {"description": "Redirect if redirect URL parameter is specified"},
163
+ })
164
+ async def logout(request: Request, redirect_url: str | None = None):
165
+ """Logout the current user, and redirect to the specified URL if provided"""
166
+ request.session.pop("access_token", None)
167
+ request.session.pop("access_token_expiry", None)
168
+ if redirect_url:
169
+ return RedirectResponse(url=redirect_url)
170
+
171
+ # Change password endpoint
172
+ change_password_path = '/password'
173
+
174
+ class ChangePasswordRequest(BaseModel):
175
+ old_password: str
176
+ new_password: str
177
+
178
+ @auth_router.put(change_password_path, description="Change the password for the current user", tags=["Authentication"])
179
+ async def change_password(request: ChangePasswordRequest, user: RegisteredUser | GuestUser = Depends(self.get_current_user)) -> None:
180
+ if isinstance(user, GuestUser):
181
+ raise InvalidInputError(401, "invalid_authorization_token", "Invalid authorization token")
182
+ self.authenticator.change_password(user.username, request.old_password, request.new_password)
183
+
184
+ # API Key endpoints
185
+ api_key_path = '/api-key'
186
+
187
+ class ApiKeyRequestBody(BaseModel):
188
+ title: str = Field(description=f"The title of the API key")
189
+ expiry_minutes: int | None = Field(
190
+ default=None,
191
+ description=f"The number of minutes the API key is valid for (or valid indefinitely if not provided)."
192
+ )
193
+
194
+ @auth_router.post(api_key_path, description="Create a new API key for the user", tags=["Authentication"])
195
+ async def create_api_key(body: ApiKeyRequestBody, user: RegisteredUser | GuestUser = Depends(self.get_current_user)) -> rm.ApiKeyResponse:
196
+ if isinstance(user, GuestUser):
197
+ raise InvalidInputError(401, "invalid_authorization_token", "Invalid authorization token, cannot create API key")
198
+
199
+ api_key, _ = self.authenticator.create_access_token(user, expiry_minutes=body.expiry_minutes, title=body.title)
200
+ return rm.ApiKeyResponse(api_key=api_key)
201
+
202
+ @auth_router.get(api_key_path, description="Get all API keys with title for the current user", tags=["Authentication"])
203
+ async def get_all_api_keys(user: RegisteredUser | GuestUser = Depends(self.get_current_user)):
204
+ if isinstance(user, GuestUser):
205
+ raise InvalidInputError(401, "invalid_authorization_token", "Invalid authorization token, cannot get API keys")
206
+ return self.authenticator.get_all_api_keys(user.username)
207
+
208
+ revoke_api_key_path = '/api-key/{api_key_id}'
209
+
210
+ @auth_router.delete(revoke_api_key_path, description="Revoke an API key", tags=["Authentication"], responses={
211
+ 204: { "description": "API key revoked successfully" }
212
+ })
213
+ async def revoke_api_key(api_key_id: str, user: RegisteredUser | GuestUser = Depends(self.get_current_user)) -> Response:
214
+ if isinstance(user, GuestUser):
215
+ raise InvalidInputError(401, "invalid_authorization_token", "Invalid authorization token, cannot revoke API key")
216
+ self.authenticator.revoke_api_key(user.username, api_key_id)
217
+ return Response(status_code=204)
218
+
219
+ # User management endpoints (disabled if external auth only)
220
+ if self.manifest_cfg.authentication.type.value == "managed":
221
+ user_fields_path = '/user-fields'
222
+
223
+ @user_management_router.get(user_fields_path, description="Get details of the user fields", tags=["User Management"])
224
+ async def get_user_fields():
225
+ return self.authenticator.user_fields
226
+
227
+ add_user_path = '/users'
228
+
229
+ @user_management_router.post(add_user_path, description="Add a new user by providing details for username, password, and user fields", tags=["User Management"])
230
+ async def add_user(
231
+ new_user: AddUserModel, user: AbstractUser = Depends(self.get_current_user)
232
+ ) -> None:
233
+ if user.access_level != "admin":
234
+ raise InvalidInputError(403, "unauthorized_to_add_user", "Current user cannot add new users")
235
+ self.authenticator.add_user(new_user.username, new_user.model_dump(mode='json', exclude={"username"}))
236
+
237
+ update_user_path = '/users/{username}'
238
+
239
+ @user_management_router.put(update_user_path, description="Update the user of the given username given the new user details", tags=["User Management"])
240
+ async def update_user(
241
+ username: str, updated_user: UpdateUserModel, user: AbstractUser = Depends(self.get_current_user)
242
+ ) -> None:
243
+ if user.access_level != "admin":
244
+ raise InvalidInputError(403, "unauthorized_to_update_user", "Current user cannot update users")
245
+ self.authenticator.add_user(username, updated_user.model_dump(mode='json'), update_user=True)
246
+
247
+ list_users_path = '/users'
248
+
249
+ @user_management_router.get(list_users_path, tags=["User Management"])
250
+ async def list_all_users() -> list[UserInfoModel]:
251
+ registered_users = self.authenticator.get_all_users()
252
+ return [
253
+ UserInfoModel(username=user.username, access_level=user.access_level, **user.custom_fields.model_dump(mode='json'))
254
+ for user in registered_users
255
+ ]
256
+
257
+ delete_user_path = '/users/{username}'
258
+
259
+ @user_management_router.delete(delete_user_path, tags=["User Management"], responses={
260
+ 204: { "description": "User deleted successfully" }
261
+ })
262
+ async def delete_user(username: str, user: AbstractUser = Depends(self.get_current_user)) -> Response:
263
+ if user.access_level != "admin":
264
+ raise InvalidInputError(403, "unauthorized_to_delete_user", "Current user cannot delete users")
265
+ if username == user.username:
266
+ raise InvalidInputError(403, "cannot_delete_own_user", "Cannot delete your own user")
267
+ self.authenticator.delete_user(username)
268
+ return Response(status_code=204)
269
+
270
+ app.include_router(auth_router)
271
+ app.include_router(user_management_router)
@@ -0,0 +1,171 @@
1
+ """
2
+ Base utilities and dependencies for API routes
3
+ """
4
+ from typing import Any, Mapping, TypeVar, Callable, Coroutine
5
+ from fastapi import Request, Response, Depends
6
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
7
+ from fastapi.templating import Jinja2Templates
8
+ from cachetools import TTLCache
9
+ from mcp.server.fastmcp import Context
10
+ from pathlib import Path
11
+ from datetime import datetime, timezone
12
+
13
+ from .. import _utils as u, _constants as c
14
+ from .._exceptions import InvalidInputError, ConfigurationError
15
+ from .._project import SquirrelsProject
16
+ from .._schemas.auth_models import GuestUser, RegisteredUser
17
+
18
+ T = TypeVar('T')
19
+
20
+
21
+ class RouteBase:
22
+ """Base class for route modules providing common functionality"""
23
+
24
+ def __init__(self, get_bearer_token: HTTPBearer, project: SquirrelsProject, no_cache: bool = False):
25
+ self.project = project
26
+ self.no_cache = no_cache
27
+ self.logger = project._logger
28
+ self.env_vars = project._env_vars
29
+ self.manifest_cfg = project._manifest_cfg
30
+ self.authenticator = project._auth
31
+ self.param_cfg_set = project._param_cfg_set
32
+
33
+ # Setup templates
34
+ template_dir = Path(__file__).parent.parent / "_package_data" / "templates"
35
+ self.templates = Jinja2Templates(directory=str(template_dir))
36
+
37
+ # Authorization dependency for current user
38
+ def get_token_from_session(request: Request) -> str | None:
39
+ expiry = request.session.get("access_token_expiry")
40
+ datetime_now = datetime.now(timezone.utc).timestamp()
41
+ if expiry and expiry > datetime_now:
42
+ return request.session.get("access_token")
43
+ else:
44
+ request.session.pop("access_token", None)
45
+ request.session.pop("access_token_expiry", None)
46
+ return None
47
+
48
+ async def get_current_user(
49
+ request: Request, response: Response, auth: HTTPAuthorizationCredentials = Depends(get_bearer_token)
50
+ ) -> GuestUser | RegisteredUser:
51
+ token = auth.credentials if auth and auth.scheme == "Bearer" else None
52
+ access_token = token if token else get_token_from_session(request)
53
+ api_key = request.headers.get("x-api-key")
54
+ final_token = api_key if api_key else access_token
55
+
56
+ user = self.authenticator.get_user_from_token(final_token)
57
+ if user is None:
58
+ user = self.project._guest_user
59
+
60
+ response.headers["Applied-Username"] = user.username
61
+ return user
62
+
63
+ self.get_current_user = get_current_user
64
+
65
+ @property
66
+ def _parameters_description(self) -> str:
67
+ """Get the standard parameters description"""
68
+ return "Selections of one parameter may cascade the available options in another parameter. " \
69
+ "For example, if the dataset has parameters for 'country' and 'city', available options for 'city' would " \
70
+ "depend on the selected option 'country'. If a parameter has 'trigger_refresh' as true, provide the parameter " \
71
+ "selection to this endpoint whenever it changes to refresh the parameter options of children parameters."
72
+
73
+ def _validate_request_params(self, all_request_params: Mapping, params: Mapping, headers: Mapping[str, str]) -> None:
74
+ """Validate request parameters
75
+
76
+ When header 'x-verify-params' is set to a truthy value, ensure that all provided
77
+ query/body parameters are part of the parsed params model. Falls back to legacy
78
+ query param 'x_verify_params' for backward compatibility.
79
+ """
80
+ header_val = headers.get("x-verify-params")
81
+ verify_params = u.to_bool(header_val) or u.to_bool(params.get("x_verify_params", False))
82
+ if verify_params:
83
+ invalid_params = [param for param in all_request_params if param not in params]
84
+ if invalid_params:
85
+ raise InvalidInputError(400, "invalid_query_parameters", f"Invalid query parameters: {', '.join(invalid_params)}")
86
+
87
+ def get_selections_as_immutable(self, params: Mapping, uncached_keys: set[str]) -> tuple[tuple[str, Any], ...]:
88
+ """Convert selections into a cachable tuple of pairs"""
89
+ selections = list()
90
+ for key, val in params.items():
91
+ if key in uncached_keys or val is None:
92
+ continue
93
+ if isinstance(val, (list, tuple)):
94
+ if len(val) == 1: # for backward compatibility
95
+ val = val[0]
96
+ else:
97
+ val = tuple(val)
98
+ selections.append((u.normalize_name(key), val))
99
+ return tuple(selections)
100
+
101
+ async def do_cachable_action(self, cache: TTLCache, action: Callable[..., Coroutine[Any, Any, T]], *args) -> T:
102
+ """Execute a cachable action"""
103
+ cache_key = tuple(args)
104
+ result = cache.get(cache_key)
105
+ if result is None:
106
+ result = await action(*args)
107
+ cache[cache_key] = result
108
+ return result
109
+
110
+ def get_name_from_path_section(self, request: Request, section: int) -> str:
111
+ """Extract name from request path section"""
112
+ url_path: str = request.scope['route'].path
113
+ name_raw = url_path.split('/')[section]
114
+ return u.normalize_name(name_raw)
115
+
116
+ def _get_access_token_expiry_minutes(self) -> int:
117
+ """Get access token expiry minutes"""
118
+ expiry_mins = self.env_vars.get(c.SQRL_AUTH_TOKEN_EXPIRE_MINUTES, 30)
119
+ try:
120
+ expiry_mins = int(expiry_mins)
121
+ except ValueError:
122
+ raise ConfigurationError(f"Value for environment variable {c.SQRL_AUTH_TOKEN_EXPIRE_MINUTES} is not an integer, got: {expiry_mins}")
123
+ return expiry_mins
124
+
125
+ def get_headers_from_tool_ctx(self, tool_ctx: Context) -> dict[str, str]:
126
+ request = tool_ctx.request_context.request
127
+ assert request is not None and hasattr(request, "headers")
128
+ return dict(request.headers)
129
+
130
+ def get_configurables_from_headers(self, headers: Mapping[str, str]) -> tuple[tuple[str, str], ...]:
131
+ """Extract configurables from request headers with prefix 'x-config-'."""
132
+ prefix = "x-config-"
133
+ cfg_pairs: list[tuple[str, str]] = []
134
+ seen_configurables: dict[str, str] = {} # normalized_name -> header_name
135
+
136
+ for key, value in headers.items():
137
+ key_lower = str(key).lower()
138
+ if key_lower.startswith(prefix):
139
+ cfg_name_raw = key_lower[len(prefix):]
140
+ cfg_name_normalized = u.normalize_name(cfg_name_raw) # Convert to underscore convention
141
+
142
+ # Check if we've already seen this configurable (with different header format)
143
+ if cfg_name_normalized in seen_configurables:
144
+ existing_header = seen_configurables[cfg_name_normalized]
145
+ raise InvalidInputError(
146
+ 400, "duplicate_configurable_header",
147
+ f"Only one header format is allowed for configurable '{cfg_name_normalized}'. "
148
+ f"Both '{existing_header}' and '{key_lower}' were provided."
149
+ )
150
+
151
+ seen_configurables[cfg_name_normalized] = key_lower
152
+ cfg_pairs.append((cfg_name_normalized, str(value)))
153
+
154
+ configurables = [k for k, _ in cfg_pairs]
155
+ self.logger.info(f"Configurables specified: {configurables}", data={"configurables_specified": configurables})
156
+ return tuple(cfg_pairs)
157
+
158
+ def get_user_from_tool_headers(self, headers: dict[str, str]):
159
+ authorization_header = headers.get('Authorization')
160
+ if authorization_header:
161
+ parts = authorization_header.split()
162
+ if len(parts) == 2 and parts[0] == 'Bearer':
163
+ access_token = parts[1]
164
+ user = self.authenticator.get_user_from_token(access_token)
165
+ if user is None:
166
+ return self.project._guest_user
167
+ return user
168
+ else:
169
+ raise ValueError("Invalid Authorization header format")
170
+ else:
171
+ return self.project._guest_user
@@ -0,0 +1,158 @@
1
+ """
2
+ Dashboard routes for parameters and results
3
+ """
4
+ from typing import Callable, Coroutine, Any
5
+ from fastapi import FastAPI, Depends, Request, Response
6
+ from fastapi.responses import JSONResponse, HTMLResponse
7
+ from fastapi.security import HTTPBearer
8
+ from dataclasses import asdict
9
+ from cachetools import TTLCache
10
+ import time
11
+
12
+ from .. import _constants as c, _utils as u
13
+ from .._schemas import response_models as rm
14
+ from .._exceptions import ConfigurationError
15
+ from .._dashboards import Dashboard
16
+ from .._schemas.query_param_models import get_query_models_for_parameters, get_query_models_for_dashboard
17
+ from .._schemas.auth_models import AbstractUser
18
+ from .base import RouteBase
19
+
20
+
21
+ class DashboardRoutes(RouteBase):
22
+ """Dashboard parameter and result routes"""
23
+
24
+ def __init__(self, get_bearer_token: HTTPBearer, project, no_cache: bool = False):
25
+ super().__init__(get_bearer_token, project, no_cache)
26
+
27
+ # Setup caches
28
+ dashboard_results_cache_size = int(self.env_vars.get(c.SQRL_DASHBOARDS_CACHE_SIZE, 128))
29
+ dashboard_results_cache_ttl = int(self.env_vars.get(c.SQRL_DASHBOARDS_CACHE_TTL_MINUTES, 60))
30
+ self.dashboard_results_cache = TTLCache(maxsize=dashboard_results_cache_size, ttl=dashboard_results_cache_ttl*60)
31
+
32
+ async def _get_dashboard_results_helper(
33
+ self, dashboard: str, user: AbstractUser, selections: tuple[tuple[str, Any], ...], configurables: tuple[tuple[str, str], ...]
34
+ ) -> Dashboard:
35
+ """Helper to get dashboard results"""
36
+ cfg_filtered = {k: v for k, v in dict(configurables).items() if k in self.manifest_cfg.configurables}
37
+ return await self.project.dashboard(dashboard, user=user, selections=dict(selections), configurables=cfg_filtered)
38
+
39
+ async def _get_dashboard_results_cachable(
40
+ self, dashboard: str, user: AbstractUser, selections: tuple[tuple[str, Any], ...], configurables: tuple[tuple[str, str], ...]
41
+ ) -> Dashboard:
42
+ """Cachable version of dashboard results helper"""
43
+ return await self.do_cachable_action(self.dashboard_results_cache, self._get_dashboard_results_helper, dashboard, user, selections, configurables)
44
+
45
+ async def _get_dashboard_results_definition(
46
+ self, dashboard_name: str, user: AbstractUser, all_request_params: dict, params: dict, headers: dict[str, str]
47
+ ) -> Response:
48
+ """Get dashboard results definition"""
49
+ self._validate_request_params(all_request_params, params, headers)
50
+
51
+ get_dashboard_function = self._get_dashboard_results_helper if self.no_cache else self._get_dashboard_results_cachable
52
+ selections = self.get_selections_as_immutable(params, uncached_keys={"x_verify_params"})
53
+
54
+ user_has_elevated_privileges = u.user_has_elevated_privileges(user.access_level, self.project._elevated_access_level)
55
+ configurables = self.get_configurables_from_headers(headers) if user_has_elevated_privileges else tuple()
56
+ dashboard_obj = await get_dashboard_function(dashboard_name, user, selections, configurables)
57
+
58
+ if dashboard_obj._format == c.PNG:
59
+ assert isinstance(dashboard_obj._content, bytes)
60
+ result = Response(dashboard_obj._content, media_type="image/png")
61
+ elif dashboard_obj._format == c.HTML:
62
+ result = HTMLResponse(dashboard_obj._content)
63
+ else:
64
+ raise NotImplementedError()
65
+ return result
66
+
67
+ def setup_routes(
68
+ self, app: FastAPI, project_metadata_path: str, param_fields: dict, get_parameters_definition: Callable[..., Coroutine[Any, Any, rm.ParametersModel]]
69
+ ) -> None:
70
+ """Setup dashboard routes"""
71
+
72
+ dashboard_results_path = project_metadata_path + '/dashboard/{dashboard}'
73
+ dashboard_parameters_path = dashboard_results_path + '/parameters'
74
+
75
+ def validate_parameters_list(parameters: list[str] | None, entity_type: str, dashboard_name: str) -> None:
76
+ if parameters is None:
77
+ return
78
+ for param in parameters:
79
+ if param not in param_fields:
80
+ all_params = list(param_fields.keys())
81
+ raise ConfigurationError(
82
+ f"{entity_type} '{dashboard_name}' use parameter '{param}' which doesn't exist. Available parameters are:"
83
+ f"\n {all_params}"
84
+ )
85
+
86
+ # Dashboard parameters and results APIs
87
+ for dashboard_name, dashboard in self.project._dashboards.items():
88
+ dashboard_name_for_api = u.normalize_name_for_api(dashboard_name)
89
+ curr_parameters_path = dashboard_parameters_path.format(dashboard=dashboard_name_for_api)
90
+ curr_results_path = dashboard_results_path.format(dashboard=dashboard_name_for_api)
91
+
92
+ validate_parameters_list(dashboard.config.parameters, "Dashboard", dashboard_name)
93
+
94
+ QueryModelForGetParams, QueryModelForPostParams = get_query_models_for_parameters(dashboard.config.parameters, param_fields)
95
+ QueryModelForGetDash, QueryModelForPostDash = get_query_models_for_dashboard(dashboard.config.parameters, param_fields)
96
+
97
+ @app.get(curr_parameters_path, tags=[f"Dashboard '{dashboard_name}'"], description=self._parameters_description, response_class=JSONResponse)
98
+ async def get_dashboard_parameters(
99
+ request: Request, params: QueryModelForGetParams, user=Depends(self.get_current_user) # type: ignore
100
+ ) -> rm.ParametersModel:
101
+ start = time.time()
102
+ curr_dashboard_name = self.get_name_from_path_section(request, -2)
103
+ parameters_list = self.project._dashboards[curr_dashboard_name].config.parameters
104
+ scope = self.project._dashboards[curr_dashboard_name].config.scope
105
+ result = await get_parameters_definition(
106
+ parameters_list, "dashboard", curr_dashboard_name, scope, user, dict(request.query_params), asdict(params), headers=dict(request.headers)
107
+ )
108
+ self.logger.log_activity_time(
109
+ "GET REQUEST for PARAMETERS", start, additional_data={"dashboard_name": curr_dashboard_name}
110
+ )
111
+ return result
112
+
113
+ @app.post(curr_parameters_path, tags=[f"Dashboard '{dashboard_name}'"], description=self._parameters_description, response_class=JSONResponse)
114
+ async def get_dashboard_parameters_with_post(
115
+ request: Request, params: QueryModelForPostParams, user=Depends(self.get_current_user) # type: ignore
116
+ ) -> rm.ParametersModel:
117
+ start = time.time()
118
+ curr_dashboard_name = self.get_name_from_path_section(request, -2)
119
+ parameters_list = self.project._dashboards[curr_dashboard_name].config.parameters
120
+ scope = self.project._dashboards[curr_dashboard_name].config.scope
121
+ payload: dict = await request.json()
122
+ result = await get_parameters_definition(
123
+ parameters_list, "dashboard", curr_dashboard_name, scope, user, payload, params.model_dump(), headers=dict(request.headers)
124
+ )
125
+ self.logger.log_activity_time(
126
+ "POST REQUEST for PARAMETERS", start, additional_data={"dashboard_name": curr_dashboard_name}
127
+ )
128
+ return result
129
+
130
+ @app.get(curr_results_path, tags=[f"Dashboard '{dashboard_name}'"], description=dashboard.config.description, response_class=Response)
131
+ async def get_dashboard_results(
132
+ request: Request, params: QueryModelForGetDash, user=Depends(self.get_current_user) # type: ignore
133
+ ) -> Response:
134
+ start = time.time()
135
+ curr_dashboard_name = self.get_name_from_path_section(request, -1)
136
+ result = await self._get_dashboard_results_definition(
137
+ curr_dashboard_name, user, dict(request.query_params), asdict(params), headers=dict(request.headers)
138
+ )
139
+ self.logger.log_activity_time(
140
+ "GET REQUEST for DASHBOARD RESULTS", start, additional_data={"dashboard_name": curr_dashboard_name}
141
+ )
142
+ return result
143
+
144
+ @app.post(curr_results_path, tags=[f"Dashboard '{dashboard_name}'"], description=dashboard.config.description, response_class=Response)
145
+ async def get_dashboard_results_with_post(
146
+ request: Request, params: QueryModelForPostDash, user=Depends(self.get_current_user) # type: ignore
147
+ ) -> Response:
148
+ start = time.time()
149
+ curr_dashboard_name = self.get_name_from_path_section(request, -1)
150
+ payload: dict = await request.json()
151
+ result = await self._get_dashboard_results_definition(
152
+ curr_dashboard_name, user, payload, params.model_dump(), headers=dict(request.headers)
153
+ )
154
+ self.logger.log_activity_time(
155
+ "POST REQUEST for DASHBOARD RESULTS", start, additional_data={"dashboard_name": curr_dashboard_name}
156
+ )
157
+ return result
158
+