diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ecc14cd..43fb25b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,4 +17,12 @@ repos: rev: 25.1.0 hooks: - id: black - language_version: python3.12 +- repo: local + hooks: + - id: run-tests + name: Run Django Tests + entry: poetry run python manage.py test + language: system + pass_filenames: false + always_run: true + stages: [pre-push] diff --git a/thebook/base/templates/base/navbar.html b/thebook/base/templates/base/navbar.html index 2a324d8..86e1ac5 100644 --- a/thebook/base/templates/base/navbar.html +++ b/thebook/base/templates/base/navbar.html @@ -3,8 +3,7 @@ diff --git a/thebook/base/templates/base/sidebar.html b/thebook/base/templates/base/sidebar.html index 0854e07..41cfaac 100644 --- a/thebook/base/templates/base/sidebar.html +++ b/thebook/base/templates/base/sidebar.html @@ -23,19 +23,18 @@ - - +
+ + Cancelar + + +
+ +{% endblock content %} diff --git a/thebook/fornecedores/templates/fornecedores/confirmar_exclusao_entrega.html b/thebook/fornecedores/templates/fornecedores/confirmar_exclusao_entrega.html new file mode 100644 index 0000000..c0f0a8d --- /dev/null +++ b/thebook/fornecedores/templates/fornecedores/confirmar_exclusao_entrega.html @@ -0,0 +1,29 @@ +{% extends "base/main.html" %} + +{% block content %} +
+

Remover entrega

+ + Cancelar + +
+ +
+
+

+ Tem certeza de que deseja excluir a entrega {{ entrega.titulo }}? + Essa operação não pode ser desfeita. +

+ +
+ {% csrf_token %} + + + Voltar sem excluir + +
+
+
+{% endblock content %} diff --git a/thebook/fornecedores/templates/fornecedores/detalhe.html b/thebook/fornecedores/templates/fornecedores/detalhe.html new file mode 100644 index 0000000..0d03ba3 --- /dev/null +++ b/thebook/fornecedores/templates/fornecedores/detalhe.html @@ -0,0 +1,291 @@ +{% extends "base/main.html" %} + +{% block content %} +
+
+

+ {{ fornecedor.nome }} +

+

+ Última atualização em {{ fornecedor.atualizado_em|date:"d/m/Y H:i" }}. +

+
+
+ + + Voltar + + + + Editar + + + + Excluir + +
+
+ +
+
+
+
+
+ +
+
{{ fornecedor.nome }}
+

+ {% if fornecedor.documento %} + {{ fornecedor.documento }} + {% else %} + Documento não informado + {% endif %} +

+ {% if fornecedor.ativo %} + Fornecedor ativo + {% else %} + Fornecedor inativo + {% endif %} +
+
+
+ +
+
+
+
+ Contato +
+
+
+
+
+

E-mail

+

+ {% if fornecedor.email %} + {{ fornecedor.email }} + {% else %} + Não informado + {% endif %} +

+
+
+

Telefone

+

{{ fornecedor.telefone|default:"Não informado" }}

+
+
+

Site

+ {% if fornecedor.site %} + + {{ fornecedor.site }} + + {% else %} +

Não informado

+ {% endif %} +
+
+
+
+ +
+
+
+ Notas e observações +
+
+
+ {% if fornecedor.observacoes %} +

{{ fornecedor.observacoes|linebreaks }}

+ {% else %} +

Nenhuma observação cadastrada até o momento.

+ {% endif %} +
+
+ +
+
+
+ Histórico de entregas +
+ + Registrar entrega + +
+
+ {% if entregas %} +
+ + + + + + + + + + + + {% for entrega in entregas %} + + + + + + + + {% endfor %} + +
TítuloDataQualidadeValorAções
+ {{ entrega.titulo }} +
{{ entrega.descricao|truncatechars:80 }}
+
{{ entrega.data_entrega|date:"d/m/Y" }} + {% if entrega.qualidade == 1 %} + Excelente + {% elif entrega.qualidade == 2 %} + Boa + {% elif entrega.qualidade == 3 %} + Regular + {% else %} + Ruim + {% endif %} + + {% if entrega.valor_estimado %} + R$ {{ entrega.valor_estimado }} + {% else %} + — + {% endif %} + + +
+
+ {% else %} +

+ Nenhuma entrega registrada ainda. Use o botão “Registrar entrega” para criar o primeiro registro. +

