Краткий курс

Материал из Eludia
Перейти к: навигация, поиск
Чартков принялся за дело, усадил оригинал, сообразил несколько все это в голове; провел по воздуху кистью, мысленно устанавливая пункты; прищурил несколько глаз, подался назад, взглянул издали — и в один час начал и кончил подмалевку. Довольный ею, он принялся уже писать, работа его завлекла.

Н. Гоголь, «Портрет».

Содержание

В этом разделе приведён пример разработки небольшого WEB-приложения с нуля и до работоспособной бета-версии. В отличие от большинства аналогичных руководств, мы не стали ограничивать себя распечаткой на экране фразы "Здравствуй, мир!". Здесь решается вполне реальная задача информатизации.

Постановка задачи

Рассмотрим небольшую (порядка 10 человек) организацию, сотрудники которой хотят упорядочить хранение рабочих документов, а также наладить контроль исполнения поручений, не слишком увлекаясь формализацией процесса и сохраняя максимальную гибкость.

Сформулируем нашу мысль более конкретно. Требуется информационная система, каждый пользователь которой смог бы формулировать поручения любому другому пользователю. Ход исполнения поручения должен фиксироваться в виде последовательности реплик, каждая из которых может сопровождаться файлом-приложением. Реплики должны переключать статус задач (принято к исполнению, контрвопрос, завершено и пр.) Требуется реализовать сквозной поиск (в том числе файлов) по всем репликам.

Исходный материал настоящей главы (программный код и снимки экранов) был собран в ходе реализации данного микро-ТЗ, причём автору было запрещено копировать какие-либо фрагменты текста из сторонних источников. В его распоряжении был только сервер с Eludia.pm под Debian GNU/Linux и рабочая станция со StEludio и PuTTY. Работа заняла 4 часа 20 минут.

Создание приложения

Зайдём на сервер по протоколу SSH с правами root'а и отдадим следующую команду:

001.gif

Мастер создания приложения задаёт нам несколько вопросов, основной из которых – наименование проекта (назовём его tasks). Далее автоматически создаётся база данных и директория с исходными текстами приложения-заготовки. Работа мастера заканчивается распечаткой следующего фрагмента файла конфигурации для Apache:

Listen 8000
<VirtualHost _default_:8000>
 Include "/var/projects/tasks/conf/httpd.conf"
</VirtualHost>

Нетрудно видеть, что это ссылка на httpd.conf, расположенный в директории приложения /var/projects/tasks/. Всё необходимое для первого запуска, в том числе параметры доступа к БД, – уже там. Остаётся только скопировать этот фрагмент в глобальный httpd.conf (при желании можно поменять 8000-й порт на какой-то другой или прописать виртуальный хост по доменному имени) и перезапустить Apache.

003.gif

Итак, наше приложение tasks загружено и работоспособно. Посмотрим, что нам покажет браузер. Обратившись на 8000-й порт, получим форму авторизации.

004.gif

Более подробно процесс создания приложения описан в отдельной главе («Создание нового приложения или его экземпляра»).

Глобальные параметры приложения

Первое, что следует поменять – это заголовок окна: не оставаться же ему навеки "типовым". Для этого запустим StEludio и откроем файл lib/Config.pm (здесь и до конца главы все пути приводятся относительно базового /var/projects/tasks/, заданного на этапе создания приложения). Мы увидим основное окно, пока пустое.

005.gif

Интерфейс StEludio – двухоконный. Первое окно соответствует коду callback-процедур, формирующих динамические страницы, второе – конфигурации (собственно lib/Config.pm) и модели данных. Переключение осуществляется клавишей F6. Нажмём эту клавишу.

006.gif

Перед нами – lib/Config.pm. Параметр page_title, копируемый в заголовок окна, хорошо виден невооружённым глазом. На приведённой иллюстрации он уже поправлен. Полный список параметров конфигурации приложения (Опции конфигурации приложения ($conf)).

Роли пользователей. Авторизация.

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

Итак, нам нужно, чтобы в системе были определены две роли. Для этого в окне конфигурации StEludio двойным щелчком в левом селекторе (или нажатием F8 и клавишами-стрелками) открываем описание таблицы roles (это файл lib/model/roles.pm) – там уже присутствует описание роли admin. Добавляем туда ещё одну строку: описание роли user.

009.gif

Аналогичным образом перейдём к описанию таблицы users и добавим туда запись, которая потребуется для первого входа в систему. Значение поля password – это OLD_PASSWORD('z'). Оно присутствует в первичном шаблоне приложения.

010.gif

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

012.gif

Как и следовало ожидить, в списке – одна запись, только что определённая нами. Соответствующее ФИО приведено и на верней навигационной панели: ведь мы зашли в систему под этим пользователем. Обратим внимание: мы ни разу не использовали никакой SQL-клиент. Описание схемы данных явным образом влияет на структуру и содержимое таблиц.

Экранная форма

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

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

013.gif

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

014.gif

Окинем критическим взглядом оба варианта формы. Нас устраивает почти всё, только хотелось бы, чтобы фамилия, имя и отчество шли в один столбик (не раздувая экран) и ширина всех полей была бы одинаковой. Кроме того, отдельные поля "Фамилия", "Имя" и "Отчество" не имеют смысла на форме просмотра, а слитное "ФИО" – на форме редактирования. Для этого нам понадобится отыскать фрагмент кода, отвечающий за формирование текущего экрана.

Рассмотрим URL текущей страницы. Параметр запроса type имеет значение users, а id – непустое значение 1, следовательно, экран формируется процедурой draw_item_of_users, определённой в файле lib/Presentation/users.pm. Переместиться к редактированию нужного фрагмента кода в StEludio быстрее всего так: клавишей F6 переключиться на основное окно (если вы всё ещё находились в окне конфигурации), после чего в селекторе типов (F8) выбрать users и нажать Alt-M (мнемоника: draw_iteM).

