Commit 0c55241f authored by Rich's avatar Rich

ready for testing

parent 1ce12ad8
......@@ -94,10 +94,56 @@ class CRM_Actionlinks_BAO_ActionLink extends CRM_Actionlinks_DAO_ActionLink {
return $denied_response;
}
if ($linkBAO->use_by_contacts) {
// Limit by the number of contacts. Three cases to handle:
// 1. number of contacts < use_by_contacts: fine.
// 2. contacts >= use_by_contacts but this contact is one of them: fine.
// 3. contacts >= use_by_contacts and this contact is not one of them: deny.
$stats = CRM_Core_DAO::executeQuery(
'SELECT COUNT(DISTINCT contact_id) contacts, SUM(contact_id = %1) used_by_this_contact
FROM civicrm_action_link_contact
WHERE action_link_id = %2',
[
1 => [$contact_id, 'Integer'],
2 => [$linkBAO->id, 'Integer'],
]
);
$stats->fetch();
if ($stats->contacts >= $linkBAO->use_by_contacts && !$stats->used_by_this_contact) {
return $denied_response;
}
}
if ($linkBAO->use_per_contact) {
// Limit times a particular contact can use this link.
$stats = CRM_Core_DAO::executeQuery(
'SELECT COUNT(1) used_times
FROM civicrm_action_link_contact
WHERE contact_id = %1 AND action_link_id = %2',
[
1 => [$contact_id, 'Integer'],
2 => [$linkBAO->id, 'Integer'],
]
);
$stats->fetch();
if ($stats->used_times >= $linkBAO->use_per_contact) {
return $denied_response;
}
}
// Ok! Let's do this!
$linkBAO->use_count += 1;
$linkBAO->save();
// Record that this contact has accessed it.
$sqlParams = [
1 => [$contact_id, 'Integer'],
2 => [$linkBAO->id, 'Integer'],
];
CRM_Core_DAO::executeQuery(
'INSERT INTO civicrm_action_link_contact (contact_id, action_link_id, use_date) VALUES (%1, %2, NOW());',
$sqlParams);
if ($linkBAO->form_processor_name) {
// @todo
......
......@@ -6,7 +6,7 @@
*
* Generated from /buildkit/build/dmaster/sites/default/files/civicrm/ext/actionlinks/xml/schema/CRM/Actionlinks/ActionLink.xml
* DO NOT EDIT. Generated by CRM_Core_CodeGen
* (GenCodeChecksum:85b2afc9f8813c0f47f6a05e665d8686)
* (GenCodeChecksum:f1ec7c33cf845da13a36cf66ac2f42c0)
*/
/**
......@@ -110,6 +110,20 @@ class CRM_Actionlinks_DAO_ActionLink extends CRM_Core_DAO {
*/
public $use_by;
/**
* Number of times each contact is allowed to use this link
*
* @var int
*/
public $use_per_contact;
/**
* Number of contacts allowed to use this link
*
* @var int
*/
public $use_by_contacts;
/**
* Hash unique to this link
*
......@@ -243,7 +257,6 @@ class CRM_Actionlinks_DAO_ActionLink extends CRM_Core_DAO {
'entity' => 'ActionLink',
'bao' => 'CRM_Actionlinks_DAO_ActionLink',
'localizable' => 0,
'serialize' => self::SERIALIZE_JSON,
],
'use_count' => [
'name' => 'use_count',
......@@ -280,6 +293,28 @@ class CRM_Actionlinks_DAO_ActionLink extends CRM_Core_DAO {
'bao' => 'CRM_Actionlinks_DAO_ActionLink',
'localizable' => 0,
],
'use_per_contact' => [
'name' => 'use_per_contact',
'type' => CRM_Utils_Type::T_INT,
'title' => CRM_Actionlinks_ExtensionUtil::ts('Use Per Contact'),
'description' => CRM_Actionlinks_ExtensionUtil::ts('Number of times each contact is allowed to use this link'),
'where' => 'civicrm_action_link.use_per_contact',
'table_name' => 'civicrm_action_link',
'entity' => 'ActionLink',
'bao' => 'CRM_Actionlinks_DAO_ActionLink',
'localizable' => 0,
],
'use_by_contacts' => [
'name' => 'use_by_contacts',
'type' => CRM_Utils_Type::T_INT,
'title' => CRM_Actionlinks_ExtensionUtil::ts('Use By Contacts'),
'description' => CRM_Actionlinks_ExtensionUtil::ts('Number of contacts allowed to use this link'),
'where' => 'civicrm_action_link.use_by_contacts',
'table_name' => 'civicrm_action_link',
'entity' => 'ActionLink',
'bao' => 'CRM_Actionlinks_DAO_ActionLink',
'localizable' => 0,
],
'hash' => [
'name' => 'hash',
'type' => CRM_Utils_Type::T_STRING,
......
......@@ -11,9 +11,9 @@ class CRM_Actionlinks_Upgrader extends CRM_Actionlinks_Upgrader_Base {
/**
* Example: Run an external SQL script when the module is installed.
*
*/
public function install() {
$this->executeSqlFile('sql/myinstall.sql');
$this->executeSqlFile('sql/create_tables.sql');
}
/**
......@@ -36,9 +36,9 @@ class CRM_Actionlinks_Upgrader extends CRM_Actionlinks_Upgrader_Base {
/**
* Example: Run an external SQL script when the module is uninstalled.
*
*/
public function uninstall() {
$this->executeSqlFile('sql/myuninstall.sql');
$this->executeSqlFile('sql/drop_tables.sql');
}
/**
......
......@@ -3,12 +3,14 @@
Provides an API and an admin's UI to manage a set of links that trigger
Form Processor submissions and redirect to a success/denied URL.
![Screenshot](images/screenshot.png)
The extension is licensed under [AGPL-3.0](LICENSE.txt).
## Requirements
* PHP v7.0+
* CiviCRM 5.20+ (probably earlier too)
* CiviCRM 5.20+
## Installation (Web UI)
......@@ -36,8 +38,65 @@ cv en actionlinks
## Usage
(* FIXME: Where would a new user navigate to get started? What changes would they see? *)
Get it installed, then head to **Administer » System Settings » Action
Links**
### Create your first action link.
Here you can add your first action link. The edit screen is shown in the
screnshot above.
- Name: used for the administrative interface only.
- Description: same
- Active: if un-checked users of the link will get the denied/fallback URL
- Link URL: the web address to the page/resource you want to redirect
people to.
- Fallback URL (if denied): the web address to redirect people to if
they're not alowed the link for any reason.
- Form Processor: if you have the Form Processor extension, you can pick
a form processor to call. This gives you all the logic that extension
provides, e.g. creating activities, adding to groups. If you choose one,
that form processor's inputs will show up too.
- Max uses: the max times the link can be used by anyone. (probably not
that useful)
- Max number of unique contacts who can use this. e.g. "First 10 people to
click this get a voucher for a free pie.".
- Number of times each contact is allowed to use this link.
- When to stop allowing access.
### Create links for contacts
Currently the only way to get a usable link is via the API. You can use
the command line (shown below) or the APIv4 explorer (**Support
» Developer » Api Explorer v4**) or any other method (php, angular, js...)
for this.
You'll need the ID of the link, and the ID(s) of the contacts you want to
make th links for, e.g.
e.g.
```
cv api4 ActionLink.getLink '{"actionLinkID":1, "contactIDs":[1, 2, 3]}'
{
"0": {
"contactID": 1,
"link": "https://example.com/civicrm/actionlink?alid=1&cid=1&cs=f1cf81a779fce9d6d3217b9f3338c8ef_1582107927_inf"
},
"1": {
"contactID": 2,
"link": "https://example.com/civicrm/actionlink?alid=1&cid=2&cs=1284819e3a049f21a769cf89fdaf297e_1582107927_inf"
},
"2": {
"contactID": 3,
"link": "https://example.com/civicrm/actionlink?alid=1&cid=3&cs=4d1a15f4b048798204df98f0e74b6733_1582107927_inf"
}
}
```
## Known Issues
### Feedback / testers / ideas / bugs welcome
See Issue Queue.
Please use the [issue
queue](https://lab.civicrm.org/artfulrobot/actionlinks/issues) or contact
me (@artfulrobot) on [chat.civicrm.org](https://chat.civicrm.org).
......@@ -141,30 +141,19 @@ function actionlinks_civicrm_themes(&$themes) {
_actionlinks_civix_civicrm_themes($themes);
}
// --- Functions below this ship commented out. Uncomment as required. ---
/**
* Implements hook_civicrm_preProcess().
*
* @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_preProcess
*
function actionlinks_civicrm_preProcess($formName, &$form) {
} // */
/**
* Implements hook_civicrm_navigationMenu().
*
* @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_navigationMenu
*
*/
function actionlinks_civicrm_navigationMenu(&$menu) {
_actionlinks_civix_insert_navigation_menu($menu, 'Mailings', array(
'label' => E::ts('New subliminal message'),
'name' => 'mailing_subliminal_message',
'url' => 'civicrm/mailing/subliminal',
'permission' => 'access CiviMail',
_actionlinks_civix_insert_navigation_menu($menu, 'Administer/System Settings', array(
'label' => E::ts('Action Links'),
'name' => 'actionlinks_admin',
'url' => 'civicrm/a#/actionlinks',
'permission' => 'administer civicrm',
'operator' => 'OR',
'separator' => 0,
));
_actionlinks_civix_navigationMenu($menu);
} // */
}
......@@ -21,24 +21,26 @@
<tbody>
<tr ng-repeat="row in actionLinks">
<td>{{row.is_active ? 'Active' : 'Inactive' }}</td>
<td>{{row.name}}</td>
<td>[{{row.id}}] {{row.name}}</td>
<td>{{row.description || ''}}</td>
<td><a ng-show="row.allowed_url" href="{{row.allowed_url}}" target="_blank" rel="noopener">{{row.allowed_url}}</a>
</td>
<td>{{row.form_processor_name || '(None)'}}</td>
<td>{{row.use_count}}</td>
<td>
<a href ng-click="editRow(row)" >Edit</a>
<a href ng-click="editRow(row)" ng-show="formProcessorInstances !== null" >Edit</a>
<a href ng-click="deleteRow(row)" >Delete</a>
</td>
</tr>
</tbody>
</table>
<button class="btn" ng-click="editRow()" >Add Link</a>
<button class="crm-button btn" ng-click="editRow()" >Add Link</a>
</div>
<div ng-if="view == 'edit'">
<p ng-if="editData.id" >Editing link {{editData.id}}</p>
<p ng-if="!editData.id" >Add new link</p>
<form name="editLinkForm" crm-ui-id-scope>
<div class="crm-block">
<div class="crm-group">
......@@ -99,20 +101,28 @@
ng-model="editData.form_processor_name"
name="form_processor_name"
class="crm-form-select"
ng-change="updateFPParamsUI()"
>
<option value="" >({{ts('None')}})</option>
<option ng-repeat="n in formProcessorInstances" value="{{n.name}}" >{{n.title + (n.is_active == '1' ? '' : '(Inactive)')}}</option>
</select>
</div>
<div crm-ui-field="{name: 'myForm.form_processor_params', title: ts('Form Processor params')}">
<textarea
crm-ui-id="myForm.form_processor_params"
name="form_processor_params"
ng-model="editData.form_processor_params"
class="crm-form-textarea"
></textarea>
<div ng-show="editData.fpParams.length > 0" class="crm-section">
<div class="label">
Form processor parameters
</div>
<div class="content">
<div ng-repeat="fpParam in editData.fpParams">
<label for="{{'fpParam_' + fpParam.name}}" >{{fpParam.name}}</label><br/>
<input ng-model="fpParam.value" id="{{'fpParam_' + fpParam.name}}" /><br/>
<span><strong>{{fpParam.title}}</strong> {{fpParam.description}}</span>
<span ng-show="!fpParam.value" >This value will need to be added to the link query string, unless it has a default.</span>
</div>
</div>
</div>
</div>
<div ng-if="!formProcessorInstalled" >
{{ts('You do not have the Form Processor extension installed. That extension lets you process the data however you need to, e.g. recording an activity or adding contacts to a group.')}}
......@@ -130,6 +140,29 @@
<span>{{editData.use_count || 0}} used so far</span>
</div>
<div crm-ui-field="{name: 'editLinkForm.use_by_contacts', title: ts('Max number of unique contacts who can use this')}">
<input
type="number"
min=1
crm-ui-id="editLinkForm.use_by_contacts"
name="use_by_contacts"
ng-model="editData.use_by_contacts"
class="crm-form-text"
/>
<div>{{ts('The first N contacts get to use this link (as much as they want, subject to other limits you set here, others are denied.)')}}</div>
</div>
<div crm-ui-field="{name: 'editLinkForm.use_per_contact', title: ts('Number of times each contact is allowed to use this link')}">
<input
type="number"
min=1
crm-ui-id="editLinkForm.use_per_contact"
name="use_per_contact"
ng-model="editData.use_per_contact"
class="crm-form-text"
/>
</div>
<div crm-ui-field="{name: 'editLinkForm.use_by', title: ts('When to stop allowing access')}">
<!-- @todo date time -->
<input
......@@ -142,11 +175,12 @@
/>
</div>
</div>
</div>
<div class="crm-block">
<button class="btn btn-secondary cancel" ng-click="cancelEdit()">{{ts('Cancel')}}</button>
<button class="btn btn-primary save" ng-click="saveRow()">{{ts('Save')}}</button>
<button class="crm-button btn btn-secondary cancel" ng-click="cancelEdit()">{{ts('Cancel')}}</button>
<button class="crm-button btn btn-primary save" ng-click="saveRow()">{{ts('Save')}}</button>
</div>
</form>
</div>
......
......@@ -24,7 +24,7 @@
$scope.error = '';
$scope.view = 'list';
$scope.actionLinks = [];
$scope.formProcessorInstances = [];
$scope.formProcessorInstances = null;
$scope.formProcessorInstalled = false;
// We have myContact available in JS. We also want to reference it in HTML.
......@@ -38,9 +38,12 @@
use_limit: '',
contact_required: false,
use_by: '',
use_per_contact: '',
use_by_contacts: '',
denied_url: '',
form_processor_name: '',
form_processor_params: '',
fpParams: []
};
}
......@@ -58,15 +61,23 @@
});
crmApi('FormProcessorInstance', 'get', {
return: ["is_active","name","title"],
return: ["is_active","name","title", "inputs"],
options: {limit: 0}
}).then(function(r) {
console.log("OK ", r);
$scope.formProcessorInstances = r.values;
// Convert the values Object into an Array
$scope.formProcessorInstances = [];
for (const id in r.values) {
if (r.values.hasOwnProperty(id)) {
$scope.formProcessorInstances.push(r.values[id]);
}
}
$scope.formProcessorInstalled = true;
})
.catch(function(e) {
console.log("no form processor");
$scope.formProcessorInstalled = false;
$scope.formProcessorInstances = [];
});
}
......@@ -74,9 +85,36 @@
resetEditData();
if (row !== undefined) {
$scope.editData = Object.assign($scope.editData, row);
$scope.updateFPParamsUI();
}
$scope.view = 'edit';
};
$scope.updateFPParamsUI = function updateFPParamsUI() {
$scope.editData.fpParams = [];
var savedParams = {};
try {
savedParams = JSON.parse($scope.editData.form_processor_params);
if (savedParams === null) {
savedParams = {};
}
}
catch (e) {
// Invalid JSON
}
console.log("formProcessorInstances", $scope.formProcessorInstances);
const fpInstance = $scope.formProcessorInstances.find(o => o.name === $scope.editData.form_processor_name);
if (fpInstance === undefined || fpInstance.inputs === undefined || fpInstance.inputs.length == 0) {
// No instance, or no params to edit.
return;
}
fpInstance.inputs.forEach(input => {
// Ignore cid - our links will always have this.
if (input.name !== 'cid') {
$scope.editData.fpParams.push({name: input.name, title: input.title, value: savedParams[input.name] || ''});
}
});
}
$scope.deleteRow = function deleteRow(row) {
if (confirm("Delete " + row.name + "? This will break any links to this item that have been published.")) {
......@@ -100,9 +138,19 @@
$scope.saveRow = function saveRow() {
var apiParams = Object.assign({}, $scope.editData);
delete apiParams.fpParams;
if (!apiParams.id) {
delete apiParams.id;
}
// Handle fpParams
const inputs = {};
$scope.editData.fpParams.forEach(input => {
if (input.value) {
inputs[input.name] = input.value;
}
});
apiParams.form_processor_params = JSON.stringify(inputs);
console.log("saving", apiParams);
return crmStatus(
// Status messages. For defaults, just use "{}"
......
images/screenshot.png

11.5 KB | W: | H:

images/screenshot.png

24.7 KB | W: | H:

images/screenshot.png
images/screenshot.png
images/screenshot.png
images/screenshot.png
  • 2-up
  • Swipe
  • Onion skin
......@@ -61,6 +61,8 @@ CREATE TABLE `civicrm_action_link` (
`use_count` int unsigned DEFAULT 0 ,
`use_limit` int unsigned NULL COMMENT 'Access denied after this many uses',
`use_by` datetime NULL COMMENT 'Access denied after this',
`use_per_contact` int unsigned COMMENT 'Number of times each contact is allowed to use this link',
`use_by_contacts` int unsigned COMMENT 'Number of contacts allowed to use this link',
`hash` varchar(10) COMMENT 'Hash unique to this link'
,
PRIMARY KEY (`id`)
......
-- /*******************************************************
-- *
-- * Clean up the exisiting tables
-- *
-- *******************************************************/
SET FOREIGN_KEY_CHECKS=0;
DROP TABLE IF EXISTS `civicrm_action_link_contact`;
SET FOREIGN_KEY_CHECKS=1;
-- /*******************************************************
-- *
-- * Create new tables
-- *
-- *******************************************************/
-- /*******************************************************
-- *
-- * civicrm_action_link_contact
-- *
-- * Holds a record of who used which action link.
-- *
-- *******************************************************/
CREATE TABLE `civicrm_action_link_contact` (
`id` int unsigned NOT NULL AUTO_INCREMENT COMMENT 'Unique ID',
`action_link_id` int unsigned NOT NULL COMMENT 'Foreign Key to civicrm_action_link',
`contact_id` int unsigned NOT NULL COMMENT 'Foreign Key to civicrm_contact',
`use_date` datetime NULL COMMENT 'The date and time the link was used',
PRIMARY KEY (`id`),
INDEX `index_action_link_contact`(action_link_id, contact_id),
CONSTRAINT FOREIGN KEY fk_contact_id (contact_id) REFERENCES civicrm_contact (id) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT FOREIGN KEY fk_action_link_id (action_link_id) REFERENCES civicrm_action_link (id) ON DELETE CASCADE ON UPDATE CASCADE
);
-- /*******************************************************
-- *
-- * Drop our extra table
-- *
-- *******************************************************/
SET FOREIGN_KEY_CHECKS=0;
DROP TABLE IF EXISTS `civicrm_action_link_contact`;
SET FOREIGN_KEY_CHECKS=1;
......@@ -115,6 +115,57 @@ class CRM_ActionLinksTest extends \PHPUnit\Framework\TestCase implements Headles
$result = CRM_Actionlinks_BAO_ActionLink::handleRequest(['alid' => $linkBAO->id, 'cid' => $this->testContactID, 'cs' => $cs]);
$this->assertEquals(['status' => 400, 'redirect' => 'https://denied.com'], $result, "Second use should not be allowed");
}
/**
* Test use_per_contact
*/
public function testUsePerContact() {
// Create a 2nd test contact.
$contactID2 = \Civi\Api4\Contact::create()
->addValue('contact_type', 'Individual')
->addValue('display_name', 'Barney')
->setCheckPermissions(FALSE)
->execute()[0]['id'];
$linkBAO = $this->createReturnBAO(['use_per_contact' => 1]);
$cs = $linkBAO->generateChecksum($this->testContactID, NULL);
$result = CRM_Actionlinks_BAO_ActionLink::handleRequest(['alid' => $linkBAO->id, 'cid' => $this->testContactID, 'cs' => $cs]);
$this->assertEquals(['status' => 200, 'redirect' => 'https://allowed.com'], $result, "First use should be allowed");
$result = CRM_Actionlinks_BAO_ActionLink::handleRequest(['alid' => $linkBAO->id, 'cid' => $this->testContactID, 'cs' => $cs]);
$this->assertEquals(['status' => 400, 'redirect' => 'https://denied.com'], $result, "Second use by contact 1 should not be allowed");
$cs = $linkBAO->generateChecksum($contactID2, NULL);
$result = CRM_Actionlinks_BAO_ActionLink::handleRequest(['alid' => $linkBAO->id, 'cid' => $contactID2, 'cs' => $cs]);
$this->assertEquals(['status' => 200, 'redirect' => 'https://allowed.com'], $result, "Use by contact 2 should be allowed");
$result = CRM_Actionlinks_BAO_ActionLink::handleRequest(['alid' => $linkBAO->id, 'cid' => $contactID2, 'cs' => $cs]);
$this->assertEquals(['status' => 400, 'redirect' => 'https://denied.com'], $result, "Second use by contact 2 should not be allowed");
}
/**
* Test use_by_contacts
*/
public function testUseByContacts() {
// Create a 2nd test contact.
$contactID2 = \Civi\Api4\Contact::create()
->addValue('contact_type', 'Individual')
->addValue('display_name', 'Barney')
->setCheckPermissions(FALSE)
->execute()[0]['id'];
$linkBAO = $this->createReturnBAO(['use_by_contacts' => 1]);
$cs = $linkBAO->generateChecksum($this->testContactID, NULL);
$result = CRM_Actionlinks_BAO_ActionLink::handleRequest(['alid' => $linkBAO->id, 'cid' => $this->testContactID, 'cs' => $cs]);
$this->assertEquals(['status' => 200, 'redirect' => 'https://allowed.com'], $result, "First contact should be allowed");
$result = CRM_Actionlinks_BAO_ActionLink::handleRequest(['alid' => $linkBAO->id, 'cid' => $this->testContactID, 'cs' => $cs]);
$this->assertEquals(['status' => 200, 'redirect' => 'https://allowed.com'], $result, "Repeat use by contact 1 should be allowed");
$cs = $linkBAO->generateChecksum($contactID2, NULL);
$result = CRM_Actionlinks_BAO_ActionLink::handleRequest(['alid' => $linkBAO->id, 'cid' => $contactID2, 'cs' => $cs]);
$this->assertEquals(['status' => 400, 'redirect' => 'https://denied.com'], $result, "Use by contact 2 should not be allowed");
}
/**
* Test use_by
*/
......
......@@ -73,7 +73,6 @@
<type>text</type>
<comment>JSON parameters (object of simple key:value pairs) for the form processor</comment>
<required>false</required>
<serialize>JSON</serialize>
</field>
<field>
......@@ -96,6 +95,18 @@
<comment>Access denied after this</comment>
</field>
<field>
<name>use_per_contact</name>
<type>int unsigned</type>
<comment>Number of times each contact is allowed to use this link</comment>
</field>
<field>
<name>use_by_contacts</name>
<type>int unsigned</type>
<comment>Number of contacts allowed to use this link</comment>
</field>
<field>
<name>hash</name>
<type>varchar</type>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment