From 9f103b9d64e3a03c104d44d3f4185350df3825a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ses=C3=B3stris=20Vieira?= Date: Thu, 8 Oct 2026 12:32:47 -0300 Subject: [PATCH] Especializa eventos do tipo Visita --- sigi/apps/eventos/admin.py | 216 +++++++++++++++++- ..._visita_evento_pesquisa_previa_and_more.py | 82 +++++++ .../migrations/0068_acerta_casa_visitas.py | 27 +++ sigi/apps/eventos/models.py | 69 +++++- .../evento/change_form_object_tools.html | 7 - .../visita/change_form_object_tools.html | 10 + sigi/apps/utils/__init__.py | 34 +++ sigi/menu_conf.yaml | 5 +- 8 files changed, 432 insertions(+), 18 deletions(-) create mode 100644 sigi/apps/eventos/migrations/0067_localvisita_visita_evento_pesquisa_previa_and_more.py create mode 100644 sigi/apps/eventos/migrations/0068_acerta_casa_visitas.py create mode 100644 sigi/apps/eventos/templates/admin/eventos/visita/change_form_object_tools.html diff --git a/sigi/apps/eventos/admin.py b/sigi/apps/eventos/admin.py index e0d8346..d5a1045 100644 --- a/sigi/apps/eventos/admin.py +++ b/sigi/apps/eventos/admin.py @@ -39,6 +39,7 @@ from sigi.apps.contatos.models import UnidadeFederativa, Municipio from sigi.apps.eventos.models import ( Checklist, Cronograma, + LocalVisita, ModeloDeclaracao, Modulo, Participante, @@ -48,6 +49,7 @@ from sigi.apps.eventos.models import ( ItemSolicitado, Funcao, Evento, + Visita, Equipe, Convite, Anexo, @@ -330,6 +332,42 @@ class EventoResource(ValueModelResource): ) +class VisitaResource(resources.ModelResource): + class Meta: + model = Visita + fields = ( + "id", + "nome", + "descricao", + "virtual", + "solicitante", + "num_processo", + "data_pedido", + "data_inicio", + "data_termino", + "casa_anfitria__nome", + "casa_anfitria__logradouro", + "casa_anfitria__bairro", + "casa_anfitria__municipio__nome", + "casa_anfitria__municipio__populacao", + "casa_anfitria__municipio__uf__sigla", + "casa_anfitria__municipio__uf__regiao", + "casa_anfitria__cep", + "casa_anfitria__email", + "local_visita__nome", + "observacao", + "status", + "data_cancelamento", + "motivo_cancelamento", + "participante__casa_legislativa", + "participante__cpf", + "participante__email", + "participante__nome", + "participante__local_trabalho", + ) + export_order = fields + + class ChecklistInline(admin.StackedInline): model = Checklist @@ -905,6 +943,11 @@ class ModeloDeclaracaoAdmin(admin.ModelAdmin): formfield_overrides = {HTMLField: {"widget": AdminTinyMCE}} +@admin.register(LocalVisita) +class LocalVisitaAdmin(admin.ModelAdmin): + list_display = ("nome", "descricao") + + @admin.register(Evento) class EventoAdmin(AsciifyQParameter, ExportActionMixin, admin.ModelAdmin): form = EventoAdminForm @@ -1109,7 +1152,11 @@ class EventoAdmin(AsciifyQParameter, ExportActionMixin, admin.ModelAdmin): def get_queryset(self, request): my_decimal_field = models.DecimalField(max_digits=14, decimal_places=2) - queryset = super().get_queryset(request) + queryset = ( + super() + .get_queryset(request) + .exclude(tipo_evento__categoria=TipoEvento.CATEGORIA_VISITA) + ) return queryset.annotate( custo_total=Sum( (F("equipe__qtde_diarias") * F("equipe__valor_diaria")) @@ -1739,3 +1786,170 @@ class EventoAdmin(AsciifyQParameter, ExportActionMixin, admin.ModelAdmin): ) return redirect(change_url) + + +@admin.register(Visita) +class VisitaAdmin(AsciifyQParameter, ExportActionMixin, admin.ModelAdmin): + resource_classes = [VisitaResource] + fieldsets = ( + ( + None, + { + "fields": ( + "tipo_evento", + "casa_anfitria", + "nome", + "descricao", + "contato", + "telefone", + "data_pedido", + "data_recebido_coperi", + ("data_inicio", "hora_inicio"), + ("data_termino", "hora_termino"), + "local_visita", + "virtual", + "solicitante", + "status", + "data_cancelamento", + "motivo_cancelamento", + "pesquisa_previa", + "observacao", + "num_processo", + ) + }, + ), + ) + list_display = ( + "nome", + "casa_anfitria", + "status", + "data_inicio", + "hora_inicio", + "hora_termino", + "pesquisa_previa", + "get_link_sigad", + ) + list_filter = ( + "status", + ("num_processo", admin.EmptyFieldListFilter), + ("tipo_evento", admin.RelatedOnlyFieldListFilter), + "participante__casa_legislativa__municipio__uf", + "participante__casa_legislativa__municipio__uf__regiao", + ("data_inicio", DateRangeFilterBuilder()), + "virtual", + "solicitante", + ) + date_hierarchy = "data_inicio" + autocomplete_fields = ( + # "tipo_evento", + "casa_anfitria", + ) + + search_fields = ( + "nome", + "tipo_evento__nome", + "participante__casa_legislativa__search_text", + "participante__casa_legislativa__municipio__search_text", + "solicitante", + "num_processo", + ) + inlines = [ParticipanteInline] + + def get_form(self, request, obj, change, **kwargs): + form = super().get_form(request, obj, change, **kwargs) + form.base_fields["tipo_evento"].queryset = form.base_fields[ + "tipo_evento" + ].queryset.filter(categoria=TipoEvento.CATEGORIA_VISITA) + form.base_fields["casa_anfitria"].required = True + form.base_fields["local_visita"].required = True + if obj is None: + form.base_fields["tipo_evento"].initial = ( + form.base_fields["tipo_evento"].queryset.first().pk + ) + return form + + @admin.display(description=_("SIGAD"), ordering="num_processo") + def get_link_sigad(self, obj): + if obj.pk is None: + return "" + return mark_safe(obj.get_sigad_url()) + + def declaracao_report(self, request, object_id): + evento = get_object_or_404(Evento, id=object_id) + if request.method == "POST": + form = SelecionaModeloForm(request.POST) + if form.is_valid(): + modelo = form.cleaned_data["modelo"] + membro = ( + evento.equipe_set.filter(assina_oficio=True).first() + or evento.equipe_set.first() + ) + if membro: + servidor = membro.membro + else: + servidor = None + template_string = ( + """ + {% extends "eventos/declaracao_pdf.html" %} + {% block text_body %}""" + + modelo.texto + + """ + {% endblock %} + """ + ) + context = Context( + { + "pagesize": modelo.formato, + "pagemargin": modelo.margem, + "evento": evento, + "servidor": servidor, + "data": evento.data_inicio, + } + ) + string = Template(template_string).render(context) + # return HttpResponse(string) + response = HttpResponse( + headers={ + "Content-Type": "application/pdf", + "Content-Disposition": 'attachment; filename="declaração.pdf"', + } + ) + pdf = HTML( + string=string, + url_fetcher=DjangoURLFetcher(), + encoding="utf-8", + base_url=request.build_absolute_uri("/"), + ) + pdf.write_pdf(target=response) + return response + else: + form = SelecionaModeloForm( + initial={"modelo": ModeloDeclaracao.objects.first().id} + ) + + context = { + **self.admin_site.each_context(request), + "title": _("Emitir declaração para os participantes da visita"), + "subtitle": str(evento) if evento else None, + "form": form, + "object_id": object_id, + "original": evento, + "evento_id": object_id, + "opts": self.model._meta, + "preserved_filters": self.get_preserved_filters(request), + } + return render( + request, "admin/eventos/evento/seleciona_modelo.html", context + ) + + def get_urls(self): + urls = super().get_urls() + model_info = self.get_model_info() + my_urls = [ + path( + "/declaracao/", + self.admin_site.admin_view(self.declaracao_report), + name="%s_%s_declaracaoreport" % model_info, + ), + ] + return my_urls + urls diff --git a/sigi/apps/eventos/migrations/0067_localvisita_visita_evento_pesquisa_previa_and_more.py b/sigi/apps/eventos/migrations/0067_localvisita_visita_evento_pesquisa_previa_and_more.py new file mode 100644 index 0000000..1b73de0 --- /dev/null +++ b/sigi/apps/eventos/migrations/0067_localvisita_visita_evento_pesquisa_previa_and_more.py @@ -0,0 +1,82 @@ +# Generated by Django 6.0.5 on 2026-10-08 15:18 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("casas", "0027_alter_orgao_email"), + ("eventos", "0066_viw_eventos_paricipante"), + ] + + operations = [ + migrations.CreateModel( + name="LocalVisita", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "nome", + models.CharField( + max_length=100, verbose_name="nome do local" + ), + ), + ( + "descricao", + models.TextField(verbose_name="descrição do local"), + ), + ], + options={ + "verbose_name": "local de visita", + "verbose_name_plural": "locais de visita", + }, + ), + migrations.CreateModel( + name="Visita", + fields=[], + options={ + "verbose_name": "Visita", + "verbose_name_plural": "Visitas", + "proxy": True, + "indexes": [], + "constraints": [], + }, + bases=("eventos.evento",), + ), + migrations.AddField( + model_name="evento", + name="pesquisa_previa", + field=models.URLField(blank=True, verbose_name="pesquisa prévia"), + ), + migrations.AlterField( + model_name="evento", + name="casa_anfitria", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + to="casas.orgao", + verbose_name="instituição / casa anfitriã", + ), + ), + migrations.AddField( + model_name="evento", + name="local_visita", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + to="eventos.localvisita", + verbose_name="local da visita", + ), + ), + ] diff --git a/sigi/apps/eventos/migrations/0068_acerta_casa_visitas.py b/sigi/apps/eventos/migrations/0068_acerta_casa_visitas.py new file mode 100644 index 0000000..b955767 --- /dev/null +++ b/sigi/apps/eventos/migrations/0068_acerta_casa_visitas.py @@ -0,0 +1,27 @@ +# Generated by Django 6.0.5 on 2026-10-08 15:22 + +from django.db import migrations + + +def forward(apps, schema_editor): + Evento = apps.get_model("eventos", "Evento") + for visita in Evento.objects.filter(tipo_evento__categoria="V"): + if visita.convite_set.count() == 1: + visita.casa_anfitria = visita.convite_set.first().casa + visita.save() + + +def backward(apps, schema_editor): + Evento = apps.get_model("eventos", "Evento") + Orgao = apps.get_model("casas", "Orgao") + senado = Orgao.objects.get(tipo__sigla="SF") + Evento.objects.filter(tipo_evento__categoria="V").update( + casa_anfitria=senado + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("eventos", "0067_localvisita_visita_evento_pesquisa_previa_and_more"), + ] + operations = [migrations.RunPython(forward, backward)] diff --git a/sigi/apps/eventos/models.py b/sigi/apps/eventos/models.py index 170b2f5..2a86eda 100644 --- a/sigi/apps/eventos/models.py +++ b/sigi/apps/eventos/models.py @@ -15,14 +15,23 @@ from django.urls import reverse from django.utils import timezone from django.utils.safestring import mark_safe from django.utils.translation import gettext as _ +from django.utils.formats import localize from sigi.apps.casas.models import Orgao, Servidor from sigi.apps.contatos.models import UnidadeFederativa from sigi.apps.espacos.models import Reserva -from sigi.apps.utils import get_sigad_url -from sigi.apps.utils.templatetags.model_fields import verbose_name +from sigi.apps.utils import get_sigad_url, periodo from sigi.apps.eventos.saberes import EventoSaberes +class VisitaManager(models.Manager): + def get_queryset(self): + return ( + super() + .get_queryset() + .filter(tipo_evento__categoria=TipoEvento.CATEGORIA_VISITA) + ) + + class TipoEvento(models.Model): CATEGORIA_CURSO = "C" CATEGORIA_ENCONTRO = "E" @@ -267,6 +276,18 @@ class AnexoSolicitacao(models.Model): return _(f"{self.descricao} publicado em {self.data_pub}") +class LocalVisita(models.Model): + nome = models.CharField(_("nome do local"), max_length=100) + descricao = models.TextField(_("descrição do local")) + + class Meta: + verbose_name = _("local de visita") + verbose_name_plural = _("locais de visita") + + def __str__(self): + return self.nome + + class Evento(models.Model): STATUS_PREVISTO = "P" STATUS_AUTORIZADO = "O" @@ -350,7 +371,7 @@ class Evento(models.Model): casa_anfitria = models.ForeignKey( Orgao, on_delete=models.PROTECT, - verbose_name=_("Casa anfitriã"), + verbose_name=_("instituição / casa anfitriã"), blank=True, null=True, ) @@ -358,7 +379,15 @@ class Evento(models.Model): Reserva, blank=True, null=True, on_delete=models.PROTECT ) local = models.TextField(_("Local do evento"), blank=True) + local_visita = models.ForeignKey( + LocalVisita, + verbose_name=_("local da visita"), + on_delete=models.PROTECT, + blank=True, + null=True, + ) observacao = models.TextField(_("Observações e anotações"), blank=True) + pesquisa_previa = models.URLField(_("pesquisa prévia"), blank=True) publico_alvo = models.TextField(_("Público alvo"), blank=True) total_participantes = models.PositiveIntegerField( _("total de participantes/aprovados"), @@ -489,9 +518,20 @@ class Evento(models.Model): ] def __str__(self): - return _( - f"{self.nome} ({self.tipo_evento}): " - f"de {self.data_inicio} a {self.data_termino}" + inicio = ( + datetime.datetime.combine(self.data_inicio, self.hora_inicio) + if self.data_inicio and self.hora_inicio + else self.data_inicio + ) + termino = ( + datetime.datetime.combine(self.data_termino, self.hora_termino) + if self.data_termino and self.hora_termino + else self.data_termino + ) + return _("{nome} ({tipo_evento}): {periodo}").format( + nome=self.nome, + tipo_evento=self.tipo_evento, + periodo=periodo(inicio, termino), ) def get_absolute_url(self): @@ -614,8 +654,12 @@ class Evento(models.Model): def save(self, *args, **kwargs): # Força que a casa anfitriã de todas as visitas seja Senado # Gertik #165751 - if self.tipo_evento.categoria == TipoEvento.CATEGORIA_VISITA: - self.casa_anfitria = Orgao.objects.get(tipo__sigla="SF") + # Mudado em 08/10/2025 porque as visitas foram destacadas dos eventos + # e agora o campo casa_anfitria servirá para indicar a Casa que fará + # a visita + # if self.tipo_evento.categoria == TipoEvento.CATEGORIA_VISITA: + # self.casa_anfitria = Orgao.objects.get(tipo__sigla="SF") + # Limpa casas convidadas se a categoria do evento for Oficina # e está vinculado com um curso no Saberes. # Gertik #165984: https://gertiq.senado.leg.br/redmine/issues/165984#3em-seguida-DELETAR-as-Casas-convidadas-DOS-registros-de-OFICINAS @@ -732,6 +776,15 @@ class Evento(models.Model): ajusta_data(item, self.data_termino) +class Visita(Evento): + objects = VisitaManager() + + class Meta: + proxy = True + verbose_name = _("Visita") + verbose_name_plural = _("Visitas") + + class Funcao(models.Model): nome = models.CharField(_("Função na equipe de evento"), max_length=100) descricao = models.TextField(_("Descrição da função")) diff --git a/sigi/apps/eventos/templates/admin/eventos/evento/change_form_object_tools.html b/sigi/apps/eventos/templates/admin/eventos/evento/change_form_object_tools.html index e9d6a70..73f1347 100644 --- a/sigi/apps/eventos/templates/admin/eventos/evento/change_form_object_tools.html +++ b/sigi/apps/eventos/templates/admin/eventos/evento/change_form_object_tools.html @@ -41,11 +41,4 @@ {% icon "refresh" %} {% translate "Atualizar lista de participantes (Saberes)" %} {% endif %} - - {% if original.tipo_evento.categoria == "V" %} - {% url opts|admin_urlname:'declaracaoreport' original.pk|admin_urlquote as declaracao_url %} - - {% icon "pdf" %} {% translate "Declaração" %} - - {% endif %} {% endblock %} \ No newline at end of file diff --git a/sigi/apps/eventos/templates/admin/eventos/visita/change_form_object_tools.html b/sigi/apps/eventos/templates/admin/eventos/visita/change_form_object_tools.html new file mode 100644 index 0000000..196be8e --- /dev/null +++ b/sigi/apps/eventos/templates/admin/eventos/visita/change_form_object_tools.html @@ -0,0 +1,10 @@ +{% extends "admin/change_form_object_tools.html" %} +{% load i18n admin_urls djbs_extras %} + +{% block object-tools-items %} + {{ block.super }} + {% url opts|admin_urlname:'declaracaoreport' original.pk|admin_urlquote as declaracao_url %} + + {% icon "pdf" %} {% translate "Declaração" %} + +{% endblock %} \ No newline at end of file diff --git a/sigi/apps/utils/__init__.py b/sigi/apps/utils/__init__.py index d1ecf6e..e8c38bb 100644 --- a/sigi/apps/utils/__init__.py +++ b/sigi/apps/utils/__init__.py @@ -1,11 +1,15 @@ +import datetime import re +from threading import local from unicodedata import normalize from django.contrib import admin as django_admin from django.core.exceptions import FieldDoesNotExist from django.db import models from django.template.loader import render_to_string from django.utils.encoding import force_str +from django.utils.formats import localize from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ class SearchField(models.TextField): @@ -147,3 +151,33 @@ def mask_sigad(num_sigad): return f"{orgao}.{sequencial}/{ano}-{dv}" else: return num_sigad + + +def periodo(inicio, termino): + if isinstance(inicio, datetime.datetime) and isinstance( + termino, datetime.datetime + ): + data_inicio = inicio.date() + hora_inicio = inicio.time() + data_termino = termino.date() + hora_termino = termino.time() + if data_inicio == data_termino: + if hora_inicio == hora_termino: + result = _("em {data_inicio}, às {hora_inicio}") + else: + result = _( + "em {data_inicio}, de {hora_inicio} até {hora_termino}" + ) + else: + result = _("de {inicio} até {termino}") + else: + data_inicio = hora_inicio = data_termino = hora_termino = None + result = _("de {inicio} até {termino}") + return result.format( + inicio=localize(inicio), + termino=localize(termino), + data_inicio=localize(data_inicio), + hora_inicio=localize(hora_inicio), + data_termino=localize(data_termino), + hora_termino=localize(hora_termino), + ) diff --git a/sigi/menu_conf.yaml b/sigi/menu_conf.yaml index 4830db6..cdeaef7 100644 --- a/sigi/menu_conf.yaml +++ b/sigi/menu_conf.yaml @@ -144,8 +144,7 @@ main_menu: view_name: admin:eventos_evento_changelist querystr: tipo_evento__categoria__exact=S - title: Visitas Interlegis - view_name: admin:eventos_evento_changelist - querystr: tipo_evento__categoria__exact=V + view_name: admin:eventos_visita_changelist - title: Servidores icon: badge children: @@ -178,6 +177,8 @@ main_menu: view_name: admin:parlamentares_partido_changelist - title: Espaços para eventos view_name: admin:espacos_espaco_changelist + - title: Locais para Visitas + view_name: admin:eventos_localvisita_changelist - title: Recursos para eventos view_name: admin:espacos_recurso_changelist # contato_menu: