mirror of
https://github.com/apache/superset.git
synced 2026-08-26 10:01:19 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8521058abe | ||
|
|
b89da3e9fc | ||
|
|
88d2c2954e | ||
|
|
f903e02d91 | ||
|
|
fc4d7221ec | ||
|
|
3585e8235a | ||
|
|
34ffa37aaa | ||
|
|
02b3e43b66 | ||
|
|
f9530f31ab | ||
|
|
ab357b0f4d | ||
|
|
fd3849cef1 | ||
|
|
ef0ad01aa5 | ||
|
|
d480152735 | ||
|
|
cbfa94c4dd | ||
|
|
d2aeb29223 | ||
|
|
7bad69f523 | ||
|
|
9dd8c42f3d | ||
|
|
767b63440a | ||
|
|
8e51b03770 |
@@ -53,12 +53,6 @@ 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
|
||||
|
||||
+17
-12
@@ -184,10 +184,12 @@ 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). 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.
|
||||
`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.
|
||||
- **`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.
|
||||
@@ -196,7 +198,10 @@ 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.
|
||||
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.
|
||||
|
||||
### Scheduled report execution now enforces one application deadline
|
||||
|
||||
@@ -658,9 +663,9 @@ ALTER TABLE tagged_object DROP CONSTRAINT <constraint_name>;
|
||||
ALTER TABLE tagged_object DROP FOREIGN KEY <constraint_name>;
|
||||
```
|
||||
|
||||
### Entity version-history infrastructure (gated off by default)
|
||||
### Entity version-history infrastructure
|
||||
|
||||
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).
|
||||
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).
|
||||
|
||||
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:
|
||||
|
||||
@@ -681,7 +686,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 is empty unless version capture is on (`ENABLE_VERSIONING_CAPTURE`).
|
||||
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.
|
||||
|
||||
### Version-history retention (pruning)
|
||||
|
||||
@@ -701,7 +706,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. 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.
|
||||
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.
|
||||
|
||||
### Recently Archived view and permanent delete (purge) endpoints
|
||||
|
||||
@@ -897,7 +902,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 `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.
|
||||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -927,7 +932,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 `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.
|
||||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -951,7 +956,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 `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.
|
||||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
|
||||
@@ -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 it
|
||||
|
||||
Two switches are involved, and both matter.
|
||||
## Enabling and disabling it
|
||||
|
||||
| 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": True}
|
||||
ENABLE_VERSIONING_CAPTURE = True
|
||||
FEATURE_FLAGS = {"VERSION_HISTORY": False}
|
||||
ENABLE_VERSIONING_CAPTURE = False
|
||||
```
|
||||
|
||||
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.
|
||||
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).
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## 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.8",
|
||||
"@storybook/addon-docs": "^10.5.9",
|
||||
"@superset-ui/core": "^0.20.4",
|
||||
"@swc/core": "^1.16.0",
|
||||
"antd": "^6.6.0",
|
||||
"antd": "^6.6.1",
|
||||
"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.8",
|
||||
"storybook": "^10.5.9",
|
||||
"swagger-ui-react": "^5.32.13",
|
||||
"swc-loader": "^0.2.7",
|
||||
"tinycolor2": "^1.4.2",
|
||||
|
||||
Vendored
+12
-12
@@ -93,12 +93,6 @@
|
||||
"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,
|
||||
@@ -110,12 +104,6 @@
|
||||
"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": [
|
||||
@@ -233,6 +221,12 @@
|
||||
"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,
|
||||
@@ -245,6 +239,12 @@
|
||||
"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.0", "@rc-component/select@~1.10.1":
|
||||
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.0":
|
||||
"@rc-component/table@~1.11.1":
|
||||
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.0":
|
||||
"@rc-component/tree-select@~1.16.1":
|
||||
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.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==
|
||||
"@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==
|
||||
dependencies:
|
||||
"@mdx-js/react" "^3.0.0"
|
||||
"@storybook/csf-plugin" "10.5.8"
|
||||
"@storybook/csf-plugin" "10.5.9"
|
||||
"@storybook/icons" "^2.0.2"
|
||||
"@storybook/react-dom-shim" "10.5.8"
|
||||
"@storybook/react-dom-shim" "10.5.9"
|
||||
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.8":
|
||||
version "10.5.8"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-10.5.8.tgz#c626c5bfe55d0e279b2457e5cf150a788d1fc637"
|
||||
integrity sha512-/FHiMyOWWEXfwK/lM0WxmkP9GLzbSJJuzGtfeuNWSOVDnvAMbjavitxfHb5wSbWKIQo0XYC1EJ2Y7x91XNYP4w==
|
||||
"@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==
|
||||
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.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==
|
||||
"@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==
|
||||
|
||||
"@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.0:
|
||||
version "6.6.0"
|
||||
resolved "https://registry.yarnpkg.com/antd/-/antd-6.6.0.tgz#8acb84c54b36594b5c1a9084c8acb6a03b79961b"
|
||||
integrity sha512-UDwWIbpmrCHB9ZQ+bPh4vQfB6DTI2ulIyoQ0Tc9xxalFblttiNGHl3ySBD9SyV/8+gUjFzfSx1+iU1Fog2i46w==
|
||||
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==
|
||||
dependencies:
|
||||
"@ant-design/colors" "^8.0.1"
|
||||
"@ant-design/cssinjs" "^2.1.2"
|
||||
@@ -6217,16 +6217,16 @@ antd@^6.6.0:
|
||||
"@rc-component/rate" "~1.0.1"
|
||||
"@rc-component/resize-observer" "^1.1.2"
|
||||
"@rc-component/segmented" "~1.3.0"
|
||||
"@rc-component/select" "~1.10.0"
|
||||
"@rc-component/select" "~1.10.1"
|
||||
"@rc-component/slider" "~1.1.1"
|
||||
"@rc-component/steps" "~1.2.2"
|
||||
"@rc-component/switch" "~1.0.3"
|
||||
"@rc-component/table" "~1.11.0"
|
||||
"@rc-component/table" "~1.11.1"
|
||||
"@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.0"
|
||||
"@rc-component/tree-select" "~1.16.1"
|
||||
"@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.8:
|
||||
version "10.5.8"
|
||||
resolved "https://registry.yarnpkg.com/storybook/-/storybook-10.5.8.tgz#d5f051983e6232c0a73ea02149a72c7bafb43275"
|
||||
integrity sha512-rR4oFMSiWBSqI0lvsJPtcQUPj8+hzj3TkLu+Mw61Wo6YxPSb5FsLSHai0jZnuaIdKIlmu25KCfwlSQl4e1uvnA==
|
||||
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==
|
||||
dependencies:
|
||||
"@storybook/global" "^5.0.0"
|
||||
"@storybook/icons" "^2.0.2"
|
||||
|
||||
+1
-1
@@ -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>=4.3.1, <5",
|
||||
"marshmallow>=3.0, <5",
|
||||
"marshmallow-union>=0.1.15.post1",
|
||||
"msgpack>=1.2.0, <1.3",
|
||||
"nh3>=0.3.5, <0.4",
|
||||
|
||||
Generated
+86
-270
@@ -81,12 +81,12 @@
|
||||
"@visx/xychart": "^4.0.0",
|
||||
"ag-grid-community": "36.1.0",
|
||||
"ag-grid-react": "36.1.0",
|
||||
"antd": "^6.6.0",
|
||||
"antd": "^6.6.1",
|
||||
"chrono-node": "^2.10.1",
|
||||
"classnames": "^2.2.5",
|
||||
"content-disposition": "^2.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"dayjs": "^1.11.22",
|
||||
"dayjs": "^1.11.23",
|
||||
"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.8",
|
||||
"@storybook/addon-links": "10.5.8",
|
||||
"@storybook/react-webpack5": "10.5.8",
|
||||
"@storybook/addon-docs": "10.5.9",
|
||||
"@storybook/addon-links": "10.5.9",
|
||||
"@storybook/react-webpack5": "10.5.9",
|
||||
"@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.14",
|
||||
"baseline-browser-mapping": "^2.11.15",
|
||||
"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.8",
|
||||
"eslint-plugin-storybook": "10.5.9",
|
||||
"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.8",
|
||||
"storybook": "10.5.9",
|
||||
"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.0",
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/select/-/select-1.10.0.tgz",
|
||||
"integrity": "sha512-u/3yuF2kEXvTJXPy3P7qkVBkGGZcQo+m1uTuQqJa6qCnwdmDoEn1Rs3zTTi6y/RsLGiQGM0drPN5c/RfflCnow==",
|
||||
"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==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@rc-component/overflow": "^1.0.0",
|
||||
@@ -10765,16 +10765,16 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@storybook/addon-docs": {
|
||||
"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==",
|
||||
"version": "10.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.5.9.tgz",
|
||||
"integrity": "sha512-8sFsMkZYrrdqCLdV+hnwTwDF7RaBsBPRwl4wfc8ve9Q/7Yhi5REe/Xjvd8x1yn6fBPPw9tnID9dx6Agdnr81fw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
"@storybook/csf-plugin": "10.5.8",
|
||||
"@storybook/csf-plugin": "10.5.9",
|
||||
"@storybook/icons": "^2.0.2",
|
||||
"@storybook/react-dom-shim": "10.5.8",
|
||||
"@storybook/react-dom-shim": "10.5.9",
|
||||
"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.8"
|
||||
"storybook": "10.5.9"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
@@ -10794,9 +10794,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/addon-docs/node_modules/@storybook/csf-plugin": {
|
||||
"version": "10.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.8.tgz",
|
||||
"integrity": "sha512-/FHiMyOWWEXfwK/lM0WxmkP9GLzbSJJuzGtfeuNWSOVDnvAMbjavitxfHb5wSbWKIQo0XYC1EJ2Y7x91XNYP4w==",
|
||||
"version": "10.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.9.tgz",
|
||||
"integrity": "sha512-4H5QIHQVtQYCuL43GCRLGjNQhZpQg9gL03ja0DV80kO2Dn9LEt6ol87bSnSjn4VDgcAXtgTzXFvRLknfVgAAqg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -10809,7 +10809,7 @@
|
||||
"peerDependencies": {
|
||||
"esbuild": "*",
|
||||
"rollup": "*",
|
||||
"storybook": "10.5.8",
|
||||
"storybook": "10.5.9",
|
||||
"vite": "*",
|
||||
"webpack": "*"
|
||||
},
|
||||
@@ -10829,9 +10829,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/addon-docs/node_modules/@storybook/react-dom-shim": {
|
||||
"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==",
|
||||
"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==",
|
||||
"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.8"
|
||||
"storybook": "10.5.9"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
@@ -10855,9 +10855,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/addon-links": {
|
||||
"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==",
|
||||
"version": "10.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-10.5.9.tgz",
|
||||
"integrity": "sha512-ZDbPl6ia6hqjoV+CpQU3DjkXpc0TxUq6+y/rFD8w21dJMdqNWzY8zajHC8r4CfTWANjM1pGPUYisWtTKi1MxZw==",
|
||||
"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.8"
|
||||
"storybook": "10.5.9"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
@@ -10964,15 +10964,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-webpack5": {
|
||||
"version": "10.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-webpack5/-/react-webpack5-10.5.8.tgz",
|
||||
"integrity": "sha512-HkPi42WaoNSHC0DAERsJEF7Vhnluzsp/aiuhnH65GGYG5TmdLL9G8KDiYvXHGDCyb4RfoAPYrtzxaLMFfPPFvQ==",
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@storybook/builder-webpack5": "10.5.8",
|
||||
"@storybook/preset-react-webpack": "10.5.8",
|
||||
"@storybook/react": "10.5.8"
|
||||
"@storybook/builder-webpack5": "10.5.9",
|
||||
"@storybook/preset-react-webpack": "10.5.9",
|
||||
"@storybook/react": "10.5.9"
|
||||
},
|
||||
"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.8",
|
||||
"storybook": "10.5.9",
|
||||
"typescript": ">= 4.9.x"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
@@ -10991,13 +10991,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-webpack5/node_modules/@storybook/builder-webpack5": {
|
||||
"version": "10.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-10.5.8.tgz",
|
||||
"integrity": "sha512-ke5x27gtWQ4gpXCLWxdGkr8ZlJwBykV/KjbBTAlC04dmS9OkI9MBzGj+TteUlgrcaN7LwoNTR5zRmxKStOZYzQ==",
|
||||
"version": "10.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-10.5.9.tgz",
|
||||
"integrity": "sha512-XTLC95jP75V9NfhoUzDNKDCn9r0ZqXz4ZhrNzQXVDz4vNWkjU3/uDL6Ywh9WFu6Xrb+onQqSR9hlHjS/DOZj7Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@storybook/core-webpack": "10.5.8",
|
||||
"@storybook/core-webpack": "10.5.9",
|
||||
"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.8"
|
||||
"storybook": "10.5.9"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
@@ -11028,9 +11028,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-webpack5/node_modules/@storybook/builder-webpack5/node_modules/@storybook/core-webpack": {
|
||||
"version": "10.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.8.tgz",
|
||||
"integrity": "sha512-HccINB0UbTtnyJtKpaX+C35BRTSnAwnreIMwwI+LpeUd4x9mQg0G9orB7lfBBZwd5LQf8YhM2Vkjiawzo41GLg==",
|
||||
"version": "10.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.9.tgz",
|
||||
"integrity": "sha512-YmXR9RJdQpH8EtWEIjLTr5LMGiCXSmZp/A9UkATY5xHM4qG2QwJB++jU2wv4nGDyaPiUcbzT2PQSXN6RXhiUZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -11041,17 +11041,17 @@
|
||||
"url": "https://opencollective.com/storybook"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"storybook": "10.5.8"
|
||||
"storybook": "10.5.9"
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-webpack5/node_modules/@storybook/preset-react-webpack": {
|
||||
"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==",
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@storybook/core-webpack": "10.5.8",
|
||||
"@storybook/core-webpack": "10.5.9",
|
||||
"@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.8"
|
||||
"storybook": "10.5.9"
|
||||
},
|
||||
"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.8",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.8.tgz",
|
||||
"integrity": "sha512-HccINB0UbTtnyJtKpaX+C35BRTSnAwnreIMwwI+LpeUd4x9mQg0G9orB7lfBBZwd5LQf8YhM2Vkjiawzo41GLg==",
|
||||
"version": "10.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.9.tgz",
|
||||
"integrity": "sha512-YmXR9RJdQpH8EtWEIjLTr5LMGiCXSmZp/A9UkATY5xHM4qG2QwJB++jU2wv4nGDyaPiUcbzT2PQSXN6RXhiUZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -11090,18 +11090,18 @@
|
||||
"url": "https://opencollective.com/storybook"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"storybook": "10.5.8"
|
||||
"storybook": "10.5.9"
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-webpack5/node_modules/@storybook/react": {
|
||||
"version": "10.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.5.8.tgz",
|
||||
"integrity": "sha512-6qqkmqX6imtL+0Z9Uan2tIfYivOI0FiVmWr0zpqqQR15AkJ18JfNcNTQoyjeAlCO0Kei56SWqnu2qLq52TYplg==",
|
||||
"version": "10.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.5.9.tgz",
|
||||
"integrity": "sha512-kApGOuNT26NkpioTsr1iT/Q2c44tA7OIsNUSyFqtT7W8k3fRn/jQWfrDegYxty0WG0wxdNMZq2ndRfOOF8HaHw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@storybook/global": "^5.0.0",
|
||||
"@storybook/react-dom-shim": "10.5.8",
|
||||
"@storybook/react-dom-shim": "10.5.9",
|
||||
"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.8",
|
||||
"storybook": "10.5.9",
|
||||
"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.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==",
|
||||
"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==",
|
||||
"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.8"
|
||||
"storybook": "10.5.9"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
@@ -14928,9 +14928,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/antd": {
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/antd/-/antd-6.6.0.tgz",
|
||||
"integrity": "sha512-UDwWIbpmrCHB9ZQ+bPh4vQfB6DTI2ulIyoQ0Tc9xxalFblttiNGHl3ySBD9SyV/8+gUjFzfSx1+iU1Fog2i46w==",
|
||||
"version": "6.6.1",
|
||||
"resolved": "https://registry.npmjs.org/antd/-/antd-6.6.1.tgz",
|
||||
"integrity": "sha512-QHIHYoUk9N9nJy1T9fyxWKjY0qApdTEDd/6lzqYng8Uryv9FejNmbhKvYF7obGqB+TuLXQsPVF7fOVgyzM1KrQ==",
|
||||
"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.0",
|
||||
"@rc-component/select": "~1.10.1",
|
||||
"@rc-component/slider": "~1.1.1",
|
||||
"@rc-component/steps": "~1.2.2",
|
||||
"@rc-component/switch": "~1.0.3",
|
||||
"@rc-component/table": "~1.11.0",
|
||||
"@rc-component/table": "~1.11.1",
|
||||
"@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.0",
|
||||
"@rc-component/tree-select": "~1.16.1",
|
||||
"@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.14",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.14.tgz",
|
||||
"integrity": "sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==",
|
||||
"version": "2.11.15",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.15.tgz",
|
||||
"integrity": "sha512-FwMjJJ7HnyZpWe+oWxegG0fezZyBZUagI5LZEoO3GCbtbKNwRfMH9Ue5d5v01PNePBy1QSfPSDTTeVL0Hb9EzA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -18559,9 +18559,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.22",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.22.tgz",
|
||||
"integrity": "sha512-1YRnxzt/AabP3GHxnaB9/b+ZScCKu5TeF+co+BWG+lnWVIwEcTFc1FVE0WLNmNO3sA6GGXL40i5qkHfbLzpwrg==",
|
||||
"version": "1.11.23",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz",
|
||||
"integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debounce": {
|
||||
@@ -19140,11 +19140,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.12",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz",
|
||||
"integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==",
|
||||
"version": "3.4.13",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz",
|
||||
"integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optional": true,
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
@@ -20237,9 +20236,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-storybook": {
|
||||
"version": "10.5.8",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.5.8.tgz",
|
||||
"integrity": "sha512-bf9W5nZyWdIaCUZf4aEZnEeD1mn+csNYX8dYUQjAo6L7/DkSLtr65R4zFZ1xeS4m6dOXO6UtUySesCSw4e8w1g==",
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -20248,7 +20247,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": ">=8",
|
||||
"storybook": "10.5.8"
|
||||
"storybook": "10.5.9"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-testing-library": {
|
||||
@@ -37735,9 +37734,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/storybook": {
|
||||
"version": "10.5.8",
|
||||
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.5.8.tgz",
|
||||
"integrity": "sha512-rR4oFMSiWBSqI0lvsJPtcQUPj8+hzj3TkLu+Mw61Wo6YxPSb5FsLSHai0jZnuaIdKIlmu25KCfwlSQl4e1uvnA==",
|
||||
"version": "10.5.9",
|
||||
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.5.9.tgz",
|
||||
"integrity": "sha512-UfdMKSjEhIKr8LbqYyIE5r7vT/drL/PxN75YaouJ+UG0FssEy6cf49OdTF3kstAqVMHskc+zEqyRoiQHZXHwgA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -42986,7 +42985,7 @@
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-time": "^3.1.0",
|
||||
"d3-time-format": "^4.1.0",
|
||||
"dayjs": "^1.11.22",
|
||||
"dayjs": "^1.11.23",
|
||||
"dompurify": "^3.4.13",
|
||||
"fetch-retry": "^6.0.0",
|
||||
"handlebars": "^4.7.9",
|
||||
@@ -43146,189 +43145,6 @@
|
||||
"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.0",
|
||||
"antd": "^6.6.1",
|
||||
"chrono-node": "^2.10.1",
|
||||
"classnames": "^2.2.5",
|
||||
"content-disposition": "^2.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"dayjs": "^1.11.22",
|
||||
"dayjs": "^1.11.23",
|
||||
"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.8",
|
||||
"@storybook/addon-links": "10.5.8",
|
||||
"@storybook/react-webpack5": "10.5.8",
|
||||
"@storybook/addon-docs": "10.5.9",
|
||||
"@storybook/addon-links": "10.5.9",
|
||||
"@storybook/react-webpack5": "10.5.9",
|
||||
"@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.14",
|
||||
"baseline-browser-mapping": "^2.11.15",
|
||||
"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.8",
|
||||
"eslint-plugin-storybook": "10.5.9",
|
||||
"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.8",
|
||||
"storybook": "10.5.9",
|
||||
"style-loader": "^4.0.0",
|
||||
"stylelint": "^17.14.1",
|
||||
"swc-loader": "^0.2.7",
|
||||
|
||||
+28
-1
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { QueryFormMetric } from '@superset-ui/core';
|
||||
import { getTotalsMetrics } from './getTotalsMetrics';
|
||||
import { getTotalsMetrics, toTotalsAggregate } from './getTotalsMetrics';
|
||||
|
||||
const simpleMetric = (aggregate: string): QueryFormMetric =>
|
||||
({
|
||||
@@ -76,4 +76,31 @@ 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');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
+31
-11
@@ -18,26 +18,46 @@
|
||||
*/
|
||||
import { isAdhocMetricSimple, QueryFormMetric } from '@superset-ui/core';
|
||||
|
||||
export type TotalsAggregate = 'SUM' | 'AVG';
|
||||
/**
|
||||
* 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';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Build the metrics for a chart's "Show summary" totals query.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
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.22",
|
||||
"dayjs": "^1.11.23",
|
||||
"dompurify": "^3.4.13",
|
||||
"fetch-retry": "^6.0.0",
|
||||
"handlebars": "^4.7.9",
|
||||
|
||||
+28
@@ -246,6 +246,34 @@ test('wraps component with proper container div', () => {
|
||||
expect(wrapper).toHaveAttribute('data-themed-ag-grid', 'true');
|
||||
});
|
||||
|
||||
test('applies non-transparent backgrounds to native menus, tooltips and overlays', () => {
|
||||
const customTheme = {
|
||||
...supersetTheme,
|
||||
colorBgElevated: '#f2f2f2',
|
||||
};
|
||||
|
||||
render(
|
||||
<ThemeProvider theme={customTheme}>
|
||||
<ThemedAgGridReact rowData={mockRowData} columnDefs={mockColumnDefs} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
const agGrid = screen.getByTestId('ag-grid-react');
|
||||
const theme = JSON.parse(agGrid.getAttribute('data-theme') || '{}');
|
||||
|
||||
// ag-grid's own context/column menus, side bar, tooltips and overlays are
|
||||
// rendered against these params rather than `backgroundColor` (which is
|
||||
// intentionally 'transparent' so the surrounding app shows through the
|
||||
// grid body). Without explicit values they inherit transparency too,
|
||||
// making native menus/popups unreadable.
|
||||
expect(theme.chromeBackgroundColor).toBe('#f2f2f2');
|
||||
expect(theme.menuBackgroundColor).toBe('#f2f2f2');
|
||||
expect(theme.menuBorder).toBe(true);
|
||||
expect(theme.sideBarBackgroundColor).toBe('#f2f2f2');
|
||||
expect(theme.tooltipBackgroundColor).toBe('#f2f2f2');
|
||||
expect(theme.modalOverlayBackgroundColor).toBe('#f2f2f2');
|
||||
});
|
||||
|
||||
test('handles missing theme gracefully', () => {
|
||||
const incompleteTheme = {
|
||||
...supersetTheme,
|
||||
|
||||
+11
@@ -104,6 +104,17 @@ export const ThemedAgGridReact = forwardRef<
|
||||
foregroundColor: theme.colorText,
|
||||
browserColorScheme: isDarkMode ? 'dark' : 'light',
|
||||
|
||||
// Native menus, popups, side bar, tooltips and loading/no-rows overlays
|
||||
// are rendered against these params rather than `backgroundColor`
|
||||
// (which is intentionally transparent). Without explicit values they
|
||||
// inherit transparency too, making them unreadable.
|
||||
chromeBackgroundColor: theme.colorBgElevated,
|
||||
menuBackgroundColor: theme.colorBgElevated,
|
||||
menuBorder: true,
|
||||
sideBarBackgroundColor: theme.colorBgElevated,
|
||||
tooltipBackgroundColor: theme.colorBgElevated,
|
||||
modalOverlayBackgroundColor: theme.colorBgElevated,
|
||||
|
||||
// Header styling
|
||||
headerBackgroundColor: theme.colorFillTertiary,
|
||||
headerTextColor: theme.colorTextHeading,
|
||||
|
||||
+2
-3
@@ -64,9 +64,8 @@ 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,6 +24,12 @@ 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,
|
||||
TotalsAggregate,
|
||||
toTotalsAggregate,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { TableChartFormData } from './types';
|
||||
@@ -696,13 +696,16 @@ export const buildQueryUncached: BuildQuery<TableChartFormData> = (
|
||||
formData.show_totals &&
|
||||
queryMode === QueryMode.Aggregate,
|
||||
);
|
||||
const totalsAggregate: TotalsAggregate =
|
||||
formData.totals_aggregate === 'AVG' ? 'AVG' : 'SUM';
|
||||
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 totalsMetrics =
|
||||
rawSummaryColumns.length > 0
|
||||
? rawSummaryColumns.map(columnName => ({
|
||||
expressionType: 'SIMPLE' as const,
|
||||
aggregate: totalsAggregate,
|
||||
aggregate: rawSummaryAggregate,
|
||||
column: { column_name: columnName },
|
||||
label: columnName,
|
||||
}))
|
||||
|
||||
@@ -503,14 +503,18 @@ const config: ControlPanelConfig = {
|
||||
label: t('Summary aggregation'),
|
||||
renderTrigger: true,
|
||||
description: t(
|
||||
'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).',
|
||||
'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.',
|
||||
),
|
||||
default: 'SUM',
|
||||
default: 'ORIGINAL',
|
||||
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 SUM for a simple metric', () => {
|
||||
test("defaults aggregate-mode totals to the metric's own aggregate", () => {
|
||||
const simpleMetric = {
|
||||
expressionType: 'SIMPLE' as const,
|
||||
column: { column_name: 'sales' },
|
||||
@@ -1580,9 +1580,29 @@ describe('plugin-chart-ag-grid-table', () => {
|
||||
{ ownState: {} },
|
||||
);
|
||||
|
||||
expect(queries[1].metrics).toEqual([
|
||||
{ ...simpleMetric, aggregate: 'SUM' },
|
||||
]);
|
||||
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]);
|
||||
});
|
||||
|
||||
test('overrides aggregate-mode totals to AVG for a simple metric when totals_aggregate is set', () => {
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
getTotalsMetrics,
|
||||
isTimeComparison,
|
||||
timeCompareOperator,
|
||||
TotalsAggregate,
|
||||
toTotalsAggregate,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { TableChartFormData } from './types';
|
||||
@@ -349,8 +349,7 @@ export const buildQuery: BuildQuery<TableChartFormData> = (
|
||||
formData.show_totals &&
|
||||
queryMode === QueryMode.Aggregate
|
||||
) {
|
||||
const totalsAggregate: TotalsAggregate =
|
||||
formData.totals_aggregate === 'AVG' ? 'AVG' : 'SUM';
|
||||
const totalsAggregate = toTotalsAggregate(formData.totals_aggregate);
|
||||
extraQueries.push({
|
||||
...queryObject,
|
||||
columns: [],
|
||||
|
||||
@@ -475,14 +475,18 @@ const config: ControlPanelConfig = {
|
||||
type: 'SelectControl',
|
||||
label: t('Summary aggregation'),
|
||||
description: t(
|
||||
'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).',
|
||||
'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.',
|
||||
),
|
||||
default: 'SUM',
|
||||
default: 'ORIGINAL',
|
||||
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 the totals query metric aggregate to SUM', () => {
|
||||
test("defaults to each metric's own aggregate", () => {
|
||||
const { queries } = buildQueryCached({
|
||||
...basicFormData,
|
||||
query_mode: QueryMode.Aggregate,
|
||||
@@ -350,9 +350,28 @@ describe('plugin-chart-table', () => {
|
||||
});
|
||||
|
||||
expect(queries).toHaveLength(2);
|
||||
expect(queries[1].metrics).toEqual([
|
||||
{ ...simpleMetric, aggregate: 'SUM' },
|
||||
]);
|
||||
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]);
|
||||
});
|
||||
|
||||
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: /expanded General information/i,
|
||||
name: /General information/i,
|
||||
});
|
||||
expect(generalInfoTab).toHaveAttribute('aria-expanded', 'true');
|
||||
const fileSettingsTab = screen.getByRole('tab', {
|
||||
name: /collapsed File settings/i,
|
||||
name: /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: /expanded General information/i,
|
||||
name: /General information/i,
|
||||
});
|
||||
expect(generalInfoTab).toHaveAttribute('aria-expanded', 'true');
|
||||
const fileSettingsTab = screen.getByRole('tab', {
|
||||
name: /collapsed File settings/i,
|
||||
name: /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: /expanded General information/i,
|
||||
name: /General information/i,
|
||||
});
|
||||
expect(generalInfoTab).toHaveAttribute('aria-expanded', 'true');
|
||||
const fileSettingsTab = screen.getByRole('tab', {
|
||||
name: /collapsed File settings/i,
|
||||
name: /File settings/i,
|
||||
});
|
||||
await userEvent.click(fileSettingsTab);
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -223,7 +223,7 @@ describe('ChartPage', () => {
|
||||
window.history.pushState(
|
||||
{},
|
||||
'',
|
||||
`/?${URL_PARAMS.dashboardPageId.name}=${dashboardPageId}`,
|
||||
`/explore/?${URL_PARAMS.dashboardPageId.name}=${dashboardPageId}`,
|
||||
);
|
||||
const { getByTestId } = render(<ChartPage />, {
|
||||
useRouter: true,
|
||||
@@ -261,13 +261,13 @@ describe('ChartPage', () => {
|
||||
window.history.pushState(
|
||||
{},
|
||||
'',
|
||||
`/?${URL_PARAMS.dashboardPageId.name}=${dashboardPageId}`,
|
||||
`/explore/?${URL_PARAMS.dashboardPageId.name}=${dashboardPageId}`,
|
||||
);
|
||||
const { getByTestId } = render(
|
||||
<>
|
||||
<Link
|
||||
to={{
|
||||
pathname: '/',
|
||||
pathname: '/explore/',
|
||||
search: `?${URL_PARAMS.dashboardPageId.name}=${dashboardPageId}`,
|
||||
state: { saveAction: 'overwrite' },
|
||||
}}
|
||||
@@ -324,7 +324,7 @@ describe('ChartPage', () => {
|
||||
});
|
||||
render(
|
||||
<>
|
||||
<Link to="/?slice_id=99">Navigate away</Link>
|
||||
<Link to="/explore/?slice_id=99">Navigate away</Link>
|
||||
<ChartPage />
|
||||
</>,
|
||||
{
|
||||
@@ -382,7 +382,7 @@ describe('ChartPage', () => {
|
||||
<>
|
||||
<Link
|
||||
to={{
|
||||
pathname: '/',
|
||||
pathname: '/explore/',
|
||||
search: `?${URL_PARAMS.sliceId.name}=${formData.slice_id}`,
|
||||
state: toChartStateHistoryState({
|
||||
...formData,
|
||||
@@ -392,7 +392,7 @@ describe('ChartPage', () => {
|
||||
>
|
||||
Change the chart
|
||||
</Link>
|
||||
<Link to="/?slice_id=99">Navigate away</Link>
|
||||
<Link to="/explore/?slice_id=99">Navigate away</Link>
|
||||
<ChartPage />
|
||||
</>,
|
||||
{ useRouter: true, useRedux: true, useDnd: true },
|
||||
@@ -433,14 +433,14 @@ describe('ChartPage', () => {
|
||||
<>
|
||||
<Link
|
||||
to={{
|
||||
pathname: '/',
|
||||
pathname: '/explore/',
|
||||
search: `?${URL_PARAMS.sliceId.name}=99`,
|
||||
state: toChartStateHistoryState({ ...formData, slice_id: 99 }),
|
||||
}}
|
||||
>
|
||||
Another chart
|
||||
</Link>
|
||||
<Link to="/?slice_id=100">Navigate away</Link>
|
||||
<Link to="/explore/?slice_id=100">Navigate away</Link>
|
||||
<ChartPage />
|
||||
</>,
|
||||
{ useRouter: true, useRedux: true, useDnd: true },
|
||||
@@ -477,14 +477,14 @@ describe('ChartPage', () => {
|
||||
<>
|
||||
<Link
|
||||
to={{
|
||||
pathname: '/',
|
||||
pathname: '/explore/',
|
||||
search: `?${URL_PARAMS.sliceId.name}=${formData.slice_id}`,
|
||||
state: toChartStateHistoryState(formData),
|
||||
}}
|
||||
>
|
||||
Change the chart
|
||||
</Link>
|
||||
<Link to="/?slice_id=99">Navigate away</Link>
|
||||
<Link to="/explore/?slice_id=99">Navigate away</Link>
|
||||
<ChartPage />
|
||||
</>,
|
||||
{ useRouter: true, useRedux: true, useDnd: true, store },
|
||||
@@ -507,6 +507,32 @@ 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 () => {
|
||||
@@ -559,7 +585,7 @@ describe('ChartPage', () => {
|
||||
|
||||
render(
|
||||
<>
|
||||
<Link to="/?slice_id=99">Navigate</Link>
|
||||
<Link to="/explore/?slice_id=99">Navigate</Link>
|
||||
<ChartPage />
|
||||
</>,
|
||||
{
|
||||
|
||||
@@ -56,6 +56,11 @@ 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,
|
||||
@@ -312,6 +317,8 @@ 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
|
||||
@@ -326,6 +333,9 @@ 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: 'collapsed Recents' });
|
||||
const recentPanel = screen.getByRole('button', { name: 'Recents' });
|
||||
userEvent.click(recentPanel);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
|
||||
@@ -24,8 +24,10 @@ 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 may discard only their current redundant provisional row;
|
||||
force-purge and other meaningful outcomes are retained independently.
|
||||
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.
|
||||
|
||||
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
|
||||
@@ -87,6 +89,10 @@ 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:
|
||||
@@ -136,8 +142,18 @@ def write_ahead(
|
||||
session.close()
|
||||
|
||||
|
||||
def finalize(record_id: UUID | None, status: str, **details: Any) -> None:
|
||||
"""Finalize a pending attempt on the dedicated audit session."""
|
||||
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.
|
||||
"""
|
||||
if record_id is None:
|
||||
return
|
||||
session = _dedicated_session()
|
||||
@@ -148,6 +164,8 @@ def finalize(record_id: UUID | None, status: str, **details: Any) -> None:
|
||||
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
|
||||
@@ -192,12 +210,21 @@ def fail(record_id: UUID | None) -> None:
|
||||
finalize(record_id, STATUS_FAILED)
|
||||
|
||||
|
||||
def block(record_id: UUID | None) -> None:
|
||||
"""Mark an attempt blocked by ordinary deletion policy."""
|
||||
finalize(record_id, STATUS_BLOCKED)
|
||||
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 _capture_recovery_snapshot(record: PurgeAuditLog) -> _AuditRecoverySnapshot:
|
||||
def _capture_recovery_snapshot(
|
||||
record: PurgeAuditLog, reason: str | None
|
||||
) -> _AuditRecoverySnapshot:
|
||||
"""Capture the content-free fields needed for fail-safe recovery."""
|
||||
return _AuditRecoverySnapshot(
|
||||
id=cast(UUID, record.id),
|
||||
@@ -205,26 +232,37 @@ def _capture_recovery_snapshot(record: PurgeAuditLog) -> _AuditRecoverySnapshot:
|
||||
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``."""
|
||||
"""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.
|
||||
"""
|
||||
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
|
||||
tied_mixed_status_exists: bool = session.execute(
|
||||
# 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(
|
||||
sa.select(
|
||||
sa.exists().where(
|
||||
PurgeAuditLog.entity_uuid == current.entity_uuid,
|
||||
@@ -232,23 +270,43 @@ def _retention_predecessor(
|
||||
PurgeAuditLog.trigger == TRIGGER_RETENTION,
|
||||
PurgeAuditLog.created_on == predecessor.created_on,
|
||||
PurgeAuditLog.id != current.id,
|
||||
PurgeAuditLog.status != predecessor.status,
|
||||
sa.or_(
|
||||
PurgeAuditLog.status != predecessor.status,
|
||||
PurgeAuditLog.reason.is_distinct_from(predecessor.reason),
|
||||
),
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
if tied_mixed_status_exists:
|
||||
if tied_mixed_exists:
|
||||
return None
|
||||
return predecessor
|
||||
|
||||
|
||||
def _suppress_redundant_block(
|
||||
session: Session, current: PurgeAuditLog, predecessor: PurgeAuditLog | None
|
||||
session: Session,
|
||||
current: PurgeAuditLog,
|
||||
predecessor: PurgeAuditLog | None,
|
||||
reason: str | None,
|
||||
) -> bool:
|
||||
"""Delete only a pending row with a strictly older blocked predecessor."""
|
||||
"""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
|
||||
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(
|
||||
@@ -264,7 +322,7 @@ def _suppress_redundant_block(
|
||||
return deleted_rows == 1
|
||||
|
||||
|
||||
def _retain_blocked(session: Session, record_id: UUID) -> None:
|
||||
def _retain_blocked(session: Session, record_id: UUID, reason: str | None) -> None:
|
||||
"""Conditionally retain the current provisional row as blocked."""
|
||||
session.execute(
|
||||
sa.update(PurgeAuditLog.__table__)
|
||||
@@ -272,7 +330,7 @@ def _retain_blocked(session: Session, record_id: UUID) -> None:
|
||||
PurgeAuditLog.__table__.c.id == record_id,
|
||||
PurgeAuditLog.__table__.c.status == STATUS_PENDING,
|
||||
)
|
||||
.values(status=STATUS_BLOCKED, removed_dashboard_slices=0)
|
||||
.values(status=STATUS_BLOCKED, removed_dashboard_slices=0, reason=reason)
|
||||
)
|
||||
|
||||
|
||||
@@ -285,7 +343,11 @@ 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)
|
||||
_retain_blocked(
|
||||
recovery_session,
|
||||
record_id,
|
||||
snapshot.reason if snapshot else None,
|
||||
)
|
||||
recovery_session.commit()
|
||||
return "fallback"
|
||||
if snapshot is None:
|
||||
@@ -300,6 +362,7 @@ def _recover_retention_blocked(
|
||||
entity_uuid=snapshot.entity_uuid,
|
||||
removed_dashboard_slices=0,
|
||||
created_on=snapshot.created_on,
|
||||
reason=snapshot.reason,
|
||||
)
|
||||
)
|
||||
recovery_session.commit()
|
||||
@@ -319,9 +382,14 @@ def _recover_retention_blocked(
|
||||
|
||||
|
||||
def finalize_retention_blocked(
|
||||
record_id: UUID | None,
|
||||
record_id: UUID | None, reason: str | None
|
||||
) -> RetentionBlockedDisposition:
|
||||
"""Finalize a scheduled blocker, suppressing only proven redundant evidence."""
|
||||
"""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.
|
||||
"""
|
||||
if record_id is None:
|
||||
return "fallback"
|
||||
session: Session = _dedicated_session()
|
||||
@@ -330,15 +398,17 @@ def finalize_retention_blocked(
|
||||
current: PurgeAuditLog | None = session.get(PurgeAuditLog, record_id)
|
||||
if current is None:
|
||||
return "fallback"
|
||||
snapshot = _capture_recovery_snapshot(current)
|
||||
snapshot = _capture_recovery_snapshot(current, reason)
|
||||
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)
|
||||
suppressed: bool = _suppress_redundant_block(
|
||||
session, current, predecessor, reason
|
||||
)
|
||||
if not suppressed:
|
||||
_retain_blocked(session, record_id)
|
||||
_retain_blocked(session, record_id, reason)
|
||||
session.commit()
|
||||
return "suppressed" if suppressed else "retained"
|
||||
except SQLAlchemyError:
|
||||
@@ -383,6 +453,12 @@ 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)
|
||||
audit.block(record_id, result.blocker.code if result.blocker else None)
|
||||
else:
|
||||
audit.fail(record_id)
|
||||
if result.purged:
|
||||
|
||||
@@ -53,9 +53,11 @@ 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__)
|
||||
@@ -144,7 +146,17 @@ class CascadeResult:
|
||||
dangling_chart_uuids: list[str] = field(default_factory=list)
|
||||
removed_dashboard_slices: int = 0
|
||||
version_rows_removed: int = 0
|
||||
blocked_reason: str | None = None
|
||||
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
|
||||
|
||||
|
||||
class PurgeRaceLostError(Exception):
|
||||
@@ -267,20 +279,21 @@ def cascade_hard_delete(
|
||||
purged=False,
|
||||
entity_type=entity_type,
|
||||
entity_uuid=uuid,
|
||||
blocked_reason=str(ex),
|
||||
blocker=ex.reason,
|
||||
)
|
||||
except IntegrityError as ex:
|
||||
# Not a policy decision: a restrictive FK the cascade did not handle.
|
||||
# Not a policy decision: a database integrity constraint failed.
|
||||
# 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 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.
|
||||
# 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.
|
||||
logger.warning(
|
||||
"deletion_retention: %s id=%s purge failed on a restrictive "
|
||||
"foreign key the cascade does not handle: %s",
|
||||
"deletion_retention: %s id=%s purge failed on a database "
|
||||
"integrity constraint: %s",
|
||||
entity_type,
|
||||
entity_id,
|
||||
ex,
|
||||
@@ -289,7 +302,10 @@ def cascade_hard_delete(
|
||||
purged=False,
|
||||
entity_type=entity_type,
|
||||
entity_uuid=uuid,
|
||||
blocked_reason="blocked by database references",
|
||||
blocker=BlockerReason(
|
||||
REASON_CASCADE_INTEGRITY_FAILURE,
|
||||
"cascade blocked by a database integrity constraint",
|
||||
),
|
||||
)
|
||||
|
||||
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
|
||||
from typing import Any, cast, NamedTuple
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.orm import Mapper, Session
|
||||
@@ -36,10 +36,44 @@ 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."""
|
||||
@@ -106,11 +140,21 @@ class DependencyPolicy:
|
||||
key: DependencyKey
|
||||
classification: DependencyClassification
|
||||
phase: ExecutionPhase | None = None
|
||||
blocked_reason: str | None = None
|
||||
blocker: BlockerReason | 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:
|
||||
@@ -416,7 +460,7 @@ def purge_policy_registry() -> Mapping[type[Any], PurgeEntityPolicy]:
|
||||
keys: tuple[DependencyKey, ...],
|
||||
classifications: tuple[DependencyClassification, ...],
|
||||
synthetic: tuple[DependencyPolicy, ...],
|
||||
blocked_reasons: Mapping[str, str] = MappingProxyType({}),
|
||||
blocked_reasons: Mapping[str, BlockerReason] = MappingProxyType({}),
|
||||
version_columns: Mapping[str, str] = MappingProxyType({}),
|
||||
) -> tuple[DependencyPolicy, ...]:
|
||||
phases: dict[DependencyClassification, ExecutionPhase | None] = {
|
||||
@@ -428,15 +472,22 @@ 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(
|
||||
DependencyPolicy(
|
||||
key,
|
||||
classification,
|
||||
phases[classification],
|
||||
blocked_reason=blocked_reasons.get(key.related_table),
|
||||
version_column=version_columns.get(key.related_table),
|
||||
)
|
||||
declare(key, classification)
|
||||
for key, classification in zip(keys, classifications, strict=True)
|
||||
)
|
||||
+ synthetic
|
||||
@@ -539,7 +590,12 @@ def purge_policy_registry() -> Mapping[type[Any], PurgeEntityPolicy]:
|
||||
DependencyClassification.PRESERVE,
|
||||
),
|
||||
(tag_cleanup, chart_membership_versions),
|
||||
{"report_schedule": "associated alerts or reports exist"},
|
||||
# Keyed by related table; the audit code is declared, not derived.
|
||||
{
|
||||
"report_schedule": BlockerReason(
|
||||
REASON_REPORT_SCHEDULE, "associated alerts or reports exist"
|
||||
)
|
||||
},
|
||||
{"slices_version": "id"},
|
||||
),
|
||||
validate=validate_deletion_allowed,
|
||||
@@ -687,10 +743,16 @@ 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": "associated alerts or reports exist",
|
||||
"user_attribute": (
|
||||
"a user has this dashboard set as their welcome page"
|
||||
"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",
|
||||
),
|
||||
},
|
||||
{"dashboards_version": "id"},
|
||||
@@ -893,9 +955,9 @@ def validate_deletion_allowed(
|
||||
if session.execute(
|
||||
sa.select(sa.literal(1)).select_from(table).where(*predicates).limit(1)
|
||||
).first():
|
||||
if dependency.blocked_reason is None:
|
||||
if dependency.blocker is None:
|
||||
raise RuntimeError(f"Missing blocker reason for {key.describe()}")
|
||||
raise PurgeBlockedError(dependency.blocked_reason)
|
||||
raise PurgeBlockedError(dependency.blocker)
|
||||
|
||||
|
||||
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, matching the read-side
|
||||
# convention (404, indistinguishable from "no such version").
|
||||
# therefore inert under the kill-switch (404, indistinguishable from
|
||||
# "no such version"). Existing history remains readable.
|
||||
if not capture_enabled():
|
||||
raise self.not_found_exc()
|
||||
entity = find_active_by_uuid(self.model_cls, self._uuid)
|
||||
|
||||
+7
-15
@@ -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: development
|
||||
# @lifecycle: testing
|
||||
"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 but stays empty, so the two ship
|
||||
# with matching defaults and should be changed together.
|
||||
# @lifecycle: development
|
||||
# with capture off the panel renders empty or stale history, so the two
|
||||
# ship with matching defaults and should be changed together.
|
||||
# @lifecycle: testing
|
||||
"VERSION_HISTORY": True,
|
||||
# =================================================================
|
||||
# IN TESTING
|
||||
@@ -1694,17 +1694,9 @@ 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. 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.
|
||||
# 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.
|
||||
ENABLE_VERSIONING_CAPTURE: bool = utils.parse_boolean_string(
|
||||
os.environ.get("ENABLE_VERSIONING_CAPTURE", "true")
|
||||
)
|
||||
|
||||
@@ -791,16 +791,9 @@ 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`` (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.
|
||||
``ENABLE_VERSIONING_CAPTURE`` gates the baseline and change-record
|
||||
listener registrations. When disabled, initialization also detaches
|
||||
SQLAlchemy-Continuum's write listeners.
|
||||
|
||||
The fallback here is ``False`` so that any app-factory path that
|
||||
does not load ``superset.config`` (some test factories, embedded
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
# 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,6 +72,12 @@ 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,6 +32,7 @@ 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
|
||||
@@ -45,6 +46,7 @@ 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 (
|
||||
@@ -205,6 +207,19 @@ 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:
|
||||
@@ -279,18 +294,8 @@ def _purge_one(
|
||||
affected_referrers=result.dangling_chart_uuids,
|
||||
removed_dashboard_slices=result.removed_dashboard_slices,
|
||||
)
|
||||
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"
|
||||
)
|
||||
elif result.blocker is not None:
|
||||
_finalize_blocked(record_id, result.blocker)
|
||||
else:
|
||||
audit.fail(record_id)
|
||||
return result
|
||||
|
||||
@@ -379,37 +379,37 @@ msgstr "虛擬"
|
||||
msgid "%s aggregates(s)"
|
||||
msgstr "%s 聚合"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, 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 個欄位"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, python-format
|
||||
msgid "%s day ago"
|
||||
msgid_plural "%s days ago"
|
||||
msgstr[0] "1 天之前"
|
||||
msgstr[0] "%s 天前"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, python-format
|
||||
msgid "%s hr ago"
|
||||
msgid_plural "%s hr ago"
|
||||
msgstr[0] "%s 行"
|
||||
msgstr[0] "%s 小時前"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, python-format
|
||||
msgid "%s imported"
|
||||
msgstr "數據集已導入"
|
||||
msgstr "已匯入 %s"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, python-format
|
||||
msgid "%s item"
|
||||
msgid_plural "%s items"
|
||||
msgstr[0] "%s 個選項"
|
||||
msgstr[0] "%s 個項目"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, 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 個項目無法標記,因為您對所有選取的物件沒有編輯權限。"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, python-format
|
||||
msgid "%s metric"
|
||||
msgid_plural "%s metrics"
|
||||
msgstr[0] "排序指標"
|
||||
msgstr[0] "%s 個指標"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, python-format
|
||||
msgid "%s min ago"
|
||||
msgid_plural "%s min ago"
|
||||
msgstr[0] "月"
|
||||
msgstr[0] "%s 分鐘前"
|
||||
|
||||
#, python-format
|
||||
msgid ""
|
||||
@@ -451,47 +451,47 @@ msgstr[0] "%s 個選項"
|
||||
msgid "%s option(s)"
|
||||
msgstr "%s 個選項"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, python-format
|
||||
msgid "%s out of %s column"
|
||||
msgid_plural "%s out of %s columns"
|
||||
msgstr[0] "自定義列"
|
||||
msgstr[0] "已選取 %s/%s 個欄位"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, python-format
|
||||
msgid "%s out of %s metric"
|
||||
msgid_plural "%s out of %s metrics"
|
||||
msgstr[0] "排序指標"
|
||||
msgstr[0] "已選取 %s/%s 個指標"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, python-format
|
||||
msgid "%s out of %s selected"
|
||||
msgstr "%s 已選定"
|
||||
msgstr "已選取 %s/%s 個項目"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, python-format
|
||||
msgid "%s recipients"
|
||||
msgstr "%s 最近"
|
||||
msgstr "%s 收件者"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, python-format
|
||||
msgid "%s record..."
|
||||
msgid_plural "%s records..."
|
||||
msgstr[0] "%s 異常"
|
||||
msgstr[0] "%s 筆記錄..."
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, python-format
|
||||
msgid "%s row"
|
||||
msgid_plural "%s rows"
|
||||
msgstr[0] "%s 行"
|
||||
msgstr[0] "%s 列"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, python-format
|
||||
msgid "%s s ago"
|
||||
msgid_plural "%s s ago"
|
||||
msgstr[0] "30 天之前"
|
||||
msgstr[0] "%s 秒前"
|
||||
|
||||
#, python-format
|
||||
msgid "%s saved metric(s)"
|
||||
msgstr "%s 保存的指標"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, python-format
|
||||
msgid "%s second"
|
||||
msgid_plural "%s seconds"
|
||||
msgstr[0] "5 秒"
|
||||
msgstr[0] "%s 秒"
|
||||
|
||||
# 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"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, python-format
|
||||
msgid "%s tab selected"
|
||||
msgstr "%s 已選定"
|
||||
msgstr "已選取「%s」分頁"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, python-format
|
||||
msgid "%s updated"
|
||||
msgstr "上次更新 %s"
|
||||
msgstr "已更新 %s"
|
||||
|
||||
#, python-format
|
||||
msgid "%s%s"
|
||||
@@ -677,13 +677,11 @@ msgstr "每年年初的頻率"
|
||||
msgid "10 minute"
|
||||
msgstr "10 分鐘"
|
||||
|
||||
#, fuzzy
|
||||
msgid "10 seconds"
|
||||
msgstr "30 秒"
|
||||
msgstr "10 秒"
|
||||
|
||||
#, fuzzy
|
||||
msgid "10/90 percentiles"
|
||||
msgstr "9/91 百分位"
|
||||
msgstr "10/90 百分位數"
|
||||
|
||||
#. do-not-translate
|
||||
msgid "10000"
|
||||
@@ -696,9 +694,8 @@ msgstr "週"
|
||||
msgid "104 weeks ago"
|
||||
msgstr "104 週之前"
|
||||
|
||||
#, fuzzy
|
||||
msgid "12 hours"
|
||||
msgstr "1 小時"
|
||||
msgstr "12 小時"
|
||||
|
||||
msgid "15 minute"
|
||||
msgstr "15 分鐘"
|
||||
@@ -761,9 +758,8 @@ msgstr "2/98 百分位"
|
||||
msgid "22"
|
||||
msgstr "22"
|
||||
|
||||
#, fuzzy
|
||||
msgid "24 hours"
|
||||
msgstr "6 小時"
|
||||
msgstr "24 小時"
|
||||
|
||||
#, fuzzy
|
||||
msgid "28 days"
|
||||
@@ -831,9 +827,8 @@ msgstr "5 秒"
|
||||
msgid "5 seconds"
|
||||
msgstr "5 秒"
|
||||
|
||||
#, fuzzy
|
||||
msgid "5/95 percentiles"
|
||||
msgstr "9/91 百分位"
|
||||
msgstr "5/95 百分位數"
|
||||
|
||||
#, fuzzy
|
||||
msgid "52 weeks"
|
||||
|
||||
@@ -29,6 +29,10 @@ 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
|
||||
|
||||
@@ -75,6 +79,7 @@ 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:
|
||||
@@ -97,6 +102,7 @@ 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."""
|
||||
@@ -172,6 +178,9 @@ 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;
|
||||
@@ -184,7 +193,7 @@ class TestPurgeAudit(DeletionRetentionTestBase):
|
||||
entity_uuid="00000000-0000-0000-0000-00000000cafe",
|
||||
removed_dashboard_slices=7,
|
||||
)
|
||||
audit.block(record_id)
|
||||
audit.block(record_id, REASON_REPORT_SCHEDULE)
|
||||
|
||||
row = db.session.query(PurgeAuditLog).filter_by(id=record_id).one()
|
||||
assert row.status == audit.STATUS_BLOCKED
|
||||
@@ -194,20 +203,56 @@ class TestPurgeAudit(DeletionRetentionTestBase):
|
||||
record_id: UUID = self._write_retention_record(entity_uuid="first-block")
|
||||
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(record_id)
|
||||
audit.finalize_retention_blocked(record_id, REASON_REPORT_SCHEDULE)
|
||||
)
|
||||
|
||||
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)
|
||||
audit.finalize_retention_blocked(first_id, REASON_REPORT_SCHEDULE)
|
||||
second_id: UUID = self._write_retention_record(entity_uuid="repeat-block")
|
||||
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(second_id)
|
||||
audit.finalize_retention_blocked(second_id, REASON_REPORT_SCHEDULE)
|
||||
)
|
||||
|
||||
assert disposition == "suppressed"
|
||||
@@ -215,18 +260,160 @@ 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)
|
||||
audit.finalize_retention_blocked(first_id, REASON_REPORT_SCHEDULE)
|
||||
second_id: UUID = self._write_retention_record(
|
||||
entity_uuid="equal-time", created_on=timestamp
|
||||
)
|
||||
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(second_id)
|
||||
audit.finalize_retention_blocked(second_id, REASON_REPORT_SCHEDULE)
|
||||
)
|
||||
|
||||
assert disposition == "retained"
|
||||
@@ -257,7 +444,7 @@ class TestPurgeAudit(DeletionRetentionTestBase):
|
||||
session.close()
|
||||
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(current_id)
|
||||
audit.finalize_retention_blocked(current_id, REASON_REPORT_SCHEDULE)
|
||||
)
|
||||
|
||||
assert predecessor is None
|
||||
@@ -273,10 +460,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)
|
||||
audit.finalize_retention_blocked(newer_id, REASON_REPORT_SCHEDULE)
|
||||
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(current_id)
|
||||
audit.finalize_retention_blocked(current_id, REASON_REPORT_SCHEDULE)
|
||||
)
|
||||
|
||||
assert disposition == "retained"
|
||||
@@ -291,7 +478,7 @@ class TestPurgeAudit(DeletionRetentionTestBase):
|
||||
)
|
||||
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(current_id)
|
||||
audit.finalize_retention_blocked(current_id, REASON_REPORT_SCHEDULE)
|
||||
)
|
||||
|
||||
assert disposition == "retained"
|
||||
@@ -300,7 +487,7 @@ class TestPurgeAudit(DeletionRetentionTestBase):
|
||||
null_id: UUID = self._write_retention_record(entity_uuid=None)
|
||||
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(null_id)
|
||||
audit.finalize_retention_blocked(null_id, REASON_REPORT_SCHEDULE)
|
||||
)
|
||||
|
||||
record: PurgeAuditLog = self._get_audit_record(null_id)
|
||||
@@ -312,13 +499,13 @@ class TestPurgeAudit(DeletionRetentionTestBase):
|
||||
chart_id: UUID = self._write_retention_record(
|
||||
entity_uuid="shared-type", entity_type="slices"
|
||||
)
|
||||
audit.finalize_retention_blocked(chart_id)
|
||||
audit.finalize_retention_blocked(chart_id, REASON_REPORT_SCHEDULE)
|
||||
dashboard_id: UUID = self._write_retention_record(
|
||||
entity_uuid="shared-type", entity_type="dashboards"
|
||||
)
|
||||
|
||||
dashboard_disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(dashboard_id)
|
||||
audit.finalize_retention_blocked(dashboard_id, REASON_REPORT_SCHEDULE)
|
||||
)
|
||||
|
||||
assert dashboard_disposition == "retained"
|
||||
@@ -328,7 +515,7 @@ class TestPurgeAudit(DeletionRetentionTestBase):
|
||||
audit.fail(record_id)
|
||||
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(record_id)
|
||||
audit.finalize_retention_blocked(record_id, REASON_REPORT_SCHEDULE)
|
||||
)
|
||||
|
||||
record: PurgeAuditLog = self._get_audit_record(record_id)
|
||||
@@ -343,7 +530,7 @@ class TestPurgeAudit(DeletionRetentionTestBase):
|
||||
side_effect=audit.SQLAlchemyError("lookup failed"),
|
||||
):
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(record_id)
|
||||
audit.finalize_retention_blocked(record_id, REASON_REPORT_SCHEDULE)
|
||||
)
|
||||
|
||||
record: PurgeAuditLog = self._get_audit_record(record_id)
|
||||
@@ -352,7 +539,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)
|
||||
audit.finalize_retention_blocked(first_id, REASON_REPORT_SCHEDULE)
|
||||
current_id: UUID = self._write_retention_record(entity_uuid="delete-failure")
|
||||
|
||||
with patch(
|
||||
@@ -360,7 +547,7 @@ class TestPurgeAudit(DeletionRetentionTestBase):
|
||||
side_effect=audit.SQLAlchemyError("delete failed"),
|
||||
):
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(current_id)
|
||||
audit.finalize_retention_blocked(current_id, REASON_REPORT_SCHEDULE)
|
||||
)
|
||||
|
||||
record: PurgeAuditLog = self._get_audit_record(current_id)
|
||||
@@ -383,16 +570,18 @@ class TestPurgeAudit(DeletionRetentionTestBase):
|
||||
),
|
||||
):
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(record_id)
|
||||
audit.finalize_retention_blocked(record_id, REASON_REPORT_SCHEDULE)
|
||||
)
|
||||
|
||||
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)
|
||||
audit.finalize_retention_blocked(first_id, REASON_REPORT_SCHEDULE)
|
||||
current_id: UUID = self._write_retention_record(entity_uuid="absent-current")
|
||||
primary_session: Session = audit._dedicated_session()
|
||||
recovery_session: Session = audit._dedicated_session()
|
||||
@@ -410,12 +599,16 @@ class TestPurgeAudit(DeletionRetentionTestBase):
|
||||
),
|
||||
):
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(current_id)
|
||||
audit.finalize_retention_blocked(current_id, REASON_REPORT_SCHEDULE)
|
||||
)
|
||||
|
||||
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")
|
||||
@@ -438,7 +631,7 @@ class TestPurgeAudit(DeletionRetentionTestBase):
|
||||
),
|
||||
):
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(record_id)
|
||||
audit.finalize_retention_blocked(record_id, REASON_REPORT_SCHEDULE)
|
||||
)
|
||||
|
||||
record: PurgeAuditLog = self._get_audit_record(record_id)
|
||||
@@ -464,31 +657,34 @@ 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)
|
||||
audit._suppress_redundant_block(
|
||||
session, current, predecessor, REASON_REPORT_SCHEDULE
|
||||
)
|
||||
|
||||
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)
|
||||
audit.finalize_retention_blocked(first_id, REASON_REPORT_SCHEDULE)
|
||||
overlap_id: UUID = self._write_retention_record(
|
||||
entity_uuid="bounded-overlap", created_on=timestamp
|
||||
)
|
||||
audit.finalize_retention_blocked(overlap_id)
|
||||
audit.finalize_retention_blocked(overlap_id, REASON_REPORT_SCHEDULE)
|
||||
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)
|
||||
audit.finalize_retention_blocked(later_id, REASON_REPORT_SCHEDULE)
|
||||
)
|
||||
|
||||
retained_count: int = (
|
||||
@@ -516,7 +712,7 @@ class TestPurgeAudit(DeletionRetentionTestBase):
|
||||
current_id: UUID = self._write_retention_record(entity_uuid=entity_uuid)
|
||||
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(current_id)
|
||||
audit.finalize_retention_blocked(current_id, REASON_REPORT_SCHEDULE)
|
||||
)
|
||||
|
||||
current: PurgeAuditLog = self._get_audit_record(current_id)
|
||||
|
||||
@@ -140,6 +140,7 @@ 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,6 +24,7 @@ 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
|
||||
@@ -45,6 +46,9 @@ 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,
|
||||
@@ -286,6 +290,7 @@ 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,
|
||||
@@ -768,6 +773,7 @@ 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)
|
||||
@@ -812,10 +818,120 @@ class TestExplicitBlockerGuards(DeletionRetentionTestBase):
|
||||
db.session.commit()
|
||||
|
||||
assert result.purged is False
|
||||
assert result.blocked_reason == "blocked by database references"
|
||||
assert (
|
||||
result.blocked_reason
|
||||
== "cascade blocked by a database integrity constraint"
|
||||
)
|
||||
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,182 @@
|
||||
# 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,11 +68,10 @@ 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. 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.
|
||||
# ``@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.
|
||||
db.session.commit()
|
||||
|
||||
# Verify initial state: 2 charts associated with the dashboard
|
||||
|
||||
Reference in New Issue
Block a user