mirror of
https://github.com/MinaSaad1/pbi-cli
synced 2026-04-21 13:37:19 +00:00
fix: correct 7 PBIR report-layer issues found during Desktop testing
- visual bind: remove legacy Commands/SemanticQueryDataShapeCommand block
(PBIR 2.7.0 uses additionalProperties:false -- Commands is a hard schema error)
- visual bind: add active:true to column (category/row/detail) projections
so Desktop treats the field as the active axis
- visual add: remove empty "objects:{}" from all 32 visual templates
(noisy and rejected by strict schema validators)
- visual add: write position coordinates as integers not floats
(Desktop normalises to int; 320.0 vs 320 caused inconsistency)
- report set-background: always write transparency:0 alongside color
(Desktop defaults missing transparency to 100 = fully invisible)
- report validate: drop false-positive layoutOptimization required error
(real Microsoft 3.2.0 schema does not require this field)
- all write commands: add --no-sync flag to report/visual/filters/bookmarks
groups to suppress per-command Desktop reload during scripted builds;
use pbi report reload for a single sync at the end
This commit is contained in:
parent
63f4738a2e
commit
895e90d710
46 changed files with 144 additions and 173 deletions
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [3.10.6] - 2026-04-07
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- `visual bind` no longer writes the legacy `Commands` block (SemanticQueryDataShapeCommand) to `visual.json`. PBIR 2.7.0 uses `additionalProperties: false` on the query object, so the `Commands` field is a hard schema violation. Only `queryState` projections are now written.
|
||||||
|
- `pbi report validate` and the full PBIR validator no longer flag a missing `layoutOptimization` field as an error. The real Microsoft schema does not list it as required; the previous check was against a stale internal schema.
|
||||||
|
- `pbi report set-background` now always writes `transparency: 0` alongside the color. Power BI Desktop defaults a missing `transparency` property to 100 (fully invisible), making the color silently unrendered. The new `--transparency` flag (0-100, default 0) lets callers override for semi-transparent backgrounds.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `--no-sync` flag on `report`, `visual`, `filters`, and `bookmarks` command groups. Suppresses the per-command Desktop auto-sync for scripted multi-step builds. Use `pbi report reload` for a single explicit sync at the end of the script.
|
||||||
|
|
||||||
## [3.10.5] - 2026-04-06
|
## [3.10.5] - 2026-04-06
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "pbi-cli-tool"
|
name = "pbi-cli-tool"
|
||||||
version = "3.10.5"
|
version = "3.10.6"
|
||||||
description = "CLI for Power BI semantic models and PBIR reports - direct .NET connection for token-efficient AI agent usage"
|
description = "CLI for Power BI semantic models and PBIR reports - direct .NET connection for token-efficient AI agent usage"
|
||||||
readme = "README.pypi.md"
|
readme = "README.pypi.md"
|
||||||
license = "MIT AND LicenseRef-Microsoft-AS-Client-Libraries"
|
license = "MIT AND LicenseRef-Microsoft-AS-Client-Libraries"
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ def run_command(
|
||||||
|
|
||||||
|
|
||||||
def _is_report_write(result: Any) -> bool:
|
def _is_report_write(result: Any) -> bool:
|
||||||
"""Check if the result indicates a report-layer write."""
|
"""Check if the result indicates a report-layer write that should trigger sync."""
|
||||||
if not isinstance(result, dict):
|
if not isinstance(result, dict):
|
||||||
return False
|
return False
|
||||||
status = result.get("status", "")
|
status = result.get("status", "")
|
||||||
|
|
@ -74,11 +74,13 @@ def _is_report_write(result: Any) -> bool:
|
||||||
if click_ctx is None:
|
if click_ctx is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Walk up to the group to find report_path
|
# Walk up to the group to find report_path; also check for --no-sync flag
|
||||||
parent = click_ctx.parent
|
parent = click_ctx.parent
|
||||||
while parent is not None:
|
while parent is not None:
|
||||||
obj = parent.obj
|
obj = parent.obj
|
||||||
if isinstance(obj, dict) and "report_path" in obj:
|
if isinstance(obj, dict) and "report_path" in obj:
|
||||||
|
if obj.get("no_sync", False):
|
||||||
|
return False
|
||||||
return True
|
return True
|
||||||
parent = parent.parent
|
parent = parent.parent
|
||||||
return False
|
return False
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,18 @@ from pbi_cli.main import PbiContext, pass_context
|
||||||
default=None,
|
default=None,
|
||||||
help="Path to .Report folder (auto-detected from CWD if omitted).",
|
help="Path to .Report folder (auto-detected from CWD if omitted).",
|
||||||
)
|
)
|
||||||
|
@click.option(
|
||||||
|
"--no-sync",
|
||||||
|
is_flag=True,
|
||||||
|
default=False,
|
||||||
|
help="Skip Desktop auto-sync after write commands. Use for scripted multi-step builds.",
|
||||||
|
)
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def bookmarks(ctx: click.Context, path: str | None) -> None:
|
def bookmarks(ctx: click.Context, path: str | None, no_sync: bool) -> None:
|
||||||
"""Manage report bookmarks."""
|
"""Manage report bookmarks."""
|
||||||
ctx.ensure_object(dict)
|
ctx.ensure_object(dict)
|
||||||
ctx.obj["report_path"] = path
|
ctx.obj["report_path"] = path
|
||||||
|
ctx.obj["no_sync"] = no_sync
|
||||||
|
|
||||||
|
|
||||||
@bookmarks.command(name="list")
|
@bookmarks.command(name="list")
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,18 @@ from pbi_cli.main import PbiContext, pass_context
|
||||||
default=None,
|
default=None,
|
||||||
help="Path to .Report folder (auto-detected from CWD if omitted).",
|
help="Path to .Report folder (auto-detected from CWD if omitted).",
|
||||||
)
|
)
|
||||||
|
@click.option(
|
||||||
|
"--no-sync",
|
||||||
|
is_flag=True,
|
||||||
|
default=False,
|
||||||
|
help="Skip Desktop auto-sync after write commands. Use for scripted multi-step builds.",
|
||||||
|
)
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def filters(ctx: click.Context, path: str | None) -> None:
|
def filters(ctx: click.Context, path: str | None, no_sync: bool) -> None:
|
||||||
"""Manage page and visual filters."""
|
"""Manage page and visual filters."""
|
||||||
ctx.ensure_object(dict)
|
ctx.ensure_object(dict)
|
||||||
ctx.obj["report_path"] = path
|
ctx.obj["report_path"] = path
|
||||||
|
ctx.obj["no_sync"] = no_sync
|
||||||
|
|
||||||
|
|
||||||
@filters.command(name="list")
|
@filters.command(name="list")
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,18 @@ from pbi_cli.main import PbiContext, pass_context
|
||||||
default=None,
|
default=None,
|
||||||
help="Path to .Report folder (auto-detected from CWD if omitted).",
|
help="Path to .Report folder (auto-detected from CWD if omitted).",
|
||||||
)
|
)
|
||||||
|
@click.option(
|
||||||
|
"--no-sync",
|
||||||
|
is_flag=True,
|
||||||
|
default=False,
|
||||||
|
help="Skip Desktop auto-sync after write commands. Use for scripted multi-step builds.",
|
||||||
|
)
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def report(ctx: click.Context, path: str | None) -> None:
|
def report(ctx: click.Context, path: str | None, no_sync: bool) -> None:
|
||||||
"""Manage Power BI PBIR reports (pages, themes, validation)."""
|
"""Manage Power BI PBIR reports (pages, themes, validation)."""
|
||||||
ctx.ensure_object(dict)
|
ctx.ensure_object(dict)
|
||||||
ctx.obj["report_path"] = path
|
ctx.obj["report_path"] = path
|
||||||
|
ctx.obj["no_sync"] = no_sync
|
||||||
|
|
||||||
|
|
||||||
@report.command()
|
@report.command()
|
||||||
|
|
@ -195,9 +202,19 @@ def diff_theme(ctx: PbiContext, click_ctx: click.Context, file: str) -> None:
|
||||||
@report.command(name="set-background")
|
@report.command(name="set-background")
|
||||||
@click.argument("page_name")
|
@click.argument("page_name")
|
||||||
@click.option("--color", "-c", required=True, help="Hex color e.g. '#F8F9FA'.")
|
@click.option("--color", "-c", required=True, help="Hex color e.g. '#F8F9FA'.")
|
||||||
|
@click.option(
|
||||||
|
"--transparency",
|
||||||
|
"-t",
|
||||||
|
default=0,
|
||||||
|
show_default=True,
|
||||||
|
type=click.IntRange(0, 100),
|
||||||
|
help="Transparency 0 (opaque) to 100 (invisible). Defaults to 0 so the color is visible.",
|
||||||
|
)
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
@pass_context
|
@pass_context
|
||||||
def set_background(ctx: PbiContext, click_ctx: click.Context, page_name: str, color: str) -> None:
|
def set_background(
|
||||||
|
ctx: PbiContext, click_ctx: click.Context, page_name: str, color: str, transparency: int
|
||||||
|
) -> None:
|
||||||
"""Set the background color of a page."""
|
"""Set the background color of a page."""
|
||||||
from pbi_cli.core.pbir_path import resolve_report_path
|
from pbi_cli.core.pbir_path import resolve_report_path
|
||||||
from pbi_cli.core.report_backend import page_set_background
|
from pbi_cli.core.report_backend import page_set_background
|
||||||
|
|
@ -210,6 +227,7 @@ def set_background(ctx: PbiContext, click_ctx: click.Context, page_name: str, co
|
||||||
definition_path=definition_path,
|
definition_path=definition_path,
|
||||||
page_name=page_name,
|
page_name=page_name,
|
||||||
color=color,
|
color=color,
|
||||||
|
transparency=transparency,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,18 @@ from pbi_cli.main import PbiContext, pass_context
|
||||||
default=None,
|
default=None,
|
||||||
help="Path to .Report folder (auto-detected from CWD if omitted).",
|
help="Path to .Report folder (auto-detected from CWD if omitted).",
|
||||||
)
|
)
|
||||||
|
@click.option(
|
||||||
|
"--no-sync",
|
||||||
|
is_flag=True,
|
||||||
|
default=False,
|
||||||
|
help="Skip Desktop auto-sync after write commands. Use for scripted multi-step builds.",
|
||||||
|
)
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def visual(ctx: click.Context, path: str | None) -> None:
|
def visual(ctx: click.Context, path: str | None, no_sync: bool) -> None:
|
||||||
"""Manage visuals in PBIR report pages."""
|
"""Manage visuals in PBIR report pages."""
|
||||||
ctx.ensure_object(dict)
|
ctx.ensure_object(dict)
|
||||||
ctx.obj["report_path"] = path
|
ctx.obj["report_path"] = path
|
||||||
|
ctx.obj["no_sync"] = no_sync
|
||||||
|
|
||||||
|
|
||||||
def _get_report_path(click_ctx: click.Context) -> str | None:
|
def _get_report_path(click_ctx: click.Context) -> str | None:
|
||||||
|
|
|
||||||
|
|
@ -163,11 +163,6 @@ def _validate_report_json(definition_path: Path) -> list[ValidationResult]:
|
||||||
ValidationResult("warning", "report.json", "themeCollection missing 'baseTheme'")
|
ValidationResult("warning", "report.json", "themeCollection missing 'baseTheme'")
|
||||||
)
|
)
|
||||||
|
|
||||||
if "layoutOptimization" not in data:
|
|
||||||
findings.append(
|
|
||||||
ValidationResult("error", "report.json", "Missing required 'layoutOptimization'")
|
|
||||||
)
|
|
||||||
|
|
||||||
return findings
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -240,8 +240,6 @@ def report_validate(definition_path: Path) -> dict[str, Any]:
|
||||||
data = _read_json(report_json)
|
data = _read_json(report_json)
|
||||||
if "themeCollection" not in data:
|
if "themeCollection" not in data:
|
||||||
errors.append("report.json missing required 'themeCollection'")
|
errors.append("report.json missing required 'themeCollection'")
|
||||||
if "layoutOptimization" not in data:
|
|
||||||
errors.append("report.json missing required 'layoutOptimization'")
|
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
pass # Already caught above
|
pass # Already caught above
|
||||||
|
|
||||||
|
|
@ -420,14 +418,21 @@ def page_set_background(
|
||||||
definition_path: Path,
|
definition_path: Path,
|
||||||
page_name: str,
|
page_name: str,
|
||||||
color: str,
|
color: str,
|
||||||
|
transparency: int = 0,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Set the background color of a page.
|
"""Set the background color of a page.
|
||||||
|
|
||||||
Updates the ``objects.background`` property in ``page.json``.
|
Updates the ``objects.background`` property in ``page.json``.
|
||||||
The color must be a hex string, e.g. ``'#F8F9FA'``.
|
The color must be a hex string, e.g. ``'#F8F9FA'``.
|
||||||
|
|
||||||
|
``transparency`` is 0 (fully opaque) to 100 (fully transparent). Desktop
|
||||||
|
defaults missing transparency to 100 (invisible), so this function always
|
||||||
|
writes it explicitly. Pass a value to override.
|
||||||
"""
|
"""
|
||||||
if not re.fullmatch(r"#[0-9A-Fa-f]{3,8}", color):
|
if not re.fullmatch(r"#[0-9A-Fa-f]{3,8}", color):
|
||||||
raise PbiCliError(f"Invalid color '{color}' -- expected hex format like '#F8F9FA'.")
|
raise PbiCliError(f"Invalid color '{color}' -- expected hex format like '#F8F9FA'.")
|
||||||
|
if not 0 <= transparency <= 100:
|
||||||
|
raise PbiCliError(f"Invalid transparency '{transparency}' -- must be 0-100.")
|
||||||
|
|
||||||
page_dir = get_page_dir(definition_path, page_name)
|
page_dir = get_page_dir(definition_path, page_name)
|
||||||
page_json_path = page_dir / "page.json"
|
page_json_path = page_dir / "page.json"
|
||||||
|
|
@ -437,12 +442,18 @@ def page_set_background(
|
||||||
page_data = _read_json(page_json_path)
|
page_data = _read_json(page_json_path)
|
||||||
background_entry = {
|
background_entry = {
|
||||||
"properties": {
|
"properties": {
|
||||||
"color": {"solid": {"color": {"expr": {"Literal": {"Value": f"'{color}'"}}}}}
|
"color": {"solid": {"color": {"expr": {"Literal": {"Value": f"'{color}'"}}}}},
|
||||||
|
"transparency": {"expr": {"Literal": {"Value": f"{transparency}D"}}},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
objects = {**page_data.get("objects", {}), "background": [background_entry]}
|
objects = {**page_data.get("objects", {}), "background": [background_entry]}
|
||||||
_write_json(page_json_path, {**page_data, "objects": objects})
|
_write_json(page_json_path, {**page_data, "objects": objects})
|
||||||
return {"status": "updated", "page": page_name, "background_color": color}
|
return {
|
||||||
|
"status": "updated",
|
||||||
|
"page": page_name,
|
||||||
|
"background_color": color,
|
||||||
|
"transparency": transparency,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def page_set_visibility(
|
def page_set_visibility(
|
||||||
|
|
|
||||||
|
|
@ -206,10 +206,10 @@ def _build_visual_json(
|
||||||
"""Fill placeholders in a template string and return parsed JSON."""
|
"""Fill placeholders in a template string and return parsed JSON."""
|
||||||
filled = (
|
filled = (
|
||||||
template_str.replace("__VISUAL_NAME__", name)
|
template_str.replace("__VISUAL_NAME__", name)
|
||||||
.replace("__X__", str(x))
|
.replace("__X__", str(int(x)))
|
||||||
.replace("__Y__", str(y))
|
.replace("__Y__", str(int(y)))
|
||||||
.replace("__WIDTH__", str(width))
|
.replace("__WIDTH__", str(int(width)))
|
||||||
.replace("__HEIGHT__", str(height))
|
.replace("__HEIGHT__", str(int(height)))
|
||||||
.replace("__Z__", str(z))
|
.replace("__Z__", str(z))
|
||||||
.replace("__TAB_ORDER__", str(tab_order))
|
.replace("__TAB_ORDER__", str(tab_order))
|
||||||
)
|
)
|
||||||
|
|
@ -566,11 +566,6 @@ def visual_bind(
|
||||||
query = visual_config.setdefault("query", {})
|
query = visual_config.setdefault("query", {})
|
||||||
query_state = query.setdefault("queryState", {})
|
query_state = query.setdefault("queryState", {})
|
||||||
|
|
||||||
# Collect existing Commands From/Select to merge (fix: don't overwrite)
|
|
||||||
from_entities: dict[str, dict[str, Any]] = {}
|
|
||||||
select_items: list[dict[str, Any]] = []
|
|
||||||
_collect_existing_commands(query, from_entities, select_items)
|
|
||||||
|
|
||||||
role_map = ROLE_ALIASES.get(visual_type, {})
|
role_map = ROLE_ALIASES.get(visual_type, {})
|
||||||
applied: list[dict[str, str]] = []
|
applied: list[dict[str, str]] = []
|
||||||
|
|
||||||
|
|
@ -588,14 +583,6 @@ def visual_bind(
|
||||||
# Determine measure vs column: explicit flag, or role-based heuristic
|
# Determine measure vs column: explicit flag, or role-based heuristic
|
||||||
is_measure = force_measure or pbir_role in MEASURE_ROLES
|
is_measure = force_measure or pbir_role in MEASURE_ROLES
|
||||||
|
|
||||||
# Track source alias for Commands block (use full name to avoid collisions)
|
|
||||||
source_alias = table.replace(" ", "_").lower() if table else "t"
|
|
||||||
from_entities[source_alias] = {
|
|
||||||
"Name": source_alias,
|
|
||||||
"Entity": table,
|
|
||||||
"Type": 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Build queryState projection (uses Entity directly, matching Desktop)
|
# Build queryState projection (uses Entity directly, matching Desktop)
|
||||||
query_ref = f"{table}.{column}"
|
query_ref = f"{table}.{column}"
|
||||||
if is_measure:
|
if is_measure:
|
||||||
|
|
@ -613,38 +600,18 @@ def visual_bind(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
projection = {
|
projection: dict[str, Any] = {
|
||||||
"field": field_expr,
|
"field": field_expr,
|
||||||
"queryRef": query_ref,
|
"queryRef": query_ref,
|
||||||
"nativeQueryRef": column,
|
"nativeQueryRef": column,
|
||||||
}
|
}
|
||||||
|
if not is_measure:
|
||||||
|
projection["active"] = True
|
||||||
|
|
||||||
# Add to query state
|
# Add to query state
|
||||||
role_state = query_state.setdefault(pbir_role, {"projections": []})
|
role_state = query_state.setdefault(pbir_role, {"projections": []})
|
||||||
role_state["projections"].append(projection)
|
role_state["projections"].append(projection)
|
||||||
|
|
||||||
# Build Commands select item (uses Source alias)
|
|
||||||
if is_measure:
|
|
||||||
cmd_field_expr: dict[str, Any] = {
|
|
||||||
"Measure": {
|
|
||||||
"Expression": {"SourceRef": {"Source": source_alias}},
|
|
||||||
"Property": column,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
cmd_field_expr = {
|
|
||||||
"Column": {
|
|
||||||
"Expression": {"SourceRef": {"Source": source_alias}},
|
|
||||||
"Property": column,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
select_items.append(
|
|
||||||
{
|
|
||||||
**cmd_field_expr,
|
|
||||||
"Name": query_ref,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
applied.append(
|
applied.append(
|
||||||
{
|
{
|
||||||
"role": pbir_role,
|
"role": pbir_role,
|
||||||
|
|
@ -653,20 +620,6 @@ def visual_bind(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Set the semantic query Commands block (merges with existing)
|
|
||||||
if from_entities and select_items:
|
|
||||||
query["Commands"] = [
|
|
||||||
{
|
|
||||||
"SemanticQueryDataShapeCommand": {
|
|
||||||
"Query": {
|
|
||||||
"Version": 2,
|
|
||||||
"From": list(from_entities.values()),
|
|
||||||
"Select": select_items,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
data["visual"] = visual_config
|
data["visual"] = visual_config
|
||||||
_write_json(vfile, data)
|
_write_json(vfile, data)
|
||||||
|
|
||||||
|
|
@ -714,21 +667,6 @@ def _summarize_field(field: dict[str, Any]) -> str:
|
||||||
return str(field)
|
return str(field)
|
||||||
|
|
||||||
|
|
||||||
def _collect_existing_commands(
|
|
||||||
query: dict[str, Any],
|
|
||||||
from_entities: dict[str, dict[str, Any]],
|
|
||||||
select_items: list[dict[str, Any]],
|
|
||||||
) -> None:
|
|
||||||
"""Extract existing From entities and Select items from Commands block."""
|
|
||||||
for cmd in query.get("Commands", []):
|
|
||||||
sq = cmd.get("SemanticQueryDataShapeCommand", {}).get("Query", {})
|
|
||||||
for entity in sq.get("From", []):
|
|
||||||
name = entity.get("Name", "")
|
|
||||||
if name:
|
|
||||||
from_entities[name] = entity
|
|
||||||
select_items.extend(sq.get("Select", []))
|
|
||||||
|
|
||||||
|
|
||||||
def _next_y_position(definition_path: Path, page_name: str) -> float:
|
def _next_y_position(definition_path: Path, page_name: str) -> float:
|
||||||
"""Calculate the next y position to avoid overlap with existing visuals."""
|
"""Calculate the next y position to avoid overlap with existing visuals."""
|
||||||
visuals_dir = definition_path / "pages" / page_name / "visuals"
|
visuals_dir = definition_path / "pages" / page_name / "visuals"
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@
|
||||||
},
|
},
|
||||||
"visual": {
|
"visual": {
|
||||||
"visualType": "actionButton",
|
"visualType": "actionButton",
|
||||||
"objects": {},
|
|
||||||
"visualContainerObjects": {},
|
"visualContainerObjects": {},
|
||||||
"drillFilterOtherVisuals": true
|
"drillFilterOtherVisuals": true
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"objects": {},
|
|
||||||
"drillFilterOtherVisuals": true
|
"drillFilterOtherVisuals": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"objects": {},
|
|
||||||
"drillFilterOtherVisuals": true
|
"drillFilterOtherVisuals": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"objects": {},
|
|
||||||
"drillFilterOtherVisuals": true
|
"drillFilterOtherVisuals": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"objects": {},
|
|
||||||
"drillFilterOtherVisuals": true
|
"drillFilterOtherVisuals": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"objects": {},
|
|
||||||
"drillFilterOtherVisuals": true
|
"drillFilterOtherVisuals": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"objects": {},
|
|
||||||
"drillFilterOtherVisuals": true
|
"drillFilterOtherVisuals": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@
|
||||||
"isDefaultSort": true
|
"isDefaultSort": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"objects": {},
|
|
||||||
"visualContainerObjects": {},
|
"visualContainerObjects": {},
|
||||||
"drillFilterOtherVisuals": true
|
"drillFilterOtherVisuals": true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@
|
||||||
"Legend": {"projections": []}
|
"Legend": {"projections": []}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"objects": {},
|
|
||||||
"drillFilterOtherVisuals": true
|
"drillFilterOtherVisuals": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@
|
||||||
"Legend": {"projections": []}
|
"Legend": {"projections": []}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"objects": {},
|
|
||||||
"drillFilterOtherVisuals": true
|
"drillFilterOtherVisuals": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"objects": {},
|
|
||||||
"drillFilterOtherVisuals": true
|
"drillFilterOtherVisuals": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"objects": {},
|
|
||||||
"drillFilterOtherVisuals": true
|
"drillFilterOtherVisuals": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"objects": {},
|
|
||||||
"drillFilterOtherVisuals": true
|
"drillFilterOtherVisuals": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"objects": {},
|
|
||||||
"drillFilterOtherVisuals": true
|
"drillFilterOtherVisuals": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@
|
||||||
},
|
},
|
||||||
"visual": {
|
"visual": {
|
||||||
"visualType": "image",
|
"visualType": "image",
|
||||||
"objects": {},
|
|
||||||
"visualContainerObjects": {},
|
"visualContainerObjects": {},
|
||||||
"drillFilterOtherVisuals": true
|
"drillFilterOtherVisuals": true
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"objects": {},
|
|
||||||
"drillFilterOtherVisuals": true
|
"drillFilterOtherVisuals": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"objects": {},
|
|
||||||
"drillFilterOtherVisuals": true
|
"drillFilterOtherVisuals": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"objects": {},
|
|
||||||
"drillFilterOtherVisuals": true
|
"drillFilterOtherVisuals": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@
|
||||||
"Values": {"projections": [], "active": true}
|
"Values": {"projections": [], "active": true}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"objects": {},
|
|
||||||
"drillFilterOtherVisuals": true
|
"drillFilterOtherVisuals": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"objects": {},
|
|
||||||
"drillFilterOtherVisuals": true
|
"drillFilterOtherVisuals": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@
|
||||||
},
|
},
|
||||||
"visual": {
|
"visual": {
|
||||||
"visualType": "pageNavigator",
|
"visualType": "pageNavigator",
|
||||||
"objects": {},
|
|
||||||
"visualContainerObjects": {},
|
"visualContainerObjects": {},
|
||||||
"drillFilterOtherVisuals": true
|
"drillFilterOtherVisuals": true
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"objects": {},
|
|
||||||
"drillFilterOtherVisuals": true
|
"drillFilterOtherVisuals": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"objects": {},
|
|
||||||
"drillFilterOtherVisuals": true
|
"drillFilterOtherVisuals": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"objects": {},
|
|
||||||
"drillFilterOtherVisuals": true
|
"drillFilterOtherVisuals": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@
|
||||||
},
|
},
|
||||||
"visual": {
|
"visual": {
|
||||||
"visualType": "shape",
|
"visualType": "shape",
|
||||||
"objects": {},
|
|
||||||
"visualContainerObjects": {},
|
"visualContainerObjects": {},
|
||||||
"drillFilterOtherVisuals": true
|
"drillFilterOtherVisuals": true
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"objects": {},
|
|
||||||
"drillFilterOtherVisuals": true
|
"drillFilterOtherVisuals": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"objects": {},
|
|
||||||
"drillFilterOtherVisuals": true
|
"drillFilterOtherVisuals": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"objects": {},
|
|
||||||
"drillFilterOtherVisuals": true
|
"drillFilterOtherVisuals": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@
|
||||||
"Values": {"projections": []}
|
"Values": {"projections": []}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"objects": {},
|
|
||||||
"drillFilterOtherVisuals": true
|
"drillFilterOtherVisuals": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@
|
||||||
},
|
},
|
||||||
"visual": {
|
"visual": {
|
||||||
"visualType": "textbox",
|
"visualType": "textbox",
|
||||||
"objects": {},
|
|
||||||
"drillFilterOtherVisuals": true
|
"drillFilterOtherVisuals": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"objects": {},
|
|
||||||
"drillFilterOtherVisuals": true
|
"drillFilterOtherVisuals": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"objects": {},
|
|
||||||
"drillFilterOtherVisuals": true
|
"drillFilterOtherVisuals": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -159,14 +159,8 @@ class TestBindMerge:
|
||||||
assert len(query["queryState"]["Category"]["projections"]) == 1
|
assert len(query["queryState"]["Category"]["projections"]) == 1
|
||||||
assert len(query["queryState"]["Y"]["projections"]) == 1
|
assert len(query["queryState"]["Y"]["projections"]) == 1
|
||||||
|
|
||||||
# Commands block should have both From entities
|
# PBIR 2.7.0: Commands is a legacy binary format field - must not be present
|
||||||
cmds = query["Commands"][0]["SemanticQueryDataShapeCommand"]["Query"]
|
assert "Commands" not in query
|
||||||
from_names = {e["Entity"] for e in cmds["From"]}
|
|
||||||
assert "Date" in from_names
|
|
||||||
assert "Sales" in from_names
|
|
||||||
|
|
||||||
# Commands Select should have both fields
|
|
||||||
assert len(cmds["Select"]) == 2
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -115,18 +115,6 @@ class TestValidateReportFull:
|
||||||
assert result["valid"] is False
|
assert result["valid"] is False
|
||||||
assert any("themeCollection" in e["message"] for e in result["errors"])
|
assert any("themeCollection" in e["message"] for e in result["errors"])
|
||||||
|
|
||||||
def test_missing_layout_optimization(self, valid_report: Path) -> None:
|
|
||||||
_write(
|
|
||||||
valid_report / "report.json",
|
|
||||||
{
|
|
||||||
"$schema": "...",
|
|
||||||
"themeCollection": {"baseTheme": {"name": "CY24SU06"}},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
result = validate_report_full(valid_report)
|
|
||||||
assert result["valid"] is False
|
|
||||||
assert any("layoutOptimization" in e["message"] for e in result["errors"])
|
|
||||||
|
|
||||||
def test_page_missing_required_fields(self, valid_report: Path) -> None:
|
def test_page_missing_required_fields(self, valid_report: Path) -> None:
|
||||||
_write(
|
_write(
|
||||||
valid_report / "pages" / "page1" / "page.json",
|
valid_report / "pages" / "page1" / "page.json",
|
||||||
|
|
|
||||||
|
|
@ -442,20 +442,6 @@ class TestReportValidate:
|
||||||
assert result["valid"] is False
|
assert result["valid"] is False
|
||||||
assert any("themeCollection" in e for e in result["errors"])
|
assert any("themeCollection" in e for e in result["errors"])
|
||||||
|
|
||||||
def test_report_validate_missing_layout_optimization(self, sample_report: Path) -> None:
|
|
||||||
"""report.json without 'layoutOptimization' is invalid."""
|
|
||||||
_write(
|
|
||||||
sample_report / "report.json",
|
|
||||||
{
|
|
||||||
"$schema": _SCHEMA_REPORT,
|
|
||||||
"themeCollection": {"baseTheme": {}},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
result = report_validate(sample_report)
|
|
||||||
assert result["valid"] is False
|
|
||||||
assert any("layoutOptimization" in e for e in result["errors"])
|
|
||||||
|
|
||||||
def test_report_validate_page_missing_page_json(self, sample_report: Path) -> None:
|
def test_report_validate_page_missing_page_json(self, sample_report: Path) -> None:
|
||||||
"""A page folder without page.json is flagged as invalid."""
|
"""A page folder without page.json is flagged as invalid."""
|
||||||
orphan_page = sample_report / "pages" / "orphan_page"
|
orphan_page = sample_report / "pages" / "orphan_page"
|
||||||
|
|
@ -1020,9 +1006,24 @@ def test_page_set_background_writes_color(sample_report: Path) -> None:
|
||||||
result = page_set_background(sample_report, "page1", "#F8F9FA")
|
result = page_set_background(sample_report, "page1", "#F8F9FA")
|
||||||
assert result["status"] == "updated"
|
assert result["status"] == "updated"
|
||||||
assert result["background_color"] == "#F8F9FA"
|
assert result["background_color"] == "#F8F9FA"
|
||||||
|
assert result["transparency"] == 0
|
||||||
page_data = _read(sample_report / "pages" / "page1" / "page.json")
|
page_data = _read(sample_report / "pages" / "page1" / "page.json")
|
||||||
bg = page_data["objects"]["background"][0]["properties"]["color"]
|
props = page_data["objects"]["background"][0]["properties"]
|
||||||
assert bg["solid"]["color"]["expr"]["Literal"]["Value"] == "'#F8F9FA'"
|
assert props["color"]["solid"]["color"]["expr"]["Literal"]["Value"] == "'#F8F9FA'"
|
||||||
|
# transparency must always be written so Desktop renders the color as opaque
|
||||||
|
assert props["transparency"]["expr"]["Literal"]["Value"] == "0D"
|
||||||
|
|
||||||
|
|
||||||
|
def test_page_set_background_custom_transparency(sample_report: Path) -> None:
|
||||||
|
result = page_set_background(sample_report, "page1", "#0E1117", transparency=50)
|
||||||
|
assert result["transparency"] == 50
|
||||||
|
props = _read(sample_report / "pages" / "page1" / "page.json")["objects"]["background"][0]["properties"]
|
||||||
|
assert props["transparency"]["expr"]["Literal"]["Value"] == "50D"
|
||||||
|
|
||||||
|
|
||||||
|
def test_page_set_background_rejects_invalid_transparency(sample_report: Path) -> None:
|
||||||
|
with pytest.raises(PbiCliError, match="Invalid transparency"):
|
||||||
|
page_set_background(sample_report, "page1", "#000000", transparency=101)
|
||||||
|
|
||||||
|
|
||||||
def test_page_set_background_preserves_other_objects(sample_report: Path) -> None:
|
def test_page_set_background_preserves_other_objects(sample_report: Path) -> None:
|
||||||
|
|
@ -1110,6 +1111,7 @@ def test_page_set_background_accepts_valid_color(sample_report: Path) -> None:
|
||||||
result = page_set_background(sample_report, "page1", "#F8F9FA")
|
result = page_set_background(sample_report, "page1", "#F8F9FA")
|
||||||
assert result["status"] == "updated"
|
assert result["status"] == "updated"
|
||||||
assert result["background_color"] == "#F8F9FA"
|
assert result["background_color"] == "#F8F9FA"
|
||||||
|
assert result["transparency"] == 0
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -194,7 +194,7 @@ def test_visual_add_matrix(report_with_page: Path) -> None:
|
||||||
|
|
||||||
|
|
||||||
def test_visual_add_custom_position(report_with_page: Path) -> None:
|
def test_visual_add_custom_position(report_with_page: Path) -> None:
|
||||||
"""Explicitly provided x, y, width, height are stored verbatim."""
|
"""Explicitly provided x, y, width, height are stored as integers, not floats."""
|
||||||
result = visual_add(
|
result = visual_add(
|
||||||
report_with_page,
|
report_with_page,
|
||||||
"test_page",
|
"test_page",
|
||||||
|
|
@ -206,18 +206,35 @@ def test_visual_add_custom_position(report_with_page: Path) -> None:
|
||||||
height=450.0,
|
height=450.0,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result["x"] == 100.0
|
assert result["x"] == 100
|
||||||
assert result["y"] == 200.0
|
assert result["y"] == 200
|
||||||
assert result["width"] == 600.0
|
assert result["width"] == 600
|
||||||
assert result["height"] == 450.0
|
assert result["height"] == 450
|
||||||
|
|
||||||
vfile = report_with_page / "pages" / "test_page" / "visuals" / "positioned" / "visual.json"
|
vfile = report_with_page / "pages" / "test_page" / "visuals" / "positioned" / "visual.json"
|
||||||
data = json.loads(vfile.read_text(encoding="utf-8"))
|
data = json.loads(vfile.read_text(encoding="utf-8"))
|
||||||
pos = data["position"]
|
pos = data["position"]
|
||||||
assert pos["x"] == 100.0
|
assert pos["x"] == 100
|
||||||
assert pos["y"] == 200.0
|
assert pos["y"] == 200
|
||||||
assert pos["width"] == 600.0
|
assert pos["width"] == 600
|
||||||
assert pos["height"] == 450.0
|
assert pos["height"] == 450
|
||||||
|
# Positions must be integers, not floats (Desktop normalises to int)
|
||||||
|
assert isinstance(pos["x"], int)
|
||||||
|
assert isinstance(pos["y"], int)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 7b. visual_add - no empty objects key
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_visual_add_no_empty_objects(report_with_page: Path) -> None:
|
||||||
|
"""Scaffolded visuals must not contain an empty 'objects' key."""
|
||||||
|
visual_add(report_with_page, "test_page", "bar_chart", name="clean_bar")
|
||||||
|
vfile = report_with_page / "pages" / "test_page" / "visuals" / "clean_bar" / "visual.json"
|
||||||
|
data = json.loads(vfile.read_text(encoding="utf-8"))
|
||||||
|
assert "objects" not in data.get("visual", {}), \
|
||||||
|
"visual.json must not contain empty 'objects: {}' -- Desktop strips it and schema validators reject it"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -357,19 +374,19 @@ def test_visual_update_position(report_with_page: Path) -> None:
|
||||||
|
|
||||||
assert result["status"] == "updated"
|
assert result["status"] == "updated"
|
||||||
assert result["name"] == "movable"
|
assert result["name"] == "movable"
|
||||||
assert result["position"]["x"] == 50.0
|
assert result["position"]["x"] == 50
|
||||||
assert result["position"]["y"] == 75.0
|
assert result["position"]["y"] == 75
|
||||||
assert result["position"]["width"] == 350.0
|
assert result["position"]["width"] == 350
|
||||||
assert result["position"]["height"] == 250.0
|
assert result["position"]["height"] == 250
|
||||||
|
|
||||||
# Confirm the file on disk reflects the change
|
# Confirm the file on disk reflects the change
|
||||||
vfile = report_with_page / "pages" / "test_page" / "visuals" / "movable" / "visual.json"
|
vfile = report_with_page / "pages" / "test_page" / "visuals" / "movable" / "visual.json"
|
||||||
data = json.loads(vfile.read_text(encoding="utf-8"))
|
data = json.loads(vfile.read_text(encoding="utf-8"))
|
||||||
pos = data["position"]
|
pos = data["position"]
|
||||||
assert pos["x"] == 50.0
|
assert pos["x"] == 50
|
||||||
assert pos["y"] == 75.0
|
assert pos["y"] == 75
|
||||||
assert pos["width"] == 350.0
|
assert pos["width"] == 350
|
||||||
assert pos["height"] == 250.0
|
assert pos["height"] == 250
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -469,8 +486,15 @@ def test_visual_bind_category_value(report_with_page: Path) -> None:
|
||||||
assert cat_proj["queryRef"] == "Date.Year"
|
assert cat_proj["queryRef"] == "Date.Year"
|
||||||
assert cat_proj["nativeQueryRef"] == "Year"
|
assert cat_proj["nativeQueryRef"] == "Year"
|
||||||
|
|
||||||
# The semantic query Commands block should be present
|
# PBIR 2.7.0: Commands is a legacy binary format field - must not be present
|
||||||
assert "Commands" in data["visual"]["query"]
|
assert "Commands" not in data["visual"]["query"]
|
||||||
|
|
||||||
|
# Column (category) projections must have active: true so Desktop renders the axis
|
||||||
|
assert cat_proj.get("active") is True
|
||||||
|
|
||||||
|
# Measure (value) projections must NOT have active: true
|
||||||
|
val_proj = query_state["Y"]["projections"][0]
|
||||||
|
assert "active" not in val_proj
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue