|
3 | 3 |
|
4 | 4 | import pandas as pd |
5 | 5 | from pydantic import ( |
| 6 | + BaseModel, |
6 | 7 | ConfigDict, |
7 | 8 | Field, |
8 | 9 | field_validator, |
|
37 | 38 | ) |
38 | 39 |
|
39 | 40 |
|
| 41 | +class MultipleColumnPanel(BaseModel): |
| 42 | + """Panel configuration for a :class:`MultipleColumnChart` column. |
| 43 | +
|
| 44 | + Datawrapper stores multiple-column panel settings as a mapping in |
| 45 | + ``metadata.visualize.panels`` where each key is a data column name and each |
| 46 | + value is that panel's configuration. This model keeps the column name as a |
| 47 | + first-class Python attribute while serializing back to the keyed API shape. |
| 48 | +
|
| 49 | + Unknown extra fields are preserved so new Datawrapper panel options can |
| 50 | + round-trip before this wrapper has explicit typed attributes for them. |
| 51 | + """ |
| 52 | + |
| 53 | + model_config = ConfigDict( |
| 54 | + populate_by_name=True, |
| 55 | + strict=True, |
| 56 | + extra="allow", |
| 57 | + ) |
| 58 | + |
| 59 | + #: The data column this panel configures. Serialized as the API mapping key. |
| 60 | + column: str = Field( |
| 61 | + description="The data column this panel configures", |
| 62 | + min_length=1, |
| 63 | + ) |
| 64 | + |
| 65 | + #: Optional custom panel title, which can include Datawrapper-supported HTML. |
| 66 | + title: str | None = Field( |
| 67 | + default=None, |
| 68 | + description="Optional custom panel title", |
| 69 | + ) |
| 70 | + |
| 71 | + #: Whether to show this panel on mobile layouts. |
| 72 | + show_on_mobile: bool | None = Field( |
| 73 | + default=None, |
| 74 | + alias="showOnMobile", |
| 75 | + description="Whether to show this panel on mobile layouts", |
| 76 | + ) |
| 77 | + |
| 78 | + #: Whether to show this panel on desktop layouts. |
| 79 | + show_on_desktop: bool | None = Field( |
| 80 | + default=None, |
| 81 | + alias="showOnDesktop", |
| 82 | + description="Whether to show this panel on desktop layouts", |
| 83 | + ) |
| 84 | + |
| 85 | + def serialize_model(self) -> dict[str, Any]: |
| 86 | + """Serialize the panel to the value stored under its column key.""" |
| 87 | + return self.model_dump( |
| 88 | + by_alias=True, |
| 89 | + exclude={"column"}, |
| 90 | + exclude_none=True, |
| 91 | + ) |
| 92 | + |
| 93 | + |
40 | 94 | class MultipleColumnTextAnnotation(TextAnnotation): |
41 | 95 | """Text annotation with additional fields specific to MultipleColumnChart. |
42 | 96 |
|
@@ -366,11 +420,51 @@ class MultipleColumnChart( |
366 | 420 | ) |
367 | 421 |
|
368 | 422 | #: Panels configuration |
369 | | - panels: list[dict[str, Any]] = Field( |
| 423 | + panels: list[MultipleColumnPanel] = Field( |
370 | 424 | default_factory=list, |
371 | 425 | description="Panel configurations for the chart", |
372 | 426 | ) |
373 | 427 |
|
| 428 | + @field_validator("panels", mode="before") |
| 429 | + @classmethod |
| 430 | + def convert_panels( |
| 431 | + cls, |
| 432 | + value: ( |
| 433 | + dict[str, dict[str, Any]] |
| 434 | + | Sequence[MultipleColumnPanel | dict[str, Any]] |
| 435 | + | None |
| 436 | + ), |
| 437 | + ) -> list[MultipleColumnPanel]: |
| 438 | + """Convert API and legacy panel inputs to MultipleColumnPanel objects. |
| 439 | +
|
| 440 | + Accepts both the Datawrapper API's keyed dictionary shape and the |
| 441 | + wrapper's historical list-of-dicts shape for backwards compatibility. |
| 442 | + """ |
| 443 | + if not value: |
| 444 | + return [] |
| 445 | + |
| 446 | + if isinstance(value, dict): |
| 447 | + result = [] |
| 448 | + for column, panel in value.items(): |
| 449 | + if isinstance(panel, MultipleColumnPanel): |
| 450 | + result.append(panel) |
| 451 | + elif isinstance(panel, dict): |
| 452 | + panel_data = {"column": column, **panel} |
| 453 | + result.append(MultipleColumnPanel(**panel_data)) |
| 454 | + else: |
| 455 | + result.append(panel) |
| 456 | + return result |
| 457 | + |
| 458 | + result = [] |
| 459 | + for item in value: |
| 460 | + if isinstance(item, MultipleColumnPanel): |
| 461 | + result.append(item) |
| 462 | + elif isinstance(item, dict): |
| 463 | + result.append(MultipleColumnPanel(**item)) |
| 464 | + else: |
| 465 | + result.append(item) |
| 466 | + return result |
| 467 | + |
374 | 468 | # |
375 | 469 | # Layout |
376 | 470 | # |
@@ -701,7 +795,7 @@ def serialize_model(self) -> dict: |
701 | 795 | self.plot_height_fixed, |
702 | 796 | self.plot_height_ratio, |
703 | 797 | ), |
704 | | - "panels": {panel["column"]: panel for panel in self.panels}, |
| 798 | + "panels": {panel.column: panel.serialize_model() for panel in self.panels}, |
705 | 799 | # Tooltips |
706 | 800 | "show-tooltips": self.show_tooltips, |
707 | 801 | "syncMultipleTooltips": self.sync_multiple_tooltips, |
@@ -837,14 +931,9 @@ def deserialize_model(cls, api_response: dict[str, Any]) -> dict[str, Any]: |
837 | 931 | # Plot height |
838 | 932 | init_data.update(PlotHeight.deserialize(visualize)) |
839 | 933 |
|
840 | | - # Parse panels (dict to list) |
841 | | - panels_obj = visualize.get("panels", {}) |
842 | | - if isinstance(panels_obj, dict): |
843 | | - init_data["panels"] = [ |
844 | | - {"column": col, **config} for col, config in panels_obj.items() |
845 | | - ] |
846 | | - else: |
847 | | - init_data["panels"] = [] |
| 934 | + # Parse panels. The field validator accepts the API's keyed dict shape |
| 935 | + # and converts values to MultipleColumnPanel objects. |
| 936 | + init_data["panels"] = visualize.get("panels", {}) |
848 | 937 |
|
849 | 938 | # Tooltips |
850 | 939 | if "show-tooltips" in visualize: |
|
0 commit comments