cumulusci-plus 5.0.35__py3-none-any.whl → 5.0.43__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. cumulusci/__about__.py +1 -1
  2. cumulusci/cli/task.py +9 -10
  3. cumulusci/cli/tests/test_org.py +5 -0
  4. cumulusci/cli/tests/test_task.py +34 -0
  5. cumulusci/core/config/__init__.py +1 -0
  6. cumulusci/core/config/org_config.py +2 -1
  7. cumulusci/core/config/project_config.py +12 -0
  8. cumulusci/core/config/scratch_org_config.py +12 -0
  9. cumulusci/core/config/tests/test_config.py +1 -0
  10. cumulusci/core/dependencies/base.py +4 -0
  11. cumulusci/cumulusci.yml +18 -1
  12. cumulusci/schema/cumulusci.jsonschema.json +5 -0
  13. cumulusci/tasks/apex/testrunner.py +7 -4
  14. cumulusci/tasks/bulkdata/tests/test_select_utils.py +20 -0
  15. cumulusci/tasks/metadata_etl/__init__.py +2 -0
  16. cumulusci/tasks/metadata_etl/applications.py +256 -0
  17. cumulusci/tasks/metadata_etl/tests/test_applications.py +710 -0
  18. cumulusci/tasks/salesforce/insert_record.py +18 -19
  19. cumulusci/tasks/salesforce/tests/test_enable_prediction.py +4 -2
  20. cumulusci/tasks/salesforce/tests/test_update_external_auth_identity_provider.py +927 -0
  21. cumulusci/tasks/salesforce/tests/test_update_external_credential.py +523 -8
  22. cumulusci/tasks/salesforce/tests/test_update_record.py +512 -0
  23. cumulusci/tasks/salesforce/update_external_auth_identity_provider.py +551 -0
  24. cumulusci/tasks/salesforce/update_external_credential.py +89 -4
  25. cumulusci/tasks/salesforce/update_record.py +217 -0
  26. cumulusci/tasks/sfdmu/sfdmu.py +14 -1
  27. cumulusci/tasks/utility/credentialManager.py +58 -12
  28. cumulusci/tasks/utility/secretsToEnv.py +2 -2
  29. cumulusci/tasks/utility/tests/test_credentialManager.py +586 -0
  30. cumulusci/tasks/utility/tests/test_secretsToEnv.py +42 -15
  31. cumulusci/utils/yaml/cumulusci_yml.py +1 -0
  32. {cumulusci_plus-5.0.35.dist-info → cumulusci_plus-5.0.43.dist-info}/METADATA +6 -7
  33. {cumulusci_plus-5.0.35.dist-info → cumulusci_plus-5.0.43.dist-info}/RECORD +37 -31
  34. {cumulusci_plus-5.0.35.dist-info → cumulusci_plus-5.0.43.dist-info}/WHEEL +1 -1
  35. {cumulusci_plus-5.0.35.dist-info → cumulusci_plus-5.0.43.dist-info}/entry_points.txt +0 -0
  36. {cumulusci_plus-5.0.35.dist-info → cumulusci_plus-5.0.43.dist-info}/licenses/AUTHORS.rst +0 -0
  37. {cumulusci_plus-5.0.35.dist-info → cumulusci_plus-5.0.43.dist-info}/licenses/LICENSE +0 -0
cumulusci/__about__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "5.0.35"
1
+ __version__ = "5.0.43"
cumulusci/cli/task.py CHANGED
@@ -140,10 +140,13 @@ class RunTaskCommand(click.MultiCommand):
140
140
 
141
141
  def get_command(self, ctx, task_name):
142
142
  runtime = ctx.obj
143
- if runtime.project_config is None:
144
- raise runtime.project_config_error
145
143
  runtime._load_keychain()
146
- task_config = runtime.project_config.get_task(task_name)
144
+ if runtime.project_config is None:
145
+ task_config = runtime.universal_config.get_task(task_name)
146
+ if not task_config.is_global:
147
+ raise runtime.project_config_error
148
+ else:
149
+ task_config = runtime.project_config.get_task(task_name)
147
150
 
