Browse Source

Feat(API): Página interativa de documentação e teste de endpoints

Adiciona /api/doc/ com documentação auto-gerada de todos os endpoints
REST, incluindo testador interativo com suporte a GET, POST, PUT, PATCH
e DELETE, autenticação por token e busca por endpoints.
pull/3858/head
rangelbruno 7 months ago
parent
commit
0def78d1a9
  1. 6
      sapl/api/urls.py
  2. 62
      sapl/api/views.py
  3. 514
      sapl/templates/api/api_doc.html

6
sapl/api/urls.py

@ -4,7 +4,8 @@ from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, \
from rest_framework.authtoken.views import obtain_auth_token
from sapl.api.deprecated import SessaoPlenariaViewSet
from sapl.api.views import recria_token, SaplApiViewSetConstrutor
from sapl.api.views import recria_token, SaplApiViewSetConstrutor, \
ApiDocView, api_doc_data
from .apps import AppConfig
from .views_health import HealthzView, ReadyzView
@ -33,6 +34,9 @@ urlpatterns_api_doc = [
]
urlpatterns = [
url(r'^api/doc/$', ApiDocView.as_view(), name='api_doc'),
url(r'^api/doc/data/$', api_doc_data, name='api_doc_data'),
url(r'^api/', include(urlpatterns_api_doc)),
url(r'^api/', include(urlpatterns_router)),

62
sapl/api/views.py

@ -2,6 +2,7 @@ import logging
from django.conf import settings
from django.http import HttpResponse, JsonResponse
from django.views.generic import TemplateView
from rest_framework.authtoken.models import Token
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated, IsAdminUser
@ -22,6 +23,67 @@ def recria_token(request, pk):
return Response({"message": "Token recriado com sucesso!", "token": token.key})
class ApiDocView(TemplateView):
template_name = 'api/api_doc.html'
@api_view(['GET'])
@permission_classes([])
def api_doc_data(request):
"""Retorna metadados de todos os endpoints da API para a pagina de documentacao."""
apps_data = {}
for app_config, models_dict in ApiViewSetConstrutor._built_sets.items():
app_label = app_config.label
models_data = {}
for model, viewset_class in models_dict.items():
model_name = model._meta.model_name
# Determine allowed HTTP methods
allowed = getattr(viewset_class, 'http_method_names',
['get', 'post', 'put', 'patch', 'delete', 'head', 'options'])
relevant_methods = {'get': 'GET', 'post': 'POST', 'put': 'PUT',
'patch': 'PATCH', 'delete': 'DELETE'}
methods = [relevant_methods[m] for m in allowed if m in relevant_methods]
# Discover custom @action endpoints
actions = []
for attr_name in dir(viewset_class):
attr = getattr(viewset_class, attr_name, None)
if attr and hasattr(attr, 'mapping'):
url_path = getattr(attr, 'url_path', attr_name)
detail = getattr(attr, 'detail', False)
action_methods = [m.upper() for m in attr.mapping.keys() if m != '']
if not action_methods:
action_methods = ['GET']
if detail:
action_url = f'{app_label}/{model_name}/{{pk}}/{url_path}/'
else:
action_url = f'{app_label}/{model_name}/{url_path}/'
actions.append({
'name': attr_name,
'url': action_url,
'detail': detail,
'methods': action_methods,
})
models_data[model_name] = {
'url': f'{app_label}/{model_name}',
'methods': methods,
'verbose_name': str(model._meta.verbose_name),
'verbose_name_plural': str(model._meta.verbose_name_plural),
'actions': actions,
}
if models_data:
apps_data[app_label] = models_data
return Response({'apps': apps_data})
SaplApiViewSetConstrutor = ApiViewSetConstrutor
SaplApiViewSetConstrutor.import_modules([
'sapl.api.views_audiencia',

514
sapl/templates/api/api_doc.html

@ -0,0 +1,514 @@
{% extends "base.html" %}
{% load i18n %}
{% block head_title %}API - Documentacao{% endblock %}
{% block base_content %}
<style>
.api-doc { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
.api-doc h2 { border-bottom: 2px solid #dee2e6; padding-bottom: .5rem; }
.api-app-section { margin-bottom: 1.5rem; }
.api-app-section h4 { cursor: pointer; padding: .5rem .75rem; background: #f8f9fa; border-radius: .25rem; margin-bottom: 0; }
.api-app-section h4:hover { background: #e9ecef; }
.api-app-section h4 .badge { font-size: .75rem; }
.api-endpoint-list { display: none; }
.api-endpoint-list.show { display: block; }
.api-endpoint { border: 1px solid #dee2e6; border-radius: .25rem; margin: .5rem 0; }
.api-endpoint-header { padding: .5rem .75rem; cursor: pointer; display: flex; align-items: center; gap: .5rem; background: #fff; }
.api-endpoint-header:hover { background: #f8f9fa; }
.api-endpoint-body { display: none; padding: 1rem; border-top: 1px solid #dee2e6; background: #fdfdfe; }
.api-endpoint-body.show { display: block; }
.method-badge { display: inline-block; font-size: .7rem; font-weight: 700; padding: .15rem .4rem; border-radius: .2rem; color: #fff; min-width: 50px; text-align: center; }
.method-get { background: #28a745; }
.method-post { background: #007bff; }
.method-put { background: #fd7e14; }
.method-patch { background: #6f42c1; }
.method-delete { background: #dc3545; }
.method-head, .method-options { background: #6c757d; }
.endpoint-url { font-family: SFMono-Regular, Menlo, Monaco, monospace; font-size: .85rem; color: #333; }
.response-area { background: #1e1e1e; color: #d4d4d4; border-radius: .25rem; padding: 1rem; font-family: SFMono-Regular, Menlo, Monaco, monospace; font-size: .8rem; max-height: 400px; overflow: auto; white-space: pre-wrap; word-break: break-all; }
.param-input { font-size: .85rem; }
.auth-panel { background: #f0f7ff; border: 1px solid #b8daff; border-radius: .25rem; padding: 1rem; margin-bottom: 1.5rem; }
.filter-input { margin-bottom: 1rem; }
.btn-send { min-width: 100px; }
.status-code { font-weight: 700; }
.status-code.s2xx { color: #28a745; }
.status-code.s4xx { color: #fd7e14; }
.status-code.s5xx { color: #dc3545; }
.spinner-border-sm { width: 1rem; height: 1rem; border-width: .15em; }
.readonly-methods .method-badge { opacity: .5; font-size: .6rem; }
.tab-pane { padding-top: 1rem; }
.json-editor { width: 100%; min-height: 120px; font-family: SFMono-Regular, Menlo, Monaco, monospace; font-size: .8rem; border: 1px solid #ced4da; border-radius: .25rem; padding: .5rem; }
.quick-stats { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1rem; }
.quick-stats .card { min-width: 120px; text-align: center; }
.quick-stats .card-body { padding: .75rem; }
.quick-stats .display-6 { font-size: 1.5rem; font-weight: 700; }
</style>
<div class="api-doc">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="mb-0">API REST - Documentacao</h2>
<div>
<a href="{% url 'sapl.api:swagger_ui_schema_api' %}" class="btn btn-sm btn-outline-secondary" target="_blank">Swagger UI</a>
<a href="{% url 'sapl.api:redoc_schema_api' %}" class="btn btn-sm btn-outline-secondary" target="_blank">ReDoc</a>
</div>
</div>
<div class="quick-stats">
<div class="card">
<div class="card-body">
<div class="display-6" id="stat-apps">-</div>
<small class="text-muted">Apps</small>
</div>
</div>
<div class="card">
<div class="card-body">
<div class="display-6" id="stat-models">-</div>
<small class="text-muted">Models</small>
</div>
</div>
<div class="card">
<div class="card-body">
<div class="display-6" id="stat-endpoints">-</div>
<small class="text-muted">Endpoints</small>
</div>
</div>
</div>
<!-- Auth Panel -->
<div class="auth-panel">
<h5>Autenticacao</h5>
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label for="auth-token"><strong>Token</strong> <small class="text-muted">(Authorization: Token xxx)</small></label>
<div class="input-group">
<input type="text" id="auth-token" class="form-control form-control-sm" placeholder="Cole seu token aqui...">
<div class="input-group-append">
<button class="btn btn-sm btn-outline-secondary" onclick="clearToken()">Limpar</button>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label><strong>Obter token</strong></label>
<div class="input-group">
<input type="text" id="auth-username" class="form-control form-control-sm" placeholder="Usuario">
<input type="password" id="auth-password" class="form-control form-control-sm" placeholder="Senha">
<div class="input-group-append">
<button class="btn btn-sm btn-primary" onclick="obtainToken()">Obter Token</button>
</div>
</div>
</div>
</div>
</div>
<div id="auth-message" class="mt-2" style="display:none;"></div>
</div>
<!-- Search -->
<div class="filter-input">
<input type="text" id="search-endpoints" class="form-control" placeholder="Buscar endpoints... (ex: parlamentar, sessao, materia)">
</div>
<!-- Endpoints -->
<div id="api-endpoints-container">
<div class="text-center py-5">
<div class="spinner-border" role="status"></div>
<p class="mt-2 text-muted">Carregando endpoints...</p>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
fetch('{% url "sapl.api:api_doc_data" %}')
.then(r => r.json())
.then(data => renderEndpoints(data))
.catch(err => {
document.getElementById('api-endpoints-container').innerHTML =
'<div class="alert alert-danger">Erro ao carregar endpoints: ' + err.message + '</div>';
});
document.getElementById('search-endpoints').addEventListener('input', function() {
filterEndpoints(this.value.toLowerCase());
});
});
function getBaseUrl() {
return window.location.origin;
}
function getAuthHeaders() {
var headers = {'Content-Type': 'application/json'};
var token = document.getElementById('auth-token').value.trim();
if (token) {
headers['Authorization'] = 'Token ' + token;
}
return headers;
}
function obtainToken() {
var username = document.getElementById('auth-username').value;
var password = document.getElementById('auth-password').value;
var msgEl = document.getElementById('auth-message');
fetch(getBaseUrl() + '/api/auth/token', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({username: username, password: password})
})
.then(r => r.json().then(data => ({status: r.status, data: data})))
.then(result => {
if (result.data.token) {
document.getElementById('auth-token').value = result.data.token;
msgEl.className = 'mt-2 alert alert-success';
msgEl.textContent = 'Token obtido com sucesso!';
} else {
msgEl.className = 'mt-2 alert alert-danger';
msgEl.textContent = 'Erro: ' + JSON.stringify(result.data);
}
msgEl.style.display = 'block';
setTimeout(function() { msgEl.style.display = 'none'; }, 5000);
})
.catch(err => {
msgEl.className = 'mt-2 alert alert-danger';
msgEl.textContent = 'Erro de conexao: ' + err.message;
msgEl.style.display = 'block';
});
}
function clearToken() {
document.getElementById('auth-token').value = '';
}
function renderEndpoints(data) {
var container = document.getElementById('api-endpoints-container');
var html = '';
var totalModels = 0;
var totalEndpoints = 0;
var appCount = 0;
var apps = data.apps;
var appNames = Object.keys(apps).sort();
appNames.forEach(function(appName) {
appCount++;
var models = apps[appName];
var modelNames = Object.keys(models).sort();
var modelCount = modelNames.length;
totalModels += modelCount;
html += '<div class="api-app-section" data-app="' + appName + '">';
html += '<h4 onclick="toggleApp(this)">';
html += '<i class="fa fa-chevron-right mr-2"></i>';
html += appName;
html += ' <span class="badge badge-secondary">' + modelCount + ' models</span>';
html += '</h4>';
html += '<div class="api-endpoint-list">';
modelNames.forEach(function(modelName) {
var info = models[modelName];
var methods = info.methods || ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
var url = info.url;
var actions = info.actions || [];
totalEndpoints++;
html += '<div class="api-endpoint" data-search="' + appName + ' ' + modelName + ' ' + url + '">';
// Header
html += '<div class="api-endpoint-header" onclick="toggleEndpoint(this)">';
methods.forEach(function(m) {
html += '<span class="method-badge method-' + m.toLowerCase() + '">' + m + '</span>';
});
html += '<span class="endpoint-url">/api/' + url + '/</span>';
html += '<span class="text-muted ml-auto" style="font-size:.8rem;">' + modelName + '</span>';
html += '</div>';
// Body
html += '<div class="api-endpoint-body">';
html += buildEndpointTester(appName, modelName, url, methods, actions);
html += '</div>';
html += '</div>';
// Custom actions
actions.forEach(function(act) {
totalEndpoints++;
var actMethods = act.methods || ['GET'];
var actUrl = act.url;
html += '<div class="api-endpoint" data-search="' + appName + ' ' + modelName + ' ' + actUrl + ' ' + act.name + '">';
html += '<div class="api-endpoint-header" onclick="toggleEndpoint(this)">';
actMethods.forEach(function(m) {
html += '<span class="method-badge method-' + m.toLowerCase() + '">' + m + '</span>';
});
html += '<span class="endpoint-url">/api/' + actUrl + '</span>';
html += '<span class="badge badge-info ml-2">action</span>';
html += '<span class="text-muted ml-auto" style="font-size:.8rem;">' + act.name + '</span>';
html += '</div>';
html += '<div class="api-endpoint-body">';
html += buildActionTester(actUrl, actMethods);
html += '</div>';
html += '</div>';
});
});
html += '</div></div>';
});
container.innerHTML = html;
document.getElementById('stat-apps').textContent = appCount;
document.getElementById('stat-models').textContent = totalModels;
document.getElementById('stat-endpoints').textContent = totalEndpoints;
}
function buildEndpointTester(appName, modelName, url, methods, actions) {
var id = appName + '_' + modelName;
var fullUrl = '/api/' + url + '/';
var h = '';
h += '<ul class="nav nav-tabs" role="tablist">';
h += '<li class="nav-item"><a class="nav-link active" data-toggle="tab" href="#tab-list-' + id + '">Listar (GET)</a></li>';
if (methods.indexOf('POST') !== -1) {
h += '<li class="nav-item"><a class="nav-link" data-toggle="tab" href="#tab-create-' + id + '">Criar (POST)</a></li>';
}
if (methods.indexOf('PUT') !== -1) {
h += '<li class="nav-item"><a class="nav-link" data-toggle="tab" href="#tab-update-' + id + '">Atualizar (PUT)</a></li>';
}
if (methods.indexOf('DELETE') !== -1) {
h += '<li class="nav-item"><a class="nav-link" data-toggle="tab" href="#tab-delete-' + id + '">Deletar (DELETE)</a></li>';
}
h += '</ul>';
h += '<div class="tab-content">';
// GET tab
h += '<div class="tab-pane active" id="tab-list-' + id + '">';
h += '<div class="form-row align-items-end mb-2">';
h += '<div class="col-md-2"><label class="mb-0"><small>ID (opcional)</small></label>';
h += '<input type="text" class="form-control form-control-sm param-input" id="pk-' + id + '" placeholder="pk"></div>';
h += '<div class="col-md-3"><label class="mb-0"><small>Filtros</small></label>';
h += '<input type="text" class="form-control form-control-sm param-input" id="filter-' + id + '" placeholder="campo=valor&campo2=valor2"></div>';
h += '<div class="col-md-2"><label class="mb-0"><small>Pagina</small></label>';
h += '<input type="number" class="form-control form-control-sm param-input" id="page-' + id + '" placeholder="1" value="1" min="1"></div>';
h += '<div class="col-md-2"><label class="mb-0"><small>Itens/pag</small></label>';
h += '<input type="number" class="form-control form-control-sm param-input" id="psize-' + id + '" value="10" min="1" max="100"></div>';
h += '<div class="col-md-3">';
h += '<button class="btn btn-sm btn-success btn-send" onclick="sendGet(\'' + id + '\', \'' + fullUrl + '\')">Enviar GET</button></div>';
h += '</div>';
h += '<div class="response-area" id="resp-list-' + id + '">Clique em "Enviar GET" para testar...</div>';
h += '</div>';
// POST tab
if (methods.indexOf('POST') !== -1) {
h += '<div class="tab-pane" id="tab-create-' + id + '">';
h += '<p class="text-muted"><small>Envie um JSON com os campos do model. Os campos obrigatorios variam por model.</small></p>';
h += '<textarea class="json-editor" id="body-create-' + id + '" placeholder=\'{"campo": "valor"}\'></textarea>';
h += '<button class="btn btn-sm btn-primary btn-send mt-2" onclick="sendPost(\'' + id + '\', \'' + fullUrl + '\')">Enviar POST</button>';
h += '<div class="response-area mt-2" id="resp-create-' + id + '">...</div>';
h += '</div>';
}
// PUT tab
if (methods.indexOf('PUT') !== -1) {
h += '<div class="tab-pane" id="tab-update-' + id + '">';
h += '<div class="form-row mb-2">';
h += '<div class="col-md-2"><label class="mb-0"><small>ID (obrigatorio)</small></label>';
h += '<input type="text" class="form-control form-control-sm" id="pk-update-' + id + '" placeholder="pk"></div>';
h += '</div>';
h += '<textarea class="json-editor" id="body-update-' + id + '" placeholder=\'{"campo": "novo_valor"}\'></textarea>';
h += '<button class="btn btn-sm btn-warning btn-send mt-2" onclick="sendPut(\'' + id + '\', \'' + fullUrl + '\')">Enviar PUT</button>';
h += '<button class="btn btn-sm btn-outline-secondary btn-send mt-2 ml-1" onclick="sendPatch(\'' + id + '\', \'' + fullUrl + '\')">PATCH</button>';
h += '<div class="response-area mt-2" id="resp-update-' + id + '">...</div>';
h += '</div>';
}
// DELETE tab
if (methods.indexOf('DELETE') !== -1) {
h += '<div class="tab-pane" id="tab-delete-' + id + '">';
h += '<div class="form-row align-items-end mb-2">';
h += '<div class="col-md-3"><label class="mb-0"><small>ID (obrigatorio)</small></label>';
h += '<input type="text" class="form-control form-control-sm" id="pk-delete-' + id + '" placeholder="pk"></div>';
h += '<div class="col-md-3">';
h += '<button class="btn btn-sm btn-danger btn-send" onclick="sendDelete(\'' + id + '\', \'' + fullUrl + '\')">Enviar DELETE</button></div>';
h += '</div>';
h += '<div class="response-area mt-2" id="resp-delete-' + id + '">...</div>';
h += '</div>';
}
h += '</div>';
return h;
}
function buildActionTester(actUrl, methods) {
var id = actUrl.replace(/[^a-zA-Z0-9]/g, '_');
var fullUrl = '/api/' + actUrl;
var method = methods[0] || 'GET';
var h = '';
h += '<div class="form-row align-items-end mb-2">';
h += '<div class="col-md-6"><label class="mb-0"><small>URL completa</small></label>';
h += '<input type="text" class="form-control form-control-sm" id="url-' + id + '" value="' + fullUrl + '"></div>';
h += '<div class="col-md-3"><label class="mb-0"><small>Filtros (query string)</small></label>';
h += '<input type="text" class="form-control form-control-sm" id="qs-' + id + '" placeholder="param=valor"></div>';
h += '<div class="col-md-3">';
if (method === 'GET') {
h += '<button class="btn btn-sm btn-success btn-send" onclick="sendAction(\'' + id + '\', \'GET\')">GET</button>';
} else {
h += '<button class="btn btn-sm btn-primary btn-send" onclick="sendAction(\'' + id + '\', \'' + method + '\')"> ' + method + '</button>';
}
h += '</div></div>';
if (method !== 'GET') {
h += '<textarea class="json-editor" id="body-' + id + '" placeholder=\'{"campo": "valor"}\'></textarea>';
}
h += '<div class="response-area mt-2" id="resp-' + id + '">...</div>';
return h;
}
// API call functions
function sendGet(id, url) {
var pk = document.getElementById('pk-' + id).value.trim();
var filter = document.getElementById('filter-' + id).value.trim();
var page = document.getElementById('page-' + id).value || '1';
var psize = document.getElementById('psize-' + id).value || '10';
var fullUrl = getBaseUrl() + url;
if (pk) fullUrl += pk + '/';
var params = 'page=' + page + '&page_size=' + psize;
if (filter) params += '&' + filter;
if (!pk) fullUrl += '?' + params;
doFetch('resp-list-' + id, fullUrl, 'GET');
}
function sendPost(id, url) {
var body = document.getElementById('body-create-' + id).value;
doFetch('resp-create-' + id, getBaseUrl() + url, 'POST', body);
}
function sendPut(id, url) {
var pk = document.getElementById('pk-update-' + id).value.trim();
if (!pk) { alert('Informe o ID para atualizar.'); return; }
var body = document.getElementById('body-update-' + id).value;
doFetch('resp-update-' + id, getBaseUrl() + url + pk + '/', 'PUT', body);
}
function sendPatch(id, url) {
var pk = document.getElementById('pk-update-' + id).value.trim();
if (!pk) { alert('Informe o ID para atualizar.'); return; }
var body = document.getElementById('body-update-' + id).value;
doFetch('resp-update-' + id, getBaseUrl() + url + pk + '/', 'PATCH', body);
}
function sendDelete(id, url) {
var pk = document.getElementById('pk-delete-' + id).value.trim();
if (!pk) { alert('Informe o ID para deletar.'); return; }
if (!confirm('Confirma exclusao do registro #' + pk + '?')) return;
doFetch('resp-delete-' + id, getBaseUrl() + url + pk + '/', 'DELETE');
}
function sendAction(id, method) {
var url = document.getElementById('url-' + id).value.trim();
var qs = document.getElementById('qs-' + id).value.trim();
var fullUrl = getBaseUrl() + url;
if (qs) fullUrl += (fullUrl.indexOf('?') === -1 ? '?' : '&') + qs;
var body = null;
var bodyEl = document.getElementById('body-' + id);
if (bodyEl) body = bodyEl.value;
doFetch('resp-' + id, fullUrl, method, body);
}
function doFetch(respId, url, method, body) {
var respEl = document.getElementById(respId);
respEl.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Enviando...';
var opts = {
method: method,
headers: getAuthHeaders()
};
if (body && method !== 'GET' && method !== 'DELETE') {
try {
opts.body = JSON.stringify(JSON.parse(body));
} catch(e) {
opts.body = body;
}
}
var startTime = Date.now();
fetch(url, opts)
.then(function(r) {
var elapsed = Date.now() - startTime;
var statusClass = r.status < 300 ? 's2xx' : (r.status < 500 ? 's4xx' : 's5xx');
return r.text().then(function(text) {
var formatted;
try {
formatted = JSON.stringify(JSON.parse(text), null, 2);
} catch(e) {
formatted = text;
}
respEl.innerHTML = '<span class="status-code ' + statusClass + '">' +
method + ' ' + r.status + ' ' + r.statusText + '</span>' +
' <span class="text-muted">(' + elapsed + 'ms)</span>\n' +
'<span class="text-muted">' + url + '</span>\n\n' + formatted;
});
})
.catch(function(err) {
respEl.innerHTML = '<span class="status-code s5xx">ERRO</span> ' + err.message;
});
}
function toggleApp(el) {
var list = el.nextElementSibling;
var icon = el.querySelector('i');
list.classList.toggle('show');
icon.classList.toggle('fa-chevron-right');
icon.classList.toggle('fa-chevron-down');
}
function toggleEndpoint(el) {
el.nextElementSibling.classList.toggle('show');
}
function filterEndpoints(query) {
if (!query) {
document.querySelectorAll('.api-app-section').forEach(function(s) { s.style.display = ''; });
document.querySelectorAll('.api-endpoint').forEach(function(e) { e.style.display = ''; });
return;
}
document.querySelectorAll('.api-app-section').forEach(function(section) {
var hasMatch = false;
section.querySelectorAll('.api-endpoint').forEach(function(ep) {
var searchText = ep.getAttribute('data-search') || '';
if (searchText.toLowerCase().indexOf(query) !== -1) {
ep.style.display = '';
hasMatch = true;
} else {
ep.style.display = 'none';
}
});
section.style.display = hasMatch ? '' : 'none';
if (hasMatch) {
section.querySelector('.api-endpoint-list').classList.add('show');
var icon = section.querySelector('h4 i');
icon.classList.remove('fa-chevron-right');
icon.classList.add('fa-chevron-down');
}
});
}
</script>
{% endblock base_content %}
Loading…
Cancel
Save