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.contrib import admin, messages
from django.core.exceptions import ValidationError
from django.forms import BaseInlineFormSet
from django.shortcuts import redirect
from django.urls import path, reverse
from django.utils import timezone
from .intf_sqlserver import listar_para_selecionar_colaborador
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):
@ -207,12 +215,43 @@ class PessoaRMAdmin(admin.ModelAdmin):
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)
class UsuarioSistemaAdmin(admin.ModelAdmin):
list_display = (
"nome",
"matricula",
"perfil",
"perfis_ativos_display",
"ativo",
"criado_em",
)
@ -231,6 +270,8 @@ class UsuarioSistemaAdmin(admin.ModelAdmin):
"criado_em",
"atualizado_em",
)
inlines = (UsuarioPerfilExtraInline,)
fieldsets = (
("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)
class HeadGestorAdmin(admin.ModelAdmin):
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_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

View File

@ -18,6 +18,7 @@ def get_sqlserver_connection():
database=settings.SQLSERVER_CONFIG["DATABASE"],
)
def listar_para_selecionar_colaborador(apenas_desligados=False):
"""
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
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):
return f"{self.nome} ({self.matricula})"

View File

@ -3,7 +3,7 @@ from django.test import Client, TestCase
from django.urls import reverse
from ..acesso import usuario_pode_gerenciar_permissoes
from ..models import ConfiguracaoSGMP, UsuarioSistema
from ..models import ConfiguracaoSGMP, UsuarioPerfilExtra, UsuarioSistema
User = get_user_model()
@ -91,3 +91,29 @@ class GerenciarPermissoesViewTests(TestCase):
self.client.login(username="20", password="secret")
r = self.client.get(self.url)
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,
name="todas_solicitacoes",
),
path(
"solicitacoes/todas/exportar.xlsx",
views.exportar_todas_solicitacoes_xlsx,
name="exportar_todas_solicitacoes_xlsx",
),
# =========================
# DESLIGAMENTO

View File

@ -2,7 +2,10 @@
import json
import logging
import time
import uuid
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 import messages
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.utils import timezone
from django.core.paginator import Paginator
from django.db.models import Q
from django.template.loader import render_to_string
from openpyxl import Workbook
from .models import (
HeadGestor,
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).
Usa :meth:`UsuarioSistema.tem_perfil` (perfil principal + ``UsuarioPerfilExtra``),
não apenas ``usuario.perfil``, para alinhar com perfis secundários.
A seleção do ramo principal é baseada em ``usuario.perfil`` para preservar
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):
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
(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()
if usuario.tem_perfil(UsuarioSistema.Perfil.GG) or usuario.tem_perfil(
UsuarioSistema.Perfil.CONTROLADORIA
if perfil_principal in (
UsuarioSistema.Perfil.GG,
UsuarioSistema.Perfil.CONTROLADORIA,
):
qs_fila = Solicitacao.objects.filter(status=StatusSolicitacao.ENVIADA)
if usuario.tem_perfil(UsuarioSistema.Perfil.GG) and not usuario.tem_perfil(
UsuarioSistema.Perfil.CONTROLADORIA
):
if perfil_principal == UsuarioSistema.Perfil.GG:
qs_fila = qs_fila.exclude(
pareceres__etapa=EtapaAprovacao.GG, pareceres__usuario=usuario
)
elif usuario.tem_perfil(UsuarioSistema.Perfil.CONTROLADORIA) and not usuario.tem_perfil(
UsuarioSistema.Perfil.GG
):
elif perfil_principal == UsuarioSistema.Perfil.CONTROLADORIA:
qs_fila = qs_fila.exclude(
pareceres__etapa=EtapaAprovacao.CONTROLADORIA,
pareceres__usuario=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)
qs_fila = Solicitacao.objects.filter(status=StatusSolicitacao.AGUARDANDO_HEAD)
if matriculas:
qs_fila = qs_fila.filter(solicitante__matricula__in=matriculas)
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)
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)
if usuario.tem_perfil(UsuarioSistema.Perfil.GESTOR):
if perfil_principal == UsuarioSistema.Perfil.GESTOR:
return Solicitacao.objects.filter(solicitante=usuario)
return Solicitacao.objects.none()
@ -722,7 +728,7 @@ def dashboard_view(request):
paginator = Paginator(solicitacoes, 10)
page = request.GET.get('page')
solicitacoes_page = paginator.get_page(page)
# Prepara informações sobre quais solicitações podem ser aprovadas ou receber parecer
solicitacoes_com_acao = []
for solicitacao in solicitacoes_page:
@ -778,23 +784,10 @@ def dashboard_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."""
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")
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 por status (GET)
filtro_status = request.GET.get("status", "").strip()
if filtro_status:
qs_base = qs_base.filter(status=filtro_status)
qs_base, filtro_status, busca = _build_solicitacoes_queryset(request, usuario)
total = qs_base.count()
@ -842,15 +835,116 @@ def todas_solicitacoes_view(request):
"is_solicitante": is_solicitante,
"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", {
"solicitacoes": solicitacoes_page,
"solicitacoes_com_acao": solicitacoes_com_acao,
"total": total,
"filtro_status": filtro_status,
"busca": busca,
"export_query": export_query,
"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
@pode_criar_solicitacao