Formset no Django é um conjunto de formulários podemos gerenciar de uma vez só (como uma lista de formulários), criar/editar vários objetos de um mesmo modelo de uma vez.
Crispy Forms é um pacote que deixa os formulários do Django mais bonitos e personalizáveis sem precisar escrever muito HTML.
Um sistema para cadastrar, revisar e organizar manuais técnicos, vinculados a códigos de Produtos específicos.
- Cadastro de Manuais:
- Cadastrar manuais com descrição, idioma, e um identificador único.
- Ativar ou desativar manuais conforme necessário.
- Gerenciamento de Revisões:
- Associar revisões aos manuais existentes.
- Upload de arquivos PDF relacionados à revisão.
- Cadastro de Produto:
- Associar manuais a códigos de produto específicos.
- Listagem:
- Visualizar as revisões associadas a cada manual.
Requirements
- Django
- pillow
- crispy-bootstrap5
- django-crispy-forms==1.14.0
Criar o Projeto Django, Configurações, Models, Admin, PARTE 01
-
Crie um ambiente virtual:
python -m venv venv source venv/bin/activate # (ou venv\Scripts\activate no Windows)
-
Instale o Django:
pip install django
-
Crie o projeto:
django-admin startproject gerenciador_manual . -
Crie o app principal:
python manage.py startapp manuais
-
Adicione o app
manuaisao projeto: No arquivosettings.py, localize a listaINSTALLED_APPSe adicione:INSTALLED_APPS = [ ... 'manuais', ]
STATICS
STATIC_ROOT = BASE_DIR / 'static' STATIC_URL = '/static/' # STATICFILES_DIRS = [ # talvez em Produção podesse usar assim. # BASE_DIR / 'staticfiles', # ] MEDIA_ROOT = BASE_DIR / 'media' MEDIA_URL = '/media/'
LANGUAGE_CODE = 'pt-br' TIME_ZONE = 'America/Sao_Paulo' USE_I18N = True USE_L10N = True USE_TZ = True
TEMPLATE_DIR = BASE_DIR / 'templates'
-
Models
Lista de Manuais
from django.db import models class Manual(models.Model): manual_id = models.CharField(max_length=200, unique=True) descricao = models.CharField(max_length=200, null=True, blank=True) lang = models.CharField(max_length=100, choices=[ ('br', 'pt-BR'), ('en', 'en-US'), ('es', 'es-ES')]) is_active = models.BooleanField(default=False) class Meta: verbose_name = 'Manual' verbose_name_plural = 'Manuais' ordering = ['id'] def __str__(self): return self.manual_id
Lista de Modelos (Produto)
class Produto(models.Model): manual = models.ForeignKey(Manual, on_delete=models.PROTECT, related_name="manual_produto") codigo_modelo = models.CharField(max_length=50) descricao = models.CharField(max_length=200, unique=True) class Meta: verbose_name = 'Cadastro de Produto (modelo)' verbose_name_plural = 'Cadastro de Produtos (modelos)' ordering = ['id'] def __str__(self): return "{} ({})".format(self.manual, self.descricao)
Lista de Revião
class Revisao(models.Model): manual = models.ForeignKey(Manual, on_delete=models.PROTECT, related_name="manual_revisao") revisao = models.IntegerField() pdf = models.FileField(upload_to='manuais/pdf', null=True, blank=True) class Meta: verbose_name = 'Revisão Manual' verbose_name_plural = 'Revisões Manuais' ordering = ['-id'] def __str__(self): return "{} (REV: {})".format(str(self.manual), self.revisao)
-
Faça as migrações:
python manage.py makemigrations python manage.py migrate
-
No arquivo
manuais/admin.py, registre os Produtos:from django.contrib import admin from manuais.models import * class ProdutoInline(admin.StackedInline): model = Produto extra = 0 min_num = 0 class RevisaoInline(admin.StackedInline): model = Revisao extra = 0 min_num = 0 class ManualAdmin(admin.ModelAdmin): list_display = ('descricao', 'manual_id', 'lang', 'is_active') search_fields = ('descricao', 'manual_id') list_filter = ('lang', 'is_active') ordering = ['id'] inlines = (ProdutoInline, RevisaoInline) admin.site.register(Manual, ManualAdmin)
-
Rode o servidor e acesse o Django Admin:
python manage.py runserver
Vá para http://127.0.0.1:8000/admin, faça login (crie um superuser com
python manage.py createsuperuser), e veja os produtos registrados.
-
Criar Views e Template, CrispyForm e Formset PARTE 02
- FBV → simples, direto, bom para views pequenas.
- CBV → reaproveita código, organiza por métodos (
get,post), facilita herança e mixins.
Lista de Manuais
views.py
from django.views.generic import ListView
from .models import Manual
class ManualListView(ListView):
model = Manual
template_name = "manual_list.html"
context_object_name = "manuais" # Nome da variável usada no template urls.py
from django.urls import path
from .views import ManualListView
urlpatterns = [
path('manuais/', ManualListView.as_view(), name='manual_list'),
]manual_list.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lista de Manuais</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
</head>
<body>
<div class="container mt-5">
<h1 class="mb-4 text-center">Lista de Manuais</h1>
<div class="table-responsive">
<table class="table table-striped">
<thead class="table-light">
<tr>
<th scope="col">ID</th>
<th scope="col">Descrição</th>
<th scope="col">Idioma</th>
<th scope="col">Ativo</th>
</tr>
</thead>
<tbody>
{% for manual in manuais %}
<tr>
<td>{{ manual.manual_id }}</td>
<td>{{ manual.descricao }}</td>
<td>{{ manual.lang}}</td>
<td>
<span class="badge {{ manual.is_active|yesno:'bg-success,bg-danger' }}">
{{ manual.is_active|yesno:'Sim,Não' }}
</span>
</td>
</tr>
{% empty %}
<tr>
<td colspan="4" class="text-center">Nenhum manual encontrado.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.min.js" integrity="sha384-0pUGZvbkm6XF6gxjEnlmuGrJXVbNuzT9qBBavbLwCsOGabYfZo0T0to5eqruptLy" crossorigin="anonymous"></script>
</body>
</html>Conseguimos testar,
python manage.py runserverhttps://pypi.org/project/crispy-bootstrap5/
https://pypi.org/project/django-crispy-forms/
gera Requirements.txt
crispy-bootstrap5
django-crispy-forms==1.14.0
Criar template base para renderizar nosso conteúdo
{% load static %}
<!doctype html>
<html lang="en">
<head>
<!-- Required meta tags -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<title>My Site</title>
</head>
<body>
<div class="container my-5">
{% block content %}{% endblock %}
</div>
<!-- Optional JavaScript -->
<!-- jQuery first, then Popper.js, then Bootstrap JS -->
<script src="https://code.jquery.com/jquery-3.7.1.min.js" integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/@popperjs/[email protected]/dist/umd/popper.min.js" integrity="sha384-I7E8VVD/ismYTF4hNIPjVp/Zjvgyol6VFvRkX/vR+Vc4jQkC+hVqc2pM8ODewa9r" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.min.js" integrity="sha384-0pUGZvbkm6XF6gxjEnlmuGrJXVbNuzT9qBBavbLwCsOGabYfZo0T0to5eqruptLy" crossorigin="anonymous"></script>
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.15.4/css/all.css" integrity="sha384-DyZ88mC6Up2uqS4h/KRgHuoeGwBcD4Ng9SiP4dIRy0EXTlnuz47vAwmeGwVChigm" crossorigin="anonymous">
</body>
</html>manual_list.html
{% extends "base.html" %}
{% block content %}
<h1 class="mb-4 text-center">Lista de Manuais</h1>
<div class="table-responsive">
<table class="table table-striped table-bordered">
<thead class="table-dark">
<tr>
<th scope="col">ID</th>
<th scope="col">Descrição</th>
<th scope="col">Idioma</th>
<th scope="col">Ativo</th>
</tr>
</thead>
<tbody>
{% for manual in manuais %}
<tr>
<td>{{ manual.manual_id }}</td>
<td>{{ manual.descricao }}</td>
<td>{{ manual.lang}}</td>
<td>
<span class="badge {{ manual.is_active|yesno:'bg-success,bg-danger' }}">
{{ manual.is_active|yesno:'Sim,Não' }}
</span>
</td>
</tr>
{% empty %}
<tr>
<td colspan="4" class="text-center">Nenhum manual encontrado.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock content %}Formulário para o Manual
from django import forms
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Field, Fieldset, ButtonHolder, Submit, HTML
from crispy_forms.bootstrap import TabHolder, Tab
from .models import Manual, Produto, Revisao
from django.forms.models import inlineformset_factory
# Formulário para o Manual
class ManualForm(forms.ModelForm):
class Meta:
model = Manual
exclude = ()
labels = {
'is_active': 'Ativar revisões do produto'
}
def __init__(self, *args, **kwargs):
super(ManualForm, self).__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_show_labels = True
self.helper.form_enctype = 'multipart/form-data'
self.helper.form_class = 'form-horizontal'
self.helper.label_class = 'col-md-3 create-label'
self.helper.field_class = 'col-md-9'
# Layout com abas
self.helper.layout = Layout(
TabHolder(
Tab('Manual do Produto',
Field('descricao'),
Field('manual_id'),
Field('lang'),
CustomCheckbox('is_active')
),
Tab('Produtos (Modelos)',
Fieldset('Adicionar Modelo', CustomFormset('produto'))
),
Tab('Revisão do manual (PDF)',
Fieldset('Adicionar Revisão', CustomFormset('revisao'))
)
),
HTML("<br>"),
ButtonHolder(
Submit('submit', 'Salvar'),
)
)Formulário para o Produto
class ProdutoForm(forms.ModelForm):
class Meta:
model = Produto
exclude = ()
def __init__(self, *args, **kwargs):
super(ProdutoForm, self).__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_show_labels = False
self.helper.layout = Layout(
Field('codigo_modelo'),
Field('descricao')
)
ProdutoFormSet = inlineformset_factory(
Manual, Produto, form=ProdutoForm, extra=1, can_delete=True)Formulário para Revisao
class RevisaoForm(forms.ModelForm):
pdf = forms.FileField(
label="Upload do arquivo PDF",
help_text="Selecione o arquivo PDF para upload.",
error_messages={
"required": "Escolha o arquivo PDF que foi exportado da planilha"
},
)
class Meta:
model = Revisao
exclude = ()
def __init__(self, *args, **kwargs):
super(RevisaoForm, self).__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_show_labels = False
self.helper.layout = Layout(
Field('revisao'),
Field('pdf'),
)
RevisaoFormSet = inlineformset_factory(
Manual, Revisao, form=RevisaoForm, extra=1, can_delete=True)CustomFormset
from crispy_forms.layout import LayoutObject, Field
from django.template.loader import render_to_string
###### Thanks!
###### https://stackoverflow.com/questions/15157262/django-crispy-forms-nesting-a-formset-within-a-form/22053952#22053952
class CustomFormset(LayoutObject):
"""
Renders an entire formset, as though it were a Field.
Accepts the names (as a string) of formset and helper as they
are defined in the context
Examples:
Formset('cadastro_formset')
Formset('cadastro_formset', 'cadastro_formset_helper')
"""
template = "generic/custom_formset.html"
def __init__(self, formset_context_name, helper_context_name=None,
template=None, label=None):
self.formset_context_name = formset_context_name
self.helper_context_name = helper_context_name
# crispy_forms/layout.py:302 requires us to have a fields property
self.fields = []
# Overrides class variable with an instance level variable
if template:
self.template = template
def render(self, form, form_style, context, **kwargs):
formset = context.get(self.formset_context_name)
helper = context.get(self.helper_context_name)
# closes form prematurely if this isn't explicitly stated
if helper:
helper.form_tag = False
context.update({'formset': formset, 'helper': helper})
return render_to_string(self.template, context.flatten())
class CustomCheckbox(Field):
template = 'generic/custom_checkbox.html'generic/custom_formset.html generic/custom_checkbox.html
{% load crispy_forms_tags %}
<table class="table table-striped table-hover">
{{ formset.management_form|crispy }}
{% if formset.forms %}
<thead>
<tr>
{% for field in formset.forms.0.visible_fields %}
<th>{{ field.label|capfirst }}</th>
{% endfor %}
</tr>
</thead>
{% endif %}
<tbody>
{% for form in formset.forms %}
<tr class="{% cycle 'row1' 'row2' %} formset_row-{{ formset.prefix }}">
{% for field in form.visible_fields %}
<td>
{% if forloop.first %}
{% for hidden in form.hidden_fields %}
{{ hidden }}
{% endfor %}
{% endif %}
{{ field.errors.as_ul }}
{{ field|as_crispy_field }}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
<br>
<script src="https://code.jquery.com/jquery-3.7.1.min.js" integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery.formset/1.2.2/jquery.formset.min.js" integrity="sha512-ltwjKsDTo3hW/wV66ZaEkf2wOAFxmg7rWM76J8kOcYKLSKy44WBYO/BFaNNH3NGDS8BSz3meB9wtSnm41oL+pA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script type="text/javascript">
$('.formset_row-{{ formset.prefix }}').formset({
addText: 'Adicionar Identificador',
deleteText: 'remove',
prefix: '{{ formset.prefix }}',
addCssClass: 'add-row btn btn-primary',
added: function (row) {
// Adiciona o ícone Font Awesome ao botão delete de cada nova linha
$(row).find('.delete-row').html('<span class="text-danger"><i class="fas fa-trash fa-3x"></i></span>');
}
});
// Adiciona o ícone de lixeira para as linhas já existentes
$('.delete-row').html('<span class="text-danger"><i class="fas fa-trash fa-3x"></i></span>');
</script>{% load crispy_forms_field %}
<div class="form-group">
<div class="custom-control custom-checkbox">
{% crispy_field field 'class' 'custom-control-input' %}
<label class="custom-control-label" for="{{ field.id_for_label }}">{{ field.label }}</label>
</div>
</div>views.py
from django.urls import reverse_lazy
from django.shortcuts import render
from django.views.generic import ListView, CreateView
from django.db import transaction
from .forms import ManualForm, ProdutoFormSet, RevisaoFormSet
from .models import Manual
class ManualCreateView(CreateView):
model = Manual
template_name = 'form_create.html'
form_class = ManualForm
success_url = None
def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs)
if self.request.POST:
data['produto'] = ProdutoFormSet(self.request.POST, self.request.FILES)
data['revisao'] = RevisaoFormSet(self.request.POST, self.request.FILES)
else:
data['produto'] = ProdutoFormSet()
data['revisao'] = RevisaoFormSet()
return data
def form_valid(self, form):
context = self.get_context_data()
produto = context['produto']
revisao = context['revisao']
with transaction.atomic():
self.object = form.save()
if produto.is_valid() and revisao.is_valid():
produto.instance = self.object
revisao.instance = self.object
produto.save()
revisao.save()
else:
return self.form_invalid(form)
return super().form_valid(form)
def get_success_url(self):
return reverse_lazy('manual_detail', kwargs={'pk': self.object.pk})urls.py
from django.urls import path
from .views import ManualCreateView
urlpatterns = [
path('manual-create/', ManualCreateView.as_view(), name='manual_create'),
]manual_create.html
{% extends "base.html" %}
{% block title %}Page{% endblock %}
{% load crispy_forms_tags %}
{% block content %}
<div class="card shadow-sm">
<div class="card-header bg-success text-white">
<h2 class="mb-0">Criar/Atualizar</h2>
</div>
<div class="card-body">
<form class="form-horizontal" method="POST" enctype="multipart/form-data">
{% crispy form %}
</form>
</div>
</div>
{% endblock content %}erro
from crispy_forms.utils import TEMPLATE_PACK
def render(self, form, context, template_pack=TEMPLATE_PACK, **kwargs):update
from django.views.generic.edit import UpdateView
from .models import Manual
from .forms import ProdutoFormSet, RevisaoFormSet, ManualForm
class ManualUpdateView(UpdateView):
model = Manual
form_class = ManualForm
template_name = 'generic/form_generic.html'
def get_context_data(self, **kwargs):
data = super(ManualUpdateView, self).get_context_data(**kwargs)
if self.request.POST: # Salva os dados postados
data['produto'] = ProdutoFormSet(self.request.POST, instance=self.object)
data['revisao'] = RevisaoFormSet(self.request.POST, self.request.FILES, instance=self.object)
else:
data['produto'] = ProdutoFormSet(instance=self.object)
data['revisao'] = RevisaoFormSet(instance=self.object)
data['title'] = 'Editar Manual'
return data
def form_valid(self, form):
context = self.get_context_data()
produto = context['produto']
revisao = context['revisao']
with transaction.atomic():
self.object = form.save()
if produto.is_valid() and revisao.is_valid():
produto.instance = self.object
revisao.instance = self.object
produto.save()
revisao.save()
else:
return self.form_invalid(form)
return super(ManualUpdateView, self).form_valid(form)
def get_success_url(self):
return reverse_lazy('manual_detail', kwargs={'pk': self.object.pk})
url.py
path('manual-update/<int:pk>/', views.ManualUpdateView.as_view(), name='manual_update'),Pagina de Detalhes e Delete View PARTE 03
ManualDetailView
from django.views.generic import DetailView, DeleteView
class ManualDetailView(DetailView):
model = Manual
template_name = 'manuais/manual_detail.html'
def get_context_data(self, **kwargs):
context = super(ManualDetailView, self).get_context_data(**kwargs)
context['produtos'] = self.object.manual_produto.all()
context['revisoes'] = self.object.manual_revisao.all()
return contextManualDeleteView
class ManualDeleteView(DeleteView):
model = Manual
template_name = 'manuais/manual_detail.html'
success_url = reverse_lazy('manual_list'){% extends "base.html" %}
{% block content %}
<div class="card shadow-sm">
<div class="card-header bg-success text-white">
<h2 class="mb-0">Manual ID: {{ object }}</h2>
</div>
<div class="card-body">
<!-- Detalhes do Manual -->
<div class="mb-4">
{% if object.descricao %}
<p><strong>Descrição:</strong> {{ object.descricao }}</p>
{% endif %}
{% if object.idioma %}
<p><strong>Idioma:</strong> {{ object.idioma }}</p>
{% endif %}
{% if object.ativo %}
<p><strong>Status:</strong> {{ object.ativo|yesno:"Ativo,Inativo" }}</p>
{% endif %}
</div>
<!-- Modelos -->
{% if produtos %}
<h4 class="mb-3">Modelos Associados</h4>
<table class="table table-bordered table-hover">
<thead class="table-light">
<tr>
<th scope="col">Nome do Modelo</th>
<th scope="col">Descrição</th>
</tr>
</thead>
<tbody>
{% for p in produtos %}
<tr>
<td>{{ p.codigo_modelo }}</td>
<td>{{ p.descricao }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<!-- Revisões -->
{% if revisoes %}
<h4 class="mb-3">Revisões</h4>
<table class="table table-bordered table-hover">
<thead class="table-light">
<tr>
<th scope="col">Revisão</th>
<th scope="col">Documento</th>
<th scope="col">Link</th>
</tr>
</thead>
<tbody>
{% for revisao in revisoes %}
<tr>
<td>{{ revisao.revisao }}</td>
<td><a href="{{ revisao.pdf.url }}" target="_blank">Ver Documento</a></td>
<td><a href="" target="_blank">Link</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<!-- Ações -->
<div class="d-flex justify-content-end mt-4">
<a class="btn btn-outline-info me-2" href="{% url 'manual_update' pk=object.id %}">Atualizar</a>
<a class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#deleteModal">Excluir</a>
</div>
</div>
</div>
<!-- Modal de Confirmação -->
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title" id="deleteModalLabel">Confirmar Exclusão</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Fechar"></button>
</div>
<div class="modal-body">
<p>Você tem certeza de que deseja excluir este manual e todos os itens relacionados?</p>
<p><strong>Total de itens relacionados:</strong> {{ produtos.count|add:revisoes.count }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
<form method="post" action="{% url 'manual_delete' pk=object.id %}">
{% csrf_token %}
<button type="submit" class="btn btn-danger">Confirmar Exclusão</button>
</form>
</div>
</div>
</div>
</div>
{% endblock content %}path('manual-detail/<int:pk>/', views.ManualDetailView.as_view(), name='manual_detail'),
path('manual-delete/<int:pk>/', views.ManualDeleteView.as_view(), name='manual_delete'),