. * * @category Entity * @package Galette * * @author Johan Cwiklinski * @copyright 2009-2023 The Galette Team * @license http://www.gnu.org/licenses/gpl-3.0.html GPL License 3.0 or (at your option) any later version * @link http://galette.tuxfamily.org * @since Available since 0.7dev - 2009-06-02 */ namespace Galette\Entity; use ArrayObject; use Galette\Events\GaletteEvent; use Galette\Features\Socials; use Throwable; use Analog\Analog; use Laminas\Db\Sql\Expression; use Galette\Core\Db; use Galette\Core\Picture; use Galette\Core\GaletteMail; use Galette\Core\Password; use Galette\Core\Preferences; use Galette\Core\History; use Galette\Repository\Groups; use Galette\Core\Login; use Galette\Repository\Members; use Galette\Features\Dynamics; /** * Member class for galette * * @category Entity * @name Adherent * @package Galette * @author Johan Cwiklinski * @copyright 2009-2023 The Galette Team * @license http://www.gnu.org/licenses/gpl-3.0.html GPL License 3.0 or (at your option) any later version * @link http://galette.tuxfamily.org * @since Available since 0.7dev - 02-06-2009 * * @property integer $id * @property integer|Title $title Either a title id or an instance of Title * @property string $stitle Title label * @property string $company_name * @property string $name * @property string $surname * @property string $nickname * @property string $birthdate Localized birthdate * @property string $rbirthdate Raw birthdate * @property string $birth_place * @property integer $gender * @property string $sgender Gender label * @property string $job * @property string $language * @property integer $status * @property string $sstatus Status label * @property string $address * @property string $zipcode * @property string $town * @property string $country * @property string $phone * @property string $gsm * @property string $email * @property string $gnupgid * @property string $fingerprint * @property string $login * @property string $creation_date Localized creation date * @property string $modification_date Localized modification date * @property string $due_date Localized due date * @property string $others_infos * @property string $others_infos_admin * @property Picture $picture * @property array $groups * @property array $managed_groups * @property integer|Adherent $parent Parent id if parent dep is not loaded, Adherent instance otherwise * @property array $children * @property boolean $admin better to rely on isAdmin() * @property boolean $staff better to rely on isStaff() * @property boolean $due_free better to rely on isDueFree() * @property boolean $appears_in_list better to rely on appearsInMembersList() * @property boolean $active better to rely on isActive() * @property boolean $duplicate better to rely on isDuplicate() * @property string $sadmin yes/no * @property string $sstaff yes/no * @property string $sdue_free yes/no * @property string $sappears_in_list yes/no * @property string $sactive yes/no * @property string $sfullname * @property string $sname * @property string $saddress * @property string $contribstatus State of member contributions * @property integer $days_remaining * @property-read integer $parent_id * @property Social $social Social networks/Contact * @property string $number Member number * @property-read bool $self_adh */ class Adherent { use Dynamics; use Socials; public const TABLE = 'adherents'; public const PK = 'id_adh'; public const NC = 0; public const MAN = 1; public const WOMAN = 2; public const AFTER_ADD_DEFAULT = 0; public const AFTER_ADD_TRANS = 1; public const AFTER_ADD_NEW = 2; public const AFTER_ADD_SHOW = 3; public const AFTER_ADD_LIST = 4; public const AFTER_ADD_HOME = 5; private $_id; //Identity private $_title; private $_company_name; private $_name; private $_surname; private $_nickname; private $_birthdate; private $_birth_place; private $_gender; private $_job; private $_language; private $_active; private $_status; //Contact information private $_address; private $_zipcode; private $_town; private $_country; private $_phone; private $_gsm; private $_email; private $_gnupgid; private $_fingerprint; //Galette relative information private $_appears_in_list; private $_admin; private $_staff = false; private $_due_free; private $_login; private $_password; private $_creation_date; private $_modification_date; private $_due_date; private $_others_infos; private $_others_infos_admin; private $_picture; private $_oldness; private $_days_remaining; private $_groups = []; private $_managed_groups = []; private $_parent; private $_children = []; private $_duplicate = false; private $_socials; private $_number; private $_row_classes; private $_self_adh = false; protected array $_deps = array( 'picture' => true, 'groups' => true, 'dues' => true, 'parent' => false, 'children' => false, 'dynamics' => false, 'socials' => false ); private $zdb; private $preferences; private $fields; private $history; private $parent_fields = [ 'adresse_adh', 'cp_adh', 'ville_adh', 'email_adh' ]; private $errors = []; private $sendmail = false; /** * Default constructor * * @param Db $zdb Database instance * @param mixed $args Either a ResultSet row, its id or its * login or its email for to load s specific * member, or null to just instantiate object * @param false|array|null $deps Dependencies configuration, see Adherent::$_deps */ public function __construct(Db $zdb, $args = null, $deps = null) { global $i18n; $this->zdb = $zdb; if ($deps !== null) { if (is_array($deps)) { $this->_deps = array_merge( $this->_deps, $deps ); } elseif ($deps === false) { //no dependencies $this->_deps = array_fill_keys( array_keys($this->_deps), false ); } else { Analog::log( '$deps should be an array, ' . gettype($deps) . ' given!', Analog::WARNING ); } } if ($args == null || is_int($args)) { if (is_int($args) && $args > 0) { $this->load($args); } else { $this->_active = true; $this->_language = $i18n->getID(); $this->_creation_date = date("Y-m-d"); $this->_status = $this->getDefaultStatus(); $this->_title = null; $this->_gender = self::NC; $gp = new Password($this->zdb); $this->_password = $gp->makeRandomPassword(); $this->_picture = new Picture(); $this->_admin = false; $this->_staff = false; $this->_due_free = false; $this->_appears_in_list = false; $this->_parent = null; if ($this->_deps['dynamics'] === true) { $this->loadDynamicFields(); } } } elseif (is_object($args)) { $this->loadFromRS($args); } elseif (is_string($args)) { $this->loadFromLoginOrMail($args); } } /** * Loads a member from its id * * @param int $id the identifier for the member to load * * @return bool true if query succeed, false otherwise */ public function load(int $id): bool { try { $select = $this->zdb->select(self::TABLE, 'a'); $select->join( array('b' => PREFIX_DB . Status::TABLE), 'a.' . Status::PK . '=b.' . Status::PK, array('priorite_statut') )->where(array(self::PK => $id)); $results = $this->zdb->execute($select); if ($results->count() === 0) { return false; } /** @var ArrayObject $result */ $result = $results->current(); $this->loadFromRS($result); return true; } catch (Throwable $e) { Analog::log( 'Cannot load member form id `' . $id . '` | ' . $e->getMessage(), Analog::WARNING ); throw $e; } } /** * Loads a member from its login * * @param string $login login for the member to load * * @return boolean */ public function loadFromLoginOrMail(string $login): bool { try { $select = $this->zdb->select(self::TABLE); if (GaletteMail::isValidEmail($login)) { //we got a valid email address, use it $select->where(array('email_adh' => $login)); } else { ///we did not get an email address, consider using login $select->where(array('login_adh' => $login)); } $results = $this->zdb->execute($select); if ($results->count() > 0) { /** @var ArrayObject $result */ $result = $results->current(); $this->loadFromRS($result); } return true; } catch (Throwable $e) { Analog::log( 'Cannot load member form login `' . $login . '` | ' . $e->getMessage(), Analog::WARNING ); throw $e; } } /** * Populate object from a resultset row * * @param ArrayObject $r the resultset row * * @return void */ private function loadFromRS(ArrayObject $r): void { $this->_self_adh = false; $this->_id = $r->id_adh; //Identity if ($r->titre_adh !== null) { $this->_title = new Title((int)$r->titre_adh); } $this->_company_name = $r->societe_adh; $this->_name = $r->nom_adh; $this->_surname = $r->prenom_adh; $this->_nickname = $r->pseudo_adh; if ($r->ddn_adh != '1901-01-01') { $this->_birthdate = $r->ddn_adh; } $this->_birth_place = $r->lieu_naissance; $this->_gender = (int)$r->sexe_adh; $this->_job = $r->prof_adh; $this->_language = $r->pref_lang; $this->_active = $r->activite_adh == 1; $this->_status = (int)$r->id_statut; //Contact information $this->_address = $r->adresse_adh; $this->_zipcode = $r->cp_adh; $this->_town = $r->ville_adh; $this->_country = $r->pays_adh; $this->_phone = $r->tel_adh; $this->_gsm = $r->gsm_adh; $this->_email = $r->email_adh; $this->_gnupgid = $r->gpgid; $this->_fingerprint = $r->fingerprint; //Galette relative information $this->_appears_in_list = $r->bool_display_info == 1; $this->_admin = $r->bool_admin_adh == 1; if ( isset($r->priorite_statut) && $r->priorite_statut < Members::NON_STAFF_MEMBERS ) { $this->_staff = true; } $this->_due_free = $r->bool_exempt_adh == 1; $this->_login = $r->login_adh; $this->_password = $r->mdp_adh; $this->_creation_date = $r->date_crea_adh; if ($r->date_modif_adh != '1901-01-01') { $this->_modification_date = $r->date_modif_adh; } else { $this->_modification_date = $this->_creation_date; } $this->_due_date = $r->date_echeance; $this->_others_infos = $r->info_public_adh; $this->_others_infos_admin = $r->info_adh; $this->_number = $r->num_adh; if ($r->parent_id !== null) { $this->_parent = (int)$r->parent_id; if ($this->_deps['parent'] === true) { $this->loadParent(); } } if ($this->_deps['children'] === true) { $this->loadChildren(); } if ($this->_deps['picture'] === true) { $this->_picture = new Picture($this->_id); } if ($this->_deps['groups'] === true) { $this->loadGroups(); } if ($this->_deps['dues'] === true) { $this->checkDues(); } if ($this->_deps['dynamics'] === true) { $this->loadDynamicFields(); } if ($this->_deps['socials'] === true) { $this->loadSocials(); } } /** * Load member parent * * @return void */ private function loadParent(): void { if ($this->_parent !== null && !$this->_parent instanceof Adherent) { $deps = array_fill_keys(array_keys($this->_deps), false); $this->_parent = new Adherent($this->zdb, (int)$this->_parent, $deps); } } /** * Load member children * * @return void */ private function loadChildren(): void { $this->_children = array(); try { $id = self::PK; $select = $this->zdb->select(self::TABLE); $select->columns( array($id) )->where(['parent_id' => $this->_id]); $results = $this->zdb->execute($select); if ($results->count() > 0) { foreach ($results as $row) { $deps = $this->_deps; $deps['children'] = false; $deps['parent'] = false; $this->_children[] = new Adherent($this->zdb, (int)$row->$id, $deps); } } } catch (Throwable $e) { Analog::log( 'Cannot load children for member #' . $this->_id . ' | ' . $e->getMessage(), Analog::WARNING ); throw $e; } } /** * Load member groups * * @return void */ public function loadGroups(): void { $this->_groups = Groups::loadGroups($this->_id); $this->_managed_groups = Groups::loadManagedGroups($this->_id); } /** * Load member social network/contact information * * @return void */ public function loadSocials(): void { $this->_socials = Social::getListForMember($this->_id); } /** * Retrieve status from preferences * * @return integer * */ private function getDefaultStatus(): int { global $preferences; if ($preferences->pref_statut != '') { return $preferences->pref_statut; } else { Analog::log( 'Unable to get pref_statut; is it defined in preferences?', Analog::ERROR ); return Status::DEFAULT_STATUS; } } /** * Check for dues status * * @return void */ private function checkDues(): void { //how many days since our beloved member has been created $now = new \DateTime(); $this->_oldness = $now->diff( new \DateTime($this->_creation_date) )->days; if ($this->isDueFree()) { //no fee required, we don't care about dates $this->_row_classes .= ' cotis-exempt'; } else { //ok, fee is required. Let's check the dates if ($this->_due_date == '') { $this->_row_classes .= ' cotis-never'; } else { // To count the days remaining, the next begin date is required. $due_date = new \DateTime($this->_due_date); $next_begin_date = clone $due_date; $next_begin_date->add(new \DateInterval('P1D')); $date_diff = $now->diff($next_begin_date); $this->_days_remaining = $date_diff->days; // Active if ($date_diff->invert == 0 && $date_diff->days >= 0) { $this->_days_remaining = $date_diff->days; if ($this->_days_remaining <= 30) { if ($date_diff->days == 0) { $this->_row_classes .= ' cotis-lastday'; } $this->_row_classes .= ' cotis-soon'; } else { $this->_row_classes .= ' cotis-ok'; } // Expired } elseif ($date_diff->invert == 1 && $date_diff->days >= 0) { $this->_days_remaining = $date_diff->days; //check if member is still active $this->_row_classes .= $this->isActive() ? ' cotis-late' : ' cotis-old'; } } } } /** * Is member admin? * * @return bool */ public function isAdmin(): bool { return $this->_admin; } /** * Is user member of staff? * * @return bool */ public function isStaff(): bool { return $this->_staff; } /** * Is member freed of dues? * * @return bool */ public function isDueFree(): bool { return $this->_due_free; } /** * Is member in specified group? * * @param string $group_name Group name * * @return boolean */ public function isGroupMember(string $group_name): bool { if (!$this->isDepEnabled('groups')) { $this->loadGroups(); } foreach ($this->_groups as $g) { if ($g->getName() == $group_name) { return true; } } return false; } /** * Is member manager of specified group? * * @param string $group_name Group name * * @return boolean */ public function isGroupManager(string $group_name): bool { if (!$this->isDepEnabled('groups')) { $this->loadGroups(); } foreach ($this->_managed_groups as $mg) { if ($mg->getName() == $group_name) { return true; } } return false; } /** * Does current member represents a company? * * @return boolean */ public function isCompany(): bool { return trim($this->_company_name ?? '') != ''; } /** * Is current member a man? * * @return boolean */ public function isMan(): bool { return (int)$this->_gender === self::MAN; } /** * Is current member a woman? * * @return boolean */ public function isWoman(): bool { return (int)$this->_gender === self::WOMAN; } /** * Can member appears in public members list? * * @return bool */ public function appearsInMembersList(): bool { return $this->_appears_in_list; } /** * Is member active? * * @return bool */ public function isActive(): bool { return $this->_active; } /** * Does member have uploaded a picture? * * @return bool */ public function hasPicture(): bool { return $this->_picture->hasPicture(); } /** * Does member have a parent? * * @return bool */ public function hasParent(): bool { return !empty($this->_parent); } /** * Does member have children? * * @return bool */ public function hasChildren(): bool { if ($this->_children === null) { if ($this->id) { Analog::log( 'Children has not been loaded!', Analog::WARNING ); } return false; } else { return count($this->_children) > 0; } } /** * Get row class related to current fee status * * @param boolean $public we want the class for public pages * * @return string the class to apply */ public function getRowClass(bool $public = false): string { $strclass = ($this->isActive()) ? 'active-account' : 'inactive-account'; if ($public === false) { $strclass .= $this->_row_classes; } return $strclass; } /** * Get current member due status * * @return string i18n string representing state of due */ public function getDues(): string { $ret = ''; $never_contributed = false; $now = new \DateTime(); // To count the days remaining, the next begin date is required. if ($this->_due_date === null) { $this->_due_date = $now->format('Y-m-d'); $never_contributed = true; } $due_date = new \DateTime($this->_due_date); $next_begin_date = clone $due_date; $next_begin_date->add(new \DateInterval('P1D')); $date_diff = $now->diff($next_begin_date); if ($this->isDueFree()) { $ret = _T("Freed of dues"); } elseif ($never_contributed === true) { $patterns = array('/%days/', '/%date/'); $cdate = new \DateTime($this->_creation_date); $replace = array( $this->_oldness, $cdate->format(__("Y-m-d")) ); if ($this->_active) { $ret = preg_replace( $patterns, $replace, _T("Never contributed: Registered %days days ago (since %date)") ); } else { $ret = _T("Never contributed"); } // Last active or first expired day } elseif ($this->_days_remaining == 0) { if ($date_diff->invert == 0) { $ret = _T("Last day!"); } else { $ret = _T("Late since today!"); } // Active } elseif ($date_diff->invert == 0 && $this->_days_remaining > 0) { $patterns = array('/%days/', '/%date/'); $replace = array( $this->_days_remaining, $due_date->format(__("Y-m-d")) ); $ret = preg_replace( $patterns, $replace, _T("%days days remaining (ending on %date)") ); // Expired } elseif ($date_diff->invert == 1 && $this->_days_remaining > 0) { $patterns = array('/%days/', '/%date/'); $replace = array( // We need the number of days expired, not the number of days remaining. $this->_days_remaining + 1, $due_date->format(__("Y-m-d")) ); if ($this->_active) { $ret = preg_replace( $patterns, $replace, _T("Late of %days days (since %date)") ); } else { $ret = _T("No longer member"); } } return $ret; } /** * Retrieve Full name and surname for the specified member id * * @param Db $zdb Database instance * @param integer $id Member id * @param boolean $wid Add member id * @param boolean $wnick Add member nickname * * @return string formatted Name and Surname */ public static function getSName(Db $zdb, int $id, bool $wid = false, bool $wnick = false): string { try { $select = $zdb->select(self::TABLE); $select->where([self::PK => $id]); $results = $zdb->execute($select); $row = $results->current(); return self::getNameWithCase( $row->nom_adh, $row->prenom_adh, false, ($wid === true ? $row->id_adh : false), ($wnick === true ? $row->pseudo_adh : false) ); } catch (Throwable $e) { Analog::log( 'Cannot get formatted name for member form id `' . $id . '` | ' . $e->getMessage(), Analog::WARNING ); throw $e; } } /** * Get member name with correct case * * @param string $name Member name * @param string $surname Member surname * @param false|Title $title Member title to show or false * @param false|integer $id Member id to display or false * @param false|string $nick Member nickname to display or false * * @return string */ public static function getNameWithCase( ?string $name, ?string $surname, $title = false, $id = false, $nick = false ): string { $str = ''; if ($title instanceof Title) { $str .= $title->tshort . ' '; } $str .= mb_strtoupper($name ?? '', 'UTF-8') . ' ' . ucwords(mb_strtolower($surname ?? '', 'UTF-8'), " \t\r\n\f\v-_|"); if ($id !== false || !empty($nick)) { $str .= ' ('; } if (!empty($nick)) { $str .= $nick; } if ($id !== false) { if (!empty($nick)) { $str .= ', '; } $str .= $id; } if ($id !== false || !empty($nick)) { $str .= ')'; } return strip_tags($str); } /** * Change password for a given user * * @param Db $zdb Database instance * @param integer $id_adh Member identifier * @param string $pass New password * * @return boolean */ public static function updatePassword(Db $zdb, int $id_adh, string $pass): bool { try { $cpass = password_hash($pass, PASSWORD_BCRYPT); $update = $zdb->update(self::TABLE); $update->set( array('mdp_adh' => $cpass) )->where([self::PK => $id_adh]); $zdb->execute($update); Analog::log( 'Password for `' . $id_adh . '` has been updated.', Analog::DEBUG ); return true; } catch (Throwable $e) { Analog::log( 'An error occurred while updating password for `' . $id_adh . '` | ' . $e->getMessage(), Analog::ERROR ); throw $e; } } /** * Get field label * * @param string $field Field name * * @return string */ private function getFieldLabel(string $field): string { $label = $this->fields[$field]['label'] ?? ''; //replace " " $label = str_replace(' ', ' ', $label); //remove trailing ':' and then trim $label = trim(trim($label, ':')); return $label; } /** * Retrieve fields from database * * @param Db $zdb Database instance * * @return array */ public static function getDbFields(Db $zdb): array { $columns = $zdb->getColumns(self::TABLE); $fields = array(); foreach ($columns as $col) { $fields[] = $col->getName(); } return $fields; } /** * Mark as self membership * * @return void */ public function setSelfMembership(): void { $this->_self_adh = true; } /** * Is member up to date? * * @return boolean */ public function isUp2Date(): bool { if (!$this->isDepEnabled('dues')) { $this->checkDues(); } if ($this->isDueFree()) { //member is due free, he's up to date. return true; } else { //let's check from due date, if present if ($this->_due_date == null) { return false; } else { $due_date = new \DateTime($this->_due_date); $now = new \DateTime(); $now->setTime(0, 0, 0); return $due_date >= $now; } } } /** * Set dependencies * * @param Preferences $preferences Preferences instance * @param array $fields Members fields configuration * @param History $history History instance * * @return void */ public function setDependencies( Preferences $preferences, array $fields, History $history ) { $this->preferences = $preferences; $this->fields = $fields; $this->history = $history; } /** * Check posted values validity * * @param array $values All values to check, basically the $_POST array * after sending the form * @param array $required Array of required fields * @param array $disabled Array of disabled fields * * @return true|array */ public function check(array $values, array $required, array $disabled) { global $login; $this->errors = array(); //Sanitize foreach ($values as &$rawvalue) { if (is_string($rawvalue)) { $rawvalue = strip_tags($rawvalue); } } $fields = self::getDbFields($this->zdb); //reset company name if needed if (!isset($values['is_company'])) { unset($values['is_company']); $values['societe_adh'] = ''; } //no parent if checkbox was unchecked if ( !isset($values['attach']) && empty($this->_id) && isset($values['parent_id']) ) { unset($values['parent_id']); } if (isset($values['duplicate'])) { //if we're duplicating, keep a trace (if an error occurs) $this->_duplicate = true; } foreach ($fields as $key) { //first, let's sanitize values $key = strtolower($key); $prop = '_' . $this->fields[$key]['propname']; if (isset($values[$key])) { $value = $values[$key]; if ($value !== true && $value !== false) { //@phpstan-ignore-next-line $value = trim($value ?? ''); } } elseif (empty($this->_id)) { switch ($key) { case 'bool_admin_adh': case 'bool_exempt_adh': case 'bool_display_info': $value = 0; break; case 'activite_adh': //values that are set at object instantiation $value = true; break; case 'date_crea_adh': case 'sexe_adh': case 'titre_adh': case 'id_statut': case 'pref_lang': case 'parent_id': //values that are set at object instantiation $value = $this->$prop; break; case self::PK: $value = null; break; default: $value = ''; break; } } else { //keep stored value on update if ($prop != '_password' || isset($values['mdp_adh']) && isset($values['mdp_adh2'])) { $value = $this->$prop; } else { $value = null; } } // if the field is enabled, check it if (!isset($disabled[$key])) { // fill up the adherent structure if ($value !== null && $value !== true && $value !== false && !is_object($value)) { $value = stripslashes($value); } $this->$prop = $value; // now, check validity if ($value !== null && $value != '') { if ($key !== 'mdp_adh') { $this->validate($key, $value, $values); } } elseif (empty($this->_id)) { //ensure login and password are not empty if (($key == 'login_adh' || $key == 'mdp_adh') && !isset($required[$key])) { $p = new Password($this->zdb); $generated_value = $p->makeRandomPassword(15); if ($key == 'login_adh') { //'@' is not permitted in logins $this->$prop = str_replace('@', 'a', $generated_value); } else { $this->$prop = $generated_value; } } } } } //password checks need data to be previously set if (isset($values['mdp_adh'])) { $this->validate('mdp_adh', $values['mdp_adh'], $values); } // missing required fields? foreach ($required as $key => $val) { $prop = '_' . $this->fields[$key]['propname']; if (!isset($disabled[$key])) { $mandatory_missing = false; if (!isset($this->$prop) || $this->$prop == '') { $mandatory_missing = true; } elseif ($key === 'titre_adh' && $this->$prop == '-1') { $mandatory_missing = true; } if ($mandatory_missing === true) { $this->errors[] = str_replace( '%field', '' . $this->getFieldLabel($key) . '', _T("- Mandatory field %field empty.") ); } } } //attach to/detach from parent if (isset($values['detach_parent'])) { $this->_parent = null; } if ($login->isGroupManager() && !$login->isAdmin() && !$login->isStaff() && $this->parent_id !== $login->id) { if (!isset($values['groups_adh'])) { $this->errors[] = _T('You have to select a group you own!'); } else { $owned_group = false; foreach ($values['groups_adh'] as $group) { list($gid) = explode('|', $group); if ($login->isGroupManager($gid)) { $owned_group = true; } } if ($owned_group === false) { $this->errors[] = _T('You have to select a group you own!'); } } } $this->dynamicsCheck($values, $required, $disabled); $this->checkSocials($values); if (count($this->errors) > 0) { Analog::log( 'Some errors has been thew attempting to edit/store a member' . "\n" . print_r($this->errors, true), Analog::ERROR ); return $this->errors; } else { $this->checkDues(); Analog::log( 'Member checked successfully.', Analog::DEBUG ); return true; } } /** * Validate data for given key * Set valid data in current object, also resets errors list * * @param string $field Field name * @param mixed $value Value we want to set * @param array $values All values, for some references * * @return void */ public function validate(string $field, $value, array $values): void { global $preferences; $prop = '_' . $this->fields[$field]['propname']; if ($value === null || (is_string($value) && trim($value) == '')) { //empty values are OK $this->$prop = $value; return; } switch ($field) { // dates case 'date_crea_adh': case 'date_modif_adh_': case 'ddn_adh': case 'date_echeance': try { $d = \DateTime::createFromFormat(__("Y-m-d"), $value); if ($d === false) { //try with non localized date $d = \DateTime::createFromFormat("Y-m-d", $value); if ($d === false) { throw new \Exception('Incorrect format'); } } if ($field === 'ddn_adh') { $now = new \DateTime(); $now->setTime(0, 0, 0); $d->setTime(0, 0, 0); $diff = $now->diff($d); $days = (int)$diff->format('%R%a'); if ($days >= 0) { $this->errors[] = _T('- Birthdate must be set in the past!'); } $years = (int)$diff->format('%R%Y'); if ($years <= -200) { $this->errors[] = str_replace( '%years', $years * -1, _T('- Members must be less than 200 years old (currently %years)!') ); } } $this->$prop = $d->format('Y-m-d'); } catch (Throwable $e) { Analog::log( 'Wrong date format. field: ' . $field . ', value: ' . $value . ', expected fmt: ' . __("Y-m-d") . ' | ' . $e->getMessage(), Analog::INFO ); $this->errors[] = str_replace( array( '%date_format', '%field' ), array( __("Y-m-d"), $this->getFieldLabel($field) ), _T("- Wrong date format (%date_format) for %field!") ); } break; case 'titre_adh': if ($value !== null && $value !== '') { if ($value == '-1') { $this->$prop = null; } elseif (!$value instanceof Title) { $this->$prop = new Title((int)$value); } } else { $this->$prop = null; } break; case 'email_adh': if (!GaletteMail::isValidEmail($value)) { $this->errors[] = _T("- Non-valid E-Mail address!") . ' (' . $this->getFieldLabel($field) . ')'; } if ($field == 'email_adh') { try { $select = $this->zdb->select(self::TABLE); $select->columns( array(self::PK) )->where(array('email_adh' => $value)); if (!empty($this->_id)) { $select->where->notEqualTo( self::PK, $this->_id ); } $results = $this->zdb->execute($select); if ($results->count() !== 0) { $this->errors[] = _T("- This E-Mail address is already used by another member!"); } } catch (Throwable $e) { Analog::log( 'An error occurred checking member email uniqueness.', Analog::ERROR ); $this->errors[] = _T("An error has occurred while looking if login already exists."); } } break; case 'login_adh': /** FIXME: add a preference for login length */ if (strlen($value) < 2) { $this->errors[] = str_replace( '%i', 2, _T("- The username must be composed of at least %i characters!") ); } else { //check if login does not contain the @ character if (strpos($value, '@') != false) { $this->errors[] = _T("- The username cannot contain the @ character"); } else { //check if login is already taken try { $select = $this->zdb->select(self::TABLE); $select->columns( array(self::PK) )->where(array('login_adh' => $value)); if (!empty($this->_id)) { $select->where->notEqualTo( self::PK, $this->_id ); } $results = $this->zdb->execute($select); if ( $results->count() !== 0 || $value == $preferences->pref_admin_login ) { $this->errors[] = _T("- This username is already in use, please choose another one!"); } } catch (Throwable $e) { Analog::log( 'An error occurred checking member login uniqueness.', Analog::ERROR ); $this->errors[] = _T("An error has occurred while looking if login already exists."); } } } break; case 'mdp_adh': if ( $this->_self_adh !== true && (!isset($values['mdp_adh2']) || $values['mdp_adh2'] != $value) ) { $this->errors[] = _T("- The passwords don't match!"); } elseif ( $this->_self_adh === true && !crypt($value, $values['mdp_crypt']) == $values['mdp_crypt'] ) { $this->errors[] = _T("Password misrepeated: "); } else { $pinfos = password_get_info($value); //check if value is already a hash if ($pinfos['algo'] == 0) { $this->$prop = password_hash( $value, PASSWORD_BCRYPT ); $pwcheck = new \Galette\Util\Password($preferences); $pwcheck->setAdherent($this); if (!$pwcheck->isValid($value)) { $this->errors = array_merge( $this->errors, $pwcheck->getErrors() ); } } } break; case 'id_statut': try { $this->$prop = (int)$value; //check if status exists $select = $this->zdb->select(Status::TABLE); $select->where([Status::PK => $value]); $results = $this->zdb->execute($select); $result = $results->current(); if (!$result) { $this->errors[] = str_replace( '%id', $value, _T("Status #%id does not exists in database.") ); break; } } catch (Throwable $e) { Analog::log( 'An error occurred checking status existence: ' . $e->getMessage(), Analog::ERROR ); $this->errors[] = _T("An error has occurred while looking if status does exists."); } break; case 'sexe_adh': if (in_array($value, [self::NC, self::MAN, self::WOMAN])) { $this->$prop = (int)$value; } else { $this->errors[] = _T("Gender %gender does not exists!"); } break; case 'parent_id': $this->$prop = ($value instanceof Adherent) ? (int)$value->id : (int)$value; $this->loadParent(); break; } } /** * Store the member * * @return boolean */ public function store(): bool { global $hist, $emitter, $login; $event = null; if (!$login->isAdmin() && !$login->isStaff() && !$login->isGroupManager() && $this->id == '') { if ($this->preferences->pref_bool_create_member) { $this->_parent = $login->id; } } try { $values = array(); $fields = self::getDbFields($this->zdb); foreach ($fields as $field) { if ( $field !== 'date_modif_adh' || empty($this->_id) ) { $prop = '_' . $this->fields[$field]['propname']; if ( ($field === 'bool_admin_adh' || $field === 'bool_exempt_adh' || $field === 'bool_display_info' || $field === 'activite_adh') && $this->$prop === false ) { //Handle booleans for postgres ; bugs #18899 and #19354 $values[$field] = $this->zdb->isPostgres() ? 'false' : 0; } elseif ($field === 'parent_id') { //handle parents if ($this->_parent === null) { $values['parent_id'] = new Expression('NULL'); } elseif ($this->parent instanceof Adherent) { $values['parent_id'] = $this->_parent->id; } else { $values['parent_id'] = $this->_parent; } } else { $values[$field] = $this->$prop; } } } //an empty value will cause date to be set to 1901-01-01, a null //will result in 0000-00-00. We want a database NULL value here. if (!$this->_birthdate) { $values['ddn_adh'] = new Expression('NULL'); } if (!$this->_due_date) { $values['date_echeance'] = new Expression('NULL'); } if ($this->_title instanceof Title) { $values['titre_adh'] = $this->_title->id; } else { $values['titre_adh'] = new Expression('NULL'); } if (!$this->_parent) { $values['parent_id'] = new Expression('NULL'); } if (!$this->_number) { $values['num_adh'] = new Expression('NULL'); } //fields that cannot be null $notnull = [ '_surname' => 'prenom_adh', '_nickname' => 'pseudo_adh', '_address' => 'adresse_adh', '_zipcode' => 'cp_adh', '_town' => 'ville_adh' ]; foreach ($notnull as $prop => $field) { if ($this->$prop === null) { $values[$field] = ''; } } $success = false; if (empty($this->_id)) { //we're inserting a new member unset($values[self::PK]); //set modification date $this->_modification_date = date('Y-m-d'); $values['date_modif_adh'] = $this->_modification_date; $insert = $this->zdb->insert(self::TABLE); $insert->values($values); $add = $this->zdb->execute($insert); if ($add->count() > 0) { $this->_id = $this->zdb->getLastGeneratedValue($this); $this->_picture = new Picture($this->_id); // logging if ($this->_self_adh) { $hist->add( _T("Self_subscription as a member: ") . $this->getNameWithCase($this->_name, $this->_surname), $this->sname ); } else { $hist->add( _T("Member card added"), $this->sname ); } $success = true; $event = 'member.add'; } else { $hist->add(_T("Fail to add new member.")); throw new \Exception( 'An error occurred inserting new member!' ); } } else { //we're editing an existing member if (!$this->isDueFree()) { // deadline $due_date = Contribution::getDueDate($this->zdb, $this->_id); if ($due_date) { $values['date_echeance'] = $due_date; } } if (!$this->_password) { unset($values['mdp_adh']); } $update = $this->zdb->update(self::TABLE); $update->set($values); $update->where([self::PK => $this->_id]); $edit = $this->zdb->execute($update); //edit == 0 does not mean there were an error, but that there //were nothing to change if ($edit->count() > 0) { $this->updateModificationDate(); $hist->add( _T("Member card updated"), $this->sname ); } $success = true; $event = 'member.edit'; } //dynamic fields if ($success) { $success = $this->dynamicsStore(); $this->storeSocials($this->id); } //send event at the end of process, once all has been stored if ($event !== null) { $emitter->dispatch(new GaletteEvent($event, $this)); } return $success; } catch (Throwable $e) { Analog::log( 'Something went wrong :\'( | ' . $e->getMessage() . "\n" . $e->getTraceAsString(), Analog::ERROR ); throw $e; } } /** * Update member modification date * * @return void */ private function updateModificationDate(): void { try { $modif_date = date('Y-m-d'); $update = $this->zdb->update(self::TABLE); $update->set( array('date_modif_adh' => $modif_date) )->where([self::PK => $this->_id]); $edit = $this->zdb->execute($update); $this->_modification_date = $modif_date; } catch (Throwable $e) { Analog::log( 'Something went wrong updating modif date :\'( | ' . $e->getMessage() . "\n" . $e->getTraceAsString(), Analog::ERROR ); throw $e; } } /** * Global getter method * * @param string $name name of the property we want to retrieve * * @return mixed */ public function __get(string $name) { $forbidden = array( 'admin', 'staff', 'due_free', 'appears_in_list', 'active', 'row_classes', 'oldness', 'duplicate', 'groups', 'managed_groups' ); if (!defined('GALETTE_TESTS')) { $forbidden[] = 'password'; //keep that for tests only } $virtuals = array( 'sadmin', 'sstaff', 'sdue_free', 'sappears_in_list', 'sactive', 'stitle', 'sstatus', 'sfullname', 'sname', 'saddress', 'rbirthdate', 'sgender', 'contribstatus', ); $socials = array('website', 'msn', 'jabber', 'icq'); if (in_array($name, $forbidden)) { Analog::log( 'Calling property "' . $name . '" directly is discouraged.', Analog::WARNING ); switch ($name) { case 'admin': return $this->isAdmin(); case 'staff': return $this->isStaff(); case 'due_free': return $this->isDueFree(); case 'appears_in_list': return $this->appearsInMembersList(); case 'active': return $this->isActive(); case 'duplicate': return $this->isDuplicate(); case 'groups': return $this->getGroups(); case 'managed_groups': return $this->getManagedGroups(); default: throw new \RuntimeException("Call to __get for '$name' is forbidden!"); } } if (in_array($name, $virtuals)) { if (substr($name, 0, 1) !== '_') { $real = '_' . substr($name, 1); } else { $real = $name; } switch ($name) { case 'sadmin': return (($this->isAdmin()) ? _T("Yes") : _T("No")); case 'sdue_free': return (($this->isDueFree()) ? _T("Yes") : _T("No")); case 'sappears_in_list': return (($this->appearsInMembersList()) ? _T("Yes") : _T("No")); case 'sstaff': return (($this->$real) ? _T("Yes") : _T("No")); case 'sactive': return (($this->isActive()) ? _T("Active") : _T("Inactive")); case 'stitle': if (isset($this->_title) && $this->_title instanceof Title) { return $this->_title->tshort; } else { return null; } case 'sstatus': $status = new Status($this->zdb); return $status->getLabel($this->_status); case 'sfullname': return $this->getNameWithCase( $this->_name, $this->_surname, (isset($this->_title) ? $this->title : false) ); case 'saddress': $address = $this->_address; return $address; case 'sname': return $this->getNameWithCase($this->_name, $this->_surname); case 'rbirthdate': return $this->_birthdate; case 'sgender': switch ($this->gender) { case self::MAN: return _T('Man'); case self::WOMAN: return _T('Woman'); default: return _T('Unspecified'); } case 'contribstatus': return $this->getDues(); } } //for backward compatibility if (in_array($name, $socials)) { $values = Social::getListForMember($this->_id, $name); return $values[0] ?? null; } if (substr($name, 0, 1) !== '_') { $rname = '_' . $name; } else { $rname = $name; } switch ($name) { case 'id': case 'id_statut': if ($this->$rname !== null) { return (int)$this->$rname; } else { return null; } case 'address': return $this->$rname ?? ''; case 'birthdate': case 'creation_date': case 'modification_date': case 'due_date': if ($this->$rname != '') { try { $d = new \DateTime($this->$rname); return $d->format(__("Y-m-d")); } catch (Throwable $e) { //oops, we've got a bad date :/ Analog::log( 'Bad date (' . $this->$rname . ') | ' . $e->getMessage(), Analog::INFO ); return $this->$rname; } } break; case 'parent_id': return ($this->_parent instanceof Adherent) ? (int)$this->_parent->id : (int)$this->_parent; default: if (!property_exists($this, $rname)) { Analog::log( "Unknown property '$rname'", Analog::WARNING ); return null; } else { return $this->$rname; } } } /** * Global isset method * Required for twig to access properties via __get * * @param string $name name of the property we want to retrieve * * @return bool */ public function __isset(string $name) { $forbidden = array( 'admin', 'staff', 'due_free', 'appears_in_list', 'active', 'row_classes', 'oldness', 'duplicate', 'groups', 'managed_groups' ); if (!defined('GALETTE_TESTS')) { $forbidden[] = 'password'; //keep that for tests only } $virtuals = array( 'sadmin', 'sstaff', 'sdue_free', 'sappears_in_list', 'sactive', 'stitle', 'sstatus', 'sfullname', 'sname', 'saddress', 'rbirthdate', 'sgender', 'contribstatus', ); $socials = array('website', 'msn', 'jabber', 'icq'); if (in_array($name, $forbidden)) { Analog::log( 'Calling property "' . $name . '" directly is discouraged.', Analog::WARNING ); switch ($name) { case 'admin': case 'staff': case 'due_free': case 'appears_in_list': case 'active': case 'duplicate': case 'groups': case 'managed_groups': return true; } return false; } if (in_array($name, $virtuals)) { return true; } //for backward compatibility if (in_array($name, $socials)) { return true; } if (substr($name, 0, 1) !== '_') { $rname = '_' . $name; } else { $rname = $name; } switch ($name) { case 'id': case 'id_statut': case 'address': case 'birthdate': case 'creation_date': case 'modification_date': case 'due_date': case 'parent_id': return true; default: return property_exists($this, $rname); } return false; } /** * Get member email * If member does not have an email address, but is attached to * another member, we'll take information from its parent. * * @return string */ public function getEmail(): string { $email = $this->_email; if (empty($email) && $this->hasParent()) { $this->loadParent(); $email = $this->parent->email; } return $email ?? ''; } /** * Get member address. * If member does not have an address, but is attached to another member, we'll take information from its parent. * * @return string */ public function getAddress(): string { $address = $this->_address; if (empty($address) && $this->hasParent()) { $this->loadParent(); $address = $this->parent->address; } return $address ?? ''; } /** * Get member zipcode. * If member does not have an address, but is attached to another member, we'll take information from its parent. * * @return string */ public function getZipcode(): string { $address = $this->_address; $zip = $this->_zipcode; if (empty($address) && $this->hasParent()) { $this->loadParent(); $zip = $this->parent->zipcode; } return $zip ?? ''; } /** * Get member town. * If member does not have an address, but is attached to another member, we'll take information from its parent. * * @return string */ public function getTown(): string { $address = $this->_address; $town = $this->_town; if (empty($address) && $this->hasParent()) { $this->loadParent(); $town = $this->parent->town; } return $town ?? ''; } /** * Get member country. * If member does not have an address, but is attached to another member, we'll take information from its parent. * * @return string */ public function getCountry(): string { $address = $this->_address; $country = $this->_country; if (empty($address) && $this->hasParent()) { $this->loadParent(); $country = $this->parent->country; } return $country ?? ''; } /** * Get member age * * @return string */ public function getAge(): string { if ($this->_birthdate == null) { return ''; } $d = \DateTime::createFromFormat('Y-m-d', $this->_birthdate); if ($d === false) { Analog::log( 'Invalid birthdate: ' . $this->_birthdate, Analog::ERROR ); return ''; } return str_replace( '%age', $d->diff(new \DateTime())->y, _T(' (%age years old)') ); } /** * Get parent inherited fields * * @return array */ public function getParentFields(): array { return $this->parent_fields; } /** * Handle files (photo and dynamics files) * * @param array $files Files sent * * @return array|true */ public function handleFiles(array $files) { $this->errors = []; // picture upload if (isset($files['photo'])) { if ($files['photo']['error'] === UPLOAD_ERR_OK) { if ($files['photo']['tmp_name'] != '') { if (is_uploaded_file($files['photo']['tmp_name'])) { $res = $this->picture->store($files['photo']); if ($res < 0) { $this->errors[] = $this->picture->getErrorMessage($res); } } } } elseif ($files['photo']['error'] !== UPLOAD_ERR_NO_FILE) { Analog::log( $this->picture->getPhpErrorMessage($files['photo']['error']), Analog::WARNING ); $this->errors[] = $this->picture->getPhpErrorMessage( $files['photo']['error'] ); } } $this->dynamicsFiles($files); if (count($this->errors) > 0) { Analog::log( 'Some errors has been thew attempting to edit/store a member files' . "\n" . print_r($this->errors, true), Analog::ERROR ); return $this->errors; } else { return true; } } /** * Set member as duplicate * * @return void */ public function setDuplicate(): void { //mark as duplicated $this->_duplicate = true; $infos = $this->_others_infos_admin; $this->_others_infos_admin = str_replace( ['%name', '%id'], [$this->sname, $this->_id], _T('Duplicated from %name (%id)') ); if (!empty($infos)) { $this->_others_infos_admin .= "\n" . $infos; } //drop id_adh $this->_id = null; //drop email, must be unique $this->_email = null; //drop creation date $this->_creation_date = date("Y-m-d"); //drop login $this->_login = null; //reset picture $this->_picture = new Picture(); //remove birthdate $this->_birthdate = null; //remove surname $this->_surname = null; //not admin $this->_admin = false; //not due free $this->_due_free = false; } /** * Get current errors * * @return array */ public function getErrors(): array { return $this->errors; } /** * Get user groups * * @return array */ public function getGroups(): array { if (!$this->isDepEnabled('groups')) { $this->loadGroups(); } return $this->_groups; } /** * Get user managed groups * * @return array */ public function getManagedGroups(): array { if (!$this->isDepEnabled('groups')) { $this->loadGroups(); } return $this->_managed_groups; } /** * Can current logged-in user create member * * @param Login $login Login instance * * @return boolean */ public function canCreate(Login $login): bool { global $preferences; if ($this->id && $login->id == $this->id || $login->isAdmin() || $login->isStaff()) { return true; } if ($preferences->pref_bool_groupsmanagers_create_member && $login->isGroupManager()) { return true; } if ($preferences->pref_bool_create_member && $login->isLogged()) { return true; } return false; } /** * Can current logged-in user edit member * * @param Login $login Login instance * * @return boolean */ public function canEdit(Login $login): bool { global $preferences; //admin and staff users can edit, as well as member itself if ($this->id && $login->id == $this->id || $login->isAdmin() || $login->isStaff()) { return true; } //parent can edit their child cards if ($this->hasParent() && $this->parent_id === $login->id) { return true; } //group managers can edit members of groups they manage when pref is on if ($preferences->pref_bool_groupsmanagers_edit_member && $login->isGroupManager()) { foreach ($this->getGroups() as $g) { if ($login->isGroupManager($g->getId())) { return true; } } } return false; } /** * Can current logged-in user display member * * @param Login $login Login instance * * @return boolean */ public function canShow(Login $login): bool { //group managers can show members of groups they manage if ($login->isGroupManager()) { foreach ($this->getGroups() as $g) { if ($login->isGroupManager($g->getId())) { return true; } } } return $this->canEdit($login); } /** * Are we currently duplicated a member? * * @return boolean */ public function isDuplicate(): bool { return $this->_duplicate; } /** * Flag creation mail sending * * @param boolean $send True (default) to send creation email * * @return Adherent */ public function setSendmail(bool $send = true): self { $this->sendmail = $send; return $this; } /** * Should we send administrative emails to member? * * @return boolean */ public function sendEMail(): bool { return $this->sendmail; } /** * Set member parent * * @param integer $id Parent identifier * * @return $this */ public function setParent(int $id): self { $this->_parent = $id; $this->loadParent(); return $this; } }