feat(orders): tg notifications, ya metrika, meta tags

This commit is contained in:
Nikita Kiselev
2025-08-03 09:39:51 +03:00
parent 454bd39f1f
commit 86d0fa9594
32 changed files with 1205 additions and 76 deletions

View File

@@ -0,0 +1,345 @@
<?php
class ControllerExtensionModuleTgshop extends Controller
{
private static array $themes = [
'light' => 'Светлая (light)',
'dark' => 'Тёмная (dark)',
'cupcake' => 'Капкейк (cupcake)',
'bumblebee' => 'Шмель (bumblebee)',
'emerald' => 'Изумруд (emerald)',
'corporate' => 'Корпоративная (corporate)',
'synthwave' => 'Синтвейв (synthwave)',
'retro' => 'Ретро (retro)',
'cyberpunk' => 'Киберпанк (cyberpunk)',
'valentine' => 'Валентинка (valentine)',
'halloween' => 'Хэллоуин (halloween)',
'garden' => 'Сад (garden)',
'forest' => 'Лес (forest)',
'aqua' => 'Аква (aqua)',
'lofi' => 'Лоу-фай (lofi)',
'pastel' => 'Пастель (pastel)',
'fantasy' => 'Фэнтези (fantasy)',
'wireframe' => 'Каркас (wireframe)',
'black' => 'Чёрная (black)',
'luxury' => 'Люкс (luxury)',
'dracula' => 'Дракула (dracula)',
'cmyk' => 'CMYK (cmyk)',
'autumn' => 'Осень (autumn)',
'business' => 'Бизнес (business)',
'acid' => 'Кислотная (acid)',
'lemonade' => 'Лимонад (lemonade)',
'night' => 'Ночная (night)',
'coffee' => 'Кофейная (coffee)',
'winter' => 'Зимняя (winter)',
'dim' => 'Тусклая (dim)',
'nord' => 'Нордическая (nord)',
'sunset' => 'Закат (sunset)',
'caramellatte' => 'Карамель-латте (caramellatte)',
'abyss' => 'Бездна (abyss)',
'silk' => 'Шёлк (silk)',
];
private $error = array();
public function index()
{
$this->load->language('extension/module/tgshop');
$this->load->model('setting/setting');
$hasConfig = $this->config->get('module_tgshop_app_name') !== null;
if ($hasConfig) {
$this->config();
} else {
$this->init();
}
}
private function config(): void
{
$data = [];
$this->document->setTitle($this->language->get('heading_title'));
if (($this->request->server['REQUEST_METHOD'] === 'POST') && $this->validate()) {
$this->model_setting_setting->editSetting('module_tgshop', $this->request->post);
$this->session->data['success'] = $this->language->get('text_success');
$this->response->redirect(
$this->url->link(
'extension/module/tgshop',
'user_token=' . $this->session->data['user_token'] . '&type=module',
true
)
);
}
$this->baseData($data);
$data['action'] = $this->url->link(
'extension/module/tgshop',
'user_token=' . $this->session->data['user_token'],
true
);
$data['settings'] = static::$settings;
foreach (static::$settings as $configs) {
foreach ($configs as $key => $config) {
if ($config['type'] === 'image') {
$this->load->model('tool/image');
if (isset($this->request->post[$key]) && is_file(DIR_IMAGE . $this->request->post[$key])) {
$data[$key] = $this->model_tool_image->resize($this->request->post[$key], 100, 100);
} elseif ($this->config->get($key) && is_file(DIR_IMAGE . $this->config->get($key))) {
$data[$key] = $this->model_tool_image->resize($this->config->get($key), 100, 100);
} else {
$data[$key] = $this->model_tool_image->resize('no_image.png', 100, 100);
}
} elseif ($config['type'] === 'products') {
$products = $this->request->post[$key] ?? $this->config->get($key) ?? [];
$this->load->model('catalog/product');
foreach ($products as $productId) {
$productItem = $this->model_catalog_product->getProduct($productId);
$data[$key][] = [
'product_id' => $productId,
'name' => $productItem['name'],
];
}
} else {
if (isset($this->request->post[$key])) {
$data[$key] = $this->request->post[$key];
} else {
$data[$key] = $this->config->get($key);
}
}
}
}
$this->response->setOutput($this->load->view('extension/module/tgshop', $data));
}
public function init(): void
{
$data = [];
$this->baseData($data);
$data['action'] = $this->url->link(
'extension/module/tgshop/init',
'user_token=' . $this->session->data['user_token'],
true
);
if ($this->request->server['REQUEST_METHOD'] === 'POST') {
$defaults = $this->getDefaultConfig();
$this->model_setting_setting->editSetting('module_tgshop', $defaults);
$this->session->data['success'] = 'Инициализация модуля выполнена успешно.';
$this->response->redirect(
$this->url->link(
'extension/module/tgshop',
'user_token=' . $this->session->data['user_token'],
true
)
);
}
$this->response->setOutput($this->load->view('extension/module/tgshop_init', $data));
}
protected function validate()
{
if (! $this->user->hasPermission('modify', 'extension/module/tgshop')) {
$this->error['error_warning'] = $this->language->get('error_permission');
}
foreach (static::$settings as $configs) {
foreach ($configs as $key => $config) {
if (($config['required'] ?? false) === true && ! $this->request->post[$key]) {
$this->error["error_$key"] = 'Поле "' . $this->language->get(
"lbl_$key"
) . '" обязательно для заполнения.';
}
}
}
return ! $this->error;
}
private function baseData(array &$data): void
{
$this->document->setTitle($this->language->get('heading_title'));
$data['header'] = $this->load->controller('common/header');
$data['column_left'] = $this->load->controller('common/column_left');
$data['footer'] = $this->load->controller('common/footer');
$data['cancel'] = $this->url->link(
'marketplace/extension',
'user_token=' . $this->session->data['user_token'] . '&type=module',
true
);
$data = array_merge($data, $this->error);
$data['breadcrumbs'] = array();
$data['breadcrumbs'][] = array(
'text' => $this->language->get('text_home'),
'href' => $this->url->link('common/dashboard', 'user_token=' . $this->session->data['user_token'], true)
);
$data['breadcrumbs'][] = array(
'text' => $this->language->get('text_module'),
'href' => $this->url->link(
'marketplace/extension',
'user_token=' . $this->session->data['user_token'] . '&type=module',
true
)
);
$data['breadcrumbs'][] = array(
'text' => $this->language->get('heading_title'),
'href' => $this->url->link(
'extension/module/tgshop',
'user_token=' . $this->session->data['user_token'],
true
)
);
if (isset($this->session->data['success'])) {
$data['success'] = $this->session->data['success'];
unset($this->session->data['success']);
} else {
$data['success'] = '';
}
$data['user_token'] = $this->session->data['user_token'];
}
private function getDefaultConfig(): array
{
return [
'module_tgshop_status' => 1,
'module_tgshop_app_name' => $this->config->get('config_meta_title'),
'module_tgshop_app_icon' => $this->config->get('config_image') ?: $this->model_tool_image->resize(
'no_image.png',
100,
100
),
'module_tgshop_owner_notification_template' => 'Новый заказ!',
'module_tgshop_theme_light' => 'light',
'module_tgshop_theme_dark' => 'dark',
'module_tgshop_mainpage_products' => 'most_viewed',
'module_tgshop_featured_products' => [],
];
}
private function getSettingsConfig(): array
{
return [
'general' => [
'module_tgshop_status' => [
'type' => 'select',
'options' => [
0 => 'Выключено',
1 => 'Включено',
],
'help' => '',
],
'module_tgshop_app_name' => [
'hidden' => true,
'required' => true,
'type' => 'text',
'placeholder' => 'Введите название Телеграм магазина',
'help' => <<<TEXT
Отображается в заголовке Telegram Mini App при запуске, а также используется как подпись
под иконкой, если пользователь добавит приложение на главный экран своего устройства.
Рекомендуется короткое и понятное название (до 20 символов).
TEXT,
],
'module_tgshop_app_icon' => [
'hidden' => true,
'type' => 'image',
'help' => <<<TEXT
Изображение, которое будет отображаться в Telegram Mini App и на рабочем столе устройства,
если пользователь добавит приложение как ярлык. Используйте квадратное изображение PNG или SVG,
размером не менее 192×192 пикселей, а лучше 512x512.
TEXT,
],
'module_tgshop_theme_light' => [
'type' => 'select',
'options' => static::$themes,
'help' => 'Выберите стиль, который будет использоваться при отображении вашего магазина в Telegram для дневного режима. <a href="https://daisyui.com/docs/themes/#list-of-themes" target="_blank">Посмотреть как выглядят темы</a>',
],
'module_tgshop_theme_dark' => [
'type' => 'select',
'options' => static::$themes,
'help' => 'Выберите стиль, который будет использоваться при отображении вашего магазина в Telegram для ночного режима. <a href="https://daisyui.com/docs/themes/#list-of-themes" target="_blank">Посмотреть как выглядят темы</a>',
],
],
'telegram' => [
'module_tgshop_bot_token' => [
'type' => 'text',
'placeholder' => 'Введите токен от телеграм бота',
'help' => <<<TEXT
Токен, полученный при создании бота через @BotFather.
Он используется для взаимодействия модуля с Telegram API.
TEXT,
],
'module_tgshop_chat_id' => [
'type' => 'chatid',
'placeholder' => 'Введите Chat ID',
'help' => <<<TEXT
Идентификатор Telegram-чата, куда будут отправляться уведомления о новых заказах.
Если оставить поле пустым, уведомления отправляться не будут.
TEXT,
],
'module_tgshop_owner_notification_template' => [
'type' => 'tg_message_template',
'placeholder' => 'Введите текст уведомления',
'rows' => 15,
'help' => 'Введите шаблон сообщения для Telegram-уведомлений о новом заказе владельцу магазина.',
],
'module_tgshop_customer_notification_template' => [
'type' => 'tg_message_template',
'placeholder' => 'Введите текст уведомления',
'rows' => 15,
'help' => 'Введите шаблон сообщения для Telegram-уведомлений о новом заказе покупателю.',
],
],
'statistics' => [
'module_tgshop_yandex_metrika' => [
'type' => 'textarea',
'placeholder' => 'Вставьте код счётчика Яндекс Метрики',
'rows' => 15,
'help' => ''
],
],
'shop' => [
'module_tgshop_mainpage_products' => [
'type' => 'select',
'options' => [
'most_viewed' => 'Популярные товары',
'featured' => 'Избранные товары (задать в поле ниже)',
],
'help' => 'Выберите, какие товары показывать на главной странице магазина в Telegram. Это влияет на первую видимую секцию каталога для пользователя.',
],
'module_tgshop_featured_products' => [
'type' => 'products',
'help' => 'На главной странице будут отображаться избранные товары, если вы выберете этот вариант в настройке “Товары на главной”.',
],
],
'orders' => [
],
];
}
}

