Skip to content

Commit

Permalink
CustomGroup - Add metadata about which types of entities support mult…
Browse files Browse the repository at this point in the history
…i-record custom fields

This is still only enabled for contacts, but now the switch could be flipped for other entities.
Refactors the custom group form to be metadata-driven & respect the new setting (and cleanup lots of cruft).
  • Loading branch information
colemanw committed Sep 27, 2023
1 parent 85bcab8 commit a641291
Show file tree
Hide file tree
Showing 17 changed files with 366 additions and 351 deletions.
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

0 comments on commit a641291

Please sign in to comment.