From f99b42e37fa40c161e358be14086e98fc1b54c84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ses=C3=B3stris=20Vieira?= Date: Tue, 2 Sep 2025 10:57:47 -0300 Subject: [PATCH] =?UTF-8?q?Altera=C3=A7=C3=B5es=20dos=20t=C3=ADquetes=20de?= =?UTF-8?q?=20agosto=20de=202025?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sigi/apps/casas/models.py | 3 +- sigi/apps/eventos/admin.py | 58 +++- .../eventos/jobs/daily/sincroniza_saberes.py | 5 +- .../commands/carga_participantes.py | 21 +- ...ter_modelodeclaracao_texto_participante.py | 80 +++++ .../migrations/0065_participantes_visitas.py | 168 +++++++++++ .../0066_viw_eventos_paricipante.py | 27 ++ sigi/apps/eventos/models.py | 153 +++++----- sigi/apps/eventos/saberes.py | 281 ++++++++++++++++++ .../evento/change_form_object_tools.html | 51 ++++ .../eventos/evento/checklist_report.html | 0 .../admin/eventos/evento/custos_report.html | 0 .../admin/eventos/evento/gant_report.html | 0 .../eventos/evento/gant_report_classes.html | 0 .../eventos/evento/plano_comunicacao.html | 0 .../eventos/evento/seleciona_modelo.html | 26 ++ .../eventos/declaracao_pdf.html | 15 +- .../templates/eventos/snippets/comitiva.html | 20 ++ .../eventos/evento/seleciona_modelo.html | 49 --- sigi/apps/utils/mixins.py | 22 +- sigi/settings.py | 17 ++ 21 files changed, 827 insertions(+), 169 deletions(-) create mode 100644 sigi/apps/eventos/migrations/0064_alter_modelodeclaracao_texto_participante.py create mode 100644 sigi/apps/eventos/migrations/0065_participantes_visitas.py create mode 100644 sigi/apps/eventos/migrations/0066_viw_eventos_paricipante.py create mode 100644 sigi/apps/eventos/saberes.py create mode 100644 sigi/apps/eventos/templates/admin/eventos/evento/change_form_object_tools.html rename sigi/apps/eventos/{templates3 => templates}/admin/eventos/evento/checklist_report.html (100%) rename sigi/apps/eventos/{templates3 => templates}/admin/eventos/evento/custos_report.html (100%) rename sigi/apps/eventos/{templates3 => templates}/admin/eventos/evento/gant_report.html (100%) rename sigi/apps/eventos/{templates3 => templates}/admin/eventos/evento/gant_report_classes.html (100%) rename sigi/apps/eventos/{templates3 => templates}/admin/eventos/evento/plano_comunicacao.html (100%) create mode 100644 sigi/apps/eventos/templates/admin/eventos/evento/seleciona_modelo.html rename sigi/apps/eventos/{templates3 => templates}/eventos/declaracao_pdf.html (60%) create mode 100644 sigi/apps/eventos/templates/eventos/snippets/comitiva.html delete mode 100644 sigi/apps/eventos/templates3/admin/eventos/evento/seleciona_modelo.html diff --git a/sigi/apps/casas/models.py b/sigi/apps/casas/models.py index 2701ca5..ae94162 100644 --- a/sigi/apps/casas/models.py +++ b/sigi/apps/casas/models.py @@ -136,7 +136,8 @@ class Orgao(models.Model): brasao_largura = models.SmallIntegerField(editable=False, null=True) brasao_altura = models.SmallIntegerField(editable=False, null=True) - def _mathnames(nome, orgaos): + @classmethod + def _mathnames(cls, nome, orgaos): for o, nome_canonico in orgaos: ratio = SequenceMatcher( None, to_ascii(nome).lower(), nome_canonico diff --git a/sigi/apps/eventos/admin.py b/sigi/apps/eventos/admin.py index b64d851..eed0e42 100644 --- a/sigi/apps/eventos/admin.py +++ b/sigi/apps/eventos/admin.py @@ -40,6 +40,7 @@ from sigi.apps.eventos.models import ( Cronograma, ModeloDeclaracao, Modulo, + Participante, TipoEvento, Solicitacao, AnexoSolicitacao, @@ -56,6 +57,7 @@ from sigi.apps.eventos.views import ( context_custos_eventos, context_custos_servidor, ) +from sigi.apps.eventos.saberes import SaberesSyncException from sigi.apps.utils import abreviatura from sigi.apps.utils.filters import DateRangeFilter from sigi.apps.utils.mixins import AsciifyQParameter @@ -334,27 +336,60 @@ class ChecklistInline(admin.StackedInline): class EquipeInline(admin.StackedInline): model = Equipe + fields = ( + ("membro", "funcao"), + "assina_oficio", + ("qtde_diarias", "valor_diaria"), + ("emissao_passagens", "total_passagens"), + "observacoes", + ) autocomplete_fields = ("membro", "funcao") + stacked_cols = "1" class ConviteInline(admin.StackedInline): model = Convite + fields = [("casa", "qtde_participantes"), "nomes_participantes"] autocomplete_fields = ("casa",) + readonly_fields = ["nomes_participantes"] + stacked_cols = "1" class ModuloInline(admin.StackedInline): model = Modulo + fields = ( + "nome", + "descricao", + "tipo", + ("inicio", "termino", "carga_horaria"), + ("apresentador", "monitor"), + "qtde_participantes", + ) autocomplete_fields = ("apresentador", "monitor") + stacked_cols = "1" -class AnexoInline(admin.StackedInline): +class AnexoInline(admin.TabularInline): model = Anexo exclude = ("data_pub", "convite") class CronogramaInline(admin.StackedInline): model = Cronograma - extra = 0 + fields = ( + ("etapa", "nome"), + "descricao", + "duracao", + ("data_prevista_inicio", "data_prevista_termino"), + ("data_inicio", "data_termino"), + "dependencia", + "responsaveis", + "comunicar_inicio", + "comunicar_termino", + "recursos", + ) + extra = 1 + stacked_cols = "1" class ParticipantesEventoInline(admin.TabularInline): @@ -363,6 +398,13 @@ class ParticipantesEventoInline(admin.TabularInline): autocomplete_fields = ["uf"] +class ParticipanteInline(admin.StackedInline): + model = Participante + fields = ("casa_legislativa", ("cpf", "email"), "nome", "local_trabalho") + autocomplete_fields = ["casa_legislativa"] + stacked_cols = "1" + + class ItemSolicitadoInline(admin.StackedInline): model = ItemSolicitado fields = ( @@ -996,6 +1038,7 @@ class EventoAdmin(AsciifyQParameter, ExportActionMixin, admin.ModelAdmin): inlines = ( EquipeInline, ConviteInline, + ParticipanteInline, ParticipantesEventoInline, ModuloInline, AnexoInline, @@ -1210,10 +1253,10 @@ class EventoAdmin(AsciifyQParameter, ExportActionMixin, admin.ModelAdmin): return deleted_objects 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(): - evento = get_object_or_404(Evento, id=object_id) modelo = form.cleaned_data["modelo"] membro = ( evento.equipe_set.filter(assina_oficio=True).first() @@ -1263,7 +1306,12 @@ class EventoAdmin(AsciifyQParameter, ExportActionMixin, admin.ModelAdmin): ) 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), @@ -1429,7 +1477,7 @@ class EventoAdmin(AsciifyQParameter, ExportActionMixin, admin.ModelAdmin): ) if not evento.equipe_set.filter( - Q(valor_diaria__gte=0) | Q(total_passagens__gte=0) + Q(valor_diaria__gt=0) | Q(total_passagens__gt=0) ).exists(): self.message_user( request, @@ -1684,7 +1732,7 @@ class EventoAdmin(AsciifyQParameter, ExportActionMixin, admin.ModelAdmin): f"{request.user.get_full_name()}" ) ) - except Evento.SaberesSyncException as e: + except SaberesSyncException as e: self.message_user( request, _(f"Erro ao sincronizar dados do Saberes: '{e.message}'"), diff --git a/sigi/apps/eventos/jobs/daily/sincroniza_saberes.py b/sigi/apps/eventos/jobs/daily/sincroniza_saberes.py index d412ee3..d45f47b 100644 --- a/sigi/apps/eventos/jobs/daily/sincroniza_saberes.py +++ b/sigi/apps/eventos/jobs/daily/sincroniza_saberes.py @@ -6,6 +6,7 @@ from django.utils import timezone from django.utils.translation import gettext as _ from sigi.apps.utils.management.jobs import JobReportMixin from sigi.apps.eventos.models import Evento +from sigi.apps.eventos.saberes import SaberesSyncException class Job(JobReportMixin, DailyJob): @@ -42,11 +43,11 @@ class Job(JobReportMixin, DailyJob): total_sinc += 1 else: total_ok += 1 - except Evento.SaberesSyncException as err: + except SaberesSyncException as err: errors.append( _( f"Erro ao sincronizar evento {evento.nome} " - f"({evento,id}), com a mensagem '{err.message}'" + f"({evento.id}), com a mensagem '{err.message}'" ) ) total_erros += 1 diff --git a/sigi/apps/eventos/management/commands/carga_participantes.py b/sigi/apps/eventos/management/commands/carga_participantes.py index 239980c..7491551 100644 --- a/sigi/apps/eventos/management/commands/carga_participantes.py +++ b/sigi/apps/eventos/management/commands/carga_participantes.py @@ -1,21 +1,30 @@ from django.core.management.base import BaseCommand -from sigi.apps.eventos.models import Evento from django.utils import timezone +from sigi.apps.eventos.models import Evento +from sigi.apps.eventos.saberes import SaberesSyncException class Command(BaseCommand): help = "Carrega dados de participantes de eventos do Moodle para o SIGI" def handle(self, *args, **options): - for evento in Evento.objects.exclude(moodle_courseid=None).filter( + eventos = Evento.objects.exclude(moodle_courseid=None).filter( data_termino__lt=timezone.localtime() - ): + ) + self.stdout.write(f"Processando {eventos.count()} eventos:") + counter = 0 + for evento in eventos: + counter += 1 try: evento.sincroniza_saberes() self.stdout.write( - self.style.SUCCESS(f"✔ {evento.nome} sincronizado.") + self.style.SUCCESS( + f"✔ {counter}: {evento.nome} sincronizado." + ) ) - except Evento.SaberesSyncException as err: + except SaberesSyncException as err: self.stdout.write( - self.style.ERROR(f"✖ {evento.nome}: {err.message}") + self.style.ERROR( + f"✖ {counter}: {evento.nome}: {err.message}" + ) ) diff --git a/sigi/apps/eventos/migrations/0064_alter_modelodeclaracao_texto_participante.py b/sigi/apps/eventos/migrations/0064_alter_modelodeclaracao_texto_participante.py new file mode 100644 index 0000000..7d75a4c --- /dev/null +++ b/sigi/apps/eventos/migrations/0064_alter_modelodeclaracao_texto_participante.py @@ -0,0 +1,80 @@ +# Generated by Django 5.2.1 on 2025-08-29 14:08 + +import django.db.models.deletion +import tinymce.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("casas", "0027_alter_orgao_email"), + ("eventos", "0063_participantesevento_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="modelodeclaracao", + name="texto", + field=tinymce.models.HTMLField( + help_text="Use as seguintes marcações:", + verbose_name="Texto da declaração", + ), + ), + migrations.CreateModel( + name="Participante", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("cpf", models.CharField(max_length=30, verbose_name="CPF")), + ( + "nome", + models.CharField( + max_length=100, verbose_name="nome completo" + ), + ), + ( + "email", + models.EmailField( + blank=True, max_length=254, verbose_name="e-mail" + ), + ), + ( + "local_trabalho", + models.TextField( + blank=True, verbose_name="local de trabalho / cargo" + ), + ), + ( + "casa_legislativa", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="casas.orgao", + verbose_name="casa legislativa", + ), + ), + ( + "evento", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="eventos.evento", + verbose_name="evento", + ), + ), + ], + options={ + "verbose_name": "participante", + "verbose_name_plural": "participantes", + "ordering": ("casa_legislativa", "nome"), + }, + ), + ] diff --git a/sigi/apps/eventos/migrations/0065_participantes_visitas.py b/sigi/apps/eventos/migrations/0065_participantes_visitas.py new file mode 100644 index 0000000..b8815c2 --- /dev/null +++ b/sigi/apps/eventos/migrations/0065_participantes_visitas.py @@ -0,0 +1,168 @@ +# Generated by Django 5.2.1 on 2025-08-28 01:37 +import re +from django.db import migrations + + +def forward(apps, schema_editor): + # Monta regexps # + cargos = r"|".join( + [ + "AUXILIAR LEGISLATIVA", + "Acessor Parlamentar", + "Administrador", + "Agente Administrativo de Eventos", + "Agente Legislativo", + "Analista de Revisão Pessoal", + "Analista de TI", + "Analista jurídico", + "Assessora Especial da Secretaria Legislativa e Presidente da Comissão de Regulamentação de Cargos", + "ASSESSOR JURÍDICO DA PRESIDÊNCIA", + "Assessor Jurídico", + "Assessor Legislativo", + "Assessor Parlamentar", + "Assessor de Comunicação", + "Assessora Jurídica", + "Assessora", + "Assessor", + "Auxiliar Administrativa", + "Chefe de Gabinete da Presidência", + "Chefe de Gabinete", + "Controlador Geral da Câmara", + "Controlador Interno", + "Controlador", + "Coord do Departamento Legislativo", + "Coordenador de Tecnologia da Informação", + "Coordenador do Departamento Legislativo da Câmara", + "Coordenadora", + "Coordenadora da Escola do Parlamento", + "DIRETOR GERAL", + "Deputado", + "Diretor Administrativo", + "Diretor", + "Diretor Geral", + "Diretor Hospital", + "Diretor Legislativo", + "Diretor Tesoureiro", + "Diretor de Compras e Licitações", + "Diretor de Matérias e Protocolo", + "Diretor de TI", + "Diretor de Tecnologia e Informação", + "Diretor do Departamento Legislativo", + "Diretor do Legislativo", + "Diretora Administrativa", + "Diretora", + "Diretora Legislativa", + "Diretora da Escola do Legislativo", + "Diretora geral da Câmara", + "Motorista", + "O Subprocurador Geral", + "Ouvidor", + "Ouvidor Geral", + "PRESIDENTE", + "Prefeito", + "Presidente", + "Presidente Vereador", + "Presidente Vereadora", + "Presidente da Escola do Legislativo", + "Presidente do InGEPE", + "Primeira Secretária", + "Procurador Geral", + "Procurador", + "Procuradora", + "Secretária", + "Secretária Saúde", + "Secretária da Casa Civil", + "Secretária-Geral", + "Secretário Administrativo", + "Secretário", + "Secretário de Administração", + "Secretário de Planejamento", + "Secretário de Saúde", + "Secretário-geral da Câmara", + "Servidor", + "Servidora", + "Tesoureiro", + "Técnica Administrativa", + "Técnico Legislativo da Secretaria Legislativa e Presidente da Comissão Sistêmica de Sustentabilidade Legislativa", + "Técnico Legislativo", + "VEREADOR", + "Verador", + "Vereadora", + "Vice Prefeita", + "Vice Prefeito", + "Vice Presidente", + "Vice-Prefeito", + "a Vereadora", + "o Assessor Jurídico", + "o Presidente", + "o Servidor", + "o Vereador", + ] + ) + patterns = [ + re.compile(p, re.IGNORECASE) + for p in [ + r"(?P.+?)[,?][ ?]inscrit[o|a] no CPF[ ?](?P.+?)[,?][ ?](?P.+)", + r"(?P.+?)[ ]*-[ ]*(?P.+)", + r"(?P.+?)( *):( *)(?P.+)", + r"(?P.+?)( *);( *)[cpf?][ *](?P.+)", + r"(?P.+?)( *),( *)(?P.+)", + r"(?P.+?) CPF (?P.+)", + r"(?P.+?)(?P[\d.]*[-]*\d+)", + r"(?P.+?)[ ]*\((?P.+)\)", + rf"(?P{cargos})[ ]*(?P.+)", + rf"(?P.+?)[ ]*(?P{cargos})", + ] + ] + + Evento = apps.get_model("eventos", "Evento") + eventos = ( + Evento.objects.filter(tipo_evento__categoria="V") + .exclude(convite=None) + .exclude(convite__nomes_participantes="") + ).prefetch_related("convite_set") + + for evento in eventos: + evento.participante_set.all().delete() + for convite in evento.convite_set.all(): + participantes = convite.nomes_participantes.strip().splitlines() + for nome in participantes: + if nome.strip() == "": + continue + for pattern in patterns: + match = pattern.match(nome) + if match is not None: + break + if match is None: + dados = {"nome": nome} + else: + dados = { + k: v.strip() for k, v in match.groupdict().items() + } + if len(dados["nome"]) > 100: + dados["nome"] = dados["nome"][:100] + if "cpf" in dados and len(dados["cpf"]) > 30: + dados["cpf"] = dados["cpf"][:30] + evento.participante_set.create( + casa_legislativa_id=convite.casa_id, **dados + ) + + +def backward(apps, schema_editor): + Evento = apps.get_model("eventos", "Evento") + Participante = apps.get_model("eventos", "Participante") + eventos = ( + Evento.objects.filter(tipo_evento__categoria="V") + .exclude(convite=None) + .exclude(convite__nomes_participantes="") + ) + Participante.objects.filter(evento__in=eventos).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("eventos", "0064_alter_modelodeclaracao_texto_participante"), + ] + + operations = [migrations.RunPython(forward, backward)] diff --git a/sigi/apps/eventos/migrations/0066_viw_eventos_paricipante.py b/sigi/apps/eventos/migrations/0066_viw_eventos_paricipante.py new file mode 100644 index 0000000..6a40dee --- /dev/null +++ b/sigi/apps/eventos/migrations/0066_viw_eventos_paricipante.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.1 on 2025-08-29 13:47 + +from django.db import migrations + +SQL_STMT = """ +create view viw_eventos_participante as +select ep.evento_id as id_evento, + ep.casa_legislativa_id as id_casa, + ep.cpf, + ep.nome, + ep.email, + ep.local_trabalho +from eventos_participante ep; +grant select on viw_eventos_participante to sigi_qs; +""" +SQL_REVERSE_STMT = "DROP VIEW viw_eventos_participante;" + + +class Migration(migrations.Migration): + + dependencies = [ + ("eventos", "0065_participantes_visitas"), + ] + + operations = [ + migrations.RunSQL(sql=SQL_STMT, reverse_sql=SQL_REVERSE_STMT) + ] diff --git a/sigi/apps/eventos/models.py b/sigi/apps/eventos/models.py index 359d887..f44d413 100644 --- a/sigi/apps/eventos/models.py +++ b/sigi/apps/eventos/models.py @@ -1,6 +1,8 @@ from collections.abc import Iterable import datetime import re +import lxml +import lxml.html from moodle import Moodle from tinymce.models import HTMLField from django.conf import settings @@ -16,6 +18,8 @@ from django.utils.translation import gettext as _ 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.templatetags.model_fields import verbose_name +from sigi.apps.eventos.saberes import EventoSaberes class TipoEvento(models.Model): @@ -281,11 +285,6 @@ class AnexoSolicitacao(models.Model): class Evento(models.Model): - class SaberesSyncException(Exception): - @property - def message(self): - return str(self) - STATUS_PREVISTO = "P" STATUS_AUTORIZADO = "O" STATUS_REALIZADO = "R" @@ -546,81 +545,39 @@ class Evento(models.Model): ) def sincroniza_saberes(self, origem="Cronjob"): - if self.moodle_courseid is None: - raise Evento.SaberesSyncException( - _("Este evento não tem curso associado no Saberes"), - ) - - api_url = f"{settings.MOODLE_BASE_URL}/webservice/rest/server.php" - mws = Moodle(api_url, settings.MOODLE_API_TOKEN) - try: - inscritos = mws.post( - "core_enrol_get_enrolled_users", - courseid=self.moodle_courseid, - ) - except Exception as e: - raise Evento.SaberesSyncException( - _( - "Ocorreu um erro ao acessar o curso no Saberes com " - f"a mensagem {e.message}" - ), - ) - participantes = list( - filter( - lambda u: any( - r["roleid"] in settings.MOODLE_STUDENT_ROLES - for r in u["roles"] - ), - inscritos, - ) - ) - - aprovados = 0 + saberes = EventoSaberes(self) + participantes = saberes.get_participantes() + tot_aprovados = len(saberes.get_aprovados()) self.participantesevento_set.update(inscritos=0, aprovados=0) + self.participante_set.all().delete() + saberes.identifica_orgaos() for participante in participantes: - try: - nome_uf = [ - f["value"].lower() - for f in participante["customfields"] - if f["shortname"] == settings.MOODLE_UF_CUSTOMFIELD - ][0] - uf = UnidadeFederativa.objects.get(nome__iexact=nome_uf) - except ( - IndexError, - UnidadeFederativa.DoesNotExist, - UnidadeFederativa.MultipleObjectsReturned, - ): - uf = None + uf = participante["uf"] part_uf, created = ParticipantesEvento.objects.get_or_create( evento=self, uf=uf ) part_uf.inscritos += 1 - try: - completion_data = mws.post( - "core_completion_get_course_completion_status", - courseid=self.moodle_courseid, - userid=participante["id"], - ) - except Exception: - completion_data = None - - if completion_data and ( - completion_data["completionstatus"]["completed"] - or any( - filter( - lambda c: c["type"] - == settings.MOODLE_COMPLETE_CRITERIA_TYPE - and c["complete"], - completion_data["completionstatus"]["completions"], - ) - ) - ): - aprovados += 1 + if participante["completed"]: part_uf.aprovados += 1 part_uf.save() + part = Participante(evento=self) + if "orgao" in participante: + part.casa_legislativa = participante["orgao"] + part.cpf = participante["username"] + part.nome = participante["fullname"] + part.email = participante["email"] + local_trabalho = [] + if "customfields" in participante: + for cf in participante["customfields"]: + value = lxml.html.fromstring(cf["value"]).text_content() + local_trabalho.append(f"{cf['name']}: {value}") + if "city" in participante: + local_trabalho.append(_(f"Cidade: {participante['city']}")) + part.local_trabalho = "\n".join(local_trabalho) + part.save() self.inscritos_saberes = len(participantes) - self.aprovados_saberes = aprovados + self.aprovados_saberes = tot_aprovados self.data_sincronizacao = timezone.localtime() self.origem_sincronizacao = origem @@ -897,6 +854,36 @@ class Convite(models.Model): verbose_name = _("Casa convidada") verbose_name_plural = _("Casas convidadas") + def __str__(self): + return str(self.id) + + +class Participante(models.Model): + evento = models.ForeignKey( + Evento, verbose_name=_("evento"), on_delete=models.CASCADE + ) + casa_legislativa = models.ForeignKey( + Orgao, + verbose_name=_("casa legislativa"), + on_delete=models.SET_NULL, + blank=True, + null=True, + ) + cpf = models.CharField(_("CPF"), max_length=30) + nome = models.CharField(_("nome completo"), max_length=100) + email = models.EmailField(_("e-mail"), blank=True) + local_trabalho = models.TextField( + _("local de trabalho / cargo"), blank=True + ) + + class Meta: + verbose_name = _("participante") + verbose_name_plural = _("participantes") + ordering = ("casa_legislativa", "nome") + + def __str__(self): + return self.nome + class Modulo(models.Model): TIPO_AULA = "A" @@ -1011,17 +998,25 @@ class ModeloDeclaracao(models.Model): texto = HTMLField( _("Texto da declaração"), help_text=_( - "Use as seguintes marcações:
  • {{ casa.nome }} para o" - " nome da Casa Legislativa / órgão
  • " + "Use as seguintes marcações:
      " + "
    • {{ casa.nome }} para o nome da Casa Legislativa / órgão
    • " "
    • {{ casa.municipio.uf.sigla }} para a sigla da UF da " - "Casa legislativa
    • {{ nome }} " - "para o nome do visitante
    • {{ data }} para a data " - "de emissão da declaração
    • {{ evento.data_inicio }}" - " para a data/hora do início da visita
    • " + "Casa legislativa" + "
    • {{ participante.cpf }} para o CPF do visitante
    • " + "
    • {{ participante.nome }} para o nome do visitante
    • " + "
    • {{ participante.email }} para o e-mail do visitante
    • " + "
    • {{ participante.local_trabalho }} para o cargo / função / " + "local de trabalho do visitante
    • " + "
    • {{ data }} para a data de emissão da declaração
    • " + "
    • {{ evento.data_inicio }} para a data/hora do início " + "da visita
    • " "
    • {{ evento.data_termino }} para a data/hora do " - "término da visita
    • {{ evento.nome }} para o nome " - "do evento
    • {{ evento.descricao }} para a descrição" - " do evento
    " + "término da visita" + "
  • {{ evento.nome }} para o nome do evento
  • " + "
  • {{ evento.descricao }} para a descrição do evento
  • " + "
  • {% include 'eventos/snippets/comitiva.html' %} para a tabela com toda" + " a comitiva da visita
  • " + "
" ), ) diff --git a/sigi/apps/eventos/saberes.py b/sigi/apps/eventos/saberes.py new file mode 100644 index 0000000..150c2b5 --- /dev/null +++ b/sigi/apps/eventos/saberes.py @@ -0,0 +1,281 @@ +import lxml +from difflib import SequenceMatcher +from moodle import Moodle +from django.db import models +from django.conf import settings +from django.utils.translation import gettext as _ +from sigi.apps.utils import to_ascii +from sigi.apps.contatos.models import UnidadeFederativa +from sigi.apps.casas.models import Orgao + + +CAR_ESP = {x: " " for x in range(33, 65) if x < 48 or x > 58} +CONECTIVOS = ["a", "e", "o", "da", "de", "do", "na", "no", "em"] + + +def canonize_full(s): + """canoniza uma string removendo símbolos, artigos e outros conectivos + + Args: + s (str): A string a ser canonizada + + Returns: + (str, list): a string canonizada e a lista de palavras + """ + s = to_ascii(s.lower()).strip().translate(CAR_ESP) + palavras = [ + p.strip() + for p in s.split(" ") + if p.strip() != "" and p.strip() not in CONECTIVOS + ] + s = " ".join(palavras) + return (s, palavras) + + +def canonize(s): + """canoniza uma string retornando apenas a string canonizada + + Args: + s (str): A string a ser canonizada + + Returns: + str: a string canonizada + """ + return canonize_full(s)[0] + + +class SaberesSyncException(Exception): + @property + def message(self): + return str(self) + + +class EventoSaberes(Moodle): + _inscritos = None + _participantes = None + _aprovados = None + evento = None + _ufs = None + + def __init__(self, evento): + url = f"{settings.MOODLE_BASE_URL}/webservice/rest/server.php" + super().__init__(url, settings.MOODLE_API_TOKEN) + self.evento = evento + self._inscritos = None + self._participantes = None + self._aprovados = None + self._ufs = { + canonize(uf.nome): uf for uf in UnidadeFederativa.objects.all() + } + + def get_inscritos(self): + if self.evento.moodle_courseid is None: + raise SaberesSyncException( + _( + f"O evento {self.evento} não tem curso associado no Saberes" + ), + ) + + if self._inscritos is None: + try: + self._inscritos = self.post( + "core_enrol_get_enrolled_users", + courseid=self.evento.moodle_courseid, + ) + except Exception as e: + raise SaberesSyncException( + _( + "Ocorreu um erro ao acessar o curso no Saberes com " + f"a mensagem {e.message}" + ), + ) + for i in self._inscritos: + if "customfields" in i: + i["dictcustomfields"] = { + f["shortname"]: canonize( + lxml.html.fromstring(f["value"]).text_content() + ) + for f in i["customfields"] + } + uf_nome = ( + i["dictcustomfields"][settings.MOODLE_UF_CUSTOMFIELD] + if settings.MOODLE_UF_CUSTOMFIELD + in i["dictcustomfields"] + else None + ) + i["uf"] = ( + self._ufs[uf_nome] if uf_nome in self._ufs else None + ) + return self._inscritos + + def get_participantes(self): + if self._participantes is None: + self._participantes = list( + filter( + lambda u: any( + r["roleid"] in settings.MOODLE_STUDENT_ROLES + for r in u["roles"] + ), + self.get_inscritos(), + ) + ) + return self._participantes + + def get_aprovados(self): + if self._aprovados is None: + for participante in self.get_participantes(): + try: + participante["completion_data"] = self.post( + "core_completion_get_course_completion_status", + courseid=self.evento.moodle_courseid, + userid=participante["id"], + ) + except Exception: + participante["completed"] = False + participante["completion_data"] = None + continue + participante["completed"] = participante["completion_data"][ + "completionstatus" + ]["completed"] or any( + filter( + lambda c: c["type"] + == settings.MOODLE_COMPLETE_CRITERIA_TYPE + and c["complete"], + participante["completion_data"]["completionstatus"][ + "completions" + ], + ) + ) + self._aprovados = list( + filter(lambda p: p["completed"], self.get_participantes()) + ) + return self._aprovados + + def identifica_orgaos(self): + obj_list = ( + Orgao.objects.all() + .order_by() + .annotate(uf_sigla=models.F("municipio__uf__sigla")) + ) + assembleias = obj_list.filter(tipo__sigla="AL") + orgaos = [(o, canonize(f"{o.nome} {o.uf_sigla}")) for o in obj_list] + siglados = {canonize(o.sigla): o for o in obj_list if o.sigla != ""} + siglados.update({canonize(f"ALE{o.uf_sigla}"): o for o in assembleias}) + siglados.update({canonize(f"AL{o.uf_sigla}"): o for o in assembleias}) + kcm = ["camara", "municipal", "vereadores"] + kal = ["assembleia", "legislativa", "estado"] + ufs = self._ufs + try: + senado = Orgao.objects.get(nome__iexact="senado federal") + except Exception: + senado = None + + def get_names(name, uf, municipio): + municipio = canonize(municipio) + uf_sigla = canonize(uf.sigla) if uf else None + name, palavras = canonize_full(name) + names = [name] + # Acrescenta uma versão com a sigla do estado se já não tiver # + if uf_sigla and uf_sigla not in palavras: + names.insert(0, f"{name} {uf_sigla}") # Coloca como primeiro + # Corrige grafia das palavras-chave para Câmara + matches = { + s: [ + p + for p in palavras + if SequenceMatcher(a=s, b=p).ratio() > 0.8 + ] + for s in kcm + } + for kw in matches: + for s in matches[kw]: + name = name.replace(s, kw) + # Elimina o termo vereadores + if "vereadores" in name: + if "municipal" in name: + name = name.replace("vereadores", "") # Só elimina + else: + name = name.replace( + "vereadores", "municipal" + ) # troca por municipal + names.append(canonize(name)) + if "camara" in name: + if "municipal" not in name: + name = name.replace("camara", "camara municipal") + names.append(canonize(name)) + # Cria versão canonica com o nome do municipio e a UF + if uf_sigla: + names.append( + canonize(f"camara municipal {municipio} {uf_sigla}") + ) + # Corrige grafia das palavras-chave para Assembleia + matches = { + s: [ + p + for p in palavras + if SequenceMatcher(a=s, b=p).ratio() > 0.8 + ] + for s in kal + } + for kw in matches: + for s in matches[kw]: + name = name.replace(s, kw) + if "assembleia" in name: + name = name.replace("estado", "") # Elimina o termo "estado" + # Adiciona "legislativa" se necessário + if "legislativa" not in name: + name = name.replace("assembleia", "assembleia legislativa") + names.append(canonize(name)) + # Cria versão canonica com o nome e sigla da UF + if uf_sigla: + names.append( + canonize(f"assembleia legislativa {uf} {uf_sigla}") + ) + # remove duplicados sem mudar a ordem + names = list(dict.fromkeys(names)) + return names + + for p in self.get_participantes(): + if ( + "dictcustomfields" in p + and settings.MOODLE_ORGAO_CUSTOMFIELD in p["dictcustomfields"] + and p["dictcustomfields"][ + settings.MOODLE_ORGAO_CUSTOMFIELD + ].strip() + != "" + ): + nome_orgao = p["dictcustomfields"][ + settings.MOODLE_ORGAO_CUSTOMFIELD + ] + municipio = ( + p["dictcustomfields"][ + settings.MOODLE_MUNICIPIO_CUSTOMFIELD + ] + if settings.MOODLE_MUNICIPIO_CUSTOMFIELD + in p["dictcustomfields"] + else p["city"] if "city" in p else "" + ) + nomes_possiveis = get_names(nome_orgao, p["uf"], municipio) + for nome in nomes_possiveis: + semelhantes = Orgao.get_semelhantes(nome, orgaos) + if len(semelhantes) > 0: + p["orgao"] = semelhantes[-1][0] + break + if "orgao" not in p: + # Buscar por sigla + nome, palavras = canonize_full(nome_orgao) + for nome in palavras: + if nome in siglados: + p["orgao"] = siglados[nome] + break + # Pode ser servidor do Senado - última chance ;D + if ( + "orgao" not in p + and senado is not None + and settings.MOODLE_SERVSENADO_CUSTOMFIELD + in p["dictcustomfields"] + and not p["dictcustomfields"][ + settings.MOODLE_SERVSENADO_CUSTOMFIELD + ].startswith("nao ") + ): + p["orgao"] = senado 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 new file mode 100644 index 0000000..e9d6a70 --- /dev/null +++ b/sigi/apps/eventos/templates/admin/eventos/evento/change_form_object_tools.html @@ -0,0 +1,51 @@ +{% extends "admin/change_form_object_tools.html" %} +{% load i18n admin_urls djbs_extras %} + +{% block object-tools-items %} + {{ block.super }} + + {% if original.equipe_set.exists %} + {% url opts|admin_urlname:'custos' original.pk|admin_urlquote as custos_url %} + + {% icon "money" %} {% translate "Relatório de custos" %} + + {% endif %} + + {% if original.cronograma_set.exists %} + {% url opts|admin_urlname:'gantreport' original.pk|admin_urlquote as gant_url %} + {% url opts|admin_urlname:'checklistreport' original.pk|admin_urlquote as checklist_url %} + {% url opts|admin_urlname:'comunicacaoreport' original.pk|admin_urlquote as comunicacao_url %} + + {% icon "chart" %} {% translate "Gráfico de gant" %} + + + {% icon "checklist" %} {% translate "Checklist" %} + + + {% icon "speak" %} {% translate "Plano de comunicação" %} + + {% endif %} + + {% if original.moodle_courseid is None %} + {% if original.tipo_evento.moodle_template_courseid is not None and evento.tipo_evento.moodle_categoryid is not None %} + {% url opts|admin_urlname:'createcourse' original.pk|admin_urlquote as createcourse_url %} + + {% icon "create" %} {% translate "Criar curso no Saberes" %} + + {% endif %} + {% endif %} + + {% if original.moodle_courseid is not None %} + {% url opts|admin_urlname:'updateparticipantes' original.pk|admin_urlquote as updateparticipantes_url %} + + {% 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/templates3/admin/eventos/evento/checklist_report.html b/sigi/apps/eventos/templates/admin/eventos/evento/checklist_report.html similarity index 100% rename from sigi/apps/eventos/templates3/admin/eventos/evento/checklist_report.html rename to sigi/apps/eventos/templates/admin/eventos/evento/checklist_report.html diff --git a/sigi/apps/eventos/templates3/admin/eventos/evento/custos_report.html b/sigi/apps/eventos/templates/admin/eventos/evento/custos_report.html similarity index 100% rename from sigi/apps/eventos/templates3/admin/eventos/evento/custos_report.html rename to sigi/apps/eventos/templates/admin/eventos/evento/custos_report.html diff --git a/sigi/apps/eventos/templates3/admin/eventos/evento/gant_report.html b/sigi/apps/eventos/templates/admin/eventos/evento/gant_report.html similarity index 100% rename from sigi/apps/eventos/templates3/admin/eventos/evento/gant_report.html rename to sigi/apps/eventos/templates/admin/eventos/evento/gant_report.html diff --git a/sigi/apps/eventos/templates3/admin/eventos/evento/gant_report_classes.html b/sigi/apps/eventos/templates/admin/eventos/evento/gant_report_classes.html similarity index 100% rename from sigi/apps/eventos/templates3/admin/eventos/evento/gant_report_classes.html rename to sigi/apps/eventos/templates/admin/eventos/evento/gant_report_classes.html diff --git a/sigi/apps/eventos/templates3/admin/eventos/evento/plano_comunicacao.html b/sigi/apps/eventos/templates/admin/eventos/evento/plano_comunicacao.html similarity index 100% rename from sigi/apps/eventos/templates3/admin/eventos/evento/plano_comunicacao.html rename to sigi/apps/eventos/templates/admin/eventos/evento/plano_comunicacao.html diff --git a/sigi/apps/eventos/templates/admin/eventos/evento/seleciona_modelo.html b/sigi/apps/eventos/templates/admin/eventos/evento/seleciona_modelo.html new file mode 100644 index 0000000..9939c13 --- /dev/null +++ b/sigi/apps/eventos/templates/admin/eventos/evento/seleciona_modelo.html @@ -0,0 +1,26 @@ +{% extends "admin/change_form.html" %} +{% load i18n static admin_urls djbs_extras %} + +{% block content %} +
+
+
+
+ {% csrf_token %} + {{ form }} +
+
+
+ +
+ + {% url opts|admin_urlname:'change' evento_id|admin_urlquote as change_url %} + + {% icon "dismiss" %} {% translate 'Close' %} + +
+
+{% endblock %} + diff --git a/sigi/apps/eventos/templates3/eventos/declaracao_pdf.html b/sigi/apps/eventos/templates/eventos/declaracao_pdf.html similarity index 60% rename from sigi/apps/eventos/templates3/eventos/declaracao_pdf.html rename to sigi/apps/eventos/templates/eventos/declaracao_pdf.html index dd6755b..c60e0af 100644 --- a/sigi/apps/eventos/templates3/eventos/declaracao_pdf.html +++ b/sigi/apps/eventos/templates/eventos/declaracao_pdf.html @@ -17,16 +17,11 @@ {% block page_margin %}3cm {{ pagemargin }}cm 2cm {{ pagemargin }}cm{% endblock page_margin %} {% block main_content %} - {% for convite in evento.convite_set.all %} - {% with convite.casa as casa %} - {% for nome in convite.nomes_participantes.splitlines %} -
- {% block text_body %}{% endblock %} -
- {% if not forloop.last %} -
- {% endif %} - {% endfor %} + {% for participante in evento.participante_set.all %} + {% with casa=participante.casa_legislativa %} +
+ {% block text_body %}{% endblock %} +
{% endwith %} {% if not forloop.last %}
diff --git a/sigi/apps/eventos/templates/eventos/snippets/comitiva.html b/sigi/apps/eventos/templates/eventos/snippets/comitiva.html new file mode 100644 index 0000000..7678dc7 --- /dev/null +++ b/sigi/apps/eventos/templates/eventos/snippets/comitiva.html @@ -0,0 +1,20 @@ +{% load i18n %} + + + + + + + {% for membro in evento.participante_set.all %} + {% ifchanged membro.casa_legislativa %} + + + + {% endifchanged %} + + + + + + {% endfor %} +
{% trans "CPF" %}{% trans "Nome" %}{% trans "Cargo / função / setor" %}
{{ membro.casa_legislativa.nome }}
{{ membro.cpf }}{{ membro.nome }}{{ membro.local_trabalho }}
\ No newline at end of file diff --git a/sigi/apps/eventos/templates3/admin/eventos/evento/seleciona_modelo.html b/sigi/apps/eventos/templates3/admin/eventos/evento/seleciona_modelo.html deleted file mode 100644 index ec5200f..0000000 --- a/sigi/apps/eventos/templates3/admin/eventos/evento/seleciona_modelo.html +++ /dev/null @@ -1,49 +0,0 @@ -{% extends "admin/base_site.html" %} -{% load i18n static admin_urls %} - -{% block extrastyle %} - {{ block.super }} - -{% endblock %} - -{% block breadcrumbs %}{% endblock %} - -{% block messages %} - {% if error %} -
    -
  • {{ error|capfirst }}
  • -
- {% endif %} -{% endblock messages %} - -{% block content %} -
-
-
- {% trans 'Emitir declaração de comparecimento' %} -
- {% csrf_token %} -
- {{ form }} -
-
-
-
-
-
- - {% url opts|admin_urlname:'change' evento_id|admin_urlquote as change_url %} - - undo - {% trans "Voltar" %} - -
-
-
-
-
-{% endblock %} - diff --git a/sigi/apps/utils/mixins.py b/sigi/apps/utils/mixins.py index 27a3eaf..6c5732a 100644 --- a/sigi/apps/utils/mixins.py +++ b/sigi/apps/utils/mixins.py @@ -1,25 +1,13 @@ -from collections import OrderedDict -from django import forms -from django.contrib import admin -from django.contrib.admin import helpers -from django.contrib.admin.options import csrf_protect_m -from django.contrib.admin.utils import pretty_name from django.contrib.auth.mixins import UserPassesTestMixin -from django.core.exceptions import PermissionDenied, ImproperlyConfigured -from django.http import Http404 -from django.http.response import HttpResponse, HttpResponseRedirect +from django.core.exceptions import ImproperlyConfigured +from django.http.response import HttpResponseRedirect from django.template.response import TemplateResponse -from django.urls import path +from django.urls import path, reverse_lazy from django.utils import timezone -from django.utils.encoding import force_str -from django.utils.translation import gettext as _, ngettext +from django.utils.translation import gettext as _ from django_weasyprint.views import WeasyTemplateResponse -from import_export import resources -from import_export.admin import ImportMixin, ExportMixin -from import_export.fields import Field -from import_export.forms import ExportForm from import_export.signals import post_export -from sigi.apps.utils import field_label, to_ascii +from sigi.apps.utils import to_ascii class ReturnMixin: diff --git a/sigi/settings.py b/sigi/settings.py index 5beb3ad..ffd34eb 100644 --- a/sigi/settings.py +++ b/sigi/settings.py @@ -259,6 +259,14 @@ DJBSTHEME = { "FIELDSET_STYLE": djbs_constants.STYLE_TAB, "INLINESET_STYLE": djbs_constants.STYLE_TAB, "BADGERIZE_FACETS": True, + "ICONS": { + "chart": "bi bi-graph-up", + "checklist": "bi bi-list-check", + "create": "bi bi-node-plus", + "money": "bi bi-coin", + "refresh": "bi bi-arrow-clockwise", + "speak": "bi bi-megaphone", + }, } # tinyMCE rich text editor settings @@ -299,7 +307,16 @@ MOODLE_STUDENT_ROLES = env("MOODLE_STUDENT_ROLES", eval, default=(5, 9)) MOODLE_COMPLETE_CRITERIA_TYPE = env( "MOODLE_COMPLETE_CRITERIA_TYPE", int, default=6 # Type Grade ) +MOODLE_ORGAO_CUSTOMFIELD = env( + "MOODLE_ORGAO_CUSTOMFIELD", str, default="nomeorgao" +) MOODLE_UF_CUSTOMFIELD = env("MOODLE_UF_CUSTOMFIELD", str, default="estado") +MOODLE_MUNICIPIO_CUSTOMFIELD = env( + "MOODLE_MUNICIPIO_CUSTOMFIELD", str, default="Municipio" +) +MOODLE_SERVSENADO_CUSTOMFIELD = env( + "MOODLE_SERVSENADO_CUSTOMFIELD", str, default="servidorsenado" +) # Integração com reserva de salas