feat: initialize governed datasources module
This commit is contained in:
35
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
35
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
name: "Bug"
|
||||||
|
about: "Report a reproducible defect, regression, or incorrect behavior"
|
||||||
|
title: "[Bug] "
|
||||||
|
labels:
|
||||||
|
- type/bug
|
||||||
|
- status/triage
|
||||||
|
- module/datasources
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Repository:
|
||||||
|
- Area/module:
|
||||||
|
- Affected version or commit:
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
Actual:
|
||||||
|
|
||||||
|
## Reproduction
|
||||||
|
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
3.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
Logs, screenshots, traces, or failing test output:
|
||||||
|
|
||||||
|
## Verification Target
|
||||||
|
|
||||||
|
Command or workflow that should pass when fixed:
|
||||||
1
.gitea/ISSUE_TEMPLATE/config.yaml
Normal file
1
.gitea/ISSUE_TEMPLATE/config.yaml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
27
.gitea/ISSUE_TEMPLATE/docs_workflow.md
Normal file
27
.gitea/ISSUE_TEMPLATE/docs_workflow.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
name: "Docs / workflow"
|
||||||
|
about: "Request documentation, process, or developer workflow changes"
|
||||||
|
title: "[Docs] "
|
||||||
|
labels:
|
||||||
|
- type/docs
|
||||||
|
- status/triage
|
||||||
|
- module/datasources
|
||||||
|
- area/docs
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Repository:
|
||||||
|
- Document or workflow:
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
What is missing, unclear, duplicated, or stale?
|
||||||
|
|
||||||
|
## Desired State
|
||||||
|
|
||||||
|
What should the docs or workflow make clear?
|
||||||
|
|
||||||
|
## Verification Target
|
||||||
|
|
||||||
|
How should this be checked?
|
||||||
32
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal file
32
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
name: "Feature"
|
||||||
|
about: "Propose new user-visible behavior or platform capability"
|
||||||
|
title: "[Feature] "
|
||||||
|
labels:
|
||||||
|
- type/feature
|
||||||
|
- status/triage
|
||||||
|
- module/datasources
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
What user, operator, or developer problem should this solve?
|
||||||
|
|
||||||
|
## Proposed Capability
|
||||||
|
|
||||||
|
What should exist when this is done?
|
||||||
|
|
||||||
|
## Ownership
|
||||||
|
|
||||||
|
- Owning repository:
|
||||||
|
- Related module repositories:
|
||||||
|
- Extension point or integration boundary:
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ]
|
||||||
|
- [ ]
|
||||||
|
|
||||||
|
## Verification Target
|
||||||
|
|
||||||
|
Command, scenario, or UI flow that should prove completion:
|
||||||
28
.gitea/ISSUE_TEMPLATE/task.md
Normal file
28
.gitea/ISSUE_TEMPLATE/task.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
name: "Task"
|
||||||
|
about: "Track implementation, maintenance, or migration work"
|
||||||
|
title: "[Task] "
|
||||||
|
labels:
|
||||||
|
- type/task
|
||||||
|
- status/triage
|
||||||
|
- module/datasources
|
||||||
|
---
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
What needs to be completed?
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Owning repository:
|
||||||
|
- In-scope:
|
||||||
|
- Out-of-scope:
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ]
|
||||||
|
- [ ]
|
||||||
|
|
||||||
|
## Verification Target
|
||||||
|
|
||||||
|
Command or manual check:
|
||||||
25
.gitea/ISSUE_TEMPLATE/tech_debt.md
Normal file
25
.gitea/ISSUE_TEMPLATE/tech_debt.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
name: "Tech debt"
|
||||||
|
about: "Track cleanup, refactoring, risk reduction, or deferred engineering work"
|
||||||
|
title: "[Debt] "
|
||||||
|
labels:
|
||||||
|
- type/debt
|
||||||
|
- status/triage
|
||||||
|
- module/datasources
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Cost
|
||||||
|
|
||||||
|
What does this make harder, riskier, slower, or more fragile?
|
||||||
|
|
||||||
|
## Desired Shape
|
||||||
|
|
||||||
|
What should the code, tests, or architecture look like afterwards?
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
What behavior, compatibility, or module boundary must be preserved?
|
||||||
|
|
||||||
|
## Verification Target
|
||||||
|
|
||||||
|
Focused checks that should pass:
|
||||||
15
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
15
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
## Issue
|
||||||
|
|
||||||
|
Closes #
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
Follow-up issues:
|
||||||
168
.gitignore
vendored
Normal file
168
.gitignore
vendored
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
# Node
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
lib-cov
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
.nyc_output
|
||||||
|
.grunt
|
||||||
|
bower_components
|
||||||
|
.lock-wscript
|
||||||
|
build/Release
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
web_modules/
|
||||||
|
*.tsbuildinfo
|
||||||
|
.npm
|
||||||
|
.eslintcache
|
||||||
|
.stylelintcache
|
||||||
|
.rpt2_cache/
|
||||||
|
.rts2_cache_cjs/
|
||||||
|
.rts2_cache_es/
|
||||||
|
.rts2_cache_umd/
|
||||||
|
.node_repl_history
|
||||||
|
*.tgz
|
||||||
|
.yarn-integrity
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
.vuepress/dist
|
||||||
|
.temp
|
||||||
|
**/.vitepress/dist
|
||||||
|
**/.vitepress/cache
|
||||||
|
.docusaurus
|
||||||
|
.serverless/
|
||||||
|
.fusebox/
|
||||||
|
.dynamodb/
|
||||||
|
.tern-port
|
||||||
|
.vscode-test
|
||||||
|
.yarn/cache
|
||||||
|
.yarn/unplugged
|
||||||
|
.yarn/build-state.yml
|
||||||
|
.yarn/install-state.gz
|
||||||
|
.pnp.*
|
||||||
|
.component-test-build/
|
||||||
|
.file-drop-test-build/
|
||||||
|
.module-test-build/
|
||||||
|
.policy-test-build/
|
||||||
|
.template-preview-test-build/
|
||||||
|
.import-test-build/
|
||||||
|
webui/.component-test-build/
|
||||||
|
webui/.file-drop-test-build/
|
||||||
|
webui/.module-test-build/
|
||||||
|
webui/.policy-test-build/
|
||||||
|
webui/.template-preview-test-build/
|
||||||
|
webui/.import-test-build/
|
||||||
|
audit-reports/
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
cover/
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
.scrapy
|
||||||
|
docs/_build/
|
||||||
|
.pybuilder/
|
||||||
|
target/
|
||||||
|
.ipynb_checkpoints
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
.pdm.toml
|
||||||
|
.pdm-python
|
||||||
|
.pdm-build/
|
||||||
|
__pypackages__/
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
*.sage.py
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
.ropeproject
|
||||||
|
/site
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
.pyre/
|
||||||
|
.pytype/
|
||||||
|
cython_debug/
|
||||||
|
.ruff_cache/
|
||||||
|
.pypirc
|
||||||
|
|
||||||
|
# Editors
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
!.vscode/*.code-snippets
|
||||||
|
.history/
|
||||||
|
*.vsix
|
||||||
|
|
||||||
|
# GovOPlaN local state
|
||||||
|
*.db
|
||||||
|
runtime/
|
||||||
25
README.md
Normal file
25
README.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# govoplan-datasources
|
||||||
|
|
||||||
|
<!-- govoplan-repository-type:start -->
|
||||||
|
**Repository type:** module (platform).
|
||||||
|
<!-- govoplan-repository-type:end -->
|
||||||
|
|
||||||
|
`govoplan-datasources` is GovOPlaN's provider-neutral data access layer. It owns
|
||||||
|
the tenant catalogue, lifecycle, staging, immutable materializations, frozen
|
||||||
|
states, schema and fingerprint metadata, provenance, and bounded reads.
|
||||||
|
|
||||||
|
The module deliberately separates two layers:
|
||||||
|
|
||||||
|
- **Datasources** describe governed data that modules can consume consistently.
|
||||||
|
- **Connectors** acquire data from external systems and expose provider origins.
|
||||||
|
|
||||||
|
Dataflow, Workflow, Reporting, Risk Compliance, and other consumers depend on
|
||||||
|
Datasource contracts rather than connector implementations.
|
||||||
|
|
||||||
|
The first executable slice supports tabular static uploads, connector-backed
|
||||||
|
live and cached sources, staging and promotion, refresh, immutable snapshots,
|
||||||
|
explicit frozen states, previews, and retirement. The contracts already model
|
||||||
|
database, HTTP/REST, directory, file, feed, document, binary, directory, and
|
||||||
|
stream sources so providers can be added without changing consumers.
|
||||||
|
|
||||||
|
See [docs/CONCEPT.md](docs/CONCEPT.md) for ownership and lifecycle details.
|
||||||
63
docs/CONCEPT.md
Normal file
63
docs/CONCEPT.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# GovOPlaN Datasources
|
||||||
|
|
||||||
|
## Boundary
|
||||||
|
|
||||||
|
Datasources owns the governed identity and lifecycle of consumable data.
|
||||||
|
Connectors owns external protocols, connection profiles, credentials, provider
|
||||||
|
health, discovery, and source-side query pushdown.
|
||||||
|
|
||||||
|
An external system is therefore represented in two steps:
|
||||||
|
|
||||||
|
1. a connector exposes a provider-specific **origin**;
|
||||||
|
2. Datasources registers that origin as a governed live or cached datasource.
|
||||||
|
|
||||||
|
Static uploads are staged and promoted directly by Datasources. Other modules
|
||||||
|
only retain opaque datasource and materialization references.
|
||||||
|
|
||||||
|
## Modes
|
||||||
|
|
||||||
|
- `live`: reads the external origin at request time. A live source can still be
|
||||||
|
frozen to preserve evidence for a run.
|
||||||
|
- `cached`: refreshes the origin into immutable local materializations. Reads
|
||||||
|
use the current materialization unless a specific frozen revision is chosen.
|
||||||
|
- `static`: promotes supplied rows into immutable materializations and never
|
||||||
|
refreshes an external origin.
|
||||||
|
|
||||||
|
Materializations are append-only. Changing source data creates a new revision;
|
||||||
|
old revisions remain addressable for reproducibility.
|
||||||
|
|
||||||
|
## Staging
|
||||||
|
|
||||||
|
Staging is the inspection boundary before data becomes generally consumable.
|
||||||
|
A stage records schema, row and byte counts, fingerprint, validation result,
|
||||||
|
provenance, and intended target. Promotion either creates a datasource or adds
|
||||||
|
a new immutable materialization to an existing compatible datasource.
|
||||||
|
|
||||||
|
The first slice stores bounded tabular JSON/CSV stages. Future providers may
|
||||||
|
stage file references, object-store blobs, directory snapshots, or streaming
|
||||||
|
checkpoints through the same lifecycle contract.
|
||||||
|
|
||||||
|
## Consumer Contract
|
||||||
|
|
||||||
|
Consumers request:
|
||||||
|
|
||||||
|
- the current state;
|
||||||
|
- a direct live read where policy permits;
|
||||||
|
- a particular materialization; or
|
||||||
|
- the latest frozen state.
|
||||||
|
|
||||||
|
Reads are bounded and tenant-scoped. Schema and expected fingerprints allow a
|
||||||
|
Dataflow or Workflow definition to detect changed inputs before execution.
|
||||||
|
|
||||||
|
## Next Providers
|
||||||
|
|
||||||
|
Connector providers should cover:
|
||||||
|
|
||||||
|
- SQL databases with governed query and pushdown policies;
|
||||||
|
- REST and generic HTTP requests;
|
||||||
|
- LDAP and directory services;
|
||||||
|
- managed files and watched directories;
|
||||||
|
- feeds such as RSS/Atom;
|
||||||
|
- object storage and public-sector application APIs.
|
||||||
|
|
||||||
|
Credential and connection policy remain outside this module.
|
||||||
22
pyproject.toml
Normal file
22
pyproject.toml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=69", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "govoplan-datasources"
|
||||||
|
version = "0.1.14"
|
||||||
|
description = "Governed datasource catalogue, staging, and materialization lifecycle for GovOPlaN."
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
license = "AGPL-3.0-or-later"
|
||||||
|
authors = [{ name = "GovOPlaN" }]
|
||||||
|
dependencies = ["govoplan-core>=0.1.14"]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
govoplan_datasources = ["py.typed"]
|
||||||
|
|
||||||
|
[project.entry-points."govoplan.modules"]
|
||||||
|
datasources = "govoplan_datasources.backend.manifest:get_manifest"
|
||||||
3
src/govoplan_datasources/__init__.py
Normal file
3
src/govoplan_datasources/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""GovOPlaN Datasources module."""
|
||||||
|
|
||||||
|
__version__ = "0.1.14"
|
||||||
1
src/govoplan_datasources/backend/__init__.py
Normal file
1
src/govoplan_datasources/backend/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Datasources backend."""
|
||||||
13
src/govoplan_datasources/backend/db/__init__.py
Normal file
13
src/govoplan_datasources/backend/db/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
"""Datasource database models."""
|
||||||
|
|
||||||
|
from govoplan_datasources.backend.db.models import (
|
||||||
|
DatasourceMaterializationRecord,
|
||||||
|
DatasourceRecord,
|
||||||
|
DatasourceStageRecord,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DatasourceMaterializationRecord",
|
||||||
|
"DatasourceRecord",
|
||||||
|
"DatasourceStageRecord",
|
||||||
|
]
|
||||||
217
src/govoplan_datasources/backend/db/models.py
Normal file
217
src/govoplan_datasources/backend/db/models.py
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
JSON,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from govoplan_core.db.base import Base, TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
def new_uuid() -> str:
|
||||||
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
class DatasourceRecord(Base, TimestampMixin):
|
||||||
|
__tablename__ = "datasource_catalogue"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "source_name", name="uq_datasource_source_name"),
|
||||||
|
Index("ix_datasource_catalogue_tenant_status", "tenant_id", "status"),
|
||||||
|
Index("ix_datasource_catalogue_tenant_mode", "tenant_id", "mode"),
|
||||||
|
Index("ix_datasource_catalogue_origin", "tenant_id", "provider", "provider_ref"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
source_name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(300), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
|
kind: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||||
|
mode: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||||
|
shape: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(30), default="active", nullable=False, index=True)
|
||||||
|
provider: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
|
||||||
|
provider_ref: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
schema_version: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||||
|
schema_: Mapped[list[dict[str, Any]]] = mapped_column(
|
||||||
|
"schema",
|
||||||
|
JSON,
|
||||||
|
default=list,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
fingerprint: Mapped[str] = mapped_column(String(64), default="", nullable=False, index=True)
|
||||||
|
current_materialization_id: Mapped[str | None] = mapped_column(
|
||||||
|
String(36),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
row_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
byte_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
provenance_: Mapped[dict[str, Any]] = mapped_column(
|
||||||
|
"provenance",
|
||||||
|
JSON,
|
||||||
|
default=dict,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
metadata_: Mapped[dict[str, Any]] = mapped_column(
|
||||||
|
"metadata",
|
||||||
|
JSON,
|
||||||
|
default=dict,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
created_by: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||||
|
updated_by: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||||
|
deleted_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
materializations: Mapped[list["DatasourceMaterializationRecord"]] = relationship(
|
||||||
|
back_populates="datasource",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="DatasourceMaterializationRecord.revision",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DatasourceMaterializationRecord(Base, TimestampMixin):
|
||||||
|
__tablename__ = "datasource_materializations"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"datasource_id",
|
||||||
|
"revision",
|
||||||
|
name="uq_datasource_materialization_revision",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_datasource_materializations_tenant_source",
|
||||||
|
"tenant_id",
|
||||||
|
"datasource_id",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_datasource_materializations_frozen",
|
||||||
|
"tenant_id",
|
||||||
|
"datasource_id",
|
||||||
|
"frozen_at",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
datasource_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("datasource_catalogue.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
state: Mapped[str] = mapped_column(String(30), default="published", nullable=False, index=True)
|
||||||
|
schema_version: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||||
|
schema_: Mapped[list[dict[str, Any]]] = mapped_column(
|
||||||
|
"schema",
|
||||||
|
JSON,
|
||||||
|
default=list,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
rows: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
|
||||||
|
fingerprint: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
|
row_count: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
byte_count: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
frozen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
frozen_label: Mapped[str | None] = mapped_column(String(300), nullable=True)
|
||||||
|
source_timestamp: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
provenance_: Mapped[dict[str, Any]] = mapped_column(
|
||||||
|
"provenance",
|
||||||
|
JSON,
|
||||||
|
default=dict,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
metadata_: Mapped[dict[str, Any]] = mapped_column(
|
||||||
|
"metadata",
|
||||||
|
JSON,
|
||||||
|
default=dict,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
created_by: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||||
|
|
||||||
|
datasource: Mapped[DatasourceRecord] = relationship(back_populates="materializations")
|
||||||
|
|
||||||
|
|
||||||
|
class DatasourceStageRecord(Base, TimestampMixin):
|
||||||
|
__tablename__ = "datasource_stages"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_datasource_stages_tenant_state", "tenant_id", "state"),
|
||||||
|
Index("ix_datasource_stages_target", "tenant_id", "target_datasource_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
target_datasource_id: Mapped[str | None] = mapped_column(
|
||||||
|
ForeignKey("datasource_catalogue.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(300), nullable=False)
|
||||||
|
source_name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
|
kind: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||||
|
mode: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
shape: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||||
|
state: Mapped[str] = mapped_column(String(30), default="ready", nullable=False, index=True)
|
||||||
|
provider: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
provider_ref: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
schema_: Mapped[list[dict[str, Any]]] = mapped_column(
|
||||||
|
"schema",
|
||||||
|
JSON,
|
||||||
|
default=list,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
rows: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
|
||||||
|
fingerprint: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
|
row_count: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
byte_count: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
validation_: Mapped[dict[str, Any]] = mapped_column(
|
||||||
|
"validation",
|
||||||
|
JSON,
|
||||||
|
default=dict,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
provenance_: Mapped[dict[str, Any]] = mapped_column(
|
||||||
|
"provenance",
|
||||||
|
JSON,
|
||||||
|
default=dict,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
metadata_: Mapped[dict[str, Any]] = mapped_column(
|
||||||
|
"metadata",
|
||||||
|
JSON,
|
||||||
|
default=dict,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
promoted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
promoted_materialization_id: Mapped[str | None] = mapped_column(
|
||||||
|
String(36),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
created_by: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DatasourceMaterializationRecord",
|
||||||
|
"DatasourceRecord",
|
||||||
|
"DatasourceStageRecord",
|
||||||
|
"new_uuid",
|
||||||
|
]
|
||||||
287
src/govoplan_datasources/backend/manifest.py
Normal file
287
src/govoplan_datasources/backend/manifest.py
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from govoplan_core.core.access import (
|
||||||
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.datasources import (
|
||||||
|
CAPABILITY_DATASOURCE_CATALOGUE,
|
||||||
|
CAPABILITY_DATASOURCE_LIFECYCLE,
|
||||||
|
CAPABILITY_DATASOURCE_ORIGINS,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.module_guards import (
|
||||||
|
drop_table_retirement_provider,
|
||||||
|
persistent_table_uninstall_guard,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.modules import (
|
||||||
|
DocumentationTopic,
|
||||||
|
FrontendModule,
|
||||||
|
MigrationSpec,
|
||||||
|
ModuleContext,
|
||||||
|
ModuleInterfaceProvider,
|
||||||
|
ModuleInterfaceRequirement,
|
||||||
|
ModuleManifest,
|
||||||
|
NavItem,
|
||||||
|
PermissionDefinition,
|
||||||
|
RoleTemplate,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_datasources.backend.db import models as datasource_models
|
||||||
|
from govoplan_datasources.backend.service import (
|
||||||
|
ADMIN_SCOPE,
|
||||||
|
CATALOGUE_READ_SCOPE,
|
||||||
|
SOURCE_WRITE_SCOPE,
|
||||||
|
STAGE_WRITE_SCOPE,
|
||||||
|
SqlDatasourceProvider,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
MODULE_ID = "datasources"
|
||||||
|
MODULE_NAME = "Datasources"
|
||||||
|
MODULE_VERSION = "0.1.14"
|
||||||
|
DATASOURCE_INTERFACE_VERSION = "0.1.0"
|
||||||
|
|
||||||
|
|
||||||
|
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||||
|
module_id, resource, action = scope.split(":", 2)
|
||||||
|
return PermissionDefinition(
|
||||||
|
scope=scope,
|
||||||
|
label=label,
|
||||||
|
description=description,
|
||||||
|
category="Datasources",
|
||||||
|
level="tenant",
|
||||||
|
module_id=module_id,
|
||||||
|
resource=resource,
|
||||||
|
action=action,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
PERMISSIONS = (
|
||||||
|
_permission(
|
||||||
|
CATALOGUE_READ_SCOPE,
|
||||||
|
"View datasources",
|
||||||
|
"Discover and preview policy-visible datasources and materializations.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
SOURCE_WRITE_SCOPE,
|
||||||
|
"Manage datasources",
|
||||||
|
"Register, refresh, freeze, and retire governed datasources.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
STAGE_WRITE_SCOPE,
|
||||||
|
"Stage datasource content",
|
||||||
|
"Upload, validate, inspect, and promote bounded datasource stages.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
ADMIN_SCOPE,
|
||||||
|
"Administer datasources",
|
||||||
|
"Manage every tenant datasource, stage, materialization, and lifecycle policy.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
ROLE_TEMPLATES = (
|
||||||
|
RoleTemplate(
|
||||||
|
slug="datasource_manager",
|
||||||
|
name="Datasource manager",
|
||||||
|
description="Register, stage, refresh, freeze, and retire governed data.",
|
||||||
|
permissions=(
|
||||||
|
CATALOGUE_READ_SCOPE,
|
||||||
|
SOURCE_WRITE_SCOPE,
|
||||||
|
STAGE_WRITE_SCOPE,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
RoleTemplate(
|
||||||
|
slug="datasource_reader",
|
||||||
|
name="Datasource reader",
|
||||||
|
description="Discover and preview policy-visible datasource states.",
|
||||||
|
permissions=(CATALOGUE_READ_SCOPE,),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _router(context: ModuleContext):
|
||||||
|
from govoplan_datasources.backend.runtime import configure_runtime
|
||||||
|
|
||||||
|
configure_runtime(registry=context.registry)
|
||||||
|
from govoplan_datasources.backend.router import router
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
def _provider(context: ModuleContext) -> SqlDatasourceProvider:
|
||||||
|
return SqlDatasourceProvider(registry=context.registry)
|
||||||
|
|
||||||
|
|
||||||
|
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||||
|
return {
|
||||||
|
"datasources": (
|
||||||
|
session.query(datasource_models.DatasourceRecord)
|
||||||
|
.filter(
|
||||||
|
datasource_models.DatasourceRecord.tenant_id == tenant_id,
|
||||||
|
datasource_models.DatasourceRecord.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
.count()
|
||||||
|
),
|
||||||
|
"datasource_stages_ready": (
|
||||||
|
session.query(datasource_models.DatasourceStageRecord)
|
||||||
|
.filter(
|
||||||
|
datasource_models.DatasourceStageRecord.tenant_id == tenant_id,
|
||||||
|
datasource_models.DatasourceStageRecord.state == "ready",
|
||||||
|
)
|
||||||
|
.count()
|
||||||
|
),
|
||||||
|
"datasource_frozen_states": (
|
||||||
|
session.query(datasource_models.DatasourceMaterializationRecord)
|
||||||
|
.filter(
|
||||||
|
datasource_models.DatasourceMaterializationRecord.tenant_id
|
||||||
|
== tenant_id,
|
||||||
|
datasource_models.DatasourceMaterializationRecord.frozen_at.is_not(
|
||||||
|
None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.count()
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
manifest = ModuleManifest(
|
||||||
|
id=MODULE_ID,
|
||||||
|
name=MODULE_NAME,
|
||||||
|
version=MODULE_VERSION,
|
||||||
|
dependencies=(),
|
||||||
|
optional_dependencies=(
|
||||||
|
"access",
|
||||||
|
"audit",
|
||||||
|
"connectors",
|
||||||
|
"files",
|
||||||
|
"notifications",
|
||||||
|
"policy",
|
||||||
|
),
|
||||||
|
optional_capabilities=(
|
||||||
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
|
CAPABILITY_DATASOURCE_ORIGINS,
|
||||||
|
),
|
||||||
|
provides_interfaces=(
|
||||||
|
ModuleInterfaceProvider(
|
||||||
|
name="datasources.catalogue",
|
||||||
|
version=DATASOURCE_INTERFACE_VERSION,
|
||||||
|
),
|
||||||
|
ModuleInterfaceProvider(
|
||||||
|
name="datasources.lifecycle",
|
||||||
|
version=DATASOURCE_INTERFACE_VERSION,
|
||||||
|
),
|
||||||
|
ModuleInterfaceProvider(
|
||||||
|
name="datasources.materializations",
|
||||||
|
version=DATASOURCE_INTERFACE_VERSION,
|
||||||
|
),
|
||||||
|
ModuleInterfaceProvider(
|
||||||
|
name="datasources.staging",
|
||||||
|
version=DATASOURCE_INTERFACE_VERSION,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
requires_interfaces=(
|
||||||
|
ModuleInterfaceRequirement(
|
||||||
|
name="connectors.datasource_origins",
|
||||||
|
version_min="0.1.0",
|
||||||
|
version_max_exclusive="1.0.0",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
permissions=PERMISSIONS,
|
||||||
|
role_templates=ROLE_TEMPLATES,
|
||||||
|
nav_items=(
|
||||||
|
NavItem(
|
||||||
|
path="/datasources",
|
||||||
|
label="Datasources",
|
||||||
|
icon="database-zap",
|
||||||
|
required_any=(CATALOGUE_READ_SCOPE, ADMIN_SCOPE),
|
||||||
|
order=70,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
frontend=FrontendModule(
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
package_name="@govoplan/datasources-webui",
|
||||||
|
nav_items=(
|
||||||
|
NavItem(
|
||||||
|
path="/datasources",
|
||||||
|
label="Datasources",
|
||||||
|
icon="database-zap",
|
||||||
|
required_any=(CATALOGUE_READ_SCOPE, ADMIN_SCOPE),
|
||||||
|
order=70,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
route_factory=_router,
|
||||||
|
capability_factories={
|
||||||
|
CAPABILITY_DATASOURCE_CATALOGUE: _provider,
|
||||||
|
CAPABILITY_DATASOURCE_LIFECYCLE: _provider,
|
||||||
|
},
|
||||||
|
tenant_summary_providers=(_tenant_summary,),
|
||||||
|
migration_spec=MigrationSpec(
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
metadata=Base.metadata,
|
||||||
|
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
||||||
|
retirement_supported=True,
|
||||||
|
retirement_provider=drop_table_retirement_provider(
|
||||||
|
datasource_models.DatasourceStageRecord,
|
||||||
|
datasource_models.DatasourceMaterializationRecord,
|
||||||
|
datasource_models.DatasourceRecord,
|
||||||
|
label="Datasources",
|
||||||
|
),
|
||||||
|
retirement_notes=(
|
||||||
|
"Destructive retirement drops datasource catalogue entries, immutable "
|
||||||
|
"materializations, and staging evidence after a database snapshot."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
uninstall_guard_providers=(
|
||||||
|
persistent_table_uninstall_guard(
|
||||||
|
datasource_models.DatasourceRecord,
|
||||||
|
datasource_models.DatasourceMaterializationRecord,
|
||||||
|
datasource_models.DatasourceStageRecord,
|
||||||
|
label="Datasources",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
documentation=(
|
||||||
|
DocumentationTopic(
|
||||||
|
id="datasources.lifecycle",
|
||||||
|
title="Datasource lifecycle",
|
||||||
|
summary="Governed live, cached, and static data with staging and frozen states.",
|
||||||
|
body=(
|
||||||
|
"Datasources is the provider-neutral catalogue consumed by Dataflow, "
|
||||||
|
"Workflow, Reporting, and policy-aware modules. Static data is staged "
|
||||||
|
"before promotion. Cached data refreshes connector origins into immutable "
|
||||||
|
"materializations. Live data is read through a connector and may be frozen "
|
||||||
|
"for reproducible evidence. Connectors owns protocols and credentials; "
|
||||||
|
"Datasources owns data identity, provenance, lifecycle, and read semantics."
|
||||||
|
),
|
||||||
|
layer="available",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("operator", "module_admin", "power_user", "product_owner"),
|
||||||
|
related_modules=(
|
||||||
|
"connectors",
|
||||||
|
"dataflow",
|
||||||
|
"workflow",
|
||||||
|
"files",
|
||||||
|
"reporting",
|
||||||
|
"risk_compliance",
|
||||||
|
),
|
||||||
|
order=70,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_manifest() -> ModuleManifest:
|
||||||
|
return manifest
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DATASOURCE_INTERFACE_VERSION",
|
||||||
|
"MODULE_ID",
|
||||||
|
"MODULE_VERSION",
|
||||||
|
"get_manifest",
|
||||||
|
"manifest",
|
||||||
|
]
|
||||||
1
src/govoplan_datasources/backend/migrations/__init__.py
Normal file
1
src/govoplan_datasources/backend/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Datasource Alembic migrations."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Datasource Alembic revisions."""
|
||||||
@@ -0,0 +1,417 @@
|
|||||||
|
"""v0.1.14 Datasources baseline
|
||||||
|
|
||||||
|
Revision ID: f3a8d2c7b1e0
|
||||||
|
Revises: None
|
||||||
|
Create Date: 2026-07-28 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "f3a8d2c7b1e0"
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"datasource_catalogue",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("source_name", sa.String(length=120), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=300), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("kind", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("mode", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("shape", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("provider", sa.String(length=100), nullable=True),
|
||||||
|
sa.Column("provider_ref", sa.String(length=500), nullable=True),
|
||||||
|
sa.Column("schema_version", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("schema", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("fingerprint", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("current_materialization_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("row_count", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("byte_count", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("provenance", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("metadata", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("created_by", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("updated_by", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_datasource_catalogue")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"source_name",
|
||||||
|
name="uq_datasource_source_name",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_datasource_catalogue_created_by"),
|
||||||
|
"datasource_catalogue",
|
||||||
|
["created_by"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_datasource_catalogue_current_materialization_id"),
|
||||||
|
"datasource_catalogue",
|
||||||
|
["current_materialization_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_datasource_catalogue_deleted_at"),
|
||||||
|
"datasource_catalogue",
|
||||||
|
["deleted_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_datasource_catalogue_fingerprint"),
|
||||||
|
"datasource_catalogue",
|
||||||
|
["fingerprint"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_datasource_catalogue_kind"),
|
||||||
|
"datasource_catalogue",
|
||||||
|
["kind"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_datasource_catalogue_mode"),
|
||||||
|
"datasource_catalogue",
|
||||||
|
["mode"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_datasource_catalogue_provider"),
|
||||||
|
"datasource_catalogue",
|
||||||
|
["provider"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_datasource_catalogue_shape"),
|
||||||
|
"datasource_catalogue",
|
||||||
|
["shape"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_datasource_catalogue_status"),
|
||||||
|
"datasource_catalogue",
|
||||||
|
["status"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_datasource_catalogue_tenant_id"),
|
||||||
|
"datasource_catalogue",
|
||||||
|
["tenant_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_datasource_catalogue_updated_by"),
|
||||||
|
"datasource_catalogue",
|
||||||
|
["updated_by"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_datasource_catalogue_origin",
|
||||||
|
"datasource_catalogue",
|
||||||
|
["tenant_id", "provider", "provider_ref"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_datasource_catalogue_tenant_mode",
|
||||||
|
"datasource_catalogue",
|
||||||
|
["tenant_id", "mode"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_datasource_catalogue_tenant_status",
|
||||||
|
"datasource_catalogue",
|
||||||
|
["tenant_id", "status"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"datasource_materializations",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("datasource_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("revision", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("state", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("schema_version", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("schema", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("rows", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("fingerprint", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("row_count", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("byte_count", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("frozen_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("frozen_label", sa.String(length=300), nullable=True),
|
||||||
|
sa.Column("source_timestamp", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("provenance", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("metadata", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("created_by", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["datasource_id"],
|
||||||
|
["datasource_catalogue.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_datasource_materializations_datasource_id_datasource_catalogue"
|
||||||
|
),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_datasource_materializations")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"datasource_id",
|
||||||
|
"revision",
|
||||||
|
name="uq_datasource_materialization_revision",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_datasource_materializations_created_by"),
|
||||||
|
"datasource_materializations",
|
||||||
|
["created_by"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_datasource_materializations_datasource_id"),
|
||||||
|
"datasource_materializations",
|
||||||
|
["datasource_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_datasource_materializations_fingerprint"),
|
||||||
|
"datasource_materializations",
|
||||||
|
["fingerprint"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_datasource_materializations_state"),
|
||||||
|
"datasource_materializations",
|
||||||
|
["state"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_datasource_materializations_tenant_id"),
|
||||||
|
"datasource_materializations",
|
||||||
|
["tenant_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_datasource_materializations_frozen",
|
||||||
|
"datasource_materializations",
|
||||||
|
["tenant_id", "datasource_id", "frozen_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_datasource_materializations_tenant_source",
|
||||||
|
"datasource_materializations",
|
||||||
|
["tenant_id", "datasource_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"datasource_stages",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("target_datasource_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("name", sa.String(length=300), nullable=False),
|
||||||
|
sa.Column("source_name", sa.String(length=120), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("kind", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("mode", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("shape", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("state", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("provider", sa.String(length=100), nullable=True),
|
||||||
|
sa.Column("provider_ref", sa.String(length=500), nullable=True),
|
||||||
|
sa.Column("schema", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("rows", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("fingerprint", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("row_count", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("byte_count", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("validation", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("provenance", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("metadata", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("promoted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("promoted_materialization_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("created_by", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["target_datasource_id"],
|
||||||
|
["datasource_catalogue.id"],
|
||||||
|
name=op.f("fk_datasource_stages_target_datasource_id_datasource_catalogue"),
|
||||||
|
ondelete="SET NULL",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_datasource_stages")),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_datasource_stages_created_by"),
|
||||||
|
"datasource_stages",
|
||||||
|
["created_by"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_datasource_stages_fingerprint"),
|
||||||
|
"datasource_stages",
|
||||||
|
["fingerprint"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_datasource_stages_promoted_materialization_id"),
|
||||||
|
"datasource_stages",
|
||||||
|
["promoted_materialization_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_datasource_stages_state"),
|
||||||
|
"datasource_stages",
|
||||||
|
["state"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_datasource_stages_target_datasource_id"),
|
||||||
|
"datasource_stages",
|
||||||
|
["target_datasource_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_datasource_stages_tenant_id"),
|
||||||
|
"datasource_stages",
|
||||||
|
["tenant_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_datasource_stages_target",
|
||||||
|
"datasource_stages",
|
||||||
|
["tenant_id", "target_datasource_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_datasource_stages_tenant_state",
|
||||||
|
"datasource_stages",
|
||||||
|
["tenant_id", "state"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_datasource_stages_tenant_state", table_name="datasource_stages")
|
||||||
|
op.drop_index("ix_datasource_stages_target", table_name="datasource_stages")
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_datasource_stages_tenant_id"),
|
||||||
|
table_name="datasource_stages",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_datasource_stages_target_datasource_id"),
|
||||||
|
table_name="datasource_stages",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_datasource_stages_state"),
|
||||||
|
table_name="datasource_stages",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_datasource_stages_promoted_materialization_id"),
|
||||||
|
table_name="datasource_stages",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_datasource_stages_fingerprint"),
|
||||||
|
table_name="datasource_stages",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_datasource_stages_created_by"),
|
||||||
|
table_name="datasource_stages",
|
||||||
|
)
|
||||||
|
op.drop_table("datasource_stages")
|
||||||
|
|
||||||
|
op.drop_index(
|
||||||
|
"ix_datasource_materializations_tenant_source",
|
||||||
|
table_name="datasource_materializations",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
"ix_datasource_materializations_frozen",
|
||||||
|
table_name="datasource_materializations",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_datasource_materializations_tenant_id"),
|
||||||
|
table_name="datasource_materializations",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_datasource_materializations_state"),
|
||||||
|
table_name="datasource_materializations",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_datasource_materializations_fingerprint"),
|
||||||
|
table_name="datasource_materializations",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_datasource_materializations_datasource_id"),
|
||||||
|
table_name="datasource_materializations",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_datasource_materializations_created_by"),
|
||||||
|
table_name="datasource_materializations",
|
||||||
|
)
|
||||||
|
op.drop_table("datasource_materializations")
|
||||||
|
|
||||||
|
op.drop_index(
|
||||||
|
"ix_datasource_catalogue_tenant_status",
|
||||||
|
table_name="datasource_catalogue",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
"ix_datasource_catalogue_tenant_mode",
|
||||||
|
table_name="datasource_catalogue",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
"ix_datasource_catalogue_origin",
|
||||||
|
table_name="datasource_catalogue",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_datasource_catalogue_updated_by"),
|
||||||
|
table_name="datasource_catalogue",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_datasource_catalogue_tenant_id"),
|
||||||
|
table_name="datasource_catalogue",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_datasource_catalogue_status"),
|
||||||
|
table_name="datasource_catalogue",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_datasource_catalogue_shape"),
|
||||||
|
table_name="datasource_catalogue",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_datasource_catalogue_provider"),
|
||||||
|
table_name="datasource_catalogue",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_datasource_catalogue_mode"),
|
||||||
|
table_name="datasource_catalogue",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_datasource_catalogue_kind"),
|
||||||
|
table_name="datasource_catalogue",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_datasource_catalogue_fingerprint"),
|
||||||
|
table_name="datasource_catalogue",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_datasource_catalogue_deleted_at"),
|
||||||
|
table_name="datasource_catalogue",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_datasource_catalogue_current_materialization_id"),
|
||||||
|
table_name="datasource_catalogue",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_datasource_catalogue_created_by"),
|
||||||
|
table_name="datasource_catalogue",
|
||||||
|
)
|
||||||
|
op.drop_table("datasource_catalogue")
|
||||||
624
src/govoplan_datasources/backend/router.py
Normal file
624
src/govoplan_datasources/backend/router.py
Normal file
@@ -0,0 +1,624 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.audit.logging import audit_event
|
||||||
|
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
||||||
|
from govoplan_core.core.datasources import (
|
||||||
|
DatasourceAccessError,
|
||||||
|
DatasourceDescriptor,
|
||||||
|
DatasourceError,
|
||||||
|
DatasourceMaterialization,
|
||||||
|
DatasourceNotFoundError,
|
||||||
|
DatasourceOrigin,
|
||||||
|
DatasourceReadRequest,
|
||||||
|
DatasourceStage,
|
||||||
|
DatasourceStageInput,
|
||||||
|
DatasourceUnavailableError,
|
||||||
|
datasource_origins,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.session import get_session
|
||||||
|
from govoplan_datasources.backend.runtime import get_registry
|
||||||
|
from govoplan_datasources.backend.schemas import (
|
||||||
|
DatasourceFieldResponse,
|
||||||
|
DatasourceFreezeRequest,
|
||||||
|
DatasourceListResponse,
|
||||||
|
DatasourceMaterializationListResponse,
|
||||||
|
DatasourceMaterializationResponse,
|
||||||
|
DatasourceOriginListResponse,
|
||||||
|
DatasourceOriginRegisterRequest,
|
||||||
|
DatasourceOriginResponse,
|
||||||
|
DatasourcePreviewResponse,
|
||||||
|
DatasourceResponse,
|
||||||
|
DatasourceRetireResponse,
|
||||||
|
DatasourceStageCreateRequest,
|
||||||
|
DatasourceStageListResponse,
|
||||||
|
DatasourceStagePromoteRequest,
|
||||||
|
DatasourceStagePromoteResponse,
|
||||||
|
DatasourceStageResponse,
|
||||||
|
)
|
||||||
|
from govoplan_datasources.backend.service import (
|
||||||
|
ADMIN_SCOPE,
|
||||||
|
CATALOGUE_READ_SCOPE,
|
||||||
|
SOURCE_WRITE_SCOPE,
|
||||||
|
STAGE_WRITE_SCOPE,
|
||||||
|
SqlDatasourceProvider,
|
||||||
|
)
|
||||||
|
from govoplan_datasources.backend.tabular import parse_csv_rows
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/datasources", tags=["datasources"])
|
||||||
|
|
||||||
|
|
||||||
|
def _provider() -> SqlDatasourceProvider:
|
||||||
|
return SqlDatasourceProvider(registry=get_registry())
|
||||||
|
|
||||||
|
|
||||||
|
def _require_any_scope(principal: ApiPrincipal, *scopes: str) -> None:
|
||||||
|
if any(has_scope(principal, scope) for scope in scopes):
|
||||||
|
return
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"Missing one of the required scopes: {', '.join(scopes)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _http_error(exc: DatasourceError) -> HTTPException:
|
||||||
|
if isinstance(exc, DatasourceNotFoundError):
|
||||||
|
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||||
|
if isinstance(exc, DatasourceAccessError):
|
||||||
|
return HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc))
|
||||||
|
if isinstance(exc, DatasourceUnavailableError):
|
||||||
|
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=str(exc),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/origins", response_model=DatasourceOriginListResponse)
|
||||||
|
def api_list_origins(
|
||||||
|
query: str = Query(default="", max_length=200),
|
||||||
|
limit: int = Query(default=100, ge=1, le=100),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> DatasourceOriginListResponse:
|
||||||
|
_require_any_scope(
|
||||||
|
principal,
|
||||||
|
CATALOGUE_READ_SCOPE,
|
||||||
|
SOURCE_WRITE_SCOPE,
|
||||||
|
ADMIN_SCOPE,
|
||||||
|
)
|
||||||
|
provider = datasource_origins(get_registry())
|
||||||
|
if provider is None:
|
||||||
|
return DatasourceOriginListResponse(available=False, origins=[])
|
||||||
|
try:
|
||||||
|
origins = provider.list_origins(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
query=query,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
except DatasourceError as exc:
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
return DatasourceOriginListResponse(
|
||||||
|
available=True,
|
||||||
|
origins=[_origin_response(origin) for origin in origins],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/origins/register",
|
||||||
|
response_model=DatasourceResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def api_register_origin(
|
||||||
|
payload: DatasourceOriginRegisterRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> DatasourceResponse:
|
||||||
|
_require_any_scope(principal, SOURCE_WRITE_SCOPE, ADMIN_SCOPE)
|
||||||
|
try:
|
||||||
|
datasource = _provider().register_origin(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
origin_ref=payload.origin_ref,
|
||||||
|
name=payload.name,
|
||||||
|
source_name=payload.source_name,
|
||||||
|
mode=payload.mode,
|
||||||
|
description=payload.description,
|
||||||
|
)
|
||||||
|
except DatasourceError as exc:
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
_audit(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="datasources.origin.registered",
|
||||||
|
object_type="datasource",
|
||||||
|
object_id=datasource.ref,
|
||||||
|
details={
|
||||||
|
"origin_ref": payload.origin_ref,
|
||||||
|
"mode": datasource.mode,
|
||||||
|
"source_name": datasource.source_name,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return _datasource_response(datasource)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stages", response_model=DatasourceStageListResponse)
|
||||||
|
def api_list_stages(
|
||||||
|
limit: int = Query(default=100, ge=1, le=100),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> DatasourceStageListResponse:
|
||||||
|
_require_any_scope(
|
||||||
|
principal,
|
||||||
|
CATALOGUE_READ_SCOPE,
|
||||||
|
STAGE_WRITE_SCOPE,
|
||||||
|
ADMIN_SCOPE,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
stages = _provider().list_stages(session, principal, limit=limit)
|
||||||
|
except DatasourceError as exc:
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
return DatasourceStageListResponse(
|
||||||
|
stages=[_stage_response(stage) for stage in stages]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/stages",
|
||||||
|
response_model=DatasourceStageResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def api_create_stage(
|
||||||
|
payload: DatasourceStageCreateRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> DatasourceStageResponse:
|
||||||
|
_require_any_scope(principal, STAGE_WRITE_SCOPE, ADMIN_SCOPE)
|
||||||
|
try:
|
||||||
|
rows = (
|
||||||
|
tuple(payload.rows or ())
|
||||||
|
if payload.format == "json"
|
||||||
|
else parse_csv_rows(payload.csv_text or "", delimiter=payload.delimiter)
|
||||||
|
)
|
||||||
|
stage = _provider().create_stage(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
stage=DatasourceStageInput(
|
||||||
|
name=payload.name,
|
||||||
|
source_name=payload.source_name,
|
||||||
|
description=payload.description,
|
||||||
|
kind=payload.kind,
|
||||||
|
mode=payload.mode,
|
||||||
|
shape=payload.shape,
|
||||||
|
rows=rows,
|
||||||
|
target_datasource_ref=payload.target_datasource_ref,
|
||||||
|
provider="datasources.upload",
|
||||||
|
provenance={
|
||||||
|
**payload.provenance,
|
||||||
|
"created_via": "datasources",
|
||||||
|
"source_format": payload.format,
|
||||||
|
},
|
||||||
|
metadata=payload.metadata,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except DatasourceError as exc:
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
_audit(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="datasources.stage.created",
|
||||||
|
object_type="datasource_stage",
|
||||||
|
object_id=stage.ref,
|
||||||
|
details={
|
||||||
|
"source_name": stage.source_name,
|
||||||
|
"mode": stage.mode,
|
||||||
|
"row_count": stage.row_count,
|
||||||
|
"fingerprint": stage.fingerprint,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return _stage_response(stage)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/stages/{stage_id}/promote",
|
||||||
|
response_model=DatasourceStagePromoteResponse,
|
||||||
|
)
|
||||||
|
def api_promote_stage(
|
||||||
|
stage_id: str,
|
||||||
|
payload: DatasourceStagePromoteRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> DatasourceStagePromoteResponse:
|
||||||
|
_require_any_scope(principal, STAGE_WRITE_SCOPE, ADMIN_SCOPE)
|
||||||
|
try:
|
||||||
|
datasource, materialization = _provider().promote_stage(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
stage_ref=f"stage:{stage_id}",
|
||||||
|
freeze=payload.freeze,
|
||||||
|
frozen_label=payload.frozen_label,
|
||||||
|
)
|
||||||
|
except DatasourceError as exc:
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
_audit(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="datasources.stage.promoted",
|
||||||
|
object_type="datasource",
|
||||||
|
object_id=datasource.ref,
|
||||||
|
details={
|
||||||
|
"stage_ref": f"stage:{stage_id}",
|
||||||
|
"materialization_ref": materialization.ref,
|
||||||
|
"revision": materialization.revision,
|
||||||
|
"frozen": materialization.frozen_at is not None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return DatasourceStagePromoteResponse(
|
||||||
|
datasource=_datasource_response(datasource),
|
||||||
|
materialization=_materialization_response(materialization),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=DatasourceListResponse)
|
||||||
|
def api_list_datasources(
|
||||||
|
query: str = Query(default="", max_length=200),
|
||||||
|
limit: int = Query(default=100, ge=1, le=100),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> DatasourceListResponse:
|
||||||
|
_require_any_scope(principal, CATALOGUE_READ_SCOPE, ADMIN_SCOPE)
|
||||||
|
try:
|
||||||
|
items = _provider().list_datasources(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
query=query,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
except DatasourceError as exc:
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
return DatasourceListResponse(
|
||||||
|
datasources=[_datasource_response(item) for item in items]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{datasource_id}", response_model=DatasourceResponse)
|
||||||
|
def api_get_datasource(
|
||||||
|
datasource_id: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> DatasourceResponse:
|
||||||
|
_require_any_scope(principal, CATALOGUE_READ_SCOPE, ADMIN_SCOPE)
|
||||||
|
try:
|
||||||
|
item = _provider().get_datasource(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
datasource_ref=f"datasource:{datasource_id}",
|
||||||
|
)
|
||||||
|
except DatasourceError as exc:
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Datasource not found.",
|
||||||
|
)
|
||||||
|
return _datasource_response(item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{datasource_id}/preview",
|
||||||
|
response_model=DatasourcePreviewResponse,
|
||||||
|
)
|
||||||
|
def api_preview_datasource(
|
||||||
|
datasource_id: str,
|
||||||
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
consistency: str = Query(default="current", pattern="^(current|live|frozen)$"),
|
||||||
|
materialization_ref: str | None = Query(default=None, max_length=120),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> DatasourcePreviewResponse:
|
||||||
|
_require_any_scope(principal, CATALOGUE_READ_SCOPE, ADMIN_SCOPE)
|
||||||
|
try:
|
||||||
|
result = _provider().read_datasource(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
request=DatasourceReadRequest(
|
||||||
|
datasource_ref=f"datasource:{datasource_id}",
|
||||||
|
materialization_ref=materialization_ref,
|
||||||
|
consistency=consistency, # type: ignore[arg-type]
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except DatasourceError as exc:
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
return DatasourcePreviewResponse(
|
||||||
|
datasource=_datasource_response(result.datasource),
|
||||||
|
rows=[dict(row) for row in result.rows],
|
||||||
|
total_rows=result.total_rows,
|
||||||
|
truncated=result.truncated,
|
||||||
|
materialization=(
|
||||||
|
_materialization_response(result.materialization)
|
||||||
|
if result.materialization
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{datasource_id}/materializations",
|
||||||
|
response_model=DatasourceMaterializationListResponse,
|
||||||
|
)
|
||||||
|
def api_list_materializations(
|
||||||
|
datasource_id: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> DatasourceMaterializationListResponse:
|
||||||
|
_require_any_scope(principal, CATALOGUE_READ_SCOPE, ADMIN_SCOPE)
|
||||||
|
try:
|
||||||
|
rows = _provider().list_materializations(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
datasource_ref=f"datasource:{datasource_id}",
|
||||||
|
)
|
||||||
|
except DatasourceError as exc:
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
return DatasourceMaterializationListResponse(
|
||||||
|
materializations=[_materialization_response(item) for item in rows]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{datasource_id}/refresh",
|
||||||
|
response_model=DatasourceStagePromoteResponse,
|
||||||
|
)
|
||||||
|
def api_refresh_datasource(
|
||||||
|
datasource_id: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> DatasourceStagePromoteResponse:
|
||||||
|
_require_any_scope(principal, SOURCE_WRITE_SCOPE, ADMIN_SCOPE)
|
||||||
|
try:
|
||||||
|
datasource, materialization = _provider().refresh_datasource(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
datasource_ref=f"datasource:{datasource_id}",
|
||||||
|
)
|
||||||
|
except DatasourceError as exc:
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
_audit(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="datasources.refreshed",
|
||||||
|
object_type="datasource",
|
||||||
|
object_id=datasource.ref,
|
||||||
|
details={
|
||||||
|
"materialization_ref": materialization.ref,
|
||||||
|
"revision": materialization.revision,
|
||||||
|
"fingerprint": materialization.fingerprint,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return DatasourceStagePromoteResponse(
|
||||||
|
datasource=_datasource_response(datasource),
|
||||||
|
materialization=_materialization_response(materialization),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{datasource_id}/freeze",
|
||||||
|
response_model=DatasourceMaterializationResponse,
|
||||||
|
)
|
||||||
|
def api_freeze_datasource(
|
||||||
|
datasource_id: str,
|
||||||
|
payload: DatasourceFreezeRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> DatasourceMaterializationResponse:
|
||||||
|
_require_any_scope(principal, SOURCE_WRITE_SCOPE, ADMIN_SCOPE)
|
||||||
|
datasource_ref = f"datasource:{datasource_id}"
|
||||||
|
try:
|
||||||
|
materialization = _provider().freeze_datasource(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
datasource_ref=datasource_ref,
|
||||||
|
label=payload.label,
|
||||||
|
)
|
||||||
|
except DatasourceError as exc:
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
_audit(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="datasources.frozen",
|
||||||
|
object_type="datasource",
|
||||||
|
object_id=datasource_ref,
|
||||||
|
details={
|
||||||
|
"materialization_ref": materialization.ref,
|
||||||
|
"revision": materialization.revision,
|
||||||
|
"label": materialization.frozen_label,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return _materialization_response(materialization)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{datasource_id}",
|
||||||
|
response_model=DatasourceRetireResponse,
|
||||||
|
)
|
||||||
|
def api_retire_datasource(
|
||||||
|
datasource_id: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> DatasourceRetireResponse:
|
||||||
|
_require_any_scope(principal, SOURCE_WRITE_SCOPE, ADMIN_SCOPE)
|
||||||
|
datasource_ref = f"datasource:{datasource_id}"
|
||||||
|
try:
|
||||||
|
_provider().retire_datasource(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
datasource_ref=datasource_ref,
|
||||||
|
)
|
||||||
|
except DatasourceError as exc:
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
_audit(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="datasources.retired",
|
||||||
|
object_type="datasource",
|
||||||
|
object_id=datasource_ref,
|
||||||
|
details={},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return DatasourceRetireResponse(retired=True, datasource_ref=datasource_ref)
|
||||||
|
|
||||||
|
|
||||||
|
def _datasource_response(item: DatasourceDescriptor) -> DatasourceResponse:
|
||||||
|
return DatasourceResponse(
|
||||||
|
ref=item.ref,
|
||||||
|
source_name=item.source_name,
|
||||||
|
name=item.name,
|
||||||
|
description=item.description,
|
||||||
|
kind=item.kind,
|
||||||
|
mode=item.mode,
|
||||||
|
shape=item.shape,
|
||||||
|
status=item.status,
|
||||||
|
provider=item.provider,
|
||||||
|
provider_ref=item.provider_ref,
|
||||||
|
schema=[
|
||||||
|
DatasourceFieldResponse(
|
||||||
|
name=field.name,
|
||||||
|
data_type=field.data_type,
|
||||||
|
nullable=field.nullable,
|
||||||
|
)
|
||||||
|
for field in item.schema
|
||||||
|
],
|
||||||
|
schema_version=item.schema_version,
|
||||||
|
fingerprint=item.fingerprint,
|
||||||
|
current_materialization_ref=item.current_materialization_ref,
|
||||||
|
row_count=item.row_count,
|
||||||
|
byte_count=item.byte_count,
|
||||||
|
updated_at=item.updated_at.isoformat() if item.updated_at else None,
|
||||||
|
capabilities=list(item.capabilities),
|
||||||
|
provenance=dict(item.provenance),
|
||||||
|
metadata=dict(item.metadata),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _materialization_response(
|
||||||
|
item: DatasourceMaterialization,
|
||||||
|
) -> DatasourceMaterializationResponse:
|
||||||
|
return DatasourceMaterializationResponse(
|
||||||
|
ref=item.ref,
|
||||||
|
datasource_ref=item.datasource_ref,
|
||||||
|
revision=item.revision,
|
||||||
|
state=item.state,
|
||||||
|
fingerprint=item.fingerprint,
|
||||||
|
schema=[
|
||||||
|
DatasourceFieldResponse(
|
||||||
|
name=field.name,
|
||||||
|
data_type=field.data_type,
|
||||||
|
nullable=field.nullable,
|
||||||
|
)
|
||||||
|
for field in item.schema
|
||||||
|
],
|
||||||
|
row_count=item.row_count,
|
||||||
|
byte_count=item.byte_count,
|
||||||
|
frozen_at=item.frozen_at.isoformat() if item.frozen_at else None,
|
||||||
|
frozen_label=item.frozen_label,
|
||||||
|
source_timestamp=(
|
||||||
|
item.source_timestamp.isoformat() if item.source_timestamp else None
|
||||||
|
),
|
||||||
|
created_at=item.created_at.isoformat() if item.created_at else None,
|
||||||
|
provenance=dict(item.provenance),
|
||||||
|
metadata=dict(item.metadata),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _stage_response(item: DatasourceStage) -> DatasourceStageResponse:
|
||||||
|
return DatasourceStageResponse(
|
||||||
|
ref=item.ref,
|
||||||
|
name=item.name,
|
||||||
|
source_name=item.source_name,
|
||||||
|
kind=item.kind,
|
||||||
|
mode=item.mode,
|
||||||
|
shape=item.shape,
|
||||||
|
state=item.state,
|
||||||
|
target_datasource_ref=item.target_datasource_ref,
|
||||||
|
fingerprint=item.fingerprint,
|
||||||
|
schema=[
|
||||||
|
DatasourceFieldResponse(
|
||||||
|
name=field.name,
|
||||||
|
data_type=field.data_type,
|
||||||
|
nullable=field.nullable,
|
||||||
|
)
|
||||||
|
for field in item.schema
|
||||||
|
],
|
||||||
|
row_count=item.row_count,
|
||||||
|
byte_count=item.byte_count,
|
||||||
|
validation=dict(item.validation),
|
||||||
|
created_at=item.created_at.isoformat() if item.created_at else None,
|
||||||
|
promoted_at=item.promoted_at.isoformat() if item.promoted_at else None,
|
||||||
|
promoted_materialization_ref=item.promoted_materialization_ref,
|
||||||
|
provenance=dict(item.provenance),
|
||||||
|
metadata=dict(item.metadata),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _origin_response(item: DatasourceOrigin) -> DatasourceOriginResponse:
|
||||||
|
return DatasourceOriginResponse(
|
||||||
|
ref=item.ref,
|
||||||
|
source_name=item.source_name,
|
||||||
|
name=item.name,
|
||||||
|
description=item.description,
|
||||||
|
kind=item.kind,
|
||||||
|
shape=item.shape,
|
||||||
|
supported_modes=list(item.supported_modes),
|
||||||
|
provider=item.provider,
|
||||||
|
schema=[
|
||||||
|
DatasourceFieldResponse(
|
||||||
|
name=field.name,
|
||||||
|
data_type=field.data_type,
|
||||||
|
nullable=field.nullable,
|
||||||
|
)
|
||||||
|
for field in item.schema
|
||||||
|
],
|
||||||
|
schema_version=item.schema_version,
|
||||||
|
fingerprint=item.fingerprint,
|
||||||
|
row_count=item.row_count,
|
||||||
|
byte_count=item.byte_count,
|
||||||
|
updated_at=item.updated_at.isoformat() if item.updated_at else None,
|
||||||
|
capabilities=list(item.capabilities),
|
||||||
|
metadata=dict(item.metadata),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _audit(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
*,
|
||||||
|
action: str,
|
||||||
|
object_type: str,
|
||||||
|
object_id: str,
|
||||||
|
details: dict[str, object],
|
||||||
|
) -> None:
|
||||||
|
audit_event(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
user_id=getattr(principal.user, "id", None),
|
||||||
|
api_key_id=principal.api_key_id,
|
||||||
|
action=action,
|
||||||
|
object_type=object_type,
|
||||||
|
object_id=object_id,
|
||||||
|
details=details,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["router"]
|
||||||
16
src/govoplan_datasources/backend/runtime.py
Normal file
16
src/govoplan_datasources/backend/runtime.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
_registry: object | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def configure_runtime(*, registry: object) -> None:
|
||||||
|
global _registry
|
||||||
|
_registry = registry
|
||||||
|
|
||||||
|
|
||||||
|
def get_registry() -> object | None:
|
||||||
|
return _registry
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["configure_runtime", "get_registry"]
|
||||||
181
src/govoplan_datasources/backend/schemas.py
Normal file
181
src/govoplan_datasources/backend/schemas.py
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
DatasourceModeValue = Literal["live", "cached", "static"]
|
||||||
|
DatasourceKindValue = Literal[
|
||||||
|
"upload",
|
||||||
|
"database",
|
||||||
|
"http",
|
||||||
|
"rest",
|
||||||
|
"directory",
|
||||||
|
"file",
|
||||||
|
"feed",
|
||||||
|
"custom",
|
||||||
|
]
|
||||||
|
DatasourceShapeValue = Literal["tabular", "document", "binary", "directory", "stream"]
|
||||||
|
|
||||||
|
|
||||||
|
class DatasourceFieldResponse(BaseModel):
|
||||||
|
name: str
|
||||||
|
data_type: str
|
||||||
|
nullable: bool
|
||||||
|
|
||||||
|
|
||||||
|
class DatasourceResponse(BaseModel):
|
||||||
|
ref: str
|
||||||
|
source_name: str
|
||||||
|
name: str
|
||||||
|
description: str | None
|
||||||
|
kind: DatasourceKindValue
|
||||||
|
mode: DatasourceModeValue
|
||||||
|
shape: DatasourceShapeValue
|
||||||
|
status: str
|
||||||
|
provider: str | None
|
||||||
|
provider_ref: str | None
|
||||||
|
fields: list[DatasourceFieldResponse] = Field(alias="schema")
|
||||||
|
schema_version: str
|
||||||
|
fingerprint: str
|
||||||
|
current_materialization_ref: str | None
|
||||||
|
row_count: int | None
|
||||||
|
byte_count: int | None
|
||||||
|
updated_at: str | None
|
||||||
|
capabilities: list[str]
|
||||||
|
provenance: dict[str, Any]
|
||||||
|
metadata: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class DatasourceListResponse(BaseModel):
|
||||||
|
datasources: list[DatasourceResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class DatasourceMaterializationResponse(BaseModel):
|
||||||
|
ref: str
|
||||||
|
datasource_ref: str
|
||||||
|
revision: int
|
||||||
|
state: str
|
||||||
|
fingerprint: str
|
||||||
|
fields: list[DatasourceFieldResponse] = Field(alias="schema")
|
||||||
|
row_count: int | None
|
||||||
|
byte_count: int | None
|
||||||
|
frozen_at: str | None
|
||||||
|
frozen_label: str | None
|
||||||
|
source_timestamp: str | None
|
||||||
|
created_at: str | None
|
||||||
|
provenance: dict[str, Any]
|
||||||
|
metadata: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class DatasourceMaterializationListResponse(BaseModel):
|
||||||
|
materializations: list[DatasourceMaterializationResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class DatasourceStageResponse(BaseModel):
|
||||||
|
ref: str
|
||||||
|
name: str
|
||||||
|
source_name: str
|
||||||
|
kind: DatasourceKindValue
|
||||||
|
mode: DatasourceModeValue
|
||||||
|
shape: DatasourceShapeValue
|
||||||
|
state: str
|
||||||
|
target_datasource_ref: str | None
|
||||||
|
fingerprint: str
|
||||||
|
fields: list[DatasourceFieldResponse] = Field(alias="schema")
|
||||||
|
row_count: int | None
|
||||||
|
byte_count: int | None
|
||||||
|
validation: dict[str, Any]
|
||||||
|
created_at: str | None
|
||||||
|
promoted_at: str | None
|
||||||
|
promoted_materialization_ref: str | None
|
||||||
|
provenance: dict[str, Any]
|
||||||
|
metadata: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class DatasourceStageListResponse(BaseModel):
|
||||||
|
stages: list[DatasourceStageResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class DatasourceStageCreateRequest(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=300)
|
||||||
|
source_name: str = Field(min_length=1, max_length=120)
|
||||||
|
description: str | None = Field(default=None, max_length=4_000)
|
||||||
|
kind: DatasourceKindValue = "upload"
|
||||||
|
mode: Literal["static", "cached"] = "static"
|
||||||
|
shape: Literal["tabular"] = "tabular"
|
||||||
|
format: Literal["json", "csv"] = "json"
|
||||||
|
rows: list[dict[str, Any]] | None = Field(default=None, max_length=10_000)
|
||||||
|
csv_text: str | None = Field(default=None, max_length=5_000_000)
|
||||||
|
delimiter: Literal[",", ";", "\t", "|"] = ","
|
||||||
|
target_datasource_ref: str | None = None
|
||||||
|
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def matching_payload(self) -> "DatasourceStageCreateRequest":
|
||||||
|
if self.format == "json" and self.rows is None:
|
||||||
|
raise ValueError("JSON stages require rows.")
|
||||||
|
if self.format == "csv" and not self.csv_text:
|
||||||
|
raise ValueError("CSV stages require csv_text.")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class DatasourceStagePromoteRequest(BaseModel):
|
||||||
|
freeze: bool = False
|
||||||
|
frozen_label: str | None = Field(default=None, max_length=300)
|
||||||
|
|
||||||
|
|
||||||
|
class DatasourceStagePromoteResponse(BaseModel):
|
||||||
|
datasource: DatasourceResponse
|
||||||
|
materialization: DatasourceMaterializationResponse
|
||||||
|
|
||||||
|
|
||||||
|
class DatasourceOriginResponse(BaseModel):
|
||||||
|
ref: str
|
||||||
|
source_name: str
|
||||||
|
name: str
|
||||||
|
description: str | None
|
||||||
|
kind: DatasourceKindValue
|
||||||
|
shape: DatasourceShapeValue
|
||||||
|
supported_modes: list[DatasourceModeValue]
|
||||||
|
provider: str
|
||||||
|
fields: list[DatasourceFieldResponse] = Field(alias="schema")
|
||||||
|
schema_version: str
|
||||||
|
fingerprint: str
|
||||||
|
row_count: int | None
|
||||||
|
byte_count: int | None
|
||||||
|
updated_at: str | None
|
||||||
|
capabilities: list[str]
|
||||||
|
metadata: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class DatasourceOriginListResponse(BaseModel):
|
||||||
|
available: bool
|
||||||
|
origins: list[DatasourceOriginResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class DatasourceOriginRegisterRequest(BaseModel):
|
||||||
|
origin_ref: str = Field(min_length=1, max_length=500)
|
||||||
|
name: str = Field(min_length=1, max_length=300)
|
||||||
|
source_name: str = Field(min_length=1, max_length=120)
|
||||||
|
mode: Literal["live", "cached"]
|
||||||
|
description: str | None = Field(default=None, max_length=4_000)
|
||||||
|
|
||||||
|
|
||||||
|
class DatasourcePreviewResponse(BaseModel):
|
||||||
|
datasource: DatasourceResponse
|
||||||
|
rows: list[dict[str, Any]]
|
||||||
|
total_rows: int
|
||||||
|
truncated: bool
|
||||||
|
materialization: DatasourceMaterializationResponse | None
|
||||||
|
|
||||||
|
|
||||||
|
class DatasourceFreezeRequest(BaseModel):
|
||||||
|
label: str | None = Field(default=None, max_length=300)
|
||||||
|
|
||||||
|
|
||||||
|
class DatasourceRetireResponse(BaseModel):
|
||||||
|
retired: bool
|
||||||
|
datasource_ref: str
|
||||||
1111
src/govoplan_datasources/backend/service.py
Normal file
1111
src/govoplan_datasources/backend/service.py
Normal file
File diff suppressed because it is too large
Load Diff
198
src/govoplan_datasources/backend/tabular.py
Normal file
198
src/govoplan_datasources/backend/tabular.py
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from govoplan_core.core.datasources import DatasourceField, DatasourceValidationError
|
||||||
|
|
||||||
|
|
||||||
|
MAX_STAGE_ROWS = 10_000
|
||||||
|
MAX_STAGE_BYTES = 5_000_000
|
||||||
|
MAX_READ_ROWS = 500
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_rows(
|
||||||
|
rows: Sequence[Mapping[str, object]],
|
||||||
|
) -> tuple[dict[str, Any], ...]:
|
||||||
|
if len(rows) > MAX_STAGE_ROWS:
|
||||||
|
raise DatasourceValidationError(
|
||||||
|
f"Staging is limited to {MAX_STAGE_ROWS:,} rows."
|
||||||
|
)
|
||||||
|
normalized = tuple(_json_row(row) for row in rows)
|
||||||
|
if encoded_size(normalized) > MAX_STAGE_BYTES:
|
||||||
|
raise DatasourceValidationError(
|
||||||
|
f"Staging is limited to {MAX_STAGE_BYTES // 1_000_000} MB."
|
||||||
|
)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def parse_csv_rows(
|
||||||
|
csv_text: str,
|
||||||
|
*,
|
||||||
|
delimiter: str = ",",
|
||||||
|
) -> tuple[dict[str, Any], ...]:
|
||||||
|
if delimiter not in {",", ";", "\t", "|"}:
|
||||||
|
raise DatasourceValidationError("Unsupported CSV delimiter.")
|
||||||
|
if len(csv_text.encode("utf-8")) > MAX_STAGE_BYTES:
|
||||||
|
raise DatasourceValidationError(
|
||||||
|
f"Staging is limited to {MAX_STAGE_BYTES // 1_000_000} MB."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
reader = csv.reader(io.StringIO(csv_text), delimiter=delimiter)
|
||||||
|
raw_header = next(reader, None)
|
||||||
|
if not raw_header:
|
||||||
|
raise DatasourceValidationError("CSV input needs a non-empty header row.")
|
||||||
|
header = [
|
||||||
|
str(name or "").removeprefix("\ufeff").strip()
|
||||||
|
for name in raw_header
|
||||||
|
]
|
||||||
|
if any(not name for name in header):
|
||||||
|
raise DatasourceValidationError("CSV input needs a non-empty header row.")
|
||||||
|
if len(set(header)) != len(header):
|
||||||
|
raise DatasourceValidationError("CSV headers must be unique.")
|
||||||
|
rows: list[dict[str, object]] = []
|
||||||
|
for line_number, values in enumerate(reader, start=2):
|
||||||
|
if len(values) != len(header):
|
||||||
|
raise DatasourceValidationError(
|
||||||
|
f"CSV row {line_number} has {len(values)} values; expected {len(header)}."
|
||||||
|
)
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
name: _csv_value(value)
|
||||||
|
for name, value in zip(header, values, strict=True)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except csv.Error as exc:
|
||||||
|
raise DatasourceValidationError(f"CSV input is invalid: {exc}") from exc
|
||||||
|
return normalize_rows(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def infer_schema(
|
||||||
|
rows: Sequence[Mapping[str, object]],
|
||||||
|
) -> tuple[DatasourceField, ...]:
|
||||||
|
names: list[str] = []
|
||||||
|
for row in rows:
|
||||||
|
for name in row:
|
||||||
|
if name not in names:
|
||||||
|
names.append(name)
|
||||||
|
result: list[DatasourceField] = []
|
||||||
|
for name in names:
|
||||||
|
values = [row.get(name) for row in rows]
|
||||||
|
concrete = [value for value in values if value is not None]
|
||||||
|
data_type = _type_name(concrete[0]) if concrete else "unknown"
|
||||||
|
if any(_type_name(value) != data_type for value in concrete[1:]):
|
||||||
|
data_type = "mixed"
|
||||||
|
result.append(
|
||||||
|
DatasourceField(
|
||||||
|
name=name,
|
||||||
|
data_type=data_type,
|
||||||
|
nullable=len(concrete) != len(values),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(result)
|
||||||
|
|
||||||
|
|
||||||
|
def fingerprint_rows(
|
||||||
|
rows: Sequence[Mapping[str, object]],
|
||||||
|
schema: Sequence[DatasourceField],
|
||||||
|
) -> str:
|
||||||
|
payload = {
|
||||||
|
"schema": [field_payload(field) for field in schema],
|
||||||
|
"rows": [dict(row) for row in rows],
|
||||||
|
}
|
||||||
|
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str)
|
||||||
|
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def encoded_size(rows: Sequence[Mapping[str, object]]) -> int:
|
||||||
|
return len(
|
||||||
|
json.dumps(rows, sort_keys=True, separators=(",", ":"), default=str).encode(
|
||||||
|
"utf-8"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def field_payload(field: DatasourceField) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"name": field.name,
|
||||||
|
"data_type": field.data_type,
|
||||||
|
"nullable": field.nullable,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _json_row(row: Mapping[str, object]) -> dict[str, Any]:
|
||||||
|
normalized = {str(key).strip(): value for key, value in row.items()}
|
||||||
|
if not normalized or any(not key for key in normalized):
|
||||||
|
raise DatasourceValidationError("Every row needs named columns.")
|
||||||
|
try:
|
||||||
|
encoded = json.dumps(normalized, default=_json_value)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise DatasourceValidationError(
|
||||||
|
f"Datasource values must be JSON compatible: {exc}"
|
||||||
|
) from exc
|
||||||
|
return json.loads(encoded)
|
||||||
|
|
||||||
|
|
||||||
|
def _json_value(value: object) -> object:
|
||||||
|
if isinstance(value, (date, datetime, Decimal)):
|
||||||
|
return str(value)
|
||||||
|
raise TypeError(f"{type(value).__name__} is not JSON serializable")
|
||||||
|
|
||||||
|
|
||||||
|
def _type_name(value: object) -> str:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return "boolean"
|
||||||
|
if isinstance(value, int):
|
||||||
|
return "integer"
|
||||||
|
if isinstance(value, (float, Decimal)):
|
||||||
|
return "number"
|
||||||
|
if isinstance(value, str):
|
||||||
|
return "string"
|
||||||
|
if isinstance(value, list):
|
||||||
|
return "array"
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return "object"
|
||||||
|
return type(value).__name__.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _csv_value(value: str) -> object:
|
||||||
|
cleaned = value.strip()
|
||||||
|
if not cleaned:
|
||||||
|
return None
|
||||||
|
lowered = cleaned.lower()
|
||||||
|
if lowered == "true":
|
||||||
|
return True
|
||||||
|
if lowered == "false":
|
||||||
|
return False
|
||||||
|
if (
|
||||||
|
cleaned.isdigit()
|
||||||
|
and (cleaned == "0" or not cleaned.startswith("0"))
|
||||||
|
) or (
|
||||||
|
cleaned.startswith("-")
|
||||||
|
and cleaned[1:].isdigit()
|
||||||
|
and (cleaned[1:] == "0" or not cleaned[1:].startswith("0"))
|
||||||
|
):
|
||||||
|
return int(cleaned)
|
||||||
|
try:
|
||||||
|
return float(cleaned)
|
||||||
|
except ValueError:
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"MAX_READ_ROWS",
|
||||||
|
"MAX_STAGE_BYTES",
|
||||||
|
"MAX_STAGE_ROWS",
|
||||||
|
"encoded_size",
|
||||||
|
"field_payload",
|
||||||
|
"fingerprint_rows",
|
||||||
|
"infer_schema",
|
||||||
|
"normalize_rows",
|
||||||
|
"parse_csv_rows",
|
||||||
|
]
|
||||||
1
src/govoplan_datasources/py.typed
Normal file
1
src/govoplan_datasources/py.typed
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
351
tests/test_lifecycle.py
Normal file
351
tests/test_lifecycle.py
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.access import PrincipalRef
|
||||||
|
from govoplan_core.core.datasources import (
|
||||||
|
CAPABILITY_DATASOURCE_ORIGINS,
|
||||||
|
DatasourceAccessError,
|
||||||
|
DatasourceField,
|
||||||
|
DatasourceOrigin,
|
||||||
|
DatasourceOriginReadRequest,
|
||||||
|
DatasourceOriginReadResult,
|
||||||
|
DatasourceReadRequest,
|
||||||
|
DatasourceStageInput,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.base import Base, utcnow
|
||||||
|
from govoplan_datasources.backend.db.models import (
|
||||||
|
DatasourceMaterializationRecord,
|
||||||
|
DatasourceRecord,
|
||||||
|
DatasourceStageRecord,
|
||||||
|
)
|
||||||
|
from govoplan_datasources.backend.service import (
|
||||||
|
CATALOGUE_READ_SCOPE,
|
||||||
|
SOURCE_WRITE_SCOPE,
|
||||||
|
STAGE_WRITE_SCOPE,
|
||||||
|
SqlDatasourceProvider,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def principal(
|
||||||
|
tenant_id: str = "tenant-1",
|
||||||
|
*,
|
||||||
|
scopes: tuple[str, ...] = (
|
||||||
|
CATALOGUE_READ_SCOPE,
|
||||||
|
SOURCE_WRITE_SCOPE,
|
||||||
|
STAGE_WRITE_SCOPE,
|
||||||
|
),
|
||||||
|
) -> ApiPrincipal:
|
||||||
|
return ApiPrincipal(
|
||||||
|
principal=PrincipalRef(
|
||||||
|
account_id="account-1",
|
||||||
|
membership_id="membership-1",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
scopes=frozenset(scopes),
|
||||||
|
),
|
||||||
|
account=object(),
|
||||||
|
user=object(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeOriginProvider:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.rows = [{"id": 1, "name": "Initial"}]
|
||||||
|
|
||||||
|
def _origin(self) -> DatasourceOrigin:
|
||||||
|
return DatasourceOrigin(
|
||||||
|
ref="snapshot:origin-1",
|
||||||
|
source_name="connector_cases",
|
||||||
|
name="Connector cases",
|
||||||
|
kind="database",
|
||||||
|
shape="tabular",
|
||||||
|
supported_modes=("live", "cached"),
|
||||||
|
provider="connectors.test",
|
||||||
|
schema=(
|
||||||
|
DatasourceField(name="id", data_type="integer", nullable=False),
|
||||||
|
DatasourceField(name="name", data_type="string", nullable=False),
|
||||||
|
),
|
||||||
|
schema_version="1",
|
||||||
|
fingerprint=f"version-{len(self.rows)}-{self.rows[-1]['name']}",
|
||||||
|
row_count=len(self.rows),
|
||||||
|
updated_at=utcnow(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def list_origins(
|
||||||
|
self,
|
||||||
|
_session: object,
|
||||||
|
_principal: object,
|
||||||
|
*,
|
||||||
|
query: str = "",
|
||||||
|
limit: int = 100,
|
||||||
|
):
|
||||||
|
origin = self._origin()
|
||||||
|
return (origin,) if query.lower() in origin.name.lower() else ()
|
||||||
|
|
||||||
|
def get_origin(
|
||||||
|
self,
|
||||||
|
_session: object,
|
||||||
|
_principal: object,
|
||||||
|
*,
|
||||||
|
origin_ref: str,
|
||||||
|
):
|
||||||
|
return self._origin() if origin_ref == "snapshot:origin-1" else None
|
||||||
|
|
||||||
|
def read_origin(
|
||||||
|
self,
|
||||||
|
_session: object,
|
||||||
|
_principal: object,
|
||||||
|
*,
|
||||||
|
request: DatasourceOriginReadRequest,
|
||||||
|
) -> DatasourceOriginReadResult:
|
||||||
|
origin = self.get_origin(None, None, origin_ref=request.origin_ref)
|
||||||
|
if origin is None:
|
||||||
|
raise AssertionError("Unexpected origin")
|
||||||
|
rows = self.rows[request.offset : request.offset + request.limit]
|
||||||
|
return DatasourceOriginReadResult(
|
||||||
|
origin=origin,
|
||||||
|
rows=tuple(dict(row) for row in rows),
|
||||||
|
total_rows=len(self.rows),
|
||||||
|
truncated=request.offset + len(rows) < len(self.rows),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRegistry:
|
||||||
|
def __init__(self, origin_provider: FakeOriginProvider) -> None:
|
||||||
|
self.origin_provider = origin_provider
|
||||||
|
|
||||||
|
def has_capability(self, name: str) -> bool:
|
||||||
|
return name == CAPABILITY_DATASOURCE_ORIGINS
|
||||||
|
|
||||||
|
def capability(self, name: str) -> object:
|
||||||
|
if not self.has_capability(name):
|
||||||
|
raise KeyError(name)
|
||||||
|
return self.origin_provider
|
||||||
|
|
||||||
|
|
||||||
|
class DatasourceLifecycleTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(
|
||||||
|
self.engine,
|
||||||
|
tables=[
|
||||||
|
DatasourceRecord.__table__,
|
||||||
|
DatasourceMaterializationRecord.__table__,
|
||||||
|
DatasourceStageRecord.__table__,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.Session = sessionmaker(bind=self.engine)
|
||||||
|
self.session = self.Session()
|
||||||
|
self.origins = FakeOriginProvider()
|
||||||
|
self.provider = SqlDatasourceProvider(
|
||||||
|
registry=FakeRegistry(self.origins),
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
Base.metadata.drop_all(
|
||||||
|
self.engine,
|
||||||
|
tables=[
|
||||||
|
DatasourceStageRecord.__table__,
|
||||||
|
DatasourceMaterializationRecord.__table__,
|
||||||
|
DatasourceRecord.__table__,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def test_static_stage_promote_update_and_frozen_read(self) -> None:
|
||||||
|
first_stage = self.provider.create_stage(
|
||||||
|
self.session,
|
||||||
|
principal(),
|
||||||
|
stage=DatasourceStageInput(
|
||||||
|
name="Monthly cases",
|
||||||
|
source_name="monthly_cases",
|
||||||
|
kind="upload",
|
||||||
|
mode="static",
|
||||||
|
shape="tabular",
|
||||||
|
rows=({"id": 1, "status": "new"},),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
datasource, first = self.provider.promote_stage(
|
||||||
|
self.session,
|
||||||
|
principal(),
|
||||||
|
stage_ref=first_stage.ref,
|
||||||
|
)
|
||||||
|
frozen = self.provider.freeze_datasource(
|
||||||
|
self.session,
|
||||||
|
principal(),
|
||||||
|
datasource_ref=datasource.ref,
|
||||||
|
label="Import evidence",
|
||||||
|
)
|
||||||
|
second_stage = self.provider.create_stage(
|
||||||
|
self.session,
|
||||||
|
principal(),
|
||||||
|
stage=DatasourceStageInput(
|
||||||
|
name="Monthly cases",
|
||||||
|
source_name="monthly_cases",
|
||||||
|
kind="upload",
|
||||||
|
mode="static",
|
||||||
|
shape="tabular",
|
||||||
|
target_datasource_ref=datasource.ref,
|
||||||
|
rows=({"id": 2, "status": "checked", "note": "schema changed"},),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
updated, second = self.provider.promote_stage(
|
||||||
|
self.session,
|
||||||
|
principal(),
|
||||||
|
stage_ref=second_stage.ref,
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
current = self.provider.read_datasource(
|
||||||
|
self.session,
|
||||||
|
principal(),
|
||||||
|
request=DatasourceReadRequest(datasource_ref=datasource.ref),
|
||||||
|
)
|
||||||
|
frozen_result = self.provider.read_datasource(
|
||||||
|
self.session,
|
||||||
|
principal(),
|
||||||
|
request=DatasourceReadRequest(
|
||||||
|
datasource_ref=datasource.ref,
|
||||||
|
consistency="frozen",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(1, first.revision)
|
||||||
|
self.assertEqual(2, frozen.revision)
|
||||||
|
self.assertEqual(3, second.revision)
|
||||||
|
self.assertEqual(
|
||||||
|
[{"id": 2, "status": "checked", "note": "schema changed"}],
|
||||||
|
list(current.rows),
|
||||||
|
)
|
||||||
|
self.assertEqual([{"id": 1, "status": "new"}], list(frozen_result.rows))
|
||||||
|
self.assertEqual(second.ref, updated.current_materialization_ref)
|
||||||
|
self.assertEqual("2", updated.schema_version)
|
||||||
|
self.assertEqual(
|
||||||
|
"2",
|
||||||
|
current.datasource.schema_version,
|
||||||
|
)
|
||||||
|
self.assertEqual(frozen.ref, frozen_result.materialization.ref)
|
||||||
|
|
||||||
|
def test_live_reads_origin_and_cached_refresh_is_explicit(self) -> None:
|
||||||
|
live = self.provider.register_origin(
|
||||||
|
self.session,
|
||||||
|
principal(),
|
||||||
|
origin_ref="snapshot:origin-1",
|
||||||
|
name="Live cases",
|
||||||
|
source_name="live_cases",
|
||||||
|
mode="live",
|
||||||
|
)
|
||||||
|
cached = self.provider.register_origin(
|
||||||
|
self.session,
|
||||||
|
principal(),
|
||||||
|
origin_ref="snapshot:origin-1",
|
||||||
|
name="Cached cases",
|
||||||
|
source_name="cached_cases",
|
||||||
|
mode="cached",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
self.origins.rows = [
|
||||||
|
{"id": 1, "name": "Initial"},
|
||||||
|
{"id": 2, "name": "New"},
|
||||||
|
]
|
||||||
|
live_read = self.provider.read_datasource(
|
||||||
|
self.session,
|
||||||
|
principal(),
|
||||||
|
request=DatasourceReadRequest(datasource_ref=live.ref),
|
||||||
|
)
|
||||||
|
cached_before = self.provider.read_datasource(
|
||||||
|
self.session,
|
||||||
|
principal(),
|
||||||
|
request=DatasourceReadRequest(datasource_ref=cached.ref),
|
||||||
|
)
|
||||||
|
cached_live = self.provider.read_datasource(
|
||||||
|
self.session,
|
||||||
|
principal(),
|
||||||
|
request=DatasourceReadRequest(
|
||||||
|
datasource_ref=cached.ref,
|
||||||
|
consistency="live",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
refreshed, materialization = self.provider.refresh_datasource(
|
||||||
|
self.session,
|
||||||
|
principal(),
|
||||||
|
datasource_ref=cached.ref,
|
||||||
|
)
|
||||||
|
cached_after = self.provider.read_datasource(
|
||||||
|
self.session,
|
||||||
|
principal(),
|
||||||
|
request=DatasourceReadRequest(datasource_ref=cached.ref),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(2, live_read.total_rows)
|
||||||
|
self.assertEqual(1, cached_before.total_rows)
|
||||||
|
self.assertEqual(2, cached_live.total_rows)
|
||||||
|
self.assertEqual(2, cached_after.total_rows)
|
||||||
|
self.assertEqual(materialization.ref, refreshed.current_materialization_ref)
|
||||||
|
|
||||||
|
def test_tenant_and_scope_isolation(self) -> None:
|
||||||
|
stage = self.provider.create_stage(
|
||||||
|
self.session,
|
||||||
|
principal(),
|
||||||
|
stage=DatasourceStageInput(
|
||||||
|
name="Private",
|
||||||
|
source_name="private",
|
||||||
|
kind="upload",
|
||||||
|
mode="static",
|
||||||
|
shape="tabular",
|
||||||
|
rows=({"id": 1},),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
datasource, _ = self.provider.promote_stage(
|
||||||
|
self.session,
|
||||||
|
principal(),
|
||||||
|
stage_ref=stage.ref,
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
(),
|
||||||
|
self.provider.list_datasources(self.session, principal("tenant-2")),
|
||||||
|
)
|
||||||
|
self.assertIsNone(
|
||||||
|
self.provider.get_datasource(
|
||||||
|
self.session,
|
||||||
|
principal("tenant-2"),
|
||||||
|
datasource_ref=datasource.ref,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with self.assertRaises(DatasourceAccessError):
|
||||||
|
self.provider.list_datasources(
|
||||||
|
self.session,
|
||||||
|
principal(scopes=()),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_stage_writer_can_list_stages_without_catalogue_scope(self) -> None:
|
||||||
|
writer = principal(scopes=(STAGE_WRITE_SCOPE,))
|
||||||
|
stage = self.provider.create_stage(
|
||||||
|
self.session,
|
||||||
|
writer,
|
||||||
|
stage=DatasourceStageInput(
|
||||||
|
name="Pending import",
|
||||||
|
source_name="pending_import",
|
||||||
|
kind="upload",
|
||||||
|
mode="static",
|
||||||
|
shape="tabular",
|
||||||
|
rows=({"id": 1},),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
(stage.ref,),
|
||||||
|
tuple(item.ref for item in self.provider.list_stages(self.session, writer)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
32
tests/test_manifest.py
Normal file
32
tests/test_manifest.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_core.core.datasources import (
|
||||||
|
CAPABILITY_DATASOURCE_CATALOGUE,
|
||||||
|
CAPABILITY_DATASOURCE_LIFECYCLE,
|
||||||
|
)
|
||||||
|
from govoplan_datasources.backend.manifest import get_manifest
|
||||||
|
|
||||||
|
|
||||||
|
class DatasourceManifestTests(unittest.TestCase):
|
||||||
|
def test_manifest_exposes_provider_neutral_contracts(self) -> None:
|
||||||
|
manifest = get_manifest()
|
||||||
|
|
||||||
|
self.assertEqual(manifest.id, "datasources")
|
||||||
|
self.assertIn(CAPABILITY_DATASOURCE_CATALOGUE, manifest.capability_factories)
|
||||||
|
self.assertIn(CAPABILITY_DATASOURCE_LIFECYCLE, manifest.capability_factories)
|
||||||
|
self.assertIn(
|
||||||
|
"datasources.materializations",
|
||||||
|
{item.name for item in manifest.provides_interfaces},
|
||||||
|
)
|
||||||
|
requirement = next(
|
||||||
|
item
|
||||||
|
for item in manifest.requires_interfaces
|
||||||
|
if item.name == "connectors.datasource_origins"
|
||||||
|
)
|
||||||
|
self.assertTrue(requirement.optional)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
47
tests/test_migrations.py
Normal file
47
tests/test_migrations.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from alembic.runtime.migration import MigrationContext
|
||||||
|
from sqlalchemy import create_engine, inspect
|
||||||
|
|
||||||
|
from govoplan_core.db.migrations import migrate_database
|
||||||
|
from govoplan_datasources.backend.manifest import get_manifest
|
||||||
|
|
||||||
|
|
||||||
|
class DatasourceMigrationTests(unittest.TestCase):
|
||||||
|
def test_baseline_creates_datasource_tables_and_head(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-datasources-migration-") as directory:
|
||||||
|
url = f"sqlite:///{Path(directory) / 'datasources.db'}"
|
||||||
|
migrate_database(
|
||||||
|
database_url=url,
|
||||||
|
enabled_modules=("datasources",),
|
||||||
|
manifest_factories=(get_manifest,),
|
||||||
|
)
|
||||||
|
engine = create_engine(url)
|
||||||
|
try:
|
||||||
|
with engine.connect() as connection:
|
||||||
|
self.assertIn(
|
||||||
|
"f3a8d2c7b1e0",
|
||||||
|
set(MigrationContext.configure(connection).get_current_heads()),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"datasource_catalogue",
|
||||||
|
"datasource_materializations",
|
||||||
|
"datasource_stages",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name
|
||||||
|
for name in inspect(connection).get_table_names()
|
||||||
|
if name.startswith("datasource_")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
31
tests/test_tabular.py
Normal file
31
tests/test_tabular.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_core.core.datasources import DatasourceValidationError
|
||||||
|
from govoplan_datasources.backend.tabular import (
|
||||||
|
fingerprint_rows,
|
||||||
|
infer_schema,
|
||||||
|
parse_csv_rows,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DatasourceTabularTests(unittest.TestCase):
|
||||||
|
def test_csv_schema_and_fingerprint_are_stable(self) -> None:
|
||||||
|
rows = parse_csv_rows("id;name\n1;Ada\n2;\n", delimiter=";")
|
||||||
|
schema = infer_schema(rows)
|
||||||
|
|
||||||
|
self.assertEqual(["id", "name"], [field.name for field in schema])
|
||||||
|
self.assertTrue(schema[1].nullable)
|
||||||
|
self.assertEqual(
|
||||||
|
fingerprint_rows(rows, schema),
|
||||||
|
fingerprint_rows(rows, schema),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_csv_requires_headers(self) -> None:
|
||||||
|
with self.assertRaises(DatasourceValidationError):
|
||||||
|
parse_csv_rows("", delimiter=",")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
32
webui/package.json
Normal file
32
webui/package.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "@govoplan/datasources-webui",
|
||||||
|
"version": "0.1.14",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"module": "src/index.ts",
|
||||||
|
"types": "src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"import": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"./styles/datasources.css": "./src/styles/datasources.css"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@govoplan/core-webui": "^0.1.14",
|
||||||
|
"lucide-react": "^1.23.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.1.1",
|
||||||
|
"typescript": "^5.7.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@govoplan/core-webui": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
231
webui/src/api/datasources.ts
Normal file
231
webui/src/api/datasources.ts
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import {
|
||||||
|
apiFetch,
|
||||||
|
type ApiSettings
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
export type DatasourceMode = "live" | "cached" | "static";
|
||||||
|
export type DatasourceShape = "tabular" | "document" | "binary" | "directory" | "stream";
|
||||||
|
|
||||||
|
export type DatasourceField = {
|
||||||
|
name: string;
|
||||||
|
data_type: string;
|
||||||
|
nullable: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Datasource = {
|
||||||
|
ref: string;
|
||||||
|
source_name: string;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
kind: string;
|
||||||
|
mode: DatasourceMode;
|
||||||
|
shape: DatasourceShape;
|
||||||
|
status: string;
|
||||||
|
provider?: string | null;
|
||||||
|
provider_ref?: string | null;
|
||||||
|
schema: DatasourceField[];
|
||||||
|
schema_version: string;
|
||||||
|
fingerprint: string;
|
||||||
|
current_materialization_ref?: string | null;
|
||||||
|
row_count?: number | null;
|
||||||
|
byte_count?: number | null;
|
||||||
|
updated_at?: string | null;
|
||||||
|
capabilities: string[];
|
||||||
|
provenance: Record<string, unknown>;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DatasourceMaterialization = {
|
||||||
|
ref: string;
|
||||||
|
datasource_ref: string;
|
||||||
|
revision: number;
|
||||||
|
state: string;
|
||||||
|
fingerprint: string;
|
||||||
|
schema: DatasourceField[];
|
||||||
|
row_count?: number | null;
|
||||||
|
byte_count?: number | null;
|
||||||
|
frozen_at?: string | null;
|
||||||
|
frozen_label?: string | null;
|
||||||
|
source_timestamp?: string | null;
|
||||||
|
created_at?: string | null;
|
||||||
|
provenance: Record<string, unknown>;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DatasourceStage = {
|
||||||
|
ref: string;
|
||||||
|
name: string;
|
||||||
|
source_name: string;
|
||||||
|
kind: string;
|
||||||
|
mode: "static" | "cached";
|
||||||
|
shape: DatasourceShape;
|
||||||
|
state: string;
|
||||||
|
target_datasource_ref?: string | null;
|
||||||
|
fingerprint: string;
|
||||||
|
schema: DatasourceField[];
|
||||||
|
row_count?: number | null;
|
||||||
|
byte_count?: number | null;
|
||||||
|
validation: Record<string, unknown>;
|
||||||
|
created_at?: string | null;
|
||||||
|
promoted_at?: string | null;
|
||||||
|
promoted_materialization_ref?: string | null;
|
||||||
|
provenance: Record<string, unknown>;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DatasourceOrigin = {
|
||||||
|
ref: string;
|
||||||
|
source_name: string;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
kind: string;
|
||||||
|
shape: DatasourceShape;
|
||||||
|
supported_modes: DatasourceMode[];
|
||||||
|
provider: string;
|
||||||
|
schema: DatasourceField[];
|
||||||
|
schema_version: string;
|
||||||
|
fingerprint: string;
|
||||||
|
row_count?: number | null;
|
||||||
|
byte_count?: number | null;
|
||||||
|
updated_at?: string | null;
|
||||||
|
capabilities: string[];
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DatasourcePreview = {
|
||||||
|
datasource: Datasource;
|
||||||
|
rows: Record<string, unknown>[];
|
||||||
|
total_rows: number;
|
||||||
|
truncated: boolean;
|
||||||
|
materialization?: DatasourceMaterialization | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listDatasources(
|
||||||
|
settings: ApiSettings,
|
||||||
|
query = ""
|
||||||
|
): Promise<Datasource[]> {
|
||||||
|
const suffix = query.trim() ? `?query=${encodeURIComponent(query.trim())}` : "";
|
||||||
|
const response = await apiFetch<{ datasources: Datasource[] }>(
|
||||||
|
settings,
|
||||||
|
`/api/v1/datasources${suffix}`
|
||||||
|
);
|
||||||
|
return response.datasources;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listDatasourceStages(
|
||||||
|
settings: ApiSettings
|
||||||
|
): Promise<DatasourceStage[]> {
|
||||||
|
const response = await apiFetch<{ stages: DatasourceStage[] }>(
|
||||||
|
settings,
|
||||||
|
"/api/v1/datasources/stages"
|
||||||
|
);
|
||||||
|
return response.stages;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listDatasourceOrigins(
|
||||||
|
settings: ApiSettings
|
||||||
|
): Promise<{ available: boolean; origins: DatasourceOrigin[] }> {
|
||||||
|
return apiFetch(settings, "/api/v1/datasources/origins");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listDatasourceMaterializations(
|
||||||
|
settings: ApiSettings,
|
||||||
|
datasourceRef: string
|
||||||
|
): Promise<DatasourceMaterialization[]> {
|
||||||
|
const response = await apiFetch<{ materializations: DatasourceMaterialization[] }>(
|
||||||
|
settings,
|
||||||
|
`/api/v1/datasources/${refId(datasourceRef)}/materializations`
|
||||||
|
);
|
||||||
|
return response.materializations;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function previewDatasource(
|
||||||
|
settings: ApiSettings,
|
||||||
|
datasourceRef: string,
|
||||||
|
consistency: "current" | "live" | "frozen" = "current"
|
||||||
|
): Promise<DatasourcePreview> {
|
||||||
|
return apiFetch(
|
||||||
|
settings,
|
||||||
|
`/api/v1/datasources/${refId(datasourceRef)}/preview?limit=100&consistency=${consistency}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDatasourceStage(
|
||||||
|
settings: ApiSettings,
|
||||||
|
payload: {
|
||||||
|
name: string;
|
||||||
|
source_name: string;
|
||||||
|
description?: string | null;
|
||||||
|
mode: "static" | "cached";
|
||||||
|
target_datasource_ref?: string | null;
|
||||||
|
} & (
|
||||||
|
{ format: "json"; rows: Record<string, unknown>[] }
|
||||||
|
| { format: "csv"; csv_text: string; delimiter: string }
|
||||||
|
)
|
||||||
|
): Promise<DatasourceStage> {
|
||||||
|
return apiFetch(settings, "/api/v1/datasources/stages", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function promoteDatasourceStage(
|
||||||
|
settings: ApiSettings,
|
||||||
|
stageRef: string,
|
||||||
|
payload: { freeze?: boolean; frozen_label?: string | null } = {}
|
||||||
|
): Promise<{ datasource: Datasource; materialization: DatasourceMaterialization }> {
|
||||||
|
return apiFetch(settings, `/api/v1/datasources/stages/${refId(stageRef)}/promote`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerDatasourceOrigin(
|
||||||
|
settings: ApiSettings,
|
||||||
|
payload: {
|
||||||
|
origin_ref: string;
|
||||||
|
name: string;
|
||||||
|
source_name: string;
|
||||||
|
mode: "live" | "cached";
|
||||||
|
description?: string | null;
|
||||||
|
}
|
||||||
|
): Promise<Datasource> {
|
||||||
|
return apiFetch(settings, "/api/v1/datasources/origins/register", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function refreshDatasource(
|
||||||
|
settings: ApiSettings,
|
||||||
|
datasourceRef: string
|
||||||
|
): Promise<{ datasource: Datasource; materialization: DatasourceMaterialization }> {
|
||||||
|
return apiFetch(settings, `/api/v1/datasources/${refId(datasourceRef)}/refresh`, {
|
||||||
|
method: "POST"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function freezeDatasource(
|
||||||
|
settings: ApiSettings,
|
||||||
|
datasourceRef: string,
|
||||||
|
label: string
|
||||||
|
): Promise<DatasourceMaterialization> {
|
||||||
|
return apiFetch(settings, `/api/v1/datasources/${refId(datasourceRef)}/freeze`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ label: label.trim() || null })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function retireDatasource(
|
||||||
|
settings: ApiSettings,
|
||||||
|
datasourceRef: string
|
||||||
|
): Promise<{ retired: boolean; datasource_ref: string }> {
|
||||||
|
return apiFetch(settings, `/api/v1/datasources/${refId(datasourceRef)}`, {
|
||||||
|
method: "DELETE"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function refId(ref: string): string {
|
||||||
|
const separator = ref.indexOf(":");
|
||||||
|
return encodeURIComponent(separator >= 0 ? ref.slice(separator + 1) : ref);
|
||||||
|
}
|
||||||
1122
webui/src/features/datasources/DatasourcesPage.tsx
Normal file
1122
webui/src/features/datasources/DatasourcesPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
2
webui/src/index.ts
Normal file
2
webui/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { datasourcesModule as default, datasourcesModule } from "./module";
|
||||||
|
export * from "./api/datasources";
|
||||||
40
webui/src/module.ts
Normal file
40
webui/src/module.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { createElement, lazy } from "react";
|
||||||
|
import type { PlatformWebModule } from "@govoplan/core-webui";
|
||||||
|
import "./styles/datasources.css";
|
||||||
|
|
||||||
|
const DatasourcesPage = lazy(() => import("./features/datasources/DatasourcesPage"));
|
||||||
|
|
||||||
|
const readScopes = ["datasources:catalogue:read", "datasources:source:admin"];
|
||||||
|
|
||||||
|
export const datasourcesModule: PlatformWebModule = {
|
||||||
|
id: "datasources",
|
||||||
|
label: "Datasources",
|
||||||
|
version: "0.1.14",
|
||||||
|
optionalDependencies: [
|
||||||
|
"access",
|
||||||
|
"audit",
|
||||||
|
"connectors",
|
||||||
|
"files",
|
||||||
|
"notifications",
|
||||||
|
"policy"
|
||||||
|
],
|
||||||
|
navItems: [
|
||||||
|
{
|
||||||
|
to: "/datasources",
|
||||||
|
label: "Datasources",
|
||||||
|
iconName: "database-zap",
|
||||||
|
anyOf: readScopes,
|
||||||
|
order: 70
|
||||||
|
}
|
||||||
|
],
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: "/datasources",
|
||||||
|
anyOf: readScopes,
|
||||||
|
order: 70,
|
||||||
|
render: ({ settings, auth }) => createElement(DatasourcesPage, { settings, auth })
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
export default datasourcesModule;
|
||||||
478
webui/src/styles/datasources.css
Normal file
478
webui/src/styles/datasources.css
Normal file
@@ -0,0 +1,478 @@
|
|||||||
|
.datasources-page {
|
||||||
|
position: relative;
|
||||||
|
height: calc(100vh - 115px);
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-page *,
|
||||||
|
.datasources-page *::before,
|
||||||
|
.datasources-page *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(250px, 300px) minmax(0, 1fr);
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border: var(--border-line);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-sidebar,
|
||||||
|
.datasources-workspace,
|
||||||
|
.datasources-content,
|
||||||
|
.datasources-list-frame {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
border-right: var(--border-line);
|
||||||
|
background: var(--panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-sidebar-toolbar,
|
||||||
|
.datasources-workspace-toolbar,
|
||||||
|
.datasources-section-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
background: var(--panel-header);
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-sidebar-toolbar {
|
||||||
|
min-height: 52px;
|
||||||
|
padding: 8px 10px 8px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-toolbar-actions,
|
||||||
|
.datasources-current-title,
|
||||||
|
.datasources-current-title > span,
|
||||||
|
.datasources-section-heading > span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-view-switch {
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-search {
|
||||||
|
padding: 9px;
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-search input {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-list-frame {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-list {
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-list > button {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 56px;
|
||||||
|
padding: 8px 9px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-list > button:hover,
|
||||||
|
.datasources-list > button:focus-visible {
|
||||||
|
background: var(--primary-soft);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-list > button.is-selected {
|
||||||
|
background: var(--primary-soft-strong);
|
||||||
|
box-shadow: inset 3px 0 0 var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-list > button > span:first-child {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-list strong,
|
||||||
|
.datasources-list small {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-list strong {
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-list small {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-list-empty,
|
||||||
|
.datasources-inline-empty,
|
||||||
|
.datasources-inline-loading {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 110px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-workspace {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-workspace-toolbar {
|
||||||
|
min-height: 58px;
|
||||||
|
padding: 8px 10px 8px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-current-title {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-current-title > span {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-current-title strong,
|
||||||
|
.datasources-current-title small {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-current-title strong {
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-current-title small {
|
||||||
|
margin-top: 2px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-alerts {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-alerts:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-alerts .alert {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-content {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(100px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-metrics > span {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 61px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border: var(--border-line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-metrics small,
|
||||||
|
.datasources-metrics strong {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-metrics small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-metrics strong {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: 14px;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-description {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
background: var(--panel-soft);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-detail-section {
|
||||||
|
min-width: 0;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
border: var(--border-line);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-section-heading {
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-section-heading > span {
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-section-heading small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-table-scroll {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-preview-table {
|
||||||
|
max-height: 310px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-table-scroll table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-table-scroll th,
|
||||||
|
.datasources-table-scroll td {
|
||||||
|
min-width: 100px;
|
||||||
|
max-width: 320px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-right: var(--border-line);
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
overflow: hidden;
|
||||||
|
text-align: left;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-table-scroll th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
background: var(--panel-header);
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-table-scroll tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-fingerprint {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-key-values {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-key-values > span {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 12px;
|
||||||
|
border-right: var(--border-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-key-values > span:last-child {
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-key-values small,
|
||||||
|
.datasources-key-values strong {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-key-values small {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-key-values strong {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-workspace-empty {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
align-content: center;
|
||||||
|
min-height: 100%;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-working {
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
bottom: 12px;
|
||||||
|
z-index: 4;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: var(--border-line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--panel);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-add-dialog {
|
||||||
|
width: min(760px, calc(100vw - 32px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-dialog-fields {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-dialog-fields textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 180px;
|
||||||
|
resize: vertical;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-dialog-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-dialog-copy {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.datasources-page {
|
||||||
|
height: auto;
|
||||||
|
min-height: calc(100vh - 92px);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
height: auto;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-sidebar {
|
||||||
|
max-height: 42vh;
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-workspace {
|
||||||
|
min-height: 58vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-metrics {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-workspace-toolbar {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-toolbar-actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.datasources-metrics,
|
||||||
|
.datasources-key-values,
|
||||||
|
.datasources-dialog-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-key-values > span {
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-key-values > span:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
webui/src/vite-env.d.ts
vendored
Normal file
1
webui/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference path="../../../govoplan-core/webui/src/vite-env.d.ts" />
|
||||||
31
webui/tsconfig.json
Normal file
31
webui/tsconfig.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ES2020"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"preserveSymlinks": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@govoplan/core-webui": ["../../govoplan-core/webui/src/index.ts"],
|
||||||
|
"@govoplan/core-webui/*": ["../../govoplan-core/webui/src/*"],
|
||||||
|
"lucide-react": ["../../govoplan-core/webui/node_modules/lucide-react/dist/lucide-react.d.ts"],
|
||||||
|
"react": ["../../govoplan-core/webui/node_modules/@types/react/index.d.ts"],
|
||||||
|
"react/jsx-runtime": ["../../govoplan-core/webui/node_modules/@types/react/jsx-runtime.d.ts"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user