- Задача
- Проблема
- Решение
- Описание
- Предварительные требования
- Дерево команд
- Команды
- Получить токен
- Запуск
- Тесты
- Интеграция в производственной среде
- Как работает
- Алгоритм уведомлений
- Диаграммы
- Пробуем новую архитектуру
- Полезные ссылки
- Вопрос-ответ
- Проблемы Яндекс Календаря
- Будущие доработки
- Благодарности
📝 В Яндекс Календаре назначаются встречи. Нужно как по ссылке YandexCalendar(plugin)
- Присылать утром список встреч на день.
- Информировать за 10 минут до встречи, показывать ссылку на яндекс-телемост у встречи.
- Сообщать об отменённых встречах, переносах встреч, добавленных встречах.
- Сообщать о действиях участников встреч со мной (не подтвердили встречу, отказались от встречи и так далее).
- Автоматически проставлять статус
📆 На встрече
, когда на встрече.
Плагин YandexCalendar(plugin) местами не работает
- Переработка плагина (Go) https://github.com/LugaMuga/mattermost-yandex-calendar-plugin на Python
- Добавление необходимого функционала
- Исправление ошибок
- Программа представлена в виде интеграции сервиса mattermost и яндекс календаря по протоколу CalDAV, а именно, вытягивание конференций из сервера.
- Доступны запросы на получение списка конференций с атрибутами в различные промежутки времени.
- Аутентификация по логину яндекса и токену яндекс приложения.
- Настройка часового пояса.
- Ежедневное уведомление в заданное время по часовому поясу пользователя.
- Уведомление о предстоящей конференции за 10 минут до начала.
- Установка статуса "📆 In a meeting" во время конференции. Если изначальный статус длительнее или в
режиме
не стирать
, то происходит возврат вашего статуса после конференции. Напрмер: 🏠 "Working from home". StatusDon't clear
-> 📆In a meeting
. Status18:30
-> 🏠 "Working from home". StatusDon't clear
. - Проверка активности аккаунта.
- Проверка активности планировщика.
- Возможность удалить/обновить установленные параметры в интеграции
- Уведомление об изменении/добавлении/удалении конференций, наличие всех доступных атрибутов участников конференции
- Интеграция с несколькими яндекс календарями по выбору
- Docker Engine 24.0.5
- Python 3.12
/yandex_calendar
.
├── calendars
│ ├── current
│ ├── from_to
│ ├── get_a_month
│ ├── get_a_week
│ └── today
├── checks
│ ├── check_account
│ └── check_scheduler
├── connections
│ ├── connect
│ ├── disconnect
│ ├── update
│ └── profile
├── info
└── notifications
├── create
├── delete
└── update
- yandex_calendar [root all commands in integration app]
-
connections [module authentication client]
- connect [connection to yandex calendar, need required login, token yandex app, timezone - form command]
- disconnect [disconnection account from integration - execute command]
- update [update login, token, timezone - form command ]
- profile [show info about me(user attributes) - execute command]
-
calendars [module Yandex Calendar API for client]
- get_a_week [get conferences for the week by user timezone, execute command]
- get_a_month [get conferences for the month by user timezone, execute command]
- current [get conferences on current day by user timezone, need
dd.mm.YYYY
- form command] - from_to [get conferences from date
dd.mm.YYYY
to datedd.mm.YYYY
by user timezone, need start date and end date in formatdd.mm.YYYY
- form command] - today [get conferences for today by user timezone, execute command]
-
notifications [module scheduler with notifications]
- create [create jobs with notifications every day or/and every next conference before in 10 minutes,
need select calendar with exists conferences, select time 00:00->23:45 with interval 15 minutes(required),
click
Notification
for every next conferences notifications(optional), clickStatus
for change status when in a meeting] - update [clear user jobs(scheduler) and create all by command
create
again] - delete [clear user jobs(scheduler)]
- create [create jobs with notifications every day or/and every next conference before in 10 minutes,
need select calendar with exists conferences, select time 00:00->23:45 with interval 15 minutes(required),
click
-
checks [module checking info about active user]
- check_account [check exist user in integration]
- check_scheduler [check exist notifications for user in integration ]
-
info [help information about commands app, execute command]
-
- Перейти на сайт Яндекс ID
- Войти или зарегистрироваться
- Перейти в
Безопасность
- В самом низу
Пароли приложений
- Нажмите на
Календарь CalDAV
и создайте пароль - Для интеграции потребуется логин яндекс аккаунта и созданный ранее пароль
Или можно почитать первый шаг из https://yandex.ru/support/calendar/common/sync/sync-desktop.html
-
Клонируем репозиторий
git clone https://github.com/ArtemIsmagilov/mm-yc-notify && cd mm-yc-notify/
-
Активируем виртуальное окружение
python3 -m venv venv && source venv/bin/activate
-
Копируем файл с переменными окружения и редактируем в зависимости от ваших общих конфигураций
cp .example.env .env
-
Настраиваем переменные
wsgi/settings.py
-
Настраиваем переменные
gunicorn.conf.py
-
Настраиваем
microservice/docker-compose.yml
-
Запускаем докер контейнер mattermost
cd ./mm/ sudo docker compose up
-
Запускаем докер контейнер приложения
сd ../microservice sudo docker compose up
-
Устанавливаем бота в mattermost, а именно - пишем команду в любом диалоговом окне
/app install http http://192.168.31.57:8065/manifest.json
-
Теперь необходимо добавить бота в команду https://www.ibm.com/docs/en/z-chatops/latest?topic=platform-inviting-created-bot-your-mattermost-team
-
Создать токен, предоставить права
-
В файле
.env
добавить токен для приложенияMM_APP_TOKEN=example
-
Перезапустить докер контейнер
sudo docker compose -f ./microservice/docker-compose.yml down sudo docker compose -f ./microservice/docker-compose.yml up
-
При разработке, удобно запустить отдельно postgres и app
-
postgres
cd /psql sudo docker compose up
-
app
cd ../ flask init-db bash run-app.bash
-
- предварительно у вас должен быть запущен mattermost, psql контейнеры, у бота должны быть токен и права.
- очищаем БД в
src/
sudo quart init-db -c
- запускаем тесты в папке
src
coverage run -m pytest --cache-clear
coverage report -m
Подробная информация в htmlName Stmts Miss Cover Missing --------------------------------------------------------------------------------- src/app/__init__.py 48 2 96% 43, 47 src/app/app_handlers.py 14 0 100% src/app/async_wraps/async_wrap_caldav.py 27 3 89% 17, 24-25 src/app/bots/bot_commands.py 34 4 88% 12, 33, 50, 55 src/app/calendars/caldav_api.py 97 14 86% 73-95, 180 src/app/calendars/caldav_filters.py 10 6 40% 10-14, 18, 22 src/app/calendars/caldav_funcs.py 15 2 87% 23-25 src/app/calendars/caldav_searchers.py 21 5 76% 41-45 src/app/calendars/calendar_app.py 15 0 100% src/app/calendars/calendar_backgrounds.py 25 8 68% 22-28, 32-33 src/app/calendars/calendar_views.py 11 1 91% 12 src/app/calendars/conference.py 96 9 91% 25, 35, 50, 60, 90, 103, 118, 123, 170 src/app/checks/check_app.py 9 0 100% src/app/checks/check_my_account.py 13 0 100% src/app/checks/check_my_scheduler.py 14 0 100% src/app/connections/connection_app.py 24 0 100% src/app/connections/connection_backgrounds.py 40 7 82% 46, 49-56, 71, 92 src/app/connections/connection_handlers.py 62 0 100% src/app/constants.py 3 0 100% src/app/converters.py 80 46 42% 25-26, 36, 40-42, 46-47, 52-54, 61-76, 83-94, 98-103, 107, 117-120, 124, 132, 136 src/app/decorators/account_decorators.py 66 5 92% 74-75, 87-89 src/app/dict_responses.py 48 7 85% 49, 106, 120, 127, 148, 155, 162 src/app/notifications/notification_app.py 24 0 100% src/app/notifications/notification_backgrounds.py 46 3 93% 54-56, 94 src/app/notifications/notification_handlers.py 99 4 96% 47, 122, 184, 261 src/app/notifications/notification_views.py 23 7 70% 20, 37-40, 53-56 src/app/notifications/tasks.py 214 103 52% 66-67, 73-74, 80-81, 87-88, 94-95, 107-108, 122, 138, 157-205, 220, 245-300, 313, 320, 357-456, 462-476 src/app/notifications/worker.py 14 0 100% src/app/schemas.py 31 0 100% src/app/settings.py 39 0 100% src/app/sql_app/crud.py 87 10 89% 154, 178-188, 192-199, 249, 266-277, 281 src/app/sql_app/database.py 8 0 100% src/app/sql_app/db_CLI.py 22 10 55% 20-25, 29-30, 34-36 src/app/sql_app/models.py 5 0 100% src/app/validators.py 8 2 75% 9-10 src/tests/__init__.py 0 0 100% src/tests/additional_funcs.py 33 2 94% 62-63 src/tests/conftest.py 64 4 94% 47, 69, 73, 112 src/tests/test_app.py 654 0 100% --------------------------------------------------------------------------------- TOTAL 2143 264 88%
Помимо тестов также требуется соблюдение PEP8coverage html
flake8 src/
- получить сертификаты для зашифрованного трафика https://certbot.eff.org/
- развернуть докер контейнер маттермоста с HTTPS https://docs.mattermost.com/install/install-docker.html
- в
wsgi/gunicorn.conf.py
меняем протокол на https, хост и порт на локальные127.0.0.1:5000
, добавляем сертификаты и указываем пути к ним. Настраиваем количество работников и потоков. В зависимости от вашей кофигурации, меняем переменные в.env
- добавляем nginx конфигурацию для проксирования запросов к интеграции на локальный хост и порт
sudo nano /etc/nginx/sites-enabled/app_nginx
# in /etc/nginx/sites-enabled # reverse proxy app # http #server { # listen 10081; # listen [::]:10081; # location / { # include proxy_params; # proxy_pass http://127.0.0.1:5000/; # } #} # https server { listen 10441 ssl; # managed by Certbot listen [::]:10441 ssl; ssl_certificate /etc/letsencrypt/live/CHANGE/fullchain.pem; # managed by Certbot ssl_certificate_key /etc/letsencrypt/live/CHANGE/privkey.pem; # managed by Certbot include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot server_name CHANGE; # managed by Certbot location / { include proxy_params; proxy_pass https://127.0.0.1:5000/; } }
sudo systemctl reload nginx
- После добавления интеграции не забудьте создать токен, предоставить права и добавить в
src/.env
- Перезапустить докер контейнер.
-
/yandex-calendar calendars get_a_month
Снизу будут высвечиваться ошибки клиента и разработчика, в данном случае пользователь не авторизовался, но пытается получить список конференций за месяц. -
/yandex-calendar calendars get_a_month
В модулеcalendars
список конференций в различных интервалах будет в следующей форме -
/yandex-calendar notifications create/update
Вы можете создать только один планировщик. С помощьюupdate
можно обновить планировщик, задать другие параметры для уведомлений -
/yandex-calendar notifications delete
Попытка удалить несуществующий планировщик -
Уведомление (за 10 минут до конференции)/(ежедневное обо всех сегодня конференциях)
-
В переменной окружения следует прописать в стиле cron частоту запроса от каждого пользователя, который подключить планировщик, к яндекс-серверу
Примеры https://crontab.guru/examples.html -
Внимание! Администратору следует тонко настроить время повторения пинга сервера. Если запросы слишком регулярные, то приложение будет излишне перегружено, если же редкие, то для сотрудников с очень частыми конференциями информация от уведомлений будет не актуальна.
-
Вы получаете уведомление об удалённых/изменённых/добавленных конференций на яндекс-сервере.
-
При тестировании следует поменять несколько параметров, а именно:
- поменять в коде функцию уведомление за 1 минуту(поменять 10 на 1)
- поменять функцию, которая проверяет существование конференции
в пределах от 15 минут и более
нав переделах от времени сейчас и более
. - поменять параметры создания задач на пинг сервера яндекс календаря с cron стилистики на параметр секунды,
например пинг каждые 10 секунд, в продакшене этого не стоит делать. Также добавить параметр случайности.
jitter
- исполнение задач в случайное время в заданном порядке секунд https://apscheduler.readthedocs.io/en/latest/modules/triggers/cron.html#module-apscheduler.triggers.cron . Это обеспечивает стабильность при сильных нагрузках в час пик.
-
Как работает:
- Отслеживаются приложением все конференции и при совершении над ними операция приходят уведомления
- Приложение уведомляет вас о предстоящей конференции за 10 минут до начал при условии, что конференция была добавлена не раньше 15 минут. Почему? Потому что нужно гарантированно отправлять уведомления о событиях которые не были удалены за 10 минут до сообщения. 5 минут буфер.
- Смена статус осуществляется в момент начала конференции и закачивается в момент окончания конференции. Если, ваш статус длительнее по времени статуса конференции или стоит в режиме(постоянно), то приложение вернут ваш статус.
- Блок
notifications
состоит из подпунктов:- уведомление ежедневные в конкретное время
- уведомление обо всех операция связанных с вашими конференциями, синхронизация через лонг-пуллинг, зависит от настройки администратора
- уведомление за 10 минут до начала предстоящей конференции
- Примеры:
-
Предостережение
-
Если клиент при создании интеграции вводите валидный логин, токен, потом пользуетесь приложением, затем меняете в яндекс календаре логин или токен, то интеграция сразу удаляет ваши данные из приложения как невалидного пользователя. Следует либо ничего не менять, либо в приложении удалять/обновлять данные через команды приложения.
-
Если вы выбираете некоторые календари, а потом удаляете в яндекс календаре, ваш изначальный выбор уведомлений с синхронизированными конференциями удаляется как не валидные данные, опять же - обновляйте/удаляте ваши изменения через команды приложения.
-
Конференции, которые были добавлены раньше 15 минут не будут обрабатываться приложением для обновления статуса и сообщения о предстоящем событии, так как невозможно уведомить о предстоящей конференции за 10 минут если вы создали конференцию за 1, 3, 5 и так далее до 15 минут. 5 минут добавлены как буфер.
-
библиотека apscheduler не корректно работает с несколькими процессами, поэтому в настройках gunicorn не следует запускать более 1 процесса. Существуют обходные пути, которые позволяют работать с несколькими потоками в apscheduler.
https://apscheduler.readthedocs.io/en/latest/faq.html#how-do-i-share-a-single-job-store-among-one-or-more-worker-processes
APScheduler does not currently have any interprocess synchronization and signalling scheme that would enable the scheduler to be notified when a job has been added, modified or removed from a job store.
Workaround: Run the scheduler in a dedicated process and connect to it via some sort of remote access mechanism like RPyC, gRPC or an HTTP server. The source repository contains an example of a RPyC based service that is accessed by a client.
-
- Повысили эффективность и производительность кода сделав его асинхронным(asyncio, Quart).
- Добавили брокер сообщений rabbitmq через который очень быстро и надёжно передаются сообщения. (Раньше просто БД)
- Внедрили библиотеку фоновых задач Dramatiq(Для наших задач самое то)
- Есть идея хранить данные синхронизированных календарей и конференций в Redis так как обмен данными очень быстрый, но структура данных не совсем ключь-значние(пока думаем)
- Требуется нагрузочное и mock тестирование
- Можно удалить middleware Prometheus в dramatiq(в 2 версии автор собирается убрать из коробки)
- rabbitmq
Добавляем переменные окружения в .env
sudo docker compose up -d
- worker dramatiq
dramatiq app.notifications.tasks
- events scheduler
python -m app.notifications.task0_scheduler
- web server
bash run-server.bash
- После проверки корректности работы всех компонентов добавляем логирование
- Запускаем докер файл
Cмотрим статистику
cd src/microsevice/ && sudo docker compose up -d
Смотрим логиsudo docker stats
sudo docker compose logs > output.txt
- Можно добавить миграцию БД
- https://alembic.sqlalchemy.org/en/latest/tutorial.html
- https://alembic.sqlalchemy.org/en/latest/autogenerate.html
- sqlalchemy/alembic#805
- в
/src
alembic init -t async alembic
- в файле
alembic.ini
меняем url на действительный
sqlalchemy.url =driver://user:pass@localhost/dbname - в файле
env.py
меняем target_metadatafrom app.sql_app.models import metadata_obj target_metadata = metadata_obj # target_metadata = None
- при изменениях делаем автогенерацию скрипта миграции
alembic revision --autogenerate -m "Added account table"
- запускаем миграцию
alembic upgrade head
- получить информацию
alembic history --verbose
- Scheduling All Kinds of Recurring Jobs with Python - https://martinheinz.dev/blog/39
- 7 способов выполнения запланированных заданий с помощью Python - https://evogeek.ru/articles/250819/
- Работа с токенами на Flask - https://kirill-sklyarenko.ru/lenta/flask-api-json-web-token-1
- Кодирование секретных ключей - https://stackoverflow.com/questions/2490334/simple-way-to-encode-a-string-according-to-a-password
- Настройка работников и потоков в gunicorn - https://stackoverflow.com/questions/38425620/gunicorn-workers-and-threads
- Mattermost Yandex Calendar Plugin (CALDav) - https://github.com/LugaMuga/mattermost-yandex-calendar-plugin
- Команды Postgresql - https://www.postgresqltutorial.com/postgresql-administration/psql-commands/
- Команды SQL - https://www.w3schools.com/sql/default.asp
- Mattermost troubleshooting - https://docs.mattermost.com/install/troubleshooting.html
- Installing packages - https://packaging.python.org/en/latest/guides/installing-using-pip-and-virtual-environments/#creating-a-virtual-environment
- RFC2445(iCalendar) - https://www.ietf.org/rfc/rfc2445.txt
- RFC4791(CALDAV) - https://www.ietf.org/rfc/rfc4791.txt
- Python iCalendar - https://github.com/collective/icalendar
- Python CalDAV - https://github.com/python-caldav/caldav
- Add column psql with foreign key - https://stackoverflow.com/questions/17645609/add-new-column-with-foreign-key-constraint-in-one-command
- What is a reasonable code coverage % for unit tests (and why)? - https://stackoverflow.com/questions/90002/what-is-a-reasonable-code-coverage-for-unit-tests-and-why
- sync/thread/async https://ru.stackoverflow.com/questions/1159101/python-thread-multiprocessing-asyncio
- rebbitmq docker-compose https://habr.com/ru/companies/slurm/articles/704208/
- periodic task https://docs.celeryq.dev/en/latest/userguide/periodic-tasks.html#using-custom-scheduler-classes
- prefork vs eventlet https://stackoverflow.com/questions/29952907/celery-eventlet-pool-does-not-improve-execution-speed-of-asynchronous-web-requ
- Asynchronous yield from https://peps.python.org/pep-0525/#asynchronous-yield-from
- SQLAlchemy Transactions https://docs.sqlalchemy.org/en/20/core/connections.html#begin-once
- Flower --broker_api mher/flower#1036
- rabbitmq (unacked,ready) https://stackoverflow.com/questions/31915773/rabbitmq-what-are-ready-and-unacked-types-of-messages
- rabbitmq docker environs https://www.rabbitmq.com/configure.html#supported-environment-variables
- asyncpg interface error https://stackoverflow.com/questions/66444620/asyncpg-cannot-perform-operation-another-operation-is-in-progress
- best naming endpoints in restful https://blog.dreamfactory.com/best-practices-for-naming-rest-api-endpoints/
- pgadmin https://www.pgadmin.org/docs/pgadmin4/latest/container_deployment.html
- Recurrence events in CalDAV https://icalendar.org/iCalendar-RFC-5545/3-8-4-4-recurrence-id.html
- cancel asyncio.to_thread Task https://stackoverflow.com/questions/71416383/python-asyncio-cancelling-a-to-thread-task-wont-stop-the-thread
- asyncpg transaction error https://stackoverflow.com/questions/74313692/fastapi-asyncpg-sqlalchemy-cannot-use-connection-transaction-in-a-manual
-
Почему не шифруешь токены?
-
Изначально шифровал с помощью библиотеки cryptography через объект Fernet. В процессе разработки выяснил, что ни Redmine, ни Mattermost не шифруют токены. Хотя пароли хэшируют - это мы одобряем. В интернете большая полемика по тому, как правильно шифровать личную информацию. Я склонился к тому, что нужно придерживаться политика самого сайта, где рядом создаётся интеграция. Если шифруют, то нужно тоже шифровать и наоборот.
-
Давайте посмотрим в как токен храниться в нашей БД.
- вы должны уже были авторизоваться в интеграции через маттермост(добавить логин, токен, часовой пояс).
sudo docker exec -it microservice-db-1 bash
Вы перешли в оболочку контейнера БД, смотрим содержимое БД
su postgres
psql
\dt
List of relations Schema | Name | Type | Owner --------+-------------------+-------+---------- public | user_account | table | postgres public | yandex_calendar | table | postgres public | yandex_conference | table | postgres (3 rows)
SELECT id, token FROM user_account;
id | token ----+---------------------------- 1 | hak3jkeh67y4dd1h6q8r16gqrr (1 row)
-
Токен у нас не зашифрован(мы ничего и не делали для этого), предыдущий метод долгий, лучше просто добавить в докер контейнер маттермоста adminer и посмотреть в вебе.
-
В маттермост тоже не зашифрован, такая же ситуация и в редмайне.
-
Почему не шифруем? Если, буквально в двух словах, то REST API токены позволяем использовать совсем малую часть функционала приложений. Пароли захешированы, уже хорошо. Если унесут БД все токены можно сразу же поменять.
-
https://docs.mattermost.com/developer/personal-access-tokens.html
-
-
Что мы тестируем?
- Тесты проверяют правильность работы клиентской части - разделы
- /calendars
- /checks
- /connections
- /notifications
- /jobs
- Тесты проверяют правильность работы клиентской части - разделы
- Отсутствие какой-либо документации, RESTAPI, туториала, инструментария от яндекса для работы с Яндекс-Календарём в удобном формате JSON, XML. Google хорошо задокументировал работу с google календарём на python https://developers.google.com/calendar/api/quickstart/python?hl=ru
- Выборка текущих конференций происходит по часовому поясу клиента, если вы поменяли часовой пояс, то старые конференции не меняют часовой пояс, только на сайте визуально. Поэтому нужно настроить часовой пояс один раз при создании клиента интеграции в маттермост.
- Не доступны запросы на занятость (тег freebusy, статус 504).
- Если в яндекс-календаре создаю календари с одинаковым именем, то при добавлении в форму, маттермост удаляет эти дубликаты. Это и логично, как нам их отличить, если одинаковые имена. Можно сделать выбор по id, но это не самые комфортный интерфейс для пользователя.
- Произвольные обновления событий без участия клиента, что требует дополнительной корректировки синхронизации событий. Скорее всего, сервер обновляет свойства событий не относящиеся к атрибутам вытягиваемой конференции. Для правильной синхронизации потребовалась перепроверка на идентичность новых событий-конференций по sync_token с событиями-конфренециями в БД.
- Не корректное хранение данных о событии. Здесь имеется ввиду, что при поиске событий через метод
calendar.search
возвращаются не отсортированные и лишние события. Я создал issue для автора библиотекиcaldav
python-caldav/caldav#351 . Тут не понятно, то ли это проблема Яндекс Календаря(не правильно сохраняет timezone) то ли библиотекиcaldav
- добавить русский язык
- оптимизировать алгоритм обновлений конференций через CalDAV сервер
- покрыть код тестами
- выявить уязвимости
- желательна переработка кода
- добавить автоматизацию тестов и деплоя на сервер (CI/CD) через Github workflows
- Проекту https://github.com/lugamuga/mattermost-yandex-calendar-plugin, демонстрирует работу с яндекс календарём, есть документация https://pkg.go.dev/github.com/lugamuga/mattermost-yandex-calendar-plugin
- Проекту https://github.com/python-caldav/caldav , очень простые интерфейс-запросы на сервер, вся тяжелейшая работа под капотом пакета. Отличная документация, но есть ещё над чем поработать(фильтрация по параметрам). Документация - https://caldav.readthedocs.io/en/latest/
- Проекту https://github.com/agronholm/apscheduler . Отличный инструмент для планирования нетривиальных задач, триггеров, настройка планировщика и БД. Документация - https://pypi.org/project/APScheduler/
- Проекту mattermostautodriver, форк от заброшенного проекта mattermostdriver . На данный момент поддерживается, соответствует последним изменения и дополнениям Mattermost API Reference (4.0.0). Документация https://embl-bio-it.github.io/python-mattermost-autodriver/ . Есть возможность автоматически обновить конечные точки Mattermost API в пакете драйвера https://github.com/embl-bio-it/python-mattermost-autodriver#updating-openapi-specification Драйвер не упоминается на сайте Mattermost, однако имеет все шансы быть официальным. Требуется дальнейшая доработка
- Василию, очень интересный проект
- Анастасии, помощь в разработке и тестировании приложения