Компания
90,85
рейтинг
8 декабря 2015 в 13:40

Разработка → Lori Timesheets — учет времени на платформе CUBA



“Время – это капитал работника умственного труда.”
Оноре де Бальзак


Часто случается, что люди отдают предпочтение старым и привычным вещам, игнорируя новые, даже себе во вред. Вот так и мы долгое время с упорством использовали систему учета времени, которая не отвечала нашим требованиям и постоянно создавала проблемы буквально всем — от программистов до бухгалтерии.

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

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

В этой статье я расскажу, как мы в сжатые сроки (< 1 мес), ограниченными силами (человек и еще полчеловека) разработали это приложение.

Первые шаги


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

Давайте подумаем, какие сущности нам понадобятся в системе учета времени.
  • Пользователь — понятно, куда же без пользователя. К счастью, такая сущность уже есть в платформе, используем ее.
  • Клиент — тот кто платит деньги.
  • Проект — за что платят деньги.
  • Задача — конкретный вид работ.
  • Запись времени — сколько времени пользователь потратил на выполнение определенной задачи в конкретный день.


Казалось бы, этого достаточно. Именно такой набор сущностей мы имели в старой системе. Для своего удобства нам пришлось кое-что добавить.

  • Роль на проекте — проектов много, один и тот же пользователь может быть менеджером одного проекта и разработчиком на другом (у нас так бывает).
  • Участник проекта — связь проекта и пользователя, содержит ссылки на них и роль на данном проекте.
  • Тип задачи — признак, по которому можно объединить несколько задач. Например тип задач «Тестирование» позволит нам понять, сколько мы времени тратим на тестирование в рамках компании, и сравнить этот показатель для разных проектов.
  • Тип активности — признак по которому можно объединить несколько записей времени. Например в рамках задачи по разработке мобильного приложения мы делаем анализ, пишем код, пишем тесты, фиксим баги. При необходимости, добавив в проект соответствующие типы активности, мы сможем отслеживать, сколько времени было затрачено по каждому пункту.
  • Нерабочие дни — чтобы люди не ошибались, заполняя таймшиты на праздничные дни, мы подсвечиваем такие дни в системе используя заданные нерабочие дни.
  • Теги — слабоструктурированные метки, которые можно добавлять в записи времени.
  • Типы тегов — признак, по которому группируются определенные теги.


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

Определившись с объектной моделью, мы начали работу в CUBA-студии. Для начала мы создали соответствующие сущности. После этого мы воспользовались замечательной возможностью автогенерации кода в студии, получив SQL скрипты для создания базы данных, а также стандартные экраны (экран списка сущностей и экран редактирования отдельной сущности) с CRUD-действиями. Для 80% сущностей оказалось достаточно стандартных экранов. Те экраны, которые нуждались в доработке, мы редактировали с помощью WYSIWYG редактора экранов.

Затем мы настроили главное меню приложения, чтобы пользователи имели удобный доступ к необходимым сущностям.
В тот момент приложение уже можно было запускать и работать с сущностями объектной модели (создавать/редактировать/удалять).

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

Делаем интерфейс более дружелюбным


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


Ввод таймшитов за неделю


Первое, что нам было нужно — это удобное заполнение таймшитов за неделю. В старой системе был такой экран и мы решили сделать похожий. Вот что у нас получилось.



Как вы возможно помните, в объектной модели нет сущности «Отчет за неделю». Можно было бы реализовать данный экран вообще без каких-либо привязок к сущностям, однако гораздо удобнее создать промежуточную неперсистентную сущность, которая не связана с базой данных напрямую, но весь набор компонентов работает с ней как с обычной сущностью. Такой прием позволяет абстрагироваться от структуры базы данных и создавать экраны, более понятные пользователю. Выглядит такая сущность очень просто:

@MetaClass(name = "ts$WeeklyReportEntry")
public class WeeklyReportEntry extends AbstractNotPersistentEntity {
    .....
    @MetaProperty(mandatory = true)
    protected Project project;
    @MetaProperty(mandatory = true)
    protected Task task;
    .....
}

В данном случае WeeklyReportEntry представляет собой 1 строку в таблице.

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

Ввод таймшитов с календаря


Следующим по важности для нас был экран ввода таймшитов с календаря. К сожалению, на данный момент в платформе нет компонента “Календарь”. Однако, он есть во фреймворке Vaadin, который мы используем для отрисовки веб-клиента. Унаследовав его и слегка доработав (описано ниже), мы использовали его в своем приложении. В календарь мы также добавили валидацию потенциально неверно заполненных таймшитов, подсветку праздников и выходных дней, суммирование часов по неделям и месяцу.



