diff --git a/CRM/Utils/Address.php b/CRM/Utils/Address.php index ff30f95cb138c82f9781db19cb0fc3512b65fcc6..ff54a5b83b56ac42f42389c02fa8ccdc8196c931 100644 --- a/CRM/Utils/Address.php +++ b/CRM/Utils/Address.php @@ -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'; + } + } diff --git a/CRM/Utils/Geocode/Google.php b/CRM/Utils/Geocode/Google.php index ad670f6c94ffe796fde3d12bba175f70a4b98ca8..0885a3d3ae6a59daa4d31dc7e8a707c33b05af3e 100644 --- a/CRM/Utils/Geocode/Google.php +++ b/CRM/Utils/Geocode/Google.php @@ -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 []; } } diff --git a/Civi/Api4/Action/Address/GetCoordinates.php b/Civi/Api4/Action/Address/GetCoordinates.php new file mode 100644 index 0000000000000000000000000000000000000000..86bcd16a49026ab6095a476409dfd9e1c14d3589 --- /dev/null +++ b/Civi/Api4/Action/Address/GetCoordinates.php @@ -0,0 +1,43 @@ +<?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']); + } + } + +} diff --git a/Civi/Api4/Address.php b/Civi/Api4/Address.php index a4ec60aeb31eefeeac3fa2cd34681d97bd8f3877..c9db2a80150d569211bca93f329a8c14855c192f 100644 --- a/Civi/Api4/Address.php +++ b/Civi/Api4/Address.php @@ -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); + } + } diff --git a/Civi/Api4/Generic/BasicGetFieldsAction.php b/Civi/Api4/Generic/BasicGetFieldsAction.php index afaee7fa0794a75286d49ceebd557e69445c4e58..494af4829cd44341f42e73e02ee646e6f485ac9f 100644 --- a/Civi/Api4/Generic/BasicGetFieldsAction.php +++ b/Civi/Api4/Generic/BasicGetFieldsAction.php @@ -340,6 +340,7 @@ class BasicGetFieldsAction extends BasicGetAction { 'Radio' => ts('Radio Buttons'), 'Select' => ts('Select'), 'Text' => ts('Text'), + 'Location' => ts('Address Location'), ], ], [ diff --git a/Civi/Api4/Service/Spec/Provider/AddressGetSpecProvider.php b/Civi/Api4/Service/Spec/Provider/AddressGetSpecProvider.php new file mode 100644 index 0000000000000000000000000000000000000000..65e891bde9fae5724c1b299b99fdb44330caa720 --- /dev/null +++ b/Civi/Api4/Service/Spec/Provider/AddressGetSpecProvider.php @@ -0,0 +1,90 @@ +<?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)'; + } + +} diff --git a/ext/legacycustomsearches/CRM/Contact/Form/Search/Custom/Proximity.php b/ext/legacycustomsearches/CRM/Contact/Form/Search/Custom/Proximity.php index e572e99f5e8bfbeba7d903575252ab04ffbd82eb..d33c0731a4011cbf93fa6e3935d3a027daae82d8 100644 --- a/ext/legacycustomsearches/CRM/Contact/Form/Search/Custom/Proximity.php +++ b/ext/legacycustomsearches/CRM/Contact/Form/Search/Custom/Proximity.php @@ -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; } /** diff --git a/ext/search_kit/Civi/Search/Admin.php b/ext/search_kit/Civi/Search/Admin.php index 5a652474b3886bc39ee79250fd9ab90b971baa63..5eb3d4601a7743e63e432fe627ef4cec4f04dbbc 100644 --- a/ext/search_kit/Civi/Search/Admin.php +++ b/ext/search_kit/Civi/Search/Admin.php @@ -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') diff --git a/ext/search_kit/ang/crmSearchAdmin/crmSearchClause.component.js b/ext/search_kit/ang/crmSearchAdmin/crmSearchClause.component.js index cd235a73128d0853aa2e035cf29b9a559db684a0..4b7b4b05ee0cd4d2e1093454ab25d64e22bb847b 100644 --- a/ext/search_kit/ang/crmSearchAdmin/crmSearchClause.component.js +++ b/ext/search_kit/ang/crmSearchAdmin/crmSearchClause.component.js @@ -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, []]); }; diff --git a/ext/search_kit/ang/crmSearchAdmin/crmSearchClause.html b/ext/search_kit/ang/crmSearchAdmin/crmSearchClause.html index b5a3ef25fb478483fbf295ee7c94f1df84bf9ef5..5baaaa7bd1ca0ab0c7cbc4227390556e704d428f 100644 --- a/ext/search_kit/ang/crmSearchAdmin/crmSearchClause.html +++ b/ext/search_kit/ang/crmSearchAdmin/crmSearchClause.html @@ -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> diff --git a/ext/search_kit/ang/crmSearchAdmin/crmSearchCondition.html b/ext/search_kit/ang/crmSearchAdmin/crmSearchCondition.html index f1c149de3a5e6b9b77423b04e6395afc5d52392c..ed54a75a289ae8c4696685b362fe89fe44799cd5 100644 --- a/ext/search_kit/ang/crmSearchAdmin/crmSearchCondition.html +++ b/ext/search_kit/ang/crmSearchAdmin/crmSearchCondition.html @@ -1,2 +1,2 @@ -<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> diff --git a/ext/search_kit/ang/crmSearchTasks/crmSearchInput/crmSearchInputVal.component.js b/ext/search_kit/ang/crmSearchTasks/crmSearchInput/crmSearchInputVal.component.js index a249019898ceb36389faacd84d3135988edce4ab..5cc68b77d209e8fb0124f8e28f909e17eed662f6 100644 --- a/ext/search_kit/ang/crmSearchTasks/crmSearchInput/crmSearchInputVal.component.js +++ b/ext/search_kit/ang/crmSearchTasks/crmSearchInput/crmSearchInputVal.component.js @@ -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'; } diff --git a/ext/search_kit/ang/crmSearchTasks/crmSearchInput/location.html b/ext/search_kit/ang/crmSearchTasks/crmSearchInput/location.html new file mode 100644 index 0000000000000000000000000000000000000000..8e5959808490556d24722942464810539f1f9cc9 --- /dev/null +++ b/ext/search_kit/ang/crmSearchTasks/crmSearchInput/location.html @@ -0,0 +1,8 @@ +<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> diff --git a/tests/phpunit/api/v4/Entity/AddressTest.php b/tests/phpunit/api/v4/Entity/AddressTest.php index 19c21e40447249d5b06d20afc5fc72fada0d3c6b..087bf350637cabba18517fee842268d04731f297 100644 --- a/tests/phpunit/api/v4/Entity/AddressTest.php +++ b/tests/phpunit/api/v4/Entity/AddressTest.php @@ -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); + } + }