From 2ae781f6e3262fbc19f7a1884fc9fd3d3da2afc6 Mon Sep 17 00:00:00 2001 From: Rich Lott / Artful Robot Date: Wed, 26 Mar 2025 10:13:00 +0000 Subject: [PATCH] update tests, fix stuff, implement bespoke save, create actions for contactcategory --- CRM/Contactcats/DAO/ContactCategory.php | 13 +- Civi/Api4/Action/ContactCategory/Create.php | 28 ++++ Civi/Api4/Action/ContactCategory/GetFlows.php | 152 ++++++++++++++++-- Civi/Api4/Action/ContactCategory/Save.php | 46 ++++++ Civi/Api4/ContactCategory.php | 20 +++ .../Action/ContactCategory/GetFlowsTest.php | 43 +++-- 6 files changed, 273 insertions(+), 29 deletions(-) create mode 100644 Civi/Api4/Action/ContactCategory/Create.php create mode 100644 Civi/Api4/Action/ContactCategory/Save.php diff --git a/CRM/Contactcats/DAO/ContactCategory.php b/CRM/Contactcats/DAO/ContactCategory.php index cf50f46..d66df7e 100644 --- a/CRM/Contactcats/DAO/ContactCategory.php +++ b/CRM/Contactcats/DAO/ContactCategory.php @@ -8,12 +8,15 @@ * * This stub provides compatibility. It is not intended to be modified in a * substantive way. Property annotations may be added, but are not required. - * @property string $id - * @property string $contact_id - * @property string $category - * @property string $next_category + * + * TODO: these are wrong, if it matters: + * + * @property string $id + * @property string $contact_id + * @property string $category + * @property string $next_category */ -class CRM_Contactcats_DAO_ContactCategory extends CRM_Contactcats_DAO_Base { +class CRM_Contactcats_DAO_ContactCategory extends CRM_Core_DAO_Base { /** * Required by older versions of CiviCRM (<5.74). diff --git a/Civi/Api4/Action/ContactCategory/Create.php b/Civi/Api4/Action/ContactCategory/Create.php new file mode 100644 index 0000000..d276942 --- /dev/null +++ b/Civi/Api4/Action/ContactCategory/Create.php @@ -0,0 +1,28 @@ +getCheckPermissions()) + ->addRecord($this->values) + ->execute()->getArrayCopy(); + $result->exchangeArray($saveResults); + } + +} diff --git a/Civi/Api4/Action/ContactCategory/GetFlows.php b/Civi/Api4/Action/ContactCategory/GetFlows.php index 502c630..bc3f5cd 100644 --- a/Civi/Api4/Action/ContactCategory/GetFlows.php +++ b/Civi/Api4/Action/ContactCategory/GetFlows.php @@ -72,6 +72,7 @@ class GetFlows extends \Civi\Api4\Generic\AbstractAction { // $result['windowFunctionsSupported'] = $this->windowFunctionsSupported(); // if ($result['windowFunctionsSupported']) { if ($this->windowFunctionsSupported()) { + // $this->debug1($startDate, $endDate); $this->solveWithWindowFunctions($result, $startDate, $endDate); } else { @@ -114,19 +115,75 @@ class GetFlows extends \Civi\Api4\Generic\AbstractAction { return $supported; } - /** - * - */ - protected function solveWithWindowFunctions(Result $result, string $startDateYmd, ?string $endDateYmd) { + protected function debug1(string $startDateYmd, ?string $endDateYmd) { - $sql = << [$startDateYmd, 'Int'], 2 => [$endDateYmd, 'Int']]; + $params1 = [1 => [$startDateYmd, 'Int']]; + + $this->dump("all activities", <<activityTypeId + SQL); + + $this->dump("all start activities", <<activityTypeId + ), + + /* startActivity is the latest activity before the window's start date */ + startActivity AS ( + SELECT contact_id, activity_id, + ROW_NUMBER() OVER ( + PARTITION BY (contact_id) + ORDER BY activity_date_time DESC + ) rn + FROM activities a1 + WHERE a1.activity_date_time < %1 + ) + + select * from startActivity; + SQL, $params1); + + $this->dump("all end activities", <<activityTypeId + ), + + /* endActivity is the latest activity before the window's end date */ + endActivity AS ( + SELECT contact_id, activity_id, + ROW_NUMBER() OVER ( + PARTITION BY (contact_id) + ORDER BY activity_date_time DESC + ) rn + FROM activities a2 + WHERE a2.activity_date_time < %2 + ) + + select * from endActivity; + SQL, $params); + + $this->dump("all 2", <<activityTypeId ), @@ -149,7 +206,68 @@ class GetFlows extends \Civi\Api4\Generic\AbstractAction { ORDER BY activity_date_time DESC ) rn FROM activities a2 - WHERE a2.activity_date_time BETWEEN %1 AND %2 + WHERE a2.activity_date_time < %2 + ) + + SELECT /*startCat.new_category_id from_category_id, endCat.new_category_id to_category_id,*/ + endActivity.contact_id endCtID, endActivity.activity_id endAcID, + startActivity.contact_id startCtID, startActivity.activity_id startAcID + FROM endActivity + /* INNER JOIN $this->catChangesTableName endCat + ON endActivity.activity_id = endCat.entity_id */ + LEFT JOIN ( + startActivity + /* INNER JOIN $this->catChangesTableName startCat + ON startActivity.activity_id = startCat.entity_id */ + ) + ON startActivity.contact_id = endActivity.contact_id AND startActivity.rn = 1 + WHERE endActivity.rn = 1 + /*ORDER BY from_category_id, to_category_id*/ + ; + SQL, $params); + + } + + /** + * This SQL uses the change activities to determine the flows between two dates. + * + * It does so by finding the latest activity before the start date and using it's new_category_id + * as the category for that contact on that date, and likewise for the end date. + * + * It is assumed that a change activity is always present. + */ + protected function solveWithWindowFunctions(Result $result, string $startDateYmd, ?string $endDateYmd) { + + $sql = <<activityTypeId + ), + + /* startActivity is the latest activity before the window's start date */ + startActivity AS ( + SELECT contact_id, activity_id, + ROW_NUMBER() OVER ( + PARTITION BY (contact_id) + ORDER BY activity_date_time DESC + ) rn + FROM activities a1 + WHERE a1.activity_date_time < %1 + ), + + /* endActivity is the latest activity before the window's end date */ + endActivity AS ( + SELECT contact_id, activity_id, + ROW_NUMBER() OVER ( + PARTITION BY (contact_id) + ORDER BY activity_date_time DESC + ) rn + FROM activities a2 + WHERE a2.activity_date_time < %2 ) /* join startActivity and endActivity to count changes between each shift */ @@ -188,11 +306,21 @@ class GetFlows extends \Civi\Api4\Generic\AbstractAction { } } - protected function dump(string $msg, string $sql) { - print "\n$msg ===============================\n$sql\n"; - $data = CRM_Core_DAO::executeQuery($sql)->fetchAll(); - print_r($data); - print "\n ===============================\n"; + /** + * useful in debugging phpunit tests only; unused in normal operation. + */ + protected function dump(string $msg, string $sql, $params = []) { + try { + $data = CRM_Core_DAO::executeQuery($sql, $params)->fetchAll(); + } + catch (\Exception $e) { + print $e->getCause()->userinfo; + } + print "\n$msg (" . count($data) . ") records ===============================\n"; + foreach ($data as $row) { + print json_encode($row) . "\n"; + } + print "===============================\n"; } } diff --git a/Civi/Api4/Action/ContactCategory/Save.php b/Civi/Api4/Action/ContactCategory/Save.php new file mode 100644 index 0000000..1bbeec8 --- /dev/null +++ b/Civi/Api4/Action/ContactCategory/Save.php @@ -0,0 +1,46 @@ +records, 'id') as $id) { + if ($id) { + $ids[] = (int) $id; + } + } + if ($ids) { + $idsList = implode(",", array_filter($ids)); + $preExisting = array_column(CRM_Core_DAO::executeQuery( + "SELECT id FROM civicrm_contact_category WHERE id IN ($idsList)" + )->fetchAll(), 'id'); + // Any where we were given an ID that doesn't exist are important. + foreach (array_diff($ids, $preExisting) as $unknownID) { + CRM_Core_DAO::executeQuery("INSERT INTO civicrm_contact_category (id) VALUES ($unknownID)"); + } + } + + // Now let it proceed as normal. + parent::_run($result); + } + +} diff --git a/Civi/Api4/ContactCategory.php b/Civi/Api4/ContactCategory.php index dea52bc..0c8e5fa 100644 --- a/Civi/Api4/ContactCategory.php +++ b/Civi/Api4/ContactCategory.php @@ -3,6 +3,8 @@ namespace Civi\Api4; use Civi\Api4\Action\ContactCategory\GetFlows; use Civi\Api4\Action\ContactCategory\Sync; +use Civi\Api4\Action\ContactCategory\Create; +use Civi\Api4\Action\ContactCategory\Save; /** * ContactCategory entity. @@ -13,6 +15,24 @@ use Civi\Api4\Action\ContactCategory\Sync; */ class ContactCategory extends Generic\DAOEntity { + /** + * @param bool $checkPermissions + * @return \Civi\Api4\Action\ContactCategory\Create + */ + public static function create($checkPermissions = TRUE) { + return (new Create(static::getEntityName(), __FUNCTION__)) + ->setCheckPermissions($checkPermissions); + } + + /** + * @param bool $checkPermissions + * @return \Civi\Api4\Action\ContactCategory\Save + */ + public static function save($checkPermissions = TRUE) { + return (new Save(static::getEntityName(), __FUNCTION__)) + ->setCheckPermissions($checkPermissions); + } + /** * */ diff --git a/tests/phpunit/Civi/Api4/Action/ContactCategory/GetFlowsTest.php b/tests/phpunit/Civi/Api4/Action/ContactCategory/GetFlowsTest.php index 4bdfc82..1ed452b 100644 --- a/tests/phpunit/Civi/Api4/Action/ContactCategory/GetFlowsTest.php +++ b/tests/phpunit/Civi/Api4/Action/ContactCategory/GetFlowsTest.php @@ -96,15 +96,24 @@ class GetFlowsTest extends \PHPUnit\Framework\TestCase implements HeadlessInterf ]) ->execute()->column('id'); - // Create a contact - $ctID = Contact::create(FALSE) - ->setValues([ - 'contact_type' => 'Individual', - 'display_name' => 'Wilma', - ])->execute()->first()['id']; - // print "xxxxx created ct $ctID\n"; + [$ctID, $ctID2] = Contact::save(FALSE) + ->setDefaults(['contact_type' => 'Individual']) + ->setRecords([ + ['display_name' => 'Wilma'], + ['display_name' => 'Fred'], + ])->execute()->column('id'); - // Create two activities. + // Put them in meh category. This is not required for the test, + // but makes the data realistic. + $ccIDs = ContactCategory::save(FALSE) + ->setRecords([ + ['id' => $ctID, 'category_definition_id' => $mehCatID], + ['id' => $ctID2, 'category_definition_id' => $mehCatID], + ])->execute()->column('id'); + $this->assertEquals([$ctID, $ctID2], $ccIDs); + // print "ccs\n" . implode("\n", array_map('json_encode', CRM_Core_DAO::executeQuery("SELECT * from civicrm_contact_category cc")->fetchAll())) . "\n---\n"; + + // Create two activities for ct1 and just one for ct2 $activityIDs = Activity::save(FALSE) ->setDefaults([ 'activity_type_id:name' => 'changed_contact_category', @@ -115,23 +124,33 @@ class GetFlowsTest extends \PHPUnit\Framework\TestCase implements HeadlessInterf [ 'activity_date_time' => '2025-01-01', 'Category_changes.new_category_id' => $amazingCatID, + 'subject' => "Fake subject: $ctID change to amazing from nothing", + ], + [ + 'activity_date_time' => '2025-01-01', + 'Category_changes.new_category_id' => $mehCatID, + 'target_contact_id' => $ctID2, + 'subject' => "Fake subject: $ctID2 change to meh from nothing", ], [ 'activity_date_time' => '2025-02-01', 'Category_changes.new_category_id' => $mehCatID, + 'subject' => "Fake subject: $ctID change to meh from amazing", ], ])->execute()->column('id'); - // $acts = Activity::get(FALSE)->addWhere('id', 'IN', $activityIDs)->execute()->getArrayCopy(); print_r($acts); - - // FIXME: this is returning backwards. + // ================ + // At 3 Jan, we had 1 amazing, 1 meh + // Later, 1 amazing moved to meh. + // Flows should return non-changes, too. $flows = ContactCategory::getFlows() ->setStartDate('2025-01-03') ->execute()->getArrayCopy(); - // print_r($flows); $this->assertEquals([ ['from_category_id' => $amazingCatID, 'to_category_id' => $mehCatID, 'contact_count' => 1], + ['from_category_id' => $mehCatID, 'to_category_id' => $mehCatID, 'contact_count' => 1], ], $flows); + } }