Otimização da página de logs
O template basse concentra o menu principal e os blocos usados pelas páginas de projetos, logs e gráficos.
Template base
{% load static %}
<html lang="pt-br">
<head>
<meta charset="utf-8">
<title>Painel de Testes</title>
<link rel="stylesheet" href="{% static 'adminlte/css/adminlte.min.css' %}">
</head>
<body class="hold-transition sidebar-mini">
<div class="wrapper">
<aside class="main-sidebar sidebar-dark-primary elevation-4">
<a href="{% url 'home' %}" class="brand-link">Painel de Testes</a>
<div class="sidebar">
<nav class="mt-2">
<ul class="nav nav-pills nav-sidebar flex-column">
<li class="nav-item"><a href="{% url 'home' %}" class="nav-link">Projetos</a></li>
<li class="nav-item"><a href="{% url 'log_list' %}" class="nav-link">Logs</a></li>
<li class="nav-item"><a href="{% url 'charts' %}" class="nav-link">Gráficos</a></li>
</ul>
</nav>
</div>
</aside>
<div class="content-wrapper">
<section class="content">
<div class="container-fluid">
{% block content %}{% endblock %}
</div>
</section>
</div>
</div>
<script src="{% static 'adminlte/js/adminlte.min.js' %}"></script>
{% block js %}{% endblock %}
</body>
</html>
Lista de logs
{% extends 'base.html' %}
{% block content %}
<table class="table table-striped table-hover table-bordered">
<thead>
<tr>
<th>#</th>
<th>Projeto</th>
<th>Criado em</th>
<th>Total</th>
<th>Falhas</th>
<th>Erros</th>
<th>Aprovados</th>
<th>Taxa</th>
<th>Relatório</th>
</tr>
</thead>
<tbody>
{% for log in logs %}
<tr>
<td>{{ forloop.counter }}</td>
<td>{{ log.project.name }}</td>
<td>{{ log.created_at }}</td>
<td>{{ log.total_count }}</td>
<td>{{ log.failed_count }}</td>
<td>{{ log.error_count }}</td>
<td>{{ log.passed_count }}</td>
<td>{{ log.pass_rate }}</td>
<td><a href="{% url 'preview_log' log.pk %}">Visualizar</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
Views principais
import json
from io import BytesIO
from django.http import JsonResponse, HttpResponse, FileResponse
from django.shortcuts import render, redirect
from django.utils.encoding import escape_uri_path
from app01 import models
from utils.forms import ProjectForm, CaseForm
from utils.runner import TestRunner
from utils.charts import ChartData
def home(request):
projects = models.Project.objects.all()
return render(request, "index.html", {"projects": projects})
def project_create(request):
form = ProjectForm(request.POST or None)
if request.method == "POST" and form.is_valid():
form.save()
return redirect("home")
return render(request, "project_form.html", {"form": form})
def project_update(request, pk):
project = models.Project.objects.filter(pk=pk).first()
form = ProjectForm(request.POST or None, instance=project)
if request.method == "POST" and form.is_valid():
form.save()
return redirect("home")
return render(request, "project_form.html", {"form": form})
def project_delete(request, pk):
models.Project.objects.filter(pk=pk).delete()
return redirect("home")
def case_list(request, pk):
project = models.Project.objects.filter(pk=pk).first()
cases = models.TestCase.objects.filter(project_id=pk)
return render(request, "case_list.html", {"cases": cases, "project": project})
def case_create(request, pk):
form = CaseForm(request.POST or None)
if request.method == "POST" and form.is_valid():
case = form.save(commit=False)
case.project_id = pk
case.save()
return redirect("case_list", pk=pk)
return render(request, "case_form.html", {"form": form})
def case_update(request, pk):
case = models.TestCase.objects.filter(pk=pk).first()
form = CaseForm(request.POST or None, instance=case)
if request.method == "POST" and form.is_valid():
case = form.save(commit=False)
case.passed = False
case.executed = False
case.report = ""
case.save()
return redirect("case_list", pk=case.project_id)
return render(request, "case_form.html", {"form": form})
def case_delete(request, pk):
case = models.TestCase.objects.filter(pk=pk).first()
project_id = case.project_id
case.delete()
return redirect("case_list", pk=project_id)
def execute_cases(request, pk=0):
if request.headers.get("x-requested-with") == "XMLHttpRequest":
ids = json.loads(request.POST.get("selected_cases", "[]"))
cases = models.TestCase.objects.filter(pk__in=ids)
TestRunner.run(cases)
return JsonResponse({"redirect": "/logs/"})
case = models.TestCase.objects.filter(pk=pk).first()
TestRunner.run([case])
return redirect("log_list")
def download_case_report(request, pk):
case = models.TestCase.objects.filter(pk=pk).first()
content = BytesIO(case.report.encode("utf-8"))
return FileResponse(
content,
as_attachment=True,
filename=f"{case.name}.html",
)
def log_list(request):
logs = models.ExecutionLog.objects.all()
return render(request, "logs_list.html", {"logs": logs})
def preview_log(request, pk):
if request.method == "POST":
log_id = request.POST.get("log_id")
log = models.ExecutionLog.objects.filter(pk=log_id).first()
content = BytesIO(log.report.encode("utf-8"))
return FileResponse(
content,
as_attachment=True,
filename=f"{log.project.name}.html",
)
log = models.ExecutionLog.objects.filter(pk=pk).first()
return render(request, "preview.html", {"log": log})
URLs
from django.urls import path, re_path
from app01 import views
urlpatterns = [
path("", views.home, name="home"),
path("projects/", views.home, name="home"),
path("projects/add/", views.project_create, name="project_create"),
re_path(r"^projects/(?P<pk>\d+)/edit/$", views.project_update, name="project_update"),
re_path(r"^projects/(?P<pk>\d+)/delete/$", views.project_delete, name="project_delete"),
re_path(r"^projects/(?P<pk>\d+)/cases/$", views.case_list, name="case_list"),
re_path(r"^projects/(?P<pk>\d+)/cases/add/$", views.case_create, name="case_create"),
re_path(r"^cases/(?P<pk>\d+)/edit/$", views.case_update, name="case_update"),
re_path(r"^cases/(?P<pk>\d+)/delete/$", views.case_delete, name="case_delete"),
re_path(r"^cases/(?P<pk>\d+)/run/$", views.execute_cases, name="execute_case"),
re_path(r"^cases/(?P<pk>\d+)/report/$", views.download_case_report, name="download_case_report"),
path("logs/", views.log_list, name="log_list"),
re_path(r"^logs/(?P<pk>\d+)/preview/$", views.preview_log, name="preview_log"),
path("charts/", views.show_charts, name="charts"),
re_path(r"^projects/(?P<pk>\d+)/upload/$", views.upload_cases, name="upload_cases"),
]
Modelos
from django.db import models
class Project(models.Model):
name = models.CharField(max_length=32, default="", verbose_name="Nome do projeto")
description = models.TextField(max_length=255, default="", verbose_name="Descrição")
start_date = models.DateField(verbose_name="Início")
end_date = models.DateField(verbose_name="Término")
def __str__(self):
return self.name
def coverage_rate(self):
total = self.test_cases.count()
if not total:
return "0.0%"
passed = self.test_cases.filter(passed=True).count()
return f"{(passed / total) * 100:.2f}%"
class TestCase(models.Model):
project = models.ForeignKey(
Project,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="test_cases",
verbose_name="Projeto",
)
name = models.CharField(max_length=32, default="", verbose_name="Nome do caso")
description = models.CharField(max_length=255, default="", verbose_name="Descrição")
url = models.CharField(max_length=255, default="", verbose_name="URL")
method = models.CharField(max_length=32, default="", verbose_name="Método HTTP")
params = models.CharField(max_length=255, default="{}", verbose_name="Parâmetros")
data = models.CharField(max_length=255, default="{}", verbose_name="Corpo")
expected = models.CharField(max_length=4196, default="{}", verbose_name="Resultado esperado")
report = models.TextField(default="", verbose_name="Relatório")
run_at = models.DateTimeField(null=True, blank=True, verbose_name="Executado em")
passed = models.BooleanField(default=False, verbose_name="Aprovado")
executed = models.BooleanField(default=False, verbose_name="Executado")
def __str__(self):
return self.name
class ExecutionLog(models.Model):
report = models.TextField(default="", verbose_name="Relatório")
project = models.ForeignKey(
Project,
on_delete=models.CASCADE,
related_name="logs",
verbose_name="Projeto",
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Criado em")
passed_count = models.IntegerField(verbose_name="Aprovados")
failed_count = models.IntegerField(verbose_name="Falhas")
error_count = models.IntegerField(verbose_name="Erros")
total_count = models.IntegerField(verbose_name="Total executado")
@property
def pass_rate(self):
if not self.total_count:
return "0%"
return f"{(self.passed_count / self.total_count) * 100:.0f}%"
class Meta:
ordering = ["-created_at"]
Os formulários podem ser definidos como ModelForm:
from django import forms
from app01 import models
class ProjectForm(forms.ModelForm):
class Meta:
model = models.Project
fields = "__all__"
class CaseForm(forms.ModelForm):
class Meta:
model = models.TestCase
exclude = ("report", "run_at", "passed", "executed")
Pré-visualização de logs
{% extends 'base.html' %}
{% block content %}
<div class="card card-primary">
<div class="card-header">
<h3 class="card-title">[{{ log.project.name }}] Relatório</h3>
</div>
<div class="card-body">
<div class="chart">
{{ log.report|safe }}
</div>
<form method="post">
{% csrf_token %}
<input type="hidden" name="log_id" value="{{ log.pk }}">
<a href="{% url 'log_list' %}" class="btn btn-default">Voltar</a>
<button type="submit" class="btn btn-success">Baixar relatório</button>
</form>
</div>
</div>
{% endblock %}
Visualização
Três abordagens comuns para gráficos são Highcharts, ECharts e Pyecharts. No modelo com ECharts ou Highcharts, o frontend obtém os dados do backend e renderiza o gráfico. No Pyecharts, o backend gera o HTML do gráfico.
Pyecharts
import webbrowser
from pyecharts import Pie
def draw_gender_pie(contacts):
labels = ["Masculino", "Feminino", "Não informado"]
counts = [0, 0, 0]
for contact in contacts:
if contact.sex == 1:
counts[0] += 1
elif contact.sex == 2:
counts[1] += 1
else:
counts[2] += 1
chart = Pie("Distribuição por gênero")
chart.add("", labels, counts, is_label_show=True)
chart.render("genero.html")
webbrowser.open("genero.html")
ECharts
Baixe o arquivo echarts.min.js, coloque-o no diretório static e referencie no template. O fluxo é: criar um elemetno com id, instanciar echarts.init, configurar opções e chamar setOption.
def show_charts(request):
if request.headers.get("x-requested-with") == "XMLHttpRequest":
chart_data = ChartData()
payload = {}
payload.update(chart_data.pie())
payload.update(chart_data.line_simple())
return JsonResponse(payload)
return render(request, "show_tab.html")
{% extends 'base.html' %}
{% load static %}
{% block content %}
<div class="card card-primary">
<div class="card-header">
<h3 class="card-title">Casos nos últimos 12 meses</h3>
</div>
<div class="card-body">
<div id="line-chart" style="height: 400px;"></div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="card card-primary">
<div class="card-header"><h3 class="card-title">Execução</h3></div>
<div class="card-body"><div id="execute-chart" style="height: 400px;"></div></div>
</div>
</div>
<div class="col-md-6">
<div class="card card-primary">
<div class="card-header"><h3 class="card-title">Aprovação</h3></div>
<div class="card-body"><div id="pass-chart" style="height: 400px;"></div></div>
</div>
</div>
</div>
{% csrf_token %}
{% endblock %}
{% block js %}
<script src="{% static 'echarts.min.js' %}"></script>
<script>
function renderPie(elementId, labels, values, seriesName) {
const chart = echarts.init(document.getElementById(elementId));
chart.setOption({
tooltip: { trigger: 'item', formatter: '{a} <br/>{b} : {c} ({d}%)' },
legend: { orient: 'vertical', left: 'left', data: labels },
series: [{
name: seriesName,
type: 'pie',
radius: '55%',
center: ['50%', '60%'],
data: values,
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0,0,0,0.5)'
}
}
}]
});
}
function renderLine(elementId, labels, values) {
const chart = echarts.init(document.getElementById(elementId));
chart.setOption({
xAxis: { type: 'category', boundaryGap: false, data: labels },
yAxis: { type: 'value' },
series: [{ data: values, type: 'line', areaStyle: {} }]
});
}
function loadCharts() {
$.post("{% url 'charts' %}", {
csrfmiddlewaretoken: $("[name=csrfmiddlewaretoken]").val()
}, function (data) {
renderPie("pass-chart", data.pass_pie.title, data.pass_pie.data, "Casos");
renderPie("execute-chart", data.execute_pie.title, data.execute_pie.data, "Casos");
renderLine("line-chart", data.line_simple.title, data.line_simple.data);
});
}
loadCharts();
</script>
{% endblock %}
import datetime
from collections import defaultdict
from app01 import models
class ChartData:
def pie(self):
approved = models.TestCase.objects.filter(passed=True).count()
rejected = models.TestCase.objects.filter(passed=False).count()
executed = models.TestCase.objects.filter(executed=True).count()
pending = models.TestCase.objects.filter(executed=False).count()
return {
"pass_pie": {
"title": ["Aprovado", "Reprovado"],
"data": [
{"value": approved, "name": "Aprovado"},
{"value": rejected, "name": "Reprovado"},
],
},
"execute_pie": {
"title": ["Executado", "Não executado"],
"data": [
{"value": executed, "name": "Executado"},
{"value": pending, "name": "Não executado"},
],
},
}
def line_simple(self):
end_date = datetime.date.today()
start_date = end_date - datetime.timedelta(days=365)
projects = models.Project.objects.filter(start_date__range=(start_date, end_date))
monthly = defaultdict(int)
for project in projects:
month = project.start_date.strftime("%Y-%m")
monthly[month] += project.test_cases.count()
ordered = sorted(monthly.items())
return {
"line_simple": {
"title": [month for month, _ in ordered],
"data": [count for _, count in ordered],
}
}
Tarefas agendadas
Ferramentas comuns para agendamento incluem crontab, tarefas agendadas do Django, APScheduler e Celery. Neste exemplo, usa-se APScheduler.
pip install apscheduler
Execução em data específica
import datetime
from apscheduler.schedulers.blocking import BlockingScheduler
def tarefa_data(texto):
print("tarefa_data", datetime.datetime.now(), texto)
agendador = BlockingScheduler()
agendador.add_job(
tarefa_data,
"date",
run_date=datetime.datetime(2025, 2, 25, 19, 5, 6),
args=["exemplo"],
id="tarefa_data",
)
agendador.start()
Intervalo fixo
import datetime
from apscheduler.schedulers.blocking import BlockingScheduler
def tarefa_intervalo():
print("tarefa_intervalo", datetime.datetime.now())
agendador = BlockingScheduler()
agendador.add_job(tarefa_intervalo, "interval", seconds=5, id="tarefa_intervalo")
agendador.start()
Execução diária em horário fixo
from apscheduler.schedulers.blocking import BlockingScheduler
agendador = BlockingScheduler()
@agendador.scheduled_job("cron", day_of_week="*", hour=11, minute=56, second=2)
def verificar_banco():
print("verificação executada")
if __name__ == "__main__":
agendador.start()
Envio de e-mail com Django
Configuração SMTP
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_USE_TLS = True
EMAIL_USE_SSL = False
EMAIL_HOST = "smtp.example.com"
EMAIL_PORT = 25
EMAIL_HOST_USER = "no-reply@example.com"
EMAIL_HOST_PASSWORD = "SUA_SENHA"
EMAIL_RECIPIENTS = ["destino@example.com"]
Envio simples
from django.conf import settings
from django.core.mail import send_mail
from django.http import HttpResponse
def send_simple_email(request):
send_mail(
subject="Título do e-mail",
message="Conteúdo do e-mail",
from_email=settings.EMAIL_HOST_USER,
recipient_list=settings.EMAIL_RECIPIENTS,
fail_silently=False,
)
return HttpResponse("OK")
Envio com anexo
from django.conf import settings
from django.core.mail import EmailMessage
from django.http import HttpResponse
def send_email_with_attachment(request):
message = EmailMessage(
subject="Relatório de execução",
body="Segue o relatório em anexo.",
from_email=settings.EMAIL_HOST_USER,
to=settings.EMAIL_RECIPIENTS,
)
message.attach_file("/caminho/relatorio.html")
message.send(fail_silently=False)
return HttpResponse("OK")
O envio de e-mail pode ser combinado com a tarefa agendada para entregar o relatório após a execução em lote.
Upload de arquivos
O upload pode ser feito por formulário tradicional ou por AJAX. No exemplo abaixo, usa-se AJAX com FormData e leitura do Excel com xlrd dentro de uma transação atômica.
import xlrd
from django.db import transaction
from django.http import JsonResponse
from django.shortcuts import render
from app01 import models
def upload_cases(request, pk):
if request.headers.get("x-requested-with") == "XMLHttpRequest":
try:
with transaction.atomic():
excel_file = request.FILES.get("file")
project_id = request.POST.get("project_id")
workbook = xlrd.open_workbook(file_contents=excel_file.read())
sheet = workbook.sheet_by_index(0)
headers = sheet.row_values(0)
rows = [
dict(zip(headers, sheet.row_values(index)))
for index in range(1, sheet.nrows)
]
for row in rows:
models.TestCase.objects.create(
project_id=project_id,
name=row["title"],
description=row["desc"],
url=row["url"],
method=row["method"],
params=row["params"],
data=row["data"],
expected=row["expect"],
)
return JsonResponse({
"status": 200,
"redirect": f"/projects/{pk}/cases/",
})
except Exception as exc:
return JsonResponse({
"status": 500,
"errors": f"Envie um arquivo XLS/XLSX válido. Detalhe: {exc}",
"redirect": f"/projects/{pk}/cases/",
})
return render(request, "upload.html", {"project_id": pk})
Versões recentes do xlrd podem não ler arquivos .xlsx. Se necessário, use pip install xlrd==1.2.0.
{% extends 'base.html' %}
{% block content %}
<div class="alert alert-warning" id="alert-box">
<strong>Atenção:</strong> envie apenas arquivos Excel compatíveis.
</div>
<div class="form-group">
<label for="file-input">Arquivo</label>
<input type="file" id="file-input" class="form-control">
<button id="upload-btn" class="btn btn-primary mt-2">Enviar</button>
</div>
<span id="error-message"></span>
<div id="project-id" hidden>{{ project_id }}</div>
{% csrf_token %}
{% endblock %}
{% block js %}
<script>
$("#upload-btn").click(function () {
const formData = new FormData();
const file = document.getElementById("file-input").files[0];
const projectId = $("#project-id").text();
formData.append("file", file);
formData.append("project_id", projectId);
formData.append("csrfmiddlewaretoken", $("[name=csrfmiddlewaretoken]").val());
$.ajax({
url: "/projects/" + projectId + "/upload/",
type: "POST",
data: formData,
processData: false,
contentType: false,
success: function (response) {
if (response.status === 200) {
window.location.href = response.redirect;
} else {
$("#alert-box").attr("class", "alert alert-danger");
$("#error-message").text(response.errors);
}
}
});
});
</script>
{% endblock %}
Na página de projetos, adicione o link de importação:
<a href="{% url 'upload_cases' project.pk %}" class="btn btn-default btn-sm">Importar em lote</a>
Atividades práticas
Implemente a pré-visualização do relatório com botão de retorno e download, os três gráficos com ECharts e uma tarefa agendada que verifica projetos com término no dia atual.
Requisito da tarefa: diariamente às 1:30:50, verificar os projetos cujo end_date é o dia atual, executar todos os casos associados e gerar um registro em ExecutionLog. Como extensão, enviar o relatório por e-mail e executar o agendador em uma thread separada.
import datetime
from apscheduler.schedulers.blocking import BlockingScheduler
from app01 import models
from utils.runner import TestRunner
def check_projects():
today = datetime.date.today()
projects = models.Project.objects.filter(end_date=today)
for project in projects:
cases = project.test_cases.all()
if cases.exists():
TestRunner.run(cases)
def start_scheduler():
scheduler = BlockingScheduler()
scheduler.add_job(
check_projects,
"cron",
hour=1,
minute=30,
second=50,
id="check_projects",
)
scheduler.start()
import os
import sys
from threading import Thread
def main():
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plataforma.settings")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError("Django não está disponível no PYTHONPATH.") from exc
from utils.CrontabHandler import start_scheduler
worker = Thread(target=start_scheduler, daemon=True)
worker.start()
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()
Execução de casos e geração de relatórios
import json
import datetime
import unittest
import requests
from io import BytesIO
from deepdiff import DeepDiff
from HTMLTestRunner import HTMLTestRunner
from app01 import models
class DynamicApiTest(unittest.TestCase):
def test_response(self):
self._testMethodDoc = self.case_description
diff = DeepDiff(self.actual, self.expected)
self.assertFalse(diff, msg=f"Diferença encontrada: {diff}")
class RequestRunner:
def __init__(self, case):
self.case = case
def execute(self):
return requests.request(
method=self.case.method,
url=self.case.url,
params=self._json_or_empty(self.case.params),
data=self._json_or_empty(self.case.data),
)
def build_test(self, response):
test = DynamicApiTest(methodName="test_response")
test.case_description = self.case.description
test.actual = response.json()
test.expected = self._json_or_empty(self.case.expected)
return test
def save_single_report(self, test):
suite = unittest.TestSuite()
suite.addTest(test)
stream = BytesIO()
result = HTMLTestRunner(
stream=stream,
title=self.case.name,
description=self.case.description,
).run(suite)
obj = models.TestCase.objects.get(pk=self.case.pk)
obj.report = stream.getvalue()
obj.run_at = datetime.datetime.now()
obj.executed = True
obj.passed = result.wasSuccessful()
obj.save()
@staticmethod
def _json_or_empty(value):
return json.loads(value) if value else {}
class BatchRunner:
@staticmethod
def run(cases):
suite = unittest.TestSuite()
last_case = None
for case in cases:
last_case = case
runner = RequestRunner(case)
response = runner.execute()
test = runner.build_test(response)
suite.addTest(test)
runner.save_single_report(test)
if last_case:
BatchRunner.save_batch_report(last_case, suite)
@staticmethod
def save_batch_report(last_case, suite):
stream = BytesIO()
result = HTMLTestRunner(
stream=stream,
title="Execução em lote",
description=f"Projeto: {last_case.project.name}",
).run(suite)
models.ExecutionLog.objects.create(
report=stream.getvalue(),
project_id=last_case.project_id,
passed_count=getattr(result, "success_count", 0),
failed_count=getattr(result, "failure_count", 0),
error_count=getattr(result, "error_count", 0),
total_count=result.testsRun,
)