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