diff --git a/README.md b/README.md index 04ea8ba..6a7a5a5 100644 --- a/README.md +++ b/README.md @@ -377,11 +377,22 @@ Script generates: - MAQL transformation log to `metrics_maql.log` - Failed Cloud metrics to `cloud_failed_metrics.json` - Skipped objects IDs to `cloud_skipped_metrics.json` +- Computed attribute mappings to `computed_attribute_mappings.csv` +- Failed / skipped computed attributes to `cloud_failed_computed_attributes.json` / `cloud_skipped_computed_attributes.json` +- Computed attribute dumps to `legacy_computed_attributes.json` / `cloud_computed_attributes.json` (with `--dump-legacy` / `--dump-cloud`) Script reads: - LDM mappings from `ldm_mappings.csv` +Computed attributes: + +- Legacy computed attributes (`attr.comp.X` + `label.comp.X`) are migrated together with metrics as Cloud computed attributes with ID `label.comp.X`. The Legacy `CASE WHEN` definition is translated to `SELECT CASE WHEN SUM({metric/...}) <= n THEN "bucket" ... END BY {label/...} ALL OTHER`. +- Metrics, insights and dashboards reference them as `{computed_attribute/label.comp.X}` / `"type": "computedAttribute"`. +- Deployment order is: metrics → computed attributes → metrics using computed attributes. +- Requires the `enableComputedAttributes` feature flag in Cloud, on the parent workspace and on every child workspace that uses them (it is not inherited). Cloud computed attributes are in Beta: exports, date dimension and period-over-period are not supported with them. +- Only one-metric, numeric-threshold definitions are supported; other definitions are logged as errors and skipped. + Notes: - If any element values are missing within the Legacy metric, they are replaced with `--MISSING VALUE--`. Such metrics can be found in `metrics_maql.log` file and these metrics are also marked with [WARN] in their title (unless `--suppress-migration-warnings` is used). Warnings are always printed to console. @@ -561,7 +572,7 @@ gooddata-legacy2cloud dashboard-permissions **What this script does:** 1. Fetches selected Legacy dashboards based on filter parameters -2. Identifies the creator (account that created the dashboard) from Legacy +2. Identifies the creator (`author`) and last modifier (`contributor`) from Legacy 3. Collects sharing information (grantees) from Legacy dashboard permissions 4. Maps Legacy users to Cloud users based on either Legacy `email` field or Legacy `login` field 5. Maps Legacy user groups to Cloud user groups based on group names @@ -588,6 +599,10 @@ gooddata-legacy2cloud dashboard-permissions **Dashboard Permissions-specific options:** +**--include-metrics** - Also migrate creator/modifier of metrics (Legacy `author` → Cloud `createdBy`, Legacy `contributor` → Cloud `modifiedBy`). Metrics have no permissions, only these two fields are updated. Reads `metric_mappings.csv` (`--metric-mapping-file`). + +**--include-insights** - Also migrate creator/modifier of insights, same as above. Reads `insight_mappings.csv` (`--insight-mapping-file`). + **--dump-layout** - Store the layout JSON locally before and after modifications for debugging purposes **--use-email** - Use Legacy email field instead of login field when matching Legacy users to Cloud users. By default, the script uses the login field from Legacy users to match against Cloud user emails. With this parameter it uses email field from Legacy users to match against Cloud user emails. @@ -625,6 +640,9 @@ gooddata-legacy2cloud dashboard-permissions --permission VIEW # Update with client prefix gooddata-legacy2cloud dashboard-permissions --client-prefix client1_ --legacy-ws client1_legacy_ws --cloud-ws client1_cloud_ws + +# Also set creator/modifier of metrics and insights +gooddata-legacy2cloud dashboard-permissions --include-metrics --include-insights ``` **Important notes:** diff --git a/src/gooddata_legacy2cloud/arg_parsing/arg_parser.py b/src/gooddata_legacy2cloud/arg_parsing/arg_parser.py index e666946..3a1c179 100644 --- a/src/gooddata_legacy2cloud/arg_parsing/arg_parser.py +++ b/src/gooddata_legacy2cloud/arg_parsing/arg_parser.py @@ -648,6 +648,18 @@ def parse_dashboard_permission_cli_args() -> argparse.Namespace: add_object_filter_arguments(parser) add_dashboard_mapping_arguments(parser) add_pp_dashboard_mapping_arguments(parser) + add_metric_mapping_arguments(parser) + add_insight_mapping_arguments(parser) + parser.add_argument( + "--include-metrics", + action="store_true", + help="Also migrate creator/modifier (author/contributor) of metrics", + ) + parser.add_argument( + "--include-insights", + action="store_true", + help="Also migrate creator/modifier (author/contributor) of insights", + ) parser.add_argument( "--dump-layout", action="store_true", diff --git a/src/gooddata_legacy2cloud/backends/cloud/client.py b/src/gooddata_legacy2cloud/backends/cloud/client.py index 5d49785..cc71f97 100644 --- a/src/gooddata_legacy2cloud/backends/cloud/client.py +++ b/src/gooddata_legacy2cloud/backends/cloud/client.py @@ -257,6 +257,28 @@ def update_metric(self, metric_id, metric): url = f"api/v1/entities/workspaces/{self.ws}/metrics/{metric_id}" return self._put(url, metric) + def get_computed_attributes(self, page=0, origin: str = "ALL"): + """Sends a GET request to the server to get all computed attributes.""" + url = f"api/v1/entities/workspaces/{self.ws}/computedAttributes?origin={origin}&size={CLOUD_REQUEST_PAGE_SIZE}&page={page}" + try: + result = self._get(url) + data = result["data"] + if len(data) == CLOUD_REQUEST_PAGE_SIZE: + data.extend(self.get_computed_attributes(page + 1)) + return data + except KeyError: + return [] + + def create_computed_attribute(self, computed_attribute): + """Post a computed attribute to the server.""" + url = f"api/v1/entities/workspaces/{self.ws}/computedAttributes" + return self._post(url, computed_attribute) + + def update_computed_attribute(self, computed_attribute_id, computed_attribute): + """Update a computed attribute on the server.""" + url = f"api/v1/entities/workspaces/{self.ws}/computedAttributes/{computed_attribute_id}" + return self._put(url, computed_attribute) + def remove_metric(self, metric_id): """Sends a DELETE request to the server.""" url = f"api/v1/entities/workspaces/{self.ws}/metrics/{metric_id}" diff --git a/src/gooddata_legacy2cloud/backends/cloud/object_creator_strategy.py b/src/gooddata_legacy2cloud/backends/cloud/object_creator_strategy.py index 093fcb8..599f638 100644 --- a/src/gooddata_legacy2cloud/backends/cloud/object_creator_strategy.py +++ b/src/gooddata_legacy2cloud/backends/cloud/object_creator_strategy.py @@ -27,6 +27,8 @@ CLOUD_FAILED_REPORTS_FILE = "cloud_failed_reports.json" CLOUD_FAILED_PP_DASHBOARDS_FILE = "cloud_failed_pp_dashboards.json" CLOUD_SKIPPED_METRICS_FILE = "cloud_skipped_metrics.json" +CLOUD_FAILED_COMPUTED_ATTRIBUTES_FILE = "cloud_failed_computed_attributes.json" +CLOUD_SKIPPED_COMPUTED_ATTRIBUTES_FILE = "cloud_skipped_computed_attributes.json" CLOUD_SKIPPED_INSIGHTS_FILE = "cloud_skipped_insights.json" CLOUD_SKIPPED_KPI_COMPARISON_INSIGHTS_FILE = ( "cloud_skipped_kpi_comparison_insights.json" @@ -147,6 +149,28 @@ def get_object_type(self) -> str: return "metric" +class ComputedAttributeCreationStrategy(CreationStrategy): + """Strategy for creating computed attributes""" + + def get_existing_objects(self) -> List[Dict]: + return self.cloud_client.get_computed_attributes() + + def create_object(self, obj: T) -> Any: + return self.cloud_client.create_computed_attribute(obj) + + def update_object(self, obj: T) -> Any: + return self.cloud_client.update_computed_attribute(obj["data"]["id"], obj) + + def disable_object(self, obj: T, response: Response) -> Any: + return obj # no placeholder for computed attributes + + def get_object_link(self, domain: str, workspace_id: str, obj_id: str) -> str: + return f"{domain}/api/v1/entities/workspaces/{workspace_id}/computedAttributes/{obj_id}" + + def get_object_type(self) -> str: + return "computed_attribute" + + class InsightCreationStrategy(CreationStrategy): """Strategy for creating insights""" @@ -463,6 +487,12 @@ class ObjectConfig(TypedDict): "skipped_file": CLOUD_SKIPPED_METRICS_FILE, "log_file_mode": "w", }, + "computed_attribute": { + "strategy_class": ComputedAttributeCreationStrategy, + "failed_file": CLOUD_FAILED_COMPUTED_ATTRIBUTES_FILE, + "skipped_file": CLOUD_SKIPPED_COMPUTED_ATTRIBUTES_FILE, + "log_file_mode": "w", + }, "insight": { "strategy_class": InsightCreationStrategy, "failed_file": CLOUD_FAILED_INSIGHTS_FILE, diff --git a/src/gooddata_legacy2cloud/config/configuration_objects.py b/src/gooddata_legacy2cloud/config/configuration_objects.py index b991385..ed7a26f 100644 --- a/src/gooddata_legacy2cloud/config/configuration_objects.py +++ b/src/gooddata_legacy2cloud/config/configuration_objects.py @@ -62,8 +62,25 @@ class DashboardPermissionsConfig(BaseConfig): + "is treated as default and also used for output, all files are used " + f"for input. Default: [{PIXEL_PERFECT_DASHBOARD_MAPPING_FILE}]", ) + metric_mapping_file: list[str] = Field( + default_factory=lambda: [METRIC_MAPPING_FILE], + description=f"Metric mappings file name(s). Default: [{METRIC_MAPPING_FILE}]", + ) + insight_mapping_file: list[str] = Field( + default_factory=lambda: [INSIGHT_MAPPING_FILE], + description="Insight mappings file name(s). " + + f"Default: [{INSIGHT_MAPPING_FILE}]", + ) # Custom migration arguments + include_metrics: bool = Field( + default=False, + description="Also migrate creator/modifier of metrics", + ) + include_insights: bool = Field( + default=False, + description="Also migrate creator/modifier of insights", + ) dump_layout: bool = Field( default=False, description="Save the layout JSON before and after modifications", diff --git a/src/gooddata_legacy2cloud/constants.py b/src/gooddata_legacy2cloud/constants.py index 0e5617b..8ed3d66 100644 --- a/src/gooddata_legacy2cloud/constants.py +++ b/src/gooddata_legacy2cloud/constants.py @@ -10,6 +10,7 @@ LDM_MAPPING_FILE = "ldm_mappings.csv" METRIC_MAPPING_FILE = "metric_mappings.csv" +COMPUTED_ATTRIBUTE_MAPPING_FILE = "computed_attribute_mappings.csv" INSIGHT_MAPPING_FILE = "insight_mappings.csv" DASHBOARD_MAPPING_FILE = "dashboard_mappings.csv" REPORT_MAPPING_FILE = "report_mappings.csv" diff --git a/src/gooddata_legacy2cloud/dashboard_permissions/change_formatter.py b/src/gooddata_legacy2cloud/dashboard_permissions/change_formatter.py index c015c15..3f07867 100644 --- a/src/gooddata_legacy2cloud/dashboard_permissions/change_formatter.py +++ b/src/gooddata_legacy2cloud/dashboard_permissions/change_formatter.py @@ -15,11 +15,15 @@ def format_change_line(change: ActualChange) -> str: Formatted string containing dashboard, action, and target context. """ - prefix = f" Dashboard: '{change.dashboard_title}' (ID: {change.dashboard_id}) - " + prefix = ( + f" {change.object_type.capitalize()}: '{change.dashboard_title}' " + f"(ID: {change.dashboard_id}) - " + ) if change.change_type == "creator_updated": return ( - f"{prefix}Changed creator from '{change.old_value}' to '{change.new_value}'" + f"{prefix}Changed creator / modifier from '{change.old_value}' " + f"to '{change.new_value}'" ) if change.change_type == "permission_removed": diff --git a/src/gooddata_legacy2cloud/dashboard_permissions/data_classes.py b/src/gooddata_legacy2cloud/dashboard_permissions/data_classes.py index 2e0a3bd..47625a9 100644 --- a/src/gooddata_legacy2cloud/dashboard_permissions/data_classes.py +++ b/src/gooddata_legacy2cloud/dashboard_permissions/data_classes.py @@ -36,6 +36,7 @@ class ActualChange: new_value: str = field(default="") old_permission: str = field(default="") new_permission: str = field(default="") + object_type: str = field(default="dashboard") @dataclass(frozen=True) @@ -67,3 +68,5 @@ class DashboardPermissionContext: keep_existing_permissions: bool = field(default=False) print_user_mappings: bool = field(default=False) client_prefix: str | None = field(default=None) + metric_mappings: IdMappings | None = field(default=None) + insight_mappings: IdMappings | None = field(default=None) diff --git a/src/gooddata_legacy2cloud/dashboard_permissions/permissions_updater.py b/src/gooddata_legacy2cloud/dashboard_permissions/permissions_updater.py index b8063f2..ee07515 100644 --- a/src/gooddata_legacy2cloud/dashboard_permissions/permissions_updater.py +++ b/src/gooddata_legacy2cloud/dashboard_permissions/permissions_updater.py @@ -5,6 +5,7 @@ from gooddata_legacy2cloud.dashboard_permissions.data_classes import ( DashboardPermissionContext, ) +from gooddata_legacy2cloud.id_mappings import IdMappings from gooddata_legacy2cloud.layout.layout_manager import ( update_object_creators_in_layout, ) @@ -47,6 +48,8 @@ def __init__(self, context: DashboardPermissionContext): """ self.ctx = context self.legacy_dashboards: list[dict] = [] + self.legacy_metrics: list[dict] = [] + self.legacy_insights: list[dict] = [] self.legacy_user_map: dict[str, str] = {} self.cloud_user_map: dict[str, str] = {} self.user_mappings: dict[str, UserMapping] = {} @@ -59,18 +62,26 @@ def __init__(self, context: DashboardPermissionContext): self.identifier_plural = f"{self.identifier_type}s" def process_dashboards( - self, legacy_dashboards: list[dict] + self, + legacy_dashboards: list[dict], + legacy_metrics: list[dict] | None = None, + legacy_insights: list[dict] | None = None, ) -> tuple[list[ObjectUpdate], dict[str, int]]: """ - Process Legacy dashboards and prepare permission updates. + Process Legacy dashboards (permissions + creators) and optionally + metrics and insights (creators only) and prepare layout updates. Args: legacy_dashboards: List of Legacy dashboard objects + legacy_metrics: List of Legacy metric objects + legacy_insights: List of Legacy insight objects Returns: tuple: (list of ObjectUpdate instances, skip statistics dictionary) """ self.legacy_dashboards = legacy_dashboards + self.legacy_metrics = legacy_metrics or [] + self.legacy_insights = legacy_insights or [] # Fetch and map users self._fetch_and_map_users() @@ -118,10 +129,12 @@ def _fetch_and_map_users(self) -> None: legacy_users, self.identifier_type ) - # Extract creator IDs - user_ids = extract_creator_modifier_ids(self.legacy_dashboards) + # Extract creator and modifier IDs + user_ids = extract_creator_modifier_ids( + self.legacy_dashboards + self.legacy_metrics + self.legacy_insights + ) logger.info( - "Found %s unique creator %s in dashboards", + "Found %s unique creator/modifier %s in objects", len(user_ids), self.identifier_plural, ) @@ -317,8 +330,63 @@ def _build_object_updates(self) -> tuple[list[ObjectUpdate], dict[str, int]]: if update: object_updates.append(update) + for objects, object_type, mappings in ( + (self.legacy_metrics, "metric", self.ctx.metric_mappings), + (self.legacy_insights, "insight", self.ctx.insight_mappings), + ): + if objects: + logger.info("----Processing %ss----", object_type) + for obj in objects: + update = self._process_creator_only( + obj, object_type, mappings, skip_stats + ) + if update: + object_updates.append(update) + return object_updates, skip_stats + def _process_creator_only( + self, + obj: dict, + object_type: str, + mappings: IdMappings | None, + skip_stats: dict[str, int], + ) -> ObjectUpdate | None: + """Build a creator/modifier-only update for a metric or an insight.""" + if not obj or len(obj) != 1 or mappings is None: + return None + root_obj = next(iter(obj.values())) + if not isinstance(root_obj, dict) or "meta" not in root_obj: + return None + meta = root_obj["meta"] + cloud_object_id = mappings.get_value_by_key(meta.get("identifier")) + if not cloud_object_id: + skip_stats["no_cloud_mapping"] += 1 + return None + legacy_creator_id = self._extract_profile_id(meta.get("author")) + cloud_creator_id, creator_email = self._get_mapped_user(legacy_creator_id) + if not cloud_creator_id: + skip_stats["no_user_mapping"] += 1 + logger.warning( + "Creator '%s' not mapped for %s '%s' - skipped", + creator_email or legacy_creator_id, + object_type, + meta.get("title", ""), + ) + return None + legacy_modifier_id = self._extract_profile_id(meta.get("contributor")) + cloud_modifier_id, _ = self._get_mapped_user(legacy_modifier_id) + return ObjectUpdate( + legacy_object_id=meta.get("identifier"), + legacy_object_title=meta.get("title", ""), + cloud_object_id=cloud_object_id, + object_type=object_type, + legacy_creator_id=legacy_creator_id, + legacy_modifier_id=legacy_modifier_id, + cloud_creator_id=cloud_creator_id, + cloud_modifier_id=cloud_modifier_id, + ) + def _process_single_dashboard( self, obj: dict, idx: int, skip_stats: dict[str, int] ) -> ObjectUpdate | None: @@ -344,6 +412,8 @@ def _process_single_dashboard( creator_uri = dashboard_meta.get("author") legacy_creator_id = self._extract_profile_id(creator_uri) cloud_creator_id, creator_email = self._get_mapped_user(legacy_creator_id) + legacy_modifier_id = self._extract_profile_id(dashboard_meta.get("contributor")) + cloud_modifier_id, _ = self._get_mapped_user(legacy_modifier_id) # Process grantees if sharing is not skipped grantee_user_ids, grantee_mappings, grantee_group_ids, group_mappings = ( @@ -376,9 +446,9 @@ def _process_single_dashboard( cloud_object_id=cloud_object_id, object_type="dashboard", legacy_creator_id=legacy_creator_id, - legacy_modifier_id=None, + legacy_modifier_id=legacy_modifier_id, cloud_creator_id=cloud_creator_id, - cloud_modifier_id=None, + cloud_modifier_id=cloud_modifier_id, grantee_user_ids=grantee_user_ids if grantee_user_ids else None, grantee_group_ids=grantee_group_ids if grantee_group_ids else None, ) diff --git a/src/gooddata_legacy2cloud/dashboards/drill_converter.py b/src/gooddata_legacy2cloud/dashboards/drill_converter.py index 84a779c..97a04e3 100644 --- a/src/gooddata_legacy2cloud/dashboards/drill_converter.py +++ b/src/gooddata_legacy2cloud/dashboards/drill_converter.py @@ -11,6 +11,7 @@ from typing import Optional from gooddata_legacy2cloud.dashboards.data_classes import DashboardContext +from gooddata_legacy2cloud.helpers import cloud_display_form_type logger = logging.getLogger("migration") @@ -61,7 +62,7 @@ def _get_attribute_identifier(self, legacy_attribute_id: str) -> dict: return { "identifier": { "id": cloud_id, - "type": "label", # Most Legacy attributes map to Cloud labels + "type": cloud_display_form_type(legacy_attribute_id), } } diff --git a/src/gooddata_legacy2cloud/dashboards/filter_context.py b/src/gooddata_legacy2cloud/dashboards/filter_context.py index 752986f..a34345c 100644 --- a/src/gooddata_legacy2cloud/dashboards/filter_context.py +++ b/src/gooddata_legacy2cloud/dashboards/filter_context.py @@ -2,7 +2,7 @@ import logging from gooddata_legacy2cloud.dashboards.data_classes import DashboardContext -from gooddata_legacy2cloud.helpers import get_cloud_id +from gooddata_legacy2cloud.helpers import cloud_display_form_type, get_cloud_id from gooddata_legacy2cloud.metrics.attribute_element import AttributeElement from gooddata_legacy2cloud.metrics.contants import DELETED_VALUE from gooddata_legacy2cloud.models.cloud.filter_context import ( @@ -28,13 +28,18 @@ def __init__(self, ctx: DashboardContext, filter_context_uri: str): @staticmethod def _transform_filter_type_value(obj: dict) -> str: filter_type = obj["attributeDisplayForm"]["meta"]["category"].lower() - filter_type = "label" if filter_type == "attributedisplayform" else filter_type + if filter_type == "attributedisplayform": + return cloud_display_form_type( + obj["attributeDisplayForm"]["meta"]["identifier"] + ) return filter_type @staticmethod def _transform_attribute_type_value(obj: dict) -> str: attr_type = obj["attribute"]["meta"]["category"].lower() - return "label" if attr_type == "attribute" else attr_type + if attr_type == "attribute": + return cloud_display_form_type(obj["attribute"]["meta"]["identifier"]) + return attr_type def _get_filter_attribute_elements( self, attribute_elements: list diff --git a/src/gooddata_legacy2cloud/helpers.py b/src/gooddata_legacy2cloud/helpers.py index 6c668e7..aa1c0a2 100644 --- a/src/gooddata_legacy2cloud/helpers.py +++ b/src/gooddata_legacy2cloud/helpers.py @@ -170,6 +170,24 @@ def resolve_kpi_description(legacy_client: LegacyClient, obj: dict[str, Any]) -> return metric_obj["metric"]["meta"].get("summary", "") +COMPUTED_ATTRIBUTE_PREFIXES = ("attr.comp.", "label.comp.") + + +def is_computed_attribute(legacy_identifier: str) -> bool: + """Legacy computed attributes always use the attr.comp./label.comp. prefix.""" + return legacy_identifier.startswith(COMPUTED_ATTRIBUTE_PREFIXES) + + +def computed_attribute_cloud_id(legacy_identifier: str) -> str: + """Cloud computed attribute id is the Legacy label id (label.comp.X).""" + return "label." + legacy_identifier.split(".", 1)[1] + + +def cloud_display_form_type(legacy_identifier: str) -> str: + """Entity type Cloud expects for a Legacy display form reference.""" + return "computedAttribute" if is_computed_attribute(legacy_identifier) else "label" + + def get_cloud_id(title: str, legacy_identifier: str) -> str: """ Returns the Cloud metric identifier. diff --git a/src/gooddata_legacy2cloud/id_mappings.py b/src/gooddata_legacy2cloud/id_mappings.py index aacaa06..184dc1f 100644 --- a/src/gooddata_legacy2cloud/id_mappings.py +++ b/src/gooddata_legacy2cloud/id_mappings.py @@ -8,6 +8,11 @@ import logging import os +from gooddata_legacy2cloud.helpers import ( + computed_attribute_cloud_id, + is_computed_attribute, +) + logger = logging.getLogger("migration") @@ -71,6 +76,9 @@ def search_mapping_identifier(self, legacy_identifier): if legacy_identifier in self.id_mapping: return self.id_mapping[legacy_identifier] + if is_computed_attribute(legacy_identifier): + return computed_attribute_cloud_id(legacy_identifier) + raise ValueError( f"Search Cloud Id - Unknown Legacy identifier {legacy_identifier}" ) diff --git a/src/gooddata_legacy2cloud/insights/cloud_insight.py b/src/gooddata_legacy2cloud/insights/cloud_insight.py index 654fa04..408ca18 100644 --- a/src/gooddata_legacy2cloud/insights/cloud_insight.py +++ b/src/gooddata_legacy2cloud/insights/cloud_insight.py @@ -8,7 +8,12 @@ import logging import uuid -from gooddata_legacy2cloud.helpers import get_cloud_id, parse_legacy_tags +from gooddata_legacy2cloud.helpers import ( + cloud_display_form_type, + get_cloud_id, + is_computed_attribute, + parse_legacy_tags, +) from gooddata_legacy2cloud.id_mappings import IdMappings from gooddata_legacy2cloud.insights.data_classes import InsightContext from gooddata_legacy2cloud.insights.period_comparison_insight import ( @@ -141,7 +146,10 @@ def _transform_identifier_in_date_filter(self, filter: dict, filter_name: str): @staticmethod def _transform_filter_type_value(obj: dict) -> str: filter_type = obj["attributeDisplayForm"]["meta"]["category"].lower() - filter_type = "label" if filter_type == "attributedisplayform" else filter_type + if filter_type == "attributedisplayform": + return cloud_display_form_type( + obj["attributeDisplayForm"]["meta"]["identifier"] + ) return filter_type def _process_cyclical_date_filter( @@ -350,13 +358,15 @@ def _get_filters(self, filters: list, origin: str) -> list: def _transform_bucket( self, mapping: IdMappings, bucket: dict, legacy_object: dict, bucket_type: str ): - cloud_id = mapping.search_mapping_identifier( - legacy_object[bucket_type]["meta"]["identifier"] - ) + legacy_id = legacy_object[bucket_type]["meta"]["identifier"] + cloud_id = mapping.search_mapping_identifier(legacy_id) bucket["measure"]["definition"]["measureDefinition"]["item"] = { "identifier": { "id": cloud_id, - "type": legacy_object[bucket_type]["meta"]["category"], + # COUNT(attr.comp.X) has to reference the computed attribute entity + "type": "computedAttribute" + if is_computed_attribute(legacy_id) + else legacy_object[bucket_type]["meta"]["category"], } } bucket["measure"]["definition"]["measureDefinition"]["filters"] = ( diff --git a/src/gooddata_legacy2cloud/layout/layout_manager.py b/src/gooddata_legacy2cloud/layout/layout_manager.py index eb36333..9877ef7 100644 --- a/src/gooddata_legacy2cloud/layout/layout_manager.py +++ b/src/gooddata_legacy2cloud/layout/layout_manager.py @@ -11,6 +11,12 @@ logger = logging.getLogger("migration") +LAYOUT_SECTIONS = { + "dashboard": "analyticalDashboards", + "metric": "metrics", + "insight": "visualizationObjects", +} + def find_dashboard_in_layout(layout: dict, dashboard_id: str) -> dict | None: """ @@ -46,8 +52,9 @@ def update_object_creators_in_layout( """ Updates creator fields and permissions in the layout for specified objects. - Modifies the createdBy field, modifiedBy field (if present), and permissions - in analyticalDashboards section, leaving all other sections and fields untouched. + Modifies the createdBy field and modifiedBy field (if present) of dashboards, + metrics and insights, and permissions of dashboards, leaving all other + sections and fields untouched. Args: layout: The workspace layout dictionary @@ -63,56 +70,43 @@ def update_object_creators_in_layout( """ updates_made = 0 actual_changes: list[ActualChange] = [] - - # Navigate to analyticalDashboards section analytics = layout.get("analytics", {}) - analytical_dashboards = analytics.get("analyticalDashboards", []) - - # Create a mapping of Cloud dashboard IDs to update info - update_map = {obj.cloud_object_id: obj for obj in object_updates} - # Track which dashboards were found in the layout - found_dashboard_ids = set() + # Create a mapping of (object type, Cloud ID) to update info + update_map = {(obj.object_type, obj.cloud_object_id): obj for obj in object_updates} + found_keys = set() - # Update each dashboard - for dashboard in analytical_dashboards: - dashboard_id = dashboard.get("id") - if dashboard_id not in update_map: - continue - update_info = update_map[dashboard_id] - dashboard_title = dashboard.get("title", "Untitled") - dashboard_changes = [] - dashboard_changes.extend( - _update_creator_fields( - dashboard, - dashboard_id, - dashboard_title, - update_info, - skip_creators, - ) - ) - dashboard_changes.extend( - _sync_permissions( - dashboard, - dashboard_id, - dashboard_title, - update_info, - skip_creators, - skip_individual_grantees, - skip_group_grantees, - permission_level, - keep_existing_permissions, + for object_type, section in LAYOUT_SECTIONS.items(): + for obj in analytics.get(section, []): + key = (object_type, obj.get("id")) + if key not in update_map: + continue + update_info = update_map[key] + title = obj.get("title", "Untitled") + changes = _update_creator_fields( + obj, key[1], title, update_info, skip_creators ) - ) - if dashboard_changes: - for change in dashboard_changes: + if object_type == "dashboard": + changes.extend( + _sync_permissions( + obj, + key[1], + title, + update_info, + skip_creators, + skip_individual_grantees, + skip_group_grantees, + permission_level, + keep_existing_permissions, + ) + ) + for change in changes: actual_changes.append(change) logger.info("%s", format_change_line(change)) - updates_made += 1 - found_dashboard_ids.add(dashboard_id) + updates_made += 1 + found_keys.add(key) - # Calculate how many dashboards were not found in the layout - not_found_count = len(update_map) - len(found_dashboard_ids) + not_found_count = len(update_map) - len(found_keys) return layout, updates_made, not_found_count, actual_changes @@ -124,24 +118,26 @@ def _update_creator_fields( update_info: ObjectUpdate, skip_creators: bool, ) -> list[ActualChange]: - """Update createdBy/modifiedBy metadata.""" + """Update createdBy (Legacy author) and modifiedBy (Legacy contributor).""" if skip_creators or not update_info.has_creator_mapping: return [] new_creator = update_info.cloud_creator_id or "" + new_modifier = update_info.cloud_modifier_id or new_creator old_creator = dashboard.get("createdBy", {}).get("id", "None") - if old_creator == new_creator: + old_modifier = dashboard.get("modifiedBy", {}).get("id", new_modifier) + if old_creator == new_creator and old_modifier == new_modifier: return [] - user_ref = {"id": new_creator, "type": "user"} - dashboard["createdBy"] = user_ref + dashboard["createdBy"] = {"id": new_creator, "type": "user"} if "modifiedBy" in dashboard: - dashboard["modifiedBy"] = user_ref + dashboard["modifiedBy"] = {"id": new_modifier, "type": "user"} return [ ActualChange( dashboard_id=dashboard_id, dashboard_title=dashboard_title, change_type="creator_updated", - old_value=old_creator, - new_value=new_creator, + old_value=f"{old_creator} / {old_modifier}", + new_value=f"{new_creator} / {new_modifier}", + object_type=update_info.object_type, ) ] diff --git a/src/gooddata_legacy2cloud/metrics/computed_attribute.py b/src/gooddata_legacy2cloud/metrics/computed_attribute.py new file mode 100644 index 0000000..122d1ca --- /dev/null +++ b/src/gooddata_legacy2cloud/metrics/computed_attribute.py @@ -0,0 +1,119 @@ +# (C) 2026 GoodData Corporation +""" +Transforms Legacy computed attributes (attr.comp.X + label.comp.X) into Cloud +computed attribute entities defined by a MAQL CASE expression. +""" + +import logging +import re + +from gooddata_legacy2cloud.backends.legacy.client import LegacyClient +from gooddata_legacy2cloud.helpers import ( + computed_attribute_cloud_id, + is_computed_attribute, + parse_legacy_tags, +) +from gooddata_legacy2cloud.metrics.data_classes import MetricContext + +logger = logging.getLogger("migration") + +_WHEN = ( + r"when \{(?P[^}]+)\} (?P<=|<|>=|>|=) (?P-?\d+(?:\.\d+)?) " + r'then \{[^?}]+\?"(?P