5.3 Написание модуля "Сообщения"
- 1. Введение
- 2. Планирование
- 3. Структура БД
- 4. Программирование действий
- 5. Подведение итогов
В этой главе будет описан процесс создания модуля messages, предназначенного для обмена пользователями сообщениями.
Для начала определимся с тем, чего же мы хотим от этого модуля. Прежде всего - модуль должен предоставлять пользователям возможность отправлять личные сообщения, хранить историю отправленных сообщений. Таким образом у этого модуля будет следующий набор экшнов:
list- просмотр сообщений в выбранной категории (к категориям относятся: "отправленные", "входящие", "удалённые").send- отправка сообщений.view- просмотр сообщения.delete- удаление (перемещение в папку "удалённые") сообщений.
Таким образом модуль будет предоставлять базовый функционал, который при необходимости может быть легко расширен до требуемого в конкретной ситуации.
В систему будет добавлено следующие 2 сущности:
message- собственно само сообщение.messageCategory- категория сообщений.
Теперь давайте создадим эти сущности. Для этого откроем devToolbar (по умолчанию урл будет такой: http://mzz/admin/devToolbar). Создадим новый модуль message. В результате этого действия в каталоге с активными шаблонами будет создан подкаталог message, а в каталоге с модулями - необходимая иерархия каталогов для модуля. Теперь в этот модуль добавим 2 сущности: message и messageCategory. Также создадим новый раздел message и зарегистрируем в нём только что созданный модуль.
Таблица для сущности message будет содержать следующие поля:
id- первичный ключ таблицы, идентификатор сообщенияtitle- заголовок сообщенияtext- текст сообщенияsender- id отправителяrecipient- id получателяtime- timestamp времени отправления сообщенияwatched- флаг, определяющий, просмотрено сообщение или нетcategory_id- идентификатор категории, к которой относится сообщениеobj_id- уникальный идентификатор объекта, служебное поле для ACL
CREATE TABLE `message_message` ( `id` int(11) UNSIGNED NOT NULL AUTO_INCREMENT, `title` varchar(255) DEFAULT NULL, `text` text, `sender` int(11) DEFAULT NULL, `recipient` int(11) DEFAULT NULL, `time` int(11) DEFAULT NULL, `watched` tinyint(4) DEFAULT NULL, `category_id` int(11) DEFAULT NULL, `obj_id` int(11) UNSIGNED DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=MyISAM DEFAULT CHARSET=utf8;
Для сущности messageCategory таблица будет несколько проще:
id- первичный ключ таблицы, идентификатор категорииtitle- название категории, будет отображаться для пользователейname- имя категории, будет составлять часть урла и использоваться для служебных целейobj_id- уникальный идентификатор объекта, служебное поле для ACL
CREATE TABLE `message_messageCategory` ( `id` int(11) UNSIGNED NOT NULL AUTO_INCREMENT, `title` char(255) DEFAULT NULL, `name` char(20) DEFAULT NULL, `obj_id` int(11) UNSIGNED DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=MyISAM DEFAULT CHARSET=utf8;
Теперь вернёмся снова в devToolbar, и для каждой из сущностей откроем редактор map-файла. В результате message.map.ini и messageCategory.map.ini будут сгенерированы на основе таблиц в БД. Если всё выполнено верно, то файлы будут выглядеть так:
[id] accessor = "getId" mutator = "setId" [title] accessor = "getTitle" mutator = "setTitle" [text] accessor = "getText" mutator = "setText" [sender] accessor = "getSender" mutator = "setSender" [recipient] accessor = "getRecipient" mutator = "setRecipient" [time] accessor = "getTime" mutator = "setTime" [watched] accessor = "getWatched" mutator = "setWatched" [category_id] accessor = "getCategory_id" mutator = "setCategory_id"
[id] accessor = "getId" mutator = "setId" [title] accessor = "getTitle" mutator = "setTitle" [name] accessor = "getName" mutator = "setName"
Теперь свяжем сообщения с категорями, а пользователей - с сущностью пользователь системы, а также переименуем методы для category_id в более удобные:
[id] accessor = "getId" mutator = "setId" [title] accessor = "getTitle" mutator = "setTitle" [text] accessor = "getText" mutator = "setText" [sender] accessor = "getSender" mutator = "setSender" owns = "user.id" module = "user" section = "user" [recipient] accessor = "getRecipient" mutator = "setRecipient" owns = "user.id" module = "user" section = "user" [time] accessor = "getTime" mutator = "setTime" [watched] accessor = "getWatched" mutator = "setWatched" [category_id] accessor = "getCategory" mutator = "setCategory" owns = "messageCategory.id"
Начнём с действия list. Это действие будет показывать список сообщений в данной категории. Для начала создадим необходимые нам категории: "Входящие", "Исходящие", "Корзина". Для этого в devToolbar сгенерируем 3 obj_id для объектов типа messageCategory. И добавим в таблицу message_messageCategory 3 записи с этими идентификаторами. Также добавим одно сообщение, поместим его в категорию incoming (в рассматриваемом примере у этой категории id = 1). Для сообщения укажем отправителем, допустим, гостя (id = 1), а получаетелем - себя (id = 2). В вашем случае id могут не совпадать. Для сообщения по известной схеме сгенерируем obj_id и добавим всю эту информацию в таблицу message_message. Теперь добавим действие, в devToolbar. Теперь запросим урл http://mzz/message/incoming/list. Если всё прошло успешно будет надпись:
Автоматически сгенерированный шаблон Модуль: message Экшн: list Путь до этого файла: templates\list.tpl
Т.к. просмотр списка сообщений будет всегда разрешён всем пользователям, отключим ACL для этого экшна. Для этого в активном шаблоне act/message/list.tpl пропишем 403handle="none" для этого метода. Теперь напишем код, который будет получать список сообщений:
<?php class messageListController extends simpleController { public function getView() { $name = $this->request->get('name', 'string'); // получаем имя категории $isSent = $name == 'sent'; $messageCategoryMapper = $this->toolkit->getMapper('message', 'messageCategory'); // получаем маппер $messageCategory = $messageCategoryMapper->searchOneByField('name', $name); // ищем категорию if (empty($messageCategory)) { // если не нашли - отображаем 404 ошибку return $messageCategoryMapper->get404()->run(); } $me = $this->toolkit->getUser(); // получаем текущего пользователя $messageMapper = $this->toolkit->getMapper('message', 'message'); // получаем маппер $criteria = new criteria(); // составляем критерий поиска $criteria->add('category_id', $messageCategory->getId()); // сообщения из текущей категории $criteria->add($isSent ? 'sender' : 'recipient', $me->getId()); // для текущего пользователя (если категория "исходящие", то текущий пользователь является отправителем) $messages = $messageMapper->searchAllByCriteria($criteria); // ищем все сообщения $messageCategories = $messageCategoryMapper->searchAll(); // ищем все категории // передаём полученные данные в шаблон $this->smarty->assign('messages', $messages); $this->smarty->assign('isSent', $isSent); $this->smarty->assign('categories', $messageCategories); $this->smarty->assign('messageCategory', $messageCategory); return $this->smarty->fetch('message/list.tpl'); } } ?>
Сам шаблон выглядит так (в начале добавлен блок {add...} для того, чтобы загрузить необходимые для использования jip файлы. Если бы в шаблоне встрачался хоть один вызов метода getJip(), это было бы произведено автоматически):
{add file="prototype.js"} {add file="prototype_improvements.js"} {add file="effects.js"} {add file="dragdrop.js"} {add file="popup.js"} {add file="jip.css"} {add file="jip.js"} {title append=$messageCategory->getTitle()|htmlspecialchars} {title append="Список сообщений"} {foreach from=$categories item=category name=cat} {if $messageCategory->getName() ne $category->getName()}<a href="{url route=withAnyParam action=list section=message name=$category->getName()}">{$category->getTitle()|htmlspecialchars}</a>{else}{$category->getTitle()|htmlspecialchars}{/if} {if not $smarty.foreach.cat.last} | {/if} {/foreach} <br /><br /> <a href="{url route=default2 action=send section=message}" class="jipLink">отправить сообщение</a> <br /><br /> <table border="0" width="99%" cellpadding="4" cellspacing="0" class="systemTable"> <tr align="center"> <td><strong>id</strong></td> <td><strong>сообщение</strong></td> <td><strong>{if $isSent}кому{else}от{/if}</strong></td> <td><strong>дата</strong></td> </tr> {foreach from=$messages item=message} <tr align="center"> <td>{$message->getId()}</td> <td><a href="{url route=withId action=view section=message id=$message->getId()}">{if not $message->getWatched()}<b>{/if}{$message->getTitle()|htmlspecialchars}{if not $message->getWatched()}</b>{/if}</a>{$message->getJip()}</td> <td>{if $isSent}{$message->getRecipient()->getLogin()}{else}<a href="{url route=withAnyParam action=send section=message name=$message->getSender()->getLogin()}">{$message->getSender()->getLogin()}</a>{/if}</td> <td>{$message->getTime()|date_format:"%H:%M:%S %e-%m-%Y"}</td> </tr> {/foreach} </table>
И теперь запрос урла http://mzz/message/incoming/list должен нам выдавать список сообщений в данной категории. Сверху списка располагаются список категорий.
Далее реализуем метод просмотра сообщений. Создадим экшн view. Это будет экшн сущности message. Право на просмотр сообщения будет определяться просто - если получателем является текущий пользователь (или в случае с исходящими сообщениями - наоборот отправитель), тогда доступ разрешён. Эта проверка будет осуществляться с помощью специального метода getAcl() в классе message:
<?php class message extends simple { [...] public function getAcl($name = null) { // у экшна 'delete' логика проверки авторизации будет такая же if ($name == 'view' || $name == 'delete') { // получаем id получателя сообщения (или отправителя, в случае с категорией 'sent' $user_id = ($this->getCategory()->getName() == 'sent') ? $this->getSender()->getId() : $this->getRecipient()->getId(); // если id получателя/отправителя == id текущего пользователя - доступ есть return $user_id == systemToolkit::getInstance()->getUser()->getId(); } return parent::getAcl($name); } } ?>
Также, для того чтобы ACL работал, необходимо реализовать метод convertArgsToObj в messageMapper'е:
<?php class messageMapper extends simpleMapper { [...] public function convertArgsToObj($args) { $message = $this->searchByKey($args['id']); if ($message) { return $message; } throw new mzzDONotFoundException(); } } ?>
Реализуем контроллер:
<?php class messageViewController extends simpleController { public function getView() { $id = $this->request->get('id', 'integer'); // получаем id сообщения $messageMapper = $this->toolkit->getMapper('message', 'message'); // получаем маппер $message = $messageMapper->searchByKey($id); // получаем сообщение // если сообщение не найдено - показываем ошибку if (!$message) { return $messageMapper->get404()->run(); } // если сообщение ещё не было просмотрено - устанавливаем флаг "просмотра" в 1 if (!$message->getWatched()) { $message->setWatched(1); $messageMapper->save($message); } $category = $message->getCategory(); // получаем категорию сообщения $isSent = $category->getName() == 'sent'; $messageCategoryMapper = $this->toolkit->getMapper('message', 'messageCategory'); // получаем маппер категорий $messageCategories = $messageCategoryMapper->searchAll(); // выбираем все категории // передаём данные в шаблон $this->smarty->assign('categories', $messageCategories); $this->smarty->assign('messageCategory', $category); $this->smarty->assign('isSent', $isSent); $this->smarty->assign('message', $message); return $this->smarty->fetch('message/view.tpl'); } } ?>
Шаблон:
{add file="prototype.js"} {add file="prototype_improvements.js"} {add file="effects.js"} {add file="dragdrop.js"} {add file="popup.js"} {add file="jip.css"} {add file="jip.js"} {title append=$message->getTitle()|htmlspecialchars} {title append="Просмотр сообщения"} {foreach from=$categories item=category name=cat} <a href="{url route=withAnyParam action=list section=message name=$category->getName()}">{$category->getTitle()|htmlspecialchars}</a> {if not $smarty.foreach.cat.last} | {/if} {/foreach} <br /><br /> <strong>Тема:</strong> {$message->getTitle()|htmlspecialchars}<br /> {if $isSent} <strong>Получатель:</strong> {$message->getRecipient()->getLogin()}<br /> {else} <strong>Отправитель:</strong> {$message->getSender()->getLogin()}<br /> {/if} <strong>Текст сообщения:</strong><br />{$message->getText()|htmlspecialchars|nl2br}<br /> <br /> {if not $isSent} <a href="{url route=withAnyParam section=message action=send name=$message->getSender()->getLogin()}">ответить</a> | {/if} <a href="{url route=withId section=message action=delete id=$message->getId()}" class="jipLink">удалить</a>
Теперь кликом на заголовке сообщения в списке сообщений либо по урлу http://mzz/message/1/view должно быть доступно созданное вручную сообщение. Теперь реализуем отправку сообщения, метод send у сущности messageCategory. Создадим его, и также пропишем на него 403handle="none". Этот экшн будет в jip-окне, поэтому поставим для него опцию "Добавить в JIP", а также из активного шаблона уберём загрузку в основной шаблон, оставив лишь {load module="message" action="send" 403handle="none"}. Откроем ссылку http://mzz/message/send. Форма отправки сообщения должна представлять собой поля для ввода темы и текста сообщения, а также выпадающего списка для выбора получателя сообщения. После отправки сообщения, копия должна поместиться в категорию "отправленные". Реализация этого экшна:
<?php class messageSendController extends simpleController { public function getView() { // получаем текущего пользователя $me = $this->toolkit->getUser(); // из запроса - получаем имя получателя $recipient = $this->request->get('name', 'string'); // ищем получателя $userMapper = $this->toolkit->getMapper('user', 'user', 'user'); $recipient_user = $userMapper->searchByLogin($recipient); // если получатель был указан в УРЛе, но такого пользователя не существует - показываем ошибку if ($recipient && !$recipient_user) { $controller = new messageController('Получателя не существует', messageController::WARNING); return $controller->run(); } // ищем пользователей, которым можно отправить сообщение (все, кроме текущего пользователя и гостя) $criteria = new criteria(); $criteria->add('id', MZZ_USER_GUEST_ID, criteria::NOT_EQUAL); $criteria->setOrderByFieldAsc('login'); $users = $userMapper->searchAllByCriteria($criteria); $usersArray = array(); foreach ($users as $user) { $usersArray[$user->getId()] = $user->getLogin(); } unset($usersArray[$me->getId()]); // составляем валидатор для формы $validator = new formValidator(); $validator->add('required', 'message[title]', 'Необходимо указать тему сообщения'); $validator->add('required', 'message[text]', 'Необходимо указать текст сообщения'); $validator->add('required', 'message[recipient]', 'Необходимо указать получателя сообщения'); $validator->add('callback', 'message[recipient]', 'Пользователь не найден', array('checkRecipient', $usersArray)); // валидируем форму if ($validator->validate()) { // получаем данные сообщения $msg = $this->request->get('message', 'array', SC_POST); // получаем необходимые мапперы $messageMapper = $this->toolkit->getMapper('message', 'message'); $messageCategoryMapper = $this->toolkit->getMapper('message', 'messageCategory'); // ищем категории сообщений для отправленного и входящего сообщений $incoming = $messageCategoryMapper->searchOneByField('name', 'incoming'); $sent = $messageCategoryMapper->searchOneByField('name', 'sent'); // составляем сообщение, которое будет отправлено пользователю $message = $messageMapper->create(); $message->setTitle($msg['title']); $message->setText($msg['text']); $message->setRecipient($msg['recipient']); $message->setSender($me); $message->setWatched(0); $message->setCategory($incoming); // делаем копию, помещаем её в "отправленные" $messageSent = $messageMapper->create(); $messageSent->setTitle($msg['title']); $messageSent->setText($msg['text']); $messageSent->setRecipient($msg['recipient']); $messageSent->setSender($me); $messageSent->setWatched(1); $messageSent->setCategory($sent); // сохраняем оба сообщения $messageMapper->save($message); $messageMapper->save($messageSent); // закрываем jip-окно return jipTools::redirect(); } // генерируем урл if ($recipient) { // если пользователь был указан, то урл будет вида: site/message/USERNAME/send $url = new url('withAnyParam'); $url->add('name', $recipient); } else { // иначе: site/message/send $url = new url('default2'); } $url->setSection('message'); $url->setAction('send'); // передаём в шаблон данные $this->smarty->assign('recipient', $recipient_user->getId()); $this->smarty->assign('action', $url->get()); $this->smarty->assign('errors', $validator->getErrors()); $this->smarty->assign('users', $usersArray); return $this->smarty->fetch('message/send.tpl'); } } // функция-валидатор, проверяющая что выбранный пользователь может являться получателем сообщения function checkRecipient($user_id, $users) { return isset($users[$user_id]); } ?>
Также в messageMapper реализуем хуки, для автоматического добавления текущего времени в сообщение:
<?php class messageMapper extends simpleMapper { [...] /** * Выполнение операций с массивом $fields перед обновлением в БД * * @param array $fields */ protected function updateDataModify(&$fields) { $fields['time'] = new sqlFunction('UNIX_TIMESTAMP'); } /** * Выполнение операций с массивом $fields перед вставкой в БД * * @param array $fields */ protected function insertDataModify(&$fields) { $this->updateDataModify($fields); } } ?>
Шаблон для этого экшна будет выглядеть так:
<div class="jipTitle">Отправка сообщения</div> <form action="{$action}" method="post" onsubmit="return jipWindow.sendForm(this);"> <table width="100%" border="0" cellpadding="5" cellspacing="0" align="center"> <tr> <td style='width: 20%;'>{form->caption name="message[recipient]" value="Получатель"}</td> <td style='width: 80%;'>{form->select name="message[recipient]" options=$users value=$recipient}{$errors->get('message[recipient]')}</td> </tr> <tr> <td style='width: 20%;'>{form->caption name="message[title]" value="Тема"}</td> <td style='width: 80%;'>{form->text name="message[title]" size="60"}{$errors->get('message[title]')}</td> </tr> <tr> <td style='width: 20%;'>{form->caption name="message[text]" value="Текст"}</td> <td style='width: 80%;'>{form->textarea name="message[text]" rows="6" cols="50"}{$errors->get('message[text]')}</td> </tr> <tr> <td> </td> <td>{form->submit name="submit" value="Сохранить"} {form->reset jip=true name="reset" value="Отмена"}</td> </tr> </table> </form>
Ну и последний экшн, удаление сообщения. Создаём его по уже известной схеме, но дополнительно - укажем для него иконку delete.gif и сообщение "Вы действительно хотите удалить сообщение?". При удалении сообщения - оно перемещается в категорию "Удалённые", при удалении из этой категории - оно удаляется безвозвратно. Проверку прав на удаление сообщения мы уже реализовали ранее. Удаление сообщения будет вызываться из jip-меню, из списка сообщений. Добавим соответствующий функционал в шаблон list.tpl. Интересующая нас строка будет выглядеть следующим образом:
<td><a href="{url route=withId action=view section=message id=$message->getId()}">{if not $message->getWatched()}<b>{/if}{$message->getTitle()|htmlspecialchars}{if not $message->getWatched()}</b>{/if}</a>{$message->getJip()}</td>
Сам шаблон delete.tpl, сгенерированный автоматически, следует удалить, т.к. использоваться он не будет. Сама реализация экшна:
<?php class messageDeleteController extends simpleController { public function getView() { // получаем id удаляемого сообщения и само сообщение $id = $this->request->get('id', 'integer'); $messageMapper = $this->toolkit->getMapper('message', 'message'); $message = $messageMapper->searchByKey($id); // если сообщение не найдено - показываем ошибку if (!$message) { return $messageMapper->get404()->run(); } // если сообщение находится не в категории "удалённые" - перемещаем его туда if ($message->getCategory()->getName() != 'recycle') { $messageCategoryMapper = $this->toolkit->getMapper('message', 'messageCategory'); $recycle = $messageCategoryMapper->searchOneByField('name', 'recycle'); $message->setCategory($recycle); $messageMapper->save($message); } else { // если уже в "удалённых" - тогда удаляем окончательно $messageMapper->delete($message->getId()); } // закрываем jip-окно return jipTools::redirect(); } } ?>
Теперь если в списке сообщений кликнуть по jip, выбрать "удалить" для сообщения, появится jip-окно с подтверждением удаления. После подтверждения удаления сообщения из категорий "входящие" и "исходящие" будет перенесено в корзину, если же сообщение удаляется из корзины - то оно будет удалено безвозвратно.
Таким образом за это время мы написали систему обмена сообщений между пользователями. Система примитивная, но выполняющая свои задачи. Естественно при желании она может быть расширена и дополнена.
В приведённом примере во всех экшнах проверка прав осуществлялась вручную. Это обусловлено тем, что в данном конкретном случае это правильнее и удобнее. О встроенных механизмах проверки прав вы можете прочитать в соответствующей главе, посвещённой ACL и о методе convertArgsToObj() в частности