adapt-server 0.2.0__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. {adapt_server-0.2.0/adapt_server.egg-info → adapt_server-0.2.2}/PKG-INFO +1 -1
  2. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/__init__.py +1 -1
  3. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/admin/resources.py +5 -0
  4. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/app.py +179 -8
  5. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/commands/check.py +2 -0
  6. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/commands/list_endpoints.py +5 -0
  7. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/config.py +2 -1
  8. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/plugins/dataset_plugin.py +14 -2
  9. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/plugins/media_plugin.py +4 -6
  10. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/templates/base.html +8 -2
  11. adapt_server-0.2.2/adapt/templates/landing.html +93 -0
  12. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/utils/__init__.py +4 -10
  13. {adapt_server-0.2.0 → adapt_server-0.2.2/adapt_server.egg-info}/PKG-INFO +1 -1
  14. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt_server.egg-info/SOURCES.txt +1 -0
  15. {adapt_server-0.2.0 → adapt_server-0.2.2}/pyproject.toml +1 -1
  16. adapt_server-0.2.2/tests/test_cli_integration.py +43 -0
  17. adapt_server-0.2.2/tests/test_integration.py +527 -0
  18. {adapt_server-0.2.0 → adapt_server-0.2.2}/tests/test_phase3.py +8 -3
  19. adapt_server-0.2.2/tests/test_readonly.py +120 -0
  20. adapt_server-0.2.0/adapt/templates/landing.html +0 -56
  21. adapt_server-0.2.0/tests/test_integration.py +0 -273
  22. adapt_server-0.2.0/tests/test_readonly.py +0 -70
  23. {adapt_server-0.2.0 → adapt_server-0.2.2}/LICENSE +0 -0
  24. {adapt_server-0.2.0 → adapt_server-0.2.2}/README.md +0 -0
  25. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/__main__.py +0 -0
  26. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/admin/__init__.py +0 -0
  27. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/admin/api_keys.py +0 -0
  28. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/admin/audit_logs.py +0 -0
  29. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/admin/cache.py +0 -0
  30. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/admin/groups.py +0 -0
  31. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/admin/locks.py +0 -0
  32. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/admin/models.py +0 -0
  33. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/admin/permissions.py +0 -0
  34. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/admin/ui.py +0 -0
  35. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/admin/users.py +0 -0
  36. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/api_keys.py +0 -0
  37. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/audit.py +0 -0
  38. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/auth/__init__.py +0 -0
  39. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/auth/dependencies.py +0 -0
  40. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/auth/password.py +0 -0
  41. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/auth/routes.py +0 -0
  42. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/auth/session.py +0 -0
  43. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/cache.py +0 -0
  44. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/cli.py +0 -0
  45. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/commands/__init__.py +0 -0
  46. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/commands/addsuperuser.py +0 -0
  47. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/commands/admin/__init__.py +0 -0
  48. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/commands/admin/add_to_group.py +0 -0
  49. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/commands/admin/create_group.py +0 -0
  50. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/commands/admin/create_permissions.py +0 -0
  51. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/commands/admin/create_user.py +0 -0
  52. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/commands/admin/delete_group.py +0 -0
  53. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/commands/admin/delete_user.py +0 -0
  54. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/commands/admin/list_groups.py +0 -0
  55. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/commands/admin/list_resources.py +0 -0
  56. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/commands/admin/list_users.py +0 -0
  57. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/commands/admin/remove_from_group.py +0 -0
  58. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/commands/serve.py +0 -0
  59. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/discovery.py +0 -0
  60. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/locks.py +0 -0
  61. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/models.py +0 -0
  62. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/permissions.py +0 -0
  63. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/plugins/__init__.py +0 -0
  64. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/plugins/base.py +0 -0
  65. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/plugins/csv_plugin.py +0 -0
  66. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/plugins/excel_plugin.py +0 -0
  67. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/plugins/html_plugin.py +0 -0
  68. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/plugins/markdown_plugin.py +0 -0
  69. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/plugins/parquet_plugin.py +0 -0
  70. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/plugins/python_plugin.py +0 -0
  71. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/routes.py +0 -0
  72. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/security.py +0 -0
  73. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/security_urls.py +0 -0
  74. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/static/admin/app.js +0 -0
  75. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/static/admin/style.css +0 -0
  76. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/storage.py +0 -0
  77. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/templates/admin/index.html +0 -0
  78. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/templates/admin_base.html +0 -0
  79. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/templates/datatable.html +0 -0
  80. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/templates/login.html +0 -0
  81. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/templates/media_gallery.html +0 -0
  82. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/templates/media_player.html +0 -0
  83. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/templates/profile.html +0 -0
  84. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/utils/query.py +0 -0
  85. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt_server.egg-info/dependency_links.txt +0 -0
  86. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt_server.egg-info/entry_points.txt +0 -0
  87. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt_server.egg-info/requires.txt +0 -0
  88. {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt_server.egg-info/top_level.txt +0 -0
  89. {adapt_server-0.2.0 → adapt_server-0.2.2}/setup.cfg +0 -0
  90. {adapt_server-0.2.0 → adapt_server-0.2.2}/tests/test_admin.py +0 -0
  91. {adapt_server-0.2.0 → adapt_server-0.2.2}/tests/test_auth.py +0 -0
  92. {adapt_server-0.2.0 → adapt_server-0.2.2}/tests/test_config.py +0 -0
  93. {adapt_server-0.2.0 → adapt_server-0.2.2}/tests/test_constraints.py +0 -0
  94. {adapt_server-0.2.0 → adapt_server-0.2.2}/tests/test_csv_plugin.py +0 -0
  95. {adapt_server-0.2.0 → adapt_server-0.2.2}/tests/test_dataset_plugin.py +0 -0
  96. {adapt_server-0.2.0 → adapt_server-0.2.2}/tests/test_discovery.py +0 -0
  97. {adapt_server-0.2.0 → adapt_server-0.2.2}/tests/test_html_plugin.py +0 -0
  98. {adapt_server-0.2.0 → adapt_server-0.2.2}/tests/test_locks.py +0 -0
  99. {adapt_server-0.2.0 → adapt_server-0.2.2}/tests/test_markdown_plugin.py +0 -0
  100. {adapt_server-0.2.0 → adapt_server-0.2.2}/tests/test_on_delete.py +0 -0
  101. {adapt_server-0.2.0 → adapt_server-0.2.2}/tests/test_parquet_plugin.py +0 -0
  102. {adapt_server-0.2.0 → adapt_server-0.2.2}/tests/test_plugin_interface.py +0 -0
  103. {adapt_server-0.2.0 → adapt_server-0.2.2}/tests/test_python_plugin.py +0 -0
  104. {adapt_server-0.2.0 → adapt_server-0.2.2}/tests/test_security_urls.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: adapt-server
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Adaptive file-backed FastAPI server that turns datasets into CRUD APIs and UIs.
5
5
  Author-email: notesofcliff <notesofcliff@gmail.com>
6
6
  License-Expression: MIT
@@ -1,3 +1,3 @@
1
1
  """Adapt package namespace."""
2
2
 
3
- __version__ = "0.1.1"
3
+ __version__ = "0.2.2"
@@ -2,8 +2,10 @@ from pathlib import Path
2
2
  from typing import List
3
3
  import logging
4
4
 
5
+ from .. import cache
5
6
  from ..config import AdaptConfig
6
7
  from ..discovery import discover_resources
8
+ from ..storage import init_database
7
9
 
8
10
  logger = logging.getLogger(__name__)
9
11
 
@@ -14,6 +16,9 @@ def list_resources(root: Path) -> List[str]:
14
16
  Returns a list of resource identifiers including sub-namespaces for multi-resource files.
15
17
  """
16
18
  config = AdaptConfig(root=root)
19
+ config.load_from_file()
20
+ init_database(config.db_path)
21
+ cache.configure(str(config.db_path))
17
22
  resources = discover_resources(root, config)
18
23
  resource_names = []
19
24
  for r in resources:
@@ -6,8 +6,11 @@ from contextlib import asynccontextmanager
6
6
  from pathlib import Path
7
7
 
8
8
  from fastapi import FastAPI, Request, Depends
9
+ from fastapi.openapi.docs import get_swagger_ui_html, get_swagger_ui_oauth2_redirect_html
10
+ from fastapi.openapi.utils import get_openapi
9
11
  from fastapi.responses import JSONResponse, RedirectResponse
10
12
  from fastapi.middleware.trustedhost import TrustedHostMiddleware
13
+ from fastapi.routing import APIRoute
11
14
  from datetime import datetime, timezone
12
15
  import time
13
16
  from .auth.dependencies import get_current_user
@@ -35,6 +38,10 @@ from .security_urls import login_redirect_url
35
38
 
36
39
  logger = logging.getLogger(__name__)
37
40
 
41
+ _DOCS_INTERNAL_PATHS = frozenset({"/docs", "/docs/", "/docs/oauth2-redirect", "/openapi.json"})
42
+ _PUBLIC_OPENAPI_PATHS = frozenset({"/", "/auth/login", "/health"})
43
+ _AUTHENTICATED_OPENAPI_PATHS = frozenset({"/auth/logout", "/auth/me", "/profile", "/api/apikeys"})
44
+
38
45
 
39
46
  async def cleanup_expired_sessions(engine, interval_hours=24):
40
47
  """Background task to clean up expired sessions."""
@@ -52,6 +59,132 @@ async def cleanup_expired_sessions(engine, interval_hours=24):
52
59
  logger.info("Cleaned up %d expired sessions", result.rowcount)
53
60
 
54
61
 
62
+ def _normalize_path(path: str) -> str:
63
+ """Normalize route paths so matching is consistent with and without trailing slashes."""
64
+ normalized = path.rstrip("/")
65
+ return normalized or "/"
66
+
67
+
68
+ def _resource_namespaces(resource) -> set[str]:
69
+ """Return the supported permission namespaces for a discovered resource."""
70
+ namespace_no_ext = resource.relative_path.with_suffix("").as_posix()
71
+ namespace_with_ext = resource.relative_path.as_posix()
72
+ if "sub_namespace" in resource.metadata:
73
+ suffix = f"/{resource.metadata['sub_namespace']}"
74
+ namespace_no_ext += suffix
75
+ namespace_with_ext += suffix
76
+ return {namespace_no_ext, namespace_with_ext}
77
+
78
+
79
+ def _all_resource_namespaces(resources) -> set[str]:
80
+ """Collect all permission namespaces exposed by discovered resources."""
81
+ namespaces: set[str] = set()
82
+ for resource in resources:
83
+ namespaces.update(_resource_namespaces(resource))
84
+ return namespaces
85
+
86
+
87
+ def _visible_resource_namespaces(request: Request, user: User | None) -> set[str]:
88
+ """Return the resource namespaces that should be visible to the current user."""
89
+ resources = request.app.state.resources
90
+ if not user:
91
+ return set()
92
+
93
+ if getattr(user, "is_superuser", False):
94
+ return _all_resource_namespaces(resources)
95
+
96
+ from .permissions import PermissionChecker
97
+
98
+ visible: set[str] = set()
99
+ with Session(request.app.state.db_engine) as db:
100
+ checker = PermissionChecker(db)
101
+ for resource in resources:
102
+ for namespace in _resource_namespaces(resource):
103
+ if checker.has_permission(user, namespace, "read"):
104
+ visible.add(namespace)
105
+ return visible
106
+
107
+
108
+ def _extract_resource_namespace(path: str, all_namespaces: set[str]) -> str | None:
109
+ """Extract the resource namespace from a route path if it maps to a discovered resource."""
110
+ normalized_path = _normalize_path(path)
111
+ stripped_path = normalized_path.lstrip("/")
112
+ if stripped_path in all_namespaces:
113
+ return stripped_path
114
+
115
+ for prefix in ("/api/", "/schema/", "/ui/", "/media/"):
116
+ if normalized_path.startswith(prefix):
117
+ candidate = normalized_path[len(prefix):]
118
+ if candidate in all_namespaces:
119
+ return candidate
120
+ return None
121
+
122
+
123
+ def _route_is_visible(route: APIRoute, request: Request, user: User | None, all_namespaces: set[str], visible_namespaces: set[str]) -> bool:
124
+ """Decide whether a route should appear in the current request's OpenAPI schema."""
125
+ path = _normalize_path(route.path)
126
+
127
+ if path in _DOCS_INTERNAL_PATHS:
128
+ return False
129
+
130
+ if path.startswith("/admin") or "admin" in (route.tags or []):
131
+ return bool(user and getattr(user, "is_superuser", False))
132
+
133
+ if path in _PUBLIC_OPENAPI_PATHS:
134
+ return True
135
+
136
+ if path in _AUTHENTICATED_OPENAPI_PATHS:
137
+ return user is not None
138
+
139
+ if path == "/ui/media":
140
+ media_namespaces = {
141
+ ns
142
+ for r in request.app.state.resources
143
+ if r.resource_type == "media"
144
+ for ns in _resource_namespaces(r)
145
+ }
146
+ return bool(media_namespaces & visible_namespaces)
147
+
148
+ resource_namespace = _extract_resource_namespace(path, all_namespaces)
149
+ if resource_namespace is not None:
150
+ return resource_namespace in visible_namespaces
151
+
152
+ if path.startswith("/auth/"):
153
+ return user is not None
154
+
155
+ return False
156
+
157
+
158
+ def _build_openapi_schema(app: FastAPI, request: Request, user: User | None) -> dict:
159
+ """Build a filtered OpenAPI schema for the current request context."""
160
+ all_namespaces = _all_resource_namespaces(request.app.state.resources)
161
+ visible_namespaces = _visible_resource_namespaces(request, user)
162
+ visible_routes = [
163
+ route
164
+ for route in app.routes
165
+ if isinstance(route, APIRoute)
166
+ and route.include_in_schema
167
+ and _route_is_visible(route, request, user, all_namespaces, visible_namespaces)
168
+ ]
169
+
170
+ return get_openapi(
171
+ title=app.title,
172
+ version=app.version,
173
+ description=app.description,
174
+ routes=visible_routes,
175
+ )
176
+
177
+
178
+ def _visible_resource_paths(request: Request, user: User | None) -> list[str]:
179
+ """Return resource paths visible to the current user for JSON discovery responses."""
180
+ visible_namespaces = _visible_resource_namespaces(request, user)
181
+ visible_resources: list[str] = []
182
+ for resource in request.app.state.resources:
183
+ if _resource_namespaces(resource) & visible_namespaces:
184
+ visible_resources.append(resource.relative_path.as_posix())
185
+ return visible_resources
186
+
187
+
55
188
  @asynccontextmanager
56
189
  async def lifespan(app: FastAPI):
57
190
  """Handle application startup and shutdown events."""
@@ -68,6 +201,9 @@ async def lifespan(app: FastAPI):
68
201
  await cleanup_task
69
202
  except asyncio.CancelledError:
70
203
  logger.debug("Application shutdown: cleanup task cancelled")
204
+ finally:
205
+ engine.dispose()
206
+ logger.debug("Application shutdown: database engine disposed")
71
207
 
72
208
 
73
209
  def create_app(config: AdaptConfig) -> FastAPI:
@@ -82,7 +218,7 @@ def create_app(config: AdaptConfig) -> FastAPI:
82
218
  logger.debug("Creating FastAPI app with config: %s", config)
83
219
  engine = init_database(config.db_path)
84
220
  cache.configure(str(config.db_path))
85
- app = FastAPI(title="Adapt", version=config.version, lifespan=lifespan)
221
+ app = FastAPI(title="Adapt", version=config.version, lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None)
86
222
  app.state.config = config
87
223
  app.state.db_engine = engine
88
224
  app.state.use_tls = bool(config.tls_cert and config.tls_key)
@@ -168,6 +304,27 @@ def create_app(config: AdaptConfig) -> FastAPI:
168
304
 
169
305
  # Generate and mount routes
170
306
  generate_routes(app, resources, config)
307
+
308
+ @app.get("/openapi.json", include_in_schema=False)
309
+ def openapi_schema(request: Request):
310
+ """Return an OpenAPI document filtered to the current user's visible routes."""
311
+ user = get_current_user(request)
312
+ return JSONResponse(_build_openapi_schema(app, request, user))
313
+
314
+ @app.get("/docs", include_in_schema=False)
315
+ @app.get("/docs/", include_in_schema=False)
316
+ def swagger_ui():
317
+ """Render Swagger UI against the request-filtered OpenAPI schema."""
318
+ return get_swagger_ui_html(
319
+ openapi_url="/openapi.json",
320
+ title=f"{app.title} - API Docs",
321
+ oauth2_redirect_url="/docs/oauth2-redirect",
322
+ )
323
+
324
+ @app.get("/docs/oauth2-redirect", include_in_schema=False)
325
+ def swagger_ui_redirect():
326
+ """Serve the Swagger UI OAuth redirect helper."""
327
+ return get_swagger_ui_oauth2_redirect_html()
171
328
 
172
329
  @app.get("/health", tags=["system"])
173
330
  async def health(request: Request, user=Depends(get_current_user)):
@@ -215,11 +372,24 @@ def create_app(config: AdaptConfig) -> FastAPI:
215
372
  logger.debug("Unauthenticated access to media gallery, redirecting to login")
216
373
  return RedirectResponse(url=login_redirect_url("/ui/media"), status_code=302)
217
374
 
218
- media_resources = [r for r in request.app.state.resources if r.resource_type == "media"]
375
+ all_media = [r for r in request.app.state.resources if r.resource_type == "media"]
376
+ if getattr(user, "is_superuser", False):
377
+ permitted_media = all_media
378
+ else:
379
+ from .permissions import PermissionChecker
380
+ with Session(engine) as db:
381
+ checker = PermissionChecker(db)
382
+ permitted_media = [
383
+ r for r in all_media
384
+ if checker.has_permission(user, r.relative_path.with_suffix("").as_posix(), "read")
385
+ ]
386
+ if not permitted_media and not getattr(user, "is_superuser", False):
387
+ from fastapi import HTTPException
388
+ logger.warning("Permission denied for user %s: no accessible media resources", user.username)
389
+ raise HTTPException(status_code=403, detail="No accessible media resources")
390
+
219
391
  media_items = []
220
- for r in media_resources:
221
- # Check permission, but for simplicity, assume read permission
222
- # In full impl, check permission
392
+ for r in permitted_media:
223
393
  media_items.append({
224
394
  "name": r.path.name,
225
395
  "relative_path": r.relative_path.as_posix(),
@@ -256,8 +426,8 @@ def create_app(config: AdaptConfig) -> FastAPI:
256
426
  user = get_current_user(request)
257
427
  accessible_resources = build_accessible_ui_links(request, user)
258
428
 
259
- # Add media gallery if there are media files
260
- if any(r.resource_type == "media" for r in request.app.state.resources):
429
+ # Add media gallery link only if the user can access at least one media file
430
+ if any(link["type"] == "media" for link in accessible_resources):
261
431
  accessible_resources.append({"name": "Media Gallery", "url": "/ui/media", "type": "media"})
262
432
 
263
433
  context = {
@@ -269,7 +439,8 @@ def create_app(config: AdaptConfig) -> FastAPI:
269
439
  return request.app.state.templates.TemplateResponse(request, "landing.html", context)
270
440
  else:
271
441
  # JSON API response
272
- resources = [r.relative_path.as_posix() for r in request.app.state.resources]
442
+ user = get_current_user(request)
443
+ resources = _visible_resource_paths(request, user)
273
444
  logger.debug("Returning JSON API response with %d resources", len(resources))
274
445
  return {"resources": resources}
275
446
 
@@ -4,6 +4,7 @@ import logging
4
4
  from ..config import AdaptConfig
5
5
  from ..discovery import discover_resources
6
6
  from ..storage import init_database
7
+ from .. import cache
7
8
 
8
9
  logger = logging.getLogger(__name__)
9
10
 
@@ -23,6 +24,7 @@ def run_check(root: Path) -> None:
23
24
  config = AdaptConfig(root=root)
24
25
  config.load_from_file()
25
26
  engine = init_database(config.db_path)
27
+ cache.configure(str(config.db_path))
26
28
  resources = discover_resources(config.root, config)
27
29
  count = len(resources)
28
30
  logger.info("Discovered %d dataset(s) in root %s", count, config.root)
@@ -1,8 +1,10 @@
1
1
  from pathlib import Path
2
2
  import logging
3
3
 
4
+ from .. import cache
4
5
  from ..config import AdaptConfig
5
6
  from ..discovery import discover_resources
7
+ from ..storage import init_database
6
8
 
7
9
  logger = logging.getLogger(__name__)
8
10
 
@@ -20,6 +22,9 @@ def run_list_endpoints(root: Path) -> None:
20
22
  None
21
23
  """
22
24
  config = AdaptConfig(root=root)
25
+ config.load_from_file()
26
+ init_database(config.db_path)
27
+ cache.configure(str(config.db_path))
23
28
  resources = discover_resources(config.root, config)
24
29
  if not resources:
25
30
  logger.info("No resources discovered in root %s", config.root)
@@ -9,6 +9,7 @@ import json
9
9
  import os
10
10
  import sys
11
11
 
12
+ from adapt import __version__ as adapt_version
12
13
  logger = logging.getLogger(__name__)
13
14
 
14
15
 
@@ -31,7 +32,7 @@ class AdaptConfig:
31
32
  port: int = 8000
32
33
  readonly: bool = False
33
34
  debug: bool = False
34
- version: str = "0.1.0"
35
+ version: str = adapt_version
35
36
  tls_cert: Path | None = None
36
37
  tls_key: Path | None = None
37
38
  secure_cookies: bool = False # Whether to set secure flag on cookies
@@ -7,8 +7,10 @@ import logging
7
7
 
8
8
  from fastapi import Request
9
9
  from fastapi.routing import APIRouter
10
+ from sqlmodel import Session
10
11
 
11
12
  from ..utils import build_ui_links
13
+ from ..auth.dependencies import check_permission
12
14
  from .base import Plugin, ResourceDescriptor, PluginContext, ensure_file
13
15
 
14
16
  logger = logging.getLogger(__name__)
@@ -343,7 +345,18 @@ class DatasetPlugin(Plugin):
343
345
  @ui_router.get("/", response_class=HTMLResponse)
344
346
  def get_ui(request: Request):
345
347
  """Get the UI for the dataset."""
346
- template_name, context = self.get_ui_template(descriptor, request.app.state.config.readonly)
348
+ # UI should be read-only when either the server is read-only or the user lacks write permission.
349
+ user = getattr(request.state, "user", None)
350
+ can_write = not request.app.state.config.readonly
351
+ if can_write and user is not None:
352
+ resource_namespace = request.url.path
353
+ if resource_namespace.startswith("/ui/"):
354
+ resource_namespace = resource_namespace[len("/ui/"):]
355
+ resource_namespace = resource_namespace.strip("/")
356
+ with Session(request.app.state.db_engine) as db:
357
+ can_write = check_permission(user, db, "write", resource_namespace)
358
+
359
+ template_name, context = self.get_ui_template(descriptor, readonly=not can_write)
347
360
 
348
361
  # Calculate API URL from UI URL
349
362
  # Assumes /ui/... -> /api/... mapping
@@ -362,7 +375,6 @@ class DatasetPlugin(Plugin):
362
375
  })
363
376
 
364
377
  # Add common navbar context
365
- user = getattr(request.state, 'user', None)
366
378
  is_superuser = user and getattr(user, 'is_superuser', False)
367
379
  ui_links = build_ui_links(request)
368
380
  context.update({
@@ -132,13 +132,11 @@ class MediaPlugin(Plugin):
132
132
  @router_ui.get("")
133
133
  def media_player(request: Request):
134
134
  """Serve the media player UI."""
135
- from ..auth.dependencies import get_current_user
136
135
  from ..utils import build_accessible_ui_links
137
- user = get_current_user(request)
138
- if not user:
139
- from fastapi.responses import RedirectResponse
140
- return RedirectResponse(url=login_redirect_url(request.url.path), status_code=302)
141
-
136
+ # Auth and permission are enforced by the route dependency injected in routes.py;
137
+ # request.state.user is populated by auth_middleware.
138
+ user = getattr(request.state, "user", None)
139
+
142
140
  media_url = f"/media/{descriptor.path.relative_to(request.app.state.config.root).as_posix()}"
143
141
  accessible_resources = build_accessible_ui_links(request, user)
144
142
  context = {
@@ -26,10 +26,14 @@
26
26
  {% endfor %}
27
27
  </ul>
28
28
  </li>
29
+ {% for link in ui_links %}
30
+ {% if link.name == "Media Gallery" %}
29
31
  <li class="nav-item">
30
- <a class="nav-link" href="/ui/media">Media</a>
32
+ <a class="nav-link" href="{{ link.url }}">Media</a>
31
33
  </li>
32
34
  {% endif %}
35
+ {% endfor %}
36
+ {% endif %}
33
37
  {% if is_superuser %}
34
38
  <li class="nav-item">
35
39
  <a class="nav-link" href="/admin/">Admin</a>
@@ -39,7 +43,7 @@
39
43
  <div class="collapse navbar-collapse" id="navbarNav">
40
44
  <ul class="navbar-nav me-auto">
41
45
  <li class="nav-item">
42
- <a class="nav-link" href="/docs">Docs</a>
46
+ <a class="nav-link" href="/docs">API Docs</a>
43
47
  </li>
44
48
  {% if user %}
45
49
  <li class="nav-item">
@@ -48,10 +52,12 @@
48
52
  {% endif %}
49
53
  </ul>
50
54
  </div>
55
+ {% if user %}
51
56
  <form class="d-flex" method="post" action="/auth/logout">
52
57
  <input type="hidden" name="csrf_token" value="{{ request.state.csrf_token if request and request.state else '' }}">
53
58
  <button class="btn btn-outline-light" type="submit">Logout</button>
54
59
  </form>
60
+ {% endif %}
55
61
  </div>
56
62
  </nav>
57
63
  <div class="container-fluid mt-4">
@@ -0,0 +1,93 @@
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}Adapt - Adaptive File-Backed Web Server{% endblock %}
4
+
5
+ {% block content %}
6
+ <div class="row g-4 align-items-stretch">
7
+ {% if user %}
8
+ <div class="col-lg-8">
9
+ <div class="p-4 p-lg-5 bg-light border rounded-3 h-100">
10
+ <span class="badge text-bg-dark mb-3">Signed in</span>
11
+ <h1 class="display-6">Welcome to Adapt</h1>
12
+ <p class="lead mb-4">Your workspace is ready. Adapt turns files in your document root into APIs, HTML UIs, and media views without additional app wiring.</p>
13
+
14
+ {% if ui_links %}
15
+ <h2 class="h4 mb-3">Your Accessible Resources</h2>
16
+ <div class="list-group shadow-sm">
17
+ {% for res in ui_links %}
18
+ <a href="{{ res.url }}" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
19
+ <span>
20
+ <strong>{{ res.name }}</strong>
21
+ <span class="text-body-secondary">
22
+ {% if res.type == 'csv' %}CSV dataset{% elif res.type == 'excel' %}Excel workbook{% elif res.type == 'html' %}HTML page{% elif res.type == 'markdown' %}Markdown document{% elif res.type == 'media' %}Media collection{% else %}Dataset{% endif %}
23
+ </span>
24
+ </span>
25
+ <span aria-hidden="true">Open</span>
26
+ </a>
27
+ {% endfor %}
28
+ </div>
29
+ {% else %}
30
+ <div class="alert alert-info mb-0">
31
+ <h2 class="h5">No accessible resources found yet.</h2>
32
+ <p class="mb-0">If you expected to see data here, contact an administrator to assign the right permissions. {% if is_superuser %}You can also review access rules in the <a href="/admin/">Admin Dashboard</a>.{% endif %}</p>
33
+ </div>
34
+ {% endif %}
35
+ </div>
36
+ </div>
37
+ <div class="col-lg-4">
38
+ <div class="card h-100 shadow-sm">
39
+ <div class="card-header">Next Steps</div>
40
+ <div class="card-body d-flex flex-column">
41
+ <h2 class="h5 card-title">Use your workspace</h2>
42
+ <p class="card-text">Browse the resources you can access, review your profile, and use the generated APIs or UI views for each provisioned file.</p>
43
+ <div class="mt-auto d-grid gap-2">
44
+ <a href="/profile" class="btn btn-primary">Open Profile</a>
45
+ <a href="https://github.com/McIndi/adapt" class="btn btn-outline-secondary">Documentation</a>
46
+ </div>
47
+ </div>
48
+ </div>
49
+ {% if is_superuser %}
50
+ <div class="card mt-3 shadow-sm">
51
+ <div class="card-header">Admin Panel</div>
52
+ <div class="card-body">
53
+ <p class="card-text">Manage users, groups, permissions, and system settings.</p>
54
+ <a href="/admin/" class="btn btn-secondary">Go to Admin</a>
55
+ </div>
56
+ </div>
57
+ {% endif %}
58
+ </div>
59
+ {% else %}
60
+ <div class="col-lg-7">
61
+ <div class="p-4 p-lg-5 bg-light border rounded-3 h-100">
62
+ <span class="badge text-bg-secondary mb-3">Authentication required</span>
63
+ <h1 class="display-6">Sign in to access this Adapt workspace</h1>
64
+ <p class="lead">This landing page is public, but the workspace contents are intended for authenticated users only.</p>
65
+ <p class="mb-4">Use your existing account to continue. If you do not have access yet, contact your Adapt administrator and ask them to provision an account and assign the right permissions.</p>
66
+ <div class="d-flex flex-wrap gap-2">
67
+ <a href="/auth/login" class="btn btn-primary btn-lg">Sign In</a>
68
+ <a href="https://github.com/McIndi/adapt" class="btn btn-outline-secondary btn-lg">Read the Docs</a>
69
+ </div>
70
+ </div>
71
+ </div>
72
+ <div class="col-lg-5">
73
+ <div class="card h-100 shadow-sm">
74
+ <div class="card-header">What happens after login?</div>
75
+ <div class="card-body">
76
+ <div class="mb-3">
77
+ <h2 class="h6">Provisioned resources</h2>
78
+ <p class="mb-0">Datasets, documents, HTML pages, and media views appear automatically when your account has permission to see them.</p>
79
+ </div>
80
+ <div class="mb-3">
81
+ <h2 class="h6">Least-privilege access</h2>
82
+ <p class="mb-0">Most routes are restricted, so you will only see the files and interfaces your account is allowed to use.</p>
83
+ </div>
84
+ <div>
85
+ <h2 class="h6">Need an account?</h2>
86
+ <p class="mb-0">Share the workspace URL with your admin and ask them to create a user and place you in the appropriate permission groups.</p>
87
+ </div>
88
+ </div>
89
+ </div>
90
+ </div>
91
+ {% endif %}
92
+ </div>
93
+ {% endblock %}
@@ -63,20 +63,14 @@ def build_accessible_ui_links(request: Request, user: User | None) -> list[dict[
63
63
  namespace = res.relative_path.with_suffix("").as_posix()
64
64
  if "sub_namespace" in res.metadata:
65
65
  namespace += f"/{res.metadata['sub_namespace']}"
66
+ if not checker.has_permission(user, namespace, "read"):
67
+ continue
66
68
  if res.resource_type in ("html", "markdown"):
67
- # Assume public
68
69
  url = f"/{namespace}"
69
- accessible_resources.append({"name": namespace, "url": url, "type": res.resource_type})
70
- elif checker.has_permission(user, namespace, "read"):
70
+ else:
71
71
  url = f"/ui/{namespace}"
72
- accessible_resources.append({"name": namespace, "url": url, "type": res.resource_type})
73
- else:
74
- # For unauthenticated, show only public html/markdown
75
- for res in resources:
76
- if res.resource_type in ("html", "markdown"):
77
- namespace = res.relative_path.with_suffix("").as_posix()
78
- url = f"/{namespace}"
79
72
  accessible_resources.append({"name": namespace, "url": url, "type": res.resource_type})
73
+ # Unauthenticated users see nothing — all resources require explicit permission.
80
74
 
81
75
  logger.debug("Built %d accessible UI links for user %s", len(accessible_resources), user.username if user else None)
82
76
  return accessible_resources
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: adapt-server
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Adaptive file-backed FastAPI server that turns datasets into CRUD APIs and UIs.
5
5
  Author-email: notesofcliff <notesofcliff@gmail.com>
6
6
  License-Expression: MIT
@@ -80,6 +80,7 @@ adapt_server.egg-info/requires.txt
80
80
  adapt_server.egg-info/top_level.txt
81
81
  tests/test_admin.py
82
82
  tests/test_auth.py
83
+ tests/test_cli_integration.py
83
84
  tests/test_config.py
84
85
  tests/test_constraints.py
85
86
  tests/test_csv_plugin.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "adapt-server"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "Adaptive file-backed FastAPI server that turns datasets into CRUD APIs and UIs."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -0,0 +1,43 @@
1
+ from adapt.commands.admin import run_create_permissions
2
+ from adapt.commands.check import run_check
3
+ from adapt.commands.list_endpoints import run_list_endpoints
4
+
5
+
6
+ def _seed_workspace(root):
7
+ # Include a media extension to exercise MediaPlugin cache reads during discovery.
8
+ (root / "data.csv").write_text("name,age\nAlice,30\nBob,25\n")
9
+ (root / "clip.mp3").write_bytes(b"not-a-real-mp3")
10
+
11
+
12
+ def test_run_check_discovers_media_without_cache_table_error(tmp_path, capsys):
13
+ _seed_workspace(tmp_path)
14
+
15
+ run_check(tmp_path)
16
+
17
+ output = capsys.readouterr().out
18
+ assert "Document root:" in output
19
+ assert "Discovered" in output
20
+
21
+
22
+ def test_run_list_endpoints_discovers_media_without_cache_table_error(tmp_path, capsys):
23
+ _seed_workspace(tmp_path)
24
+
25
+ run_list_endpoints(tmp_path)
26
+
27
+ output = capsys.readouterr().out
28
+ assert "/api/data" in output
29
+
30
+
31
+ def test_run_create_permissions_all_with_media_does_not_crash(tmp_path, capsys):
32
+ _seed_workspace(tmp_path)
33
+
34
+ run_create_permissions(
35
+ root=tmp_path,
36
+ resources=["__all__"],
37
+ all_group_name="all-rw",
38
+ read_group_name="all-ro",
39
+ )
40
+
41
+ output = capsys.readouterr().out
42
+ assert "Using all" in output
43
+ assert "Permissions and groups created successfully." in output