Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Permit multi-record custom field sets to extend any entity (in theory) #27549

Merged
merged 1 commit into from
Sep 27, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CRM/Contact/BAO/ContactType.php
Original file line number Diff line number Diff line change
Expand Up @@ -849,9 +849,10 @@ public static function getAllContactTypes() {
$contactTypes[$name]['parent_label'] = $contactType['parent_id'] ? $parents[$contactType['parent_id']]['label'] : NULL;
// Cast int/bool types.
$contactTypes[$name]['id'] = (int) $contactType['id'];
$contactTypes[$name]['parent_id'] = $contactType['parent_id'] ? (int) $contactType['parent_id'] : NULL;
$contactTypes[$name]['parent_id'] = ((int) $contactType['parent_id']) ?: NULL;
$contactTypes[$name]['is_active'] = (bool) $contactType['is_active'];
$contactTypes[$name]['is_reserved'] = (bool) $contactType['is_reserved'];
$contactTypes[$name]['icon'] = $contactType['icon'] ?? $parents[$contactType['parent_id']]['icon'] ?? NULL;
}
$cache->set($cacheKey, $contactTypes);
}
Expand Down
73 changes: 65 additions & 8 deletions CRM/Core/BAO/CustomGroup.php
Original file line number Diff line number Diff line change
Expand Up @@ -2061,7 +2061,7 @@ public static function getExtendedObjectTypes(&$types = []) {
* @param array $params
* @return array
*/
public static function getExtendsEntityColumnValueOptions($context, $params) {
public static function getExtendsEntityColumnValueOptions($context, $params): array {
$props = $params['values'] ?? [];
// Requesting this option list only makes sense if the value of 'extends' is known or can be looked up
if (!empty($props['id']) || !empty($props['name']) || !empty($props['extends']) || !empty($props['extends_entity_column_id'])) {
Expand Down Expand Up @@ -2397,18 +2397,52 @@ public static function checkGroupAccess($groupId, $operation = CRM_Core_Permissi
return in_array($groupId, $allowedGroups);
}

/**
* Given the name of a custom group, gets the name of the API entity the group extends.
*
* Sort of the inverse of this function:
* @see \Civi\Api4\Utils\CoreUtil::getCustomGroupExtends
*
* @param string $groupName
* @return string
* @throws \CRM_Core_Exception
*/
public static function getEntityForGroup(string $groupName): string {
$extends = \CRM_Core_DAO::getFieldValue('CRM_Core_DAO_CustomGroup', $groupName, 'extends', 'name');
if (!$extends) {
throw new \CRM_Core_Exception("Custom group $groupName not found");
}
return self::getEntityFromExtends($extends);
}

/**
* Translate CustomGroup.extends to entity name.
*
* CustomGroup.extends pretty much maps 1-1 with entity names, except for Individual, Organization & Household.
* @param string $extends
* @return string
* @see self::getCustomGroupExtendsOptions
*/
public static function getEntityFromExtends(string $extends): string {
if ($extends === 'Contact' || in_array($extends, \CRM_Contact_BAO_ContactType::basicTypes(TRUE))) {
return 'Contact';
}
return $extends;
}

/**
* List all possible values for `CustomGroup.extends`.
*
* This includes the fake entities "Individual", "Organization", "Household"
* but not the extra options from `custom_data_type` used on the form ("ParticipantStatus", etc).
*
* Returns a mix of hard-coded array and `cg_extend_objects` OptionValues.
* The 'id' return key maps to the 'value' in `cg_extend_objects`.
* The 'grouping' key refers to the entity field used to select a sub-type.
* The 'table_name' key is for internal use (is not returned by getFields.loadOptions), and
* maps to the 'name' in `cg_extend_objects`. We don't return it as the 'name' in getFields because
* it is not always unique (since contact types are pseudo-entities in this list).
* - 'id' return key (maps to `cg_extend_objects.value`).
* - 'grouping' key refers to the entity field used to select a sub-type.
* - 'is_multiple' (@internal, not returned by getFields.loadOptions) (maps to `cg_extend_objects.filter`)
* controls whether the entity supports multi-record custom groups.
* - 'table_name' (@internal, not returned by getFields.loadOptions) (maps to `cg_extend_objects.name`).
* We don't return it as the 'name' in getFields because it is not always unique (since contact types are pseudo-entities).
*
* @return array{id: string, label: string, grouping: string, table_name: string}[]
*/
Expand All @@ -2419,81 +2453,100 @@ public static function getCustomGroupExtendsOptions() {
'label' => ts('Activities'),
'grouping' => 'activity_type_id',
'table_name' => 'civicrm_activity',
'is_multiple' => FALSE,
],
[
'id' => 'Relationship',
'label' => ts('Relationships'),
'grouping' => 'relationship_type_id',
'table_name' => 'civicrm_relationship',
'is_multiple' => FALSE,
],
// TODO: Move to civi_contribute extension (example: OptionValue_cg_extends_objects_grant.mgd.php)
[
'id' => 'Contribution',
'label' => ts('Contributions'),
'grouping' => 'financial_type_id',
'table_name' => 'civicrm_contribution',
'is_multiple' => FALSE,
],
[
'id' => 'ContributionRecur',
'label' => ts('Recurring Contributions'),
'grouping' => NULL,
'table_name' => 'civicrm_contribution_recur',
'is_multiple' => FALSE,
],
[
'id' => 'Group',
'label' => ts('Groups'),
'grouping' => NULL,
'table_name' => 'civicrm_group',
'is_multiple' => FALSE,
],
// TODO: Move to civi_member extension (example: OptionValue_cg_extends_objects_grant.mgd.php)
[
'id' => 'Membership',
'label' => ts('Memberships'),
'grouping' => 'membership_type_id',
'table_name' => 'civicrm_membership',
'is_multiple' => FALSE,
],
// TODO: Move to civi_event extension (example: OptionValue_cg_extends_objects_grant.mgd.php)
[
'id' => 'Event',
'label' => ts('Events'),
'grouping' => 'event_type_id',
'table_name' => 'civicrm_event',
'is_multiple' => FALSE,
],
[
'id' => 'Participant',
'label' => ts('Participants'),
'grouping' => NULL,
'table_name' => 'civicrm_participant',
'is_multiple' => FALSE,
],
// TODO: Move to civi_pledge extension (example: OptionValue_cg_extends_objects_grant.mgd.php)
[
'id' => 'Pledge',
'label' => ts('Pledges'),
'grouping' => NULL,
'table_name' => 'civicrm_pledge',
'is_multiple' => FALSE,
],
[
'id' => 'Address',
'label' => ts('Addresses'),
'grouping' => NULL,
'table_name' => 'civicrm_address',
'is_multiple' => FALSE,
],
// TODO: Move to civi_campaign extension (example: OptionValue_cg_extends_objects_grant.mgd.php)
[
'id' => 'Campaign',
'label' => ts('Campaigns'),
'grouping' => 'campaign_type_id',
'table_name' => 'civicrm_campaign',
'is_multiple' => FALSE,
],
[
'id' => 'Contact',
'label' => ts('Contacts'),
'grouping' => NULL,
'table_name' => 'civicrm_contact',
'is_multiple' => TRUE,
],
];
// `CustomGroup.extends` stores contact type as if it were an entity.
foreach (CRM_Contact_BAO_ContactType::basicTypePairs(TRUE) as $contactType => $contactTypeLabel) {
foreach (CRM_Contact_BAO_ContactType::basicTypeInfo(TRUE) as $contactType => $contactInfo) {
$options[] = [
'id' => $contactType,
'label' => $contactTypeLabel,
'label' => $contactInfo['label'],
'grouping' => 'contact_sub_type',
'table_name' => 'civicrm_contact',
'is_multiple' => TRUE,
'icon' => $contactInfo['icon'],
];
}
$ogId = CRM_Core_DAO::getFieldValue('CRM_Core_DAO_OptionGroup', 'cg_extend_objects', 'id', 'name');
Expand All @@ -2504,8 +2557,12 @@ public static function getCustomGroupExtendsOptions() {
'label' => $ogValue['label'],
'grouping' => $ogValue['grouping'] ?? NULL,
'table_name' => $ogValue['name'],
'is_multiple' => !empty($ogValue['filter']),
];
}
foreach ($options as &$option) {
$option['icon'] = $option['icon'] ?? \Civi\Api4\Utils\CoreUtil::getInfoItem($option['id'], 'icon');
}
return $options;
}

Expand Down
31 changes: 12 additions & 19 deletions CRM/Core/BAO/CustomValue.php
Original file line number Diff line number Diff line change
Expand Up @@ -211,18 +211,19 @@ public static function deleteCustomValue($customValueID, $customGroupID) {
}

/**
* ACL clause for an APIv4 custom pseudo-entity (aka multi-record custom group extending Contact).
* ACL clause for an APIv4 custom pseudo-entity (aka multi-record custom group).
* @param string|null $entityName
* @param int|null $userId
* @param array $conditions
* @return array
*/
public function addSelectWhereClause(string $entityName = NULL, int $userId = NULL, array $conditions = []): array {
// To-date, custom-value-based entities are only supported for contacts.
// If this changes, $entityName variable contains the name of this custom group,
// and could be used to lookup the type of entity this custom group joins to.
// Some legacy code omits $entityName, in which case fall-back on 'Contact' which until 2023
// was the only type of entity that could be extended by multi-record custom groups.
$groupName = \Civi\Api4\Utils\CoreUtil::getCustomGroupName((string) $entityName);
$joinEntity = $groupName ? CRM_Core_BAO_CustomGroup::getEntityForGroup($groupName) : 'Contact';
$clauses = [
'entity_id' => CRM_Utils_SQL::mergeSubquery('Contact'),
'entity_id' => CRM_Utils_SQL::mergeSubquery($joinEntity),
];
CRM_Utils_Hook::selectWhereClause($entityName ?? $this, $clauses);
return $clauses;
Expand All @@ -232,7 +233,7 @@ public function addSelectWhereClause(string $entityName = NULL, int $userId = NU
* Special checkAccess function for multi-record custom pseudo-entities
*
* @param string $entityName
* Ex: 'Contact' or 'Custom_Foobar'
* APIv4-style entity name e.g. 'Custom_Foobar'
* @param string $action
* @param array $record
* @param int $userID
Expand All @@ -243,8 +244,9 @@ public function addSelectWhereClause(string $entityName = NULL, int $userId = NU
public static function _checkAccess(string $entityName, string $action, array $record, int $userID): ?bool {
// This check implements two rules: you must have access to the specific custom-data-group - and to the underlying record (e.g. Contact).

$groupName = substr($entityName, 0, 7) === 'Custom_' ? substr($entityName, 7) : NULL;
$extends = CRM_Core_DAO::getFieldValue('CRM_Core_DAO_CustomGroup', $groupName, 'extends', 'name');
// Expecting APIv4-style entity name
$groupName = \Civi\Api4\Utils\CoreUtil::getCustomGroupName($entityName);
$extends = \CRM_Core_BAO_CustomGroup::getEntityForGroup($groupName);
$id = CRM_Core_DAO::getFieldValue('CRM_Core_DAO_CustomGroup', $groupName, 'id', 'name');
if (!$groupName) {
// $groupName is required but the function signature has to match the parent.
Expand All @@ -267,17 +269,8 @@ public static function _checkAccess(string $entityName, string $action, array $r
}

// Do we have access to the target record?
if ($extends === 'Contact' || in_array($extends, CRM_Contact_BAO_ContactType::basicTypes(TRUE), TRUE)) {
return \Civi\Api4\Utils\CoreUtil::checkAccessDelegated('Contact', 'update', ['id' => $eid], $userID);
}
elseif (\Civi\Api4\Utils\CoreUtil::getApiClass($extends)) {
// For most entities (Activity, Relationship, Contribution, ad nauseum), we acn just use an eponymous API.
return \Civi\Api4\Utils\CoreUtil::checkAccessDelegated($extends, 'update', ['id' => $eid], $userID);
}
else {
// Do you need to add a special case for some oddball custom-group type?
throw new CRM_Core_Exception("Cannot assess delegated permissions for group {$groupName}.");
}
$delegatedAction = $action === 'get' ? 'get' : 'update';
return \Civi\Api4\Utils\CoreUtil::checkAccessDelegated($extends, $delegatedAction, ['id' => $eid], $userID);
}

}
7 changes: 4 additions & 3 deletions CRM/Core/DAO/CustomGroup.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*
* Generated from xml/schema/CRM/Core/CustomGroup.xml
* DO NOT EDIT. Generated by CRM_Core_CodeGen
* (GenCodeChecksum:7a53822fedde32eeb1089f09654a2d88)
* (GenCodeChecksum:05aa9cfdd47acd56178a0a33a2f6ffc1)
*/

/**
Expand Down Expand Up @@ -87,7 +87,7 @@ class CRM_Core_DAO_CustomGroup extends CRM_Core_DAO {
public $extends;

/**
* FK to civicrm_option_value.id (for option group custom_data_type.)
* FK to civicrm_option_value.value (for option group custom_data_type)
*
* @var int|string|null
* (SQL type: int unsigned)
Expand Down Expand Up @@ -384,6 +384,7 @@ public static function &fields() {
'name',
'label',
'grouping',
'icon',
],
],
'add' => '1.1',
Expand All @@ -392,7 +393,7 @@ public static function &fields() {
'name' => 'extends_entity_column_id',
'type' => CRM_Utils_Type::T_INT,
'title' => ts('Custom Group Subtype List'),
'description' => ts('FK to civicrm_option_value.id (for option group custom_data_type.)'),
'description' => ts('FK to civicrm_option_value.value (for option group custom_data_type)'),
'usage' => [
'import' => FALSE,
'export' => FALSE,
Expand Down
10 changes: 3 additions & 7 deletions CRM/Core/Form.php
Original file line number Diff line number Diff line change
Expand Up @@ -478,13 +478,9 @@ public function &add(
// Like select but accepts rich array data (with nesting, colors, icons, etc) as option list.
if ($inputType === 'select2') {
$type = 'text';
$options = [];
foreach ($attributes as $option) {
// Transform options from api4.getFields format
$option['text'] = $option['text'] ?? $option['label'];
unset($option['label']);
$options[] = $option;
}
// Options stored in $attributes. Transform from api4.getFields format if needed.
$options = CRM_Utils_Array::formatForSelect2($attributes ?: []);
// Attributes stored in $extra
$attributes = ($extra ?: []) + ['class' => ''];
$attributes['class'] = ltrim($attributes['class'] . ' crm-select2 crm-form-select2');
$attributes['data-select-params'] = json_encode(['data' => $options, 'multiple' => !empty($attributes['multiple'])]);
Expand Down
Loading