148
151
  if "options" not in task_config.config:
149
152
  task_config.config["options"] = {}
@@ -157,14 +160,10 @@ class RunTaskCommand(click.MultiCommand):
157
160
  def run_task(*args, **kwargs):
158
161
  """Callback function that executes when the command fires."""
159
162
  # Load environment variables FIRST, before any task processing
160
- if kwargs.get("loadenv", None):
163
+ if kwargs.get("loadenv", None) and runtime.project_config is not None:
161
164
  # Load .env file from the project root directory
162
- env_path = (
163
- Path(runtime.project_config.repo_root) / ".env"
164
- if runtime.project_config
165
- else None
166
- )
167
- if env_path:
165
+ env_path = Path(runtime.project_config.repo_root) / ".env"
166
+ if env_path and env_path.exists():
168
167
  load_dotenv(env_path)
169
168
 
170
169
  org, org_config = runtime.get_org(
@@ -116,6 +116,7 @@ class TestOrgCommands:
116
116
  "IsSandbox": False,
117
117
  "InstanceName": "CS420",
118
118
  "NamespacePrefix": None,
119
+ "Name": "Test Org",
119
120
  },
120
121
  status=200,
121
122
  )
@@ -173,6 +174,7 @@ class TestOrgCommands:
173
174
  "IsSandbox": False,
174
175
  "InstanceName": "CS420",
175
176
  "NamespacePrefix": None,
177
+ "Name": "Test Org",
176
178
  },
177
179
  status=200,
178
180
  )
@@ -304,6 +306,7 @@ class TestOrgCommands:
304
306
  "IsSandbox": True,
305
307
  "InstanceName": "CS420",
306
308
  "NamespacePrefix": None,
309
+ "Name": "Test Org",
307
310
  },
308
311
  status=200,
309
312
  )
@@ -439,6 +442,7 @@ class TestOrgCommands:
439
442
  "IsSandbox": True,
440
443
  "InstanceName": "CS420",
441
444
  "NamespacePrefix": None,
445
+ "Name": "Test Org",
442
446
  },
443
447
  status=200,
444
448
  )
@@ -494,6 +498,7 @@ class TestOrgCommands:
494
498
  "IsSandbox": True,
495
499
  "InstanceName": "CS420",
496
500
  "NamespacePrefix": None,
501
+ "Name": "Test Org",
497
502
  },
498
503
  status=200,
499
504
  )
@@ -52,6 +52,8 @@ def test_task_run(runtime):
52
52
 
53
53
 
54
54
  def test_task_run__no_project(runtime):
55
+ # Add task to universal_config so it can be found
56
+ runtime.universal_config.config["tasks"] = {**test_tasks}
55
57
  runtime.project_config = None
56
58
  runtime.project_config_error = Exception("Broken")
57
59
  multi_cmd = task.RunTaskCommand()
@@ -60,6 +62,34 @@ def test_task_run__no_project(runtime):
60
62
  multi_cmd.get_command(ctx, "dummy-task")
61
63
 
62
64
 
65
+ def test_task_run__global_task_without_project(runtime):
66
+ """Test that a global task can run without a project context."""
67
+ # Define a global task
68
+ global_task = {
69
+ "global-dummy-task": {
70
+ "class_path": "cumulusci.cli.tests.utils.DummyTask",
71
+ "description": "This is a global dummy task.",
72
+ "is_global": True,
73
+ }
74
+ }
75
+
76
+ # Add global task to universal_config
77
+ runtime.universal_config.config["tasks"] = {**global_task}
78
+ runtime.project_config = None
79
+ runtime.project_config_error = Exception("No project config")
80
+
81
+ DummyTask._run_task = Mock()
82
+ multi_cmd = task.RunTaskCommand()
83
+
84
+ # Should NOT raise an exception because the task is global
85
+ with click.Context(multi_cmd, obj=runtime) as ctx:
86
+ cmd = multi_cmd.get_command(ctx, "global-dummy-task")
87
+ cmd.callback(runtime, "global-dummy-task", color="blue")
88
+
89
+ # Verify the task was executed
90
+ DummyTask._run_task.assert_called_once()
91
+
92
+
63
93
  def test_task_run__debug_before(runtime):
