Пишем интерфейс: таблица очереди писем

На этом уроке мы закрепляем работу с ExtJS. Здесь не будет ничего нового, мы рисуем очередную таблицу и задаём для неё процессоры.

Логика работы такая:
  1. У нас есть рассылка
  2. К ней прикреплены подписчики
  3. Нужно взять рассылку, сгенерировать письмо и поставить в очередь
  4. Один подписчик — одно письмо для каждой рассылки
  5. После создания письма его можно удалить или отправить

В итоге у меня получилась вот такая таблица:

Корректировки

Для начала нужно внести кое-какие изменения, которые я пропустил на прошлых уроках.

Улучшаем процессоры создания и обновления рассылок. Теперь мы требуем или шаблон или сниппет.
if (!$this->getProperty('template') && !$this->getProperty('snippet')) {
	$this->addFieldError('template', $this->modx->lexicon('sendex_newsletter_err_template'));
	$this->addFieldError('snippet', $this->modx->lexicon('sendex_newsletter_err_snippet'));
}

И меняем проверку поля active — при обновлении подписки там отправляется строка «false», нужно проверять и её:
$this->setProperty('active', !empty($active) && $active != 'false');

Добавляем возможность удаления сниппета и шаблона из рассылки. Нужно просто включить параметр editable в комбобоксах widgets/newsletters.grid.js:
{xtype: 'modx-combo-template',editable:true,fieldLabel: _('sendex_newsletter_template'),name: 'template',id: 'sendex-'+this.ident+'-template',anchor: '99%'}
// ...
{xtype: 'sendex-combo-snippet',editable:true,fieldLabel: _('sendex_newsletter_snippet'),name: 'snippet',id: 'sendex-'+this.ident+'-snippet',anchor: '99%'}

Вот коммит с изменениями.

Меняем модель sxQueue

Нам нужно добавить защиту от дублирования писем. Для этого будем использовать новый столбец hash в объекте sxQueue.

Меняем sendex.mysql.schema.xml:
<field key="hash" dbtype="char" precision="40" phptype="string" null="true" default="" />

<index alias="newsletter_id" name="newsletter_id" primary="false" unique="false" type="BTREE">
	<column key="newsletter_id" length="" collation="A" null="false" />
</index>
<index alias="subscriber_id" name="user_id" primary="false" unique="false" type="BTREE">
	<column key="subscriber_id" length="" collation="A" null="false" />
</index>
<index alias="timestamp" name="timestamp" primary="false" unique="false" type="BTREE">
	<column key="timestamp" length="" collation="A" null="false" />
</index>
<index alias="hash" name="hash" primary="false" unique="true" type="BTREE">
	<column key="hash" length="" collation="A" null="false" />
</index>

Добавляем новое поле и уникальный индекс для него. Заодно исправляем старую опечатку — меняем user_id на subscriber_id.

Теперь нужно запрограммировать изменение таблицы при установке и обновлении пакета. Для этого редактируем скрипт ресолвера tables:
// Запоминаем текущий уровень ошибок
$level = $modx->getLogLevel();
// Выставляем самый мощный уровень, чтобы не было ругани в логах при попытке создания существующих полей
$modx->setLogLevel(xPDO::LOG_LEVEL_FATAL);

// Добавляем поле и индекс
$manager->addField('sxQueue', 'hash');
$manager->addIndex('sxQueue', 'hash');

// Возвращаем старый уровень логирования
$modx->setLogLevel($level);
При установке пакета будет изменена таблица в БД.
Переустанавливаем пакет, чтобы применить изменения, запуская build.transport.php из браузера.

Вот коммит с изменениями.

Таблица очереди писем

Здесь всё как обычно, простая таблица ExtJS.

Создаём новый файл /assets/components/sendex/js/mgr/widgets/queues.grid.js и пишем там:
Sendex.grid.Queues = function(config) {
	config = config || {};
	Ext.applyIf(config,{
		id: 'sendex-grid-queues'
		,url: Sendex.config.connector_url
		,baseParams: {
			action: 'mgr/queue/getlist'
		}
		,fields: ['id','newsletter_id','subscriber_id','timestamp','email_to','email_subject','email_body','email_from','email_from_name','email_reply','newsletter']
		,autoHeight: true
		,paging: true
		,remoteSort: true
		,columns: [
			{header: _('sendex_queue_id'),dataIndex: 'id',width: 50}
			,{header: _('sendex_newsletter'),dataIndex: 'newsletter',width: 100}
			,{header: _('sendex_queue_email_to'),dataIndex: 'email_to',width: 75}
			//,{header: _('sendex_queue_email_body'),dataIndex: 'email_body',width: 100}
			,{header: _('sendex_queue_email_subject'),dataIndex: 'email_subject',width: 100}
			//,{header: _('sendex_queue_email_from_name'),dataIndex: 'email_from_name',width: 100}
			//,{header: _('sendex_queue_email_reply'),dataIndex: 'email_reply',width: 100}
			,{header: _('sendex_queue_email_from'),dataIndex: 'email_from',width: 100}
			,{header: _('sendex_queue_timestamp'),dataIndex: 'timestamp',width: 75}
		]
	});
	Sendex.grid.Queues.superclass.constructor.call(this,config);
};
Ext.extend(Sendex.grid.Queues,MODx.grid.Grid);

