Merge pull request #23939 from civicrm/5.51
[civicrm-core.git] / CRM / Core / BAO / EntityTag.php
1 <?php
2 /*
3 +--------------------------------------------------------------------+
4 | Copyright CiviCRM LLC. All rights reserved. |
5 | |
6 | This work is published under the GNU AGPLv3 license with some |
7 | permitted exceptions and without any warranty. For full license |
8 | and copyright information, see https://civicrm.org/licensing |
9 +--------------------------------------------------------------------+
10 */
11
12 /**
13 * This class contains functions for managing Tag(tag) for a contact
14 *
15 * @package CRM
16 * @copyright CiviCRM LLC https://civicrm.org/licensing
17 */
18 class CRM_Core_BAO_EntityTag extends CRM_Core_DAO_EntityTag {
19 use CRM_Core_DynamicFKAccessTrait;
20
21 /**
22 * Given a contact id, it returns an array of tag id's the contact belongs to.
23 *
24 * @param int $entityID
25 * Id of the entity usually the contactID.
26 * @param string $entityTable
27 * Name of the entity table usually 'civicrm_contact'.
28 *
29 * @return array
30 * reference $tag array of category id's the contact belongs to.
31 */
32 public static function getTag($entityID, $entityTable = 'civicrm_contact') {
33 $tags = [];
34
35 $entityTag = new CRM_Core_BAO_EntityTag();
36 $entityTag->entity_id = $entityID;
37 $entityTag->entity_table = $entityTable;
38 $entityTag->find();
39
40 while ($entityTag->fetch()) {
41 $tags[$entityTag->tag_id] = $entityTag->tag_id;
42 }
43 return $tags;
44 }
45
46 /**
47 * Takes an associative array and creates a entityTag object.
48 *
49 * the function extract all the params it needs to initialize the create a
50 * group object. the params array could contain additional unused name/value
51 * pairs
52 *
53 * @param array $params
54 * (reference ) an assoc array of name/value pairs.
55 *
56 * @return CRM_Core_BAO_EntityTag
57 */
58 public static function add(&$params) {
59 $dataExists = self::dataExists($params);
60 if (!$dataExists) {
61 return NULL;
62 }
63
64 $entityTag = new CRM_Core_BAO_EntityTag();
65 $entityTag->copyValues($params);
66
67 // dont save the object if it already exists, CRM-1276
68 if (!$entityTag->find(TRUE)) {
69 //invoke pre hook
70 CRM_Utils_Hook::pre('create', 'EntityTag', $params['tag_id'], $params);
71
72 $entityTag->save();
73
74 //invoke post hook on entityTag
75 // we are using this format to keep things consistent between the single and bulk operations
76 // so a bit different from other post hooks
77 $object = [0 => [0 => $params['entity_id']], 1 => $params['entity_table']];
78 CRM_Utils_Hook::post('create', 'EntityTag', $params['tag_id'], $object);
79 }
80 return $entityTag;
81 }
82
83 /**
84 * Check if there is data to create the object.
85 *
86 * @param array $params
87 * An assoc array of name/value pairs.
88 *
89 * @return bool
90 */
91 public static function dataExists($params) {
92 return !($params['tag_id'] == 0);
93 }
94
95 /**
96 * Delete the tag for a contact.
97 *
98 * @param array $params
99 * @deprecated
100 * WARNING: Nonstandard params searches by tag_id rather than id!
101 */
102 public static function del(&$params) {
103 //invoke pre hook
104 if (!empty($params['tag_id'])) {
105 CRM_Utils_Hook::pre('delete', 'EntityTag', $params['tag_id'], $params);
106 }
107
108 $entityTag = new CRM_Core_BAO_EntityTag();
109 $entityTag->copyValues($params);
110 $entityTag->delete();
111
112 //invoke post hook on entityTag
113 if (!empty($params['tag_id'])) {
114 $object = [0 => [0 => $params['entity_id']], 1 => $params['entity_table']];
115 CRM_Utils_Hook::post('delete', 'EntityTag', $params['tag_id'], $object);
116 }
117 }
118
119 /**
120 * Given an array of entity ids and entity table, add all the entity to the tags.
121 *
122 * @param array $entityIds
123 * (reference ) the array of entity ids to be added.
124 * @param int $tagId
125 * The id of the tag.
126 * @param string $entityTable
127 * Name of entity table default:civicrm_contact.
128 * @param bool $applyPermissions
129 * Should permissions be applied in this function.
130 *
131 * @return array
132 * (total, added, notAdded) count of entities added to tag
133 */
134 public static function addEntitiesToTag(&$entityIds, $tagId, $entityTable, $applyPermissions) {
135 $numEntitiesAdded = 0;
136 $numEntitiesNotAdded = 0;
137 $entityIdsAdded = [];
138
139 //invoke pre hook for entityTag
140 $preObject = [$entityIds, $entityTable];
141 CRM_Utils_Hook::pre('create', 'EntityTag', $tagId, $preObject);
142
143 foreach ($entityIds as $entityId) {
144 // CRM-17350 - check if we have permission to edit the contact
145 // that this tag belongs to.
146 if ($applyPermissions && !self::checkPermissionOnEntityTag($entityId, $entityTable)) {
147 $numEntitiesNotAdded++;
148 continue;
149 }
150 $tag = new CRM_Core_DAO_EntityTag();
151
152 $tag->entity_id = $entityId;
153 $tag->tag_id = $tagId;
154 $tag->entity_table = $entityTable;
155 if (!$tag->find()) {
156 $tag->save();
157 $entityIdsAdded[] = $entityId;
158 $numEntitiesAdded++;
159 }
160 else {
161 $numEntitiesNotAdded++;
162 }
163 }
164
165 //invoke post hook on entityTag
166 $object = [$entityIdsAdded, $entityTable];
167 CRM_Utils_Hook::post('create', 'EntityTag', $tagId, $object);
168
169 CRM_Contact_BAO_GroupContactCache::opportunisticCacheFlush();
170
171 return [count($entityIds), $numEntitiesAdded, $numEntitiesNotAdded];
172 }
173
174 /**
175 * Basic check for ACL permission on editing/creating/removing a tag.
176 *
177 * In the absence of something better contacts get a proper check and other entities
178 * default to 'edit all contacts'. This is currently only accessed from the api which previously
179 * applied edit all contacts to all - so while still too restrictive it represents a loosening.
180 *
181 * Current possible entities are attachments, activities, cases & contacts.
182 *
183 * @param int $entityID
184 * @param string $entityTable
185 *
186 * @return bool
187 */
188 public static function checkPermissionOnEntityTag($entityID, $entityTable) {
189 if ($entityTable == 'civicrm_contact') {
190 return CRM_Contact_BAO_Contact_Permission::allow($entityID, CRM_Core_Permission::EDIT);
191 }
192 else {
193 return CRM_Core_Permission::check('edit all contacts');
194 }
195 }
196
197 /**
198 * Given an array of entity ids and entity table, remove entity(s)tags.
199 *
200 * @param array $entityIds
201 * (reference ) the array of entity ids to be removed.
202 * @param int $tagId
203 * The id of the tag.
204 * @param string $entityTable
205 * Name of entity table default:civicrm_contact.
206 * @param bool $applyPermissions
207 * Should permissions be applied in this function.
208 *
209 * @return array
210 * (total, removed, notRemoved) count of entities removed from tags
211 */
212 public static function removeEntitiesFromTag(&$entityIds, $tagId, $entityTable, $applyPermissions) {
213 $numEntitiesRemoved = 0;
214 $numEntitiesNotRemoved = 0;
215 $entityIdsRemoved = [];
216
217 //invoke pre hook for entityTag
218 $preObject = [$entityIds, $entityTable];
219 CRM_Utils_Hook::pre('delete', 'EntityTag', $tagId, $preObject);
220
221 foreach ($entityIds as $entityId) {
222 // CRM-17350 - check if we have permission to edit the contact
223 // that this tag belongs to.
224 if ($applyPermissions && !self::checkPermissionOnEntityTag($entityId, $entityTable)) {
225 $numEntitiesNotRemoved++;
226 continue;
227 }
228 $tag = new CRM_Core_DAO_EntityTag();
229
230 $tag->entity_id = $entityId;
231 $tag->tag_id = $tagId;
232 $tag->entity_table = $entityTable;
233 if ($tag->find()) {
234 $tag->delete();
235 $entityIdsRemoved[] = $entityId;
236 $numEntitiesRemoved++;
237 }
238 else {
239 $numEntitiesNotRemoved++;
240 }
241 }
242
243 //invoke post hook on entityTag
244 $object = [$entityIdsRemoved, $entityTable];
245 CRM_Utils_Hook::post('delete', 'EntityTag', $tagId, $object);
246
247 CRM_Contact_BAO_GroupContactCache::opportunisticCacheFlush();
248
249 return [count($entityIds), $numEntitiesRemoved, $numEntitiesNotRemoved];
250 }
251
252 /**
253 * Takes an associative array and creates tag entity record for all tag entities.
254 *
255 * @param array $params
256 * (reference) an assoc array of name/value pairs.
257 * @param string $entityTable
258 * @param int $entityID
259 */
260 public static function create(&$params, $entityTable, $entityID) {
261 // get categories for the entity id
262 $entityTag = CRM_Core_BAO_EntityTag::getTag($entityID, $entityTable);
263
264 // get the list of all the categories
265 $allTag = CRM_Core_BAO_Tag::getTags($entityTable);
266
267 // this fix is done to prevent warning generated by array_key_exits incase of empty array is given as input
268 if (!is_array($params)) {
269 $params = [];
270 }
271
272 // this fix is done to prevent warning generated by array_key_exits incase of empty array is given as input
273 if (!is_array($entityTag)) {
274 $entityTag = [];
275 }
276
277 // check which values has to be inserted/deleted for contact
278 foreach ($allTag as $key => $varValue) {
279 $tagParams['entity_table'] = $entityTable;
280 $tagParams['entity_id'] = $entityID;
281 $tagParams['tag_id'] = $key;
282
283 if (array_key_exists($key, $params) && !array_key_exists($key, $entityTag)) {
284 // insert a new record
285 CRM_Core_BAO_EntityTag::add($tagParams);
286 }
287 elseif (!array_key_exists($key, $params) && array_key_exists($key, $entityTag)) {
288 // delete a record for existing contact
289 CRM_Core_BAO_EntityTag::del($tagParams);
290 }
291 }
292 }
293
294 /**
295 * Get contact tags.
296 *
297 * @param int $contactID
298 * @param bool $count
299 *
300 * @return array|int
301 * Dependant on $count
302 */
303 public static function getContactTags($contactID, $count = FALSE) {
304 $contactTags = [];
305 if (!$count) {
306 $select = "SELECT ct.id, ct.name ";
307 }
308 else {
309 $select = "SELECT count(*) as cnt";
310 }
311
312 $query = "{$select}
313 FROM civicrm_tag ct
314 INNER JOIN civicrm_entity_tag et ON ( ct.id = et.tag_id AND
315 et.entity_id = {$contactID} AND
316 et.entity_table = 'civicrm_contact' AND
317 ct.is_tagset = 0 )";
318
319 $dao = CRM_Core_DAO::executeQuery($query);
320
321 if ($count) {
322 $dao->fetch();
323 return (int) $dao->cnt;
324 }
325
326 while ($dao->fetch()) {
327 $contactTags[$dao->id] = $dao->name;
328 }
329
330 return $contactTags;
331 }
332
333 /**
334 * Get child contact tags given parentId.
335 *
336 * @param int $parentId
337 * @param int $entityId
338 * @param string $entityTable
339 *
340 * @return array
341 */
342 public static function getChildEntityTags($parentId, $entityId, $entityTable = 'civicrm_contact') {
343 $entityTags = [];
344 $query = "SELECT ct.id as tag_id, name FROM civicrm_tag ct
345 INNER JOIN civicrm_entity_tag et ON ( et.entity_id = {$entityId} AND
346 et.entity_table = '{$entityTable}' AND et.tag_id = ct.id)
347 WHERE ct.parent_id = {$parentId}";
348
349 $dao = CRM_Core_DAO::executeQuery($query);
350
351 while ($dao->fetch()) {
352 $entityTags[$dao->tag_id] = [
353 'id' => $dao->tag_id,
354 'name' => $dao->name,
355 ];
356 }
357
358 return $entityTags;
359 }
360
361 /**
362 * Merge two tags
363 *
364 * Tag A will inherit all of tag B's properties.
365 * Tag B will be deleted.
366 *
367 * @param int $tagAId
368 * @param int $tagBId
369 *
370 * @return array
371 */
372 public static function mergeTags($tagAId, $tagBId) {
373 $queryParams = [
374 1 => [$tagAId, 'Integer'],
375 2 => [$tagBId, 'Integer'],
376 ];
377
378 // re-compute used_for field
379 $query = "SELECT id, name, used_for FROM civicrm_tag WHERE id IN (%1, %2)";
380 $dao = CRM_Core_DAO::executeQuery($query, $queryParams);
381 $tags = [];
382 while ($dao->fetch()) {
383 $label = ($dao->id == $tagAId) ? 'tagA' : 'tagB';
384 $tags[$label] = $dao->name;
385 $tags["{$label}_used_for"] = $dao->used_for ? explode(",", $dao->used_for) : [];
386 }
387 $usedFor = array_merge($tags["tagA_used_for"], $tags["tagB_used_for"]);
388 $usedFor = implode(',', array_unique($usedFor));
389 $tags["used_for"] = explode(",", $usedFor);
390
391 // get all merge queries together
392 $sqls = [
393 // 1. update entity tag entries
394 "UPDATE IGNORE civicrm_entity_tag SET tag_id = %1 WHERE tag_id = %2",
395 // 2. move children
396 "UPDATE civicrm_tag SET parent_id = %1 WHERE parent_id = %2",
397 // 3. update used_for info for tag A & children
398 "UPDATE civicrm_tag SET used_for = '{$usedFor}' WHERE id = %1 OR parent_id = %1",
399 // 4. delete tag B
400 "DELETE FROM civicrm_tag WHERE id = %2",
401 // 5. remove duplicate entity tag records
402 "DELETE et2.* from civicrm_entity_tag et1 INNER JOIN civicrm_entity_tag et2 ON et1.entity_table = et2.entity_table AND et1.entity_id = et2.entity_id AND et1.tag_id = et2.tag_id WHERE et1.id < et2.id",
403 // 6. remove orphaned entity_tags
404 "DELETE FROM civicrm_entity_tag WHERE tag_id = %2",
405 ];
406 $tables = ['civicrm_entity_tag', 'civicrm_tag'];
407
408 // Allow hook_civicrm_merge() to add SQL statements for the merge operation AND / OR
409 // perform any other actions like logging
410 CRM_Utils_Hook::merge('sqls', $sqls, $tagAId, $tagBId, $tables);
411
412 // call the SQL queries in one transaction
413 $transaction = new CRM_Core_Transaction();
414 foreach ($sqls as $sql) {
415 CRM_Core_DAO::executeQuery($sql, $queryParams, TRUE, NULL, TRUE);
416 }
417 $transaction->commit();
418
419 $tags['status'] = TRUE;
420 return $tags;
421 }
422
423 /**
424 * Get options for a given field.
425 *
426 * @see CRM_Core_DAO::buildOptions
427 * @see CRM_Core_DAO::buildOptionsContext
428 *
429 * @param string $fieldName
430 * @param string $context
431 * As per CRM_Core_DAO::buildOptionsContext.
432 * @param array $props
433 * whatever is known about this dao object.
434 *
435 * @return array|bool
436 */
437 public static function buildOptions($fieldName, $context = NULL, $props = []) {
438 $params = [];
439
440 if ($fieldName == 'tag' || $fieldName == 'tag_id') {
441 $table = 'civicrm_contact';
442 if (!empty($props['entity_table'])) {
443 $table = CRM_Utils_Type::escape($props['entity_table'], 'String');
444 $params['condition'][] = "used_for LIKE '%$table%'";
445 }
446
447 // Output tag list as nested hierarchy
448 // TODO: This will only work when api.entity is "entity_tag". What about others?
449 if ($context == 'search' || $context == 'create') {
450 $dummyArray = [];
451 return CRM_Core_BAO_Tag::getTags($table, $dummyArray, NULL, '- ');
452 }
453 }
454
455 $options = CRM_Core_PseudoConstant::get(__CLASS__, $fieldName, $params, $context);
456
457 // Special formatting for validate/match context
458 if ($fieldName == 'entity_table' && in_array($context, ['validate', 'match'])) {
459 $options = [];
460 foreach (self::buildOptions($fieldName) as $tableName => $label) {
461 $bao = CRM_Core_DAO_AllCoreTables::getClassForTable($tableName);
462 $apiName = CRM_Core_DAO_AllCoreTables::getBriefName($bao);
463 $options[$tableName] = $apiName;
464 }
465 }
466
467 return $options;
468 }
469
470 /**
471 * This function deletes entity tags when a related entity is called.
472 *
473 * It is registered as a listener in \Civi\Core\Container::createEventDispatcher
474 *
475 * @param \Civi\Core\DAO\Event\PreDelete $event
476 */
477 public static function preDeleteOtherEntity($event) {
478 if (
479 $event->object instanceof CRM_Core_DAO_EntityTag
480 // Activity can call the pre hook for delete with no ID - this seems to be isolated to activity....
481 // @todo - what is the correct way to standardise activity delete?
482 || ($event->object instanceof CRM_Activity_DAO_Activity && !$event->object->id)
483
484 ) {
485 return;
486 }
487 // This is probably fairly mild in terms of helping performance - a case could be made to check if tags
488 // exist before deleting (further down) as delete is a locking action.
489 $entity = CRM_Core_DAO_AllCoreTables::getBriefName(get_class($event->object));
490 if ($entity && !isset(Civi::$statics[__CLASS__]['tagged_entities'][$entity])) {
491 $tableName = CRM_Core_DAO_AllCoreTables::getTableForEntityName($entity);
492 $used_for = CRM_Core_OptionGroup::values('tag_used_for');
493 Civi::$statics[__CLASS__]['tagged_entities'][$entity] = !empty($used_for[$tableName]) ? $tableName : FALSE;
494 }
495
496 if (!empty(Civi::$statics[__CLASS__]['tagged_entities'][$entity])) {
497 CRM_Core_DAO::executeQuery('DELETE FROM civicrm_entity_tag WHERE entity_table = %1 AND entity_id = %2',
498 [1 => [Civi::$statics[__CLASS__]['tagged_entities'][$entity], 'String'], 2 => [$event->object->id, 'Integer']]
499 );
500 }
501
502 }
503
504 }