MZZ.Framework 0.3.x: Документация
Разделы

5.3 Написание модуля "Сообщения"

1. Введение
2. Планирование
3. Структура БД
4. Программирование действий
5. Подведение итогов
1. Введение

В этой главе будет описан процесс создания модуля messages, предназначенного для обмена пользователями сообщениями.

2. Планирование

Для начала определимся с тем, чего же мы хотим от этого модуля. Прежде всего - модуль должен предоставлять пользователям возможность отправлять личные сообщения, хранить историю отправленных сообщений. Таким образом у этого модуля будет следующий набор экшнов:

Таким образом модуль будет предоставлять базовый функционал, который при необходимости может быть легко расширен до требуемого в конкретной ситуации.

В систему будет добавлено следующие 2 сущности:

Теперь давайте создадим эти сущности. Для этого откроем devToolbar (по умолчанию урл будет такой: http://mzz/admin/devToolbar). Создадим новый модуль message. В результате этого действия в каталоге с активными шаблонами будет создан подкаталог message, а в каталоге с модулями - необходимая иерархия каталогов для модуля. Теперь в этот модуль добавим 2 сущности: message и messageCategory. Также создадим новый раздел message и зарегистрируем в нём только что созданный модуль.

3. Структура БД

Таблица для сущности message будет содержать следующие поля:

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 таблица будет несколько проще:

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"
4. Программирование действий

Начнём с действия 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>&nbsp;</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-окно с подтверждением удаления. После подтверждения удаления сообщения из категорий "входящие" и "исходящие" будет перенесено в корзину, если же сообщение удаляется из корзины - то оно будет удалено безвозвратно.

5. Подведение итогов

Таким образом за это время мы написали систему обмена сообщений между пользователями. Система примитивная, но выполняющая свои задачи. Естественно при желании она может быть расширена и дополнена.

В приведённом примере во всех экшнах проверка прав осуществлялась вручную. Это обусловлено тем, что в данном конкретном случае это правильнее и удобнее. О встроенных механизмах проверки прав вы можете прочитать в соответствующей главе, посвещённой ACL и о методе convertArgsToObj() в частности