View File

@@ -0,0 +1,32 @@
<?php
// Heading
$_['heading_title'] = 'Telegram Магазин';
$_['text_module'] = 'Модули';
$_['text_success'] = 'Настройки успешно изменены!';
$_['text_edit'] = 'Настройки';
$_['tab_telegram'] = 'Telegram';
$_['tab_statistics'] = 'Статистика';
$_['tab_shop'] = 'Магазин';
$_['tab_orders'] = 'Заказы';
$_['lbl_module_tgshop_status'] = 'Статус';
$_['lbl_module_tgshop_app_name'] = 'Название приложения';
$_['lbl_module_tgshop_app_icon'] = 'Иконка приложения';
$_['lbl_module_tgshop_bot_token'] = 'Telegram Bot Token';
$_['lbl_module_tgshop_chat_id'] = 'Chat ID для уведомлений';
$_['lbl_module_tgshop_owner_notification_template'] = 'Шаблон уведомления о новом заказе владельцу';
$_['lbl_module_tgshop_customer_notification_template'] = 'Шаблон уведомления о новом заказе покупателю';
$_['lbl_module_tgshop_yandex_metrika'] = 'Код счётчика Яндекс Метрики';
$_['lbl_module_tgshop_theme_light'] = 'Светлая тема';
$_['lbl_module_tgshop_theme_dark'] = 'Тёмная тема';
$_['lbl_module_tgshop_mainpage_products'] = 'Товары на главной';
$_['lbl_module_tgshop_featured_products'] = 'Избранные товары';
// Entry
$_['entry_status'] = 'Статус';
// Error
$_['error_permission'] = 'У вас недостаточно прав для внесения изменений!';

