update tests, fix stuff, implement bespoke save, create actions for contactcategory

This commit is contained in:
Rich Lott / Artful Robot 2025-03-26 10:13:00 +00:00
parent 78e5b83c1d
commit 2ae781f6e3
6 changed files with 273 additions and 29 deletions

View file

@ -8,12 +8,15 @@
* *
* This stub provides compatibility. It is not intended to be modified in a * This stub provides compatibility. It is not intended to be modified in a
* substantive way. Property annotations may be added, but are not required. * substantive way. Property annotations may be added, but are not required.
* @property string $id *
* @property string $contact_id * TODO: these are wrong, if it matters:
* @property string $category *
* @property string $next_category * @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). * Required by older versions of CiviCRM (<5.74).

View file

@ -0,0 +1,28 @@
<?php
namespace Civi\Api4\Action\ContactCategory;
use Civi\Api4\Generic\Result;
use Civi\Api4\ContactCategory;
use CRM_Contactcats_ExtensionUtil as E;
/**
* Create ContactCategory action
*
* This entity's ID is a unique primary key that references
* contact_id. Here we overwrite the validateValues which normally
*
*/
class Create extends \Civi\Api4\Generic\DAOCreateAction {
/**
* @inheritDoc
*/
public function _run(Result $result) {
// Wrap Save API
$saveResults = ContactCategory::save($this->getCheckPermissions())
->addRecord($this->values)
->execute()->getArrayCopy();
$result->exchangeArray($saveResults);
}
}

View file

