Dica 23 - CRUD de produtos
Versões usadas no vídeo: Django 4.1.3, Python 3.10, django-widget-tweaks 1.4.12 e Tailwind CSS com o template Windster (Flowbite).
Na Dica 20 - Templates criamos um padrão de templates, urls e views para os usuários (lista, detalhes, adicionar e editar). Agora vamos reaproveitar esse padrão para fazer um CRUD completo dos produtos do projeto Dicas de Django: listar, ver detalhes, adicionar, editar e deletar, desta vez salvando os dados.
No fim, também tiramos da lista de usuários os botões de adicionar, editar e deletar: não faz muito sentido o administrador cadastrar ou editar usuários por ali. O usuário se cadastra sozinho (dica 17) e, mais para frente, vai editar só os próprios dados, num perfil.
Pré-requisitos
- O projeto das dicas anteriores, com a app
product(modelsCategory,ProductePhoto, criados na Dica 19.7) e os templates de usuário da Dica 20. django-widget-tweaksemINSTALLED_APPS.
O model Product, como ficou na dica 19.7:
# product/models.py
class Product(models.Model):
title = models.CharField('título', max_length=255, unique=True)
description = models.TextField('descrição', null=True, blank=True)
category = models.ForeignKey(
Category,
on_delete=models.SET_NULL,
verbose_name='categoria',
related_name='products',
null=True,
blank=True,
)
class Meta:
ordering = ('title',)
verbose_name = 'produto'
verbose_name_plural = 'produtos'
def __str__(self):
return f'{self.title}'
Os arquivos de template
A app product ainda não tem a pasta templates. Seguindo o padrão, criamos templates/product (com o nome da app, no singular) e os três templates:
mkdir -p backend/product/templates/product
touch backend/product/templates/product/product_list.html
touch backend/product/templates/product/product_detail.html
touch backend/product/templates/product/product_form.html
Em seguida copiamos o conteúdo dos templates de usuário para os de produto (user_list.html para product_list.html, user_detail.html para product_detail.html, user_form.html para product_form.html) e a pasta includes:
cp -r backend/accounts/templates/accounts/includes backend/product/templates/product/
breadcrumb.html, search.html e navigation_buttons.html ficam iguais aos da dica 20. O delete_modal.html vai ganhar um id no botão de deletar, como veremos adiante.
As urls
Crie product/urls.py, copiando a estrutura de accounts/urls.py. Aqui definimos app_name, então as urls serão referenciadas com o namespace product::
# product/urls.py
from django.urls import include, path
from backend.product import views as v
app_name = 'product'
# A ordem das urls é importante por causa do slug, quando existir.
product_patterns = [
path('', v.product_list, name='product_list'), # noqa E501
path('create/', v.product_create, name='product_create'), # noqa E501
path('<int:pk>/', v.product_detail, name='product_detail'), # noqa E501
path('<int:pk>/update/', v.product_update, name='product_update'), # noqa E501
path('<int:pk>/delete/', v.product_delete, name='product_delete'), # noqa E501
]
urlpatterns = [
path('', include(product_patterns)),
]
E inclua no urls.py principal, com o namespace:
# urls.py
from django.contrib import admin
from django.urls import include, path
urlpatterns = [
path('', include('backend.core.urls', namespace='core')), # noqa E501
path('accounts/', include('backend.accounts.urls')), # noqa E501
path('bookstore/', include('backend.bookstore.urls', namespace='bookstore')), # noqa E501
path('crm/', include('backend.crm.urls', namespace='crm')), # noqa E501
path('expense/', include('backend.expense.urls', namespace='expense')), # noqa E501
path('product/', include('backend.product.urls', namespace='product')), # noqa E501
path('admin/', admin.site.urls), # noqa E501
]
No menu lateral, ligue o item Produtos:
<!-- core/templates/includes/aside.html -->
<a href="{% url 'product:product_list' %}" class="text-base text-gray-900 font-normal rounded-lg hover:bg-gray-100 flex items-center p-2 group ">
...
<span class="ml-3 flex-1 whitespace-nowrap">Produtos</span>
</a>
No vídeo, o primeiro runserver reclamou porque o include da app ainda não estava no urls.py principal e o namespace product não existia. Com os dois ajustes acima, o menu passa a funcionar.
O formulário
# product/forms.py
from django import forms
from .models import Product
class ProductForm(forms.ModelForm):
required_css_class = 'required'
class Meta:
model = Product
fields = ('title', 'description', 'category')
Um ModelForm simples, com os campos um a um. O required_css_class = 'required' acrescenta a classe CSS required nas linhas dos campos obrigatórios quando o formulário é renderizado pelo próprio Django (útil para, por exemplo, colocar um asterisco via CSS).
As views
# product/views.py
from django.shortcuts import get_object_or_404, redirect, render
from .forms import ProductForm
from .models import Product
def product_list(request):
template_name = 'product/product_list.html'
object_list = Product.objects.all()
context = {'object_list': object_list}
return render(request, template_name, context)
def product_detail(request, pk):
template_name = 'product/product_detail.html'
instance = get_object_or_404(Product, pk=pk)
context = {'object': instance}
return render(request, template_name, context)
def product_create(request):
template_name = 'product/product_form.html'
form = ProductForm(request.POST or None)
if request.method == 'POST' and form.is_valid():
form.save()
return redirect('product:product_list')
context = {'form': form}
return render(request, template_name, context)
def product_update(request, pk):
template_name = 'product/product_form.html'
instance = get_object_or_404(Product, pk=pk)
form = ProductForm(request.POST or None, instance=instance)
if request.method == 'POST' and form.is_valid():
form.save()
return redirect('product:product_list')
context = {'form': form, 'object': instance}
return render(request, template_name, context)
def product_delete(request, pk):
instance = get_object_or_404(Product, pk=pk)
instance.delete()
return redirect('product:product_list')
Cada uma, em ordem:
- product_list: todos os produtos em
object_list(o padrão de nomes que adotamos). - product_detail: busca o produto pelo
pkda url comget_object_or_404e manda comoobject. - product_create: a diferença em relação à dica 20 é o trecho que salva. Se a requisição é POST e o formulário é válido,
form.save()grava o produto eredirectvolta para a lista. Num GET (ou com erros), o template é renderizado com o formulário. - product_update: igual ao create, mas com
instance=instance, para o formulário vir preenchido e osave()atualizar o mesmo registro. O'object': instanceno contexto faz o título mostrar Editar. - product_delete: busca o produto, apaga e volta para a lista.
O get_absolute_url
Para o título do produto na lista virar um link para os detalhes, crie o get_absolute_url no model, como fizemos no User, só que agora com o namespace:
# product/models.py
from django.db import models
from django.urls import reverse_lazy
...
class Product(models.Model):
...
def __str__(self):
return f'{self.title}'
def get_absolute_url(self):
return reverse_lazy('product:product_detail', kwargs={'pk': self.pk})
A lista de produtos
Em relação ao user_list.html: o título passa a ser Produtos, as urls usam o namespace product:, as colunas são Título, Descrição e Categoria (mais uma coluna vazia no começo, para a imagem), e a coluna de status sai.
<!-- product_list.html -->
{% extends "base.html" %}
{% block content %}
<!-- START: header -->
<div class="p-4 bg-white block sm:flex items-center justify-between border-b border-gray-200 lg:mt-1.5">
<div class="mb-1 w-full">
<div class="mb-4">
{% include "./includes/breadcrumb.html" %}
<h1 class="text-xl sm:text-2xl font-semibold text-gray-900">Produtos</h1>
</div>
<div class="sm:flex">
{% include "./includes/search.html" %}
<!-- ... -->
<th scope="col" class="p-4 text-left text-xs font-medium text-gray-500 uppercase">
</th>
<th scope="col" class="p-4 text-left text-xs font-medium text-gray-500 uppercase">
Título
</th>
<th scope="col" class="p-4 text-left text-xs font-medium text-gray-500 uppercase">
Descrição
</th>
<th scope="col" class="p-4 text-left text-xs font-medium text-gray-500 uppercase">
Categoria
</th>
<th scope="col" class="p-4">
</th>
</tr>
<!-- ... -->
<tbody class="bg-white divide-y divide-gray-200">
{% for object in object_list %}
<!-- ... -->
<td class="p-4 whitespace-nowrap text-base font-medium text-gray-900">{{ object.title }}</td>
<td class="p-4 whitespace-nowrap text-base font-medium text-gray-900">{{ object.description|default:"---" }}</td>
<td class="p-4 whitespace-nowrap text-base font-medium text-gray-900">{{ object.category|default:"---" }}</td>
<!-- ... -->
Código completo: backend/product/templates/product/product_list.html
Os detalhes
<!-- product_detail.html -->
{% extends "base.html" %}
{% block content %}
<div class="bg-white shadow rounded-lg p-4 sm:p-6 xl:p-8 ">
<div class="mb-4">
{% include "./includes/breadcrumb.html" %}
<h1 class="text-xl sm:text-2xl font-semibold text-gray-900">Detalhes: {{ object }}</h1>
</div>
<div class="mb-5">
<a href="javascript: history.go(-1)" class="text-base font-medium text-cyan-600 hover:bg-gray-100 rounded-lg">Voltar</a>
</div>
<div class="block w-full overflow-x-auto">
<img class="h-20 w-20 rounded-lg" src="https://via.placeholder.com/500" alt="">
</div>
<!-- Título -->
<div class="block w-full overflow-x-auto">
<div class="mt-2">
<div class="text-sm font-normal text-gray-500">Título</div>
<div class="text-base font-semibold text-gray-900">{{ object.title }}</div>
</div>
</div>
<!-- Descrição -->
<div class="block w-full overflow-x-auto">
<div class="mt-2">
<div class="text-sm font-normal text-gray-500">Descrição</div>
<div class="text-base font-semibold text-gray-900">{{ object.description }}</div>
</div>
</div>
<!-- Categoria -->
<div class="block w-full overflow-x-auto">
<div class="mt-2">
<div class="text-sm font-normal text-gray-500">Categoria</div>
<div class="text-base font-semibold text-gray-900">{{ object.category|default:"---" }}</div>
</div>
</div>
</div>
{% endblock content %}
Do user_detail.html copiado saem os campos de perfil (object.profile...), que não existem no produto.
O formulário de adicionar e editar
Igual ao user_form.html da dica 22 (já com os non_field_errors), trocando "Usuário" por "Produto" e o Cancelar para voltar à lista de produtos:
<!-- product_form.html -->
{% extends "base.html" %}
{% load widget_tweaks %}
{% block content %}
<div class="mx-auto md:h-screen flex flex-col px-6 pt-8 pt:mt-0">
<!-- Card -->
<div class="bg-white shadow rounded-lg md:mt-0 w-full sm:max-w-screen-sm xl:p-0">
<div class="p-6 sm:p-8 lg:p-16 space-y-8">
<h2 class="text-2xl lg:text-3xl font-bold text-gray-900">
{% if object.pk %}
Editar
{% else %}
Adicionar
{% endif %}
Produto
</h2>
{% if form.errors %}
{% for error in form.non_field_errors %}
<p class="text-red-500">{{ error }}</p>
{% endfor %}
{% endif %}
<form class="mt-8 space-y-6" action="." method="POST" enctype="multipart/form-data">
{% csrf_token %}
{% for field in form.visible_fields %}
<div>
<label class="text-sm font-medium text-gray-900 block mb-2">{{ field.label }}</label>
{% render_field field class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-cyan-600 focus:border-cyan-600 block w-full p-2.5" %}
</div>
<span class="text-sm font-medium text-gray-500">{{ field.help_text }}</span>
{% for error in field.errors %}
<span class="text-red-500">{{ error }}</span> <br>
{% endfor %}
{% endfor %}
<div class="flex flex-col sm:flex-row">
<button type="submit" class="text-white bg-cyan-600 hover:bg-cyan-700 focus:ring-4 focus:ring-cyan-200 font-medium rounded-lg text-base px-5 py-3 w-full sm:w-auto text-center">Salvar</button>
<a href="{% url 'product:product_list' %}" class="text-gray-900 bg-white border border-gray-300 hover:bg-gray-100 focus:ring-4 focus:ring-cyan-200 font-medium rounded-lg text-sm px-5 py-3 mt-2 sm:mt-0 sm:ml-2 w-full sm:w-auto text-center">
Cancelar
</a>
</div>
</form>
</div>
</div>
</div>
{% endblock content %}
Deletando com o modal
O modal de exclusão é um só e fica fora do {% for %} da tabela. Então, ao clicar em Deletar numa linha, o modal precisa saber qual produto apagar. A solução tem três partes:
- Cada botão Deletar guarda a url de exclusão do seu produto num atributo
data-urle passa o evento do clique paraopenModal(event):
<button type="button" data-url="{% url 'product:product_delete' object.pk %}" data-modal-toggle="delete-user-modal" onclick="openModal(event)" class="...">
- No
delete_modal.htmlda appproduct, o link Sim, deletar ganha oid="id_delete":
<!-- product/templates/product/includes/delete_modal.html -->
<!-- Delete User Modal -->
<div class="hidden overflow-x-hidden overflow-y-auto fixed top-4 left-0 right-0 md:inset-0 z-50 justify-center items-center h-modal sm:h-full" id="delete-user-modal">
<div class="relative w-full max-w-md px-4 h-full md:h-auto">
<!-- Modal content -->
<div class="bg-white rounded-lg shadow relative">
<!-- Modal header -->
<div class="flex justify-end p-2">
<button type="button" onclick="closeModal()" class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center" data-modal-toggle="delete-user-modal">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path></svg>
</button>
</div>
<!-- Modal body -->
<div class="p-6 pt-0 text-center">
<svg class="w-20 h-20 text-red-600 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
<h3 class="text-xl font-normal text-gray-500 mt-5 mb-6">Tem certeza que você quer deletar este registro?</h3>
<a id="id_delete" href="#" class="text-white bg-red-600 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-base inline-flex items-center px-3 py-2.5 text-center mr-2">
Sim, deletar
</a>
<a href="#" onclick="closeModal()" class="text-gray-900 bg-white hover:bg-gray-100 focus:ring-4 focus:ring-cyan-200 border border-gray-200 font-medium inline-flex items-center rounded-lg text-base px-3 py-2.5 text-center" data-modal-toggle="delete-user-modal">
Não, cancelar
</a>
</div>
</div>
</div>
</div>
- No bloco
jsdoproduct_list.html, oopenModallê odata-urldo botão clicado (event.target.dataset.url) e coloca essa url nohrefdo linkid_deleteantes de abrir o modal:
openModal = (event) => {
const url = event.target.dataset.url
document.getElementById('id_delete').setAttribute('href', url)
modal.show()
}
Inspecionando o elemento no navegador, depois de clicar em Deletar no produto 6, o link Sim, deletar aponta para /product/6/delete/. Clicando nele, a view product_delete apaga o produto e volta para a lista.
Observação: aqui a exclusão é feita por um link (requisição GET), do jeito que está no vídeo. Em produção o mais seguro é deletar só via POST, com {% csrf_token %}.
Testando
python manage.py runserver
- No admin, crie uma categoria (por exemplo, "Calçados").
- Em Produtos, clique em Adicionar, preencha "Tênis", uma descrição e a categoria, e clique em Salvar: o produto aparece na lista.
- Clique no título: abre a página de detalhes.
- Clique em Editar: o formulário vem preenchido e o título mostra Editar Produto; ao salvar, a lista mostra o valor novo. Cancelar volta para a lista.
- Clique em Deletar e confirme: o produto some da lista.
Limpando a lista de usuários
Por fim, na lista de usuários comentamos o botão Adicionar e a coluna com Editar e Deletar:
<!-- accounts/templates/accounts/user_list.html -->
<!-- <a href="{% url 'user_create' %}" data-modal-toggle="add-user-modal" class="...">
...
Adicionar
</a> -->
...
<!-- <td class="p-4 whitespace-nowrap space-x-2">
<a href="{% url 'user_update' object.pk %}" class="...">
...
Editar
</a>
<button type="button" data-modal-toggle="delete-user-modal" onclick="openModal()" class="...">
...
Deletar
</button>
</td> -->
Observação: o serviço de imagens via.placeholder.com, usado nos templates, não funciona mais; troque por outra imagem se for rodar hoje.