И добавляем его в контроллер для загрузки:
$this->addJavascript($this->Sendex->config['jsUrl'] . 'mgr/widgets/queues.grid.js');

Теперь можно вызвать новый xtype в /assets/components/sendex/js/mgr/widgets/home.panel.js:
{
	title: _('sendex_queues')
	,items: [{
		html: _('sendex_queue_intro')
		,border: false
		,bodyCssClass: 'panel-desc'
		,bodyStyle: 'margin-bottom: 10px'
	},{
		xtype: 'sendex-grid-queues'
		,preventRender: true
	}]
}
Коммит с изменением.

Для выборки данных из таблицы sxQueue нам нужен процессор /core/components/sendex/processors/mgr/queue/getlist.class.php. Из интересного в нём только присоединение таблицы рассылок, чтобы выводить имя в таблице:
public function prepareQueryBeforeCount(xPDOQuery $c) {
	$c->innerJoin('sxNewsletter', 'sxNewsletter', 'sxNewsletter.id = sxQueue.newsletter_id');
	$c->select($this->modx->getSelectColumns('sxQueue', 'sxQueue'));
	$c->select('sxNewsletter.name as newsletter');

	return $c;
}
Весь процессор на GitHub.

Добавление и отправка писем

Для добавления писем в очередь на отправку я предлагаю использовать специальный метод в объекте sxNewsletter. Будет удобно работать с ним не только из процессора, но и просто через xPDO.

Логика такая:
  1. Получаем объект рассылки
  2. Запускаем метод addQueues()
  3. Он получает всех подписчиков рассылки
  4. Смотрит в свойствах, sxNewsletter, кто генерирует письмо: шаблон или сниппет
  5. Запускает этот шаблон или сниппет и рендерит остатки.
  6. Добавляет письма для юзеров со свойствами рассылки и сгенерированным контентом

Вот коммит с новым обновлённым объектом sxNewsletter.

Процессор, который будет запускать добавление писем рассылки выглядит так:
public function process() {
	// Получаем newsletter_id
	if (!$id = $this->getProperty('newsletter_id')) {
		return $this->failure($this->modx->lexicon('sendex_newsletter_err_ns'));
	}
	elseif (!$newsletter = $this->modx->getObject('sxNewsletter', $id)) {
		return $this->failure($this->modx->lexicon('sendex_newsletter_err_nf'));
	}

	/** @var sxNewsletter $newsletter */
	// Запускаем нужный метод 
	$result = $newsletter->addQueues();
	// Если в ответ приходит не true - показываем ошибку
	if ($result !== true) {
		return $this->failure($result);
	}
	else {
		return $this->success();
	}
}
Весь процессор на GitHub.

Теперь добавляем новые методы в объект sxQueue: save и send. Save() расширяет метод xPDOObject() и проверяет письмо на уникальность, через генерацию хеша и сравнение с имеющимися — вот зачем нам нужно было новое поле в БД.
А метод send() берет нужные параметры из БД и осуществляет отправку.

Вот коммит с обоими методами. Теперь при сохранении письма всегда будут проверяться на уникальность и отправлять их можно вызовом метода send().

Например:
if ($queue = $modx->getObject('sxQueue', 12)) {
	$queue->send();
	$queue->remove();
}

Элементы ExtJS

Для добавления писем в очередь нам нужно выбрать рассылку, для которой они будут сгенерированы. Самый логичный элемент для такого выбора — комбобокс.

Добавляем новый в /assets/components/sendex/js/mgr/misc/sendex.combo.js
Sendex.combo.Newsletter = function(config) {
	config = config || {};
	Ext.applyIf(config,{
		name: 'user_id'
		,fieldLabel: _('sendex_newsletter')
		,hiddenName: config.name || 'user_id'
		,displayField: 'name'
		,valueField: 'id'
		,anchor: '99%'
		,fields: ['id','name']
		,pageSize: 20
		,url: Sendex.config.connector_url
		,editable: true
		,allowBlank: true
		,emptyText: _('sendex_select_newsletter')
		,baseParams: {
			action: 'mgr/newsletter/getlist'
			,combo: 1
		}
		,tpl: new Ext.XTemplate(''
			+'<tpl for="."><div class="sendex-list-item">'
			+'<span><small>({id})</small> {name}</span>'
			+'</div></tpl>',{
			compiled: true
		})
		,itemSelector: 'div.sendex-list-item'
	});
	Sendex.combo.Newsletter.superclass.constructor.call(this,config);
};
Ext.extend(Sendex.combo.Newsletter,MODx.combo.ComboBox);
Ext.reg('sendex-combo-newsletter',Sendex.combo.Newsletter);

