Skip to content
Snippets Groups Projects
Unverified Commit 941983b5 authored by Eileen McNaughton's avatar Eileen McNaughton Committed by GitHub
Browse files

Merge pull request #23597 from colemanw/searchKitProximity

SearchKit - add address proximity (map radius) search
parents 079943f6 d2a29eba
No related branches found
No related tags found
No related merge requests found
Showing
with 257 additions and 24 deletions
......@@ -598,4 +598,13 @@ class CRM_Utils_Address {
return CRM_Utils_Address::format($addressFields);
}
/**
* @return string
*/
public static function getDefaultDistanceUnit() {
$countryDefault = Civi::settings()->get('defaultContactCountry');
// US, UK use miles. Everything else is Km
return ($countryDefault == '1228' || $countryDefault == '1226') ? 'miles' : 'km';
}
}
......@@ -51,8 +51,6 @@ class CRM_Utils_Geocode_Google {
return FALSE;
}
$config = CRM_Core_Config::singleton();
$add = '';
if (!empty($values['street_address'])) {
......@@ -99,6 +97,37 @@ class CRM_Utils_Geocode_Google {
$add .= '+' . urlencode(str_replace('', '+', $values['country']));
}
$coord = self::makeRequest($add);
$values['geo_code_1'] = $coord['geo_code_1'] ?? 'null';
$values['geo_code_2'] = $coord['geo_code_2'] ?? 'null';
if (isset($coord['geo_code_error'])) {
$values['geo_code_error'] = $coord['geo_code_error'];
}
return isset($coord['geo_code_1'], $coord['geo_code_2']);
}
/**
* @param string $address
* Plain text address
* @return array
* @throws \GuzzleHttp\Exception\GuzzleException
*/
public static function getCoordinates($address) {
return self::makeRequest(urlencode($address));
}
/**
* @param string $add
* Url-encoded address
* @return array
* @throws \GuzzleHttp\Exception\GuzzleException
*/
private static function makeRequest($add) {
$config = CRM_Core_Config::singleton();
if (!empty($config->geoAPIKey)) {
$add .= '&key=' . urlencode($config->geoAPIKey);
}
......@@ -115,7 +144,7 @@ class CRM_Utils_Geocode_Google {
if ($xml === FALSE) {
// account blocked maybe?
CRM_Core_Error::debug_var('Geocoding failed. Message from Google:', $string);
return FALSE;
return ['geo_code_error' => $string];
}
if (isset($xml->status)) {
......@@ -126,23 +155,22 @@ class CRM_Utils_Geocode_Google {
) {
$ret = $xml->result->geometry->location->children();
if ($ret->lat && $ret->lng) {
$values['geo_code_1'] = (float) $ret->lat;
$values['geo_code_2'] = (float) $ret->lng;
return TRUE;
return [
'geo_code_1' => (float) $ret->lat,
'geo_code_2' => (float) $ret->lng,
];
}
}
elseif ($xml->status == 'ZERO_RESULTS') {
// reset the geo code values if we did not get any good values
$values['geo_code_1'] = $values['geo_code_2'] = 'null';
return FALSE;
return [];
}
else {
CRM_Core_Error::debug_var("Geocoding failed. Message from Google: ({$xml->status})", (string ) $xml->error_message);
$values['geo_code_1'] = $values['geo_code_2'] = 'null';
$values['geo_code_error'] = $xml->status;
return FALSE;
return ['geo_code_error' => $xml->status];
}
}
return [];
}
}
<?php
/*
+--------------------------------------------------------------------+
| Copyright CiviCRM LLC. All rights reserved. |
| |
| This work is published under the GNU AGPLv3 license with some |
| permitted exceptions and without any warranty. For full license |
| and copyright information, see https://civicrm.org/licensing |
+--------------------------------------------------------------------+
*/
namespace Civi\Api4\Action\Address;
use Civi\Api4\Generic\Result;
/**
* Converts an address string to lat/long coordinates.
*
* @method $this setAddress(string $address)
* @method string getAddress()
*/
class GetCoordinates extends \Civi\Api4\Generic\AbstractAction {
/**
* Address string to convert to lat/long
*
* @var string
* @required
*/
protected $address;
public function _run(Result $result) {
$coord = \CRM_Utils_Geocode_Google::getCoordinates($this->address);
if (isset($coord['geo_code_1'], $coord['geo_code_2'])) {
$result[] = $coord;
}
elseif (!empty($coord['geo_code_error'])) {
throw new \API_Exception('Geocoding failed. ' . $coord['geo_code_error']);
}
}
}
......@@ -54,4 +54,13 @@ class Address extends Generic\DAOEntity {
->setCheckPermissions($checkPermissions);
}
/**
* @param bool $checkPermissions
* @return Action\Address\GetCoordinates
*/
public static function getCoordinates($checkPermissions = TRUE) {
return (new Action\Address\GetCoordinates(__CLASS__, __FUNCTION__))
->setCheckPermissions($checkPermissions);
}
}
......@@ -340,6 +340,7 @@ class BasicGetFieldsAction extends BasicGetAction {
'Radio' => ts('Radio Buttons'),
'Select' => ts('Select'),
'Text' => ts('Text'),
'Location' => ts('Address Location'),
],
],
[
......
<?php
/*
+--------------------------------------------------------------------+
| Copyright CiviCRM LLC. All rights reserved. |
| |
| This work is published under the GNU AGPLv3 license with some |
| permitted exceptions and without any warranty. For full license |
| and copyright information, see https://civicrm.org/licensing |
+--------------------------------------------------------------------+
*/
namespace Civi\Api4\Service\Spec\Provider;
use Civi\Api4\Address;
use Civi\Api4\Query\Api4SelectQuery;
use Civi\Api4\Service\Spec\FieldSpec;
use Civi\Api4\Service\Spec\RequestSpec;
class AddressGetSpecProvider implements Generic\SpecProviderInterface {
/**
* @param \Civi\Api4\Service\Spec\RequestSpec $spec
*/
public function modifySpec(RequestSpec $spec) {
// Groups field
$field = new FieldSpec('proximity', 'Address', 'Boolean');
$field->setLabel(ts('Address Proximity'))
->setTitle(ts('Address Proximity'))
->setInputType('Location')
->setColumnName('geo_code_1')
->setDescription(ts('Address is within a given distance to a location'))
->setType('Filter')
->setOperators(['<='])
->addSqlFilter([__CLASS__, 'getProximitySql']);
$spec->addFieldSpec($field);
}
/**
* @param string $entity
* @param string $action
*
* @return bool
*/
public function applies($entity, $action) {
return $entity === 'Address' && $action === 'get';
}
/**
* @param array $field
* @param string $fieldAlias
* @param string $operator
* @param mixed $value
* @param \Civi\Api4\Query\Api4SelectQuery $query
* @param int $depth
* return string
*/
public static function getProximitySql(array $field, string $fieldAlias, string $operator, $value, Api4SelectQuery $query, int $depth): string {
$unit = $value['distance_unit'] ?? 'km';
$distance = $value['distance'] ?? 0;
if ($unit === 'miles') {
$distance = $distance * 1609.344;
}
else {
$distance = $distance * 1000.00;
}
if (!isset($value['geo_code_1'], $value['geo_code_2'])) {
$value = Address::getCoordinates(FALSE)
->setAddress($value['address'])
->execute()->first();
}
if (
isset($value['geo_code_1']) && is_numeric($value['geo_code_1']) &&
isset($value['geo_code_2']) && is_numeric($value['geo_code_2'])
) {
return \CRM_Contact_BAO_ProximityQuery::where(
$value['geo_code_1'],
$value['geo_code_2'],
$distance,
explode('.', $fieldAlias)[0]
);
}
return '(0)';
}
}
......@@ -237,7 +237,7 @@ class CRM_Contact_Form_Search_Custom_Proximity extends CRM_Contact_Form_Search_C
}
/**
* @return array|null
* @return array
*/
public function setDefaultValues() {
if (!empty($this->_formValues)) {
......@@ -246,22 +246,17 @@ class CRM_Contact_Form_Search_Custom_Proximity extends CRM_Contact_Form_Search_C
$config = CRM_Core_Config::singleton();
$countryDefault = $config->defaultContactCountry;
$stateprovinceDefault = $config->defaultContactStateProvince;
$defaults = [];
$defaults = [
'prox_distance_unit' => CRM_Utils_Address::getDefaultDistanceUnit(),
];
if ($countryDefault) {
if ($countryDefault == '1228' || $countryDefault == '1226') {
$defaults['prox_distance_unit'] = 'miles';
}
else {
$defaults['prox_distance_unit'] = 'km';
}
$defaults['country_id'] = $countryDefault;
if ($stateprovinceDefault) {
$defaults['state_province_id'] = $stateprovinceDefault;
}
return $defaults;
}
return NULL;
return $defaults;
}
/**
......
......@@ -47,6 +47,7 @@ class Admin {
'defaultDisplay' => SearchDisplay::getDefault(FALSE)->setSavedSearch(['id' => NULL])->execute()->first(),
'modules' => $extensions,
'defaultContactType' => \CRM_Contact_BAO_ContactType::basicTypeInfo()['Individual']['name'] ?? NULL,
'defaultDistanceUnit' => \CRM_Utils_Address::getDefaultDistanceUnit(),
'tags' => Tag::get()
->addSelect('id', 'name', 'color', 'is_selectable', 'description')
->addWhere('used_for', 'CONTAINS', 'civicrm_saved_search')
......
......@@ -61,6 +61,10 @@
return expr.indexOf('(') > -1;
};
this.areFunctionsAllowed = function(expr) {
return this.allowFunctions && ctrl.getField(expr).type !== 'Filter';
};
this.addGroup = function(op) {
ctrl.clauses.push([op, []]);
};
......
......@@ -15,7 +15,7 @@
</span>
</div>
<div ng-if="!$ctrl.conjunctions[clause[0]]" class="api4-input-group">
<crm-search-function ng-if="$ctrl.allowFunctions" class="form-group" expr="clause[0]" mode="clause"></crm-search-function>
<crm-search-function ng-if="$ctrl.areFunctionsAllowed(clause[0])" class="form-group" expr="clause[0]" mode="clause"></crm-search-function>
<span ng-if="!$ctrl.hasFunction(clause[0])">
<input class="form-control collapsible-optgroups" ng-model="clause[0]" crm-ui-select="{data: $ctrl.fields, allowClear: true, placeholder: 'Field'}" ng-change="$ctrl.changeClauseField(clause, index)" />
</span>
......
<select class="form-control api4-operator" ng-model="$ctrl.getSetOperator" ng-model-options="{getterSetter: true}" ng-options="o.key as o.value for o in $ctrl.getOperators()" ng-change="$ctrl.changeClauseOperator()" ></select>
<select class="form-control api4-operator" ng-model="$ctrl.getSetOperator" ng-if="$ctrl.getOperators().length > 1" ng-model-options="{getterSetter: true}" ng-options="o.key as o.value for o in $ctrl.getOperators()" ng-change="$ctrl.changeClauseOperator()" ></select>
<crm-search-input ng-if="$ctrl.operatorTakesInput()" ng-model="$ctrl.getSetValue" ng-model-options="{getterSetter: true}" field="$ctrl.field" option-key="$ctrl.optionKey" op="$ctrl.getSetOperator()" format="$ctrl.format" class="form-group"></crm-search-input>
......@@ -9,7 +9,7 @@
},
require: {ngModel: 'ngModel'},
template: '<div class="form-group" ng-include="$ctrl.getTemplate()"></div>',
controller: function($scope, formatForSelect2) {
controller: function($scope, formatForSelect2, crmApi4) {
var ts = $scope.ts = CRM.ts('org.civicrm.search_kit'),
ctrl = this;
......@@ -95,6 +95,21 @@
}
};
this.lookupAddress = function() {
ctrl.value.geo_code_1 = null;
ctrl.value.geo_code_2 = null;
if (ctrl.value.address) {
crmApi4('Address', 'getCoordinates', {
address: ctrl.value.address
}).then(function(coordinates) {
if (coordinates[0]) {
ctrl.value.geo_code_1 = coordinates[0].geo_code_1;
ctrl.value.geo_code_2 = coordinates[0].geo_code_2;
}
});
}
};
this.getTemplate = function() {
var field = ctrl.field || {};
......@@ -102,6 +117,11 @@
return '~/crmSearchTasks/crmSearchInput/text.html';
}
if (field.input_type === 'Location') {
ctrl.value = ctrl.value || {distance_unit: CRM.crmSearchAdmin.defaultDistanceUnit};
return '~/crmSearchTasks/crmSearchInput/location.html';
}
if (isDateField(field)) {
return '~/crmSearchTasks/crmSearchInput/date.html';
}
......
<div class="form-group">
<input class="form-control" type="number" ng-model="$ctrl.value.distance" placeholder="{{:: ts('Distance') }}" >
<select class="form-control" ng-model="$ctrl.value.distance_unit">
<option value="km">{{:: ts('Km') }}</option>
<option value="miles">{{:: ts('Miles') }}</option>
</select>
<input class="form-control" ng-model="$ctrl.value.address" placeholder="{{:: ts('Street, City, State, Country') }}" ng-change="$ctrl.lookupAddress()" ng-model-options="{updateOn: 'blur'}" >
</div>
......@@ -59,4 +59,29 @@ class AddressTest extends Api4TestBase implements TransactionalInterface {
$this->assertTrue($addresses[1]['is_primary']);
}
public function testSearchProximity() {
$cid = $this->createTestRecord('Contact')['id'];
$sampleData = [
['geo_code_1' => 20, 'geo_code_2' => 20],
['geo_code_1' => 21, 'geo_code_2' => 21],
['geo_code_1' => 19, 'geo_code_2' => 19],
['geo_code_1' => 15, 'geo_code_2' => 15],
];
$addreses = $this->saveTestRecords('Address', [
'records' => $sampleData,
'defaults' => ['contact_id' => $cid],
])->column('id');
$result = Address::get(FALSE)
->addWhere('contact_id', '=', $cid)
->addWhere('proximity', '<=', ['distance' => 600, 'geo_code_1' => 20, 'geo_code_2' => 20])
->execute()->column('id');
$this->assertCount(3, $result);
$this->assertContains($addreses[0], $result);
$this->assertContains($addreses[1], $result);
$this->assertContains($addreses[2], $result);
$this->assertNotContains($addreses[3], $result);
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment