From d6059f40dd36333b7bc679e514047858e17e289e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ses=C3=B3stris=20Vieira?= Date: Thu, 14 Mar 2024 16:54:10 -0300 Subject: [PATCH] Integra SIGI com sistema de reserva de salas do Prodasen --- sigi/apps/espacos/admin.py | 33 +- sigi/apps/espacos/jobs/__init__.py | 0 sigi/apps/espacos/jobs/daily/__init__.py | 0 sigi/apps/espacos/jobs/hourly/__init__.py | 0 .../jobs/hourly/sincroniza_reservas.py | 605 ++++++++++++++++++ sigi/apps/espacos/jobs/monthly/__init__.py | 0 sigi/apps/espacos/jobs/weekly/__init__.py | 0 sigi/apps/espacos/jobs/yearly/__init__.py | 0 .../0005_alter_reserva_options_and_more.py | 129 ++++ .../migrations/0006_separa_hora_da_data.py | 36 ++ ..._data_inicio_alter_reserva_data_termino.py | 23 + .../0008_carga_inicial_reserva_salas.py | 34 + sigi/apps/espacos/models.py | 99 ++- .../admin/espacos/reserva/change_form.html | 52 ++ .../espacos/templates/espacos/agenda.html | 7 + .../espacos/templates/espacos/agenda_pdf.html | 3 + .../espacos/snippets/agenda_cal.html | 64 +- sigi/apps/espacos/views.py | 103 +-- sigi/apps/eventos/admin.py | 24 +- sigi/apps/eventos/forms.py | 12 +- ..._evento_hora_inicio_evento_hora_termino.py | 30 + .../migrations/0059_separa_hora_da_data.py | 46 ++ .../migrations/0060_drop_viw_eventos.py | 48 ++ ...o_data_inicio_alter_evento_data_termino.py | 27 + .../migrations/0062_create_viw_eventos.py | 48 ++ sigi/apps/eventos/models.py | 26 +- sigi/apps/eventos/serializers.py | 16 + sigi/apps/eventos/views.py | 56 +- sigi/settings.py | 6 + 29 files changed, 1380 insertions(+), 147 deletions(-) create mode 100644 sigi/apps/espacos/jobs/__init__.py create mode 100644 sigi/apps/espacos/jobs/daily/__init__.py create mode 100644 sigi/apps/espacos/jobs/hourly/__init__.py create mode 100644 sigi/apps/espacos/jobs/hourly/sincroniza_reservas.py create mode 100644 sigi/apps/espacos/jobs/monthly/__init__.py create mode 100644 sigi/apps/espacos/jobs/weekly/__init__.py create mode 100644 sigi/apps/espacos/jobs/yearly/__init__.py create mode 100644 sigi/apps/espacos/migrations/0005_alter_reserva_options_and_more.py create mode 100644 sigi/apps/espacos/migrations/0006_separa_hora_da_data.py create mode 100644 sigi/apps/espacos/migrations/0007_alter_reserva_data_inicio_alter_reserva_data_termino.py create mode 100644 sigi/apps/espacos/migrations/0008_carga_inicial_reserva_salas.py create mode 100644 sigi/apps/espacos/templates/admin/espacos/reserva/change_form.html create mode 100644 sigi/apps/eventos/migrations/0058_evento_hora_inicio_evento_hora_termino.py create mode 100644 sigi/apps/eventos/migrations/0059_separa_hora_da_data.py create mode 100644 sigi/apps/eventos/migrations/0060_drop_viw_eventos.py create mode 100644 sigi/apps/eventos/migrations/0061_alter_evento_data_inicio_alter_evento_data_termino.py create mode 100644 sigi/apps/eventos/migrations/0062_create_viw_eventos.py diff --git a/sigi/apps/espacos/admin.py b/sigi/apps/espacos/admin.py index e408d31..090ffd0 100644 --- a/sigi/apps/espacos/admin.py +++ b/sigi/apps/espacos/admin.py @@ -71,26 +71,33 @@ class ReservaAdmin(CartExportMixin, admin.ModelAdmin): form = ReservaAdminForm resource_classes = [ReservaResource] list_display = [ - "get_status", + "status", "proposito", "get_link_sigad", "get_espaco", - "inicio", - "termino", + "data_inicio", + "data_termino", + "hora_inicio", + "hora_termino", "virtual", "solicitante", "contato", "telefone_contato", ] - list_display_links = ["get_status", "proposito"] - list_filter = ["status", "espaco", "virtual"] + list_display_links = ["status", "proposito"] + list_filter = [ + "espaco", + "virtual", + "status", + ("id_reserva", admin.EmptyFieldListFilter), + ] search_fields = [ "proposito", "espaco__nome", "espaco__sigla", "num_processo", ] - date_hierarchy = "inicio" + date_hierarchy = "data_inicio" fieldsets = [ (None, {"fields": ("status",)}), ( @@ -111,8 +118,8 @@ class ReservaAdmin(CartExportMixin, admin.ModelAdmin): _("Detalhes"), { "fields": ( - "inicio", - "termino", + ("data_inicio", "hora_inicio"), + ("data_termino", "hora_termino"), "informacoes", ) }, @@ -121,9 +128,13 @@ class ReservaAdmin(CartExportMixin, admin.ModelAdmin): _("Contato"), {"fields": ("solicitante", "contato", "telefone_contato")}, ), + ( + _("Integração com sistema de reservas"), + {"fields": ("id_reserva", "data_ult_atualizacao")}, + ), ] autocomplete_fields = ["espaco"] - readonly_fields = ("evento",) + readonly_fields = ("evento", "id_reserva", "data_ult_atualizacao") inlines = [RecursoSolicitadoInline] def get_readonly_fields(self, request, obj=None): @@ -142,10 +153,6 @@ class ReservaAdmin(CartExportMixin, admin.ModelAdmin): return self.get_fields(request) return super().get_readonly_fields(request, obj) - @admin.display(description=_("Status"), ordering="status", boolean=True) - def get_status(self, obj): - return obj.status == Reserva.STATUS_ATIVO - @admin.display(description=_("Espaço"), ordering="espaco") def get_espaco(self, obj): return obj.espaco.sigla diff --git a/sigi/apps/espacos/jobs/__init__.py b/sigi/apps/espacos/jobs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sigi/apps/espacos/jobs/daily/__init__.py b/sigi/apps/espacos/jobs/daily/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sigi/apps/espacos/jobs/hourly/__init__.py b/sigi/apps/espacos/jobs/hourly/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sigi/apps/espacos/jobs/hourly/sincroniza_reservas.py b/sigi/apps/espacos/jobs/hourly/sincroniza_reservas.py new file mode 100644 index 0000000..52a3847 --- /dev/null +++ b/sigi/apps/espacos/jobs/hourly/sincroniza_reservas.py @@ -0,0 +1,605 @@ +import requests +import datetime +from django.conf import settings +from django.db.models import Q +from django.utils import timezone +from django.utils.formats import localize +from django.utils.translation import gettext as _ +from django_extensions.management.jobs import HourlyJob +from sigi.apps.utils.management.jobs import JobReportMixin +from sigi.apps.espacos.models import ( + Espaco, + Recurso, + Reserva, +) + +DEPARA_SITUACAO = { + "Aguardando análise": Reserva.STATUS_ATIVO, + "Excluída": None, # Será tratado como caso especial + "Reserva aprovada": Reserva.STATUS_ATIVO, + "Reserva cancelada": Reserva.STATUS_CANCELADO, + "Reserva rejeitada": Reserva.STATUS_CANCELADO, +} + + +class Job(JobReportMixin, HourlyJob): + help = "Sincroniza dados do sistema de reserva de salas" + report_data = [] + resumo = [] + infos = [] + erros = [] + + @property + def auth_data(self): + return ( + settings.RESERVA_SALA_API_USER, + settings.RESERVA_SALA_API_PASSWORD, + ) + + def do_job(self): + self.resumo = [] + self.infos = [] + self.erros = [] + + if ( + settings.RESERVA_SALA_BASE_URL is None + or settings.RESERVA_SALA_API_USER is None + or settings.RESERVA_SALA_API_PASSWORD is None + ): + # Acesso ao sistema não configurado. Não fazer nada + return + + self.carrega_salas() + self.carrega_recursos() + self.carrega_reservas() + + def carrega_salas(self): + tit = ["", "\t*Carga de salas*", ""] + self.infos.extend(tit) + self.erros.extend(tit) + self.resumo.extend(tit) + + tot_novas = 0 + tot_erros = 0 + tot_atualizadas = 0 + req = requests.get( + settings.RESERVA_SALA_BASE_URL + "salas", auth=self.auth_data + ) + if not req.ok: + self.erros.append( + _( + "\t* Erro de autenticação na API do sistema de reserva " + f"de salas, com a mensagem *{req.reason}*" + ) + ) + return + for sala in req.json(): + try: + espaco = Espaco.objects.get(id_sala=sala["id"]) + except Espaco.DoesNotExist: + # Criar espaço + espaco = Espaco( + nome=sala["nome"], + sigla=sala["nome"][:20], + descricao=sala["nome"], + local=sala["local"], + capacidade=sala["capacidade"], + id_sala=sala["id"], + ) + espaco.save() + self.infos.append( + _( + f"\t* Criado espaço *{espaco.id}* para a " + f"sala *{sala['id']} - {sala['nome']}*" + ) + ) + tot_novas += 1 + continue + except Espaco.MultipleObjectsReturned: + self.erros.append( + _( + "\t* Existe mais de um espaço com o mesmo ID de sala. " + "Isso deve ser corrigido manualmente no SIGI. " + f"id_sala={sala['id']}" + ) + ) + tot_erros += 1 + continue + # verifica se precisa atualizar os dados do espaço + jespaco = { + "id": espaco.id_sala, + "nome": espaco.nome, + "local": espaco.local, + "capacidade": espaco.capacidade, + } + if sorted(sala) != sorted(jespaco): + # Atualizar o espaço + espaco.sala_id = sala["id"] + espaco.nome = sala["nome"] + espaco.local = sala["local"] + espaco.capacidade = sala["capacidade"] + espaco.save() + self.infos.append( + _( + f"\t* Espaço *{espaco.id}* atualizado com novos dados " + f"da sala *{sala['id']}*" + ) + ) + tot_atualizadas += 1 + self.resumo.append( + _(f"\t* Total de salas processadas: {len(req.json())}") + ) + self.resumo.append(_(f"\t* Novos espaços criados: {tot_novas}")) + self.resumo.append(_(f"\t* Espaços atualizados: {tot_atualizadas}")) + self.resumo.append(_(f"\t* Erros encontrados nas salas: {tot_erros}")) + + def carrega_recursos(self): + tit = ["", "\t*Carga de recursos*", ""] + self.infos.extend(tit) + self.erros.extend(tit) + self.resumo.extend(tit) + + tot_novas = 0 + tot_erros = 0 + tot_atualizadas = 0 + + req = requests.get( + settings.RESERVA_SALA_BASE_URL + "equipamentos", + auth=self.auth_data, + ) + if not req.ok: + self.erros.append( + _( + "\t* Erro na API do sistema de reserva ao ler " + f"equipamentos, com a mensagem *{req.reason}*" + ) + ) + return + + for equipamento in req.json(): + if equipamento["status"] == "Não": + # Não importar + continue + try: + recurso = Recurso.objects.get(id_equipamento=equipamento["id"]) + except Recurso.DoesNotExist: + recurso = Recurso( + nome=equipamento["nome"], + sigla=equipamento["nome"][:20], + descricao=equipamento["nome"], + id_equipamento=equipamento["id"], + ) + recurso.save() + self.infos.append( + f"\t* Recurso *{recurso}* criado a partir do equipamento *{equipamento['id']} - {equipamento['nome']}*" + ) + tot_novas += 1 + continue + except Recurso.MultipleObjectsReturned: + lista = ", ".join( + [ + str(r) + for r in Recurso.objects.filter( + id_equipamento=equipamento["id"] + ) + ] + ) + self.erros.append( + f"\t* O equipamento *{equipamento['id']} - " + f"{equipamento['nome']}* possui os seguintes recursos " + f"com mesmo ID no SIGI: *{lista}*" + ) + tot_erros += 1 + continue + if equipamento["nome"] != recurso.nome: + recurso.nome = equipamento["nome"] + recurso.save() + self.infos.append( + f"\t* Recurso *{str(recurso)}* atualizado com as alterações " + f"do equipamento *{equipamento['id']}*" + ) + tot_atualizadas += 1 + + self.resumo.append( + _(f"\t* Total de equipamentos processados: {len(req.json())}") + ) + self.resumo.append(_(f"\t* Novos recursos criados: {tot_novas}")) + self.resumo.append(_(f"\t* Recursos atualizados: {tot_atualizadas}")) + self.resumo.append( + _(f"\t* Erros encontrados nos equipamentos: {tot_erros}") + ) + + def carrega_reservas( + self, + ontem=(timezone.localdate() - timezone.timedelta(days=1)).isoformat(), + ): + tit = ["", "\t*Carga de reservas*", ""] + self.infos.extend(tit) + self.erros.extend(tit) + self.resumo.extend(tit) + + tot_processadas = 0 + tot_novas = 0 + tot_excluidas = 0 + tot_erros = 0 + tot_atualizadas = 0 + + for espaco in Espaco.objects.exclude(id_sala=None): + req = requests.get( + settings.RESERVA_SALA_BASE_URL + + f"salas/{espaco.id_sala}/reservas/datas?dataInicio={ontem}", + auth=self.auth_data, + ) + if not req.ok: + self.erros.append( + _( + "\t* Erro na API do sistema de reserva ao ler " + f"reservas da sala *{espaco.id_sala}*, com data de " + f"início maior que *{ontem}*, " + f"com a mensagem *{req.reason}*" + ) + ) + continue + tot_processadas += len(req.json()) + for reserva in req.json(): + # Hack sujo para campos igual a None + if reserva["horaInicio"] is None: + reserva["horaInicio"] = "00:00:00" + if reserva["horaFim"] is None: + reserva["horaFim"] = "23:59:59" + if reserva["descricao"] is None: + reserva["descricao"] = "" + if reserva["informacao"] is None: + reserva["informacao"] = "" + if reserva["ramal"] is None: + reserva["ramal"] = "" + # Hack sujo para strings muito grandes + reserva["evento"] = reserva["evento"][:100] + reserva["coordenador"] = reserva["coordenador"][:100] + reserva["ramal"] = reserva["ramal"][:100] + + data_inicio = datetime.date.fromisoformat( + reserva["dataInicio"] + ) + hora_inicio = datetime.time.fromisoformat( + reserva["horaInicio"] + ) + data_termino = datetime.date.fromisoformat(reserva["dataFim"]) + hora_termino = datetime.time.fromisoformat(reserva["horaFim"]) + status = DEPARA_SITUACAO[reserva["situacao"]] + # Tratar reservas excluídas no sistema de reservas + if reserva["situacao"] == "Excluída": + res = Reserva.objects.filter( + id_reserva=reserva["id"] + ).delete()[1] + if "espacos.Reserva" in res: + tot_excluidas += res["espacos.Reserva"] + continue + # Tratar os demais casos + try: + reserva_sigi = Reserva.objects.get( + id_reserva=reserva["id"] + ) + except Reserva.DoesNotExist: + conflitos = self.verifica_conflito( + espaco, + data_inicio, + data_termino, + hora_inicio, + hora_termino, + ) + if conflitos: + # Verificar se existe um conflitante com as mesmas + # datas/horas e coordenador. + reserva_sigi = Reserva.objects.filter( + espaco=espaco, + id_reserva=None, + data_inicio=data_inicio, + data_termino=data_termino, + hora_inicio=hora_inicio, + hora_termino=hora_termino, + contato=reserva["coordenador"], + ).first() + if reserva_sigi: + # Se existe, então é a mesma, bastando vincular + reserva_sigi.id_reserva = reserva["id"] + reserva_sigi.save() + # Deixa seguir para atualizar outros campos + else: + # Criar a reserva conflitante + if status != Reserva.STATUS_CANCELADO: + status = Reserva.STATUS_CONFLITO + reserva_sigi = self.cria_reserva( + reserva, + espaco, + status, + data_inicio, + data_termino, + hora_inicio, + hora_termino, + ) + if reserva_sigi.status == Reserva.STATUS_CONFLITO: + # Reportar como erro se a reserva é conflitante + lista = ", ".join([str(c) for c in conflitos]) + self.erros.append( + f"\t* A reserva *{reserva['id']} - " + f"{reserva['evento']}" + "* do sistema de reservas conflita com " + "a(s) seguinte(s) reserva(s) do SIGI: " + f"*{lista}*. e foi copiada para o SIGI " + "como conflitante." + ) + tot_erros += 1 + else: + # Reportar como nova se o status for cancelada + self.infos.append( + f"\t* Reserva *{str(reserva_sigi)}* " + "criada no SIGI a partir da reserva " + f"*{reserva['descricao']}* " + "do sistema de reservas" + ) + tot_novas += 1 + continue + else: # Não há conflitos, basta criar a reserva + reserva_sigi = self.cria_reserva( + reserva, + espaco, + status, + data_inicio, + data_termino, + hora_inicio, + hora_termino, + ) + self.infos.append( + f"\t* Reserva *{str(reserva_sigi)}* criada no SIGI" + f" a partir da reserva *{reserva['descricao']}* " + "do sistema de reservas" + ) + tot_novas += 1 + continue + except Reserva.MultipleObjectsReturned: + # Esse erro nunca poderia acontecer, mas ... + self.erros.append( + _( + "\t* Existe mais de uma reserva no SIGI com o " + "mesmo ID de reserva do sistema de reservas. " + "Isso deve ser corrigido manualmente no SIGI. " + f"id_reserva=*{reserva['id']}*" + ) + ) + tot_erros += 1 + continue + # Reserva foi encontrada no SIGI. Podemos atualizar + atualizou = False + if ( + reserva_sigi.data_inicio != data_inicio + or reserva_sigi.data_termino != data_termino + or reserva_sigi.hora_inicio != hora_inicio + or reserva_sigi.hora_termino != hora_termino + ): + # Se mudou de data/hora, pode ocorrer conflitos + conflitos = self.verifica_conflito( + espaco, + data_inicio, + data_termino, + hora_inicio, + hora_termino, + reserva_sigi, + ) + if not conflitos: + # Nenhum conflito, podemos alterar as datas de boa + reserva_sigi.data_inicio = data_inicio + reserva_sigi.data_termino = data_termino + reserva_sigi.hora_inicio = hora_inicio + reserva_sigi.hora_termino = hora_termino + self.infos.append( + f"\t* *{str(reserva_sigi)}* mudou para o período " + f"de *{localize(data_inicio)} " + f"{localize(hora_inicio)}* a " + f"*{localize(data_termino)} " + f"{localize(hora_termino)}*" + ) + atualizou = True + else: + lista = ", ".join([str(c) for c in conflitos]) + self.erros.append( + f"\t* A reserva *{reserva['evento']}* no sistema " + "de reservas mudou de data, mas esta mudança não " + "pode ser aplicada no SIGI pois gera conflito " + "com a(s) seguinte(s) outra(s) reserva(s): " + f"*{lista}*" + ) + tot_erros += 1 + continue + # Verificar outras atualizações + if reserva_sigi.status != status: + reserva_sigi.status = status + self.infos.append( + f"\t* A reserva SIGI *{str(reserva_sigi)}* mudou de " + f"status para *{reserva_sigi.get_status_display()}*" + ) + atualizou = True + rr = ( + reserva["evento"], + reserva["quantidadeAlunos"], + "\n".join( + [reserva["descricao"], str(reserva["informacao"])] + ), + reserva["coordenador"], + reserva["coordenador"], + reserva["ramal"], + ) + rs = ( + reserva_sigi.proposito, + reserva_sigi.total_participantes, + reserva_sigi.informacoes, + reserva_sigi.solicitante, + reserva_sigi.contato, + reserva_sigi.telefone_contato, + ) + if rr != rs: + # Campos descritivos foram alterados + reserva_sigi.proposito = reserva["evento"] + reserva_sigi.total_participantes = reserva[ + "quantidadeAlunos" + ] + reserva_sigi.informacoes = "\n".join( + [reserva["descricao"], str(reserva["informacao"])] + ) + reserva_sigi.solicitante = reserva["coordenador"] + reserva_sigi.contato = reserva["coordenador"] + reserva_sigi.telefone_contato = reserva["ramal"] + reserva_sigi.save() + self.infos.append( + f"\t* A reserva SIGI *{str(reserva_sigi)}* foi " + "atualizada com as alterações da " + f"reserva *{reserva['id']}*" + ) + atualizou = True + if self.recursos_solicitados( + reserva_sigi, reserva["equipamentos"] + ): + self.infos.append( + "\t* Os recursos solicitados da reserva SIGI " + f"*{str(reserva_sigi)}* foram atualizados" + ) + atualizou = True + if atualizou: + tot_atualizadas += 1 + self.resumo.append( + _(f"\t* Total de reservas processadas: {tot_processadas}") + ) + self.resumo.append(_(f"\t* Novas reservas criadas: {tot_novas}")) + self.resumo.append(_(f"\t* Reservas atualizados: {tot_atualizadas}")) + self.resumo.append(_(f"\t* Reservas excluídas: {tot_excluidas}")) + self.resumo.append( + _(f"\t* Erros encontrados nas reservas: {tot_erros}") + ) + + def verifica_conflito( + self, + espaco, + data_inicio, + data_termino, + hora_inicio, + hora_termino, + reserva_sigi=None, + ): + # Verifica se existe alguma reserva do espaço que conflita com o + # período desejado + reservas_conflitantes = Reserva.objects.exclude( + status=Reserva.STATUS_CANCELADO + ).filter( + espaco=espaco, + data_inicio__lte=data_termino, + data_termino__gte=data_inicio, + hora_inicio__lte=hora_termino, + hora_termino__gte=hora_inicio, + ) + if reserva_sigi: + reservas_conflitantes = reservas_conflitantes.exclude( + id=reserva_sigi.id + ) + + if not reservas_conflitantes.exists(): + return None + else: + return reservas_conflitantes.all() + + def cria_reserva( + self, + reserva, + espaco, + status, + data_inicio, + data_termino, + hora_inicio, + hora_termino, + ): + reserva_sigi = Reserva( + status=status, + espaco=espaco, + proposito=reserva["evento"], + virtual=False, + total_participantes=reserva["quantidadeAlunos"], + data_pedido=timezone.localdate(), + data_inicio=data_inicio, + data_termino=data_termino, + hora_inicio=hora_inicio, + hora_termino=hora_termino, + informacoes="\n".join( + [ + reserva["descricao"], + str(reserva["informacao"]), + ] + ), + solicitante=reserva["coordenador"], + contato=reserva["coordenador"], + telefone_contato=reserva["ramal"], + id_reserva=reserva["id"], + data_ult_atualizacao=timezone.localtime(), + ) + reserva_sigi.save() + self.recursos_solicitados(reserva_sigi, reserva["equipamentos"]) + return reserva_sigi + + def recursos_solicitados(self, reserva_sigi, equipamentos_solicitados): + atualizou = False + for equipamento in equipamentos_solicitados: + recurso = Recurso.objects.filter( + id_equipamento=equipamento["id"] + ).first() + if not recurso: + # Cria um novo recurso na tabela de recursos + recurso = Recurso( + nome=equipamento["nome"], + sigla=equipamento["nome"][:20], + descricao=equipamento["nome"], + id_equipamento=equipamento["id"], + ) + recurso.save() + atualizou = True + continue + + if not reserva_sigi.recursosolicitado_set.filter( + recurso=recurso + ).exists(): + reserva_sigi.recursosolicitado_set.create( + recurso=recurso, quantidade=1 + ) + atualizou = True + return atualizou + + def report(self, start_time, end_time): + self.report_data = [ + "", + "", + "RESUMO", + "------", + "", + "", + ] + self.report_data.extend(self.resumo) + self.report_data.extend( + [ + "", + "", + "ERROS ENCONTRADOS", + "-----------------", + "", + "", + ] + ) + self.report_data.extend(self.erros) + self.report_data.extend( + [ + "", + "", + "MAIS INFORMAÇÕES", + "----------------", + "", + "", + ] + ) + self.report_data.extend(self.infos) + super().report(start_time, end_time) diff --git a/sigi/apps/espacos/jobs/monthly/__init__.py b/sigi/apps/espacos/jobs/monthly/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sigi/apps/espacos/jobs/weekly/__init__.py b/sigi/apps/espacos/jobs/weekly/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sigi/apps/espacos/jobs/yearly/__init__.py b/sigi/apps/espacos/jobs/yearly/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sigi/apps/espacos/migrations/0005_alter_reserva_options_and_more.py b/sigi/apps/espacos/migrations/0005_alter_reserva_options_and_more.py new file mode 100644 index 0000000..eeff5ef --- /dev/null +++ b/sigi/apps/espacos/migrations/0005_alter_reserva_options_and_more.py @@ -0,0 +1,129 @@ +# Generated by Django 4.2.7 on 2024-03-12 12:36 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ("espacos", "0004_alter_reserva_status"), + ] + + operations = [ + migrations.AlterModelOptions( + name="reserva", + options={ + "ordering": ( + "data_inicio", + "hora_inicio", + "espaco", + "proposito", + ), + "verbose_name": "reserva", + "verbose_name_plural": "reservas", + }, + ), + migrations.RenameField( + model_name="reserva", + old_name="inicio", + new_name="data_inicio", + ), + migrations.RenameField( + model_name="reserva", + old_name="termino", + new_name="data_termino", + ), + migrations.AddField( + model_name="espaco", + name="capacidade", + field=models.PositiveBigIntegerField( + default=0, + help_text="Número de acentos ou lotação máxima do espaço", + verbose_name="capacidade", + ), + ), + migrations.AddField( + model_name="espaco", + name="id_sala", + field=models.PositiveIntegerField( + blank=True, + help_text="ID da sala no sistema de reserva de salas do ILB", + null=True, + verbose_name="ID da sala", + ), + ), + migrations.AddField( + model_name="espaco", + name="reserva_eventos", + field=models.BooleanField( + default=False, + help_text="Pode ser reservado para eventos cadastrados no SIGI", + verbose_name="reserva para eventos", + ), + ), + migrations.AddField( + model_name="recurso", + name="id_equipamento", + field=models.PositiveBigIntegerField( + blank=True, + help_text="ID do equipamento no sistema de reserva de salas do ILB", + null=True, + unique=True, + verbose_name="ID do equipamento", + ), + ), + migrations.AddField( + model_name="reserva", + name="data_ult_atualizacao", + field=models.DateTimeField( + blank=True, + editable=False, + null=True, + verbose_name="data da última atualização", + ), + ), + migrations.AddField( + model_name="reserva", + name="hora_inicio", + field=models.TimeField( + default=django.utils.timezone.now, + verbose_name="hora início", + ), + preserve_default=False, + ), + migrations.AddField( + model_name="reserva", + name="hora_termino", + field=models.TimeField( + default=django.utils.timezone.now, + verbose_name="hora término", + ), + preserve_default=False, + ), + migrations.AddField( + model_name="reserva", + name="id_reserva", + field=models.PositiveBigIntegerField( + blank=True, + editable=False, + null=True, + unique=True, + verbose_name="ID da reserva", + ), + ), + migrations.AlterField( + model_name="reserva", + name="status", + field=models.CharField( + choices=[ + ("A", "Ativo"), + ("C", "Cancelado"), + ("O", "Conflito de datas"), + ], + default="A", + max_length=1, + verbose_name="status", + ), + ), + ] diff --git a/sigi/apps/espacos/migrations/0006_separa_hora_da_data.py b/sigi/apps/espacos/migrations/0006_separa_hora_da_data.py new file mode 100644 index 0000000..70af4af --- /dev/null +++ b/sigi/apps/espacos/migrations/0006_separa_hora_da_data.py @@ -0,0 +1,36 @@ +# Generated by Django 4.2.7 on 2024-03-12 12:39 + +from datetime import datetime +from django.db import migrations +from django.utils import timezone + + +def forward(apps, schema_editor): + Reserva = apps.get_model("espacos", "Reserva") + for reserva in Reserva.objects.all(): + reserva.hora_inicio = timezone.localtime(reserva.data_inicio).time() + reserva.hora_termino = timezone.localtime(reserva.data_termino).time() + reserva.save() + + +def backward(apps, schema_editor): + Reserva = apps.get_model("espacos", "Reserva") + for reserva in Reserva.objects.all(): + reserva.data_inicio = datetime.combine( + reserva.data_inicio, reserva.hora_inicio + ).replace(tzinfo=timezone.get_current_timezone()) + reserva.data_termino = datetime.combine( + reserva.data_termino, reserva.hora_termino + ).replace(tzinfo=timezone.get_current_timezone()) + reserva.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("espacos", "0005_alter_reserva_options_and_more"), + ] + + operations = [ + migrations.RunPython(forward, backward), + ] diff --git a/sigi/apps/espacos/migrations/0007_alter_reserva_data_inicio_alter_reserva_data_termino.py b/sigi/apps/espacos/migrations/0007_alter_reserva_data_inicio_alter_reserva_data_termino.py new file mode 100644 index 0000000..c5b909e --- /dev/null +++ b/sigi/apps/espacos/migrations/0007_alter_reserva_data_inicio_alter_reserva_data_termino.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.7 on 2024-03-12 12:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("espacos", "0006_separa_hora_da_data"), + ] + + operations = [ + migrations.AlterField( + model_name="reserva", + name="data_inicio", + field=models.DateField(verbose_name="data início"), + ), + migrations.AlterField( + model_name="reserva", + name="data_termino", + field=models.DateField(verbose_name="data término"), + ), + ] diff --git a/sigi/apps/espacos/migrations/0008_carga_inicial_reserva_salas.py b/sigi/apps/espacos/migrations/0008_carga_inicial_reserva_salas.py new file mode 100644 index 0000000..e254a51 --- /dev/null +++ b/sigi/apps/espacos/migrations/0008_carga_inicial_reserva_salas.py @@ -0,0 +1,34 @@ +# Generated by Django 4.2.7 on 2024-03-12 19:05 +from datetime import datetime +from django.db import migrations +from django.db import migrations +from sigi.apps.espacos.jobs.hourly.sincroniza_reservas import Job + + +def forward(apps, schema_editor): + start = datetime.now() + Espaco = apps.get_model("espacos", "Espaco") + DEPARA_SALAS = [(5, 62), (4, 66), (3, 63)] + + for espaco_id, id_sala in DEPARA_SALAS: + espaco = Espaco.objects.get(id=espaco_id) + espaco.id_sala = id_sala + espaco.save() + + job = Job() + job.carrega_salas() + job.carrega_recursos() + job.carrega_reservas(ontem="2023-04-01") + job.report(start, datetime.now()) + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "espacos", + "0007_alter_reserva_data_inicio_alter_reserva_data_termino", + ), + ] + + operations = [migrations.RunPython(forward, migrations.RunPython.noop)] diff --git a/sigi/apps/espacos/models.py b/sigi/apps/espacos/models.py index ff35952..0a9f512 100644 --- a/sigi/apps/espacos/models.py +++ b/sigi/apps/espacos/models.py @@ -17,6 +17,22 @@ class Espaco(models.Model): "Indique o prédio/bloco/sala onde este espaço está localizado." ), ) + capacidade = models.PositiveBigIntegerField( + _("capacidade"), + default=0, + help_text=_("Número de acentos ou lotação máxima do espaço"), + ) + reserva_eventos = models.BooleanField( + _("reserva para eventos"), + default=False, + help_text=_("Pode ser reservado para eventos cadastrados no SIGI"), + ) + id_sala = models.PositiveIntegerField( + _("ID da sala"), + blank=True, + null=True, + help_text=_("ID da sala no sistema de reserva de salas do ILB"), + ) class Meta: verbose_name = _("espaço") @@ -31,6 +47,13 @@ class Recurso(models.Model): nome = models.CharField(_("nome"), max_length=100) sigla = models.CharField(_("sigla"), max_length=20) descricao = models.TextField(_("descrição"), blank=True) + id_equipamento = models.PositiveBigIntegerField( + _("ID do equipamento"), + blank=True, + null=True, + unique=True, + help_text=_("ID do equipamento no sistema de reserva de salas do ILB"), + ) class Meta: verbose_name = _("recurso") @@ -44,10 +67,12 @@ class Recurso(models.Model): class Reserva(models.Model): STATUS_ATIVO = "A" STATUS_CANCELADO = "C" + STATUS_CONFLITO = "O" STATUS_CHOICES = ( (STATUS_ATIVO, _("Ativo")), (STATUS_CANCELADO, _("Cancelado")), + (STATUS_CONFLITO, _("Conflito de datas")), ) status = models.CharField( @@ -72,8 +97,10 @@ class Reserva(models.Model): _("total de participantes"), default=0 ) data_pedido = models.DateField(_("data do pedido"), blank=True, null=True) - inicio = models.DateTimeField(_("data/hora de início")) - termino = models.DateTimeField(_("data/hora de término")) + data_inicio = models.DateField(_("data início")) + data_termino = models.DateField(_("data término")) + hora_inicio = models.TimeField(_("hora início")) + hora_termino = models.TimeField(_("hora término")) num_processo = models.CharField( _("número do processo SIGAD"), max_length=20, @@ -113,50 +140,72 @@ class Reserva(models.Model): "Indique o telefone/ramal da pessoa responsável pela reserva." ), ) + id_reserva = models.PositiveBigIntegerField( + _("ID da reserva"), blank=True, null=True, editable=False, unique=True + ) + data_ult_atualizacao = models.DateTimeField( + _("data da última atualização"), blank=True, null=True, editable=False + ) class Meta: verbose_name = _("reserva") verbose_name_plural = _("reservas") - ordering = ("inicio", "espaco", "proposito") + ordering = ("data_inicio", "hora_inicio", "espaco", "proposito") def __str__(self): return _(f"{self.proposito} em {self.espaco.nome}") def clean(self): - if self.inicio > self.termino: + if self.data_inicio > self.data_termino: raise ValidationError( _("Data de início deve ser anterior à data de término") ) - reservas_conflitantes = Reserva.objects.exclude(id=self.pk).filter( - espaco=self.espaco, - inicio__lte=self.termino, - termino__gte=self.inicio, - status=Reserva.STATUS_ATIVO, - ) - - if reservas_conflitantes.exists(): - link_list = ", ".join( - [ - f"" - f"{ reserva }" - for reserva in reservas_conflitantes - ] - ) + if self.hora_inicio > self.hora_termino: raise ValidationError( - mark_safe( - _( - "Existe(m) reserva(s) que conflita(m) com essas datas: " - f"{ link_list }" + _("Hora de início deve ser anterior à hora de término") + ) + reservas_conflitantes = self.get_conflitantes() + if reservas_conflitantes.exists(): + if self.status == Reserva.STATUS_CONFLITO: + # Marco as conflitantes com status CONFLITO e deixo seguir + reservas_conflitantes.update(status=Reserva.STATUS_CONFLITO) + elif self.status == Reserva.STATUS_ATIVO: + # Não pode salvar assim. Lança exceção + link_list = ", ".join( + [ + f"" + f"{ reserva }" + for reserva in reservas_conflitantes + ] + ) + raise ValidationError( + mark_safe( + _( + "Existe(m) reserva(s) que conflita(m) " + f"com essas datas: { link_list }" + ) ) ) - ) return super().clean() def save(self, *args, **kwargs): self.clean() return super().save(*args, **kwargs) + def get_conflitantes(self): + return ( + Reserva.objects.exclude(id=self.pk) + .exclude(status=Reserva.STATUS_CANCELADO) + .filter( + espaco=self.espaco, + data_inicio__lte=self.data_termino, + data_termino__gte=self.data_inicio, + hora_inicio__lte=self.hora_termino, + hora_termino__gte=self.hora_inicio, + ) + ) + def get_sigad_url(self): m = re.match( "(?P00100|00200)\.(?P\d{6})/(?P" diff --git a/sigi/apps/espacos/templates/admin/espacos/reserva/change_form.html b/sigi/apps/espacos/templates/admin/espacos/reserva/change_form.html new file mode 100644 index 0000000..3004003 --- /dev/null +++ b/sigi/apps/espacos/templates/admin/espacos/reserva/change_form.html @@ -0,0 +1,52 @@ +{% extends 'admin/change_form.html' %} +{% load i18n admin_urls %} + + +{% block extrastyle %} +{{ block.super }} + +{% endblock extrastyle %} + +{% block after_related_objects %} + {% if original.get_conflitantes.exists %} +
+

{% translate "Reservas conflitantes" %}

+ + + + + + + + + + + + + + + {% for conf in original.get_conflitantes %} + + + + + + + + + + + {% endfor %} +
 {% translate "Status" %}{% translate "Propósito" %}{% translate "Data/hora início" %}{% translate "Data/hora término" %}{% translate "Senador/autoridade solicitante" %}{% translate "Pessoa de contato" %}{% translate "Telefone de contato" %}
+ + + + {{ conf.get_status_display }}{{ conf.proposito }}{{ conf.data_inicio|date:"SHORT_DATE_FORMAT" }} {{ conf.hora_inicio|time }}{{ conf.data_termino|date:"SHORT_DATE_FORMAT" }} {{ conf.hora_termino|time }}{{ conf.solicitante }}{{ conf.contato }}{{ conf.telefone_contato }}
+
+ {% endif %} +{% endblock %} \ No newline at end of file diff --git a/sigi/apps/espacos/templates/espacos/agenda.html b/sigi/apps/espacos/templates/espacos/agenda.html index 2fdf9fa..8b87e56 100644 --- a/sigi/apps/espacos/templates/espacos/agenda.html +++ b/sigi/apps/espacos/templates/espacos/agenda.html @@ -7,9 +7,16 @@ tr.linha-evento { border-bottom: 1px solid var(--hairline-color); } + tr.linha-evento.last { + border-bottom: 1px solid var(--main-bg-color); + } tr td { border-left: 1px solid var(--hairline-color); } + .col-horario { + width: 5em; + text-aign: right; + } {% endblock %} diff --git a/sigi/apps/espacos/templates/espacos/agenda_pdf.html b/sigi/apps/espacos/templates/espacos/agenda_pdf.html index 6325afd..c054f3b 100644 --- a/sigi/apps/espacos/templates/espacos/agenda_pdf.html +++ b/sigi/apps/espacos/templates/espacos/agenda_pdf.html @@ -39,6 +39,9 @@ tr.linha-evento { border-bottom: 1px solid #d2d2d2; } + tr.linha-evento.last { + border-bottom: 1px solid #007433; + } span.numero-dia { font-size: 1em; } diff --git a/sigi/apps/espacos/templates/espacos/snippets/agenda_cal.html b/sigi/apps/espacos/templates/espacos/snippets/agenda_cal.html index 3872910..cd7a81c 100644 --- a/sigi/apps/espacos/templates/espacos/snippets/agenda_cal.html +++ b/sigi/apps/espacos/templates/espacos/snippets/agenda_cal.html @@ -8,9 +8,14 @@ {% endblocktranslate %} + + + + + {% for name in day_names %} {% endfor %} @@ -22,33 +27,40 @@ - {% for espaco, reservas in semana.reservas.items %} - - - {% for reserva, tupla in reservas %} - {% for x in ""|ljust:tupla.0|make_list %}{% endfor %} - - {% if forloop.last %} - {% for x in ""|ljust:tupla.2|make_list %}{% endfor %} - {% endif %} - {% empty %} - {% for x in "1234567"|make_list %}{% endfor %} + {% for espaco, linhas in semana.items %} + {% if espaco != "datas" %} + {% for linha in linhas %} + + {% if forloop.first %} + + {% endif %} + + {% for coluna in linha.colunas %} + {% if "reserva" in coluna %} + + {% else %} + {% if not coluna is None %} + + {% endif %} + {% endif %} + {% endfor %} + {% endfor %} - + {% endif %} {% endfor %}
{% trans "Espaço" %}{{ name }}
{{ espaco.sigla }} -

- - - {{ reserva.proposito }} - - -

-

{{ reserva.inicio|interval:reserva.termino }}

-

- {% blocktranslate with solicitante=reserva.solicitante %} - solicitado por {{ solicitante }} - {% endblocktranslate %} -

-
{{ espaco.sigla }}{{ linha.hora }} +

+ + + {{ coluna.reserva.proposito }} + + +

+

{{ coluna.reserva.data_inicio|interval:coluna.reserva.data_termino }}

+

+ {% blocktranslate with solicitante=coluna.reserva.solicitante %} + solicitado por {{ solicitante }} + {% endblocktranslate %} +

+
diff --git a/sigi/apps/espacos/views.py b/sigi/apps/espacos/views.py index 50a39e2..56461b2 100644 --- a/sigi/apps/espacos/views.py +++ b/sigi/apps/espacos/views.py @@ -37,9 +37,11 @@ class Agenda(ReportViewMixin, StaffMemberRequiredMixin, TemplateView): locale.setlocale(locale.LC_ALL, lang) for ano, mes in ( - Reserva.objects.values_list("inicio__year", "inicio__month") - .order_by("inicio__year", "inicio__month") - .distinct("inicio__year", "inicio__month") + Reserva.objects.values_list( + "data_inicio__year", "data_inicio__month" + ) + .order_by("data_inicio__year", "data_inicio__month") + .distinct("data_inicio__year", "data_inicio__month") ): if ano in meses: meses[ano][mes] = calendar.month_name[mes] @@ -49,50 +51,75 @@ class Agenda(ReportViewMixin, StaffMemberRequiredMixin, TemplateView): espacos = list(Espaco.objects.all()) semanas = [ - {"datas": s, "reservas": {espaco: [] for espaco in espacos}} + {"datas": s} for s in calendar.Calendar().monthdatescalendar( ano_pesquisa, mes_pesquisa ) ] - primeiro_dia = timezone.make_aware( - timezone.datetime(*semanas[0]["datas"][0].timetuple()[:6]) - ) - ultimo_dia = timezone.make_aware( - timezone.datetime(*semanas[-1]["datas"][-1].timetuple()[:6]) - ) + primeiro_dia = semanas[0]["datas"][0] + ultimo_dia = semanas[-1]["datas"][-1] + shift = primeiro_dia.isocalendar().week - for reserva in Reserva.objects.exclude( - status=Reserva.STATUS_CANCELADO + for reserva in Reserva.objects.filter( + status=Reserva.STATUS_ATIVO ).filter( - Q(inicio__range=[primeiro_dia, ultimo_dia]) - | Q(termino__range=[primeiro_dia, ultimo_dia]) + Q(data_inicio__range=[primeiro_dia, ultimo_dia]) + | Q(data_termino__range=[primeiro_dia, ultimo_dia]) ): - for semana in semanas: - if not ( - (reserva.termino.date() < semana["datas"][0]) - or (reserva.inicio.date() > semana["datas"][-1]) - ): - start = max(semana["datas"][0], reserva.inicio.date()) - end = min(semana["datas"][-1], reserva.termino.date()) - semana["reservas"][reserva.espaco].append( - [ - reserva, - [ - start.weekday(), - end.weekday() - start.weekday() + 1, - 6 - end.weekday(), - ], - ] - ) + for ix in range( + reserva.data_inicio.isocalendar().week - shift, + reserva.data_termino.isocalendar().week - shift + 1, + ): + if ix < 0 or ix > len(semanas): + continue + semana = semanas[ix] + start = max(semana["datas"][0], reserva.data_inicio).weekday() + end = min(semana["datas"][-1], reserva.data_termino).weekday() + if reserva.espaco not in semana: + semana[reserva.espaco] = [] + semana[reserva.espaco].append( + { + "reserva": reserva, + "col_start": start, + "col_end": end, + } + ) for semana in semanas: - for espaco, reservas in semana["reservas"].items(): - last_pos = 0 + for espaco, reservas in semana.items(): + if not isinstance(espaco, Espaco): + continue + horas = sorted( + { + h + for hh in [ + ( + r["reserva"].hora_inicio, + r["reserva"].hora_termino, + ) + for r in reservas + ] + for h in hh + } + ) + semana[espaco] = [ + {"hora": h, "colunas": [False] * 7} for h in horas + ] for reserva in reservas: - if last_pos > 0: - reserva[1][0] -= last_pos - last_pos += reserva[1][0] + reserva[1][1] + row_start = horas.index(reserva["reserva"].hora_inicio) + row_end = horas.index(reserva["reserva"].hora_termino) + col_start = reserva["col_start"] + col_end = reserva["col_end"] + semana[espaco][row_start]["colunas"][col_start] = { + "reserva": reserva["reserva"], + "colspan": col_end - col_start + 1, + "rowspan": row_end - row_start + 1, + } + for rx in range(row_start, row_end + 1): + for cx in range(col_start, col_end + 1): + if rx != row_start or cx != col_start: + semana[espaco][rx]["colunas"][cx] = None context = super().get_context_data(**kwargs) context["mes_pesquisa"] = mes_pesquisa @@ -153,7 +180,9 @@ class UsoEspacos(ReportViewMixin, StaffMemberRequiredMixin, TemplateView): if agrupar_espacos: espacos = ( - sel_espacos.filter(q_virtual, reserva__status=Reserva.STATUS_ATIVO) + sel_espacos.filter( + q_virtual, reserva__status=Reserva.STATUS_ATIVO + ) .filter( Q(reserva__inicio__range=(data_inicio, data_fim)) | Q(reserva__termino__range=(data_inicio, data_fim)) diff --git a/sigi/apps/eventos/admin.py b/sigi/apps/eventos/admin.py index 8f43fbf..f2bd275 100644 --- a/sigi/apps/eventos/admin.py +++ b/sigi/apps/eventos/admin.py @@ -884,8 +884,8 @@ class EventoAdmin(AsciifyQParameter, CartExportReportMixin, admin.ModelAdmin): "num_processo", "data_pedido", "data_recebido_coperi", - "data_inicio", - "data_termino", + ("data_inicio", "hora_inicio"), + ("data_termino", "hora_termino"), "carga_horaria", "casa_anfitria", "espaco", @@ -939,6 +939,8 @@ class EventoAdmin(AsciifyQParameter, CartExportReportMixin, admin.ModelAdmin): "get_link_sigad", "data_inicio", "data_termino", + "hora_inicio", + "hora_termino", "get_municipio", "get_uf", "get_regiao", @@ -1161,8 +1163,8 @@ class EventoAdmin(AsciifyQParameter, CartExportReportMixin, admin.ModelAdmin): self.message_user( request, _( - f"Reserva do espaço '{obj.reserva.espaco}' criada para " - "este evento.", + f"Reserva do espaço '{obj.reserva.espaco}' criada para" + " este evento.", ), level=messages.SUCCESS, ) @@ -1759,8 +1761,8 @@ class EventoAdmin(AsciifyQParameter, CartExportReportMixin, admin.ModelAdmin): self.message_user( request, _( - "O evento precisa ter datas de início e término para criar " - "curso no Saberes." + "O evento precisa ter datas de início e término para criar" + " curso no Saberes." ), level=messages.ERROR, ) @@ -1781,8 +1783,14 @@ class EventoAdmin(AsciifyQParameter, CartExportReportMixin, admin.ModelAdmin): mws = Moodle(api_url, settings.MOODLE_API_TOKEN) fullname = f"{evento.tipo_evento.nome} - {evento.casa_anfitria.municipio.nome}/{evento.casa_anfitria.municipio.uf.sigla} - {evento.tipo_evento.prefixo_turma}{evento.turma}" shortname = f"{abreviatura(evento.tipo_evento.nome)} - {evento.tipo_evento.prefixo_turma}{evento.turma}" - inicio = int(time.mktime(evento.data_inicio.astimezone().timetuple())) - fim = int(time.mktime(evento.data_termino.astimezone().timetuple())) + dt_inicio = datetime.datetime.combine( + evento.data_inicio, evento.hora_inicio + ).replace(tzinfo=timezone.get_current_timezone()) + dt_termino = datetime.datetime.combine( + evento.data_termino, evento.hora_termino + ).replace(tzinfo=timezone.get_current_timezone()) + inicio = int(time.mktime(dt_inicio.astimezone().timetuple())) + fim = int(time.mktime(dt_termino.astimezone().timetuple())) erros = [] try: # Criar novo curso a partir do template novo_curso = mws.core.course.duplicate_course( diff --git a/sigi/apps/eventos/forms.py b/sigi/apps/eventos/forms.py index 3047b4e..c0f22ab 100644 --- a/sigi/apps/eventos/forms.py +++ b/sigi/apps/eventos/forms.py @@ -16,7 +16,7 @@ class EventoAdminForm(forms.ModelForm): espaco = forms.ModelChoiceField( label=_("Reservar espaço"), required=False, - queryset=Espaco.objects.all(), + queryset=Espaco.objects.filter(reserva_eventos=True), ) class Meta: @@ -33,6 +33,8 @@ class EventoAdminForm(forms.ModelForm): "data_recebido_coperi", "data_inicio", "data_termino", + "hora_inicio", + "hora_termino", "carga_horaria", "casa_anfitria", "espaco", @@ -64,6 +66,8 @@ class EventoAdminForm(forms.ModelForm): cleaned_data = super().clean() data_inicio = cleaned_data.get("data_inicio") data_termino = cleaned_data.get("data_termino") + hora_inicio = cleaned_data.get("hora_inicio") + hora_termino = cleaned_data.get("hora_termino") publicar = cleaned_data.get("publicar") if data_inicio and data_termino and data_inicio > data_termino: @@ -72,6 +76,12 @@ class EventoAdminForm(forms.ModelForm): code="invalid_period", ) + if hora_inicio and hora_termino and hora_inicio > hora_termino: + raise forms.ValidationError( + _("Hora término deve ser posterior à hora inicio"), + code="invalid_period", + ) + if publicar and (data_inicio is None or data_termino is None): raise forms.ValidationError( _( diff --git a/sigi/apps/eventos/migrations/0058_evento_hora_inicio_evento_hora_termino.py b/sigi/apps/eventos/migrations/0058_evento_hora_inicio_evento_hora_termino.py new file mode 100644 index 0000000..d9a1a25 --- /dev/null +++ b/sigi/apps/eventos/migrations/0058_evento_hora_inicio_evento_hora_termino.py @@ -0,0 +1,30 @@ +# Generated by Django 4.2.7 on 2024-03-12 13:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "eventos", + "0057_alter_equipe_qtde_diarias_alter_equipe_valor_diaria", + ), + ] + + operations = [ + migrations.AddField( + model_name="evento", + name="hora_inicio", + field=models.TimeField( + blank=True, null=True, verbose_name="hora início" + ), + ), + migrations.AddField( + model_name="evento", + name="hora_termino", + field=models.TimeField( + blank=True, null=True, verbose_name="hora término" + ), + ), + ] diff --git a/sigi/apps/eventos/migrations/0059_separa_hora_da_data.py b/sigi/apps/eventos/migrations/0059_separa_hora_da_data.py new file mode 100644 index 0000000..20dbf2e --- /dev/null +++ b/sigi/apps/eventos/migrations/0059_separa_hora_da_data.py @@ -0,0 +1,46 @@ +# Generated by Django 4.2.7 on 2024-03-12 14:15 + +from datetime import datetime +from django.db import migrations +from django.utils import timezone + + +def forward(apps, schema_editor): + Evento = apps.get_model("eventos", "Evento") + for evento in Evento.objects.all(): + if evento.data_inicio is not None: + evento.hora_inicio = timezone.localtime(evento.data_inicio).time() + if evento.data_termino is not None: + evento.hora_termino = timezone.localtime( + evento.data_termino + ).time() + evento.save() + + +def backward(apps, schema_editor): + Evento = apps.get_model("eventos", "Evento") + for evento in Evento.objects.all(): + if evento.data_inicio is not None and evento.hora_inicio is not None: + evento.data_inicio = datetime.combine( + evento.data_inicio, evento.hora_inicio + ).replace(tzinfo=timezone.get_current_timezone()) + elif evento.data_inicio is not None: + evento.data_inicio.replace(tzinfo=timezone.get_current_timezone()) + if evento.data_termino is not None and evento.hora_termino is not None: + evento.data_termino = datetime.combine( + evento.data_termino, evento.hora_termino + ).replace(tzinfo=timezone.get_current_timezone()) + elif evento.data_termino is not None: + evento.data_termino.replace(tzinfo=timezone.get_current_timezone()) + evento.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("eventos", "0058_evento_hora_inicio_evento_hora_termino"), + ] + + operations = [ + migrations.RunPython(forward, backward), + ] diff --git a/sigi/apps/eventos/migrations/0060_drop_viw_eventos.py b/sigi/apps/eventos/migrations/0060_drop_viw_eventos.py new file mode 100644 index 0000000..cb11ab3 --- /dev/null +++ b/sigi/apps/eventos/migrations/0060_drop_viw_eventos.py @@ -0,0 +1,48 @@ +# Generated by Django 4.2.7 on 2024-03-12 14:27 + +from django.db import migrations + +SQL_STMT = "DROP VIEW viw_eventos;" +SQL_REVERSE_STMT = """ +create view viw_eventos as +select e.id, e.nome, e.descricao, e.solicitante, e.data_inicio, e.data_termino, + e.local, e.publico_alvo, + (case + when e.status = 'P' then 'Previsto' + when e.status = 'O' then 'Autorizado' + when e.status = 'R' then 'Realizado' + when e.status = 'C' then 'Cancelado' + when e.status = 'Q' then 'Sobrestado' + else e.status -- Fallback retorna a sigla do status + end) as status, + e.data_cancelamento, e.motivo_cancelamento, + e.casa_anfitria_id, o.nome as casa_anfitria, + o.municipio_id, m.nome as municipio, uf.sigla as uf_sigla, + uf.nome as uf_nome, t.nome as tipo_evento, + (case + when t.categoria='C' then 'Curso' + when t.categoria='E' then 'Encontro' + when t.categoria='O' then 'Oficina' + when t.categoria='S' then 'Seminário' + when t.categoria='V' then 'Visita' + end) as categoria, + e.virtual, e.total_participantes, + e.data_pedido, e.num_processo, e.observacao, e.turma +from eventos_evento e + inner join casas_orgao o on o.id = e.casa_anfitria_id + inner join contatos_municipio m on m.codigo_ibge = o.municipio_id + inner join contatos_unidadefederativa uf on uf.codigo_ibge = m.uf_id + inner join eventos_tipoevento t on t.id = e.tipo_evento_id; +grant select on viw_eventos to sigi_qs; +""" + + +class Migration(migrations.Migration): + + dependencies = [ + ("eventos", "0059_separa_hora_da_data"), + ] + + operations = [ + migrations.RunSQL(sql=SQL_STMT, reverse_sql=SQL_REVERSE_STMT) + ] diff --git a/sigi/apps/eventos/migrations/0061_alter_evento_data_inicio_alter_evento_data_termino.py b/sigi/apps/eventos/migrations/0061_alter_evento_data_inicio_alter_evento_data_termino.py new file mode 100644 index 0000000..7c35c43 --- /dev/null +++ b/sigi/apps/eventos/migrations/0061_alter_evento_data_inicio_alter_evento_data_termino.py @@ -0,0 +1,27 @@ +# Generated by Django 4.2.7 on 2024-03-12 14:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("eventos", "0060_drop_viw_eventos"), + ] + + operations = [ + migrations.AlterField( + model_name="evento", + name="data_inicio", + field=models.DateField( + blank=True, null=True, verbose_name="data início" + ), + ), + migrations.AlterField( + model_name="evento", + name="data_termino", + field=models.DateField( + blank=True, null=True, verbose_name="data término" + ), + ), + ] diff --git a/sigi/apps/eventos/migrations/0062_create_viw_eventos.py b/sigi/apps/eventos/migrations/0062_create_viw_eventos.py new file mode 100644 index 0000000..3dfbbf4 --- /dev/null +++ b/sigi/apps/eventos/migrations/0062_create_viw_eventos.py @@ -0,0 +1,48 @@ +# Generated by Django 4.2.7 on 2024-03-12 14:50 + +from django.db import migrations + +SQL_STMT = """ +create view viw_eventos as +select e.id, e.nome, e.descricao, e.solicitante, (e.data_inicio + e.hora_inicio) as data_inicio, + (e.data_termino + e.hora_termino) as data_termino, e.local, e.publico_alvo, + (case + when e.status = 'P' then 'Previsto' + when e.status = 'O' then 'Autorizado' + when e.status = 'R' then 'Realizado' + when e.status = 'C' then 'Cancelado' + when e.status = 'Q' then 'Sobrestado' + else e.status -- Fallback retorna a sigla do status + end) as status, + e.data_cancelamento, e.motivo_cancelamento, + e.casa_anfitria_id, o.nome as casa_anfitria, + o.municipio_id, m.nome as municipio, uf.sigla as uf_sigla, + uf.nome as uf_nome, t.nome as tipo_evento, + (case + when t.categoria='C' then 'Curso' + when t.categoria='E' then 'Encontro' + when t.categoria='O' then 'Oficina' + when t.categoria='S' then 'Seminário' + when t.categoria='V' then 'Visita' + end) as categoria, + e.virtual, e.total_participantes, + e.data_pedido, e.num_processo, e.observacao, e.turma +from eventos_evento e + inner join casas_orgao o on o.id = e.casa_anfitria_id + inner join contatos_municipio m on m.codigo_ibge = o.municipio_id + inner join contatos_unidadefederativa uf on uf.codigo_ibge = m.uf_id + inner join eventos_tipoevento t on t.id = e.tipo_evento_id; +grant select on viw_eventos to sigi_qs; +""" +SQL_REVERSE_STMT = "DROP VIEW viw_eventos;" + + +class Migration(migrations.Migration): + + dependencies = [ + ("eventos", "0061_alter_evento_data_inicio_alter_evento_data_termino"), + ] + + 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 3cb1cfa..bdd0252 100644 --- a/sigi/apps/eventos/models.py +++ b/sigi/apps/eventos/models.py @@ -361,12 +361,10 @@ class Evento(models.Model): verbose_name=_("Solicitação de origem"), on_delete=models.SET_NULL, ) - data_inicio = models.DateTimeField( - _("Data/hora do Início"), null=True, blank=True - ) - data_termino = models.DateTimeField( - _("Data/hora do Termino"), null=True, blank=True - ) + data_inicio = models.DateField(_("data início"), null=True, blank=True) + data_termino = models.DateField(_("data término"), null=True, blank=True) + hora_inicio = models.TimeField(_("hora início"), null=True, blank=True) + hora_termino = models.TimeField(_("hora término"), null=True, blank=True) carga_horaria = models.PositiveIntegerField(_("carga horária"), default=0) casa_anfitria = models.ForeignKey( Orgao, @@ -624,6 +622,14 @@ class Evento(models.Model): raise ValidationError( _("Data de término deve ser posterior à data de início") ) + if ( + self.hora_inicio + and self.hora_termino + and self.hora_inicio > self.hora_termino + ): + raise ValidationError( + _("Hora de término deve ser posterior à hora de início") + ) if self.reserva: self.update_reserva() self.reserva.clean() @@ -635,8 +641,10 @@ class Evento(models.Model): self.reserva.proposito = self.nome self.reserva.virtual = self.virtual self.reserva.data_pedido = self.data_pedido - self.reserva.inicio = self.data_inicio - self.reserva.termino = self.data_termino + self.reserva.data_inicio = self.data_inicio + self.reserva.data_termino = self.data_termino + self.reserva.hora_inicio = self.hora_inicio + self.reserva.hora_termino = self.hora_termino self.reserva.num_processo = self.num_processo self.reserva.informacoes = self.observacao self.reserva.solicitante = self.solicitante @@ -778,7 +786,7 @@ class Evento(models.Model): == 0 ] for item in leafs: - ajusta_data(item, self.data_termino.date()) + ajusta_data(item, self.data_termino) class Funcao(models.Model): diff --git a/sigi/apps/eventos/serializers.py b/sigi/apps/eventos/serializers.py index 822598a..b62148d 100644 --- a/sigi/apps/eventos/serializers.py +++ b/sigi/apps/eventos/serializers.py @@ -1,6 +1,8 @@ import base64 import magic +from datetime import datetime from rest_framework import serializers +from django.utils import timezone from sigi.apps.eventos.models import Evento @@ -12,6 +14,8 @@ class EventoSerializer(serializers.ModelSerializer): casa_uf = serializers.SerializerMethodField("get_casa_uf") casa_cep = serializers.SerializerMethodField("get_casa_cep") banner_base64 = serializers.SerializerMethodField("get_banner_base64") + data_inicio = serializers.SerializerMethodField("get_data_inicio") + data_termino = serializers.SerializerMethodField("get_data_termino") class Meta: model = Evento @@ -78,6 +82,18 @@ class EventoSerializer(serializers.ModelSerializer): return f"data:{mime_type};base64, {b64str}" return None + def get_data_inicio(self, obj): + if obj.data_inicio and obj.hora_inicio: + return datetime.combine(obj.data_inicio, obj.hora_inicio) + else: + return obj.data_inicio + + def get_data_termino(self, obj): + if obj.data_termino and obj.hora_termino: + return datetime.combine(obj.data_termino, obj.hora_termino) + else: + return obj.data_termino + class EventoListSerializer(EventoSerializer): class Meta: diff --git a/sigi/apps/eventos/views.py b/sigi/apps/eventos/views.py index 05c35a8..c43e733 100644 --- a/sigi/apps/eventos/views.py +++ b/sigi/apps/eventos/views.py @@ -127,11 +127,11 @@ def calendario(request): for e in eventos: for s in semanas: if not ( - (e.data_termino.date() < s["datas"][0]) - or (e.data_inicio.date() > s["datas"][-1]) + (e.data_termino < s["datas"][0]) + or (e.data_inicio > s["datas"][-1]) ): - start = max(s["datas"][0], e.data_inicio.date()) - end = min(s["datas"][-1], e.data_termino.date()) + start = max(s["datas"][0], e.data_inicio) + end = min(s["datas"][-1], e.data_termino) s["eventos"].append( ( e, @@ -246,24 +246,20 @@ def alocacao_equipe(request): if mes_pesquisa > 0: if semana_pesquisa > 0: for dia in dias: - if ( - evento.data_inicio.date() - <= dia - <= evento.data_termino.date() - ): + if evento.data_inicio <= dia <= evento.data_termino: registro[2][dia].append(evento) else: for idx, [inicio, fim] in enumerate(semanas): - if inicio <= evento.data_inicio.date() <= fim: + if inicio <= evento.data_inicio <= fim: registro[2][idx]["dias"] += ( - min(fim, evento.data_termino.date()) - - evento.data_inicio.date() + min(fim, evento.data_termino) + - evento.data_inicio ).days + 1 registro[2][idx]["eventos"] += 1 - elif inicio <= evento.data_termino.date() <= fim: + elif inicio <= evento.data_termino <= fim: registro[2][idx]["dias"] += ( - min(fim, evento.data_termino.date()) - - evento.data_inicio.date() + min(fim, evento.data_termino) + - evento.data_inicio ).days + 1 registro[2][idx]["eventos"] += 1 else: @@ -295,18 +291,22 @@ def alocacao_equipe(request): linhas.append( [r[1]] + [ - _( - ngettext("%(dias)s dia", "%(dias)s dias", d["dias"]) - + " em " - + ngettext( - "%(eventos)s evento", - "%(eventos)s eventos", - d["eventos"], + ( + _( + ngettext( + "%(dias)s dia", "%(dias)s dias", d["dias"] + ) + + " em " + + ngettext( + "%(eventos)s evento", + "%(eventos)s eventos", + d["eventos"], + ) ) + % d + if d["dias"] > 0 or d["eventos"] > 0 + else "" ) - % d - if d["dias"] > 0 or d["eventos"] > 0 - else "" for d in r[2] ] ) @@ -353,9 +353,9 @@ def alocacao_equipe(request): ) elif formato == "csv": response = HttpResponse(content_type="text/csv") - response[ - "Content-Disposition" - ] = 'attachment; filename="alocacao_equipe_%s.csv"' % (ano_pesquisa,) + response["Content-Disposition"] = ( + 'attachment; filename="alocacao_equipe_%s.csv"' % (ano_pesquisa,) + ) writer = csv.writer(response) writer.writerow(cabecalho) writer.writerows(linhas) diff --git a/sigi/settings.py b/sigi/settings.py index 9d52743..2cc8827 100644 --- a/sigi/settings.py +++ b/sigi/settings.py @@ -296,3 +296,9 @@ 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 ) + +# Integração com reserva de salas + +RESERVA_SALA_BASE_URL = env("RESERVA_SALA_BASE_URL", default=None) +RESERVA_SALA_API_USER = env("RESERVA_SALA_API_USER", default=None) +RESERVA_SALA_API_PASSWORD = env("RESERVA_SALA_API_PASSWORD", default=None)