Мы обращаемся к уже существующему процессору newsletter/getlist, который будет проверять параметр combo, и если он не пуст — добавлять кое-какие условия:
public function prepareQueryBeforeCount(xPDOQuery $c) {
	if ($query = $this->getProperty('query')) {
		$c->where(array(
			'name:LIKE' => "%$query%",
			'OR:description:LIKE' => "%$query%"
		));
	}
	if ($this->getProperty('combo')) {
		$c->where(array('active' => 1));
	}
	return $c;
}
То есть, выбираем только активные рассылки, и поддерживаем поиск по имени и описанию. Коммит с этими изменениями.

Получается вот так:


При выборе строки в комбобоксе запускается метод createQueues() таблицы:
,createQueues: function(combo, newsletter, e) {
	combo.reset();

	MODx.Ajax.request({
		url: Sendex.config.connector_url
		,params: {
			action: 'mgr/queue/add'
			,newsletter_id: newsletter.id
		}
		,listeners: {
			success: {fn:function® {this.refresh();},scope:this}
		}
	});
}

А тот запускает процессор queue/add и обновляет таблицу. В общем, ничего нового, вы всё это уже знаете и видели на предыдущих уроках.

Добавляем контекстное меню:
,getMenu: function() {
	var m = [];
		
	m.push({
		text: _('sendex_queue_send')
		,handler: this.sendQueue
	});
	m.push('-');
	m.push({
		text: _('sendex_queue_remove')
		,handler: this.removeQueue
	});
	this.addContextMenuItem(m);
}

И методы для него:
,sendQueue: function(btn,e,row) {
	if (!this.menu.record) return;

	MODx.Ajax.request({
		url: Sendex.config.connector_url
		,params: {
			action: 'mgr/queue/send'
			,id: this.menu.record.id
		}
		,listeners: {
			success: {fn:function® {this.refresh();},scope:this}
		}
	});
}

,removeQueue: function(btn,e,row) {
	if (!this.menu.record) return;

	MODx.msg.confirm({
		title: _('sendex_queue_remove')
		,text: _('sendex_queue_remove_confirm')
		,url: Sendex.config.connector_url
		,params: {
			action: 'mgr/queue/remove'
			,id: this.menu.record.id
		}
		,listeners: {
			success: {fn:function® {this.refresh();},scope:this}
		}
	});
}

Затем нужно добавить процессоры, к которым обращаются эти методы, и недостающие записи лексикона.
Вот итоговый коммит со всеми оставшимися изменениями.

Заключение

Вот мы и сделали таблицу с очередью писем.

Мы можем самостоятельно добавлять рассылки, прикреплять к ним юзеров, генерировать и отправлять письма. Пока что, всё это вручную, но автоматизировать имеющийся функционал будет несложно.

Нам осталось написать сниппеты для подписки и отписки пользователей на сайте. Ну и добавить отправку писем по расписанию в cron.

После этого, на мой взгляд, первая версия компонента Sendex будет готова, и его можно будет выпускать для бета-тестирования.

Следующая заметка
Сниппет Sendex и формы подписки\отписки
Предыдущая заметка
Пишем интерфейс: окно редактирования подписки


