adiciona o aplicativo de diarios

This commit is contained in:
2025-06-18 11:13:22 -04:00
parent 76ac842b47
commit f71984600b
25 changed files with 2128 additions and 0 deletions

0
diarios/__init__.py Normal file
View File

150
diarios/admin.py Normal file
View File

@ -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'<a href="{url}">{instance.numero}</a>')
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'<a href="{obj.arquivo.url}" target="_blank">Download PDF</a>'
)
return "-"
arquivo_link.short_description = "Arquivo"
arquivo_link.allow_tags = True
def link_externo(self, obj):
if obj.link:
return mark_safe(f'<a href="{obj.link}" target="_blank">Acessar Online</a>')
return "-"
link_externo.short_description = "Link Externo"
link_externo.allow_tags = True
def arquivo_preview(self, obj):
if obj.arquivo:
return mark_safe(
f'<a href="{obj.arquivo.url}" target="_blank">Visualizar PDF</a>'
)
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'<a href="{url}">{obj.diario}</a>')
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")

5
diarios/api.py Normal file
View File

@ -0,0 +1,5 @@
from ninja import NinjaAPI
api = NinjaAPI()
api.add_router("/diarios/", route)

10
diarios/apps.py Normal file
View File

@ -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

179
diarios/crud_service.py Normal file
View File

@ -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")

67
diarios/documents.py Normal file
View File

@ -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
]

21
diarios/forms.py Normal file
View File

@ -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

View File

@ -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)

View File

@ -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)),
],
),
]

View File

@ -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),
),
]

View File

@ -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"
),
),
]

View File

@ -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",
),
),
]

View File

@ -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",
),
]

View File

@ -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")},
},
),
]

View File

@ -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),
),
]

View File

@ -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),
),
]

View File

167
diarios/models.py Normal file
View File

@ -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}{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}"

126
diarios/schemas.py Normal file
View File

@ -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

848
diarios/search_service.py Normal file
View File

@ -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": ["<mark>"],
"post_tags": ["</mark>"],
},
"_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": ["<mark>"],
"post_tags": ["</mark>"],
},
"_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": []
}

View File

View File

@ -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"]

View File

@ -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)

6
diarios/urls.py Normal file
View File

@ -0,0 +1,6 @@
from django.urls import path
from . import views
urlpatterns = [
path("", views.home, name='home')
]

113
diarios/views.py Normal file
View File

@ -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
)