Вместо последнего клавиатурного ускорителя можно воспользоваться радиокнопкой на верхней панели – там приведены 6 стандартных типов callback-процедур Eludia. При начальном ознакомлении удобно пользоваться этим переключателем, однако для эффективной и насыщенной работы клавиатура подходит гораздо лучше.

015.gif

Так или иначе, перед нами открыт исходный текст формы редактирования пользователя и ничто не мешает внести намеченную правку. Убираем квадратные скобки вокруг описаний полей "Фамилия", "Имя" и "Отчество" (они разгруппируются), прописываем везде параметр size (размер поля ввода), периодически нажимая в браузере F5 и оценивая эстетичность на каждом шаге. А для того, чтобы скрывать те или иные поля в режиме показа или редактирования, ставим для них значение параметра off (невидимость элемента интерфейса) в зависимость от значения $_REQUEST {__read_only}. Вообще %_REQUEST – это в Eludia.pm, как и в PHP, хэш параметров запроса. Некоторые параметры зарезервированы. В итоге набор описаний полей принимает следующий вид:

{
 name  => 'f',
 label => 'Фамилия',
 size  => 40,
 off   => $_REQUEST {__read_only},
},
{
 name  => 'i',
 label => 'Имя',
 size  => 40,
 off   => $_REQUEST {__read_only},
},
{
 name  => 'o',
 label => 'Отчество',
 size  => 40,
 off   => $_REQUEST {__read_only},
},
{
 name  => 'label',
 label => 'ФИО',
 type  => 'static',
 off   => !$_REQUEST {__read_only},
 size  => 40,				
},
{
 name  => 'login',
 label => 'login',
 size  => 40,				
},
{
 name  => 'password',
 label => 'пароль',
 type  => 'password',
 size  => 53,				
},
{
 name   => 'id_role',
 label  => 'Роль',
 type   => 'radio',
 values => $data -> {roles},
},

и отображается в браузере следующим образом:

016.gif

017.gif

Проверка и запись данных

