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