diff --git a/diarios/__init__.py b/diarios/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/diarios/admin.py b/diarios/admin.py new file mode 100644 index 0000000..e819428 --- /dev/null +++ b/diarios/admin.py @@ -0,0 +1,150 @@ +from django.contrib import admin +from django.utils.html import format_html +from django.urls import reverse +from django.utils.safestring import mark_safe +from .models import TipoDiarioOficial, DiarioOficial, PageDiarioOficial +from .forms import PageDiarioOficialInlineForm + + +@admin.register(TipoDiarioOficial) +class TipoDiarioOficialAdmin(admin.ModelAdmin): + list_display = ("nome", "quantidade_diarios") + search_fields = ("nome",) + ordering = ("nome",) + + def quantidade_diarios(self, obj): + return obj.diarios.count() + + quantidade_diarios.short_description = "Nº de Diários" + + +class PageDiarioOficialInline(admin.TabularInline): + form = PageDiarioOficialInlineForm + model = PageDiarioOficial + extra = 0 + fields = ("id", "numero", "conteudo") + can_delete = False + + def numero_link(self, instance): + if instance.id: + url = reverse("admin:diarios_pagediariooficial_change", args=[instance.id]) + return mark_safe(f'{instance.numero}') + return instance.numero + + def conteudo_resumido(self, instance): + return ( + (instance.conteudo[:100] + "...") + if len(instance.conteudo) > 100 + else instance.conteudo + ) + + conteudo_resumido.short_description = "Conteúdo (resumo)" + + +@admin.register(DiarioOficial) +class DiarioOficialAdmin(admin.ModelAdmin): + list_display = ( + "numero", + "tipo_nome", + "data_formatada_admin", + "arquivo_link", + "link_externo", + "paginas_count", + ) + list_filter = ("tipo", "data") + search_fields = ("numero", "tipo__nome", "data") + date_hierarchy = "data" + ordering = ("-data", "-numero") + readonly_fields = ( + "data_formatada_admin", + "arquivo_preview", + "paginas_count", + "link_externo", + ) + fieldsets = ( + ( + "Informações Básicas", + {"fields": ("numero", "tipo", "data", "data_formatada_admin")}, + ), + ( + "Arquivos e Links", + {"fields": ("arquivo", "arquivo_preview", "link", "link_externo")}, + ), + ("Estatísticas", {"fields": ("paginas_count",), "classes": ("collapse",)}), + ) + # inlines = (PageDiarioOficialInline,) + + def tipo_nome(self, obj): + return obj.tipo.nome if obj.tipo else "-" + + tipo_nome.short_description = "Tipo" + tipo_nome.admin_order_field = "tipo__nome" + + def data_formatada_admin(self, obj): + return obj.data_formatada + + data_formatada_admin.short_description = "Data" + + def arquivo_link(self, obj): + if obj.arquivo: + return mark_safe( + f'Download PDF' + ) + return "-" + + arquivo_link.short_description = "Arquivo" + arquivo_link.allow_tags = True + + def link_externo(self, obj): + if obj.link: + return mark_safe(f'Acessar Online') + return "-" + + link_externo.short_description = "Link Externo" + link_externo.allow_tags = True + + def arquivo_preview(self, obj): + if obj.arquivo: + return mark_safe( + f'Visualizar PDF' + ) + return "-" + + arquivo_preview.short_description = "Pré-visualização" + arquivo_preview.allow_tags = True + + def paginas_count(self, obj): + return obj.paginas.count() + + paginas_count.short_description = "Nº de Páginas" + + def get_queryset(self, request): + return super().get_queryset(request).prefetch_related("paginas") + + +@admin.register(PageDiarioOficial) +class PageDiarioOficialAdmin(admin.ModelAdmin): + autocomplete_fields = ("diario",) + list_display = ("id", "diario_link", "numero", "conteudo_resumido") + list_display_links = ('id', 'numero') + list_filter = ("diario__tipo", "diario__data", "layout_duas_colunas") + search_fields = ("diario__numero",) + readonly_fields = ( + "diario_link", + ) + + + def diario_link(self, obj): + url = reverse("admin:diarios_diariooficial_change", args=[obj.diario.id]) + return mark_safe(f'{obj.diario}') + + diario_link.short_description = "Diário Oficial" + diario_link.allow_tags = True + + def conteudo_resumido(self, obj): + return (obj.conteudo[:100] + "...") if len(obj.conteudo) > 100 else obj.conteudo + + conteudo_resumido.short_description = "Conteúdo" + + def get_queryset(self, request): + return super().get_queryset(request).select_related("diario") diff --git a/diarios/api.py b/diarios/api.py new file mode 100644 index 0000000..135c01e --- /dev/null +++ b/diarios/api.py @@ -0,0 +1,5 @@ +from ninja import NinjaAPI + + +api = NinjaAPI() +api.add_router("/diarios/", route) diff --git a/diarios/apps.py b/diarios/apps.py new file mode 100644 index 0000000..8a7f41e --- /dev/null +++ b/diarios/apps.py @@ -0,0 +1,10 @@ +from django.apps import AppConfig + + +class DiariosConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "diarios" + + def ready(self): + import diarios.documents + diff --git a/diarios/crud_service.py b/diarios/crud_service.py new file mode 100644 index 0000000..9b68e8c --- /dev/null +++ b/diarios/crud_service.py @@ -0,0 +1,179 @@ +from typing import Optional, List +from datetime import date +from diarios.models import DiarioOficial, TipoDiarioOficial +from django.core.exceptions import ObjectDoesNotExist +from ninja.errors import HttpError + + +class DiarioOficialService: + @staticmethod + def criar_diario( + data: date, + numero: str, + tipo_id: Optional[int] = None, + arquivo=None, + link: Optional[str] = None + ) -> DiarioOficial: + """ + Cria um novo diário oficial + + Args: + data: Data do diário + numero: Número do diário + tipo_id: ID do tipo de diário + arquivo: Arquivo PDF (opcional) + link: URL do PDF (opcional) + + Returns: + DiarioOficial: O diário criado + + Raises: + HttpError: Se ocorrer algum erro na criação + """ + try: + tipo = TipoDiarioOficial.objects.get(pk=tipo_id) if tipo_id else None + + diario = DiarioOficial( + data=data, + numero=numero, + tipo=tipo, + link=link + ) + + if arquivo: + diario.arquivo = arquivo + + diario.full_clean() + diario.save() + return diario + + except ObjectDoesNotExist: + raise HttpError(404, "Tipo de diário não encontrado") + except Exception as e: + raise HttpError(400, f"Erro ao criar diário: {str(e)}") + + @staticmethod + def obter_diario_por_id(id: int) -> DiarioOficial: + """ + Obtém um diário pelo ID + + Args: + id: ID do diário + + Returns: + DiarioOficial: O diário encontrado + + Raises: + HttpError: Se o diário não for encontrado + """ + try: + return DiarioOficial.objects.get(pk=id) + except ObjectDoesNotExist: + raise HttpError(404, "Diário não encontrado") + + @staticmethod + def listar_diarios( + tipo_id: Optional[int] = None, + data_inicio: Optional[date] = None, + data_fim: Optional[date] = None, + numero: Optional[str] = None + ) -> List[DiarioOficial]: + """ + Lista diários com filtros opcionais + + Args: + tipo_id: ID do tipo de diário para filtrar + data_inicio: Data inicial para filtrar + data_fim: Data final para filtrar + numero: Número do diário para filtrar + + Returns: + List[DiarioOficial]: Lista de diários filtrados + """ + queryset = DiarioOficial.objects.all().order_by('-data') + + if tipo_id: + queryset = queryset.filter(tipo_id=tipo_id) + + if data_inicio and data_fim: + queryset = queryset.filter(data__range=[data_inicio, data_fim]) + elif data_inicio: + queryset = queryset.filter(data__gte=data_inicio) + elif data_fim: + queryset = queryset.filter(data__lte=data_fim) + + if numero: + queryset = queryset.filter(numero__icontains=numero) + + return list(queryset) + + @staticmethod + def atualizar_diario( + id: int, + data: Optional[date] = None, + numero: Optional[str] = None, + tipo_id: Optional[int] = None, + arquivo=None, + link: Optional[str] = None + ) -> DiarioOficial: + """ + Atualiza um diário existente + + Args: + id: ID do diário a ser atualizado + data: Nova data (opcional) + numero: Novo número (opcional) + tipo_id: Novo tipo (opcional) + arquivo: Novo arquivo (opcional) + link: Novo link (opcional) + + Returns: + DiarioOficial: O diário atualizado + + Raises: + HttpError: Se ocorrer algum erro na atualização + """ + try: + diario = DiarioOficial.objects.get(pk=id) + + if data is not None: + diario.data = data + + if numero is not None: + diario.numero = numero + + if tipo_id is not None: + tipo = TipoDiarioOficial.objects.get(pk=tipo_id) if tipo_id else None + diario.tipo = tipo + + if arquivo is not None: + diario.arquivo = arquivo + + if link is not None: + diario.link = link + + diario.full_clean() + diario.save() + return diario + + except ObjectDoesNotExist: + raise HttpError(404, "Diário ou tipo não encontrado") + except Exception as e: + raise HttpError(400, f"Erro ao atualizar diário: {str(e)}") + + @staticmethod + def deletar_diario(id: int) -> None: + """ + Remove um diário + + Args: + id: ID do diário a ser removido + + Raises: + HttpError: Se o diário não for encontrado + """ + try: + diario = DiarioOficial.objects.get(pk=id) + diario.delete() + except ObjectDoesNotExist: + raise HttpError(404, "Diário não encontrado") \ No newline at end of file diff --git a/diarios/documents.py b/diarios/documents.py new file mode 100644 index 0000000..e8a5990 --- /dev/null +++ b/diarios/documents.py @@ -0,0 +1,67 @@ +from django_elasticsearch_dsl import Document, fields +from django_elasticsearch_dsl.registries import registry +from .models import DiarioOficial + + +@registry.register_document +class DiarioOficialDocument(Document): + numero = fields.KeywordField() + data = fields.DateField() + link = fields.KeywordField() + tipo = fields.ObjectField(properties={"nome": fields.KeywordField()}) + + # Campo para páginas + paginas = fields.NestedField( + properties={ + "id": fields.IntegerField(), + "numero": fields.IntegerField(), + "conteudo": fields.TextField( + analyzer="custom_portuguese", + fields={ + "keyword": fields.KeywordField(), + "search": fields.TextField(analyzer="custom_portuguese"), + }, + ), + } + ) + + class Index: + name = "diario_oficial" + settings = { + "number_of_shards": 1, + "number_of_replicas": 0, + "analysis": { + "analyzer": { + "custom_portuguese": { + "type": "custom", + "tokenizer": "standard", + "filter": [ + "lowercase", + "asciifolding", + "portuguese_stop", + ], + } + }, + "filter": { + "portuguese_stop": {"type": "stop", "stopwords": "_portuguese_"}, + }, + }, + } + + class Django: + model = DiarioOficial + fields = ["id"] + + def prepare_tipo(self, instance): + return {"nome": instance.tipo.nome if instance.tipo else "Sem Tipo"} + + def prepare_link(self, instance): + return instance.link or "" + + def prepare_paginas(self, instance): + # Preparar páginas ordenadas + paginas = instance.paginas.all().order_by("numero") + return [ + {"id": pagina.id, "numero": pagina.numero, "conteudo": pagina.conteudo} + for pagina in paginas + ] diff --git a/diarios/forms.py b/diarios/forms.py new file mode 100644 index 0000000..d8c50d2 --- /dev/null +++ b/diarios/forms.py @@ -0,0 +1,21 @@ +from django import forms +from .models import PageDiarioOficial + + +class PageDiarioOficialInlineForm(forms.ModelForm): + class Meta: + model = PageDiarioOficial + fields = "__all__" + + def clean(self): + cleaned_data = super().clean() + if "numero" in cleaned_data and self.instance.diario: + if ( + PageDiarioOficial.objects.filter( + diario=self.instance.diario, numero=cleaned_data["numero"] + ) + .exclude(pk=self.instance.pk) + .exists() + ): + self.add_error("numero", "Já existe uma página com este número") + return cleaned_data diff --git a/diarios/management/commands/importar_diarios_com_ocr.py b/diarios/management/commands/importar_diarios_com_ocr.py new file mode 100644 index 0000000..0ca8ecf --- /dev/null +++ b/diarios/management/commands/importar_diarios_com_ocr.py @@ -0,0 +1,105 @@ +import os +import time +from django.core.files import File +from django.core.management.base import BaseCommand +from diarios.models import DiarioOficial +from diarios.signals import update_document, delete_document +from django.db.models.signals import post_save, post_delete + + +class Command(BaseCommand): + help = "Importa arquivos PDF de diários oficiais e associa aos objetos existentes no banco." + + def add_arguments(self, parser): + parser.add_argument( + "pasta", + type=str, + help="Caminho para a pasta contendo os arquivos PDF (nomes devem conter o número do diário)", + ) + + def handle(self, *args, **options): + post_save.disconnect(update_document, sender=DiarioOficial) + post_delete.disconnect(delete_document, sender=DiarioOficial) + + pasta = options["pasta"] + if not os.path.isdir(pasta): + self.stderr.write( + self.style.ERROR(f"A pasta fornecida não existe: {pasta}") + ) + return + + arquivos_pdf = [ + f + for f in os.listdir(pasta) + if f.lower().endswith(".pdf") and "Diário_Oficial_Eletrônico_nº_" in f + ] + + if not arquivos_pdf: + self.stdout.write( + self.style.WARNING("Nenhum arquivo PDF válido encontrado na pasta.") + ) + return + + total = len(arquivos_pdf) + erros = [] + atualizados = [] + start_time = time.time() + + # Mapeia os números aos nomes de arquivos + numero_para_arquivo = { + f.replace("Diário_Oficial_Eletrônico_nº_", "") + .replace(".pdf", "") + .strip("_-"): f + for f in arquivos_pdf + } + + # Busca todos os objetos de uma vez + diarios_existentes = DiarioOficial.objects.in_bulk( + numero_para_arquivo.keys(), field_name="numero" + ) + + for idx, (numero, nome_arquivo) in enumerate( + numero_para_arquivo.items(), start=1 + ): + try: + diario = diarios_existentes.get(numero) + if not diario: + raise DiarioOficial.DoesNotExist() + + caminho_pdf = os.path.join(pasta, nome_arquivo) + with open(caminho_pdf, "rb") as f: + diario.arquivo.save(nome_arquivo, File(f), save=True) + + atualizados.append(diario.pk) + elapsed = time.time() - start_time + remaining = (elapsed / idx) * (total - idx) + self.stdout.write( + f"[{idx}/{total}] Atualizado: {nome_arquivo} | Estimativa restante: {remaining:.1f}s" + ) + + except DiarioOficial.DoesNotExist: + msg = f"Não encontrado no banco: {nome_arquivo}" + erros.append(msg) + self.stderr.write(self.style.WARNING(msg)) + + except Exception as e: + msg = f"Erro ao processar {nome_arquivo}: {str(e)}" + erros.append(msg) + self.stderr.write(self.style.ERROR(msg)) + + self.stdout.write( + self.style.SUCCESS(f"{len(atualizados)} arquivos importados com sucesso.") + ) + self.stdout.write(self.style.WARNING(f"{len(erros)} arquivos com erro.")) + + if erros: + caminho_log = os.path.join(pasta, "erros_importacao.txt") + with open(caminho_log, "w", encoding="utf-8") as erro_file: + for linha in erros: + erro_file.write(f"{linha}\n") + self.stdout.write( + self.style.WARNING(f"Erros registrados em: {caminho_log}") + ) + + post_save.connect(update_document, sender=DiarioOficial) + post_delete.connect(delete_document, sender=DiarioOficial) diff --git a/diarios/migrations/0001_initial.py b/diarios/migrations/0001_initial.py new file mode 100644 index 0000000..a146501 --- /dev/null +++ b/diarios/migrations/0001_initial.py @@ -0,0 +1,31 @@ +# Generated by Django 5.0.12 on 2025-03-06 16:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="PDFDocument", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=255)), + ("file", models.FileField(upload_to="pdfs/")), + ("content", models.TextField(blank=True)), + ("uploaded_at", models.DateTimeField(auto_now_add=True)), + ], + ), + ] diff --git a/diarios/migrations/0002_pdfdocument_page_content.py b/diarios/migrations/0002_pdfdocument_page_content.py new file mode 100644 index 0000000..8aafd71 --- /dev/null +++ b/diarios/migrations/0002_pdfdocument_page_content.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.12 on 2025-03-07 13:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("diarios", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="pdfdocument", + name="page_content", + field=models.TextField(blank=True), + ), + ] diff --git a/diarios/migrations/0003_tipodiariooficial_diariooficial_and_more.py b/diarios/migrations/0003_tipodiariooficial_diariooficial_and_more.py new file mode 100644 index 0000000..f4eefb8 --- /dev/null +++ b/diarios/migrations/0003_tipodiariooficial_diariooficial_and_more.py @@ -0,0 +1,75 @@ +# Generated by Django 5.0.12 on 2025-03-07 14:58 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("diarios", "0002_pdfdocument_page_content"), + ] + + operations = [ + migrations.CreateModel( + name="TipoDiarioOficial", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("nome", models.CharField(max_length=100, unique=True)), + ], + options={ + "verbose_name_plural": "Tipos de Diários Oficiais", + }, + ), + migrations.CreateModel( + name="DiarioOficial", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("data", models.DateField()), + ( + "arquivo", + models.FileField( + blank=True, null=True, upload_to="diarios_oficiais/" + ), + ), + ("numero", models.CharField(max_length=20, unique=True)), + ("link", models.URLField(blank=True, null=True, unique=True)), + ("finalizado", models.BooleanField(default=False)), + ( + "tipo", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="diarios", + to="diarios.tipodiariooficial", + ), + ), + ], + options={ + "verbose_name_plural": "Diários Oficiais", + }, + ), + migrations.AddConstraint( + model_name="diariooficial", + constraint=models.UniqueConstraint( + fields=("numero",), name="unique_numero" + ), + ), + ] diff --git a/diarios/migrations/0004_remove_diariooficial_finalizado_and_more.py b/diarios/migrations/0004_remove_diariooficial_finalizado_and_more.py new file mode 100644 index 0000000..5feaa8f --- /dev/null +++ b/diarios/migrations/0004_remove_diariooficial_finalizado_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 5.0.12 on 2025-03-07 15:25 + +import django.core.serializers.json +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("diarios", "0003_tipodiariooficial_diariooficial_and_more"), + ] + + operations = [ + migrations.RemoveField( + model_name="diariooficial", + name="finalizado", + ), + migrations.AddField( + model_name="diariooficial", + name="page_content", + field=models.JSONField( + blank=True, + encoder=django.core.serializers.json.DjangoJSONEncoder, + null=True, + ), + ), + migrations.AlterField( + model_name="diariooficial", + name="tipo", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="diarios", + to="diarios.tipodiariooficial", + ), + ), + ] diff --git a/diarios/migrations/0005_delete_pdfdocument.py b/diarios/migrations/0005_delete_pdfdocument.py new file mode 100644 index 0000000..0e0679e --- /dev/null +++ b/diarios/migrations/0005_delete_pdfdocument.py @@ -0,0 +1,16 @@ +# Generated by Django 5.0.12 on 2025-03-15 15:51 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("diarios", "0004_remove_diariooficial_finalizado_and_more"), + ] + + operations = [ + migrations.DeleteModel( + name="PDFDocument", + ), + ] diff --git a/diarios/migrations/0006_remove_diariooficial_page_content_pagediariooficial.py b/diarios/migrations/0006_remove_diariooficial_page_content_pagediariooficial.py new file mode 100644 index 0000000..8ea5de1 --- /dev/null +++ b/diarios/migrations/0006_remove_diariooficial_page_content_pagediariooficial.py @@ -0,0 +1,47 @@ +# Generated by Django 5.0.12 on 2025-03-27 12:30 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("diarios", "0005_delete_pdfdocument"), + ] + + operations = [ + migrations.RemoveField( + model_name="diariooficial", + name="page_content", + ), + migrations.CreateModel( + name="PageDiarioOficial", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("numero", models.PositiveIntegerField()), + ("conteudo", models.TextField()), + ( + "diario", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="paginas", + to="diarios.diariooficial", + ), + ), + ], + options={ + "verbose_name": "Página de Diário Oficial", + "verbose_name_plural": "Páginas de Diários Oficiais", + "unique_together": {("diario", "numero")}, + }, + ), + ] diff --git a/diarios/migrations/0007_diariooficial_layout_duas_colunas.py b/diarios/migrations/0007_diariooficial_layout_duas_colunas.py new file mode 100644 index 0000000..60e83cf --- /dev/null +++ b/diarios/migrations/0007_diariooficial_layout_duas_colunas.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.12 on 2025-04-28 13:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("diarios", "0006_remove_diariooficial_page_content_pagediariooficial"), + ] + + operations = [ + migrations.AddField( + model_name="diariooficial", + name="layout_duas_colunas", + field=models.BooleanField(default=False), + ), + ] diff --git a/diarios/migrations/0008_remove_diariooficial_layout_duas_colunas_and_more.py b/diarios/migrations/0008_remove_diariooficial_layout_duas_colunas_and_more.py new file mode 100644 index 0000000..5227f73 --- /dev/null +++ b/diarios/migrations/0008_remove_diariooficial_layout_duas_colunas_and_more.py @@ -0,0 +1,22 @@ +# Generated by Django 5.0.12 on 2025-04-28 13:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("diarios", "0007_diariooficial_layout_duas_colunas"), + ] + + operations = [ + migrations.RemoveField( + model_name="diariooficial", + name="layout_duas_colunas", + ), + migrations.AddField( + model_name="pagediariooficial", + name="layout_duas_colunas", + field=models.BooleanField(default=False), + ), + ] diff --git a/diarios/migrations/__init__.py b/diarios/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/diarios/models.py b/diarios/models.py new file mode 100644 index 0000000..bd448f1 --- /dev/null +++ b/diarios/models.py @@ -0,0 +1,167 @@ +import os +from urllib.parse import urlparse +import requests +from babel.dates import format_date +from django.core.files.base import ContentFile +from django.db import models, transaction +from django.core.exceptions import ValidationError +import fitz # PyMuPDF +from asgiref.sync import async_to_sync + + +class TipoDiarioOficial(models.Model): + nome = models.CharField(max_length=100, unique=True) + + def __str__(self): + return self.nome + + class Meta: + verbose_name_plural = "Tipos de Diários Oficiais" + + +class DiarioOficial(models.Model): + data = models.DateField() + arquivo = models.FileField(upload_to="diarios_oficiais/", blank=True, null=True) + tipo = models.ForeignKey( + TipoDiarioOficial, + blank=True, + null=True, + on_delete=models.SET_NULL, + related_name="diarios", + ) + numero = models.CharField(max_length=20, unique=True) + link = models.URLField(blank=True, null=True, unique=True) + + def save(self, *args, **kwargs): + updated = False + super().save(*args, **kwargs) + + if self.link and not self.arquivo: + self._download_pdf_from_link() + updated = True + + if self.arquivo and not self.paginas.exists(): + self._extract_pdf_pages() + updated = True + + if updated: + super().save(*args, **kwargs) + + def clean(self): + super().clean() + if not self.arquivo and not self.link: + raise ValidationError("Informe um arquivo ou um link para o Diário.") + + def _validar_link(self): + if not self.link.lower().endswith(".pdf"): + raise ValidationError("O link deve apontar para um arquivo PDF.") + + def _download_pdf_from_link(self): + try: + response = requests.get(self.link) + response.raise_for_status() + + parsed_url = urlparse(self.link) + file_name = os.path.basename(parsed_url.path) or f"diario_{self.numero}.pdf" + + self.arquivo.save(file_name, ContentFile(response.content), save=True) + except requests.RequestException as e: + raise ValidationError(f"Não foi possível baixar o PDF: {e}") + + def _extract_pdf_pages(self): + try: + # Salvar temporariamente o PDF para abrir com o PyMuPDF + with self.arquivo.open("rb") as pdf_file: + temp_pdf_path = f"/tmp/diario_{self.id}.pdf" + with open(temp_pdf_path, "wb") as temp_file: + temp_file.write(pdf_file.read()) + + # Abrir e processar com fitz + doc = fitz.open(temp_pdf_path) + self._process_pdf_pages(doc) + doc.close() + + # Remover arquivo temporário + os.remove(temp_pdf_path) + + except Exception as pdf_error: + raise ValidationError(f"Não foi possível processar o PDF: {pdf_error}") + + def _process_pdf_pages(self, doc): + with transaction.atomic(): + self.paginas.all().delete() + + for i, page in enumerate(doc): + try: + blocks = page.get_text("blocks") + # Ordenar os blocos por coordenadas (y, x) para manter a ordem de leitura + blocks.sort(key=lambda b: (b[1], b[0])) + page_text = "" + for block in blocks: + text = block[4].strip() + if text: + page_text += text + "\n" + + # Crucial: Remove NULL bytes from the extracted text + # PostgreSQL text fields cannot contain NUL (0x00) bytes + cleaned_text = page_text.strip().replace('\x00', '') + + if cleaned_text: + PageDiarioOficial.objects.create( + diario=self, + numero=i + 1, + conteudo=cleaned_text, + ) + else: + PageDiarioOficial.objects.create( + diario=self, + numero=i + 1, + conteudo="[Conteúdo não extraído ou vazio]", + ) + + except Exception as page_error: + PageDiarioOficial.objects.create( + diario=self, + numero=i + 1, + conteudo=f"[Erro na extração do texto: {str(page_error)}]", + ) + print(f"Erro ao processar a página {i+1} no Diario ID {self.id}: {page_error}") + + @property + def data_formatada(self): + return format_date(self.data, format="long", locale="pt_BR") + + @property + def is_online(self): + return bool(self.link) + + def __str__(self): + tipo_nome = self.tipo.nome if self.tipo else "Sem Tipo" + return f"Diário {tipo_nome} nº {self.numero}, {self.data_formatada}" + + class Meta: + constraints = [models.UniqueConstraint(fields=["numero"], name="unique_numero")] + verbose_name_plural = "Diários Oficiais" + + +class PageDiarioOficial(models.Model): + diario = models.ForeignKey( + DiarioOficial, on_delete=models.CASCADE, related_name="paginas" + ) + layout_duas_colunas = models.BooleanField(default=False) + numero = models.PositiveIntegerField() + conteudo = models.TextField() + + class Meta: + unique_together = ("diario", "numero") + verbose_name = "Página de Diário Oficial" + verbose_name_plural = "Páginas de Diários Oficiais" + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + if self.diario: + self.diario.save() + + def __str__(self): + return f"Página {self.numero} do Diário {self.diario.numero}" + diff --git a/diarios/schemas.py b/diarios/schemas.py new file mode 100644 index 0000000..bdac278 --- /dev/null +++ b/diarios/schemas.py @@ -0,0 +1,126 @@ +from ninja import Schema, ModelSchema, UploadedFile +from typing import List, Optional +from .models import TipoDiarioOficial +from datetime import date + + +class PaginaSchema(Schema): + """Schema que representa uma página de um Diário Oficial. + + Attributes: + numero (int): Número ordinal da página no Diário (começa em 1). + conteudo (str): Texto extraído da página. + """ + + numero: int + conteudo: str + + +class ResultadoSchema(Schema): + """Schema de resposta para um Diário Oficial em resultados de busca. + + Attributes: + id (int): ID único do Diário no banco de dados. + numero (str): Número de identificação oficial do Diário (e.g., '123-A'). + data (str): Data de publicação no formato ISO (YYYY-MM-DD). + link (str): URL para acessar o Diário Oficial online. + tipo (str): Nome do tipo de Diário (e.g., 'Municipal', 'Federal'). + paginas (List[PaginaSchema]): Lista de páginas com conteúdo extraído. + score (Optional[float]): Relevância do resultado (0 a 1), se aplicável. + """ + + id: int + numero: str + data: str + link: str + tipo: str + paginas: List[PaginaSchema] + score: Optional[float] = None + + +class BuscaDiariosResponseSchema(Schema): + """Schema de resposta para buscas paginadas em Diários Oficiais. + + Attributes: + total (int): Total de resultados disponíveis (ignorando paginação). + resultados (List[ResultadoSchema]): Lista de Diários encontrados. + pagina (int): Número da página atual (começa em 1). + por_pagina (int): Quantidade de resultados por página. + """ + + total: int + resultados: List[ResultadoSchema] + pagina: int + por_pagina: int + + +class SugestaoResponse(Schema): + """Schema para sugestões de correção de busca (e.g., 'Voc quis dizer...?'). + + Attributes: + sugestao (Optional[str]): Termo sugerido para refinar a busca. + None se nenhuma sugestão for relevante. + """ + + sugestao: Optional[str] + +class TipoDiarioSchema(ModelSchema): + class Config: + model = TipoDiarioOficial + model_fields = ["id", "nome"] + +class DiarioOficialIn(Schema): + """Schema para criação de Diário Oficial""" + data: date + numero: str + tipo_id: Optional[int] = None + link: Optional[str] = None + arquivo: Optional[UploadedFile] = None # Adicionando o campo de arquivo + + class Config: + # Configuração adicional para o Swagger UI + json_schema_extra = { + "example": { + "data": "2023-12-01", + "numero": "1234/2023", + } + } + +class DiarioOficialOut(Schema): + """Schema para retorno de Diário Oficial (com detalhes completos)""" + id: int + data: str # Será formatado como ISO (YYYY-MM-DD) + numero: str + link: Optional[str] + tipo: Optional[TipoDiarioSchema] + total_paginas: int + + @staticmethod + def resolve_data(obj): + return obj.data.isoformat() + + @staticmethod + def resolve_total_paginas(obj): + return obj.paginas.count() + +class DiarioOficialUpdate(Schema): + """Schema para atualização de Diário Oficial""" + data: Optional[date] = None + numero: Optional[str] = None + tipo_id: Optional[int] = None + link: Optional[str] = None + +class DiarioListagem(Schema): + """Schema simplificado para listagem de Diários""" + id: int + data: str + numero: str + tipo_nome: Optional[str] + + @staticmethod + def resolve_data(obj): + return obj.data.isoformat() + + @staticmethod + def resolve_tipo_nome(obj): + return obj.tipo.nome if obj.tipo else None \ No newline at end of file diff --git a/diarios/search_service.py b/diarios/search_service.py new file mode 100644 index 0000000..fed9293 --- /dev/null +++ b/diarios/search_service.py @@ -0,0 +1,848 @@ +import re +from datetime import datetime +from typing import Optional, Dict, Any, List +from elasticsearch import Elasticsearch, AsyncElasticsearch +from .schemas import BuscaDiariosResponseSchema, ResultadoSchema, PaginaSchema +import unicodedata +import asyncio +from django.conf import settings + + +async def is_fuzzy_appropriate(term: str) -> bool: + """ + Determina se a fuzziness é apropriada para o termo de busca. + + Args: + term: Termo de busca a ser avaliado + + Returns: + bool: True se fuzziness é apropriada, False caso contrário + """ + return not re.match(r"^\d+/\d+$", term.strip()) + + +async def parse_date(date_str: Optional[str]) -> Optional[str]: + """ + Converte string de data para formato ISO para ElasticSearch. + + Args: + date_str: String de data no formato YYYY-MM-DD + + Returns: + Optional[str]: Data formatada ou None se inválida + """ + if not date_str: + return None + try: + dt = datetime.strptime(date_str, "%Y-%m-%d") + return dt.strftime("%Y-%m-%d") + except ValueError: + print(f"Alerta: Formato de data inválido recebido: {date_str}") + return None + + +async def buscar_diarios( + query: Optional[str] = None, + data_inicio: Optional[str] = None, + data_fim: Optional[str] = None, + tipo_diario: Optional[str] = None, + page: int = 1, + page_size: int = 10, +) -> Dict[str, Any]: + """ + Realiza busca nos diários oficiais com os parâmetros fornecidos. + + Args: + query: Termo de busca + data_inicio: Data inicial no formato YYYY-MM-DD + data_fim: Data final no formato YYYY-MM-DD + tipo_diario: Tipo de diário a ser filtrado + page: Número da página de resultados + page_size: Quantidade de resultados por página + + Returns: + Dict[str, Any]: Dicionário com resultados da busca + """ + try: + es = AsyncElasticsearch( + "http://elasticsearch:9200", + request_timeout=30, + basic_auth=( + settings.ELASTICSEARCH_USER, + settings.ELASTICSEARCH_PASSWORD, + ), + ) + if not await es.ping(): + raise ConnectionError("Não foi possível conectar ao Elasticsearch") + except Exception as e: + print(f"Erro ao conectar com Elasticsearch: {e}") + return {"total": 0, "resultados": [], "pagina": page, "por_pagina": page_size} + + es_body = { + "query": {"bool": {"must": [], "filter": []}}, + "size": page_size, + "from": (page - 1) * page_size, + "_source": [ + "numero", + "data", + "link", + "tipo", + "paginas.numero", + "paginas.conteudo", + ], + } + + parsed_dt_inicio = await parse_date(data_inicio) + parsed_dt_fim = await parse_date(data_fim) + if parsed_dt_inicio or parsed_dt_fim: + date_range_filter = {} + if parsed_dt_inicio: + date_range_filter["gte"] = parsed_dt_inicio + if parsed_dt_fim: + date_range_filter["lte"] = parsed_dt_fim + es_body["query"]["bool"]["filter"].append( + {"range": {"data": date_range_filter}} + ) + + if tipo_diario: + es_body["query"]["bool"]["filter"].append({"term": {"tipo.nome": tipo_diario}}) + + if query: + aplicar_fuzziness = await is_fuzzy_appropriate(query) + text_query_bool = { + "bool": { + "should": [ + { + "match_phrase": { + "paginas.conteudo.search": { + "query": query, + "slop": 4, + "boost": 5.0, + } + } + }, + { + "match": { + "paginas.conteudo.search": { + "query": query, + "fuzziness": "AUTO", + "operator": "and", + "prefix_length": 3, + } + } + }, + ], + "minimum_should_match": "75%", + } + } + + nested_query = { + "nested": { + "path": "paginas", + "query": text_query_bool, + "inner_hits": { + "highlight": { + "fields": {"paginas.conteudo.search": {}}, + "fragment_size": 500, + "number_of_fragments": 1, + "pre_tags": [""], + "post_tags": [""], + }, + "_source": ["paginas.numero"], + }, + } + } + + es_body["query"]["bool"]["must"].append(nested_query) + else: + es_body["query"]["bool"]["must"].append({"match_all": {}}) + + # Adicionar min_score + if query: + es_body["min_score"] = 2.5 * len(query.split()) + try: + response = await es.search(index="diario_oficial", body=es_body) + except Exception as e: + print(f"Erro ao executar busca no Elasticsearch: {e}") + return {"total": 0, "resultados": [], "pagina": page, "por_pagina": page_size} + finally: + await es.close() + + hits = response.get("hits", {}) + total = hits.get("total", {}).get("value", 0) + resultados_formatados = [] + + for hit in hits.get("hits", []): + source = hit.get("_source", {}) + resultado_data = { + "id": hit.get("_id"), + "numero": source.get("numero", ""), + "data": source.get("data"), + "link": source.get("link", ""), + "tipo": source.get("tipo", {}).get("nome", "Sem Tipo"), + "score": hit.get("_score"), + "paginas": [], + } + + pagina_match = None + if query and "inner_hits" in hit and "paginas" in hit["inner_hits"]: + paginas_inner_hits = hit["inner_hits"]["paginas"]["hits"]["hits"] + if paginas_inner_hits: + inner_hit = paginas_inner_hits[0] + inner_source = inner_hit.get("_source", {}) + page_num = inner_source.get("numero", "N/A") + highlights = inner_hit.get("highlight", {}).get( + "paginas.conteudo.search", [] + ) + highlight_content = " ... ".join(highlights) if highlights else "" + + if page_num != "N/A" and highlight_content: + pagina_match = PaginaSchema( + numero=page_num, conteudo=highlight_content + ) + + if pagina_match: + resultado_data["paginas"].append(pagina_match) + elif not query and "paginas" in source and source["paginas"]: + primeira_pagina_source = source["paginas"][0] + conteudo_orig = primeira_pagina_source.get("conteudo", "") + resultado_data["paginas"].append( + PaginaSchema( + numero=primeira_pagina_source.get("numero", 0), + conteudo=conteudo_orig, + ) + ) + + resultados_formatados.append(ResultadoSchema(**resultado_data)) + + return { + "total": total, + "resultados": resultados_formatados, + "pagina": page, + "por_pagina": page_size, + } + + +async def remover_acentos(texto: str) -> str: + """ + Remove acentos de uma string para comparações mais neutras. + + Args: + texto: Texto a ser normalizado + + Returns: + str: Texto sem acentos + """ + return "".join( + c + for c in unicodedata.normalize("NFD", texto) + if unicodedata.category(c) != "Mn" + ) + + +async def processar_query(query: str) -> str: + """ + Faz pré-processamento da query para separar termos como 'processonº404'. + + Args: + query: Texto da consulta original + + Returns: + str: Consulta processada + """ + return re.sub(r"([a-zA-Z]+)(nº|n°|no)(\d+)", r"\1 \2 \3", query) + + +async def montar_suggest_body(query_processada: str) -> dict: + """ + Monta o corpo da sugestão para a requisição ao Elasticsearch. + + Args: + query_processada: Query processada para sugestão + + Returns: + dict: Corpo da requisição para suggest + """ + return { + "suggest": { + "text": query_processada, + "correcao": { + "phrase": { + "field": "paginas.conteudo.search", + "size": 3, + "gram_size": 2, + "confidence": 0.8, + "max_errors": 4, + "highlight": {"pre_tag": "**", "post_tag": "**"}, + "collate": { + "query": { + "source": { + "nested": { + "path": "paginas", + "query": { + "bool": { + "should": [ + { + "match_phrase": { + "paginas.conteudo.search": { + "query": "{{suggestion}}", + "slop": 1, + } + } + } + ] + } + }, + } + } + }, + "params": {"field_name": "paginas.conteudo.search"}, + "prune": True, + }, + } + }, + }, + "size": 0, + } + + +async def sugestao_termo(query: str) -> Optional[str]: + """ + Oferece sugestões de correção para a query, verificando se a sugestão + realmente retorna resultados antes de apresentá-la ao usuário. + + Args: + query: Texto da consulta original + + Returns: + Optional[str]: Sugestão para a consulta ou None + """ + es = await conectar_elasticsearch() + if not es: + return None + + query_processada = await processar_query(query) + suggest_body = await montar_suggest_body(query_processada) + + try: + response = await es.search(index="diario_oficial", body=suggest_body) + suggestions = response.get("suggest", {}).get("correcao", []) + + for sug in suggestions: + for option in sug.get("options", []): + if option.get("collate_match", False): + sugestao = option["text"] + + # Verifica se a sugestão é idêntica à query (ignorando acentos e case) + if sugestao.lower() == query.lower(): + continue + if await remover_acentos(sugestao.lower()) == await remover_acentos( + query.lower() + ): + continue + + return sugestao + + return None + + except Exception as e: + print(f"Erro ao buscar sugestão: {e}") + return None + finally: + await es.close() + + +async def conectar_elasticsearch() -> Optional[AsyncElasticsearch]: + """ + Conecta ao Elasticsearch e retorna o cliente. + + Returns: + Optional[AsyncElasticsearch]: Cliente Elasticsearch ou None se falhar + """ + try: + es = AsyncElasticsearch( + "http://elasticsearch:9200", + request_timeout=30, + basic_auth=( + settings.ELASTICSEARCH_USER, + settings.ELASTICSEARCH_PASSWORD, + ), + ) + if not await es.ping(): + raise ConnectionError("Não foi possível conectar ao Elasticsearch") + return es + except Exception as e: + print(f"Erro ao conectar com Elasticsearch: {e}") + return None + + +async def construir_ordenacao(ordenar_por: str) -> List[Dict[str, Any]]: + """ + Constrói a cláusula de ordenação para a consulta. + + Args: + ordenar_por: Critério de ordenação + + Returns: + List[Dict[str, Any]]: Lista de ordenação para Elasticsearch + """ + if ordenar_por == "data_asc": + return [{"data": {"order": "asc"}}] + elif ordenar_por == "data_desc": + return [{"data": {"order": "desc"}}] + else: # relevancia + return ["_score"] + + +async def preencher_numero_do_diario_com_zeros(numero_diario: str) -> str: + """ + Preenche o número do diário com zeros à esquerda. + + Args: + numero_diario: Número do diário + + Returns: + str: Número formatado com zeros à esquerda + """ + return numero_diario.zfill(4) + + +async def construir_filtros( + data_inicio: Optional[str], + data_fim: Optional[str], + tipo_diario: Optional[str], + numero_diario: Optional[str], +) -> List[Dict[str, Any]]: + """ + Constrói os filtros de data, tipo e número do diário. + + Args: + data_inicio: Data inicial no formato YYYY-MM-DD + data_fim: Data final no formato YYYY-MM-DD + tipo_diario: Tipo de diário a ser filtrado + numero_diario: Número do diário + + Returns: + List[Dict[str, Any]]: Lista de filtros para Elasticsearch + """ + filtros = [] + + parsed_dt_inicio = await parse_date(data_inicio) + parsed_dt_fim = await parse_date(data_fim) + if parsed_dt_inicio or parsed_dt_fim: + date_range = {} + if parsed_dt_inicio: + date_range["gte"] = parsed_dt_inicio + if parsed_dt_fim: + date_range["lte"] = parsed_dt_fim + filtros.append({"range": {"data": date_range}}) + + if tipo_diario: + filtros.append({"term": {"tipo.nome": tipo_diario}}) + + if numero_diario: + # numero_diario = await preencher_numero_do_diario_com_zeros(numero_diario) + filtros.append({"wildcard": {"numero": f"*{numero_diario}*"}}) + + return filtros + + +async def construir_query_busca( + query: Optional[str], modo_busca: str +) -> Dict[str, Any]: + """ + Constrói a query de busca com base no termo e modo de busca. + + Args: + query: Termo de busca + modo_busca: Modo de busca (exata ou qualquer) + + Returns: + Dict[str, Any]: Query de busca para Elasticsearch + """ + if not query: + return { + "nested": { + "path": "paginas", + "query": {"match_all": {}}, + "inner_hits": { + "_source": ["paginas.numero", "paginas.conteudo"], + "size": 100, + }, + } + } + + should_queries = [] + + # Busca exata (boost maior) + should_queries.append( + { + "match_phrase": { + "paginas.conteudo.search": {"query": query, "slop": 3, "boost": 5.0} + } + } + ) + + # Busca mais leve (separando termos), se modo_busca permitir + if modo_busca == "qualquer": + should_queries.append( + { + "match": { + "paginas.conteudo.search": { + "query": query, + "operator": "or", + "fuzziness": "AUTO", + "prefix_length": 3, + "boost": 1.0, + } + } + } + ) + + return { + "nested": { + "path": "paginas", + "query": {"bool": {"should": should_queries, "minimum_should_match": 1}}, + "inner_hits": { + "highlight": { + "fields": {"paginas.conteudo.search": {}}, + "fragment_size": 500, + "number_of_fragments": 1, + "pre_tags": [""], + "post_tags": [""], + }, + "_source": ["paginas.numero"], + "size": 100, # Aumentar para retornar mais páginas correspondentes + }, + } + } + + +async def construir_request_body( + query: Optional[str], + modo_busca: str, + ordenar_por: str, + data_inicio: Optional[str], + data_fim: Optional[str], + tipo_diario: Optional[str], + numero_diario: Optional[str], + page: int, + page_size: int, +) -> Dict[str, Any]: + """ + Constrói o corpo da requisição para o Elasticsearch. + + Args: + query: Termo de busca + modo_busca: Modo de busca (exata ou qualquer) + ordenar_por: Critério de ordenação + data_inicio: Data inicial + data_fim: Data final + tipo_diario: Tipo de diário + numero_diario: Número do diário + page: Número da página + page_size: Tamanho da página + + Returns: + Dict[str, Any]: Corpo da requisição para Elasticsearch + """ + es_body = { + "query": {"bool": {"must": [], "filter": []}}, + "size": page_size, + "from": (page - 1) * page_size, + "_source": [ + "numero", + "data", + "link", + "tipo", + "paginas.numero", + "paginas.conteudo", + ], + } + + # Adicionar ordenação + es_body["sort"] = await construir_ordenacao(ordenar_por) + + # Adicionar filtros + es_body["query"]["bool"]["filter"] = await construir_filtros( + data_inicio, data_fim, tipo_diario, numero_diario + ) + + # Adicionar query principal + query_principal = await construir_query_busca(query, modo_busca) + es_body["query"]["bool"]["must"].append(query_principal) + + return es_body + + +async def processar_paginas_encontradas( + hit: Dict[str, Any], query: Optional[str] +) -> List[PaginaSchema]: + """ + Processa as páginas encontradas em um hit, retornando todas as correspondências. + + Args: + hit: Item de resultado do Elasticsearch + query: Termo de busca original + + Returns: + List[PaginaSchema]: Lista de páginas encontradas + """ + if not query: + return [] + + paginas = [] + source = hit.get("_source", {}) + + #if not query and "paginas" in source: + # for pagina in source["paginas"]: + # paginas.append( + # PaginaSchema( + # numero=pagina.get("numero", 0), conteudo=pagina.get("conteudo", "") + # ) + # ) + # Se temos uma query e há inner_hits, processamos todas as páginas correspondentes + if query and "inner_hits" in hit and "paginas" in hit["inner_hits"]: + paginas_inner_hits = hit["inner_hits"]["paginas"]["hits"]["hits"] + + # Processar todas as páginas encontradas, não apenas a primeira + for inner_hit in paginas_inner_hits: + inner_source = inner_hit.get("_source", {}) + page_num = inner_source.get("numero", "N/A") + highlights = inner_hit.get("highlight", {}).get( + "paginas.conteudo.search", [] + ) + highlight_content = " ... ".join(highlights) if highlights else "" + + if page_num != "N/A" and highlight_content: + paginas.append( + PaginaSchema(numero=page_num, conteudo=highlight_content) + ) + + # Se não há query ou não encontramos páginas nos inner_hits, usamos a primeira página do documento + if not paginas and "paginas" in source and source["paginas"]: + primeira_pagina_source = source["paginas"][0] + conteudo_orig = primeira_pagina_source.get("conteudo", "") + paginas.append( + PaginaSchema( + numero=primeira_pagina_source.get("numero", 0), + conteudo=conteudo_orig, + ) + ) + + return paginas + + +async def processar_resultados( + response: Dict[str, Any], query: Optional[str], page: int, page_size: int +) -> Dict[str, Any]: + """ + Processa os resultados da busca no Elasticsearch. + + Args: + response: Resposta do Elasticsearch + query: Termo de busca original + page: Número da página atual + page_size: Tamanho da página + + Returns: + Dict[str, Any]: Resultados processados + """ + hits = response.get("hits", {}) + total = hits.get("total", {}).get("value", 0) + resultados_formatados = [] + + for hit in hits.get("hits", []): + source = hit.get("_source", {}) + resultado_data = { + "id": hit.get("_id"), + "numero": source.get("numero", ""), + "data": source.get("data"), + "link": source.get("link", ""), + "tipo": source.get("tipo", {}).get("nome", "Sem Tipo"), + "score": hit.get("_score"), + "paginas": [], + } + + # Processar todas as páginas encontradas + resultado_data["paginas"] = await processar_paginas_encontradas(hit, query) + + resultados_formatados.append(ResultadoSchema(**resultado_data)) + + return { + "total": total, + "resultados": resultados_formatados, + "pagina": page, + "por_pagina": page_size, + } + + +async def buscar_diarios_simples( + query: Optional[str] = None, + numero_diario: Optional[str] = None, + modo_busca: str = "exata", # "exata" ou "qualquer" + ordenar_por: str = "data_asc", # "relevancia" ou "data" + data_inicio: Optional[str] = None, + data_fim: Optional[str] = None, + tipo_diario: Optional[str] = None, + page: int = 1, + page_size: int = 10, +) -> Dict[str, Any]: + """ + Função principal para buscar diários oficiais. + + Args: + query: Termo de busca + numero_diario: Número do diário oficial + modo_busca: Modo de busca (exata ou qualquer) + ordenar_por: Critério de ordenação + data_inicio: Data inicial + data_fim: Data final + tipo_diario: Tipo de diário + page: Número da página + page_size: Tamanho da página + + Returns: + Dict[str, Any]: Dicionário com os resultados da busca + """ + # Conectar ao Elasticsearch + es = await conectar_elasticsearch() + if not es: + return {"total": 0, "resultados": [], "pagina": page, "por_pagina": page_size} + + # Construir o corpo da requisição + es_body = await construir_request_body( + query, + modo_busca, + ordenar_por, + data_inicio, + data_fim, + tipo_diario, + numero_diario, + page, + page_size, + ) + + # Executar a busca + try: + response = await es.search(index="diario_oficial", body=es_body) + except Exception as e: + print(f"Erro ao executar busca no Elasticsearch: {e}") + return {"total": 0, "resultados": [], "pagina": page, "por_pagina": page_size} + finally: + await es.close() + + # Processar e retornar os resultados + return await processar_resultados(response, query, page, page_size) + + +async def list_diarios( + es_client: AsyncElasticsearch, + page: int = 1, + page_size: int = 10, + data_inicio: Optional[str] = None, + data_fim: Optional[str] = None, + tipo_diario: Optional[str] = None, + numero_diario: Optional[str] = None, + ordenar_por: str = "data_desc" +) -> Dict[str, any]: + """ + Lista diários com paginação e filtros opcionais + + Args: + es_client: Cliente do Elasticsearch + page: Número da página + page_size: Itens por página + data_inicio: Data inicial (YYYY-MM-DD) + data_fim: Data final (YYYY-MM-DD) + tipo_diario: Filtro por tipo de diário + numero_diario: Filtro por número do diário + ordenar_por: Campo para ordenação (data_desc, data_asc) + + Returns: + Dict com total de itens e lista de diários + """ + # Construir query de filtros + filters = [] + + # Filtro por data + if data_inicio or data_fim: + date_range = {} + if data_inicio: + try: + datetime.strptime(data_inicio, "%Y-%m-%d") + date_range["gte"] = data_inicio + except ValueError: + pass + if data_fim: + try: + datetime.strptime(data_fim, "%Y-%m-%d") + date_range["lte"] = data_fim + except ValueError: + pass + if date_range: + filters.append({"range": {"data": date_range}}) + + # Filtro por tipo + if tipo_diario: + filters.append({"term": {"tipo.nome": tipo_diario}}) + + # Filtro por número + if numero_diario: + filters.append({"wildcard": {"numero": f"*{numero_diario}*"}}) + + # Construir query completa + query = { + "bool": { + "must": [{"match_all": {}}], + "filter": filters + } + } + + # Definir ordenação + sort = [{"data": {"order": "asc" if ordenar_por == "data_asc" else "desc"}}] + + # Executar consulta + try: + response = await es_client.search( + index="diario_oficial", + body={ + "query": query, + "sort": sort, + "from": (page - 1) * page_size, + "size": page_size, + "_source": ["numero", "data", "tipo", "link"] + } + ) + + hits = response["hits"]["hits"] + total = response["hits"]["total"]["value"] + + # Processar resultados + diarios = [] + for hit in hits: + source = hit["_source"] + diarios.append(BuscaDiariosResponseSchema( + id=hit["_id"], + numero=source.get("numero"), + data=source.get("data"), + tipo=source.get("tipo", {}).get("nome"), + link=source.get("link") + )) + + return { + "total": total, + "page": page, + "page_size": page_size, + "results": diarios + } + + except Exception as e: + print(f"Erro ao listar diários: {e}") + return { + "total": 0, + "page": page, + "page_size": page_size, + "results": [] + } + diff --git a/diarios/tests/__init__.py b/diarios/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/diarios/tests/factories.py b/diarios/tests/factories.py new file mode 100644 index 0000000..2d9b8eb --- /dev/null +++ b/diarios/tests/factories.py @@ -0,0 +1,31 @@ +from factory.django import DjangoModelFactory +from factory import Faker, SubFactory, LazyAttribute, Sequence +import datetime +from diarios.models import PageDiarioOficial, DiarioOficial, TipoDiarioOficial + + +class TipoDiarioOficialFactory(DjangoModelFactory): + nome = Faker("word") + + class Meta: + model = TipoDiarioOficial + django_get_or_create = ["nome"] + +class DiarioOficialFactory(DjangoModelFactory): + data = Faker("date_this_decade") + numero = Sequence(lambda n: f"{n:04d}-DO") # ex: 0001-DO, 0002-DO + tipo = SubFactory(TipoDiarioOficialFactory) + link = None + + class Meta: + model = DiarioOficial + +class PageDiarioOficialFactory(DjangoModelFactory): + diario = SubFactory(DiarioOficialFactory) + numero = Sequence(lambda n: n + 1) + layout_duas_colunas = False + conteudo = Faker("text", max_nb_chars=3000) + + class Meta: + model = PageDiarioOficial + django_get_or_create = ["diario", "numero"] \ No newline at end of file diff --git a/diarios/tests/test_models.py b/diarios/tests/test_models.py new file mode 100644 index 0000000..9c16ba1 --- /dev/null +++ b/diarios/tests/test_models.py @@ -0,0 +1,34 @@ +from django.test import TestCase +from diarios.models import TipoDiarioOficial, DiarioOficial, PageDiarioOficial +from .factories import ( + TipoDiarioOficialFactory, + DiarioOficialFactory, + PageDiarioOficialFactory, +) + + +class TipoDiarioOficialFactoryTest(TestCase): + def test_create_tipo_diario_oficial(self): + tipo = TipoDiarioOficialFactory() + self.assertIsInstance(tipo, TipoDiarioOficial) + self.assertIsNotNone(tipo.pk) + self.assertTrue(tipo.nome) + + +class DiarioOficialFactoryTest(TestCase): + def test_create_diario_oficial(self): + diario = DiarioOficialFactory() + self.assertIsInstance(diario, DiarioOficial) + self.assertIsNotNone(diario.pk) + self.assertIsNotNone(diario.numero) + self.assertIsInstance(diario.tipo, TipoDiarioOficial) + + +class PageDiarioOficialFactoryTest(TestCase): + def test_create_page_diario_oficial(self): + page = PageDiarioOficialFactory() + self.assertIsInstance(page, PageDiarioOficial) + self.assertIsNotNone(page.pk) + self.assertIsInstance(page.diario, DiarioOficial) + self.assertIsInstance(page.conteudo, str) + self.assertGreater(len(page.conteudo), 0) diff --git a/diarios/urls.py b/diarios/urls.py new file mode 100644 index 0000000..a8187ab --- /dev/null +++ b/diarios/urls.py @@ -0,0 +1,6 @@ +from django.urls import path +from . import views + +urlpatterns = [ + path("", views.home, name='home') +] diff --git a/diarios/views.py b/diarios/views.py new file mode 100644 index 0000000..9e858c8 --- /dev/null +++ b/diarios/views.py @@ -0,0 +1,113 @@ +from ninja import Router, File, Form +from ninja.files import UploadedFile +from typing import Optional +from django.http import HttpRequest, HttpResponse +from .search_service import ( + buscar_diarios, + sugestao_termo, + buscar_diarios_simples, +) +from .schemas import BuscaDiariosResponseSchema, SugestaoResponse, DiarioOficialIn, DiarioOficialOut +from django.shortcuts import render + +router = Router(tags=["Diários Oficiais"]) + +async def home(request): + return render(request, 'diarios/index.html') + +@router.get( + "/sugestao", + response=SugestaoResponse, + summary="Sugestão de correção para termo de busca", +) +async def sugestao_busca(request: HttpRequest, q: str) -> SugestaoResponse: + """ + Sugere correção para o termo buscado, se necessário. + + Args: + request (HttpRequest): Requisição HTTP. + q (str): Termo original digitado pelo usuário. + + Returns: + SugestaoResponse: Termo corrigido. + """ + sugestao = await sugestao_termo(q) + return {"sugestao": sugestao} + + +@router.get( + "/busca", + response=BuscaDiariosResponseSchema, + summary="Busca simplificada com modos e ordenação", +) +async def busca_diarios_oficiais_simples( + request: HttpRequest, + q: Optional[str] = None, + numero_diario: Optional[str] = None, + data_inicio: Optional[str] = None, + data_fim: Optional[str] = None, + tipo: Optional[str] = None, + ordenar_por: str = "relevancia", # "relevancia", "data_asc", "data_desc" + modo_busca: str = "exata", # "exata" ou "qualquer" + page: int = 1, + page_size: int = 10, +) -> BuscaDiariosResponseSchema: + """ + Busca com modo de correspondência, ordenação e número do diário. + + Args: + request (HttpRequest): Requisição HTTP. + q (Optional[str]): Termo de busca. + numero_diario (Optional[str]): Número exato do diário (ex: 1234/2024). + data_inicio (Optional[str]): Data inicial (YYYY-MM-DD). + data_fim (Optional[str]): Data final (YYYY-MM-DD). + tipo (Optional[str]): Tipo exato do diário. + ordenar_por (str): "relevancia", "data_asc" ou "data_desc". + modo_busca (str): "exata" ou "qualquer". + page (int): Página atual (mínimo: 1). + page_size (int): Itens por página (mínimo: 1, máximo: 50). + + Returns: + BuscaDiariosResponseSchema: Resultado paginado da busca. + """ + page_size = min(max(page_size, 1), 50) + page = max(page, 1) + + resultado = await buscar_diarios_simples( + query=q, + numero_diario=numero_diario, + data_inicio=data_inicio, + data_fim=data_fim, + tipo_diario=tipo, + ordenar_por=ordenar_por, + modo_busca=modo_busca, + page=page, + page_size=page_size, + ) + return resultado + +@router.post("/", response=DiarioOficialOut, summary="Criar novo diário oficial") +def criar_diario( + request: HttpRequest, + # Usamos Form para os dados normais e File para o upload + payload: DiarioOficialIn = Form(...), + arquivo: UploadedFile = File(None) +): + """ + Cria um novo diário oficial. + + Observações: + - Aceita tanto upload de arquivo PDF quanto link para o diário + - Se ambos (arquivo e link) forem fornecidos, o arquivo terá prioridade + - O arquivo deve ser um PDF válido + """ + # Prioriza o arquivo se ambos existirem + arquivo_final = arquivo if arquivo else payload.arquivo + + return DiarioOficialService.criar_diario( + data=payload.data, + numero=payload.numero, + tipo_id=payload.tipo_id, + arquivo=arquivo_final, + link=payload.link + ) \ No newline at end of file