squirrels 0.4.1__py3-none-any.whl → 0.5.0__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 (125) 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 +13 -11
  6. squirrels/_api_routes/__init__.py +5 -0
  7. squirrels/_api_routes/auth.py +271 -0
  8. squirrels/_api_routes/base.py +165 -0
  9. squirrels/_api_routes/dashboards.py +150 -0
  10. squirrels/_api_routes/data_management.py +145 -0
  11. squirrels/_api_routes/datasets.py +257 -0
  12. squirrels/_api_routes/oauth2.py +298 -0
  13. squirrels/_api_routes/project.py +252 -0
  14. squirrels/_api_server.py +256 -450
  15. squirrels/_arguments/__init__.py +0 -0
  16. squirrels/_arguments/init_time_args.py +108 -0
  17. squirrels/_arguments/run_time_args.py +147 -0
  18. squirrels/_auth.py +960 -0
  19. squirrels/_command_line.py +126 -45
  20. squirrels/_compile_prompts.py +147 -0
  21. squirrels/_connection_set.py +48 -26
  22. squirrels/_constants.py +68 -38
  23. squirrels/_dashboards.py +160 -0
  24. squirrels/_data_sources.py +570 -0
  25. squirrels/_dataset_types.py +84 -0
  26. squirrels/_exceptions.py +29 -0
  27. squirrels/_initializer.py +177 -80
  28. squirrels/_logging.py +115 -0
  29. squirrels/_manifest.py +208 -79
  30. squirrels/_model_builder.py +69 -0
  31. squirrels/_model_configs.py +74 -0
  32. squirrels/_model_queries.py +52 -0
  33. squirrels/_models.py +926 -367
  34. squirrels/_package_data/base_project/.env +42 -0
  35. squirrels/_package_data/base_project/.env.example +42 -0
  36. squirrels/_package_data/base_project/assets/expenses.db +0 -0
  37. squirrels/_package_data/base_project/connections.yml +16 -0
  38. squirrels/_package_data/base_project/dashboards/dashboard_example.py +34 -0
  39. squirrels/_package_data/base_project/dashboards/dashboard_example.yml +22 -0
  40. squirrels/{package_data → _package_data}/base_project/docker/.dockerignore +5 -2
  41. squirrels/{package_data → _package_data}/base_project/docker/Dockerfile +3 -3
  42. squirrels/{package_data → _package_data}/base_project/docker/compose.yml +1 -1
  43. squirrels/_package_data/base_project/duckdb_init.sql +10 -0
  44. squirrels/{package_data/base_project/.gitignore → _package_data/base_project/gitignore} +3 -2
  45. squirrels/_package_data/base_project/macros/macros_example.sql +17 -0
  46. squirrels/_package_data/base_project/models/builds/build_example.py +26 -0
  47. squirrels/_package_data/base_project/models/builds/build_example.sql +16 -0
  48. squirrels/_package_data/base_project/models/builds/build_example.yml +57 -0
  49. squirrels/_package_data/base_project/models/dbviews/dbview_example.sql +12 -0
  50. squirrels/_package_data/base_project/models/dbviews/dbview_example.yml +26 -0
  51. squirrels/_package_data/base_project/models/federates/federate_example.py +37 -0
  52. squirrels/_package_data/base_project/models/federates/federate_example.sql +19 -0
  53. squirrels/_package_data/base_project/models/federates/federate_example.yml +65 -0
  54. squirrels/_package_data/base_project/models/sources.yml +38 -0
  55. squirrels/{package_data → _package_data}/base_project/parameters.yml +56 -40
  56. squirrels/_package_data/base_project/pyconfigs/connections.py +14 -0
  57. squirrels/{package_data → _package_data}/base_project/pyconfigs/context.py +21 -40
  58. squirrels/_package_data/base_project/pyconfigs/parameters.py +141 -0
  59. squirrels/_package_data/base_project/pyconfigs/user.py +44 -0
  60. squirrels/_package_data/base_project/seeds/seed_categories.yml +15 -0
  61. squirrels/_package_data/base_project/seeds/seed_subcategories.csv +15 -0
  62. squirrels/_package_data/base_project/seeds/seed_subcategories.yml +21 -0
  63. squirrels/_package_data/base_project/squirrels.yml.j2 +61 -0
  64. squirrels/_package_data/templates/dataset_results.html +112 -0
  65. squirrels/_package_data/templates/oauth_login.html +271 -0
  66. squirrels/_package_data/templates/squirrels_studio.html +20 -0
  67. squirrels/_package_loader.py +8 -4
  68. squirrels/_parameter_configs.py +104 -103
  69. squirrels/_parameter_options.py +348 -0
  70. squirrels/_parameter_sets.py +57 -47
  71. squirrels/_parameters.py +1664 -0
  72. squirrels/_project.py +721 -0
  73. squirrels/_py_module.py +7 -5
  74. squirrels/_schemas/__init__.py +0 -0
  75. squirrels/_schemas/auth_models.py +167 -0
  76. squirrels/_schemas/query_param_models.py +75 -0
  77. squirrels/{_api_response_models.py → _schemas/response_models.py} +126 -47
  78. squirrels/_seeds.py +35 -16
  79. squirrels/_sources.py +110 -0
  80. squirrels/_utils.py +248 -73
  81. squirrels/_version.py +1 -1
  82. squirrels/arguments.py +7 -0
  83. squirrels/auth.py +4 -0
  84. squirrels/connections.py +3 -0
  85. squirrels/dashboards.py +2 -81
  86. squirrels/data_sources.py +14 -631
  87. squirrels/parameter_options.py +13 -348
  88. squirrels/parameters.py +14 -1266
  89. squirrels/types.py +16 -0
  90. squirrels-0.5.0.dist-info/METADATA +113 -0
  91. squirrels-0.5.0.dist-info/RECORD +97 -0
  92. {squirrels-0.4.1.dist-info → squirrels-0.5.0.dist-info}/WHEEL +1 -1
  93. squirrels-0.5.0.dist-info/entry_points.txt +3 -0
  94. {squirrels-0.4.1.dist-info → squirrels-0.5.0.dist-info/licenses}/LICENSE +1 -1
  95. squirrels/_authenticator.py +0 -85
  96. squirrels/_dashboards_io.py +0 -61
  97. squirrels/_environcfg.py +0 -84
  98. squirrels/arguments/init_time_args.py +0 -40
  99. squirrels/arguments/run_time_args.py +0 -208
  100. squirrels/package_data/assets/favicon.ico +0 -0
  101. squirrels/package_data/assets/index.css +0 -1
  102. squirrels/package_data/assets/index.js +0 -58
  103. squirrels/package_data/base_project/assets/expenses.db +0 -0
  104. squirrels/package_data/base_project/connections.yml +0 -7
  105. squirrels/package_data/base_project/dashboards/dashboard_example.py +0 -32
  106. squirrels/package_data/base_project/dashboards.yml +0 -10
  107. squirrels/package_data/base_project/env.yml +0 -29
  108. squirrels/package_data/base_project/models/dbviews/dbview_example.py +0 -47
  109. squirrels/package_data/base_project/models/dbviews/dbview_example.sql +0 -22
  110. squirrels/package_data/base_project/models/federates/federate_example.py +0 -21
  111. squirrels/package_data/base_project/models/federates/federate_example.sql +0 -3
  112. squirrels/package_data/base_project/pyconfigs/auth.py +0 -45
  113. squirrels/package_data/base_project/pyconfigs/connections.py +0 -19
  114. squirrels/package_data/base_project/pyconfigs/parameters.py +0 -95
  115. squirrels/package_data/base_project/seeds/seed_subcategories.csv +0 -15
  116. squirrels/package_data/base_project/squirrels.yml.j2 +0 -94
  117. squirrels/package_data/templates/index.html +0 -18
  118. squirrels/project.py +0 -378
  119. squirrels/user_base.py +0 -55
  120. squirrels-0.4.1.dist-info/METADATA +0 -117
  121. squirrels-0.4.1.dist-info/RECORD +0 -60
  122. squirrels-0.4.1.dist-info/entry_points.txt +0 -4
  123. /squirrels/{package_data → _package_data}/base_project/assets/weather.db +0 -0
  124. /squirrels/{package_data → _package_data}/base_project/seeds/seed_categories.csv +0 -0
  125. /squirrels/{package_data → _package_data}/base_project/tmp/.gitignore +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,165 @@
