Compare commits

...

1 Commits

Author SHA1 Message Date
Felipe Araujo 3ae6856463 feat(solicitacoes): ajustar perfis no dashboard e admin
Prioriza o perfil principal na visibilidade do dashboard, adiciona gestão de perfis extras no admin e consolida filtros/exportação XLSX da listagem de solicitações.

Made-with: Cursor
2026-04-23 11:52:49 -03:00
7 changed files with 239 additions and 34 deletions

View File

@ -1,13 +1,21 @@
from django import forms from django import forms
from django.contrib import admin, messages from django.contrib import admin, messages
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.forms import BaseInlineFormSet
from django.shortcuts import redirect from django.shortcuts import redirect
from django.urls import path, reverse from django.urls import path, reverse
from django.utils import timezone from django.utils import timezone
from .intf_sqlserver import listar_para_selecionar_colaborador from .intf_sqlserver import listar_para_selecionar_colaborador
from .intf_winthor import buscar_colaborador_oracle from .intf_winthor import buscar_colaborador_oracle
from .models import ConfiguracaoSGMP, HeadGestor, PessoaRM, Solicitacao, UsuarioSistema from .models import (
ConfiguracaoSGMP,
HeadGestor,
PessoaRM,
Solicitacao,
UsuarioPerfilExtra,
UsuarioSistema,
)
class ConfiguracaoSGMPForm(forms.ModelForm): class ConfiguracaoSGMPForm(forms.ModelForm):
@ -207,12 +215,43 @@ class PessoaRMAdmin(admin.ModelAdmin):
return redirect("..") return redirect("..")
class UsuarioPerfilExtraInlineFormSet(BaseInlineFormSet):
def clean(self):
super().clean()
vistos = set()
principal = self.data.get("perfil") or getattr(self.instance, "perfil", None)
for form in self.forms:
if not hasattr(form, "cleaned_data"):
continue
if form.cleaned_data.get("DELETE"):
continue
perfil_extra = form.cleaned_data.get("perfil")
if not perfil_extra:
continue
if perfil_extra == principal:
raise ValidationError(
"Perfil extra não pode ser igual ao perfil principal."
)
if perfil_extra in vistos:
raise ValidationError(f"Perfil extra duplicado: {perfil_extra}.")
vistos.add(perfil_extra)
class UsuarioPerfilExtraInline(admin.TabularInline):
model = UsuarioPerfilExtra
formset = UsuarioPerfilExtraInlineFormSet
extra = 0
fields = ("perfil", "criado_em")
readonly_fields = ("criado_em",)
@admin.register(UsuarioSistema) @admin.register(UsuarioSistema)
class UsuarioSistemaAdmin(admin.ModelAdmin): class UsuarioSistemaAdmin(admin.ModelAdmin):
list_display = ( list_display = (
"nome", "nome",
"matricula", "matricula",
"perfil", "perfil",
"perfis_ativos_display",
"ativo", "ativo",
"criado_em", "criado_em",
) )
@ -231,6 +270,8 @@ class UsuarioSistemaAdmin(admin.ModelAdmin):
"criado_em", "criado_em",
"atualizado_em", "atualizado_em",
) )
inlines = (UsuarioPerfilExtraInline,)
fieldsets = ( fieldsets = (
("Informações Básicas", { ("Informações Básicas", {
@ -245,6 +286,25 @@ class UsuarioSistemaAdmin(admin.ModelAdmin):
}), }),
) )
def get_queryset(self, request):
qs = super().get_queryset(request)
return qs.prefetch_related("perfis_extras")
@admin.display(description="Perfis ativos")
def perfis_ativos_display(self, obj):
codigos = obj.perfis_ativos()
labels = dict(UsuarioSistema.Perfil.choices)
resolved = [str(labels.get(c, c)) for c in codigos]
return ", ".join(resolved)
@admin.register(UsuarioPerfilExtra)
class UsuarioPerfilExtraAdmin(admin.ModelAdmin):
list_display = ("usuario", "perfil", "criado_em")
list_filter = ("perfil",)
search_fields = ("usuario__nome", "usuario__matricula")
autocomplete_fields = ("usuario",)
@admin.register(HeadGestor) @admin.register(HeadGestor)
class HeadGestorAdmin(admin.ModelAdmin): class HeadGestorAdmin(admin.ModelAdmin):
list_display = ("head", "gestor") list_display = ("head", "gestor")

View File

@ -32,7 +32,7 @@ def usuario_sistema(request):
usuario_eh_gestor or usuario_eh_admin or usuario_eh_diretoria usuario_eh_gestor or usuario_eh_admin or usuario_eh_diretoria
), ),
"usuario_pode_ver_todas_solicitacoes": ( "usuario_pode_ver_todas_solicitacoes": (
usuario.perfil != UsuarioSistema.Perfil.GESTOR or usuario_eh_admin not usuario.eh_apenas_gestor()
), ),
"usuario_pode_gerenciar_permissoes": usuario_pode_gerenciar_permissoes( "usuario_pode_gerenciar_permissoes": usuario_pode_gerenciar_permissoes(
usuario usuario

View File

@ -18,6 +18,7 @@ def get_sqlserver_connection():
database=settings.SQLSERVER_CONFIG["DATABASE"], database=settings.SQLSERVER_CONFIG["DATABASE"],
) )
def listar_para_selecionar_colaborador(apenas_desligados=False): def listar_para_selecionar_colaborador(apenas_desligados=False):
""" """
Lista colaboradores do RM para seleção. Lista colaboradores do RM para seleção.

View File

@ -140,6 +140,25 @@ class UsuarioSistema(BaseModel):
# Garante que o principal venha primeiro e não seja duplicado # Garante que o principal venha primeiro e não seja duplicado
return [self.perfil] + [p for p in extras if p != self.perfil] return [self.perfil] + [p for p in extras if p != self.perfil]
def eh_apenas_gestor(self) -> bool:
"""
Verdadeiro quando o usuário possui somente capacidades de gestor.
Útil para regras que restringem funcionalidades administrativas
(ex.: listagem "todas as solicitações") sem bloquear usuários que
acumulam gestor + outro perfil operacional.
"""
perfis_nao_gestor = [
self.Perfil.ADMIN,
self.Perfil.GG,
self.Perfil.CONTROLADORIA,
self.Perfil.HEAD,
self.Perfil.DIRETORIA,
]
return self.tem_perfil(self.Perfil.GESTOR) and not any(
self.tem_perfil(perfil) for perfil in perfis_nao_gestor
)
def __str__(self): def __str__(self):
return f"{self.nome} ({self.matricula})" return f"{self.nome} ({self.matricula})"

View File

@ -3,7 +3,7 @@ from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from ..acesso import usuario_pode_gerenciar_permissoes from ..acesso import usuario_pode_gerenciar_permissoes
from ..models import ConfiguracaoSGMP, UsuarioSistema from ..models import ConfiguracaoSGMP, UsuarioPerfilExtra, UsuarioSistema
User = get_user_model() User = get_user_model()
@ -91,3 +91,29 @@ class GerenciarPermissoesViewTests(TestCase):
self.client.login(username="20", password="secret") self.client.login(username="20", password="secret")
r = self.client.get(self.url) r = self.client.get(self.url)
self.assertEqual(r.status_code, 200) self.assertEqual(r.status_code, 200)
class TodasSolicitacoesPerfilCombinadoTests(TestCase):
def setUp(self):
self.client = Client()
self.url = reverse("solicitacoes:todas_solicitacoes")
User.objects.create_user(username="30", password="secret")
self.usuario = UsuarioSistema.objects.create(
matricula="30",
nome="GG com perfil Gestor",
perfil=UsuarioSistema.Perfil.GG,
ativo=True,
)
UsuarioPerfilExtra.objects.create(
usuario=self.usuario,
perfil=UsuarioSistema.Perfil.GESTOR,
)
def test_usuario_com_gg_e_gestor_acessa_todas_solicitacoes(self):
self.client.login(username="30", password="secret")
r = self.client.get(self.url)
self.assertEqual(r.status_code, 200)
def test_eh_apenas_gestor_false_quando_ha_perfil_operacional(self):
self.assertFalse(self.usuario.eh_apenas_gestor())

View File