Настройка проектов и задач


Еще одной целью для нас была простота настройки проектов и задач. Несмотря на то, что у нас были базовые экраны проекта и задачи, мы решили сделать специальный экран, который сделал бы настойку легкой и приятной. Ключевыми требованиями были: возможность быстро переключаться между проектами, возможность быстро добавлять людей и задачи в разные проекты. Было принято решение сделать экран в виде 3-х связанных таблиц: проекты, задачи и участники проектов.

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



«Командная строка»


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



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

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

Быстрая разработка


Естественно, все эти экраны не были разработаны нами за один раз.
Как обычно, доведение UI до ума заняло гораздо больше времени, чем создание его первого варианта. Здесь нам сильно помог механизм “горячей загрузки” изменений на сервер, реализованный в платформе CUBA. Благодаря ему около 90%(~) изменений в UI не нуждаются в перезагрузке сервера. Более того, существует возможность перезагружать так логику ядра (сервисы и бины). Более подробно этот механизм описан в нашей статье.

Расширяем client-side компоненты


Добавляем календарь


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

Vaadin построен на GWT, но при этом Vaadin-компонент существует как на клиенте, так и на сервере. Обычно существует также промежуточная часть, которая используется для связи клиента и сервера. Таким образом, чтобы расширить календарь, нам придется работать как с серверным, так и с GWT кодом.

У календаря есть состояние com.vaadin.shared.ui.calendar.CalendarState. Мы хотим чтобы состояние хранило, помимо прочего, дни, которые считаются выходными (это настраивается в системе), и праздники. Для этого мы наследуем этот класс.

public class TimeSheetsCalendarState extends CalendarState {
    .....
    public Set<Integer> weekends = new HashSet<>();
    public Set<String> holidays = new HashSet<>();
    ....

}

Теперь мы должны унаследовать серверный класс com.vaadin.ui.Calendar, чтобы заполнять новые свойства.
public class TimeSheetsCalendar extends Calendar {
   ....
   public TimeSheetsCalendar(CalendarEventProvider eventProvider) {
        super(eventProvider);

        getState().weekends = getWeekends();
    }

    @Override
    public void beforeClientResponse(boolean initial) {
        super.beforeClientResponse(initial);
        getState().holidays = getHolidays();
    }
    ....
}

После этого мы можем унаследовать виджет com.vaadin.client.ui.VCalendar и сделать так, чтобы он менял стиль ячейки в зависимости от того, праздник это или нет.
public class TimeSheetsCalendarWidget extends VCalendar {

    protected Set<Integer> weekends = new HashSet<Integer>();
    protected Set<String> holidays = new HashSet<String>();

    protected boolean isWeekend(int dayNumber) {
        return weekends.contains(dayNumber);
    }

    protected boolean isHoliday(String date) {
        return holidays.contains(date);
    }

    @Override
    protected void setCellStyle(Date today, List<CalendarDay> days, String date, SimpleDayCell cell, int columns, int pos) {
        CalendarDay day = days.get(pos);
        if (isWeekend(day.getDayOfWeek()) || isHoliday(date)) {
            cell.addStyleName("holiday");
            cell.setTitle(date);
        }
    }

Осталось только расширить класс com.vaadin.client.ui.calendar.CalendarConnector, чтобы он копировал данные о праздниках и выходных из состояния в виджет.
@Connect(value = TimeSheetsCalendar.class, loadStyle = Connect.LoadStyle.LAZY)
public class TimeSheetsCalendarConnector extends CalendarConnector {

    @Override
    public TimeSheetsCalendarWidget getWidget() {
        return (TimeSheetsCalendarWidget) super.getWidget();
    }

    @Override
    public TimeSheetsCalendarState getState() {
        return (TimeSheetsCalendarState) super.getState();
    }

