714 lines
28 KiB
HTML
714 lines
28 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Medods{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="container-fluid">
|
|
<h3 class="mb-4">🔌 Medods API</h3>
|
|
|
|
<!-- Настройка подключения -->
|
|
<div class="card mb-4">
|
|
<div class="card-header bg-primary text-white">
|
|
<h5 class="mb-0">Подключение к серверу</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<form id="serverForm">
|
|
<div class="row g-3 align-items-end">
|
|
<div class="col-md-9">
|
|
<label class="form-label">URL адрес сервера</label>
|
|
<input type="text" class="form-control" id="server_url" placeholder="https://api.example.com"
|
|
required>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<button type="button" class="btn btn-success w-100" onclick="saveServerUrl()">
|
|
💾 Сохранить
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
|
|
<hr class="my-4">
|
|
|
|
<form id="apiKeyForm" enctype="multipart/form-data">
|
|
<div class="row g-3 align-items-end">
|
|
<div class="col-md-9">
|
|
<label class="form-label">Загрузка API ключа</label>
|
|
<input type="file" class="form-control" id="api_key_file" accept=".csv" required>
|
|
<div class="form-text">
|
|
Файл формата CSV с колонками: identity;secretKey
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<button type="button" class="btn btn-primary w-100" onclick="uploadApiKey()">
|
|
📤 Загрузить apiKey.csv
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Аккордеон с запросами -->
|
|
<div class="accordion mb-4" id="requestsAccordion">
|
|
<div class="accordion-item">
|
|
<h2 class="accordion-header">
|
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
|
data-bs-target="#requestsCollapse" aria-expanded="false" aria-controls="requestsCollapse">
|
|
⚙️ Настроенные запросы
|
|
</button>
|
|
</h2>
|
|
<div id="requestsCollapse" class="accordion-collapse collapse" data-bs-parent="#requestsAccordion">
|
|
<div class="accordion-body">
|
|
<!-- Форма создания/редактирования запроса -->
|
|
<div class="card mb-4">
|
|
<div class="card-header">
|
|
<h5 class="mb-0">Добавить/Редактировать запрос</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<form id="requestForm">
|
|
<input type="hidden" id="requestId">
|
|
|
|
<div class="row g-3 mb-3">
|
|
<div class="col-md-6">
|
|
<label class="form-label">Название запроса</label>
|
|
<input type="text" class="form-control" id="title"
|
|
placeholder="Получить список пользователей" required>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label">HTTP метод</label>
|
|
<select class="form-select" id="method" required>
|
|
<option value="GET">GET</option>
|
|
<option value="POST">POST</option>
|
|
<option value="PUT">PUT</option>
|
|
<option value="DELETE">DELETE</option>
|
|
<option value="PATCH">PATCH</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label">URL путь</label>
|
|
<input type="text" class="form-control" id="url_path" placeholder="/users"
|
|
required>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Динамические параметры Query -->
|
|
<div class="mb-4">
|
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
<label class="form-label fw-bold">Параметры Query</label>
|
|
<button type="button" class="btn btn-sm btn-outline-primary"
|
|
onclick="addQueryParam()">
|
|
➕ Добавить параметр
|
|
</button>
|
|
</div>
|
|
<div id="queryParamsContainer">
|
|
<!-- Поля будут добавляться динамически -->
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Динамические параметры Payload -->
|
|
<div class="mb-4">
|
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
<label class="form-label fw-bold">Параметры Payload (для POST/PUT/PATCH)</label>
|
|
<button type="button" class="btn btn-sm btn-outline-primary"
|
|
onclick="addPayloadParam()">
|
|
➕ Добавить параметр
|
|
</button>
|
|
</div>
|
|
<div id="payloadParamsContainer">
|
|
<!-- Поля будут добавляться динамически -->
|
|
</div>
|
|
</div>
|
|
|
|
<div class="d-flex gap-2">
|
|
<button type="button" class="btn btn-success" onclick="saveRequest()">
|
|
💾 Сохранить запрос
|
|
</button>
|
|
<button type="button" class="btn btn-secondary" onclick="resetForm()">
|
|
🆕 Новый запрос
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Список существующих запросов -->
|
|
<div id="requestsList">
|
|
<div class="text-center">
|
|
<div class="spinner-border text-primary" role="status">
|
|
<span class="visually-hidden">Загрузка...</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Раздел выполнения запросов -->
|
|
<div class="card">
|
|
<div class="card-header bg-info text-white">
|
|
<h5 class="mb-0">📥 Выполнение запроса</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<form id="executeForm">
|
|
<div class="row g-3 align-items-end mb-4">
|
|
<div class="col-md-10">
|
|
<label class="form-label">Выберите запрос для выполнения</label>
|
|
<select class="form-select" id="requestSelect" required>
|
|
<option value="" disabled selected>Выберите запрос...</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-md-2">
|
|
<button type="button" class="btn btn-warning w-100" onclick="executeRequest()">
|
|
🚀 Отправить запрос
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
|
|
<!-- Окно с результатом -->
|
|
<div id="responseSection" style="display: none;">
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<h6>Результат выполнения:</h6>
|
|
<button type="button" class="btn btn-sm btn-outline-success" onclick="downloadResponse()">
|
|
⬇️ Скачать JSON
|
|
</button>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<div id="responseContainer" class="response-container"
|
|
style="max-height: 500px; overflow-y: auto;">
|
|
<!-- Ответ будет отображен здесь -->
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.response-container {
|
|
font-family: 'Courier New', monospace;
|
|
font-size: 14px;
|
|
line-height: 1.4;
|
|
background-color: #f8f9fa;
|
|
padding: 15px;
|
|
border-radius: 5px;
|
|
}
|
|
|
|
.json-key {
|
|
color: #92278f;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.json-string {
|
|
color: #3ab54a;
|
|
}
|
|
|
|
.json-number {
|
|
color: #25aae2;
|
|
}
|
|
|
|
.json-boolean {
|
|
color: #f98280;
|
|
}
|
|
|
|
.json-null {
|
|
color: #f1592a;
|
|
}
|
|
|
|
.param-row {
|
|
margin-bottom: 8px;
|
|
padding: 8px;
|
|
background-color: #f8f9fa;
|
|
border-radius: 4px;
|
|
border-left: 4px solid #0d6efd;
|
|
}
|
|
|
|
.accordion-button:not(.collapsed) {
|
|
background-color: #e7f1ff;
|
|
color: #0c63e4;
|
|
}
|
|
</style>
|
|
|
|
<script>
|
|
// Сохранение URL сервера
|
|
async function saveServerUrl() {
|
|
const serverUrl = document.getElementById('server_url').value;
|
|
|
|
if (!serverUrl) {
|
|
alert('Пожалуйста, введите URL сервера');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/settings/medods_url', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ url: serverUrl })
|
|
});
|
|
|
|
if (response.ok) {
|
|
alert('URL сервера сохранен!');
|
|
} else {
|
|
const error = await response.text();
|
|
alert('Ошибка сохранения: ' + error);
|
|
}
|
|
} catch (error) {
|
|
console.error('Ошибка:', error);
|
|
alert('Ошибка сохранения!');
|
|
}
|
|
}
|
|
|
|
// Загрузка API ключа
|
|
async function uploadApiKey() {
|
|
const fileInput = document.getElementById('api_key_file');
|
|
const file = fileInput.files[0];
|
|
|
|
if (!file) {
|
|
alert('Пожалуйста, выберите файл');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const text = await file.text();
|
|
const lines = text.split('\n');
|
|
|
|
if (lines.length < 2) {
|
|
alert('Файл должен содержать заголовок и данные');
|
|
return;
|
|
}
|
|
|
|
const headers = lines[0].split(';').map(h => h.trim());
|
|
if (!headers.includes('identity') || !headers.includes('secretKey')) {
|
|
alert('Файл должен содержать колонки: identity и secretKey');
|
|
return;
|
|
}
|
|
|
|
const data = {};
|
|
for (let i = 1; i < lines.length; i++) {
|
|
if (lines[i].trim()) {
|
|
const values = lines[i].split(';').map(v => v.trim());
|
|
if (values.length >= 2) {
|
|
data[values[0]] = values[1];
|
|
}
|
|
}
|
|
}
|
|
|
|
const response = await fetch('/settings/medods_apikey', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(data)
|
|
});
|
|
|
|
if (response.ok) {
|
|
alert('API ключ загружен!');
|
|
fileInput.value = '';
|
|
} else {
|
|
const error = await response.text();
|
|
alert('Ошибка загрузки: ' + error);
|
|
}
|
|
} catch (error) {
|
|
console.error('Ошибка:', error);
|
|
alert('Ошибка загрузки файла!');
|
|
}
|
|
}
|
|
|
|
// Загрузка запросов при раскрытии аккордеона
|
|
document.getElementById('requestsAccordion').addEventListener('show.bs.collapse', function () {
|
|
loadRequests();
|
|
});
|
|
|
|
// Загрузка списка запросов
|
|
async function loadRequests() {
|
|
try {
|
|
const response = await fetch('/settings/requests');
|
|
const requests = await response.json();
|
|
|
|
// Обновляем выпадающий список для выполнения
|
|
const select = document.getElementById('requestSelect');
|
|
select.innerHTML = '<option value="" disabled selected>Выберите запрос...</option>';
|
|
requests.forEach(req => {
|
|
const option = document.createElement('option');
|
|
option.value = req.id;
|
|
option.textContent = `${req.id} - ${req.title}`;
|
|
select.appendChild(option);
|
|
});
|
|
|
|
// Отображаем список запросов
|
|
const container = document.getElementById('requestsList');
|
|
container.innerHTML = '';
|
|
|
|
if (requests.length === 0) {
|
|
container.innerHTML = '<div class="alert alert-info">Нет настроенных запросов</div>';
|
|
return;
|
|
}
|
|
|
|
requests.forEach(request => {
|
|
const card = document.createElement('div');
|
|
card.className = 'card mb-2';
|
|
card.innerHTML = `
|
|
<div class="card-body">
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<div>
|
|
<h6 class="mb-1">${request.title}</h6>
|
|
<small class="text-muted">
|
|
${request.method} ${request.url_path}
|
|
</small>
|
|
</div>
|
|
<div class="btn-group">
|
|
<button class="btn btn-sm btn-outline-primary" onclick="editRequest(${request.id})">
|
|
✏️
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-danger" onclick="deleteRequest(${request.id})">
|
|
🗑️
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
container.appendChild(card);
|
|
});
|
|
|
|
// Сохраняем запросы для использования
|
|
window.requestsData = requests;
|
|
} catch (error) {
|
|
console.error('Ошибка загрузки запросов:', error);
|
|
document.getElementById('requestsList').innerHTML =
|
|
'<div class="alert alert-danger">Ошибка загрузки запросов</div>';
|
|
}
|
|
}
|
|
|
|
// Добавление параметра Query
|
|
function addQueryParam(key = '', value = '') {
|
|
const container = document.getElementById('queryParamsContainer');
|
|
const div = document.createElement('div');
|
|
div.className = 'row g-2 param-row';
|
|
div.innerHTML = `
|
|
<div class="col-md-5">
|
|
<input type="text" class="form-control param-key" placeholder="Ключ" value="${key}">
|
|
</div>
|
|
<div class="col-md-5">
|
|
<input type="text" class="form-control param-value" placeholder="Значение" value="${value}">
|
|
</div>
|
|
<div class="col-md-2">
|
|
<button type="button" class="btn btn-sm btn-outline-danger w-100" onclick="this.parentElement.parentElement.remove()">
|
|
🗑️
|
|
</button>
|
|
</div>
|
|
`;
|
|
container.appendChild(div);
|
|
}
|
|
|
|
// Добавление параметра Payload
|
|
function addPayloadParam(key = '', value = '') {
|
|
const container = document.getElementById('payloadParamsContainer');
|
|
const div = document.createElement('div');
|
|
div.className = 'row g-2 param-row';
|
|
div.innerHTML = `
|
|
<div class="col-md-5">
|
|
<input type="text" class="form-control param-key" placeholder="Ключ" value="${key}">
|
|
</div>
|
|
<div class="col-md-5">
|
|
<input type="text" class="form-control param-value" placeholder="Значение" value="${value}">
|
|
</div>
|
|
<div class="col-md-2">
|
|
<button type="button" class="btn btn-sm btn-outline-danger w-100" onclick="this.parentElement.parentElement.remove()">
|
|
🗑️
|
|
</button>
|
|
</div>
|
|
`;
|
|
container.appendChild(div);
|
|
}
|
|
|
|
// Редактирование запроса
|
|
function editRequest(id) {
|
|
const request = window.requestsData.find(r => r.id === id);
|
|
if (!request) return;
|
|
|
|
document.getElementById('requestId').value = request.id;
|
|
document.getElementById('title').value = request.title;
|
|
document.getElementById('method').value = request.method;
|
|
document.getElementById('url_path').value = request.url_path;
|
|
|
|
// Очищаем контейнеры параметров
|
|
document.getElementById('queryParamsContainer').innerHTML = '';
|
|
document.getElementById('payloadParamsContainer').innerHTML = '';
|
|
|
|
// Добавляем query параметры
|
|
if (request.query && typeof request.query === 'object') {
|
|
Object.entries(request.query).forEach(([key, value]) => {
|
|
addQueryParam(key, value);
|
|
});
|
|
}
|
|
|
|
// Добавляем payload параметры
|
|
if (request.payload && typeof request.payload === 'object') {
|
|
Object.entries(request.payload).forEach(([key, value]) => {
|
|
addPayloadParam(key, typeof value === 'object' ? JSON.stringify(value) : value);
|
|
});
|
|
}
|
|
|
|
// Прокручиваем к форме
|
|
document.getElementById('requestForm').scrollIntoView({ behavior: 'smooth' });
|
|
}
|
|
|
|
// Сброс формы
|
|
function resetForm() {
|
|
document.getElementById('requestForm').reset();
|
|
document.getElementById('requestId').value = '';
|
|
document.getElementById('queryParamsContainer').innerHTML = '';
|
|
document.getElementById('payloadParamsContainer').innerHTML = '';
|
|
}
|
|
|
|
// Сохранение запроса
|
|
async function saveRequest() {
|
|
const id = document.getElementById('requestId').value;
|
|
const title = document.getElementById('title').value;
|
|
const method = document.getElementById('method').value;
|
|
const url_path = document.getElementById('url_path').value;
|
|
|
|
if (!title || !method || !url_path) {
|
|
alert('Пожалуйста, заполните все обязательные поля');
|
|
return;
|
|
}
|
|
|
|
// Собираем query параметры
|
|
const query = {};
|
|
document.querySelectorAll('#queryParamsContainer .param-row').forEach(row => {
|
|
const key = row.querySelector('.param-key').value;
|
|
const value = row.querySelector('.param-value').value;
|
|
if (key) query[key] = value;
|
|
});
|
|
|
|
// Собираем payload параметры
|
|
const payload = {};
|
|
document.querySelectorAll('#payloadParamsContainer .param-row').forEach(row => {
|
|
const key = row.querySelector('.param-key').value;
|
|
const value = row.querySelector('.param-value').value;
|
|
if (key) {
|
|
// Пробуем парсить JSON, если это объект
|
|
try {
|
|
payload[key] = JSON.parse(value);
|
|
} catch {
|
|
payload[key] = value;
|
|
}
|
|
}
|
|
});
|
|
|
|
const requestData = {
|
|
title,
|
|
method,
|
|
url_path,
|
|
query,
|
|
payload
|
|
};
|
|
|
|
if (id) {
|
|
requestData.id = parseInt(id);
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/settings/requests', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(requestData)
|
|
});
|
|
|
|
if (response.ok) {
|
|
alert('Запрос сохранен!');
|
|
resetForm();
|
|
loadRequests();
|
|
} else {
|
|
const error = await response.text();
|
|
alert('Ошибка сохранения: ' + error);
|
|
}
|
|
} catch (error) {
|
|
console.error('Ошибка:', error);
|
|
alert('Ошибка сохранения!');
|
|
}
|
|
}
|
|
|
|
// Удаление запроса
|
|
async function deleteRequest(id) {
|
|
if (!confirm('Вы уверены, что хотите удалить этот запрос?')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/settings/requests/${id}`, {
|
|
method: 'DELETE'
|
|
});
|
|
|
|
if (response.ok) {
|
|
alert('Запрос удален!');
|
|
loadRequests();
|
|
} else {
|
|
const error = await response.text();
|
|
alert('Ошибка удаления: ' + error);
|
|
}
|
|
} catch (error) {
|
|
console.error('Ошибка:', error);
|
|
alert('Ошибка удаления!');
|
|
}
|
|
}
|
|
|
|
// Выполнение запроса
|
|
async function executeRequest() {
|
|
const select = document.getElementById('requestSelect');
|
|
const requestId = select.value;
|
|
|
|
if (!requestId) {
|
|
alert('Пожалуйста, выберите запрос');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/settings/requests', {
|
|
method: 'PATCH',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ id: parseInt(requestId) })
|
|
});
|
|
|
|
const data = await response.json();
|
|
displayResponse(data);
|
|
document.getElementById('responseSection').style.display = 'block';
|
|
|
|
// Прокручиваем к результату
|
|
document.getElementById('responseSection').scrollIntoView({ behavior: 'smooth' });
|
|
} catch (error) {
|
|
console.error('Ошибка:', error);
|
|
displayResponse({ error: error.message });
|
|
document.getElementById('responseSection').style.display = 'block';
|
|
}
|
|
}
|
|
|
|
// Отображение ответа
|
|
function displayResponse(data, container = document.getElementById('responseContainer'), level = 0) {
|
|
container.innerHTML = '';
|
|
|
|
function formatValue(value, indent = 0) {
|
|
const indentStr = ' '.repeat(indent);
|
|
|
|
if (value === null) {
|
|
const span = document.createElement('span');
|
|
span.className = 'json-null';
|
|
span.textContent = 'null';
|
|
return span;
|
|
} else if (typeof value === 'boolean') {
|
|
const span = document.createElement('span');
|
|
span.className = 'json-boolean';
|
|
span.textContent = value.toString();
|
|
return span;
|
|
} else if (typeof value === 'number') {
|
|
const span = document.createElement('span');
|
|
span.className = 'json-number';
|
|
span.textContent = value;
|
|
return span;
|
|
} else if (typeof value === 'string') {
|
|
const span = document.createElement('span');
|
|
span.className = 'json-string';
|
|
span.textContent = `"${value}"`;
|
|
return span;
|
|
} else if (Array.isArray(value)) {
|
|
if (value.length === 0) {
|
|
return document.createTextNode('[]');
|
|
}
|
|
|
|
const div = document.createElement('div');
|
|
div.appendChild(document.createTextNode('['));
|
|
|
|
value.forEach((item, index) => {
|
|
const itemDiv = document.createElement('div');
|
|
itemDiv.style.paddingLeft = '20px';
|
|
itemDiv.appendChild(formatValue(item, indent + 1));
|
|
if (index < value.length - 1) {
|
|
itemDiv.appendChild(document.createTextNode(','));
|
|
}
|
|
div.appendChild(itemDiv);
|
|
});
|
|
|
|
div.appendChild(document.createTextNode(']'));
|
|
return div;
|
|
} else if (typeof value === 'object') {
|
|
const entries = Object.entries(value);
|
|
if (entries.length === 0) {
|
|
return document.createTextNode('{}');
|
|
}
|
|
|
|
const div = document.createElement('div');
|
|
div.appendChild(document.createTextNode('{'));
|
|
|
|
entries.forEach(([key, val], index) => {
|
|
const itemDiv = document.createElement('div');
|
|
itemDiv.style.paddingLeft = '20px';
|
|
|
|
const keySpan = document.createElement('span');
|
|
keySpan.className = 'json-key';
|
|
keySpan.textContent = `"${key}": `;
|
|
itemDiv.appendChild(keySpan);
|
|
|
|
itemDiv.appendChild(formatValue(val, indent + 1));
|
|
|
|
if (index < entries.length - 1) {
|
|
itemDiv.appendChild(document.createTextNode(','));
|
|
}
|
|
|
|
div.appendChild(itemDiv);
|
|
});
|
|
|
|
div.appendChild(document.createTextNode('}'));
|
|
return div;
|
|
}
|
|
|
|
return document.createTextNode(String(value));
|
|
}
|
|
|
|
container.appendChild(formatValue(data));
|
|
window.lastResponse = data;
|
|
}
|
|
|
|
// Скачивание ответа
|
|
function downloadResponse() {
|
|
if (!window.lastResponse) return;
|
|
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
const filename = `request_${timestamp}.json`;
|
|
const jsonStr = JSON.stringify(window.lastResponse, null, 2);
|
|
|
|
const blob = new Blob([jsonStr], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
function setServerUrlInput(data) {
|
|
const serverUrlInput = document.getElementById('server_url');
|
|
if (serverUrlInput && data) {
|
|
serverUrlInput.value = data.url;
|
|
serverUrlInput.disabled = true;
|
|
}
|
|
}
|
|
// Инициализация
|
|
document.addEventListener('DOMContentLoaded', function () {
|
|
// Добавляем примеры параметров
|
|
addQueryParam();
|
|
addPayloadParam();
|
|
setServerUrlInput(pageData);
|
|
});
|
|
</script>
|
|
{% endblock %} |