@ -25,6 +25,11 @@ urlpatterns = [
views.todas_solicitacoes_view, views.todas_solicitacoes_view,
name="todas_solicitacoes", name="todas_solicitacoes",
), ),
path(
"solicitacoes/todas/exportar.xlsx",
views.exportar_todas_solicitacoes_xlsx,
name="exportar_todas_solicitacoes_xlsx",
),
# ========================= # =========================
# DESLIGAMENTO # DESLIGAMENTO

View File

@ -2,7 +2,10 @@
import json import json
import logging import logging
import time import time
import uuid
from datetime import date from datetime import date
from io import BytesIO
from urllib.parse import urlencode
from django.contrib.auth import login, logout, get_user_model from django.contrib.auth import login, logout, get_user_model
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
@ -10,7 +13,9 @@ from django.shortcuts import get_object_or_404, redirect, render
from django.http import HttpResponse from django.http import HttpResponse
from django.utils import timezone from django.utils import timezone
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.db.models import Q
from django.template.loader import render_to_string from django.template.loader import render_to_string
from openpyxl import Workbook
from .models import ( from .models import (
HeadGestor, HeadGestor,
PessoaRM, PessoaRM,
@ -41,8 +46,9 @@ def _queryset_dashboard_solicitacoes(usuario: UsuarioSistema):
""" """
Queryset visível no dashboard para o usuário (antes de order_by/paginação). Queryset visível no dashboard para o usuário (antes de order_by/paginação).
Usa :meth:`UsuarioSistema.tem_perfil` (perfil principal + ``UsuarioPerfilExtra``), A seleção do ramo principal é baseada em ``usuario.perfil`` para preservar
não apenas ``usuario.perfil``, para alinhar com perfis secundários. precedência do perfil principal. Perfis extras são considerados apenas
como capacidades complementares dentro de cada ramo quando necessário.
**Precedência** (primeiro ramo que se aplica; papéis operacionais antes de GESTOR): **Precedência** (primeiro ramo que se aplica; papéis operacionais antes de GESTOR):
1. ADMIN todas as solicitações 1. ADMIN todas as solicitações
@ -55,36 +61,36 @@ def _queryset_dashboard_solicitacoes(usuario: UsuarioSistema):
Total e pendentes no dashboard devem usar ``.count()`` sobre este mesmo queryset Total e pendentes no dashboard devem usar ``.count()`` sobre este mesmo queryset
(pendentes = excluir FINALIZADA e REPROVADA). (pendentes = excluir FINALIZADA e REPROVADA).
""" """
if usuario.tem_perfil(UsuarioSistema.Perfil.ADMIN): perfil_principal = usuario.perfil
if perfil_principal == UsuarioSistema.Perfil.ADMIN:
return Solicitacao.objects.all() return Solicitacao.objects.all()
if usuario.tem_perfil(UsuarioSistema.Perfil.GG) or usuario.tem_perfil( if perfil_principal in (
UsuarioSistema.Perfil.CONTROLADORIA UsuarioSistema.Perfil.GG,
UsuarioSistema.Perfil.CONTROLADORIA,
): ):
qs_fila = Solicitacao.objects.filter(status=StatusSolicitacao.ENVIADA) qs_fila = Solicitacao.objects.filter(status=StatusSolicitacao.ENVIADA)
if usuario.tem_perfil(UsuarioSistema.Perfil.GG) and not usuario.tem_perfil( if perfil_principal == UsuarioSistema.Perfil.GG:
UsuarioSistema.Perfil.CONTROLADORIA
):
qs_fila = qs_fila.exclude( qs_fila = qs_fila.exclude(
pareceres__etapa=EtapaAprovacao.GG, pareceres__usuario=usuario pareceres__etapa=EtapaAprovacao.GG, pareceres__usuario=usuario
) )
elif usuario.tem_perfil(UsuarioSistema.Perfil.CONTROLADORIA) and not usuario.tem_perfil( elif perfil_principal == UsuarioSistema.Perfil.CONTROLADORIA:
UsuarioSistema.Perfil.GG
):
qs_fila = qs_fila.exclude( qs_fila = qs_fila.exclude(
pareceres__etapa=EtapaAprovacao.CONTROLADORIA, pareceres__etapa=EtapaAprovacao.CONTROLADORIA,
pareceres__usuario=usuario, pareceres__usuario=usuario,
) )
return _qs_dashboard_unir_fila_com_minhas(qs_fila, usuario) return _qs_dashboard_unir_fila_com_minhas(qs_fila, usuario)
if usuario.tem_perfil(UsuarioSistema.Perfil.HEAD): if perfil_principal == UsuarioSistema.Perfil.HEAD:
matriculas = matriculas_gestores_do_head(usuario) matriculas = matriculas_gestores_do_head(usuario)
qs_fila = Solicitacao.objects.filter(status=StatusSolicitacao.AGUARDANDO_HEAD) qs_fila = Solicitacao.objects.filter(status=StatusSolicitacao.AGUARDANDO_HEAD)
if matriculas: if matriculas:
qs_fila = qs_fila.filter(solicitante__matricula__in=matriculas) qs_fila = qs_fila.filter(solicitante__matricula__in=matriculas)
return _qs_dashboard_unir_fila_com_minhas(qs_fila, usuario) return _qs_dashboard_unir_fila_com_minhas(qs_fila, usuario)
if usuario.tem_perfil(UsuarioSistema.Perfil.DIRETORIA): if perfil_principal == UsuarioSistema.Perfil.DIRETORIA:
qs_fila = Solicitacao.objects.filter(status=StatusSolicitacao.AGUARDANDO_DIRETORIA) qs_fila = Solicitacao.objects.filter(status=StatusSolicitacao.AGUARDANDO_DIRETORIA)
if usuario.perfis_extras.filter(perfil=UsuarioSistema.Perfil.HEAD).exists():
qs_fila = qs_fila | Solicitacao.objects.filter(status=StatusSolicitacao.AGUARDANDO_HEAD)
return _qs_dashboard_unir_fila_com_minhas(qs_fila, usuario) return _qs_dashboard_unir_fila_com_minhas(qs_fila, usuario)
if usuario.tem_perfil(UsuarioSistema.Perfil.GESTOR): if perfil_principal == UsuarioSistema.Perfil.GESTOR:
return Solicitacao.objects.filter(solicitante=usuario) return Solicitacao.objects.filter(solicitante=usuario)
return Solicitacao.objects.none() return Solicitacao.objects.none()
@ -722,7 +728,7 @@ def dashboard_view(request):
paginator = Paginator(solicitacoes, 10) paginator = Paginator(solicitacoes, 10)
page = request.GET.get('page') page = request.GET.get('page')
solicitacoes_page = paginator.get_page(page) solicitacoes_page = paginator.get_page(page)
# Prepara informações sobre quais solicitações podem ser aprovadas ou receber parecer # Prepara informações sobre quais solicitações podem ser aprovadas ou receber parecer
solicitacoes_com_acao = [] solicitacoes_com_acao = []
for solicitacao in solicitacoes_page: for solicitacao in solicitacoes_page:
@ -778,23 +784,10 @@ def dashboard_view(request):
def todas_solicitacoes_view(request): def todas_solicitacoes_view(request):
"""Listagem de solicitações: Gestor não acessa; Head vê só dos gestores vinculados a ele; GG/Controladoria/Diretoria veem todas.""" """Listagem de solicitações: Gestor não acessa; Head vê só dos gestores vinculados a ele; GG/Controladoria/Diretoria veem todas."""
usuario = get_usuario_sistema(request) usuario = get_usuario_sistema(request)
if usuario.tem_perfil(UsuarioSistema.Perfil.GESTOR) and not usuario.tem_perfil(UsuarioSistema.Perfil.ADMIN): if usuario.eh_apenas_gestor():
return redirect("solicitacoes:dashboard") return redirect("solicitacoes:dashboard")
qs_base = Solicitacao.objects.all().order_by("-criado_em") qs_base, filtro_status, busca = _build_solicitacoes_queryset(request, usuario)
# Head: apenas solicitações dos gestores que ele aprova (subordinados imediatos)
if usuario.tem_perfil(UsuarioSistema.Perfil.HEAD) and not usuario.tem_perfil(UsuarioSistema.Perfil.ADMIN):
matriculas = matriculas_gestores_do_head(usuario)
if matriculas:
qs_base = qs_base.filter(solicitante__matricula__in=matriculas)
else:
qs_base = qs_base.none()
# Filtro por status (GET)
filtro_status = request.GET.get("status", "").strip()
if filtro_status:
qs_base = qs_base.filter(status=filtro_status)
total = qs_base.count() total = qs_base.count()
@ -842,15 +835,116 @@ def todas_solicitacoes_view(request):
"is_solicitante": is_solicitante, "is_solicitante": is_solicitante,
"dados_winthor_organizados": dados_winthor_organizados, "dados_winthor_organizados": dados_winthor_organizados,
}) })
_exp_params = {}
if filtro_status:
_exp_params["status"] = filtro_status
if busca:
_exp_params["q"] = busca
export_query = ("?" + urlencode(_exp_params)) if _exp_params else ""
return render(request, "solicitacoes/todas_solicitacoes.html", { return render(request, "solicitacoes/todas_solicitacoes.html", {
"solicitacoes": solicitacoes_page, "solicitacoes": solicitacoes_page,
"solicitacoes_com_acao": solicitacoes_com_acao, "solicitacoes_com_acao": solicitacoes_com_acao,
"total": total, "total": total,
"filtro_status": filtro_status, "filtro_status": filtro_status,
"busca": busca,
"export_query": export_query,
"status_choices": StatusSolicitacao.choices, "status_choices": StatusSolicitacao.choices,
}) })
def _build_solicitacoes_queryset(request, usuario: UsuarioSistema):
"""Queryset base para listagem/exportação da tela 'Todas as Solicitações'."""
qs_base = Solicitacao.objects.all().order_by("-criado_em")
# Head: apenas solicitações dos gestores que ele aprova (subordinados imediatos)
if usuario.tem_perfil(UsuarioSistema.Perfil.HEAD) and not usuario.tem_perfil(
UsuarioSistema.Perfil.ADMIN
):
matriculas = matriculas_gestores_do_head(usuario)
if matriculas:
qs_base = qs_base.filter(solicitante__matricula__in=matriculas)
else:
qs_base = qs_base.none()
filtro_status = request.GET.get("status", "").strip()
if filtro_status:
qs_base = qs_base.filter(status=filtro_status)
busca = request.GET.get("q", "").strip()
if busca:
q_filter = (
Q(funcionario__nome__icontains=busca)
| Q(solicitante__nome__icontains=busca)
| Q(tipo__icontains=busca)
)
if len(busca) == 36:
try:
q_filter |= Q(id=uuid.UUID(busca))
except ValueError:
pass
qs_base = qs_base.filter(q_filter)
return qs_base, filtro_status, busca
@login_required
def exportar_todas_solicitacoes_xlsx(request):
"""Exporta todas as solicitações visíveis ao usuário com os filtros atuais."""
usuario = get_usuario_sistema(request)
if usuario.eh_apenas_gestor():
return redirect("solicitacoes:dashboard")
qs_base, filtro_status, busca = _build_solicitacoes_queryset(request, usuario)
wb = Workbook()
ws = wb.active
ws.title = "Solicitacoes"
ws.append(
[
"ID",
"Tipo",
"Colaborador",
"Solicitante",
"Status",
"Criada em",
]
)
def excel_text(value):
if value is None:
return ""
return str(value)
for solicitacao in qs_base.iterator():
ws.append(
[
excel_text(solicitacao.id),
excel_text(solicitacao.get_tipo_display()),
excel_text(
solicitacao.funcionario.nome if solicitacao.funcionario else ""
),
excel_text(solicitacao.solicitante.nome),
excel_text(solicitacao.get_status_display_para_usuario(usuario)),
excel_text(
timezone.localtime(solicitacao.criado_em).strftime("%d/%m/%Y %H:%M")
),
]
)
output = BytesIO()
wb.save(output)
output.seek(0)
suffix = f"_{filtro_status}" if filtro_status else ""
filename = f"solicitacoes_todas{suffix}.xlsx"
response = HttpResponse(
output.getvalue(),
content_type=(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
),
)
response["Content-Disposition"] = f'attachment; filename="{filename}"'
return response
@login_required @login_required
@pode_criar_solicitacao @pode_criar_solicitacao