View File

@@ -0,0 +1,324 @@
{{ header }}{{ column_left }}
<div id="content">
<div class="page-header">
<div class="container-fluid">
<div class="pull-right">
<button type="submit" form="form-module" data-toggle="tooltip" title="{{ button_save }}"
class="btn btn-primary"><i class="fa fa-save"></i></button>
<a href="{{ cancel }}" data-toggle="tooltip" title="{{ button_cancel }}" class="btn btn-default"><i
class="fa fa-reply"></i></a></div>
<h1>{{ heading_title }}</h1>
<ul class="breadcrumb">
{% for breadcrumb in breadcrumbs %}
<li><a href="{{ breadcrumb.href }}">{{ breadcrumb.text }}</a></li>
{% endfor %}
</ul>
</div>
</div>
<div class="container-fluid">
{% if error_warning %}
<div class="alert alert-danger alert-dismissible"><i
class="fa fa-exclamation-circle"></i> {{ error_warning }}
<button type="button" class="close" data-dismiss="alert">&times;</button>
</div>
{% endif %}
{% if success %}
<div class="alert alert-success alert-dismissible"><i class="fa fa-check-circle"></i> {{ success }}
<button type="button" class="close" data-dismiss="alert">&times;</button>
</div>
{% endif %}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title"><i class="fa fa-pencil"></i> {{ text_edit }}</h3>
</div>
<div class="panel-body">
<form action="{{ action }}" method="post" enctype="multipart/form-data" id="form-module"
class="form-horizontal">
<pre>
* Проверка request от телеграм
4. Выбор товаров, которые будут отображаться на главной странице
1. Шаблон для уведомлений покупателя о заказе
2. Требовать ввод email/phone
3. Группа покупателей для заказов от ТГ
</pre>
<ul class="nav nav-tabs">
{% for tabKey, tabItems in settings %}
<li{% if tabKey == 'general' %} class="active" {% endif %}>
<a href="#{{ tabKey }}" data-toggle="tab">
{% if attribute(_context, 'tab_' ~ tabKey) %}
{{ attribute(_context, 'tab_' ~ tabKey) }}
{% else %}
{{ 'tab_' ~ tabKey }}
{% endif %}
</a>
</li>
{% endfor %}
</ul>
<div class="tab-content">
{% for tabKey, tabItems in settings %}
<div class="tab-pane{%if tabKey == 'general' %} active{% endif %}" id="{{ tabKey }}">
{% for settingKey, item in tabItems %}
<div class="form-group{%if item['required'] %} required{% endif %}{% if item['hidden'] %} hidden{% endif %}">
<label class="col-sm-2 control-label" for="{{ settingKey }}">
{% if attribute(_context, 'lbl_' ~ settingKey) %}
{{ attribute(_context, 'lbl_' ~ settingKey) }}
{% else %}
{{ 'lbl_' ~ settingKey }}
{% endif %}
</label>
<div class="col-sm-10">
{# Select #}
{% if item['type'] == 'select' %}
<select name="{{ settingKey }}" id="{{ settingKey }}" class="form-control">
{% for key, value in item['options'] %}
<option value="{{ key }}" {% if key == attribute(_context, settingKey) %}selected="selected"{% endif %}>
{{ value }}
</option>
{% endfor %}
</select>
{# Text Input #}
{% elseif item['type'] == 'text' %}
<input type="text"
name="{{ settingKey }}"
value="{{ attribute(_context, settingKey) }}"
placeholder="{{ item['placeholder'] }}"
id="{{ settingKey }}"
class="form-control"
/>
{# Image #}
{% elseif item['type'] == 'image' %}
<a href="" id="thumb-image" data-toggle="image" class="img-thumbnail">
<img src="{{ attribute(_context, settingKey) }}"
data-placeholder="{{ attribute(_context, settingKey) }}"
/>
</a>
<input type="hidden"
name="{{ settingKey }}"
value="{{ attribute(_context, settingKey) }}"
id="{{ settingKey }}"
/>
{# Image #}
{% elseif item['type'] == 'textarea' %}
<textarea name="{{ settingKey }}"
rows="{{ item['rows'] }}"
placeholder="{{ item['placeholder'] }}"
id="{{ settingKey }}"
class="form-control"
>{{ attribute(_context, settingKey) }}</textarea>
{# Products #}
{% elseif item['type'] == 'products' %}
<input type="text" value="" placeholder="Начните вводить название товара..." id="{{ settingKey }}-input" class="form-control"/>
<div id="{{ settingKey }}-list" class="well well-sm" style="height: 150px; overflow: auto;">
{% for product in attribute(_context, settingKey) %}
<div id="{{ settingKey }}-{{ product.product_id }}">
<i class="fa fa-minus-circle"></i> {{ product.name }}
<input type="hidden" name="{{ settingKey }}[]" value="{{ product.product_id }}"/>
</div>
{% endfor %}
</div>
<script>
$('#{{ settingKey }}-input').autocomplete({
'source': function(request, response) {
$.ajax({
url: 'index.php?route=catalog/product/autocomplete&user_token={{ user_token }}&filter_name=' + encodeURIComponent(request),
dataType: 'json',
success: function(json) {
response($.map(json, function(item) {
return {
label: item['name'],
value: item['product_id']
}
}));
}
});
},
'select': function(item) {
$('#{{ settingKey }}').val('');
$('#{{ settingKey }}-' + item['value']).remove();
$('#{{ settingKey }}-list').append('<div id="{{ settingKey }}-' + item['value'] + '"><i class="fa fa-minus-circle"></i> ' + item['label'] + '<input type="hidden" name="{{ settingKey }}[]" value="' + item['value'] + '" /></div>');
}
});
$('#{{ settingKey }}-list').delegate('.fa-minus-circle', 'click', function() {
$(this).parent().remove();
});
</script>
{# ChatID #}
{% elseif item['type'] == 'chatid' %}
<div class="input-group">
<span class="input-group-btn">
<button id="{{ settingKey }}-btn" class="btn btn-primary" type="button">
<i class="fa fa-refresh"></i> Получить Chat ID
</button>
</span>
<input type="text"
name="{{ settingKey }}"
value="{{ attribute(_context, settingKey) }}"
placeholder="{{ item['placeholder'] }}"
id="{{ settingKey }}"
class="form-control"
/>
<script>
$('#{{ settingKey }}-btn').click(function () {
const telegramToken = $('#module_tgshop_bot_token').val(); // fetch from input
if (! telegramToken) {
alert('Сначала введите Telegram Bot Token!');
return;
}
fetch(`https://api.telegram.org/bot${telegramToken}/getUpdates`)
.then(res => res.json())
.then(data => {
if (!data.ok || !data.result.length) {
alert('Не удалось получить обновления от бота. Убедитесь, что вы написали боту сообщение.');
return;
}
// Ищем последнее сообщение с chat_id
const lastMessage = data.result.reverse().find(update => update.message && update.message.chat);
if (!lastMessage) {
alert('Не найдено сообщений с chat_id.');
return;
}
const chatId = lastMessage.message.chat.id;
$('#{{ settingKey }}').val(chatId); // подставляем в поле
})
.catch(err => {
console.error(err);
alert('Ошибка при получении chat_id. Проверьте токен и соединение.');
});
});
</script>
</div>
<button class="btn btn-link btn-xs" type="button" data-toggle="collapse" data-target="#{{ settingKey }}-collapse" aria-expanded="false" aria-controls="collapseExample">
Инструкция как получить ChatID.
</button>
<div class="collapse" id="{{ settingKey }}-collapse">
<div class="well">
<p class="text-primary">Как получить Chat ID</p>
<ol>
<li>Убедитесь, что вы ввели Telegram Bot Token выше.</li>
<li>Откройте вашего бота в Telegram и отправьте ему любое сообщение.</li>
<li>Вернитесь сюда и нажмите кнопку «Получить Chat ID» — мы автоматически подставим его в поле ниже.</li>
</ol>
</div>
</div>
{% elseif item['type'] == 'tg_message_template' %}
<div style="margin-bottom: 10px;">
<textarea name="{{ settingKey }}"
rows="{{ item['rows'] }}"
placeholder="{{ item['placeholder'] }}"
id="{{ settingKey }}"
class="form-control"
>{{ attribute(_context, settingKey) }}</textarea>
</div>
<div>
<button class="btn btn-link" type="button" data-toggle="collapse" data-target="#{{ settingKey }}-collapse">
Документация
</button>
<button id="{{ settingKey }}-btn-test" type="button" class="btn btn-primary btn-sm">
<i class="fa fa-envelope"></i>
Отправить тестовое уведомление
</button>
</div>
<div class="collapse" id="{{ settingKey }}-collapse" style="margin-top: 15px">
<div class="well">
<p>Вы можете использовать переменные:</p>
<ul>
<li><code>{store_name}</code> — название магазина</li>
<li><code>{order_id}</code> — номер заказа</li>
<li><code>{customer}</code> — имя и фамилия покупателя</li>
<li><code>{email}</code> — email покупателя</li>
<li><code>{phone}</code> — телефон</li>
<li><code>{comment}</code> — комментарий к заказу</li>
<li><code>{address}</code> — адрес доставки</li>
<li><code>{total}</code> — сумма заказа</li>
<li><code>{ip}</code> — IP покупателя</li>
<li><code>{created_at}</code> — дата и время создания заказа</li>
</ul>
<p>Форматирование: поддерживается <a href="https://core.telegram.org/bots/api#markdownv2-style" target="_blank">*MarkdownV2* <i class="fa fa-external-link"></i></a>.</p>
<p>Символы, которые нужно экранировать в тексте:</p>
<pre>_ * [ ] ( ) ~ ` > # + - = | { } . !</pre>
<p>Каждый из них нужно экранировать обратным слэшем \, если он не используется для форматирования. Например вместо <code>Заказ #123</code> нужно писать <code>Заказ \#123</code>.</p>
</div>
</div>
<script>
$('#{{ settingKey }}-btn-test').click(function () {
const telegramToken = $('#module_tgshop_bot_token').val(); // fetch from input
if (! telegramToken) {
alert('Сначала введите Telegram Bot Token!');
return;
}
const chatId = $('#module_tgshop_chat_id').val(); // fetch from input
if (! chatId) {
alert('Сначала введите Chat ID!');
return;
}
const template = $('#{{ settingKey }}').val();
if (! template) {
alert('Сначала задайте шаблон!');
return;
}
fetch('/index.php?route=extension/tgshop/handle&api_action=testTgMessage', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
token: telegramToken,
chat_id: chatId,
template: template
})
})
.then(res => res.json())
.then(response => {
alert(response.message || 'Уведомление успешно отправлено');
})
.catch(error => {
console.error(error);
alert('Ошибка при отправке тестового сообщения');
});
});
</script>
{% else %}
Unsupported {{ item|json_encode }}
{% endif %}
{% if attribute(_context, 'error_' ~ settingKey) %}
<div class="text-danger">{{ attribute(_context, 'error_' ~ settingKey) }}</div>
{% endif %}
{% if item['help'] %}
<p class="help-block">{{ item['help'] }}</p>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endfor %}
</div>
</form>
</div>
</div>
</div>
</div>
{{ footer }}

View File

@@ -0,0 +1,48 @@
{{ header }}{{ column_left }}
<div id="content">
<div class="page-header">
<div class="container-fluid">
<div class="pull-right">
<a href="{{ cancel }}" data-toggle="tooltip" title="{{ button_cancel }}" class="btn btn-default"><i
class="fa fa-reply"></i></a></div>
<h1>{{ heading_title }}</h1>
<ul class="breadcrumb">
{% for breadcrumb in breadcrumbs %}
<li><a href="{{ breadcrumb.href }}">{{ breadcrumb.text }}</a></li>
{% endfor %}
</ul>
</div>
</div>
<div class="container-fluid">
{% if error_warning %}
<div class="alert alert-danger alert-dismissible"><i
class="fa fa-exclamation-circle"></i> {{ error_warning }}
<button type="button" class="close" data-dismiss="alert">&times;</button>
</div>
{% endif %}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title"><i class="fa fa-pencil"></i> Инициализация модуля</h3>
</div>
<div class="panel-body">
<div class="col-md-push-4 col-md-4 text-center">
<h2>Инициализация модуля</h2>
<p>Модуль запускается первый раз, поэтому необходимо инициализировать настройки для его корректной работы.</p>
<form action="{{ action }}" method="post" enctype="multipart/form-data" class="form-horizontal">
<button type="submit"
data-toggle="tooltip"
title="Нажмите чтобы выполнить начальную инициализацию модуля"
class="btn btn-primary"
>
Инициализация
</button>
</form>
</div>
</div>
</div>
</div>
</div>
{{ footer }}