1
+ """
2
+ Base utilities and dependencies for API routes
3
+ """
4
+ from typing import Any, Mapping, TypeVar, Callable, Coroutine, Literal
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 pydantic import BaseModel, create_model, Field
10
+ from mcp.server.fastmcp import Context
11
+ from pathlib import Path
12
+ from datetime import datetime, timezone
13
+
14
+ from .. import _utils as u, _constants as c
15
+ from .._exceptions import InvalidInputError, ConfigurationError
16
+ from .._project import SquirrelsProject
17
+ from .._schemas.auth_models import GuestUser, RegisteredUser
18
+
19
+ T = TypeVar('T')
20
+
21
+
22
+ class RouteBase:
23
+ """Base class for route modules providing common functionality"""
24
+
25
+ def __init__(self, get_bearer_token: HTTPBearer, project: SquirrelsProject, no_cache: bool = False):
26
+ self.project = project
27
+ self.no_cache = no_cache
28
+ self.logger = project._logger
29
+ self.env_vars = project._env_vars
30
+ self.manifest_cfg = project._manifest_cfg
31
+ self.authenticator = project._auth
32
+ self.param_cfg_set = project._param_cfg_set
33
+
34
+ # Setup templates
35
+ template_dir = Path(__file__).parent.parent / "_package_data" / "templates"
36
+ self.templates = Jinja2Templates(directory=str(template_dir))
37
+
38
+ # Authorization dependency for current user
39
+ def get_token_from_session(request: Request) -> str | None:
40
+ expiry = request.session.get("access_token_expiry")
41
+ datetime_now = datetime.now(timezone.utc).timestamp()
42
+ if expiry and expiry > datetime_now:
43
+ return request.session.get("access_token")
44
+ else:
45
+ request.session.pop("access_token", None)
46
+ request.session.pop("access_token_expiry", None)
47
+ return None
48
+
49
+ async def get_current_user(
50
+ request: Request, response: Response, auth: HTTPAuthorizationCredentials = Depends(get_bearer_token)
51
+ ) -> GuestUser | RegisteredUser:
52
+ token = auth.credentials if auth and auth.scheme == "Bearer" else None
53
+ access_token = token if token else get_token_from_session(request)
54
+ api_key = request.headers.get("x-api-key")
55
+ final_token = api_key if api_key else access_token
56
+
57
+ user = self.authenticator.get_user_from_token(final_token)
58
+ if user is None:
59
+ user = self.project._guest_user
60
+
61
+ response.headers["Applied-Username"] = user.username
62
+ return user
63
+
64
+ self.get_current_user = get_current_user
65
+
66
+ @property
67
+ def _parameters_description(self) -> str:
68
+ """Get the standard parameters description"""
69
+ return "Selections of one parameter may cascade the available options in another parameter. " \
70
+ "For example, if the dataset has parameters for 'country' and 'city', available options for 'city' would " \
71
+ "depend on the selected option 'country'. If a parameter has 'trigger_refresh' as true, provide the parameter " \
72
+ "selection to this endpoint whenever it changes to refresh the parameter options of children parameters."
73
+
74
+ def _validate_request_params(self, all_request_params: Mapping, params: Mapping, headers: Mapping[str, str]) -> None:
75
+ """Validate request parameters
76
+
77
+ When header 'x-verify-params' is set to a truthy value, ensure that all provided
78
+ query/body parameters are part of the parsed params model. Falls back to legacy
79
+ query param 'x_verify_params' for backward compatibility.
80
+ """
81
+ header_val = headers.get("x-verify-params")
82
+ verify_params = u.to_bool(header_val) or u.to_bool(params.get("x_verify_params", False))
83
+ if verify_params:
84
+ invalid_params = [param for param in all_request_params if param not in params]
85
+ if invalid_params:
86
+ raise InvalidInputError(400, "invalid_query_parameters", f"Invalid query parameters: {', '.join(invalid_params)}")
87
+
88
+ def get_selections_as_immutable(self, params: Mapping, uncached_keys: set[str]) -> tuple[tuple[str, Any], ...]:
89
+ """Convert selections into a cachable tuple of pairs"""
90
+ selections = list()
91
+ for key, val in params.items():
92
+ if key in uncached_keys or val is None:
93
+ continue
94
+ if isinstance(val, (list, tuple)):
95
+ if len(val) == 1: # for backward compatibility
96
+ val = val[0]
97
+ else:
98
+ val = tuple(val)
99
+ selections.append((u.normalize_name(key), val))
100
+ return tuple(selections)
101
+
102
+ async def do_cachable_action(self, cache: TTLCache, action: Callable[..., Coroutine[Any, Any, T]], *args) -> T:
103
+ """Execute a cachable action"""
104
+ cache_key = tuple(args)
105
+ result = cache.get(cache_key)
106
+ if result is None:
107
+ result = await action(*args)
108
+ cache[cache_key] = result
109
+ return result
110
+
111
+ def _get_request_id(self, request: Request) -> str:
112
+ """Get request ID from headers"""
113
+ return request.headers.get("x-request-id", "")
114
+
115
+ def log_activity_time(self, activity: str, start_time: float, request: Request) -> None:
116
+ """Log activity time"""
117
+ self.logger.log_activity_time(activity, start_time, request_id=self._get_request_id(request))
118
+
119
+ def get_name_from_path_section(self, request: Request, section: int) -> str:
120
+ """Extract name from request path section"""
121
+ url_path: str = request.scope['route'].path
122
+ name_raw = url_path.split('/')[section]
123
+ return u.normalize_name(name_raw)
124
+
125
+ def _get_access_token_expiry_minutes(self) -> int:
126
+ """Get access token expiry minutes"""
127
+ expiry_mins = self.env_vars.get(c.SQRL_AUTH_TOKEN_EXPIRE_MINUTES, 30)
128
+ try:
129
+ expiry_mins = int(expiry_mins)
130
+ except ValueError:
131
+ raise ConfigurationError(f"Value for environment variable {c.SQRL_AUTH_TOKEN_EXPIRE_MINUTES} is not an integer, got: {expiry_mins}")
132
+ return expiry_mins
133
+
134
+ def get_headers_from_tool_ctx(self, tool_ctx: Context) -> dict[str, str]:
135
+ request = tool_ctx.request_context.request
136
+ assert request is not None and hasattr(request, "headers")
137
+ return dict(request.headers)
138
+
139
+ def get_configurables_from_headers(self, headers: Mapping[str, str]) -> tuple[tuple[str, str], ...]:
140
+ """Extract configurables from request headers with prefix 'x-config-'."""
141
+ prefix = "x-config-"
142
+ cfg_pairs: list[tuple[str, str]] = []
143
+ for key, value in headers.items():
144
+ key_lower = str(key).lower()
145
+ if key_lower.startswith(prefix):
146
+ cfg_name = key_lower[len(prefix):]
147
+ cfg_pairs.append((u.normalize_name(cfg_name), str(value)))
148
+
149
+ self.logger.info(f"Configurables specified: {[k for k, _ in cfg_pairs]}")
150
+ return tuple(cfg_pairs)
151
+
152
+ def get_user_from_tool_headers(self, headers: dict[str, str]):
153
+ authorization_header = headers.get('Authorization')
154
+ if authorization_header:
155
+ parts = authorization_header.split()
156
+ if len(parts) == 2 and parts[0] == 'Bearer':
157
+ access_token = parts[1]
158
+ user = self.authenticator.get_user_from_token(access_token)
159
+ if user is None:
160
+ return self.project._guest_user
161
+ return user
162
+ else:
163
+ raise ValueError("Invalid Authorization header format")
164
+ else:
165
+ return self.project._guest_user
@@ -0,0 +1,150 @@
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, 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.log_activity_time("GET REQUEST for PARAMETERS", start, request)
109
+ return result
110
+
111
+ @app.post(curr_parameters_path, tags=[f"Dashboard '{dashboard_name}'"], description=self._parameters_description, response_class=JSONResponse)
112
+ async def get_dashboard_parameters_with_post(
113
+ request: Request, params: QueryModelForPostParams, user=Depends(self.get_current_user) # type: ignore
114
+ ) -> rm.ParametersModel:
115
+ start = time.time()
116
+ curr_dashboard_name = self.get_name_from_path_section(request, -2)
117
+ parameters_list = self.project._dashboards[curr_dashboard_name].config.parameters
118
+ scope = self.project._dashboards[curr_dashboard_name].config.scope
119
+ payload: dict = await request.json()
120
+ result = await get_parameters_definition(
121
+ parameters_list, "dashboard", curr_dashboard_name, scope, user, payload, params.model_dump(), headers=dict(request.headers)
122
+ )
123
+ self.log_activity_time("POST REQUEST for PARAMETERS", start, request)
124
+ return result
125
+
126
+ @app.get(curr_results_path, tags=[f"Dashboard '{dashboard_name}'"], description=dashboard.config.description, response_class=Response)
127
+ async def get_dashboard_results(
128
+ request: Request, params: QueryModelForGetDash, user=Depends(self.get_current_user) # type: ignore
129
+ ) -> Response:
130
+ start = time.time()
131
+ curr_dashboard_name = self.get_name_from_path_section(request, -1)
132
+ result = await self._get_dashboard_results_definition(
133
+ curr_dashboard_name, user, dict(request.query_params), asdict(params), headers=dict(request.headers)
134
+ )
135
+ self.log_activity_time("GET REQUEST for DASHBOARD RESULTS", start, request)
136
+ return result
137
+
138
+ @app.post(curr_results_path, tags=[f"Dashboard '{dashboard_name}'"], description=dashboard.config.description, response_class=Response)
139
+ async def get_dashboard_results_with_post(
140
+ request: Request, params: QueryModelForPostDash, user=Depends(self.get_current_user) # type: ignore
141
+ ) -> Response:
142
+ start = time.time()
143
+ curr_dashboard_name = self.get_name_from_path_section(request, -1)
144
+ payload: dict = await request.json()
145
+ result = await self._get_dashboard_results_definition(
146
+ curr_dashboard_name, user, payload, params.model_dump(), headers=dict(request.headers)
147
+ )
148
+ self.log_activity_time("POST REQUEST for DASHBOARD RESULTS", start, request)
149
+ return result
150
+