diff --git a/.gitattributes b/.gitattributes index 28fccd2..3700b95 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,7 +1,6 @@ /tests export-ignore /docs export-ignore /.travis.yml export-ignore -/.travis.yml export-ignore /.phpunit.xml.dist export-ignore /.phpcs.xml.dist export-ignore README.md export-ignore diff --git a/.travis.yml b/.travis.yml index 12ecc98..4e49142 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,38 +1,46 @@ language: php php: - - 5.4 + - 5.6 + env: matrix: - - DB=MYSQL CORE_RELEASE=3.1 + - DB=MYSQL CORE_RELEASE=4 matrix: include: - - php: 5.4 + - php: 5.6 env: + - RECIPE_VERSION=1.0.x-dev - DB=MYSQL - - CORE_RELEASE=3.1 - - php: 5.4 + - PHPCS_TEST=1 + - PHPUNIT_TEST=1 + - php: 7.0 env: + - RECIPE_VERSION=1.1.x-dev - DB=PGSQL - - CORE_RELEASE=3.2 - - php: 5.5 + - PHPUNIT_TEST=1 + - php: 7.1 env: + - RECIPE_VERSION=4.2.x-dev - DB=MYSQL - - CORE_RELEASE=3.3 - - php: 5.6 + - PDO=1 + - PHPUNIT_TEST=1 + - php: 7.2 env: + - RECIPE_VERSION=4.x-dev - DB=MYSQL - - PHPCS_TEST=1 - - CORE_RELEASE=3 + - PDO=1 + - PHPUNIT_TEST=1 before_script: - - composer self-update || true - phpenv rehash - - git clone git://github.com/silverstripe-labs/silverstripe-travis-support.git ~/travis-support - - php ~/travis-support/travis_setup.php --source `pwd` --target ~/builds/ss - - cd ~/builds/ss + - phpenv config-rm xdebug.ini + - composer validate + - composer require --no-update silverstripe/recipe-cms:$RECIPE_VERSION + - if [[ $DB == PGSQL ]]; then composer require --no-update silverstripe/postgresql:2.0.x-dev; fi + - composer install --prefer-dist --no-interaction --no-progress --no-suggest --optimize-autoloader --verbose --profile script: - - if [[ $PHPCS_TEST ]]; then vendor/bin/phpcs addressable/code/ addressable/tests/ -n; fi - - vendor/bin/phpunit addressable/tests/ + - if [[ $PHPUNIT_TEST ]]; then vendor/bin/phpunit; fi + - if [[ $PHPCS_TEST ]]; then vendor/bin/phpcs src/ src/Forms/ tests/ ; fi diff --git a/.upgrade.yml b/.upgrade.yml new file mode 100644 index 0000000..e5b0015 --- /dev/null +++ b/.upgrade.yml @@ -0,0 +1,5 @@ +mappings: + GoogleGeocoding: Symbiote\Addressable\GeocodeService + Addressable: Symbiote\Addressable\Addressable + Geocodable: Symbiote\Addressable\Geocodable + RegexTextField: Symbiote\Addressable\Forms\RegexTextField diff --git a/LICENSE.md b/LICENSE.md index d8304b7..9484f90 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,28 +1,29 @@ -SilverStripe Addressable Module License -======================================= +BSD 3-Clause License -Copyright © 2009, Symbiote PTY LTD - www.symbiote.com.au +Copyright (c) 2018, Symbiote All rights reserved. -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. -* Redistributions in binary form must reproduce the above copyright notice, this - list of conditions and the following disclaimer in thebvdocumentation and/or - other materials provided with the distribution. -* Neither the name of SilverStripe nor the names of its contributors may be used - to endorse or promote products derived from this software without specific - prior written permission. -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON -ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md index b7f10b0..3ac3cad 100644 --- a/README.md +++ b/README.md @@ -1,70 +1,44 @@ -SilverStripe Addressable Module -=============================== -[![Build Status](https://travis-ci.org/symbiote/silverstripe-addressable.svg)](https://travis-ci.org/symbiote/silverstripe-addressable) +# Addressable -The Addressable module adds address fields to an object, and also has support -for automatic geocoding. +[![Build Status](https://travis-ci.org/symbiote/silverstripe-addressable.svg?branch=master)](https://travis-ci.org/symbiote/silverstripe-addressable) +[![Latest Stable Version](https://poser.pugx.org/symbiote/silverstripe-addressable/version.svg)](https://github.com/symbiote/silverstripe-addressable/releases) +[![Latest Unstable Version](https://poser.pugx.org/symbiote/silverstripe-addressable/v/unstable.svg)](https://packagist.org/packages/symbiote/silverstripe-addressable) +[![Total Downloads](https://poser.pugx.org/symbiote/silverstripe-addressable/downloads.svg)](https://packagist.org/packages/symbiote/silverstripe-addressable) +[![License](https://poser.pugx.org/symbiote/silverstripe-addressable/license.svg)](https://github.com/symbiote/silverstripe-addressable/blob/master/LICENSE.md) -Maintainer Contact ------------------- -* Marcus Nyeholt () +Adds address fields to a DataObject and also has support for automatic geocoding of the provided address. -Requirements ------------- -* SilverStripe 3.0+ +![CMS screenshot](https://user-images.githubusercontent.com/3859574/43246926-8b218be2-90f6-11e8-9929-72192e23fc81.png) -Documentation -------------- +## Composer Install -Quick Usage Overview --------------------- - -In order to add simple address fields (address, suburb, city, postcode and -country) to an object, simply apply to `Addressable` extension: - -```yml -Page: - extensions: - - Addressable +``` +composer require symbiote/silverstripe-addressable:~4.0 ``` +## Requirements -In order to then render the full address into a template, you can use either -`$FullAddress` to return a simple string, or `$FullAddressHTML` to render -the address into a HTML `
` tag. - -You can define a global set of allowed states or countries using -`Addressable::set_allowed_states()` and `::set_allowed_countries()` -respectively. These can also be set per-instance using `setAllowedStates()` and -`setAllowedCountries()`. - -If a single string is provided as a value, then this will be set as the field -for all new objects and the user will not be presented with an input field. If -the value is an array, the user will be presented with a dropdown field. - -To add automatic geocoding to an `Addressable` object when the address is -changed, simple apply the `Geocodable` extension: +* SilverStripe 4.0+ -```yml +## Documentation -Page: - extensions: - - Geocodable +* [Quick Start](docs/en/quick-start.md) +* [Advanced Usage](docs/en/advanced-usage.md) +* [License](LICENSE.md) +* [Contributing](CONTRIBUTING.md) -``` +## Changes from SilverStripe 3.X -This will then use the Google Maps API to translate the address into a latitude -and longitude on save, and save it into the `Lat` and `Lng` fields. NOTE - to support -this, you _must_ specify a Google app Server API key +* `GoogleGeocoding` changed class name to `Symbiote\Addressable\GeocodeService` + * The static method `address_to_point` was changed to a non-static method called `addressToPoint`. This allows you to use the Injector and replace GeocodeService with something else if you need to. +* `Addressable::set_allowed_states(array('' => '', 'NSW' => "New South Wales"));` has been deprecated in favour of config values. +* `Addressable::set_allowed_countries(array('' => '', 'AU' => "Australia"));` has been deprecated in favour of config values. +* `Addressable::set_postcode_regex(...);` has been deprecated in favour of config values. + * `Addressable::set_postcode_regex` config value has been deprecated in favour of `Addressable::postcode_regex` + * NOTE: Previously there was a hack in Addressable that read `Addressable::set_postcode_regex` config value, then called `Addressable::set_postcode_regex()` to update the `protected static postcode_regex;` value in the Addressable __construct() method. -```yml -GoogleGeocoding: - google_api_key: {your_google_server_api_key} +## Credits -``` - -Allow different postcode regex (e.g. UK postcode with numbers and letters mixed) in config.yml -```yml -Addressable: - set_postcode_regex: '/^[0-9A-Za-z]+$/' -``` +* [Mark Taylor](https://github.com/symbiote/silverstripe-addressable/commit/7eb2f81c66502093c82c293943f43de9154ad807) for adding the ability to easily embed a map with AddressMap +* [Nic](https://github.com/muskie9) for writing tests for this module +* [AJ Short](https://github.com/ajshort) for initially writing this module diff --git a/_config/config.yml b/_config/config.yml index 95cf3e7..e69de29 100644 --- a/_config/config.yml +++ b/_config/config.yml @@ -1,8 +0,0 @@ -name: Addressable ---- -GoogleGeocoding: - google_api_url: 'https://maps.googleapis.com/maps/api/geocode/xml' - google_api_key: '' - -Geocodable: - is_geocodable: true diff --git a/code/Addressable.php b/code/Addressable.php deleted file mode 100644 index 839c148..0000000 --- a/code/Addressable.php +++ /dev/null @@ -1,303 +0,0 @@ - 'Varchar(255)', - 'Suburb' => 'varchar(64)', - 'State' => 'Varchar(64)', - 'Postcode' => 'Varchar(10)', - 'Country' => 'Varchar(2)' - ); - - protected static $allowed_states; - protected static $allowed_countries; - protected static $postcode_regex = '/^[0-9]+$/'; - - protected $allowedStates; - protected $allowedCountries; - protected $postcodeRegex; - - public function __construct() - { - $this->allowedStates = self::$allowed_states; - $this->allowedCountries = self::$allowed_countries; - $customRegex = Config::inst()->get('Addressable', 'set_postcode_regex'); - if (!empty($customRegex)) { - self::set_postcode_regex($customRegex); - } - $this->postcodeRegex = self::$postcode_regex; - - parent::__construct(); - } - - public function updateCMSFields(FieldList $fields) - { - if ($fields->hasTabSet()) { - $fields->addFieldsToTab('Root.Address', $this->getAddressFields()); - } else { - $newFields = $this->getAddressFields(); - foreach ($newFields as $field) { - $fields->push($field); - } - } - } - - public function updateFrontEndFields(FieldList $fields) - { - if (!$fields->dataFieldByName("Address")) { - $fields->merge($this->getAddressFields()); - } - } - - public function populateDefaults() - { - if (is_string($this->allowedStates)) { - $this->owner->State = $this->allowedStates; - } - - if (is_string($this->allowedCountries)) { - $this->owner->Country = $this->allowedCountries; - } - } - - /** - * Sets the default allowed states for new instances. - * - * @param null|string|array $states - * @see Addressable::setAllowedStates - */ - public static function set_allowed_states($states) - { - self::$allowed_states = $states; - } - - /** - * Sets the default allowed countries for new instances. - * - * @param null|string|array $countries - * @see Addressable::setAllowedCountries - */ - public static function set_allowed_countries($countries) - { - self::$allowed_countries = $countries; - } - - /** - * get the allowed states for this object - * - * @return array - */ - public function getAllowedStates() - { - return $this->allowedStates; - } - - /** - * get the allowed countries for this object - * - * @return array - */ - public function getAllowedCountries() - { - return $this->allowedCountries; - } - - /** - * Sets the default postcode regex for new instances. - * - * @param string $regex - */ - public static function set_postcode_regex($regex) - { - self::$postcode_regex = $regex; - } - - /** - * @return array - */ - public function getAddressFields($_params = array()) - { - $params = array_merge( - array( - 'includeHeader' => true, - ), - (array) $_params - ); - - $fields = array( - new TextField('Address', _t('Addressable.ADDRESS', 'Address')), - new TextField('Suburb', _t('Addressable.SUBURB', 'Suburb')) - ); - - if ($params['includeHeader']) { - array_unshift( - $fields, - new HeaderField('AddressHeader', _t('Addressable.ADDRESSHEADER', 'Address')) - ); - } - - $label = _t('Addressable.STATE', 'State'); - if (is_array($this->allowedStates)) { - $fields[] = new DropdownField('State', $label, $this->allowedStates); - } elseif (!is_string($this->allowedStates)) { - $fields[] = new TextField('State', $label); - } - - $postcode = new RegexTextField('Postcode', _t('Addressable.POSTCODE', 'Postcode')); - $postcode->setRegex($this->postcodeRegex); - $fields[] = $postcode; - - $label = _t('Addressable.COUNTRY', 'Country'); - if (is_array($this->allowedCountries)) { - $fields[] = new DropdownField('Country', $label, $this->allowedCountries); - } elseif (!is_string($this->allowedCountries)) { - $fields[] = new CountryDropdownField('Country', $label); - } - $this->owner->extend("updateAddressFields", $fields); - - return $fields; - } - - /** - * @return bool - */ - public function hasAddress() - { - return ( - $this->owner->Address - && $this->owner->Suburb - && $this->owner->State - && $this->owner->Postcode - && $this->owner->Country - ); - } - - /** - * Returns the full address as a simple string. - * - * @return string - */ - public function getFullAddress() - { - $parts = array( - $this->owner->Address, - $this->owner->Suburb, - $this->owner->State, - $this->owner->Postcode, - $this->owner->getCountryName() - ); - - return implode(', ', array_filter($parts)); - } - - /** - * Returns the full address in a simple HTML template. - * - * @return string - */ - public function getFullAddressHTML() - { - return $this->owner->renderWith('Address'); - } - - /** - * Returns a static google map of the address, linking out to the address. - * - * @param int $width (optional) - * @param int $height (optional) - * @param int $scale (optional) - * @return string - */ - public function AddressMap($width = 320, $height = 240, $scale = 1) - { - $data = $this->owner->customise(array( - 'Width' => $width, - 'Height' => $height, - 'Scale' => $scale, - 'Address' => rawurlencode($this->getFullAddress()), - 'Key' => Config::inst()->get('GoogleGeocoding', 'google_api_key') - )); - return $data->renderWith('AddressMap'); - } - - /** - * Returns the country name (not the 2 character code). - * - * @return string - */ - public function getCountryName() - { - return Zend_Locale::getTranslation($this->owner->Country, 'territory', i18n::get_locale()); - } - - /** - * Returns TRUE if any of the address fields have changed. - * - * @param int $level - * @return bool - */ - public function isAddressChanged($level = 1) - { - $fields = array('Address', 'Suburb', 'State', 'Postcode', 'Country'); - $changed = $this->owner->getChangedFields(false, $level); - - foreach ($fields as $field) { - if (array_key_exists($field, $changed)) { - return true; - } - } - - return false; - } - - /** - * Sets the states that a user can select. By default they can input any - * state into a text field, but if you set an array it will be replaced with - * a dropdown field. - * - * @param array $states - */ - public function setAllowedStates($states) - { - $this->allowedStates = $states; - } - - /** - * Sets the countries that a user can select. There are three possible - * values: - * - * - * - * @param null|string|array $states - */ - public function setAllowedCountries($countries) - { - $this->allowedCountries = $countries; - } - - /** - * Sets a regex that an entered postcode must match to be accepted. This can - * be set to NULL to disable postcode validation and allow any value. - * - * The postcode regex defaults to only accepting numerical postcodes. - * - * @param string $regex - */ - public function setPostcodeRegex($regex) - { - $this->postcodeRegex = $regex; - } -} diff --git a/code/GoogleGeocoding.php b/code/GoogleGeocoding.php deleted file mode 100644 index bed8282..0000000 --- a/code/GoogleGeocoding.php +++ /dev/null @@ -1,56 +0,0 @@ -get('GoogleGeocoding', 'google_api_url'); - $key = Config::inst()->get('GoogleGeocoding', 'google_api_key'); - - // Query the Google API - $service = new RestfulService($url); - $service->setQueryString(array( - 'address' => $address, - 'sensor' => 'false', - 'region' => $region, - 'key' => $key - )); - if ($service->request()->getStatusCode() === 500) { - $errorMessage = '500 status code, Are you sure your SSL certificates are properly setup? You can workaround this locally by setting CURLOPT_SSL_VERIFYPEER to "false", however this is not recommended for security reasons.'; - if (Director::isDev()) { - throw new Exception($errorMessage); - } else { - user_error($errorMessage, E_USER_WARNING); - } - return false; - } - if (!$service->request()->getBody()) { - // If blank response, ignore to avoid XML parsing errors. - return false; - } - $response = $service->request()->simpleXML(); - - if ($response->status != 'OK') { - return false; - } - - $location = $response->result->geometry->location; - return array( - 'lat' => (float) $location->lat, - 'lng' => (float) $location->lng - ); - } -} diff --git a/composer.json b/composer.json index 9ff4797..814ed91 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "symbiote/silverstripe-addressable", "description": "SilverStripe addressable and geocoding module", - "type": "silverstripe-module", + "type": "silverstripe-vendormodule", "keywords": [ "silverstripe", "symbiote", @@ -20,23 +20,32 @@ } ], "require": { - "silverstripe/framework": "^3.1" + "silverstripe/cms": "^4.0", + "guzzlehttp/guzzle": "^5.3.1|^6.2.1" }, "require-dev": { + "phpunit/phpunit": "^5.7", "squizlabs/php_codesniffer": "^3.0" }, "scripts": { - "phpcbf": "phpcbf code/ tests/", - "phpcs": "phpcs code/ tests/" + "phpcbf": "phpcbf src/ src/Forms/ tests/", + "phpcs": "phpcs src/ src/Forms/ tests/" + }, + "autoload": { + "psr-4": { + "Symbiote\\Addressable\\": "src/", + "Symbiote\\Addressable\\Tests\\": "tests/" + } }, "extra": { - "installer-name": "addressable", "branch-alias": { - "dev-master": "3.0.x-dev" + "dev-master": "4.0.x-dev" } }, "replace": { "ajshort/silverstripe-addressable": "self.version", "silverstripe-australia/addressable": "self.version" - } + }, + "prefer-stable": true, + "minimum-stability": "dev" } diff --git a/docs/en/advanced-usage.md b/docs/en/advanced-usage.md new file mode 100644 index 0000000..50382b0 --- /dev/null +++ b/docs/en/advanced-usage.md @@ -0,0 +1,72 @@ +# Advanced Configuration + + +## Configure Geocodable Service + +```yml +Symbiote\Addressable\GeocodeService: + google_api_url: 'https://maps.googleapis.com/maps/api/geocode/xml' # This is already defined as the default value. + google_api_key: 'API_KEY_HERE' # Recommended! You will hit quota limit issues in production without this! +``` + +## Change regex to validate postcode + +```yml +Symbiote\Addressable\Addressable: + postcode_regex: '/^[0-9A-Za-z]+$/' +``` + +## Lock to 1 country or state + +You can lock down Addressable to only use 1 country or 1 state by configuring it as shown below. +When you only have 1 country or 1 state, the `Country` or `State` field will be automatically populated when a new record is created. (Before it's even written) + +### Global setting (affects all DataObjects using Addressable) +```yml +Symbiote\Addressable\Addressable: + allowed_countries: + au: 'Australia' + allowed_states: + vic: 'Victoria' +``` + +### Local setting (affects the targetted DataObjects) +You can also change what countries and states are available on a per-DataObject level like so: +```yml +Page: + extensions: + - Symbiote\Addressable\Addressable + allowed_countries: + au: 'Australia' + allowed_states: + vic: 'Victoria' +``` + + +## Configure multiple countries or states + +### Global setting (affects all DataObjects using Addressable) + +```yml +Symbiote\Addressable\Addressable: + allowed_countries: + au: 'Australia' + nz: 'New Zealand' + allowed_states: + vic: 'Victoria' + nsw: 'New South Wales' +``` + + +### Local setting (affects the targetted DataObjects) +```yml +Page: + extensions: + - Symbiote\Addressable\Addressable + allowed_countries: + au: 'Australia' + nz: 'New Zealand' + allowed_states: + vic: 'Victoria' + nsw: 'New South Wales' +``` diff --git a/docs/en/quick-start.md b/docs/en/quick-start.md new file mode 100644 index 0000000..99b1c5c --- /dev/null +++ b/docs/en/quick-start.md @@ -0,0 +1,30 @@ +# Quick Start + +## Add Address fields + +1. Install via composer. + +2. Apply the "Addressable" extension to your SiteTree or DataObject class to automatically add address fields. +```yml +Page: + extensions: + - Symbiote\Addressable\Addressable +``` + +## Transform Address field data into a latitude and longitude + +1. First, complete the steps above to "Add Address fields" + +2. Apply the "Geocodable" extension to your SiteTree or DataObject class. You will also need the Addressable extension applied as well. +```yml +Page: + extensions: + - Symbiote\Addressable\Addressable + - Symbiote\Addressable\Geocodable +``` + +3. It is highly recommended that you configure a Google API key or else you will most likely hit "over your quota" issues in production very quickly. +```yml +Symbiote\Addressable\GeocodeService: + google_api_key: 'API_KEY_HERE' +``` diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..160ed2b --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,14 @@ + + + + tests/ + + + + src/ + + tests/ + + + + diff --git a/src/Addressable.php b/src/Addressable.php new file mode 100644 index 0000000..848c406 --- /dev/null +++ b/src/Addressable.php @@ -0,0 +1,329 @@ + 'Varchar(255)', + 'Suburb' => 'Varchar(64)', + 'State' => 'Varchar(64)', + 'Postcode' => 'Varchar(10)', + 'Country' => 'Varchar(2)' + ); + + /** + * Define an array of states that the user can select from. + * If no states are defined, a user can type in any plain text for their state. + * If only 1 state is defined, that will be the default populated value. + * + * @var array + * @config + */ + private static $allowed_states = []; + + /** + * Define an array of countries that the user can select from. + * If only 1 country is defined, that will be the default populated value. + * + * @var array + * @config + */ + private static $allowed_countries = []; + + /** + * @var string + * @config + */ + private static $postcode_regex = '/^[0-9]+$/'; + + public function __construct() + { + parent::__construct(); + + // Throw exception for deprecated config + if (Config::inst()->get('Addressable', 'set_postcode_regex') || + Config::inst()->get(__CLASS__, 'set_postcode_regex')) { + throw new Exception('Addressable config "set_postcode_regex" is deprecated in favour of using YML config "postcode_regex"'); + } + } + + public function updateCMSFields(FieldList $fields) + { + if ($fields->hasTabSet()) { + $fields->addFieldsToTab('Root.Address', $this->getAddressFields()); + } else { + $newFields = $this->getAddressFields(); + foreach ($newFields as $field) { + $fields->push($field); + } + } + } + + public function updateFrontEndFields(FieldList $fields) + { + if (!$fields->dataFieldByName("Address")) { + $fields->merge($this->getAddressFields()); + } + } + + public function populateDefaults() + { + $allowedStates = $this->owner->getAllowedStates(); + if (is_array($allowedStates) && + count($allowedStates) === 1) { + reset($allowedStates); + $this->owner->State = key($allowedStates); + } + + $allowedCountries = $this->owner->getAllowedCountries(); + if (is_array($allowedCountries) && + count($allowedCountries) === 1) { + reset($allowedCountries); + $this->owner->Country = key($allowedCountries); + } + } + + /** + * @return bool + */ + public function hasAddress() + { + return ( + $this->owner->Address + && $this->owner->Suburb + && $this->owner->State + && $this->owner->Postcode + && $this->owner->Country + ); + } + + /** + * Returns the full address as a simple string. + * + * @return string + */ + public function getFullAddress() + { + $parts = array( + $this->owner->Address, + $this->owner->Suburb, + $this->owner->State, + $this->owner->Postcode, + $this->owner->getCountryName() + ); + + return implode(', ', array_filter($parts)); + } + + /** + * Returns the full address in a simple HTML template. + * + * @return DBHTMLText + */ + public function getFullAddressHTML() + { + return $this->owner->renderWith('Symbiote/Addressable/Address'); + } + + /** + * Returns a static google map of the address, linking out to the address. + * + * @param int $width (optional) + * @param int $height (optional) + * @param int $scale (optional) + * @return DBHTMLText + */ + public function AddressMap($width = 320, $height = 240, $scale = 1) + { + $data = $this->owner->customise(array( + 'Width' => $width, + 'Height' => $height, + 'Scale' => $scale, + 'Address' => rawurlencode($this->getFullAddress()), + 'Key' => Config::inst()->get(GeocodeService::class, 'google_api_key') + )); + return $data->renderWith('Symbiote/Addressable/AddressMap'); + } + + /** + * Returns the country name (not the 2 character code). + * + * @return string + */ + public function getCountryName() + { + return IntlLocales::singleton()->countryName($this->owner->Country); + } + + /** + * Returns TRUE if any of the address fields have changed. + * + * @param int $level + * @return bool + */ + public function isAddressChanged($level = 1) + { + $fields = array('Address', 'Suburb', 'State', 'Postcode', 'Country'); + $changed = $this->owner->getChangedFields(false, $level); + + foreach ($fields as $field) { + if (array_key_exists($field, $changed)) { + return true; + } + } + + return false; + } + + /** + * NOTE: + * + * This was made private as you should *probably* be using "updateAddressFields" to manipulate + * these fields (if at all). + * + * If this doesn't end up being the case, feel free to make a PR and change this back to "public". + * + * @return array + */ + private function getAddressFields($_params = array()) + { + $params = array_merge( + array( + 'includeHeader' => true, + ), + (array) $_params + ); + + $fields = array( + TextField::create('Address', _t('Addressable.ADDRESS', 'Address')), + TextField::create('Suburb', _t('Addressable.SUBURB', 'Suburb')) + ); + + if ($params['includeHeader']) { + array_unshift( + $fields, + HeaderField::create('AddressHeader', _t('Addressable.ADDRESSHEADER', 'Address')) + ); + } + + + // Get state field + $label = _t('Addressable.STATE', 'State'); + $allowedStates = $this->owner->getAllowedStates(); + if (count($allowedStates) >= 1) { + // If allowed states are restricted, only allow those + $fields[] = DropdownField::create('State', $label, $allowedStates); + } elseif (!$allowedStates) { + // If no allowed states defined, allow the user to type anything + $fields[] = TextField::create('State', $label); + } + + // Get postcode field + $postcode = RegexTextField::create('Postcode', _t('Addressable.POSTCODE', 'Postcode')); + $postcode->setRegex($this->getPostcodeRegex()); + $fields[] = $postcode; + + // Get country field + $fields[] = DropdownField::create( + 'Country', + _t('Addressable.COUNTRY', 'Country'), + $this->owner->getAllowedCountries() + ); + + $this->owner->extend("updateAddressFields", $fields); + + return $fields; + } + + /** + * Get the allowed states for this object + * + * @return array + */ + public function getAllowedStates() + { + // Get states from extending object. (ie. Page, DataObject) + $allowedStates = $this->owner->config()->allowed_states; + if (is_array($allowedStates) && + $allowedStates) { + return $allowedStates; + } + + // Get allowed states global. If there are no specific rules on a Page/DataObject + // fallback to what is configured on this extension + $allowedStates = Config::inst()->get(__CLASS__, 'allowed_states'); + if (is_array($allowedStates) && + $allowedStates) { + return $allowedStates; + } + return []; + } + + /** + * get the allowed countries for this object + * + * @return array + */ + public function getAllowedCountries() + { + // Get allowed_countries from extending object. (ie. Page, DataObject) + $allowedCountries = $this->owner->config()->allowed_countries; + if (is_array($allowedCountries) && + $allowedCountries) { + return $allowedCountries; + } + + // Get allowed countries global. If there are no specific rules on a Page/DataObject + // fallback to what is configured on this extension + $allowedCountries = Config::inst()->get(__CLASS__, 'allowed_countries'); + if (is_array($allowedCountries) && + $allowedCountries) { + return $allowedCountries; + } + + // Finally, fallback to a full list of countries + return IntlLocales::singleton()->config()->get('countries'); + } + + /** + * @return string + */ + private function getPostcodeRegex() + { + // Get postcode regex from extending object. (ie. Page, DataObject) + $regex = $this->owner->config()->postcode_regex; + if ($regex) { + return $regex; + } + + // Get postcode regex global. If there are no specific rules on a Page/DataObject + // fallback to what is configured on this extension + $regex = Config::inst()->get(__CLASS__, 'postcode_regex'); + if ($regex) { + return $regex; + } + + return ''; + } +} diff --git a/code/formfields/RegexTextField.php b/src/Forms/RegexTextField.php similarity index 93% rename from code/formfields/RegexTextField.php rename to src/Forms/RegexTextField.php index d3a72dd..6eb4d59 100644 --- a/code/formfields/RegexTextField.php +++ b/src/Forms/RegexTextField.php @@ -1,4 +1,9 @@ 'Boolean', @@ -17,58 +41,77 @@ class Geocodable extends DataExtension public function onBeforeWrite() { - if (!Config::inst()->get('Geocodable', 'is_geocodable')) { + $record = $this->getOwner(); + // Reset last error + $record->__geocodable_exception = null; + if (!Config::inst()->get(__CLASS__, 'is_geocodable')) { // Allow user-code to disable Geocodable. This was added // so that dev/tasks that write a *lot* of Geocodable records can // ignore this expensive logic. return; } - if ($this->owner->LatLngOverride) { + if ($record->LatLngOverride) { // A CMS user disabled automatical retrieval of Lat/Lng // and most likely input their own values. return; } - if (!$this->owner->hasMethod('isAddressChanged') || - !$this->owner->isAddressChanged()) { + if (!$record->hasMethod('isAddressChanged') || + !$record->isAddressChanged()) { return; } - $address = $this->owner->getFullAddress(); - $region = strtolower($this->owner->Country); + $address = $record->getFullAddress(); + $region = strtolower($record->Country); - $point = GoogleGeocoding::address_to_point($address, $region); + $point = []; + try { + $point = Injector::inst()->get(GeocodeService::class)->addressToPoint($address, $region); + } catch (GeocodeServiceException $e) { + // Default behaviour is to ignore errors like ZERO_RESULTS or this just failing. + $record->__geocodable_exception = $e; + return; + } if (!$point) { return; } - $this->owner->Lat = $point['lat']; - $this->owner->Lng = $point['lng']; + $record->Lat = $point['lat']; + $record->Lng = $point['lng']; + } + + /** + * @return GeocodeServiceException|null + */ + public function getLastGeocodableException() + { + return $this->owner->__geocodable_exception; } public function updateCMSFields(FieldList $fields) { + $record = $this->getOwner(); $fields->removeByName(array('LatLngOverride', 'Lat', 'Lng')); // Adds Lat/Lng fields for viewing in the CMS $compositeField = CompositeField::create(); $compositeField->push($overrideField = CheckboxField::create('LatLngOverride', 'Override Latitude and Longitude?')); $overrideField->setDescription('Check this box and save to be able to edit the latitude and longitude manually.'); - if ($this->owner->Lng && $this->owner->Lat) { - $googleMapURL = 'http://maps.google.com/?q='.$this->owner->Lat.','.$this->owner->Lng; + if ($record->Lng && $record->Lat) { + $googleMapURL = 'https://maps.google.com/?q='.$record->Lat.','.$record->Lng; $googleMapDiv = ''; $compositeField->push(LiteralField::create('MapURL_Readonly', $googleMapDiv)); } - if ($this->owner->LatLngOverride) { + if ($record->LatLngOverride) { $compositeField->push(TextField::create('Lat', 'Lat')); $compositeField->push(TextField::create('Lng', 'Lng')); } else { - $compositeField->push(ReadonlyField::create('Lat_Readonly', 'Lat', $this->owner->Lat)); - $compositeField->push(ReadonlyField::create('Lng_Readonly', 'Lng', $this->owner->Lng)); + $compositeField->push(ReadonlyField::create('Lat_Readonly', 'Lat', $record->Lat)); + $compositeField->push(ReadonlyField::create('Lng_Readonly', 'Lng', $record->Lng)); } - if ($this->owner->hasExtension('Addressable')) { + if ($record->hasExtension('Addressable')) { // If using addressable, put the fields with it $fields->addFieldToTab('Root.Address', ToggleCompositeField::create('Coordinates', 'Coordinates', $compositeField)); - } elseif ($this->owner instanceof SiteTree) { + } elseif ($record instanceof SiteTree) { // If SIteTree but not using Addressable, put after 'Metadata' toggle composite field $fields->insertAfter($compositeField, 'ExtraMeta'); } else { diff --git a/src/GeocodeService.php b/src/GeocodeService.php new file mode 100644 index 0000000..571f854 --- /dev/null +++ b/src/GeocodeService.php @@ -0,0 +1,96 @@ +get(__CLASS__, 'google_api_url'); + $key = Config::inst()->get(__CLASS__, 'google_api_key'); + + if (!$url) { + // If no URL configured. Stop. + throw new GeocodeServiceException('No google_api_url configured. This is not allowed.'); + } + + // Add params + $queryVars = [ + 'address' => $address, + 'sensor' => 'false', + ]; + if ($region) { + $queryVars['region'] = $region; + } + if ($key) { + $queryVars['key'] = $key; + } + $url .= '?'.http_build_query($queryVars); + + $client = new Client(); + $response = $client->get($url); + if (!$response) { + throw new GeocodeServiceException('No response.', 0, ''); + } + $statusCode = $response->getStatusCode(); + if ($statusCode !== 200) { + throw new GeocodeServiceException('Unexpected status code:'.$statusCode, $statusCode, ''); + } + $responseBody = (string)$response->getBody(); + $xml = new SimpleXMLElement($responseBody); + if (!isset($xml->result)) { + // Error handling + if (isset($xml->status)) { + $status = (string)$xml->status; + if ($status === self::ERROR_ZERO_RESULTS) { + throw new GeocodeServiceException('Zero results returned. Invalid status from response: '.$status, $statusCode, $responseBody); + } else { + throw new GeocodeServiceException('Unhandled status from response: '.$status, $statusCode, $responseBody); + } + } + // Fallback to full string dump + $text = trim($response->getBody()); + throw new GeocodeServiceException('Invalid response: '.$text, $responseBody); + } + $location = $xml->result->geometry->location; + return [ + 'lat' => (float)$location->lat, + 'lng' => (float)$location->lng + ]; + } +} diff --git a/src/GeocodeServiceException.php b/src/GeocodeServiceException.php new file mode 100644 index 0000000..603bc1e --- /dev/null +++ b/src/GeocodeServiceException.php @@ -0,0 +1,51 @@ +responseBody = $responseBody; + + $xml = new SimpleXMLElement($responseBody); + if (isset($xml->status)) { + $this->status = (string)$xml->status; + } + } + + public function getResponse() + { + return $this->responseBody; + } + + /** + * Return "status" values: + * - ZERO_RESULTS + * - OVER_QUERY_LIMIT + * + * @return string + */ + public function getStatus() + { + return $this->status; + } +} diff --git a/templates/Address.ss b/templates/Symbiote/Addressable/Address.ss similarity index 100% rename from templates/Address.ss rename to templates/Symbiote/Addressable/Address.ss diff --git a/templates/AddressMap.ss b/templates/Symbiote/Addressable/AddressMap.ss similarity index 100% rename from templates/AddressMap.ss rename to templates/Symbiote/Addressable/AddressMap.ss diff --git a/tests/AddressableDataObjectTest.php b/tests/AddressableDataObjectTest.php index 07cc053..98e383b 100644 --- a/tests/AddressableDataObjectTest.php +++ b/tests/AddressableDataObjectTest.php @@ -1,8 +1,13 @@ update(GeocodeService::class, 'google_api_key', self::FAKE_GOOGLE_MAP_API_KEY); + + $record = new AddressableDataObjectTest(); + $record->Address = '101-103 Courtenay Place'; + $record->Suburb = 'Wellington'; + $record->Postcode = '6011'; + $record->Country = 'NZ'; + + $expected = << + + 101-103CourtenayPlace,Wellington,6011,NewZealand + + +HTML; + $this->assertEqualIgnoringWhitespace( + $expected, + $record->AddressMap()->getValue() + ); + } + + /** + * Taken from "framework\tests\view\SSViewerTest.php" + */ + protected function assertEqualIgnoringWhitespace($a, $b, $message = '') + { + $this->assertEquals(preg_replace('/\s+/', '', $a), preg_replace('/\s+/', '', $b), $message); + } +} diff --git a/tests/AddressableTest.php b/tests/AddressableTest.php index f07574f..042477e 100644 --- a/tests/AddressableTest.php +++ b/tests/AddressableTest.php @@ -1,5 +1,12 @@ assertTrue($addressable2->Postcode == '53081'); $this->assertTrue($addressable2->Country == 'US'); } + + /** + * Test the case where nothing is configured for the allowed_countries so + * we fallback to a full list of countries provided by SilverStripe. + */ + public function testConfigureNoCountry() + { + $record = new AddressableDataObjectTest(); + + // Test that nothing is populated by default + // (we only populate if 1 item is defined in the list) + $this->assertEquals( + '', + $record->Country + ); + + // Test that with nothing configured, it gets all countries + $this->assertEquals( + IntlLocales::singleton()->config()->get('countries'), + $record->getAllowedCountries() + ); + } + + /** + * Test the case where we configure 1 country in the allowed_countries config. + */ + public function testConfigureOneCountryGlobally() + { + Config::inst()->update(Addressable::class, 'allowed_countries', [ + 'au' => 'Australia', + ]); + $record = new AddressableDataObjectTest(); + + // Test that populateDefaults() is working + $this->assertEquals( + 'au', + $record->Country + ); + + // Test that we only get one country back in array + $this->assertEquals( + [ + 'au' => 'Australia', + ], + $record->getAllowedCountries() + ); + } + + public function testConfigureOneCountryOnExtendable() + { + Config::inst()->update(AddressableDataObjectTest::class, 'allowed_countries', [ + 'nz' => 'New Zealand', + ]); + $record = new AddressableDataObjectTest(); + + // Test that populateDefaults() is working + $this->assertEquals( + 'nz', + $record->Country + ); + + // Test that we only get one country back in array + $this->assertEquals( + [ + 'nz' => 'New Zealand', + ], + $record->getAllowedCountries() + ); + } + + public function testConfigureOneStateGlobally() + { + Config::inst()->update(Addressable::class, 'allowed_states', [ + 'vic' => 'Victoria', + ]); + $record = new AddressableDataObjectTest(); + + // Test that populateDefaults() is working + $this->assertEquals( + 'vic', + $record->State + ); + + // Test that we only get one country back in array + $this->assertEquals( + [ + 'vic' => 'Victoria', + ], + $record->getAllowedStates() + ); + } + + public function testConfigureOneStateOnExtendable() + { + Config::inst()->update(AddressableDataObjectTest::class, 'allowed_states', [ + 'nsw' => 'New South Wales', + ]); + $record = new AddressableDataObjectTest(); + + // Test that populateDefaults() is working + $this->assertEquals( + 'nsw', + $record->State + ); + + // Test that we only get one country back in array + $this->assertEquals( + [ + 'nsw' => 'New South Wales', + ], + $record->getAllowedStates() + ); + } } diff --git a/tests/GeocodableDataObjectTest.php b/tests/GeocodableDataObjectTest.php index b3dd603..7ca669e 100644 --- a/tests/GeocodableDataObjectTest.php +++ b/tests/GeocodableDataObjectTest.php @@ -1,9 +1,15 @@ 174.7789792, ]; - // NOTE(Jake): 2018-07-25 - // - // Ideally we would be able to determine a failure from GoogleGeocoding - // rather than assuming a 0,0 == failure. - // - // This was implemented as sometimes tests would fail in TravisCI, so I'd - // rather them be skipped. - // - if ($record->Lat == 0 && - $record->Lng == 0) { + $e = $record->getLastGeocodableException(); + if ($e && + $e->getStatus() === GeocodeService::ERROR_OVER_QUERY_LIMIT) { $this->markTestSkipped( - 'Skipping '. get_class($this).'::'.__FUNCTION__.'() due to Google endpoint seemingly not being reachable.' + 'Skipping '. get_class($this).'::'.__FUNCTION__.'() due to being over quota limit. Exception: '.$e->getMessage() ); $this->skipTest = true; return; @@ -46,13 +46,41 @@ public function testUpdatingLatLngFromAddress() ); } + /** + * Get the last geocodable error + */ + public function testGetLastError() + { + $record = new GeocodableDataObjectTest(); + $record->Address = '33 Jeremy McDooglestrontles House'; + $record->Suburb = 'Frinkiac'; + $record->Postcode = '3011'; + $record->Country = ''; + $record->write(); + + $e = $record->getLastGeocodableException(); + if ($e && + $e->getStatus() === GeocodeService::ERROR_OVER_QUERY_LIMIT) { + $this->markTestSkipped( + 'Skipping '. get_class($this).'::'.__FUNCTION__.'() due to being over quota limit. Exception: '.$e->getMessage() + ); + $this->skipTest = true; + return; + } + + $this->assertEquals( + GeocodeService::ERROR_ZERO_RESULTS, + $e->getStatus() + ); + } + /** * Make sure that Lat / Lng is not written to if "is_geocodable" * is false. */ public function testDisableLatLngUpdate() { - Config::inst()->update('Geocodable', 'is_geocodable', false); + Config::inst()->update(Geocodable::class, 'is_geocodable', false); $record = new GeocodableDataObjectTest(); $record->Address = '101-103 Courtenay Place';