64
94
  DummyTask._run_task = Mock()
65
95
  multi_cmd = task.RunTaskCommand()
@@ -277,6 +307,10 @@ def test_task_run__loadenv_with_project_root(load_dotenv, runtime):
277
307
  with tempfile.TemporaryDirectory() as temp_dir:
278
308
  runtime.project_config._repo_info = {"root": temp_dir}
279
309
 
310
+ # Create the .env file so it exists
311
+ env_path = Path(temp_dir) / ".env"
312
+ env_path.write_text("TEST_VAR=test_value\n")
313
+
280
314
  multi_cmd = task.RunTaskCommand()
281
315
  with click.Context(multi_cmd, obj=runtime) as ctx:
282
316
  cmd = multi_cmd.get_command(ctx, "dummy-task")
@@ -68,6 +68,7 @@ class TaskConfig(BaseConfig):
68
68
  name: str
69
69
  checks: list
70
70
  project_config: "BaseProjectConfig"
71
+ is_global: bool
71
72
 
72
73
  # TODO: What if an intermediate repo "allows" a downstream repo?
73
74
  # Only the top repo should be allowed to do so.
@@ -63,7 +63,7 @@ class OrgConfig(BaseConfig):
63
63
  client_secret: str
64
64
  connected_app: str
65
65
  serialization_format: str
66
-
66
+ org_name: str
67
67
  createable: Optional[bool] = None
68
68
 
69
69
  # make sure it can be mocked for tests
@@ -246,6 +246,7 @@ class OrgConfig(BaseConfig):
246
246
  "is_sandbox": self._org_sobject["IsSandbox"],
247
247
  "instance_name": self._org_sobject["InstanceName"],
248
248
  "namespace": self._org_sobject["NamespacePrefix"],
249
+ "org_name": self._org_sobject["Name"],
249
250
  }
250
251
  self.config.update(result)
251
252
 
@@ -452,6 +452,18 @@ class BaseProjectConfig(BaseTaskFlowConfig, ProjectConfigPropertiesMixin):
452
452
  ):
453
453
  return parts[0]
454
454
 
455
+ @property
456
+ def is_dependency_flow(self) -> bool:
457
+ return (
458
+ False
459
+ if self.executing_dependency_flow is None
460
+ else self.executing_dependency_flow
461
+ )
462
+
463
+ @is_dependency_flow.setter
464
+ def is_dependency_flow(self, value: bool):
465
+ self.executing_dependency_flow = value
466
+
455
467
  def get_repo(self) -> AbstractRepo:
456
468
  repo = self.repo_service.get_repository()
457
469
  if repo is None:
@@ -162,6 +162,18 @@ class ScratchOrgConfig(SfdxOrgConfig):
162
162
  args += ["-a", self.sfdx_alias]
163
163
  with open(self.config_file, "r") as org_def:
164
164
  org_def_data = json.load(org_def)
165
+ if (
166
+ "orgName" in org_def_data
167
+ and org_def_data["orgName"] is not None
168
+ and self.keychain.project_config.project__name is not None
169
+ ):
170
+ org_def_data["orgName"] = (
171
+ org_def_data["orgName"]
172
+ .replace(
173
+ "%%%PROJECT_NAME%%%", self.keychain.project_config.project__name
174
+ )
175
+ .replace("%%%CONFIG_NAME%%%", self.name.upper())
176
+ )
165
177
  org_def_has_email = "adminEmail" in org_def_data
166
178
  if self.email_address and not org_def_has_email:
167
179
  args += [f"--admin-email={self.email_address}"]
@@ -1222,6 +1222,7 @@ class TestOrgConfig:
1222
1222
  "IsSandbox": False,
1223
1223
  "InstanceName": "cs420",
1224
1224
  "NamespacePrefix": "ns",
1225
+ "Name": "Test Org",
1225
1226
  },
1226
1227
  )
1227
1228
 
@@ -738,11 +738,15 @@ class UnmanagedVcsDependencyFlow(UnmanagedStaticDependency, ABC):
738
738
  )
739
739
  )
740
740
 
741
+ project_config.is_dependency_flow = True
742
+
741
743
  start_time = datetime.now()
742
744
  coordinator.run(org)
743
745
  duration = datetime.now() - start_time
744
746
  context.logger.info(f"Ran {self.flow_name} in {format_duration(duration)}")
745
747
 
748
+ project_config.is_dependency_flow = False
749
+
746
750
 
747
751
  class BasePackageVersionDependency(StaticDependency, ABC):
748
752
  @abstractmethod
cumulusci/cumulusci.yml CHANGED
@@ -168,6 +168,10 @@ tasks:
168
168
  description: Inserts a record of any sObject using the REST API
169
169
  class_path: cumulusci.tasks.salesforce.insert_record.InsertRecord
170
170
  group: Data Operations
171
+ update_record:
172
+ description: Updates one or more records of any sObject using the REST API
173
+ class_path: cumulusci.tasks.salesforce.update_record.UpdateRecord
174
+ group: Data Operations
171
175
  create_package:
172
176
  description: Creates a package in the target org with the default package name for the project
173
177
  class_path: cumulusci.tasks.salesforce.CreatePackage
@@ -661,6 +665,12 @@ tasks:
661
665
  class_path: cumulusci.tasks.metadata_etl.SetOrgWideDefaults
662
666
  options:
663
667
  namespace_inject: $project_config.project__package__namespace
668
+ add_applications_profile_action_overrides:
669
+ group: Metadata Transformations
670
+ description: Adds or updates profile action overrides to the Custom Applications.
671
+ class_path: cumulusci.tasks.metadata_etl.applications.AddProfileActionOverrides
672
+ options:
673
+ namespace_inject: $project_config.project__package__namespace
664
674
  strip_unwanted_components:
665
675
  description: Removes components from src folder which are not mentioned in given package.xml file
666
676
  class_path: cumulusci.tasks.metadata.package.RemoveUnwantedComponents
@@ -761,10 +771,12 @@ tasks:
761
771
  description: "Copy a file for src to dest with environment variable support, Ex: &TMPDIR&/src resolves to /tmp/src on linux or %TMPDIR%/src on windows."
762
772
  class_path: cumulusci.tasks.util.CopyFile
763
773
  group: Utilities
774
+ is_global: True
764
775
  load_dot_env:
765
776
  description: "Log the contents of the .env file"
766
777
  class_path: cumulusci.tasks.util.LoadDotEnv
767
778
  group: Utilities
779
+ is_global: True
768
780
  configure_env:
769
781
  description: Get or set environment variables.
770
782
  class_path: cumulusci.tasks.utility.env_management.EnvManagement
@@ -773,6 +785,7 @@ tasks:
773
785
  description: Get all environment variables from a target JSON file and generates a .env file that can be used to set up the environment variables for the deployment
774
786
  class_path: cumulusci.tasks.utility.secretsToEnv.SecretsToEnv
775
787
  group: Utilities
788
+ is_global: True
776
789
  directory_recreator:
777
790
  description: Remove and recreate a directory
778
791
  class_path: cumulusci.tasks.utility.directoryRecreator.DirectoryRecreator
@@ -874,6 +887,10 @@ tasks:
874
887
  class_path: cumulusci.tasks.salesforce.update_external_credential.UpdateExternalCredential
875
888
  description: Update external credential parameters
876
889
  group: Metadata Transformations
890
+ update_external_auth_identity_provider:
891
+ class_path: cumulusci.tasks.salesforce.update_external_auth_identity_provider.UpdateExternalAuthIdentityProvider
892
+ description: Update external auth identity provider parameters and credentials
893
+ group: Metadata Transformations
877
894
 
878
895
  flows:
879
896
  ci_beta:
@@ -1617,7 +1634,7 @@ project:
1617
1634
  apex_test_access:
1618
1635
  package_metadata_access:
1619
1636
  unpackaged_metadata_path:
1620
- api_version: "63.0"
1637
+ api_version: "64.0"
1621
1638
  git:
1622
1639
  default_branch: master
1623
1640
  prefix_feature: feature/
@@ -122,6 +122,11 @@
122
122
  "name": {
123
123
  "title": "Name",
124
124
  "type": "string"
125
+ },
126
+ "is_global": {
127
+ "title": "Is Global",
128
+ "default": false,
129
+ "type": "boolean"
125
130
  }
126
131
  },
127
132
  "additionalProperties": false
@@ -246,7 +246,8 @@ class RunApexTests(BaseSalesforceApiTask):
246
246
  description="Defines a dynamic filter to apply to test classes from the org that match test_name_match. Supported values: "
247
247
  "'package_only' - only runs test classes that exist in the default package directory (force-app/ or src/),"
248
248
  "'delta_changes' - only runs test classes that are affected by the delta changes in the current branch (force-app/ or src/),"
249
- "Default is None, which means no dynamic filter is applied and all test classes from the org that match test_name_match are run.",
249
+ "Default is None, which means no dynamic filter is applied and all test classes from the org that match test_name_match are run."
250
+ "Setting this option, the org code coverage will not be calculated.",
250
251
  )
251
252
  base_ref: Optional[str] = Field(
252
253
  None,
@@ -897,12 +898,14 @@ class RunApexTests(BaseSalesforceApiTask):
897
898
  )
898
899
 
899
900
  if self.code_coverage_level or self.required_per_class_code_coverage_percent:
900
- if self.parsed_options.namespace not in self.org_config.installed_packages:
901
- self._check_code_coverage()
902
- else:
901
+ if self.parsed_options.managed:
903
902
  self.logger.info(
904
903
  "This org contains a managed installation; not checking code coverage."
905
904
  )
905
+ elif self.parsed_options.dynamic_filter is not None:
906
+ self.logger.info("Dynamic filter is set; not checking code coverage.")
907
+ else:
908
+ self._check_code_coverage()
906
909
  else:
907
910
  self.logger.info(
908
911
  "No code coverage level specified; not checking code coverage."
@@ -624,6 +624,10 @@ def test_vectorize_records_mixed_numerical_boolean_categorical():
624
624
  sys.platform == "darwin" and sys.version_info[:2] in [(3, 11), (3, 13)],
625
625
  reason="Annoy library has known compatibility issues on macOS with Python 3.11 and 3.13",
626
626
  )
627
+ @pytest.mark.skipif(
628
+ sys.platform == "linux" and sys.version_info[:2] == (3, 12),
629
+ reason="Annoy library has known compatibility issues on Linux with Python 3.12",
630
+ )
627
631
  def test_annoy_post_process():
628
632
  # Test data
629
633
  load_records = [["Alice", "Engineer"], ["Bob", "Doctor"]]
@@ -658,6 +662,10 @@ def test_annoy_post_process():
658
662
  sys.platform == "darwin" and sys.version_info[:2] in [(3, 11), (3, 13)],
659
663
  reason="Annoy library has known compatibility issues on macOS with Python 3.11 and 3.13",
660
664
  )
665
+ @pytest.mark.skipif(
666
+ sys.platform == "linux" and sys.version_info[:2] == (3, 12),
667
+ reason="Annoy library has known compatibility issues on Linux with Python 3.12",
668
+ )
661
669
  def test_annoy_post_process__insert_records():
662
670
  # Test data
663
671
  load_records = [["Alice", "Engineer"], ["Bob", "Doctor"]]
@@ -707,6 +715,10 @@ def test_annoy_post_process__insert_records():
707
715
  sys.platform == "darwin" and sys.version_info[:2] in [(3, 11), (3, 13)],
708
716
  reason="Annoy library has known compatibility issues on macOS with Python 3.11 and 3.13",
709
717
  )
718
+ @pytest.mark.skipif(
719
+ sys.platform == "linux" and sys.version_info[:2] == (3, 12),
720
+ reason="Annoy library has known compatibility issues on Linux with Python 3.12",
721
+ )
710
722
  def test_annoy_post_process__no_query_records():
711
723
  # Test data
712
724
  load_records = [["Alice", "Engineer"], ["Bob", "Doctor"]]
@@ -743,6 +755,10 @@ def test_annoy_post_process__no_query_records():
743
755
  sys.platform == "darwin" and sys.version_info[:2] in [(3, 11), (3, 13)],
744
756
  reason="Annoy library has known compatibility issues on macOS with Python 3.11 and 3.13",
745
757
  )
758
+ @pytest.mark.skipif(
759
+ sys.platform == "linux" and sys.version_info[:2] == (3, 12),
760
+ reason="Annoy library has known compatibility issues on Linux with Python 3.12",
761
+ )
746
762
  def test_annoy_post_process__insert_records_with_polymorphic_fields():
747
763
  # Test data
748
764
  load_records = [
@@ -804,6 +820,10 @@ def test_annoy_post_process__insert_records_with_polymorphic_fields():
804
820
  sys.platform == "darwin" and sys.version_info[:2] in [(3, 11), (3, 13)],
805
821
  reason="Annoy library has known compatibility issues on macOS with Python 3.11 and 3.13",
806
822
  )
823
+ @pytest.mark.skipif(
824
+ sys.platform == "linux" and sys.version_info[:2] == (3, 12),
825
+ reason="Annoy library has known compatibility issues on Linux with Python 3.12",
826
+ )
807
827
  def test_single_record_match_annoy_post_process():
808
828
  # Mock data where only the first query record matches the first load record
809
829
  load_records = [["Alice", "Engineer"], ["Bob", "Doctor"]]
@@ -6,6 +6,7 @@ from cumulusci.tasks.metadata_etl.base import (
6
6
  MetadataOperation,
7
7
  UpdateMetadataFirstChildTextTask,
8
8
  )
9
+ from cumulusci.tasks.metadata_etl.applications import AddProfileActionOverrides
9
10
  from cumulusci.tasks.metadata_etl.duplicate_rules import SetDuplicateRuleStatus
10
11
  from cumulusci.tasks.metadata_etl.layouts import AddRelatedLists
11
12
  from cumulusci.tasks.metadata_etl.objects import SetObjectSettings
@@ -18,6 +19,7 @@ flake8 = (
18
19
  BaseMetadataSynthesisTask,
19
20
  BaseMetadataTransformTask,
20
21
  MetadataSingleEntityTransformTask,
22
+ AddProfileActionOverrides,
21
23
  AddRelatedLists,
22
24
  AddPermissionSetPermissions,
23
25
  AddValueSetEntries,
@@ -0,0 +1,256 @@
1
+ from typing import List, Optional, Set
2
+ from urllib.parse import quote
3
+
4
+ from pydantic.v1 import BaseModel
5
+
6
+ from cumulusci.tasks.metadata_etl import MetadataSingleEntityTransformTask
7
+ from cumulusci.utils import inject_namespace
8
+ from cumulusci.utils.xml.metadata_tree import MetadataElement
9
+
10
+
11
+ def _inject_namespace(text: str, options: dict) -> str:
12
+ return inject_namespace(
13
+ "",
14
+ text,
15
+ namespace=options["namespace_inject"]
16
+ if not options.get("namespaced_org")
17
+ else "",
18
+ managed=options.get("managed") or False,
19
+ namespaced_org=options.get("namespaced_org"),
20
+ )[1]
21
+
22
+
23
+ class ProfileActionOverrideOptions(BaseModel):
24
+ """Options for a single profileActionOverride"""
25
+
26
+ action_name: str
27
+ content: str
28
+ form_factor: str
29
+ page_or_sobject_type: str
30
+ record_type: Optional[str]
31
+ type: str
32
+ profile: str
33
+ options: dict = {}
34
+
35
+ def namespace_inject(self, field_name: str) -> None:
36
+ setattr(
37
+ self, field_name, _inject_namespace(getattr(self, field_name), self.options)
38
+ )
39
+
40
+
41
+ class AddProfileActionOverridesOptions(BaseModel):
42
+ """Options container for profile action overrides"""
43
+
44
+ name: str
45
+ overrides: List[ProfileActionOverrideOptions]
46
+
47
+
48
+ class AddProfileActionOverrides(MetadataSingleEntityTransformTask):
49
+ """
50
+ Inserts or updates profileActionOverrides in CustomApplication metadata.
51
+ If a profileActionOverride with the same actionName, pageOrSobjectType,
52
+ recordType, and profile already exists, it will be updated with a warning.
53
+ Otherwise, a new override will be added.
54
+ Task option details:
55
+ - applications: List of CustomApplications to modify
56
+ - name: API name of the CustomApplication to modify
57
+ - overrides: List of profile action overrides to add/update
58
+ - action_name: Action name (e.g., "View", "Edit", "New")
59
+ - content: Content reference (page/component API name)
60
+ - form_factor: Form factor (e.g., "Large", "Small")
61
+ - page_or_sobject_type: Page or SObject type
62
+ - record_type: Record type API name (optional)
63
+ - type: Override type (e.g., "Flexipage", "Visualforce", "LightningComponent")
64
+ - profile: Profile name or API name
65
+ Example Usage
66
+ -----------------------
67
+ .. code-block:: yaml
68
+ task: add_profile_action_overrides
69
+ options:
70
+ applications:
71
+ - name: "%%%NAMESPACE%%%CustomApplicationConsole"
72
+ overrides:
73
+ - action_name: View
74
+ content: "%%%NAMESPACED_ORG%%%AccountUserRecordPage"
75
+ form_factor: Large
76
+ page_or_sobject_type: Account
77
+ record_type: PersonAccount.User
78
+ type: Flexipage
79
+ profile: Admin
80
+ """
81
+
82
+ entity = "CustomApplication"
83
+ task_options = {
84
+ "applications": {
85
+ "description": "List of CustomApplications to modify. See task info for structure.",
86
+ "required": True,
87
+ },
88
+ **MetadataSingleEntityTransformTask.task_options,
89
+ }
90
+
91
+ def _init_options(self, kwargs):
92
+ super()._init_options(kwargs)
93
+
94
+ # Validate options using Pydantic
95
+ self._validated_options: List[AddProfileActionOverridesOptions] = []
96
+ for application in self.options.get("applications"):
97
+ validated_options = AddProfileActionOverridesOptions(
98
+ name=quote(_inject_namespace(application.get("name"), self.options)),
99
+ overrides=application.get("overrides"),
100
+ )
101
+
102
+ self._validated_options.append(validated_options)
103
+
104
+ self.api_names: Set[str] = set(
105
+ application.name for application in self._validated_options
106
+ )
107
+
108
+ def _transform_entity(
109
+ self, metadata: MetadataElement, api_name: str
110
+ ) -> Optional[MetadataElement]:
111
+
112
+ if not self._validated_options:
113
+ self.logger.warning("No applications to add profile action overrides for")
114
+ return None
115
+
116
+ for application in self._validated_options:
117
+ if application.name != api_name:
118
+ continue
119
+
120
+ for override_config in application.overrides:
121
+ self._add_or_update_override(
122
+ metadata, application.name, override_config
123
+ )
124
+
125
+ return metadata
126
+
127
+ def _add_or_update_override(self, metadata, api_name, override_config):
128
+ """Add or update a single profileActionOverride"""
129
+ override_config.options = self.options
130
+
131
+ # Inject namespace where needed
132
+ override_config.namespace_inject("content")
133
+ override_config.namespace_inject("page_or_sobject_type")
134
+ override_config.namespace_inject(
135
+ "record_type"
136
+ ) if override_config.record_type else None
137
+
138
+ # Find existing override with same key fields
139
+ existing_override = self._find_existing_override(metadata, override_config)
140
+
141
+ if existing_override:
142
+ self.logger.warning(
143
+ f"Updating existing profileActionOverride for {override_config.profile}/{override_config.page_or_sobject_type}/{override_config.action_name} in {api_name}"
144
+ )
145
+ # Update the existing override
146
+ self._update_override_element(
147
+ existing_override,
148
+ override_config,
149
+ )
150
+ else:
151
+ self.logger.info(
152
+ f"Adding profileActionOverride for {override_config.profile}/{override_config.page_or_sobject_type}/{override_config.action_name} to {api_name}"
153
+ )
154
+ # Create new override
155
+ self._create_new_override(metadata, override_config)
156
+
157
+ def _find_existing_override(self, metadata, override_config):
158
+ """
159
+ Find an existing profileActionOverride that matches the key fields:
160
+ actionName, pageOrSobjectType, recordType, and profile
161
+ """
162
+ for override_elem in metadata.findall("profileActionOverrides"):
163
+ elem_action = override_elem.find("actionName")
164
+ elem_page = override_elem.find("pageOrSobjectType")
165
+ elem_record_type = override_elem.find("recordType")
166
+ elem_profile = override_elem.find("profile")
167
+
168
+ # Match on all key fields
169
+ if (
170
+ elem_action
171
+ and elem_action.text == override_config.action_name
172
+ and elem_page
173
+ and elem_page.text == override_config.page_or_sobject_type
174
+ and elem_profile
175
+ and elem_profile.text == override_config.profile
176
+ ):
177
+ # Handle recordType - both must be None or both must match
178
+ if override_config.record_type is None and elem_record_type is None:
179
+ return override_elem
180
+ elif (
181
+ override_config.record_type is not None
182
+ and elem_record_type is not None
183
+ and elem_record_type.text == override_config.record_type
184
+ ):
185
+ return override_elem
186
+
187
+ return None
188
+
189
+ def _update_override_element(
190
+ self,
191
+ override_elem,
192
+ override_config,
193
+ ):
194
+ """Update an existing profileActionOverride element"""
195
+ # Update each child element
196
+ # actionName
197
+ elem = override_elem.find("actionName")
198
+ if elem is not None:
199
+ elem.text = override_config.action_name
200
+
201
+ # content
202
+ elem = override_elem.find("content")
203
+ if elem is not None:
204
+ elem.text = override_config.content
205
+
206
+ # formFactor
207
+ elem = override_elem.find("formFactor")
208
+ if elem is not None:
209
+ elem.text = override_config.form_factor
210
+
211
+ # pageOrSobjectType
212
+ elem = override_elem.find("pageOrSobjectType")
213
+ if elem is not None:
214
+ elem.text = override_config.page_or_sobject_type
215
+
216
+ # recordType (optional)
217
+ elem = override_elem.find("recordType")
218
+ if override_config.record_type:
219
+ if elem is not None:
220
+ elem.text = override_config.record_type
221
+ elif elem is not None:
222
+ # Remove recordType if it exists but shouldn't
223
+ override_elem.remove(elem)
224
+
225
+ # type
226
+ elem = override_elem.find("type")
227
+ if elem is not None:
228
+ elem.text = override_config.type
229
+
230
+ # profile
231
+ elem = override_elem.find("profile")
232
+ if elem is not None:
233
+ elem.text = override_config.profile
234
+
235
+ def _create_new_override(
236
+ self,
237
+ metadata,
238
+ override_config,
239
+ ):
240
+ """Create a new profileActionOverride element with proper ordering"""
241
+ override_elem = metadata.append("profileActionOverrides")
242
+
243
+ # Add elements in the correct order per Salesforce metadata API
244
+ override_elem.append("actionName", text=override_config.action_name)
245
+ override_elem.append("content", text=override_config.content)
246
+ override_elem.append("formFactor", text=override_config.form_factor)
247
+ override_elem.append(
248
+ "pageOrSobjectType", text=override_config.page_or_sobject_type
249
+ )
250
+
251
+ # recordType is optional
252
+ if override_config.record_type:
253
+ override_elem.append("recordType", text=override_config.record_type)
254
+
255
+ override_elem.append("type", text=override_config.type)
256
+ override_elem.append("profile", text=override_config.profile)