diff --git a/solicitacoes/admin.py b/solicitacoes/admin.py index 7bae6e1..d6de8ef 100644 --- a/solicitacoes/admin.py +++ b/solicitacoes/admin.py @@ -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") diff --git a/solicitacoes/context_processors.py b/solicitacoes/context_processors.py index fdddbb2..e392caf 100644 --- a/solicitacoes/context_processors.py +++ b/solicitacoes/context_processors.py @@ -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 diff --git a/solicitacoes/intf_sqlserver.py b/solicitacoes/intf_sqlserver.py index 1366806..6260280 100644 --- a/solicitacoes/intf_sqlserver.py +++ b/solicitacoes/intf_sqlserver.py @@ -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. diff --git a/solicitacoes/models.py b/solicitacoes/models.py index 4d30cde..ae742aa 100644 --- a/solicitacoes/models.py +++ b/solicitacoes/models.py @@ -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})" diff --git a/solicitacoes/tests/test_acesso_gerenciar_permissoes.py b/solicitacoes/tests/test_acesso_gerenciar_permissoes.py index f6aedef..3ff73e6 100644 --- a/solicitacoes/tests/test_acesso_gerenciar_permissoes.py +++ b/solicitacoes/tests/test_acesso_gerenciar_permissoes.py @@ -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()) diff --git a/solicitacoes/urls.py b/solicitacoes/urls.py index 124dfce..5d70f5f 100644 --- a/solicitacoes/urls.py +++ b/solicitacoes/urls.py @@ -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 diff --git a/solicitacoes/views.py b/solicitacoes/views.py index 620c600..1e3e1e2 100644 --- a/solicitacoes/views.py +++ b/solicitacoes/views.py @@ -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