adapt-server 0.2.6__tar.gz → 0.2.8__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.
- {adapt_server-0.2.6 → adapt_server-0.2.8}/PKG-INFO +2 -2
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/__init__.py +1 -1
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/discovery.py +31 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/plugins/base.py +15 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/plugins/dataset_plugin.py +115 -12
- adapt_server-0.2.8/adapt/plugins/excel_plugin.py +279 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/templates/datatable.html +47 -21
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt_server.egg-info/PKG-INFO +2 -2
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt_server.egg-info/SOURCES.txt +2 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/pyproject.toml +2 -2
- adapt_server-0.2.8/tests/test_datatable_template.py +120 -0
- adapt_server-0.2.8/tests/test_excel_plugin.py +265 -0
- adapt_server-0.2.6/adapt/plugins/excel_plugin.py +0 -128
- {adapt_server-0.2.6 → adapt_server-0.2.8}/LICENSE +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/README.md +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/__main__.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/admin/__init__.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/admin/api_keys.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/admin/audit_logs.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/admin/cache.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/admin/groups.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/admin/locks.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/admin/models.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/admin/permissions.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/admin/resources.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/admin/ui.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/admin/users.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/api_keys.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/app.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/audit.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/auth/__init__.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/auth/dependencies.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/auth/password.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/auth/routes.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/auth/session.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/cache.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/cli.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/commands/__init__.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/commands/addsuperuser.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/commands/admin/__init__.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/commands/admin/add_to_group.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/commands/admin/create_group.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/commands/admin/create_permissions.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/commands/admin/create_user.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/commands/admin/delete_group.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/commands/admin/delete_user.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/commands/admin/list_groups.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/commands/admin/list_resources.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/commands/admin/list_users.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/commands/admin/remove_from_group.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/commands/check.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/commands/list_endpoints.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/commands/passwords.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/commands/reindex.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/commands/serve.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/config.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/locks.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/mcp.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/models.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/permissions.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/plugins/__init__.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/plugins/csv_plugin.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/plugins/file_plugin.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/plugins/html_plugin.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/plugins/markdown_plugin.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/plugins/media_plugin.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/plugins/parquet_plugin.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/plugins/python_plugin.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/routes.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/routes_search.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/search.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/security.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/security_urls.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/static/admin/app.js +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/static/admin/style.css +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/static/sortable-tables.css +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/static/sortable-tables.js +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/storage.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/templates/admin/index.html +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/templates/admin_base.html +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/templates/base.html +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/templates/landing.html +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/templates/login.html +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/templates/media_gallery.html +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/templates/media_player.html +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/templates/profile.html +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/templates/search_results.html +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/utils/__init__.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/utils/query.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt_server.egg-info/dependency_links.txt +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt_server.egg-info/entry_points.txt +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt_server.egg-info/requires.txt +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt_server.egg-info/top_level.txt +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/setup.cfg +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_admin.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_auth.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_cache.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_cli_integration.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_config.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_constraints.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_csv_plugin.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_dataset_plugin.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_dependencies.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_discovery.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_file_plugin.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_html_plugin.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_integration.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_locks.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_markdown_plugin.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_mcp.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_on_delete.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_parquet_plugin.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_phase3.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_plugin_interface.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_python_plugin.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_readonly.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_routes.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_search.py +0 -0
- {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_security_urls.py +0 -0
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: adapt-server
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.8
|
|
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
|
|
7
|
-
Project-URL: Homepage, https://
|
|
7
|
+
Project-URL: Homepage, https://www.mcindi.com/software/adapt/
|
|
8
8
|
Project-URL: Repository, https://github.com/McIndi/adapt
|
|
9
9
|
Project-URL: Issues, https://github.com/McIndi/adapt/issues
|
|
10
10
|
Keywords: fastapi,api,csv,excel,parquet,markdown,media
|
|
@@ -23,9 +23,31 @@ class DatasetResource:
|
|
|
23
23
|
schema_path: Path
|
|
24
24
|
ui_path: Path
|
|
25
25
|
plugin_name: str
|
|
26
|
+
options_path: Path | None = None
|
|
26
27
|
metadata: dict[str, Any] = field(default_factory=dict)
|
|
27
28
|
|
|
28
29
|
|
|
30
|
+
def read_resource_options(options_path: Path) -> dict[str, Any]:
|
|
31
|
+
"""Read a companion `.options.json` file, tolerating absence and bad content.
|
|
32
|
+
|
|
33
|
+
Options are hand-written, so a malformed file must not take the server down —
|
|
34
|
+
it is logged and ignored, leaving the resource on its defaults.
|
|
35
|
+
"""
|
|
36
|
+
if not options_path.exists():
|
|
37
|
+
return {}
|
|
38
|
+
try:
|
|
39
|
+
with options_path.open(encoding="utf-8") as f:
|
|
40
|
+
data = json.load(f)
|
|
41
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
42
|
+
logger.error("Ignoring invalid options file %s: %s", options_path, exc)
|
|
43
|
+
return {}
|
|
44
|
+
if not isinstance(data, dict):
|
|
45
|
+
logger.error("Ignoring options file %s: expected a JSON object", options_path)
|
|
46
|
+
return {}
|
|
47
|
+
logger.debug("Loaded options from %s: %s", options_path, data)
|
|
48
|
+
return data
|
|
49
|
+
|
|
50
|
+
|
|
29
51
|
def should_ignore(path: Path) -> bool:
|
|
30
52
|
"""Check if a path should be ignored during discovery.
|
|
31
53
|
|
|
@@ -91,9 +113,16 @@ def discover_resources(root: Path, config: AdaptConfig) -> list[DatasetResource]
|
|
|
91
113
|
base_path = adapt_dir / path.relative_to(root)
|
|
92
114
|
schema_path = base_path.with_suffix(f"{suffix}.schema.json")
|
|
93
115
|
ui_path = base_path.with_suffix(f"{suffix}.index.html")
|
|
116
|
+
options_path = base_path.with_suffix(f"{suffix}.options.json")
|
|
94
117
|
|
|
95
118
|
descriptor.schema_path = schema_path
|
|
96
119
|
descriptor.ui_path = ui_path
|
|
120
|
+
descriptor.options_path = options_path
|
|
121
|
+
|
|
122
|
+
# Options must be applied before companion files are generated, so a
|
|
123
|
+
# schema derived from an overridden header row is written out correctly.
|
|
124
|
+
descriptor.metadata["options"] = read_resource_options(options_path)
|
|
125
|
+
plugin.apply_options(descriptor)
|
|
97
126
|
|
|
98
127
|
plugin.generate_companion_files(descriptor)
|
|
99
128
|
|
|
@@ -103,6 +132,7 @@ def discover_resources(root: Path, config: AdaptConfig) -> list[DatasetResource]
|
|
|
103
132
|
resource_type=descriptor.resource_type,
|
|
104
133
|
schema_path=schema_path,
|
|
105
134
|
ui_path=ui_path,
|
|
135
|
+
options_path=options_path,
|
|
106
136
|
plugin_name=plugin_cls.__name__,
|
|
107
137
|
metadata=descriptor.metadata,
|
|
108
138
|
)
|
|
@@ -115,4 +145,5 @@ def discover_resources(root: Path, config: AdaptConfig) -> list[DatasetResource]
|
|
|
115
145
|
__all__ = [
|
|
116
146
|
"DatasetResource",
|
|
117
147
|
"discover_resources",
|
|
148
|
+
"read_resource_options",
|
|
118
149
|
]
|
|
@@ -37,6 +37,7 @@ class ResourceDescriptor:
|
|
|
37
37
|
resource_type: str
|
|
38
38
|
schema_path: Path | None = None
|
|
39
39
|
ui_path: Path | None = None
|
|
40
|
+
options_path: Path | None = None
|
|
40
41
|
metadata: dict[str, Any] = field(default_factory=dict)
|
|
41
42
|
|
|
42
43
|
|
|
@@ -77,6 +78,20 @@ class Plugin(ABC):
|
|
|
77
78
|
"""Write data/content for the resource."""
|
|
78
79
|
...
|
|
79
80
|
|
|
81
|
+
def apply_options(self, descriptor: ResourceDescriptor) -> None:
|
|
82
|
+
"""Apply per-resource options to a descriptor after discovery has located them.
|
|
83
|
+
|
|
84
|
+
Options come from the companion `.adapt/<name>[.<sub_namespace>].options.json`
|
|
85
|
+
file and are already parsed into `descriptor.metadata["options"]`. This runs
|
|
86
|
+
after `load()` because `load()` receives only a path and cannot know where the
|
|
87
|
+
companion directory is; it runs before `generate_companion_files()` so that any
|
|
88
|
+
derived schema reflects the options.
|
|
89
|
+
|
|
90
|
+
The default does nothing. Plugins override this to honour the options they
|
|
91
|
+
support.
|
|
92
|
+
"""
|
|
93
|
+
logger.debug(f"No options to apply for resource: {descriptor.path}")
|
|
94
|
+
|
|
80
95
|
def get_route_configs(self, descriptor: ResourceDescriptor) -> list[tuple[str, APIRouter]]:
|
|
81
96
|
"""Return list of (prefix, router) tuples for mounting routes."""
|
|
82
97
|
logger.debug(f"Getting route configs for resource: {descriptor.path}")
|
|
@@ -19,6 +19,67 @@ from .base import Plugin, ResourceDescriptor, PluginContext, SearchDocument, ens
|
|
|
19
19
|
|
|
20
20
|
logger = logging.getLogger(__name__)
|
|
21
21
|
|
|
22
|
+
#: 1-based sheet/file row that holds the column names, unless a resource
|
|
23
|
+
#: overrides it via `header_row` in its companion `.options.json`.
|
|
24
|
+
DEFAULT_HEADER_ROW = 1
|
|
25
|
+
|
|
26
|
+
#: Marks a companion schema.json as machine-written, so Adapt may refresh it when
|
|
27
|
+
#: the resource's shape changes. A schema without this key is treated as
|
|
28
|
+
#: hand-maintained and is never overwritten.
|
|
29
|
+
GENERATED_MARKER = "generated_by"
|
|
30
|
+
GENERATED_BY_ADAPT = "adapt"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def resolve_header_row(resource: ResourceDescriptor) -> int:
|
|
34
|
+
"""Return the 1-based header row for a resource, falling back to the default.
|
|
35
|
+
|
|
36
|
+
A bad override is logged and ignored rather than raised: the value is
|
|
37
|
+
hand-written into a companion file, and a typo should not break the resource.
|
|
38
|
+
"""
|
|
39
|
+
raw = resource.metadata.get("header_row", DEFAULT_HEADER_ROW)
|
|
40
|
+
try:
|
|
41
|
+
header_row = int(raw)
|
|
42
|
+
except (TypeError, ValueError):
|
|
43
|
+
logger.error("Ignoring non-integer header_row %r for %s", raw, resource.path)
|
|
44
|
+
return DEFAULT_HEADER_ROW
|
|
45
|
+
if header_row < 1:
|
|
46
|
+
logger.error("Ignoring header_row %d for %s: must be >= 1", header_row, resource.path)
|
|
47
|
+
return DEFAULT_HEADER_ROW
|
|
48
|
+
return header_row
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def write_generated_schema(path: Path, schema: dict[str, Any]) -> bool:
|
|
52
|
+
"""Write an auto-derived schema, refreshing a stale one but sparing hand-edits.
|
|
53
|
+
|
|
54
|
+
`ensure_file` would leave a stale schema in place forever, which matters once a
|
|
55
|
+
resource can be re-shaped by its options: a workbook switched to `header_row: 3`
|
|
56
|
+
would keep serving the old column names to the UI while the API returned the new
|
|
57
|
+
ones, and the table would render empty.
|
|
58
|
+
|
|
59
|
+
Returns True when the file was written.
|
|
60
|
+
"""
|
|
61
|
+
payload = {GENERATED_MARKER: GENERATED_BY_ADAPT, **schema}
|
|
62
|
+
if path.exists():
|
|
63
|
+
try:
|
|
64
|
+
existing = json.loads(path.read_text(encoding="utf-8"))
|
|
65
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
66
|
+
logger.warning("Leaving unreadable schema %s in place: %s", path, exc)
|
|
67
|
+
return False
|
|
68
|
+
if not isinstance(existing, dict) or existing.get(GENERATED_MARKER) != GENERATED_BY_ADAPT:
|
|
69
|
+
# Schemas written before the marker existed carry no provenance. One that
|
|
70
|
+
# still matches what we derive was ours, so adopt it; anything else is
|
|
71
|
+
# assumed hand-maintained and left alone.
|
|
72
|
+
if existing != schema:
|
|
73
|
+
logger.debug("Schema %s is hand-maintained; not regenerating", path)
|
|
74
|
+
return False
|
|
75
|
+
logger.debug("Adopting pre-marker generated schema %s", path)
|
|
76
|
+
if existing == payload:
|
|
77
|
+
return False
|
|
78
|
+
logger.info("Refreshing stale generated schema %s", path)
|
|
79
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
81
|
+
return True
|
|
82
|
+
|
|
22
83
|
|
|
23
84
|
def _guess_type(value: str | None) -> str:
|
|
24
85
|
"""Guess the data type of a string value."""
|
|
@@ -64,7 +125,12 @@ def _build_columns(header: Sequence[str], sample: Sequence[str | None]) -> dict[
|
|
|
64
125
|
|
|
65
126
|
class DatasetPlugin(Plugin):
|
|
66
127
|
"""Base plugin for dataset-like resources (CSV, Excel, etc.)."""
|
|
67
|
-
|
|
128
|
+
|
|
129
|
+
#: (path, sub_namespace) pairs already reported by `_warn_on_column_drift`,
|
|
130
|
+
#: so a misconfigured resource warns once rather than on every request.
|
|
131
|
+
_drift_warned: set[tuple[str, str]] = set()
|
|
132
|
+
|
|
133
|
+
|
|
68
134
|
def load(self, path: Path) -> ResourceDescriptor:
|
|
69
135
|
"""Load a resource descriptor for the dataset."""
|
|
70
136
|
header, sample = self._get_header_and_sample(path)
|
|
@@ -75,6 +141,22 @@ class DatasetPlugin(Plugin):
|
|
|
75
141
|
logger.debug("Loaded descriptor for %s", path)
|
|
76
142
|
return descriptor
|
|
77
143
|
|
|
144
|
+
def derive_schema(self, resource: ResourceDescriptor) -> dict[str, Any]:
|
|
145
|
+
"""Build the schema from the descriptor's parsed header and sample row.
|
|
146
|
+
|
|
147
|
+
Kept separate from `schema()` because `schema()` prefers whatever is already
|
|
148
|
+
on disk; regenerating a companion file has to compare against a freshly
|
|
149
|
+
derived schema or it would only ever rewrite a stale file with itself.
|
|
150
|
+
"""
|
|
151
|
+
header = resource.metadata.get("header", [])
|
|
152
|
+
sample = resource.metadata.get("sample_row", [])
|
|
153
|
+
return {
|
|
154
|
+
"type": "object",
|
|
155
|
+
"name": resource.path.stem,
|
|
156
|
+
"primary_key": resource.metadata.get("primary_key"),
|
|
157
|
+
"columns": _build_columns(header, sample),
|
|
158
|
+
}
|
|
159
|
+
|
|
78
160
|
def schema(self, resource: ResourceDescriptor) -> dict[str, Any]:
|
|
79
161
|
"""Get the schema for the resource, with caching."""
|
|
80
162
|
sub_namespace = resource.metadata.get("sub_namespace", "")
|
|
@@ -86,16 +168,10 @@ class DatasetPlugin(Plugin):
|
|
|
86
168
|
if resource.schema_path and resource.schema_path.exists():
|
|
87
169
|
with resource.schema_path.open() as f:
|
|
88
170
|
schema = json.load(f)
|
|
171
|
+
# Internal bookkeeping, not part of the published schema.
|
|
172
|
+
schema.pop(GENERATED_MARKER, None)
|
|
89
173
|
else:
|
|
90
|
-
|
|
91
|
-
sample = resource.metadata.get("sample_row", [])
|
|
92
|
-
columns = _build_columns(header, sample)
|
|
93
|
-
schema = {
|
|
94
|
-
"type": "object",
|
|
95
|
-
"name": resource.path.stem,
|
|
96
|
-
"primary_key": resource.metadata.get("primary_key"),
|
|
97
|
-
"columns": columns,
|
|
98
|
-
}
|
|
174
|
+
schema = self.derive_schema(resource)
|
|
99
175
|
set_cache(cache_key, schema, ttl_seconds=3600, resource=str(resource.path)) # 1 hour TTL
|
|
100
176
|
logger.debug("Generated schema for %s", resource.path)
|
|
101
177
|
return schema
|
|
@@ -105,6 +181,7 @@ class DatasetPlugin(Plugin):
|
|
|
105
181
|
header = resource.metadata.get("header", [])
|
|
106
182
|
schema = self.schema(resource)
|
|
107
183
|
columns = schema.get("columns", {})
|
|
184
|
+
self._warn_on_column_drift(resource, header, columns)
|
|
108
185
|
raw_rows = self._read_raw_rows(resource)
|
|
109
186
|
|
|
110
187
|
# Apply Row-Level Security
|
|
@@ -170,6 +247,31 @@ class DatasetPlugin(Plugin):
|
|
|
170
247
|
logger.debug("Indexed %d rows from %s", len(documents), resource.path)
|
|
171
248
|
return documents
|
|
172
249
|
|
|
250
|
+
def _warn_on_column_drift(
|
|
251
|
+
self, resource: ResourceDescriptor, header: Sequence[str], columns: dict[str, Any]
|
|
252
|
+
) -> None:
|
|
253
|
+
"""Warn once when the schema's columns don't match the parsed header.
|
|
254
|
+
|
|
255
|
+
`read()` keys its row dicts off the header parsed from the file, while the
|
|
256
|
+
UI builds its table from the schema. If a hand-edited schema.json names
|
|
257
|
+
different columns the two never meet, and the table renders empty with no
|
|
258
|
+
error anywhere — so say something instead of failing silently.
|
|
259
|
+
"""
|
|
260
|
+
if not columns or not header:
|
|
261
|
+
return
|
|
262
|
+
if list(columns) == list(header):
|
|
263
|
+
return
|
|
264
|
+
key = (str(resource.path), resource.metadata.get("sub_namespace", ""))
|
|
265
|
+
if key in self._drift_warned:
|
|
266
|
+
return
|
|
267
|
+
self._drift_warned.add(key)
|
|
268
|
+
logger.warning(
|
|
269
|
+
"Schema columns %s do not match the parsed header %s for %s%s; rows are keyed "
|
|
270
|
+
"by the header, so mismatched columns will render empty in the UI.",
|
|
271
|
+
list(columns), list(header), resource.path,
|
|
272
|
+
f" [{key[1]}]" if key[1] else "",
|
|
273
|
+
)
|
|
274
|
+
|
|
173
275
|
def _convert_value(self, value: str, col_type: str) -> Any:
|
|
174
276
|
"""Convert a string value to the appropriate type."""
|
|
175
277
|
if col_type == "integer":
|
|
@@ -448,8 +550,9 @@ class DatasetPlugin(Plugin):
|
|
|
448
550
|
logger.debug(f"Generating companion files for {descriptor.path}")
|
|
449
551
|
# Generate schema.json
|
|
450
552
|
if descriptor.schema_path:
|
|
451
|
-
|
|
452
|
-
|
|
553
|
+
if write_generated_schema(descriptor.schema_path, self.derive_schema(descriptor)):
|
|
554
|
+
# The cached copy describes the shape we just replaced.
|
|
555
|
+
invalidate_cache(str(descriptor.path))
|
|
453
556
|
logger.debug(f"Generated schema.json at {descriptor.schema_path}")
|
|
454
557
|
|
|
455
558
|
# Generate index.html using datatable.html template
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import logging
|
|
3
|
+
from adapt.cache import get_cache, set_cache, invalidate_cache
|
|
4
|
+
|
|
5
|
+
from itertools import zip_longest
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Iterator, Sequence
|
|
8
|
+
|
|
9
|
+
from openpyxl import load_workbook
|
|
10
|
+
|
|
11
|
+
from .base import ResourceDescriptor, atomic_write
|
|
12
|
+
from .dataset_plugin import DEFAULT_HEADER_ROW, DatasetPlugin, resolve_header_row
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _is_blank(value: Any) -> bool:
|
|
19
|
+
"""True for a cell holding nothing a reader would call a value."""
|
|
20
|
+
return value is None or (isinstance(value, str) and not value.strip())
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _merge_formula(value: Any, formula: Any) -> Any:
|
|
24
|
+
"""Fall back to a cell's formula text when it has no cached result.
|
|
25
|
+
|
|
26
|
+
openpyxl reports only the value Excel last calculated and stored in the file.
|
|
27
|
+
A workbook written by openpyxl itself carries no cached results at all, so
|
|
28
|
+
under `data_only=True` every formula cell reads back as None and the column
|
|
29
|
+
renders blank. Surfacing the formula is worse than a number and much better
|
|
30
|
+
than an empty column with nothing to explain it.
|
|
31
|
+
"""
|
|
32
|
+
if value is not None:
|
|
33
|
+
return value
|
|
34
|
+
if isinstance(formula, str) and formula.startswith("="):
|
|
35
|
+
return formula
|
|
36
|
+
return value
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _merged_rows(value_sheet: Any, formula_sheet: Any) -> Iterator[tuple[int, list[Any]]]:
|
|
40
|
+
"""Yield (1-based row number, cells) for a sheet, formulas filled in.
|
|
41
|
+
|
|
42
|
+
Both sheets are streamed in lockstep so a large workbook is never fully
|
|
43
|
+
materialised. `zip_longest` guards the case where the two passes disagree on
|
|
44
|
+
width rather than silently truncating a row.
|
|
45
|
+
"""
|
|
46
|
+
paired = zip_longest(
|
|
47
|
+
value_sheet.iter_rows(values_only=True),
|
|
48
|
+
formula_sheet.iter_rows(values_only=True),
|
|
49
|
+
fillvalue=(),
|
|
50
|
+
)
|
|
51
|
+
for row_number, (value_row, formula_row) in enumerate(paired, start=1):
|
|
52
|
+
yield row_number, [
|
|
53
|
+
_merge_formula(value, formula)
|
|
54
|
+
for value, formula in zip_longest(value_row or (), formula_row or ())
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _extract_header_and_sample(
|
|
59
|
+
value_sheet: Any, formula_sheet: Any, header_row: int
|
|
60
|
+
) -> tuple[list[Any], list[Any]]:
|
|
61
|
+
"""Return the header cells and the first non-blank row beneath them."""
|
|
62
|
+
header: list[Any] = []
|
|
63
|
+
sample: list[Any] = []
|
|
64
|
+
for row_number, cells in _merged_rows(value_sheet, formula_sheet):
|
|
65
|
+
if row_number < header_row:
|
|
66
|
+
continue
|
|
67
|
+
if row_number == header_row:
|
|
68
|
+
header = cells
|
|
69
|
+
continue
|
|
70
|
+
if all(_is_blank(cell) for cell in cells):
|
|
71
|
+
continue
|
|
72
|
+
sample = cells
|
|
73
|
+
break
|
|
74
|
+
return header, sample
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ExcelPlugin(DatasetPlugin):
|
|
78
|
+
@property
|
|
79
|
+
def resource_type(self) -> str:
|
|
80
|
+
"""Return the resource type string."""
|
|
81
|
+
return "excel"
|
|
82
|
+
|
|
83
|
+
def detect(self, path: Path) -> bool:
|
|
84
|
+
"""Detect if the path is an Excel file.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
path: The file path to check.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
True if the file has .xlsx extension, False otherwise.
|
|
91
|
+
"""
|
|
92
|
+
return path.suffix.lower() == ".xlsx"
|
|
93
|
+
|
|
94
|
+
def load(self, path: Path) -> Sequence[ResourceDescriptor]:
|
|
95
|
+
"""Load Excel file and create descriptors for each worksheet.
|
|
96
|
+
|
|
97
|
+
Every sheet is read at the default header row; a sheet that overrides it
|
|
98
|
+
is re-read later by `apply_options`, once discovery has located the
|
|
99
|
+
companion options file.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
path: The path to the Excel file.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
A sequence of ResourceDescriptors, one for each worksheet.
|
|
106
|
+
"""
|
|
107
|
+
logger.debug(f"Loading Excel file: {path}")
|
|
108
|
+
descriptors = []
|
|
109
|
+
with self._open_pair(path) as (values, formulas):
|
|
110
|
+
for sheet_name in values.sheetnames:
|
|
111
|
+
logger.debug(f"Processing sheet: {sheet_name}")
|
|
112
|
+
descriptor = ResourceDescriptor(path=path, resource_type=self.resource_type)
|
|
113
|
+
descriptor.metadata["primary_key"] = "_row_id"
|
|
114
|
+
descriptor.metadata["sub_namespace"] = sheet_name
|
|
115
|
+
self._set_header_metadata(
|
|
116
|
+
descriptor, values[sheet_name], formulas[sheet_name], DEFAULT_HEADER_ROW
|
|
117
|
+
)
|
|
118
|
+
descriptors.append(descriptor)
|
|
119
|
+
logger.info(f"Loaded {len(descriptors)} worksheets from Excel file: {path}")
|
|
120
|
+
return descriptors
|
|
121
|
+
|
|
122
|
+
def apply_options(self, descriptor: ResourceDescriptor) -> None:
|
|
123
|
+
"""Honour a `header_row` override from the companion options file.
|
|
124
|
+
|
|
125
|
+
Sheets often open with a title banner rather than column names, which
|
|
126
|
+
would otherwise be parsed as the header and turn a sentence into a column
|
|
127
|
+
name. Setting `{"header_row": 3}` points Adapt at the real header.
|
|
128
|
+
"""
|
|
129
|
+
options = descriptor.metadata.get("options") or {}
|
|
130
|
+
if "header_row" not in options:
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
descriptor.metadata["header_row"] = options["header_row"]
|
|
134
|
+
header_row = resolve_header_row(descriptor)
|
|
135
|
+
descriptor.metadata["header_row"] = header_row
|
|
136
|
+
if header_row == DEFAULT_HEADER_ROW:
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
sheet_name = descriptor.metadata["sub_namespace"]
|
|
140
|
+
logger.info(
|
|
141
|
+
"Re-reading header of %s [%s] from row %d", descriptor.path, sheet_name, header_row
|
|
142
|
+
)
|
|
143
|
+
with self._open_pair(descriptor.path) as (values, formulas):
|
|
144
|
+
self._set_header_metadata(
|
|
145
|
+
descriptor, values[sheet_name], formulas[sheet_name], header_row
|
|
146
|
+
)
|
|
147
|
+
# Rows cached under the previous header row describe a different shape.
|
|
148
|
+
invalidate_cache(str(descriptor.path))
|
|
149
|
+
|
|
150
|
+
def _read_raw_rows(self, resource: ResourceDescriptor) -> list[list[str]]:
|
|
151
|
+
"""Read raw rows from the Excel worksheet.
|
|
152
|
+
|
|
153
|
+
Rows that are entirely empty are dropped, and the sheet's extent is taken
|
|
154
|
+
from the rows that actually carry values rather than from the declared
|
|
155
|
+
`<dimension>`. Writers routinely leave styled-but-valueless cells behind
|
|
156
|
+
(a whole formatted column, say), which inflates the dimension and would
|
|
157
|
+
otherwise surface as hundreds of blank records. Note that openpyxl's
|
|
158
|
+
`reset_dimensions()` is not a fix here — it falls back to the sheet
|
|
159
|
+
maximum and yields over a million empty rows.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
resource: The resource descriptor.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
A list of rows as lists of strings.
|
|
166
|
+
"""
|
|
167
|
+
sub_namespace = resource.metadata["sub_namespace"]
|
|
168
|
+
header_row = resolve_header_row(resource)
|
|
169
|
+
logger.debug(f"Reading raw rows from Excel sheet: {sub_namespace}")
|
|
170
|
+
cache_key = f"data:{resource.path}:{sub_namespace}:{header_row}"
|
|
171
|
+
cached = get_cache(cache_key, str(resource.path))
|
|
172
|
+
if cached:
|
|
173
|
+
logger.debug(f"Cache hit for Excel data: {resource.path}")
|
|
174
|
+
return cached
|
|
175
|
+
|
|
176
|
+
logger.debug(f"Cache miss, reading from Excel file: {resource.path}")
|
|
177
|
+
width = len(resource.metadata.get("header") or [])
|
|
178
|
+
rows: list[list[str]] = []
|
|
179
|
+
skipped = 0
|
|
180
|
+
with self._open_pair(resource.path) as (values, formulas):
|
|
181
|
+
merged = _merged_rows(values[sub_namespace], formulas[sub_namespace])
|
|
182
|
+
for row_number, cells in merged:
|
|
183
|
+
if row_number <= header_row:
|
|
184
|
+
continue
|
|
185
|
+
if all(_is_blank(cell) for cell in cells):
|
|
186
|
+
skipped += 1
|
|
187
|
+
continue
|
|
188
|
+
row = ["" if cell is None else str(cell) for cell in cells]
|
|
189
|
+
if width:
|
|
190
|
+
row = (row + [""] * width)[:width]
|
|
191
|
+
rows.append(row)
|
|
192
|
+
|
|
193
|
+
if skipped:
|
|
194
|
+
logger.debug(
|
|
195
|
+
"Skipped %d empty rows in %s [%s]", skipped, resource.path, sub_namespace
|
|
196
|
+
)
|
|
197
|
+
set_cache(cache_key, rows, ttl_seconds=300, resource=str(resource.path)) # 5 min TTL
|
|
198
|
+
return rows
|
|
199
|
+
|
|
200
|
+
def _write_rows(self, resource: ResourceDescriptor, rows: list[dict[str, Any]], header: list[str]) -> None:
|
|
201
|
+
"""Write rows to the Excel worksheet.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
resource: The resource descriptor.
|
|
205
|
+
rows: The rows to write.
|
|
206
|
+
header: The column headers.
|
|
207
|
+
"""
|
|
208
|
+
sub_namespace = resource.metadata["sub_namespace"]
|
|
209
|
+
header_row = resolve_header_row(resource)
|
|
210
|
+
first_data_row = header_row + 1
|
|
211
|
+
logger.info(f"Writing rows to Excel worksheet: {sub_namespace}")
|
|
212
|
+
workbook = load_workbook(resource.path)
|
|
213
|
+
try:
|
|
214
|
+
sheet = workbook[sub_namespace]
|
|
215
|
+
# Clear existing data, leaving the header and anything above it intact.
|
|
216
|
+
for row in sheet.iter_rows(min_row=first_data_row):
|
|
217
|
+
for cell in row:
|
|
218
|
+
cell.value = None
|
|
219
|
+
# Write new data
|
|
220
|
+
for row_idx, row_data in enumerate(rows, start=first_data_row):
|
|
221
|
+
for col_idx, col_name in enumerate(header):
|
|
222
|
+
sheet.cell(row=row_idx, column=col_idx+1).value = row_data.get(col_name, "")
|
|
223
|
+
|
|
224
|
+
def write_tmp(tmp_path: Path) -> None:
|
|
225
|
+
workbook.save(tmp_path)
|
|
226
|
+
|
|
227
|
+
atomic_write(resource.path, ".xlsx", write_tmp)
|
|
228
|
+
finally:
|
|
229
|
+
workbook.close()
|
|
230
|
+
|
|
231
|
+
# Invalidate cache after mutation
|
|
232
|
+
invalidate_cache(str(resource.path))
|
|
233
|
+
logger.debug(f"Successfully wrote {len(rows)} rows to Excel file: {resource.path}")
|
|
234
|
+
|
|
235
|
+
def _set_header_metadata(
|
|
236
|
+
self, descriptor: ResourceDescriptor, value_sheet: Any, formula_sheet: Any, header_row: int
|
|
237
|
+
) -> None:
|
|
238
|
+
"""Parse the header and sample row for a sheet into descriptor metadata."""
|
|
239
|
+
from .dataset_plugin import _ensure_header
|
|
240
|
+
|
|
241
|
+
header, sample = _extract_header_and_sample(value_sheet, formula_sheet, header_row)
|
|
242
|
+
descriptor.metadata["header"] = _ensure_header(header)
|
|
243
|
+
descriptor.metadata["sample_row"] = [
|
|
244
|
+
str(cell) if cell is not None else None for cell in sample
|
|
245
|
+
]
|
|
246
|
+
descriptor.metadata["header_row"] = header_row
|
|
247
|
+
|
|
248
|
+
@staticmethod
|
|
249
|
+
def _open_pair(path: Path) -> "_WorkbookPair":
|
|
250
|
+
"""Open the workbook twice: once for cached values, once for formulas.
|
|
251
|
+
|
|
252
|
+
Two passes are the only way to tell "empty cell" from "formula whose
|
|
253
|
+
result was never cached" — openpyxl surfaces one or the other, never both.
|
|
254
|
+
Both are read-only and streamed, and the result is cached for 5 minutes,
|
|
255
|
+
so the second parse is paid rarely.
|
|
256
|
+
"""
|
|
257
|
+
return _WorkbookPair(path)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class _WorkbookPair:
|
|
261
|
+
"""Context manager yielding (values_workbook, formulas_workbook)."""
|
|
262
|
+
|
|
263
|
+
def __init__(self, path: Path) -> None:
|
|
264
|
+
self._path = path
|
|
265
|
+
|
|
266
|
+
def __enter__(self) -> tuple[Any, Any]:
|
|
267
|
+
self._values = load_workbook(self._path, read_only=True, data_only=True)
|
|
268
|
+
try:
|
|
269
|
+
self._formulas = load_workbook(self._path, read_only=True, data_only=False)
|
|
270
|
+
except Exception:
|
|
271
|
+
self._values.close()
|
|
272
|
+
raise
|
|
273
|
+
return self._values, self._formulas
|
|
274
|
+
|
|
275
|
+
def __exit__(self, *exc_info: Any) -> None:
|
|
276
|
+
try:
|
|
277
|
+
self._formulas.close()
|
|
278
|
+
finally:
|
|
279
|
+
self._values.close()
|
|
@@ -53,8 +53,8 @@
|
|
|
53
53
|
<form id="createForm">
|
|
54
54
|
{% for col_name, col_info in schema.columns.items() %}
|
|
55
55
|
<div class="mb-3">
|
|
56
|
-
<label for="
|
|
57
|
-
<input type="text" class="form-control" id="
|
|
56
|
+
<label for="create_col_{{ loop.index0 }}" class="form-label">{{ col_name }}</label>
|
|
57
|
+
<input type="text" class="form-control" id="create_col_{{ loop.index0 }}" name="{{ col_name }}">
|
|
58
58
|
</div>
|
|
59
59
|
{% endfor %}
|
|
60
60
|
</form>
|
|
@@ -80,8 +80,8 @@
|
|
|
80
80
|
<input type="hidden" id="edit_row_id" name="_row_id">
|
|
81
81
|
{% for col_name, col_info in schema.columns.items() %}
|
|
82
82
|
<div class="mb-3">
|
|
83
|
-
<label for="
|
|
84
|
-
<input type="text" class="form-control" id="
|
|
83
|
+
<label for="edit_col_{{ loop.index0 }}" class="form-label">{{ col_name }}</label>
|
|
84
|
+
<input type="text" class="form-control" id="edit_col_{{ loop.index0 }}" name="{{ col_name }}">
|
|
85
85
|
</div>
|
|
86
86
|
{% endfor %}
|
|
87
87
|
</form>
|
|
@@ -102,7 +102,16 @@
|
|
|
102
102
|
|
|
103
103
|
<script>
|
|
104
104
|
let dataTable;
|
|
105
|
-
const apiUrl =
|
|
105
|
+
const apiUrl = {{ api_url | tojson }};
|
|
106
|
+
|
|
107
|
+
// Column names are data, not code. They come from a spreadsheet header or a
|
|
108
|
+
// hand-written schema, so they may contain spaces, quotes, parentheses, dots
|
|
109
|
+
// or a leading digit. Interpolating them into JS expressions turns a column
|
|
110
|
+
// named "Total (net)" into a syntax error that takes down this whole script
|
|
111
|
+
// -- including the DataTable init below -- and the table renders as a bare
|
|
112
|
+
// header with no rows and nothing in the console to explain it. Everything
|
|
113
|
+
// below indexes into this array instead.
|
|
114
|
+
const COLUMNS = {{ schema.columns | list | tojson }};
|
|
106
115
|
|
|
107
116
|
function csrfHeaders(headers = {}) {
|
|
108
117
|
const token = window.getAdaptCsrfToken ? window.getAdaptCsrfToken() : '';
|
|
@@ -124,26 +133,40 @@
|
|
|
124
133
|
alert('Error loading data: ' + error);
|
|
125
134
|
}
|
|
126
135
|
},
|
|
127
|
-
columns:
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
136
|
+
columns: buildColumns()
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
function buildColumns() {
|
|
141
|
+
// An accessor function rather than `data: name`: DataTables parses a
|
|
142
|
+
// string source as object notation, so a column called "Q1.Revenue"
|
|
143
|
+
// would be looked up as row.Q1.Revenue and quietly resolve to nothing.
|
|
144
|
+
const columns = COLUMNS.map(function (name) {
|
|
145
|
+
return {
|
|
146
|
+
data: function (row) { return row[name]; },
|
|
147
|
+
defaultContent: '',
|
|
148
|
+
render: $.fn.dataTable.render.text()
|
|
149
|
+
};
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
{% if not readonly %}
|
|
153
|
+
// Guarded by Jinja rather than at runtime so a read-only page never ships
|
|
154
|
+
// write controls, not even as unreachable markup.
|
|
155
|
+
columns.push({
|
|
133
156
|
data: null,
|
|
134
157
|
orderable: false,
|
|
135
158
|
searchable: false,
|
|
136
159
|
render: function (data, type, row) {
|
|
137
160
|
return `
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
161
|
+
<button class="btn btn-sm btn-warning me-1" onclick="editRecord(${row._row_id})">Edit</button>
|
|
162
|
+
<button class="btn btn-sm btn-danger" onclick="deleteRecord(${row._row_id})">Delete</button>
|
|
163
|
+
`;
|
|
141
164
|
}
|
|
142
|
-
}
|
|
143
|
-
{% endif %}
|
|
144
|
-
]
|
|
145
|
-
});
|
|
146
165
|
});
|
|
166
|
+
{% endif %}
|
|
167
|
+
|
|
168
|
+
return columns;
|
|
169
|
+
}
|
|
147
170
|
|
|
148
171
|
{% if not readonly %}
|
|
149
172
|
function createRecord() {
|
|
@@ -180,9 +203,12 @@
|
|
|
180
203
|
const rowData = dataTable.rows().data().toArray().find(row => row._row_id === rowId);
|
|
181
204
|
if (rowData) {
|
|
182
205
|
document.getElementById('edit_row_id').value = rowId;
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
206
|
+
COLUMNS.forEach(function (name, index) {
|
|
207
|
+
const input = document.getElementById('edit_col_' + index);
|
|
208
|
+
if (input) {
|
|
209
|
+
input.value = rowData[name] ?? '';
|
|
210
|
+
}
|
|
211
|
+
});
|
|
186
212
|
const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('editModal'));
|
|
187
213
|
modal.show();
|
|
188
214
|
}
|