Комментарии ()

  1. Roman Smile 12 февраля 2014, 17:37 # 0
    Василий, вопрос на примере этого процессора:
    github.com/bezumkin/Sendex/blob/e1f6b17ac9975cc9d0433a4c74beea53d1dd4657/core/components/sendex/processors/mgr/queue/add.class.php

    Вопрос в том, как происходит такое волшебство, что мы в одно действие там получаем класс sxNewsletter вот так:
    $this->modx->getObject('sxNewsletter', $id)
    И затем простейшим образом используем его метод addQueues() вот так:
    $result = $newsletter->addQueues();
    Что нужно сделать, чтобы нам был доступен наш личный класс через getObject в каком-либо скрипте? За счет чего это происходит?
    1. Василий Наумкин 12 февраля 2014, 19:04 # 0
      Нет никакого волшебства, схематично это можно объяснить так:
      $newsletter = new sxNewsletter($modx); // создание экземпляра класса
      $newsletter->set('id', '1'); // присвоение ему свойств
      $result = $newsletter->addQueues(); // вызов метода
      
      То есть, modX::getObject() — это создание объекта и забивание в него данных из таблицы. А так как объект — это экземпляр обычного PHP класса, то мы можем вызывать его методы.

      Подробнее можно почитать здесь.

      Что нужно сделать, чтобы нам был доступен наш личный класс через getObject в каком-либо скрипте? За счет чего это происходит?
      Нужно описать схему XML, сгенерировать по ней модель и подключить через modX::addPackage(). Дальше можно получать свои объекты и вызывать из них методы.

      Я это, вроде бы, подробно в данном курсе и описываю.
      1. Roman Smile 12 февраля 2014, 20:29 # 0
        Не могу найти, где в нашем случае происходит addPackage() класса sxNewsletter?
        1. Василий Наумкин 12 февраля 2014, 20:56 # 0
          При инициализации основного класса Sendex.

          То есть, при вызове
          $modx->getService('Sendex', .....);
          1. Roman Smile 12 февраля 2014, 21:28 # 0
            Вот при вызове основного класса Sendex:
            github.com/bezumkin/Sendex/blob/master/core/components/sendex/model/sendex/sendex.class.php
            На строке 42 есть единственный addPackage:
            $this->modx->addPackage('sendex', $this->config['modelPath']);
            Это он? И здесь мы как бы сделали addPackage всей папки sendex со всеми классами в ней?
            1. Василий Наумкин 13 февраля 2014, 06:19 # 0
              Ага, это оно.
              Мы загрузили модель компонента, которая включает в себя все его классы, да.
              1. Roman Smile 27 февраля 2014, 19:01 # 0
                А если мы вызываем
                $this->modx->getObject('sxNewsletter', $id)
                лишь чтобы затем вызвать некий метод в классе
                $newsletter->addQueues();
                и это безотносительно какого-либо $id, то как вызывать getObject? Оказалось, что если передавать id, которого нет в таблице, то getObject вернет null и далее работать с методами нельзя. Вроде работает, если передать в параметры пустой массив. Или есть более подходящий вариант получить доступ к методам sxNewsletter из процессора?
                1. Василий Наумкин 27 февраля 2014, 19:26 # +1
                  $newsletter = new sxNewsletter($modx);
                  1. Roman Smile 10 марта 2014, 12:31 # 0
                    Выглядит логично, но не работает и в логах сервера пишет, что класс такой не найден. Однако все работает, если вызвать так:
                    $newsletter = getObject('sxNewsletter', array())
                    Вопрос в том, как все-таки правильно получать в процессорах свои классы?
                    1. Василий Наумкин 10 марта 2014, 12:41 # 0
                      Попробуй подумать, почему сервер пишет, что класса нет? Может быть, ты его не загрузил?

                      modX::getObject() загружает объекты самостоятельно, но ты то пытаешься сделать это вручную.

                      Значит, нужно подключить файл с классом sxNewsletter:
                      require_once MODX_CORE_PATH . 'components/sendex/model/sendex/sxnewsletter.class.php';
                      $newsletter = new sxNewsletter($modx);
                      
                      1. Roman Smile 10 марта 2014, 13:10 # 0
                        Это все понятно, как нужно подключать классы в php я знаю. Просто думал, что addPackage, который делается до этого перед всеми процессорами в modExtra, предполагает затем какой-то ловкий способ получения классов взамен обычного… Хотя, казалось бы, чего мудрить над 2-мя строчками кода.
                        1. Василий Наумкин 10 марта 2014, 14:40 # +2
                          addPackage добавляет модель дополнения в систему, но классы загружаются при первом обращении.

                          Кстати, мы забыли про самый банальный способ решения твоего вопроса:
                          $newsletter = $modx->newObject('sxNewsletter');
                          1. Roman Smile 10 марта 2014, 15:46 # 0
                            Кстати, мы забыли про самый банальный способ решения твоего вопроса:

                            $newsletter = $modx->newObject('sxNewsletter');
                            Да, вот это я и имел в виду, говоря о «каком-то ловком способе получения класса учитывая addPackage, который был до этого») Это работает, спасибо)
        2. Roman Smile 28 февраля 2014, 12:04 # 0
          Как вынести новую таблицу на новую вкладку понятно. А как сделать новую таблицу новым пунктом верхнего меню? Ну, чтобы в разделе Действия в админке было доступно создание своей менюхи с таким пунктом.

          Очевидный вариант — создание для каждого такого пункта своего нового Компонента… Есть другие варианты?
          1. Василий Наумкин 28 февраля 2014, 13:21 # 0
            1й вариант — зайти а настройки верхнего меню в админке и создать нам новые действия

            2й вариант — посмотреть в сборщике, как создаются пункты меню и добавить там новые действия.
          Добавление новых комментариев отключено.