diff --git a/sigi/apps/eventos/admin.py b/sigi/apps/eventos/admin.py index 27f0016..d16b8bf 100644 --- a/sigi/apps/eventos/admin.py +++ b/sigi/apps/eventos/admin.py @@ -3,7 +3,6 @@ import pandas as pd import time from admin_auto_filters.filters import AutocompleteFilter from moodle import Moodle -from typing import Any from django.db import models from django.db.models import ( F, @@ -23,7 +22,6 @@ from django.db.models.functions import ExtractDay, Cast from django.conf import settings from django.contrib import admin, messages from django.contrib.admin.utils import get_deleted_objects -from django.core.exceptions import ValidationError from django.http import HttpResponse from django.shortcuts import get_object_or_404, render, redirect from django.template import Template, Context @@ -53,6 +51,7 @@ from sigi.apps.eventos.models import ( Equipe, Convite, Anexo, + ParticipantesEvento, ) from sigi.apps.eventos.forms import EventoAdminForm, SelecionaModeloForm from sigi.apps.servidores.models import Servidor @@ -360,6 +359,12 @@ class CronogramaInline(admin.StackedInline): extra = 0 +class ParticipantesEventoInline(admin.TabularInline): + model = ParticipantesEvento + fields = ("uf", "inscritos", "aprovados") + autocomplete_fields = ["uf"] + + class ItemSolicitadoInline(admin.StackedInline): model = ItemSolicitado fields = ( @@ -993,6 +998,7 @@ class EventoAdmin(AsciifyQParameter, CartExportReportMixin, admin.ModelAdmin): inlines = ( EquipeInline, ConviteInline, + ParticipantesEventoInline, ModuloInline, AnexoInline, CronogramaInline, @@ -1819,7 +1825,7 @@ class EventoAdmin(AsciifyQParameter, CartExportReportMixin, admin.ModelAdmin): "startdate": inicio, "enddate": fim, } - res = mws.core.course.update_courses([changes]) + mws.core.course.update_courses([changes]) except Exception as e: erros.append( _( diff --git a/sigi/apps/eventos/admin_urls.py b/sigi/apps/eventos/admin_urls.py index 04ee891..d00601e 100644 --- a/sigi/apps/eventos/admin_urls.py +++ b/sigi/apps/eventos/admin_urls.py @@ -7,6 +7,11 @@ urlpatterns = [ "alocacaoequipe/", views.alocacao_equipe, name="eventos_alocacaoequipe" ), path("eventosporuf/", views.eventos_por_uf, name="eventos_eventosporuf"), + path( + "alunosporuf/", + views.AlunosPorUfReportView.as_view(), + name="eventos_alunosporuf", + ), path( "solicitacoesporperiodo/", views.solicitacoes_por_periodo, diff --git a/sigi/apps/eventos/forms.py b/sigi/apps/eventos/forms.py index 3a05bb3..cace32d 100644 --- a/sigi/apps/eventos/forms.py +++ b/sigi/apps/eventos/forms.py @@ -1,9 +1,4 @@ -from collections.abc import Mapping -from typing import Any from django import forms -from django.core.files.base import File -from django.db.models.base import Model -from django.forms.utils import ErrorList from django.utils.translation import gettext as _ from material.admin.widgets import ( MaterialAdminTextareaWidget, diff --git a/sigi/apps/eventos/migrations/0063_participantesevento_and_more.py b/sigi/apps/eventos/migrations/0063_participantesevento_and_more.py new file mode 100644 index 0000000..f6e7c46 --- /dev/null +++ b/sigi/apps/eventos/migrations/0063_participantesevento_and_more.py @@ -0,0 +1,69 @@ +# Generated by Django 5.0.4 on 2024-05-28 13:30 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("contatos", "0007_alter_mesorregiao_options"), + ("eventos", "0062_create_viw_eventos"), + ] + + operations = [ + migrations.CreateModel( + name="ParticipantesEvento", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "inscritos", + models.PositiveIntegerField( + default=0, verbose_name="total de inscritos" + ), + ), + ( + "aprovados", + models.PositiveIntegerField( + default=0, verbose_name="total de aprovados" + ), + ), + ( + "evento", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="eventos.evento", + verbose_name="evento", + ), + ), + ( + "uf", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="contatos.unidadefederativa", + verbose_name="uf", + ), + ), + ], + options={ + "verbose_name": "Participante por UF", + "verbose_name_plural": "Participantes por UF", + }, + ), + migrations.AddConstraint( + model_name="participantesevento", + constraint=models.UniqueConstraint( + models.F("evento"), models.F("uf"), name="unique_evento_uf" + ), + ), + ] diff --git a/sigi/apps/eventos/migrations/0064_participantes_evento_carga_inicial.py b/sigi/apps/eventos/migrations/0064_participantes_evento_carga_inicial.py new file mode 100644 index 0000000..bd4e720 --- /dev/null +++ b/sigi/apps/eventos/migrations/0064_participantes_evento_carga_inicial.py @@ -0,0 +1,25 @@ +# Generated by Django 5.0.4 on 2024-05-28 13:41 + +from django.db import migrations +from django.utils import timezone +from sigi.apps.eventos.models import Evento + + +def carga(apps, schema_editor): + for evento in Evento.objects.exclude(moodle_courseid=None).filter( + data_termino__lt=timezone.localtime() + ): + try: + evento.sincroniza_saberes() + print(f"\t{evento.nome} sincronizado.") + except Evento.SaberesSyncException as err: + print(f"\tERRO: {evento.nome}: {err.message}") + + +class Migration(migrations.Migration): + + dependencies = [ + ("eventos", "0063_participantesevento_and_more"), + ] + + operations = [migrations.RunPython(carga, migrations.RunPython.noop)] diff --git a/sigi/apps/eventos/models.py b/sigi/apps/eventos/models.py index bdd0252..6b18742 100644 --- a/sigi/apps/eventos/models.py +++ b/sigi/apps/eventos/models.py @@ -14,9 +14,8 @@ from django.utils import timezone from django.utils.safestring import mark_safe from django.utils.translation import gettext as _ from sigi.apps.casas.models import Orgao, Servidor -from sigi.apps.contatos.models import Municipio +from sigi.apps.contatos.models import UnidadeFederativa from sigi.apps.espacos.models import Reserva -from sigi.apps.servidores.models import Servidor class TipoEvento(models.Model): @@ -577,7 +576,25 @@ class Evento(models.Model): ) aprovados = 0 + self.participantesevento_set.update(inscritos=0, aprovados=0) 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 + 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", @@ -599,6 +616,8 @@ class Evento(models.Model): ) ): aprovados += 1 + part_uf.aprovados += 1 + part_uf.save() self.inscritos_saberes = len(participantes) self.aprovados_saberes = aprovados @@ -937,6 +956,41 @@ class Modulo(models.Model): return _(f"{self.nome} ({self.get_tipo_display()})") +class ParticipantesEvento(models.Model): + evento = models.ForeignKey( + Evento, verbose_name=_("evento"), on_delete=models.CASCADE + ) + uf = models.ForeignKey( + UnidadeFederativa, + verbose_name=_("uf"), + on_delete=models.CASCADE, + blank=True, + null=True, + ) + inscritos = models.PositiveIntegerField(_("total de inscritos"), default=0) + aprovados = models.PositiveIntegerField(_("total de aprovados"), default=0) + + class Meta: + constraints = [ + models.UniqueConstraint("evento", "uf", name="unique_evento_uf") + ] + verbose_name = _("Participante por UF") + verbose_name_plural = _("Participantes por UF") + + def __str__(self): + if self.uf is None: + return _( + f"{self.inscritos} pessoas se inscreveram no " + f"evento {self.evento.nome}, tendo {self.aprovados} " + "pessoas aprovadas, mas não informaram a UF onde residem." + ) + return _( + f"{self.inscritos} pessoas de {self.uf.nome} se inscreveram no " + f"evento {self.evento.nome}, tendo {self.aprovados} " + "pessoas aprovadas" + ) + + class ModeloDeclaracao(models.Model): FORMATO_CHOICES = ( ("A4 portrait", _("A4 retrato")), diff --git a/sigi/apps/eventos/templates/eventos/report/alunos_por_uf_report_view/dataset_snippet.html b/sigi/apps/eventos/templates/eventos/report/alunos_por_uf_report_view/dataset_snippet.html new file mode 100644 index 0000000..e8f6181 --- /dev/null +++ b/sigi/apps/eventos/templates/eventos/report/alunos_por_uf_report_view/dataset_snippet.html @@ -0,0 +1,36 @@ +{% load i18n %} +
+
+
+
+ {{ data_title }} + + + + + {% for label in dataset.columns %} + + {% endfor %} + + + + {% for row_data in dataset.itertuples %} + + {% for value in row_data %} + {% if forloop.first %} + + {% else %} + + {% endif %} + {% endfor %} + + {% endfor %} + +
{% translate "Evento" %}{{ label }}
{{ value.0 }} - {{ value.1 }} + {{ value|default:"" }} +
+
* {% translate "Alunos que não informaram a UF de residência no Saberes" %}
+
+
+
+
\ No newline at end of file diff --git a/sigi/apps/eventos/templates/eventos/report/alunos_por_uf_report_view/report.html b/sigi/apps/eventos/templates/eventos/report/alunos_por_uf_report_view/report.html new file mode 100644 index 0000000..b1dbeb4 --- /dev/null +++ b/sigi/apps/eventos/templates/eventos/report/alunos_por_uf_report_view/report.html @@ -0,0 +1,35 @@ +{% extends 'utils/report/report.html' %} +{% load i18n %} + +{% block extrastyle %} + {{ block.super }} + +{% endblock %} + +{% block data %} + {% if not inscritos_uf is None %} + {% include 'eventos/report/alunos_por_uf_report_view/dataset_snippet.html' with dataset=inscritos_uf data_title=_("Total de alunos inscritos por UF") %} + {% endif %} + {% if not aprovados_uf is None %} + {% include 'eventos/report/alunos_por_uf_report_view/dataset_snippet.html' with dataset=aprovados_uf data_title=_("Total de alunos aprovados por UF") %} + {% endif %} + + {% if not inscritos_regiao is None %} + {% include 'eventos/report/alunos_por_uf_report_view/dataset_snippet.html' with dataset=inscritos_regiao data_title=_("Total de alunos inscritos por região") %} + {% endif %} + {% if not aprovados_regiao is None %} + {% include 'eventos/report/alunos_por_uf_report_view/dataset_snippet.html' with dataset=aprovados_regiao data_title=_("Total de alunos aprovados por região") %} + {% endif %} +{% endblock data %} \ No newline at end of file diff --git a/sigi/apps/eventos/templates/eventos/report/alunos_por_uf_report_view/report_pdf.html b/sigi/apps/eventos/templates/eventos/report/alunos_por_uf_report_view/report_pdf.html new file mode 100644 index 0000000..69698a8 --- /dev/null +++ b/sigi/apps/eventos/templates/eventos/report/alunos_por_uf_report_view/report_pdf.html @@ -0,0 +1,83 @@ +{% extends 'utils/report/report_pdf.html' %} +{% load i18n %} + +{% block extra_style %} + {{ block.super }} + tr.total-row td, td.total-col { + background-color: #007433; + border-top: 1px solid var(--hairline-color); + border-bottom: 1px solid var(--hairline-color); + color: white; + font-weight: 600; + padding: 5px 10px; + text-transform: uppercase; + } + .card-title { + font-weight: 600; + font-size: 1.5em; + margin: 20px 0; + } + table.applied-filter caption { + font-size: 1.2em; + font-weight: 600; + text-transform: uppercase; + margin-left: 4px; + } + table.applied-filter { + border: 1px solid #e0e0e0; + border-radius: 5px; + padding: 5px; + } + table.applied-filter tr { + background-color: white !important; + font-weight: 600; + } + table.applied-filter tr th { + text-align: left; + padding-right: 48px; + width: 20%; + background-color: unset !important; + } + table.applied-filter tr td { + width: 80%; + background-color: unset !important; + padding: 0 24px; + border-bottom: 1px solid #e0e0e0; + } + +{% endblock %} + +{% block main_content %} + + + {% for field in form %} + + + + + {% endfor %} +
{% translate "Filtros aplicados" %}
{{ field.label }} + {% for choice in field.field.choices %} + {% if choice.0 in field.value %}{{ choice.1 }}, {% endif %} + {% empty %} + {{ field.value|default:"" }} + {% endfor %} +
+
+ {% if not inscritos_uf is None %} + {% include 'eventos/report/alunos_por_uf_report_view/dataset_snippet.html' with dataset=inscritos_uf data_title=_("Total de alunos inscritos por UF") %} +
+ {% endif %} + {% if not aprovados_uf is None %} + {% include 'eventos/report/alunos_por_uf_report_view/dataset_snippet.html' with dataset=aprovados_uf data_title=_("Total de alunos aprovados por UF") %} +
+ {% endif %} + {% if not inscritos_regiao is None %} + {% include 'eventos/report/alunos_por_uf_report_view/dataset_snippet.html' with dataset=inscritos_regiao data_title=_("Total de alunos inscritos por região") %} +
+ {% endif %} + {% if not aprovados_regiao is None %} + {% include 'eventos/report/alunos_por_uf_report_view/dataset_snippet.html' with dataset=aprovados_regiao data_title=_("Total de alunos aprovados por região") %} + {% endif %} + +{% endblock %} \ No newline at end of file diff --git a/sigi/apps/eventos/views.py b/sigi/apps/eventos/views.py index d95f691..a643f88 100644 --- a/sigi/apps/eventos/views.py +++ b/sigi/apps/eventos/views.py @@ -10,7 +10,18 @@ from typing import OrderedDict from django.contrib import messages from django.contrib.admin.views.decorators import staff_member_required from django.contrib.auth.decorators import login_required -from django.db.models import Count, Sum, Q, F, OuterRef, Subquery +from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin +from django.db.models import ( + Count, + Sum, + Q, + F, + OuterRef, + Subquery, + Case, + When, + Value, +) from django.http import HttpResponse from django.shortcuts import render from django.utils import timezone @@ -30,6 +41,7 @@ from sigi.apps.eventos.models import ( Equipe, Solicitacao, ItemSolicitado, + ParticipantesEvento, ) from sigi.apps.eventos.forms import ( EventosPorUfForm, @@ -39,6 +51,136 @@ from sigi.apps.eventos.serializers import ( EventoSerializer, EventoListSerializer, ) +from sigi.apps.utils.views import ReportListView + + +class AlunosPorUfReportView( + LoginRequiredMixin, UserPassesTestMixin, ReportListView +): + title = _("Alunos por UF") + empty_message = _("Nenhum evento para os parâmetros solicitados") + filter_form = EventosPorUfForm + list_fields = ["evento__nome", "uf__sigla", "inscritos", "aprovados"] + list_labels = [""] + + def test_func(self): + return self.request.user.is_staff + + def get_queryset(self): + form = self.get_filter_form_instance() + queryset = ParticipantesEvento.objects.none() + if form.is_valid(): + data_inicio = form.cleaned_data.get("data_inicio") + data_fim = form.cleaned_data.get("data_fim") + categorias = form.cleaned_data.get( + "categoria", [c[0] for c in TipoEvento.CATEGORIA_CHOICES] + ) + modo = form.cleaned_data.get("virtual", ["V", "P"]) + queryset = ParticipantesEvento.objects.filter( + evento__status=Evento.STATUS_REALIZADO, + evento__data_inicio__gte=data_inicio, + evento__data_termino__lte=data_fim, + evento__tipo_evento__categoria__in=categorias, + ) + if len(modo) == 1: + if "V" in modo: + queryset = queryset.filter(evento__virtual=True) + else: + queryset = queryset.filter(evento__virtual=False) + return queryset + + def get_dataset(self): + queryset = self.get_queryset() + fieldnames = [ + "evento__nome", + "evento__virtual", + "uf__nome", + "uf__sigla", + "uf__regiao", + "inscritos", + "aprovados", + ] + queryset = queryset.values(*fieldnames) + return queryset, fieldnames + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + queryset = self.get_queryset() + if queryset: + uf_sigla = Case( + When(uf__sigla=None, then=Value("*")), default="uf__sigla" + ) + uf_regiao = Case( + *[ + When(uf__regiao=r[0], then=Value(r[1])) + for r in UnidadeFederativa.REGIAO_CHOICES + ], + default=Value("*"), + ) + modo = Case( + When(evento__virtual=True, then=Value("Virtual")), + default=Value("Presencial"), + ) + + df = pd.DataFrame( + queryset.order_by("evento", "uf").values( + "evento__nome", + "inscritos", + "aprovados", + uf_sigla=uf_sigla, + modo=modo, + ) + ) + context["inscritos_uf"] = df.pivot_table( + values="inscritos", + index=["evento__nome", "modo"], + columns="uf_sigla", + aggfunc="sum", + margins=True, + margins_name="Total", + sort=True, + fill_value=0, + ).astype(pd.Int64Dtype()) + context["aprovados_uf"] = df.pivot_table( + values="aprovados", + index=["evento__nome", "modo"], + columns="uf_sigla", + aggfunc="sum", + margins=True, + margins_name="Total", + sort=True, + fill_value=0, + ).astype(pd.Int64Dtype()) + df = pd.DataFrame( + queryset.order_by("evento", "uf__regiao").values( + "evento__nome", + "inscritos", + "aprovados", + uf_regiao=uf_regiao, + modo=modo, + ) + ) + context["inscritos_regiao"] = df.pivot_table( + values="inscritos", + index=["evento__nome", "modo"], + columns="uf_regiao", + aggfunc="sum", + margins=True, + margins_name="Total", + sort=True, + fill_value=0, + ).astype(pd.Int64Dtype()) + context["aprovados_regiao"] = df.pivot_table( + values="aprovados", + index=["evento__nome", "modo"], + columns="uf_regiao", + aggfunc="sum", + margins=True, + margins_name="Total", + sort=True, + fill_value=0, + ).astype(pd.Int64Dtype()) + return context @login_required diff --git a/sigi/menu_conf.yaml b/sigi/menu_conf.yaml index 123d3bb..bc62f50 100644 --- a/sigi/menu_conf.yaml +++ b/sigi/menu_conf.yaml @@ -29,14 +29,17 @@ main_menu: - title: Relatórios icon: print children: - - title: Erros importação Gescon + - title: VALIDAÇÃO - Erros importação Gescon view_name: convenios-report_erros_gescon - - title: Órgãos com CNPJ duplicado + - title: VALIDAÇÃO - Órgãos com CNPJ duplicado view_name: casas_cnpj_duplicado - - title: Órgãos com CNPJ errado + - title: VALIDAÇÃO - Órgãos com CNPJ errado view_name: casas_cnpj_errado + separator: [after,] - title: Eventos por UF view_name: eventos_eventosporuf + - title: Alunos por UF + view_name: eventos_alunosporuf - title: Solicitações de eventos por período view_name: eventos_solicitacoesporperiodo - title: Calendário de eventos diff --git a/sigi/settings.py b/sigi/settings.py index 597cd97..d71b190 100644 --- a/sigi/settings.py +++ b/sigi/settings.py @@ -298,6 +298,7 @@ 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_UF_CUSTOMFIELD = env("MOODLE_UF_CUSTOMFIELD", str, default="estado") # Integração com reserva de salas