+ {% endif %} +
+
+
+
+
+
+
+
+
+ Histórico financeiro (Transações) +
+
+
+ {% if transactions %} +
+ + + + + + + + + + + + {% for transaction in transactions %} + + + + + + + + + + + {% endfor %} + +
#DataDescriçãoCategoriaValor
+ + {{ transaction.date|date:"d/m/Y" }} + {{ transaction.description }} + {% if transaction.notes %} +
{{ transaction.notes|truncatechars:60 }} + {% endif %} +
+ {% if transaction.category %} + {{ transaction.category.name }} + {% else %} + + {% endif %} + + R$ {{ transaction.amount }} +
+
+ Carregando detalhes... +
+
+
+
+ {% else %} +

+ Nenhuma transação financeira vinculada a este fornecedor. +

+ {% endif %} + + +
+
+
+
+{% endblock content %} + +{% block js_scripts %} + +{% endblock js_scripts %} diff --git a/thebook/fornecedores/templates/fornecedores/entrega_formulario.html b/thebook/fornecedores/templates/fornecedores/entrega_formulario.html new file mode 100644 index 0000000..4f69d34 --- /dev/null +++ b/thebook/fornecedores/templates/fornecedores/entrega_formulario.html @@ -0,0 +1,88 @@ +{% extends "base/main.html" %} + +{% block content %} +
+
+

{{ titulo }}

+

+ Registre cada fornecimento para acompanhar histórico, qualidade percebida e custos aproximados. +

+
+ + Voltar para o fornecedor + +
+ +
+
+
+
Dados da Entrega
+
+
+
+ {% csrf_token %} + +
+ + {{ form.titulo }} + {% include "fornecedores/partials/_help_and_errors.html" with field=form.titulo %} +
+ +
+
+
+ + {{ form.valor_estimado }} + {% include "fornecedores/partials/_help_and_errors.html" with field=form.valor_estimado %} +
+
+
+
+ + {{ form.data_entrega }} + {% include "fornecedores/partials/_help_and_errors.html" with field=form.data_entrega %} +
+
+
+ +
+ + {{ form.qualidade }} + {% include "fornecedores/partials/_help_and_errors.html" with field=form.qualidade %} +
+ +
+ + {{ form.descricao }} + {% include "fornecedores/partials/_help_and_errors.html" with field=form.descricao %} +
+ +
+ + {{ form.observacoes }} + {% include "fornecedores/partials/_help_and_errors.html" with field=form.observacoes %} +
+ +
+ + Cancelar + + + + + +
+
+
+
+
+{% endblock content %} diff --git a/thebook/fornecedores/templates/fornecedores/formulario.html b/thebook/fornecedores/templates/fornecedores/formulario.html new file mode 100644 index 0000000..c13cff1 --- /dev/null +++ b/thebook/fornecedores/templates/fornecedores/formulario.html @@ -0,0 +1,321 @@ +{% extends "base/main.html" %} + +{% block content %} +
+
+

{{ titulo }}

+

+ Preencha os dados passando por 3 etapas simples. Só salvamos no fim, então fique à vontade para revisar antes de confirmar. +

+
+ + Cancelar + +
+ +
+
+ +
+
+
1
+
Identificação
+
+
+
2
+
Contato
+
+
+
3
+
Observações
+
+
+ +
+ {% csrf_token %} + +
+
+
+
+ Dados do fornecedor +
+
+
+
+
+ + {{ form.nome }} + {% include "fornecedores/partials/_help_and_errors.html" with field=form.nome %} +
+
+ + {{ form.documento }} + {% include "fornecedores/partials/_help_and_errors.html" with field=form.documento %} +
+
+
+
+
+ Status do cadastro +
+ {{ form.ativo }} + +
+ {% for error in form.ativo.errors %} +
{{ error }}
+ {% endfor %} +
+
+
+
+
+
+ +
+
+
+
+ Como entrar em contato +
+
+
+
+
+ + {{ form.email }} + {% include "fornecedores/partials/_help_and_errors.html" with field=form.email %} +
+
+ + {{ form.telefone }} + {% include "fornecedores/partials/_help_and_errors.html" with field=form.telefone %} +
+
+ + {{ form.site }} + {% include "fornecedores/partials/_help_and_errors.html" with field=form.site %} +
+
+
+
+
+ +
+
+
+
+ Notas gerais +
+
+
+
+ + {{ form.observacoes }} + {% include "fornecedores/partials/_help_and_errors.html" with field=form.observacoes %} +
+
+
+
+ + +
+ + + +
+
+
+
+ + + + +{% endblock content %} diff --git a/thebook/fornecedores/templates/fornecedores/listar.html b/thebook/fornecedores/templates/fornecedores/listar.html new file mode 100644 index 0000000..73a4c66 --- /dev/null +++ b/thebook/fornecedores/templates/fornecedores/listar.html @@ -0,0 +1,99 @@ +{% extends "base/main.html" %} + +{% block content %} +
+
+

