X-Git-Url: http://git.polytechnique.org/?a=blobdiff_plain;f=include%2Fnewsletter.inc.php;h=b8c248e527116433c08e1fb25bba3a6e6af60828;hb=d53c12097f4e82ad4657de86f199699973dee29f;hp=7e4b13b740a621dbd00607d16be9e9e87d27f359;hpb=736dcfda7006f30d6150722a40c94cafa1a3f112;p=platal.git diff --git a/include/newsletter.inc.php b/include/newsletter.inc.php index 7e4b13b..b8c248e 100644 --- a/include/newsletter.inc.php +++ b/include/newsletter.inc.php @@ -1,6 +1,6 @@ addCssLink('nl.css'); +class MailNotFound extends Exception { } -define('FEMME', 1); -define('HOMME', 0); - // }}} + // {{{ class NewsLetter class NewsLetter { - // {{{ properties - - var $_id; - var $_date; - var $_title; - var $_head; - var $_cats = Array(); - var $_arts = Array(); + public $id; // ID of the NL (in table newsletters) + public $group; // Short name of the group corresponding to the NL + public $group_id; // ID of that group + public $name; // Name of the NL (e.g "Lettre de Polytechnique.org", ...) + public $cats; // List of all categories for this NL + public $criteria; // PlFlagSet of allowed filters for recipient selection - // }}} - // {{{ constructor - - function NewsLetter($id=null) - { - if (isset($id)) { - if ($id == 'last') { - $res = XDB::query("SELECT MAX(id) FROM newsletter WHERE bits!='new'"); - $id = $res->fetchOneCell(); - } - $res = XDB::query("SELECT * FROM newsletter WHERE id={?}", $id); - } else { - $res = XDB::query("SELECT * FROM newsletter WHERE bits='new'"); + protected $custom_css = false; + + // Base name to use instead of the group short name for NLs without a custom CSS + const FORMAT_DEFAULT_GROUP = 'default'; + + // Diminutif of X.net groups with a specific NL view + const GROUP_XORG = 'Polytechnique.org'; + const GROUP_AX = 'AX'; + const GROUP_EP = 'Ecole'; + + // {{{ Constructor, NewsLetter retrieval (forGroup, getAll) + + public function __construct($id) + { + // Load NL data + $res = XDB::query('SELECT nls.group_id, g.diminutif AS group_name, + nls.name AS nl_name, nls.custom_css, nls.criteria + FROM newsletters AS nls + LEFT JOIN groups AS g ON (nls.group_id = g.id) + WHERE nls.id = {?}', + $id); if (!$res->numRows()) { - insert_new_nl(); - } - $res = XDB::query("SELECT * FROM newsletter WHERE bits='new'"); - } - $nl = $res->fetchOneAssoc(); - - $this->_id = $nl['id']; - $this->_date = $nl['date']; - $this->_title = $nl['titre']; - $this->_head = $nl['head']; - - $res = XDB::iterRow("SELECT cid,titre FROM newsletter_cat ORDER BY pos"); - while (list($cid, $title) = $res->next()) { - $this->_cats[$cid] = $title; - } - - $res = XDB::iterRow( - "SELECT a.title,a.body,a.append,a.aid,a.cid,a.pos - FROM newsletter_art AS a - INNER JOIN newsletter AS n USING(id) - LEFT JOIN newsletter_cat AS c ON(a.cid=c.cid) - WHERE a.id={?} - ORDER BY c.pos,a.pos", $this->_id); - while (list($title, $body, $append, $aid, $cid, $pos) = $res->next()) { - $this->_arts[$cid]["a$aid"] = new NLArticle($title, $body, $append, $aid, $cid, $pos); - } + throw new MailNotFound(); + } + + $data = $res->fetchOneAssoc(); + $this->id = $id; + $this->group_id = $data['group_id']; + $this->group = $data['group_name']; + $this->name = $data['nl_name']; + $this->custom_css = $data['custom_css']; + $this->criteria = new PlFlagSet($data['criteria']); + + // Load the categories + $res = XDB::iterRow( + 'SELECT cid, title + FROM newsletter_cat + WHERE nlid = {?} + ORDER BY pos', $id); + while (list($cid, $title) = $res->next()) { + $this->cats[$cid] = $title; + } + } + + /** Retrieve the NL associated with a given group. + * @p $group Short name of the group + * @return A NewsLetter object, or null if the group doesn't have a NL. + */ + public static function forGroup($group) + { + $res = XDB::query('SELECT nls.id + FROM newsletters AS nls + LEFT JOIN groups AS g ON (nls.group_id = g.id) + WHERE g.diminutif = {?}', $group); + if (!$res->numRows()) { + return null; + } + return new NewsLetter($res->fetchOneCell()); + } + + /** Retrieve all newsletters + * @return An array of $id => NewsLetter objects + */ + public static function getAll() + { + $res = XDB::query('SELECT id + FROM newsletters'); + $nls = array(); + foreach ($res->fetchColumn() as $id) { + $nls[$id] = new NewsLetter($id); + } + return $nls; } // }}} - // {{{ function setSent() + // {{{ Issue retrieval - function setSent() + /** Retrieve all issues which should be sent + * @return An array of NLIssue objects to send (i.e state = 'new' and send_before <= today) + */ + public static function getIssuesToSend() { - XDB::execute("UPDATE newsletter SET bits='sent' WHERE id={?}", $this->_id); + $res = XDB::query('SELECT id + FROM newsletter_issues + WHERE state = \'pending\' AND send_before <= NOW()'); + $issues = array(); + foreach ($res->fetchColumn() as $id) { + $issues[$id] = new NLIssue($id); + } + return $issues; + } + + /** Retrieve a given issue of this NewsLetter + * @p $name Name or ID of the issue to retrieve. + * @return A NLIssue object. + * + * $name may be either a short_name, an ID or the special value 'last' which + * selects the latest sent NL. + * If $name is null, this will retrieve the current pending NL. + */ + public function getIssue($name = null, $only_sent = true) + { + if ($name) { + if ($name == 'last') { + if ($only_sent) { + $where = 'state = \'sent\' AND '; + } else { + $where = ''; + } + $res = XDB::query('SELECT MAX(id) + FROM newsletter_issues + WHERE ' . $where . ' nlid = {?}', + $this->id); + } else { + $res = XDB::query('SELECT id + FROM newsletter_issues + WHERE nlid = {?} AND (id = {?} OR short_name = {?})', + $this->id, $name, $name); + } + if (!$res->numRows()) { + throw new MailNotFound(); + } + $id = $res->fetchOneCell(); + } else { + $query = XDB::format('SELECT id + FROM newsletter_issues + WHERE nlid = {?} AND state = \'new\' + ORDER BY id DESC', $this->id); + $res = XDB::query($query); + if ($res->numRows()) { + $id = $res->fetchOneCell(); + } else { + // Create a new, empty issue, and return it + $id = $this->createPending(); + } + } + + return new NLIssue($id, $this); + } + + /** Create a new, empty, pending newsletter issue + * @p $nlid The id of the NL for which a new pending issue should be created. + * @return Id of the newly created issue. + */ + public function createPending() + { + XDB::execute('INSERT INTO newsletter_issues + SET nlid = {?}, state=\'new\', date=NOW(), + title=\'to be continued\', + mail_title=\'to be continued\'', + $this->id); + return XDB::insertId(); + } + + /** Return all sent issues of this newsletter. + * @return An array of (id => NLIssue) + */ + public function listSentIssues($check_user = false, $user = null) + { + if ($check_user && $user == null) { + $user = S::user(); + } + + $res = XDB::query('SELECT id + FROM newsletter_issues + WHERE nlid = {?} AND state = \'sent\' + ORDER BY date DESC', $this->id); + $issues = array(); + foreach ($res->fetchColumn() as $id) { + $issue = new NLIssue($id, $this, false); + if (!$check_user || $issue->checkUser($user)) { + $issues[$id] = $issue; + } + } + return $issues; + } + + /** Return all issues of this newsletter, including invalid and sent. + * @return An array of (id => NLIssue) + */ + public function listAllIssues() + { + $res = XDB::query('SELECT id + FROM newsletter_issues + WHERE nlid = {?} + ORDER BY FIELD(state, \'pending\', \'new\') DESC, date DESC', $this->id); + $issues = array(); + foreach ($res->fetchColumn() as $id) { + $issues[$id] = new NLIssue($id, $this, false); + } + return $issues; + } + + /** Return the latest pending issue of the newsletter. + * @p $create Whether to create an empty issue if no pending issue exist. + * @return Either null, or a NL object. + */ + public function getPendingIssue($create = false) + { + $res = XDB::query('SELECT MAX(id) + FROM newsletter_issues + WHERE nlid = {?} AND state = \'new\'', + $this->id); + $id = $res->fetchOneCell(); + if ($id != null) { + return new NLIssue($id, $this); + } else if ($create) { + $id = $this->createPending(); + return new NLIssue($id, $this); + } else { + return null; + } } // }}} - // {{{ function save() + // {{{ Subscription related function - function save() + /** Unsubscribe a user from this newsletter + * @p $uid UID to unsubscribe from the newsletter; if null, use current user. + * @p $hash True if the uid is actually a hash. + * @return True if the user was successfully unsubscribed. + */ + public function unsubscribe($uid = null, $hash = false) { - XDB::execute('UPDATE newsletter SET date={?},titre={?},head={?} WHERE id={?}', - $this->_date, $this->_title, $this->_head, $this->_id); + if (is_null($uid) && $hash) { + // Unable to unsubscribe from an empty hash + return false; + } + $user = is_null($uid) ? S::user()->id() : $uid; + $field = $hash ? 'hash' : 'uid'; + $res = XDB::query('SELECT uid + FROM newsletter_ins + WHERE nlid = {?} AND ' . $field . ' = {?}', + $this->id, $user); + if (!$res->numRows()) { + // No subscribed user with that UID/hash + return false; + } + $user = $res->fetchOneCell(); + + XDB::execute('DELETE FROM newsletter_ins + WHERE nlid = {?} AND uid = {?}', + $this->id, $user); + return true; + } + + /** Subscribe a user to a newsletter + * @p $user User to subscribe to the newsletter; if null, use current user. + */ + public function subscribe($user = null) + { + if (is_null($user)) { + $user = S::user(); + } + if (self::maySubscribe($user)) { + XDB::execute('INSERT IGNORE INTO newsletter_ins (nlid, uid, last, hash) + VALUES ({?}, {?}, NULL, hash)', + $this->id, $user->id()); + } + } + + /** Retrieve subscription state of a user + * @p $user Target user; if null, use current user. + * @return Boolean: true if the user has subscribed to the NL. + */ + public function subscriptionState($user = null) + { + if (is_null($user)) { + $user = S::user(); + } + $res = XDB::query('SELECT 1 + FROM newsletter_ins + WHERE nlid = {?} AND uid = {?}', + $this->id, $user->id()); + return ($res->numRows() == 1); + } + + /** Get the count of subscribers to the NL. + * @return Number of subscribers. + */ + public function subscriberCount() + { + return XDB::fetchOneCell('SELECT COUNT(uid) + FROM newsletter_ins + WHERE nlid = {?}', $this->id); + } + + /** Get the number of subscribers to the NL whose last received mailing was $last. + * @p $last ID of the issue for which subscribers should be counted. + * @return Number of subscribers + */ + public function subscriberCountForLast($last) + { + return XDB::fetchOneCell('SELECT COUNT(uid) + FROM newsletter_ins + WHERE nlid = {?} AND last = {?}', $this->id, $last); + } + + /** Retrieve the list of newsletters a user has subscribed to + * @p $user User whose subscriptions should be retrieved (if null, use session user). + * @return Array of newsletter IDs + */ + public static function getUserSubscriptions($user = null) + { + if (is_null($user)) { + $user = S::user(); + } + $res = XDB::query('SELECT nlid + FROM newsletter_ins + WHERE uid = {?}', + $user->id()); + return $res->fetchColumn(); + } + + /** Retrieve the UserFilterBuilder for subscribers to this NL. + * This is the place where NL-specific filters may be allowed or prevented. + * @p $envprefix Prefix to use for env fields (cf. UserFilterBuilder) + * @return A UserFilterBuilder object using the given env prefix + */ + public function getSubscribersUFB($envprefix = '') + { + require_once 'ufbuilder.inc.php'; + return new UFB_NewsLetter($this->criteria, $envprefix); } // }}} - // {{{ function title() + // {{{ Permissions related functions + + /** For later use: check whether a given user may subscribe to this newsletter. + * @p $user User whose access should be checked + * @return Boolean: whether the user may subscribe to the NL. + */ + public function maySubscribe($user = null) + { + return true; + } + + /** Whether a given user may edit this newsletter + * @p $uid UID of the user whose perms should be checked (if null, use current user) + * @return Boolean: whether the user may edit the NL + */ + public function mayEdit($user = null) + { + if (is_null($user)) { + $user = S::user(); + } + if ($user->checkPerms('admin')) { + return true; + } + $res = XDB::query('SELECT perms + FROM group_members + WHERE asso_id = {?} AND uid = {?}', + $this->group_id, $user->id()); + return ($res->numRows() && $res->fetchOneCell() == 'admin'); + } - function title() - { return $this->_title; } + /** Whether a given user may submit articles to this newsletter using X.org validation system + * @p $user User whose access should be checked (if null, use current user) + * @return Boolean: whether the user may submit articles + */ + public function maySubmit($user = null) + { + // Submission of new articles is only enabled for the X.org NL (and forbidden when viewing issues on X.net) + return ($this->group == self::GROUP_XORG && !isset($GLOBALS['IS_XNET_SITE'])); + } // }}} - // {{{ function head() - - function head() - { return $this->_head; } + // {{{ Display-related functions: cssFile, tplFile, prefix, admin_prefix, admin_links_enabled, automatic_mailings_enabled + + /** Get the name of the css file used to display this newsletter. + */ + public function cssFile() + { + if ($this->custom_css) { + $base = $this->group; + } else { + $base = self::FORMAT_DEFAULT_GROUP; + } + return 'nl.' . $base . '.css'; + } + + /** Get the name of the template file used to display this newsletter. + */ + public function tplFile() + { + if ($this->custom_css) { + $base = $this->group; + } else { + $base = self::FORMAT_DEFAULT_GROUP; + } + return 'newsletter/nl.' . $base . '.mail.tpl'; + } + + /** Get the prefix leading to the page for this NL + * Only X.org / AX / X groups may be seen on X.org. + */ + public function prefix($enforce_xnet=true) + { + if (!empty($GLOBALS['IS_XNET_SITE'])) { + return $this->group . '/nl'; + } + switch ($this->group) { + case self::GROUP_XORG: + return 'nl'; + case self::GROUP_AX: + return 'ax'; + case self::GROUP_EP: + return 'epletter'; + default: + // Don't display groups NLs on X.org + assert(!$enforce_xnet); + } + } + + /** Get the prefix to use for all 'admin' pages of this NL. + */ + public function adminPrefix($enforce_xnet=true) + { + if (!empty($GLOBALS['IS_XNET_SITE'])) { + return $this->group . '/admin/nl'; + } + switch ($this->group) { + case self::GROUP_XORG: + return 'admin/newsletter'; + case self::GROUP_AX: + return 'ax/admin'; + case self::GROUP_EP: + return 'epletter/admin'; + default: + // Don't display groups NLs on X.org + assert(!$enforce_xnet); + } + } + + /** Hack used to remove "admin" links on X.org page on X.net + * The 'admin' links are enabled for all pages, except for X.org when accessing NL through X.net + */ + public function adminLinksEnabled() + { + return ($this->group != self::GROUP_XORG || !isset($GLOBALS['IS_XNET_SITE'])); + } + + /** Automatic mailings are disabled for X.org NL. + */ + public function automaticMailingEnabled() + { + return $this->group != self::GROUP_XORG; + } + + public function hasCustomCss() + { + return $this->custom_css; + } // }}} - // {{{ function getArt() - - function getArt($aid) +} + +// }}} + +// {{{ class NLIssue + +// A NLIssue is an issue of a given NewsLetter +class NLIssue +{ + protected $nlid; // Id of the newsletter + + const STATE_NEW = 'new'; // New, currently being edited + const STATE_PENDING = 'pending'; // Ready for mailing + const STATE_SENT = 'sent'; // Sent + + public $nl; // Related NL + + public $id; // Id of this issue of the newsletter + public $shortname; // Shortname for this issue + public $title; // Title of this issue + public $title_mail; // Title of the email + public $state; // State of the issue (one of the STATE_ values) + public $sufb; // Environment to use to generate the UFC through an UserFilterBuilder + + public $date; // Date at which this issue was sent + public $send_before; // Date at which issue should be sent + public $head; // Foreword of the issue (or body for letters with no articles) + public $signature; // Signature of the letter + public $arts = array(); // Articles of the issue + + const BATCH_SIZE = 60; // Number of emails to send every minute. + + // {{{ Constructor, id-related functions + + /** Build a NewsLetter. + * @p $id: ID of the issue (unique among all newsletters) + * @p $nl: Optional argument containing an already built NewsLetter object. + */ + function __construct($id, $nl = null, $fetch_articles = true) + { + return $this->fetch($id, $nl, $fetch_articles); + } + + protected function refresh() { - foreach ($this->_arts as $key=>$artlist) { - if (isset($artlist["a$aid"])) { - return $artlist["a$aid"]; + return $this->fetch($this->id, $this->nl, false); + } + + protected function fetch($id, $nl = null, $fetch_articles = true) + { + // Load this issue + $res = XDB::query('SELECT nlid, short_name, date, send_before, state, sufb_json, + title, mail_title, head, signature + FROM newsletter_issues + WHERE id = {?}', + $id); + if (!$res->numRows()) { + throw new MailNotFound(); + } + $issue = $res->fetchOneAssoc(); + if ($nl && $nl->id == $issue['nlid']) { + $this->nl = $nl; + } else { + $this->nl = new NewsLetter($issue['nlid']); + } + $this->id = $id; + $this->shortname = $issue['short_name']; + $this->date = $issue['date']; + $this->send_before = $issue['send_before']; + $this->state = $issue['state']; + $this->title = $issue['title']; + $this->title_mail = $issue['mail_title']; + $this->head = $issue['head']; + $this->signature = $issue['signature']; + $this->sufb = $this->importJSonStoredUFB($issue['sufb_json']); + + if ($fetch_articles) { + $this->fetchArticles(); + } + } + + protected function fetchArticles($force = false) + { + if (count($this->arts) && !$force) { + return; + } + + // Load the articles + $res = XDB::iterRow( + 'SELECT a.title, a.body, a.append, a.aid, a.cid, a.pos + FROM newsletter_art AS a + INNER JOIN newsletter_issues AS ni USING(id) + LEFT JOIN newsletter_cat AS c ON (a.cid = c.cid) + WHERE a.id = {?} + ORDER BY c.pos, a.pos', + $this->id); + while (list($title, $body, $append, $aid, $cid, $pos) = $res->next()) { + $this->arts[$cid][$aid] = new NLArticle($title, $body, $append, $aid, $cid, $pos); + } + } + + protected function importJSonStoredUFB($json = null) + { + require_once 'ufbuilder.inc.php'; + $ufb = $this->nl->getSubscribersUFB(); + if (is_null($json)) { + return new StoredUserFilterBuilder($ufb, new PFC_True()); + } + $export = json_decode($json, true); + if (is_null($export)) { + PlErrorReport::report("Invalid json while reading NL {$this->nlid}, issue {$this->id}: failed to import '''{$json}'''."); + return new StoredUserFilterBuilder($ufb, new PFC_True()); + } + $sufb = new StoredUserFilterBuilder($ufb); + $sufb->fillFromExport($export); + return $sufb; + } + + protected function exportStoredUFBAsJSon() + { + return json_encode($this->sufb->export()); + } + + public function id() + { + return is_null($this->shortname) ? $this->id : $this->shortname; + } + + protected function selectId($where) + { + $res = XDB::query("SELECT IFNULL(ni.short_name, ni.id) + FROM newsletter_issues AS ni + WHERE ni.state != 'new' AND ni.nlid = {?} AND ${where} + LIMIT 1", $this->nl->id); + if ($res->numRows() != 1) { + return null; + } + return $res->fetchOneCell(); + } + + /** Delete this issue + * @return True if the issue could be deleted, false otherwise. + * Related articles will be deleted through cascading FKs. + * If this issue was the last issue for at least one subscriber, the deletion will be aborted. + */ + public function delete() + { + if ($this->state == self::STATE_NEW) { + $res = XDB::query('SELECT COUNT(*) + FROM newsletter_ins + WHERE last = {?}', $this->id); + if ($res->fetchOneCell() > 0) { + return false; } - } - return null; + + return XDB::execute('DELETE FROM newsletter_issues + WHERE id = {?}', $this->id); + } else { + return false; + } } - // }}} - // {{{ function saveArticle() - - function saveArticle(&$a) - { - if ($a->_aid>=0) { - XDB::execute('REPLACE INTO newsletter_art (id,aid,cid,pos,title,body,append) - VALUES ({?},{?},{?},{?},{?},{?},{?})', - $this->_id, $a->_aid, $a->_cid, $a->_pos, - $a->_title, $a->_body, $a->_append); - $this->_arts['a'.$a->_aid] = $a; - } else { - XDB::execute( - 'INSERT INTO newsletter_art - SELECT {?},MAX(aid)+1,{?},'.($a->_pos ? intval($a->_pos) : 'MAX(pos)+1').',{?},{?},{?} - FROM newsletter_art AS a - WHERE a.id={?}', - $this->_id, $a->_cid, $a->_title, $a->_body, $a->_append, $this->_id); - $this->_arts['a'.$a->_aid] = $a; - } + /** Schedule a mailing of this NL + * If the 'send_before' field was NULL, it is set to the current time. + * @return Boolean Whether the date could be set (false if trying to schedule an already sent NL) + */ + public function scheduleMailing() + { + if ($this->state == self::STATE_NEW) { + $success = XDB::execute('UPDATE newsletter_issues + SET state = \'pending\', send_before = IFNULL(send_before, NOW()) + WHERE id = {?}', + $this->id); + if ($success) { + global $globals; + $mailer = new PlMailer('newsletter/notify_scheduled.mail.tpl'); + $mailer->assign('issue', $this); + $mailer->assign('base', $globals->baseurl); + $mailer->send(); + $this->refresh(); + } + return $success; + } else { + return false; + } } - // }}} - // {{{ function delArticle() - - function delArticle($aid) + /** Cancel the scheduled mailing of this NL + * @return Boolean: whether the mailing could be cancelled. + */ + public function cancelMailing() { - XDB::execute('DELETE FROM newsletter_art WHERE id={?} AND aid={?}', $this->_id, $aid); - foreach ($this->_arts as $key=>$art) { - unset($this->_arts[$key]["a$aid"]); - } + if ($this->state == self::STATE_PENDING) { + $success = XDB::execute('UPDATE newsletter_issues + SET state = \'new\' + WHERE id = {?}', $this->id); + if ($success) { + $this->refresh(); + } + return $success; + } else { + return false; + } + } + + /** Helper function for smarty templates: is this issue editable ? + */ + public function isEditable() + { + return $this->state == self::STATE_NEW; + } + + /** Helper function for smarty templates: is the mailing of this issue scheduled ? + */ + public function isPending() + { + return $this->state == self::STATE_PENDING; + } + + /** Helper function for smarty templates: has this issue been sent ? + */ + public function isSent() + { + return $this->state == self::STATE_SENT; } // }}} - // {{{ function footer - - function footer($html) - { - global $globals; - $url = 'https://www.polytechnique.org'; - - if ($html) { - return '