    @Override
    public void onStateChanged(StateChangeEvent stateChangeEvent) {
        getWidget().setWeekends(getState().weekends);
        getWidget().setHolidays(getState().holidays);
        super.onStateChanged(stateChangeEvent);
    }
}

В результате мы можем добавить TimeSheetsCalendar в любой экран, созданный на платформе CUBA.
public class CalendarScreen extends AbstractWindow {
    @Inject
    protected BoxLayout calBox;
    protected TimeSheetsCalendar calendar;
    ....
    protected void initCalendar() {
         ....
         calendar = new TimeSheetsCalendar(dataSource);
         ....
         AbstractOrderedLayout calendarLayout = WebComponentsHelper.unwrap(calBox);
         calendarLayout.addComponent(calendar);
    }

Добавляем подсказки в «командную строку»


Чтобы пользователям было удобно использовать «командную строку», мы решили, что она должна подсказывать варианты ввода.

В Vaadin есть компонент AceEditor, который умеет это делать. Он используется в платформе (WebSourceCodeEditor), чтобы выдавать подсказки в JPQL запросах (например при редактировании запроса в отчете).
Мы решили упростить себе жизнь и вместо написания нового компонента на базе AceEditor расширили WebSourceCodeEditor.

В первую очередь мы расширили org.vaadin.aceeditor.SuggestionExtension, зарегистрировав в нем RPC сервис, который должен обрабатывать применение командной строки.
public class CommandLineSuggestionExtension extends SuggestionExtension {
    protected Runnable applyHandler;
    
    public CommandLineSuggestionExtension(Suggester suggester) {
        super(suggester);

        registerRpc(new CommandLineRpc() {
            @Override
            public void apply() {
                if (applyHandler != null) {
                    applyHandler.run();
                }
            }
        });
    }

    public void setApplyHandler(Runnable applyHandler) {
        this.applyHandler = applyHandler;
    }

    public Runnable getApplyHandler() {
        return applyHandler;
    }
}

Затем пришла очередь платформенного класса com.haulmont.cuba.web.gui.components.WebSourceCodeEditor.
public class WebCommandLine extends WebSourceCodeEditor implements CommandLine {
    @Override
    public void setSuggester(Suggester suggester) {
        this.suggester = suggester;

        if (suggester != null && suggestionExtension == null) {
            suggestionExtension = new CommandLineSuggestionExtension(new CommandLineSourceCodeEditorSuggester());
            suggestionExtension.extend(component);
            suggestionExtension.setShowDescriptions(false);
        }
    }

    protected class CommandLineSourceCodeEditorSuggester extends SourceCodeEditorSuggester {
    }

    public CommandLineSuggestionExtension getSuggestionExtension() {
        return (CommandLineSuggestionExtension) suggestionExtension;
    }
}

И наконец client-side класс org.vaadin.aceeditor.client.SuggesterConnector.
@Connect(CommandLineSuggestionExtension.class)
public class CommandLineSuggesterConnector extends SuggesterConnector {
    protected CommandLineRpc commandLineRpc = RpcProxy.create(
            CommandLineRpc.class, this);

    @Override
    public Command handleKeyboard(JavaScriptObject data, int hashId,
                                  String keyString, int keyCode, GwtAceKeyboardEvent e) {
        if (suggesting) {
            return keyPressWhileSuggesting(keyCode);
        }
        if (e == null) {
            return Command.DEFAULT;
        }

        if (keyCode == 13) {//Enter
            commandLineRpc.apply();
            return Command.NULL;//ignore enter
        } else if ((keyCode == 32 && e.isCtrlKey())) {//Ctrl+Space
            startSuggesting();
            return Command.NULL;
        } else if ((keyCode == 50 && e.isShiftKey())//@
                || (keyCode == 51 && e.isShiftKey())//#
                || (keyCode == 52 && e.isShiftKey())//$
                || (keyCode == 56 && e.isShiftKey())) {//*
            startSuggestingOnNextSelectionChange = true;
            widget.addSelectionChangeListener(this);
            return Command.DEFAULT;
        }

        return Command.DEFAULT;
    }
}

В нем мы переопределили поведение редактора — подсказки должны появляться кроме Ctrl-Space при нажатии @,#,$,* (подсказка проектов, задач, тегов, типов активности). Нажатие Enter должно применять командную строку (заполнить таймшиты).

Расширяем функционал платформы


Как вы возможно помните, мы решили использовать класс User, предоставляемый платформой. Нам хотелось, чтобы в записи пользователя можно было хранить количество обязательных рабочих часов в неделю. Это нужно для валидации введенных данных (если человек указал в таймшитах больше или меньше чем должен). У нас был выбор — создать новую сущность, которая бы ссылалась на систменого пользователя, или расширить платформенную сущность. В целях экономии усилий мы решили пойти путем расширения, потому что этот механизм довольно прост (с точки зрения использования) и отлично работает. Сейчас я покажу, как мы реализовали данное расширение.

Во-первых, нужно было сделать наследника класса com.haulmont.cuba.security.entity.User и добавить туда новое поле.
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorValue("Ext")
@Entity(name = "ts$ExtUser")
@Extends(User.class)
public class ExtUser extends User {
    ....
    @Column(name = "WORK_HOURS_FOR_WEEK", nullable = false)
    protected BigDecimal workHoursForWeek;
    public BigDecimal getWorkHoursForWeek() {
        return workHoursForWeek;
    }

    public void setWorkHoursForWeek(BigDecimal workHoursForWeek) {
        this.workHoursForWeek = workHoursForWeek;
    }
    ....
}

Затем мы создали экран, расширяющий экран редактирования пользователя, и зарегистрировали его в системе.
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<window xmlns="http://schemas.haulmont.com/cuba/window.xsd" caption="msg://editCaption"
        class="com.haulmont.timesheets.gui.extuser.ExtUserEdit"
        extends="/com/haulmont/cuba/gui/app/security/user/edit/user-edit.xml"
        messagesPack="com.haulmont.timesheets.gui.extuser"
        xmlns:ext="http://schemas.haulmont.com/cuba/window-ext.xsd">
    <layout>
        <groupBox id="propertiesBox">
<h4></h4>            <grid id="propertiesGrid">
                <rows>
                    <row id="propertiesRow">
                        <fieldGroup id="fieldGroupRight">
                            <column>
                                <field id="workHoursForWeek"
                                       caption="msg://com.haulmont.timesheets.entity/ExtUser.workHoursForWeek"
                                       ext:index="5"/>
                            </column>
                        </fieldGroup>
                    </row>
                </rows>
            </grid>
        </groupBox>
    </layout>
</window>

Теперь вместо сущности User в системе присутствует ExtUser и экран редактирования содержит поле workHoursForWeek.

Это самый простой пример расширения функциональности, если вы хотите узнать о расширениях больше — прочитайте нашу статью.

Делаем дистрибутив своими руками


С самого начала мы планировали сделать данную систему продуктом, которым будут пользоваться другие люди. Для того, чтобы установка и запуск системы были простыми, мы решили сделать что-то вроде дистрибутива.

Наш «дистрибутив» представляет собой zip-файл, который содержит папку c контейнером сервлетов Tomcat и скриптами для запуска/остановки системы.

Так как для сборки проектов на платформе используется Gradle, собирать такой «дистрибутив» не представляет труда.
def distribDir="./distrib"
def scriptsDir="./scripts"

task cleanTomcatLogs << {
    def dir = new File(tomcatDir, '/logs/')
    if (dir.isDirectory()) {
        ant.delete(includeemptydirs: true) {
            fileset(dir: dir, includes: '**/*')
        }
    }
}

task copyTomcat(type: Copy, dependsOn: ['setupTomcat',':app-core:deploy', ':app-web:deploy', ':app-web-toolkit:deploy', 'cleanTomcatLogs']) {
    from file("$tomcatDir/..")
    include "tomcat/**"
    into "$distribDir"
}

task copyLoriScripts(type: Copy) {
    from file("$scriptsDir")
    include "*lori.*"
    into "$distribDir"
}

task copyTomcatScripts(type: Copy, dependsOn: 'copyTomcat') {
    from file("$scriptsDir")
    include "*classpath.*"
    into "$distribDir/tomcat/bin/"
}

task buildDistributionZip(type: Zip, dependsOn: ['copyLoriScripts', 'copyTomcatScripts']) {
    from "$distribDir"
    exclude "*.zip"
    baseName = 'lori'
    version= "$artifactVersion"
    destinationDir = file("$distribDir")
}

task distribution(dependsOn: buildDistributionZip) << {
}

Единственная проблема возникла с Tomcat. Он отчаянно не хотел стартовать в системе, где не задана системная переменная JAVA_HOME.

Чтобы заставить его игнорировать отсутствие этой переменной, пришлось заменить скрипты setclasspath.sh и setclasspath.bat на более простые.

Заключение


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

Само приложение бесплатно, его код доступен на github. Встроенная лицензия на платформу CUBA позволяет одновременно работать 5 пользователям. Пожизненная лицензия для неограниченного числа пользователей стоит символические 300 руб.

Мы надеемся, что Lori Timesheets принесет пользу не только нам, но и кому-то еще. Открытый код и механизм расширений позволят легко адаптировать приложение под себя.
Автор: @tinhol
Haulmont
рейтинг 90,85

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

  • +1
    С того момента как я начал работать в IT, меня не оставлял вопрос: почему каждая компания и каждый программист первым делом начинают писать для себя очередной трекер времени? Их уже написано ровно 100500 миллионов штук. По миллиону на каждую платформу, по миллиону на каждом языке программирования, по миллиону для каждого GUI-фреймворка, Web-фремворка, среды, редактора, командной строки.

