]> git.agnieray.net Git - galette.git/blob - galette/lib/Galette/Entity/Adherent.php
Cannot set member as its own parent; closes #1806
[galette.git] / galette / lib / Galette / Entity / Adherent.php
1 <?php
2
3 /* vim: set expandtab tabstop=4 shiftwidth=4 softtabstop=4: */
4
5 /**
6 * Member class for galette
7 *
8 * PHP version 5
9 *
10 * Copyright © 2009-2023 The Galette Team
11 *
12 * This file is part of Galette (http://galette.tuxfamily.org).
13 *
14 * Galette is free software: you can redistribute it and/or modify
15 * it under the terms of the GNU General Public License as published by
16 * the Free Software Foundation, either version 3 of the License, or
17 * (at your option) any later version.
18 *
19 * Galette is distributed in the hope that it will be useful,
20 * but WITHOUT ANY WARRANTY; without even the implied warranty of
21 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
22 * GNU General Public License for more details.
23 *
24 * You should have received a copy of the GNU General Public License
25 * along with Galette. If not, see <http://www.gnu.org/licenses/>.
26 *
27 * @category Entity
28 * @package Galette
29 *
30 * @author Johan Cwiklinski <johan@x-tnd.be>
31 * @copyright 2009-2023 The Galette Team
32 * @license http://www.gnu.org/licenses/gpl-3.0.html GPL License 3.0 or (at your option) any later version
33 * @link http://galette.tuxfamily.org
34 * @since Available since 0.7dev - 2009-06-02
35 */
36
37 namespace Galette\Entity;
38
39 use ArrayObject;
40 use Galette\Events\GaletteEvent;
41 use Galette\Features\HasEvent;
42 use Galette\Features\Socials;
43 use Throwable;
44 use Analog\Analog;
45 use Laminas\Db\Sql\Expression;
46 use Galette\Core\Db;
47 use Galette\Core\Picture;
48 use Galette\Core\GaletteMail;
49 use Galette\Core\Password;
50 use Galette\Core\Preferences;
51 use Galette\Core\History;
52 use Galette\Repository\Groups;
53 use Galette\Core\Login;
54 use Galette\Repository\Members;
55 use Galette\Features\Dynamics;
56
57 /**
58 * Member class for galette
59 *
60 * @category Entity
61 * @name Adherent
62 * @package Galette
63 * @author Johan Cwiklinski <johan@x-tnd.be>
64 * @copyright 2009-2023 The Galette Team
65 * @license http://www.gnu.org/licenses/gpl-3.0.html GPL License 3.0 or (at your option) any later version
66 * @link http://galette.tuxfamily.org
67 * @since Available since 0.7dev - 02-06-2009
68 *
69 * @property integer $id
70 * @property integer|Title $title Either a title id or an instance of Title
71 * @property string $stitle Title label
72 * @property string $company_name
73 * @property string $name
74 * @property ?string $surname
75 * @property string $nickname
76 * @property string $birthdate Localized birthdate
77 * @property string $rbirthdate Raw birthdate
78 * @property string $birth_place
79 * @property integer $gender
80 * @property string $sgender Gender label
81 * @property string $job
82 * @property string $language
83 * @property integer $status
84 * @property string $sstatus Status label
85 * @property string $address
86 * @property string $zipcode
87 * @property string $town
88 * @property string $country
89 * @property string $phone
90 * @property string $gsm
91 * @property string $email
92 * @property string $gnupgid
93 * @property string $fingerprint
94 * @property string $login
95 * @property string $creation_date Localized creation date
96 * @property string $modification_date Localized modification date
97 * @property string $due_date Localized due date
98 * @property string $others_infos
99 * @property string $others_infos_admin
100 * @property Picture $picture
101 * @property array $groups
102 * @property array $managed_groups
103 * @property integer|Adherent $parent Parent id if parent dep is not loaded, Adherent instance otherwise
104 * @property array $children
105 * @property boolean $admin better to rely on isAdmin()
106 * @property boolean $staff better to rely on isStaff()
107 * @property boolean $due_free better to rely on isDueFree()
108 * @property boolean $appears_in_list better to rely on appearsInMembersList()
109 * @property boolean $active better to rely on isActive()
110 * @property boolean $duplicate better to rely on isDuplicate()
111 * @property string $sadmin yes/no
112 * @property string $sstaff yes/no
113 * @property string $sdue_free yes/no
114 * @property string $sappears_in_list yes/no
115 * @property string $sactive yes/no
116 * @property string $sfullname
117 * @property string $sname
118 * @property string $saddress
119 * @property string $contribstatus State of member contributions
120 * @property integer $days_remaining
121 * @property-read integer $parent_id
122 * @property Social $social Social networks/Contact
123 * @property string $number Member number
124 * @property-read bool $self_adh
125 */
126 class Adherent
127 {
128 use Dynamics;
129 use Socials;
130 use HasEvent;
131
132 public const TABLE = 'adherents';
133 public const PK = 'id_adh';
134
135 public const NC = 0;
136 public const MAN = 1;
137 public const WOMAN = 2;
138
139 public const AFTER_ADD_DEFAULT = 0;
140 public const AFTER_ADD_TRANS = 1;
141 public const AFTER_ADD_NEW = 2;
142 public const AFTER_ADD_SHOW = 3;
143 public const AFTER_ADD_LIST = 4;
144 public const AFTER_ADD_HOME = 5;
145
146 private $_id;
147 //Identity
148 private $_title;
149 private $_company_name;
150 private $_name;
151 private $_surname;
152 private $_nickname;
153 private $_birthdate;
154 private $_birth_place;
155 private $_gender;
156 private $_job;
157 private $_language;
158 private $_active;
159 private $_status;
160 //Contact information
161 private $_address;
162 private $_zipcode;
163 private $_town;
164 private $_country;
165 private $_phone;
166 private $_gsm;
167 private $_email;
168 private $_gnupgid;
169 private $_fingerprint;
170 //Galette relative information
171 private $_appears_in_list;
172 private $_admin;
173 private $_staff = false;
174 private $_due_free;
175 private $_login;
176 private $_password;
177 private $_creation_date;
178 private $_modification_date;
179 private $_due_date;
180 private $_others_infos;
181 private $_others_infos_admin;
182 private $_picture;
183 private $_oldness;
184 private $_days_remaining;
185 private $_groups = [];
186 private $_managed_groups = [];
187 private $_parent;
188 private $_children = [];
189 private $_duplicate = false;
190 private $_socials;
191 private $_number;
192
193 private $_row_classes;
194
195 private $_self_adh = false;
196
197 private $zdb;
198 private $preferences;
199 private $fields;
200 private $history;
201
202 private $parent_fields = [
203 'adresse_adh',
204 'cp_adh',
205 'ville_adh',
206 'email_adh'
207 ];
208
209 private $errors = [];
210
211 private $sendmail = false;
212
213 /**
214 * Default constructor
215 *
216 * @param Db $zdb Database instance
217 * @param mixed $args Either a ResultSet row, its id or its
218 * login or its email for to load s specific
219 * member, or null to just instantiate object
220 * @param false|array|null $deps Dependencies configuration, see Adherent::$_deps
221 */
222 public function __construct(Db $zdb, $args = null, $deps = null)
223 {
224 global $i18n;
225
226 $this->zdb = $zdb;
227
228 if ($deps !== null) {
229 if (is_array($deps)) {
230 $this->setDeps($deps);
231 } elseif ($deps === false) {
232 //no dependencies
233 $this->disableAllDeps();
234 } else {
235 Analog::log(
236 '$deps should be an array, ' . gettype($deps) . ' given!',
237 Analog::WARNING
238 );
239 }
240 }
241
242 $this
243 ->withAddEvent()
244 ->withEditEvent()
245 ->withoutDeleteEvent()
246 ->activateEvents();
247
248 if ($args == null || is_int($args)) {
249 if (is_int($args) && $args > 0) {
250 $this->load($args);
251 } else {
252 $this->_active = true;
253 $this->_language = $i18n->getID();
254 $this->_creation_date = date("Y-m-d");
255 $this->_status = $this->getDefaultStatus();
256 $this->_title = null;
257 $this->_gender = self::NC;
258 $gp = new Password($this->zdb);
259 $this->_password = $gp->makeRandomPassword();
260 $this->_picture = new Picture();
261 $this->_admin = false;
262 $this->_staff = false;
263 $this->_due_free = false;
264 $this->_appears_in_list = false;
265 $this->_parent = null;
266
267 if ($this->_deps['dynamics'] === true) {
268 $this->loadDynamicFields();
269 }
270 }
271 } elseif (is_object($args)) {
272 $this->loadFromRS($args);
273 } elseif (is_string($args)) {
274 $this->loadFromLoginOrMail($args);
275 }
276 }
277
278 /**
279 * Loads a member from its id
280 *
281 * @param int $id the identifier for the member to load
282 *
283 * @return bool true if query succeed, false otherwise
284 */
285 public function load(int $id): bool
286 {
287 try {
288 $select = $this->zdb->select(self::TABLE, 'a');
289
290 $select->join(
291 array('b' => PREFIX_DB . Status::TABLE),
292 'a.' . Status::PK . '=b.' . Status::PK,
293 array('priorite_statut')
294 )->where(array(self::PK => $id));
295
296 $results = $this->zdb->execute($select);
297
298 if ($results->count() === 0) {
299 return false;
300 }
301
302 /** @var ArrayObject $result */
303 $result = $results->current();
304 $this->loadFromRS($result);
305 return true;
306 } catch (Throwable $e) {
307 Analog::log(
308 'Cannot load member form id `' . $id . '` | ' . $e->getMessage(),
309 Analog::WARNING
310 );
311 throw $e;
312 }
313 }
314
315 /**
316 * Loads a member from its login
317 *
318 * @param string $login login for the member to load
319 *
320 * @return boolean
321 */
322 public function loadFromLoginOrMail(string $login): bool
323 {
324 try {
325 $select = $this->zdb->select(self::TABLE);
326 if (GaletteMail::isValidEmail($login)) {
327 //we got a valid email address, use it
328 $select->where(array('email_adh' => $login));
329 } else {
330 ///we did not get an email address, consider using login
331 $select->where(array('login_adh' => $login));
332 }
333
334 $results = $this->zdb->execute($select);
335 if ($results->count() > 0) {
336 /** @var ArrayObject $result */
337 $result = $results->current();
338 $this->loadFromRS($result);
339 }
340 return true;
341 } catch (Throwable $e) {
342 Analog::log(
343 'Cannot load member form login `' . $login . '` | ' .
344 $e->getMessage(),
345 Analog::WARNING
346 );
347 throw $e;
348 }
349 }
350
351 /**
352 * Populate object from a resultset row
353 *
354 * @param ArrayObject $r the resultset row
355 *
356 * @return void
357 */
358 private function loadFromRS(ArrayObject $r): void
359 {
360 $this->_self_adh = false;
361 $this->_id = $r->id_adh;
362 //Identity
363 if ($r->titre_adh !== null) {
364 $this->_title = new Title((int)$r->titre_adh);
365 }
366 $this->_company_name = $r->societe_adh;
367 $this->_name = $r->nom_adh;
368 $this->_surname = $r->prenom_adh;
369 $this->_nickname = $r->pseudo_adh;
370 if ($r->ddn_adh != '1901-01-01') {
371 $this->_birthdate = $r->ddn_adh;
372 }
373 $this->_birth_place = $r->lieu_naissance;
374 $this->_gender = (int)$r->sexe_adh;
375 $this->_job = $r->prof_adh;
376 $this->_language = $r->pref_lang;
377 $this->_active = $r->activite_adh == 1;
378 $this->_status = (int)$r->id_statut;
379 //Contact information
380 $this->_address = $r->adresse_adh;
381 $this->_zipcode = $r->cp_adh;
382 $this->_town = $r->ville_adh;
383 $this->_country = $r->pays_adh;
384 $this->_phone = $r->tel_adh;
385 $this->_gsm = $r->gsm_adh;
386 $this->_email = $r->email_adh;
387 $this->_gnupgid = $r->gpgid;
388 $this->_fingerprint = $r->fingerprint;
389 //Galette relative information
390 $this->_appears_in_list = $r->bool_display_info == 1;
391 $this->_admin = $r->bool_admin_adh == 1;
392 if (
393 isset($r->priorite_statut)
394 && $r->priorite_statut < Members::NON_STAFF_MEMBERS
395 ) {
396 $this->_staff = true;
397 }
398 $this->_due_free = $r->bool_exempt_adh == 1;
399 $this->_login = $r->login_adh;
400 $this->_password = $r->mdp_adh;
401 $this->_creation_date = $r->date_crea_adh;
402 if ($r->date_modif_adh != '1901-01-01') {
403 $this->_modification_date = $r->date_modif_adh;
404 } else {
405 $this->_modification_date = $this->_creation_date;
406 }
407 $this->_due_date = $r->date_echeance;
408 $this->_others_infos = $r->info_public_adh;
409 $this->_others_infos_admin = $r->info_adh;
410 $this->_number = $r->num_adh;
411
412 if ($r->parent_id !== null) {
413 $this->_parent = (int)$r->parent_id;
414 if ($this->_deps['parent'] === true) {
415 $this->loadParent();
416 }
417 }
418
419 if ($this->_deps['children'] === true) {
420 $this->loadChildren();
421 }
422
423 if ($this->_deps['picture'] === true) {
424 $this->_picture = new Picture($this->_id);
425 }
426
427 if ($this->_deps['groups'] === true) {
428 $this->loadGroups();
429 }
430
431 if ($this->_deps['dues'] === true) {
432 $this->checkDues();
433 }
434
435 if ($this->_deps['dynamics'] === true) {
436 $this->loadDynamicFields();
437 }
438
439 if ($this->_deps['socials'] === true) {
440 $this->loadSocials();
441 }
442 }
443
444 /**
445 * Load member parent
446 *
447 * @return void
448 */
449 private function loadParent(): void
450 {
451 if ($this->_parent !== null && !$this->_parent instanceof Adherent) {
452 $deps = array_fill_keys(array_keys($this->_deps), false);
453 $this->_parent = new Adherent($this->zdb, (int)$this->_parent, $deps);
454 }
455 }
456
457 /**
458 * Load member children
459 *
460 * @return void
461 */
462 private function loadChildren(): void
463 {
464 $this->_children = array();
465 try {
466 $id = self::PK;
467 $select = $this->zdb->select(self::TABLE);
468 $select->columns(
469 array($id)
470 )->where(['parent_id' => $this->_id]);
471
472 $results = $this->zdb->execute($select);
473
474 if ($results->count() > 0) {
475 foreach ($results as $row) {
476 $deps = $this->_deps;
477 $deps['children'] = false;
478 $deps['parent'] = false;
479 $this->_children[] = new Adherent($this->zdb, (int)$row->$id, $deps);
480 }
481 }
482 } catch (Throwable $e) {
483 Analog::log(
484 'Cannot load children for member #' . $this->_id . ' | ' .
485 $e->getMessage(),
486 Analog::WARNING
487 );
488 throw $e;
489 }
490 }
491
492 /**
493 * Load member groups
494 *
495 * @return void
496 */
497 public function loadGroups(): void
498 {
499 $this->_groups = Groups::loadGroups($this->_id);
500 $this->_managed_groups = Groups::loadManagedGroups($this->_id);
501 }
502
503 /**
504 * Load member social network/contact information
505 *
506 * @return void
507 */
508 public function loadSocials(): void
509 {
510 $this->_socials = Social::getListForMember($this->_id);
511 }
512
513 /**
514 * Retrieve status from preferences
515 *
516 * @return integer
517 *
518 */
519 private function getDefaultStatus(): int
520 {
521 global $preferences;
522 if ($preferences->pref_statut != '') {
523 return $preferences->pref_statut;
524 } else {
525 Analog::log(
526 'Unable to get pref_statut; is it defined in preferences?',
527 Analog::ERROR
528 );
529 return Status::DEFAULT_STATUS;
530 }
531 }
532
533 /**
534 * Check for dues status
535 *
536 * @return void
537 */
538 private function checkDues(): void
539 {
540 //how many days since our beloved member has been created
541 $now = new \DateTime();
542 $this->_oldness = $now->diff(
543 new \DateTime($this->_creation_date)
544 )->days;
545
546 if ($this->isDueFree()) {
547 //no fee required, we don't care about dates
548 $this->_row_classes .= ' cotis-exempt';
549 } else {
550 //ok, fee is required. Let's check the dates
551 if ($this->_due_date == '') {
552 $this->_row_classes .= ' cotis-never';
553 } else {
554 // To count the days remaining, the next begin date is required.
555 $due_date = new \DateTime($this->_due_date);
556 $next_begin_date = clone $due_date;
557 $next_begin_date->add(new \DateInterval('P1D'));
558 $date_diff = $now->diff($next_begin_date);
559 $this->_days_remaining = $date_diff->days;
560 // Active
561 if ($date_diff->invert == 0 && $date_diff->days >= 0) {
562 $this->_days_remaining = $date_diff->days;
563 if ($this->_days_remaining <= 30) {
564 if ($date_diff->days == 0) {
565 $this->_row_classes .= ' cotis-lastday';
566 }
567 $this->_row_classes .= ' cotis-soon';
568 } else {
569 $this->_row_classes .= ' cotis-ok';
570 }
571 // Expired
572 } elseif ($date_diff->invert == 1 && $date_diff->days >= 0) {
573 $this->_days_remaining = $date_diff->days;
574 //check if member is still active
575 $this->_row_classes .= $this->isActive() ? ' cotis-late' : ' cotis-old';
576 }
577 }
578 }
579 }
580
581 /**
582 * Is member admin?
583 *
584 * @return bool
585 */
586 public function isAdmin(): bool
587 {
588 return $this->_admin;
589 }
590
591 /**
592 * Is user member of staff?
593 *
594 * @return bool
595 */
596 public function isStaff(): bool
597 {
598 return $this->_staff;
599 }
600
601 /**
602 * Is member freed of dues?
603 *
604 * @return bool
605 */
606 public function isDueFree(): bool
607 {
608 return $this->_due_free;
609 }
610
611 /**
612 * Is member in specified group?
613 *
614 * @param string $group_name Group name
615 *
616 * @return boolean
617 */
618 public function isGroupMember(string $group_name): bool
619 {
620 if (!$this->isDepEnabled('groups')) {
621 $this->loadGroups();
622 }
623
624 foreach ($this->_groups as $g) {
625 if ($g->getName() == $group_name) {
626 return true;
627 }
628 }
629 return false;
630 }
631
632 /**
633 * Is member manager of specified group?
634 *
635 * @param string $group_name Group name
636 *
637 * @return boolean
638 */
639 public function isGroupManager(string $group_name): bool
640 {
641 if (!$this->isDepEnabled('groups')) {
642 $this->loadGroups();
643 }
644
645 foreach ($this->_managed_groups as $mg) {
646 if ($mg->getName() == $group_name) {
647 return true;
648 }
649 }
650 return false;
651 }
652
653 /**
654 * Does current member represents a company?
655 *
656 * @return boolean
657 */
658 public function isCompany(): bool
659 {
660 return trim($this->_company_name ?? '') != '';
661 }
662
663 /**
664 * Is current member a man?
665 *
666 * @return boolean
667 */
668 public function isMan(): bool
669 {
670 return (int)$this->_gender === self::MAN;
671 }
672
673 /**
674 * Is current member a woman?
675 *
676 * @return boolean
677 */
678 public function isWoman(): bool
679 {
680 return (int)$this->_gender === self::WOMAN;
681 }
682
683
684 /**
685 * Can member appears in public members list?
686 *
687 * @return bool
688 */
689 public function appearsInMembersList(): bool
690 {
691 return $this->_appears_in_list;
692 }
693
694 /**
695 * Is member active?
696 *
697 * @return bool
698 */
699 public function isActive(): bool
700 {
701 return $this->_active;
702 }
703
704 /**
705 * Does member have uploaded a picture?
706 *
707 * @return bool
708 */
709 public function hasPicture(): bool
710 {
711 return $this->_picture->hasPicture();
712 }
713
714 /**
715 * Does member have a parent?
716 *
717 * @return bool
718 */
719 public function hasParent(): bool
720 {
721 return !empty($this->_parent);
722 }
723
724 /**
725 * Does member have children?
726 *
727 * @return bool
728 */
729 public function hasChildren(): bool
730 {
731 if ($this->_children === null) {
732 if ($this->id) {
733 Analog::log(
734 'Children has not been loaded!',
735 Analog::WARNING
736 );
737 }
738 return false;
739 } else {
740 return count($this->_children) > 0;
741 }
742 }
743
744 /**
745 * Get row class related to current fee status
746 *
747 * @param boolean $public we want the class for public pages
748 *
749 * @return string the class to apply
750 */
751 public function getRowClass(bool $public = false): string
752 {
753 $strclass = ($this->isActive()) ? 'active-account' : 'inactive-account';
754 if ($public === false) {
755 $strclass .= $this->_row_classes;
756 }
757 return $strclass;
758 }
759
760 /**
761 * Get current member due status
762 *
763 * @return string i18n string representing state of due
764 */
765 public function getDues(): string
766 {
767 $ret = '';
768 $never_contributed = false;
769 $now = new \DateTime();
770 // To count the days remaining, the next begin date is required.
771 if ($this->_due_date === null) {
772 $this->_due_date = $now->format('Y-m-d');
773 $never_contributed = true;
774 }
775 $due_date = new \DateTime($this->_due_date);
776 $next_begin_date = clone $due_date;
777 $next_begin_date->add(new \DateInterval('P1D'));
778 $date_diff = $now->diff($next_begin_date);
779 if ($this->isDueFree()) {
780 $ret = _T("Freed of dues");
781 } elseif ($never_contributed === true) {
782 $patterns = array('/%days/', '/%date/');
783 $cdate = new \DateTime($this->_creation_date);
784 $replace = array(
785 $this->_oldness,
786 $cdate->format(__("Y-m-d"))
787 );
788 if ($this->_active) {
789 $ret = preg_replace(
790 $patterns,
791 $replace,
792 _T("Never contributed: Registered %days days ago (since %date)")
793 );
794 } else {
795 $ret = _T("Never contributed");
796 }
797 // Last active or first expired day
798 } elseif ($this->_days_remaining == 0) {
799 if ($date_diff->invert == 0) {
800 $ret = _T("Last day!");
801 } else {
802 $ret = _T("Late since today!");
803 }
804 // Active
805 } elseif ($date_diff->invert == 0 && $this->_days_remaining > 0) {
806 $patterns = array('/%days/', '/%date/');
807 $replace = array(
808 $this->_days_remaining,
809 $due_date->format(__("Y-m-d"))
810 );
811 $ret = preg_replace(
812 $patterns,
813 $replace,
814 _T("%days days remaining (ending on %date)")
815 );
816 // Expired
817 } elseif ($date_diff->invert == 1 && $this->_days_remaining > 0) {
818 $patterns = array('/%days/', '/%date/');
819 $replace = array(
820 // We need the number of days expired, not the number of days remaining.
821 $this->_days_remaining + 1,
822 $due_date->format(__("Y-m-d"))
823 );
824 if ($this->_active) {
825 $ret = preg_replace(
826 $patterns,
827 $replace,
828 _T("Late of %days days (since %date)")
829 );
830 } else {
831 $ret = _T("No longer member");
832 }
833 }
834 return $ret;
835 }
836
837 /**
838 * Retrieve Full name and surname for the specified member id
839 *
840 * @param Db $zdb Database instance
841 * @param integer $id Member id
842 * @param boolean $wid Add member id
843 * @param boolean $wnick Add member nickname
844 *
845 * @return string formatted Name and Surname
846 */
847 public static function getSName(Db $zdb, int $id, bool $wid = false, bool $wnick = false): string
848 {
849 try {
850 $select = $zdb->select(self::TABLE);
851 $select->where([self::PK => $id]);
852
853 $results = $zdb->execute($select);
854 $row = $results->current();
855 return self::getNameWithCase(
856 $row->nom_adh,
857 $row->prenom_adh,
858 false,
859 ($wid === true ? $row->id_adh : false),
860 ($wnick === true ? $row->pseudo_adh : false)
861 );
862 } catch (Throwable $e) {
863 Analog::log(
864 'Cannot get formatted name for member form id `' . $id . '` | ' .
865 $e->getMessage(),
866 Analog::WARNING
867 );
868 throw $e;
869 }
870 }
871
872 /**
873 * Get member name with correct case
874 *
875 * @param string $name Member name
876 * @param string $surname Member surname
877 * @param false|Title $title Member title to show or false
878 * @param false|integer $id Member id to display or false
879 * @param false|string $nick Member nickname to display or false
880 *
881 * @return string
882 */
883 public static function getNameWithCase(
884 ?string $name,
885 ?string $surname,
886 $title = false,
887 $id = false,
888 $nick = false
889 ): string {
890 $str = '';
891
892 if ($title instanceof Title) {
893 $str .= $title->tshort . ' ';
894 }
895
896 $str .= mb_strtoupper($name ?? '', 'UTF-8') . ' ' .
897 ucwords(mb_strtolower($surname ?? '', 'UTF-8'), " \t\r\n\f\v-_|");
898
899 if ($id !== false || !empty($nick)) {
900 $str .= ' (';
901 }
902 if (!empty($nick)) {
903 $str .= $nick;
904 }
905 if ($id !== false) {
906 if (!empty($nick)) {
907 $str .= ', ';
908 }
909 $str .= $id;
910 }
911 if ($id !== false || !empty($nick)) {
912 $str .= ')';
913 }
914 return strip_tags($str);
915 }
916
917 /**
918 * Change password for a given user
919 *
920 * @param Db $zdb Database instance
921 * @param integer $id_adh Member identifier
922 * @param string $pass New password
923 *
924 * @return boolean
925 */
926 public static function updatePassword(Db $zdb, int $id_adh, string $pass): bool
927 {
928 try {
929 $cpass = password_hash($pass, PASSWORD_BCRYPT);
930
931 $update = $zdb->update(self::TABLE);
932 $update->set(
933 array('mdp_adh' => $cpass)
934 )->where([self::PK => $id_adh]);
935 $zdb->execute($update);
936 Analog::log(
937 'Password for `' . $id_adh . '` has been updated.',
938 Analog::DEBUG
939 );
940 return true;
941 } catch (Throwable $e) {
942 Analog::log(
943 'An error occurred while updating password for `' . $id_adh .
944 '` | ' . $e->getMessage(),
945 Analog::ERROR
946 );
947 throw $e;
948 }
949 }
950
951 /**
952 * Get field label
953 *
954 * @param string $field Field name
955 *
956 * @return string
957 */
958 private function getFieldLabel(string $field): string
959 {
960 $label = $this->fields[$field]['label'] ?? '';
961 //replace "&nbsp;"
962 $label = str_replace('&nbsp;', ' ', $label);
963 //remove trailing ':' and then trim
964 $label = trim(trim($label, ':'));
965 return $label;
966 }
967
968 /**
969 * Retrieve fields from database
970 *
971 * @param Db $zdb Database instance
972 *
973 * @return array
974 */
975 public static function getDbFields(Db $zdb): array
976 {
977 $columns = $zdb->getColumns(self::TABLE);
978 $fields = array();
979 foreach ($columns as $col) {
980 $fields[] = $col->getName();
981 }
982 return $fields;
983 }
984
985 /**
986 * Mark as self membership
987 *
988 * @return void
989 */
990 public function setSelfMembership(): void
991 {
992 $this->_self_adh = true;
993 }
994
995 /**
996 * Is member up to date?
997 *
998 * @return boolean
999 */
1000 public function isUp2Date(): bool
1001 {
1002 if (!$this->isDepEnabled('dues')) {
1003 $this->checkDues();
1004 }
1005
1006 if ($this->isDueFree()) {
1007 //member is due free, he's up to date.
1008 return true;
1009 } else {
1010 //let's check from due date, if present
1011 if ($this->_due_date == null) {
1012 return false;
1013 } else {
1014 $due_date = new \DateTime($this->_due_date);
1015 $now = new \DateTime();
1016 $now->setTime(0, 0, 0);
1017 return $due_date >= $now;
1018 }
1019 }
1020 }
1021
1022 /**
1023 * Set dependencies
1024 *
1025 * @param Preferences $preferences Preferences instance
1026 * @param array $fields Members fields configuration
1027 * @param History $history History instance
1028 *
1029 * @return void
1030 */
1031 public function setDependencies(
1032 Preferences $preferences,
1033 array $fields,
1034 History $history
1035 ) {
1036 $this->preferences = $preferences;
1037 $this->fields = $fields;
1038 $this->history = $history;
1039 }
1040
1041 /**
1042 * Check posted values validity
1043 *
1044 * @param array $values All values to check, basically the $_POST array
1045 * after sending the form
1046 * @param array $required Array of required fields
1047 * @param array $disabled Array of disabled fields
1048 *
1049 * @return true|array
1050 */
1051 public function check(array $values, array $required, array $disabled)
1052 {
1053 global $login;
1054
1055 $this->errors = array();
1056
1057 //Sanitize
1058 foreach ($values as &$rawvalue) {
1059 if (is_string($rawvalue)) {
1060 $rawvalue = strip_tags($rawvalue);
1061 }
1062 }
1063
1064 $fields = self::getDbFields($this->zdb);
1065
1066 //reset company name if needed
1067 if (!isset($values['is_company'])) {
1068 unset($values['is_company']);
1069 $values['societe_adh'] = '';
1070 }
1071
1072 //no parent if checkbox was unchecked
1073 if (
1074 !isset($values['attach'])
1075 && empty($this->_id)
1076 && isset($values['parent_id'])
1077 ) {
1078 unset($values['parent_id']);
1079 }
1080
1081 if (isset($values['duplicate'])) {
1082 //if we're duplicating, keep a trace (if an error occurs)
1083 $this->_duplicate = true;
1084 }
1085
1086 foreach ($fields as $key) {
1087 //first, let's sanitize values
1088 $key = strtolower($key);
1089 $prop = '_' . $this->fields[$key]['propname'];
1090
1091 if (isset($values[$key])) {
1092 $value = $values[$key];
1093 if ($value !== true && $value !== false) {
1094 //@phpstan-ignore-next-line
1095 $value = trim($value ?? '');
1096 }
1097 } elseif (empty($this->_id)) {
1098 switch ($key) {
1099 case 'bool_admin_adh':
1100 case 'bool_exempt_adh':
1101 case 'bool_display_info':
1102 $value = 0;
1103 break;
1104 case 'activite_adh':
1105 //values that are set at object instantiation
1106 $value = true;
1107 break;
1108 case 'date_crea_adh':
1109 case 'sexe_adh':
1110 case 'titre_adh':
1111 case 'id_statut':
1112 case 'pref_lang':
1113 case 'parent_id':
1114 //values that are set at object instantiation
1115 $value = $this->$prop;
1116 break;
1117 case self::PK:
1118 $value = null;
1119 break;
1120 default:
1121 $value = '';
1122 break;
1123 }
1124 } else {
1125 //keep stored value on update
1126 if ($prop != '_password' || isset($values['mdp_adh']) && isset($values['mdp_adh2'])) {
1127 $value = $this->$prop;
1128 } else {
1129 $value = null;
1130 }
1131 }
1132
1133 // if the field is enabled, check it
1134 if (!isset($disabled[$key])) {
1135 // fill up the adherent structure
1136 if ($value !== null && $value !== true && $value !== false && !is_object($value)) {
1137 $value = stripslashes($value);
1138 }
1139 $this->$prop = $value;
1140
1141 // now, check validity
1142 if ($value !== null && $value != '') {
1143 if ($key !== 'mdp_adh') {
1144 $this->validate($key, $value, $values);
1145 }
1146 } elseif (empty($this->_id)) {
1147 //ensure login and password are not empty
1148 if (($key == 'login_adh' || $key == 'mdp_adh') && !isset($required[$key])) {
1149 $p = new Password($this->zdb);
1150 $generated_value = $p->makeRandomPassword(15);
1151 if ($key == 'login_adh') {
1152 //'@' is not permitted in logins
1153 $this->$prop = str_replace('@', 'a', $generated_value);
1154 } else {
1155 $this->$prop = $generated_value;
1156 }
1157 }
1158 }
1159 }
1160 }
1161
1162 //password checks need data to be previously set
1163 if (isset($values['mdp_adh'])) {
1164 $this->validate('mdp_adh', $values['mdp_adh'], $values);
1165 }
1166
1167 // missing required fields?
1168 foreach ($required as $key => $val) {
1169 $prop = '_' . $this->fields[$key]['propname'];
1170
1171 if (!isset($disabled[$key])) {
1172 $mandatory_missing = false;
1173 if (!isset($this->$prop) || $this->$prop == '') {
1174 $mandatory_missing = true;
1175 } elseif ($key === 'titre_adh' && $this->$prop == '-1') {
1176 $mandatory_missing = true;
1177 }
1178
1179 if ($mandatory_missing === true) {
1180 $this->errors[] = str_replace(
1181 '%field',
1182 '<a href="#' . $key . '">' . $this->getFieldLabel($key) . '</a>',
1183 _T("- Mandatory field %field empty.")
1184 );
1185 }
1186 }
1187 }
1188
1189 //attach to/detach from parent
1190 if (isset($values['detach_parent'])) {
1191 $this->_parent = null;
1192 }
1193
1194 if ($login->isGroupManager() && !$login->isAdmin() && !$login->isStaff() && $this->parent_id !== $login->id) {
1195 if (!isset($values['groups_adh'])) {
1196 $this->errors[] = _T('You have to select a group you own!');
1197 } else {
1198 $owned_group = false;
1199 foreach ($values['groups_adh'] as $group) {
1200 list($gid) = explode('|', $group);
1201 if ($login->isGroupManager($gid)) {
1202 $owned_group = true;
1203 }
1204 }
1205 if ($owned_group === false) {
1206 $this->errors[] = _T('You have to select a group you own!');
1207 }
1208 }
1209 }
1210
1211 $this->dynamicsCheck($values, $required, $disabled);
1212 $this->checkSocials($values);
1213
1214 if (count($this->errors) > 0) {
1215 Analog::log(
1216 'Some errors has been thew attempting to edit/store a member' . "\n" .
1217 print_r($this->errors, true),
1218 Analog::ERROR
1219 );
1220 return $this->errors;
1221 } else {
1222 $this->checkDues();
1223
1224 Analog::log(
1225 'Member checked successfully.',
1226 Analog::DEBUG
1227 );
1228 return true;
1229 }
1230 }
1231
1232 /**
1233 * Validate data for given key
1234 * Set valid data in current object, also resets errors list
1235 *
1236 * @param string $field Field name
1237 * @param mixed $value Value we want to set
1238 * @param array $values All values, for some references
1239 *
1240 * @return void
1241 */
1242 public function validate(string $field, $value, array $values): void
1243 {
1244 global $preferences;
1245
1246 $prop = '_' . $this->fields[$field]['propname'];
1247
1248 if ($value === null || (is_string($value) && trim($value) == '')) {
1249 //empty values are OK
1250 $this->$prop = $value;
1251 return;
1252 }
1253
1254 switch ($field) {
1255 // dates
1256 case 'date_crea_adh':
1257 case 'date_modif_adh_':
1258 case 'ddn_adh':
1259 case 'date_echeance':
1260 try {
1261 $d = \DateTime::createFromFormat(__("Y-m-d"), $value);
1262 if ($d === false) {
1263 //try with non localized date
1264 $d = \DateTime::createFromFormat("Y-m-d", $value);
1265 if ($d === false) {
1266 throw new \Exception('Incorrect format');
1267 }
1268 }
1269
1270 if ($field === 'ddn_adh') {
1271 $now = new \DateTime();
1272 $now->setTime(0, 0, 0);
1273 $d->setTime(0, 0, 0);
1274
1275 $diff = $now->diff($d);
1276 $days = (int)$diff->format('%R%a');
1277 if ($days >= 0) {
1278 $this->errors[] = _T('- Birthdate must be set in the past!');
1279 }
1280
1281 $years = (int)$diff->format('%R%Y');
1282 if ($years <= -200) {
1283 $this->errors[] = str_replace(
1284 '%years',
1285 $years * -1,
1286 _T('- Members must be less than 200 years old (currently %years)!')
1287 );
1288 }
1289 }
1290 $this->$prop = $d->format('Y-m-d');
1291 } catch (Throwable $e) {
1292 Analog::log(
1293 'Wrong date format. field: ' . $field .
1294 ', value: ' . $value . ', expected fmt: ' .
1295 __("Y-m-d") . ' | ' . $e->getMessage(),
1296 Analog::INFO
1297 );
1298 $this->errors[] = str_replace(
1299 array(
1300 '%date_format',
1301 '%field'
1302 ),
1303 array(
1304 __("Y-m-d"),
1305 $this->getFieldLabel($field)
1306 ),
1307 _T("- Wrong date format (%date_format) for %field!")
1308 );
1309 }
1310 break;
1311 case 'titre_adh':
1312 if ($value !== null && $value !== '') {
1313 if ($value == '-1') {
1314 $this->$prop = null;
1315 } elseif (!$value instanceof Title) {
1316 $this->$prop = new Title((int)$value);
1317 }
1318 } else {
1319 $this->$prop = null;
1320 }
1321 break;
1322 case 'email_adh':
1323 if (!GaletteMail::isValidEmail($value)) {
1324 $this->errors[] = _T("- Non-valid E-Mail address!") .
1325 ' (' . $this->getFieldLabel($field) . ')';
1326 }
1327
1328 try {
1329 $select = $this->zdb->select(self::TABLE);
1330 $select->columns(
1331 array(self::PK)
1332 )->where(array('email_adh' => $value));
1333 if (!empty($this->_id)) {
1334 $select->where->notEqualTo(
1335 self::PK,
1336 $this->_id
1337 );
1338 }
1339
1340 $results = $this->zdb->execute($select);
1341 if ($results->count() !== 0) {
1342 $this->errors[] = _T("- This E-Mail address is already used by another member!");
1343 }
1344 } catch (Throwable $e) {
1345 Analog::log(
1346 'An error occurred checking member email uniqueness.',
1347 Analog::ERROR
1348 );
1349 $this->errors[] = _T("An error has occurred while looking if login already exists.");
1350 }
1351 break;
1352 case 'login_adh':
1353 /** FIXME: add a preference for login length */
1354 if (strlen($value) < 2) {
1355 $this->errors[] = str_replace(
1356 '%i',
1357 2,
1358 _T("- The username must be composed of at least %i characters!")
1359 );
1360 } else {
1361 //check if login does not contain the @ character
1362 if (strpos($value, '@') != false) {
1363 $this->errors[] = _T("- The username cannot contain the @ character");
1364 } else {
1365 //check if login is already taken
1366 try {
1367 $select = $this->zdb->select(self::TABLE);
1368 $select->columns(
1369 array(self::PK)
1370 )->where(array('login_adh' => $value));
1371 if (!empty($this->_id)) {
1372 $select->where->notEqualTo(
1373 self::PK,
1374 $this->_id
1375 );
1376 }
1377
1378 $results = $this->zdb->execute($select);
1379 if (
1380 $results->count() !== 0
1381 || $value == $preferences->pref_admin_login
1382 ) {
1383 $this->errors[] = _T("- This username is already in use, please choose another one!");
1384 }
1385 } catch (Throwable $e) {
1386 Analog::log(
1387 'An error occurred checking member login uniqueness.',
1388 Analog::ERROR
1389 );
1390 $this->errors[] = _T("An error has occurred while looking if login already exists.");
1391 }
1392 }
1393 }
1394 break;
1395 case 'mdp_adh':
1396 if (
1397 $this->_self_adh !== true
1398 && (!isset($values['mdp_adh2'])
1399 || $values['mdp_adh2'] != $value)
1400 ) {
1401 $this->errors[] = _T("- The passwords don't match!");
1402 } elseif (
1403 $this->_self_adh === true
1404 && !crypt($value, $values['mdp_crypt']) == $values['mdp_crypt']
1405 ) {
1406 $this->errors[] = _T("Password misrepeated: ");
1407 } else {
1408 $pinfos = password_get_info($value);
1409 //check if value is already a hash
1410 if ($pinfos['algo'] == 0) {
1411 $this->$prop = password_hash(
1412 $value,
1413 PASSWORD_BCRYPT
1414 );
1415
1416 $pwcheck = new \Galette\Util\Password($preferences);
1417 $pwcheck->setAdherent($this);
1418 if (!$pwcheck->isValid($value)) {
1419 $this->errors = array_merge(
1420 $this->errors,
1421 $pwcheck->getErrors()
1422 );
1423 }
1424 }
1425 }
1426 break;
1427 case 'id_statut':
1428 try {
1429 $this->$prop = (int)$value;
1430 //check if status exists
1431 $select = $this->zdb->select(Status::TABLE);
1432 $select->where([Status::PK => $value]);
1433
1434 $results = $this->zdb->execute($select);
1435 $result = $results->current();
1436 if (!$result) {
1437 $this->errors[] = str_replace(
1438 '%id',
1439 $value,
1440 _T("Status #%id does not exists in database.")
1441 );
1442 break;
1443 }
1444 } catch (Throwable $e) {
1445 Analog::log(
1446 'An error occurred checking status existence: ' . $e->getMessage(),
1447 Analog::ERROR
1448 );
1449 $this->errors[] = _T("An error has occurred while looking if status does exists.");
1450 }
1451 break;
1452 case 'sexe_adh':
1453 if (in_array($value, [self::NC, self::MAN, self::WOMAN])) {
1454 $this->$prop = (int)$value;
1455 } else {
1456 $this->errors[] = _T("Gender %gender does not exists!");
1457 }
1458 break;
1459 case 'parent_id':
1460 $pid = ($value instanceof Adherent) ? (int)$value->id : (int)$value;
1461 if ($pid === $this->id) {
1462 $this->errors[] = _T("A member cannot be its own parent!");
1463 $this->$prop = null;
1464 } else {
1465 $this->$prop = $pid;
1466 $this->loadParent();
1467 }
1468 break;
1469 }
1470 }
1471
1472 /**
1473 * Store the member
1474 *
1475 * @return boolean
1476 */
1477 public function store(): bool
1478 {
1479 global $hist, $emitter, $login;
1480 $event = null;
1481
1482 if (!$login->isAdmin() && !$login->isStaff() && !$login->isGroupManager() && $this->id == '') {
1483 if ($this->preferences->pref_bool_create_member) {
1484 $this->_parent = $login->id;
1485 }
1486 }
1487
1488 try {
1489 $values = array();
1490 $fields = self::getDbFields($this->zdb);
1491
1492 foreach ($fields as $field) {
1493 if (
1494 $field !== 'date_modif_adh'
1495 || empty($this->_id)
1496 ) {
1497 $prop = '_' . $this->fields[$field]['propname'];
1498 if (
1499 ($field === 'bool_admin_adh'
1500 || $field === 'bool_exempt_adh'
1501 || $field === 'bool_display_info'
1502 || $field === 'activite_adh')
1503 && $this->$prop === false
1504 ) {
1505 //Handle booleans for postgres ; bugs #18899 and #19354
1506 $values[$field] = $this->zdb->isPostgres() ? 'false' : 0;
1507 } elseif ($field === 'parent_id') {
1508 //handle parents
1509 if ($this->_parent === null) {
1510 $values['parent_id'] = new Expression('NULL');
1511 } elseif ($this->parent instanceof Adherent) {
1512 $values['parent_id'] = $this->_parent->id;
1513 } else {
1514 $values['parent_id'] = $this->_parent;
1515 }
1516 } else {
1517 $values[$field] = $this->$prop;
1518 }
1519 }
1520 }
1521
1522 //an empty value will cause date to be set to 1901-01-01, a null
1523 //will result in 0000-00-00. We want a database NULL value here.
1524 if (!$this->_birthdate) {
1525 $values['ddn_adh'] = new Expression('NULL');
1526 }
1527 if (!$this->_due_date) {
1528 $values['date_echeance'] = new Expression('NULL');
1529 }
1530
1531 if ($this->_title instanceof Title) {
1532 $values['titre_adh'] = $this->_title->id;
1533 } else {
1534 $values['titre_adh'] = new Expression('NULL');
1535 }
1536
1537 if (!$this->_parent) {
1538 $values['parent_id'] = new Expression('NULL');
1539 }
1540
1541 if (!$this->_number) {
1542 $values['num_adh'] = new Expression('NULL');
1543 }
1544
1545 //fields that cannot be null
1546 $notnull = [
1547 '_surname' => 'prenom_adh',
1548 '_nickname' => 'pseudo_adh',
1549 '_address' => 'adresse_adh',
1550 '_zipcode' => 'cp_adh',
1551 '_town' => 'ville_adh'
1552 ];
1553 foreach ($notnull as $prop => $field) {
1554 if ($this->$prop === null) {
1555 $values[$field] = '';
1556 }
1557 }
1558
1559 if (empty($this->_id)) {
1560 //we're inserting a new member
1561 unset($values[self::PK]);
1562 //set modification date
1563 $this->_modification_date = date('Y-m-d');
1564 $values['date_modif_adh'] = $this->_modification_date;
1565
1566 $insert = $this->zdb->insert(self::TABLE);
1567 $insert->values($values);
1568 $add = $this->zdb->execute($insert);
1569 if ($add->count() > 0) {
1570 $this->_id = $this->zdb->getLastGeneratedValue($this);
1571 $this->_picture = new Picture($this->_id);
1572 // logging
1573 if ($this->_self_adh) {
1574 $hist->add(
1575 _T("Self_subscription as a member: ") .
1576 $this->getNameWithCase($this->_name, $this->_surname),
1577 $this->sname
1578 );
1579 } else {
1580 $hist->add(
1581 _T("Member card added"),
1582 $this->sname
1583 );
1584 }
1585
1586 $event = $this->getAddEventName();
1587 } else {
1588 $hist->add(_T("Fail to add new member."));
1589 throw new \Exception(
1590 'An error occurred inserting new member!'
1591 );
1592 }
1593 } else {
1594 //we're editing an existing member
1595 if (!$this->isDueFree()) {
1596 // deadline
1597 $due_date = Contribution::getDueDate($this->zdb, $this->_id);
1598 if ($due_date) {
1599 $values['date_echeance'] = $due_date;
1600 }
1601 }
1602
1603 if (!$this->_password) {
1604 unset($values['mdp_adh']);
1605 }
1606
1607 $update = $this->zdb->update(self::TABLE);
1608 $update->set($values);
1609 $update->where([self::PK => $this->_id]);
1610
1611 $edit = $this->zdb->execute($update);
1612
1613 //edit == 0 does not mean there were an error, but that there
1614 //were nothing to change
1615 if ($edit->count() > 0) {
1616 $this->updateModificationDate();
1617 $hist->add(
1618 _T("Member card updated"),
1619 $this->sname
1620 );
1621 }
1622 $event = $this->getEditEventName();
1623 }
1624
1625 //dynamic fields
1626 $this->dynamicsStore();
1627 $this->storeSocials($this->id);
1628
1629 //send event at the end of process, once all has been stored
1630 if ($event !== null && $this->areEventsEnabled()) {
1631 $emitter->dispatch(new GaletteEvent($event, $this));
1632 }
1633 return true;
1634 } catch (Throwable $e) {
1635 Analog::log(
1636 'Something went wrong :\'( | ' . $e->getMessage() . "\n" .
1637 $e->getTraceAsString(),
1638 Analog::ERROR
1639 );
1640 throw $e;
1641 }
1642 }
1643
1644 /**
1645 * Update member modification date
1646 *
1647 * @return void
1648 */
1649 private function updateModificationDate(): void
1650 {
1651 try {
1652 $modif_date = date('Y-m-d');
1653 $update = $this->zdb->update(self::TABLE);
1654 $update->set(
1655 array('date_modif_adh' => $modif_date)
1656 )->where([self::PK => $this->_id]);
1657
1658 $this->zdb->execute($update);
1659 $this->_modification_date = $modif_date;
1660 } catch (Throwable $e) {
1661 Analog::log(
1662 'Something went wrong updating modif date :\'( | ' .
1663 $e->getMessage() . "\n" . $e->getTraceAsString(),
1664 Analog::ERROR
1665 );
1666 throw $e;
1667 }
1668 }
1669
1670 /**
1671 * Global getter method
1672 *
1673 * @param string $name name of the property we want to retrieve
1674 *
1675 * @return mixed
1676 */
1677 public function __get(string $name)
1678 {
1679 $forbidden = array(
1680 'admin', 'staff', 'due_free', 'appears_in_list', 'active',
1681 'row_classes', 'oldness', 'duplicate', 'groups', 'managed_groups'
1682 );
1683 if (!defined('GALETTE_TESTS')) {
1684 $forbidden[] = 'password'; //keep that for tests only
1685 }
1686
1687 $virtuals = array(
1688 'sadmin', 'sstaff', 'sdue_free', 'sappears_in_list', 'sactive',
1689 'stitle', 'sstatus', 'sfullname', 'sname', 'saddress',
1690 'rbirthdate', 'sgender', 'contribstatus',
1691 );
1692
1693 $socials = array('website', 'msn', 'jabber', 'icq');
1694
1695 if (in_array($name, $forbidden)) {
1696 Analog::log(
1697 'Calling property "' . $name . '" directly is discouraged.',
1698 Analog::WARNING
1699 );
1700 switch ($name) {
1701 case 'admin':
1702 return $this->isAdmin();
1703 case 'staff':
1704 return $this->isStaff();
1705 case 'due_free':
1706 return $this->isDueFree();
1707 case 'appears_in_list':
1708 return $this->appearsInMembersList();
1709 case 'active':
1710 return $this->isActive();
1711 case 'duplicate':
1712 return $this->isDuplicate();
1713 case 'groups':
1714 return $this->getGroups();
1715 case 'managed_groups':
1716 return $this->getManagedGroups();
1717 default:
1718 throw new \RuntimeException("Call to __get for '$name' is forbidden!");
1719 }
1720 }
1721
1722 if (in_array($name, $virtuals)) {
1723 if (substr($name, 0, 1) !== '_') {
1724 $real = '_' . substr($name, 1);
1725 } else {
1726 $real = $name;
1727 }
1728 switch ($name) {
1729 case 'sadmin':
1730 return (($this->isAdmin()) ? _T("Yes") : _T("No"));
1731 case 'sdue_free':
1732 return (($this->isDueFree()) ? _T("Yes") : _T("No"));
1733 case 'sappears_in_list':
1734 return (($this->appearsInMembersList()) ? _T("Yes") : _T("No"));
1735 case 'sstaff':
1736 return (($this->$real) ? _T("Yes") : _T("No"));
1737 case 'sactive':
1738 return (($this->isActive()) ? _T("Active") : _T("Inactive"));
1739 case 'stitle':
1740 if (isset($this->_title) && $this->_title instanceof Title) {
1741 return $this->_title->tshort;
1742 } else {
1743 return null;
1744 }
1745 case 'sstatus':
1746 $status = new Status($this->zdb);
1747 return $status->getLabel($this->_status);
1748 case 'sfullname':
1749 return $this->getNameWithCase(
1750 $this->_name,
1751 $this->_surname,
1752 (isset($this->_title) ? $this->title : false)
1753 );
1754 case 'saddress':
1755 $address = $this->_address;
1756 return $address;
1757 case 'sname':
1758 return $this->getNameWithCase($this->_name, $this->_surname);
1759 case 'rbirthdate':
1760 return $this->_birthdate;
1761 case 'sgender':
1762 switch ($this->gender) {
1763 case self::MAN:
1764 return _T('Man');
1765 case self::WOMAN:
1766 return _T('Woman');
1767 default:
1768 return _T('Unspecified');
1769 }
1770 case 'contribstatus':
1771 return $this->getDues();
1772 }
1773 }
1774
1775 //for backward compatibility
1776 if (in_array($name, $socials)) {
1777 $values = Social::getListForMember($this->_id, $name);
1778 return $values[0] ?? null;
1779 }
1780
1781 if (substr($name, 0, 1) !== '_') {
1782 $rname = '_' . $name;
1783 } else {
1784 $rname = $name;
1785 }
1786
1787 switch ($name) {
1788 case 'id':
1789 case 'id_statut':
1790 if ($this->$rname !== null) {
1791 return (int)$this->$rname;
1792 } else {
1793 return null;
1794 }
1795 case 'address':
1796 return $this->$rname ?? '';
1797 case 'birthdate':
1798 case 'creation_date':
1799 case 'modification_date':
1800 case 'due_date':
1801 if ($this->$rname != '') {
1802 try {
1803 $d = new \DateTime($this->$rname);
1804 return $d->format(__("Y-m-d"));
1805 } catch (Throwable $e) {
1806 //oops, we've got a bad date :/
1807 Analog::log(
1808 'Bad date (' . $this->$rname . ') | ' .
1809 $e->getMessage(),
1810 Analog::INFO
1811 );
1812 return $this->$rname;
1813 }
1814 }
1815 break;
1816 case 'parent_id':
1817 return ($this->_parent instanceof Adherent) ? (int)$this->_parent->id : (int)$this->_parent;
1818 default:
1819 if (!property_exists($this, $rname)) {
1820 Analog::log(
1821 "Unknown property '$rname'",
1822 Analog::WARNING
1823 );
1824 return null;
1825 } else {
1826 return $this->$rname;
1827 }
1828 }
1829 }
1830
1831 /**
1832 * Global isset method
1833 * Required for twig to access properties via __get
1834 *
1835 * @param string $name name of the property we want to retrieve
1836 *
1837 * @return bool
1838 */
1839 public function __isset(string $name)
1840 {
1841 $forbidden = array(
1842 'admin', 'staff', 'due_free', 'appears_in_list', 'active',
1843 'row_classes', 'oldness', 'duplicate', 'groups', 'managed_groups'
1844 );
1845 if (!defined('GALETTE_TESTS')) {
1846 $forbidden[] = 'password'; //keep that for tests only
1847 }
1848
1849 $virtuals = array(
1850 'sadmin', 'sstaff', 'sdue_free', 'sappears_in_list', 'sactive',
1851 'stitle', 'sstatus', 'sfullname', 'sname', 'saddress',
1852 'rbirthdate', 'sgender', 'contribstatus',
1853 );
1854
1855 $socials = array('website', 'msn', 'jabber', 'icq');
1856
1857 if (in_array($name, $forbidden)) {
1858 Analog::log(
1859 'Calling property "' . $name . '" directly is discouraged.',
1860 Analog::WARNING
1861 );
1862 switch ($name) {
1863 case 'admin':
1864 case 'staff':
1865 case 'due_free':
1866 case 'appears_in_list':
1867 case 'active':
1868 case 'duplicate':
1869 case 'groups':
1870 case 'managed_groups':
1871 return true;
1872 }
1873
1874 return false;
1875 }
1876
1877 if (in_array($name, $virtuals)) {
1878 return true;
1879 }
1880
1881 //for backward compatibility
1882 if (in_array($name, $socials)) {
1883 return true;
1884 }
1885
1886 if (substr($name, 0, 1) !== '_') {
1887 $rname = '_' . $name;
1888 } else {
1889 $rname = $name;
1890 }
1891
1892 switch ($name) {
1893 case 'id':
1894 case 'id_statut':
1895 case 'address':
1896 case 'birthdate':
1897 case 'creation_date':
1898 case 'modification_date':
1899 case 'due_date':
1900 case 'parent_id':
1901 return true;
1902 default:
1903 return property_exists($this, $rname);
1904 }
1905 }
1906
1907 /**
1908 * Get member email
1909 * If member does not have an email address, but is attached to
1910 * another member, we'll take information from its parent.
1911 *
1912 * @return string
1913 */
1914 public function getEmail(): string
1915 {
1916 $email = $this->_email;
1917 if (empty($email) && $this->hasParent()) {
1918 $this->loadParent();
1919 $email = $this->parent->email;
1920 }
1921
1922 return $email ?? '';
1923 }
1924
1925 /**
1926 * Get member address.
1927 * If member does not have an address, but is attached to another member, we'll take information from its parent.
1928 *
1929 * @return string
1930 */
1931 public function getAddress(): string
1932 {
1933 $address = $this->_address;
1934 if (empty($address) && $this->hasParent()) {
1935 $this->loadParent();
1936 $address = $this->parent->address;
1937 }
1938
1939 return $address ?? '';
1940 }
1941
1942 /**
1943 * Get member zipcode.
1944 * If member does not have an address, but is attached to another member, we'll take information from its parent.
1945 *
1946 * @return string
1947 */
1948 public function getZipcode(): string
1949 {
1950 $address = $this->_address;
1951 $zip = $this->_zipcode;
1952 if (empty($address) && $this->hasParent()) {
1953 $this->loadParent();
1954 $zip = $this->parent->zipcode;
1955 }
1956
1957 return $zip ?? '';
1958 }
1959
1960 /**
1961 * Get member town.
1962 * If member does not have an address, but is attached to another member, we'll take information from its parent.
1963 *
1964 * @return string
1965 */
1966 public function getTown(): string
1967 {
1968 $address = $this->_address;
1969 $town = $this->_town;
1970 if (empty($address) && $this->hasParent()) {
1971 $this->loadParent();
1972 $town = $this->parent->town;
1973 }
1974
1975 return $town ?? '';
1976 }
1977
1978 /**
1979 * Get member country.
1980 * If member does not have an address, but is attached to another member, we'll take information from its parent.
1981 *
1982 * @return string
1983 */
1984 public function getCountry(): string
1985 {
1986 $address = $this->_address;
1987 $country = $this->_country;
1988 if (empty($address) && $this->hasParent()) {
1989 $this->loadParent();
1990 $country = $this->parent->country;
1991 }
1992
1993 return $country ?? '';
1994 }
1995
1996 /**
1997 * Get member age
1998 *
1999 * @return string
2000 */
2001 public function getAge(): string
2002 {
2003 if ($this->_birthdate == null) {
2004 return '';
2005 }
2006
2007 $d = \DateTime::createFromFormat('Y-m-d', $this->_birthdate);
2008 if ($d === false) {
2009 Analog::log(
2010 'Invalid birthdate: ' . $this->_birthdate,
2011 Analog::ERROR
2012 );
2013 return '';
2014 }
2015
2016 return str_replace(
2017 '%age',
2018 $d->diff(new \DateTime())->y,
2019 _T(' (%age years old)')
2020 );
2021 }
2022
2023 /**
2024 * Get parent inherited fields
2025 *
2026 * @return array
2027 */
2028 public function getParentFields(): array
2029 {
2030 return $this->parent_fields;
2031 }
2032
2033 /**
2034 * Handle files (photo and dynamics files)
2035 *
2036 * @param array $files Files sent
2037 * @param array $cropping Cropping properties
2038 *
2039 * @return array|true
2040 */
2041 public function handleFiles(array $files, array $cropping = null)
2042 {
2043 $this->errors = [];
2044 // picture upload
2045 if (isset($files['photo'])) {
2046 if ($files['photo']['error'] === UPLOAD_ERR_OK) {
2047 if ($files['photo']['tmp_name'] != '') {
2048 if (is_uploaded_file($files['photo']['tmp_name'])) {
2049 if ($this->preferences->pref_force_picture_ratio == 1 && isset($cropping)) {
2050 $res = $this->picture->store($files['photo'], false, $cropping);
2051 } else {
2052 $res = $this->picture->store($files['photo']);
2053 }
2054 if ($res < 0) {
2055 $this->errors[]
2056 = $this->picture->getErrorMessage($res);
2057 }
2058 }
2059 }
2060 } elseif ($files['photo']['error'] !== UPLOAD_ERR_NO_FILE) {
2061 Analog::log(
2062 $this->picture->getPhpErrorMessage($files['photo']['error']),
2063 Analog::WARNING
2064 );
2065 $this->errors[] = $this->picture->getPhpErrorMessage(
2066 $files['photo']['error']
2067 );
2068 }
2069 }
2070 $this->dynamicsFiles($files);
2071
2072 if (count($this->errors) > 0) {
2073 Analog::log(
2074 'Some errors has been thew attempting to edit/store a member files' . "\n" .
2075 print_r($this->errors, true),
2076 Analog::ERROR
2077 );
2078 return $this->errors;
2079 } else {
2080 return true;
2081 }
2082 }
2083
2084 /**
2085 * Set member as duplicate
2086 *
2087 * @return void
2088 */
2089 public function setDuplicate(): void
2090 {
2091 //mark as duplicated
2092 $this->_duplicate = true;
2093 $infos = $this->_others_infos_admin;
2094 $this->_others_infos_admin = str_replace(
2095 ['%name', '%id'],
2096 [$this->sname, $this->_id],
2097 _T('Duplicated from %name (%id)')
2098 );
2099 if (!empty($infos)) {
2100 $this->_others_infos_admin .= "\n" . $infos;
2101 }
2102 //drop id_adh
2103 $this->_id = null;
2104 //drop email, must be unique
2105 $this->_email = null;
2106 //drop creation date
2107 $this->_creation_date = date("Y-m-d");
2108 //drop login
2109 $this->_login = null;
2110 //reset picture
2111 $this->_picture = new Picture();
2112 //remove birthdate
2113 $this->_birthdate = null;
2114 //remove surname
2115 $this->_surname = null;
2116 //not admin
2117 $this->_admin = false;
2118 //not due free
2119 $this->_due_free = false;
2120 }
2121
2122 /**
2123 * Get current errors
2124 *
2125 * @return array
2126 */
2127 public function getErrors(): array
2128 {
2129 return $this->errors;
2130 }
2131
2132 /**
2133 * Get user groups
2134 *
2135 * @return array
2136 */
2137 public function getGroups(): array
2138 {
2139 if (!$this->isDepEnabled('groups')) {
2140 $this->loadGroups();
2141 }
2142 return $this->_groups;
2143 }
2144
2145 /**
2146 * Get user managed groups
2147 *
2148 * @return array
2149 */
2150 public function getManagedGroups(): array
2151 {
2152 if (!$this->isDepEnabled('groups')) {
2153 $this->loadGroups();
2154 }
2155 return $this->_managed_groups;
2156 }
2157
2158 /**
2159 * Can current logged-in user create member
2160 *
2161 * @param Login $login Login instance
2162 *
2163 * @return boolean
2164 */
2165 public function canCreate(Login $login): bool
2166 {
2167 global $preferences;
2168
2169 if ($this->id && $login->id == $this->id || $login->isAdmin() || $login->isStaff()) {
2170 return true;
2171 }
2172
2173 if ($preferences->pref_bool_groupsmanagers_create_member && $login->isGroupManager()) {
2174 return true;
2175 }
2176
2177 if ($preferences->pref_bool_create_member && $login->isLogged()) {
2178 return true;
2179 }
2180
2181 return false;
2182 }
2183
2184 /**
2185 * Can current logged-in user edit member
2186 *
2187 * @param Login $login Login instance
2188 *
2189 * @return boolean
2190 */
2191 public function canEdit(Login $login): bool
2192 {
2193 global $preferences;
2194
2195 //admin and staff users can edit, as well as member itself
2196 if ($this->id && $login->id == $this->id || $login->isAdmin() || $login->isStaff()) {
2197 return true;
2198 }
2199
2200 //parent can edit their child cards
2201 if ($this->hasParent() && $this->parent_id === $login->id) {
2202 return true;
2203 }
2204
2205 //group managers can edit members of groups they manage when pref is on
2206 if ($preferences->pref_bool_groupsmanagers_edit_member && $login->isGroupManager()) {
2207 foreach ($this->getGroups() as $g) {
2208 if ($login->isGroupManager($g->getId())) {
2209 return true;
2210 }
2211 }
2212 }
2213
2214 return false;
2215 }
2216
2217 /**
2218 * Can current logged-in user display member
2219 *
2220 * @param Login $login Login instance
2221 *
2222 * @return boolean
2223 */
2224 public function canShow(Login $login): bool
2225 {
2226 //group managers can show members of groups they manage
2227 if ($login->isGroupManager()) {
2228 foreach ($this->getGroups() as $g) {
2229 if ($login->isGroupManager($g->getId())) {
2230 return true;
2231 }
2232 }
2233 }
2234
2235 return $this->canEdit($login);
2236 }
2237
2238 /**
2239 * Are we currently duplicated a member?
2240 *
2241 * @return boolean
2242 */
2243 public function isDuplicate(): bool
2244 {
2245 return $this->_duplicate;
2246 }
2247
2248 /**
2249 * Flag creation mail sending
2250 *
2251 * @param boolean $send True (default) to send creation email
2252 *
2253 * @return Adherent
2254 */
2255 public function setSendmail(bool $send = true): self
2256 {
2257 $this->sendmail = $send;
2258 return $this;
2259 }
2260
2261 /**
2262 * Should we send administrative emails to member?
2263 *
2264 * @return boolean
2265 */
2266 public function sendEMail(): bool
2267 {
2268 return $this->sendmail;
2269 }
2270
2271 /**
2272 * Set member parent
2273 *
2274 * @param integer $id Parent identifier
2275 *
2276 * @return $this
2277 */
2278 public function setParent(int $id): self
2279 {
2280 $this->_parent = $id;
2281 $this->loadParent();
2282 return $this;
2283 }
2284
2285 /**
2286 * Get prefix for events
2287 *
2288 * @return string
2289 */
2290 protected function getEventsPrefix(): string
2291 {
2292 return 'member';
2293 }
2294 }