@ -72,6 +72,7 @@ class GetFlows extends \Civi\Api4\Generic\AbstractAction {
// $result['windowFunctionsSupported'] = $this->windowFunctionsSupported(); // $result['windowFunctionsSupported'] = $this->windowFunctionsSupported();
// if ($result['windowFunctionsSupported']) { // if ($result['windowFunctionsSupported']) {
if ($this->windowFunctionsSupported()) { if ($this->windowFunctionsSupported()) {
// $this->debug1($startDate, $endDate);
$this->solveWithWindowFunctions($result, $startDate, $endDate); $this->solveWithWindowFunctions($result, $startDate, $endDate);
} }
else { else {
@ -114,19 +115,75 @@ class GetFlows extends \Civi\Api4\Generic\AbstractAction {
return $supported; return $supported;
} }
/** protected function debug1(string $startDateYmd, ?string $endDateYmd) {
*
*/
protected function solveWithWindowFunctions(Result $result, string $startDateYmd, ?string $endDateYmd) {
$sql = <<<SQL if (!$endDateYmd) {
$endDateYmd = date('Ymd', strtotime('tomorrow'));
}
$params = [1 => [$startDateYmd, 'Int'], 2 => [$endDateYmd, 'Int']];
$params1 = [1 => [$startDateYmd, 'Int']];
$this->dump("all activities", <<<SQL
SELECT ac.contact_id, a.activity_date_time, a.id activity_id
FROM civicrm_activity a
INNER JOIN civicrm_activity_contact ac
ON a.id = ac.activity_id AND ac.record_type_id = 3
WHERE a.activity_type_id = $this->activityTypeId
SQL);
$this->dump("all start activities", <<<SQL
WITH activities AS (
SELECT ac.contact_id, a.activity_date_time, a.id activity_id
FROM civicrm_activity a
INNER JOIN civicrm_activity_contact ac
ON a.id = ac.activity_id AND ac.record_type_id = 3
WHERE a.activity_type_id = $this->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", <<<SQL
WITH activities AS (
SELECT ac.contact_id, a.activity_date_time, a.id activity_id
FROM civicrm_activity a
INNER JOIN civicrm_activity_contact ac
ON a.id = ac.activity_id AND ac.record_type_id = 3
WHERE a.activity_type_id = $this->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", <<<SQL
/* Identify the relevant activities for the contacts */ /* Identify the relevant activities for the contacts */
WITH activities AS ( WITH activities AS (
SELECT ac.contact_id, a.activity_date_time, a.id activity_id SELECT ac.contact_id, a.activity_date_time, a.id activity_id
FROM civicrm_activity a FROM civicrm_activity a
INNER JOIN civicrm_activity_contact ac INNER JOIN civicrm_activity_contact ac
ON a.id = ac.activity_id AND ac.record_type_id = 3 ON a.id = ac.activity_id AND ac.record_type_id = 3
/*INNER JOIN civicrm_contact_category cc ON cc.id = ac.contact_id */
WHERE a.activity_type_id = $this->activityTypeId WHERE a.activity_type_id = $this->activityTypeId
), ),
@ -149,7 +206,68 @@ class GetFlows extends \Civi\Api4\Generic\AbstractAction {
ORDER BY activity_date_time DESC ORDER BY activity_date_time DESC
) rn ) rn
FROM activities a2 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 = <<<SQL
/* Identify the relevant activities for the contacts */
WITH activities AS (
SELECT ac.contact_id, a.activity_date_time, a.id activity_id
FROM civicrm_activity a
INNER JOIN civicrm_activity_contact ac
ON a.id = ac.activity_id AND ac.record_type_id = 3
WHERE a.activity_type_id = $this->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 */ /* 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"; * useful in debugging phpunit tests only; unused in normal operation.
$data = CRM_Core_DAO::executeQuery($sql)->fetchAll(); */
print_r($data); protected function dump(string $msg, string $sql, $params = []) {
print "\n ===============================\n"; 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";
} }
} }

View file

@ -0,0 +1,46 @@
<?php
namespace Civi\Api4\Action\ContactCategory;
use Civi\Api4\Generic\Result;
use CRM_Contactcats_ExtensionUtil as E;
use CRM_Core_DAO;
/**
* Create ContactCategory action
*
* This entity's ID is a unique primary key that references
* contact_id. Here we overwrite the validateValues which normally
*
* @see \Civi\Api4\Generic\AbstractAction
*
*/
class Save extends \Civi\Api4\Generic\DAOSaveAction {
/**
* Allow creating records with ids.
*
* @inheritDoc
*/
public function _run(Result $result) {
$ids = [];
foreach (array_column($this->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);
}
}

View file

@ -3,6 +3,8 @@ namespace Civi\Api4;
use Civi\Api4\Action\ContactCategory\GetFlows; use Civi\Api4\Action\ContactCategory\GetFlows;
use Civi\Api4\Action\ContactCategory\Sync; use Civi\Api4\Action\ContactCategory\Sync;
use Civi\Api4\Action\ContactCategory\Create;
use Civi\Api4\Action\ContactCategory\Save;
/** /**
* ContactCategory entity. * ContactCategory entity.
@ -13,6 +15,24 @@ use Civi\Api4\Action\ContactCategory\Sync;
*/ */
class ContactCategory extends Generic\DAOEntity { 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);
}
/** /**
* *
*/ */

View file

@ -96,15 +96,24 @@ class GetFlowsTest extends \PHPUnit\Framework\TestCase implements HeadlessInterf
]) ])
->execute()->column('id'); ->execute()->column('id');
// Create a contact [$ctID, $ctID2] = Contact::save(FALSE)
$ctID = Contact::create(FALSE) ->setDefaults(['contact_type' => 'Individual'])
->setValues([ ->setRecords([
'contact_type' => 'Individual', ['display_name' => 'Wilma'],
'display_name' => 'Wilma', ['display_name' => 'Fred'],
])->execute()->first()['id']; ])->execute()->column('id');
// print "xxxxx created ct $ctID\n";
// 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) $activityIDs = Activity::save(FALSE)
->setDefaults([ ->setDefaults([
'activity_type_id:name' => 'changed_contact_category', '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', 'activity_date_time' => '2025-01-01',
'Category_changes.new_category_id' => $amazingCatID, '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', 'activity_date_time' => '2025-02-01',
'Category_changes.new_category_id' => $mehCatID, 'Category_changes.new_category_id' => $mehCatID,
'subject' => "Fake subject: $ctID change to meh from amazing",
], ],
])->execute()->column('id'); ])->execute()->column('id');
// $acts = Activity::get(FALSE)->addWhere('id', 'IN', $activityIDs)->execute()->getArrayCopy(); print_r($acts); // ================
// At 3 Jan, we had 1 amazing, 1 meh
// FIXME: this is returning backwards. // Later, 1 amazing moved to meh.
// Flows should return non-changes, too.
$flows = ContactCategory::getFlows() $flows = ContactCategory::getFlows()
->setStartDate('2025-01-03') ->setStartDate('2025-01-03')
->execute()->getArrayCopy(); ->execute()->getArrayCopy();
// print_r($flows);
$this->assertEquals([ $this->assertEquals([
['from_category_id' => $amazingCatID, 'to_category_id' => $mehCatID, 'contact_count' => 1], ['from_category_id' => $amazingCatID, 'to_category_id' => $mehCatID, 'contact_count' => 1],
['from_category_id' => $mehCatID, 'to_category_id' => $mehCatID, 'contact_count' => 1],
], $flows); ], $flows);
} }
} }