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:
- {{ casa.nome }} para o nome da Casa Legislativa / órgão
- {{ casa.municipio.uf.sigla }} para a sigla da UF da 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
- {% include 'eventos/snippets/comitiva.html' %} para a tabela com toda a comitiva da visita
",
+ 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 %}
+
+{% 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 %}
+
+
+ {% trans "CPF" %} |
+ {% trans "Nome" %} |
+ {% trans "Cargo / função / setor" %} |
+
+ {% for membro in evento.participante_set.all %}
+ {% ifchanged membro.casa_legislativa %}
+
+ {{ membro.casa_legislativa.nome }} |
+
+ {% endifchanged %}
+
+ {{ membro.cpf }} |
+ {{ membro.nome }} |
+ {{ membro.local_trabalho }} |
+
+ {% endfor %}
+
\ 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 %}
-
- {% endif %}
-{% endblock messages %}
-
-{% block content %}
-
-
-
-
{% trans 'Emitir declaração de comparecimento' %}
-
-
-
-
-
-
- {% 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