Хорошо, форма выглядит так, как нам нравится. Но что будет происходить в введёнными в неё данными? Прежде всего, это должна быть проверка и предварительная обработка. В сколько-нибудь реальной информационной системе практически на каждое поле каждой формы (за исключением checkbox'ов) должна стоять проверка по крайней мере одного условия допустимости.

Посмотрим, что проверяется при записи карточки пользователя в БД. Для этого, не меняя состояния селектора типов (users), нажмём Ctrl-Alt-U и переместимся в файл lib/Content/users.pm к процедуре validate_update_users. Как мы увидим, по умолчанию там проверяется только уникальность.

018.gif

Потребуем того, чтобы фамилия, имя и отчество соответствовали нормам русского языка (с использованием регулярных выражений Perl это сущий пустяк), а поле login было бы непусто (напоминаем, что в нашем приложении карточки пользователей редактирует только администратор, а пользователи без прав доступа смысла не имеют). Получим следующий код для процедуры валидации:

sub validate_update_users {

 $_REQUEST {_f} =~ /^[А-ЯЁ][а-яё]+$/     or return "#_f#:Некорректная фамилия";
 $_REQUEST {_i} =~ /^[А-ЯЁ][а-яё]+$/     or return "#_i#:Некорректное имя";
 $_REQUEST {_o} =~ /^[А-ЯЁ][а-яё]*[ач]$/ or return "#_o#:Некорректное отчество";

 $_REQUEST {_label} = "$_REQUEST{_f} $_REQUEST{_i} $_REQUEST{_o}";
	
 $_REQUEST {_login}   or return "#_login#:Вы забыли указать login";
 $_REQUEST {_id_role} or return "#_id_role#:Вы забыли указать роль";

 vld_unique ('users', {field => 'login'}) or return "#_login#:Login '$_REQUEST{_login}' уже занят";
	
 return undef;
 	
}

Проверим, как работает:

020.gif

Отметим, что при выдаче сообщения страница не перерисовывается, соответственно, все значения остаются на местах, а курсор устанавливается как раз на проблемное поле. AJAX? Ну да, можно и так сказать.

На всякий случай окинем взглядом процедуру записи данных do_update_users (Alt-U). Тут всё так, как и должно быть. Обращаем внимание на то, что отдельную обработку пароля: он должен перекодироваться внутренней MySQL-функцией и храниться в зашифрованном виде – на это требуется специальный DML-запрос. Остальные же поля можно записать гораздо проще: для этого есть функция sql_do_update.

sub do_update_users {
	
 sql_do_update ('users', [qw(f i o label login id_role)]);

 $_REQUEST {_password} and sql_do ("UPDATE users SET password=PASSWORD(?) WHERE id=?", $_REQUEST {_password}, $_REQUEST {id});

}

Теперь вернёмся к списку пользователей (Esc) и добавим новую карточку (Ctrl-Д – буква "Д" на соответствующей кнопке подчёркнута). Попробуем ввести данные второго, непривилегированного пользователя. На всякий случай попытаемся дать ему тот же login. Конфликт обнаружен, всё в порядке.

023.gif

Удаление и восстановление

Второй пользователь успешно введён. На его форме присутствует кнопка "Удалить" (Ctrl-Del). Что произойдёт, если нажать на неё? За это отвечает процедура do_delete_users (Alt-D). В шаблонном приложении при этом действии используется функция sql_do_delete, которая физически стирает запись из требуемой таблицы. Это "жёсткое" удаление.

Допустим, мы хотим реализовать "мягкое" удаление пользователей, при котором всегда есть возможность восстановить запись («Мягкое удаление»). Для этого полностью сотрём do_delete_users и снова нажмём (Alt-D). Не найдя процедуры, StEludio предложит нам создать её по собственному шаблону – а там записано как раз то, что нам сейчас требуется.

sub do_delete_users {

 sql_do ("UPDATE users SET fake = -1 WHERE id = ?", $_REQUEST {id});

 delete $_REQUEST {id};

}

Мягкое удаление сводится к записи -1 в поле fake, которое автоматически создаётся во всех таблицах вашего приложения («Поле fake. Недосозданные (фиктивные) записи.»). А восстановление (действие undelete), соответственно, к записи 0 в то же поле. Копируем и чуть-чуть правим код:

sub do_undelete_users {

 sql_do ("UPDATE users SET fake = 0 WHERE id = ?", $_REQUEST {id});

 delete $_REQUEST {id};

}

Значение поля fake должно учитываться в каждом запросе на выборку. Посмотрим, как выглядит соответствующий фильтр в процедуре, достающей данные для экрана со списком пользователей select_users (Alt-S):

my ($users, $cnt)= sql_select_all_cnt (<<EOS, $q, $q);
 SELECT
  users.*
  , roles.label AS role_label
 FROM
  users
  LEFT JOIN roles ON users.id_role = roles.id
 WHERE
  (users.label LIKE ? or users.login LIKE ?)
  and users.fake = 0
 ORDER BY
  users.label
 LIMIT
  $start, $$conf{portion}
EOS

Фильтр есть и работает, но так мы никогда не увидим стёртых записей, которые требуется восстановить. Нам требуется, чтобы по умолчанию отбирались только записи с fake = 0 (актуальные), но при необходимости условие можно было бы заменить на fake = -1 (удалённые) или fake IN (0,-1) (все). Нужный нам фрагмент кода будет подставляться в SQL автоматически, как только мы укажем имя фильтруемой таблицы в специальной опции процедуры sql_select_all_cnt.

my ($users, $cnt)= sql_select_all_cnt (<<EOS, $q, $q, {fake => 'users'});
 SELECT
  users.*
  , roles.label AS role_label
 FROM
  users
  LEFT JOIN roles ON users.id_role = roles.id
 WHERE
  (users.label LIKE ? or users.login LIKE ?)
 ORDER BY
  users.label
 LIMIT
  $start, $$conf{portion}
EOS

Динамический фильтр генерируется в зависимости от значения параметра запроса $_REQUEST {fake}. Чтобы управлять значением этого параметра, нужно добавить соответствующий переключатель fake_select () на панель над таблицей пользователей. Она описывается в draw_users (Alt-W):

top_toolbar => [
	
 {keep_params => []},
		
 {
  icon => 'create',
  label => '&Добавить',
  href => "?type=users&action=create",
 },
	
 {
  type   => 'input_text',
  label  => 'Искать',
  name   => 'q',
 },
		
 {
  type    => 'pager',
  cnt     => 0 + @{$data -> {users}},
  total   => $data -> {cnt},
  portion => $data -> {portion},
 },
		
 fake_select (),
		
],

Теперь посмотрим, как это всё работает. Удалим последнюю карточку пользователя, вернёмся к списку, убедимся, что переключатель виден и установим его в положение "Все", заодно проверив поиск по тексту. Зачёркнутая строка – перед нами.

032.gif

Снова заходим на форму, убеждаемся, что кнопка "удалить" заменилась на "восстановить" (за это отвечает функция del, использованная при формировании right_buttons в draw_item_of_users) и возвращаем карточку в актуальное состояние. Попробуем теперь войтив систему под новым пользователем.

Главное меню

Авторизация прошла успешно, но экран девственно чист.

033.gif

Ничего не сломалось, всё так и должно быть, ведь мы действуем от имени пользователя с ролью, для которой не определено главное меню. В шаблонном приложении меню задано только для администратора. Обратимся к тексту select_menu (выбрать тип menu, нажать Alt-S).

################################################################################

sub select_menu_for_admin {

 return [
  {
   name  => 'users',
   label => '&Пользователи',
  },
  {
   name  => 'log',
   label => 'Пр&отокол',
  },		
  {
   name  => '_info',
   label => '&Версии',
  },
 ];

}

################################################################################

sub select_menu {
 return [];
}

Сгуппируем все имеющиеся пункты в раздел "Администрирование" и добавим к нему "Поручения", заодно прикинув, какие типы экранов нам понадобятся. Сделаем так, чтобы процедура select_menu была общей для всех пользователей, а роль учитывалась в виде условия для параметра off: это позволит избежать дублирования подменю поручений:

sub select_menu {

 return [
	
  {
   label   => 'Поручения',
   name    => 'tasks',
   items   => [

    {
     name  => 'task_notes',
     label => 'Переписка',
    },
				
    BREAK,
				
    {
     name  => 'task_topics',
     label => 'Темы',
    },

   ],
  },
		
  {
   label   => 'Администрирование',
   no_page => 1,
   off     => $_USER -> {role} ne 'admin',
   items   => [

    {
     name  => 'users',
     label => 'Пользователи',
    },
    {
     name  => 'log',
     label => 'Протокол',
    },		
    {
     name  => '_info',
     label => 'Версии',
    },

   ],
  },
	
 ];
	
}

Проверим, как теперь выглядит интерфейс администратора...

035.gif

... и пользователя:

036.gif

Простой справочник (темы поручений)

В нашем микро-ТЗ упоминалось, что каждое поручение должно относиться к определённой теме. Список тем, естественно, на этапе разработки фиксировать нельзя, так что необходимо разработать редактор соответствующего справочника. В главном меню мы запланировали под него тип экрана task_topics.

Это должен быть простой справочник, состоящий из записей, каждая из которых содержит лишь одно поле, и поле это имеет тип VARCHAR (255). В больших информационных системах такого рода элементарные справочники составляют около половины всех таблиц в БД или половины всех типов экранов. Несмотря на схожесть, их не удаётся свести к одному, так как в почти в каждом случае возникают специфические проверки, кое-где добавляются скалярные поля, а то и связи в другими таблицами. В конце концов периферийный справочник вполне может превратиться в цетральную таблицу фактов – этого никогда не предугадаешь. Но пока мы делаем простой скалярный справочник.

Снова перейдём к окну конфигурации (F6) и создадим новую таблицу (Ctrl-N).

041.gif

Описание таблицы будет создано по шаблону. Там, в закомментаренном виде, присутствуют самые популярные имена и типы полей. Заметим, кстати, что в Eludia.pm существует совершенно определённая система именования объектов («Соглашеня об именовании», Приложение A, Словарь для идентификаторов), соблюдение которой прежде всего удобно разработчику, так как даёт ему ключ к использованию многочисленных умолчаний в функциях API. В частности, строка, показываемая в списке объектов как основной (или единственный) заголовок, должна называться label (именно поэтому в таблице пользователей она определена и синтезируется из ФИО). Так что в нашем случае мы раскомментариваем описание label, остальные строки стираем, сохраняем файл (Ctrl-S) и идём дальше.

columns => {
 label   => {TYPE_NAME => 'varchar', COLUMN_SIZE => 255},
},

Возвращаемся к окну процедур (F6), устанавливаем переключатель callback-имён на select (Alt-S) и создадаём новый тип экрана (Ctrl-N). Последовательно подтверждаем создание типа, файла и, наконец, процедуры select_task_topics. Получаем код, сгенерированный по шаблону:

044.gif

На 95% это уже то, что требуется. Его только надо зачистить от лишних, закомментированных, строк – и готово. Далее нажимем Ctrl-W, совершенно аналогичным образом генерируем и потом зачищаем процедуру draw_task_topics. Там нужно проставить заготовок экрана ("Темы поручений") вместо многоточия, убрать описание bottom_toolbar и оставить в вызове draw_cells одно только поле label. Теперь можно кликнуть в меню на "Темы поручений" – покажется таблица-список.

045.gif

Рискуем показаться назойливыми, но снова обратим внимание на то, что SQL-запрос успешно исполняется (можно проверить по логам), хотя мы не создавали таблицу DDL-запросом, а лишь привели её описание. Если запустить из любопытства SQL-консоль, то легко убедиться, что помимо требуемого label таблица содержит обязательные fake и id (первичный ключ). См. «Описание модели данных».

Теперь самое время заняться созданием новой записи. По идее следовало бы нажать Alt-C и создать процедуру do_create_task_topics, однако это вовсе не обязательно. Если эта процедура останется неопределённой, вместо неё будет вызвана do_create_DEFAULT (см. Приложение E, Callback-функции и порядок их вызова), которая присутствует в ядре и делает как раз всё необходимое для создания записи без вычисляемых начальных значений.

Так что, пропустив этап создания, переходим к отображению записи. Генерируем get_item_of_task_topics (Alt-G), прописываем заголовок экрана "Темы поручений" на место многоточия (это соответствует "пути" вверху формы), убираем закомментированные строки – здесь всё.

Далее – отображение формы: draw_item_of_task_topics (Alt-M). Здесь сгенерированный код даже не надо править, разве только подрегулировать размер поля ввода. Смотрим в браузер, нажимаем "Добавить" (Ctrl-Д).

046.gif

Как и в прошлом разделе, увидев текстовое поле, обязательно задумываемся о множестве допустимых значений. Генерируем validate_update_task_topics (Ctrl-Alt-U). Там уже содержится тест на непустоту строки – как раз то, что надо. Отметим по ходу дела, что лидирующие и концевые пробельные символы счищаются автоматически.

047.gif

Если проверка данных – пусть часто дублирующаяся, но всё-таки индивидуальная функция, то запись значений скалярных параметров в одноимённые поля таблицы – дело совершенно автоматическое. Это раз и навсегда реализовано в do_update_DEFAULT. Так что вводим строку в поле и нажимаем Ctrl-Enter. Вот мы и ввели нашу первую запись в новый справочник.

048.gif

А больше здесь делать нечего. Совсем. Всё, что связано с удалением и восстановлением, осуществляется процедурами do_delete_DEFAULT и do_undelete_DEFAULT (если вы заметили, переключатель видов записей на экране-таблице уже присутствовал). Ещё один полезный механизм, который будет функционировать для нашего справочника без малейших усилий со стороны программиста – корректная работа с дубликатами («Слияние и перенос ссылок»).

Итак, максимум 10 минут – и редактор готов.

Основная логика системы

В прошлом разделе мы изучили, как создать с нуля элементарный справочник, используя максимум умолчаний. Двумя разделами выше – как модифицировать и отлаживать унаследованную логику для нестандартного типа экрана. Сейчас, когда, с программной точки зрения, начинается основная работа (реализация бизнес-процесса, связанного с поручениями), мы осветили уже все основные приёмы, которые могут при этом понадобиться. Новыми будут только некоторые детали. Поэтому мы позволим себе не описывать каждое переключение между экранами и обновление страницы.

Модель данных

Прежде всего, определимся с таблицами. Нам понадобится ещё один справочник (на этот раз неизменяемый: статусы поручений), а также 2 таблицы фактов: собственно поручения и связанные с ними реплики. Соотношение между поручениями и репликами должно быть примерно как между темами и комментариями в WEB-форумах.

Итак, статусы:

columns => {
 icon    => {TYPE_NAME => 'int'},
 label   => {TYPE_NAME => 'varchar', COLUMN_SIZE => 255},
},

data => [
 {id => 1, fake => 0, label => 'Новое',       icon => 100},
 {id => 2, fake => 0, label => 'В работе',    icon => 101},
 {id => 3, fake => 0, label => 'На проверку', icon => 200},
 {id => 4, fake => 0, label => 'Вопрос',      icon => 201},
 {id => 5, fake => 0, label => 'Отказ',       icon => 202},
 {id => 6, fake => 0, label => 'Сделано',     icon => 300},
 {id => 7, fake => 0, label => 'Отменено',    icon => 301},
],

Значения поля icon соответствуют кодам стандартных статусных иконок Eludia.pm. Перейдём к таблице поручений (tasks).

columns => {
 id_user_from   => {TYPE_NAME => 'int', ref => 'users'},
 id_user_to     => {TYPE_NAME => 'int', ref => 'users'},
 id_task_topic  => {TYPE_NAME => 'int'},
 id_task_status => {TYPE_NAME => 'int'},
 is_closed      => {TYPE_NAME => 'tinyint', COLUMN_DEF => 0, NULLABLE => 0},
 label          => {TYPE_NAME => 'varchar', COLUMN_SIZE => 255},
},

keys => {
 id_user_from   => 'id_user_from,is_closed',
 id_user_to     => 'id_user_to,is_closed',
},

Отметим, что целевая таблица для поля-ссылки либо прямо вычисляется по его имени, либо указана в опции ref. Поле is_closed является функцией id_task_status и, таким образом, является избыточным. Однако места оно занимает немного, а поиск текущих (незакрытых) поручений вполне может ускорить. Для этого, конечно, мы включили его в описание индексов.

Теперь – реплики (task_notes). Их модель несколько богаче поручений в целом: туда входят метки времени, комментарии и ссылки на файлы:

columns => {

 id_task        => {TYPE_NAME => 'int'},

 id_user_from   => {TYPE_NAME => 'int', ref => 'users'},
 id_user_to     => {TYPE_NAME => 'int', ref => 'users'},

 id_task_status => {TYPE_NAME => 'int', ref => 'users'},

 dt             => {TYPE_NAME => 'datetime'},

 label          => {TYPE_NAME => 'varchar', COLUMN_SIZE => 255},
 body           => {TYPE_NAME => 'text'},

 file_name      => {TYPE_NAME    => 'varchar', COLUMN_SIZE  => 255},
 file_type      => {TYPE_NAME    => 'varchar', COLUMN_SIZE  => 255},
 file_path      => {TYPE_NAME    => 'varchar', COLUMN_SIZE  => 255},
 file_size      => {TYPE_NAME    => 'int'},

},

keys => {
 id_task        => 'id_task,id',
 id_user_from   => 'id_user_from,id',
 id_user_to     => 'id_user_to,id',
},

Поля id_user_from и id_user_to реплик не дублируют соответствующие поля поручений: поскольку предполагается диалог, они могут меняться местами.

Выборка списка поручений (select)

Как и в предыдущем разделе, после описания модели переходим к экрану-списку (ведь это единственное, что можно корректно отобразить при пустых таблицах). Начинаем с генерации кода по шаблону, а дальше дорабатываем SQL-запрос, внося туда связанные таблицы и дополнительные фильтры.

sub select_tasks {
 
 my $item = {
  portion => $conf -> {portion},
 };
 
 add_vocabularies ($item, 'users', 'task_topics');
 
 my $filter = ;
 my @params = ();
 
 if ($_REQUEST {q}) {  
  $filter .= ' AND tasks.label LIKE ?';
  push @params, '%' . $_REQUEST {q} . '%';  
 }

 if ($_REQUEST {id_task_topic}) {  
  $filter .= ' AND tasks.id_task_topic = ?';
  push @params, $_REQUEST {id_task_topic};  
 }

 exists $_REQUEST {id_user} or $_REQUEST {id_user} = $_USER -> {id};

 if ($_REQUEST {id_user}) {
 
  $filter .= $_REQUEST {from} ?
   ' AND tasks.id_user_from = ?' :
   ' AND tasks.id_user_to = ?';
   
  push @params, $_REQUEST {id_user};
  
 }

 my $start = $_REQUEST {start} + 0;

 ($item -> {tasks}, $item -> {cnt}) = sql_select_all_cnt (<<EOS, @params, {fake => 'tasks'});
  SELECT
   tasks.*
   , task_status.icon
   , users_from.label AS users_from_label
   , users_to.label AS users_to_label
  FROM
   tasks
   LEFT JOIN task_status ON tasks.id_task_status = task_status.id
   LEFT JOIN users AS users_from ON tasks.id_user_from = users_from.id
   LEFT JOIN users AS users_to   ON tasks.id_user_to   = users_to.id
  WHERE
   tasks.is_closed = 0
   $filter
  ORDER BY
   tasks.id DESC
  LIMIT
   $start, $$item{portion}
EOS

 return $item;

}

Фильтр по теме ($_REQUEST {id_task_topic}) копируется с шаблонного фильтра по тексту ($_REQUEST {q}), потом для него менняется условие с LIKE на = и убираются спецсимволы %. Фильтр по пользователю ($_REQUEST {id_user}) добавляется аналогично, только для него вводится ветвление в зависимости от нового параметра $_REQUEST {from}, который определяет, кого мы указали в запоре: автора (непустое значение) или исполнителя (пустое, по умолчанию).

Значение $_REQUEST {id_user} может быть нулевым – это соответствует поиску поручений по всем авторам и исполнителям. Однако естественно, чтобы при первом заходе на странице отображался список для текущего пользователя как исполнителя ("Что мне велели"). Поэтому если до формирования фильтра $_REQUEST {id_user} не определён, то он приравнивается к первичному ключу текущего пользователя: $_USER -> {id}.

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

Отображение списка поручений (draw)

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

sub draw_tasks {

 my ($data) = @_;

 return

  draw_table (

   [
    '№',
    'Формулировка',
    'Кто кому',
   ],

   sub {

    draw_cells ({
     href => "/?type=tasks&id=$$i{id}",
    },[
 
     {
      label  => $i -> {id},
      status => {icon => $i -> {icon}},
     },
     
     $i -> {label},

     {
      label => "$i->{users_from_label} → $i->{users_to_label}",
      max_len => 1000,
     },

    ])

   },

   $data -> {tasks},

   {
    title => {label => 'Поручения'},

    top_toolbar => [{
      keep_params => ['type', 'select'],
     },
     {
      icon  => 'create',
      label => '&Добавить',
      href  => '?type=tasks&action=create',
     },

     {
      type  => 'input_text',
      label => 'Искать',
      name  => 'q',
      keep_params => [],
     },

     {
      type    => 'pager',
      cnt     => 0 + @{$data -> {tasks}},
      total   => $data -> {cnt},
      portion => $data -> {portion},
     },
     {
      type        => 'break',
      break_table => 1,
     },

     {
      type   => 'input_select',
      name   => 'from',
      values => [
       {id => 1, label => 'Автор'},
      ],
      empty  => 'Исполнитель',
     },
     
     {
      type   => 'input_select',
      name   => 'id_user',
      values => $data -> {users},
      empty  => '[Не важно]',
     },

     {
      type   => 'input_select',
      name   => 'id_task_topic',
      values => $data -> {task_topics},
      empty  => '[Все темы]',
     },


    ],
    
   }

  );

}

Первым делом, как всегда, прописываем заголовок экрана на место многоточия. Далее уточняем список отображаемых полей (параметры вызова draw_cells). Заметим, что там наряду со строками могут встречаться и наборы опций: в таком случае текстовое содержимое берётся из опции label («draw_cells»).

Имея перед глазами список полей, поднимаемся чуть выше и добавляем заголовок таблицы: список строк. Некоторые из них могут превратиться в наборы опций («draw_table_header»), но здесь это не требуется, а строки читаются куда проще.

Осталось только добавить в список top_toolbar описания для всех вышеописанных фильтров.

Создание поручения (do_create)

Кнопку "Добавить" на верхней панели оставляем нетронутой, она нас вполне устраивает. А вот обработчик для создания новой записи, в отличие от примитивного справочника, придётся написать самостоятельно (впрочем, это дело секунд на 10): нам нужно, чтобы автор поручения проставлялся автоматически.

sub do_create_tasks {

 $_REQUEST {id} = sql_do_insert ('tasks', {
  id_user_from   => $_USER -> {id},
  id_task_status => 1,
 });

}

Выборка поручения (get_item_of)

Созданную запись надо уметь излечь из БД вместе со всем контекстом, необходимым для последующего отображения. В свежесгенерированной процедуре get_item_of_tasks прежде всего уточняем, какие словари доставать процедурой add_vocabularies.

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

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

Соответственно, форма поручения должна находится в режиме просмотра и редактирования в зависимости от статуса записи, а также от того, кто на неё смотрит: автор, исполнитель или посторонний. Логика принятия данного решения записывается 6 строками, вычисляющими $_REQUEST {__read_only}.

Аналогичным образом определяется и множество действий, которые текущий пользователь способен совершить с данным поручением. Каковое можно представить как набор статусов, видимых на форме редактирования. Матрица переходов реализована как вычисление словаря $item -> {task_status}.

sub get_item_of_tasks {

 my $item = sql_select_hash ('tasks');
 
 $item -> {task_status} = sql_select_hash ('task_status', $item -> {id_task_status});
 
 if ($item -> {task_status} -> {icon} =~ /^2/ || $item -> {fake}) {
  $_REQUEST {__read_only} = $item -> {id_user_from} != $_USER -> {id};
 }
 else {
  $_REQUEST {__read_only} = $item -> {id_user_to}   != $_USER -> {id};
 }
  
 if ($item -> {fake} > 0) {
 
  $item -> {task_status} = [
   {id => 100, label => 'Сформулировать поручение'},
  ],

 }
 elsif ($item -> {task_status} -> {icon} == 100) {
 
  $item -> {task_status} = [
   {id => 101, label => 'Принять'},
   {id => 200, label => 'Сообщить о готовности'},
   {id => 201, label => 'Задать вопрос'},
   {id => 202, label => 'Задать отказаться'},
  ],

 }
 elsif ($item -> {task_status} -> {icon} == 101) {
 
  $item -> {task_status} = [
   {id => 101, label => 'Оставить в работе'},
   {id => 200, label => 'Сообщить о готовности'},
   {id => 201, label => 'Задать вопрос'},
   {id => 202, label => 'Задать отказаться'},
  ],

 }
 elsif ($item -> {task_status} -> {icon} == 200) {
 
  $item -> {task_status} = [
   {id => 300, label => 'Принять'},
   {id => 101, label => 'Вернуть в работу'},
  ],

 }
 elsif ($item -> {task_status} -> {icon} == 201) {
 
  $item -> {task_status} = [
   {id => 101, label => 'Ответить и вернуть в работу'},
   {id => 301, label => 'Отменить'},
  ],

 }
 elsif ($item -> {task_status} -> {icon} == 202) {
 
  $item -> {task_status} = [
   {id => 101, label => 'Вернуть в работу'},
   {id => 301, label => 'Отменить'},
  ],

 }
 elsif ($item -> {task_status} -> {icon} >= 300) {
 
  $item -> {task_status} = [
   {id => 101, label => 'Вернуть в работу'},
  ],

 }
 
 if (@{$item -> {task_status}} == 1) {
  $item -> {status} = $item -> {task_status} -> [0] -> {id};
 }


 add_vocabularies ($item, 'users', 'task_topics');

 $item -> {path} = [
  {type => 'tasks', name => 'Поручения'},
  {type => 'tasks', name => $item -> {label}, id => $item -> {id}},
 ];

 $item -> {task_notes} = sql_select_all (<<EOS, $item -> {id}, {fake => 'task_notes'});
  SELECT
   task_notes.*
   , users.label AS user_label
   , task_status.icon
  FROM
   task_notes
   LEFT JOIN users       ON task_notes.id_user_from   = users.id
   LEFT JOIN task_status ON task_notes.id_task_status = task_status.id
  WHERE
   task_notes.id_task = ?
  ORDER BY
   task_notes.id
EOS

 return $item;

}

Отображение поручения (draw_item_of)

Данные готовы – можно их отобразить. Снова генериуем шаблонный код и начинаем приспосабливать его к специфическим потребностям текущего экрана.

Сначала расставляем описания скалярных полей в соответствии с моделью task_notes (ведь основная информация будет писаться туда). Поле id, однако, соответствует записи tasks – это будет наш "номер документа".

Обратим внимание на опцию other для поля id_task_topic. Она обеспечивает появление в списке специальной строки: "справочник...", которая позволяет использовать редактор тем поручений, не покидая текущей формы ввода.

Опция add_hidden используется для того, чтобы при записи данных селектор присылал значение нужного параметра, даже будучи отображённым как строка (когда read_only = 1). Это позволяет заметно упростить код процедур-обработчиков для действия update, хотя и открывает некоторый простор для действий URL-хакеров. Но в данном случае мы чётко осознаём, что делаем систему для собственного употребления и с максимальной скоростью. При других условиях этот угол срезать не следует.

Причесав форму, переходим к таблице. Здесь ход рассуждений почти такой же, как для экрана-списка. Отметим только, что вместо одной callback-функции мы передаём draw_table список из двух процедур. В результате каждой строке выборки может соответствовать 2 строки экранной таблицы: одна для заголовка, другая – для длинного комментария.

sub draw_item_of_tasks {

 my ($data) = @_;
 
 my @option = $_REQUEST {__read_only} ? (bottom_toolbar => ) : ();

 draw_form ({
   @option
  },
  
  $data,
  
  [
  
   [
    {
     name      => 'id_task_topic',
     label     => 'Тема',
     type      => 'select',
     values    => $data -> {task_topics},
     read_only  => $data -> {id_task_topic},
     empty      => '[Выберите тему]',
     add_hidden => 1,
     other     => '/?type=task_topics',
    },
    
    {
     name      => 'id',
     label     => '№',
     type      => 'static',
    },

   ],
   [
    
    {
     name       => 'id_user_from',
     label      => 'Автор',
     type       => 'select',
     values     => $data -> {users},
     read_only  => 1,    
     add_hidden => 1,
    },
    {
     name       => 'id_user_to',
     label      => 'Исполнитель',
     type       => 'select',
     values     => $data -> {users},
     read_only  => $data -> {id_user_to},
     empty      => '[Выберите адресата]',
     add_hidden => 1,
    },

   ],

   {
    name       => 'status',
    label      => 'Действие',
    type       => 'radio',
    values     => $data -> {task_status},
    off        => $_REQUEST {__read_only},
   },

   {
    name  => 'label',
    label => 'Формулировка',
    size  => 80,
    value => $_REQUEST {__read_only} || $data -> {fake} ? $data -> {label} : ' ',
   },
   {
    name  => 'body',
    label => 'Подробнее',
    type  => 'text',
    rows  => 3,
    cols  => 82,    
   },
   {
    name  => 'file',
    type  => 'file',
    label => 'Файл',
    size  => 87,    
   },
   
   
  ],
 )

 .

 draw_table (
  
  [

   sub {
    
    __d ($i, 'dt');
   
    draw_cells ({
 
    },[    
            {
             label  => $i -> {dt},
             status => {icon => $i -> {icon}},
            },
     $i -> {user_label},
     $i -> {label},
     { 
      label => $i -> {file_name},
      href  => "/?type=task_notes&id=$$i{id}&action=download",
      target => 'invisible',
     },
     
    ]),
   
   },
   
   sub {
   
    $i -> {body} or return undef;    
   
    draw_cells ({
 
    },[    
    
     { 
      label  => $i -> {body},
      max_len => 1000,
      no_nobr => 1,
      colspan => 4,
     },
     
    ]),
   
   },
  
  ],

  $data -> {task_notes},
  
  {
   
   title => {label => 'История', height => 1},
   
   off   => @{$data -> {task_notes}} == 0,
   
   name  => 't1',   
 
  }

 );

}

Проверка данных (validate_update)

При виде незаполненных полей ввода должен срабатывать рефлекс: немедленно нажать Ctrl-Alt-U и перейти к отладке процедуры-валидатора. Чем мы сейчас и займёмся.

В данном случае дело сводится в основном к проверке нескольких полей на непустоту. Помимо этого, именно здесь, в валидаторе, мы уточняем значения некоторых параметров запроса.

sub validate_update_tasks {

 $_REQUEST {_id_task_topic}  or return "#_id_task_topic#:Вы забыли выбрать тему";
 $_REQUEST {_id_user_to}     or return "#_id_user_to#:Вы забыли выбрать исполнителя";
 $_REQUEST {_status}         or return "#_status#:Вы забыли выбрать действие";
 
 $_REQUEST {_is_closed} = $_REQUEST {_status} >= 300 ? 1 : 0;
 
 $_REQUEST {_id_task_status} = sql_select_scalar ('SELECT id FROM task_status WHERE icon = ?', $_REQUEST {_status});

 $_REQUEST {_label} or return "#_label#:Вы забыли вести реплику";
 
 return undef;

}

Запись данных (do_update)

Поскольку нам предстоит не просто копировать параметры запроса в одноимённые поля единственной таблицы, воспользоваться do_update_DEFAULT нет никаких шансов. Ну да не беда. Как всегда, начинаем с использования шаблона, а потом записываем в тело процедуры свои мысли: записать данные в tasks (sql_do_update); разобраться с тем, кто у нового сообщения автор, а кто адресат (@ids); попробовать получить загруженный файл (upload_file) и, наконец, добавить реплику в task_notes (sql_do_insert).

 my $item = sql_select_hash ('tasks');

 if ($item -> {fake}) {
  sql_do_update ('tasks', [qw(id_user_to id_task_topic id_task_status label)]);
 }
 else {
  sql_do_update ('tasks', [qw(id_task_status is_closed)]);
 }
 
 my @ids = ($_REQUEST {_id_user_from}, $_REQUEST {_id_user_to});
 
 if ($_REQUEST {_id_user_from} != $_USER -> {id}) {
  @ids = reverse @ids;
 }

 my $file = upload_file ({
  name             => 'file',
  dir   => 'upload/images'
 });
 
 my $id = sql_do_insert ('task_notes', {
  
  fake           => 0,

  id_task        => $item -> {id},
 
  id_user_from   => $ids [0],
  id_user_to     => $ids [1],
 
  id_task_status => $_REQUEST {_id_task_status},
 
  dt             => {TYPE_NAME => 'datetime'},
 
  label          => $_REQUEST {_label},
  body           => $_REQUEST {_body},
 
  file_name      => $file -> {file_name},
  file_type      => $file -> {type},
  file_path      => $file -> {path},
  file_size      => $file -> {size},

 });
 
 sql_do ('UPDATE task_notes SET dt = NOW() WHERE id = ?', $id);

Наконец мы научились писать нужные данные в нужные таблицы – теперь можно всласть заняться тестированием.

055.gif

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

Поиск по репликам

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

Поступая точно так же, как раньше, и широко используя копирование готовых фрагментов кода, создаём процедуру select_task_notes...

sub select_task_notes {
 
 my $item = {
  portion => $conf -> {portion},
 };
 
 add_vocabularies ($item, 'users');

 my $filter = ;
 my @params = ();
 
 if ($_REQUEST {q}) {  
  $filter = ' AND (task_notes.label LIKE ? OR task_notes.file_name LIKE ?)';
  push @params, '%' . $_REQUEST {q} . '%';  
  push @params, '%' . $_REQUEST {q} . '%';  
 }

 if ($_REQUEST {file}) {  
  $filter = ' AND LENGTH(task_notes.file_name) > 0';
 }

 exists $_REQUEST {id_user} or $_REQUEST {id_user} = $_USER -> {id};
 
 if ($_REQUEST {id_user} && $_REQUEST {id_user_1}) {
 
  $filter .= ' AND ((task_notes.id_user_from = ? AND task_notes.id_user_to = ?) OR (task_notes.id_user_from = ? AND task_notes.id_user_to = ?))';
 
  push @params, $_REQUEST {id_user};
  push @params, $_REQUEST {id_user_1};
  push @params, $_REQUEST {id_user_1};
  push @params, $_REQUEST {id_user};
 
 }
 else {

  if ($_REQUEST {id_user}) {
  
   $filter .= ' AND (task_notes.id_user_from = ? OR task_notes.id_user_to = ?)';
 
   push @params, $_REQUEST {id_user};
   push @params, $_REQUEST {id_user};
   
  }
 
  if ($_REQUEST {id_user_1}) {
  
   $filter .= ' AND (task_notes.id_user_from = ? OR task_notes.id_user_to = ?)';
 
   push @params, $_REQUEST {id_user_1};
   push @params, $_REQUEST {id_user_1};
   
  }
 
 }

 my $start = $_REQUEST {start} + 0;

 ($item -> {task_notes}, $item -> {cnt}) = sql_select_all_cnt (<<EOS, @params, {fake => 'task_notes'});
  SELECT
   task_notes.*
   , task_status.icon
   , users_from.label AS users_from_label
   , users_to.label AS users_to_label
  FROM
   task_notes
   LEFT JOIN task_status ON task_notes.id_task_status = task_status.id
   LEFT JOIN users AS users_from ON task_notes.id_user_from = users_from.id
   LEFT JOIN users AS users_to   ON task_notes.id_user_to   = users_to.id
  WHERE
   1=1
   $filter
  ORDER BY
   task_notes.id DESC
  LIMIT
   $start, $$item{portion}
EOS

 return $item;

}

... и draw_task_notes:

sub draw_task_notes {

 my ($data) = @_;

 return

  draw_table (

   [
    '№',
    'Дата',
    'Кто кому',
    'Реплика',
    'Файл',
   ],

   sub {
    
    __d ($i, 'dt');
   
    draw_cells ({
     href  => "/?type=tasks&id=$$i{id_task}",
    },[    
            {
             label  => $i -> {id_task},
             status => {icon => $i -> {icon}},
            },
            {
             label  => $i -> {dt},
            },
     {
      label => "$i->{users_from_label} → $i->{users_to_label}",
      max_len => 1000,
     },
     $i -> {label},
     { 
      label => $i -> {file_name},
      href  => "/?type=task_notes&id=$$i{id}&action=download",
      target => 'invisible',
     },
     
    ]),
   
   },

   $data -> {task_notes},

   {
    title => {label => 'Переписка'},

    top_toolbar=>[{
      keep_params => ['type', 'select'],
     },
     {
      type    => 'pager',
      cnt     => 0 + @{$data -> {task_notes}},
      total   => $data -> {cnt},
      portion => $data -> {portion},
     },

     {
      type  => 'input_text',
      label => 'Искать',
      name  => 'q',
      keep_params => [],
     },
     {
      type   => 'input_select',
      name   => 'id_user',
      values => $data -> {users},
      empty  => '[Все]',
     },
     {
      type   => 'input_select',
      name   => 'id_user_1',
      values => $data -> {users},
      empty  => '[Все]',
     },
     {
      type   => 'input_checkbox',
      label  => 'Файл',
      name   => 'file',
     },

    ],
    
   }
  );

}

Комментировать тут особенно нечего: все составные части этих процедур уже рассмотрены выше. Осталось только проверить систему и начать её использовать:

056.gif

Заключение

Итак, спустя 4 часа 20 минут после запуска первого скрипта у нас в руках полноценная бета-версия вполне реальной информационной системы (+ набор из 50 исходных снимков экранов, так что чистое время разработки – ровно 4 часа). Разумеется, тут можно многое совершенствовать, но, на наш взгляд, изначальная задача выполнена: ведь теперь совершенно очевидно, что любая доделка вроде фильтра по дате для реплик или выделения "любимых тем" для отдельных пользователей решается в минуты. А, скажем, реализация реестра договоров и платежей и стыковка с поручиями – максимум дня за два.

В рассмотренном примере упомянуты многие механизмы Eludia, обеспечивающие одновременно простоту разработки и одновременно качество результирующего продукта. Однако могие из них остались за кадром. Кроме того, материал организован из соображений максиимальной наглядности, что неизбежно идёт в ущерб логической стройности.

Персональные инструменты
Пространства имён

Варианты
Действия
Навигация
Разработчику
Администратору
Инструменты