diff --git a/sigi/apps/eventos/admin.py b/sigi/apps/eventos/admin.py index 35cf5ef..10d9e6e 100644 --- a/sigi/apps/eventos/admin.py +++ b/sigi/apps/eventos/admin.py @@ -1,6 +1,8 @@ import datetime import time +from typing import Any from moodle import Moodle +from django.db.models import Q from django.conf import settings from django.contrib import admin, messages from django.http import HttpResponse @@ -21,6 +23,8 @@ from sigi.apps.eventos.models import ( ModeloDeclaracao, Modulo, TipoEvento, + Solicitacao, + ItemSolicitado, Funcao, Evento, Equipe, @@ -33,6 +37,52 @@ from sigi.apps.utils.filters import EmptyFilter, DateRangeFilter from sigi.apps.utils.mixins import CartExportMixin, ValueLabeledResource +class SolicitacaoStatusFilter(admin.SimpleListFilter): + title = _("status") + parameter_name = "status" + + def lookups(self, request, model_admin): + return ( + ("aberto", _("Aberto")), + ("analise", _("Análise")), + ("inconcluso", _("Inconcluso (aberto + análise)")), + ("concluido", _("Concluído")), + ) + + def queryset(self, request, queryset): + if self.value() == "aberto": + return queryset.exclude( + itemsolicitado__status__in=[ + ItemSolicitado.STATUS_AUTORIZADO, + ItemSolicitado.STATUS_REJEITADO, + ] + ).distinct() + elif self.value() == "analise": + return ( + queryset.filter( + itemsolicitado__status=ItemSolicitado.STATUS_SOLICITADO + ) + .filter( + itemsolicitado__status__in=[ + ItemSolicitado.STATUS_AUTORIZADO, + ItemSolicitado.STATUS_REJEITADO, + ] + ) + .distinct() + ) + elif self.value() == "inconcluso": + return queryset.exclude( + id__in=Solicitacao.objects.exclude( + itemsolicitado__status=ItemSolicitado.STATUS_SOLICITADO + ).only("id") + ) + elif self.value() == "concluido": + return queryset.exclude( + itemsolicitado__status=ItemSolicitado.STATUS_SOLICITADO + ).distinct() + return queryset + + class EventoResource(ValueLabeledResource): # categoria_evento = Field(column_name="tipo_evento__categoria") # status = Field(column_name="status") @@ -120,6 +170,21 @@ class CronogramaInline(admin.StackedInline): extra = 0 +class ItemSolicitadoInline(admin.StackedInline): + model = ItemSolicitado + fields = ( + "tipo_evento", + "virtual", + "inicio_desejado", + "status", + "justificativa", + "servidor", + "evento", + ) + readonly_fields = ("servidor", "evento") + extra = 1 + + @admin.register(TipoEvento) class TipoEventoAdmin(admin.ModelAdmin): list_display = ["nome", "categoria"] @@ -128,6 +193,176 @@ class TipoEventoAdmin(admin.ModelAdmin): inlines = [ChecklistInline] +@admin.register(Solicitacao) +class SolicitacaoAdmin(admin.ModelAdmin): + list_display = ( + "num_processo", + "casa", + "senador", + "data_pedido", + "get_oficinas", + "get_municipio", + "get_uf", + "get_regiao", + "get_populacao", + "get_oficinas_uf", + "estimativa_casas", + "estimativa_servidores", + "get_status", + ) + list_filter = ( + "casa__municipio__uf", + "casa__municipio__uf__regiao", + "senador", + "itemsolicitado__tipo_evento", + SolicitacaoStatusFilter, + ) + list_select_related = ["casa", "casa__municipio", "casa__municipio__uf"] + search_fields = ( + "casa__search_text", + "casa__municipio__search_text", + "casa__municipio__uf__search_text", + "senador", + ) + date_hierarchy = "data_pedido" + inlines = (ItemSolicitadoInline,) + autocomplete_fields = ("casa",) + + def save_formset(self, request, form, formset, change): + instances = formset.save(commit=False) + + if hasattr(request.user, "servidor"): + servidor = request.user.servidor + else: + servidor = None + + for item in instances: + if ( + item.status == ItemSolicitado.STATUS_SOLICITADO + and item.evento is not None + ): + item.evento.status = Evento.STATUS_ACONFIRMAR + self.message_user( + request, + _( + f"Status do evento {item.evento} alterado para " + f"{item.evento.get_status_display()}" + ), + messages.INFO, + ) + elif item.status == ItemSolicitado.STATUS_AUTORIZADO: + item.servidor = servidor + if item.evento is None: + item.evento = Evento( + tipo_evento=item.tipo_evento, + nome=f"{item.tipo_evento} em {item.solicitacao.casa}", + descricao=f"{item.tipo_evento} em {item.solicitacao.casa}", + virtual=item.virtual, + solicitante=item.solicitacao.senador, + num_processo=item.solicitacao.num_processo, + data_pedido=item.solicitacao.data_pedido, + data_inicio=item.inicio_desejado, + data_termino=item.inicio_desejado + + datetime.timedelta(days=item.tipo_evento.duracao), + casa_anfitria=item.solicitacao.casa, + municipio=item.solicitacao.casa.municipio, + observacao=f"Autorizado por {servidor} com a justificativa '{item.justificativa}", + status=Evento.STATUS_CONFIRMADO, + contato=item.solicitacao.contato, + telefone=item.solicitacao.telefone_contato, + ) + self.message_user( + request, + _(f"Evento {item.evento} criado automaticamente."), + messages.INFO, + ) + else: + item.evento.status = Evento.STATUS_CONFIRMADO + self.message_user( + request, + _( + f"Status do evento {item.evento} alterado para " + f"{item.evento.get_status_display()}" + ), + messages.INFO, + ) + elif ItemSolicitado.STATUS_REJEITADO and item.evento is not None: + item.evento.status = Evento.STATUS_CANCELADO + item.evento.save() + self.message_user( + request, + _( + f"Status do evento {item.evento} alterado para " + f"{item.evento.get_status_display()}" + ), + messages.INFO, + ) + if item.evento: + item.evento.tipo_evento = item.tipo_evento + item.evento.nome = ( + f"{item.tipo_evento} em {item.solicitacao.casa}" + ) + item.evento.descricao = ( + f"{item.tipo_evento} em {item.solicitacao.casa}" + ) + item.evento.virtual = item.virtual + item.evento.solicitante = item.solicitacao.senador + item.evento.num_processo = item.solicitacao.num_processo + item.evento.data_pedido = item.solicitacao.data_pedido + item.evento.data_inicio = item.inicio_desejado + item.evento.data_termino = ( + item.inicio_desejado + + datetime.timedelta(days=item.tipo_evento.duracao) + ) + item.evento.casa_anfitria = item.solicitacao.casa + item.evento.municipio = item.solicitacao.casa.municipio + item.evento.observacao = f"Autorizado por {servidor} com a justificativa '{item.justificativa}" + item.evento.contato = item.solicitacao.contato + item.evento.telefone = item.solicitacao.telefone_contato + item.evento.save() + item.save() + return super().save_formset(request, form, formset, change) + + @admin.display(description=_("Oficinas solicitadas")) + def get_oficinas(self, obj): + return mark_safe( + "" + ) + + @admin.display( + description=_("Município"), ordering="casa__municipio__nome" + ) + def get_municipio(self, obj): + return obj.casa.municipio.nome + + @admin.display(description=_("UF"), ordering="casa__municipio__uf__nome") + def get_uf(self, obj): + return obj.casa.municipio.uf.nome + + @admin.display( + description=_("Região"), ordering="casa__municipio__uf__regiao" + ) + def get_regiao(self, obj): + return obj.casa.municipio.uf.get_regiao_display() + + @admin.display( + description=_("População"), ordering="casa__municipio__populacao" + ) + def get_populacao(self, obj): + return obj.casa.municipio.populacao + + @admin.display(description=_("Oficinas atendidas/confirmadas na UF")) + def get_oficinas_uf(self, obj): + return Evento.objects.filter( + status__in=[Evento.STATUS_CONFIRMADO, Evento.STATUS_REALIZADO], + municipio__uf=obj.casa.municipio.uf, + ).count() + + @admin.register(Funcao) class FuncaoAdmin(admin.ModelAdmin): list_display = ( diff --git a/sigi/apps/eventos/migrations/0037_tipoevento_duracao_tipoevento_sigla_solicitacao_and_more.py b/sigi/apps/eventos/migrations/0037_tipoevento_duracao_tipoevento_sigla_solicitacao_and_more.py new file mode 100644 index 0000000..dd254b7 --- /dev/null +++ b/sigi/apps/eventos/migrations/0037_tipoevento_duracao_tipoevento_sigla_solicitacao_and_more.py @@ -0,0 +1,219 @@ +# Generated by Django 4.2.4 on 2023-08-30 19:21 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("servidores", "0013_servidor_moodle_userid"), + ("casas", "0027_alter_orgao_email"), + ("eventos", "0036_tipoevento_prefixo_turma_alter_evento_turma"), + ] + + operations = [ + migrations.AddField( + model_name="tipoevento", + name="duracao", + field=models.PositiveIntegerField( + default=1, verbose_name="Duração (dias)" + ), + ), + migrations.AddField( + model_name="tipoevento", + name="sigla", + field=models.CharField( + blank=True, max_length=20, verbose_name="sigla" + ), + ), + migrations.CreateModel( + name="Solicitacao", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "senador", + models.CharField( + max_length=100, verbose_name="senador solicitante" + ), + ), + ( + "num_processo", + models.CharField( + blank=True, + help_text="Formato:XXXXX.XXXXXX/XXXX-XX", + max_length=20, + verbose_name="número do processo SIGAD", + ), + ), + ( + "descricao", + models.TextField(verbose_name="descrição da solicitação"), + ), + ( + "data_pedido", + models.DateField( + help_text="Data em que o pedido do Gabinete chegou à COPERI", + verbose_name="Data do pedido", + ), + ), + ( + "contato", + models.CharField( + max_length=100, + verbose_name="pessoa de contato na Casa", + ), + ), + ( + "email_contato", + models.EmailField( + blank=True, + max_length=254, + verbose_name="e-mail do contato", + ), + ), + ( + "telefone_contato", + models.CharField( + blank=True, + max_length=20, + verbose_name="telefone do contato", + ), + ), + ( + "whatsapp_contato", + models.CharField( + blank=True, + max_length=20, + verbose_name="whatsapp do contato", + ), + ), + ( + "estimativa_casas", + models.PositiveIntegerField( + help_text="estimativa de quantas Casas participarão dos eventos", + verbose_name="estimativa de Casas participantes", + ), + ), + ( + "estimativa_servidores", + models.PositiveIntegerField( + help_text="estimativa de quantos Servidores participarão dos eventos", + verbose_name="estimativa de servidores participantes", + ), + ), + ( + "casa", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to="casas.orgao", + verbose_name="casa solicitante", + ), + ), + ], + options={ + "verbose_name": "Solicitação de eventos", + "verbose_name_plural": "Solicitações de eventos", + "ordering": ("-data_pedido",), + }, + ), + migrations.CreateModel( + name="ItemSolicitado", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "virtual", + models.BooleanField(default=False, verbose_name="virtual"), + ), + ( + "inicio_desejado", + models.DateField( + help_text="Data desejada para o início do evento. Pode ser solicitado pela Casa ou definido pela conveniência do Interlegis. Será usada como data de início do evento, caso seja autorizado.", + verbose_name="início desejado", + ), + ), + ( + "status", + models.CharField( + choices=[ + ("S", "Solicitado"), + ("A", "Autorizado"), + ("R", "Rejeitado"), + ], + default="S", + verbose_name="status", + ), + ), + ( + "data_analise", + models.DateTimeField( + blank=True, + editable=False, + null=True, + verbose_name="data da autorização/rejeição", + ), + ), + ( + "justificativa", + models.TextField(blank=True, verbose_name="Justificativa"), + ), + ( + "evento", + models.ForeignKey( + editable=False, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="eventos.evento", + ), + ), + ( + "servidor", + models.ForeignKey( + blank=True, + editable=False, + help_text="Servidor que autorizou ou rejeitou a realização do evento", + limit_choices_to={"externo": False}, + null=True, + on_delete=django.db.models.deletion.PROTECT, + to="servidores.servidor", + verbose_name="servidor analisador", + ), + ), + ( + "solicitacao", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="eventos.solicitacao", + ), + ), + ( + "tipo_evento", + models.ForeignKey( + limit_choices_to={"casa_solicita": True}, + on_delete=django.db.models.deletion.PROTECT, + to="eventos.tipoevento", + ), + ), + ], + options={ + "verbose_name": "Evento solicitado", + "verbose_name_plural": "Eventos solicitados", + "ordering": ("status",), + }, + ), + ] diff --git a/sigi/apps/eventos/migrations/0038_migra_pedidos.py b/sigi/apps/eventos/migrations/0038_migra_pedidos.py new file mode 100644 index 0000000..b057b45 --- /dev/null +++ b/sigi/apps/eventos/migrations/0038_migra_pedidos.py @@ -0,0 +1,141 @@ +# Generated by Django 4.2.4 on 2023-08-11 20:36 +import re +from functools import reduce +from django.db import migrations +from django.utils import timezone +from sigi.apps.utils import to_ascii + + +def forwards(apps, schema_editor): + TipoEvento = apps.get_model("eventos", "TipoEvento") + Evento = apps.get_model("eventos", "Evento") + Solicitacao = apps.get_model("eventos", "Solicitacao") + ItemSolicitado = apps.get_model("eventos", "ItemSolicitado") + + conjuncoes = [ + "oficina", + "a", + "e", + "o", + "da", + "de", + "do", + "na", + "no", + "em", + "ao", + "-", + "com", + ] + + # Siglas para TipoEvento com as iniciais de cada palavra do nome + + for t in TipoEvento.objects.all(): + t.sigla = "".join( + [s[:1] for s in t.nome.split(" ") if s.lower() not in conjuncoes] + ) + t.save() + + tipos = { + t.id: { + s + for s in to_ascii(t.nome.lower()).replace("/", " ").split(" ") + if s not in conjuncoes + } + for t in TipoEvento.objects.exclude(id=35) + } + + conjugados = [ + to_ascii(re.search("(\w+) e (\w+)", t.nome.lower()).group()) + for t in TipoEvento.objects.exclude(id=35) + if " e " in t.nome + ] + + # Tipo_evento_id 35 foi cadastrado como 'pedidos SIGAD' e todos os eventos + # deste tipo serão convertidos em Solicitacao + + for e in Evento.objects.filter(tipo_evento_id=35).exclude( + casa_anfitria=None + ): + solicitacao = Solicitacao( + casa=e.casa_anfitria, + senador=e.solicitante, + num_processo=e.num_processo, + descricao=e.descricao, + data_pedido=e.data_pedido or timezone.localdate(), + contato=e.contato, + telefone_contato=e.telefone, + estimativa_casas=0, + estimativa_servidores=0, + ) + solicitacao.save() + + if e.num_processo: + for se in Evento.objects.filter( + num_processo=e.num_processo + ).exclude(id=e.id): + ItemSolicitado( + solicitacao=solicitacao, + tipo_evento=se.tipo_evento, + virtual=se.virtual, + inicio_desejado=se.data_inicio + or se.data_pedido + or timezone.localdate(), + status="A", # autorizado + justificativa="Automática na migração dos dados", + evento=se, + ).save() + + descricoes = ( + reduce( + lambda x, y: x.replace(y, y.replace(" e ", " ")), + conjugados, + to_ascii( + e.descricao.lower().replace("\r\n", "").replace(";", ",") + ), + ) + .replace(" e ", ", ") + .replace("/", " ") + .replace(".", " ") + .replace("(", " ") + .replace(")", " ") + .split(",") + ) + + for d in descricoes: + termos = { + s.strip() for s in d.strip().split(" ") if s not in conjuncoes + } + similar = min( + [(id, len(termos.difference(t))) for id, t in tipos.items()], + key=lambda x: x[1], + ) + if similar and similar[1] < len(termos): + if not ItemSolicitado.objects.filter( + solicitacao=solicitacao, tipo_evento_id=similar[0] + ).exists(): + ItemSolicitado( + solicitacao=solicitacao, + tipo_evento_id=similar[0], + virtual=e.virtual, + inicio_desejado=e.data_inicio + or e.data_pedido + or timezone.localdate(), + status="S", + ).save() + e.delete() + + +class Migration(migrations.Migration): + dependencies = [ + ( + "eventos", + "0037_tipoevento_duracao_tipoevento_sigla_solicitacao_and_more", + ), + ] + + operations = [ + migrations.RunPython( + forwards, + ) + ] diff --git a/sigi/apps/eventos/models.py b/sigi/apps/eventos/models.py index f417f57..598519b 100644 --- a/sigi/apps/eventos/models.py +++ b/sigi/apps/eventos/models.py @@ -1,16 +1,17 @@ import datetime import re +from tinymce.models import HTMLField +from django.contrib import admin from django.core.validators import RegexValidator +from django.core.exceptions import ValidationError from django.db import models -from django.db.models import Sum +from django.db.models import Sum, Count from django.urls import reverse from django.utils import timezone from django.utils.translation import gettext as _ -from sigi.apps.casas.models import Orgao +from sigi.apps.casas.models import Orgao, Servidor from sigi.apps.contatos.models import Municipio from sigi.apps.servidores.models import Servidor -from django.core.exceptions import ValidationError -from tinymce.models import HTMLField class TipoEvento(models.Model): @@ -29,12 +30,14 @@ class TipoEvento(models.Model): ) nome = models.CharField(_("Nome"), max_length=100) + sigla = models.CharField(_("sigla"), max_length=20, blank=True) categoria = models.CharField( _("Categoria"), max_length=1, choices=CATEGORIA_CHOICES ) casa_solicita = models.BooleanField( _("casa pode solicitar"), default=False ) + duracao = models.PositiveIntegerField(_("Duração (dias)"), default=1) moodle_template_courseid = models.PositiveBigIntegerField( _("Curso protótipo"), blank=True, @@ -66,6 +69,133 @@ class TipoEvento(models.Model): return self.nome +class Solicitacao(models.Model): + casa = models.ForeignKey( + Orgao, verbose_name=_("casa solicitante"), on_delete=models.PROTECT + ) + senador = models.CharField(_("senador solicitante"), max_length=100) + num_processo = models.CharField( + _("número do processo SIGAD"), + max_length=20, + blank=True, + help_text=_("Formato:XXXXX.XXXXXX/XXXX-XX"), + ) + descricao = models.TextField(_("descrição da solicitação")) + data_pedido = models.DateField( + _("Data do pedido"), + help_text=_("Data em que o pedido do Gabinete chegou à COPERI"), + ) + contato = models.CharField(_("pessoa de contato na Casa"), max_length=100) + email_contato = models.EmailField(_("e-mail do contato"), blank=True) + telefone_contato = models.CharField( + _("telefone do contato"), max_length=20, blank=True + ) + whatsapp_contato = models.CharField( + _("whatsapp do contato"), max_length=20, blank=True + ) + estimativa_casas = models.PositiveIntegerField( + _("estimativa de Casas participantes"), + help_text=_("estimativa de quantas Casas participarão dos eventos"), + ) + estimativa_servidores = models.PositiveIntegerField( + _("estimativa de servidores participantes"), + help_text=_( + "estimativa de quantos Servidores participarão dos eventos" + ), + ) + + class Meta: + ordering = ("-data_pedido",) + verbose_name = _("Solicitação de eventos") + verbose_name_plural = _("Solicitações de eventos") + + def __str__(self): + return _(f"{self.num_processo}: {self.casa} / Senador {self.senador}") + + @admin.display(description="Status") + def get_status(self): + # TODO: Definir status do pedido com base no status de seus ítens: + # Aberto: Todos os itens estão em estado Solicitado + # Análise: Parte dos pedidos estão Autorizados/Rejeitados e o restante + # está Solicitado + # Concluído: Nenhum pedido está em estado Solicitado + item_status = set( + self.itemsolicitado_set.distinct("status").values_list( + "status", flat=True + ) + ) + if {ItemSolicitado.STATUS_SOLICITADO} == item_status: + return _("Aberto") + elif ItemSolicitado.STATUS_SOLICITADO in item_status and ( + ItemSolicitado.STATUS_AUTORIZADO in item_status + or ItemSolicitado.STATUS_REJEITADO in item_status + ): + return _("Análise") + else: + return _("Concluído") + + +class ItemSolicitado(models.Model): + STATUS_SOLICITADO = "S" + STATUS_AUTORIZADO = "A" + STATUS_REJEITADO = "R" + STATUS_CHOICES = ( + (STATUS_SOLICITADO, _("Solicitado")), + (STATUS_AUTORIZADO, _("Autorizado")), + (STATUS_REJEITADO, _("Rejeitado")), + ) + solicitacao = models.ForeignKey(Solicitacao, on_delete=models.CASCADE) + tipo_evento = models.ForeignKey( + TipoEvento, + on_delete=models.PROTECT, + limit_choices_to={"casa_solicita": True}, + ) + virtual = models.BooleanField(_("virtual"), default=False) + inicio_desejado = models.DateField( + _("início desejado"), + help_text=_( + "Data desejada para o início do evento. Pode ser solicitado pela Casa ou definido pela conveniência do Interlegis. Será usada como data de início do evento, caso seja autorizado." + ), + ) + status = models.CharField( + verbose_name=_("status"), + choices=STATUS_CHOICES, + default=STATUS_SOLICITADO, + ) + data_analise = models.DateTimeField( + _("data da autorização/rejeição"), + blank=True, + null=True, + editable=False, + ) + servidor = models.ForeignKey( + Servidor, + verbose_name=_("servidor analisador"), + help_text=_( + "Servidor que autorizou ou rejeitou a realização do evento" + ), + on_delete=models.PROTECT, + limit_choices_to={"externo": False}, + blank=True, + null=True, + editable=False, + ) + justificativa = models.TextField( + verbose_name=_("Justificativa"), blank=True + ) + evento = models.ForeignKey( + "Evento", on_delete=models.SET_NULL, null=True, editable=False + ) + + class Meta: + ordering = ("status",) + verbose_name = _("Evento solicitado") + verbose_name_plural = _("Eventos solicitados") + + def __str__(self): + return _(f"{self.tipo_evento}: {self.get_status_display()}") + + class Evento(models.Model): STATUS_PLANEJAMENTO = "E" STATUS_AGUARDANDOSIGAD = "G" diff --git a/sigi/menu_conf.yaml b/sigi/menu_conf.yaml index 84ebf54..00b479a 100644 --- a/sigi/menu_conf.yaml +++ b/sigi/menu_conf.yaml @@ -74,6 +74,9 @@ main_menu: - title: Eventos icon: school children: + - title: Solicitações + view_name: admin:eventos_solicitacao_changelist + querystr: status=inconcluso - title: Todos os eventos view_name: admin:eventos_evento_changelist - title: Cursos @@ -95,9 +98,6 @@ main_menu: view_name: eventos_calendario - title: Alocação de equipe view_name: eventos_alocacaoequipe - - title: Solicitações de eventos - view_name: ocorrencias_painel - querystr: tipo_categoria=E&status=1&status=2 - title: Servidores icon: account_circle children: