diff --git a/Civi/Api4/Action/ContactCategory/Sync.php b/Civi/Api4/Action/ContactCategory/Sync.php index 5603545..a7cb9ec 100644 --- a/Civi/Api4/Action/ContactCategory/Sync.php +++ b/Civi/Api4/Action/ContactCategory/Sync.php @@ -2,12 +2,8 @@ namespace Civi\Api4\Action\ContactCategory; use Civi; -use Civi\Api4\Activity; use Civi\Api4\Generic\Result; -use Civi\Api4\Group; -use Civi\Api4\OptionValue; -use Civi\Api4\Setting; -use CRM_Core_DAO; +use Civi\ContactCats\Processor; /** * Sync the data @@ -28,145 +24,24 @@ class Sync extends \Civi\Api4\Generic\AbstractAction { public function _run(Result $result) { ini_set('memory_limit', '256M'); Civi::log()->debug('Begin', ['=start' => 'ContactCatSync', '=timed' => 1]); - // this does not unserialize $settings = Civi::settings()->get('contact_categories'); - $settings = Setting::get(FALSE) - ->addSelect('contact_categories') - ->execute()->first()['value'] ?? NULL; - if (empty($settings['groupIDs'])) { - throw new \API_Exception('Unconfigured'); - } - if (!$this->force && time() < $settings['updateAfter']) { - // not needed yet. - Civi::log()->debug("Skipping because not due until " . date('H:i j M Y', $settings['updateAfter']), ['=' => 'pop']); - return; - } - // Ensure we're definitely dealing with integer Group IDs. - array_walk($settings['groupIDs'], fn (&$groupID) => $groupID = (int) $groupID); - - // Load category names - $catNames = OptionValue::get(FALSE) - ->addWhere('option_group_id:name', '=', 'contact_categories') - ->addSelect('value', 'label') - ->execute()->indexBy('value')->column('label'); - - $groups = Group::get(FALSE) - ->addWhere('id', 'IN', $settings['groupIDs']) - ->execute()->indexBy('id')->getArrayCopy(); - - $smartGroups = []; - foreach ($groups as $groupID => $group) { - if (!empty($group['saved_search_id'])) { - $smartGroups[] = $groupID; - } - } - if ($smartGroups) { - Civi::log()->debug('Refreshing smart groups', ['=' => 'timed,start', 'groups' => $smartGroups]); - \CRM_Contact_BAO_GroupContactCache::loadAll($smartGroups); - Civi::log()->debug('', ['=' => 'pop']); - } - - Civi::log()->debug('Resetting table', ['=' => 'start,timed']); - // clear out temp space. - CRM_Core_DAO::executeQuery("UPDATE civicrm_contact_category SET next_category = 0;")->free(); - // ensure we have all our contacts covered. - CRM_Core_DAO::executeQuery(<<free(); - Civi::log()->debug('', ['=' => 'pop']); - - foreach ($settings['groupIDs'] as $groupID) { - if ($groupID == 0) { - // The default 'group' isn't a ... group. - continue; - } - if (!$groups[$groupID]) { - Civi::log()->warning("Group $groupID no longer exists."); - continue; - } - // Put unclaimed contacts in this group into this category. - $isSmart = !empty($groups[$groupID]['saved_search_id']); - Civi::log()->debug($groups[$groupID]['title'] . ($isSmart ? '(smart)' : ''), ['=' => 'timed', '=start' => "group$groupID"]); - $table = $isSmart ? 'civicrm_group_contact_cache' : 'civicrm_group_contact'; - $statusWhere = $isSmart ? '' : 'AND gc.status = "Added"'; - $sql = <<free(); - Civi::log()->debug('', ['=' => 'pop']); - } - - Civi::log()->debug("Calculate changes", ['=' => 'timed', '=start' => "changes"]); - $changes = CRM_Core_DAO::executeQuery(<< category - ORDER BY category, next_category - SQL); - $lastChange = [0, 0]; - $batch = []; - $n = 0; - $writeBatch = function() use (&$lastChange, &$batch, $catNames) { - if (empty($batch)) { + if (!$this->force) { + $nextRun = Civi::settings()->get('contactcats_next_run') ?? 0; + if (time() < $nextRun) { + // not needed yet. + Civi::log()->debug("Skipping because not due until " . date('H:i j M Y', $nextRun), ['=' => 'pop']); return; } - $oldCategoryId = (int) ($lastChange[0] ?? 0); - $newCategoryId = (int) ($lastChange[1] ?? 0); - // Create activity on the first contact - $a = Activity::create(FALSE) - ->addValue('target_contact_id', $batch[0]) - ->addValue('activity_type_id:name', 'changed_contact_category') - ->addValue('source_contact_id', \CRM_Core_BAO_Domain::getDomain()->contact_id) - ->addValue('status_id:name', 'Completed') - ->addValue('subject', $catNames[$oldCategoryId] . ' → ' . $catNames[$newCategoryId]) - ->execute()->first(); - $activityID = (int) $a['id']; - // Join activity to all relevant contacts - CRM_Core_DAO::executeQuery("INSERT INTO civicrm_activity_contact (contact_id, activity_id, record_type_id) - SELECT id contact_id, $activityID activity_id, 3 record_type_id - FROM civicrm_contact_category cc - WHERE cc.category = $oldCategoryId - AND cc.next_category = $newCategoryId - AND contact_id <> $batch[0] - ")->free(); - Civi::log()->debug(count($batch) . " changes: " . $catNames[$lastChange[0] ?? 0] . ' → ' . $catNames[$lastChange[1] ?? 0]); - }; - while ($changes->fetch()) { - $n++; - if ($lastChange[0] !== $changes->category || $lastChange[1] !== $changes->next_category) { - $writeBatch(); - $lastChange = [$changes->category, $changes->next_category]; - $batch = []; - } - $batch[] = $changes->id; } - $changes->free(); - $writeBatch(); - Civi::log()->debug('Apply changes', ['=change' => 'applyChanges', '=timed' => 1]); - CRM_Core_DAO::executeQuery("UPDATE civicrm_contact_category - SET category = next_category WHERE category <> next_category")->free(); - Civi::log()->debug('', ['=pop' => 1]); - - $summary = CRM_Core_DAO::executeQuery("SELECT next_category, count(*) from civicrm_contact_category group by next_category")->fetchAll(); - $summary['changes'] = $n; - $_ = memory_get_peak_usage(TRUE); - $summary['memory_use'] = @round($_ / pow(1024, ($i = floor(log($_, 1024)))), 2) . ' ' . ['b', 'kb', 'mb', 'gb', 'tb', 'pb'][$i]; - $result->exchangeArray($summary); + $processor = new Processor(); + $result->exchangeArray($processor->run()); // Limit to running every 24 hours; we actually want it to be stable within one day. // Schedule for 3am to avoid busy times and DST. - $settings['updateAfter'] = strtotime('tomorrow + 180 minutes'); - Setting::set(FALSE) - ->addValue('contact_categories', $settings) - ->execute(); - - Civi::log()->debug("Complete. Scheduling next run after " . date('H:i j M Y', $settings['updateAfter']), ['=' => 'set']); - + $nextRun = strtotime('tomorrow + 180 minutes'); + Civi::settings()->set('contactcats_next_run', $nextRun); + Civi::settings()->set('contactcats_last_run', time()); + Civi::log()->debug("Complete. Scheduling next run after " . date('H:i j M Y', $nextRun), ['=' => 'set']); } } diff --git a/Civi/ContactCats/Processor.php b/Civi/ContactCats/Processor.php new file mode 100644 index 0000000..b6a3c90 --- /dev/null +++ b/Civi/ContactCats/Processor.php @@ -0,0 +1,246 @@ +categories = ContactCategoryDefinition::get(FALSE) + ->addOrderBy('execution_order') + ->execute()->getArrayCopy(); + + // Identify groups and searches used by definitions. + $groupIDs = $searchIDs = []; + foreach ($this->categories as $cat) { + if ($cat['search_type'] === 'group') { + $groupIDs[] = (int) $cat['search_data']['group_id']; + } + elseif ($cat['search_type'] === 'search') { + $searchIDs[] = (int) $cat['search_data']['saved_search_id']; + } + } + + // Load group details; ensure all groups still exist. + $this->groupDetails = []; + if ($groupIDs) { + $this->groupDetails = Group::get(FALSE) + ->addWhere('id', 'IN', $groupIDs) + ->execute()->indexBy('id')->getArrayCopy(); + foreach ($this->categories as $cat) { + if ($cat['search_type'] === 'group') { + if (!isset($this->groupDetails[$cat['search_data']['group_id'] ?? 0])) { + throw new CRM_Core_Exception("ContactCategoryDefinition $cat[id] $cat[label] references invalid Group: " . $cat['search_data']['group_id']); + } + } + } + } + + // Load all searches + $this->searchDetails = []; + if ($searchIDs) { + $this->searchDetails = SavedSearch::get(FALSE) + ->addWhere('id', 'IN', $searchIDs) + ->execute()->indexBy('id')->getArrayCopy(); + foreach ($this->categories as $cat) { + if ($cat['search_type'] === 'search') { + if (!isset($this->searchDetails[$cat['search_data']['saved_search_id'] ?? 0])) { + throw new CRM_Core_Exception("ContactCategoryDefinition $cat[id] $cat[label] references invalid Search: " . $cat['search_data']['saved_search_id']); + } + $search = $this->searchDetails[$cat['search_data']['saved_search_id']]; + if (!isset($search['api_params']) || !isset($search['api_entity'])) { + throw new CRM_Core_Exception("ContactCategoryDefinition $cat[id] $cat[label] uses search $search[id] which does not look appropriate."); + } + } + } + } + + // TODO: check things like searches that group by contact_id + } + + public function run() { + + $this->refreshSmartGroups(); + $summary = []; + \CRM_Core_Transaction::create()->run(function($tx) use (&$summary) { + $this->resetTable(); + foreach ($this->categories as $cat) { + if ($cat['search_type'] === 'group') { + $this->assignCategoryFromGroup($cat); + } + elseif ($cat['search_type'] === 'search') { + $this->assignCategoryFromSearch($cat); + } + // future... + } + $summary = $this->createChangeActivities(); + }); + + return $summary; + } + + protected function createChangeActivities() { + Civi::log()->debug("Calculate changes", ['=' => 'timed', '=start' => "changes"]); + $changes = CRM_Core_DAO::executeQuery(<< category + ORDER BY category, next_category + SQL); + $lastChange = [0, 0]; + $batch = []; + $n = 0; + $domainContactID = \CRM_Core_BAO_Domain::getDomain()->contact_id; + $writeBatch = function() use (&$lastChange, &$batch, $domainContactID) { + if (empty($batch)) { + return; + } + $oldCategoryId = (int) ($lastChange[0] ?? 0); + $newCategoryId = (int) ($lastChange[1] ?? 0); + $oldName = $this->categories[$oldCategoryId]['label'] ?? E::ts('(Unknown)'); + $subject = "$oldName → " . $this->categories[$newCategoryId]; + // Create activity on the first contact + $a = Activity::create(FALSE) + ->addValue('target_contact_id', $batch[0]) + ->addValue('activity_type_id:name', 'changed_contact_category') + ->addValue('source_contact_id', $domainContactID) + ->addValue('status_id:name', 'Completed') + ->addValue('subject', $subject) + ->execute()->first(); + $activityID = (int) $a['id']; + // Join activity to all relevant contacts + CRM_Core_DAO::executeQuery("INSERT INTO civicrm_activity_contact (contact_id, activity_id, record_type_id) + SELECT id contact_id, $activityID activity_id, 3 record_type_id + FROM civicrm_contact_category cc + WHERE cc.category = $oldCategoryId + AND cc.next_category = $newCategoryId + AND contact_id <> $batch[0] + ")->free(); + Civi::log()->debug(count($batch) . " changes: $subject"); + }; + + while ($changes->fetch()) { + $n++; + if ($lastChange[0] !== $changes->category || $lastChange[1] !== $changes->next_category) { + $writeBatch(); + $lastChange = [$changes->category, $changes->next_category]; + $batch = []; + } + $batch[] = $changes->id; + } + $changes->free(); + $writeBatch(); + + Civi::log()->debug('Apply changes', ['=change' => 'applyChanges', '=timed' => 1]); + CRM_Core_DAO::executeQuery("UPDATE civicrm_contact_category + SET category = next_category WHERE category <> next_category")->free(); + Civi::log()->debug('', ['=pop' => 1]); + + $summary = CRM_Core_DAO::executeQuery("SELECT next_category, count(*) from civicrm_contact_category group by next_category")->fetchAll(); + $summary['changes'] = $n; + $_ = memory_get_peak_usage(TRUE); + $summary['memory_use'] = @round($_ / pow(1024, ($i = floor(log($_, 1024)))), 2) . ' ' . ['b', 'kb', 'mb', 'gb', 'tb', 'pb'][$i]; + return $summary; + + } + + protected function refreshSmartGroups() { + $smartGroups = []; + foreach ($this->groupDetails as $groupID => $group) { + if (!empty($group['saved_search_id'])) { + $smartGroups[] = $groupID; + } + } + if ($smartGroups) { + Civi::log()->debug('Refreshing smart groups', ['=' => 'timed,start', 'groups' => $smartGroups]); + \CRM_Contact_BAO_GroupContactCache::loadAll($smartGroups); + Civi::log()->debug('', ['=' => 'pop']); + } + } + + protected function resetTable() { + Civi::log()->debug('Resetting table', ['=' => 'start,timed']); + // clear out temp space. + CRM_Core_DAO::executeQuery("UPDATE civicrm_contact_category SET next_category = 0;")->free(); + // ensure we have all our contacts covered. + // TODO: is it quicker to do a WHERE NOT EXISTS? + CRM_Core_DAO::executeQuery(<<free(); + Civi::log()->debug('', ['=' => 'pop']); + } + + /** + * + */ + protected function assignCategoryFromGroup(array $cat) { + $groupID = (int) $cat['search_data']['group_id']; + $group = $this->groupDetails[$groupID]; + + // Put unclaimed contacts in this group into this category. + $isSmart = !empty($group['saved_search_id']); + Civi::log()->debug($group['title'] . ($isSmart ? '(smart)' : ''), ['=' => 'timed', '=start' => "group$groupID"]); + $table = $isSmart ? 'civicrm_group_contact_cache' : 'civicrm_group_contact'; + $statusWhere = $isSmart ? '' : 'AND gc.status = "Added"'; + $sql = <<free(); + } + + /** + * + */ + protected function assignCategoryFromSearch(array $cat) { + $search = $this->searchDetails[$cat['search_data']['saved_search_id']]; + + $apiParams = $search['api_params']; + if ($search['api_entity'] === 'Contact' && in_array('id', $apiParams['select'] ?? [])) { + $contactIdKey = 'id'; + // We only need the ID. + $apiParams['select'] = ['id']; + } + else { + throw new CRM_Core_Exception("can't figure out contactID in search: " . json_encode($search)); + } + // We don't need them ordered. + unset($apiParams['orderBy']); + + $contactIDs = civicrm_api4($search['api_entity'], $search['get'], $apiParams)->column($contactIdKey); + // Unsure if this batching is needed + $batchSize = 10000; + while ($batch = array_splice($contactIDs, 0, $batchSize)) { + $batch = implode(',', $batch); + if (!preg_match('/^[0-9,]+$/', $batch)) { + // Surely this can never happen. + throw new CRM_Core_Exception("Erm, received non-integer contact IDs from Search $search[id]!"); + } + $sql = <<free(); + } + } + +} diff --git a/README.md b/README.md index 3b37c4f..e9ebd9c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,5 @@ # Contact Categories - This is an [extension for CiviCRM](https://docs.civicrm.org/sysadmin/en/latest/customize/extensions/), licensed under [AGPL-3.0](LICENSE.txt). It provides a way to categorise contacts by priority group; a contact only ever has one category, which is the most important one that applies. @@ -13,7 +12,7 @@ How categories are defined will depend on your organisation's needs. A real worl 4. "Loyal" - other regular donors 5. "Cooling" - people who recently cancelled regular giving 6. "Interested" - donated within last 3 months. -7. "Missed" 2+ donations over 3 months ago. +7. "Missed" 2+ donations over 3 months ago. 8. "Drifting" gave once over 3 months ago. 9. "Active" never given money, but done some other action recently. 10. "Dormant" never given money and not done anything else for a while. @@ -28,17 +27,22 @@ As well as giving you the opportunity for oversight metrics, this makes it reall Example useful things you can do with this approach: - avoid asking for money too soon after it's been given! -- contact all regulars (Amazing, Loyal) to ask for increased donations, notably excluding "buzzing" (would be rude) and "VIPs" (personal approach). +- contact all regulars (Amazing, Loyal) to ask for increased donations, notably excluding "buzzing" (would be rude) and "VIPs" (personal approach). - ask those who gave before to start regular giving etc. - send urgent actions to those most likely to respond urgently. - follow up cancelled regulars to see if they can be rescued. - - - ## Getting Started Once installed, you'll find a (bit crude at the mo) Contact » Categories page that lets you specify your prioritised SearchKit searches to identify your contacts. A scheduled job needs to run to do the categorisation, so wait a day for that or run it manually. You can also use SearchKit/FormBuilder to summarise people by categories, e.g. counts. +## Technical notes + +This uses a settings keys to record unix timestamps as to when the last and previous assignment run happened. + +- `contactcats_next_run` +- `contactcats_last_run` + +It uses `ContactCategoryDefinition` entity to hold data about each configured category and `ContactCategory` entities join a Contact and a ContactCategoryDefinition. diff --git a/ang/crmContactcats.css b/ang/crmContactcats.css index 7c1b891..2734d00 100644 --- a/ang/crmContactcats.css +++ b/ang/crmContactcats.css @@ -15,6 +15,7 @@ crm-contact-category-settings .crm-contact-cats-move-target { transition: opacity 0.3s ease, transform 0.3s ease, display 0.001s allow-discrete, max-height 0.3s ease; transform-origin: top left; display: none; + text-align: right; } crm-contact-category-settings .moving .crm-contact-cats-move-target { display: block; diff --git a/ang/crmContactcats.js b/ang/crmContactcats.js index 3d7a18f..3bb4503 100644 --- a/ang/crmContactcats.js +++ b/ang/crmContactcats.js @@ -15,21 +15,44 @@ // supposed to use for components. // v: '<' }, - controller: function($scope, $timeout, crmApi4, $document) { + controller: function($scope, $timeout, crmApi4, crmStatus, $document) { var ts = ($scope.ts = CRM.ts(null)), ctrl = this; // this.$onInit gets run after the this controller is called, and after the bindings have been applied. this.$onInit = async function() { - ctrl.saved = false; + ctrl.dirty = 'pristine'; //pristine|dirty ctrl.view = 'list'; ctrl.moveIdx = null; ctrl.categoryToEdit = null; ctrl.categoryDefinitions = null; ctrl.categoryDefinitions = await crmApi4("ContactCategoryDefinition", 'get', { orderBy: { label: 'ASC' }, withLabels: true }); + updateOrders(); + console.log("Got", ctrl.categoryDefinitions); $scope.$digest(); + /** + * Presentation order is by label. Promoting "0123 label" type labels. + * + * We set execution_order to the array index to keep things simpler. + */ + function updateOrders() { + + // re-set execution_order. + ctrl.categoryDefinitions.forEach((item, idx) => { + item.execution_order = idx; + }); + + const po = ctrl.categoryDefinitions.slice(); + po.sort((a, b) => { + if (a.label < b.label) return -1; + if (a.label > b.label) return 1; + return (a.execution_order ?? 1) - (b.execution_order ?? 1); + }); + ctrl.presentationOrder = po; + } + ctrl.edit = idx => { if (idx === -1) { // New item. @@ -42,7 +65,8 @@ // Create a blank ctrl.categoryToEdit = { - idx: ctrl.categoryDefinitions.length, + id: 0, + execution_order: ctrl.categoryDefinitions.length, label: '', search_type: 'search', search_data: { saved_search_id: null }, @@ -52,7 +76,7 @@ }; } else { - ctrl.categoryToEdit = Object.assign({idx}, JSON.parse(JSON.stringify(ctrl.categoryDefinitions[idx]))); + ctrl.categoryToEdit = Object.assign({}, JSON.parse(JSON.stringify(ctrl.categoryDefinitions[idx]))); } ctrl.view = 'edit'; }; @@ -64,7 +88,10 @@ } ctrl.categoryDefinitions.splice(idx, 0, item); ctrl.moveIdx = null; + updateOrders(); + $ctrl.dirty = 'dirty'; }; + ctrl.deleteCategory = idx => { if (!confirm(ts( 'Confirm deleting category ‘%1’. You will lose history related to this category. Sure?', @@ -74,36 +101,36 @@ } ctrl.categoryDefinitions[idx].deleted = true; ctrl.categoryToEdit = null; + updateOrders(); ctrl.view = 'list'; + ctrl.dirty = 'dirty'; }; ctrl.updateEditedThing = () => { + const edited = ctrl.categoryToEdit; // @todo validate, e.g. - if (!ctrl.categoryToEdit.label) { + if (!edited.label) { alert("No name"); return; } - const search_data = ctrl.categoryToEdit.search_data; - console.log("search_data", search_data); - if (ctrl.categoryToEdit.search_type === 'group') { + const search_data = edited.search_data; + if (edited.search_type === 'group') { // Only store what we need. const {group_id} = search_data; - console.log("group_id", group_id, search_data); - ctrl.categoryToEdit.search_data = {group_id}; + edited.search_data = {group_id}; } - else if (ctrl.categoryToEdit.search_type === 'search') { + else if (edited.search_type === 'search') { const {saved_search_id} = search_data; - ctrl.categoryToEdit.search_data = {saved_search_id}; + edited.search_data = {saved_search_id}; } - const edited = ctrl.categoryToEdit; - const idx = edited.idx; - delete(edited.idx); + const idx = edited.execution_order; ctrl.categoryDefinitions[idx] = edited; ctrl.categoryToEdit = null; + updateOrders(); + ctrl.dirty = 'dirty'; ctrl.view = 'list'; - console.log("done editing"); } // We need to ensure the search_data object contains the fields required for the selected search_type @@ -121,74 +148,29 @@ } }; - ctrl.save = async () => { + ctrl.save = () => { if (!confirm(ts("Confirm saving changes to categories? Note that categories will not be fully applied until tomorrow."))) { return; } + + + // Handle deletions first. + const deletedIds = ctrl.categoryDefinitions.filter(d => d.deleted && d.id > 0).map(d => d.id); + + const chain = Promise.resolve(); + if (deletedIds.length) { + chain.then(() => crmApi4('ContactCategoryDefinition', 'delete', { where: [ ['id', 'IN', deletedIds] ] })); + } + // Now enact the deletions on our local model. + ctrl.categoryDefinitions = ctrl.categoryDefinitions.filter(d => !d.deleted); + // Tidy execution_order + updateOrders(); + if (ctrl.categoryDefinitions.length) { + chain.then(() => crmApi4('ContactCategoryDefinition', 'save', {records:ctrl.categoryDefinitions})); + } + chain.then(() => { + ctrl.dirty = 'pristine'; + }) + crmStatus({ start: ts('Saving...'), success: ts('Saved')}, chain); }; - - // ctrl.deleteRow = idx => { - // ctrl.catmap.splice(idx, 1); - // }; - // ctrl.getGroupsFor = idx => { - // let groupsInUse = ctrl.catmap.map(c => c.groupID); - // groupsInUse.splice(idx, 1); - // return ctrl.groups.filter( - // g => !groupsInUse.includes(g.id.toString()) - // ); - // }; - - // ctrl.save = async () => { - // console.log("save", ctrl.catmap); - // // reconstruct everything. - // - // const optValsRecords = []; - // ctrl.catmap.forEach(r => { - // if (!r.name || r.groupID === "") { - // return; - // } - // // Do we have an option value for this group ID? - // let c = various.cats.find(cat => cat.value == r.groupID); - // if (c) { - // if (c.label != r.name) { - // optValsRecords.push({ - // id: c.id, - // label: r.name - // }); - // } - // } else { - // optValsRecords.push({ - // label: r.name, - // value: r.groupID, - // "option_group_id:name": "contact_categories" - // }); - // } - // }); - // console.log("optionValue updates", optValsRecords, ctrl.catmap); - // const updates = { - // saveSetting: [ - // "Setting", - // "set", - // { - // values: { - // contact_categories: { - // groupIDs: ctrl.catmap.map(i => i.groupID), - // updateAfter: 0 - // } - // } - // } - // ] - // }; - // if (optValsRecords.length) { - // updates.saveOptions = [ - // "OptionValue", - // "save", - // { records: optValsRecords } - // ]; - // } - // await crmApi4(updates); - // console.log("saved", updates); - // ctrl.saved = true; - // $scope.$digest(); - // }; }; // this.$onChange = function(changes) { // // changes is object keyed by name what '<' binding changed in diff --git a/ang/crmContactcats/crmContactCategorySettings.html b/ang/crmContactcats/crmContactCategorySettings.html index 24f9647..30a8d1b 100644 --- a/ang/crmContactcats/crmContactCategorySettings.html +++ b/ang/crmContactcats/crmContactCategorySettings.html @@ -1,149 +1,168 @@ -
- Loading... -
-
+
+
+ Loading... +
+ - + -
    -
  1. - -
    - -
    +

    {{ts('Execution order')}}

    +

    {{ts('A contact is assigned the first category that matches according to the order defined here. Categories are presented in order of their label - see list below - so you may want to consider this when naming your categories.')}}

    + +
      +
    1. -
      +
      + + + {{row.label}} + + + + + + + + + + + + +
      +
      + + + + +
    2. +
    + +

    + +

    + +

    + +
    + Categories saved. Contacts will be updated shortly (when the next Scheduled + Job run happens). +
    + +

    Presentation order

    +
      +
    1. {{row.label}} +
    2. +
    +
  2. - - - - - - - - + +
    +
    +

    {{ts('Edit category %1', {1 : $ctrl.categoryDefinitions[$ctrl.categoryToEdit.idx].label })}}

    +

    {{ts('Add new category')}}

    + +
    +
    -
    - -
    - -
    - -
-

- -

+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + + -

- -

- -
- Categories saved. Contacts will be updated shortly (when the next Scheduled - Job run happens). -
- - - -
-
-

{{ts('Edit category %1', {1 : $ctrl.categoryDefinitions[$ctrl.categoryToEdit.idx].label })}}

-

{{ts('Add new category')}}

- -
-
- -
- -
- -
- -
- -
- -
- -
- -
- - -
- -
- - -
- -
- - - - - - -
-
- + +
diff --git a/settings/contactcategory.setting.php b/settings/contactcategory.setting.php index 65ddf91..ef1096c 100644 --- a/settings/contactcategory.setting.php +++ b/settings/contactcategory.setting.php @@ -1,12 +1,22 @@ [ - 'name' => 'contact_categories', - 'title' => ts('Contact Category settings'), - 'description' => ts('JSON encoded settings.'), + 'contactcats_last_run' => [ + 'name' => 'contact_categories_last_run', + 'title' => ts('Contact categories last assigned timestamp'), + 'description' => ts('UNIX timestamp of last time the categories were assigned to contacts.'), 'group_name' => 'domain', - 'type' => 'String', - 'serialize' => CRM_Core_DAO::SERIALIZE_JSON, + 'type' => 'Int', + 'default' => FALSE, + 'add' => '5.70', + 'is_domain' => 1, + 'is_contact' => 0, + ], + 'contactcats_next_run' => [ + 'name' => 'contact_categories_next_run', + 'title' => ts('Contact categories next assignment due'), + 'description' => ts('UNIX timestamp after which cron will re-assign categories to contacts.'), + 'group_name' => 'domain', + 'type' => 'Int', 'default' => FALSE, 'add' => '5.70', 'is_domain' => 1,