Fornecedores

+

+ Centralize todos os contatos estratégicos. Use os botões para editar ou arquivar quando necessário. +

+
+ + + Novo fornecedor + +
+ + {% if fornecedores %} +
+
+
+ Cadastros atuais +
+ {{ fornecedores|length }} fornecedor(es) +
+
+
+ + + + + + + + + + + + + {% for fornecedor in fornecedores %} + + + + + + + + + {% endfor %} + +
NomeDocumentoE-mailTelefoneStatusAções
+ + {{ fornecedor.nome }} + + {{ fornecedor.documento|default:"—" }} + {% if fornecedor.email %} + {{ fornecedor.email }} + {% else %} + — + {% endif %} + {{ fornecedor.telefone|default:"—" }} + {% if fornecedor.ativo %} + + Ativo + + {% else %} + + Inativo + + {% endif %} + + +
+
+
+
+ {% else %} +
+
+ +
Nenhum fornecedor cadastrado
+

+ Cadastre o primeiro fornecedor para acompanhar contatos de compras, manutenção e serviços terceirizados. +

+ + Cadastrar fornecedor + +
+
+ {% endif %} +{% endblock content %} diff --git a/thebook/fornecedores/templates/fornecedores/partials/_help_and_errors.html b/thebook/fornecedores/templates/fornecedores/partials/_help_and_errors.html new file mode 100644 index 0000000..e0a498a --- /dev/null +++ b/thebook/fornecedores/templates/fornecedores/partials/_help_and_errors.html @@ -0,0 +1,8 @@ +{% if field.help_text and field.name != "ativo" %} + {{ field.help_text }} +{% endif %} +{% for error in field.errors %} +
+ {{ error }} +
+{% endfor %} diff --git a/thebook/fornecedores/templates/fornecedores/vincular_transacoes.html b/thebook/fornecedores/templates/fornecedores/vincular_transacoes.html new file mode 100644 index 0000000..02679a0 --- /dev/null +++ b/thebook/fornecedores/templates/fornecedores/vincular_transacoes.html @@ -0,0 +1,131 @@ +{% extends "base/main.html" %} + +{% load i18n currency %} + +{% block content %} +
+

+ Vincular Transações a {{ fornecedor.nome }} +