    Кстати, говоря, ваш не самый лучший — не хватает интеграции со средой разработки. Гораздо удобнее и точнее, когда прямо в среде или в баг-трекере можно начать отсчет времени по текущей задаче.
    • 0
      Добрый день. Спасибо за Ваш комментарий.

      Как было написано в начале этой статьи, мы несколько лет пользовались системой учета времени написанной не нами. Так что, технически, мы не писали систему учета времени первым делом. Просто старая система с самого начала не соответствовала нашим требованиям на 100% и со временем приносила все больше проблем. Когда наши мучения достигли определенной точки — мы решили систему поменять. Посмотрев на аналоги и оценив время разработки, взвесив все, мы решили, что дешевле и быстрее разработать ее на нашей собственной платформе. При таком подходе мы на 100% удовлетворили наши требования, и кроме того получили неплохой демонстрационный продукт.

      Кроме того, Lori Timesheets в настоящее время является не трекером времени, а корпоративной системой учета рабочего времени. Ее задача состоит не только в том, чтобы позволять вводить время, но и в том, чтобы дать механизм контроля и утверждения введенных таймшитов, обеспечить разделение доступа к различным задачам и проектам, упростить сбор статистики. Возможно, со временем в Lori Timesheets появится модуль трекера времени, но пока таких потребностей у нас нет.

      Позвольте и мне задать Вам вопрос: какой системой учета/трекинга времени пользуетесь Вы?
      • 0
        Я фрилансер, и сейчас не пользуюсь системой учета/трекинга. До этого пользовался разными, самая последняя и более всего понравившаяся: gtimelog (https://mg.pov.lt/gtimelog/). Он маленький и невероятно удобный и гибкий. Это парадоксальное сочетание качеств обеспечивается продуманностью интерфейса и форматом хранения данных: обычный текстовый файл. Так как мне свойственно задумываться и забывать запустить/остановить трекинг, то я запросто могу открыть файл и поправить интервал.

        Но вообще сейчас я не хочу пользоваться никакой системой. Постепенно я отказываюсь от всех отвлечений типа социальных сетей. Остались только Geektimes и Habrahabr, от которых пока не хватает сил отказаться. Facebook, vk и прочее уже около месяца назад перестал читать.

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

        Сейчас стараюсь использовать Pomodoro и просто работать. Когда приходит время заканчивать работу, заканчиваешь, и нет сожаления, что провел время нерационально, так как постоянно отвлекался от кода на соц. сети и прочее, теряя сосредоточенность и растягивая из-за этого выполнение задач.

        Единственная проблема: под Linux нет нормального таймера Pomodoro (хотя их тоже пишут все кому не лень), а таймером в телефоне (под Android есть хорошие) или в часах (для Pebble тоже есть удобные, но мне неудобно работать в часах) пользоваться неудобно.
        • 0
          Наверное мой комментарий показался сумбурным. Перефразирую так: я для себя перестал видеть необходимость учета времени, так как стараюсь изменить отношение ко всему в жизни, включая работу. Не видеть в этом каторгру, отсчитывая минуты до ее конца. Работа для меня — источник существования, способ помощи семье и окружающим людям, да и работодателям своим я помогаю, решая их задачи (за что они мне очень благодарны). Это неотъемлемая часть жизни и я стараюсь относиться к ней с любовью и уважением. Мы же не считаем, сколько мы съедаем и спим (кроме совсем уж фриков) — мы съедаем сколько нам надо и спим сколько нам надо. Так же надо делать и с работой — делать ее спокойно и осознанно, без насилия над собой.

          Это и просто и непросто одновременно. И для компании наверное не очень подходит, так как заставить сотрудников быть осознанными невозможно, надо чтобы каждый сам к этому пришел.
        • 0
          Спасибо за ответ.

          gtimelog действительно выглядит минималистично и приятно.

          Однако, как я уже писал, Lori Timesheets решает немного другую задачу — учет времени на уровне компании. В нашем случае нет необходимости иметь данные по каждой минуте, проведенной сотрудником в курилке, на кухне или в соц. сетях. Есть необходимость постоянно собирать данные по затраченному на конкретные проекты времени, формировать статистические отчеты, считать зарплаты и переработки.

          И как оказалось (или как нам кажется), таких систем не очень много. А подходящих именно нам на 100% мы ни одной не нашли.

          Поэтому-то мы и решили написать свою.

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

Самое читаемое Разработка