mirror of
https://github.com/apache/superset.git
synced 2026-08-26 01:51:15 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4df7ca02c | ||
|
|
f7503213e2 |
@@ -53,6 +53,12 @@ jobs:
|
||||
python-version: ${{ github.event_name == 'pull_request' && fromJSON('["current"]') || fromJSON('["current", "next"]') }}
|
||||
env:
|
||||
PYTHONPATH: ${{ github.workspace }}
|
||||
# Promotes the SQLAlchemy 2.0 deprecation warnings already locked in as
|
||||
# errors via pytest.ini's `filterwarnings` to actually run in CI, so a
|
||||
# regression on those fails the build instead of relying on a
|
||||
# contributor remembering to set this locally. See the migration
|
||||
# battleplan: https://github.com/apache/superset/discussions/40273
|
||||
SQLALCHEMY_WARN_20: "1"
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
+12
-18
@@ -58,7 +58,6 @@ the old counter to use the outcome-specific replacements.
|
||||
|
||||
- [42930](https://github.com/apache/superset/pull/42930): Dataset import data-URI fetches no longer honor an HTTP(S) proxy when `DATASET_IMPORT_ALLOW_INTERNAL_DATA_URLS` is `False` (the default): the connection is now made directly to the destination so the peer-address check validates the real target instead of a proxy's. Deployments that require an egress proxy to reach legitimate external data URLs for dataset import should set `DATASET_IMPORT_ALLOW_INTERNAL_DATA_URLS = True` or otherwise ensure those URLs resolve without one.
|
||||
- [42935](https://github.com/apache/superset/pull/42935): The MCP service now refuses to start (`MCPAuthConfigError`) when `MCP_JWT_ISSUER` trusts more than one issuer and no `MCP_USER_RESOLVER` is configured, instead of only logging a warning. This was already a documented misconfiguration (the default resolver isn't issuer-scoped, so distinct trusted issuers minting the same username/email would resolve to the same Superset user); deployments trusting multiple issuers must configure an `MCP_USER_RESOLVER` that derives its identity from the token's `iss` claim before upgrading. Single-issuer deployments are unaffected.
|
||||
- [42429](https://github.com/apache/superset/pull/42429): The Country Map chart's Iran GeoJSON now gives Alborz province its own ISO 3166-2 code, `IR-32`, instead of `IR-30`. `ISO` is the join key used to color/filter provinces on this chart, so any existing dataset keyed on `IR-30` for Alborz will silently stop matching after upgrading; re-key that data to `IR-32`.
|
||||
- [43388](https://github.com/apache/superset/pull/43388): The MCP service now refuses to start (`MCPAuthConfigError`) if `MCP_DEV_USERNAME` and `MCP_AUTH_ENABLED = True` are both set, and separately if `MCP_AUTH_ENABLED = True` but no usable JWT key material is configured (RSA key/JWKS, or an explicit `MCP_JWT_SECRET` for HMAC) — both previously started with authentication silently weaker than configured. Deployments combining a dev-mode username with JWT auth enabled, or enabling JWT auth without key material, must pick one before upgrading: unset `MCP_DEV_USERNAME` for a real auth deployment, or unset `MCP_AUTH_ENABLED` (or configure the key material) for a dev-mode one. Response caching (`MCP_CACHE_CONFIG["enabled"] = True`) now also excludes every tool with a side effect by default, not only a partial list, so a previously-cached mutating tool call is no longer served from cache; no config change is needed to pick this up.
|
||||
- [42393](https://github.com/apache/superset/pull/42393): Exported dataset YAML now carries a `uuid` for each metric and column so that custom folder assignments (which reference metrics/columns by UUID) survive an import into another workspace. This affects any export bundle that contains datasets, not just a dataset export: chart, dashboard, database and full-asset exports all embed the same dataset YAML, so a dashboard exported from this release also fails to import into an older one even though no dataset was exported directly. As with `folders` and `currency_code_column`, the affected `datasets/` files fail schema validation (`Unknown field: uuid`) when imported into Superset releases that predate this change; regenerate or hand-edit exports for older targets in mixed-version fleets.
|
||||
- [42300](https://github.com/apache/superset/pull/42300): Timeseries charts (line/area/bar) with a Y-axis bound in effect — either an explicit `yAxisBounds` or one derived from `truncateYAxis` — now clamp out-of-range data points to that bound instead of letting ECharts drop the point (and the line segments around it) entirely. Any existing chart with a configured Y-axis bound and data outside it will look different after upgrading: a gap becomes a point pinned to the boundary. The clamp also rewrites the value ECharts reads for that point's tooltip and data label, so the displayed value is the bound rather than the true observation.
|
||||
@@ -185,12 +184,10 @@ misrepresents the entity as unchanged.
|
||||
- **Storage growth.** Capture writes shadow rows per save, so the metadata
|
||||
database grows with edit volume. The `version_history.prune_old_versions`
|
||||
beat task removes rows whose transaction is older than
|
||||
`SUPERSET_VERSION_HISTORY_RETENTION_DAYS` (default 30).
|
||||
- **Check a replaced `CELERY_CONFIG`.** Carry both the
|
||||
`superset.tasks.version_history_retention` import and the
|
||||
`version_history.prune_old_versions` beat entry; see
|
||||
[Version-history retention (pruning)](#version-history-retention-pruning) for
|
||||
the startup-warning behavior.
|
||||
`SUPERSET_VERSION_HISTORY_RETENTION_DAYS` (default 30). A deployment that
|
||||
replaces `CELERY_CONFIG` rather than inheriting it must carry both the
|
||||
`superset.tasks.version_history_retention` import and the beat entry; a
|
||||
startup warning names whichever is absent.
|
||||
- **`PUT` responses change shape.** Entity updates now return populated
|
||||
`old_version_uuid` / `new_version_uuid` fields and an `ETag` header, which
|
||||
were null or absent while capture was off.
|
||||
@@ -199,10 +196,7 @@ misrepresents the entity as unchanged.
|
||||
kill-switch — not removed with the rollout toggles. Setting it to a falsy value
|
||||
stops capture within a restart, without a revert-and-redeploy. Unlike the
|
||||
soft-delete toggle, turning it off is a clean stop: existing version rows remain
|
||||
readable and no entity state is altered. Restore is unavailable (404) while
|
||||
capture is off. A full rollback also sets
|
||||
`FEATURE_FLAGS = {"VERSION_HISTORY": False}` to hide the panel — capture off
|
||||
with the panel left on shows an empty or stale history.
|
||||
readable and no entity state is altered.
|
||||
|
||||
### Scheduled report execution now enforces one application deadline
|
||||
|
||||
@@ -664,9 +658,9 @@ ALTER TABLE tagged_object DROP CONSTRAINT <constraint_name>;
|
||||
ALTER TABLE tagged_object DROP FOREIGN KEY <constraint_name>;
|
||||
```
|
||||
|
||||
### Entity version-history infrastructure
|
||||
### Entity version-history infrastructure (gated off by default)
|
||||
|
||||
Introduces the schema and SQLAlchemy-Continuum wiring that captures version history for charts, dashboards, and datasets, plus read-only `GET /api/v1/{chart,dashboard,dataset}/<uuid>/versions/` endpoints. Capture is governed by the `ENABLE_VERSIONING_CAPTURE` config value — an operational kill-switch (a release toggle that became a permanent ops switch), not a feature flag; see "Version history is on by default" above for the shipped default. With capture off, no save writes version rows; the endpoints continue to serve already-captured rows read-only. The migration is additive; existing entity `PUT` responses gain `old_version_uuid` / `new_version_uuid` body fields and an `ETag` header (both null/absent when capture is off).
|
||||
Introduces the schema and SQLAlchemy-Continuum wiring that captures version history for charts, dashboards, and datasets, plus read-only `GET /api/v1/{chart,dashboard,dataset}/<uuid>/versions/` endpoints. This ships **inert**: a new config flag `ENABLE_VERSIONING_CAPTURE` defaults to `False`, so no save writes any version rows and the endpoints return empty. It is an operational kill-switch (a release toggle that becomes a permanent ops switch), not a feature flag — set it to `True` to enable capture once validated. The migration is additive; existing entity `PUT` responses gain `old_version_uuid` / `new_version_uuid` body fields and an `ETag` header (both null/absent when capture is off).
|
||||
|
||||
A few save- and import-path internals change **unconditionally** (independent of the flag), because the versioned mappers must behave correctly whether or not capture is enabled:
|
||||
|
||||
@@ -687,7 +681,7 @@ A read-only companion to the version-history endpoints: each entity type gains a
|
||||
| `q` | string | — | Case-insensitive search over the full history, applied before pagination (so `count` reflects matches) |
|
||||
| `page` / `page_size` | integer | `0` / `25` | Pagination (`page_size` clamped to 200) |
|
||||
|
||||
Authorization reuses the resource's `can_read` permission and per-object `raise_for_access`; related-entity rows are visibility-filtered to what the caller may see. The stream reflects captured history; with capture off it remains readable but stops accruing new entries.
|
||||
Authorization reuses the resource's `can_read` permission and per-object `raise_for_access`; related-entity rows are visibility-filtered to what the caller may see. The stream is empty unless version capture is on (`ENABLE_VERSIONING_CAPTURE`).
|
||||
|
||||
### Version-history retention (pruning)
|
||||
|
||||
@@ -707,7 +701,7 @@ Purging is **live by default** (`SOFT_DELETE_PURGE_DRY_RUN=False`), so the reten
|
||||
|
||||
Deployments that replace the default `CELERY_CONFIG` must ensure workers register `superset.tasks.deletion_retention` and schedule the `deletion_retention.purge_soft_deleted` task themselves. The shipped Docker development config uses `imports` and includes both entries. While `SOFT_DELETE` is statically enabled, a missing beat entry logs a startup warning; when the override explicitly defines `imports`, a missing purge module is also reported.
|
||||
|
||||
Operators can immediately erase a specific entity for compliance (GDPR) via `superset deletion-retention force-purge --uuid <uuid>`; this applies legacy hard-delete semantics — a live chart referencing a force-purged dataset is left without a datasource until re-pointed (the chart is not modified), and it purges the named entity even when it was never soft-deleted. Every scheduled evaluation writes a provisional, content-free record to the new `purge_audit_log` table before the cascade starts. Meaningful retained outcomes survive the entity they name. Blocked audit records carry a stable machine-readable `reason` code (`report_schedule`, `user_attribute`, or `cascade_integrity_failure` for an unexpected cascade failure caused by a database integrity constraint) so the audit table alone answers why an entity was not purged; records finalized before the column existed keep a NULL reason. Apply the migration before rolling out the new code: the audit model declares the column, so a worker on the new code with an un-migrated table fails its write-ahead write and the scheduled purge fails closed until the migration lands. During a rolling deploy, workers still on the old code write reason-less blocked rows and suppress on status alone; both effects are self-healing, since a NULL-reason record never matches a reason code and the next all-new-code run re-anchors the entity. Consecutive scheduled evaluations blocked with the same status **and reason** suppress only the redundant current provisional record — a reason change writes one new blocked record carrying the new code; completed outcomes, outcome transitions, and every force-purge attempt remain independent and immutable. Retained transition records are not automatically expired, so entities whose block reason changes repeatedly can accumulate multiple audit rows. The **scheduled** purge fails closed when its provisional record cannot be written, while **force-purge** proceeds even if the audit write fails — the operator is present and deletion outranks audit for a compliance erasure. Operators can monitor `deletion_retention.blocked_audit_suppressed` and `deletion_retention.blocked_audit_dedupe_fallback` to verify suppression and fail-safe fallback behavior without changing the existing blocked-workload gauge.
|
||||
Operators can immediately erase a specific entity for compliance (GDPR) via `superset deletion-retention force-purge --uuid <uuid>`; this applies legacy hard-delete semantics — a live chart referencing a force-purged dataset is left without a datasource until re-pointed (the chart is not modified), and it purges the named entity even when it was never soft-deleted. Every scheduled evaluation writes a provisional, content-free record to the new `purge_audit_log` table before the cascade starts. Meaningful retained outcomes survive the entity they name. Consecutive scheduled evaluations with the same blocked outcome suppress only the redundant current provisional record; completed outcomes, outcome transitions, and every force-purge attempt remain independent and immutable. The **scheduled** purge fails closed when its provisional record cannot be written, while **force-purge** proceeds even if the audit write fails — the operator is present and deletion outranks audit for a compliance erasure. Operators can monitor `deletion_retention.blocked_audit_suppressed` and `deletion_retention.blocked_audit_dedupe_fallback` to verify suppression and fail-safe fallback behavior without changing the existing blocked-workload gauge.
|
||||
|
||||
### Recently Archived view and permanent delete (purge) endpoints
|
||||
|
||||
@@ -903,7 +897,7 @@ The migration is transactional (all-or-nothing) and idempotent — it can be saf
|
||||
|
||||
### Soft delete and restore for datasets
|
||||
|
||||
**The soft-delete behavior in this section applies only when the `SOFT_DELETE` feature flag is enabled. The flag defaults to `True`** (`@lifecycle: testing`), so on a default deployment `DELETE /api/v1/dataset/<id>` uses the recoverable soft-delete behavior described below. Setting `SOFT_DELETE` to `False` restores legacy permanent hard-delete behavior for subsequent deletes.
|
||||
**The soft-delete behavior in this section applies only when the `SOFT_DELETE` feature flag is enabled. The flag defaults to `False`** (`@lifecycle: development`), so on a default deployment `DELETE /api/v1/dataset/<id>` continues to **hard-delete permanently** — nothing is recoverable. Enable `SOFT_DELETE` to get the behavior described below.
|
||||
|
||||
**Flag-toggle caveat:** the soft-delete visibility filter is evaluated per query while the flag is on. If datasets are soft-deleted during a flag-on window and the flag is later turned **off**, those rows reappear as live datasets in all lists, lookups, and relationship loads (including charts that reference them). The `POST /<uuid>/restore` endpoint and the `dataset_deleted_state` list filter remain functional regardless of the flag, deliberately, so rows soft-deleted during a flag-on window stay discoverable and restorable after a rollback of the flag.
|
||||
|
||||
@@ -933,7 +927,7 @@ With the flag enabled: `DELETE /api/v1/dataset/<id>` no longer hard-deletes the
|
||||
|
||||
### Soft delete and restore for charts
|
||||
|
||||
**Everything in this section applies only when the `SOFT_DELETE` feature flag is enabled. The flag defaults to `True`** (`@lifecycle: testing`), so on a default deployment `DELETE /api/v1/chart/<id>` uses the recoverable soft-delete behavior described below. Setting `SOFT_DELETE` to `False` restores legacy permanent hard-delete behavior for subsequent deletes.
|
||||
**Everything in this section applies only when the `SOFT_DELETE` feature flag is enabled. The flag defaults to `False`** (`@lifecycle: development`), so on a default deployment `DELETE /api/v1/chart/<id>` continues to **hard-delete permanently** — nothing is recoverable. Enable `SOFT_DELETE` to get the behavior described below.
|
||||
|
||||
**Flag-toggle caveat:** the soft-delete visibility filter is evaluated per query while the flag is on. If charts are soft-deleted during a flag-on window and the flag is later turned **off**, those rows reappear as live charts in all lists, lookups, and relationship loads (including dashboards that contained them). The `POST /<uuid>/restore` endpoint and the `chart_deleted_state` list filter remain functional regardless of the flag, deliberately, so rows soft-deleted during a flag-on window stay discoverable and restorable after a rollback of the flag.
|
||||
|
||||
@@ -957,7 +951,7 @@ With the flag enabled: `DELETE /api/v1/chart/<id>` no longer hard-deletes the ch
|
||||
|
||||
### Soft delete and restore for dashboards
|
||||
|
||||
**Everything in this section applies only when the `SOFT_DELETE` feature flag is enabled. The flag defaults to `True`** (`@lifecycle: testing`), so on a default deployment `DELETE /api/v1/dashboard/<id>` uses the recoverable soft-delete behavior described below. Setting `SOFT_DELETE` to `False` restores legacy permanent hard-delete behavior for subsequent deletes.
|
||||
**Everything in this section applies only when the `SOFT_DELETE` feature flag is enabled. The flag defaults to `False`** (`@lifecycle: development`), so on a default deployment `DELETE /api/v1/dashboard/<id>` continues to **hard-delete permanently** — nothing is recoverable. Enable `SOFT_DELETE` to get the behavior described below.
|
||||
|
||||
**Flag-toggle caveat:** the soft-delete visibility filter is evaluated per query while the flag is on. If dashboards are soft-deleted during a flag-on window and the flag is later turned **off**, those rows reappear as live dashboards in all lists and lookups (including slug lookups — if a soft-deleted dashboard's slug was reused while the flag was on, both rows become visible with the same slug). The `POST /<uuid>/restore` endpoint and the `dashboard_deleted_state` list filter remain functional regardless of the flag, deliberately, so rows soft-deleted during a flag-on window stay discoverable and restorable after a rollback of the flag.
|
||||
|
||||
|
||||
@@ -86,7 +86,6 @@
|
||||
"Israel",
|
||||
"Italy",
|
||||
"Italy (regions)",
|
||||
"Italy (regions and autonomous provinces)",
|
||||
"Ivory Coast",
|
||||
"Japan",
|
||||
"Jordan",
|
||||
|
||||
@@ -15,29 +15,29 @@ description of what changed — "Chart renamed to Q3 Revenue", "Added filter on
|
||||
'Region'" — rather than a raw diff. You can search the history and filter it
|
||||
down to changes on the entity itself or on the things it depends on.
|
||||
|
||||
## Enabling and disabling it
|
||||
## Enabling it
|
||||
|
||||
Two switches are involved, and both matter.
|
||||
|
||||
| Setting | Type | Effect |
|
||||
| --- | --- | --- |
|
||||
| `VERSION_HISTORY` | Feature flag | Shows the version history UI |
|
||||
| `ENABLE_VERSIONING_CAPTURE` | Config value | Records versions as entities are saved |
|
||||
|
||||
Both default to on. To turn the feature off:
|
||||
|
||||
```python
|
||||
# superset_config.py
|
||||
FEATURE_FLAGS = {"VERSION_HISTORY": False}
|
||||
ENABLE_VERSIONING_CAPTURE = False
|
||||
FEATURE_FLAGS = {"VERSION_HISTORY": True}
|
||||
ENABLE_VERSIONING_CAPTURE = True
|
||||
```
|
||||
|
||||
Restart Superset and its workers for the capture change to take effect. Existing
|
||||
history remains readable while capture is off, but **Restore** is unavailable
|
||||
(404).
|
||||
Both default to off. They are separate because capture is the expensive half:
|
||||
an operator may want to start recording history before exposing the UI, so that
|
||||
there is something to show when they do.
|
||||
|
||||
Disable them together: capture off with the UI left on gives a panel that
|
||||
stops filling — an empty or stale history misrepresents the entity as
|
||||
unchanged. History only accrues while capture is on; edits made while it was
|
||||
off are not reconstructed.
|
||||
Turning the UI on without capture gives a panel that reports "No history yet"
|
||||
and never fills, so enable capture first — or at the same time. History only
|
||||
accrues from the moment capture is switched on; earlier edits are not
|
||||
reconstructed.
|
||||
|
||||
## Viewing history
|
||||
|
||||
|
||||
+3
-3
@@ -58,10 +58,10 @@
|
||||
"@fontsource/inter": "^5.3.0",
|
||||
"@mdx-js/react": "^3.1.1",
|
||||
"@saucelabs/theme-github-codeblock": "^0.3.0",
|
||||
"@storybook/addon-docs": "^10.5.9",
|
||||
"@storybook/addon-docs": "^10.5.8",
|
||||
"@superset-ui/core": "^0.20.4",
|
||||
"@swc/core": "^1.16.0",
|
||||
"antd": "^6.6.1",
|
||||
"antd": "^6.6.0",
|
||||
"baseline-browser-mapping": "^2.11.15",
|
||||
"caniuse-lite": "^1.0.30001809",
|
||||
"docusaurus-plugin-openapi-docs": "^5.2.0",
|
||||
@@ -77,7 +77,7 @@
|
||||
"react-table": "^7.8.0",
|
||||
"remark-import-partial": "^0.0.2",
|
||||
"reselect": "^5.2.0",
|
||||
"storybook": "^10.5.9",
|
||||
"storybook": "^10.5.8",
|
||||
"swagger-ui-react": "^5.32.13",
|
||||
"swc-loader": "^0.2.7",
|
||||
"tinycolor2": "^1.4.2",
|
||||
|
||||
Vendored
+12
-12
@@ -93,6 +93,12 @@
|
||||
"lifecycle": "development",
|
||||
"description": "Enable semantic layers and show semantic views alongside datasets"
|
||||
},
|
||||
{
|
||||
"name": "SOFT_DELETE",
|
||||
"default": true,
|
||||
"lifecycle": "development",
|
||||
"description": "Temporary rollout / kill-switch gate for soft delete (off = legacy hard delete). An emergency stop, not a clean rollback: flipping ON->OFF resurrects already-soft-deleted rows. Retained through this release as the move-back lever; removed (along with its two gate points \u2014 BaseDAO.delete routing and the do_orm_execute visibility listener) once post-flip confidence is established."
|
||||
},
|
||||
{
|
||||
"name": "TABLE_V2_TIME_COMPARISON_ENABLED",
|
||||
"default": false,
|
||||
@@ -104,6 +110,12 @@
|
||||
"default": false,
|
||||
"lifecycle": "development",
|
||||
"description": "Enables the tagging system for organizing assets"
|
||||
},
|
||||
{
|
||||
"name": "VERSION_HISTORY",
|
||||
"default": true,
|
||||
"lifecycle": "development",
|
||||
"description": "Enables the version history panel on Explore and Dashboard pages. History only accrues while ``ENABLE_VERSIONING_CAPTURE`` is also on; with capture off the panel renders but stays empty, so the two ship with matching defaults and should be changed together."
|
||||
}
|
||||
],
|
||||
"testing": [
|
||||
@@ -221,12 +233,6 @@
|
||||
"lifecycle": "testing",
|
||||
"description": "Apply RLS rules to SQL Lab queries. Requires query parsing/manipulation. May break queries or allow RLS bypass. Use with care!"
|
||||
},
|
||||
{
|
||||
"name": "SOFT_DELETE",
|
||||
"default": true,
|
||||
"lifecycle": "testing",
|
||||
"description": "Temporary rollout / kill-switch gate for soft delete (off = legacy hard delete). An emergency stop, not a clean rollback: flipping ON->OFF resurrects already-soft-deleted rows. Retained through this release as the move-back lever; removed (along with its two gate points \u2014 BaseDAO.delete routing and the do_orm_execute visibility listener) once post-flip confidence is established."
|
||||
},
|
||||
{
|
||||
"name": "SSH_TUNNELING",
|
||||
"default": false,
|
||||
@@ -239,12 +245,6 @@
|
||||
"default": false,
|
||||
"lifecycle": "testing",
|
||||
"description": "Use analogous colors in charts"
|
||||
},
|
||||
{
|
||||
"name": "VERSION_HISTORY",
|
||||
"default": true,
|
||||
"lifecycle": "testing",
|
||||
"description": "Enables the version history panel on Explore and Dashboard pages. History only accrues while ``ENABLE_VERSIONING_CAPTURE`` is also on; with capture off the panel renders empty or stale history, so the two ship with matching defaults and should be changed together."
|
||||
}
|
||||
],
|
||||
"stable": [
|
||||
|
||||
+28
-28
@@ -3789,7 +3789,7 @@
|
||||
"@rc-component/util" "^1.3.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/select@~1.10.0", "@rc-component/select@~1.10.1":
|
||||
"@rc-component/select@~1.10.0":
|
||||
version "1.10.1"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/select/-/select-1.10.1.tgz#323b2f458a637e8e752f8341094783741c613c34"
|
||||
integrity sha512-H+yQsl+qED9NilQ3g6zdpsMwUgwVjrcMTkNHAWRVU/MoNCYgTbDgU+MIMgZDK+rVdd2JUfI/MkysMcZZ0cyQKw==
|
||||
@@ -3824,7 +3824,7 @@
|
||||
"@rc-component/util" "^1.3.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/table@~1.11.1":
|
||||
"@rc-component/table@~1.11.0":
|
||||
version "1.11.1"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/table/-/table-1.11.1.tgz#7b5c2a7c26fd37b6a403082029b5a72fcb330a4d"
|
||||
integrity sha512-OWdS6DMmeWb7bJBGqPxYZpQbzBlBiXZUu2sqo6Ii7Sjs9GeK1IsrXrWk26SL2c6KEseabswdxrRj7WUm9LdECw==
|
||||
@@ -3866,7 +3866,7 @@
|
||||
"@rc-component/util" "^1.7.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/tree-select@~1.16.1":
|
||||
"@rc-component/tree-select@~1.16.0":
|
||||
version "1.16.1"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/tree-select/-/tree-select-1.16.1.tgz#dcaea96e396e98108cb29cc051840d4fbdda38cc"
|
||||
integrity sha512-a1Oi6EJhqAhdOxxupdJi6fP0RPHMKn5TcfkX2+llaQ4lF4nwfH7b6SCHcnsybaa2s+pk1yZYwVyeOYkDnEBRdg==
|
||||
@@ -4122,23 +4122,23 @@
|
||||
resolved "https://registry.yarnpkg.com/@standard-schema/utils/-/utils-0.3.0.tgz#3d5e608f16c2390c10528e98e59aef6bf73cae7b"
|
||||
integrity sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==
|
||||
|
||||
"@storybook/addon-docs@^10.5.9":
|
||||
version "10.5.9"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-10.5.9.tgz#6d871977f7ad833dc142d12ee43490105dec4d07"
|
||||
integrity sha512-8sFsMkZYrrdqCLdV+hnwTwDF7RaBsBPRwl4wfc8ve9Q/7Yhi5REe/Xjvd8x1yn6fBPPw9tnID9dx6Agdnr81fw==
|
||||
"@storybook/addon-docs@^10.5.8":
|
||||
version "10.5.8"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-10.5.8.tgz#767c10c7a4cc1b625b93f869b2a2b09fc8514f2e"
|
||||
integrity sha512-NlHiMKW/UvW/uL8HXFDCEVwoH3qZeGYZ/qlWax4d7H471b/T54MBq2KcB4ZrdA785FfIH3numAJdBb5jwn00Mg==
|
||||
dependencies:
|
||||
"@mdx-js/react" "^3.0.0"
|
||||
"@storybook/csf-plugin" "10.5.9"
|
||||
"@storybook/csf-plugin" "10.5.8"
|
||||
"@storybook/icons" "^2.0.2"
|
||||
"@storybook/react-dom-shim" "10.5.9"
|
||||
"@storybook/react-dom-shim" "10.5.8"
|
||||
react "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
react-dom "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
ts-dedent "^2.0.0"
|
||||
|
||||
"@storybook/csf-plugin@10.5.9":
|
||||
version "10.5.9"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-10.5.9.tgz#805e4c93a1704b220351d62bb0c74ce3b78c5e10"
|
||||
integrity sha512-4H5QIHQVtQYCuL43GCRLGjNQhZpQg9gL03ja0DV80kO2Dn9LEt6ol87bSnSjn4VDgcAXtgTzXFvRLknfVgAAqg==
|
||||
"@storybook/csf-plugin@10.5.8":
|
||||
version "10.5.8"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-10.5.8.tgz#c626c5bfe55d0e279b2457e5cf150a788d1fc637"
|
||||
integrity sha512-/FHiMyOWWEXfwK/lM0WxmkP9GLzbSJJuzGtfeuNWSOVDnvAMbjavitxfHb5wSbWKIQo0XYC1EJ2Y7x91XNYP4w==
|
||||
dependencies:
|
||||
unplugin "^2.3.5"
|
||||
|
||||
@@ -4152,10 +4152,10 @@
|
||||
resolved "https://registry.yarnpkg.com/@storybook/icons/-/icons-2.1.0.tgz#edfc2450a39c5e780f28c6cbc49acd7bff59b41a"
|
||||
integrity sha512-Fxh9vYpX9bQqFeHRiY8h2ApeRGDzRSMLwJwNZ/AIRqnyOKHxRKL+yFe+ctEkVJmuptRE9u1Hrn8ZZNHyfDKKNg==
|
||||
|
||||
"@storybook/react-dom-shim@10.5.9":
|
||||
version "10.5.9"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/react-dom-shim/-/react-dom-shim-10.5.9.tgz#549793845bb8b966acd36002d33d7b54cc5c92d4"
|
||||
integrity sha512-7qZD6CSa64p1m/zX9tG4ALcEHlEk0Bx+5++4RL3MFlRCgCFfAomXsCHTzF0RyF8cI4vl+hS7Y0ryjQchVs6UQA==
|
||||
"@storybook/react-dom-shim@10.5.8":
|
||||
version "10.5.8"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/react-dom-shim/-/react-dom-shim-10.5.8.tgz#40cc3e32af424baa2e4109a325dae2ede29999e2"
|
||||
integrity sha512-N8D13/Xny+V3kfe1KBgsAHS0nKWXLLdgOOXS9poKdYzVwVCN+CGEGBxWX0zMMtdCptqa6/57em9coPlZMoO+bg==
|
||||
|
||||
"@superset-ui/core@^0.20.4":
|
||||
version "0.20.4"
|
||||
@@ -6181,10 +6181,10 @@ ansis@^3.2.0:
|
||||
resolved "https://registry.yarnpkg.com/ansis/-/ansis-3.17.0.tgz#fa8d9c2a93fe7d1177e0c17f9eeb562a58a832d7"
|
||||
integrity sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==
|
||||
|
||||
antd@^6.6.1:
|
||||
version "6.6.1"
|
||||
resolved "https://registry.yarnpkg.com/antd/-/antd-6.6.1.tgz#3235d76413b525b1f3287b87bdaf6ba0e7148521"
|
||||
integrity sha512-QHIHYoUk9N9nJy1T9fyxWKjY0qApdTEDd/6lzqYng8Uryv9FejNmbhKvYF7obGqB+TuLXQsPVF7fOVgyzM1KrQ==
|
||||
antd@^6.6.0:
|
||||
version "6.6.0"
|
||||
resolved "https://registry.yarnpkg.com/antd/-/antd-6.6.0.tgz#8acb84c54b36594b5c1a9084c8acb6a03b79961b"
|
||||
integrity sha512-UDwWIbpmrCHB9ZQ+bPh4vQfB6DTI2ulIyoQ0Tc9xxalFblttiNGHl3ySBD9SyV/8+gUjFzfSx1+iU1Fog2i46w==
|
||||
dependencies:
|
||||
"@ant-design/colors" "^8.0.1"
|
||||
"@ant-design/cssinjs" "^2.1.2"
|
||||
@@ -6217,16 +6217,16 @@ antd@^6.6.1:
|
||||
"@rc-component/rate" "~1.0.1"
|
||||
"@rc-component/resize-observer" "^1.1.2"
|
||||
"@rc-component/segmented" "~1.3.0"
|
||||
"@rc-component/select" "~1.10.1"
|
||||
"@rc-component/select" "~1.10.0"
|
||||
"@rc-component/slider" "~1.1.1"
|
||||
"@rc-component/steps" "~1.2.2"
|
||||
"@rc-component/switch" "~1.0.3"
|
||||
"@rc-component/table" "~1.11.1"
|
||||
"@rc-component/table" "~1.11.0"
|
||||
"@rc-component/tabs" "~1.12.0"
|
||||
"@rc-component/tooltip" "~1.5.0"
|
||||
"@rc-component/tour" "~2.4.0"
|
||||
"@rc-component/tree" "~1.4.0"
|
||||
"@rc-component/tree-select" "~1.16.1"
|
||||
"@rc-component/tree-select" "~1.16.0"
|
||||
"@rc-component/trigger" "^3.10.1"
|
||||
"@rc-component/upload" "~1.1.1"
|
||||
"@rc-component/util" "^1.12.0"
|
||||
@@ -14783,10 +14783,10 @@ stop-iteration-iterator@^1.1.0:
|
||||
es-errors "^1.3.0"
|
||||
internal-slot "^1.1.0"
|
||||
|
||||
storybook@^10.5.9:
|
||||
version "10.5.9"
|
||||
resolved "https://registry.yarnpkg.com/storybook/-/storybook-10.5.9.tgz#61f476fd73785dcf09e9198ddf404b9b8c06964a"
|
||||
integrity sha512-UfdMKSjEhIKr8LbqYyIE5r7vT/drL/PxN75YaouJ+UG0FssEy6cf49OdTF3kstAqVMHskc+zEqyRoiQHZXHwgA==
|
||||
storybook@^10.5.8:
|
||||
version "10.5.8"
|
||||
resolved "https://registry.yarnpkg.com/storybook/-/storybook-10.5.8.tgz#d5f051983e6232c0a73ea02149a72c7bafb43275"
|
||||
integrity sha512-rR4oFMSiWBSqI0lvsJPtcQUPj8+hzj3TkLu+Mw61Wo6YxPSb5FsLSHai0jZnuaIdKIlmu25KCfwlSQl4e1uvnA==
|
||||
dependencies:
|
||||
"@storybook/global" "^5.0.0"
|
||||
"@storybook/icons" "^2.0.2"
|
||||
|
||||
+3
-7
@@ -80,7 +80,7 @@ dependencies = [
|
||||
# marshmallow 4 compatibility: see superset/marshmallow_compatibility.py for a
|
||||
# Flask-AppBuilder workaround. Tracking issue:
|
||||
# https://github.com/apache/superset/issues/33162
|
||||
"marshmallow>=3.0, <5",
|
||||
"marshmallow>=4.3.1, <5",
|
||||
"marshmallow-union>=0.1.15.post1",
|
||||
"msgpack>=1.2.0, <1.3",
|
||||
"nh3>=0.3.5, <0.4",
|
||||
@@ -218,12 +218,8 @@ motherduck = ["apache-superset[duckdb]"]
|
||||
mysql = ["mysqlclient>=2.2.8, <3"]
|
||||
ocient = [
|
||||
# Closed-source vendor package with no public changelog; permissive
|
||||
# unpinned sqlalchemy>=1.4 declared. Verified compatible with SQLAlchemy
|
||||
# 2.0 against pyocient>=3.9.0 (discussion #40273): dialect construction,
|
||||
# error extraction, and GIS-type sanitization all pass under 2.0.52. Note
|
||||
# pyocient 3.9.0 relocated its geo-type classes from private top-level
|
||||
# names (pyocient._STPoint) to public ones under pyocient.api
|
||||
# (pyocient.api.STPoint), which is unrelated to the SQLAlchemy bump.
|
||||
# unpinned sqlalchemy>=1.4 declared, but SQLAlchemy 2.0 support is
|
||||
# unverified. Lower confidence than the other bumps in this PR.
|
||||
"sqlalchemy-ocient>=3.0.0, <4",
|
||||
"pyocient>=3.9.0, <4",
|
||||
"shapely",
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""Semantic layer contracts for extension authors."""
|
||||
Generated
+270
-86
@@ -81,12 +81,12 @@
|
||||
"@visx/xychart": "^4.0.0",
|
||||
"ag-grid-community": "36.1.0",
|
||||
"ag-grid-react": "36.1.0",
|
||||
"antd": "^6.6.1",
|
||||
"antd": "^6.6.0",
|
||||
"chrono-node": "^2.10.1",
|
||||
"classnames": "^2.2.5",
|
||||
"content-disposition": "^2.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"dayjs": "^1.11.23",
|
||||
"dayjs": "^1.11.22",
|
||||
"dom-to-image-more": "^3.10.2",
|
||||
"dom-to-pdf": "^0.3.2",
|
||||
"echarts": "^6.1.0",
|
||||
@@ -180,9 +180,9 @@
|
||||
"@istanbuljs/nyc-config-typescript": "^1.0.1",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
|
||||
"@storybook/addon-docs": "10.5.9",
|
||||
"@storybook/addon-links": "10.5.9",
|
||||
"@storybook/react-webpack5": "10.5.9",
|
||||
"@storybook/addon-docs": "10.5.8",
|
||||
"@storybook/addon-links": "10.5.8",
|
||||
"@storybook/react-webpack5": "10.5.8",
|
||||
"@storybook/test-runner": "0.24.4",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.16.0",
|
||||
@@ -218,7 +218,7 @@
|
||||
"babel-loader": "^10.1.1",
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"baseline-browser-mapping": "^2.11.15",
|
||||
"baseline-browser-mapping": "^2.11.14",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^10.0.5",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
@@ -235,7 +235,7 @@
|
||||
"eslint-plugin-no-only-tests": "^3.4.0",
|
||||
"eslint-plugin-react-prefer-function-component": "^5.0.0",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.1",
|
||||
"eslint-plugin-storybook": "10.5.9",
|
||||
"eslint-plugin-storybook": "10.5.8",
|
||||
"eslint-plugin-testing-library": "^7.16.2",
|
||||
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
|
||||
"fetch-mock": "^12.6.0",
|
||||
@@ -266,7 +266,7 @@
|
||||
"source-map": "^0.8.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"speed-measure-webpack-plugin": "^1.6.0",
|
||||
"storybook": "10.5.9",
|
||||
"storybook": "10.5.8",
|
||||
"style-loader": "^4.0.0",
|
||||
"stylelint": "^17.14.1",
|
||||
"swc-loader": "^0.2.7",
|
||||
@@ -9946,9 +9946,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rc-component/select": {
|
||||
"version": "1.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/select/-/select-1.10.1.tgz",
|
||||
"integrity": "sha512-H+yQsl+qED9NilQ3g6zdpsMwUgwVjrcMTkNHAWRVU/MoNCYgTbDgU+MIMgZDK+rVdd2JUfI/MkysMcZZ0cyQKw==",
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/select/-/select-1.10.0.tgz",
|
||||
"integrity": "sha512-u/3yuF2kEXvTJXPy3P7qkVBkGGZcQo+m1uTuQqJa6qCnwdmDoEn1Rs3zTTi6y/RsLGiQGM0drPN5c/RfflCnow==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@rc-component/overflow": "^1.0.0",
|
||||
@@ -10765,16 +10765,16 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@storybook/addon-docs": {
|
||||
"version": "10.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.5.9.tgz",
|
||||
"integrity": "sha512-8sFsMkZYrrdqCLdV+hnwTwDF7RaBsBPRwl4wfc8ve9Q/7Yhi5REe/Xjvd8x1yn6fBPPw9tnID9dx6Agdnr81fw==",
|
||||
"version": "10.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.5.8.tgz",
|
||||
"integrity": "sha512-NlHiMKW/UvW/uL8HXFDCEVwoH3qZeGYZ/qlWax4d7H471b/T54MBq2KcB4ZrdA785FfIH3numAJdBb5jwn00Mg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
"@storybook/csf-plugin": "10.5.9",
|
||||
"@storybook/csf-plugin": "10.5.8",
|
||||
"@storybook/icons": "^2.0.2",
|
||||
"@storybook/react-dom-shim": "10.5.9",
|
||||
"@storybook/react-dom-shim": "10.5.8",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"ts-dedent": "^2.0.0"
|
||||
@@ -10785,7 +10785,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "10.5.9"
|
||||
"storybook": "10.5.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
@@ -10794,9 +10794,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/addon-docs/node_modules/@storybook/csf-plugin": {
|
||||
"version": "10.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.9.tgz",
|
||||
"integrity": "sha512-4H5QIHQVtQYCuL43GCRLGjNQhZpQg9gL03ja0DV80kO2Dn9LEt6ol87bSnSjn4VDgcAXtgTzXFvRLknfVgAAqg==",
|
||||
"version": "10.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.8.tgz",
|
||||
"integrity": "sha512-/FHiMyOWWEXfwK/lM0WxmkP9GLzbSJJuzGtfeuNWSOVDnvAMbjavitxfHb5wSbWKIQo0XYC1EJ2Y7x91XNYP4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -10809,7 +10809,7 @@
|
||||
"peerDependencies": {
|
||||
"esbuild": "*",
|
||||
"rollup": "*",
|
||||
"storybook": "10.5.9",
|
||||
"storybook": "10.5.8",
|
||||
"vite": "*",
|
||||
"webpack": "*"
|
||||
},
|
||||
@@ -10829,9 +10829,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/addon-docs/node_modules/@storybook/react-dom-shim": {
|
||||
"version": "10.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.9.tgz",
|
||||
"integrity": "sha512-7qZD6CSa64p1m/zX9tG4ALcEHlEk0Bx+5++4RL3MFlRCgCFfAomXsCHTzF0RyF8cI4vl+hS7Y0ryjQchVs6UQA==",
|
||||
"version": "10.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.8.tgz",
|
||||
"integrity": "sha512-N8D13/Xny+V3kfe1KBgsAHS0nKWXLLdgOOXS9poKdYzVwVCN+CGEGBxWX0zMMtdCptqa6/57em9coPlZMoO+bg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -10843,7 +10843,7 @@
|
||||
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "10.5.9"
|
||||
"storybook": "10.5.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
@@ -10855,9 +10855,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/addon-links": {
|
||||
"version": "10.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-10.5.9.tgz",
|
||||
"integrity": "sha512-ZDbPl6ia6hqjoV+CpQU3DjkXpc0TxUq6+y/rFD8w21dJMdqNWzY8zajHC8r4CfTWANjM1pGPUYisWtTKi1MxZw==",
|
||||
"version": "10.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-10.5.8.tgz",
|
||||
"integrity": "sha512-mpWw4alBJVGqgVh897LZ2keN/xnMHcH93wKJG+oGg4+cdEUA+06hCs5T4k+AS5Aa+EZ6LvdOoi2VPHssyQlCCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -10870,7 +10870,7 @@
|
||||
"peerDependencies": {
|
||||
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "10.5.9"
|
||||
"storybook": "10.5.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
@@ -10964,15 +10964,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-webpack5": {
|
||||
"version": "10.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-webpack5/-/react-webpack5-10.5.9.tgz",
|
||||
"integrity": "sha512-mrCJub/WAt6RAU1P+bpBvcdTHMHePXLuNM+TuJl0Sl3r9Ta0YjDvtdODweud9sTEpVN3ao/fk0LpiaDMfLd84w==",
|
||||
"version": "10.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-webpack5/-/react-webpack5-10.5.8.tgz",
|
||||
"integrity": "sha512-HkPi42WaoNSHC0DAERsJEF7Vhnluzsp/aiuhnH65GGYG5TmdLL9G8KDiYvXHGDCyb4RfoAPYrtzxaLMFfPPFvQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@storybook/builder-webpack5": "10.5.9",
|
||||
"@storybook/preset-react-webpack": "10.5.9",
|
||||
"@storybook/react": "10.5.9"
|
||||
"@storybook/builder-webpack5": "10.5.8",
|
||||
"@storybook/preset-react-webpack": "10.5.8",
|
||||
"@storybook/react": "10.5.8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -10981,7 +10981,7 @@
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "10.5.9",
|
||||
"storybook": "10.5.8",
|
||||
"typescript": ">= 4.9.x"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
@@ -10991,13 +10991,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-webpack5/node_modules/@storybook/builder-webpack5": {
|
||||
"version": "10.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-10.5.9.tgz",
|
||||
"integrity": "sha512-XTLC95jP75V9NfhoUzDNKDCn9r0ZqXz4ZhrNzQXVDz4vNWkjU3/uDL6Ywh9WFu6Xrb+onQqSR9hlHjS/DOZj7Q==",
|
||||
"version": "10.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-10.5.8.tgz",
|
||||
"integrity": "sha512-ke5x27gtWQ4gpXCLWxdGkr8ZlJwBykV/KjbBTAlC04dmS9OkI9MBzGj+TteUlgrcaN7LwoNTR5zRmxKStOZYzQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@storybook/core-webpack": "10.5.9",
|
||||
"@storybook/core-webpack": "10.5.8",
|
||||
"case-sensitive-paths-webpack-plugin": "^2.4.0",
|
||||
"cjs-module-lexer": "^1.2.3",
|
||||
"css-loader": "^7.1.2",
|
||||
@@ -11019,7 +11019,7 @@
|
||||
"url": "https://opencollective.com/storybook"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"storybook": "10.5.9"
|
||||
"storybook": "10.5.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
@@ -11028,9 +11028,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-webpack5/node_modules/@storybook/builder-webpack5/node_modules/@storybook/core-webpack": {
|
||||
"version": "10.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.9.tgz",
|
||||
"integrity": "sha512-YmXR9RJdQpH8EtWEIjLTr5LMGiCXSmZp/A9UkATY5xHM4qG2QwJB++jU2wv4nGDyaPiUcbzT2PQSXN6RXhiUZA==",
|
||||
"version": "10.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.8.tgz",
|
||||
"integrity": "sha512-HccINB0UbTtnyJtKpaX+C35BRTSnAwnreIMwwI+LpeUd4x9mQg0G9orB7lfBBZwd5LQf8YhM2Vkjiawzo41GLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -11041,17 +11041,17 @@
|
||||
"url": "https://opencollective.com/storybook"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"storybook": "10.5.9"
|
||||
"storybook": "10.5.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-webpack5/node_modules/@storybook/preset-react-webpack": {
|
||||
"version": "10.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/preset-react-webpack/-/preset-react-webpack-10.5.9.tgz",
|
||||
"integrity": "sha512-LOr8SoM2CejVCHeLyxbdRMiKuQb2q95CE5D75oK3+513mMZ8LxVPVApxy8B6FvFYNe9CTqVK+95jETefOqabTw==",
|
||||
"version": "10.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/preset-react-webpack/-/preset-react-webpack-10.5.8.tgz",
|
||||
"integrity": "sha512-0JjgVoX5t9Wb+gwddYHx/Ej7KFqwd65lpHXEhBoT4pFWRqVI0pvfHu42M+DRGjsgOye3uE+3pH4yHR3+0/fCHA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@storybook/core-webpack": "10.5.9",
|
||||
"@storybook/core-webpack": "10.5.8",
|
||||
"@storybook/react-docgen-typescript-plugin": "1.0.6--canary.9.0c3f3b7.0",
|
||||
"@types/semver": "^7.7.1",
|
||||
"magic-string": "^0.30.5",
|
||||
@@ -11068,7 +11068,7 @@
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "10.5.9"
|
||||
"storybook": "10.5.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
@@ -11077,9 +11077,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-webpack5/node_modules/@storybook/preset-react-webpack/node_modules/@storybook/core-webpack": {
|
||||
"version": "10.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.9.tgz",
|
||||
"integrity": "sha512-YmXR9RJdQpH8EtWEIjLTr5LMGiCXSmZp/A9UkATY5xHM4qG2QwJB++jU2wv4nGDyaPiUcbzT2PQSXN6RXhiUZA==",
|
||||
"version": "10.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.8.tgz",
|
||||
"integrity": "sha512-HccINB0UbTtnyJtKpaX+C35BRTSnAwnreIMwwI+LpeUd4x9mQg0G9orB7lfBBZwd5LQf8YhM2Vkjiawzo41GLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -11090,18 +11090,18 @@
|
||||
"url": "https://opencollective.com/storybook"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"storybook": "10.5.9"
|
||||
"storybook": "10.5.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-webpack5/node_modules/@storybook/react": {
|
||||
"version": "10.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.5.9.tgz",
|
||||
"integrity": "sha512-kApGOuNT26NkpioTsr1iT/Q2c44tA7OIsNUSyFqtT7W8k3fRn/jQWfrDegYxty0WG0wxdNMZq2ndRfOOF8HaHw==",
|
||||
"version": "10.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.5.8.tgz",
|
||||
"integrity": "sha512-6qqkmqX6imtL+0Z9Uan2tIfYivOI0FiVmWr0zpqqQR15AkJ18JfNcNTQoyjeAlCO0Kei56SWqnu2qLq52TYplg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@storybook/global": "^5.0.0",
|
||||
"@storybook/react-dom-shim": "10.5.9",
|
||||
"@storybook/react-dom-shim": "10.5.8",
|
||||
"react-docgen": "^8.0.2",
|
||||
"react-docgen-typescript": "^2.2.2"
|
||||
},
|
||||
@@ -11114,7 +11114,7 @@
|
||||
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "10.5.9",
|
||||
"storybook": "10.5.8",
|
||||
"typescript": ">= 4.9.x"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
@@ -11130,9 +11130,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-webpack5/node_modules/@storybook/react/node_modules/@storybook/react-dom-shim": {
|
||||
"version": "10.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.9.tgz",
|
||||
"integrity": "sha512-7qZD6CSa64p1m/zX9tG4ALcEHlEk0Bx+5++4RL3MFlRCgCFfAomXsCHTzF0RyF8cI4vl+hS7Y0ryjQchVs6UQA==",
|
||||
"version": "10.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.8.tgz",
|
||||
"integrity": "sha512-N8D13/Xny+V3kfe1KBgsAHS0nKWXLLdgOOXS9poKdYzVwVCN+CGEGBxWX0zMMtdCptqa6/57em9coPlZMoO+bg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -11144,7 +11144,7 @@
|
||||
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "10.5.9"
|
||||
"storybook": "10.5.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
@@ -14928,9 +14928,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/antd": {
|
||||
"version": "6.6.1",
|
||||
"resolved": "https://registry.npmjs.org/antd/-/antd-6.6.1.tgz",
|
||||
"integrity": "sha512-QHIHYoUk9N9nJy1T9fyxWKjY0qApdTEDd/6lzqYng8Uryv9FejNmbhKvYF7obGqB+TuLXQsPVF7fOVgyzM1KrQ==",
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/antd/-/antd-6.6.0.tgz",
|
||||
"integrity": "sha512-UDwWIbpmrCHB9ZQ+bPh4vQfB6DTI2ulIyoQ0Tc9xxalFblttiNGHl3ySBD9SyV/8+gUjFzfSx1+iU1Fog2i46w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ant-design/colors": "^8.0.1",
|
||||
@@ -14964,16 +14964,16 @@
|
||||
"@rc-component/rate": "~1.0.1",
|
||||
"@rc-component/resize-observer": "^1.1.2",
|
||||
"@rc-component/segmented": "~1.3.0",
|
||||
"@rc-component/select": "~1.10.1",
|
||||
"@rc-component/select": "~1.10.0",
|
||||
"@rc-component/slider": "~1.1.1",
|
||||
"@rc-component/steps": "~1.2.2",
|
||||
"@rc-component/switch": "~1.0.3",
|
||||
"@rc-component/table": "~1.11.1",
|
||||
"@rc-component/table": "~1.11.0",
|
||||
"@rc-component/tabs": "~1.12.0",
|
||||
"@rc-component/tooltip": "~1.5.0",
|
||||
"@rc-component/tour": "~2.4.0",
|
||||
"@rc-component/tree": "~1.4.0",
|
||||
"@rc-component/tree-select": "~1.16.1",
|
||||
"@rc-component/tree-select": "~1.16.0",
|
||||
"@rc-component/trigger": "^3.10.1",
|
||||
"@rc-component/upload": "~1.1.1",
|
||||
"@rc-component/util": "^1.12.0",
|
||||
@@ -15715,9 +15715,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.11.15",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.15.tgz",
|
||||
"integrity": "sha512-FwMjJJ7HnyZpWe+oWxegG0fezZyBZUagI5LZEoO3GCbtbKNwRfMH9Ue5d5v01PNePBy1QSfPSDTTeVL0Hb9EzA==",
|
||||
"version": "2.11.14",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.14.tgz",
|
||||
"integrity": "sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -18559,9 +18559,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.23",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz",
|
||||
"integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
|
||||
"version": "1.11.22",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.22.tgz",
|
||||
"integrity": "sha512-1YRnxzt/AabP3GHxnaB9/b+ZScCKu5TeF+co+BWG+lnWVIwEcTFc1FVE0WLNmNO3sA6GGXL40i5qkHfbLzpwrg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debounce": {
|
||||
@@ -19140,10 +19140,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.13",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz",
|
||||
"integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==",
|
||||
"version": "3.4.12",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz",
|
||||
"integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optional": true,
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
@@ -20236,9 +20237,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-storybook": {
|
||||
"version": "10.5.9",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.5.9.tgz",
|
||||
"integrity": "sha512-4Hrqccy/zttV0S/32TSUbqtizzj4sbkgYvv7UzHveH58v96PhrO+fSpOFAqYvzNDzdOU6smKwObFtzX+RZqj4w==",
|
||||
"version": "10.5.8",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.5.8.tgz",
|
||||
"integrity": "sha512-bf9W5nZyWdIaCUZf4aEZnEeD1mn+csNYX8dYUQjAo6L7/DkSLtr65R4zFZ1xeS4m6dOXO6UtUySesCSw4e8w1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -20247,7 +20248,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": ">=8",
|
||||
"storybook": "10.5.9"
|
||||
"storybook": "10.5.8"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-testing-library": {
|
||||
@@ -37734,9 +37735,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/storybook": {
|
||||
"version": "10.5.9",
|
||||
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.5.9.tgz",
|
||||
"integrity": "sha512-UfdMKSjEhIKr8LbqYyIE5r7vT/drL/PxN75YaouJ+UG0FssEy6cf49OdTF3kstAqVMHskc+zEqyRoiQHZXHwgA==",
|
||||
"version": "10.5.8",
|
||||
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.5.8.tgz",
|
||||
"integrity": "sha512-rR4oFMSiWBSqI0lvsJPtcQUPj8+hzj3TkLu+Mw61Wo6YxPSb5FsLSHai0jZnuaIdKIlmu25KCfwlSQl4e1uvnA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -42985,7 +42986,7 @@
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-time": "^3.1.0",
|
||||
"d3-time-format": "^4.1.0",
|
||||
"dayjs": "^1.11.23",
|
||||
"dayjs": "^1.11.22",
|
||||
"dompurify": "^3.4.13",
|
||||
"fetch-retry": "^6.0.0",
|
||||
"handlebars": "^4.7.9",
|
||||
@@ -43145,6 +43146,189 @@
|
||||
"version": "0.20.3",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"plugins/legacy-plugin-chart-calendar": {
|
||||
"name": "@superset-ui/legacy-plugin-chart-calendar",
|
||||
"version": "0.20.3",
|
||||
"extraneous": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.4",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-tip": "^0.9.1",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@emotion/react": "^11.4.1",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"react": "^18.3.0"
|
||||
}
|
||||
},
|
||||
"plugins/legacy-plugin-chart-chord": {
|
||||
"name": "@superset-ui/legacy-plugin-chart-chord",
|
||||
"version": "0.20.3",
|
||||
"extraneous": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"d3": "^3.5.17",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^19.2.7"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*"
|
||||
}
|
||||
},
|
||||
"plugins/legacy-plugin-chart-country-map": {
|
||||
"name": "@superset-ui/legacy-plugin-chart-country-map",
|
||||
"version": "0.20.3",
|
||||
"extraneous": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"d3": "^3.5.17",
|
||||
"d3-array": "^3.2.4",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"react": "^18.3.0"
|
||||
}
|
||||
},
|
||||
"plugins/legacy-plugin-chart-horizon": {
|
||||
"name": "@superset-ui/legacy-plugin-chart-horizon",
|
||||
"version": "0.20.3",
|
||||
"extraneous": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.4",
|
||||
"d3-scale": "^4.0.2",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"react": "^18.3.0"
|
||||
}
|
||||
},
|
||||
"plugins/legacy-plugin-chart-paired-t-test": {
|
||||
"name": "@superset-ui/legacy-plugin-chart-paired-t-test",
|
||||
"version": "0.20.3",
|
||||
"extraneous": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"distributions": "^2.2.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"reactable": "^1.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"react": "^18.3.0"
|
||||
}
|
||||
},
|
||||
"plugins/legacy-plugin-chart-parallel-coordinates": {
|
||||
"name": "@superset-ui/legacy-plugin-chart-parallel-coordinates",
|
||||
"version": "0.20.3",
|
||||
"extraneous": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"d3v3": "npm:d3@3.5.17",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"react": "^18.3.0"
|
||||
}
|
||||
},
|
||||
"plugins/legacy-plugin-chart-partition": {
|
||||
"name": "@superset-ui/legacy-plugin-chart-partition",
|
||||
"version": "0.20.3",
|
||||
"extraneous": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"d3": "^3.5.17",
|
||||
"d3-hierarchy": "^3.1.2",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"@testing-library/jest-dom": "*",
|
||||
"@testing-library/react": "^15.0.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0"
|
||||
}
|
||||
},
|
||||
"plugins/legacy-plugin-chart-rose": {
|
||||
"name": "@superset-ui/legacy-plugin-chart-rose",
|
||||
"version": "0.20.3",
|
||||
"extraneous": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"d3": "^3.5.17",
|
||||
"nvd3-fork": "^2.0.5",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@emotion/react": "^11.4.1",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"react": "^18.3.0"
|
||||
}
|
||||
},
|
||||
"plugins/legacy-plugin-chart-world-map": {
|
||||
"name": "@superset-ui/legacy-plugin-chart-world-map",
|
||||
"version": "0.20.3",
|
||||
"extraneous": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"d3": "^3.5.17",
|
||||
"d3-array": "^3.2.4",
|
||||
"datamaps": "^0.5.10",
|
||||
"prop-types": "^15.8.1",
|
||||
"tinycolor2": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"react": "^18.3.0"
|
||||
}
|
||||
},
|
||||
"plugins/legacy-preset-chart-nvd3": {
|
||||
"name": "@superset-ui/legacy-preset-chart-nvd3",
|
||||
"version": "0.20.3",
|
||||
"extraneous": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"d3": "^3.5.17",
|
||||
"d3-tip": "^0.9.1",
|
||||
"dompurify": "^3.4.12",
|
||||
"fast-safe-stringify": "^2.1.1",
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.18.1",
|
||||
"nvd3-fork": "^2.0.5",
|
||||
"prop-types": "^15.8.1",
|
||||
"urijs": "^1.19.11"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"dayjs": "^1.11.21",
|
||||
"react": "^18.3.0"
|
||||
}
|
||||
},
|
||||
"plugins/plugin-chart-ag-grid-table": {
|
||||
"name": "@superset-ui/plugin-chart-ag-grid-table",
|
||||
"version": "0.20.3",
|
||||
|
||||
@@ -158,12 +158,12 @@
|
||||
"@visx/xychart": "^4.0.0",
|
||||
"ag-grid-community": "36.1.0",
|
||||
"ag-grid-react": "36.1.0",
|
||||
"antd": "^6.6.1",
|
||||
"antd": "^6.6.0",
|
||||
"chrono-node": "^2.10.1",
|
||||
"classnames": "^2.2.5",
|
||||
"content-disposition": "^2.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"dayjs": "^1.11.23",
|
||||
"dayjs": "^1.11.22",
|
||||
"dom-to-image-more": "^3.10.2",
|
||||
"dom-to-pdf": "^0.3.2",
|
||||
"echarts": "^6.1.0",
|
||||
@@ -257,9 +257,9 @@
|
||||
"@istanbuljs/nyc-config-typescript": "^1.0.1",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
|
||||
"@storybook/addon-docs": "10.5.9",
|
||||
"@storybook/addon-links": "10.5.9",
|
||||
"@storybook/react-webpack5": "10.5.9",
|
||||
"@storybook/addon-docs": "10.5.8",
|
||||
"@storybook/addon-links": "10.5.8",
|
||||
"@storybook/react-webpack5": "10.5.8",
|
||||
"@storybook/test-runner": "0.24.4",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.16.0",
|
||||
@@ -295,7 +295,7 @@
|
||||
"babel-loader": "^10.1.1",
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"baseline-browser-mapping": "^2.11.15",
|
||||
"baseline-browser-mapping": "^2.11.14",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^10.0.5",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
@@ -312,7 +312,7 @@
|
||||
"eslint-plugin-no-only-tests": "^3.4.0",
|
||||
"eslint-plugin-react-prefer-function-component": "^5.0.0",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.1",
|
||||
"eslint-plugin-storybook": "10.5.9",
|
||||
"eslint-plugin-storybook": "10.5.8",
|
||||
"eslint-plugin-testing-library": "^7.16.2",
|
||||
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
|
||||
"fetch-mock": "^12.6.0",
|
||||
@@ -343,7 +343,7 @@
|
||||
"source-map": "^0.8.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"speed-measure-webpack-plugin": "^1.6.0",
|
||||
"storybook": "10.5.9",
|
||||
"storybook": "10.5.8",
|
||||
"style-loader": "^4.0.0",
|
||||
"stylelint": "^17.14.1",
|
||||
"swc-loader": "^0.2.7",
|
||||
|
||||
+1
-28
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { QueryFormMetric } from '@superset-ui/core';
|
||||
import { getTotalsMetrics, toTotalsAggregate } from './getTotalsMetrics';
|
||||
import { getTotalsMetrics } from './getTotalsMetrics';
|
||||
|
||||
const simpleMetric = (aggregate: string): QueryFormMetric =>
|
||||
({
|
||||
@@ -76,31 +76,4 @@ describe('getTotalsMetrics', () => {
|
||||
test('returns an empty array when given no metrics', () => {
|
||||
expect(getTotalsMetrics([], 'AVG')).toEqual([]);
|
||||
});
|
||||
|
||||
test("ORIGINAL keeps each metric's own aggregate", () => {
|
||||
const metrics = [
|
||||
simpleMetric('COUNT_DISTINCT'),
|
||||
sqlMetric(),
|
||||
savedMetric(),
|
||||
];
|
||||
const result = getTotalsMetrics(metrics, 'ORIGINAL');
|
||||
|
||||
expect(result).toBe(metrics);
|
||||
expect(result[0]).toEqual(
|
||||
expect.objectContaining({ aggregate: 'COUNT_DISTINCT' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toTotalsAggregate', () => {
|
||||
test.each(['SUM', 'AVG'] as const)('passes %s through', value => {
|
||||
expect(toTotalsAggregate(value)).toBe(value);
|
||||
});
|
||||
|
||||
test.each([undefined, null, '', 'MEDIAN', 'sum'])(
|
||||
'falls back to ORIGINAL for %p',
|
||||
value => {
|
||||
expect(toTotalsAggregate(value)).toBe('ORIGINAL');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
+11
-31
@@ -18,46 +18,26 @@
|
||||
*/
|
||||
import { isAdhocMetricSimple, QueryFormMetric } from '@superset-ui/core';
|
||||
|
||||
/**
|
||||
* How the "Show summary" totals row aggregates each metric.
|
||||
*
|
||||
* ``ORIGINAL`` keeps every metric's own aggregation. It is the default because
|
||||
* overriding is not universally valid: ``SUM`` over a ``COUNT_DISTINCT`` of a
|
||||
* non-numeric column (a uuid, say) is rejected outright by the database, and
|
||||
* over a numeric id column it silently produces a meaningless number.
|
||||
*/
|
||||
export type TotalsAggregate = 'ORIGINAL' | 'SUM' | 'AVG';
|
||||
export type TotalsAggregate = 'SUM' | 'AVG';
|
||||
|
||||
/**
|
||||
* Build the metrics for a chart's "Show summary" totals query.
|
||||
* Build the metrics for a chart's "Show summary" totals query, overriding
|
||||
* each Simple (adhoc) metric's aggregate function with the user-chosen
|
||||
* totals aggregate. The totals query has no GROUP BY, so the database
|
||||
* evaluates each metric fresh over all rows -- swapping the aggregate here
|
||||
* is a correct, independent computation, not a re-aggregation of
|
||||
* already-aggregated per-row values.
|
||||
*
|
||||
* With SUM or AVG, each Simple (adhoc) metric is cloned with its aggregate
|
||||
* replaced. The totals query has no GROUP BY, so the database evaluates each
|
||||
* metric fresh over all rows -- that swap is an independent computation, not a
|
||||
* re-aggregation of already-aggregated per-row values.
|
||||
*
|
||||
* Custom-SQL and saved (string) metrics always pass through unchanged: there is
|
||||
* no safe way to rewrite an arbitrary SQL expression's aggregate without
|
||||
* parsing it, so the totals row keeps their own native aggregate.
|
||||
* Custom-SQL metrics and saved (string) metrics pass through unchanged:
|
||||
* there is no safe way to rewrite an arbitrary SQL expression's aggregate
|
||||
* function without parsing it, so the totals row keeps their own native
|
||||
* aggregate for those.
|
||||
*/
|
||||
export function getTotalsMetrics(
|
||||
metrics: QueryFormMetric[],
|
||||
aggregate: TotalsAggregate,
|
||||
): QueryFormMetric[] {
|
||||
if (aggregate === 'ORIGINAL') {
|
||||
return metrics;
|
||||
}
|
||||
return metrics.map(metric =>
|
||||
isAdhocMetricSimple(metric) ? { ...metric, aggregate } : metric,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow a raw ``totals_aggregate`` form-data value to a TotalsAggregate.
|
||||
*
|
||||
* Anything other than an explicit SUM/AVG — including charts saved before the
|
||||
* control existed — keeps each metric's own aggregation.
|
||||
*/
|
||||
export function toTotalsAggregate(value: unknown): TotalsAggregate {
|
||||
return value === 'SUM' || value === 'AVG' ? value : 'ORIGINAL';
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-time": "^3.1.0",
|
||||
"d3-time-format": "^4.1.0",
|
||||
"dayjs": "^1.11.23",
|
||||
"dayjs": "^1.11.22",
|
||||
"dompurify": "^3.4.13",
|
||||
"fetch-retry": "^6.0.0",
|
||||
"handlebars": "^4.7.9",
|
||||
|
||||
+3
-2
@@ -64,8 +64,9 @@ export default function createSmartNumberFormatter(
|
||||
description,
|
||||
formatFunc: value => `${getSign(value)}${formatValue(value)}`,
|
||||
id:
|
||||
id ??
|
||||
(signed ? NumberFormats.SMART_NUMBER_SIGNED : NumberFormats.SMART_NUMBER),
|
||||
id || signed
|
||||
? NumberFormats.SMART_NUMBER_SIGNED
|
||||
: NumberFormats.SMART_NUMBER,
|
||||
label: label ?? 'Adaptive formatter',
|
||||
});
|
||||
}
|
||||
|
||||
-6
@@ -24,12 +24,6 @@ describe('createSmartNumberFormatter(options)', () => {
|
||||
const formatter = createSmartNumberFormatter();
|
||||
expect(formatter).toBeInstanceOf(NumberFormatter);
|
||||
});
|
||||
test('uses the supplied formatter id regardless of signed option', () => {
|
||||
expect(createSmartNumberFormatter({ id: 'custom' }).id).toBe('custom');
|
||||
expect(
|
||||
createSmartNumberFormatter({ id: 'custom-signed', signed: true }).id,
|
||||
).toBe('custom-signed');
|
||||
});
|
||||
describe('using default options', () => {
|
||||
const formatter = createSmartNumberFormatter();
|
||||
test('formats 0 correctly', () => {
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
getTotalsMetrics,
|
||||
isTimeComparison,
|
||||
timeCompareOperator,
|
||||
toTotalsAggregate,
|
||||
TotalsAggregate,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { TableChartFormData } from './types';
|
||||
@@ -696,16 +696,13 @@ export const buildQueryUncached: BuildQuery<TableChartFormData> = (
|
||||
formData.show_totals &&
|
||||
queryMode === QueryMode.Aggregate,
|
||||
);
|
||||
const totalsAggregate = toTotalsAggregate(formData.totals_aggregate);
|
||||
// Raw-mode summary columns have no metric of their own to preserve, so
|
||||
// ORIGINAL has nothing to fall back to; sum them as before.
|
||||
const rawSummaryAggregate =
|
||||
totalsAggregate === 'ORIGINAL' ? 'SUM' : totalsAggregate;
|
||||
const totalsAggregate: TotalsAggregate =
|
||||
formData.totals_aggregate === 'AVG' ? 'AVG' : 'SUM';
|
||||
const totalsMetrics =
|
||||
rawSummaryColumns.length > 0
|
||||
? rawSummaryColumns.map(columnName => ({
|
||||
expressionType: 'SIMPLE' as const,
|
||||
aggregate: rawSummaryAggregate,
|
||||
aggregate: totalsAggregate,
|
||||
column: { column_name: columnName },
|
||||
label: columnName,
|
||||
}))
|
||||
|
||||
@@ -503,18 +503,14 @@ const config: ControlPanelConfig = {
|
||||
label: t('Summary aggregation'),
|
||||
renderTrigger: true,
|
||||
description: t(
|
||||
'Aggregation used for the summary row. By default each metric ' +
|
||||
'keeps its own aggregation; Sum and Average override it for ' +
|
||||
'the summary row only. The override applies to simple ' +
|
||||
'metrics (a metric built from custom SQL always keeps its ' +
|
||||
'own aggregation). Overriding a count or a distinct count ' +
|
||||
'sums the counted column instead, which fails outright on a ' +
|
||||
'non-numeric column.',
|
||||
'Aggregation used for the summary row, independent of each ' +
|
||||
"metric's own aggregation. Only applies to simple metrics " +
|
||||
'(a metric built from custom SQL keeps its own aggregation ' +
|
||||
'in the summary row).',
|
||||
),
|
||||
default: 'ORIGINAL',
|
||||
default: 'SUM',
|
||||
clearable: false,
|
||||
choices: [
|
||||
['ORIGINAL', t("Each metric's own")],
|
||||
['SUM', t('Sum')],
|
||||
['AVG', t('Average')],
|
||||
],
|
||||
|
||||
@@ -1561,7 +1561,7 @@ describe('plugin-chart-ag-grid-table', () => {
|
||||
expect(queries[1].metrics).toEqual(['count']);
|
||||
});
|
||||
|
||||
test("defaults aggregate-mode totals to the metric's own aggregate", () => {
|
||||
test('defaults aggregate-mode totals to SUM for a simple metric', () => {
|
||||
const simpleMetric = {
|
||||
expressionType: 'SIMPLE' as const,
|
||||
column: { column_name: 'sales' },
|
||||
@@ -1580,29 +1580,9 @@ describe('plugin-chart-ag-grid-table', () => {
|
||||
{ ownState: {} },
|
||||
);
|
||||
|
||||
expect(queries[1].metrics).toEqual([simpleMetric]);
|
||||
});
|
||||
|
||||
test('keeps COUNT_DISTINCT in aggregate-mode totals by default', () => {
|
||||
const countDistinctMetric = {
|
||||
expressionType: 'SIMPLE' as const,
|
||||
column: { column_name: 'contract_id' },
|
||||
aggregate: 'COUNT_DISTINCT' as const,
|
||||
label: 'contracts',
|
||||
};
|
||||
const { queries } = buildQuery(
|
||||
{
|
||||
viz_type: VizType.Table,
|
||||
datasource: '11__table',
|
||||
query_mode: QueryMode.Aggregate,
|
||||
groupby: ['state'],
|
||||
metrics: [countDistinctMetric],
|
||||
show_totals: true,
|
||||
},
|
||||
{ ownState: {} },
|
||||
);
|
||||
|
||||
expect(queries[1].metrics).toEqual([countDistinctMetric]);
|
||||
expect(queries[1].metrics).toEqual([
|
||||
{ ...simpleMetric, aggregate: 'SUM' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('overrides aggregate-mode totals to AVG for a simple metric when totals_aggregate is set', () => {
|
||||
|
||||
+244
-290
@@ -105,7 +105,7 @@
|
||||
"source": [
|
||||
"## Download Data\n",
|
||||
"\n",
|
||||
"Download datasets (_Admin 0 - Countries_ in [1:10](https://www.naturalearthdata.com/downloads/10m-cultural-vectors/), and _Admin 1 \u2013 States, Provinces_ in 1:10 and [1:50](https://www.naturalearthdata.com/downloads/50m-cultural-vectors/)) from Natural Earch Data:"
|
||||
"Download datasets (_Admin 0 - Countries_ in [1:10](https://www.naturalearthdata.com/downloads/10m-cultural-vectors/), and _Admin 1 – States, Provinces_ in 1:10 and [1:50](https://www.naturalearthdata.com/downloads/50m-cultural-vectors/)) from Natural Earch Data:"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -584,7 +584,7 @@
|
||||
" </tr>\n",
|
||||
" </tbody>\n",
|
||||
"</table>\n",
|
||||
"<p>9 rows \u00d7 121 columns</p>\n",
|
||||
"<p>9 rows × 121 columns</p>\n",
|
||||
"</div>"
|
||||
],
|
||||
"text/plain": [
|
||||
@@ -926,33 +926,33 @@
|
||||
" <td>11.0</td>\n",
|
||||
" <td>11.0</td>\n",
|
||||
" <td>Q34617</td>\n",
|
||||
" <td>\u0633\u0627\u0646 \u0628\u064a\u064a\u0631 \u0648\u0645\u064a\u0643\u0644\u0648\u0646</td>\n",
|
||||
" <td>\u09b8\u09be\u0981 \u09aa\u09bf\u09af\u09bc\u09c7\u09b0 \u0993 \u09ae\u09bf\u0995\u09b2\u09cb\u0981</td>\n",
|
||||
" <td>سان بيير وميكلون</td>\n",
|
||||
" <td>সাঁ পিয়ের ও মিকলোঁ</td>\n",
|
||||
" <td>Saint-Pierre und Miquelon</td>\n",
|
||||
" <td>Saint Pierre and Miquelon</td>\n",
|
||||
" <td>San Pedro y Miquel\u00f3n</td>\n",
|
||||
" <td>San Pedro y Miquelón</td>\n",
|
||||
" <td>Saint-Pierre-et-Miquelon</td>\n",
|
||||
" <td>\u03a3\u03b1\u03b9\u03bd-\u03a0\u03b9\u03b5\u03c1 \u03ba\u03b1\u03b9 \u039c\u03b9\u03ba\u03b5\u03bb\u03cc\u03bd</td>\n",
|
||||
" <td>\u0938\u0928\u094d\u0924 \u092a\u093f\u092f\u0930 \u0914\u0930 \u092e\u093f\u0915\u0932\u093e\u0928</td>\n",
|
||||
" <td>Saint-Pierre \u00e9s Miquelon</td>\n",
|
||||
" <td>Σαιν-Πιερ και Μικελόν</td>\n",
|
||||
" <td>सन्त पियर और मिकलान</td>\n",
|
||||
" <td>Saint-Pierre és Miquelon</td>\n",
|
||||
" <td>Saint Pierre dan Miquelon</td>\n",
|
||||
" <td>Saint-Pierre e Miquelon</td>\n",
|
||||
" <td>\u30b5\u30f3\u30d4\u30a8\u30fc\u30eb\u5cf6\u30fb\u30df\u30af\u30ed\u30f3\u5cf6</td>\n",
|
||||
" <td>\uc0dd\ud53c\uc5d0\ub974 \ubbf8\ud074\ub871</td>\n",
|
||||
" <td>サンピエール島・ミクロン島</td>\n",
|
||||
" <td>생피에르 미클롱</td>\n",
|
||||
" <td>Saint-Pierre en Miquelon</td>\n",
|
||||
" <td>Saint-Pierre i Miquelon</td>\n",
|
||||
" <td>Saint-Pierre e Miquelon</td>\n",
|
||||
" <td>\u0421\u0435\u043d-\u041f\u044c\u0435\u0440 \u0438 \u041c\u0438\u043a\u0435\u043b\u043e\u043d</td>\n",
|
||||
" <td>Сен-Пьер и Микелон</td>\n",
|
||||
" <td>Saint-Pierre och Miquelon</td>\n",
|
||||
" <td>Saint Pierre ve Miquelon</td>\n",
|
||||
" <td>Saint-Pierre v\u00e0 Miquelon</td>\n",
|
||||
" <td>\u5723\u76ae\u57c3\u5c14\u548c\u5bc6\u514b\u9686</td>\n",
|
||||
" <td>Saint-Pierre và Miquelon</td>\n",
|
||||
" <td>圣皮埃尔和密克隆</td>\n",
|
||||
" <td>1159315673</td>\n",
|
||||
" <td>\u05e1\u05df-\u05e4\u05d9\u05d9\u05e8 \u05d5\u05de\u05d9\u05e7\u05dc\u05d5\u05df</td>\n",
|
||||
" <td>\u0421\u0435\u043d-\u041f'\u0454\u0440 \u0456 \u041c\u0456\u043a\u0435\u043b\u043e\u043d</td>\n",
|
||||
" <td>\u0633\u06cc\u0646\u0679 \u067e\u06cc\u0626\u0631 \u0648 \u0645\u06cc\u06a9\u06cc\u0644\u0648\u0646</td>\n",
|
||||
" <td>\u0633\u0646 \u067e\u06cc\u0631 \u0648 \u0645\u06cc\u06a9\u0644\u0646</td>\n",
|
||||
" <td>\u8056\u76ae\u57c3\u8207\u5bc6\u514b\u9686\u7fa4\u5cf6</td>\n",
|
||||
" <td>סן-פייר ומיקלון</td>\n",
|
||||
" <td>Сен-П'єр і Мікелон</td>\n",
|
||||
" <td>سینٹ پیئر و میکیلون</td>\n",
|
||||
" <td>سن پیر و میکلن</td>\n",
|
||||
" <td>聖皮埃與密克隆群島</td>\n",
|
||||
" <td>None</td>\n",
|
||||
" <td>None</td>\n",
|
||||
" <td>None</td>\n",
|
||||
@@ -1051,33 +1051,33 @@
|
||||
" <td>11.0</td>\n",
|
||||
" <td>11.0</td>\n",
|
||||
" <td>None</td>\n",
|
||||
" <td>\u0645\u064a\u0643\u0644\u0648\u0646 \u0648\u0644\u0627\u0646\u063a\u0644\u064a\u062f</td>\n",
|
||||
" <td>\u09ae\u09bf\u0995\u09c1\u0987\u09b2\u09a8-\u09b2\u09cd\u09af\u09be\u0982\u09b2\u09c7\u09a1</td>\n",
|
||||
" <td>ميكلون ولانغليد</td>\n",
|
||||
" <td>মিকুইলন-ল্যাংলেড</td>\n",
|
||||
" <td>Miquelon-Langlade</td>\n",
|
||||
" <td>Miquelon-Langlade</td>\n",
|
||||
" <td>Miquel\u00f3n-Langlade</td>\n",
|
||||
" <td>Miquelón-Langlade</td>\n",
|
||||
" <td>Miquelon-Langlade</td>\n",
|
||||
" <td>\u039c\u03b9\u03ba\u03b5\u03bb\u03cc\u03bd-\u039b\u03b1\u03b3\u03ba\u03bb\u03ad\u03b9\u03bd\u03c4</td>\n",
|
||||
" <td>\u092e\u093f\u0915\u0947\u0932\u0949\u0928-\u0932\u0948\u0902\u0917\u0932\u0947\u0921</td>\n",
|
||||
" <td>Μικελόν-Λαγκλέιντ</td>\n",
|
||||
" <td>मिकेलॉन-लैंगलेड</td>\n",
|
||||
" <td>Miquelon-Langlade</td>\n",
|
||||
" <td>Miquelon-Langlade</td>\n",
|
||||
" <td>Miquelon-Langlade</td>\n",
|
||||
" <td>\u30df\u30af\u30ed\u30f3\uff1d\u30e9\u30f3\u30b0\u30e9\u30fc\u30c9</td>\n",
|
||||
" <td>\ubbf8\ud074\ub871-\ub7ad\uae00\ub808\uc774\ub4dc</td>\n",
|
||||
" <td>ミクロン=ラングラード</td>\n",
|
||||
" <td>미클롱-랭글레이드</td>\n",
|
||||
" <td>Miquelon-Langlade</td>\n",
|
||||
" <td>Miquelon-Langlade</td>\n",
|
||||
" <td>Miquel\u00e3o-Langlade</td>\n",
|
||||
" <td>\u041c\u0438\u043a\u0435\u043b\u043e\u043d-\u041b\u0430\u043d\u0433\u043b\u0430\u0434</td>\n",
|
||||
" <td>Miquelão-Langlade</td>\n",
|
||||
" <td>Микелон-Ланглад</td>\n",
|
||||
" <td>Miquelon-Langlade</td>\n",
|
||||
" <td>Miquelon-Langlade</td>\n",
|
||||
" <td>Miquelon-Langlade</td>\n",
|
||||
" <td>\u5bc6\u514b\u9686-\u6717\u683c\u62c9\u5fb7</td>\n",
|
||||
" <td>密克隆-朗格拉德</td>\n",
|
||||
" <td>1159315961</td>\n",
|
||||
" <td>\u05de\u05d9\u05e8\u05d4</td>\n",
|
||||
" <td>\u041c\u0456\u043a\u0432\u0435\u043b\u043e\u043d-\u041b\u0430\u043d\u0433\u043b\u0430\u0434\u0435</td>\n",
|
||||
" <td>\u0645\u06cc\u06a9\u06cc\u0648\u0644\u0648\u0646 \u0644\u06cc\u0646\u06af\u0644\u0627\u0688\u06d2</td>\n",
|
||||
" <td>\u0645\u06cc\u06a9\u0648\u0626\u0644\u0648\u0646-\u0644\u0627\u0646\u06af\u0644\u06cc\u062f</td>\n",
|
||||
" <td>\u5bc6\u514b\u9686-\u6717\u683c\u62c9\u5fb7</td>\n",
|
||||
" <td>מירה</td>\n",
|
||||
" <td>Міквелон-Лангладе</td>\n",
|
||||
" <td>میکیولون لینگلاڈے</td>\n",
|
||||
" <td>میکوئلون-لانگلید</td>\n",
|
||||
" <td>密克隆-朗格拉德</td>\n",
|
||||
" <td>None</td>\n",
|
||||
" <td>None</td>\n",
|
||||
" <td>None</td>\n",
|
||||
@@ -1167,48 +1167,48 @@
|
||||
"2177 PM.97501 None None 1.0 fra SB00 None \n",
|
||||
"\n",
|
||||
" min_label max_label min_zoom wikidataid name_ar \\\n",
|
||||
"2176 11.0 11.0 11.0 Q34617 \u0633\u0627\u0646 \u0628\u064a\u064a\u0631 \u0648\u0645\u064a\u0643\u0644\u0648\u0646 \n",
|
||||
"2177 11.0 11.0 11.0 None \u0645\u064a\u0643\u0644\u0648\u0646 \u0648\u0644\u0627\u0646\u063a\u0644\u064a\u062f \n",
|
||||
"2176 11.0 11.0 11.0 Q34617 سان بيير وميكلون \n",
|
||||
"2177 11.0 11.0 11.0 None ميكلون ولانغليد \n",
|
||||
"\n",
|
||||
" name_bn name_de \\\n",
|
||||
"2176 \u09b8\u09be\u0981 \u09aa\u09bf\u09af\u09bc\u09c7\u09b0 \u0993 \u09ae\u09bf\u0995\u09b2\u09cb\u0981 Saint-Pierre und Miquelon \n",
|
||||
"2177 \u09ae\u09bf\u0995\u09c1\u0987\u09b2\u09a8-\u09b2\u09cd\u09af\u09be\u0982\u09b2\u09c7\u09a1 Miquelon-Langlade \n",
|
||||
"2176 সাঁ পিয়ের ও মিকলোঁ Saint-Pierre und Miquelon \n",
|
||||
"2177 মিকুইলন-ল্যাংলেড Miquelon-Langlade \n",
|
||||
"\n",
|
||||
" name_en name_es \\\n",
|
||||
"2176 Saint Pierre and Miquelon San Pedro y Miquel\u00f3n \n",
|
||||
"2177 Miquelon-Langlade Miquel\u00f3n-Langlade \n",
|
||||
"2176 Saint Pierre and Miquelon San Pedro y Miquelón \n",
|
||||
"2177 Miquelon-Langlade Miquelón-Langlade \n",
|
||||
"\n",
|
||||
" name_fr name_el name_hi \\\n",
|
||||
"2176 Saint-Pierre-et-Miquelon \u03a3\u03b1\u03b9\u03bd-\u03a0\u03b9\u03b5\u03c1 \u03ba\u03b1\u03b9 \u039c\u03b9\u03ba\u03b5\u03bb\u03cc\u03bd \u0938\u0928\u094d\u0924 \u092a\u093f\u092f\u0930 \u0914\u0930 \u092e\u093f\u0915\u0932\u093e\u0928 \n",
|
||||
"2177 Miquelon-Langlade \u039c\u03b9\u03ba\u03b5\u03bb\u03cc\u03bd-\u039b\u03b1\u03b3\u03ba\u03bb\u03ad\u03b9\u03bd\u03c4 \u092e\u093f\u0915\u0947\u0932\u0949\u0928-\u0932\u0948\u0902\u0917\u0932\u0947\u0921 \n",
|
||||
"2176 Saint-Pierre-et-Miquelon Σαιν-Πιερ και Μικελόν सन्त पियर और मिकलान \n",
|
||||
"2177 Miquelon-Langlade Μικελόν-Λαγκλέιντ मिकेलॉन-लैंगलेड \n",
|
||||
"\n",
|
||||
" name_hu name_id \\\n",
|
||||
"2176 Saint-Pierre \u00e9s Miquelon Saint Pierre dan Miquelon \n",
|
||||
"2176 Saint-Pierre és Miquelon Saint Pierre dan Miquelon \n",
|
||||
"2177 Miquelon-Langlade Miquelon-Langlade \n",
|
||||
"\n",
|
||||
" name_it name_ja name_ko \\\n",
|
||||
"2176 Saint-Pierre e Miquelon \u30b5\u30f3\u30d4\u30a8\u30fc\u30eb\u5cf6\u30fb\u30df\u30af\u30ed\u30f3\u5cf6 \uc0dd\ud53c\uc5d0\ub974 \ubbf8\ud074\ub871 \n",
|
||||
"2177 Miquelon-Langlade \u30df\u30af\u30ed\u30f3\uff1d\u30e9\u30f3\u30b0\u30e9\u30fc\u30c9 \ubbf8\ud074\ub871-\ub7ad\uae00\ub808\uc774\ub4dc \n",
|
||||
"2176 Saint-Pierre e Miquelon サンピエール島・ミクロン島 생피에르 미클롱 \n",
|
||||
"2177 Miquelon-Langlade ミクロン=ラングラード 미클롱-랭글레이드 \n",
|
||||
"\n",
|
||||
" name_nl name_pl \\\n",
|
||||
"2176 Saint-Pierre en Miquelon Saint-Pierre i Miquelon \n",
|
||||
"2177 Miquelon-Langlade Miquelon-Langlade \n",
|
||||
"\n",
|
||||
" name_pt name_ru name_sv \\\n",
|
||||
"2176 Saint-Pierre e Miquelon \u0421\u0435\u043d-\u041f\u044c\u0435\u0440 \u0438 \u041c\u0438\u043a\u0435\u043b\u043e\u043d Saint-Pierre och Miquelon \n",
|
||||
"2177 Miquel\u00e3o-Langlade \u041c\u0438\u043a\u0435\u043b\u043e\u043d-\u041b\u0430\u043d\u0433\u043b\u0430\u0434 Miquelon-Langlade \n",
|
||||
"2176 Saint-Pierre e Miquelon Сен-Пьер и Микелон Saint-Pierre och Miquelon \n",
|
||||
"2177 Miquelão-Langlade Микелон-Ланглад Miquelon-Langlade \n",
|
||||
"\n",
|
||||
" name_tr name_vi name_zh \\\n",
|
||||
"2176 Saint Pierre ve Miquelon Saint-Pierre v\u00e0 Miquelon \u5723\u76ae\u57c3\u5c14\u548c\u5bc6\u514b\u9686 \n",
|
||||
"2177 Miquelon-Langlade Miquelon-Langlade \u5bc6\u514b\u9686-\u6717\u683c\u62c9\u5fb7 \n",
|
||||
"2176 Saint Pierre ve Miquelon Saint-Pierre và Miquelon 圣皮埃尔和密克隆 \n",
|
||||
"2177 Miquelon-Langlade Miquelon-Langlade 密克隆-朗格拉德 \n",
|
||||
"\n",
|
||||
" ne_id name_he name_uk name_ur \\\n",
|
||||
"2176 1159315673 \u05e1\u05df-\u05e4\u05d9\u05d9\u05e8 \u05d5\u05de\u05d9\u05e7\u05dc\u05d5\u05df \u0421\u0435\u043d-\u041f'\u0454\u0440 \u0456 \u041c\u0456\u043a\u0435\u043b\u043e\u043d \u0633\u06cc\u0646\u0679 \u067e\u06cc\u0626\u0631 \u0648 \u0645\u06cc\u06a9\u06cc\u0644\u0648\u0646 \n",
|
||||
"2177 1159315961 \u05de\u05d9\u05e8\u05d4 \u041c\u0456\u043a\u0432\u0435\u043b\u043e\u043d-\u041b\u0430\u043d\u0433\u043b\u0430\u0434\u0435 \u0645\u06cc\u06a9\u06cc\u0648\u0644\u0648\u0646 \u0644\u06cc\u0646\u06af\u0644\u0627\u0688\u06d2 \n",
|
||||
"2176 1159315673 סן-פייר ומיקלון Сен-П'єр і Мікелон سینٹ پیئر و میکیلون \n",
|
||||
"2177 1159315961 מירה Міквелон-Лангладе میکیولون لینگلاڈے \n",
|
||||
"\n",
|
||||
" name_fa name_zht FCLASS_ISO FCLASS_US FCLASS_FR FCLASS_RU \\\n",
|
||||
"2176 \u0633\u0646 \u067e\u06cc\u0631 \u0648 \u0645\u06cc\u06a9\u0644\u0646 \u8056\u76ae\u57c3\u8207\u5bc6\u514b\u9686\u7fa4\u5cf6 None None None None \n",
|
||||
"2177 \u0645\u06cc\u06a9\u0648\u0626\u0644\u0648\u0646-\u0644\u0627\u0646\u06af\u0644\u06cc\u062f \u5bc6\u514b\u9686-\u6717\u683c\u62c9\u5fb7 None None None None \n",
|
||||
"2176 سن پیر و میکلن 聖皮埃與密克隆群島 None None None None \n",
|
||||
"2177 میکوئلون-لانگلید 密克隆-朗格拉德 None None None None \n",
|
||||
"\n",
|
||||
" FCLASS_ES FCLASS_CN FCLASS_TW FCLASS_IN FCLASS_NP FCLASS_PK FCLASS_DE \\\n",
|
||||
"2176 None None None None None None None \n",
|
||||
@@ -1330,7 +1330,7 @@
|
||||
" 'costa rica',\n",
|
||||
" 'croatia',\n",
|
||||
" 'cuba',\n",
|
||||
" 'cura\u00e7ao',\n",
|
||||
" 'curaçao',\n",
|
||||
" 'cyprus',\n",
|
||||
" 'czech republic',\n",
|
||||
" 'denmark',\n",
|
||||
@@ -1343,7 +1343,7 @@
|
||||
" 'equatorial guinea',\n",
|
||||
" 'eritrea',\n",
|
||||
" 'estonia',\n",
|
||||
" # 'eswatini', # not sure why this doesn't work \u2014 Swaziland isn't available to alias, either.\n",
|
||||
" # 'eswatini', # not sure why this doesn't work — Swaziland isn't available to alias, either.\n",
|
||||
" 'ethiopia',\n",
|
||||
" 'falkland islands',\n",
|
||||
" 'faroe islands',\n",
|
||||
@@ -1443,7 +1443,7 @@
|
||||
" 'portugal',\n",
|
||||
" 'puerto rico',\n",
|
||||
" 'qatar',\n",
|
||||
" # 'r\u00e9union', # part of France, in Natural Earth data\n",
|
||||
" # 'réunion', # part of France, in Natural Earth data\n",
|
||||
" 'republic of serbia',\n",
|
||||
" 'romania',\n",
|
||||
" 'russia',\n",
|
||||
@@ -1911,34 +1911,34 @@
|
||||
" <td>9.0</td>\n",
|
||||
" <td>1159320473</td>\n",
|
||||
" <td>Q8646</td>\n",
|
||||
" <td>\u0647\u0648\u0646\u063a \u0643\u0648\u0646\u063a</td>\n",
|
||||
" <td>\u09b9\u0982\u0995\u0982</td>\n",
|
||||
" <td>هونغ كونغ</td>\n",
|
||||
" <td>হংকং</td>\n",
|
||||
" <td>Hongkong</td>\n",
|
||||
" <td>Hong Kong</td>\n",
|
||||
" <td>Hong Kong</td>\n",
|
||||
" <td>\u0647\u0646\u06af \u06a9\u0646\u06af</td>\n",
|
||||
" <td>هنگ کنگ</td>\n",
|
||||
" <td>Hong Kong</td>\n",
|
||||
" <td>\u03a7\u03bf\u03bd\u03b3\u03ba \u039a\u03bf\u03bd\u03b3\u03ba</td>\n",
|
||||
" <td>\u05d4\u05d5\u05e0\u05d2 \u05e7\u05d5\u05e0\u05d2</td>\n",
|
||||
" <td>\u0939\u093e\u0902\u0917\u0915\u093e\u0902\u0917</td>\n",
|
||||
" <td>Χονγκ Κονγκ</td>\n",
|
||||
" <td>הונג קונג</td>\n",
|
||||
" <td>हांगकांग</td>\n",
|
||||
" <td>Hongkong</td>\n",
|
||||
" <td>Hong Kong</td>\n",
|
||||
" <td>Hong Kong</td>\n",
|
||||
" <td>\u9999\u6e2f</td>\n",
|
||||
" <td>\ud64d\ucf69</td>\n",
|
||||
" <td>香港</td>\n",
|
||||
" <td>홍콩</td>\n",
|
||||
" <td>Hongkong</td>\n",
|
||||
" <td>Hongkong</td>\n",
|
||||
" <td>Hong Kong</td>\n",
|
||||
" <td>\u0413\u043e\u043d\u043a\u043e\u043d\u0433</td>\n",
|
||||
" <td>Гонконг</td>\n",
|
||||
" <td>Hongkong</td>\n",
|
||||
" <td>Hong Kong</td>\n",
|
||||
" <td>\u0413\u043e\u043d\u043a\u043e\u043d\u0433</td>\n",
|
||||
" <td>\u06c1\u0627\u0646\u06af \u06a9\u0627\u0646\u06af</td>\n",
|
||||
" <td>H\u1ed3ng K\u00f4ng</td>\n",
|
||||
" <td>\u9999\u6e2f</td>\n",
|
||||
" <td>\u9999\u6e2f</td>\n",
|
||||
" <td>Гонконг</td>\n",
|
||||
" <td>ہانگ کانگ</td>\n",
|
||||
" <td>Hồng Kông</td>\n",
|
||||
" <td>香港</td>\n",
|
||||
" <td>香港</td>\n",
|
||||
" <td>MULTIPOLYGON (((114.22983 22.55581, 114.23471 ...</td>\n",
|
||||
" <td>\u9999\u6e2f\u7279\u522b\u884c\u653f\u533a</td>\n",
|
||||
" <td>香港特别行政区</td>\n",
|
||||
" <td>CN-91</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
@@ -1965,34 +1965,34 @@
|
||||
" <td>8.0</td>\n",
|
||||
" <td>1159321335</td>\n",
|
||||
" <td>Q865</td>\n",
|
||||
" <td>\u062a\u0627\u064a\u0648\u0627\u0646</td>\n",
|
||||
" <td>\u09a4\u09be\u0987\u0993\u09af\u09bc\u09be\u09a8</td>\n",
|
||||
" <td>تايوان</td>\n",
|
||||
" <td>তাইওয়ান</td>\n",
|
||||
" <td>Republik China</td>\n",
|
||||
" <td>Taiwan</td>\n",
|
||||
" <td>Rep\u00fablica de China</td>\n",
|
||||
" <td>\u062a\u0627\u06cc\u0648\u0627\u0646</td>\n",
|
||||
" <td>Ta\u00efwan</td>\n",
|
||||
" <td>\u0394\u03b7\u03bc\u03bf\u03ba\u03c1\u03b1\u03c4\u03af\u03b1 \u03c4\u03b7\u03c2 \u039a\u03af\u03bd\u03b1\u03c2</td>\n",
|
||||
" <td>\u05d8\u05d0\u05d9\u05d5\u05d5\u05d0\u05df</td>\n",
|
||||
" <td>\u091a\u0940\u0928\u0940 \u0917\u0923\u0930\u093e\u091c\u094d\u092f</td>\n",
|
||||
" <td>K\u00ednai K\u00f6zt\u00e1rsas\u00e1g</td>\n",
|
||||
" <td>República de China</td>\n",
|
||||
" <td>تایوان</td>\n",
|
||||
" <td>Taïwan</td>\n",
|
||||
" <td>Δημοκρατία της Κίνας</td>\n",
|
||||
" <td>טאיוואן</td>\n",
|
||||
" <td>चीनी गणराज्य</td>\n",
|
||||
" <td>Kínai Köztársaság</td>\n",
|
||||
" <td>Taiwan</td>\n",
|
||||
" <td>Taiwan</td>\n",
|
||||
" <td>\u4e2d\u83ef\u6c11\u56fd</td>\n",
|
||||
" <td>\uc911\ud654\ubbfc\uad6d</td>\n",
|
||||
" <td>中華民国</td>\n",
|
||||
" <td>중화민국</td>\n",
|
||||
" <td>Taiwan</td>\n",
|
||||
" <td>Republika Chi\u0144ska</td>\n",
|
||||
" <td>Republika Chińska</td>\n",
|
||||
" <td>Taiwan</td>\n",
|
||||
" <td>\u0422\u0430\u0439\u0432\u0430\u043d\u044c</td>\n",
|
||||
" <td>Тайвань</td>\n",
|
||||
" <td>Taiwan</td>\n",
|
||||
" <td>\u00c7in Cumhuriyeti</td>\n",
|
||||
" <td>\u0420\u0435\u0441\u043f\u0443\u0431\u043b\u0456\u043a\u0430 \u041a\u0438\u0442\u0430\u0439</td>\n",
|
||||
" <td>\u062a\u0627\u0626\u06cc\u0648\u0627\u0646</td>\n",
|
||||
" <td>\u0110\u00e0i Loan</td>\n",
|
||||
" <td>\u4e2d\u534e\u6c11\u56fd</td>\n",
|
||||
" <td>\u4e2d\u83ef\u6c11\u570b</td>\n",
|
||||
" <td>Çin Cumhuriyeti</td>\n",
|
||||
" <td>Республіка Китай</td>\n",
|
||||
" <td>تائیوان</td>\n",
|
||||
" <td>Đài Loan</td>\n",
|
||||
" <td>中华民国</td>\n",
|
||||
" <td>中華民國</td>\n",
|
||||
" <td>MULTIPOLYGON (((121.90577 24.9501, 121.83473 2...</td>\n",
|
||||
" <td>\u4e2d\u56fd\u53f0\u6e7e</td>\n",
|
||||
" <td>中国台湾</td>\n",
|
||||
" <td>CN-71</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
@@ -2019,34 +2019,34 @@
|
||||
" <td>9.0</td>\n",
|
||||
" <td>1159320475</td>\n",
|
||||
" <td>Q14773</td>\n",
|
||||
" <td>\u0645\u0627\u0643\u0627\u0648</td>\n",
|
||||
" <td>\u09ae\u09be\u0995\u09be\u0993</td>\n",
|
||||
" <td>ماكاو</td>\n",
|
||||
" <td>মাকাও</td>\n",
|
||||
" <td>Macau</td>\n",
|
||||
" <td>Macau</td>\n",
|
||||
" <td>Macao</td>\n",
|
||||
" <td>\u0645\u0627\u06a9\u0627\u0626\u0648</td>\n",
|
||||
" <td>ماکائو</td>\n",
|
||||
" <td>Macao</td>\n",
|
||||
" <td>\u039c\u03b1\u03ba\u03ac\u03bf\u03c5</td>\n",
|
||||
" <td>\u05de\u05e7\u05d0\u05d5</td>\n",
|
||||
" <td>\u092e\u0915\u093e\u0909</td>\n",
|
||||
" <td>Maka\u00f3</td>\n",
|
||||
" <td>Μακάου</td>\n",
|
||||
" <td>מקאו</td>\n",
|
||||
" <td>मकाउ</td>\n",
|
||||
" <td>Makaó</td>\n",
|
||||
" <td>Makau</td>\n",
|
||||
" <td>Macao</td>\n",
|
||||
" <td>\u30de\u30ab\u30aa</td>\n",
|
||||
" <td>\ub9c8\uce74\uc624</td>\n",
|
||||
" <td>マカオ</td>\n",
|
||||
" <td>마카오</td>\n",
|
||||
" <td>Macau</td>\n",
|
||||
" <td>Makau</td>\n",
|
||||
" <td>Macau</td>\n",
|
||||
" <td>\u041c\u0430\u043a\u0430\u043e</td>\n",
|
||||
" <td>Макао</td>\n",
|
||||
" <td>Macao</td>\n",
|
||||
" <td>Makao</td>\n",
|
||||
" <td>\u0410\u043e\u043c\u0438\u043d\u044c</td>\n",
|
||||
" <td>\u0645\u06a9\u0627\u0624</td>\n",
|
||||
" <td>Аоминь</td>\n",
|
||||
" <td>مکاؤ</td>\n",
|
||||
" <td>Ma Cao</td>\n",
|
||||
" <td>\u6fb3\u95e8</td>\n",
|
||||
" <td>\u6fb3\u9580</td>\n",
|
||||
" <td>澳门</td>\n",
|
||||
" <td>澳門</td>\n",
|
||||
" <td>MULTIPOLYGON (((113.5586 22.16303, 113.56943 2...</td>\n",
|
||||
" <td>\u6fb3\u95e8\u7279\u522b\u884c\u653f\u533a</td>\n",
|
||||
" <td>澳门特别行政区</td>\n",
|
||||
" <td>CN-92</td>\n",
|
||||
" </tr>\n",
|
||||
" </tbody>\n",
|
||||
@@ -2070,34 +2070,34 @@
|
||||
"2 4 3 MO 20070017 5 0.0 4.0 \n",
|
||||
"\n",
|
||||
" max_label ne_id wikidataid name_ar name_bn name_de \\\n",
|
||||
"0 9.0 1159320473 Q8646 \u0647\u0648\u0646\u063a \u0643\u0648\u0646\u063a \u09b9\u0982\u0995\u0982 Hongkong \n",
|
||||
"1 8.0 1159321335 Q865 \u062a\u0627\u064a\u0648\u0627\u0646 \u09a4\u09be\u0987\u0993\u09af\u09bc\u09be\u09a8 Republik China \n",
|
||||
"2 9.0 1159320475 Q14773 \u0645\u0627\u0643\u0627\u0648 \u09ae\u09be\u0995\u09be\u0993 Macau \n",
|
||||
"0 9.0 1159320473 Q8646 هونغ كونغ হংকং Hongkong \n",
|
||||
"1 8.0 1159321335 Q865 تايوان তাইওয়ান Republik China \n",
|
||||
"2 9.0 1159320475 Q14773 ماكاو মাকাও Macau \n",
|
||||
"\n",
|
||||
" name_en name_es name_fa name_fr name_el \\\n",
|
||||
"0 Hong Kong Hong Kong \u0647\u0646\u06af \u06a9\u0646\u06af Hong Kong \u03a7\u03bf\u03bd\u03b3\u03ba \u039a\u03bf\u03bd\u03b3\u03ba \n",
|
||||
"1 Taiwan Rep\u00fablica de China \u062a\u0627\u06cc\u0648\u0627\u0646 Ta\u00efwan \u0394\u03b7\u03bc\u03bf\u03ba\u03c1\u03b1\u03c4\u03af\u03b1 \u03c4\u03b7\u03c2 \u039a\u03af\u03bd\u03b1\u03c2 \n",
|
||||
"2 Macau Macao \u0645\u0627\u06a9\u0627\u0626\u0648 Macao \u039c\u03b1\u03ba\u03ac\u03bf\u03c5 \n",
|
||||
"0 Hong Kong Hong Kong هنگ کنگ Hong Kong Χονγκ Κονγκ \n",
|
||||
"1 Taiwan República de China تایوان Taïwan Δημοκρατία της Κίνας \n",
|
||||
"2 Macau Macao ماکائو Macao Μακάου \n",
|
||||
"\n",
|
||||
" name_he name_hi name_hu name_id name_it name_ja \\\n",
|
||||
"0 \u05d4\u05d5\u05e0\u05d2 \u05e7\u05d5\u05e0\u05d2 \u0939\u093e\u0902\u0917\u0915\u093e\u0902\u0917 Hongkong Hong Kong Hong Kong \u9999\u6e2f \n",
|
||||
"1 \u05d8\u05d0\u05d9\u05d5\u05d5\u05d0\u05df \u091a\u0940\u0928\u0940 \u0917\u0923\u0930\u093e\u091c\u094d\u092f K\u00ednai K\u00f6zt\u00e1rsas\u00e1g Taiwan Taiwan \u4e2d\u83ef\u6c11\u56fd \n",
|
||||
"2 \u05de\u05e7\u05d0\u05d5 \u092e\u0915\u093e\u0909 Maka\u00f3 Makau Macao \u30de\u30ab\u30aa \n",
|
||||
"0 הונג קונג हांगकांग Hongkong Hong Kong Hong Kong 香港 \n",
|
||||
"1 טאיוואן चीनी गणराज्य Kínai Köztársaság Taiwan Taiwan 中華民国 \n",
|
||||
"2 מקאו मकाउ Makaó Makau Macao マカオ \n",
|
||||
"\n",
|
||||
" name_ko name_nl name_pl name_pt name_ru name_sv \\\n",
|
||||
"0 \ud64d\ucf69 Hongkong Hongkong Hong Kong \u0413\u043e\u043d\u043a\u043e\u043d\u0433 Hongkong \n",
|
||||
"1 \uc911\ud654\ubbfc\uad6d Taiwan Republika Chi\u0144ska Taiwan \u0422\u0430\u0439\u0432\u0430\u043d\u044c Taiwan \n",
|
||||
"2 \ub9c8\uce74\uc624 Macau Makau Macau \u041c\u0430\u043a\u0430\u043e Macao \n",
|
||||
"0 홍콩 Hongkong Hongkong Hong Kong Гонконг Hongkong \n",
|
||||
"1 중화민국 Taiwan Republika Chińska Taiwan Тайвань Taiwan \n",
|
||||
"2 마카오 Macau Makau Macau Макао Macao \n",
|
||||
"\n",
|
||||
" name_tr name_uk name_ur name_vi name_zh_x name_zht \\\n",
|
||||
"0 Hong Kong \u0413\u043e\u043d\u043a\u043e\u043d\u0433 \u06c1\u0627\u0646\u06af \u06a9\u0627\u0646\u06af H\u1ed3ng K\u00f4ng \u9999\u6e2f \u9999\u6e2f \n",
|
||||
"1 \u00c7in Cumhuriyeti \u0420\u0435\u0441\u043f\u0443\u0431\u043b\u0456\u043a\u0430 \u041a\u0438\u0442\u0430\u0439 \u062a\u0627\u0626\u06cc\u0648\u0627\u0646 \u0110\u00e0i Loan \u4e2d\u534e\u6c11\u56fd \u4e2d\u83ef\u6c11\u570b \n",
|
||||
"2 Makao \u0410\u043e\u043c\u0438\u043d\u044c \u0645\u06a9\u0627\u0624 Ma Cao \u6fb3\u95e8 \u6fb3\u9580 \n",
|
||||
"0 Hong Kong Гонконг ہانگ کانگ Hồng Kông 香港 香港 \n",
|
||||
"1 Çin Cumhuriyeti Республіка Китай تائیوان Đài Loan 中华民国 中華民國 \n",
|
||||
"2 Makao Аоминь مکاؤ Ma Cao 澳门 澳門 \n",
|
||||
"\n",
|
||||
" geometry name_zh_y iso_3166_2 \n",
|
||||
"0 MULTIPOLYGON (((114.22983 22.55581, 114.23471 ... \u9999\u6e2f\u7279\u522b\u884c\u653f\u533a CN-91 \n",
|
||||
"1 MULTIPOLYGON (((121.90577 24.9501, 121.83473 2... \u4e2d\u56fd\u53f0\u6e7e CN-71 \n",
|
||||
"2 MULTIPOLYGON (((113.5586 22.16303, 113.56943 2... \u6fb3\u95e8\u7279\u522b\u884c\u653f\u533a CN-92 "
|
||||
"0 MULTIPOLYGON (((114.22983 22.55581, 114.23471 ... 香港特别行政区 CN-91 \n",
|
||||
"1 MULTIPOLYGON (((121.90577 24.9501, 121.83473 2... 中国台湾 CN-71 \n",
|
||||
"2 MULTIPOLYGON (((113.5586 22.16303, 113.56943 2... 澳门特别行政区 CN-92 "
|
||||
]
|
||||
},
|
||||
"execution_count": 14,
|
||||
@@ -2114,7 +2114,7 @@
|
||||
"china_sars = china_sars.merge(pd.DataFrame(\n",
|
||||
" data={\n",
|
||||
" \"name_en\": [\"Taiwan\", \"Hong Kong\", \"Macau\"],\n",
|
||||
" \"name_zh\": [\"\u4e2d\u56fd\u53f0\u6e7e\", \"\u9999\u6e2f\u7279\u522b\u884c\u653f\u533a\", \"\u6fb3\u95e8\u7279\u522b\u884c\u653f\u533a\"],\n",
|
||||
" \"name_zh\": [\"中国台湾\", \"香港特别行政区\", \"澳门特别行政区\"],\n",
|
||||
" \"iso_3166_2\": [\"CN-71\", \"CN-91\", \"CN-92\"],\n",
|
||||
" },\n",
|
||||
"), on=\"name_en\", how=\"left\")\n",
|
||||
@@ -2252,7 +2252,7 @@
|
||||
" }\n",
|
||||
")[[\"geometry\", \"iso_3166_2\", \"name\"]].copy()\n",
|
||||
"\n",
|
||||
"# Convert MA01 \u2192 MA-01\n",
|
||||
"# Convert MA01 → MA-01\n",
|
||||
"morocco_copy[\"iso_3166_2\"] = morocco_copy[\n",
|
||||
" \"iso_3166_2\"\n",
|
||||
"].str.replace(\n",
|
||||
@@ -2290,7 +2290,7 @@
|
||||
"source": [
|
||||
"#### Finland\n",
|
||||
"\n",
|
||||
"- The \u00c5land Islands (ISO country code AX) is an autonomous region of Finland, and carries the ISO-3166 code FI-01."
|
||||
"- The Åland Islands (ISO country code AX) is an autonomous region of Finland, and carries the ISO-3166 code FI-01."
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -2312,12 +2312,12 @@
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"finland_aland = df_admin0_10m.loc[\n",
|
||||
" df_admin0_10m.name_en.isin(['\u00c5land']),\n",
|
||||
" df_admin0_10m.name_en.isin(['Åland']),\n",
|
||||
" [x for x in df_admin0_10m.columns if x in df.columns]\n",
|
||||
"]\n",
|
||||
"finland_aland = finland_aland.merge(pd.DataFrame(\n",
|
||||
" data={\n",
|
||||
" \"name_en\": [\"\u00c5land\"],\n",
|
||||
" \"name_en\": [\"Åland\"],\n",
|
||||
" \"name_fi\": [\"Ahvenanmaan maakunta\"],\n",
|
||||
" \"iso_3166_2\": [\"FI-01\"],\n",
|
||||
" },\n",
|
||||
@@ -3197,34 +3197,34 @@
|
||||
"\n",
|
||||
"# Turkey city name corrections\n",
|
||||
"# Fix completely wrong spellings\n",
|
||||
"replace_column('name', turkey, 'Kinkkale', 'K\u0131r\u0131kkale')\n",
|
||||
"replace_column('name', turkey, 'Kinkkale', 'Kırıkkale')\n",
|
||||
"replace_column('name', turkey, 'Zinguldak', 'Zonguldak')\n",
|
||||
"replace_column('name', turkey, 'K. Maras', 'Kahramanmara\u015f')\n",
|
||||
"replace_column('name', turkey, 'K. Maras', 'Kahramanmaraş')\n",
|
||||
"\n",
|
||||
"# Fix missing Turkish characters\n",
|
||||
"replace_column('name', turkey, 'Adiyaman', 'Ad\u0131yaman')\n",
|
||||
"replace_column('name', turkey, 'Agri', 'A\u011fr\u0131')\n",
|
||||
"replace_column('name', turkey, 'Aydin', 'Ayd\u0131n')\n",
|
||||
"replace_column('name', turkey, 'Balikesir', 'Bal\u0131kesir')\n",
|
||||
"replace_column('name', turkey, '\u00c7ankiri', '\u00c7ank\u0131r\u0131')\n",
|
||||
"replace_column('name', turkey, 'Diyarbakir', 'Diyarbak\u0131r')\n",
|
||||
"replace_column('name', turkey, 'Elazig', 'El\u00e2z\u0131\u011f')\n",
|
||||
"replace_column('name', turkey, 'Eskisehir', 'Eski\u015fehir')\n",
|
||||
"replace_column('name', turkey, 'G\u00fcm\u00fcshane', 'G\u00fcm\u00fc\u015fhane')\n",
|
||||
"replace_column('name', turkey, 'Hakkari', 'Hakk\u00e2ri')\n",
|
||||
"replace_column('name', turkey, 'Istanbul', '\u0130stanbul')\n",
|
||||
"replace_column('name', turkey, 'Izmir', '\u0130zmir')\n",
|
||||
"replace_column('name', turkey, 'I\u011fdir', 'I\u011fd\u0131r')\n",
|
||||
"replace_column('name', turkey, 'Kirklareli', 'K\u0131rklareli')\n",
|
||||
"replace_column('name', turkey, 'Kirsehir', 'K\u0131r\u015fehir')\n",
|
||||
"replace_column('name', turkey, 'Mugla', 'Mu\u011fla')\n",
|
||||
"replace_column('name', turkey, 'Mus', 'Mu\u015f')\n",
|
||||
"replace_column('name', turkey, 'Nevsehir', 'Nev\u015fehir')\n",
|
||||
"replace_column('name', turkey, 'Nigde', 'Ni\u011fde')\n",
|
||||
"replace_column('name', turkey, 'Sanliurfa', '\u015eanl\u0131urfa')\n",
|
||||
"replace_column('name', turkey, 'Sirnak', '\u015e\u0131rnak')\n",
|
||||
"replace_column('name', turkey, 'Tekirdag', 'Tekirda\u011f')\n",
|
||||
"replace_column('name', turkey, 'Usak', 'U\u015fak')\n",
|
||||
"replace_column('name', turkey, 'Adiyaman', 'Adıyaman')\n",
|
||||
"replace_column('name', turkey, 'Agri', 'Ağrı')\n",
|
||||
"replace_column('name', turkey, 'Aydin', 'Aydın')\n",
|
||||
"replace_column('name', turkey, 'Balikesir', 'Balıkesir')\n",
|
||||
"replace_column('name', turkey, 'Çankiri', 'Çankırı')\n",
|
||||
"replace_column('name', turkey, 'Diyarbakir', 'Diyarbakır')\n",
|
||||
"replace_column('name', turkey, 'Elazig', 'Elâzığ')\n",
|
||||
"replace_column('name', turkey, 'Eskisehir', 'Eskişehir')\n",
|
||||
"replace_column('name', turkey, 'Gümüshane', 'Gümüşhane')\n",
|
||||
"replace_column('name', turkey, 'Hakkari', 'Hakkâri')\n",
|
||||
"replace_column('name', turkey, 'Istanbul', 'İstanbul')\n",
|
||||
"replace_column('name', turkey, 'Izmir', 'İzmir')\n",
|
||||
"replace_column('name', turkey, 'Iğdir', 'Iğdır')\n",
|
||||
"replace_column('name', turkey, 'Kirklareli', 'Kırklareli')\n",
|
||||
"replace_column('name', turkey, 'Kirsehir', 'Kırşehir')\n",
|
||||
"replace_column('name', turkey, 'Mugla', 'Muğla')\n",
|
||||
"replace_column('name', turkey, 'Mus', 'Muş')\n",
|
||||
"replace_column('name', turkey, 'Nevsehir', 'Nevşehir')\n",
|
||||
"replace_column('name', turkey, 'Nigde', 'Niğde')\n",
|
||||
"replace_column('name', turkey, 'Sanliurfa', 'Şanlıurfa')\n",
|
||||
"replace_column('name', turkey, 'Sirnak', 'Şırnak')\n",
|
||||
"replace_column('name', turkey, 'Tekirdag', 'Tekirdağ')\n",
|
||||
"replace_column('name', turkey, 'Usak', 'Uşak')\n",
|
||||
"turkey_copy = turkey.copy()"
|
||||
]
|
||||
},
|
||||
@@ -3263,18 +3263,18 @@
|
||||
"\n",
|
||||
"# Region names corresponding to NUTS-1\n",
|
||||
"\n",
|
||||
"region_name_dict = {'TR1':'\u0130stanbul',\n",
|
||||
" 'TR2':'Bat\u0131 Marmara',\n",
|
||||
"region_name_dict = {'TR1':'İstanbul',\n",
|
||||
" 'TR2':'Batı Marmara',\n",
|
||||
" 'TR3':'Ege',\n",
|
||||
" 'TR4':'Do\u011fu Marmara',\n",
|
||||
" 'TR5':'Bat\u0131 Anadolu',\n",
|
||||
" 'TR4':'Doğu Marmara',\n",
|
||||
" 'TR5':'Batı Anadolu',\n",
|
||||
" 'TR6':'Akdeniz',\n",
|
||||
" 'TR7':'Orta Anadolu',\n",
|
||||
" 'TR8':'Bat\u0131 Karadeniz',\n",
|
||||
" 'TR9':'Do\u011fu Karadeniz',\n",
|
||||
" 'TRA':'Kuzeydo\u011fu Anadolu',\n",
|
||||
" 'TRC':'G\u00fcneydo\u011fu Anadolu',\n",
|
||||
" 'TRB':'Ortado\u011fu Anadolu'\n",
|
||||
" 'TR8':'Batı Karadeniz',\n",
|
||||
" 'TR9':'Doğu Karadeniz',\n",
|
||||
" 'TRA':'Kuzeydoğu Anadolu',\n",
|
||||
" 'TRC':'Güneydoğu Anadolu',\n",
|
||||
" 'TRB':'Ortadoğu Anadolu'\n",
|
||||
" }\n",
|
||||
"\n",
|
||||
"\n",
|
||||
@@ -3517,8 +3517,8 @@
|
||||
"france_copy = france.copy()\n",
|
||||
"reposition(france_copy, france.name=='Guadeloupe', 57.4, 25.4, 1.5, 1.5)\n",
|
||||
"reposition(france_copy, france.name=='Martinique', 58.4, 27.1, 1.5, 1.5)\n",
|
||||
"reposition(france_copy, france.name=='Guyane fran\u00e7aise', 52, 37.7, 0.35, 0.35)\n",
|
||||
"reposition(france_copy, france.name=='La R\u00e9union', -55, 62.8, 1.5, 1.5)\n",
|
||||
"reposition(france_copy, france.name=='Guyane française', 52, 37.7, 0.35, 0.35)\n",
|
||||
"reposition(france_copy, france.name=='La Réunion', -55, 62.8, 1.5, 1.5)\n",
|
||||
"reposition(france_copy, france.name=='Mayotte', -43, 54.3, 1.5, 1.5)\n",
|
||||
"\n",
|
||||
"not speed_run and france_copy.plot(figsize=(8, 8), **plot_styles)"
|
||||
@@ -3669,8 +3669,8 @@
|
||||
"france_overseas = france.copy()\n",
|
||||
"reposition(france_overseas, france.name=='Guadeloupe', 53.2, 29, 1.5, 1.5)\n",
|
||||
"reposition(france_overseas, france.name=='Martinique', 52.8, 27.5, 1.5, 1.5)\n",
|
||||
"reposition(france_overseas, france.name=='Guyane fran\u00e7aise', 45, 35.5, 0.3, 0.3)\n",
|
||||
"reposition(france_overseas, france.name=='La R\u00e9union', -58.2, 60.5, 1.5, 1.5)\n",
|
||||
"reposition(france_overseas, france.name=='Guyane française', 45, 35.5, 0.3, 0.3)\n",
|
||||
"reposition(france_overseas, france.name=='La Réunion', -58.2, 60.5, 1.5, 1.5)\n",
|
||||
"reposition(france_overseas, france.name=='Mayotte', -50.5, 52.2, 2, 2)\n",
|
||||
"\n",
|
||||
"# Tahiti\n",
|
||||
@@ -3713,7 +3713,7 @@
|
||||
"france_overseas = pd.concat([france_overseas, saint_martin_data], ignore_index=True)\n",
|
||||
"reposition(france_overseas, france_overseas.admin=='Saint Martin', 54.8, 30.3, 5, 5)\n",
|
||||
"\n",
|
||||
"# Saint Barth\u00e9l\u00e9my\n",
|
||||
"# Saint Barthélémy\n",
|
||||
"saint_barthelemy_data = df[(df['admin'] == 'Saint Barthelemy')]\n",
|
||||
"france_overseas = pd.concat([france_overseas, saint_barthelemy_data], ignore_index=True)\n",
|
||||
"reposition(france_overseas, france_overseas.admin=='Saint Barthelemy', 54.5, 30, 8, 8)\n",
|
||||
@@ -3729,13 +3729,13 @@
|
||||
"france_overseas = pd.concat([france_overseas, paris_and_littlecrowndpts_copy], ignore_index=True)\n",
|
||||
"\n",
|
||||
"# Update metadata properly\n",
|
||||
"france_overseas.loc[france_overseas['name'] == 'Windward Islands', ['name', 'iso_3166_2']] = ['Polyn\u00e9sie fran\u00e7aise', 'FR-PF']\n",
|
||||
"france_overseas.loc[france_overseas['name'] == 'Archipel des Kerguelen', ['name', 'iso_3166_2']] = ['Terres australes et antarctiques fran\u00e7aises', 'FR-TF']\n",
|
||||
"france_overseas.loc[france_overseas['name'] == 'Windward Islands', ['name', 'iso_3166_2']] = ['Polynésie française', 'FR-PF']\n",
|
||||
"france_overseas.loc[france_overseas['name'] == 'Archipel des Kerguelen', ['name', 'iso_3166_2']] = ['Terres australes et antarctiques françaises', 'FR-TF']\n",
|
||||
"france_overseas.loc[france_overseas['admin'] == 'Wallis and Futuna', ['name', 'iso_3166_2']] = ['Wallis et Futuna', 'FR-WF']\n",
|
||||
"france_overseas.loc[france_overseas['admin'] == 'New Caledonia', ['name', 'iso_3166_2']] = ['Nouvelle-Cal\u00e9donie', 'FR-NC']\n",
|
||||
"france_overseas.loc[france_overseas['admin'] == 'New Caledonia', ['name', 'iso_3166_2']] = ['Nouvelle-Calédonie', 'FR-NC']\n",
|
||||
"france_overseas.loc[france_overseas['admin'] == 'Saint Pierre and Miquelon', ['name', 'iso_3166_2']] = ['Saint-Pierre-et-Miquelon', 'FR-PM']\n",
|
||||
"france_overseas.loc[france_overseas['admin'] == 'Saint Martin', ['name', 'iso_3166_2']] = ['Saint-Martin', 'FR-MF']\n",
|
||||
"france_overseas.loc[france_overseas['admin'] == 'Saint Barthelemy', ['name', 'iso_3166_2']] = ['Saint-Barth\u00e9l\u00e9my', 'FR-BL']\n",
|
||||
"france_overseas.loc[france_overseas['admin'] == 'Saint Barthelemy', ['name', 'iso_3166_2']] = ['Saint-Barthélémy', 'FR-BL']\n",
|
||||
"\n",
|
||||
"# Plot data\n",
|
||||
"france_overseas = france_overseas.rename(columns={'NAME_1': 'name','ISO': 'iso_3166_2'})\n",
|
||||
@@ -3821,51 +3821,6 @@
|
||||
"not speed_run and italy_regions.plot(figsize=(10, 7), **plot_styles)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {
|
||||
"id": "65aIalqEt1LR"
|
||||
},
|
||||
"source": [
|
||||
"#### Italy Regions and Autonomous Provinces"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {
|
||||
"execution": {
|
||||
"iopub.execute_input": "2026-07-27T19:54:28.892892Z",
|
||||
"iopub.status.busy": "2026-07-27T19:54:28.892454Z",
|
||||
"iopub.status.idle": "2026-07-27T19:54:31.123499Z",
|
||||
"shell.execute_reply": "2026-07-27T19:54:31.122932Z"
|
||||
}
|
||||
},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"trento_and_bozen = df[(df.admin == 'Italy') & (df.iso_3166_2.isin(['IT-TN', 'IT-BZ']))][['geometry','iso_3166_2','name']]\n",
|
||||
"\n",
|
||||
"italy_regions_and_autonomous_provinces = pd.concat([italy_regions, trento_and_bozen])\n",
|
||||
"\n",
|
||||
"italy_regions_and_autonomous_provinces = italy_regions_and_autonomous_provinces[italy_regions_and_autonomous_provinces['iso_3166_2'] != 'IT-32']"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {
|
||||
"execution": {
|
||||
"iopub.execute_input": "2026-07-27T19:54:28.892892Z",
|
||||
"iopub.status.busy": "2026-07-27T19:54:28.892454Z",
|
||||
"iopub.status.idle": "2026-07-27T19:54:31.123499Z",
|
||||
"shell.execute_reply": "2026-07-27T19:54:31.122932Z"
|
||||
}
|
||||
},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"not speed_run and italy_regions_and_autonomous_provinces.plot(figsize=(10, 7), **plot_styles)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {
|
||||
@@ -4376,86 +4331,86 @@
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"Kon Tum\n",
|
||||
"\u0110\u1eafk N\u00f4ng\n",
|
||||
"\u0110\u1eafk L\u1eafk\n",
|
||||
"Đắk Nông\n",
|
||||
"Đắk Lắk\n",
|
||||
"Gia Lai\n",
|
||||
"B\u00ecnh Ph\u01b0\u1edbc\n",
|
||||
"T\u00e2y Ninh\n",
|
||||
"Bình Phước\n",
|
||||
"Tây Ninh\n",
|
||||
"Long An\n",
|
||||
"\u0110\u1ed3ng Th\u00e1p\n",
|
||||
"Đồng Tháp\n",
|
||||
"An Giang\n",
|
||||
"Ki\u00ean Giang\n",
|
||||
"\u0110i\u1ec7n Bi\u00ean\n",
|
||||
"S\u01a1n La\n",
|
||||
"Thanh H\u00f3a\n",
|
||||
"Ngh\u1ec7 An\n",
|
||||
"H\u00e0 T\u0129nh\n",
|
||||
"Qu\u1ea3ng B\u00ecnh\n",
|
||||
"Qu\u1ea3ng Tr\u1ecb\n",
|
||||
"Th\u1eeba Thi\u00ean - Hu\u1ebf\n",
|
||||
"Qu\u1ea3ng Nam\n",
|
||||
"H\u00e0 Giang\n",
|
||||
"Cao B\u1eb1ng\n",
|
||||
"L\u00e0o Cai\n",
|
||||
"Lai Ch\u00e2u\n",
|
||||
"L\u1ea1ng S\u01a1n\n",
|
||||
"Qu\u1ea3ng Ninh\n",
|
||||
"S\u00f3c Tr\u0103ng\n",
|
||||
"Ti\u1ec1n Giang\n",
|
||||
"B\u00e0 R\u1ecba - V\u0169ng T\u00e0u\n",
|
||||
"Th\u00e0nh ph\u1ed1 H\u1ed3 Ch\u00ed Minh\n",
|
||||
"Kh\u00e1nh H\u00f2a\n",
|
||||
"C\u00e0 Mau\n",
|
||||
"B\u1ea1c Li\u00eau\n",
|
||||
"H\u1eadu Giang\n",
|
||||
"V\u0129nh Long\n",
|
||||
"Tr\u00e0 Vinh\n",
|
||||
"B\u1ebfn Tre\n",
|
||||
"\u0110\u1ed3ng Nai\n",
|
||||
"B\u00ecnh Thu\u1eadn\n",
|
||||
"Ninh Thu\u1eadn\n",
|
||||
"Ph\u00fa Y\u00ean\n",
|
||||
"B\u00ecnh \u0110\u1ecbnh\n",
|
||||
"Qu\u1ea3ng Ng\u00e3i\n",
|
||||
"\u0110\u00e0 N\u1eb5ng\n",
|
||||
"Ninh B\u00ecnh\n",
|
||||
"Nam \u0110\u1ecbnh\n",
|
||||
"Th\u00e1i B\u00ecnh\n",
|
||||
"H\u1ea3i Ph\u00f2ng\n",
|
||||
"H\u00f2a B\u00ecnh\n",
|
||||
"Tuy\u00ean Quang\n",
|
||||
"Y\u00ean B\u00e1i\n",
|
||||
"V\u0129nh Ph\u00fac\n",
|
||||
"Ph\u00fa Th\u1ecd\n",
|
||||
"H\u00e0 N\u1ed9i\n",
|
||||
"B\u1eafc K\u1ea1n\n",
|
||||
"H\u01b0ng Y\u00ean\n",
|
||||
"B\u1eafc Ninh\n",
|
||||
"B\u1eafc Giang\n",
|
||||
"Th\u00e1i Nguy\u00ean\n",
|
||||
"H\u1ea3i D\u01b0\u01a1ng\n",
|
||||
"H\u00e0 Nam\n",
|
||||
"B\u00ecnh D\u01b0\u01a1ng\n",
|
||||
"L\u00e2m \u0110\u1ed3ng\n",
|
||||
"C\u1ea7n Th\u01a1\n"
|
||||
"Kiên Giang\n",
|
||||
"Điện Biên\n",
|
||||
"Sơn La\n",
|
||||
"Thanh Hóa\n",
|
||||
"Nghệ An\n",
|
||||
"Hà Tĩnh\n",
|
||||
"Quảng Bình\n",
|
||||
"Quảng Trị\n",
|
||||
"Thừa Thiên - Huế\n",
|
||||
"Quảng Nam\n",
|
||||
"Hà Giang\n",
|
||||
"Cao Bằng\n",
|
||||
"Lào Cai\n",
|
||||
"Lai Châu\n",
|
||||
"Lạng Sơn\n",
|
||||
"Quảng Ninh\n",
|
||||
"Sóc Trăng\n",
|
||||
"Tiền Giang\n",
|
||||
"Bà Rịa - Vũng Tàu\n",
|
||||
"Thành phố Hồ Chí Minh\n",
|
||||
"Khánh Hòa\n",
|
||||
"Cà Mau\n",
|
||||
"Bạc Liêu\n",
|
||||
"Hậu Giang\n",
|
||||
"Vĩnh Long\n",
|
||||
"Trà Vinh\n",
|
||||
"Bến Tre\n",
|
||||
"Đồng Nai\n",
|
||||
"Bình Thuận\n",
|
||||
"Ninh Thuận\n",
|
||||
"Phú Yên\n",
|
||||
"Bình Định\n",
|
||||
"Quảng Ngãi\n",
|
||||
"Đà Nẵng\n",
|
||||
"Ninh Bình\n",
|
||||
"Nam Định\n",
|
||||
"Thái Bình\n",
|
||||
"Hải Phòng\n",
|
||||
"Hòa Bình\n",
|
||||
"Tuyên Quang\n",
|
||||
"Yên Bái\n",
|
||||
"Vĩnh Phúc\n",
|
||||
"Phú Thọ\n",
|
||||
"Hà Nội\n",
|
||||
"Bắc Kạn\n",
|
||||
"Hưng Yên\n",
|
||||
"Bắc Ninh\n",
|
||||
"Bắc Giang\n",
|
||||
"Thái Nguyên\n",
|
||||
"Hải Dương\n",
|
||||
"Hà Nam\n",
|
||||
"Bình Dương\n",
|
||||
"Lâm Đồng\n",
|
||||
"Cần Thơ\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"vietnam = df[df.admin == 'Vietnam']\n",
|
||||
"vietnam_copy = vietnam.copy()\n",
|
||||
"replace_column('name', vietnam_copy, '\u00d0ong Th\u00e1p', '\u0110\u1ed3ng Th\u00e1p')\n",
|
||||
"replace_column('name', vietnam_copy, 'Son La', 'S\u01a1n La')\n",
|
||||
"replace_column('name', vietnam_copy, 'Ha Tinh', 'H\u00e0 T\u0129nh')\n",
|
||||
"replace_column('name', vietnam_copy, 'Qu\u00e0ng Nam', 'Qu\u1ea3ng Nam')\n",
|
||||
"replace_column('name', vietnam_copy, 'Lai Chau', 'Lai Ch\u00e2u')\n",
|
||||
"replace_column('name', vietnam_copy, 'H\u1ed3 Ch\u00ed Minh city', 'Th\u00e0nh ph\u1ed1 H\u1ed3 Ch\u00ed Minh')\n",
|
||||
"replace_column('name', vietnam_copy, 'Hau Giang', 'H\u1eadu Giang')\n",
|
||||
"replace_column('name', vietnam_copy, 'Ha Noi', 'H\u00e0 N\u1ed9i')\n",
|
||||
"replace_column('name', vietnam_copy, 'Can Tho', 'C\u1ea7n Th\u01a1')\n",
|
||||
"replace_column('name', vietnam_copy, '\u0110\u00f4ng Nam B\u1ed9', '\u0110\u1ed3ng Nai')\n",
|
||||
"replace_column('name', vietnam_copy, '\u0110\u00f4ng B\u1eafc', 'B\u1eafc K\u1ea1n')\n",
|
||||
"replace_column('name', vietnam_copy, '\u0110\u1ed3ng B\u1eb1ng S\u00f4ng H\u1ed3ng', 'H\u01b0ng Y\u00ean')\n",
|
||||
"replace_column('name', vietnam_copy, 'Ðong Tháp', 'Đồng Tháp')\n",
|
||||
"replace_column('name', vietnam_copy, 'Son La', 'Sơn La')\n",
|
||||
"replace_column('name', vietnam_copy, 'Ha Tinh', 'Hà Tĩnh')\n",
|
||||
"replace_column('name', vietnam_copy, 'Quàng Nam', 'Quảng Nam')\n",
|
||||
"replace_column('name', vietnam_copy, 'Lai Chau', 'Lai Châu')\n",
|
||||
"replace_column('name', vietnam_copy, 'Hồ Chí Minh city', 'Thành phố Hồ Chí Minh')\n",
|
||||
"replace_column('name', vietnam_copy, 'Hau Giang', 'Hậu Giang')\n",
|
||||
"replace_column('name', vietnam_copy, 'Ha Noi', 'Hà Nội')\n",
|
||||
"replace_column('name', vietnam_copy, 'Can Tho', 'Cần Thơ')\n",
|
||||
"replace_column('name', vietnam_copy, 'Đông Nam Bộ', 'Đồng Nai')\n",
|
||||
"replace_column('name', vietnam_copy, 'Đông Bắc', 'Bắc Kạn')\n",
|
||||
"replace_column('name', vietnam_copy, 'Đồng Bằng Sông Hồng', 'Hưng Yên')\n",
|
||||
"for i in vietnam_copy['name']:\n",
|
||||
" print(i)"
|
||||
]
|
||||
@@ -4499,7 +4454,6 @@
|
||||
" \"turkey\": turkey_copy,\n",
|
||||
" \"turkey_regions\": turkey_regions,\n",
|
||||
" \"italy_regions\": italy_regions,\n",
|
||||
" \"italy_regions_and_autonomous_provinces\": italy_regions_and_autonomous_provinces,\n",
|
||||
" \"philippines_regions\": philippines_regions,\n",
|
||||
" \"latvia\": latvia_copy,\n",
|
||||
" \"netherlands\": netherlands_copy,\n",
|
||||
@@ -4538,7 +4492,7 @@
|
||||
"aruba has only one subdivision - removing from countries array\n",
|
||||
"british indian ocean territory has only one subdivision - removing from countries array\n",
|
||||
"cayman islands has only one subdivision - removing from countries array\n",
|
||||
"cura\u00e7ao has only one subdivision - removing from countries array\n",
|
||||
"curaçao has only one subdivision - removing from countries array\n",
|
||||
"falkland islands has only one subdivision - removing from countries array\n",
|
||||
"faroe islands has only one subdivision - removing from countries array\n",
|
||||
"gibraltar has only one subdivision - removing from countries array\n",
|
||||
@@ -4574,7 +4528,7 @@
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"cura\u00e7ao has only one subdivision - removing from countries array\n",
|
||||
"curaçao has only one subdivision - removing from countries array\n",
|
||||
"falkland islands has only one subdivision - removing from countries array\n",
|
||||
"faroe islands has only one subdivision - removing from countries array\n"
|
||||
]
|
||||
|
||||
@@ -103,7 +103,6 @@ import iran from './countries/iran.geojson';
|
||||
import israel from './countries/israel.geojson';
|
||||
import italy from './countries/italy.geojson';
|
||||
import italy_regions from './countries/italy_regions.geojson';
|
||||
import italy_regions_and_autonomous_provinces from './countries/italy_regions_and_autonomous_provinces.geojson';
|
||||
import ivory_coast from './countries/ivory_coast.geojson';
|
||||
import japan from './countries/japan.geojson';
|
||||
import jordan from './countries/jordan.geojson';
|
||||
@@ -307,7 +306,6 @@ export const countries = {
|
||||
israel,
|
||||
italy,
|
||||
italy_regions,
|
||||
italy_regions_and_autonomous_provinces,
|
||||
ivory_coast,
|
||||
japan,
|
||||
jordan,
|
||||
@@ -432,9 +430,6 @@ export const countryOptions = Object.keys(countries).map(x => {
|
||||
if (x === 'italy_regions') {
|
||||
return [x, 'Italy (regions)'];
|
||||
}
|
||||
if (x === 'italy_regions_and_autonomous_provinces') {
|
||||
return [x, 'Italy (regions and autonomous provinces)'];
|
||||
}
|
||||
if (x === 'france_regions') {
|
||||
return [x, 'France (regions)'];
|
||||
}
|
||||
|
||||
@@ -33,6 +33,6 @@
|
||||
{ "type": "Feature", "properties": { "ISO": "IR-25", "NAME_1": "Yazd" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 53.650710076708663, 32.61286754000497 ], [ 54.70904341032508, 32.920083929729572 ], [ 54.814670038291581, 32.970700994954939 ], [ 54.908204380227914, 33.088161526533213 ], [ 54.986339146034709, 33.329955553206219 ], [ 55.040082635105477, 33.385688585459832 ], [ 55.139404737638529, 33.430362861356969 ], [ 55.217229445082808, 33.437261664194409 ], [ 55.274590285313366, 33.470644639738339 ], [ 55.359856397954388, 33.594642238948154 ], [ 55.386418085026548, 33.665464788968791 ], [ 55.375255974983247, 34.344363918859756 ], [ 55.484706658785228, 34.365163682857656 ], [ 55.741125116131172, 34.360331935770205 ], [ 55.850885858295669, 34.407254137268581 ], [ 56.210450474010145, 34.906060898691521 ], [ 56.619211053447316, 34.993678289721288 ], [ 57.016602818165495, 35.148294175835531 ], [ 57.268370395577278, 35.201314195393763 ], [ 57.37957807766611, 35.177336330707078 ], [ 57.671239861630283, 34.993936672139682 ], [ 57.705553013161023, 34.930994777786736 ], [ 57.692013788105783, 34.86686432502853 ], [ 57.595172154271495, 34.788807075386217 ], [ 57.198917271115135, 34.557735908285508 ], [ 57.13080773289056, 34.456295071360444 ], [ 57.004820591397163, 34.142567449728006 ], [ 56.9966557148893, 33.973482164370353 ], [ 57.05887413883039, 33.685256863513416 ], [ 57.105796340328823, 33.623581041032196 ], [ 57.304440545394812, 33.603737291442826 ], [ 57.559928826753946, 33.653243313528094 ], [ 57.602716913155007, 33.607716376009932 ], [ 57.642301060445561, 33.54405101214445 ], [ 57.71702518066752, 33.121699530808655 ], [ 57.782240838144162, 32.996797594033694 ], [ 58.04062300025123, 32.871068834059429 ], [ 58.147696568142067, 32.748595689139734 ], [ 58.152554151852598, 32.671882025734874 ], [ 58.10036095639299, 32.568141588163769 ], [ 58.224384393125206, 32.352547512457704 ], [ 58.222213982789071, 32.297563788138291 ], [ 58.173948195053129, 32.146048489246368 ], [ 58.040726353038735, 31.994533189455126 ], [ 58.003932732809346, 31.907200019265474 ], [ 57.901199985590836, 31.771549384496495 ], [ 57.834744093764868, 31.637268175286067 ], [ 56.761631300743886, 32.03008657533519 ], [ 56.634093865639386, 32.049181016990303 ], [ 56.566397738564774, 31.978926906851257 ], [ 56.358451776328366, 31.879036363537352 ], [ 56.287758416617578, 31.815086777932436 ], [ 55.756834750623227, 31.576264146373262 ], [ 55.712289666834636, 31.495183823874356 ], [ 55.684487746312413, 31.110917873960716 ], [ 55.514989048006157, 31.046787421202509 ], [ 55.32637006962301, 31.024773261276948 ], [ 55.116460401726158, 31.043247586207144 ], [ 54.554427525110157, 30.957774767091792 ], [ 54.466474237296211, 30.873361314273211 ], [ 54.420275506209634, 30.797913722740077 ], [ 54.398054640709063, 30.725075792014195 ], [ 54.400638462195275, 30.675466417141422 ], [ 54.515566848231458, 30.450441393055826 ], [ 54.539441359231319, 30.350550848842602 ], [ 54.603830194407919, 30.297349962131022 ], [ 54.591221144440226, 29.973106187400617 ], [ 54.616749301838809, 29.847971707528245 ], [ 54.430197381004291, 29.793220527205563 ], [ 54.227212355265976, 29.882879137362295 ], [ 54.071769646851692, 29.984268297443975 ], [ 54.043140904029485, 30.044161282317305 ], [ 54.007587518149421, 30.263191840231229 ], [ 53.964075962235825, 30.329983628841376 ], [ 53.80460249241105, 30.499068915098348 ], [ 53.638411086002804, 30.755151476559377 ], [ 53.404936964569231, 31.261502996865772 ], [ 53.276469354377184, 31.395086574985442 ], [ 53.125160760160952, 31.51394236874529 ], [ 52.870706007576189, 31.597451483998782 ], [ 52.827091098875087, 31.744419257542688 ], [ 52.824093866238911, 31.813846544482431 ], [ 52.904915806319366, 32.164600328542292 ], [ 52.883728468693846, 32.505199692911503 ], [ 53.060358513834331, 32.576694037399875 ], [ 53.164641554664001, 32.640333563742956 ], [ 53.261069777348325, 32.672011216944099 ], [ 53.334036900182753, 32.67412995043685 ], [ 53.650710076708663, 32.61286754000497 ] ] ] } },
|
||||
{ "type": "Feature", "properties": { "ISO": "IR-26", "NAME_1": "Qom" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 51.788911574109818, 34.54197459605075 ], [ 51.451671176683419, 34.469730942728802 ], [ 51.158355747219957, 34.452522691469028 ], [ 51.063064406097453, 34.418235379259329 ], [ 51.013765089587196, 34.357903143914939 ], [ 50.983689405941277, 34.161610216338374 ], [ 51.004670037991843, 34.11838288036563 ], [ 51.032988723350854, 34.105799668819657 ], [ 50.802201776190998, 34.157889513290399 ], [ 50.699262323397477, 34.20488922825524 ], [ 50.441190219652867, 34.224784653788731 ], [ 50.32874230321471, 34.317879747052643 ], [ 50.30445438016551, 34.367463284403016 ], [ 50.30869184805033, 34.408520209140306 ], [ 50.264146763362419, 34.466423651730111 ], [ 50.158313429820907, 34.492106838759582 ], [ 50.060024855162908, 34.577062893038089 ], [ 50.069429966020095, 34.628739325459492 ], [ 50.162757603280738, 34.671992498954637 ], [ 50.173402947587931, 34.692042955018337 ], [ 50.152629022011752, 34.716124172492528 ], [ 50.153972609148582, 34.781494858700739 ], [ 50.21112674290481, 34.819373684547884 ], [ 50.301147088267498, 34.809374295387386 ], [ 50.388170200094635, 34.829915676067571 ], [ 50.447598097873879, 34.862058417262119 ], [ 50.57151818181859, 34.878129787859393 ], [ 50.693681268375826, 34.915956935963777 ], [ 50.723033481609889, 35.107883206244935 ], [ 50.784735141613453, 35.218419093866089 ], [ 51.072159457692806, 35.213251450893722 ], [ 51.31235151570985, 35.153952745222966 ], [ 51.882342564157966, 34.875494290429117 ], [ 51.893194614040169, 34.754002997440352 ], [ 51.866116163929803, 34.66646312077637 ], [ 51.788911574109818, 34.54197459605075 ] ] ] } },
|
||||
{ "type": "Feature", "properties": { "ISO": "IR-07", "NAME_1": "Tehran" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 50.784735141613453, 35.218419093866089 ], [ 50.8712414886038, 35.4471906603207 ], [ 50.870828078353099, 35.517031358410463 ], [ 50.798687778717976, 35.604777939750079 ], [ 50.825095940725078, 35.654454591106457 ], [ 50.889640414373616, 35.686726828380415 ], [ 50.942082799381637, 35.739169213388493 ], [ 50.970321006209417, 35.799679657490174 ], [ 51.059069657138934, 35.803713687037032 ], [ 51.075205776225573, 35.916666515247471 ], [ 51.135716220327254, 35.97314292980235 ], [ 51.216396812163111, 35.997347107083328 ], [ 51.297077403998969, 35.989279047989669 ], [ 51.353553818553848, 36.017517255716768 ], [ 51.356896600397761, 36.116271267305137 ], [ 51.465003697062969, 36.064698188570503 ], [ 51.618275995141062, 36.054052843363991 ], [ 51.754391717004125, 36.010980536122815 ], [ 51.856504348396925, 35.921554469962814 ], [ 51.950245395908269, 35.799804796354238 ], [ 52.029206984015048, 35.77091767021426 ], [ 52.108168573021203, 35.767171128744565 ], [ 52.177001580758315, 35.789960435925366 ], [ 52.306812778986512, 35.917394518242418 ], [ 52.398486768949226, 35.976202298397311 ], [ 52.625863072322886, 35.931347154447622 ], [ 52.740274692622961, 35.881014309162993 ], [ 52.816342400881069, 35.86825023046373 ], [ 52.901401807947025, 35.889695950507701 ], [ 52.944810011972436, 35.881556912421559 ], [ 53.0347270036483, 35.831094875927761 ], [ 53.066249628117816, 35.718905341008679 ], [ 53.079375441123659, 35.618136298551292 ], [ 53.047232699929111, 35.528374334707735 ], [ 52.888275994941182, 35.410190335415621 ], [ 52.674955682358814, 35.336189683806822 ], [ 52.594133742278359, 35.338721829348913 ], [ 52.21968631437187, 35.414221095926791 ], [ 51.982078077840981, 35.54431651499516 ], [ 51.922443475386046, 35.54684866053725 ], [ 51.870146926239613, 35.569818833971965 ], [ 51.853403762073924, 35.555452785717478 ], [ 51.821054315304366, 35.403420721988709 ], [ 51.822501255228701, 35.315260727700377 ], [ 51.980631137916646, 35.125246487135655 ], [ 51.968022087948953, 35.063725694285324 ], [ 51.916862420364396, 34.998587551174523 ], [ 51.882342564157966, 34.875494290429117 ], [ 51.31235151570985, 35.153952745222966 ], [ 51.072159457692806, 35.213251450893722 ], [ 50.784735141613453, 35.218419093866089 ] ] ] } },
|
||||
{ "type": "Feature", "properties": { "ISO": "IR-32", "NAME_1": "Alborz" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 50.696471795436992, 35.550543525163562 ], [ 50.689753858853464, 35.639246120911707 ], [ 50.650996534762271, 35.676478989813518 ], [ 50.594772576992796, 35.677047431493747 ], [ 50.581956822349468, 35.650072333271567 ], [ 50.620714146440662, 35.60635407178296 ], [ 50.610378858697345, 35.574831448212763 ], [ 50.527696568441854, 35.62911754054204 ], [ 50.285540805663629, 35.666014513558935 ], [ 50.238618605064573, 35.741022853721688 ], [ 50.2302470229817, 35.771925361466231 ], [ 50.242752720161889, 35.81083771428905 ], [ 50.297736443581982, 35.853961697474347 ], [ 50.500204706282148, 35.934266872717956 ], [ 50.534517856913567, 35.960802721368452 ], [ 50.625881789413029, 36.164769599037754 ], [ 50.47188602092308, 36.239648748890602 ], [ 50.420829706126028, 36.296389472396186 ], [ 50.449665155422565, 36.331684474958536 ], [ 50.563146599735774, 36.339177557897983 ], [ 50.966222772263109, 36.292203681354806 ], [ 51.029888137027854, 36.27179149008515 ], [ 51.086008742009824, 36.222259630477538 ], [ 51.127970005211523, 36.20745433175199 ], [ 51.291991001283634, 36.178102118517927 ], [ 51.356896600397761, 36.116271267305137 ], [ 51.353553818553848, 36.017517255716768 ], [ 51.297077403998969, 35.989279047989669 ], [ 51.216396812163111, 35.997347107083328 ], [ 51.135716220327254, 35.97314292980235 ], [ 51.075205776225573, 35.916666515247471 ], [ 51.059069657138934, 35.803713687037032 ], [ 50.970321006209417, 35.799679657490174 ], [ 50.942082799381637, 35.739169213388493 ], [ 50.889640414373616, 35.686726828380415 ], [ 50.825095940725078, 35.654454591106457 ], [ 50.798687778717976, 35.604777939750079 ], [ 50.75269575410573, 35.601341458441539 ], [ 50.724893832684188, 35.555168564877363 ], [ 50.696471795436992, 35.550543525163562 ] ] ] } }
|
||||
{ "type": "Feature", "properties": { "ISO": "IR-30", "NAME_1": "Alborz" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 50.696471795436992, 35.550543525163562 ], [ 50.689753858853464, 35.639246120911707 ], [ 50.650996534762271, 35.676478989813518 ], [ 50.594772576992796, 35.677047431493747 ], [ 50.581956822349468, 35.650072333271567 ], [ 50.620714146440662, 35.60635407178296 ], [ 50.610378858697345, 35.574831448212763 ], [ 50.527696568441854, 35.62911754054204 ], [ 50.285540805663629, 35.666014513558935 ], [ 50.238618605064573, 35.741022853721688 ], [ 50.2302470229817, 35.771925361466231 ], [ 50.242752720161889, 35.81083771428905 ], [ 50.297736443581982, 35.853961697474347 ], [ 50.500204706282148, 35.934266872717956 ], [ 50.534517856913567, 35.960802721368452 ], [ 50.625881789413029, 36.164769599037754 ], [ 50.47188602092308, 36.239648748890602 ], [ 50.420829706126028, 36.296389472396186 ], [ 50.449665155422565, 36.331684474958536 ], [ 50.563146599735774, 36.339177557897983 ], [ 50.966222772263109, 36.292203681354806 ], [ 51.029888137027854, 36.27179149008515 ], [ 51.086008742009824, 36.222259630477538 ], [ 51.127970005211523, 36.20745433175199 ], [ 51.291991001283634, 36.178102118517927 ], [ 51.356896600397761, 36.116271267305137 ], [ 51.353553818553848, 36.017517255716768 ], [ 51.297077403998969, 35.989279047989669 ], [ 51.216396812163111, 35.997347107083328 ], [ 51.135716220327254, 35.97314292980235 ], [ 51.075205776225573, 35.916666515247471 ], [ 51.059069657138934, 35.803713687037032 ], [ 50.970321006209417, 35.799679657490174 ], [ 50.942082799381637, 35.739169213388493 ], [ 50.889640414373616, 35.686726828380415 ], [ 50.825095940725078, 35.654454591106457 ], [ 50.798687778717976, 35.604777939750079 ], [ 50.75269575410573, 35.601341458441539 ], [ 50.724893832684188, 35.555168564877363 ], [ 50.696471795436992, 35.550543525163562 ] ] ] } }
|
||||
]
|
||||
}
|
||||
|
||||
-28
File diff suppressed because one or more lines are too long
@@ -1,58 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { countryOptions } from '../src/countries';
|
||||
|
||||
type ItalyFeature = { properties: { ISO: string; NAME_1: string } };
|
||||
|
||||
test('countryOptions includes labeled entries for the Italy region variants', () => {
|
||||
expect(countryOptions).toContainEqual(['italy_regions', 'Italy (regions)']);
|
||||
expect(countryOptions).toContainEqual([
|
||||
'italy_regions_and_autonomous_provinces',
|
||||
'Italy (regions and autonomous provinces)',
|
||||
]);
|
||||
});
|
||||
|
||||
test('italy_regions_and_autonomous_provinces geojson has the expected shape', () => {
|
||||
// jest maps `.geojson` imports to an empty object mock, so the file is
|
||||
// read from disk directly to verify its actual shape.
|
||||
const geojsonPath = path.join(
|
||||
__dirname,
|
||||
'../src/countries/italy_regions_and_autonomous_provinces.geojson',
|
||||
);
|
||||
const geojson = JSON.parse(fs.readFileSync(geojsonPath, 'utf-8'));
|
||||
const features: ItalyFeature[] = geojson.features;
|
||||
|
||||
expect(features).toHaveLength(21);
|
||||
features.forEach(feature => {
|
||||
expect(feature.properties).toEqual(
|
||||
expect.objectContaining({
|
||||
ISO: expect.any(String),
|
||||
NAME_1: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const isoCodes = features.map(feature => feature.properties.ISO);
|
||||
expect(new Set(isoCodes).size).toBe(isoCodes.length);
|
||||
expect(isoCodes).toContain('IT-BZ');
|
||||
expect(isoCodes).toContain('IT-TN');
|
||||
expect(isoCodes).not.toContain('IT-32');
|
||||
});
|
||||
@@ -1,69 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
type Feature = {
|
||||
properties: {
|
||||
ISO: string;
|
||||
NAME_1: string;
|
||||
};
|
||||
};
|
||||
|
||||
// `.geojson` imports are mocked out to an empty object by the Jest module
|
||||
// mapper (see jest.config.js), so the file is read from disk directly to
|
||||
// exercise the real, committed data.
|
||||
function loadIranGeoJson(): { features: Feature[] } {
|
||||
const filePath = path.join(__dirname, '../src/countries/iran.geojson');
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
}
|
||||
|
||||
test('every Iranian province has its own distinct ISO 3166-2 code', () => {
|
||||
const { features } = loadIranGeoJson();
|
||||
|
||||
// Pin the feature count too, so dropping a province other than
|
||||
// Tehran/Alborz (which would still leave every remaining ISO code
|
||||
// distinct) doesn't slip past the checks below.
|
||||
expect(features.length).toBe(31);
|
||||
|
||||
// Sanity check: every province name in this file is unique, so a
|
||||
// duplicate ISO code below can only mean two different provinces were
|
||||
// mistakenly assigned the same code (as opposed to one province being
|
||||
// split across multiple polygon features).
|
||||
const names = features.map(feature => feature.properties.NAME_1);
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
|
||||
const isoByName = new Map(
|
||||
features.map(feature => [
|
||||
feature.properties.NAME_1,
|
||||
feature.properties.ISO,
|
||||
]),
|
||||
);
|
||||
const isoCodes = features.map(feature => feature.properties.ISO);
|
||||
|
||||
expect(new Set(isoCodes).size).toBe(isoCodes.length);
|
||||
|
||||
// Tehran and Alborz were split into separate provinces in 2010, but the
|
||||
// GeoJSON still assigned both the same ISO code (IR-07), which used to
|
||||
// make it impossible to distinguish them on the Country Map chart. Alborz
|
||||
// now uses its pre-2020 ISO 3166-2 code, IR-32.
|
||||
expect(isoByName.get('Tehran')).toBe('IR-07');
|
||||
expect(isoByName.get('Alborz')).toBe('IR-32');
|
||||
});
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
getTotalsMetrics,
|
||||
isTimeComparison,
|
||||
timeCompareOperator,
|
||||
toTotalsAggregate,
|
||||
TotalsAggregate,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { TableChartFormData } from './types';
|
||||
@@ -349,7 +349,8 @@ export const buildQuery: BuildQuery<TableChartFormData> = (
|
||||
formData.show_totals &&
|
||||
queryMode === QueryMode.Aggregate
|
||||
) {
|
||||
const totalsAggregate = toTotalsAggregate(formData.totals_aggregate);
|
||||
const totalsAggregate: TotalsAggregate =
|
||||
formData.totals_aggregate === 'AVG' ? 'AVG' : 'SUM';
|
||||
extraQueries.push({
|
||||
...queryObject,
|
||||
columns: [],
|
||||
|
||||
@@ -475,18 +475,14 @@ const config: ControlPanelConfig = {
|
||||
type: 'SelectControl',
|
||||
label: t('Summary aggregation'),
|
||||
description: t(
|
||||
'Aggregation used for the summary row. By default each metric ' +
|
||||
'keeps its own aggregation; Sum and Average override it for ' +
|
||||
'the summary row only. The override applies to simple ' +
|
||||
'metrics (a metric built from custom SQL always keeps its ' +
|
||||
'own aggregation). Overriding a count or a distinct count ' +
|
||||
'sums the counted column instead, which fails outright on a ' +
|
||||
'non-numeric column.',
|
||||
'Aggregation used for the summary row, independent of each ' +
|
||||
"metric's own aggregation. Only applies to simple metrics " +
|
||||
'(a metric built from custom SQL keeps its own aggregation ' +
|
||||
'in the summary row).',
|
||||
),
|
||||
default: 'ORIGINAL',
|
||||
default: 'SUM',
|
||||
clearable: false,
|
||||
choices: [
|
||||
['ORIGINAL', t("Each metric's own")],
|
||||
['SUM', t('Sum')],
|
||||
['AVG', t('Average')],
|
||||
],
|
||||
|
||||
@@ -340,7 +340,7 @@ describe('plugin-chart-table', () => {
|
||||
label: 'sum_sales',
|
||||
};
|
||||
|
||||
test("defaults to each metric's own aggregate", () => {
|
||||
test('defaults the totals query metric aggregate to SUM', () => {
|
||||
const { queries } = buildQueryCached({
|
||||
...basicFormData,
|
||||
query_mode: QueryMode.Aggregate,
|
||||
@@ -350,28 +350,9 @@ describe('plugin-chart-table', () => {
|
||||
});
|
||||
|
||||
expect(queries).toHaveLength(2);
|
||||
expect(queries[1].metrics).toEqual([simpleMetric]);
|
||||
});
|
||||
|
||||
test('keeps COUNT_DISTINCT in the summary row by default', () => {
|
||||
// Overriding this to SUM sums the counted column instead of counting
|
||||
// it, which is meaningless on a numeric id and is rejected outright by
|
||||
// the database on a non-numeric one (e.g. a uuid).
|
||||
const countDistinctMetric = {
|
||||
expressionType: 'SIMPLE' as const,
|
||||
column: { column_name: 'contract_id' },
|
||||
aggregate: 'COUNT_DISTINCT' as const,
|
||||
label: 'contracts',
|
||||
};
|
||||
const { queries } = buildQueryCached({
|
||||
...basicFormData,
|
||||
query_mode: QueryMode.Aggregate,
|
||||
metrics: [countDistinctMetric],
|
||||
groupby: ['category'],
|
||||
show_totals: true,
|
||||
});
|
||||
|
||||
expect(queries[1].metrics).toEqual([countDistinctMetric]);
|
||||
expect(queries[1].metrics).toEqual([
|
||||
{ ...simpleMetric, aggregate: 'SUM' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('overrides simple metric aggregate with totals_aggregate for the summary query only', () => {
|
||||
|
||||
@@ -669,11 +669,11 @@ describe('UploadDataModal Collapse Tabs', () => {
|
||||
useRedux: true,
|
||||
});
|
||||
const generalInfoTab = screen.getByRole('tab', {
|
||||
name: /General information/i,
|
||||
name: /expanded General information/i,
|
||||
});
|
||||
expect(generalInfoTab).toHaveAttribute('aria-expanded', 'true');
|
||||
const fileSettingsTab = screen.getByRole('tab', {
|
||||
name: /File settings/i,
|
||||
name: /collapsed File settings/i,
|
||||
});
|
||||
await userEvent.click(fileSettingsTab);
|
||||
await waitFor(() => {
|
||||
@@ -689,11 +689,11 @@ describe('UploadDataModal Collapse Tabs', () => {
|
||||
useRedux: true,
|
||||
});
|
||||
const generalInfoTab = screen.getByRole('tab', {
|
||||
name: /General information/i,
|
||||
name: /expanded General information/i,
|
||||
});
|
||||
expect(generalInfoTab).toHaveAttribute('aria-expanded', 'true');
|
||||
const fileSettingsTab = screen.getByRole('tab', {
|
||||
name: /File settings/i,
|
||||
name: /collapsed File settings/i,
|
||||
});
|
||||
await userEvent.click(fileSettingsTab);
|
||||
await waitFor(() => {
|
||||
@@ -709,11 +709,11 @@ describe('UploadDataModal Collapse Tabs', () => {
|
||||
useRedux: true,
|
||||
});
|
||||
const generalInfoTab = screen.getByRole('tab', {
|
||||
name: /General information/i,
|
||||
name: /expanded General information/i,
|
||||
});
|
||||
expect(generalInfoTab).toHaveAttribute('aria-expanded', 'true');
|
||||
const fileSettingsTab = screen.getByRole('tab', {
|
||||
name: /File settings/i,
|
||||
name: /collapsed File settings/i,
|
||||
});
|
||||
await userEvent.click(fileSettingsTab);
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -223,7 +223,7 @@ describe('ChartPage', () => {
|
||||
window.history.pushState(
|
||||
{},
|
||||
'',
|
||||
`/explore/?${URL_PARAMS.dashboardPageId.name}=${dashboardPageId}`,
|
||||
`/?${URL_PARAMS.dashboardPageId.name}=${dashboardPageId}`,
|
||||
);
|
||||
const { getByTestId } = render(<ChartPage />, {
|
||||
useRouter: true,
|
||||
@@ -261,13 +261,13 @@ describe('ChartPage', () => {
|
||||
window.history.pushState(
|
||||
{},
|
||||
'',
|
||||
`/explore/?${URL_PARAMS.dashboardPageId.name}=${dashboardPageId}`,
|
||||
`/?${URL_PARAMS.dashboardPageId.name}=${dashboardPageId}`,
|
||||
);
|
||||
const { getByTestId } = render(
|
||||
<>
|
||||
<Link
|
||||
to={{
|
||||
pathname: '/explore/',
|
||||
pathname: '/',
|
||||
search: `?${URL_PARAMS.dashboardPageId.name}=${dashboardPageId}`,
|
||||
state: { saveAction: 'overwrite' },
|
||||
}}
|
||||
@@ -324,7 +324,7 @@ describe('ChartPage', () => {
|
||||
});
|
||||
render(
|
||||
<>
|
||||
<Link to="/explore/?slice_id=99">Navigate away</Link>
|
||||
<Link to="/?slice_id=99">Navigate away</Link>
|
||||
<ChartPage />
|
||||
</>,
|
||||
{
|
||||
@@ -382,7 +382,7 @@ describe('ChartPage', () => {
|
||||
<>
|
||||
<Link
|
||||
to={{
|
||||
pathname: '/explore/',
|
||||
pathname: '/',
|
||||
search: `?${URL_PARAMS.sliceId.name}=${formData.slice_id}`,
|
||||
state: toChartStateHistoryState({
|
||||
...formData,
|
||||
@@ -392,7 +392,7 @@ describe('ChartPage', () => {
|
||||
>
|
||||
Change the chart
|
||||
</Link>
|
||||
<Link to="/explore/?slice_id=99">Navigate away</Link>
|
||||
<Link to="/?slice_id=99">Navigate away</Link>
|
||||
<ChartPage />
|
||||
</>,
|
||||
{ useRouter: true, useRedux: true, useDnd: true },
|
||||
@@ -433,14 +433,14 @@ describe('ChartPage', () => {
|
||||
<>
|
||||
<Link
|
||||
to={{
|
||||
pathname: '/explore/',
|
||||
pathname: '/',
|
||||
search: `?${URL_PARAMS.sliceId.name}=99`,
|
||||
state: toChartStateHistoryState({ ...formData, slice_id: 99 }),
|
||||
}}
|
||||
>
|
||||
Another chart
|
||||
</Link>
|
||||
<Link to="/explore/?slice_id=100">Navigate away</Link>
|
||||
<Link to="/?slice_id=100">Navigate away</Link>
|
||||
<ChartPage />
|
||||
</>,
|
||||
{ useRouter: true, useRedux: true, useDnd: true },
|
||||
@@ -477,14 +477,14 @@ describe('ChartPage', () => {
|
||||
<>
|
||||
<Link
|
||||
to={{
|
||||
pathname: '/explore/',
|
||||
pathname: '/',
|
||||
search: `?${URL_PARAMS.sliceId.name}=${formData.slice_id}`,
|
||||
state: toChartStateHistoryState(formData),
|
||||
}}
|
||||
>
|
||||
Change the chart
|
||||
</Link>
|
||||
<Link to="/explore/?slice_id=99">Navigate away</Link>
|
||||
<Link to="/?slice_id=99">Navigate away</Link>
|
||||
<ChartPage />
|
||||
</>,
|
||||
{ useRouter: true, useRedux: true, useDnd: true, store },
|
||||
@@ -507,32 +507,6 @@ describe('ChartPage', () => {
|
||||
window.history.back();
|
||||
await waitFor(() => expect(loads()).toBe(1));
|
||||
});
|
||||
|
||||
test('does not re-fetch explore data when navigating to a dashboard', async () => {
|
||||
const exploreApiRoute = 'glob:*/api/v1/explore/*';
|
||||
const exploreFormData = getExploreFormData({
|
||||
viz_type: VizType.Table,
|
||||
show_cell_bars: true,
|
||||
});
|
||||
fetchMock.get(exploreApiRoute, {
|
||||
result: { dataset: { id: 1 }, form_data: exploreFormData },
|
||||
});
|
||||
render(
|
||||
<>
|
||||
<Link to="/dashboard/5/">Go to dashboard</Link>
|
||||
<ChartPage />
|
||||
</>,
|
||||
{ useRouter: true, useRedux: true, useDnd: true },
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(fetchMock.callHistory.calls(exploreApiRoute).length).toBe(1),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Go to dashboard'));
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
expect(fetchMock.callHistory.calls(exploreApiRoute).length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
test('does not show error toast when request is aborted on unmount', async () => {
|
||||
@@ -585,7 +559,7 @@ describe('ChartPage', () => {
|
||||
|
||||
render(
|
||||
<>
|
||||
<Link to="/explore/?slice_id=99">Navigate</Link>
|
||||
<Link to="/?slice_id=99">Navigate</Link>
|
||||
<ChartPage />
|
||||
</>,
|
||||
{
|
||||
|
||||
@@ -56,11 +56,6 @@ const isValidResult = (rv: JsonObject): boolean =>
|
||||
const hasDatasetId = (rv: JsonObject): boolean =>
|
||||
isDefined(rv?.result?.dataset?.id);
|
||||
|
||||
const EXPLORE_ROUTE_PREFIX = '/explore/';
|
||||
|
||||
const isExploreRoute = (pathname: string): boolean =>
|
||||
pathname.startsWith(EXPLORE_ROUTE_PREFIX);
|
||||
|
||||
const fetchExploreData = async (
|
||||
exploreUrlParams: URLSearchParams,
|
||||
signal?: AbortSignal,
|
||||
@@ -317,8 +312,6 @@ export default function ExplorePage() {
|
||||
// Other REPLACE: ignored (URL sync from updateHistory).
|
||||
// Entries holding a chart state of the loaded chart are skipped: Explore
|
||||
// pushed them itself, and ExploreViewContainer restores a popped one in place.
|
||||
// Navigations that leave Explore must not trigger a re-fetch while the page
|
||||
// is unmounting, as the destination's URL params are not chart params.
|
||||
useEffect(() => {
|
||||
const unlisten = history.listen((loc: Location, action: Action) => {
|
||||
const saveAction = (loc.state as Record<string, unknown>)?.saveAction as
|
||||
@@ -333,9 +326,6 @@ export default function ExplorePage() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!isExploreRoute(loc.pathname)) {
|
||||
return;
|
||||
}
|
||||
if (action === 'PUSH' || action === 'POP') {
|
||||
setIsLoaded(false);
|
||||
loadExploreData(loc, saveAction);
|
||||
|
||||
@@ -182,7 +182,7 @@ test('With sql role - renders all panels on the page on page load', async () =>
|
||||
|
||||
test('With sql role - renders distinct recent activities', async () => {
|
||||
await renderWelcome();
|
||||
const recentPanel = screen.getByRole('button', { name: 'Recents' });
|
||||
const recentPanel = screen.getByRole('button', { name: 'collapsed Recents' });
|
||||
userEvent.click(recentPanel);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
|
||||
@@ -32,11 +32,13 @@ from superset.commands.chart.exceptions import (
|
||||
DashboardsForbiddenError,
|
||||
DashboardsNotFoundValidationError,
|
||||
)
|
||||
from superset.commands.exceptions import DatasourceTypeInvalidError
|
||||
from superset.commands.utils import get_datasource_by_id, populate_subjects
|
||||
from superset.daos.chart import ChartDAO
|
||||
from superset.daos.dashboard import DashboardDAO
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.utils import json
|
||||
from superset.utils.core import DatasourceType
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -71,6 +73,16 @@ class CreateChartCommand(CreateMixin, BaseCommand):
|
||||
|
||||
# Validate/Populate datasource
|
||||
try:
|
||||
# Slice.datasource only ever resolves the ``table`` relationship
|
||||
# (see Slice.datasource in superset/models/slice.py), so a chart
|
||||
# pointed at any other datasource_type would "create"
|
||||
# successfully but could never actually render. Reject those
|
||||
# up front instead of failing later -- either at this lookup
|
||||
# (SavedQuery/Query have no ``.name`` attribute, so accessing it
|
||||
# below raises an unhandled AttributeError) or silently, by
|
||||
# producing a permanently broken chart.
|
||||
if datasource_type != DatasourceType.TABLE:
|
||||
raise DatasourceTypeInvalidError()
|
||||
datasource = get_datasource_by_id(datasource_id, datasource_type)
|
||||
self._properties["datasource_name"] = datasource.name
|
||||
security_manager.raise_for_access(datasource=datasource)
|
||||
|
||||
@@ -35,6 +35,7 @@ from superset.commands.chart.exceptions import (
|
||||
DashboardsNotFoundValidationError,
|
||||
DatasourceTypeUpdateRequiredValidationError,
|
||||
)
|
||||
from superset.commands.exceptions import DatasourceTypeInvalidError
|
||||
from superset.commands.utils import (
|
||||
compute_subjects,
|
||||
get_datasource_by_id,
|
||||
@@ -49,6 +50,7 @@ from superset.models.dashboard import Dashboard
|
||||
from superset.models.slice import Slice
|
||||
from superset.tags.models import ObjectType
|
||||
from superset.utils import json
|
||||
from superset.utils.core import DatasourceType
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
from superset.versioning.changes.normalization import (
|
||||
register_matching_normalization_context,
|
||||
@@ -221,8 +223,22 @@ class UpdateChartCommand(UpdateMixin, BaseCommand):
|
||||
exceptions.append(ex)
|
||||
|
||||
# Validate/Populate datasource
|
||||
if datasource_id is not None:
|
||||
# An empty datasource_type was already flagged above via
|
||||
# DatasourceTypeUpdateRequiredValidationError; skip this block so
|
||||
# we don't clobber that message with DatasourceTypeInvalidError.
|
||||
if datasource_id is not None and datasource_type:
|
||||
try:
|
||||
# Slice.datasource only ever resolves the ``table``
|
||||
# relationship (see Slice.datasource in
|
||||
# superset/models/slice.py), so repointing a chart at any
|
||||
# other datasource_type would "succeed" but leave the chart
|
||||
# permanently unable to render. Reject those up front
|
||||
# instead of failing later -- either at this lookup
|
||||
# (SavedQuery/Query have no ``.name`` attribute, so
|
||||
# accessing it below raises an unhandled AttributeError) or
|
||||
# silently.
|
||||
if datasource_type != DatasourceType.TABLE:
|
||||
raise DatasourceTypeInvalidError()
|
||||
datasource = get_datasource_by_id(datasource_id, datasource_type)
|
||||
self._properties["datasource_name"] = datasource.name
|
||||
security_manager.raise_for_access(datasource=datasource)
|
||||
|
||||
@@ -52,6 +52,42 @@ class DatabaseRequiredFieldValidationError(ValidationError):
|
||||
)
|
||||
|
||||
|
||||
class DatabaseExtraJSONValidationError(ValidationError):
|
||||
"""
|
||||
Marshmallow validation error for database encrypted extra must be a valid JSON
|
||||
"""
|
||||
|
||||
def __init__(self, json_error: str = "") -> None:
|
||||
super().__init__(
|
||||
[
|
||||
_(
|
||||
"Field cannot be decoded by JSON. %(json_error)s",
|
||||
json_error=json_error,
|
||||
)
|
||||
],
|
||||
field_name="extra",
|
||||
)
|
||||
|
||||
|
||||
class DatabaseExtraValidationError(ValidationError):
|
||||
"""
|
||||
Marshmallow validation error for database encrypted extra must be a valid JSON
|
||||
"""
|
||||
|
||||
def __init__(self, key: str = "") -> None:
|
||||
super().__init__(
|
||||
[
|
||||
_(
|
||||
"The metadata_params in Extra field "
|
||||
"is not configured correctly. The key "
|
||||
"%{key}s is invalid.",
|
||||
key=key,
|
||||
)
|
||||
],
|
||||
field_name="extra",
|
||||
)
|
||||
|
||||
|
||||
class DatabaseConnectionSyncPermissionsError(CommandException):
|
||||
status = 500
|
||||
message = _("Unable to sync permissions for this database connection.")
|
||||
|
||||
@@ -24,10 +24,8 @@ purge rolls back. The record is written ``pending`` *before* the purge and
|
||||
flipped to ``confirmed`` *after* it commits, so a crash leaves at most a
|
||||
``pending`` row, never a missing one. ``pending`` rows are reconciled on the
|
||||
next run. Completed records are immutable. Consecutive scheduled evaluations
|
||||
that remain blocked for the same reason may discard only their current
|
||||
redundant provisional row — a reason change retains one new row carrying the
|
||||
new code; force-purge and other meaningful outcomes are retained
|
||||
independently.
|
||||
that remain blocked may discard only their current redundant provisional row;
|
||||
force-purge and other meaningful outcomes are retained independently.
|
||||
|
||||
The dedicated ``purge_audit_log`` table is content-free (no name or PII; only
|
||||
action, actor, UTC time, entity type, UUID, and affected referrers) and is never
|
||||
@@ -89,10 +87,6 @@ class _AuditRecoverySnapshot:
|
||||
entity_type: str
|
||||
entity_uuid: str | None
|
||||
created_on: datetime
|
||||
# Sourced from the finalization call's reason argument, never from the
|
||||
# row: pending rows are reason-less by design, so reading the row here
|
||||
# would silently record NULL.
|
||||
reason: str | None
|
||||
|
||||
|
||||
def _utc_now() -> datetime:
|
||||
@@ -142,18 +136,8 @@ def write_ahead(
|
||||
session.close()
|
||||
|
||||
|
||||
def finalize(
|
||||
record_id: UUID | None,
|
||||
status: str,
|
||||
*,
|
||||
reason: str | None = None,
|
||||
**details: Any,
|
||||
) -> None:
|
||||
"""Finalize a pending attempt on the dedicated audit session.
|
||||
|
||||
``reason`` is persisted only for blocked outcomes; the audit records a
|
||||
cause for a purge that did not happen, never for one that did.
|
||||
"""
|
||||
def finalize(record_id: UUID | None, status: str, **details: Any) -> None:
|
||||
"""Finalize a pending attempt on the dedicated audit session."""
|
||||
if record_id is None:
|
||||
return
|
||||
session = _dedicated_session()
|
||||
@@ -164,8 +148,6 @@ def finalize(
|
||||
referrers = details.get("affected_referrers")
|
||||
if referrers:
|
||||
values["affected_referrers"] = ",".join(referrers)
|
||||
if reason is not None and status == STATUS_BLOCKED:
|
||||
values["reason"] = reason
|
||||
removed_dashboard_slices = details.get("removed_dashboard_slices")
|
||||
if removed_dashboard_slices is not None:
|
||||
values["removed_dashboard_slices"] = removed_dashboard_slices
|
||||
@@ -210,21 +192,12 @@ def fail(record_id: UUID | None) -> None:
|
||||
finalize(record_id, STATUS_FAILED)
|
||||
|
||||
|
||||
def block(record_id: UUID | None, reason: str | None) -> None:
|
||||
"""Mark an attempt blocked by ordinary deletion policy.
|
||||
|
||||
``reason`` is a stable machine code from the closed ``REASON_*``
|
||||
vocabulary in :mod:`superset.commands.deletion_retention.purge_policy`.
|
||||
It is required by signature (every blocked outcome has a classified
|
||||
cause); ``None`` is tolerated defensively so a threading gap can never
|
||||
block a purge, and leaves the persisted reason NULL.
|
||||
"""
|
||||
finalize(record_id, STATUS_BLOCKED, reason=reason)
|
||||
def block(record_id: UUID | None) -> None:
|
||||
"""Mark an attempt blocked by ordinary deletion policy."""
|
||||
finalize(record_id, STATUS_BLOCKED)
|
||||
|
||||
|
||||
def _capture_recovery_snapshot(
|
||||
record: PurgeAuditLog, reason: str | None
|
||||
) -> _AuditRecoverySnapshot:
|
||||
def _capture_recovery_snapshot(record: PurgeAuditLog) -> _AuditRecoverySnapshot:
|
||||
"""Capture the content-free fields needed for fail-safe recovery."""
|
||||
return _AuditRecoverySnapshot(
|
||||
id=cast(UUID, record.id),
|
||||
@@ -232,37 +205,26 @@ def _capture_recovery_snapshot(
|
||||
entity_type=str(record.entity_type),
|
||||
entity_uuid=record.entity_uuid,
|
||||
created_on=cast(datetime, record.created_on),
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
|
||||
def _retention_predecessor(
|
||||
session: Session, current: PurgeAuditLog
|
||||
) -> PurgeAuditLog | None:
|
||||
"""Return the latest row that could unambiguously precede ``current``.
|
||||
|
||||
The latest same-entity retention row by ``created_on`` — deliberately not
|
||||
bounded by ``current.created_on``. If another visible row has a later
|
||||
timestamp, it surfaces here so the caller's strictly-older check retains
|
||||
the current row. Timestamps provide database ordering for this predicate,
|
||||
not causal ordering across workers.
|
||||
"""
|
||||
"""Return the latest row that could unambiguously precede ``current``."""
|
||||
predecessor: PurgeAuditLog | None = session.execute(
|
||||
sa.select(PurgeAuditLog)
|
||||
.where(PurgeAuditLog.entity_uuid == current.entity_uuid)
|
||||
.where(PurgeAuditLog.entity_type == current.entity_type)
|
||||
.where(PurgeAuditLog.trigger == TRIGGER_RETENTION)
|
||||
.where(PurgeAuditLog.created_on <= current.created_on)
|
||||
.where(PurgeAuditLog.id != current.id)
|
||||
.order_by(PurgeAuditLog.created_on.desc())
|
||||
.limit(1)
|
||||
).scalar_one_or_none()
|
||||
if predecessor is None:
|
||||
return predecessor
|
||||
# A timestamp-tied row differing in status OR reason makes the
|
||||
# predecessor ambiguous. ``is_distinct_from`` keeps the reason
|
||||
# comparison NULL-safe on all supported dialects (reason is nullable;
|
||||
# status is not).
|
||||
tied_mixed_exists: bool = session.execute(
|
||||
tied_mixed_status_exists: bool = session.execute(
|
||||
sa.select(
|
||||
sa.exists().where(
|
||||
PurgeAuditLog.entity_uuid == current.entity_uuid,
|
||||
@@ -270,43 +232,23 @@ def _retention_predecessor(
|
||||
PurgeAuditLog.trigger == TRIGGER_RETENTION,
|
||||
PurgeAuditLog.created_on == predecessor.created_on,
|
||||
PurgeAuditLog.id != current.id,
|
||||
sa.or_(
|
||||
PurgeAuditLog.status != predecessor.status,
|
||||
PurgeAuditLog.reason.is_distinct_from(predecessor.reason),
|
||||
),
|
||||
PurgeAuditLog.status != predecessor.status,
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
if tied_mixed_exists:
|
||||
if tied_mixed_status_exists:
|
||||
return None
|
||||
return predecessor
|
||||
|
||||
|
||||
def _suppress_redundant_block(
|
||||
session: Session,
|
||||
current: PurgeAuditLog,
|
||||
predecessor: PurgeAuditLog | None,
|
||||
reason: str | None,
|
||||
session: Session, current: PurgeAuditLog, predecessor: PurgeAuditLog | None
|
||||
) -> bool:
|
||||
"""Delete a pending row only against a strictly older same-reason block."""
|
||||
if not reason:
|
||||
# Fail safe on a threading gap: a missing current code must retain
|
||||
# the row (and be visible), never silently revive the status-only
|
||||
# predicate.
|
||||
logger.warning(
|
||||
"deletion_retention: blocked audit row %s has no reason code; "
|
||||
"refusing suppression",
|
||||
current.id,
|
||||
)
|
||||
return False
|
||||
"""Delete only a pending row with a strictly older blocked predecessor."""
|
||||
if (
|
||||
predecessor is None
|
||||
or predecessor.created_on >= current.created_on
|
||||
or predecessor.status != STATUS_BLOCKED
|
||||
# A reason-less (pre-feature) predecessor never matches: the first
|
||||
# post-upgrade block of a long-blocked entity is retained once and
|
||||
# becomes the new suppression anchor.
|
||||
or predecessor.reason != reason
|
||||
):
|
||||
return False
|
||||
deleted_rows: int | None = session.execute(
|
||||
@@ -322,7 +264,7 @@ def _suppress_redundant_block(
|
||||
return deleted_rows == 1
|
||||
|
||||
|
||||
def _retain_blocked(session: Session, record_id: UUID, reason: str | None) -> None:
|
||||
def _retain_blocked(session: Session, record_id: UUID) -> None:
|
||||
"""Conditionally retain the current provisional row as blocked."""
|
||||
session.execute(
|
||||
sa.update(PurgeAuditLog.__table__)
|
||||
@@ -330,7 +272,7 @@ def _retain_blocked(session: Session, record_id: UUID, reason: str | None) -> No
|
||||
PurgeAuditLog.__table__.c.id == record_id,
|
||||
PurgeAuditLog.__table__.c.status == STATUS_PENDING,
|
||||
)
|
||||
.values(status=STATUS_BLOCKED, removed_dashboard_slices=0, reason=reason)
|
||||
.values(status=STATUS_BLOCKED, removed_dashboard_slices=0)
|
||||
)
|
||||
|
||||
|
||||
@@ -343,11 +285,7 @@ def _recover_retention_blocked(
|
||||
current: PurgeAuditLog | None = recovery_session.get(PurgeAuditLog, record_id)
|
||||
if current is not None:
|
||||
if current.status == STATUS_PENDING:
|
||||
_retain_blocked(
|
||||
recovery_session,
|
||||
record_id,
|
||||
snapshot.reason if snapshot else None,
|
||||
)
|
||||
_retain_blocked(recovery_session, record_id)
|
||||
recovery_session.commit()
|
||||
return "fallback"
|
||||
if snapshot is None:
|
||||
@@ -362,7 +300,6 @@ def _recover_retention_blocked(
|
||||
entity_uuid=snapshot.entity_uuid,
|
||||
removed_dashboard_slices=0,
|
||||
created_on=snapshot.created_on,
|
||||
reason=snapshot.reason,
|
||||
)
|
||||
)
|
||||
recovery_session.commit()
|
||||
@@ -382,14 +319,9 @@ def _recover_retention_blocked(
|
||||
|
||||
|
||||
def finalize_retention_blocked(
|
||||
record_id: UUID | None, reason: str | None
|
||||
record_id: UUID | None,
|
||||
) -> RetentionBlockedDisposition:
|
||||
"""Finalize a scheduled blocker, suppressing only proven redundant evidence.
|
||||
|
||||
``reason`` is the stable machine code for the block (see
|
||||
:func:`block`); it is persisted on retained rows and captured in the
|
||||
snapshot used by the crash-recovery path.
|
||||
"""
|
||||
"""Finalize a scheduled blocker, suppressing only proven redundant evidence."""
|
||||
if record_id is None:
|
||||
return "fallback"
|
||||
session: Session = _dedicated_session()
|
||||
@@ -398,17 +330,15 @@ def finalize_retention_blocked(
|
||||
current: PurgeAuditLog | None = session.get(PurgeAuditLog, record_id)
|
||||
if current is None:
|
||||
return "fallback"
|
||||
snapshot = _capture_recovery_snapshot(current, reason)
|
||||
snapshot = _capture_recovery_snapshot(current)
|
||||
if current.status != STATUS_PENDING or current.trigger != TRIGGER_RETENTION:
|
||||
return "retained"
|
||||
predecessor: PurgeAuditLog | None = None
|
||||
if current.entity_uuid is not None:
|
||||
predecessor = _retention_predecessor(session, current)
|
||||
suppressed: bool = _suppress_redundant_block(
|
||||
session, current, predecessor, reason
|
||||
)
|
||||
suppressed: bool = _suppress_redundant_block(session, current, predecessor)
|
||||
if not suppressed:
|
||||
_retain_blocked(session, record_id, reason)
|
||||
_retain_blocked(session, record_id)
|
||||
session.commit()
|
||||
return "suppressed" if suppressed else "retained"
|
||||
except SQLAlchemyError:
|
||||
@@ -453,12 +383,6 @@ def reconcile_pending(stale_before: datetime | None = None) -> dict[str, int]:
|
||||
``confirmed``. A surviving or unresolvable entity means the attempt did
|
||||
not durably purge it and is finalized as failed; normal selection may
|
||||
retry.
|
||||
|
||||
An attempt that had already decided ``blocked`` when its worker died is
|
||||
indistinguishable here from any other stalled attempt, so it reconciles
|
||||
as failed with no reason: pending rows carry no reason by design, and
|
||||
inventing one would assert evidence this process never witnessed. The
|
||||
next scheduled attempt re-anchors the entity with its real code.
|
||||
"""
|
||||
cutoff = stale_before or _utc_now() - _PENDING_STALE_AFTER
|
||||
reconciled = absent = failed = 0
|
||||
|
||||
@@ -183,7 +183,7 @@ class ForcePurgeCommand:
|
||||
removed_dashboard_slices=result.removed_dashboard_slices,
|
||||
)
|
||||
elif result.blocked_reason is not None:
|
||||
audit.block(record_id, result.blocker.code if result.blocker else None)
|
||||
audit.block(record_id)
|
||||
else:
|
||||
audit.fail(record_id)
|
||||
if result.purged:
|
||||
|
||||
@@ -53,11 +53,9 @@ from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from superset.commands.deletion_retention.purge_policy import (
|
||||
BlockerReason,
|
||||
get_purge_policy,
|
||||
PurgeBlockedError,
|
||||
PurgeEntityPolicy,
|
||||
REASON_CASCADE_INTEGRITY_FAILURE,
|
||||
)
|
||||
|
||||
logger: logging.Logger = logging.getLogger(__name__)
|
||||
@@ -146,17 +144,7 @@ class CascadeResult:
|
||||
dangling_chart_uuids: list[str] = field(default_factory=list)
|
||||
removed_dashboard_slices: int = 0
|
||||
version_rows_removed: int = 0
|
||||
blocker: BlockerReason | None = None
|
||||
|
||||
@property
|
||||
def blocked_reason(self) -> str | None:
|
||||
"""Return the operator-facing blocker phrase, if the purge was blocked."""
|
||||
return self.blocker.phrase if self.blocker else None
|
||||
|
||||
@property
|
||||
def blocked_reason_code(self) -> str | None:
|
||||
"""Return the stable audit code, if the purge was blocked."""
|
||||
return self.blocker.code if self.blocker else None
|
||||
blocked_reason: str | None = None
|
||||
|
||||
|
||||
class PurgeRaceLostError(Exception):
|
||||
@@ -279,21 +267,20 @@ def cascade_hard_delete(
|
||||
purged=False,
|
||||
entity_type=entity_type,
|
||||
entity_uuid=uuid,
|
||||
blocker=ex.reason,
|
||||
blocked_reason=str(ex),
|
||||
)
|
||||
except IntegrityError as ex:
|
||||
# Not a policy decision: a database integrity constraint failed.
|
||||
# Not a policy decision: a restrictive FK the cascade did not handle.
|
||||
# Two audiences, two messages. The curated reason goes to the caller
|
||||
# (and from there into a user toast), because raw driver text carries
|
||||
# the failing SQL and bind parameters. The constraint detail goes to
|
||||
# the log at WARNING, because an entity permanently unpurgeable after
|
||||
# an integrity failure represents a cascade defect someone has to be
|
||||
# able to diagnose. The stable audit code identifies this as an unexpected
|
||||
# cascade failure rather than intended policy behavior without
|
||||
# claiming which kind of constraint the database reported.
|
||||
# the log at WARNING, because an entity permanently unpurgeable via an
|
||||
# unknown FK is a cascade-coverage bug someone has to be able to
|
||||
# diagnose -- reported at INFO as a policy block, it read as intended
|
||||
# behaviour.
|
||||
logger.warning(
|
||||
"deletion_retention: %s id=%s purge failed on a database "
|
||||
"integrity constraint: %s",
|
||||
"deletion_retention: %s id=%s purge failed on a restrictive "
|
||||
"foreign key the cascade does not handle: %s",
|
||||
entity_type,
|
||||
entity_id,
|
||||
ex,
|
||||
@@ -302,10 +289,7 @@ def cascade_hard_delete(
|
||||
purged=False,
|
||||
entity_type=entity_type,
|
||||
entity_uuid=uuid,
|
||||
blocker=BlockerReason(
|
||||
REASON_CASCADE_INTEGRITY_FAILURE,
|
||||
"cascade blocked by a database integrity constraint",
|
||||
),
|
||||
blocked_reason="blocked by database references",
|
||||
)
|
||||
|
||||
return CascadeResult(
|
||||
|
||||
@@ -24,7 +24,7 @@ from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from functools import lru_cache
|
||||
from types import MappingProxyType
|
||||
from typing import Any, cast, NamedTuple
|
||||
from typing import Any, cast
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.orm import Mapper, Session
|
||||
@@ -36,44 +36,10 @@ from superset.utils.sqlalchemy_events import (
|
||||
|
||||
logger: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
# Stable machine-readable reason codes persisted on purge audit records.
|
||||
# The values are frozen identifiers pinned by a golden-set test: they equal
|
||||
# the related-table names at introduction by coincidence, never by derivation,
|
||||
# so a physical table rename changes only the blocker mapping's key and
|
||||
# leaves the persisted code untouched — audit history and the suppression
|
||||
# predicate compare these literals.
|
||||
REASON_REPORT_SCHEDULE: str = "report_schedule"
|
||||
REASON_USER_ATTRIBUTE: str = "user_attribute"
|
||||
REASON_CASCADE_INTEGRITY_FAILURE: str = "cascade_integrity_failure"
|
||||
|
||||
ALL_REASON_CODES: frozenset[str] = frozenset(
|
||||
{
|
||||
REASON_REPORT_SCHEDULE,
|
||||
REASON_USER_ATTRIBUTE,
|
||||
REASON_CASCADE_INTEGRITY_FAILURE,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class BlockerReason(NamedTuple):
|
||||
"""One blocker's persisted audit code paired with its operator phrase."""
|
||||
|
||||
code: str
|
||||
phrase: str
|
||||
|
||||
|
||||
class PurgeBlockedError(Exception):
|
||||
"""Raised when ordinary deletion policy forbids purging an entity."""
|
||||
|
||||
def __init__(self, reason: BlockerReason) -> None:
|
||||
super().__init__(reason.phrase)
|
||||
self.reason: BlockerReason = reason
|
||||
|
||||
@property
|
||||
def reason_code(self) -> str:
|
||||
"""Return the stable machine-readable blocker code."""
|
||||
return self.reason.code
|
||||
|
||||
|
||||
class DependencyClassification(str, Enum):
|
||||
"""Describe how purge treats a persistence dependency."""
|
||||
@@ -140,21 +106,11 @@ class DependencyPolicy:
|
||||
key: DependencyKey
|
||||
classification: DependencyClassification
|
||||
phase: ExecutionPhase | None = None
|
||||
blocker: BlockerReason | None = None
|
||||
blocked_reason: str | None = None
|
||||
optional_listener: bool = False
|
||||
listener_action: ListenerAction | None = None
|
||||
version_column: str | None = None
|
||||
|
||||
@property
|
||||
def blocked_reason(self) -> str | None:
|
||||
"""Return the operator-facing blocker phrase, if this policy blocks."""
|
||||
return self.blocker.phrase if self.blocker else None
|
||||
|
||||
@property
|
||||
def blocked_reason_code(self) -> str | None:
|
||||
"""Return the stable audit code, if this policy blocks."""
|
||||
return self.blocker.code if self.blocker else None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PurgeEntityPolicy:
|
||||
@@ -460,7 +416,7 @@ def purge_policy_registry() -> Mapping[type[Any], PurgeEntityPolicy]:
|
||||
keys: tuple[DependencyKey, ...],
|
||||
classifications: tuple[DependencyClassification, ...],
|
||||
synthetic: tuple[DependencyPolicy, ...],
|
||||
blocked_reasons: Mapping[str, BlockerReason] = MappingProxyType({}),
|
||||
blocked_reasons: Mapping[str, str] = MappingProxyType({}),
|
||||
version_columns: Mapping[str, str] = MappingProxyType({}),
|
||||
) -> tuple[DependencyPolicy, ...]:
|
||||
phases: dict[DependencyClassification, ExecutionPhase | None] = {
|
||||
@@ -472,22 +428,15 @@ def purge_policy_registry() -> Mapping[type[Any], PurgeEntityPolicy]:
|
||||
}
|
||||
if len(keys) != len(classifications):
|
||||
raise ValueError("Every dependency key requires one classification")
|
||||
|
||||
def declare(
|
||||
key: DependencyKey, classification: DependencyClassification
|
||||
) -> DependencyPolicy:
|
||||
blocker: BlockerReason | None = blocked_reasons.get(key.related_table)
|
||||
return DependencyPolicy(
|
||||
key,
|
||||
classification,
|
||||
phases[classification],
|
||||
blocker=blocker,
|
||||
version_column=version_columns.get(key.related_table),
|
||||
)
|
||||
|
||||
return (
|
||||
tuple(
|
||||
declare(key, classification)
|
||||
DependencyPolicy(
|
||||
key,
|
||||
classification,
|
||||
phases[classification],
|
||||
blocked_reason=blocked_reasons.get(key.related_table),
|
||||
version_column=version_columns.get(key.related_table),
|
||||
)
|
||||
for key, classification in zip(keys, classifications, strict=True)
|
||||
)
|
||||
+ synthetic
|
||||
@@ -590,12 +539,7 @@ def purge_policy_registry() -> Mapping[type[Any], PurgeEntityPolicy]:
|
||||
DependencyClassification.PRESERVE,
|
||||
),
|
||||
(tag_cleanup, chart_membership_versions),
|
||||
# Keyed by related table; the audit code is declared, not derived.
|
||||
{
|
||||
"report_schedule": BlockerReason(
|
||||
REASON_REPORT_SCHEDULE, "associated alerts or reports exist"
|
||||
)
|
||||
},
|
||||
{"report_schedule": "associated alerts or reports exist"},
|
||||
{"slices_version": "id"},
|
||||
),
|
||||
validate=validate_deletion_allowed,
|
||||
@@ -743,16 +687,10 @@ def purge_policy_registry() -> Mapping[type[Any], PurgeEntityPolicy]:
|
||||
DependencyClassification.PRESERVE,
|
||||
),
|
||||
(tag_cleanup, dashboard_membership_versions),
|
||||
# Keyed by related table; the audit code is declared, not derived.
|
||||
# Declaration order is part of the audit contract: the first
|
||||
# matching blocker's code is the one recorded.
|
||||
{
|
||||
"report_schedule": BlockerReason(
|
||||
REASON_REPORT_SCHEDULE, "associated alerts or reports exist"
|
||||
),
|
||||
"user_attribute": BlockerReason(
|
||||
REASON_USER_ATTRIBUTE,
|
||||
"a user has this dashboard set as their welcome page",
|
||||
"report_schedule": "associated alerts or reports exist",
|
||||
"user_attribute": (
|
||||
"a user has this dashboard set as their welcome page"
|
||||
),
|
||||
},
|
||||
{"dashboards_version": "id"},
|
||||
@@ -955,9 +893,9 @@ def validate_deletion_allowed(
|
||||
if session.execute(
|
||||
sa.select(sa.literal(1)).select_from(table).where(*predicates).limit(1)
|
||||
).first():
|
||||
if dependency.blocker is None:
|
||||
if dependency.blocked_reason is None:
|
||||
raise RuntimeError(f"Missing blocker reason for {key.describe()}")
|
||||
raise PurgeBlockedError(dependency.blocker)
|
||||
raise PurgeBlockedError(dependency.blocked_reason)
|
||||
|
||||
|
||||
def count_dashboard_slices(
|
||||
|
||||
@@ -138,8 +138,8 @@ class BaseRestoreVersionCommand(BaseCommand):
|
||||
# With capture off, Continuum's write listeners are detached: a
|
||||
# revert would mutate the live entity with NO new version row —
|
||||
# a destructive, untracked write. The whole restore surface is
|
||||
# therefore inert under the kill-switch (404, indistinguishable from
|
||||
# "no such version"). Existing history remains readable.
|
||||
# therefore inert under the kill-switch, matching the read-side
|
||||
# convention (404, indistinguishable from "no such version").
|
||||
if not capture_enabled():
|
||||
raise self.not_found_exc()
|
||||
entity = find_active_by_uuid(self.model_cls, self._uuid)
|
||||
|
||||
+15
-7
@@ -708,7 +708,7 @@ DEFAULT_FEATURE_FLAGS: dict[str, bool] = {
|
||||
# the move-back lever; removed (along with its two gate points —
|
||||
# BaseDAO.delete routing and the do_orm_execute visibility listener) once
|
||||
# post-flip confidence is established.
|
||||
# @lifecycle: testing
|
||||
# @lifecycle: development
|
||||
"SOFT_DELETE": True,
|
||||
# Enable semantic layers and show semantic views alongside datasets
|
||||
# @lifecycle: development
|
||||
@@ -742,9 +742,9 @@ DEFAULT_FEATURE_FLAGS: dict[str, bool] = {
|
||||
"TAGGING_SYSTEM": False,
|
||||
# Enables the version history panel on Explore and Dashboard pages.
|
||||
# History only accrues while ``ENABLE_VERSIONING_CAPTURE`` is also on;
|
||||
# with capture off the panel renders empty or stale history, so the two
|
||||
# ship with matching defaults and should be changed together.
|
||||
# @lifecycle: testing
|
||||
# with capture off the panel renders but stays empty, so the two ship
|
||||
# with matching defaults and should be changed together.
|
||||
# @lifecycle: development
|
||||
"VERSION_HISTORY": True,
|
||||
# =================================================================
|
||||
# IN TESTING
|
||||
@@ -1694,9 +1694,17 @@ DATETIME_FORMAT_DETECTION_SAMPLE_SIZE = 1000
|
||||
# The limit for the Superset Meta DB when the feature flag ENABLE_SUPERSET_META_DB is on
|
||||
SUPERSET_META_DB_LIMIT: int | None = 1000
|
||||
|
||||
# Master switch for entity-version-history capture. A falsy value disables
|
||||
# version writes while keeping existing history available read-only through the
|
||||
# ``/versions/`` endpoints; Restore is unavailable while capture is disabled.
|
||||
# Master switch for entity-version-history capture. Capture is enabled by
|
||||
# default, so saves write shadow rows and a ``version_transaction`` /
|
||||
# ``version_changes`` record. Set this to a falsy value in
|
||||
# ``superset_config.py`` (or via the environment variable of the same name) to
|
||||
# disable the before-flush listeners while keeping the /versions/ endpoints
|
||||
# available read-only.
|
||||
# Capture ships on. It is an operational escape hatch — set the environment
|
||||
# variable to a falsy value when a versioning-induced regression needs a
|
||||
# 30-second recovery instead of revert-and-redeploy — not a feature flag,
|
||||
# and it remains permanently as the kill-switch rather than being removed
|
||||
# with the rollout toggles.
|
||||
ENABLE_VERSIONING_CAPTURE: bool = utils.parse_boolean_string(
|
||||
os.environ.get("ENABLE_VERSIONING_CAPTURE", "true")
|
||||
)
|
||||
|
||||
@@ -791,9 +791,16 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
||||
Must be called after all versioned model classes have been imported so
|
||||
that VERSIONED_MODELS can be populated and configure_mappers() has run.
|
||||
|
||||
``ENABLE_VERSIONING_CAPTURE`` gates the baseline and change-record
|
||||
listener registrations. When disabled, initialization also detaches
|
||||
SQLAlchemy-Continuum's write listeners.
|
||||
``ENABLE_VERSIONING_CAPTURE`` (ships default ``False``) gates the two
|
||||
before-flush listener registrations. The flag is operational, not
|
||||
feature: with it off the infrastructure is inert (no save writes
|
||||
shadow rows); flipping it on activates capture. The switch also lets
|
||||
an operator who observes a versioning-induced regression (e.g. a
|
||||
save-path slowdown attributable to the change-record listener)
|
||||
disable capture in ``superset_config.py`` and restart workers — a
|
||||
30-second recovery instead of revert-and-redeploy. Shadow tables
|
||||
already created by the migration stay; they just stop accumulating
|
||||
new rows.
|
||||
|
||||
The fallback here is ``False`` so that any app-factory path that
|
||||
does not load ``superset.config`` (some test factories, embedded
|
||||
|
||||
@@ -665,8 +665,6 @@ def add_legend_config(form_data: Dict[str, Any], config: XYChartConfig) -> None:
|
||||
# Canonical form_data key is camelCase; the echarts plugins read
|
||||
# `legendOrientation` directly off form_data.
|
||||
form_data["legendOrientation"] = config.legend.position
|
||||
if config.legend_orientation:
|
||||
form_data["legendOrientation"] = config.legend_orientation
|
||||
|
||||
|
||||
def add_color_scheme(form_data: Dict[str, Any], color_scheme: str | None) -> None:
|
||||
@@ -1417,8 +1415,6 @@ def map_filter_operator(op: str) -> str:
|
||||
"NOT LIKE": "NOT LIKE",
|
||||
"IN": "IN",
|
||||
"NOT IN": "NOT IN",
|
||||
"IS NULL": "IS NULL",
|
||||
"IS NOT NULL": "IS NOT NULL",
|
||||
}
|
||||
return operator_map.get(op, op)
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, ClassVar
|
||||
|
||||
@@ -28,10 +29,7 @@ from superset.mcp_service.chart.chart_utils import (
|
||||
)
|
||||
from superset.mcp_service.chart.plugin import BaseChartPlugin
|
||||
from superset.mcp_service.chart.schemas import ColumnRef, HistogramChartConfig
|
||||
from superset.mcp_service.chart.validation.dataset_validator import (
|
||||
DatasetValidator,
|
||||
is_numeric_column,
|
||||
)
|
||||
from superset.mcp_service.chart.validation.dataset_validator import DatasetValidator
|
||||
from superset.mcp_service.common.error_schemas import ChartGenerationError
|
||||
|
||||
|
||||
@@ -118,13 +116,27 @@ class HistogramChartPlugin(BaseChartPlugin):
|
||||
# Column existence is validated separately; don't double-report.
|
||||
return None
|
||||
|
||||
if is_numeric_column(col_info):
|
||||
def _is_numeric(col: dict[str, Any]) -> bool:
|
||||
if col.get("is_numeric", False):
|
||||
return True
|
||||
# Backends report many spellings (BIGINT, SMALLINT, REAL, NUMBER,
|
||||
# DOUBLE PRECISION); match numeric tokens at word boundaries so
|
||||
# INTERVAL/POINT (which merely contain "INT") stay non-numeric.
|
||||
type_upper = str(col.get("type", "")).upper()
|
||||
return bool(
|
||||
re.search(
|
||||
r"\b(?:TINY|SMALL|MEDIUM|BIG)?INT(?:EGER)?\b"
|
||||
r"|\bFLOAT\b|\bDOUBLE\b|\bDECIMAL\b"
|
||||
r"|\bNUMERIC\b|\bREAL\b|\bNUMBER\b",
|
||||
type_upper,
|
||||
)
|
||||
)
|
||||
|
||||
if _is_numeric(col_info):
|
||||
return None
|
||||
|
||||
numeric_columns = sorted(
|
||||
col["name"]
|
||||
for col in dataset_context.available_columns
|
||||
if is_numeric_column(col)
|
||||
col["name"] for col in dataset_context.available_columns if _is_numeric(col)
|
||||
)
|
||||
return ChartGenerationError(
|
||||
error_type="non_numeric_histogram_column",
|
||||
|
||||
@@ -706,7 +706,7 @@ class ColumnRef(UnknownFieldCheckMixin):
|
||||
None,
|
||||
min_length=1,
|
||||
max_length=255,
|
||||
validation_alias=AliasChoices("name", "column_name", "column"),
|
||||
validation_alias=AliasChoices("name", "column_name"),
|
||||
)
|
||||
label: str | None = Field(None, max_length=500)
|
||||
dtype: str | None = None
|
||||
@@ -884,32 +884,17 @@ class FilterConfig(UnknownFieldCheckMixin):
|
||||
"NOT LIKE",
|
||||
"IN",
|
||||
"NOT IN",
|
||||
"IS NULL",
|
||||
"IS NOT NULL",
|
||||
] = Field(
|
||||
...,
|
||||
description=(
|
||||
"LIKE/ILIKE use % wildcards. IN/NOT IN take a list. "
|
||||
"IS NULL/IS NOT NULL omit value."
|
||||
),
|
||||
description="LIKE/ILIKE use % wildcards. IN/NOT IN take a list.",
|
||||
validation_alias=AliasChoices("op", "operator", "opr"),
|
||||
)
|
||||
value: str | int | float | bool | list[str | int | float | bool] | None = Field(
|
||||
None,
|
||||
description="For IN/NOT IN, provide a list. Omit for null operators.",
|
||||
value: str | int | float | bool | list[str | int | float | bool] = Field(
|
||||
...,
|
||||
description="For IN/NOT IN, provide a list.",
|
||||
validation_alias=AliasChoices("value", "val"),
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_value(self) -> "FilterConfig":
|
||||
"""Null checks have no comparator; every other operator requires one."""
|
||||
if self.op in {"IS NULL", "IS NOT NULL"}:
|
||||
if self.value is not None:
|
||||
raise ValueError(f"Filter operator {self.op!r} must not have 'value'.")
|
||||
elif self.value is None:
|
||||
raise ValueError(f"Filter operator {self.op!r} requires 'value'.")
|
||||
return self
|
||||
|
||||
@field_validator("column")
|
||||
@classmethod
|
||||
def sanitize_column(cls, v: str) -> str:
|
||||
@@ -1677,10 +1662,6 @@ class XYChartConfig(BaseChartConfig):
|
||||
None,
|
||||
validation_alias=AliasChoices("legend", "show_legend"),
|
||||
)
|
||||
legend_orientation: LEGEND_POSITION_LITERAL | None = Field(
|
||||
None,
|
||||
description="Legend placement around the chart",
|
||||
)
|
||||
x_axis_time_format: str | None = Field(
|
||||
None,
|
||||
description=(
|
||||
|
||||
@@ -22,8 +22,6 @@ Validates that referenced columns exist in the dataset schema.
|
||||
|
||||
import difflib
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Dict, List, Tuple, TypeVar
|
||||
|
||||
from superset.mcp_service.chart.schemas import (
|
||||
@@ -40,18 +38,6 @@ _C = TypeVar("_C", bound=ChartConfig)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_NUMERIC_TYPE_PATTERN = re.compile(
|
||||
r"\b(?:(?:TINY|SMALL|MEDIUM|BIG)?INT(?:EGER)?|INT[248]|FLOAT[48]?|"
|
||||
r"DOUBLE(?:\s+PRECISION)?|DECIMAL|NUMERIC|REAL|NUMBER|(?:SMALL)?MONEY)\b"
|
||||
)
|
||||
|
||||
|
||||
def is_numeric_column(column: Mapping[str, Any]) -> bool:
|
||||
"""Return whether dataset metadata identifies a numeric SQL column."""
|
||||
if column.get("is_numeric", False):
|
||||
return True
|
||||
return bool(_NUMERIC_TYPE_PATTERN.search(str(column.get("type") or "").upper()))
|
||||
|
||||
|
||||
def is_dataset_column_temporal(
|
||||
column: Any, column_name: str, db_engine_spec: Any
|
||||
@@ -716,11 +702,11 @@ class DatasetValidator:
|
||||
"STDDEV",
|
||||
"VAR",
|
||||
]
|
||||
type_name = str(col_info.get("type") or "").strip().upper()
|
||||
if (
|
||||
col_ref.aggregate in numeric_aggs
|
||||
and type_name not in {"", "UNKNOWN"}
|
||||
and not is_numeric_column(col_info)
|
||||
and not col_info.get("is_numeric", False)
|
||||
and col_info.get("type", "").upper()
|
||||
not in ["INTEGER", "FLOAT", "DOUBLE", "DECIMAL", "NUMERIC"]
|
||||
):
|
||||
from superset.mcp_service.utils.error_builder import ( # noqa: E501
|
||||
ChartErrorBuilder,
|
||||
|
||||
-60
@@ -1,60 +0,0 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
"""Add a reason to purge_audit_log.
|
||||
|
||||
Adds a nullable ``reason`` column to ``purge_audit_log`` holding a stable
|
||||
machine code identifying which policy rule blocked a purge (or the
|
||||
cascade-integrity failure class). Written at finalization for
|
||||
blocked outcomes only; NULL for confirmed, failed, non-blocked, and
|
||||
pre-existing rows. No backfill: the information was never captured for
|
||||
historical records, and readers treat the column as optional.
|
||||
|
||||
Apply this migration before deploying the code that depends on it. The
|
||||
audit model declares the column, so a worker running the new code against
|
||||
the un-migrated table cannot write its write-ahead record; the scheduled
|
||||
purge then fails closed (nothing is purged unaudited) and logs a
|
||||
write-ahead warning every run until the migration lands.
|
||||
|
||||
The downgrade discards every recorded block reason -- the rows survive and
|
||||
revert to reason-less, exactly like pre-feature history.
|
||||
|
||||
Revision ID: 39097d124752
|
||||
Revises: 1072de5ed955
|
||||
Create Date: 2026-08-24 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from superset.migrations.shared.utils import add_columns, drop_columns
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "39097d124752"
|
||||
down_revision: str = "1072de5ed955"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add the nullable ``reason`` column to ``purge_audit_log``."""
|
||||
add_columns(
|
||||
"purge_audit_log",
|
||||
sa.Column("reason", sa.String(64), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop the ``reason`` column from ``purge_audit_log``."""
|
||||
drop_columns("purge_audit_log", "reason")
|
||||
@@ -72,12 +72,6 @@ class PurgeAuditLog(Model):
|
||||
# Comma-joined UUIDs of charts left dangling / dashboards that lost a join
|
||||
# row (force-purge visibility). Free text, content-free.
|
||||
affected_referrers = Column(Text, nullable=True)
|
||||
# Stable machine code identifying which rule blocked the purge (or the
|
||||
# cascade-integrity failure class). Written at finalization for
|
||||
# blocked outcomes only; NULL for confirmed, failed, non-blocked, and
|
||||
# pre-feature rows. Vocabulary: REASON_* constants in
|
||||
# superset.commands.deletion_retention.purge_policy.
|
||||
reason: Column[str] = Column(String(64), nullable=True)
|
||||
removed_dashboard_slices = Column(Integer, nullable=False, default=0)
|
||||
created_on = Column(
|
||||
DateTime()
|
||||
|
||||
@@ -32,7 +32,6 @@ import logging
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, cast
|
||||
from uuid import UUID
|
||||
|
||||
import sqlalchemy as sa
|
||||
from flask import current_app
|
||||
@@ -46,7 +45,6 @@ from superset.commands.deletion_retention.purge_cascade import (
|
||||
entity_uuid,
|
||||
suppress_purge_association_versions,
|
||||
)
|
||||
from superset.commands.deletion_retention.purge_policy import BlockerReason
|
||||
from superset.commands.deletion_retention.window import resolve_retention_window
|
||||
from superset.extensions import celery_app, feature_flag_manager, stats_logger_manager
|
||||
from superset.models.helpers import (
|
||||
@@ -207,19 +205,6 @@ def _purge_model(
|
||||
return purged, would, failures, blocked
|
||||
|
||||
|
||||
def _finalize_blocked(record_id: UUID | None, blocker: BlockerReason) -> None:
|
||||
"""Finalize a blocked retention outcome and count suppression metrics."""
|
||||
disposition: audit.RetentionBlockedDisposition = audit.finalize_retention_blocked(
|
||||
record_id, blocker.code
|
||||
)
|
||||
if disposition == "suppressed":
|
||||
stats_logger_manager.instance.incr(f"{_METRIC_PREFIX}.blocked_audit_suppressed")
|
||||
elif disposition == "fallback":
|
||||
stats_logger_manager.instance.incr(
|
||||
f"{_METRIC_PREFIX}.blocked_audit_dedupe_fallback"
|
||||
)
|
||||
|
||||
|
||||
def _purge_one(
|
||||
model: type[SoftDeleteMixin], entity_id: int, cutoff: datetime
|
||||
) -> CascadeResult | None:
|
||||
@@ -294,8 +279,18 @@ def _purge_one(
|
||||
affected_referrers=result.dangling_chart_uuids,
|
||||
removed_dashboard_slices=result.removed_dashboard_slices,
|
||||
)
|
||||
elif result.blocker is not None:
|
||||
_finalize_blocked(record_id, result.blocker)
|
||||
elif result.blocked_reason is not None:
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(record_id)
|
||||
)
|
||||
if disposition == "suppressed":
|
||||
stats_logger_manager.instance.incr(
|
||||
f"{_METRIC_PREFIX}.blocked_audit_suppressed"
|
||||
)
|
||||
elif disposition == "fallback":
|
||||
stats_logger_manager.instance.incr(
|
||||
f"{_METRIC_PREFIX}.blocked_audit_dedupe_fallback"
|
||||
)
|
||||
else:
|
||||
audit.fail(record_id)
|
||||
return result
|
||||
|
||||
@@ -379,37 +379,37 @@ msgstr "虛擬"
|
||||
msgid "%s aggregates(s)"
|
||||
msgstr "%s 聚合"
|
||||
|
||||
#, python-format
|
||||
#, fuzzy, python-format
|
||||
msgid "%s column"
|
||||
msgid_plural "%s columns"
|
||||
msgstr[0] "%s 個欄位"
|
||||
msgstr[0] "%s 列"
|
||||
|
||||
#, python-format
|
||||
msgid "%s column(s)"
|
||||
msgstr "%s 個欄位"
|
||||
msgstr "%s 列"
|
||||
|
||||
#, python-format
|
||||
#, fuzzy, python-format
|
||||
msgid "%s day ago"
|
||||
msgid_plural "%s days ago"
|
||||
msgstr[0] "%s 天前"
|
||||
msgstr[0] "1 天之前"
|
||||
|
||||
#, python-format
|
||||
#, fuzzy, python-format
|
||||
msgid "%s hr ago"
|
||||
msgid_plural "%s hr ago"
|
||||
msgstr[0] "%s 小時前"
|
||||
msgstr[0] "%s 行"
|
||||
|
||||
#, python-format
|
||||
#, fuzzy, python-format
|
||||
msgid "%s imported"
|
||||
msgstr "已匯入 %s"
|
||||
msgstr "數據集已導入"
|
||||
|
||||
#, python-format
|
||||
#, fuzzy, python-format
|
||||
msgid "%s item"
|
||||
msgid_plural "%s items"
|
||||
msgstr[0] "%s 個項目"
|
||||
msgstr[0] "%s 個選項"
|
||||
|
||||
#, python-format
|
||||
#, fuzzy, python-format
|
||||
msgid "%s item(s)"
|
||||
msgstr "%s 個項目"
|
||||
msgstr "%s 個選項"
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: ar, cs, de,
|
||||
# es, fa, fr, ja, lv, mi, nl, pl, pt_BR, ru, sk, sl, sr, sr_Latn, tr, uk]
|
||||
@@ -419,15 +419,15 @@ msgid ""
|
||||
"all selected objects."
|
||||
msgstr "%s 個項目無法標記,因為您對所有選取的物件沒有編輯權限。"
|
||||
|
||||
#, python-format
|
||||
#, fuzzy, python-format
|
||||
msgid "%s metric"
|
||||
msgid_plural "%s metrics"
|
||||
msgstr[0] "%s 個指標"
|
||||
msgstr[0] "排序指標"
|
||||
|
||||
#, python-format
|
||||
#, fuzzy, python-format
|
||||
msgid "%s min ago"
|
||||
msgid_plural "%s min ago"
|
||||
msgstr[0] "%s 分鐘前"
|
||||
msgstr[0] "月"
|
||||
|
||||
#, python-format
|
||||
msgid ""
|
||||
@@ -451,47 +451,47 @@ msgstr[0] "%s 個選項"
|
||||
msgid "%s option(s)"
|
||||
msgstr "%s 個選項"
|
||||
|
||||
#, python-format
|
||||
#, fuzzy, python-format
|
||||
msgid "%s out of %s column"
|
||||
msgid_plural "%s out of %s columns"
|
||||
msgstr[0] "已選取 %s/%s 個欄位"
|
||||
msgstr[0] "自定義列"
|
||||
|
||||
#, python-format
|
||||
#, fuzzy, python-format
|
||||
msgid "%s out of %s metric"
|
||||
msgid_plural "%s out of %s metrics"
|
||||
msgstr[0] "已選取 %s/%s 個指標"
|
||||
msgstr[0] "排序指標"
|
||||
|
||||
#, python-format
|
||||
#, fuzzy, python-format
|
||||
msgid "%s out of %s selected"
|
||||
msgstr "已選取 %s/%s 個項目"
|
||||
msgstr "%s 已選定"
|
||||
|
||||
#, python-format
|
||||
#, fuzzy, python-format
|
||||
msgid "%s recipients"
|
||||
msgstr "%s 收件者"
|
||||
msgstr "%s 最近"
|
||||
|
||||
#, python-format
|
||||
#, fuzzy, python-format
|
||||
msgid "%s record..."
|
||||
msgid_plural "%s records..."
|
||||
msgstr[0] "%s 筆記錄..."
|
||||
msgstr[0] "%s 異常"
|
||||
|
||||
#, python-format
|
||||
#, fuzzy, python-format
|
||||
msgid "%s row"
|
||||
msgid_plural "%s rows"
|
||||
msgstr[0] "%s 列"
|
||||
msgstr[0] "%s 行"
|
||||
|
||||
#, python-format
|
||||
#, fuzzy, python-format
|
||||
msgid "%s s ago"
|
||||
msgid_plural "%s s ago"
|
||||
msgstr[0] "%s 秒前"
|
||||
msgstr[0] "30 天之前"
|
||||
|
||||
#, python-format
|
||||
msgid "%s saved metric(s)"
|
||||
msgstr "%s 保存的指標"
|
||||
|
||||
#, python-format
|
||||
#, fuzzy, python-format
|
||||
msgid "%s second"
|
||||
msgid_plural "%s seconds"
|
||||
msgstr[0] "%s 秒"
|
||||
msgstr[0] "5 秒"
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, es, sr,
|
||||
# sr_Latn]
|
||||
@@ -511,13 +511,13 @@ msgstr "%s 個語意檢視新增失敗"
|
||||
msgid "%s semantic view(s) failed to add: %s"
|
||||
msgstr "%s 個語意檢視新增失敗:%s"
|
||||
|
||||
#, python-format
|
||||
#, fuzzy, python-format
|
||||
msgid "%s tab selected"
|
||||
msgstr "已選取「%s」分頁"
|
||||
msgstr "%s 已選定"
|
||||
|
||||
#, python-format
|
||||
#, fuzzy, python-format
|
||||
msgid "%s updated"
|
||||
msgstr "已更新 %s"
|
||||
msgstr "上次更新 %s"
|
||||
|
||||
#, python-format
|
||||
msgid "%s%s"
|
||||
@@ -677,11 +677,13 @@ msgstr "每年年初的頻率"
|
||||
msgid "10 minute"
|
||||
msgstr "10 分鐘"
|
||||
|
||||
#, fuzzy
|
||||
msgid "10 seconds"
|
||||
msgstr "10 秒"
|
||||
msgstr "30 秒"
|
||||
|
||||
#, fuzzy
|
||||
msgid "10/90 percentiles"
|
||||
msgstr "10/90 百分位數"
|
||||
msgstr "9/91 百分位"
|
||||
|
||||
#. do-not-translate
|
||||
msgid "10000"
|
||||
@@ -694,8 +696,9 @@ msgstr "週"
|
||||
msgid "104 weeks ago"
|
||||
msgstr "104 週之前"
|
||||
|
||||
#, fuzzy
|
||||
msgid "12 hours"
|
||||
msgstr "12 小時"
|
||||
msgstr "1 小時"
|
||||
|
||||
msgid "15 minute"
|
||||
msgstr "15 分鐘"
|
||||
@@ -758,8 +761,9 @@ msgstr "2/98 百分位"
|
||||
msgid "22"
|
||||
msgstr "22"
|
||||
|
||||
#, fuzzy
|
||||
msgid "24 hours"
|
||||
msgstr "24 小時"
|
||||
msgstr "6 小時"
|
||||
|
||||
#, fuzzy
|
||||
msgid "28 days"
|
||||
@@ -827,8 +831,9 @@ msgstr "5 秒"
|
||||
msgid "5 seconds"
|
||||
msgstr "5 秒"
|
||||
|
||||
#, fuzzy
|
||||
msgid "5/95 percentiles"
|
||||
msgstr "5/95 百分位數"
|
||||
msgstr "9/91 百分位"
|
||||
|
||||
#, fuzzy
|
||||
msgid "52 weeks"
|
||||
|
||||
@@ -35,12 +35,14 @@ from superset.extensions import cache_manager, db, security_manager
|
||||
from superset.models.core import Database, FavStar, FavStarClassName
|
||||
from superset.models.dashboard import Dashboard
|
||||
from superset.models.slice import Slice
|
||||
from superset.models.sql_lab import SavedQuery
|
||||
from superset.reports.models import ReportSchedule, ReportScheduleType
|
||||
from superset.subjects.models import Subject
|
||||
from superset.subjects.types import SubjectType
|
||||
from superset.tags.models import ObjectType, Tag, TaggedObject, TagType
|
||||
from superset.utils import json
|
||||
from superset.utils.core import get_example_default_schema
|
||||
from superset.utils.database import get_example_database
|
||||
from tests.integration_tests.base_api_tests import ApiEditorsTestCaseMixin
|
||||
from tests.integration_tests.base_tests import (
|
||||
subjects_from_users,
|
||||
@@ -660,6 +662,46 @@ class TestChartApi(ApiEditorsTestCaseMixin, InsertChartMixin, SupersetTestCase):
|
||||
response = json.loads(rv.data.decode("utf-8"))
|
||||
assert response == {"message": {"datasource_id": ["Datasource does not exist"]}}
|
||||
|
||||
def test_create_chart_from_saved_query_rejected_cleanly(self):
|
||||
"""
|
||||
Chart API: creating a chart with datasource_type="saved_query" must
|
||||
fail with a clean validation error, not the unhandled 500 "Fatal
|
||||
error" reported in apache/superset#29697. Slice.datasource only
|
||||
ever resolves the "table" relationship, so even a chart that
|
||||
"created" successfully with this datasource_type could never
|
||||
actually render -- "saved_query" is a real, existing row here
|
||||
(not a bad ID), reproducing the original report exactly rather
|
||||
than a not-found case.
|
||||
"""
|
||||
self.login(ADMIN_USERNAME)
|
||||
example_db = get_example_database()
|
||||
saved_query = SavedQuery(
|
||||
db_id=example_db.id,
|
||||
label="issue-29697-repro",
|
||||
schema=get_example_default_schema(),
|
||||
sql="SELECT 1 AS value",
|
||||
)
|
||||
db.session.add(saved_query)
|
||||
db.session.commit()
|
||||
saved_query_id = saved_query.id
|
||||
|
||||
chart_data = {
|
||||
"slice_name": "issue-29697-repro-chart",
|
||||
"datasource_id": saved_query_id,
|
||||
"datasource_type": "saved_query",
|
||||
"viz_type": "table",
|
||||
}
|
||||
rv = self.post_assert_metric("/api/v1/chart/", chart_data, "post")
|
||||
|
||||
db.session.delete(db.session.query(SavedQuery).get(saved_query_id))
|
||||
db.session.commit()
|
||||
|
||||
assert rv.status_code == 422
|
||||
response = json.loads(rv.data.decode("utf-8"))
|
||||
assert response == {
|
||||
"message": {"datasource_type": ["Datasource type is invalid"]}
|
||||
}
|
||||
|
||||
@pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
|
||||
def test_create_chart_validate_user_is_dashboard_editor(self):
|
||||
"""
|
||||
|
||||
@@ -29,10 +29,6 @@ from sqlalchemy.orm import Session
|
||||
from superset import db
|
||||
from superset.commands.deletion_retention import audit
|
||||
from superset.commands.deletion_retention.audit import PurgeAuditLog
|
||||
from superset.commands.deletion_retention.purge_policy import (
|
||||
REASON_CASCADE_INTEGRITY_FAILURE,
|
||||
REASON_REPORT_SCHEDULE,
|
||||
)
|
||||
from superset.models.slice import Slice
|
||||
from superset.tasks.deletion_retention import _purge_impl
|
||||
|
||||
@@ -79,7 +75,6 @@ class TestPurgeAudit(DeletionRetentionTestBase):
|
||||
assert row.trigger == audit.TRIGGER_RETENTION
|
||||
assert row.actor == audit.ACTOR_SYSTEM
|
||||
assert row.confirmed_on is not None
|
||||
assert row.reason is None
|
||||
assert isinstance(row.id, UUID)
|
||||
|
||||
def test_known_failure_finalizes_audit_row(self) -> None:
|
||||
@@ -102,7 +97,6 @@ class TestPurgeAudit(DeletionRetentionTestBase):
|
||||
row = db.session.query(PurgeAuditLog).filter_by(entity_uuid=chart_uuid).one()
|
||||
assert row.status == audit.STATUS_FAILED
|
||||
assert row.confirmed_on is None
|
||||
assert row.reason is None
|
||||
|
||||
def test_reconcile_confirms_pending_after_entity_commit(self) -> None:
|
||||
"""A crash after entity commit is reconciled to confirmed."""
|
||||
@@ -178,9 +172,6 @@ class TestPurgeAudit(DeletionRetentionTestBase):
|
||||
row = db.session.get(PurgeAuditLog, record_id)
|
||||
assert row.status == audit.STATUS_TARGET_ABSENT
|
||||
assert row.removed_dashboard_slices == 0
|
||||
# The reconcile crash window is the documented reason-losing path:
|
||||
# finalized rows here never carry a fabricated code.
|
||||
assert row.reason is None
|
||||
|
||||
def test_blocked_attempt_does_not_keep_the_intended_removal_count(self) -> None:
|
||||
"""The write-ahead row records what the purge INTENDED to remove;
|
||||
@@ -193,7 +184,7 @@ class TestPurgeAudit(DeletionRetentionTestBase):
|
||||
entity_uuid="00000000-0000-0000-0000-00000000cafe",
|
||||
removed_dashboard_slices=7,
|
||||
)
|
||||
audit.block(record_id, REASON_REPORT_SCHEDULE)
|
||||
audit.block(record_id)
|
||||
|
||||
row = db.session.query(PurgeAuditLog).filter_by(id=record_id).one()
|
||||
assert row.status == audit.STATUS_BLOCKED
|
||||
@@ -203,56 +194,20 @@ class TestPurgeAudit(DeletionRetentionTestBase):
|
||||
record_id: UUID = self._write_retention_record(entity_uuid="first-block")
|
||||
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(record_id, REASON_REPORT_SCHEDULE)
|
||||
audit.finalize_retention_blocked(record_id)
|
||||
)
|
||||
|
||||
record: PurgeAuditLog = self._get_audit_record(record_id)
|
||||
assert disposition == "retained"
|
||||
assert record.status == audit.STATUS_BLOCKED
|
||||
assert record.reason == REASON_REPORT_SCHEDULE
|
||||
|
||||
def test_reason_is_persisted_only_for_blocked_outcomes(self) -> None:
|
||||
"""A reason offered for a non-blocked outcome is refused, not stored.
|
||||
|
||||
The audit records a cause for a purge that did not happen; a
|
||||
confirmed or failed row asserting a blocker would misreport its own
|
||||
outcome.
|
||||
"""
|
||||
for status in (
|
||||
audit.STATUS_CONFIRMED,
|
||||
audit.STATUS_FAILED,
|
||||
audit.STATUS_TARGET_ABSENT,
|
||||
):
|
||||
record_id: UUID = self._write_retention_record(
|
||||
entity_uuid=f"non-blocked-{status}"
|
||||
)
|
||||
audit.finalize(record_id, status, reason=REASON_REPORT_SCHEDULE)
|
||||
|
||||
record: PurgeAuditLog = self._get_audit_record(record_id)
|
||||
db.session.refresh(record)
|
||||
assert record.status == status
|
||||
assert record.reason is None
|
||||
|
||||
def test_finalized_reason_is_immutable(self) -> None:
|
||||
"""A second finalization attempt never rewrites the recorded reason."""
|
||||
record_id: UUID = self._write_retention_record(entity_uuid="reason-immutable")
|
||||
audit.finalize_retention_blocked(record_id, REASON_REPORT_SCHEDULE)
|
||||
|
||||
audit.block(record_id, "some_other_code")
|
||||
audit.finalize_retention_blocked(record_id, "some_other_code")
|
||||
|
||||
record: PurgeAuditLog = self._get_audit_record(record_id)
|
||||
db.session.refresh(record)
|
||||
assert record.status == audit.STATUS_BLOCKED
|
||||
assert record.reason == REASON_REPORT_SCHEDULE
|
||||
|
||||
def test_repeated_retention_block_suppresses_current_provisional(self) -> None:
|
||||
first_id: UUID = self._write_retention_record(entity_uuid="repeat-block")
|
||||
audit.finalize_retention_blocked(first_id, REASON_REPORT_SCHEDULE)
|
||||
audit.finalize_retention_blocked(first_id)
|
||||
second_id: UUID = self._write_retention_record(entity_uuid="repeat-block")
|
||||
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(second_id, REASON_REPORT_SCHEDULE)
|
||||
audit.finalize_retention_blocked(second_id)
|
||||
)
|
||||
|
||||
assert disposition == "suppressed"
|
||||
@@ -260,160 +215,18 @@ class TestPurgeAudit(DeletionRetentionTestBase):
|
||||
assert first.status == audit.STATUS_BLOCKED
|
||||
assert db.session.get(PurgeAuditLog, second_id) is None
|
||||
|
||||
def test_reason_change_breaks_suppression_exactly_once(self) -> None:
|
||||
"""A reason change writes one new blocked row, then re-suppresses.
|
||||
|
||||
The suppression predicate keys on status AND reason: same-reason
|
||||
nights suppress; the night the reason changes is retained with the
|
||||
new code and becomes the new anchor.
|
||||
"""
|
||||
entity: str = "reason-change"
|
||||
first_id: UUID = self._write_retention_record(entity_uuid=entity)
|
||||
assert (
|
||||
audit.finalize_retention_blocked(first_id, REASON_REPORT_SCHEDULE)
|
||||
== "retained"
|
||||
)
|
||||
second_id: UUID = self._write_retention_record(entity_uuid=entity)
|
||||
assert (
|
||||
audit.finalize_retention_blocked(second_id, REASON_REPORT_SCHEDULE)
|
||||
== "suppressed"
|
||||
)
|
||||
changed_id: UUID = self._write_retention_record(entity_uuid=entity)
|
||||
assert (
|
||||
audit.finalize_retention_blocked(
|
||||
changed_id, REASON_CASCADE_INTEGRITY_FAILURE
|
||||
)
|
||||
== "retained"
|
||||
)
|
||||
repeat_id: UUID = self._write_retention_record(entity_uuid=entity)
|
||||
assert (
|
||||
audit.finalize_retention_blocked(
|
||||
repeat_id, REASON_CASCADE_INTEGRITY_FAILURE
|
||||
)
|
||||
== "suppressed"
|
||||
)
|
||||
|
||||
rows: list[PurgeAuditLog] = (
|
||||
db.session.query(PurgeAuditLog).filter_by(entity_uuid=entity).all()
|
||||
)
|
||||
assert {row.reason for row in rows} == {
|
||||
REASON_REPORT_SCHEDULE,
|
||||
REASON_CASCADE_INTEGRITY_FAILURE,
|
||||
}
|
||||
assert len(rows) == 2
|
||||
assert all(row.status == audit.STATUS_BLOCKED for row in rows)
|
||||
|
||||
def test_mixed_reason_timestamp_tie_is_ambiguous_and_retains(self) -> None:
|
||||
"""Tied predecessors differing only in reason refuse suppression."""
|
||||
timestamp: datetime = datetime.utcnow()
|
||||
first_id: UUID = self._write_retention_record(
|
||||
entity_uuid="mixed-reason-tie", created_on=timestamp
|
||||
)
|
||||
audit.finalize_retention_blocked(first_id, REASON_REPORT_SCHEDULE)
|
||||
second_id: UUID = self._write_retention_record(
|
||||
entity_uuid="mixed-reason-tie", created_on=timestamp
|
||||
)
|
||||
audit.finalize_retention_blocked(second_id, REASON_CASCADE_INTEGRITY_FAILURE)
|
||||
current_id: UUID = self._write_retention_record(
|
||||
entity_uuid="mixed-reason-tie", created_on=timestamp + timedelta(seconds=1)
|
||||
)
|
||||
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(current_id, REASON_REPORT_SCHEDULE)
|
||||
)
|
||||
|
||||
assert disposition == "retained"
|
||||
current: PurgeAuditLog = self._get_audit_record(current_id)
|
||||
assert current.status == audit.STATUS_BLOCKED
|
||||
|
||||
def test_null_reason_historical_predecessor_never_suppresses(self) -> None:
|
||||
"""The first post-upgrade block of a long-blocked entity is retained.
|
||||
|
||||
Pre-feature blocked rows carry NULL; NULL never matches a current
|
||||
code, so the entity anchors once with its code and same-code nights
|
||||
suppress against the new anchor.
|
||||
"""
|
||||
entity: str = "null-historical"
|
||||
prior_id: UUID = self._write_retention_record(entity_uuid=entity)
|
||||
audit.finalize(prior_id, audit.STATUS_BLOCKED)
|
||||
prior: PurgeAuditLog = self._get_audit_record(prior_id)
|
||||
assert prior.reason is None
|
||||
|
||||
current_id: UUID = self._write_retention_record(entity_uuid=entity)
|
||||
assert (
|
||||
audit.finalize_retention_blocked(current_id, REASON_REPORT_SCHEDULE)
|
||||
== "retained"
|
||||
)
|
||||
current: PurgeAuditLog = self._get_audit_record(current_id)
|
||||
assert current.reason == REASON_REPORT_SCHEDULE
|
||||
|
||||
repeat_id: UUID = self._write_retention_record(entity_uuid=entity)
|
||||
assert (
|
||||
audit.finalize_retention_blocked(repeat_id, REASON_REPORT_SCHEDULE)
|
||||
== "suppressed"
|
||||
)
|
||||
|
||||
def test_none_current_code_never_suppresses_and_warns(self) -> None:
|
||||
"""A missing current code fails safe: retained, with a warning."""
|
||||
entity: str = "none-current-code"
|
||||
first_id: UUID = self._write_retention_record(entity_uuid=entity)
|
||||
audit.finalize_retention_blocked(first_id, REASON_REPORT_SCHEDULE)
|
||||
current_id: UUID = self._write_retention_record(entity_uuid=entity)
|
||||
|
||||
with patch(
|
||||
"superset.commands.deletion_retention.audit.logger.warning"
|
||||
) as warning:
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(current_id, None)
|
||||
)
|
||||
|
||||
assert disposition == "retained"
|
||||
assert warning.called
|
||||
current: PurgeAuditLog = self._get_audit_record(current_id)
|
||||
assert current.status == audit.STATUS_BLOCKED
|
||||
assert current.reason is None
|
||||
|
||||
def test_predecessor_is_the_latest_row_overall(self) -> None:
|
||||
"""A newer same-entity row forbids suppressing against an older one.
|
||||
|
||||
With rows timestamped both before and after the current attempt, the
|
||||
later-timestamped row is selected, fails the strictly-older check, and
|
||||
causes retention. This verifies timestamp ordering, not causal order
|
||||
across workers.
|
||||
"""
|
||||
timestamp: datetime = datetime.utcnow()
|
||||
older_id: UUID = self._write_retention_record(
|
||||
entity_uuid="latest-overall", created_on=timestamp - timedelta(seconds=1)
|
||||
)
|
||||
audit.finalize_retention_blocked(older_id, REASON_REPORT_SCHEDULE)
|
||||
newer_id: UUID = self._write_retention_record(
|
||||
entity_uuid="latest-overall", created_on=timestamp + timedelta(seconds=1)
|
||||
)
|
||||
audit.finalize(newer_id, audit.STATUS_BLOCKED, reason=REASON_REPORT_SCHEDULE)
|
||||
current_id: UUID = self._write_retention_record(
|
||||
entity_uuid="latest-overall", created_on=timestamp
|
||||
)
|
||||
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(current_id, REASON_REPORT_SCHEDULE)
|
||||
)
|
||||
|
||||
assert disposition == "retained"
|
||||
current: PurgeAuditLog = self._get_audit_record(current_id)
|
||||
assert current.status == audit.STATUS_BLOCKED
|
||||
|
||||
def test_equal_timestamp_is_ambiguous_and_retains_current(self) -> None:
|
||||
timestamp: datetime = datetime.utcnow()
|
||||
first_id: UUID = self._write_retention_record(
|
||||
entity_uuid="equal-time", created_on=timestamp
|
||||
)
|
||||
audit.finalize_retention_blocked(first_id, REASON_REPORT_SCHEDULE)
|
||||
audit.finalize_retention_blocked(first_id)
|
||||
second_id: UUID = self._write_retention_record(
|
||||
entity_uuid="equal-time", created_on=timestamp
|
||||
)
|
||||
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(second_id, REASON_REPORT_SCHEDULE)
|
||||
audit.finalize_retention_blocked(second_id)
|
||||
)
|
||||
|
||||
assert disposition == "retained"
|
||||
@@ -444,7 +257,7 @@ class TestPurgeAudit(DeletionRetentionTestBase):
|
||||
session.close()
|
||||
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(current_id, REASON_REPORT_SCHEDULE)
|
||||
audit.finalize_retention_blocked(current_id)
|
||||
)
|
||||
|
||||
assert predecessor is None
|
||||
@@ -460,10 +273,10 @@ class TestPurgeAudit(DeletionRetentionTestBase):
|
||||
newer_id: UUID = self._write_retention_record(
|
||||
entity_uuid="overlap", created_on=current_time + timedelta(seconds=1)
|
||||
)
|
||||
audit.finalize_retention_blocked(newer_id, REASON_REPORT_SCHEDULE)
|
||||
audit.finalize_retention_blocked(newer_id)
|
||||
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(current_id, REASON_REPORT_SCHEDULE)
|
||||
audit.finalize_retention_blocked(current_id)
|
||||
)
|
||||
|
||||
assert disposition == "retained"
|
||||
@@ -478,7 +291,7 @@ class TestPurgeAudit(DeletionRetentionTestBase):
|
||||
)
|
||||
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(current_id, REASON_REPORT_SCHEDULE)
|
||||
audit.finalize_retention_blocked(current_id)
|
||||
)
|
||||
|
||||
assert disposition == "retained"
|
||||
@@ -487,7 +300,7 @@ class TestPurgeAudit(DeletionRetentionTestBase):
|
||||
null_id: UUID = self._write_retention_record(entity_uuid=None)
|
||||
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(null_id, REASON_REPORT_SCHEDULE)
|
||||
audit.finalize_retention_blocked(null_id)
|
||||
)
|
||||
|
||||
record: PurgeAuditLog = self._get_audit_record(null_id)
|
||||
@@ -499,13 +312,13 @@ class TestPurgeAudit(DeletionRetentionTestBase):
|
||||
chart_id: UUID = self._write_retention_record(
|
||||
entity_uuid="shared-type", entity_type="slices"
|
||||
)
|
||||
audit.finalize_retention_blocked(chart_id, REASON_REPORT_SCHEDULE)
|
||||
audit.finalize_retention_blocked(chart_id)
|
||||
dashboard_id: UUID = self._write_retention_record(
|
||||
entity_uuid="shared-type", entity_type="dashboards"
|
||||
)
|
||||
|
||||
dashboard_disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(dashboard_id, REASON_REPORT_SCHEDULE)
|
||||
audit.finalize_retention_blocked(dashboard_id)
|
||||
)
|
||||
|
||||
assert dashboard_disposition == "retained"
|
||||
@@ -515,7 +328,7 @@ class TestPurgeAudit(DeletionRetentionTestBase):
|
||||
audit.fail(record_id)
|
||||
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(record_id, REASON_REPORT_SCHEDULE)
|
||||
audit.finalize_retention_blocked(record_id)
|
||||
)
|
||||
|
||||
record: PurgeAuditLog = self._get_audit_record(record_id)
|
||||
@@ -530,7 +343,7 @@ class TestPurgeAudit(DeletionRetentionTestBase):
|
||||
side_effect=audit.SQLAlchemyError("lookup failed"),
|
||||
):
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(record_id, REASON_REPORT_SCHEDULE)
|
||||
audit.finalize_retention_blocked(record_id)
|
||||
)
|
||||
|
||||
record: PurgeAuditLog = self._get_audit_record(record_id)
|
||||
@@ -539,7 +352,7 @@ class TestPurgeAudit(DeletionRetentionTestBase):
|
||||
|
||||
def test_suppression_delete_failure_recovers_blocked_evidence(self) -> None:
|
||||
first_id: UUID = self._write_retention_record(entity_uuid="delete-failure")
|
||||
audit.finalize_retention_blocked(first_id, REASON_REPORT_SCHEDULE)
|
||||
audit.finalize_retention_blocked(first_id)
|
||||
current_id: UUID = self._write_retention_record(entity_uuid="delete-failure")
|
||||
|
||||
with patch(
|
||||
@@ -547,7 +360,7 @@ class TestPurgeAudit(DeletionRetentionTestBase):
|
||||
side_effect=audit.SQLAlchemyError("delete failed"),
|
||||
):
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(current_id, REASON_REPORT_SCHEDULE)
|
||||
audit.finalize_retention_blocked(current_id)
|
||||
)
|
||||
|
||||
record: PurgeAuditLog = self._get_audit_record(current_id)
|
||||
@@ -570,18 +383,16 @@ class TestPurgeAudit(DeletionRetentionTestBase):
|
||||
),
|
||||
):
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(record_id, REASON_REPORT_SCHEDULE)
|
||||
audit.finalize_retention_blocked(record_id)
|
||||
)
|
||||
|
||||
record: PurgeAuditLog = self._get_audit_record(record_id)
|
||||
assert disposition == "fallback"
|
||||
assert record.status == audit.STATUS_BLOCKED
|
||||
# The recovery retain branch carries the argument-sourced snapshot reason.
|
||||
assert record.reason == REASON_REPORT_SCHEDULE
|
||||
|
||||
def test_uncertain_suppression_commit_recreates_absent_evidence(self) -> None:
|
||||
first_id: UUID = self._write_retention_record(entity_uuid="absent-current")
|
||||
audit.finalize_retention_blocked(first_id, REASON_REPORT_SCHEDULE)
|
||||
audit.finalize_retention_blocked(first_id)
|
||||
current_id: UUID = self._write_retention_record(entity_uuid="absent-current")
|
||||
primary_session: Session = audit._dedicated_session()
|
||||
recovery_session: Session = audit._dedicated_session()
|
||||
@@ -599,16 +410,12 @@ class TestPurgeAudit(DeletionRetentionTestBase):
|
||||
),
|
||||
):
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(current_id, REASON_REPORT_SCHEDULE)
|
||||
audit.finalize_retention_blocked(current_id)
|
||||
)
|
||||
|
||||
record: PurgeAuditLog = self._get_audit_record(current_id)
|
||||
assert disposition == "fallback"
|
||||
assert record.status == audit.STATUS_BLOCKED
|
||||
# The recovery re-insert branch sources the reason from the snapshot
|
||||
# (populated from the call argument, never from the reason-less
|
||||
# pending row).
|
||||
assert record.reason == REASON_REPORT_SCHEDULE
|
||||
|
||||
def test_failed_fallback_leaves_pending_evidence_for_reconciliation(self) -> None:
|
||||
record_id: UUID = self._write_retention_record(entity_uuid="fallback-failure")
|
||||
@@ -631,7 +438,7 @@ class TestPurgeAudit(DeletionRetentionTestBase):
|
||||
),
|
||||
):
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(record_id, REASON_REPORT_SCHEDULE)
|
||||
audit.finalize_retention_blocked(record_id)
|
||||
)
|
||||
|
||||
record: PurgeAuditLog = self._get_audit_record(record_id)
|
||||
@@ -657,34 +464,31 @@ class TestPurgeAudit(DeletionRetentionTestBase):
|
||||
entity_type="slices",
|
||||
entity_uuid="indeterminate-rowcount",
|
||||
created_on=timestamp - timedelta(seconds=1),
|
||||
reason=REASON_REPORT_SCHEDULE,
|
||||
)
|
||||
result: MagicMock = MagicMock(rowcount=-1)
|
||||
session: MagicMock = MagicMock()
|
||||
session.execute.return_value = result
|
||||
|
||||
with pytest.raises(audit.SQLAlchemyError, match="indeterminate"):
|
||||
audit._suppress_redundant_block(
|
||||
session, current, predecessor, REASON_REPORT_SCHEDULE
|
||||
)
|
||||
audit._suppress_redundant_block(session, current, predecessor)
|
||||
|
||||
def test_overlap_duplicates_do_not_cause_unbounded_sequential_growth(self) -> None:
|
||||
timestamp: datetime = datetime.utcnow()
|
||||
first_id: UUID = self._write_retention_record(
|
||||
entity_uuid="bounded-overlap", created_on=timestamp
|
||||
)
|
||||
audit.finalize_retention_blocked(first_id, REASON_REPORT_SCHEDULE)
|
||||
audit.finalize_retention_blocked(first_id)
|
||||
overlap_id: UUID = self._write_retention_record(
|
||||
entity_uuid="bounded-overlap", created_on=timestamp
|
||||
)
|
||||
audit.finalize_retention_blocked(overlap_id, REASON_REPORT_SCHEDULE)
|
||||
audit.finalize_retention_blocked(overlap_id)
|
||||
later_id: UUID = self._write_retention_record(
|
||||
entity_uuid="bounded-overlap",
|
||||
created_on=timestamp + timedelta(seconds=1),
|
||||
)
|
||||
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(later_id, REASON_REPORT_SCHEDULE)
|
||||
audit.finalize_retention_blocked(later_id)
|
||||
)
|
||||
|
||||
retained_count: int = (
|
||||
@@ -712,7 +516,7 @@ class TestPurgeAudit(DeletionRetentionTestBase):
|
||||
current_id: UUID = self._write_retention_record(entity_uuid=entity_uuid)
|
||||
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(current_id, REASON_REPORT_SCHEDULE)
|
||||
audit.finalize_retention_blocked(current_id)
|
||||
)
|
||||
|
||||
current: PurgeAuditLog = self._get_audit_record(current_id)
|
||||
|
||||
@@ -140,7 +140,6 @@ class TestForcePurge(DeletionRetentionTestBase):
|
||||
assert self.exists(Slice, chart_id)
|
||||
row = db.session.query(PurgeAuditLog).filter_by(entity_uuid=chart_uuid).one()
|
||||
assert row.status == "blocked"
|
||||
assert row.reason == "report_schedule"
|
||||
log_info.assert_called_once_with(
|
||||
"force_purge: blocked %s uuid=%s reason=%s",
|
||||
"chart",
|
||||
|
||||
@@ -24,7 +24,6 @@ guarantee under FK enforcement OFF, and the version-tables-absent no-op.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
@@ -46,9 +45,6 @@ from superset.commands.deletion_retention.purge_cascade import (
|
||||
from superset.commands.deletion_retention.purge_policy import (
|
||||
get_purge_policy,
|
||||
PurgeEntityPolicy,
|
||||
REASON_CASCADE_INTEGRITY_FAILURE,
|
||||
REASON_REPORT_SCHEDULE,
|
||||
REASON_USER_ATTRIBUTE,
|
||||
)
|
||||
from superset.connectors.sqla.models import (
|
||||
RLSFilterTables,
|
||||
@@ -290,7 +286,6 @@ class TestSoftDeletePurge(DeletionRetentionTestBase):
|
||||
.one()
|
||||
)
|
||||
assert row.status == audit.STATUS_BLOCKED
|
||||
assert row.reason == REASON_REPORT_SCHEDULE
|
||||
|
||||
def test_repeated_report_blocker_preserves_counts_and_suppresses_noise(
|
||||
self,
|
||||
@@ -773,7 +768,6 @@ class TestExplicitBlockerGuards(DeletionRetentionTestBase):
|
||||
assert result.purged is False
|
||||
assert result.blocked_reason is not None
|
||||
assert "welcome page" in result.blocked_reason
|
||||
assert result.blocked_reason_code == REASON_USER_ATTRIBUTE
|
||||
assert self.exists(Dashboard, dashboard_id)
|
||||
finally:
|
||||
self._restore_welcome(attribute, created, previous)
|
||||
@@ -818,120 +812,10 @@ class TestExplicitBlockerGuards(DeletionRetentionTestBase):
|
||||
db.session.commit()
|
||||
|
||||
assert result.purged is False
|
||||
assert (
|
||||
result.blocked_reason
|
||||
== "cascade blocked by a database integrity constraint"
|
||||
)
|
||||
assert result.blocked_reason == "blocked by database references"
|
||||
assert "SQL:" not in result.blocked_reason
|
||||
assert result.blocked_reason_code == REASON_CASCADE_INTEGRITY_FAILURE
|
||||
assert self.exists(Slice, chart_id)
|
||||
|
||||
def test_three_way_distinction_is_readable_from_the_audit_alone(self) -> None:
|
||||
"""Report, welcome, and database-integrity blocks write distinct codes.
|
||||
|
||||
The audit table is the durable record: each of the three
|
||||
non-completing outcomes must be identifiable from its row alone,
|
||||
with no SQL fragments and the integrity case keeping blocked status.
|
||||
"""
|
||||
chart: Slice = self.make_chart("threeway_report")
|
||||
report: ReportSchedule = ReportSchedule(
|
||||
type="Report",
|
||||
name="retention_it_threeway",
|
||||
crontab="0 0 * * *",
|
||||
chart=chart,
|
||||
)
|
||||
db.session.add(report)
|
||||
db.session.commit()
|
||||
chart_uuid: str = str(chart.uuid)
|
||||
self.soft_delete(chart, days_ago=90)
|
||||
|
||||
dashboard: Dashboard = self.make_dashboard("threeway_welcome")
|
||||
dashboard_uuid: str = str(dashboard.uuid)
|
||||
self.soft_delete(dashboard, days_ago=90)
|
||||
attribute: UserAttribute
|
||||
created: bool
|
||||
previous: int | None
|
||||
attribute, created, previous = self._set_welcome(dashboard.id)
|
||||
|
||||
fk_chart: Slice = self.make_chart("threeway_fk")
|
||||
fk_uuid: str = str(fk_chart.uuid)
|
||||
self.soft_delete(fk_chart, days_ago=90)
|
||||
|
||||
real_get_policy: Callable[[type[Any]], PurgeEntityPolicy] = get_purge_policy
|
||||
|
||||
def fail_fk_chart_cleanup(
|
||||
session: Session, policy: PurgeEntityPolicy, entity_id: int
|
||||
) -> None:
|
||||
if entity_id == fk_chart.id:
|
||||
raise IntegrityError("FOREIGN KEY constraint failed", None, Exception())
|
||||
real_get_policy(Slice).delete_associations(session, policy, entity_id)
|
||||
|
||||
def patched_policy(model: type[Any]) -> PurgeEntityPolicy:
|
||||
policy: PurgeEntityPolicy = real_get_policy(model)
|
||||
if model is Slice:
|
||||
return replace(policy, delete_associations=fail_fk_chart_cleanup)
|
||||
return policy
|
||||
|
||||
try:
|
||||
with patch(
|
||||
"superset.commands.deletion_retention.purge_cascade.get_purge_policy",
|
||||
side_effect=patched_policy,
|
||||
):
|
||||
_purge(window=30)
|
||||
|
||||
rows: dict[str, audit.PurgeAuditLog] = {
|
||||
uuid: db.session.query(audit.PurgeAuditLog)
|
||||
.filter_by(entity_uuid=uuid)
|
||||
.one()
|
||||
for uuid in (chart_uuid, dashboard_uuid, fk_uuid)
|
||||
}
|
||||
assert rows[chart_uuid].reason == REASON_REPORT_SCHEDULE
|
||||
assert rows[dashboard_uuid].reason == REASON_USER_ATTRIBUTE
|
||||
assert rows[fk_uuid].reason == REASON_CASCADE_INTEGRITY_FAILURE
|
||||
assert len({row.reason for row in rows.values()}) == 3
|
||||
for row in rows.values():
|
||||
assert row.status == audit.STATUS_BLOCKED
|
||||
assert "SQL" not in row.reason
|
||||
assert "?" not in row.reason
|
||||
finally:
|
||||
self._restore_welcome(attribute, created, previous)
|
||||
|
||||
def test_first_declared_blocker_wins_in_the_audit_record(self) -> None:
|
||||
"""A dashboard blocked by both rules records the first-declared code.
|
||||
|
||||
Declaration order is part of the audit contract: report_schedule is
|
||||
declared before user_attribute, so a dashboard that is both
|
||||
report-referenced and someone's welcome page records
|
||||
REASON_REPORT_SCHEDULE.
|
||||
"""
|
||||
dashboard: Dashboard = self.make_dashboard("firstmatch")
|
||||
report: ReportSchedule = ReportSchedule(
|
||||
type="Report",
|
||||
name="retention_it_firstmatch",
|
||||
crontab="0 0 * * *",
|
||||
dashboard=dashboard,
|
||||
)
|
||||
db.session.add(report)
|
||||
db.session.commit()
|
||||
dashboard_uuid: str = str(dashboard.uuid)
|
||||
self.soft_delete(dashboard, days_ago=90)
|
||||
attribute: UserAttribute
|
||||
created: bool
|
||||
previous: int | None
|
||||
attribute, created, previous = self._set_welcome(dashboard.id)
|
||||
try:
|
||||
_purge(window=30)
|
||||
|
||||
row: audit.PurgeAuditLog = (
|
||||
db.session.query(audit.PurgeAuditLog)
|
||||
.filter_by(entity_uuid=dashboard_uuid)
|
||||
.one()
|
||||
)
|
||||
assert row.status == audit.STATUS_BLOCKED
|
||||
assert row.reason == REASON_REPORT_SCHEDULE
|
||||
finally:
|
||||
self._restore_welcome(attribute, created, previous)
|
||||
|
||||
def test_policy_action_failure_rolls_back_prior_phases(self) -> None:
|
||||
"""A later policy-action failure restores earlier association cleanup."""
|
||||
chart: Slice = self.make_chart("action_rollback")
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
"""Unit tests for CreateChartCommand.
|
||||
|
||||
Regression coverage for apache/superset#29697: POST /api/v1/chart/ with
|
||||
datasource_type="saved_query" (or "query") crashes with an unhandled
|
||||
AttributeError -- reported to API clients as an opaque 500 "Fatal error" --
|
||||
because SavedQuery and Query models have no ``.name`` attribute, and because
|
||||
Slice.datasource only ever resolves a ``table``-typed datasource, so even a
|
||||
successfully created chart of another type could never actually render.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from superset.commands.chart.create import CreateChartCommand
|
||||
from superset.commands.chart.exceptions import ChartForbiddenError, ChartInvalidError
|
||||
from superset.commands.exceptions import DatasourceTypeInvalidError
|
||||
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
|
||||
|
||||
def _base_mocks(mocker: MockerFixture) -> None:
|
||||
mocker.patch(
|
||||
"superset.commands.chart.create.DashboardDAO.find_by_ids", return_value=[]
|
||||
)
|
||||
mocker.patch(
|
||||
"superset.commands.chart.create.populate_subjects",
|
||||
side_effect=lambda properties, exceptions: None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("datasource_type", ["saved_query", "query"])
|
||||
def test_create_chart_rejects_non_table_datasource_type(
|
||||
mocker: MockerFixture, datasource_type: str
|
||||
) -> None:
|
||||
"""A chart can only ever query a table-backed datasource -- Slice.datasource
|
||||
only ever resolves the ``table`` relationship, so any other type would
|
||||
produce a chart that "creates" successfully but can never render.
|
||||
|
||||
The two types fail differently before this fix, which is exactly why
|
||||
both are covered here:
|
||||
- "saved_query": SavedQuery has no ``.name`` attribute, so validation
|
||||
crashes with an unhandled AttributeError -- surfaced to API clients as
|
||||
an opaque 500 "Fatal error" (apache/superset#29697).
|
||||
- "query": Query *does* define a synthetic ``.name`` property (used for
|
||||
CTAS table naming, not as a real display name), so this one doesn't
|
||||
crash -- it silently "succeeds" and creates a chart with a nonsense
|
||||
name and a datasource that Slice.datasource can never resolve.
|
||||
|
||||
``get_datasource_by_id`` is mocked with ``spec=`` the real model classes
|
||||
so accessing ``.name`` on the mock behaves exactly like the real ORM
|
||||
objects do if the new guard doesn't stop the code from getting there;
|
||||
``raise_for_access`` is mocked to a no-op so nothing downstream masks
|
||||
that behavior.
|
||||
"""
|
||||
from superset.models.sql_lab import Query, SavedQuery
|
||||
|
||||
_base_mocks(mocker)
|
||||
model_cls = SavedQuery if datasource_type == "saved_query" else Query
|
||||
get_datasource_by_id = mocker.patch(
|
||||
"superset.commands.chart.create.get_datasource_by_id",
|
||||
return_value=mocker.MagicMock(spec=model_cls),
|
||||
)
|
||||
mocker.patch("superset.commands.chart.create.security_manager.raise_for_access")
|
||||
|
||||
with pytest.raises(ChartInvalidError) as exc_info:
|
||||
CreateChartCommand(
|
||||
{
|
||||
"datasource_id": 11,
|
||||
"datasource_type": datasource_type,
|
||||
"slice_name": "some_name",
|
||||
"viz_type": "table",
|
||||
}
|
||||
).validate()
|
||||
|
||||
assert any(
|
||||
isinstance(ex, DatasourceTypeInvalidError) for ex in exc_info.value._exceptions
|
||||
)
|
||||
# The invalid type must be rejected before ever touching the datasource
|
||||
# lookup, not caught incidentally by some downstream failure.
|
||||
get_datasource_by_id.assert_not_called()
|
||||
|
||||
|
||||
def test_create_chart_accepts_table_datasource(mocker: MockerFixture) -> None:
|
||||
"""The one supported datasource_type must keep working."""
|
||||
_base_mocks(mocker)
|
||||
datasource = mocker.MagicMock(name="table_datasource")
|
||||
datasource.name = "my_table"
|
||||
mocker.patch(
|
||||
"superset.commands.chart.create.get_datasource_by_id",
|
||||
return_value=datasource,
|
||||
)
|
||||
mocker.patch("superset.commands.chart.create.security_manager.raise_for_access")
|
||||
|
||||
cmd = CreateChartCommand(
|
||||
{
|
||||
"datasource_id": 11,
|
||||
"datasource_type": "table",
|
||||
"slice_name": "some_name",
|
||||
"viz_type": "table",
|
||||
}
|
||||
)
|
||||
cmd.validate()
|
||||
|
||||
assert cmd._properties["datasource_name"] == "my_table"
|
||||
|
||||
|
||||
def test_create_chart_datasource_access_denied_still_raises_forbidden(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""The invalid-type guard must not shadow the existing access-denied path
|
||||
for a legitimately table-typed datasource the user can't access."""
|
||||
_base_mocks(mocker)
|
||||
datasource = mocker.MagicMock()
|
||||
datasource.name = "my_table"
|
||||
mocker.patch(
|
||||
"superset.commands.chart.create.get_datasource_by_id",
|
||||
return_value=datasource,
|
||||
)
|
||||
mocker.patch(
|
||||
"superset.commands.chart.create.security_manager.raise_for_access",
|
||||
side_effect=SupersetSecurityException(
|
||||
SupersetError(
|
||||
error_type=SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR,
|
||||
message="No access",
|
||||
level=ErrorLevel.ERROR,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(ChartForbiddenError):
|
||||
CreateChartCommand(
|
||||
{
|
||||
"datasource_id": 11,
|
||||
"datasource_type": "table",
|
||||
"slice_name": "some_name",
|
||||
"viz_type": "table",
|
||||
}
|
||||
).validate()
|
||||
@@ -17,8 +17,13 @@
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from superset.commands.chart.exceptions import ChartForbiddenError, ChartInvalidError
|
||||
from superset.commands.chart.exceptions import (
|
||||
ChartForbiddenError,
|
||||
ChartInvalidError,
|
||||
DatasourceTypeUpdateRequiredValidationError,
|
||||
)
|
||||
from superset.commands.chart.update import UpdateChartCommand
|
||||
from superset.commands.exceptions import DatasourceTypeInvalidError
|
||||
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.utils import json
|
||||
@@ -238,3 +243,71 @@ def test_update_chart_query_context_without_datasource_is_allowed(
|
||||
1,
|
||||
{"query_context": query_context, "query_context_generation": True},
|
||||
).validate()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("datasource_type", ["saved_query", "query"])
|
||||
def test_update_chart_rejects_repointing_to_non_table_datasource(
|
||||
mocker: MockerFixture, datasource_type: str
|
||||
) -> None:
|
||||
"""Repointing a chart's datasource_id must be rejected the same way
|
||||
CreateChartCommand rejects it (apache/superset#29697): Slice.datasource
|
||||
only ever resolves the ``table`` relationship, so repointing at a
|
||||
saved_query or query datasource would "succeed" but leave the chart
|
||||
permanently unable to render -- or, for saved_query specifically, crash
|
||||
on SavedQuery's missing ``.name`` attribute before that point is even
|
||||
reached. This is a regular (non-query-context) update, so it goes
|
||||
through editorship + compute_subjects, unlike the query-context-only
|
||||
tests above."""
|
||||
find_by_id = mocker.patch("superset.commands.chart.update.ChartDAO.find_by_id")
|
||||
find_by_id.return_value = mocker.MagicMock(id=1, tags=[], dashboards=[])
|
||||
mocker.patch("superset.commands.chart.update.security_manager.raise_for_editorship")
|
||||
mocker.patch(
|
||||
"superset.commands.chart.update.compute_subjects",
|
||||
side_effect=lambda model, properties, exceptions: None,
|
||||
)
|
||||
get_datasource_by_id = mocker.patch(
|
||||
"superset.commands.chart.update.get_datasource_by_id"
|
||||
)
|
||||
|
||||
with pytest.raises(ChartInvalidError) as exc_info:
|
||||
UpdateChartCommand(
|
||||
1, {"datasource_id": 11, "datasource_type": datasource_type}
|
||||
).validate()
|
||||
|
||||
assert any(
|
||||
isinstance(ex, DatasourceTypeInvalidError) for ex in exc_info.value._exceptions
|
||||
)
|
||||
get_datasource_by_id.assert_not_called()
|
||||
|
||||
|
||||
def test_update_chart_missing_datasource_type_keeps_required_error(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""When datasource_id is given without datasource_type, the response
|
||||
must keep reporting DatasourceTypeUpdateRequiredValidationError
|
||||
("Datasource type is required") rather than having it overwritten by
|
||||
DatasourceTypeInvalidError ("Datasource type is invalid") -- both
|
||||
exceptions key their message under ``datasource_type``, and
|
||||
normalized_messages() only keeps the last one written for a given key."""
|
||||
find_by_id = mocker.patch("superset.commands.chart.update.ChartDAO.find_by_id")
|
||||
find_by_id.return_value = mocker.MagicMock(id=1, tags=[], dashboards=[])
|
||||
mocker.patch("superset.commands.chart.update.security_manager.raise_for_editorship")
|
||||
mocker.patch(
|
||||
"superset.commands.chart.update.compute_subjects",
|
||||
side_effect=lambda model, properties, exceptions: None,
|
||||
)
|
||||
get_datasource_by_id = mocker.patch(
|
||||
"superset.commands.chart.update.get_datasource_by_id"
|
||||
)
|
||||
|
||||
with pytest.raises(ChartInvalidError) as exc_info:
|
||||
UpdateChartCommand(1, {"datasource_id": 11}).validate()
|
||||
|
||||
assert any(
|
||||
isinstance(ex, DatasourceTypeUpdateRequiredValidationError)
|
||||
for ex in exc_info.value._exceptions
|
||||
)
|
||||
assert not any(
|
||||
isinstance(ex, DatasourceTypeInvalidError) for ex in exc_info.value._exceptions
|
||||
)
|
||||
get_datasource_by_id.assert_not_called()
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
"""Golden-set tests pinning the purge-audit reason-code vocabulary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from superset.commands.deletion_retention.purge_policy import (
|
||||
ALL_REASON_CODES,
|
||||
DependencyClassification,
|
||||
DependencyPolicy,
|
||||
get_purge_policy,
|
||||
purge_policy_registry,
|
||||
PurgeBlockedError,
|
||||
PurgeEntityPolicy,
|
||||
REASON_CASCADE_INTEGRITY_FAILURE,
|
||||
REASON_REPORT_SCHEDULE,
|
||||
REASON_USER_ATTRIBUTE,
|
||||
validate_deletion_allowed,
|
||||
)
|
||||
|
||||
|
||||
def test_reason_code_literals_are_frozen() -> None:
|
||||
"""The persisted code values are frozen identifiers.
|
||||
|
||||
Audit history and the suppression predicate compare these exact strings;
|
||||
a physical table rename or constant refactor must not re-mint them. If
|
||||
this test fails, the fix is to restore the literal, never to update the
|
||||
expectation.
|
||||
"""
|
||||
assert REASON_REPORT_SCHEDULE == "report_schedule"
|
||||
assert REASON_USER_ATTRIBUTE == "user_attribute"
|
||||
assert REASON_CASCADE_INTEGRITY_FAILURE == "cascade_integrity_failure"
|
||||
assert ALL_REASON_CODES == {
|
||||
"report_schedule",
|
||||
"user_attribute",
|
||||
"cascade_integrity_failure",
|
||||
}
|
||||
|
||||
|
||||
def test_reason_codes_are_distinct_and_column_sized() -> None:
|
||||
"""Codes are mutually distinct and fit the String(64) audit column."""
|
||||
codes: list[str] = [
|
||||
REASON_REPORT_SCHEDULE,
|
||||
REASON_USER_ATTRIBUTE,
|
||||
REASON_CASCADE_INTEGRITY_FAILURE,
|
||||
]
|
||||
assert len(set(codes)) == len(codes)
|
||||
assert all(0 < len(code) <= 64 for code in ALL_REASON_CODES)
|
||||
|
||||
|
||||
def test_every_declared_blocker_code_is_in_the_closed_set() -> None:
|
||||
"""Each blocker declared in the registry carries a code from ALL_REASON_CODES."""
|
||||
blocker_codes: set[str] = set()
|
||||
for policy in purge_policy_registry().values():
|
||||
for dependency in policy.dependencies:
|
||||
if dependency.classification is DependencyClassification.BLOCK:
|
||||
assert dependency.blocker is not None, (
|
||||
f"blocker {dependency.key.describe()} has no reason code"
|
||||
)
|
||||
blocker_codes.add(dependency.blocker.code)
|
||||
assert blocker_codes <= ALL_REASON_CODES
|
||||
assert blocker_codes == {REASON_REPORT_SCHEDULE, REASON_USER_ATTRIBUTE}
|
||||
|
||||
|
||||
def test_cascade_integrity_failure_code_is_reserved_for_the_cascade() -> None:
|
||||
"""No declared policy blocker may claim the cascade-failure code."""
|
||||
for policy in purge_policy_registry().values():
|
||||
for dependency in policy.dependencies:
|
||||
assert (
|
||||
dependency.blocker is None
|
||||
or dependency.blocker.code != REASON_CASCADE_INTEGRITY_FAILURE
|
||||
)
|
||||
|
||||
|
||||
def _session_matching_blockers(*matches: bool) -> MagicMock:
|
||||
"""A mock session whose Nth blocker query reports a match iff matches[N].
|
||||
|
||||
Deliberately positional: which blocker matches first is the audit
|
||||
contract under test, so these cases are coupled to the order (and the
|
||||
count) of the queries ``validate_deletion_allowed`` issues.
|
||||
"""
|
||||
session: MagicMock = MagicMock()
|
||||
session.execute.side_effect = [
|
||||
MagicMock(first=MagicMock(return_value=(1,) if match else None))
|
||||
for match in matches
|
||||
]
|
||||
return session
|
||||
|
||||
|
||||
def test_report_block_raises_with_the_report_schedule_code() -> None:
|
||||
"""A chart blocked by a report reference carries REASON_REPORT_SCHEDULE."""
|
||||
# avoid app-init regression: superset.models.* evaluates
|
||||
# encrypted_field_factory at class-definition time, which fails
|
||||
# in a partial-collection unit run with no Flask app active.
|
||||
from superset.models.slice import Slice
|
||||
|
||||
info: pytest.ExceptionInfo[PurgeBlockedError]
|
||||
with pytest.raises(PurgeBlockedError) as info:
|
||||
validate_deletion_allowed(
|
||||
_session_matching_blockers(True), get_purge_policy(Slice), 1
|
||||
)
|
||||
assert info.value.reason_code == REASON_REPORT_SCHEDULE
|
||||
assert str(info.value) == "associated alerts or reports exist"
|
||||
|
||||
|
||||
def test_welcome_dashboard_block_raises_with_the_user_attribute_code() -> None:
|
||||
"""A welcome-page block carries a code distinct from the report code."""
|
||||
# avoid app-init regression: superset.models.* evaluates
|
||||
# encrypted_field_factory at class-definition time, which fails
|
||||
# in a partial-collection unit run with no Flask app active.
|
||||
from superset.models.dashboard import Dashboard
|
||||
|
||||
info: pytest.ExceptionInfo[PurgeBlockedError]
|
||||
with pytest.raises(PurgeBlockedError) as info:
|
||||
validate_deletion_allowed(
|
||||
_session_matching_blockers(False, True), get_purge_policy(Dashboard), 1
|
||||
)
|
||||
assert info.value.reason_code == REASON_USER_ATTRIBUTE
|
||||
|
||||
|
||||
def test_reason_code_survives_a_related_table_rename() -> None:
|
||||
"""A renamed table keeps the blocker's declared code.
|
||||
|
||||
The code is declared on the blocker, never derived from the physical
|
||||
table name, so a schema rename changes only which table the blocker
|
||||
looks at — persisted audit history and the suppression predicate keep
|
||||
comparing the same literal.
|
||||
"""
|
||||
# avoid app-init regression: superset.models.* evaluates
|
||||
# encrypted_field_factory at class-definition time, which fails
|
||||
# in a partial-collection unit run with no Flask app active.
|
||||
from superset.models.slice import Slice
|
||||
|
||||
policy: PurgeEntityPolicy = get_purge_policy(Slice)
|
||||
renamed: tuple[DependencyPolicy, ...] = tuple(
|
||||
replace(dependency, key=replace(dependency.key, related_table="reports_v2"))
|
||||
if dependency.classification is DependencyClassification.BLOCK
|
||||
else dependency
|
||||
for dependency in policy.dependencies
|
||||
)
|
||||
blocker: DependencyPolicy = next(
|
||||
dependency
|
||||
for dependency in renamed
|
||||
if dependency.classification is DependencyClassification.BLOCK
|
||||
)
|
||||
assert blocker.key.related_table == "reports_v2"
|
||||
assert blocker.blocker is not None
|
||||
assert blocker.blocker.code == REASON_REPORT_SCHEDULE
|
||||
|
||||
|
||||
def test_first_declared_blocker_wins_when_several_match() -> None:
|
||||
"""A dashboard matching both blockers records the first-declared code."""
|
||||
# avoid app-init regression: superset.models.* evaluates
|
||||
# encrypted_field_factory at class-definition time, which fails
|
||||
# in a partial-collection unit run with no Flask app active.
|
||||
from superset.models.dashboard import Dashboard
|
||||
|
||||
info: pytest.ExceptionInfo[PurgeBlockedError]
|
||||
with pytest.raises(PurgeBlockedError) as info:
|
||||
validate_deletion_allowed(
|
||||
_session_matching_blockers(True, True), get_purge_policy(Dashboard), 1
|
||||
)
|
||||
assert info.value.reason_code == REASON_REPORT_SCHEDULE
|
||||
@@ -68,10 +68,11 @@ def test_dashboard_import_with_overwrite_replaces_charts(
|
||||
}
|
||||
ImportDashboardsCommand._import(initial_configs, overwrite=True)
|
||||
# Commit between imports, as production does: ``run()`` carries
|
||||
# ``@transaction()``, so two imports are two transactions. Without this the
|
||||
# add and the remove of one association share a Continuum transaction and
|
||||
# collide on ``dashboard_slices_version``'s composite key — an artifact of
|
||||
# the test's shortcut, not a reachable production state.
|
||||
# ``@transaction()``, so two imports are two transactions. Calling the
|
||||
# private ``_import`` twice without committing puts both in one Continuum
|
||||
# transaction, where adding and removing the same association collides on
|
||||
# ``dashboard_slices_version``'s (dashboard_id, slice_id, transaction_id)
|
||||
# key — an artifact of the test's shortcut, not a reachable state.
|
||||
db.session.commit()
|
||||
|
||||
# Verify initial state: 2 charts associated with the dashboard
|
||||
|
||||
@@ -557,44 +557,6 @@ def test_extra_validator_accepts_catalog_cache_timeout() -> None:
|
||||
assert extra["metadata_cache_timeout"]["catalog_cache_timeout"] == 600
|
||||
|
||||
|
||||
def test_extra_validator_interpolates_invalid_metadata_params_key() -> None:
|
||||
"""
|
||||
The message names the offending key. It is built with a lazy translated
|
||||
string, so a malformed placeholder would only fail once the message is
|
||||
rendered; asserting on the rendered text pins the interpolation.
|
||||
"""
|
||||
from superset.databases.schemas import DatabasePostSchema
|
||||
|
||||
schema = DatabasePostSchema()
|
||||
payload = {
|
||||
"database_name": "test_db",
|
||||
"extra": json.dumps({"metadata_params": {"not_a_metadata_arg": 1}}),
|
||||
}
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
schema.load(payload)
|
||||
message = str(exc_info.value)
|
||||
assert "not_a_metadata_arg" in message
|
||||
assert "%(" not in message
|
||||
|
||||
|
||||
def test_extra_validator_interpolates_json_decode_error() -> None:
|
||||
"""
|
||||
As above, for the message raised when ``extra`` is not decodable JSON.
|
||||
"""
|
||||
from superset.databases.schemas import DatabasePostSchema
|
||||
|
||||
schema = DatabasePostSchema()
|
||||
payload = {"database_name": "test_db", "extra": "{not json"}
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
schema.load(payload)
|
||||
message = str(exc_info.value)
|
||||
# Assert on the interpolated value rather than the surrounding wording. The
|
||||
# value comes from json.JSONDecodeError, which is not translated, so this
|
||||
# stays valid under any locale.
|
||||
assert "line 1 column" in message
|
||||
assert "%(" not in message
|
||||
|
||||
|
||||
def test_cache_timeout_rejects_values_below_minus_one() -> None:
|
||||
"""
|
||||
Test that cache_timeout rejects values less than -1.
|
||||
|
||||
@@ -229,12 +229,7 @@ def _generate_gis_type_sanitization_test_cases() -> list[
|
||||
if not ocient_is_installed():
|
||||
return []
|
||||
|
||||
from pyocient import TypeCodes
|
||||
from pyocient.api import (
|
||||
STLinestring as _STLinestring,
|
||||
STPoint as _STPoint,
|
||||
STPolygon as _STPolygon,
|
||||
)
|
||||
from pyocient import _STLinestring, _STPoint, _STPolygon, TypeCodes
|
||||
|
||||
return [
|
||||
(
|
||||
@@ -301,7 +296,7 @@ def _generate_gis_type_sanitization_test_cases() -> list[
|
||||
(
|
||||
"empty_polygon",
|
||||
TypeCodes.ST_POLYGON,
|
||||
_STPolygon(exterior=[], holes=[], fullFlag=False),
|
||||
_STPolygon(exterior=[], holes=[]),
|
||||
{
|
||||
"geometry": None,
|
||||
"properties": {},
|
||||
@@ -316,7 +311,6 @@ def _generate_gis_type_sanitization_test_cases() -> list[
|
||||
_STPoint(long=t[0], lat=t[1]) for t in [(1, 0), (1, 1), (1, 0)]
|
||||
],
|
||||
holes=[],
|
||||
fullFlag=False,
|
||||
),
|
||||
{
|
||||
"geometry": {
|
||||
@@ -338,7 +332,6 @@ def _generate_gis_type_sanitization_test_cases() -> list[
|
||||
[_STPoint(long=t[0], lat=t[1]) for t in [(2, 0), (2, 1), (2, 0)]],
|
||||
[_STPoint(long=t[0], lat=t[1]) for t in [(3, 0), (3, 1), (3, 0)]],
|
||||
],
|
||||
fullFlag=False,
|
||||
),
|
||||
{
|
||||
"geometry": {
|
||||
@@ -359,7 +352,6 @@ def _generate_gis_type_sanitization_test_cases() -> list[
|
||||
_STPolygon(
|
||||
exterior=[_STPoint(long=t[0], lat=t[1]) for t in [(1, 0)]],
|
||||
holes=[],
|
||||
fullFlag=False,
|
||||
),
|
||||
{
|
||||
"geometry": {
|
||||
@@ -376,7 +368,6 @@ def _generate_gis_type_sanitization_test_cases() -> list[
|
||||
_STPolygon(
|
||||
exterior=[_STPoint(long=t[0], lat=t[1]) for t in [(1, 0), (0, 1)]],
|
||||
holes=[],
|
||||
fullFlag=False,
|
||||
),
|
||||
{
|
||||
"geometry": {
|
||||
@@ -409,7 +400,7 @@ def test_gis_type_sanitization(
|
||||
|
||||
@pytest.mark.skipif(not ocient_is_installed(), reason="requires ocient dependencies")
|
||||
def test_point_list_to_wkt() -> None:
|
||||
from pyocient.api import STPoint as _STPoint
|
||||
from pyocient import _STPoint
|
||||
|
||||
wkt = _point_list_to_wkt(
|
||||
[_STPoint(long=t[0], lat=t[1]) for t in [(2, 0), (2, 1), (2, 0)]]
|
||||
|
||||
@@ -479,25 +479,6 @@ class TestMapTableConfig:
|
||||
|
||||
assert result["row_limit"] == 500
|
||||
|
||||
def test_map_table_config_supports_null_filter(self) -> None:
|
||||
config = TableChartConfig(
|
||||
chart_type="table",
|
||||
columns=[ColumnRef(name="optional_value")],
|
||||
filters=[FilterConfig(column="optional_value", op="IS NOT NULL")],
|
||||
)
|
||||
|
||||
result = map_table_config(config)
|
||||
|
||||
assert result["adhoc_filters"] == [
|
||||
{
|
||||
"clause": "WHERE",
|
||||
"expressionType": "SIMPLE",
|
||||
"subject": "optional_value",
|
||||
"operator": "IS NOT NULL",
|
||||
"comparator": None,
|
||||
}
|
||||
]
|
||||
|
||||
def test_map_table_config_default_row_limit(self) -> None:
|
||||
"""Test that default row_limit is mapped to form_data."""
|
||||
config = TableChartConfig(
|
||||
@@ -705,23 +686,6 @@ class TestMapXYConfig:
|
||||
assert result["show_legend"] is False
|
||||
assert result["legendOrientation"] == "top"
|
||||
|
||||
def test_map_xy_config_with_legend_orientation(self) -> None:
|
||||
config = XYChartConfig.model_validate(
|
||||
{
|
||||
"chart_type": "xy",
|
||||
"x": {"name": "date"},
|
||||
"y": [{"name": "revenue", "aggregate": "SUM"}],
|
||||
"show_legend": True,
|
||||
"legend_orientation": "bottom",
|
||||
}
|
||||
)
|
||||
|
||||
result = map_xy_config(config)
|
||||
|
||||
assert config.legend is not None
|
||||
assert config.legend.show is True
|
||||
assert result["legendOrientation"] == "bottom"
|
||||
|
||||
def test_map_xy_config_with_color_scheme(self) -> None:
|
||||
"""color_scheme propagates to form_data when set."""
|
||||
config = XYChartConfig(
|
||||
|
||||
@@ -188,13 +188,6 @@ class TestGenerateChart:
|
||||
for i, f in enumerate(filters):
|
||||
assert f.op == operators[i]
|
||||
|
||||
null_filter = FilterConfig(column="optional_value", op="IS NOT NULL")
|
||||
assert null_filter.value is None
|
||||
with pytest.raises(ValueError, match="must not have 'value'"):
|
||||
FilterConfig(column="optional_value", op="IS NULL", value="unexpected")
|
||||
with pytest.raises(ValueError, match="requires 'value'"):
|
||||
FilterConfig(column="optional_value", op="=")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_chart_response_structure(self):
|
||||
"""Test the expected response structure for chart generation."""
|
||||
@@ -313,10 +306,6 @@ class TestGenerateChart:
|
||||
assert col2.aggregate == "SUM"
|
||||
assert col2.label == "Total Sales"
|
||||
|
||||
aliased = ColumnRef.model_validate({"column": "sales", "aggregate": "AVG"})
|
||||
assert aliased.name == "sales"
|
||||
assert aliased.aggregate == "AVG"
|
||||
|
||||
# All supported aggregations
|
||||
aggs = ["SUM", "AVG", "COUNT", "MIN", "MAX", "COUNT_DISTINCT"]
|
||||
for agg in aggs:
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
import pytest
|
||||
|
||||
from superset.mcp_service.chart.schemas import ColumnRef
|
||||
from superset.mcp_service.chart.validation.dataset_validator import DatasetValidator
|
||||
from superset.mcp_service.common.error_schemas import (
|
||||
ChartGenerationError,
|
||||
DatasetContext,
|
||||
)
|
||||
|
||||
|
||||
def _validate_sum(sql_type: str) -> list[ChartGenerationError]:
|
||||
context = DatasetContext(
|
||||
id=69,
|
||||
table_name="virtual_metrics",
|
||||
schema=None,
|
||||
database_name="database",
|
||||
available_columns=[
|
||||
{"name": "computed_total", "type": sql_type, "is_numeric": False}
|
||||
],
|
||||
available_metrics=[],
|
||||
)
|
||||
|
||||
return DatasetValidator._validate_aggregations(
|
||||
[ColumnRef(name="computed_total", aggregate="SUM")], context
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sql_type",
|
||||
[
|
||||
"BIGINT",
|
||||
"SMALLINT",
|
||||
"TINYINT",
|
||||
"REAL",
|
||||
"NUMBER",
|
||||
"DOUBLE PRECISION",
|
||||
"INT8",
|
||||
"FLOAT8",
|
||||
"DECIMAL(10, 2)",
|
||||
"MONEY",
|
||||
"SMALLMONEY",
|
||||
],
|
||||
)
|
||||
def test_numeric_type_spelling_is_accepted(sql_type: str) -> None:
|
||||
assert _validate_sum(sql_type) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sql_type", ["", "UNKNOWN"])
|
||||
def test_unknown_type_is_deferred_to_compile_check(sql_type: str) -> None:
|
||||
assert _validate_sum(sql_type) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sql_type", ["VARCHAR", "INTERVAL", "POINT"])
|
||||
def test_non_numeric_type_is_rejected_for_numeric_aggregation(
|
||||
sql_type: str,
|
||||
) -> None:
|
||||
assert _validate_sum(sql_type)[0].error_type == "invalid_aggregation"
|
||||
Reference in New Issue
Block a user