0011522: improve handling of group-lists
[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         if (! empty($_record->account_id) || $_record->type == Addressbook_Model_Contact::CONTACTTYPE_USER) {
352
353             // first check if something changed that requires special rights
354             $changeAccount = false;
355             foreach (Addressbook_Model_Contact::getManageAccountFields() as $field) {
356                 if ($_record->{$field} != $_oldRecord->{$field}) {
357                     $changeAccount = true;
358                     break;
359                 }
360             }
361
362             // if so, check rights
363             if ($changeAccount) {
364                 if (!Tinebase_Core::getUser()->hasRight('Admin', Admin_Acl_Rights::MANAGE_ACCOUNTS)) {
365                     throw new Tinebase_Exception_AccessDenied('No permission to change account properties.');
366                 }
367             }
368         }
369     }
370     
371     /**
372      * set geodata for given address of record
373      * 
374      * @param string                     $_address (addressbook prefix - adr_one_ or adr_two_)
375      * @param Addressbook_Model_Contact $_record
376      * @param array $_ommitFields do not submit these fields to nominatim
377      * @return void
378      */
379     protected function _setGeoDataForAddress($_address, Addressbook_Model_Contact $_record, $_ommitFields = array())
380     {
381         if (
382             empty($_record->{$_address . 'locality'}) && 
383             empty($_record->{$_address . 'postalcode'}) && 
384             empty($_record->{$_address . 'street'}) && 
385             empty($_record->{$_address . 'countryname'})
386         ) {
387             $_record->{$_address . 'lon'} = NULL;
388             $_record->{$_address . 'lat'} = NULL;
389             
390             return;
391         }
392         
393         $nominatim = new Zend_Service_Nominatim();
394
395         if (! empty($_record->{$_address . 'locality'})) {
396             $nominatim->setVillage($_record->{$_address . 'locality'});
397         }
398         
399         if (! empty($_record->{$_address . 'postalcode'}) && ! in_array($_address . 'postalcode', $_ommitFields)) {
400             $nominatim->setPostcode($_record->{$_address . 'postalcode'});
401         }
402         
403         if (! empty($_record->{$_address . 'street'})) {
404             $nominatim->setStreet($_record->{$_address . 'street'});
405         }
406         
407         if (! empty($_record->{$_address . 'countryname'})) {
408             try {
409                 $country = Zend_Locale::getTranslation($_record->{$_address . 'countryname'}, 'Country', $_record->{$_address . 'countryname'});
410                 if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
411                     . ($_address == 'adr_one_' ? ' Company address' : ' Private address') . ' country ' . $country);
412                 $nominatim->setCountry($country);
413             } catch (Zend_Locale_Exception $zle) {
414                 Tinebase_Exception::log($zle, true);
415             }
416         }
417         
418         try {
419             $places = $nominatim->search();
420             
421             if (count($places) > 0) {
422                 $place = $places->current();
423                 $this->_applyNominatimPlaceToRecord($_address, $_record, $place);
424                 
425             } else {
426                 if (! in_array($_address . 'postalcode', $_ommitFields)) {
427                     if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ . 
428                         ($_address == 'adr_one_' ? ' Company address' : ' Private address') . ' could not find place - try it again without postalcode.');
429                         
430                     $this->_setGeoDataForAddress($_address, $_record, array($_address . 'postalcode'));
431                     return;
432                 }
433                 
434                 Tinebase_Core::getLogger()->notice(__METHOD__ . '::' . __LINE__ .
435                     ' ' . ($_address == 'adr_one_' ? 'Company address' : 'Private address') . ' Could not find place.');
436                 if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ . 
437                     ' ' . $_record->{$_address . 'street'} . ', ' . $_record->{$_address . 'postalcode'} . ', ' . $_record->{$_address . 'locality'} . ', ' . $_record->{$_address . 'countryname'});
438                 
439                 $_record->{$_address . 'lon'} = NULL;
440                 $_record->{$_address . 'lat'} = NULL;
441             }
442         } catch (Exception $e) {
443             Tinebase_Core::getLogger()->warn(__METHOD__ . '::' . __LINE__ . ' ' . $e->getMessage());
444             
445             // the address has changed, the old values for lon/lat can not be valid anymore
446             $_record->{$_address . 'lon'} = NULL;
447             $_record->{$_address . 'lat'} = NULL;
448         }
449     }
450     
451     /**
452      * _applyNominatimPlaceToRecord
453      * 
454      * @param string $address
455      * @param Addressbook_Model_Contact $record
456      * @param Zend_Service_Nominatim_Result $place
457      */
458     protected function _applyNominatimPlaceToRecord($address, $record, $place)
459     {
460         if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__ .
461             ' Place: ' . var_export($place, true));
462         
463         $record->{$address . 'lon'} = $place->lon;
464         $record->{$address . 'lat'} = $place->lat;
465         
466         if (empty($record->{$address . 'countryname'}) && ! empty($place->country_code)) {
467             if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
468                 . ' Updating record countryname from Nominatim: ' . $place->country_code);
469             $record->{$address . 'countryname'} = $place->country_code;
470         }
471         
472         if (empty($record->{$address . 'postalcode'}) && ! empty($place->postcode)) {
473             $this->_applyNominatimPostcode($address, $record, $place->postcode);
474         }
475         
476         if (empty($record->{$address . 'locality'}) && ! empty($place->city)) {
477             if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
478                 . ' Updating record locality from Nominatim: ' . $place->city);
479             $record->{$address . 'locality'} = $place->city;
480         }
481         
482         if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ . 
483             ($address == 'adr_one_' ? ' Company' : ' Private') . ' Place found: lon/lat ' . $record->{$address . 'lon'} . ' / ' . $record->{$address . 'lat'});
484     }
485     
486     /**
487      * _applyNominatimPostcode
488      * 
489      * @param string $address
490      * @param Addressbook_Model_Contact $record
491      * @param string $postcode
492      */
493     protected function _applyNominatimPostcode($address, $record, $postcode)
494     {
495         if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
496             . ' Got postalcode from Nominatim: ' . $postcode);
497         
498         // @see 0009424: missing postalcode prevents saving of contact
499         if (strpos($postcode, ',') !== false) {
500             $postcodes = explode(',', $postcode);
501             $postcode = $postcodes[0];
502             if (preg_match('/^[0-9]+$/',$postcode)) {
503                 // find the similar numbers to create a postcode with placeholders ('x')
504                 foreach ($postcodes as $code) {
505                     for ($i = 0; $i < strlen($postcode); $i++) {
506                         if ($code[$i] !== $postcode[$i]) {
507                             $postcode[$i] = 'x';
508                         }
509                     }
510                 }
511             }
512         }
513         
514         if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
515             . ' Updating record postalcode from Nominatim: ' . $postcode);
516         
517         $record->{$address . 'postalcode'} = $postcode;
518     }
519     
520     /**
521      * set geodata of record
522      * 
523      * @param Addressbook_Model_Contact $_record
524      * @return void
525      */
526     protected function _setGeoData(Addressbook_Model_Contact $_record)
527     {
528         if (! $this->_setGeoDataForContacts) {
529             return;
530         }
531         
532         $this->_setGeoDataForAddress('adr_one_', $_record);
533         $this->_setGeoDataForAddress('adr_two_', $_record);
534     }
535     
536     /**
537      * get number from street (and remove it)
538      * 
539      * @param string $_street
540      * @return string
541      */
542     protected function _splitNumberAndStreet(&$_street)
543     {
544         $pattern = '([0-9]+)';
545         preg_match('/ ' . $pattern . '$/', $_street, $matches);
546         
547         if (empty($matches)) {
548             // look at the beginning
549             preg_match('/^' . $pattern . ' /', $_street, $matches);
550         }
551         
552         if ((isset($matches[1]) || array_key_exists(1, $matches))) {
553             $result = $matches[1];
554             $_street = str_replace($matches[0], '', $_street);
555         } else {
556             $result = '';
557         }
558         
559         return $result;
560     }
561     
562     /**
563      * get contact information from string by parsing it using predefined rules
564      * 
565      * @param string $_address
566      * @return array with Addressbook_Model_Contact + array of unrecognized tokens
567      */
568     public function parseAddressData($_address)
569     {
570         $converter = new Addressbook_Convert_Contact_String();
571         
572         $result = array(
573             'contact'             => $converter->toTine20Model($_address),
574             'unrecognizedTokens'  => $converter->getUnrecognizedTokens(),
575         );
576                     
577         return $result;
578     }
579
580     /**
581      * generates path for the contact
582      *
583      * - we add to the path:
584      *      - lists contact is member of
585      *      - we add list role memberships
586      *
587      * @param Tinebase_Record_Abstract $record
588      * @return Tinebase_Record_RecordSet
589      */
590     public function generatePathForRecord($record)
591     {
592         $result = new Tinebase_Record_RecordSet('Tinebase_Model_Path');
593
594         // fetch all groups and role memberships and add to path
595         $listIds = Addressbook_Controller_List::getInstance()->getMemberships($record);
596         foreach ($listIds as $listId) {
597             $list = Addressbook_Controller_List::getInstance()->get($listId);
598             $listPaths = $this->_getPathsOfRecord($list);
599             if (count($listPaths) === 0) {
600                 // add self
601                 $listPaths->addRecord(new Tinebase_Model_Path(array(
602                     'path'          => $this->_getPathPart($list),
603                     'shadow_path'   => '/' . $list->getId(),
604                     'record_id'     => $list->getId(),
605                     'creation_time' => Tinebase_DateTime::now(),
606                 )));
607             }
608
609             foreach ($listPaths as $listPath) {
610                 if (count($list->memberroles) > 0) {
611                     foreach ($list->memberroles as $role) {
612                         $rolePath = clone($listPath);
613                         if ($role->contact_id === $record->getId()) {
614                             $role = Addressbook_Controller_ListRole::getInstance()->get($role->list_role_id);
615                             $rolePath->path .= $this->_getPathPart($role);
616                             $rolePath->shadow_path .= '/' . $role->getId();
617                             $rolePath->record_id = $role->getId();
618                             $result->addRecord($rolePath);
619                         }
620                     }
621                 } else {
622                     $result->addRecord($listPath);
623                 }
624             }
625         }
626
627         foreach ($result as $listPath) {
628             $listPath->path .= $this->_getPathPart($record);
629             $listPath->shadow_path .= '/' . $record->getId();
630             $listPath->record_id = $record->getId();
631         }
632
633         return $result;
634     }
635 }