From f09d6d1f591ececf0a12db4628c953c6fcc2c652 Mon Sep 17 00:00:00 2001
From: Jaap Jansma <jaap.jansma@civicoop.org>
Date: Thu, 22 Jun 2023 11:23:01 +0200
Subject: [PATCH] Added filter: Contact has number of activities in period

---
 CHANGELOG.md                                  |   1 +
 CRM/Dataprocessor/Utils/Form.php              |   6 +-
 Civi/DataProcessor/Factory.php                |   1 +
 .../AbstractFieldInPeriodFilter.php           |   3 +-
 .../ContactHasActivityInPeriodFilter.php      |  39 ++--
 ...actHasNumberOfActivitiesInPeriodFilter.php | 187 ++++++++++++++++++
 ...actHasNumberOfActivitiesInPeriodFilter.tpl |  29 +++
 7 files changed, 251 insertions(+), 15 deletions(-)
 create mode 100644 Civi/DataProcessor/FilterHandler/ContactHasNumberOfActivitiesInPeriodFilter.php
 create mode 100644 templates/CRM/Dataprocessor/Form/Filter/ContactHasNumberOfActivitiesInPeriodFilter.tpl

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7117eb68..8f93db12 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,6 @@
 # Version 1.73 (not yet released)
 
+* Added filter: Contact has number of activities in period
 * Added filter: Most recent event date
 
 # Version 1.72
diff --git a/CRM/Dataprocessor/Utils/Form.php b/CRM/Dataprocessor/Utils/Form.php
index 15f1e598..5228fcbe 100644
--- a/CRM/Dataprocessor/Utils/Form.php
+++ b/CRM/Dataprocessor/Utils/Form.php
@@ -28,8 +28,9 @@ class CRM_Dataprocessor_Utils_Form {
    * @param array $additionalOptions
    * @param string $to string to append to the to field.
    * @param string $from string to append to the from field.
+   * @param string $additionalClass
    */
-  public static function addDatePickerRange($form, $fieldName, $label, $isDateTime = FALSE, $required = FALSE, $fromLabel = 'From', $toLabel = 'To', $additionalOptions = [], $to = '_high', $from = '_low') {
+  public static function addDatePickerRange($form, $fieldName, $label, $isDateTime = FALSE, $required = FALSE, $fromLabel = 'From', $toLabel = 'To', $additionalOptions = [], $to = '_high', $from = '_low', $additionalClass='') {
     $options = [
         '' => ts('- any -'),
         0 => ts('Choose Date Range'),
@@ -40,13 +41,12 @@ class CRM_Dataprocessor_Utils_Form {
         $options[$key] = $optionLabel;
       }
     }
-
     $form->add('select',
       "{$fieldName}_relative",
       $label,
       $options,
       $required,
-      ['class' => 'crm-select2']
+      ['class' => 'crm-select2 ' . $additionalClass]
     );
     $attributes = ['formatType' => 'searchDate'];
     $extra = ['time' => $isDateTime];
diff --git a/Civi/DataProcessor/Factory.php b/Civi/DataProcessor/Factory.php
index 96bc3160..20cc9d02 100644
--- a/Civi/DataProcessor/Factory.php
+++ b/Civi/DataProcessor/Factory.php
@@ -177,6 +177,7 @@ class Factory {
     $this->addFilter('contact_has_active_recurring_contribution', new Definition('Civi\DataProcessor\FilterHandler\ContactHasActiveRecurringContributions'), E::ts('Contact has active recurring contributions'));
     $this->addFilter('contact_has_relationship', new Definition('Civi\DataProcessor\FilterHandler\ContactHasRelationship'), E::ts('Contact has a relationship'));
     $this->addFilter('contact_has_activity_in_period_filter', new Definition('Civi\DataProcessor\FilterHandler\ContactHasActivityInPeriodFilter'), E::ts('Contact has activity in period'));
+    $this->addFilter('contact_has_number_activities_in_period_filter', new Definition('Civi\DataProcessor\FilterHandler\ContactHasNumberOfActivitiesInPeriodFilter'), E::ts('Contact has number of activities in period'));
     $this->addFilter('worldregion_filter', new Definition('Civi\DataProcessor\FilterHandler\WorldRegionFilter'), E::ts('World Region Filter'));
     $this->addFilter('contact_type_filter', new Definition('Civi\DataProcessor\FilterHandler\ContactTypeFilter'), E::ts('Contact Type filter'));
     $this->addFilter('permission_to_view_contact', new Definition('Civi\DataProcessor\FilterHandler\PermissionToViewContactFilter'), E::ts('Permission to view contact'));
diff --git a/Civi/DataProcessor/FilterHandler/AbstractFieldInPeriodFilter.php b/Civi/DataProcessor/FilterHandler/AbstractFieldInPeriodFilter.php
index d8e15a14..2d1de2d8 100644
--- a/Civi/DataProcessor/FilterHandler/AbstractFieldInPeriodFilter.php
+++ b/Civi/DataProcessor/FilterHandler/AbstractFieldInPeriodFilter.php
@@ -60,7 +60,8 @@ abstract class AbstractFieldInPeriodFilter extends AbstractFieldFilterHandler {
     } catch (CRM_Core_Exception $e) {
     }
 
-    CRM_Dataprocessor_Utils_Form::addDatePickerRange($form, $alias, $title, FALSE, FALSE, E::ts('From'), E::ts('To'));
+    CRM_Dataprocessor_Utils_Form::addDatePickerRange($form, $alias, $title, FALSE, FALSE, E::ts('From'), E::ts('To'), [], '_high', '_low', $sizeClass);
+
     if (isset($defaultFilterValue['value'])) {
       $defaults[$alias.'_value'] = $defaultFilterValue['value'];
     }
diff --git a/Civi/DataProcessor/FilterHandler/ContactHasActivityInPeriodFilter.php b/Civi/DataProcessor/FilterHandler/ContactHasActivityInPeriodFilter.php
index 0f494cf9..c9453043 100644
--- a/Civi/DataProcessor/FilterHandler/ContactHasActivityInPeriodFilter.php
+++ b/Civi/DataProcessor/FilterHandler/ContactHasActivityInPeriodFilter.php
@@ -310,32 +310,49 @@ class ContactHasActivityInPeriodFilter extends AbstractFieldInPeriodFilter {
         $tableAlias = $this->getTableAlias($dataFlow);
         $fieldName = $this->inputFieldSpecification->getName();
         $fieldAlias = $this->inputFieldSpecification->alias;
-        $sqlStatement = "`$tableAlias`.`$fieldName` {$filterParams['op']} (
-          SELECT `contact_id`
+
+        $select = "SELECT `contact_id`";
+        $from = "
           FROM `civicrm_activity` `activity_$fieldAlias`
           INNER JOIN `civicrm_activity_contact` `activity_contact_$fieldAlias` ON `activity_$fieldAlias`.`id` = `activity_contact_$fieldAlias`.`activity_id`
-          WHERE 1";
+        ";
+        $where = "WHERE 1";
         if (isset($filterParams['status_ids']) && is_array($filterParams['status_ids']) && count($filterParams['status_ids'])) {
-          $sqlStatement .= " AND `activity_$fieldAlias}`.`status_id` IN (" . implode(",", $filterParams['status_ids']) . ")";
+          $where .= " AND `activity_$fieldAlias`.`status_id` IN (" . implode(",", $filterParams['status_ids']) . ")";
         }
         if (isset($filterParams['type_ids']) && is_array($filterParams['type_ids']) && count($filterParams['type_ids'])) {
-          $sqlStatement .= " AND `activity_$fieldAlias`.`activity_type_id` IN (" . implode(",", $filterParams['type_ids']) . ")";
+          $where .= " AND `activity_$fieldAlias`.`activity_type_id` IN (" . implode(",", $filterParams['type_ids']) . ")";
         }
         if (isset($filterParams['record_type_ids']) && is_array($filterParams['record_type_ids']) && count($filterParams['record_type_ids'])) {
-          $sqlStatement .= " AND `activity_contact_$fieldAlias`.`record_type_id` IN (" . implode(",", $filterParams['record_type_ids']) . ")";
+          $where .= " AND `activity_contact_$fieldAlias`.`record_type_id` IN (" . implode(",", $filterParams['record_type_ids']) . ")";
         }
         if (isset($filterParams['campaign_ids']) && is_array($filterParams['campaign_ids']) && count($filterParams['campaign_ids'])) {
-          $sqlStatement .= " AND `activity_$fieldAlias`.`campaign_id` IN (" . implode(",", $filterParams['campaign_ids']) . ")";
+          $where .= " AND `activity_$fieldAlias`.`campaign_id` IN (" . implode(",", $filterParams['campaign_ids']) . ")";
         }
-        $sqlStatement .= " AND " . $this->getDateSqlStatement('activity_'.$fieldAlias, 'activity_date_time', $filterParams);
-        $sqlStatement .= ")";
-        $this->whereClause = new SqlDataFlow\PureSqlStatementClause($sqlStatement, FALSE);
+        $where .= " AND " . $this->getDateSqlStatement('activity_'.$fieldAlias, 'activity_date_time', $filterParams);
+
+        $subQueryStatement = $this->getSubQueryStatement($select, $from, $where, $filterParams);
+        $this->whereClause = new SqlDataFlow\PureSqlStatementClause("`$tableAlias`.`$fieldName` {$filterParams['op']} ($subQueryStatement)", FALSE);
         $this->filterCollection->addWhere($this->whereClause);
       }
     } catch (Exception $e) {
     }
   }
 
-
+  /**
+   * Returns the subquery statement.
+   *
+   * This function can be overridden in child classes to alter the subquery.
+   *
+   * @param string $select
+   * @param string $from
+   * @param string $where
+   * @param array $filterParams
+   *
+   * @return string
+   */
+  protected function getSubQueryStatement(string $select, string $from, string $where, array $filterParams): string {
+    return trim($select) . " " . trim($from) . " " . trim($where);
+  }
 
 }
diff --git a/Civi/DataProcessor/FilterHandler/ContactHasNumberOfActivitiesInPeriodFilter.php b/Civi/DataProcessor/FilterHandler/ContactHasNumberOfActivitiesInPeriodFilter.php
new file mode 100644
index 00000000..1276e3e4
--- /dev/null
+++ b/Civi/DataProcessor/FilterHandler/ContactHasNumberOfActivitiesInPeriodFilter.php
@@ -0,0 +1,187 @@
+<?php
+/**
+ * Copyright (C) 2023  Jaap Jansma (jaap.jansma@civicoop.org)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+namespace Civi\DataProcessor\FilterHandler;
+
+use Civi\DataProcessor\DataSpecification\FieldSpecification;
+use CRM_Core_Exception;
+use CRM_Core_Form;
+use CRM_Dataprocessor_ExtensionUtil as E;
+use CRM_Utils_Type;
+
+class ContactHasNumberOfActivitiesInPeriodFilter extends ContactHasActivityInPeriodFilter {
+
+  protected function getOperatorOptions(FieldSpecification $fieldSpec): array {
+    return array(
+      'IN' => E::ts('Has number of activities in period'),
+      'NOT IN' => E::ts('Does not have this number of activities in period'),
+    );
+  }
+
+  /**
+   * Add addition filter fields to the API specs.
+   *
+   * @param array $specs
+   *
+   * @return array
+   */
+  public function enhanceApi3FieldSpec(array $specs): array {
+    $specs = parent::enhanceApi3FieldSpec($specs);
+    $fieldSpec = $this->getFieldSpecification();
+    $alias = $fieldSpec->alias;
+    $specs["{$alias}_min"] = [
+      'name' => "{$alias}_min",
+      'title' => $fieldSpec->title .': '.E::ts('Min number of activities'),
+      'api.filter' => TRUE,
+      'api.return' => FALSE,
+      'type' => CRM_Utils_Type::T_INT,
+    ];
+    $specs["{$alias}_max"] = [
+      'name' => "{$alias}_max",
+      'title' => $fieldSpec->title .': '.E::ts('Max number of activities'),
+      'api.filter' => TRUE,
+      'api.return' => FALSE,
+      'type' => CRM_Utils_Type::T_INT,
+    ];
+    return $specs;
+  }
+
+  /**
+   * Add the elements to the filter form.
+   *
+   * @param \CRM_Core_Form $form
+   * @param array $defaultFilterValue
+   * @param string $size
+   *   Possible values: full or compact
+   * @return array
+   *   Return variables belonging to this filter.
+   */
+  public function addToFilterForm(CRM_Core_Form $form, $defaultFilterValue, $size='full'): array {
+    $filter = parent::addToFilterForm($form, $defaultFilterValue, $size);
+    $fieldSpec = $this->getFieldSpecification();
+    $alias = $fieldSpec->alias;
+
+    try {
+      $form->add('text', "{$alias}_min", E::ts('Min'), [
+        'class' => 'four',
+      ]);
+      $form->add('text', "{$alias}_max", E::ts('Max'), [
+        'class' => 'four',
+      ]);
+    } catch (CRM_Core_Exception $e) {
+    }
+
+    $defaults = [];
+    if (isset($defaultFilterValue['min'])) {
+      $defaults[$alias.'_min'] = $defaultFilterValue['min'];
+    }
+    if (isset($defaultFilterValue['max'])) {
+      $defaults[$alias.'_max'] = $defaultFilterValue['max'];
+    }
+    if (count($defaults)) {
+      $form->setDefaults($defaults);
+    }
+    return $filter;
+  }
+
+  /**
+   * File name of the template to add this filter to the criteria form.
+   *
+   * @return string
+   */
+  public function getTemplateFileName(): string {
+    return "CRM/Dataprocessor/Form/Filter/ContactHasNumberOfActivitiesInPeriodFilter.tpl";
+  }
+
+  /**
+   * Process the submitted values to a filter value
+   * Which could then be processed by applyFilter function
+   *
+   * @param $submittedValues
+   * @return array
+   */
+  public function processSubmittedValues($submittedValues): array {
+    $return = parent::processSubmittedValues($submittedValues);
+    $filterSpec = $this->getFieldSpecification();
+    $alias = $filterSpec->alias;
+    if (isset($submittedValues[$alias.'_min'])) {
+      $return['min'] = $submittedValues[$alias . '_min'];
+    }
+    if (isset($submittedValues[$alias.'_max'])) {
+      $return['max'] = $submittedValues[$alias . '_max'];
+    }
+    return $return;
+  }
+
+  /**
+   * Extend the filter params from the submitted values.
+   *
+   * @param array $submittedValues
+   * @param array $filterParams
+   *
+   * @return array
+   */
+  public function extendFilterParamsFromSubmittedValues(array $submittedValues, array $filterParams): array {
+    $filterParams = parent::extendFilterParamsFromSubmittedValues($submittedValues, $filterParams);
+    if (isset($submittedValues['min']) && is_numeric($submittedValues['min']) && strlen($submittedValues['min'])) {
+      $filterParams['min'] = $submittedValues['min'];
+    }
+    if (isset($submittedValues['max']) && is_numeric($submittedValues['max']) && strlen($submittedValues['max'])) {
+      $filterParams['max'] = $submittedValues['max'];
+    }
+    return $filterParams;
+  }
+
+  /**
+   * Returns the subquery statement.
+   *
+   * This function can be overridden in child classes to alter the subquery.
+   *
+   * @param string $select
+   * @param string $from
+   * @param string $where
+   * @param array $filterParams
+   *
+   * @return string
+   */
+  protected function getSubQueryStatement(string $select, string $from, string $where, array $filterParams): string {
+    $groupBy = "GROUP BY `contact_id`";
+    $havingClauses = [];
+    $having = "";
+
+    $fieldAlias = $this->inputFieldSpecification->alias;
+    if (isset($filterParams['min']) && strlen($filterParams['min']) && is_numeric($filterParams['min'])) {
+      try {
+        $havingClauses[] = "COUNT(`activity_$fieldAlias`.`id`) >= " . CRM_Utils_Type::escape($filterParams['min'], "Positive");
+      } catch (CRM_Core_Exception $e) {
+      }
+    }
+    if (isset($filterParams['max']) && strlen($filterParams['max']) && is_numeric($filterParams['max'])) {
+      try {
+        $havingClauses[] = "COUNT(`activity_$fieldAlias`.`id`) <= " . CRM_Utils_Type::escape($filterParams['max'], "Positive");
+      } catch (CRM_Core_Exception $e) {
+      }
+    }
+    if (count($havingClauses)) {
+      $having = "HAVING " . implode(" AND ", $havingClauses);
+    }
+
+    return trim(trim($select) . " " . trim($from) . " " . trim($where) . " ".$groupBy . " " . $having);
+  }
+
+}
diff --git a/templates/CRM/Dataprocessor/Form/Filter/ContactHasNumberOfActivitiesInPeriodFilter.tpl b/templates/CRM/Dataprocessor/Form/Filter/ContactHasNumberOfActivitiesInPeriodFilter.tpl
new file mode 100644
index 00000000..c1103a00
--- /dev/null
+++ b/templates/CRM/Dataprocessor/Form/Filter/ContactHasNumberOfActivitiesInPeriodFilter.tpl
@@ -0,0 +1,29 @@
+{crmScope extensionKey='dataprocessor'}
+{assign var=fieldOp     value=$filter.alias|cat:"_op"}
+{assign var=filterMin   value=$filter.alias|cat:"_min"}
+{assign var=filterMax   value=$filter.alias|cat:"_max"}
+{assign var=filterStatusIds   value=$filter.alias|cat:"_status_ids"}
+{assign var=filterActivityTypeIds   value=$filter.alias|cat:"_type_ids"}
+{assign var=filterRecordTypeIds   value=$filter.alias|cat:"_record_type_ids"}
+{assign var=filterCampaignIds   value=$filter.alias|cat:"_campaign_ids"}
+
+<tr>
+    <td class="label">{$filter.title}</td>
+    <td>
+      {if $form.$fieldOp.html}
+      <span class="filter-processor-element filter-{$filter.alias}">{$form.$fieldOp.html}</span>
+      <span class="filter-processor-show-close filter-{$filter.alias}">&nbsp;</span>
+      {/if}
+    </td>
+    <td>
+      {include file="CRM/Dataprocessor/Form/Filter/DateRange.tpl" fieldName=$filter.alias from='_low' to='_high'}
+      <p>
+      {ts}Number of activities: {/ts}<br /><span id="{$filterMin}_max_cell">{$form.$filterMin.label}&nbsp;{$form.$filterMin.html}&nbsp;&nbsp;{$form.$filterMax.label}&nbsp;{$form.$filterMax.html}</span> <br />
+      {ts}With status: {/ts}<br /><span id="{$filterStatusIds}_cell">{$form.$filterStatusIds.html}</span> <br />
+      {ts}With activity type: {/ts}<br /><span id="{$filterActivityTypeIds}_cell">{$form.$filterActivityTypeIds.html}</span> <br />
+      {ts}With contact record type: {/ts}<br /><span id="{$filterRecordTypeIds}_cell">{$form.$filterRecordTypeIds.html}</span> <br />
+      {ts}With campaign: {/ts}<br /><span id="{$filterCampaignIds}_cell">{$form.$filterCampaignIds.html}</span>
+      </p>
+    </td>
+</tr>
+{/crmScope}
-- 
GitLab