-
-
Notifications
You must be signed in to change notification settings - Fork 30
🎉 Charts as first-class ETL citizens via simple mdims #5969
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e2ed61d
aebb16d
72c04bd
cb3def6
c72f136
2409b36
6666d25
0ac9314
db49efe
0b9babb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,121 @@ | ||
| """Upsert a Collection with zero dimensions as a regular Grapher chart. | ||
|
|
||
| When a multidim `Collection` has no dimensions and exactly one view, it is the degenerate | ||
| "single chart" case. Instead of pushing it as a multi-dim data page via the `/multi-dims/` | ||
| admin endpoint, we translate the view into a standard Grapher chart config and push it | ||
| via `AdminAPI.create_chart` / `AdminAPI.update_chart`. | ||
|
|
||
| This keeps one authoring format (mdim config) across the chart↔multidim spectrum: adding a | ||
| dimension to a single-chart collection promotes it to a proper multidim without a config | ||
| migration. | ||
| """ | ||
|
|
||
| from typing import TYPE_CHECKING, Any | ||
|
|
||
| import structlog | ||
| from sqlalchemy.orm import Session | ||
| from sqlalchemy.orm.exc import NoResultFound | ||
|
|
||
| from apps.chart_sync.admin_api import AdminAPI | ||
| from etl.collection.utils import map_indicator_path_to_id | ||
| from etl.config import DEFAULT_GRAPHER_SCHEMA, GRAPHER_USER_ID, OWIDEnv | ||
| from etl.grapher.model import Chart | ||
|
|
||
| if TYPE_CHECKING: | ||
| from etl.collection.model.core import Collection | ||
| from etl.collection.model.view import View | ||
|
|
||
| log = structlog.get_logger() | ||
|
|
||
|
|
||
| _AXIS_ORDER = ("y", "x", "size", "color") | ||
|
|
||
|
|
||
| def upsert_collection_as_chart(collection: "Collection", owid_env: OWIDEnv) -> int: | ||
| """Push a zero-dimension collection to Grapher as a regular chart. | ||
|
|
||
| Expects `len(collection.dimensions) == 0` and `len(collection.views) == 1`. | ||
| """ | ||
| if len(collection.dimensions) != 0: | ||
| raise ValueError("upsert_collection_as_chart called on a collection with dimensions.") | ||
| if len(collection.views) != 1: | ||
| raise ValueError(f"Chart mode (no dimensions) requires exactly one view; got {len(collection.views)}.") | ||
|
|
||
| view = collection.views[0] | ||
| slug = _resolve_chart_slug(collection, view) | ||
| config = _build_chart_config(view, slug) | ||
|
|
||
| admin_api = AdminAPI(owid_env) | ||
| user_id = int(GRAPHER_USER_ID) if GRAPHER_USER_ID else None | ||
|
|
||
| with Session(owid_env.engine) as session: | ||
| try: | ||
| existing = Chart.load_chart(session, slug=slug) | ||
| except NoResultFound: | ||
| existing = None | ||
|
|
||
| if existing is not None: | ||
| # Preserve publication state unless the user explicitly overrode it. | ||
| config.setdefault("isPublished", existing.config.get("isPublished", False)) | ||
| log.info("collection.chart.update", slug=slug, chart_id=existing.id) | ||
| admin_api.update_chart(chart_id=existing.id, chart_config=config, user_id=user_id) | ||
| chart_id = existing.id | ||
|
Comment on lines
+57
to
+62
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So, if a chart already exists in DB, we can just overwrite it via ETL? Am I understanding it correctly? Couldn't this cause unwanted overwrites? Say the chart exists in DB and is live (i.e., this I see that on staging, one can reject changes in the Chart diff (I suppose), but the ETL config still appears and may have been merged into I'm just not sure I understand this logic. At the moment, it feels a bit dangerous, but I could be misunderstanding it. |
||
| else: | ||
| # New charts default to unpublished so humans can review before go-live. | ||
| config.setdefault("isPublished", False) | ||
| log.info("collection.chart.create", slug=slug) | ||
| result = admin_api.create_chart(chart_config=config, user_id=user_id) | ||
| chart_id = result["chartId"] | ||
|
|
||
| log.info( | ||
| "collection.chart.upsert_success", | ||
| slug=slug, | ||
| chart_id=chart_id, | ||
| admin_url=f"{owid_env.admin_site}/admin/charts/{chart_id}/edit", | ||
| ) | ||
| return chart_id | ||
|
|
||
|
|
||
| def _resolve_chart_slug(collection: "Collection", view: "View") -> str: | ||
| """Derive the chart slug from the collection's short_name. | ||
|
|
||
| Grapher chart slugs are conventionally dash-separated; the mdim short_name is snake_case. | ||
| """ | ||
| del view # unused for now; kept in the signature for future explicit-slug overrides | ||
|
pabloarosado marked this conversation as resolved.
|
||
| return collection.short_name.replace("_", "-") | ||
|
|
||
|
|
||
| def _build_chart_config(view: "View", slug: str) -> dict[str, Any]: | ||
| """Translate `view.config` + `view.indicators` into a grapher chart config dict.""" | ||
| config: dict[str, Any] = dict(view.config or {}) | ||
| config["slug"] = slug | ||
| config.setdefault("$schema", DEFAULT_GRAPHER_SCHEMA) | ||
|
|
||
| # Resolve indicator catalog paths (y/x/size/color) to variable IDs and emit as | ||
| # the grapher `dimensions` block, which charts identify by numeric variableId. | ||
| dimensions: list[dict[str, Any]] = [] | ||
| for axis in _AXIS_ORDER: | ||
| entries = _axis_entries(view, axis) | ||
| for indicator in entries: | ||
| dim: dict[str, Any] = {"property": axis, "variableId": int(map_indicator_path_to_id(indicator.catalogPath))} | ||
| if indicator.display: | ||
| dim["display"] = indicator.display | ||
| dimensions.append(dim) | ||
| if not dimensions: | ||
| raise ValueError(f"Chart view for slug '{slug}' has no indicators.") | ||
| config["dimensions"] = dimensions | ||
|
|
||
| # Rewrite catalog-path references in `sortColumnSlug` and `map.columnSlug` to IDs. | ||
| if "sortColumnSlug" in config: | ||
| config["sortColumnSlug"] = str(map_indicator_path_to_id(config["sortColumnSlug"])) | ||
| if isinstance(config.get("map"), dict) and "columnSlug" in config["map"]: | ||
| config["map"]["columnSlug"] = str(map_indicator_path_to_id(config["map"]["columnSlug"])) | ||
|
|
||
| return config | ||
|
|
||
|
|
||
| def _axis_entries(view: "View", axis: str) -> list: | ||
| value = getattr(view.indicators, axis, None) | ||
| if value is None: | ||
| return [] | ||
| return value if isinstance(value, list) else [value] | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| # Single-chart collection: `dimensions` is empty, so this is pushed as a regular | ||
| # Grapher chart (not a multi-dim data page). Replicates chart: | ||
| # https://admin.owid.io/admin/charts/7118/edit | ||
| title: | ||
| title: "Which countries have banned chick culling?" | ||
| title_variant: "" | ||
| default_selection: | ||
| - "World" | ||
| topic_tags: | ||
| - "Animal Welfare" | ||
| dimensions: [] | ||
| views: | ||
| - dimensions: {} | ||
| indicators: | ||
| y: | ||
| - "chick_culling_laws#status" | ||
| config: | ||
| $schema: "https://files.ourworldindata.org/schemas/grapher-schema.009.json" | ||
| title: "Which countries have banned chick culling?" | ||
| subtitle: "Chick culling is the process of separating and killing unwanted male and unhealthy female chicks that cannot produce eggs in industrialized egg facilities." | ||
| note: "In Switzerland grinding is banned but gassing is still allowed. Belgium has only a regional ban." | ||
| originUrl: "/animal-welfare" | ||
| tab: "map" | ||
| hasMapTab: true | ||
| chartTypes: [] | ||
| yAxis: | ||
| min: "auto" | ||
| map: | ||
| hideTimeline: true | ||
| colorScale: | ||
| baseColorScheme: "BinaryMapPaletteA" | ||
| customNumericColorsActive: true | ||
| customCategoryColors: | ||
| Banned: "#4881c6" | ||
| "No laws": "#b6a28c" | ||
| "Not banned": "#ad9882" | ||
| "Partially banned": "#a084c1" | ||
| "Banned but not yet effective": "#3e94a1" |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Uh oh!
There was an error while loading. Please reload this page.