168 lines
5.7 KiB
Python
168 lines
5.7 KiB
Python
|
|
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}"
|
||
|
|
|