diff --git a/bootstrap/sql/migrations/native/1.6.0/mysql/schemaChanges.sql b/bootstrap/sql/migrations/native/1.6.0/mysql/schemaChanges.sql index b033d86c8164..9203128c0b24 100644 --- a/bootstrap/sql/migrations/native/1.6.0/mysql/schemaChanges.sql +++ b/bootstrap/sql/migrations/native/1.6.0/mysql/schemaChanges.sql @@ -1747,3 +1747,29 @@ WHERE JSON_EXTRACT(json, '$.pipelineType') = 'metadata'; UPDATE ingestion_pipeline_entity SET json = JSON_REMOVE(json, '$.sourceConfig.config.processPiiSensitive', '$.sourceConfig.config.confidence', '$.sourceConfig.config.generateSampleData') WHERE JSON_EXTRACT(json, '$.pipelineType') = 'profiler'; + +-- Rename 'jobId' to 'jobIds', set 'jobId' as type array in 'jobIds' , add 'projectIds' for dbt cloud +UPDATE pipeline_service_entity +SET json = JSON_SET( + JSON_REMOVE( + json, + '$.connection.config.jobId' + ), + '$.connection.config.jobIds', + IF( + JSON_CONTAINS_PATH(json, 'one', '$.connection.config.jobIds'), + JSON_EXTRACT(json, '$.connection.config.jobIds'), + IF( + JSON_EXTRACT(json, '$.connection.config.jobId') IS NOT NULL, + JSON_ARRAY(JSON_UNQUOTE(JSON_EXTRACT(json, '$.connection.config.jobId'))), + JSON_ARRAY() + ) + ), + '$.connection.config.projectIds', + IF( + JSON_CONTAINS_PATH(json, 'one', '$.connection.config.projectIds'), + JSON_EXTRACT(json, '$.connection.config.projectIds'), + JSON_ARRAY() + ) +) +WHERE serviceType = 'DBTCloud'; diff --git a/bootstrap/sql/migrations/native/1.6.0/postgres/schemaChanges.sql b/bootstrap/sql/migrations/native/1.6.0/postgres/schemaChanges.sql index c5711a317417..38fb01676a24 100644 --- a/bootstrap/sql/migrations/native/1.6.0/postgres/schemaChanges.sql +++ b/bootstrap/sql/migrations/native/1.6.0/postgres/schemaChanges.sql @@ -1733,4 +1733,26 @@ WHERE json #>> '{pipelineType}' = 'metadata'; -- classification and sampling configs from the profiler pipelines UPDATE ingestion_pipeline_entity SET json = json::jsonb #- '{sourceConfig,config,processPiiSensitive}' #- '{sourceConfig,config,confidence}' #- '{sourceConfig,config,generateSampleData}' -WHERE json #>> '{pipelineType}' = 'profiler'; \ No newline at end of file +WHERE json #>> '{pipelineType}' = 'profiler'; + +-- set value of 'jobId' as an array into 'jobIds' for dbt cloud +UPDATE pipeline_service_entity +SET json = (case when json#>>'{connection, config, jobId}' IS NOT null +then + jsonb_set(json, '{connection, config, jobIds}', to_jsonb(ARRAY[json#>>'{connection, config, jobId}']), true) +else + jsonb_set(json, '{connection, config, jobIds}', '[]', true) +end +) +WHERE servicetype = 'DBTCloud'; + +-- remove 'jobId' after setting 'jobIds' for dbt cloud +UPDATE pipeline_service_entity +SET json = json::jsonb #- '{connection,config,jobId}' +WHERE json#>>'{connection, config, jobId}' IS NOT null +and servicetype = 'DBTCloud'; + +-- add 'projectIds' for dbt cloud +UPDATE pipeline_service_entity +SET json = jsonb_set(json, '{connection, config, projectIds}', '[]', true) +WHERE servicetype = 'DBTCloud'; diff --git a/ingestion/src/metadata/examples/workflows/dbtcloud.yaml b/ingestion/src/metadata/examples/workflows/dbtcloud.yaml index 9ac76d2b9805..5bc25d6d59a9 100644 --- a/ingestion/src/metadata/examples/workflows/dbtcloud.yaml +++ b/ingestion/src/metadata/examples/workflows/dbtcloud.yaml @@ -7,7 +7,8 @@ source: host: https://account_prefix.account_region.dbt.com discoveryAPI: https://metadata.cloud.getdbt.com/graphql accountId: "numeric_account_id" - # jobId: "numeric_job_id" + # jobIds: ["job_id_1", "job_id_2", "job_id_3"] + # projectIds: ["project_id_1", "project_id_2", "project_id_3"] token: auth_token sourceConfig: config: diff --git a/ingestion/src/metadata/ingestion/ometa/models.py b/ingestion/src/metadata/ingestion/ometa/models.py index 0611d515b05d..445e421e7b8b 100644 --- a/ingestion/src/metadata/ingestion/ometa/models.py +++ b/ingestion/src/metadata/ingestion/ometa/models.py @@ -30,3 +30,4 @@ class EntityList(BaseModel, Generic[T]): entities: List[T] total: int after: Optional[str] = None + before: Optional[str] = None diff --git a/ingestion/src/metadata/ingestion/ometa/ometa_api.py b/ingestion/src/metadata/ingestion/ometa/ometa_api.py index 0a99ab655228..1217d8950aff 100644 --- a/ingestion/src/metadata/ingestion/ometa/ometa_api.py +++ b/ingestion/src/metadata/ingestion/ometa/ometa_api.py @@ -378,12 +378,13 @@ def get_entity_reference( logger.debug("Cannot find the Entity %s", fqn) return None - # pylint: disable=too-many-locals + # pylint: disable=too-many-locals, too-many-arguments def list_entities( self, entity: Type[T], fields: Optional[List[str]] = None, after: Optional[str] = None, + before: Optional[str] = None, limit: int = 100, params: Optional[Dict[str, str]] = None, skip_on_failure: bool = False, @@ -395,9 +396,10 @@ def list_entities( suffix = self.get_suffix(entity) url_limit = f"?limit={limit}" url_after = f"&after={after}" if after else "" + url_before = f"&before={before}" if before else "" url_fields = f"&fields={','.join(fields)}" if fields else "" resp = self.client.get( - path=f"{suffix}{url_limit}{url_after}{url_fields}", data=params + path=f"{suffix}{url_limit}{url_after}{url_before}{url_fields}", data=params ) if self._use_raw_data: @@ -421,7 +423,8 @@ def list_entities( total = resp["paging"]["total"] after = resp["paging"]["after"] if "after" in resp["paging"] else None - return EntityList(entities=entities, total=total, after=after) + before = resp["paging"]["before"] if "before" in resp["paging"] else None + return EntityList(entities=entities, total=total, after=after, before=before) def list_all_entities( self, diff --git a/ingestion/src/metadata/ingestion/source/pipeline/airflow/connection.py b/ingestion/src/metadata/ingestion/source/pipeline/airflow/connection.py index 70dacd993d54..8579fb739b37 100644 --- a/ingestion/src/metadata/ingestion/source/pipeline/airflow/connection.py +++ b/ingestion/src/metadata/ingestion/source/pipeline/airflow/connection.py @@ -16,7 +16,9 @@ from typing import Optional from airflow import settings +from airflow.models.serialized_dag import SerializedDagModel from sqlalchemy.engine import Engine +from sqlalchemy.orm import sessionmaker from metadata.generated.schema.entity.automations.workflow import ( Workflow as AutomationWorkflow, @@ -102,6 +104,18 @@ def get_connection(connection: AirflowConnection) -> Engine: raise SourceConnectionException(msg) from exc +class AirflowPipelineDetailsAccessError(Exception): + """ + Raise when Pipeline information is not retrieved + """ + + +class AirflowTaskDetailsAccessError(Exception): + """ + Raise when Task detail information is not retrieved + """ + + def test_connection( metadata: OpenMetadata, engine: Engine, @@ -114,8 +128,37 @@ def test_connection( of a metadata workflow or during an Automation Workflow """ - test_fn = {"CheckAccess": partial(test_connection_engine_step, engine)} - + session_maker = sessionmaker(bind=engine) + session = session_maker() + + def test_pipeline_details_access(session): + try: + result = session.query(SerializedDagModel).first() + return result + except Exception as e: + raise AirflowPipelineDetailsAccessError( + f"Pipeline details access error: {e}" + ) + + def test_task_detail_access(session): + try: + json_data_column = ( + SerializedDagModel._data # For 2.3.0 onwards # pylint: disable=protected-access + if hasattr(SerializedDagModel, "_data") + else SerializedDagModel.data # For 2.2.5 and 2.1.4 + ) + result = session.query(json_data_column).first() + + retrieved_tasks = result[0]["dag"]["tasks"] + return retrieved_tasks + except Exception as e: + raise AirflowTaskDetailsAccessError(f"Task details access error : {e}") + + test_fn = { + "CheckAccess": partial(test_connection_engine_step, engine), + "PipelineDetailsAccess": partial(test_pipeline_details_access, session), + "TaskDetailAccess": partial(test_task_detail_access, session), + } return test_connection_steps( metadata=metadata, test_fn=test_fn, diff --git a/ingestion/src/metadata/ingestion/source/pipeline/dbtcloud/client.py b/ingestion/src/metadata/ingestion/source/pipeline/dbtcloud/client.py index 2cd1d21279dd..3d1ddf9b2703 100644 --- a/ingestion/src/metadata/ingestion/source/pipeline/dbtcloud/client.py +++ b/ingestion/src/metadata/ingestion/source/pipeline/dbtcloud/client.py @@ -35,6 +35,7 @@ from metadata.utils.logger import ometa_logger logger = ometa_logger() +API_VERSION = "api/v2" class DBTCloudClient: @@ -44,9 +45,13 @@ class DBTCloudClient: def __init__(self, config: DBTCloudConnection): self.config = config + + self.job_ids = self.config.jobIds + self.project_ids = self.config.projectIds + client_config: ClientConfig = ClientConfig( base_url=clean_uri(self.config.host), - api_version="api/v2", + api_version=API_VERSION, auth_header=AUTHORIZATION_HEADER, auth_token=lambda: (self.config.token.get_secret_value(), 0), allow_redirects=True, @@ -63,27 +68,43 @@ def __init__(self, config: DBTCloudConnection): self.client = REST(client_config) self.graphql_client = REST(graphql_client_config) - def test_get_jobs(self) -> Optional[List[DBTJob]]: + def _get_jobs( + self, job_id: str = None, project_id: str = None + ) -> Optional[List[DBTJob]]: """ - test fetch jobs for an account in dbt cloud + fetch jobs for an account in dbt cloud """ - job_path = f"{self.config.jobId}/" if self.config.jobId else "" - result = self.client.get(f"/accounts/{self.config.accountId}/jobs/{job_path}") - job_list = ( - [DBTJob.model_validate(result["data"])] - if self.config.jobId - else DBTJobList.model_validate(result).Jobs - ) + job_list = [] + try: + job_path = f"{job_id}/" if job_id else "" + project_path = f"?project_id={project_id}" if project_id else "" + result = self.client.get( + f"/accounts/{self.config.accountId}/jobs/{job_path}{project_path}" + ) + job_list = ( + [DBTJob.model_validate(result["data"])] + if job_id + else DBTJobList.model_validate(result).Jobs + ) + except Exception as exc: + logger.debug(traceback.format_exc()) + logger.error( + f"Failed to get job info for project_id: `{project_id}` or job_id: `{job_id}` : {exc}" + ) return job_list - def test_get_runs(self, job_id: int) -> Optional[List[DBTRun]]: + def test_get_jobs(self) -> List[DBTJob]: + """ + test fetch jobs for an account in dbt cloud + """ + job_list = self.client.get(f"/accounts/{self.config.accountId}/jobs/") + return DBTJobList.model_validate(job_list).Jobs + + def test_get_runs(self) -> List[DBTRun]: """ test fetch runs for a job in dbt cloud """ - result = self.client.get( - f"/accounts/{self.config.accountId}/runs/", - data={"job_definition_id": job_id}, - ) + result = self.client.get(f"/accounts/{self.config.accountId}/runs/") run_list = DBTRunList.model_validate(result).Runs return run_list @@ -92,17 +113,29 @@ def get_jobs(self) -> Optional[List[DBTJob]]: list jobs for an account in dbt cloud """ try: - job_path = f"{self.config.jobId}/" if self.config.jobId else "" - result = self.client.get( - f"/accounts/{self.config.accountId}/jobs/{job_path}" - ) - if result: - job_list = ( - [DBTJob.model_validate(result.get("data"))] - if self.config.jobId - else DBTJobList.model_validate(result).Jobs - ) - return job_list + jobs = [] + # case when job_ids are specified and project_ids are not + if self.job_ids and not self.project_ids: + for job_id in self.job_ids: + jobs.extend(self._get_jobs(job_id=job_id)) + # case when project_ids are specified or both are specified + elif self.project_ids: + for project_id in self.project_ids: + results = self._get_jobs(project_id=project_id) + if self.job_ids: + jobs.extend( + [ + result + for result in results + if str(result.id) in self.job_ids + ] + ) + else: + jobs.extend(results) + else: + results = self._get_jobs() + jobs.extend(results) + return jobs except Exception as exc: logger.debug(traceback.format_exc()) logger.error(f"Unable to get job info :{exc}") @@ -141,6 +174,9 @@ def get_model_details(self, job_id: int, run_id: int): if result.get("data") and result["data"].get("job"): model_list = DBTModelList.model_validate(result["data"]["job"]).models + logger.debug( + f"Successfully fetched models from dbt for job_id:{job_id} run_id:{run_id}: {model_list}" + ) return model_list except Exception as exc: @@ -150,7 +186,7 @@ def get_model_details(self, job_id: int, run_id: int): def get_models_and_seeds_details(self, job_id: int, run_id: int): """ - get model details for a job in dbt cloud for lineage + get parent model details for a job in dbt cloud for lineage """ try: query_params = { @@ -163,9 +199,12 @@ def get_models_and_seeds_details(self, job_id: int, run_id: int): if result.get("data") and result["data"].get("job"): result = DBTModelList.model_validate(result["data"]["job"]) parents_list = result.models + result.seeds + logger.debug( + f"Successfully fetched parent models from dbt for job_id:{job_id} run_id:{run_id}: {parents_list}" + ) return parents_list except Exception as exc: logger.debug(traceback.format_exc()) - logger.warning(f"Unable to get model info :{exc}") + logger.warning(f"Unable to get parents model info :{exc}") return None diff --git a/ingestion/src/metadata/ingestion/source/pipeline/dbtcloud/connection.py b/ingestion/src/metadata/ingestion/source/pipeline/dbtcloud/connection.py index 40d73ce7d789..ee54d3444d37 100644 --- a/ingestion/src/metadata/ingestion/source/pipeline/dbtcloud/connection.py +++ b/ingestion/src/metadata/ingestion/source/pipeline/dbtcloud/connection.py @@ -50,11 +50,9 @@ def test_connection( of a metadata workflow or during an Automation Workflow """ - job_id = int(service_connection.jobId) if service_connection.jobId else 0 - test_fn = { "GetJobs": client.test_get_jobs, - "GetRuns": partial(client.test_get_runs, job_id=job_id), + "GetRuns": partial(client.test_get_runs), } return test_connection_steps( diff --git a/ingestion/src/metadata/ingestion/source/pipeline/dbtcloud/models.py b/ingestion/src/metadata/ingestion/source/pipeline/dbtcloud/models.py index 6273deacd92b..b9deebc2d5c4 100644 --- a/ingestion/src/metadata/ingestion/source/pipeline/dbtcloud/models.py +++ b/ingestion/src/metadata/ingestion/source/pipeline/dbtcloud/models.py @@ -35,7 +35,7 @@ class DBTJob(BaseModel): class DBTJobList(BaseModel): - Jobs: Optional[List[DBTJob]] = Field([], alias="data") + Jobs: List[DBTJob] = Field(alias="data") class DBTRun(BaseModel): diff --git a/ingestion/src/metadata/utils/helpers.py b/ingestion/src/metadata/utils/helpers.py index 825ab6bfd3ae..0b3438c7f280 100644 --- a/ingestion/src/metadata/utils/helpers.py +++ b/ingestion/src/metadata/utils/helpers.py @@ -360,8 +360,7 @@ def clean_uri(uri: Union[str, Url]) -> str: make it http://localhost:9000 """ # force a string of the given Uri if needed - if isinstance(uri, Url): - uri = str(uri) + uri = str(uri) return uri[:-1] if uri.endswith("/") else uri diff --git a/ingestion/tests/integration/ometa/test_ometa_table_api.py b/ingestion/tests/integration/ometa/test_ometa_table_api.py index 8845dc8efa30..f8e673b5dcce 100644 --- a/ingestion/tests/integration/ometa/test_ometa_table_api.py +++ b/ingestion/tests/integration/ometa/test_ometa_table_api.py @@ -299,7 +299,7 @@ def test_list(self): ) assert data - def test_list_all(self): + def test_list_all_and_paginate(self): """ Validate generator utility to fetch all tables """ @@ -315,6 +315,17 @@ def test_list_all(self): len(list(all_entities)) >= 10 ) # In case the default testing entity is not present + entity_list = self.metadata.list_entities(entity=Table, limit=2) + assert len(entity_list.entities) == 2 + after_entity_list = self.metadata.list_entities( + entity=Table, limit=2, after=entity_list.after + ) + assert len(after_entity_list.entities) == 2 + before_entity_list = self.metadata.list_entities( + entity=Table, limit=2, before=after_entity_list.before + ) + assert before_entity_list.entities == entity_list.entities + def test_delete(self): """ We can delete a Table by ID diff --git a/ingestion/tests/unit/topology/pipeline/test_dbtcloud.py b/ingestion/tests/unit/topology/pipeline/test_dbtcloud.py index e8a99344fbe5..e1cfead56a61 100644 --- a/ingestion/tests/unit/topology/pipeline/test_dbtcloud.py +++ b/ingestion/tests/unit/topology/pipeline/test_dbtcloud.py @@ -386,7 +386,8 @@ "host": "https://abc12.us1.dbt.com", "discoveryAPI": "https://metadata.cloud.getdbt.com/graphql", "accountId": "70403103922125", - "jobId": "70403103922125", + "jobIds": ["70403103922125", "70403103922126"], + "projectIds": ["70403103922127", "70403103922128"], "token": "dbt_token", } }, @@ -510,6 +511,10 @@ sourceHash=None, ) +EXPECTED_JOB_FILTERS = ["70403103922125", "70403103922126"] + +EXPECTED_PROJECT_FILTERS = ["70403103922127", "70403103922128"] + EXPECTED_PIPELINE_NAME = str(MOCK_JOB_RESULT["data"][0]["name"]) @@ -547,6 +552,10 @@ def test_pipeline_name(self): == EXPECTED_PIPELINE_NAME ) + def test_filters_to_list(self): + assert self.dbtcloud.client.job_ids == EXPECTED_JOB_FILTERS + assert self.dbtcloud.client.project_ids == EXPECTED_PROJECT_FILTERS + def test_pipelines(self): pipeline = list(self.dbtcloud.yield_pipeline(EXPECTED_JOB_DETAILS))[0].right assert pipeline == EXPECTED_CREATED_PIPELINES diff --git a/openmetadata-docs/content/v1.5.x/deployment/bare-metal/index.md b/openmetadata-docs/content/v1.5.x/deployment/bare-metal/index.md index 180b1a29c9d5..5345831ce5a0 100644 --- a/openmetadata-docs/content/v1.5.x/deployment/bare-metal/index.md +++ b/openmetadata-docs/content/v1.5.x/deployment/bare-metal/index.md @@ -65,7 +65,7 @@ Please follow the instructions here to [install ElasticSearch](https://www.elast If you are using AWS OpenSearch Service, OpenMetadata Supports AWS OpenSearch Service engine version up to 2.7. For more information on AWS OpenSearch Service, please visit the official docs [here](https://docs.aws.amazon.com/opensearch-service/latest/developerguide/what-is.html). -## Airflow (version 2.0.0 or higher) or other workflow schedulers +## Airflow (version 2.9.1) or other workflow schedulers OpenMetadata performs metadata ingestion using the Ingestion Framework. Learn more about how to deploy and manage the ingestion workflows [here](/deployment/ingestion). diff --git a/openmetadata-docs/content/v1.6.x-SNAPSHOT/connectors/pipeline/dbtcloud/index.md b/openmetadata-docs/content/v1.6.x-SNAPSHOT/connectors/pipeline/dbtcloud/index.md index a332fe24f4ee..fa40313f8d3c 100644 --- a/openmetadata-docs/content/v1.6.x-SNAPSHOT/connectors/pipeline/dbtcloud/index.md +++ b/openmetadata-docs/content/v1.6.x-SNAPSHOT/connectors/pipeline/dbtcloud/index.md @@ -69,7 +69,11 @@ To know more about permissions required refer [here](https://docs.getdbt.com/doc - **Account Id** : The Account ID of your DBT cloud Project. Go to your dbt cloud account settings to know your Account Id. This will be a numeric value but in openmetadata we parse it as a string. -- **Job Id** : Optional. The Job ID of your DBT cloud Job in your Project to fetch metadata for. Look for the segment after "jobs" in the URL. For instance, in a URL like `https://cloud.getdbt.com/accounts/123/projects/87477/jobs/73659994`, the job ID is `73659994`. This will be a numeric value but in openmetadata we parse it as a string. If not passed all Jobs under the Account id will be ingested. +- **Job Ids** : Optional. Job IDs of your DBT cloud Jobs in your Project to fetch metadata for. Look for the segment after "jobs" in the URL. For instance, in a URL like `https://cloud.getdbt.com/accounts/123/projects/87477/jobs/73659994`, the job ID is `73659994`. This will be a numeric value but in openmetadata we parse it as a string. If not passed all Jobs under the Account id will be ingested. + +- **Project Ids** : Optional. Project IDs of your DBT cloud Account to fetch metadata for. Look for the segment after "projects" in the URL. For instance, in a URL like `https://cloud.getdbt.com/accounts/123/projects/87477/jobs/73659994`, the job ID is `87477`. This will be a numeric value but in openmetadata we parse it as a string. If not passed all Projects under the Account id will be ingested. + +Note that if both `Job Ids` and `Project Ids` are passed then it will filter out the jobs from the passed projects. any `Job Ids` not belonging to the `Project Ids` will also be filtered out. - **Token** : The Authentication Token of your DBT cloud API Account. To get your access token you can follow the docs [here](https://docs.getdbt.com/docs/dbt-cloud-apis/authentication). Make sure you have the necessary permissions on the token to run graphql queries and get job and run details. diff --git a/openmetadata-docs/content/v1.6.x-SNAPSHOT/connectors/pipeline/dbtcloud/yaml.md b/openmetadata-docs/content/v1.6.x-SNAPSHOT/connectors/pipeline/dbtcloud/yaml.md index d4846048abd9..1a9d52f04293 100644 --- a/openmetadata-docs/content/v1.6.x-SNAPSHOT/connectors/pipeline/dbtcloud/yaml.md +++ b/openmetadata-docs/content/v1.6.x-SNAPSHOT/connectors/pipeline/dbtcloud/yaml.md @@ -70,12 +70,20 @@ This is a sample config for dbt Cloud: {% codeInfo srNumber=4 %} -**jobId**: Optional. The Job ID of your DBT cloud Job in your Project to fetch metadata for. Look for the segment after "jobs" in the URL. For instance, in a URL like `https://cloud.getdbt.com/accounts/123/projects/87477/jobs/73659994`, the job ID is `73659994`. This will be a numeric value but in openmetadata we parse it as a string. If not passed all Jobs under the Account id will be ingested. +**jobIds**: Optional. Job IDs of your DBT cloud Jobs in your Project to fetch metadata for. Look for the segment after "jobs" in the URL. For instance, in a URL like `https://cloud.getdbt.com/accounts/123/projects/87477/jobs/73659994`, the job ID is `73659994`. This will be a numeric value but in openmetadata we parse it as a string. If not passed all Jobs under the Account id will be ingested. {% /codeInfo %} {% codeInfo srNumber=5 %} +**projectIds**: Optional. Project IDs of your DBT cloud Account to fetch metadata for. Look for the segment after "projects" in the URL. For instance, in a URL like `https://cloud.getdbt.com/accounts/123/projects/87477/jobs/73659994`, the job ID is `87477`. This will be a numeric value but in openmetadata we parse it as a string. If not passed all Projects under the Account id will be ingested. + +Note that if both `Job Ids` and `Project Ids` are passed then it will filter out the jobs from the passed projects. any `Job Ids` not belonging to the `Project Ids` will also be filtered out. + +{% /codeInfo %} + +{% codeInfo srNumber=6 %} + **token**: The Authentication Token of your DBT cloud API Account. To get your access token you can follow the docs [here](https://docs.getdbt.com/docs/dbt-cloud-apis/authentication). Make sure you have the necessary permissions on the token to run graphql queries and get job and run details. @@ -111,9 +119,12 @@ source: accountId: "numeric_account_id" ``` ```yaml {% srNumber=4 %} - # jobId: "numeric_job_id" + # jobIds: ["job_id_1", "job_id_2", "job_id_3"] ``` ```yaml {% srNumber=5 %} + # projectIds: ["project_id_1", "project_id_2", "project_id_3"] +``` +```yaml {% srNumber=6 %} token: auth_token ``` diff --git a/openmetadata-docs/content/v1.6.x-SNAPSHOT/deployment/bare-metal/index.md b/openmetadata-docs/content/v1.6.x-SNAPSHOT/deployment/bare-metal/index.md index 0299492340f6..f5a81bc9bc94 100644 --- a/openmetadata-docs/content/v1.6.x-SNAPSHOT/deployment/bare-metal/index.md +++ b/openmetadata-docs/content/v1.6.x-SNAPSHOT/deployment/bare-metal/index.md @@ -61,7 +61,7 @@ Please follow the instructions here to [install ElasticSearch](https://www.elast If you are using AWS OpenSearch Service, OpenMetadata Supports AWS OpenSearch Service engine version up to 2.7. For more information on AWS OpenSearch Service, please visit the official docs [here](https://docs.aws.amazon.com/opensearch-service/latest/developerguide/what-is.html). -## Airflow (version 2.0.0 or higher) or other workflow schedulers +## Airflow (version 2.9.1) or other workflow schedulers OpenMetadata performs metadata ingestion using the Ingestion Framework. Learn more about how to deploy and manage the ingestion workflows [here](/deployment/ingestion). diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/exception/CatalogExceptionMessage.java b/openmetadata-service/src/main/java/org/openmetadata/service/exception/CatalogExceptionMessage.java index 6371d754a390..7935488bd8fd 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/exception/CatalogExceptionMessage.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/exception/CatalogExceptionMessage.java @@ -220,6 +220,13 @@ public static String permissionNotAllowed(String user, List o "Principal: CatalogPrincipal{name='%s'} operations %s not allowed", user, operations); } + public static String resourcePermissionNotAllowed( + String user, List operations, List resources) { + return String.format( + "Principal: CatalogPrincipal{name='%s'} operations %s not allowed for resources {%s}.", + user, operations, resources); + } + public static String domainPermissionNotAllowed( String user, String domainName, List operations) { return String.format( diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/WorkflowEventConsumer.java b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/WorkflowEventConsumer.java index 8277275d7014..7cbc974a4d25 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/WorkflowEventConsumer.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/WorkflowEventConsumer.java @@ -1,9 +1,12 @@ package org.openmetadata.service.governance.workflows; +import static org.openmetadata.schema.entity.events.SubscriptionDestination.SubscriptionType.GOVERNANCE_WORKFLOW_CHANGE_EVENT; + import java.util.HashMap; import java.util.List; import java.util.Map; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.tuple.Pair; import org.openmetadata.schema.entity.events.EventSubscription; import org.openmetadata.schema.entity.events.SubscriptionDestination; import org.openmetadata.schema.type.ChangeEvent; @@ -13,6 +16,7 @@ import org.openmetadata.service.Entity; import org.openmetadata.service.apps.bundles.changeEvent.Destination; import org.openmetadata.service.events.errors.EventPublisherException; +import org.openmetadata.service.exception.CatalogExceptionMessage; import org.openmetadata.service.resources.feeds.MessageParser; @Slf4j @@ -46,22 +50,33 @@ public WorkflowEventConsumer( @Override public void sendMessage(ChangeEvent event) throws EventPublisherException { // NOTE: We are only consuming ENTITY related events. - EventType eventType = event.getEventType(); - - if (validEventTypes.contains(eventType)) { - String signal = String.format("%s-%s", event.getEntityType(), eventType.toString()); - - EntityReference entityReference = - Entity.getEntityReferenceById(event.getEntityType(), event.getEntityId(), Include.ALL); - MessageParser.EntityLink entityLink = - new MessageParser.EntityLink( - event.getEntityType(), entityReference.getFullyQualifiedName()); - - Map variables = new HashMap<>(); - - variables.put("relatedEntity", entityLink.getLinkString()); - - WorkflowHandler.getInstance().triggerWithSignal(signal, variables); + try { + EventType eventType = event.getEventType(); + + if (validEventTypes.contains(eventType)) { + String signal = String.format("%s-%s", event.getEntityType(), eventType.toString()); + + EntityReference entityReference = + Entity.getEntityReferenceById(event.getEntityType(), event.getEntityId(), Include.ALL); + MessageParser.EntityLink entityLink = + new MessageParser.EntityLink( + event.getEntityType(), entityReference.getFullyQualifiedName()); + + Map variables = new HashMap<>(); + + variables.put("relatedEntity", entityLink.getLinkString()); + + WorkflowHandler.getInstance().triggerWithSignal(signal, variables); + } + } catch (Exception exc) { + String message = + CatalogExceptionMessage.eventPublisherFailedToPublish( + GOVERNANCE_WORKFLOW_CHANGE_EVENT, event, exc.getMessage()); + LOG.error(message); + throw new EventPublisherException( + CatalogExceptionMessage.eventPublisherFailedToPublish( + GOVERNANCE_WORKFLOW_CHANGE_EVENT, exc.getMessage()), + Pair.of(subscriptionDestination.getId(), event)); } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/triggers/PeriodicBatchEntityTrigger.java b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/triggers/PeriodicBatchEntityTrigger.java index 3a9eb500e804..800a614b3c6e 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/triggers/PeriodicBatchEntityTrigger.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/triggers/PeriodicBatchEntityTrigger.java @@ -163,6 +163,8 @@ private ServiceTask getFetchEntitiesTask( serviceTask.getFieldExtensions().add(searchFilterExpr); serviceTask.getFieldExtensions().add(batchSizeExpr); + serviceTask.setAsynchronousLeave(true); + return serviceTask; } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java index dc64fb56ba47..7515277c1183 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java @@ -1176,7 +1176,7 @@ void deleteLineageBySource( value = "DELETE FROM entity_relationship " + "WHERE json->'pipeline'->>'id' =:toId OR toId = :toId AND relation = :relation " - + "AND json->>'source' = :source ORDER BY toId", + + "AND json->>'source' = :source", connectionType = POSTGRES) void deleteLineageBySourcePipeline( @BindUUID("toId") UUID toId, diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestCaseResultRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestCaseResultRepository.java index 401095c63921..4e247fbab2bc 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestCaseResultRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestCaseResultRepository.java @@ -4,6 +4,7 @@ import static org.openmetadata.service.Entity.TEST_CASE; import static org.openmetadata.service.Entity.TEST_CASE_RESULT; import static org.openmetadata.service.Entity.TEST_DEFINITION; +import static org.openmetadata.service.Entity.TEST_SUITE; import java.io.IOException; import java.util.Arrays; @@ -17,7 +18,9 @@ import lombok.SneakyThrows; import org.openmetadata.common.utils.CommonUtil; import org.openmetadata.schema.api.tests.CreateTestCaseResult; +import org.openmetadata.schema.tests.ResultSummary; import org.openmetadata.schema.tests.TestCase; +import org.openmetadata.schema.tests.TestSuite; import org.openmetadata.schema.tests.type.TestCaseResult; import org.openmetadata.schema.tests.type.TestCaseStatus; import org.openmetadata.schema.type.EntityReference; @@ -222,6 +225,45 @@ private void updateTestCaseStatus(TestCaseResult testCaseResult, OperationType o EntityRepository.EntityUpdater entityUpdater = testCaseRepository.getUpdater(original, updated, EntityRepository.Operation.PATCH); entityUpdater.update(); + updateTestSuiteSummary(updated); + } + + private void updateTestSuiteSummary(TestCase testCase) { + List fqns = + testCase.getTestSuites() != null + ? testCase.getTestSuites().stream().map(TestSuite::getFullyQualifiedName).toList() + : null; + TestSuiteRepository testSuiteRepository = new TestSuiteRepository(); + if (fqns != null) { + for (String fqn : fqns) { + TestSuite testSuite = Entity.getEntityByName(TEST_SUITE, fqn, "*", Include.ALL); + if (testSuite != null) { + TestSuite original = JsonUtils.deepCopy(testSuite, TestSuite.class); + List resultSummaries = testSuite.getTestCaseResultSummary(); + + if (resultSummaries != null) { + resultSummaries.stream() + .filter(s -> s.getTestCaseName().equals(testCase.getFullyQualifiedName())) + .findFirst() + .ifPresent( + s -> { + s.setStatus(testCase.getTestCaseStatus()); + s.setTimestamp(testCase.getTestCaseResult().getTimestamp()); + }); + } else { + testSuite.setTestCaseResultSummary( + List.of( + new ResultSummary() + .withTestCaseName(testCase.getFullyQualifiedName()) + .withStatus(testCase.getTestCaseStatus()) + .withTimestamp(testCase.getTestCaseResult().getTimestamp()))); + } + EntityRepository.EntityUpdater entityUpdater = + testSuiteRepository.getUpdater(original, testSuite, EntityRepository.Operation.PATCH); + entityUpdater.update(); + } + } + } } @Override diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/EntityResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/EntityResource.java index 5606685d22c2..d2044d444205 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/EntityResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/EntityResource.java @@ -19,6 +19,7 @@ import static org.openmetadata.schema.type.EventType.ENTITY_CREATED; import static org.openmetadata.schema.type.MetadataOperation.CREATE; import static org.openmetadata.schema.type.MetadataOperation.VIEW_BASIC; +import static org.openmetadata.service.security.DefaultAuthorizer.getSubjectContext; import static org.openmetadata.service.util.EntityUtil.createOrUpdateOperation; import java.io.IOException; @@ -29,6 +30,7 @@ import java.util.TreeSet; import java.util.UUID; import java.util.concurrent.ExecutorService; +import java.util.stream.Collectors; import javax.json.JsonPatch; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; @@ -42,6 +44,8 @@ import org.openmetadata.schema.type.EntityReference; import org.openmetadata.schema.type.Include; import org.openmetadata.schema.type.MetadataOperation; +import org.openmetadata.schema.type.Permission; +import org.openmetadata.schema.type.ResourcePermission; import org.openmetadata.schema.type.api.BulkOperationResult; import org.openmetadata.schema.type.csv.CsvImportResult; import org.openmetadata.service.Entity; @@ -52,11 +56,13 @@ import org.openmetadata.service.limits.Limits; import org.openmetadata.service.search.SearchListFilter; import org.openmetadata.service.search.SearchSortFilter; +import org.openmetadata.service.security.AuthorizationException; import org.openmetadata.service.security.Authorizer; import org.openmetadata.service.security.policyevaluator.CreateResourceContext; import org.openmetadata.service.security.policyevaluator.OperationContext; import org.openmetadata.service.security.policyevaluator.ResourceContext; import org.openmetadata.service.security.policyevaluator.ResourceContextInterface; +import org.openmetadata.service.security.policyevaluator.SubjectContext; import org.openmetadata.service.util.AsyncService; import org.openmetadata.service.util.BulkAssetsOperationResponse; import org.openmetadata.service.util.CSVExportResponse; @@ -418,9 +424,37 @@ public Response exportCsvInternalAsync(SecurityContext securityContext, String n public Response bulkAddToAssetsAsync( SecurityContext securityContext, UUID entityId, BulkAssetsRequestInterface request) { - OperationContext operationContext = - new OperationContext(entityType, MetadataOperation.EDIT_ALL); - authorizer.authorize(securityContext, operationContext, getResourceContextById(entityId)); + SubjectContext subjectContext = getSubjectContext(securityContext); + String user = subjectContext.user().getName(); + + Set editPermissibleResources = + authorizer.listPermissions(securityContext, user).stream() + .filter( + permission -> + permission.getPermissions().stream() + .anyMatch( + perm -> + MetadataOperation.EDIT_TAGS.equals(perm.getOperation()) + && Permission.Access.ALLOW.equals(perm.getAccess()))) + .map(ResourcePermission::getResource) + .collect(Collectors.toSet()); + + // Validate if all entity types in the request are in the permissible resources + List unauthorizedEntityTypes = + request.getAssets().stream() + .map(EntityReference::getType) + .filter(entityType -> !editPermissibleResources.contains(entityType)) + .distinct() + .toList(); + + if (!unauthorizedEntityTypes.isEmpty() + && !subjectContext.isAdmin() + && !subjectContext.isBot()) { + throw new AuthorizationException( + CatalogExceptionMessage.resourcePermissionNotAllowed( + user, List.of(MetadataOperation.EDIT_TAGS), unauthorizedEntityTypes)); + } + String jobId = UUID.randomUUID().toString(); ExecutorService executorService = AsyncService.getInstance().getExecutorService(); executorService.submit( @@ -443,9 +477,34 @@ public Response bulkAddToAssetsAsync( public Response bulkRemoveFromAssetsAsync( SecurityContext securityContext, UUID entityId, BulkAssetsRequestInterface request) { - OperationContext operationContext = - new OperationContext(entityType, MetadataOperation.EDIT_ALL); - authorizer.authorize(securityContext, operationContext, getResourceContextById(entityId)); + SubjectContext subjectContext = getSubjectContext(securityContext); + String user = subjectContext.user().getName(); + Set editPermissibleResources = + authorizer.listPermissions(securityContext, user).stream() + .filter( + permission -> + permission.getPermissions().stream() + .anyMatch( + perm -> + MetadataOperation.EDIT_TAGS.equals(perm.getOperation()) + && Permission.Access.ALLOW.equals(perm.getAccess()))) + .map(ResourcePermission::getResource) + .collect(Collectors.toSet()); + + List unauthorizedEntityTypes = + request.getAssets().stream() + .map(EntityReference::getType) + .filter(entityType -> !editPermissibleResources.contains(entityType)) + .distinct() + .toList(); + + if (!unauthorizedEntityTypes.isEmpty() + && !subjectContext.isAdmin() + && !subjectContext.isBot()) { + throw new AuthorizationException( + CatalogExceptionMessage.resourcePermissionNotAllowed( + user, List.of(MetadataOperation.EDIT_TAGS), unauthorizedEntityTypes)); + } String jobId = UUID.randomUUID().toString(); ExecutorService executorService = AsyncService.getInstance().getExecutorService(); executorService.submit( diff --git a/openmetadata-service/src/main/resources/json/data/tags/certification.json b/openmetadata-service/src/main/resources/json/data/tags/certification.json index dd02efd168ee..a2ca8c388591 100644 --- a/openmetadata-service/src/main/resources/json/data/tags/certification.json +++ b/openmetadata-service/src/main/resources/json/data/tags/certification.json @@ -11,7 +11,7 @@ "description": "Bronze certified Data Asset.", "style": { "color": "#C08329", - "iconURL": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeCAYAAAA7MK6iAAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAHqADAAQAAAABAAAAHgAAAADKQTcFAAAFZklEQVRIDZ1XTWwbVRAer3+ySZx4E9upEye109A0VSAxtE2qSpS4B1QJiSIqaA+AEokLggNcEFAaJ0WVgBOIKyLJCYpURE9IkcBpDwgFipyo+S1tbRIHO9j1xrHrjf+WN+vsy3qz+Skj2TtvZt58b+bNm7erg/9B/qudHiiAh07VFXlgmKD30kyAyvZgdHvoqdr/yZN9Fnujz8w1eCpr6jkTWw2MwSjpi/kcZFI8ZDPrfDoZm+Ajyze8gzOjdLIGsycwAlqdrSM2Z7vbyFZpuNguygmPIBZeDMaX7w/vtIAdgf1Dbs5c3+JztHneZc3cdu/7kOACwgu/fZF6GB72DgV55RRNYAS1uz1+e0uHR06nctLj8AWyDZH7U4FEeM6rBNernSBonfOov/GJpz06plyNTlIPo7BRrISCqQF03BHIghmAtUvyTGIJ1NlhiI9qi92REzJnL54Qr41N8AJiGtTAmN7GQ90etTyTWge9rRsOnvoQDGytWi2NBT4Mq9PXIRu9DdwBF7XRkyIkW+YR0ms+gOB7qChLtf9KZ/+R3rMj6iJK8mvQ0PMWmB0d1NluDC5g5dbnUMtZysxWQ7OwGlrwei/fmWCUGmvzIZ8WaNPp9zVB+dAkIIiaWM4JOAcXrCSr8zAYjfoRlNFNxGgPHu3tx7TIxEdC4DwzCOhITQh4++vzsPb379D0zAW1WtoOo6UV1kO/gtFUIelxv3NZgXv1uPEmjbjO0XxOHa3JcVwTFL3EFn+WnKUi85pRoxK3BusCi1KmWmsT2d+CjwJXWex9shKfGG1D18tKURkfnbpBx5HpHymvZhq6zkOa/5eKN6veI1W1/+phj0l1Dhizk0abF5KAzvPCOnWQis5RPr4ZvSzArXF0vSQNDWwN6IkvAFEa41Ya2WqudJyKBrcKF6ocnbIf4EN/wL3xT+lYzWC68ackW/sZeux0rJWoYko1lFItMpyyqNAiny9QQ851AjhXDx3vxbhOv01B0ZZhubIpJtLz6R6XaVQDTFf366OADncjTDHaufewy5IeToGVlYfOiwK/DQMdtj3/wTa5LOh85SvNzOjyadlEeuaE9CawPhvAgZJEIa4cUj6+8Avl1Qw2FC3a4JeoWCD3NpIUsffS3UAmlSgLsZAKkypO0gnIYNNQOseerezbWovCOcX0VndLr0lHi6epTieiASVKNWeXGr5SxpMuJZOz5w3ofWccjr15nR47XJR6sSuTo2UXRjz8F4gi3KQt82KvKcQdaKUtE9tbOjILxvp2MJltJTxyFJPhaWh/wQfNBJgxVEgRI18iHTQd22qfGO3a7Pf0qsSmxEeDxFT8rOx2mv7mxQeNbd3ukpPSPzZ6bPha/Vppp+YRVH1DLU7+BLmNdLDv47lWmmqcGA8vDsibLzvCqw0doKP9EjYTNSheiVjAYlEcRj801TgY88eCF3oNdRZby0lMtUwVLAurUz8AH56FSmtbWUHJNviUWuuf30Jy5juorbdTVTx8F1Yf3MGm+aV3cF5qgdveQDKJR0OxpfnnbOR9S9nNOIeLONqA5fGPAPt4BdcCoqFaco5nHo8fngQzKUqTrYmC8tEQRO5N4TgAhcyQrCjbY1noH/JwFWaT3/XUs+Ty2N8rrTxX+URAjJZQQCxkdn/ZQ6uxiYjw2in7tfg/85VkZSfJbQLK6NFmN8JrMDh9C1KJiJReEumA8g0T52pGrHTqv9LRrzeyvlpro7uGXOJ4vrUWgUW5Hl+BJPlJBaqDoCiKA97L8xNKfzK/J7BsiAvQ6ZlzUIQ+xmCQPmFQhz1+q93qeNCJAQI4vBOg7G/fwPIEfEofbcWiG8h1SuV6CDzOR9t/2kYjDNER/+0AAAAASUVORK5CYII=" + "iconURL": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzAiIGhlaWdodD0iMzAiIHZpZXdCb3g9IjAgMCAzMCAzMCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTEzLjI1NzIgMS4yODE3N0w2LjMyOTMyIDQuMTIyMDVDNC45MTY3IDQuNzAwNjIgNCA2LjA3NTY3IDQgNy42MDFWMTYuNzA3OUM0IDI0Ljg5ODEgMTEuMTQ1OCAyNy40ODI5IDEzLjc1MzEgMjguMTUxN0MxNC4zNjkzIDI4LjMwOTQgMTUuMDA3OSAyOC4zMDk0IDE1LjYyNDEgMjguMTUxN0MxOC4yMzE0IDI3LjQ4MjkgMjUuMzc3MiAyNC44OTgxIDI1LjM3NzIgMTYuNzA3OVY3LjYwMUMyNS4zNzcyIDYuMDc1NjcgMjQuNDUzIDQuNzAwNjIgMjMuMDQ3OSA0LjEyMjA1TDE2LjExMjUgMS4yODE3N0MxNS4xOTU4IDAuOTA2MDc2IDE0LjE3MzkgMC45MDYwNzYgMTMuMjU3MiAxLjI4MTc3WiIgZmlsbD0iI0VFRDZCMyIvPgo8cGF0aCBkPSJNMTQuNjg0NyAyNi4zODA2QzE0LjUyNjkgMjYuMzgwNiAxNC4zNjkxIDI2LjM1OCAxNC4yMTg4IDI2LjMyMDVDMTMuMDk5MyAyNi4wMzQ5IDEwLjkzNTMgMjUuMzIxMSA5LjA5NDM0IDIzLjc5NThDNi45NjAzNyAyMi4wMjI1IDUuODcwODUgMTkuNjMzMSA1Ljg3MDg1IDE2LjcwMjZWNy41OTU3MUM1Ljg3MDg1IDYuODI5MjkgNi4zMjkyIDYuMTQ1NTIgNy4wMzU1MSA1Ljg1OTk5TDEzLjk2MzQgMy4wMTk3MkMxNC4xODg4IDIuOTI5NTUgMTQuNDI5MiAyLjg3Njk1IDE0LjY3NzIgMi44NzY5NUMxNC45MjUyIDIuODc2OTUgMTUuMTY1NiAyLjkyMjA0IDE1LjM5MSAzLjAxOTcyTDIyLjMxODkgNS44NTk5OUMyMy4wMjUyIDYuMTUzMDQgMjMuNDgzNiA2LjgyOTI5IDIzLjQ4MzYgNy41OTU3MVYxNi43MDI2QzIzLjQ4MzYgMTkuNjQwNiAyMi40MDE1IDIyLjAyMjUgMjAuMjYwMSAyMy43OTU4QzE4LjQxOTEgMjUuMzIxMSAxNi4yNjI2IDI2LjAzNDkgMTUuMTM1NiAyNi4zMjA1QzE1LjAwMDMgMjYuMzY1NiAxNC44NDI1IDI2LjM4MDYgMTQuNjg0NyAyNi4zODA2WiIgZmlsbD0iI0QwQTI1RiIvPgo8cGF0aCBkPSJNMTQuNjg1NCAyMS43NzI2QzE4LjYyNzcgMjEuNzcyNiAyMS44MjM2IDE4LjU3NjcgMjEuODIzNiAxNC42MzQzQzIxLjgyMzYgMTAuNjkyIDE4LjYyNzcgNy40OTYwOSAxNC42ODU0IDcuNDk2MDlDMTAuNzQzIDcuNDk2MDkgNy41NDcxMiAxMC42OTIgNy41NDcxMiAxNC42MzQzQzcuNTQ3MTIgMTguNTc2NyAxMC43NDMgMjEuNzcyNiAxNC42ODU0IDIxLjc3MjZaIiBmaWxsPSIjRjZFQ0RGIi8+CjxwYXRoIGQ9Ik0xNS41ODcyIDEwLjk5MzlMMTYuNDEzNyAxMi41NzkzTDE4LjE3OTUgMTIuODc5OUMxOC45OTEgMTMuMDE1MiAxOS4zMTQxIDE0LjAwNyAxOC43MzU1IDE0LjYwMDZMMTcuNDg4MiAxNS44NzhMMTcuNzUxMiAxNy42NTEzQzE3Ljg3MTQgMTguNDYyOCAxNy4wMjk4IDE5LjA3ODkgMTYuMjkzNSAxOC43MTA3TDE0LjY4NTUgMTcuOTE0M0wxMy4wODUgMTguNzEwN0MxMi4zNDg2IDE5LjA3ODkgMTEuNTA3MSAxOC40NjI4IDExLjYyNzMgMTcuNjUxM0wxMS44OTAzIDE1Ljg3OEwxMC42NDMgMTQuNjAwNkMxMC4wNjQ0IDE0LjAxNDUgMTAuMzg3NSAxMy4wMjI3IDExLjE5OSAxMi44Nzk5TDEyLjk2NDggMTIuNTc5M0wxMy43OTEzIDEwLjk5MzlDMTQuMTY3IDEwLjI2NTEgMTUuMjAzOSAxMC4yNjUxIDE1LjU4NzIgMTAuOTkzOVoiIGZpbGw9IiNEMEEyNUYiLz4KPC9zdmc+Cg==" } }, { @@ -19,7 +19,7 @@ "description": "Silver certified Data Asset.", "style": { "color": "#ADADAD", - "iconURL": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeCAYAAAA7MK6iAAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAHqADAAQAAAABAAAAHgAAAADKQTcFAAAFFElEQVRIDcVWy0+bRxBfwDQ8hXEjgV1UDIQYzMtCQE0FKpVKQ1FRqnKpciHcOMIBhLgYpAalggP5C0LFKZxCOYUDLxEEBglTQAkHwEYCihSDTXg4DoXO74t3u99nE0gvHcn+Znd29rfz2Jll7H+iqP+C29XVZbu4uLBxXeJ9Op3O3dvb6+Jz131vDNzZ2VljsVgcWVlZNqPJpCdicXFxyv6BQIDt7++zA6/Xt729PbmysjLS19c3+DHwa4EBWFpa+vQru90MsJuQz+dj83Nz7oWFhZ6rDnAlcHd3t15vMDh+qKtrTU9Pvwle2BocYOT584GDg4Me2s8nL4iRB5wHaHFJyUR9ff1PN7WS68pfhCIvL8/u9/nqiouLn01OTga4PMxigObm5k782NAgkocvDrx7xzxuNzs5PmbR0dEsISGBnZ2dsRQKgffNGxYbG8vowHy5+CIH/hgZce3s7HzLLdcJaYiBe7+rrQ0DpaRhpycnjE4ukkqre3h4yFxLS8qh5APA8u/v3bP9PjjoIJ026Klc3d7e/rCpqelxUlKSak/KUmYyGuE2RtdGJZMH8fHxLDMzkyUnJ7O1tTWWlpYmxAAPBoP2L0ymqdnZWXe0kBBTXl7u0MYUoNb8fGYymeSlCr+5uckoccLmU1NTFR3oylRRUYHwPMWcsBjW/tzY+JDfTQj/XF5mBVYrw0ZaAuDAwAADuN1u14oZrNfFxLC/6H6npKQocnjr+O1bPXllSlhMsbuvtfbvi4uIoNgFrgTt7u4yxDYSwUunp6cMScnJQuGKiopyCOCMjIwaLsQX1hYXFclTKn5xcVGMZV5MhpiiwkK2TTeBUyjuNiVTyM22z2/fVpWl8/NzYS2uDDbHteC0t7fHWWE9n0BoysrKlCFc/p724oRQkmf1CjAVeXOapjoZDAa+lm1sbLDR0VEx1jJwN34yFRQUKHHG3PtgUBYpvOJqKgb6uFu3VMKjoyMxzsnJYdnZ2WJ8HVNbWytAsZZiqlJBsokYqySaAdzV0tLCsOHHCC6+yTq/3/8vsJx52JzcH4YB4IaGhrB5PkHFJ6JnPtN4E82DW+zya64E6m4k4tcokgx3OhKh1HLCvQYpwNQzUcBVbUtHwMhmmVA05M2RoXLBWV1dlZcrPHRkI0JXy8ctZm632yVroeZqS54MWlVVxegJxNra2sS1g1x7WHoMqDrWvNMJmClRMqkVekpsNlEyUd68dFrkI4o+J4/HwxobG1l1dbXSMJB44DlVVlZyVqnjSCR+VVGU8CP6TSwCMzw8vOU/OrqUfzMvX156vd7LTyXoQFfe69dHjy47Ojq2gCVcjYHT6WzGo02mIiqbr16/jtiF5HUyT/mi6ECX0/T0NKOXCCMDejAnXI0B+mT2nTup+VarXe67qK9IHBzAQHcV7o1EiO+Sy6W8UKzU1Tg55+fZ+Pg4hk8okR+DCevqnq2t7tmZmW++rqqyydWMvygQI9TxeHr2BENdh6xQMhfZazabVZm+TOvHxsaA5UpMTOwGA1LXsg9zrLW1VW80Gid+efDApg/10pDokz5jL14gfNABqHhvYULlakyA5ubmAoWFhc8WnM54qrP2FHKvfF8/rLr6H5k/NDTENqm5ED0h0Gb+yONaES3mQnzxMqGYOnLv3jXnWSzsS7hSUwKxDkm5Tjmwvr6u8HRgN5Xd5v7+/knItXQtMFfAAYi/T78ash4tVRGhR6P2hgiMC5l7FSBf+A/FoFXdoCYOlQAAAABJRU5ErkJggg==" + "iconURL": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzAiIGhlaWdodD0iMzAiIHZpZXdCb3g9IjAgMCAzMCAzMCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTEzLjI1NzIgMS4yODE3N0w2LjMyOTMyIDQuMTIyMDVDNC45MTY3IDQuNzAwNjIgNCA2LjA3NTY3IDQgNy42MDFWMTYuNzA3OUM0IDI0Ljg5ODEgMTEuMTQ1OCAyNy40ODI5IDEzLjc1MzEgMjguMTUxN0MxNC4zNjkzIDI4LjMwOTQgMTUuMDA3OSAyOC4zMDk0IDE1LjYyNDEgMjguMTUxN0MxOC4yMzE0IDI3LjQ4MjkgMjUuMzc3MiAyNC44OTgxIDI1LjM3NzIgMTYuNzA3OVY3LjYwMUMyNS4zNzcyIDYuMDc1NjcgMjQuNDUzIDQuNzAwNjIgMjMuMDQ3OSA0LjEyMjA1TDE2LjExMjUgMS4yODE3N0MxNS4xOTU4IDAuOTA2MDc2IDE0LjE3MzkgMC45MDYwNzYgMTMuMjU3MiAxLjI4MTc3WiIgZmlsbD0iI0Q5RDlEOSIvPgo8cGF0aCBkPSJNMTQuNjg0NyAyNi4zODA2QzE0LjUyNjkgMjYuMzgwNiAxNC4zNjkxIDI2LjM1OCAxNC4yMTg4IDI2LjMyMDVDMTMuMDk5MyAyNi4wMzQ5IDEwLjkzNTMgMjUuMzIxMSA5LjA5NDM0IDIzLjc5NThDNi45NjAzNyAyMi4wMjI1IDUuODcwODUgMTkuNjMzMSA1Ljg3MDg1IDE2LjcwMjZWNy41OTU3MUM1Ljg3MDg1IDYuODI5MjkgNi4zMjkyIDYuMTQ1NTIgNy4wMzU1MSA1Ljg1OTk5TDEzLjk2MzQgMy4wMTk3MkMxNC4xODg4IDIuOTI5NTUgMTQuNDI5MiAyLjg3Njk1IDE0LjY3NzIgMi44NzY5NUMxNC45MjUyIDIuODc2OTUgMTUuMTY1NiAyLjkyMjA0IDE1LjM5MSAzLjAxOTcyTDIyLjMxODkgNS44NTk5OUMyMy4wMjUyIDYuMTUzMDQgMjMuNDgzNiA2LjgyOTI5IDIzLjQ4MzYgNy41OTU3MVYxNi43MDI2QzIzLjQ4MzYgMTkuNjQwNiAyMi40MDE1IDIyLjAyMjUgMjAuMjYwMSAyMy43OTU4QzE4LjQxOTEgMjUuMzIxMSAxNi4yNjI2IDI2LjAzNDkgMTUuMTM1NiAyNi4zMjA1QzE1LjAwMDMgMjYuMzY1NiAxNC44NDI1IDI2LjM4MDYgMTQuNjg0NyAyNi4zODA2WiIgZmlsbD0iIzk3OTc5NyIvPgo8cGF0aCBkPSJNMTQuNjg1NCAyMS43NzI2QzE4LjYyNzcgMjEuNzcyNiAyMS44MjM2IDE4LjU3NjcgMjEuODIzNiAxNC42MzQzQzIxLjgyMzYgMTAuNjkyIDE4LjYyNzcgNy40OTYwOSAxNC42ODU0IDcuNDk2MDlDMTAuNzQzIDcuNDk2MDkgNy41NDcxMiAxMC42OTIgNy41NDcxMiAxNC42MzQzQzcuNTQ3MTIgMTguNTc2NyAxMC43NDMgMjEuNzcyNiAxNC42ODU0IDIxLjc3MjZaIiBmaWxsPSIjRTlFQ0VGIi8+CjxwYXRoIGQ9Ik0xNS41ODcyIDEwLjk5MzlMMTYuNDEzNyAxMi41NzkzTDE4LjE3OTUgMTIuODc5OUMxOC45OTEgMTMuMDE1MiAxOS4zMTQxIDE0LjAwNyAxOC43MzU1IDE0LjYwMDZMMTcuNDg4MiAxNS44NzhMMTcuNzUxMiAxNy42NTEzQzE3Ljg3MTQgMTguNDYyOCAxNy4wMjk4IDE5LjA3ODkgMTYuMjkzNSAxOC43MTA3TDE0LjY4NTUgMTcuOTE0M0wxMy4wODUgMTguNzEwN0MxMi4zNDg2IDE5LjA3ODkgMTEuNTA3MSAxOC40NjI4IDExLjYyNzMgMTcuNjUxM0wxMS44OTAzIDE1Ljg3OEwxMC42NDMgMTQuNjAwNkMxMC4wNjQ0IDE0LjAxNDUgMTAuMzg3NSAxMy4wMjI3IDExLjE5OSAxMi44Nzk5TDEyLjk2NDggMTIuNTc5M0wxMy43OTEzIDEwLjk5MzlDMTQuMTY3IDEwLjI2NTEgMTUuMjAzOSAxMC4yNjUxIDE1LjU4NzIgMTAuOTkzOVoiIGZpbGw9IiM5Nzk3OTciLz4KPC9zdmc+Cg==" } }, { @@ -27,7 +27,7 @@ "description": "Gold certified Data Asset.", "style": { "color": "#FFCE00", - "iconURL":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeCAYAAAA7MK6iAAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAHqADAAQAAAABAAAAHgAAAADKQTcFAAAETUlEQVRIDZ1WTW8cRRB9i8HGsUUmRrYiPscoJx9gcoEju5w4kQt38C/A/AJ7/4H9C2LuSEmk3D05wiWrHAgHS14MRA4OmzVJ7NjCLO91T+10z469Vkrq6equj9fVVd3TDbwCDW4io5maUZ9Mt7GMjk2M6xvjFExOsCYSrGKWgDPkpiiZKKSn7A/ZXqKPF8jxFHe4iM1CWtuNBXaA87iJBaQOzCz+o79B4VNzrxW85l6y7aPL1j5rAeamsCo7Aia4xAjfxwoj9BSClaoxJ4/aCS3giO03rHM3tAClY0i1wA50AVu4ym19g7raSotuaDqGkWfZyPYRc99DKwQfAXagcwT9kKCSytBI/HO2qSYwzdqa/QQ4YSATB0AvZ57Z5iivkux+J/hBCf56Vcdt73s1oEcJkHwHfLwCTJIfoVUuqgtsrwN/b8QL0Na/Q5+HTB3wvUyjiBntt1hiIU1T8q/EBb1IgaVbdJbZzPm9FtBpATPsQ9rj4LGLOrda9OJ5ruhNsuH2CjTbqgfdy32U3rr8zhY2sg1pnoMJBkYaRlwbbY8an+0wl6l0Y1JUPy76BX11P5bZqNcBfmXk032bAf4k+wStMuIruDES7dsr9aBys3vHO5NzLaKOlJqEPsIdvOwUV0vgGTRd/IPCg6K9xmI6i7Y3S8n2DyVf5eRDJ8FI9cPkuarmNmeMNonO6qVmGa2OjJyrN1KkRru3jfO9UnPtG8/rBEw3yed+rAqfRGLHKeUxii+JuaZX1HfvHvDzSjmuclpEuBDJP7hRHrupjL7zyMq2OhlecyY+TYwDrn7O1izH47hstQSV7mQaW0yWV3ssqI60XV9uMTN0eB5pi53e2nlaTFkIrMprBPon3WBQsNka8On66LzNfHGrfmdOu6bh+wC4g2POhcDHnVjZRtVCsnn1qoU6ehb40h+L5HLMv0aHP/B+BHyUx1UsbZ1X3VZGSoGaUd2iZCNfRv5o9a24dNY60XGapeb2hqn7PgRd4vn8egfQrWU3m+ThkZPVL+34h/HEubpXAvfRdi8HnTOR+r/W4mOim0hNuVSuFa1AtQAVnio/3AFF+2wTQ+qRY35Jt8OsYnAXO3gXaRT58xS4vlVG5ewu8BHo/Rbt2Bs9JHPiHoWLZcQS7mPZPVdMUb0M5UCOLkq6TKqgj2nso23LTQTMIsvxBzaiS11aAv9pkbfX8vkLUH6V0wfX40iVV/2LgQ1ibIpp6BOSe/rovbXA+9vyHSooT+4ez1gHqZfozJ8wysOcgJwK7Z5yvMvGpwHb8N01AiwNBz5F8I8IzuvtlekRLfeddQSqmWirnQo/7jV4jBYectvL3Jh4fK+zqkLyoDqTw0jNuDZiE6p3L5MJPokuI8VbnKhupSnrRvqH7YDN305dcsuubshUaSywGbgFNPhKGaDJHCbDFOiO99UqVd1+uojaZwGavwsDm4F6LiJjl7IlbEYdgimXF6L/Aeq1S9qHbqjuAAAAAElFTkSuQmCC" + "iconURL":"data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzAiIGhlaWdodD0iMzAiIHZpZXdCb3g9IjAgMCAzMCAzMCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTEzLjI1NzIgMS4yODE3N0w2LjMyOTMyIDQuMTIyMDVDNC45MTY3IDQuNzAwNjIgNCA2LjA3NTY3IDQgNy42MDFWMTYuNzA3OUM0IDI0Ljg5ODEgMTEuMTQ1OCAyNy40ODI5IDEzLjc1MzEgMjguMTUxN0MxNC4zNjkzIDI4LjMwOTQgMTUuMDA3OSAyOC4zMDk0IDE1LjYyNDEgMjguMTUxN0MxOC4yMzE0IDI3LjQ4MjkgMjUuMzc3MiAyNC44OTgxIDI1LjM3NzIgMTYuNzA3OVY3LjYwMUMyNS4zNzcyIDYuMDc1NjcgMjQuNDUzIDQuNzAwNjIgMjMuMDQ3OSA0LjEyMjA1TDE2LjExMjUgMS4yODE3N0MxNS4xOTU4IDAuOTA2MDc2IDE0LjE3MzkgMC45MDYwNzYgMTMuMjU3MiAxLjI4MTc3WiIgZmlsbD0iI0ZGREY0MyIvPgo8cGF0aCBkPSJNMTQuNjg0NyAyNi4zODA2QzE0LjUyNjkgMjYuMzgwNiAxNC4zNjkxIDI2LjM1OCAxNC4yMTg4IDI2LjMyMDVDMTMuMDk5MyAyNi4wMzQ5IDEwLjkzNTMgMjUuMzIxMSA5LjA5NDM0IDIzLjc5NThDNi45NjAzNyAyMi4wMjI1IDUuODcwODUgMTkuNjMzMSA1Ljg3MDg1IDE2LjcwMjZWNy41OTU3MUM1Ljg3MDg1IDYuODI5MjkgNi4zMjkyIDYuMTQ1NTIgNy4wMzU1MSA1Ljg1OTk5TDEzLjk2MzQgMy4wMTk3MkMxNC4xODg4IDIuOTI5NTUgMTQuNDI5MiAyLjg3Njk1IDE0LjY3NzIgMi44NzY5NUMxNC45MjUyIDIuODc2OTUgMTUuMTY1NiAyLjkyMjA0IDE1LjM5MSAzLjAxOTcyTDIyLjMxODkgNS44NTk5OUMyMy4wMjUyIDYuMTUzMDQgMjMuNDgzNiA2LjgyOTI5IDIzLjQ4MzYgNy41OTU3MVYxNi43MDI2QzIzLjQ4MzYgMTkuNjQwNiAyMi40MDE1IDIyLjAyMjUgMjAuMjYwMSAyMy43OTU4QzE4LjQxOTEgMjUuMzIxMSAxNi4yNjI2IDI2LjAzNDkgMTUuMTM1NiAyNi4zMjA1QzE1LjAwMDMgMjYuMzY1NiAxNC44NDI1IDI2LjM4MDYgMTQuNjg0NyAyNi4zODA2WiIgZmlsbD0iI0ZGOUYwMCIvPgo8cGF0aCBkPSJNMTQuNjg1NCAyMS43NzI2QzE4LjYyNzcgMjEuNzcyNiAyMS44MjM2IDE4LjU3NjcgMjEuODIzNiAxNC42MzQzQzIxLjgyMzYgMTAuNjkyIDE4LjYyNzcgNy40OTYwOSAxNC42ODU0IDcuNDk2MDlDMTAuNzQzIDcuNDk2MDkgNy41NDcxMiAxMC42OTIgNy41NDcxMiAxNC42MzQzQzcuNTQ3MTIgMTguNTc2NyAxMC43NDMgMjEuNzcyNiAxNC42ODU0IDIxLjc3MjZaIiBmaWxsPSIjRkZGMEM1Ii8+CjxwYXRoIGQ9Ik0xNS41ODcyIDEwLjk5MzlMMTYuNDEzNyAxMi41NzkzTDE4LjE3OTUgMTIuODc5OUMxOC45OTEgMTMuMDE1MiAxOS4zMTQxIDE0LjAwNyAxOC43MzU1IDE0LjYwMDZMMTcuNDg4MiAxNS44NzhMMTcuNzUxMiAxNy42NTEzQzE3Ljg3MTQgMTguNDYyOCAxNy4wMjk4IDE5LjA3ODkgMTYuMjkzNSAxOC43MTA3TDE0LjY4NTUgMTcuOTE0M0wxMy4wODUgMTguNzEwN0MxMi4zNDg2IDE5LjA3ODkgMTEuNTA3MSAxOC40NjI4IDExLjYyNzMgMTcuNjUxM0wxMS44OTAzIDE1Ljg3OEwxMC42NDMgMTQuNjAwNkMxMC4wNjQ0IDE0LjAxNDUgMTAuMzg3NSAxMy4wMjI3IDExLjE5OSAxMi44Nzk5TDEyLjk2NDggMTIuNTc5M0wxMy43OTEzIDEwLjk5MzlDMTQuMTY3IDEwLjI2NTEgMTUuMjAzOSAxMC4yNjUxIDE1LjU4NzIgMTAuOTkzOVoiIGZpbGw9IiNGRjlGMDAiLz4KPC9zdmc+Cg==" } } ] diff --git a/openmetadata-service/src/main/resources/json/data/testConnections/pipeline/airflow.json b/openmetadata-service/src/main/resources/json/data/testConnections/pipeline/airflow.json index 27fb8ecba9ba..2a8cf423d40f 100644 --- a/openmetadata-service/src/main/resources/json/data/testConnections/pipeline/airflow.json +++ b/openmetadata-service/src/main/resources/json/data/testConnections/pipeline/airflow.json @@ -9,6 +9,19 @@ "errorMessage": "Failed to connect to airflow, please validate the credentials", "shortCircuit": true, "mandatory": true + }, + { + "name": "PipelineDetailsAccess", + "description": "Check if pipeline details can be fetched.", + "errorMessage": "Failed to fetch Pipeine details.", + "mandatory": true + }, + { + "name": "TaskDetailAccess", + "description": "Check if task details can be fetched.", + "errorMessage": "Failed to fetch Task details.", + "mandatory": true } + ] } \ No newline at end of file diff --git a/openmetadata-spec/src/main/resources/json/schema/entity/services/connections/pipeline/dbtCloudConnection.json b/openmetadata-spec/src/main/resources/json/schema/entity/services/connections/pipeline/dbtCloudConnection.json index 13b41b6b4835..e9979dfe5c9a 100644 --- a/openmetadata-spec/src/main/resources/json/schema/entity/services/connections/pipeline/dbtCloudConnection.json +++ b/openmetadata-spec/src/main/resources/json/schema/entity/services/connections/pipeline/dbtCloudConnection.json @@ -39,11 +39,21 @@ "description": "ID of your DBT cloud account", "type": "string" }, - "jobId": { - "title": "Job Id", - "description": "ID of your DBT cloud job", - "type": "string", - "default": null + "jobIds": { + "title": "Job Ids", + "description": "List of IDs of your DBT cloud jobs seperated by comma `,`", + "type": "array", + "items": { + "type": "string" + } + }, + "projectIds": { + "title": "Project Ids", + "description": "List of IDs of your DBT cloud projects seperated by comma `,`", + "type": "array", + "items": { + "type": "string" + } }, "token": { "title": "Token", @@ -54,5 +64,4 @@ }, "additionalProperties": false, "required": ["host", "discoveryAPI", "accountId", "token"] - } - \ No newline at end of file + } \ No newline at end of file diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Glossary.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Glossary.spec.ts index f5c5fe15000f..09ba8d8caac0 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Glossary.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Glossary.spec.ts @@ -61,11 +61,13 @@ import { selectActiveGlossaryTerm, selectColumns, toggleAllColumnsSelection, + updateGlossaryTermDataFromTree, validateGlossaryTerm, verifyAllColumns, verifyColumnsVisibility, verifyGlossaryDetails, verifyGlossaryTermAssets, + verifyTaskCreated, } from '../../utils/glossary'; import { sidebarClick } from '../../utils/sidebar'; import { TaskDetails } from '../../utils/task'; @@ -74,131 +76,133 @@ import { performUserLogin } from '../../utils/user'; const user1 = new UserClass(); const user2 = new UserClass(); const team = new TeamClass(); +const user3 = new UserClass(); test.describe('Glossary tests', () => { test.beforeAll(async ({ browser }) => { const { afterAction, apiContext } = await performAdminLogin(browser); await user2.create(apiContext); await user1.create(apiContext); + await user3.create(apiContext); team.data.users = [user2.responseData.id]; await team.create(apiContext); await afterAction(); }); - test.fixme( - 'Glossary & terms creation for reviewer as user', - async ({ browser }) => { - test.slow(true); + test('Glossary & terms creation for reviewer as user', async ({ + browser, + }) => { + test.slow(true); - const { page, afterAction, apiContext } = await performAdminLogin( - browser - ); - const { page: page1, afterAction: afterActionUser1 } = - await performUserLogin(browser, user1); - const glossary1 = new Glossary(); - glossary1.data.owners = [{ name: 'admin', type: 'user' }]; - glossary1.data.mutuallyExclusive = true; - glossary1.data.reviewers = [ - { name: `${user1.data.firstName}${user1.data.lastName}`, type: 'user' }, - ]; - glossary1.data.terms = [new GlossaryTerm(glossary1)]; - - await test.step('Create Glossary', async () => { - await sidebarClick(page, SidebarItem.GLOSSARY); - await createGlossary(page, glossary1.data, false); - await verifyGlossaryDetails(page, glossary1.data); - }); + const { page, afterAction, apiContext } = await performAdminLogin(browser); + const { page: page1, afterAction: afterActionUser1 } = + await performUserLogin(browser, user3); + const glossary1 = new Glossary(); + glossary1.data.owners = [{ name: 'admin', type: 'user' }]; + glossary1.data.mutuallyExclusive = true; + glossary1.data.reviewers = [ + { name: `${user3.data.firstName}${user3.data.lastName}`, type: 'user' }, + ]; + glossary1.data.terms = [new GlossaryTerm(glossary1)]; - await test.step('Create Glossary Terms', async () => { - await sidebarClick(page, SidebarItem.GLOSSARY); - await createGlossaryTerms(page, glossary1.data); - }); + await test.step('Create Glossary', async () => { + await sidebarClick(page, SidebarItem.GLOSSARY); + await createGlossary(page, glossary1.data, false); + await verifyGlossaryDetails(page, glossary1.data); + }); - await test.step( - 'Approve Glossary Term from Glossary Listing for reviewer user', - async () => { - await redirectToHomePage(page1); - // wait for 15 seconds as the flowable which creates task is triggered every 10 seconds - await page1.waitForTimeout(15000); - await sidebarClick(page1, SidebarItem.GLOSSARY); - await selectActiveGlossary(page1, glossary1.data.name); - - await approveGlossaryTermTask(page1, glossary1.data.terms[0].data); - await redirectToHomePage(page1); - await sidebarClick(page1, SidebarItem.GLOSSARY); - await selectActiveGlossary(page1, glossary1.data.name); - await validateGlossaryTerm( - page1, - glossary1.data.terms[0].data, - 'Approved' - ); + await test.step('Create Glossary Terms', async () => { + await sidebarClick(page, SidebarItem.GLOSSARY); + await createGlossaryTerms(page, glossary1.data); + }); + + await test.step( + 'Approve Glossary Term from Glossary Listing for reviewer user', + async () => { + await redirectToHomePage(page1); + await sidebarClick(page1, SidebarItem.GLOSSARY); + await selectActiveGlossary(page1, glossary1.data.name); + await verifyTaskCreated( + page1, + glossary1.data.fullyQualifiedName, + glossary1.data.terms[0].data + ); - await afterActionUser1(); - } - ); + await approveGlossaryTermTask(page1, glossary1.data.terms[0].data); + await redirectToHomePage(page1); + await sidebarClick(page1, SidebarItem.GLOSSARY); + await selectActiveGlossary(page1, glossary1.data.name); + await validateGlossaryTerm( + page1, + glossary1.data.terms[0].data, + 'Approved' + ); - await glossary1.delete(apiContext); - await afterAction(); - } - ); + await afterActionUser1(); + } + ); - test.fixme( - 'Glossary & terms creation for reviewer as team', - async ({ browser }) => { - test.slow(true); + await glossary1.delete(apiContext); + await afterAction(); + }); - const { page, afterAction, apiContext } = await performAdminLogin( - browser - ); - const { page: page1, afterAction: afterActionUser1 } = - await performUserLogin(browser, user2); + test('Glossary & terms creation for reviewer as team', async ({ + browser, + }) => { + test.slow(true); - const glossary2 = new Glossary(); - glossary2.data.owners = [{ name: 'admin', type: 'user' }]; - glossary2.data.reviewers = [ - { name: team.data.displayName, type: 'team' }, - ]; - glossary2.data.terms = [new GlossaryTerm(glossary2)]; + const { page, afterAction, apiContext } = await performAdminLogin(browser); + const { page: page1, afterAction: afterActionUser1 } = + await performUserLogin(browser, user2); - await test.step('Create Glossary', async () => { - await sidebarClick(page, SidebarItem.GLOSSARY); - await createGlossary(page, glossary2.data, false); - await verifyGlossaryDetails(page, glossary2.data); - }); + const glossary2 = new Glossary(); + glossary2.data.owners = [{ name: 'admin', type: 'user' }]; + glossary2.data.reviewers = [{ name: team.data.displayName, type: 'team' }]; + glossary2.data.terms = [new GlossaryTerm(glossary2)]; - await test.step('Create Glossary Terms', async () => { - await redirectToHomePage(page); - await sidebarClick(page, SidebarItem.GLOSSARY); - await createGlossaryTerms(page, glossary2.data); - }); + await test.step('Create Glossary', async () => { + await sidebarClick(page, SidebarItem.GLOSSARY); + await createGlossary(page, glossary2.data, false); + await verifyGlossaryDetails(page, glossary2.data); + }); - await test.step( - 'Approve Glossary Term from Glossary Listing for reviewer team', - async () => { - await redirectToHomePage(page1); - // wait for 15 seconds as the flowable which creates task is triggered every 10 seconds - await page1.waitForTimeout(15000); - await sidebarClick(page1, SidebarItem.GLOSSARY); - await selectActiveGlossary(page1, glossary2.data.name); - await approveGlossaryTermTask(page1, glossary2.data.terms[0].data); - - await redirectToHomePage(page1); - await sidebarClick(page1, SidebarItem.GLOSSARY); - await selectActiveGlossary(page1, glossary2.data.name); - await validateGlossaryTerm( - page1, - glossary2.data.terms[0].data, - 'Approved' - ); + await test.step('Create Glossary Terms', async () => { + await redirectToHomePage(page); + await sidebarClick(page, SidebarItem.GLOSSARY); + await createGlossaryTerms(page, glossary2.data); + }); + + await test.step( + 'Approve Glossary Term from Glossary Listing for reviewer team', + async () => { + await redirectToHomePage(page1); + await sidebarClick(page1, SidebarItem.GLOSSARY); + await selectActiveGlossary(page1, glossary2.data.name); + + await verifyTaskCreated( + page1, + glossary2.data.fullyQualifiedName, + glossary2.data.terms[0].data + ); - await afterActionUser1(); - } - ); + await approveGlossaryTermTask(page1, glossary2.data.terms[0].data); - await glossary2.delete(apiContext); - await afterAction(); - } - ); + await redirectToHomePage(page1); + await sidebarClick(page1, SidebarItem.GLOSSARY); + await selectActiveGlossary(page1, glossary2.data.name); + await validateGlossaryTerm( + page1, + glossary2.data.terms[0].data, + 'Approved' + ); + + await afterActionUser1(); + } + ); + + await glossary2.delete(apiContext); + await afterAction(); + }); test('Update Glossary and Glossary Term', async ({ browser }) => { test.slow(true); @@ -789,9 +793,8 @@ test.describe('Glossary tests', () => { '[data-testid="viewer-container"]' ); - await expect(viewerContainerText).toContain('Updated description'); + expect(viewerContainerText).toContain('Updated description'); } finally { - await user1.delete(apiContext); await glossary1.delete(apiContext); await afterAction(); } @@ -836,9 +839,8 @@ test.describe('Glossary tests', () => { '[data-testid="viewer-container"]' ); - await expect(viewerContainerText).toContain('Updated description'); + expect(viewerContainerText).toContain('Updated description'); } finally { - await user1.delete(apiContext); await glossaryTerm1.delete(apiContext); await glossary1.delete(apiContext); await afterAction(); @@ -870,6 +872,7 @@ test.describe('Glossary tests', () => { } finally { await glossary1.delete(apiContext); await afterAction(); + await afterActionUser1(); } }); @@ -1063,10 +1066,60 @@ test.describe('Glossary tests', () => { } }); + test('Glossary Term Update in Glossary Page should persist tree', async ({ + browser, + }) => { + const { page, afterAction, apiContext } = await performAdminLogin(browser); + const glossary1 = new Glossary(); + const glossaryTerm1 = new GlossaryTerm(glossary1); + await glossary1.create(apiContext); + await glossaryTerm1.create(apiContext); + const glossaryTerm2 = new GlossaryTerm( + glossary1, + glossaryTerm1.responseData.fullyQualifiedName + ); + await glossaryTerm2.create(apiContext); + const glossaryTerm3 = new GlossaryTerm( + glossary1, + glossaryTerm2.responseData.fullyQualifiedName + ); + await glossaryTerm3.create(apiContext); + + try { + await sidebarClick(page, SidebarItem.GLOSSARY); + await selectActiveGlossary(page, glossary1.data.displayName); + await page.getByTestId('expand-collapse-all-button').click(); + + await expect( + page.getByRole('cell', { name: glossaryTerm1.data.displayName }) + ).toBeVisible(); + + await expect( + page.getByRole('cell', { name: glossaryTerm2.data.displayName }) + ).toBeVisible(); + + await expect( + page.getByRole('cell', { name: glossaryTerm3.data.displayName }) + ).toBeVisible(); + + await updateGlossaryTermDataFromTree( + page, + glossaryTerm2.responseData.fullyQualifiedName + ); + } finally { + await glossaryTerm3.delete(apiContext); + await glossaryTerm2.delete(apiContext); + await glossaryTerm1.delete(apiContext); + await glossary1.delete(apiContext); + await afterAction(); + } + }); + test.afterAll(async ({ browser }) => { const { afterAction, apiContext } = await performAdminLogin(browser); await user1.delete(apiContext); await user2.delete(apiContext); + await user3.create(apiContext); await team.delete(apiContext); await afterAction(); }); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Tag.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Tag.spec.ts index 90c8189fff5c..8ef44d871d39 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Tag.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Tag.spec.ts @@ -11,28 +11,29 @@ * limitations under the License. */ import { expect, Page, test as base } from '@playwright/test'; -import { DATA_STEWARD_RULES } from '../../constant/permission'; import { PolicyClass } from '../../support/access-control/PoliciesClass'; import { RolesClass } from '../../support/access-control/RolesClass'; import { ClassificationClass } from '../../support/tag/ClassificationClass'; import { TagClass } from '../../support/tag/TagClass'; +import { TeamClass } from '../../support/team/TeamClass'; import { UserClass } from '../../support/user/UserClass'; import { performAdminLogin } from '../../utils/admin'; -import { redirectToHomePage } from '../../utils/common'; +import { getApiContext, redirectToHomePage, uuid } from '../../utils/common'; import { addAssetsToTag, - checkAssetsCount, editTagPageDescription, + LIMITED_USER_RULES, removeAssetsFromTag, setupAssetsForTag, + verifyCertificationTagPageUI, verifyTagPageUI, } from '../../utils/tag'; const adminUser = new UserClass(); const dataConsumerUser = new UserClass(); const dataStewardUser = new UserClass(); -const policy = new PolicyClass(); -const role = new RolesClass(); +const limitedAccessUser = new UserClass(); + const classification = new ClassificationClass({ provider: 'system', mutuallyExclusive: true, @@ -45,6 +46,7 @@ const test = base.extend<{ adminPage: Page; dataConsumerPage: Page; dataStewardPage: Page; + limitedAccessPage: Page; }>({ adminPage: async ({ browser }, use) => { const adminPage = await browser.newPage(); @@ -64,6 +66,12 @@ const test = base.extend<{ await use(page); await page.close(); }, + limitedAccessPage: async ({ browser }, use) => { + const page = await browser.newPage(); + await limitedAccessUser.login(page); + await use(page); + await page.close(); + }, }); base.beforeAll('Setup pre-requests', async ({ browser }) => { @@ -73,8 +81,7 @@ base.beforeAll('Setup pre-requests', async ({ browser }) => { await dataConsumerUser.create(apiContext); await dataStewardUser.create(apiContext); await dataStewardUser.setDataStewardRole(apiContext); - await policy.create(apiContext, DATA_STEWARD_RULES); - await role.create(apiContext, [policy.responseData.name]); + await limitedAccessUser.create(apiContext); await classification.create(apiContext); await tag.create(apiContext); await afterAction(); @@ -85,8 +92,7 @@ base.afterAll('Cleanup', async ({ browser }) => { await adminUser.delete(apiContext); await dataConsumerUser.delete(apiContext); await dataStewardUser.delete(apiContext); - await policy.delete(apiContext); - await role.delete(apiContext); + await limitedAccessUser.delete(apiContext); await classification.delete(apiContext); await tag.delete(apiContext); await afterAction(); @@ -99,6 +105,12 @@ test.describe('Tag Page with Admin Roles', () => { await verifyTagPageUI(adminPage, classification.data.name, tag); }); + test('Certification Page should not have Asset button', async ({ + adminPage, + }) => { + await verifyCertificationTagPageUI(adminPage); + }); + test('Rename Tag name', async ({ adminPage }) => { await redirectToHomePage(adminPage); const res = adminPage.waitForResponse(`/api/v1/tags/name/*`); @@ -202,22 +214,15 @@ test.describe('Tag Page with Admin Roles', () => { test('Add and Remove Assets', async ({ adminPage }) => { await redirectToHomePage(adminPage); - const { assets } = await setupAssetsForTag(adminPage); + const { assets, assetCleanup } = await setupAssetsForTag(adminPage); - await test.step('Add Asset', async () => { - const res = adminPage.waitForResponse(`/api/v1/tags/name/*`); - await tag.visitPage(adminPage); - await res; - await addAssetsToTag(adminPage, assets); + await test.step('Add Asset ', async () => { + await addAssetsToTag(adminPage, assets, tag); }); await test.step('Delete Asset', async () => { - const res = adminPage.waitForResponse(`/api/v1/tags/name/*`); - await tag.visitPage(adminPage); - await res; - - await removeAssetsFromTag(adminPage, assets); - await checkAssetsCount(adminPage, 0); + await removeAssetsFromTag(adminPage, assets, tag); + await assetCleanup(); }); }); }); @@ -234,11 +239,34 @@ test.describe('Tag Page with Data Consumer Roles', () => { ); }); - test('Edit Tag Description or Data Consumer', async ({ + test('Certification Page should not have Asset button for Data Consumer', async ({ + dataConsumerPage, + }) => { + await verifyCertificationTagPageUI(dataConsumerPage); + }); + + test('Edit Tag Description for Data Consumer', async ({ dataConsumerPage, }) => { await editTagPageDescription(dataConsumerPage, tag); }); + + test('Add and Remove Assets for Data Consumer', async ({ + adminPage, + dataConsumerPage, + }) => { + const { assets, assetCleanup } = await setupAssetsForTag(adminPage); + await redirectToHomePage(dataConsumerPage); + + await test.step('Add Asset ', async () => { + await addAssetsToTag(dataConsumerPage, assets, tag); + }); + + await test.step('Delete Asset', async () => { + await removeAssetsFromTag(dataConsumerPage, assets, tag); + await assetCleanup(); + }); + }); }); test.describe('Tag Page with Data Steward Roles', () => { @@ -248,7 +276,82 @@ test.describe('Tag Page with Data Steward Roles', () => { await verifyTagPageUI(dataStewardPage, classification.data.name, tag, true); }); + test('Certification Page should not have Asset button for Data Steward', async ({ + dataStewardPage, + }) => { + await verifyCertificationTagPageUI(dataStewardPage); + }); + test('Edit Tag Description for Data Steward', async ({ dataStewardPage }) => { await editTagPageDescription(dataStewardPage, tag); }); + + test('Add and Remove Assets for Data Steward', async ({ + adminPage, + dataStewardPage, + }) => { + const { assets, assetCleanup } = await setupAssetsForTag(adminPage); + await redirectToHomePage(dataStewardPage); + + await test.step('Add Asset ', async () => { + await addAssetsToTag(dataStewardPage, assets, tag); + }); + + await test.step('Delete Asset', async () => { + await removeAssetsFromTag(dataStewardPage, assets, tag); + await assetCleanup(); + }); + }); +}); + +test.describe('Tag Page with Limited EditTag Permission', () => { + test.slow(true); + + test('Add and Remove Assets and Check Restricted Entity', async ({ + adminPage, + limitedAccessPage, + }) => { + const { apiContext, afterAction } = await getApiContext(adminPage); + const { assets, otherAsset, assetCleanup } = await setupAssetsForTag( + adminPage + ); + const id = uuid(); + const policy = new PolicyClass(); + const role = new RolesClass(); + let limitedAccessTeam: TeamClass | null = null; + + try { + await policy.create(apiContext, LIMITED_USER_RULES); + await role.create(apiContext, [policy.responseData.name]); + + limitedAccessTeam = new TeamClass({ + name: `PW%limited_user_access_team-${id}`, + displayName: `PW Limited User Access Team ${id}`, + description: 'playwright data steward team description', + teamType: 'Group', + users: [limitedAccessUser.responseData.id], + defaultRoles: role.responseData.id ? [role.responseData.id] : [], + }); + await limitedAccessTeam.create(apiContext); + + await redirectToHomePage(limitedAccessPage); + + await test.step('Add Asset ', async () => { + await addAssetsToTag(limitedAccessPage, assets, tag, otherAsset); + }); + + await test.step('Delete Asset', async () => { + await removeAssetsFromTag(limitedAccessPage, assets, tag); + }); + } finally { + await tag.delete(apiContext); + await policy.delete(apiContext); + await role.delete(apiContext); + if (limitedAccessTeam) { + await limitedAccessTeam.delete(apiContext); + } + await assetCleanup(); + await afterAction(); + } + }); }); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts index 22cba32b692d..5505efe77efa 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts @@ -37,6 +37,12 @@ import { addMultiOwner } from './entity'; import { sidebarClick } from './sidebar'; import { TaskDetails, TASK_OPEN_FETCH_LINK } from './task'; +type TaskEntity = { + entityRef: { + name: string; + }; +}; + const GLOSSARY_NAME_VALIDATION_ERROR = 'Name size must be between 1 and 128'; export const descriptionBox = @@ -469,8 +475,47 @@ export const fillGlossaryTermDetails = async ( } }; -const validateGlossaryTermTask = async (page: Page, term: GlossaryTermData) => { +export const verifyTaskCreated = async ( + page: Page, + glossaryFqn: string, + glossaryTermData: GlossaryTermData +) => { + const { apiContext } = await getApiContext(page); + const entityLink = encodeURIComponent(`<#E::glossary::${glossaryFqn}>`); + + await expect + .poll( + async () => { + const response = await apiContext + .get( + `/api/v1/feed?entityLink=${entityLink}&type=Task&taskStatus=Open` + ) + .then((res) => res.json()); + + const arr = response.data.map( + (item: TaskEntity) => item.entityRef.name + ); + + return arr; + }, + { + // Custom expect message for reporting, optional. + message: 'To get the last run execution status as success', + timeout: 200_000, + intervals: [30_000], + } + ) + .toContain(glossaryTermData.name); +}; + +export const validateGlossaryTermTask = async ( + page: Page, + term: GlossaryTermData +) => { + const taskCountRes = page.waitForResponse('/api/v1/feed/count?*'); await page.click('[data-testid="activity_feed"]'); + await taskCountRes; + const taskFeeds = page.waitForResponse(TASK_OPEN_FETCH_LINK); await page .getByTestId('global-setting-left-panel') @@ -504,6 +549,37 @@ export const approveGlossaryTermTask = async ( await toastNotification(page, /Task resolved successfully/); }; +// Show the glossary term edit modal from glossary page tree. +// Update the description and verify the changes. +export const updateGlossaryTermDataFromTree = async ( + page: Page, + termFqn: string +) => { + // eslint-disable-next-line no-useless-escape + const escapedFqn = termFqn.replace(/\"/g, '\\"'); + const termRow = page.locator(`[data-row-key="${escapedFqn}"]`); + await termRow.getByTestId('edit-button').click(); + + await page.waitForSelector('[role="dialog"].edit-glossary-modal'); + + await expect( + page.locator('[role="dialog"].edit-glossary-modal') + ).toBeVisible(); + await expect(page.locator('.ant-modal-title')).toContainText( + 'Edit Glossary Term' + ); + + await page.locator(descriptionBox).fill('Updated description'); + + const glossaryTermResponse = page.waitForResponse('/api/v1/glossaryTerms/*'); + await page.getByTestId('save-glossary-term').click(); + await glossaryTermResponse; + + await expect( + termRow.getByRole('cell', { name: 'Updated description' }) + ).toBeVisible(); +}; + export const validateGlossaryTerm = async ( page: Page, term: GlossaryTermData, @@ -516,13 +592,6 @@ export const validateGlossaryTerm = async ( await expect(page.locator(termSelector)).toContainText(term.name); await expect(page.locator(statusSelector)).toContainText(status); - - if (status === 'Draft') { - // wait for 15 seconds as the flowable which creates task is triggered every 10 seconds - await page.waitForTimeout(15000); - await validateGlossaryTermTask(page, term); - await page.click('[data-testid="terms"]'); - } }; export const createGlossaryTerm = async ( diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/tag.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/tag.ts index 536542f315ac..6619cbc2003c 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/tag.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/tag.ts @@ -11,10 +11,13 @@ * limitations under the License. */ import { expect, Page } from '@playwright/test'; -import { get } from 'lodash'; +import { get, isUndefined } from 'lodash'; import { SidebarItem } from '../constant/sidebar'; +import { PolicyRulesType } from '../support/access-control/PoliciesClass'; import { DashboardClass } from '../support/entity/DashboardClass'; import { EntityClass } from '../support/entity/EntityClass'; +import { MlModelClass } from '../support/entity/MlModelClass'; +import { PipelineClass } from '../support/entity/PipelineClass'; import { TableClass } from '../support/entity/TableClass'; import { TopicClass } from '../support/entity/TopicClass'; import { TagClass } from '../support/tag/TagClass'; @@ -51,20 +54,54 @@ export const visitClassificationPage = async ( await expect(page.locator('.activeCategory')).toContainText( classificationName ); + + await page.waitForSelector('[data-testid="loader"]', { state: 'detached' }); }; -export const addAssetsToTag = async (page: Page, assets: EntityClass[]) => { +// Other asset type that should not get from the search in explore, they are not added to the tag +export const addAssetsToTag = async ( + page: Page, + assets: EntityClass[], + tag: TagClass, + otherAsset?: EntityClass[] +) => { + const res = page.waitForResponse(`/api/v1/tags/name/*`); + await tag.visitPage(page); + await res; + await page.getByTestId('assets').click(); + const initialFetchResponse = page.waitForResponse( + '/api/v1/search/query?q=&index=all&from=0&size=25&deleted=false**' + ); await page.getByTestId('data-classification-add-button').click(); + await initialFetchResponse; + await expect(page.getByRole('dialog')).toBeVisible(); + if (!isUndefined(otherAsset)) { + for (const asset of otherAsset) { + const name = get(asset, 'entityResponseData.name'); + + const searchRes = page.waitForResponse( + `/api/v1/search/query?q=${name}&index=all&from=0&size=25&**` + ); + await page + .getByTestId('asset-selection-modal') + .getByTestId('searchbar') + .fill(name); + await searchRes; + + await expect(page.getByText(name)).not.toBeVisible(); + } + } + for (const asset of assets) { const name = get(asset, 'entityResponseData.name'); const fqn = get(asset, 'entityResponseData.fullyQualifiedName'); const searchRes = page.waitForResponse( - `/api/v1/search/query?q=${name}&index=all&from=0&size=25&*` + `/api/v1/search/query?q=${name}&index=all&from=0&size=25&**` ); await page .getByTestId('asset-selection-modal') @@ -82,8 +119,13 @@ export const addAssetsToTag = async (page: Page, assets: EntityClass[]) => { export const removeAssetsFromTag = async ( page: Page, - assets: EntityClass[] + assets: EntityClass[], + tag: TagClass ) => { + const res = page.waitForResponse(`/api/v1/tags/name/*`); + await tag.visitPage(page); + await res; + await page.getByTestId('assets').click(); for (const asset of assets) { const fqn = get(asset, 'entityResponseData.fullyQualifiedName'); @@ -94,6 +136,8 @@ export const removeAssetsFromTag = async ( await page.getByTestId('delete-all-button').click(); await assetsRemoveRes; + + await checkAssetsCount(page, 0); }; export const checkAssetsCount = async (page: Page, count: number) => { @@ -107,10 +151,14 @@ export const setupAssetsForTag = async (page: Page) => { const table = new TableClass(); const topic = new TopicClass(); const dashboard = new DashboardClass(); + const mlModel = new MlModelClass(); + const pipeline = new PipelineClass(); await Promise.all([ table.create(apiContext), topic.create(apiContext), dashboard.create(apiContext), + mlModel.create(apiContext), + pipeline.create(apiContext), ]); const assetCleanup = async () => { @@ -118,12 +166,15 @@ export const setupAssetsForTag = async (page: Page) => { table.delete(apiContext), topic.delete(apiContext), dashboard.delete(apiContext), + mlModel.delete(apiContext), + pipeline.delete(apiContext), ]); await afterAction(); }; return { assets: [table, topic, dashboard], + otherAsset: [mlModel, pipeline], assetCleanup, }; }; @@ -246,17 +297,13 @@ export const verifyTagPageUI = async ( ); await expect(page.getByText(tag.data.description)).toBeVisible(); + await expect( + page.getByTestId('data-classification-add-button') + ).toBeVisible(); + if (limitedAccess) { - await expect( - page.getByTestId('data-classification-add-button') - ).not.toBeVisible(); await expect(page.getByTestId('manage-button')).not.toBeVisible(); await expect(page.getByTestId('add-domain')).not.toBeVisible(); - - // Asset tab should show no data placeholder and not add asset button - await page.getByTestId('assets').click(); - - await expect(page.getByTestId('no-data-placeholder')).toBeVisible(); } const classificationTable = page.waitForResponse( @@ -295,3 +342,88 @@ export const editTagPageDescription = async (page: Page, tag: TagClass) => { `This is updated test description for tag ${tag.data.name}.` ); }; + +export const verifyCertificationTagPageUI = async (page: Page) => { + await redirectToHomePage(page); + const res = page.waitForResponse(`/api/v1/tags/name/*`); + await visitClassificationPage(page, 'Certification'); + await page.getByTestId('Gold').click(); + await res; + + await page.getByTestId('assets').click(); + + await expect( + page.getByTestId('data-classification-add-button') + ).not.toBeVisible(); + await expect(page.getByTestId('no-data-placeholder')).toBeVisible(); +}; + +export const LIMITED_USER_RULES: PolicyRulesType[] = [ + { + name: 'limitedUserEditTagRole', + resources: [ + 'apiCollection', + 'apiEndpoint', + 'apiService', + 'app', + 'appMarketPlaceDefinition', + 'bot', + 'chart', + 'classification', + 'container', + 'dashboardDataModel', + 'dashboardService', + 'database', + 'databaseSchema', + 'databaseService', + 'dataInsightChart', + 'dataInsightCustomChart', + 'dataInsightDashboard', + 'dataProduct', + 'document', + 'domain', + 'entityReportData', + 'eventsubscription', + 'feed', + 'glossary', + 'glossaryTerm', + 'ingestionPipeline', + 'kpi', + 'messagingService', + 'metadataService', + 'metric', + 'mlmodel', + 'mlmodelService', + 'page', + 'persona', + 'pipeline', + 'pipelineService', + 'policy', + 'query', + 'report', + 'role', + 'searchIndex', + 'searchService', + 'storageService', + 'storedProcedure', + 'suggestion', + 'tag', + 'team', + 'testCase', + 'testCaseResolutionStatus', + 'testCaseResult', + 'testConnectionDefinition', + 'testDefinition', + 'testSuite', + 'type', + 'user', + 'webAnalyticEvent', + 'workflow', + 'workflowDefinition', + 'workflowInstance', + 'workflowInstanceState', + ], + operations: ['EditTags'], + effect: 'deny', + }, +]; diff --git a/openmetadata-ui/src/main/resources/ui/public/locales/en-US/Pipeline/DBTCloud.md b/openmetadata-ui/src/main/resources/ui/public/locales/en-US/Pipeline/DBTCloud.md index 56d0186704bf..e7965a34e93e 100644 --- a/openmetadata-ui/src/main/resources/ui/public/locales/en-US/Pipeline/DBTCloud.md +++ b/openmetadata-ui/src/main/resources/ui/public/locales/en-US/Pipeline/DBTCloud.md @@ -26,8 +26,15 @@ The Account ID of your DBT cloud Project. Go to your dbt cloud account settings $$ $$section -### Job Id $(id="jobId") -The Job ID of your DBT cloud Job in your Project to fetch metadata for. Look for the segment after "jobs" in the URL. For instance, in a URL like `https://cloud.getdbt.com/accounts/123/projects/87477/jobs/73659994`, the job ID is `73659994`. This will be a numeric value but in openmetadata we parse it as a string. If not passed all Jobs under the Account id will be ingested. `Optional` +### Job Ids $(id="jobIds") +Job IDs of your DBT cloud Jobs in your Project to fetch metadata for. Look for the segment after "jobs" in the URL. For instance, in a URL like `https://cloud.getdbt.com/accounts/123/projects/87477/jobs/73659994`, the job ID is `73659994`. This will be a numeric value but in openmetadata we parse it as a string. If not passed all Jobs under the Account Id will be ingested. `Optional` +$$ + +$$section +### Project Ids $(id="projectIds") +Project IDs of your DBT cloud Account to fetch metadata for. Look for the segment after "projects" in the URL. For instance, in a URL like `https://cloud.getdbt.com/accounts/123/projects/87477/jobs/73659994`, the job ID is `87477`. This will be a numeric value but in openmetadata we parse it as a string. If not passed all Projects under the Account Id will be ingested. `Optional` + +Note that if both `Job Ids` and `Project Ids` are passed then it will filter out the jobs from the passed projects. any `Job Ids` not belonging to the `Project Ids` will also be filtered out. $$ $$section diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetails/GlossaryDetails.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetails/GlossaryDetails.component.tsx index 013b9e62d22a..470709b66484 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetails/GlossaryDetails.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetails/GlossaryDetails.component.tsx @@ -270,7 +270,7 @@ const GlossaryDetails = ({ {getWidgetFromKeyInternal(widget)} )); - }, [tagsWidget, layout]); + }, [tagsWidget, layout, permissions, termsLoading]); const detailsContent = useMemo(() => { return ( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.component.tsx index 43865ecfd7d6..63954c347c6e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.component.tsx @@ -114,6 +114,10 @@ const GlossaryTermTab = ({ const [isTableLoading, setIsTableLoading] = useState(false); const [isTableHovered, setIsTableHovered] = useState(false); const [expandedRowKeys, setExpandedRowKeys] = useState([]); + const listOfVisibleColumns = useMemo(() => { + return ['name', 'description', 'owners', 'status', 'new-term']; + }, []); + const [isStatusDropdownVisible, setIsStatusDropdownVisible] = useState(false); const statusOptions = useMemo( @@ -123,7 +127,7 @@ const GlossaryTermTab = ({ ); const [statusDropdownSelection, setStatusDropdownSelections] = useState< string[] - >(['Approved', 'Draft']); + >([Status.Approved, Status.Draft, Status.InReview]); const [selectedStatus, setSelectedStatus] = useState([ ...statusDropdownSelection, ]); @@ -296,11 +300,7 @@ const GlossaryTermTab = ({ } return data; - }, [glossaryTerms, permissions]); - - const listOfVisibleColumns = useMemo(() => { - return ['name', 'description', 'owners', 'status', 'new-term']; - }, []); + }, [permissions]); const defaultCheckedList = useMemo( () => @@ -353,7 +353,7 @@ const GlossaryTermTab = ({ return aIndex - bIndex; }), - [newColumns] + [options, newColumns] ); const handleColumnSelectionDropdownSave = useCallback(() => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryV1.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryV1.component.tsx index fcbe01ad3926..87ac05380160 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryV1.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryV1.component.tsx @@ -41,6 +41,7 @@ import { patchGlossaryTerm, } from '../../rest/glossaryAPI'; import { getEntityDeleteMessage } from '../../utils/CommonUtils'; +import { updateGlossaryTermByFqn } from '../../utils/GlossaryUtils'; import { DEFAULT_ENTITY_PERMISSION } from '../../utils/PermissionsUtils'; import { showErrorToast } from '../../utils/ToastUtils'; import { useActivityFeedProvider } from '../ActivityFeed/ActivityFeedProvider/ActivityFeedProvider'; @@ -84,6 +85,7 @@ const GlossaryV1 = ({ const { getEntityPermission } = usePermissionProvider(); const [isLoading, setIsLoading] = useState(true); const [isTermsLoading, setIsTermsLoading] = useState(false); + const [isPermissionLoading, setIsPermissionLoading] = useState(false); const [isDelete, setIsDelete] = useState(false); @@ -97,7 +99,8 @@ const GlossaryV1 = ({ const [editMode, setEditMode] = useState(false); - const { activeGlossary, setGlossaryChildTerms } = useGlossaryStore(); + const { activeGlossary, glossaryChildTerms, setGlossaryChildTerms } = + useGlossaryStore(); const { id, fullyQualifiedName } = activeGlossary ?? {}; @@ -206,6 +209,17 @@ const GlossaryV1 = ({ setIsEditModalOpen(true); }; + const updateGlossaryTermInStore = (updatedTerm: GlossaryTerm) => { + const clonedTerms = cloneDeep(glossaryChildTerms); + const updatedGlossaryTerms = updateGlossaryTermByFqn( + clonedTerms, + updatedTerm.fullyQualifiedName ?? '', + updatedTerm as ModifiedGlossary + ); + + setGlossaryChildTerms(updatedGlossaryTerms); + }; + const updateGlossaryTerm = async ( currentData: GlossaryTerm, updatedData: GlossaryTerm @@ -217,8 +231,10 @@ const GlossaryV1 = ({ throw t('server.entity-updating-error', { entity: t('label.glossary-term'), }); + } else { + updateGlossaryTermInStore(response); + setIsEditModalOpen(false); } - onTermModalSuccess(); } catch (error) { if ( (error as AxiosError).response?.status === HTTP_STATUS_CODE.CONFLICT @@ -336,18 +352,29 @@ const GlossaryV1 = ({ shouldRefreshTerms && loadGlossaryTerms(true); }; - useEffect(() => { - if (id && !action) { - loadGlossaryTerms(); - if (isGlossaryActive) { - isVersionsView + const initPermissions = async () => { + setIsPermissionLoading(true); + const permissionFetch = isGlossaryActive + ? fetchGlossaryPermission + : fetchGlossaryTermPermission; + + try { + if (isVersionsView) { + isGlossaryActive ? setGlossaryPermission(VERSION_VIEW_GLOSSARY_PERMISSION) - : fetchGlossaryPermission(); + : setGlossaryTermPermission(VERSION_VIEW_GLOSSARY_PERMISSION); } else { - isVersionsView - ? setGlossaryTermPermission(VERSION_VIEW_GLOSSARY_PERMISSION) - : fetchGlossaryTermPermission(); + await permissionFetch(); } + } finally { + setIsPermissionLoading(false); + } + }; + + useEffect(() => { + if (id && !action) { + loadGlossaryTerms(); + initPermissions(); } }, [id, isGlossaryActive, isVersionsView, action]); @@ -355,8 +382,9 @@ const GlossaryV1 = ({ ) : ( <> - {isLoading && } + {(isLoading || isPermissionLoading) && } {!isLoading && + !isPermissionLoading && !isEmpty(selectedData) && (isGlossaryActive ? ( { return users.map((item) => getEntityReferenceFromEntity(item, EntityType.USER) ); }, [users]); - + const isGroupType = useMemo( () => currentTeam.teamType === TeamType.Group, [currentTeam.teamType] @@ -379,7 +378,7 @@ export const UserTab = ({ onSearch={handleUsersSearchAction} /> - {!currentTeam.deleted && ( + {!currentTeam.deleted && isGroupType && ( {users.length > 0 && permission.EditAll && ( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/CertificationTag/CertificationTag.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/CertificationTag/CertificationTag.tsx index 7320089bad58..7f6030aaadee 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/CertificationTag/CertificationTag.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/CertificationTag/CertificationTag.tsx @@ -10,10 +10,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Tag } from 'antd'; +import { Tag, Tooltip } from 'antd'; import React from 'react'; import { AssetCertification } from '../../../generated/entity/data/table'; import { getEntityName } from '../../../utils/EntityUtils'; +import { getTagTooltip } from '../../../utils/TagsUtils'; import './certification-tag.less'; const CertificationTag = ({ @@ -21,19 +22,34 @@ const CertificationTag = ({ }: { certification: AssetCertification; }) => { + if (certification.tagLabel.style?.iconURL) { + const name = getEntityName(certification.tagLabel); + + return ( + +
+ certification +
+
+ ); + } + return ( - {certification.tagLabel.style?.iconURL && ( - certification - )} - {getEntityName(certification.tagLabel)} ); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/Form/JSONSchema/JsonSchemaWidgets/QueryBuilderWidget/QueryBuilderWidget.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/Form/JSONSchema/JsonSchemaWidgets/QueryBuilderWidget/QueryBuilderWidget.tsx index 5a5b0390303c..c5c80caef1f9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/Form/JSONSchema/JsonSchemaWidgets/QueryBuilderWidget/QueryBuilderWidget.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/Form/JSONSchema/JsonSchemaWidgets/QueryBuilderWidget/QueryBuilderWidget.tsx @@ -12,7 +12,16 @@ */ import { InfoCircleOutlined } from '@ant-design/icons'; import { WidgetProps } from '@rjsf/utils'; -import { Alert, Button, Card, Col, Row, Skeleton, Typography } from 'antd'; +import { + Alert, + Button, + Card, + Col, + Divider, + Row, + Skeleton, + Typography, +} from 'antd'; import classNames from 'classnames'; import { t } from 'i18next'; import { debounce, isEmpty, isUndefined } from 'lodash'; @@ -29,6 +38,10 @@ import { import { getExplorePath } from '../../../../../../constants/constants'; import { EntityType } from '../../../../../../enums/entity.enum'; import { SearchIndex } from '../../../../../../enums/search.enum'; +import { + EsBoolQuery, + QueryFieldInterface, +} from '../../../../../../pages/ExplorePage/ExplorePage.interface'; import { searchQuery } from '../../../../../../rest/searchAPI'; import { elasticSearchFormat, @@ -124,6 +137,22 @@ const QueryBuilderWidget: FC = ({ query: data, }; if (data) { + if (entityType !== EntityType.ALL) { + // Scope the search to the passed entity type + if ( + Array.isArray( + ((qFilter.query as QueryFieldInterface)?.bool as EsBoolQuery) + ?.must + ) + ) { + ( + (qFilter.query as QueryFieldInterface)?.bool + ?.must as QueryFieldInterface[] + )?.push({ + term: { entityType: entityType }, + }); + } + } debouncedFetchEntityCount(qFilter); } @@ -208,7 +237,19 @@ const QueryBuilderWidget: FC = ({ data-testid="query-builder-form-field"> - + + {outputType === SearchOutputType.JSONLogic && ( + <> + + {props.label} + + + + )} ( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/Form/JSONSchema/JsonSchemaWidgets/QueryBuilderWidget/query-builder-widget.less b/openmetadata-ui/src/main/resources/ui/src/components/common/Form/JSONSchema/JsonSchemaWidgets/QueryBuilderWidget/query-builder-widget.less index dc74a8ff21f2..f5081f8baf1b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/Form/JSONSchema/JsonSchemaWidgets/QueryBuilderWidget/query-builder-widget.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/Form/JSONSchema/JsonSchemaWidgets/QueryBuilderWidget/query-builder-widget.less @@ -26,6 +26,29 @@ } } +.query-builder-form-field + .query-builder-card.jsonlogic + .query-builder-container { + .group-or-rule-container.group-container { + & > .group.group-or-rule { + & > .group--header { + order: 0; + + .action.action--ADD-RULE { + position: absolute !important; + margin-top: 0; + right: 0; + top: -48px; + } + } + } + } +} + +.query-filter-label { + text-transform: capitalize; +} + .query-builder-form-field { .hide--line.one--child { margin-top: 0; @@ -176,6 +199,10 @@ } } } + + .rule { + align-items: center; + } } .query-builder-card.elasticsearch { diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/AppInstall/AppInstall.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/AppInstall/AppInstall.component.tsx index 7a62d40fac64..0c0bc910b742 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/AppInstall/AppInstall.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/AppInstall/AppInstall.component.tsx @@ -223,7 +223,7 @@ const AppInstall = () => { ); case 3: return ( -
+
{t('label.schedule')} { const { fqn: tagFqn } = useFqn(); const history = useHistory(); const { tab: activeTab = TagTabs.OVERVIEW } = useParams<{ tab?: string }>(); - const { getEntityPermission } = usePermissionProvider(); + const { permissions, getEntityPermission } = usePermissionProvider(); const [isLoading, setIsLoading] = useState(false); const [tagItem, setTagItem] = useState(); const [assetModalVisible, setAssetModalVisible] = useState(false); @@ -161,6 +162,23 @@ const TagPage = () => { return { editTagsPermission: false, editDescriptionPermission: false }; }, [tagPermissions, tagItem?.deleted]); + const editEntitiesTagPermission = useMemo( + () => getExcludedIndexesBasedOnEntityTypeEditTagPermission(permissions), + [permissions] + ); + + const haveAssetEditPermission = useMemo( + () => + editTagsPermission || + !isEmpty(editEntitiesTagPermission.entitiesHavingPermission), + [editTagsPermission, editEntitiesTagPermission.entitiesHavingPermission] + ); + + const isCertificationClassification = useMemo( + () => startsWith(tagFqn, 'Certification.'), + [tagFqn] + ); + const fetchCurrentTagPermission = async () => { if (!tagItem?.id) { return; @@ -477,7 +495,16 @@ const TagPage = () => { assetCount={assetCount} entityFqn={tagItem?.fullyQualifiedName ?? ''} isSummaryPanelOpen={Boolean(previewAsset)} - permissions={tagPermissions} + permissions={ + { + Create: + haveAssetEditPermission && + !isCertificationClassification, + EditAll: + haveAssetEditPermission && + !isCertificationClassification, + } as OperationPermission + } ref={assetTabRef} type={AssetsOfEntity.TAG} onAddAsset={() => setAssetModalVisible(true)} @@ -591,17 +618,19 @@ const TagPage = () => { titleColor={tagItem.style?.color ?? BLACK_COLOR} /> - {editTagsPermission && ( + {haveAssetEditPermission && (
- + {!isCertificationClassification && ( + + )} {manageButtonContent.length > 0 && ( { setAssetModalVisible(false)} onSave={handleAssetSave} diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchClassBase.test.ts b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchClassBase.test.ts index 7542902ab9a1..f9fee0938250 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchClassBase.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchClassBase.test.ts @@ -46,6 +46,7 @@ describe('AdvancedSearchClassBase', () => { EntityFields.TIER, 'extension', 'descriptionStatus', + 'entityType', ]); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchClassBase.ts b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchClassBase.ts index 3fc279330535..05da5daaffd5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchClassBase.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchClassBase.ts @@ -610,6 +610,19 @@ class AdvancedSearchClassBase { }, }, }, + [EntityFields.ENTITY_TYPE]: { + label: t('label.entity-type-plural', { entity: t('label.entity') }), + type: 'select', + mainWidgetProps: this.mainWidgetProps, + + fieldSettings: { + asyncFetch: this.autocomplete({ + searchIndex: entitySearchIndex, + entityField: EntityFields.ENTITY_TYPE, + }), + useAsyncSearch: true, + }, + }, }; } diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.tsx index 177c2ab0a608..c2bfda868435 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.tsx @@ -163,6 +163,30 @@ export const getGlossaryBreadcrumbs = (fqn: string) => { return breadcrumbList; }; +export const updateGlossaryTermByFqn = ( + glossaryTerms: ModifiedGlossary[], + fqn: string, + newValue: ModifiedGlossary +): ModifiedGlossary[] => { + return glossaryTerms.map((term) => { + if (term.fullyQualifiedName === fqn) { + return newValue; + } + if (term.children) { + return { + ...term, + children: updateGlossaryTermByFqn( + term.children as ModifiedGlossary[], + fqn, + newValue + ), + }; + } + + return term; + }) as ModifiedGlossary[]; +}; + // This function finds and gives you the glossary term you're looking for. // You can then use this term or update its information in the Glossary or Term with it's reference created // Reference will only be created if withReference is true diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/JSONLogicSearchClassBase.ts b/openmetadata-ui/src/main/resources/ui/src/utils/JSONLogicSearchClassBase.ts index 25bc69ed366a..625dd967e5a1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/JSONLogicSearchClassBase.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/JSONLogicSearchClassBase.ts @@ -29,7 +29,7 @@ import { import { SearchIndex } from '../enums/search.enum'; import { searchData } from '../rest/miscAPI'; import advancedSearchClassBase from './AdvancedSearchClassBase'; -import { renderQueryBuilderFilterButtons } from './QueryBuilderUtils'; +import { renderJSONLogicQueryBuilderButtons } from './QueryBuilderUtils'; class JSONLogicSearchClassBase { baseConfig = AntdConfig as Config; @@ -370,7 +370,7 @@ class JSONLogicSearchClassBase { operatorLabel: t('label.condition') + ':', showNot: false, valueLabel: t('label.criteria') + ':', - renderButton: renderQueryBuilderFilterButtons, + renderButton: renderJSONLogicQueryBuilderButtons, }, }; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/PermissionsUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/PermissionsUtils.ts index b9c3a7661fa7..463706988fe7 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/PermissionsUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/PermissionsUtils.ts @@ -52,6 +52,31 @@ export const checkPermission = ( return hasPermission; }; +/** + * + * @param operation operation like Edit, Delete + * @param resourceType Resource type like "bot", "table" + * @param permissions UIPermission + * @param checkEditAllPermission boolean to check EditALL permission as well + * @returns boolean - true/false + */ +export const checkPermissionEntityResource = ( + operation: Operation, + resourceType: ResourceEntity, + permissions: UIPermission, + checkEditAllPermission = false +) => { + const entityResource = permissions?.[resourceType]; + let hasPermission = entityResource && entityResource[operation]; + + if (checkEditAllPermission) { + hasPermission = + hasPermission || (entityResource && entityResource[Operation.EditAll]); + } + + return hasPermission; +}; + /** * * @param permission ResourcePermission diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/QueryBuilderUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/QueryBuilderUtils.tsx index de07b53e9149..e3952c1e4fa6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/QueryBuilderUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/QueryBuilderUtils.tsx @@ -10,7 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { CloseOutlined } from '@ant-design/icons'; +import { CloseOutlined, PlusOutlined } from '@ant-design/icons'; import { Button } from 'antd'; import { t } from 'i18next'; import { isUndefined } from 'lodash'; @@ -404,6 +404,43 @@ export const renderQueryBuilderFilterButtons: RenderSettings['renderButton'] = ( return <>; }; +export const renderJSONLogicQueryBuilderButtons: RenderSettings['renderButton'] = + (props) => { + const type = props?.type; + + if (type === 'delRule') { + return ( +