Dica 60 - Django: Adicionando atributos extras no formulário
Versões usadas no vídeo: Django 4.0, Python 3.9, Bootstrap 4.4.1 e htmx 1.6.1.
Repo: https://gitlab.com/rg3915/exame-inline
Veja como adicionar atributos extras no formulário.
Neste tutorial vamos colocar atributos HTML que o Django não conhece (no caso, os atributos hx-get e hx-swap do htmx) num campo de formulário, direto no forms.py, em vez de tentar montá-los no template.
O exemplo vem do projeto exame-inline: um atendimento médico (Care) tem vários itens de exame (CareItems), editados com um inlineformset_factory. Cada item tem um checkbox Feito? (is_done). A ideia é que, ao clicar no checkbox, o htmx chame uma URL que grava o is_done daquele item na hora, sem enviar o formulário inteiro.
O problema: chave dentro de chave
A URL que o checkbox tem que chamar depende do pk de cada item, por exemplo /exam/9/update/exam/. A primeira tentativa foi montar o atributo no template, algo como:
{% render_field field {{form.id.value}} %}
Isso não funciona: no template do Django não dá para colocar uma variável {{ ... }} dentro de uma tag {% ... %}. A alternativa no template seria uma gambiarra, escrevendo o <input> na mão e mantendo a numeração do formset (items-0-is_done, items-1-is_done, ...) sincronizada com o forloop.counter0.
A forma correta é definir os atributos adicionais no próprio formulário, no __init__, onde temos acesso a self.instance.pk e podemos usar o reverse para montar a URL.
Pré-requisitos
O projeto usado no vídeo pode ser clonado do GitLab:
git clone https://gitlab.com/rg3915/exame-inline.git
cd exame-inline
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
python contrib/env_gen.py
python manage.py migrate
python manage.py createsuperuser --username="admin" --email=""
O requirements.txt da época:
django-extensions==3.1.5
Django==4.0
python-decouple==3.5
O htmx é carregado no fim do base.html, junto com o JavaScript do Bootstrap:
<!-- backend/core/templates/base.html (trecho) -->
<script src="https://code.jquery.com/jquery-3.4.1.min.js"></script>
<!-- Bootstrap core JS -->
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/js/bootstrap.min.js"></script>
<script src="https://unpkg.com/htmx.org@1.6.1"></script>
</body>
Os models
# backend/exam/models.py
from django.contrib.auth.models import User
from django.db import models
from django.urls import reverse_lazy
# ... (Patient e Exam: veja o arquivo completo no GitLab)
class Care(models.Model):
doctor = models.CharField('médico', max_length=100)
patient = models.ForeignKey(
Patient,
on_delete=models.CASCADE,
verbose_name='paciente',
related_name='cares',
)
created = models.DateTimeField('data', auto_now_add=True)
# ... (veja o arquivo completo no GitLab)
class CareItems(models.Model):
care = models.ForeignKey(
Care,
on_delete=models.CASCADE,
verbose_name='atendimento',
related_name='care_items',
)
exam = models.ForeignKey(
Exam,
on_delete=models.CASCADE,
verbose_name='exame',
related_name='care_items_exam',
)
is_done = models.BooleanField('feito?', default=False)
# ... (veja o arquivo completo no GitLab)
Código completo: backend/exam/models.py
A URL que o checkbox vai chamar
O nome da URL é care_update_exam, no namespace exam. É esse nome que o reverse vai traduzir.
# backend/exam/urls.py
from django.urls import path
from backend.exam import views as v
app_name = 'exam'
urlpatterns = [
path('', v.care_list, name='care_list'),
path('create/', v.care_create, name='care_create'),
path('<int:pk>/', v.care_detail, name='care_detail'),
path('<int:pk>/update/', v.care_update, name='care_update'),
path('<int:pk>/update/exam/', v.care_update_exam, name='care_update_exam'),
]
No backend/urls.py, o app é incluído com path('exam/', include('backend.exam.urls', namespace='exam')).
A view que grava o is_done
Quando o checkbox está marcado, o htmx envia o valor do campo na query string (?items-0-is_done=on). Quando está desmarcado, não envia nada. A view só verifica se veio algum valor:
# backend/exam/views.py (trecho)
from django.http import HttpResponse
from .models import CareItems
def care_update_exam(request, pk):
'''
Atualiza cada exame marcando is_done como True ou False.
'''
care_items = CareItems.objects.get(pk=pk)
# Verifica se a lista com os valores do request tem alguma coisa ou
# se a lista vem vazia.
is_done_list = list(request.GET.values())
if is_done_list:
is_done = True
else:
is_done = False
care_items.is_done = is_done
care_items.save()
return HttpResponse(pk)
A view de edição do atendimento monta o formulário principal e o formset:
# backend/exam/views.py (trecho)
def care_update(request, pk):
template_name = 'exam/care_update.html'
care_instance = Care.objects.get(pk=pk)
form = CareForm(request.POST or None, instance=care_instance, prefix='main')
formset = CareItemsFormset(request.POST or None, instance=care_instance, prefix='items')
context = {'form': form, 'formset': formset}
return render(request, template_name, context)
Os atributos extras no forms.py
Aqui está a dica. No __init__ do CareItemsForm, depois de chamar o super():
self.instance.pké opkdo item do formset que está sendo montado.reverse('exam:care_update_exam', kwargs={'pk': ...})traduz o nome da URL para o caminho, por exemplo/exam/9/update/exam/.widget.attrsé um dicionário com os atributos HTML do campo. Tudo o que você colocar nele sai no<input>, inclusive atributos que o Django não conhece, comohx-getehx-swap.
# backend/exam/forms.py
from django import forms
from django.forms import inlineformset_factory
from django.urls import reverse
from .models import Care, CareItems
class CareForm(forms.ModelForm):
id = forms.IntegerField(required=False)
class Meta:
model = Care
fields = ('id', 'doctor', 'patient')
def __init__(self, *args, **kwargs):
super(CareForm, self).__init__(*args, **kwargs)
for field_name, field in self.fields.items():
field.widget.attrs['class'] = 'form-control'
self.fields['id'].label = ''
self.fields['id'].widget = forms.HiddenInput()
class CareItemsForm(forms.ModelForm):
id = forms.IntegerField()
class Meta:
model = CareItems
fields = ('care', 'id', 'exam', 'is_done')
def __init__(self, *args, **kwargs):
super(CareItemsForm, self).__init__(*args, **kwargs)
for field_name, field in self.fields.items():
field.widget.attrs['class'] = 'form-control'
self.fields['care'].label = ''
self.fields['care'].widget = forms.HiddenInput()
self.fields['id'].label = ''
self.fields['id'].widget = forms.HiddenInput()
pk = reverse('exam:care_update_exam', kwargs={'pk': self.instance.pk})
self.fields['is_done'].widget.attrs['hx-get'] = f'{pk}'
self.fields['is_done'].widget.attrs['hx-swap'] = f'none'
CareItemsFormset = inlineformset_factory(
Care,
CareItems,
form=CareItemsForm,
extra=0,
can_delete=False,
min_num=1,
validate_min=True,
)
Apesar do nome, a variável pk guarda a URL já traduzida, e não o número. O hx-swap="none" diz ao htmx para não trocar nada na página com a resposta: só queremos gravar o valor no banco.
O template
Como os atributos já estão no widget, o template só renderiza o campo, sem nenhuma tag dentro de outra:
<!-- backend/exam/templates/exam/care_update.html -->
{% extends "base.html" %}
{% load static %}
{% block css %}
<style>
.form-control {
margin: 10px;
}
</style>
{% endblock css %}
{% block content %}
<h1>Editar atendimento</h1>
<div class="row">
<div class="cols">
<form method="POST" novalidate>
{% csrf_token %}
{{ form.as_p }}
{{ formset.management_form }}
<div class="row">
<div class="cols">
<legend>Itens</legend>
{% for care_item_form in formset %}
<div id="care" class="form-inline">
<div id="item-{{ forloop.counter0 }}" class="form-group">
{{ care_item_form.care }}
{{ care_item_form.id }}
{{ care_item_form.exam.label }}
{{ care_item_form.exam }}
{{ care_item_form.is_done.label }}
{{ care_item_form.is_done }} <!-- Os atributos hx-get, hx-swap estão definidos no forms.py -->
</div>
</div>
{% endfor %}
</div>
</div>
</form>
<a href="{% url 'exam:care_detail' form.id.value %}" class="btn btn-primary">Fechar</a>
</div>
</div>
{% endblock content %}
Resultado
Abra http://localhost:8000/exam/9/update/ (troque o 9 pelo id do seu atendimento) e inspecione um checkbox no DevTools. O HTML gerado é parecido com este:
<input type="checkbox" name="items-0-is_done" class="form-control" hx-get="/exam/1/update/exam/" hx-swap="none" id="id_items-0-is_done">
Cada item do formset recebe a URL com o seu próprio pk. Ao marcar ou desmarcar o checkbox, o htmx faz um GET nessa URL e a view grava is_done como True ou False. Ao voltar para a página de detalhes do atendimento (/exam/9/), os exames aparecem com ✔ ou ✖ conforme o que foi marcado.
Conclusão
Sempre que precisar de um atributo HTML que dependa de dados do objeto (uma URL com pk, um data-*, um atributo do htmx), defina-o no __init__ do formulário com self.fields['campo'].widget.attrs['atributo'] = valor. Assim o template fica limpo e você evita o problema de chave dentro de chave.