0010012: testUpdateContactWithMissingPostalcode fails
[tine20] / tine20 / Addressbook / Controller / Contact.php
1 <?php
2 /**
3  * Tine 2.0
4  *
5  * @package     Addressbook
6  * @subpackage  Controller
7  * @license     http://www.gnu.org/licenses/agpl.html AGPL Version 3
8  * @author      Lars Kneschke <l.kneschke@metaways.de>
9  * @copyright   Copyright (c) 2007-2012 Metaways Infosystems GmbH (http://www.metaways.de)
10  * 
11  */
12
13 /**
14  * contact controller for Addressbook
15  *
16  * @package     Addressbook
17  * @subpackage  Controller
18  */
19 class Addressbook_Controller_Contact extends Tinebase_Controller_Record_Abstract
20 {
21     /**
22      * set geo data for contacts
23      * 
24      * @var boolean
25      */
26     protected $_setGeoDataForContacts = FALSE;
27     
28     /**
29      * the constructor
30      *
31      * don't use the constructor. use the singleton 
32      */
33     private function __construct() {
34         $this->_applicationName = 'Addressbook';
35         $this->_modelName = 'Addressbook_Model_Contact';
36         $this->_backend = Addressbook_Backend_Factory::factory(Addressbook_Backend_Factory::SQL);
37         $this->_purgeRecords = FALSE;
38         $this->_resolveCustomFields = TRUE;
39         $this->_updateMultipleValidateEachRecord = TRUE;
40         $this->_duplicateCheckFields = Addressbook_Config::getInstance()->get(Addressbook_Config::CONTACT_DUP_FIELDS, array(
41             array('n_given', 'n_family', 'org_name'),
42             array('email'),
43         ));
44         
45         // fields used for private and company address
46         $this->_addressFields = array('locality', 'postalcode', 'street', 'countryname');
47         
48         $this->_setGeoDataForContacts = Tinebase_Config::getInstance()->get(Tinebase_Config::MAPPANEL, TRUE);
49         if (! $this->_setGeoDataForContacts) {
50             if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ . ' Mappanel/geoext/nominatim disabled with config option.');
51         }
52     }
53     
54     /**
55      * don't clone. Use the singleton.
56      *
57      */
58     private function __clone() 
59     {
60     }
61     
62     /**
63      * holds the instance of the singleton
64      *
65      * @var Addressbook_Controller_Contact
66      */
67     private static $_instance = NULL;
68     
69     /**
70      * the singleton pattern
71      *
72      * @return Addressbook_Controller_Contact
73      */
74     public static function getInstance() 
75     {
76         if (self::$_instance === NULL) {
77             self::$_instance = new Addressbook_Controller_Contact();
78         }
79         
80         return self::$_instance;
81     }
82     
83     /**
84      * gets binary contactImage
85      *
86      * @param int $_contactId
87      * @return blob
88      */
89     public function getImage($_contactId) {
90         // ensure user has rights to see image
91         $this->get($_contactId);
92         
93         $image = $this->_backend->getImage($_contactId);
94         return $image;
95     }
96     
97     /**
98      * returns the default addressbook
99      * 
100      * @return Tinebase_Model_Container
101      */
102     public function getDefaultAddressbook()
103     {
104         return Tinebase_Container::getInstance()->getDefaultContainer($this->_applicationName, NULL, Addressbook_Preference::DEFAULTADDRESSBOOK);
105     }
106     
107     /**
108     * you can define default filters here
109     *
110     * @param Tinebase_Model_Filter_FilterGroup $_filter
111     */
112     protected function _addDefaultFilter(Tinebase_Model_Filter_FilterGroup $_filter = NULL)
113     {
114         if (! $_filter->isFilterSet('showDisabled')) {
115             $disabledFilter = $_filter->createFilter('showDisabled', 'equals', FALSE);
116             $disabledFilter->setIsImplicit(TRUE);
117             $_filter->addFilter($disabledFilter);
118         }
119     }
120     
121     /**
122      * fetch one contact identified by $_userId
123      *
124      * @param   int $_userId
125      * @param   boolean $_ignoreACL don't check acl grants
126      * @return  Addressbook_Model_Contact
127      * @throws  Addressbook_Exception_AccessDenied if user has no read grant
128      * @throws  Addressbook_Exception_NotFound if contact is hidden from addressbook
129      * 
130      * @todo this is almost always called with ignoreACL = TRUE because contacts can be hidden from addressbook. 
131      *       is this the way we want that?
132      */
133     public function getContactByUserId($_userId, $_ignoreACL = FALSE)
134     {
135         $contact = $this->_backend->getByUserId($_userId);
136         
137         if ($_ignoreACL === FALSE) {
138             if (empty($contact->container_id)) {
139                 throw new Addressbook_Exception_NotFound('Contact is hidden from addressbook (container id is empty).');
140             }
141             if (! Tinebase_Core::getUser()->hasGrant($contact->container_id, Tinebase_Model_Grants::GRANT_READ)) {
142                 throw new Addressbook_Exception_AccessDenied('Read access to contact denied.');
143             }
144         }
145         
146         if ($this->_resolveCustomFields && $contact->has('customfields')) {
147             Tinebase_CustomField::getInstance()->resolveRecordCustomFields($contact);
148         }
149         
150         return $contact;
151     }
152
153     /**
154     * can be called to activate/deactivate if geodata should be set for contacts (ignoring the config setting)
155     *
156     * @param  boolean optional
157     * @return boolean
158     */
159     public function setGeoDataForContacts()
160     {
161         $value = (func_num_args() === 1) ? (bool) func_get_arg(0) : NULL;
162         return $this->_setBooleanMemberVar('_setGeoDataForContacts', $value);
163     }
164     
165     /**
166      * gets profile portion of the requested user
167      * 
168      * @param string $_userId
169      * @return Addressbook_Model_Contact 
170      */
171     public function getUserProfile($_userId)
172     {
173         Tinebase_UserProfile::getInstance()->checkRight($_userId);
174         
175         $contact = $this->getContactByUserId($_userId, TRUE);
176         $userProfile = Tinebase_UserProfile::getInstance()->doProfileCleanup($contact);
177         
178         return $userProfile;
179     }
180
181     /**
182      * update multiple records in an iteration
183      * @see Tinebase_Record_Iterator / self::updateMultiple()
184      *
185      * @param Tinebase_Record_RecordSet $_records
186      * @param array $_data
187      *
188      *    Overwrites Tinebase_Controller_Record_Abstract::processUpdateMultipleIteration: jpegphoto is set to null, so no deletion of photos on multipleUpdate happens
189      *    @TODO: Can be removed when "0000284: modlog of contact images / move images to vfs" is resolved. 
190      * 
191      */
192     public function processUpdateMultipleIteration($_records, $_data)
193     {
194         if (count($_records) === 0) {
195             return;
196         }
197
198         foreach ($_records as $currentRecord) {
199             $oldRecordArray = $currentRecord->toArray();
200             $data = array_merge($oldRecordArray, $_data);
201
202             try {
203                 $record = new $this->_modelName($data);
204                 $record->__set('jpegphoto', NULL);
205                 $updatedRecord = $this->update($record, FALSE);
206
207                 $this->_updateMultipleResult['results']->addRecord($updatedRecord);
208                 $this->_updateMultipleResult['totalcount'] ++;
209
210             } catch (Tinebase_Exception_Record_Validation $e) {
211
212                 $this->_updateMultipleResult['exceptions']->addRecord(new Tinebase_Model_UpdateMultipleException(array(
213                     'id'         => $currentRecord->getId(),
214                     'exception'  => $e,
215                         'record'     => $currentRecord,
216                         'code'       => $e->getCode(),
217                         'message'    => $e->getMessage()
218                 )));
219                 $this->_updateMultipleResult['failcount'] ++;
220             }
221         }
222     }
223     
224     /**
225      * update profile portion of given contact
226      * 
227      * @param  Addressbook_Model_Contact $_userProfile
228      * @return Addressbook_Model_Contact
229      * 
230      * @todo think about adding $_ignoreACL to generic update() to simplify this
231      */
232     public function updateUserProfile($_userProfile)
233     {
234         Tinebase_UserProfile::getInstance()->checkRight($_userProfile->account_id);
235         
236         $doContainerACLChecks = $this->doContainerACLChecks(FALSE);
237         
238         $contact = $this->getContactByUserId($_userProfile->account_id, true);
239         
240         // we need to unset the jpegphoto because update() expects the image data and we only have a boolean value here
241         unset($contact->jpegphoto);
242         
243         $userProfile = Tinebase_UserProfile::getInstance()->mergeProfileInfo($contact, $_userProfile);
244         
245         $contact = $this->update($userProfile, FALSE);
246         
247         $userProfile = Tinebase_UserProfile::getInstance()->doProfileCleanup($contact);
248
249         $this->doContainerACLChecks($doContainerACLChecks);
250         
251         return $userProfile;
252     }
253     
254     /**
255      * inspect update of one record (after update)
256      * 
257      * @param   Tinebase_Record_Interface $updatedRecord   the just updated record
258      * @param   Tinebase_Record_Interface $record          the update record
259      * @param   Tinebase_Record_Interface $currentRecord   the current record (before update)
260      * @return  void
261      */
262     protected function _inspectAfterUpdate($updatedRecord, $record, $currentRecord)
263     {
264         if ($updatedRecord->type === Addressbook_Model_Contact::CONTACTTYPE_USER) {
265             Tinebase_User::getInstance()->updateContact($updatedRecord);
266         }
267     }
268     
269     /**
270      * delete one record
271      * - don't delete if it belongs to an user account
272      *
273      * @param Tinebase_Record_Interface $_record
274      * @throws Addressbook_Exception_AccessDenied
275      */
276     protected function _deleteRecord(Tinebase_Record_Interface $_record)
277     {
278         if (!empty($_record->account_id)) {
279             throw new Addressbook_Exception_AccessDenied('It is not allowed to delete a contact linked to an user account!');
280         }
281         
282         parent::_deleteRecord($_record);
283     }
284     
285     /**
286      * inspect creation of one record
287      * 
288      * @param   Tinebase_Record_Interface $_record
289      * @return  void
290      */
291     protected function _inspectBeforeCreate(Tinebase_Record_Interface $_record)
292     {
293         $this->_setGeoData($_record);
294         
295         if (isset($_record->type) &&  $_record->type == Addressbook_Model_Contact::CONTACTTYPE_USER) {
296             throw new Addressbook_Exception_InvalidArgument('can not add contact of type user');
297         }
298     }
299     
300     /**
301      * inspect update of one record
302      * 
303      * @param   Tinebase_Record_Interface $_record      the update record
304      * @param   Tinebase_Record_Interface $_oldRecord   the current persistent record
305      * @return  void
306      * 
307      * @todo remove system note for updated jpegphoto when images are modlogged (@see 0000284: modlog of contact images / move images to vfs)
308      */
309     protected function _inspectBeforeUpdate($_record, $_oldRecord)
310     {
311         // do update of geo data only if one of address field changed
312         $addressDataChanged = FALSE;
313         foreach ($this->_addressFields as $field) {
314                if (
315                    ($_record->{'adr_one_' . $field} != $_oldRecord->{'adr_one_' . $field}) ||
316                    ($_record->{'adr_two_' . $field} != $_oldRecord->{'adr_two_' . $field})
317                ) {
318                 $addressDataChanged = TRUE;
319                 break;
320             }
321         }
322         
323         if ($addressDataChanged) {
324             $this->_setGeoData($_record);
325         }
326         
327         if (isset($_record->jpegphoto) && ! empty($_record->jpegphoto)) {
328             // add system note when jpegphoto gets updated
329             $translate = $translate = Tinebase_Translation::getTranslation('Addressbook');
330             $noteMessage = $translate->_('Uploaded new contact image.');
331             $traceException = new Exception($noteMessage);
332             if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ 
333                 . ' ' . $traceException);
334             Tinebase_Notes::getInstance()->addSystemNote($_record, Tinebase_Core::getUser(), Tinebase_Model_Note::SYSTEM_NOTE_NAME_CHANGED, $noteMessage);
335         }
336         
337         if (isset($_oldRecord->type) && $_oldRecord->type == Addressbook_Model_Contact::CONTACTTYPE_USER) {
338             $_record->type = Addressbook_Model_Contact::CONTACTTYPE_USER;
339         }
340     }
341     
342     /**
343      * set geodata for given address of record
344      * 
345      * @param string                     $_address (addressbook prefix - adr_one_ or adr_two_)
346      * @param Addressbook_Model_Contact $_record
347      * @param array $_ommitFields do not submit these fields to nominatim
348      * @return void
349      */
350     protected function _setGeoDataForAddress($_address, Addressbook_Model_Contact $_record, $_ommitFields = array())
351     {
352         if (
353             empty($_record->{$_address . 'locality'}) && 
354             empty($_record->{$_address . 'postalcode'}) && 
355             empty($_record->{$_address . 'street'}) && 
356             empty($_record->{$_address . 'countryname'})
357         ) {
358             $_record->{$_address . 'lon'} = NULL;
359             $_record->{$_address . 'lat'} = NULL;
360             
361             return;
362         }
363         
364         $nominatim = new Zend_Service_Nominatim();
365
366         if (! empty($_record->{$_address . 'locality'})) {
367             $nominatim->setVillage($_record->{$_address . 'locality'});
368         }
369         
370         if (! empty($_record->{$_address . 'postalcode'}) && ! in_array($_address . 'postalcode', $_ommitFields)) {
371             $nominatim->setPostcode($_record->{$_address . 'postalcode'});
372         }
373         
374         if (! empty($_record->{$_address . 'street'})) {
375             $nominatim->setStreet($_record->{$_address . 'street'});
376         }
377         
378         if (! empty($_record->{$_address . 'countryname'})) {
379             $country = Zend_Locale::getTranslation($_record->{$_address . 'countryname'}, 'Country', $_record->{$_address . 'countryname'});
380             if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
381                 . ($_address == 'adr_one_' ? ' Company address' : ' Private address') . ' country ' . $country);
382             $nominatim->setCountry($country);
383         }
384         
385         try {
386             $places = $nominatim->search();
387             
388             if (count($places) > 0) {
389                 $place = $places->current();
390                 $this->_applyNominatimPlaceToRecord($_address, $_record, $place);
391                 
392             } else {
393                 if (! in_array($_address . 'postalcode', $_ommitFields)) {
394                     if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ . 
395                         ($_address == 'adr_one_' ? ' Company address' : ' Private address') . ' could not find place - try it again without postalcode.');
396                         
397                     $this->_setGeoDataForAddress($_address, $_record, array($_address . 'postalcode'));
398                     return;
399                 }
400                 
401                 Tinebase_Core::getLogger()->notice(__METHOD__ . '::' . __LINE__ .
402                     ' ' . ($_address == 'adr_one_' ? 'Company address' : 'Private address') . ' Could not find place.');
403                 if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ . 
404                     ' ' . $_record->{$_address . 'street'} . ', ' . $_record->{$_address . 'postalcode'} . ', ' . $_record->{$_address . 'locality'} . ', ' . $_record->{$_address . 'countryname'});
405                 
406                 $_record->{$_address . 'lon'} = NULL;
407                 $_record->{$_address . 'lat'} = NULL;
408             }
409         } catch (Exception $e) {
410             Tinebase_Core::getLogger()->warn(__METHOD__ . '::' . __LINE__ . ' ' . $e->getMessage());
411             
412             // the address has changed, the old values for lon/lat can not be valid anymore
413             $_record->{$_address . 'lon'} = NULL;
414             $_record->{$_address . 'lat'} = NULL;
415         }
416     }
417     
418     /**
419      * _applyNominatimPlaceToRecord
420      * 
421      * @param string $address
422      * @param Addressbook_Model_Contact $record
423      * @param Zend_Service_Nominatim_Result $place
424      */
425     protected function _applyNominatimPlaceToRecord($address, $record, $place)
426     {
427         if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__ .
428             ' Place: ' . var_export($place, true));
429         
430         $record->{$address . 'lon'} = $place->lon;
431         $record->{$address . 'lat'} = $place->lat;
432         
433         if (empty($record->{$address . 'countryname'}) && ! empty($place->country_code)) {
434             if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
435                 . ' Updating record countryname from Nominatim: ' . $place->country_code);
436             $record->{$address . 'countryname'} = $place->country_code;
437         }
438         
439         if (empty($record->{$address . 'postalcode'}) && ! empty($place->postcode)) {
440             $this->_applyNominatimPostcode($address, $record, $place->postcode);
441         }
442         
443         if (empty($record->{$address . 'locality'}) && ! empty($place->city)) {
444             if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
445                 . ' Updating record locality from Nominatim: ' . $place->city);
446             $record->{$address . 'locality'} = $place->city;
447         }
448         
449         if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ . 
450             ($address == 'adr_one_' ? ' Company' : ' Private') . ' Place found: lon/lat ' . $record->{$address . 'lon'} . ' / ' . $record->{$address . 'lat'});
451     }
452     
453     /**
454      * _applyNominatimPostcode
455      * 
456      * @param string $address
457      * @param Addressbook_Model_Contact $record
458      * @param string $postcode
459      */
460     protected function _applyNominatimPostcode($address, $record, $postcode)
461     {
462         if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
463             . ' Got postalcode from Nominatim: ' . $postcode);
464         
465         // @see 0009424: missing postalcode prevents saving of contact
466         if (strpos($postcode, ',') !== false) {
467             $postcodes = explode(',', $postcode);
468             $postcode = $postcodes[0];
469             if (preg_match('/^[0-9]+$/',$postcode)) {
470                 // find the similar numbers to create a postcode with placeholders ('x')
471                 foreach ($postcodes as $code) {
472                     for ($i = 0; $i < strlen($postcode); $i++) {
473                         if ($code[$i] !== $postcode[$i]) {
474                             $postcode[$i] = 'x';
475                         }
476                     }
477                 }
478             }
479         }
480         
481         if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
482             . ' Updating record postalcode from Nominatim: ' . $postcode);
483         
484         $record->{$address . 'postalcode'} = $postcode;
485     }
486     
487     /**
488      * set geodata of record
489      * 
490      * @param Addressbook_Model_Contact $_record
491      * @return void
492      */
493     protected function _setGeoData(Addressbook_Model_Contact $_record)
494     {
495         if (! $this->_setGeoDataForContacts) {
496             return;
497         }
498         
499         $this->_setGeoDataForAddress('adr_one_', $_record);
500         $this->_setGeoDataForAddress('adr_two_', $_record);
501     }
502     
503     /**
504      * get number from street (and remove it)
505      * 
506      * @param string $_street
507      * @return string
508      */
509     protected function _splitNumberAndStreet(&$_street)
510     {
511         $pattern = '([0-9]+)';
512         preg_match('/ ' . $pattern . '$/', $_street, $matches);
513         
514         if (empty($matches)) {
515             // look at the beginning
516             preg_match('/^' . $pattern . ' /', $_street, $matches);
517         }
518         
519         if ((isset($matches[1]) || array_key_exists(1, $matches))) {
520             $result = $matches[1];
521             $_street = str_replace($matches[0], '', $_street);
522         } else {
523             $result = '';
524         }
525         
526         return $result;
527     }
528     
529     /**
530      * get contact information from string by parsing it using predefined rules
531      * 
532      * @param string $_address
533      * @return array with Addressbook_Model_Contact + array of unrecognized tokens
534      */
535     public function parseAddressData($_address)
536     {
537         $converter = new Addressbook_Convert_Contact_String();
538         
539         $result = array(
540             'contact'             => $converter->toTine20Model($_address),
541             'unrecognizedTokens'  => $converter->getUnrecognizedTokens(),
542         );
543                     
544         return $result;
545     }
546 }