+
+ +
+
+
Selecione as transações para vincular
+ +
+
+ + + + + {% if month and year %} + {{ month }}/{{ year }} + {% else %} + Todos + {% endif %} + + + + +
+
+
+
+ + +
+ + +
+
+ +
+
+ +
+
+ + Limpar +
+
+
+ +
+ {% csrf_token %} +
+ + + + + + + + + + + + {% for t in transactions %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
+ + DataDescriçãoValorLivro Caixa
+ + {{ t.date|date:"d/m/Y" }} + {{ t.description }} + {% if t.category %} +
{{ t.category.name }} + {% endif %} +
+ {{ t.amount|money }} + {{ t.bank_account.name }}
+ Nenhuma transação sem fornecedor encontrada com os filtros atuais. +
+
+ +
+ + + Cancelar + +
+
+
+
+{% endblock %} + +{% block js_scripts %} + +{% endblock %} diff --git a/thebook/fornecedores/tests/__init__.py b/thebook/fornecedores/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/thebook/fornecedores/tests/test_forms.py b/thebook/fornecedores/tests/test_forms.py new file mode 100644 index 0000000..24c4160 --- /dev/null +++ b/thebook/fornecedores/tests/test_forms.py @@ -0,0 +1,34 @@ +""" +Testes para o formulário de fornecedores. +""" + +from datetime import date + +from django.test import TestCase + +from thebook.fornecedores.forms import EntregaFornecedorForm, FornecedorForm + + +class FornecedorFormTest(TestCase): + def test_form_valido_com_campos_minimos(self): + form = FornecedorForm(data={"nome": "LHC Supplies"}) + self.assertTrue(form.is_valid()) + + def test_form_invalido_quando_nome_vazio(self): + form = FornecedorForm(data={"nome": ""}) + self.assertFalse(form.is_valid()) + self.assertIn("nome", form.errors) + + +class EntregaFornecedorFormTest(TestCase): + def test_form_valido(self): + form = EntregaFornecedorForm( + data={ + "titulo": "Compra de parafusos", + "descricao": "Caixa com 500 unidades, inox.", + "qualidade": 2, + "data_entrega": date.today(), + "valor_estimado": "120.00", + } + ) + self.assertTrue(form.is_valid()) diff --git a/thebook/fornecedores/tests/test_models.py b/thebook/fornecedores/tests/test_models.py new file mode 100644 index 0000000..4a768a9 --- /dev/null +++ b/thebook/fornecedores/tests/test_models.py @@ -0,0 +1,47 @@ +""" +Testes para o modelo Fornecedor. +""" + +from decimal import Decimal + +from django.test import TestCase +from django.utils import timezone + +from thebook.fornecedores.models import ( + EntregaFornecedor, + Fornecedor, + QualidadeEntrega, +) + + +class FornecedorModelTest(TestCase): + def setUp(self): + self.fornecedor = Fornecedor.objects.create(nome="Loja do Tio Zé") + + def test_str_retorna_nome(self): + self.assertEqual(str(self.fornecedor), "Loja do Tio Zé") + + def test_ordering_por_nome(self): + Fornecedor.objects.create(nome="Alpha") + Fornecedor.objects.create(nome="Beta") + + nomes = list(Fornecedor.objects.values_list("nome", flat=True)) + self.assertEqual(nomes, ["Alpha", "Beta", "Loja do Tio Zé"]) + + +class EntregaFornecedorModelTest(TestCase): + def setUp(self): + self.fornecedor = Fornecedor.objects.create(nome="Print Lab") + + def test_cria_entrega_e_str(self): + entrega = EntregaFornecedor.objects.create( + fornecedor=self.fornecedor, + titulo="Lotes de adesivos", + descricao="200 unidades, acabamento fosco", + qualidade=QualidadeEntrega.EXCELENTE, + data_entrega=timezone.now().date(), + valor_estimado=Decimal("320.50"), + ) + + self.assertEqual(str(entrega), "Lotes de adesivos (Print Lab)") + self.assertEqual(entrega.fornecedor, self.fornecedor) diff --git a/thebook/fornecedores/tests/test_views.py b/thebook/fornecedores/tests/test_views.py new file mode 100644 index 0000000..cf9a2d2 --- /dev/null +++ b/thebook/fornecedores/tests/test_views.py @@ -0,0 +1,150 @@ +""" +Testes de integração das views de fornecedores. +""" + +from datetime import date + +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse + +from thebook.fornecedores.models import EntregaFornecedor, Fornecedor, QualidadeEntrega + + +class FornecedorViewsTest(TestCase): + def setUp(self): + self.user = get_user_model().objects.create_user( + email="user@example.com", + password="senha-segura", + ) + self.client.force_login(self.user) + + def test_listar_fornecedores(self): + Fornecedor.objects.create(nome="Fornecedor X") + + resposta = self.client.get(reverse("fornecedores:listar")) + + self.assertEqual(resposta.status_code, 200) + self.assertTemplateUsed(resposta, "fornecedores/listar.html") + self.assertContains(resposta, "Fornecedor X") + + def test_criar_fornecedor(self): + resposta = self.client.post( + reverse("fornecedores:criar"), + data={"nome": "Novo fornecedor"}, + follow=True, + ) + + self.assertRedirects(resposta, reverse("fornecedores:listar")) + self.assertTrue( + Fornecedor.objects.filter(nome="Novo fornecedor").exists(), + ) + + def test_editar_fornecedor(self): + fornecedor = Fornecedor.objects.create(nome="Fornecedor Original") + + resposta = self.client.post( + reverse("fornecedores:editar", args=[fornecedor.id]), + data={"nome": "Fornecedor Atualizado"}, + follow=True, + ) + + self.assertRedirects(resposta, reverse("fornecedores:listar")) + fornecedor.refresh_from_db() + self.assertEqual(fornecedor.nome, "Fornecedor Atualizado") + + def test_excluir_fornecedor(self): + fornecedor = Fornecedor.objects.create(nome="Fornecedor Apagar") + + resposta = self.client.post( + reverse("fornecedores:excluir", args=[fornecedor.id]), + follow=True, + ) + + self.assertRedirects(resposta, reverse("fornecedores:listar")) + self.assertFalse(Fornecedor.objects.filter(id=fornecedor.id).exists()) + + def test_detalhar_fornecedor(self): + fornecedor = Fornecedor.objects.create(nome="Fornecedor Detalhe") + + resposta = self.client.get( + reverse("fornecedores:detalhar", args=[fornecedor.id]) + ) + + self.assertEqual(resposta.status_code, 200) + self.assertTemplateUsed(resposta, "fornecedores/detalhe.html") + self.assertContains(resposta, "Fornecedor Detalhe") + + def test_criar_entrega(self): + fornecedor = Fornecedor.objects.create(nome="Fornecedor Entrega") + + resposta = self.client.post( + reverse("fornecedores:entrega-criar", args=[fornecedor.id]), + data={ + "titulo": "Compra de adesivos", + "descricao": "200 unidades", + "qualidade": QualidadeEntrega.BOA, + "data_entrega": date.today(), + }, + follow=True, + ) + + self.assertRedirects( + resposta, reverse("fornecedores:detalhar", args=[fornecedor.id]) + ) + self.assertTrue( + fornecedor.entregas.filter(titulo="Compra de adesivos").exists() + ) + + def test_editar_entrega(self): + fornecedor = Fornecedor.objects.create(nome="Fornecedor Edição") + entrega = EntregaFornecedor.objects.create( + fornecedor=fornecedor, + titulo="Entrega antiga", + descricao="Teste", + qualidade=QualidadeEntrega.REGULAR, + data_entrega=date.today(), + ) + + resposta = self.client.post( + reverse( + "fornecedores:entrega-editar", + args=[fornecedor.id, entrega.id], + ), + data={ + "titulo": "Entrega atualizada", + "descricao": "Atualizado", + "qualidade": QualidadeEntrega.EXCELENTE, + "data_entrega": date.today(), + }, + follow=True, + ) + + self.assertRedirects( + resposta, reverse("fornecedores:detalhar", args=[fornecedor.id]) + ) + entrega.refresh_from_db() + self.assertEqual(entrega.titulo, "Entrega atualizada") + + def test_excluir_entrega(self): + fornecedor = Fornecedor.objects.create(nome="Fornecedor Excluir") + entrega = EntregaFornecedor.objects.create( + fornecedor=fornecedor, + titulo="Entrega será apagada", + descricao="Teste", + qualidade=QualidadeEntrega.BOA, + data_entrega=date.today(), + ) + + resposta = self.client.post( + reverse( + "fornecedores:entrega-excluir", + args=[fornecedor.id, entrega.id], + ), + follow=True, + ) + + self.assertRedirects( + resposta, reverse("fornecedores:detalhar", args=[fornecedor.id]) + ) + self.assertFalse(fornecedor.entregas.exists()) diff --git a/thebook/fornecedores/urls.py b/thebook/fornecedores/urls.py new file mode 100644 index 0000000..f3272bb --- /dev/null +++ b/thebook/fornecedores/urls.py @@ -0,0 +1,39 @@ +""" +Rotas da aplicação de fornecedores. + +Separar as URLs por app ajuda a manter o projeto organizado. +""" + +from django.urls import path + +from thebook.fornecedores import views + +app_name = "fornecedores" + +urlpatterns = [ + path("", views.listar_fornecedores, name="listar"), + path("novo/", views.criar_fornecedor, name="criar"), + path("/", views.detalhar_fornecedor, name="detalhar"), + path("/editar/", views.editar_fornecedor, name="editar"), + path("/excluir/", views.excluir_fornecedor, name="excluir"), + path( + "/entregas/novo/", + views.criar_entrega, + name="entrega-criar", + ), + path( + "/entregas//editar/", + views.editar_entrega, + name="entrega-editar", + ), + path( + "/entregas//excluir/", + views.excluir_entrega, + name="entrega-excluir", + ), + path( + "/vincular-transacoes/", + views.vincular_transacoes, + name="vincular-transacoes", + ), +] diff --git a/thebook/fornecedores/views.py b/thebook/fornecedores/views.py new file mode 100644 index 0000000..2250c0c --- /dev/null +++ b/thebook/fornecedores/views.py @@ -0,0 +1,391 @@ +import csv +import datetime + +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.db.models import Q +from django.http import HttpResponse +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse + +from thebook.bookkeeping.models import BankAccount, Transaction +from thebook.fornecedores.forms import EntregaFornecedorForm, FornecedorForm +from thebook.fornecedores.models import EntregaFornecedor, Fornecedor + + +@login_required +def listar_fornecedores(request): + """ + Lista todos os fornecedores cadastrados. + + - Obtém todos os objetos da tabela. + - Renderiza o template passando a lista no contexto. + """ + + fornecedores = Fornecedor.objects.all() + return render( + request, + "fornecedores/listar.html", + {"fornecedores": fornecedores}, + ) + + +@login_required +def detalhar_fornecedor(request, fornecedor_id): + """ + Mostra os detalhes de um fornecedor específico. + + `get_object_or_404` tenta buscar o fornecedor e, se não existir, + levanta um erro 404 automaticamente. + """ + + fornecedor = get_object_or_404(Fornecedor, pk=fornecedor_id) + entregas = fornecedor.entregas.select_related("fornecedor") + + transactions = fornecedor.transactions.select_related("category").order_by("-date") + + return render( + request, + "fornecedores/detalhe.html", + { + "fornecedor": fornecedor, + "entregas": entregas, + "transactions": transactions, + }, + ) + + +@login_required +def criar_fornecedor(request): + """ + Cria um novo fornecedor. + + - GET -> exibe o formulário vazio. + - POST -> valida os dados, salva e redireciona para a lista. + """ + + if request.method == "POST": + form = FornecedorForm(request.POST) + if form.is_valid(): + novo_fornecedor = form.save() + messages.success( + request, + f"Fornecedora(o) '{novo_fornecedor.nome}' criada(o) com sucesso!", + ) + return redirect("fornecedores:listar") + else: + form = FornecedorForm() + + return render( + request, + "fornecedores/formulario.html", + {"form": form, "titulo": "Cadastrar fornecedor"}, + ) + + +@login_required +def editar_fornecedor(request, fornecedor_id): + """ + Atualiza um fornecedor existente. + + A diferença principal em relação a `criar_fornecedor` é que passamos + o `instance` para o `FornecedorForm`, permitindo editar os dados. + """ + + fornecedor = get_object_or_404(Fornecedor, pk=fornecedor_id) + + if request.method == "POST": + form = FornecedorForm(request.POST, instance=fornecedor) + if form.is_valid(): + fornecedor_atualizado = form.save() + messages.success( + request, + f"Fornecedora(o) '{fornecedor_atualizado.nome}' atualizada(o) com sucesso!", + ) + return redirect("fornecedores:listar") + else: + form = FornecedorForm(instance=fornecedor) + + return render( + request, + "fornecedores/formulario.html", + { + "form": form, + "titulo": f"Editar fornecedor — {fornecedor.nome}", + }, + ) + + +@login_required +def excluir_fornecedor(request, fornecedor_id): + """ + Exclui um fornecedor após confirmação. + + - GET -> mostra uma página de confirmação. + - POST -> realiza a exclusão e redireciona. + """ + + fornecedor = get_object_or_404(Fornecedor, pk=fornecedor_id) + + if request.method == "POST": + nome = fornecedor.nome + fornecedor.delete() + messages.success(request, f"Fornecedora(o) '{nome}' removida(o) com sucesso!") + return redirect("fornecedores:listar") + + return render( + request, + "fornecedores/confirmar_exclusao.html", + {"fornecedor": fornecedor}, + ) + + +@login_required +def criar_entrega(request, fornecedor_id): + """ + Cria uma nova entrega associada a um fornecedor. + + - GET -> exibe o formulário de criação. + - POST -> valida, salva e redireciona (ou retorna formulário com erros). + + Suporta HTMX: em requisições HTMX, retorna HTML parcial ou redireciona via HX-Redirect. + """ + fornecedor = get_object_or_404(Fornecedor, pk=fornecedor_id) + is_htmx = request.headers.get("HX-Request") == "true" + + if request.method == "POST": + form = EntregaFornecedorForm(request.POST) + if form.is_valid(): + entrega = form.save(commit=False) + entrega.fornecedor = fornecedor + entrega.save() + messages.success( + request, + "Entrega registrada com sucesso!", + ) + + # Se for requisição HTMX, retorna redirecionamento via header + if is_htmx: + response = HttpResponse() + response["HX-Redirect"] = reverse( + "fornecedores:detalhar", args=[fornecedor.id] + ) + return response + + # Senão, redireciona normalmente + return redirect("fornecedores:detalhar", fornecedor.id) + else: + form = EntregaFornecedorForm() + + return render( + request, + "fornecedores/entrega_formulario.html", + { + "form": form, + "fornecedor": fornecedor, + "titulo": f"Registrar entrega — {fornecedor.nome}", + }, + ) + + +@login_required +def editar_entrega(request, fornecedor_id, entrega_id): + """ + Edita uma entrega existente associada a um fornecedor. + + - GET -> exibe o formulário pré-preenchido com os dados atuais. + - POST -> valida, atualiza e redireciona (ou retorna formulário com erros). + + Suporta HTMX: em requisições HTMX, retorna HTML parcial ou redireciona via HX-Redirect. + """ + fornecedor = get_object_or_404(Fornecedor, pk=fornecedor_id) + entrega = get_object_or_404(EntregaFornecedor, pk=entrega_id, fornecedor=fornecedor) + is_htmx = request.headers.get("HX-Request") == "true" + + if request.method == "POST": + form = EntregaFornecedorForm(request.POST, instance=entrega) + if form.is_valid(): + form.save() + messages.success(request, "Entrega atualizada com sucesso!") + + # Se for requisição HTMX, retorna redirecionamento via header + if is_htmx: + response = HttpResponse() + response["HX-Redirect"] = reverse( + "fornecedores:detalhar", args=[fornecedor.id] + ) + return response + + # Senão, redireciona normalmente + return redirect("fornecedores:detalhar", fornecedor.id) + else: + form = EntregaFornecedorForm(instance=entrega) + + return render( + request, + "fornecedores/entrega_formulario.html", + { + "form": form, + "fornecedor": fornecedor, + "entrega": entrega, + "titulo": f"Editar entrega — {entrega.titulo}", + }, + ) + + +@login_required +def excluir_entrega(request, fornecedor_id, entrega_id): + fornecedor = get_object_or_404(Fornecedor, pk=fornecedor_id) + entrega = get_object_or_404(EntregaFornecedor, pk=entrega_id, fornecedor=fornecedor) + + if request.method == "POST": + titulo = entrega.titulo + entrega.delete() + messages.success( + request, + f"Entrega '{titulo}' removida com sucesso!", + ) + return redirect("fornecedores:detalhar", fornecedor.id) + + return render( + request, + "fornecedores/confirmar_exclusao_entrega.html", + { + "fornecedor": fornecedor, + "entrega": entrega, + }, + ) + + +@login_required +def vincular_transacoes(request, fornecedor_id): + """ + Permite vincular múltiplas transações a um fornecedor. + Lista transações filtradas por mês/ano, similar ao livro caixa. + """ + fornecedor = get_object_or_404(Fornecedor, pk=fornecedor_id) + + # Filtros de Data (usado tanto no POST quanto no GET) + today = datetime.date.today() + try: + month = int(request.GET.get("month", today.month)) + except (ValueError, TypeError): + month = today.month + + try: + year = int(request.GET.get("year", today.year)) + except (ValueError, TypeError): + year = today.year + + # Processar o formulário de vinculação/desvinculação + if request.method == "POST": + selected_ids = set(request.POST.getlist("transaction_ids")) + + # Buscar todas as transações do período atual que estão vinculadas a este fornecedor + # e que estão visíveis na página atual (com os filtros aplicados) + period_transactions = Transaction.objects.filter( + date__year=year, date__month=month, fornecedor=fornecedor + ) + + # Aplicar filtros adicionais se existirem + search_query = request.GET.get("q", "") + bank_account_filter = request.GET.get("bank_account", "") + + if search_query: + period_transactions = period_transactions.filter( + Q(description__icontains=search_query) + | Q(amount__icontains=search_query) + ) + if bank_account_filter: + period_transactions = period_transactions.filter( + bank_account__slug=bank_account_filter + ) + + # IDs das transações que devem estar vinculadas (marcadas) + selected_ids_int = {int(id) for id in selected_ids if id} + + # IDs das transações que estão vinculadas mas foram desmarcadas + currently_linked_ids = set(period_transactions.values_list("id", flat=True)) + to_unlink_ids = currently_linked_ids - selected_ids_int + + # Vincular as selecionadas + linked_count = 0 + if selected_ids_int: + transactions_to_link = Transaction.objects.filter(id__in=selected_ids_int) + linked_count = transactions_to_link.update(fornecedor=fornecedor) + + # Desvincular as que foram desmarcadas + unlinked_count = 0 + if to_unlink_ids: + transactions_to_unlink = Transaction.objects.filter(id__in=to_unlink_ids) + unlinked_count = transactions_to_unlink.update(fornecedor=None) + + # Mensagem de sucesso + if linked_count > 0 or unlinked_count > 0: + msg_parts = [] + if linked_count > 0: + msg_parts.append(f"{linked_count} transação(ões) vinculada(s)") + if unlinked_count > 0: + msg_parts.append(f"{unlinked_count} transação(ões) desvinculada(s)") + messages.success(request, f"{' e '.join(msg_parts)} com sucesso!") + else: + messages.info(request, "Nenhuma alteração realizada.") + + # Construir URL com parâmetros preservados + url = reverse("fornecedores:vincular-transacoes", args=[fornecedor.id]) + # Preservar todos os parâmetros GET + query_params = request.GET.copy() + if query_params: + url = f"{url}?{query_params.urlencode()}" + + # Redirecionar mantendo os filtros (POST-redirect-GET pattern) + return redirect(url) + + # Navegação de períodos + reference_date = datetime.date(year, month, 1) + + # Mês anterior + previous_date = reference_date - datetime.timedelta(days=1) + previous_period = f"month={previous_date.month}&year={previous_date.year}" + + # Próximo mês + next_date_temp = reference_date + datetime.timedelta(days=32) + next_date = datetime.date(next_date_temp.year, next_date_temp.month, 1) + next_period = f"month={next_date.month}&year={next_date.year}" + + # Filtros Adicionais + search_query = request.GET.get("q", "") + bank_account_filter = request.GET.get("bank_account", "") + + # Query Base - Todas as transações do período + transactions = ( + Transaction.objects.filter(date__year=year, date__month=month) + .order_by("-date") + .select_related("bank_account", "category", "fornecedor") + ) + + if search_query: + transactions = transactions.filter( + Q(description__icontains=search_query) | Q(amount__icontains=search_query) + ) + + if bank_account_filter: + transactions = transactions.filter(bank_account__slug=bank_account_filter) + + bank_accounts = BankAccount.objects.filter(active=True) + + return render( + request, + "fornecedores/vincular_transacoes.html", + { + "fornecedor": fornecedor, + "transactions": transactions, + "bank_accounts": bank_accounts, + "search_query": search_query, + "current_bank_account": bank_account_filter, + "year": year, + "month": month, + "previous_period": previous_period, + "next_period": next_period, + }, + ) diff --git a/thebook/settings.py b/thebook/settings.py index f9e8b94..0fdd457 100644 --- a/thebook/settings.py +++ b/thebook/settings.py @@ -34,7 +34,7 @@ SECRET_KEY = config("SECRET_KEY") # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True # config("DEBUG", default=False, cast=bool) +DEBUG = config("DEBUG", default=False, cast=bool) LOGGING = { @@ -106,11 +106,11 @@ "django_structlog", "taggit", "thebook.bookkeeping", - "thebook.integrations", "thebook.members", "thebook.reimbursements", "thebook.users", "thebook.webhooks", + "thebook.fornecedores", "debug_toolbar", ] @@ -261,23 +261,10 @@ EMAIL_HOST_PASSWORD = config("EMAIL_HOST_PASSWORD", default="") DEFAULT_FROM_EMAIL = config("DEFAULT_FROM_EMAIL") -PAYPAL_BANK_ACCOUNT = "PayPal" PAYPAL_API_BASE_URL = config("PAYPAL_API_BASE_URL", default="https://api-m.paypal.com") PAYPAL_CLIENT_ID = config("PAYPAL_CLIENT_ID", default="") PAYPAL_CLIENT_SECRET = config("PAYPAL_CLIENT_SECRET", default="") -CORA_BANK_ACCOUNT = "Cora" -CORA_CREDIT_CARD_BANK_ACCOUNT = "Cora - Cartão de Crédito" - -OPENPIX_BANK_ACCOUNT = "OpenPix" -OPENPIX_API_BASE_URL = config( - "OPENPIX_API_BASE_URL", default="https://api.openpix.com.br" -) -OPENPIX_APP_ID = config("OPENPIX_APP_ID", default="") -OPENPIX_PLAN = config("OPENPIX_PLAN", default="PERCENTUAL") - -BANK_FEE_CATEGORY_NAME = "Tarifas Bancárias" - def show_callback(request): return True diff --git a/thebook/urls.py b/thebook/urls.py index 5b2ca38..afdb3fd 100644 --- a/thebook/urls.py +++ b/thebook/urls.py @@ -31,4 +31,8 @@ include("thebook.reimbursements.urls", namespace="reimbursements"), ), path("webhooks/", include("thebook.webhooks.urls", namespace="webhooks")), + path( + "fornecedores/", + include("thebook.fornecedores.urls", namespace="fornecedores"), + ), ] + debug_toolbar_urls()