feat: várias melhorias e evoluções no projeto
This commit is contained in:
@ -1,20 +0,0 @@
|
||||
|
||||
#
|
||||
# .bashrc.override.sh
|
||||
#
|
||||
|
||||
# persistent bash history
|
||||
HISTFILE=~/.bash_history
|
||||
PROMPT_COMMAND="history -a; $PROMPT_COMMAND"
|
||||
|
||||
# set some django env vars
|
||||
source /entrypoint
|
||||
|
||||
# restore default shell options
|
||||
set +o errexit
|
||||
set +o pipefail
|
||||
set +o nounset
|
||||
|
||||
# start ssh-agent
|
||||
# https://code.visualstudio.com/docs/remote/troubleshooting
|
||||
eval "$(ssh-agent -s)"
|
||||
@ -1,68 +0,0 @@
|
||||
// For format details, see https://containers.dev/implementors/json_reference/
|
||||
{
|
||||
"name": "diários_oficiais_alems_dev",
|
||||
"dockerComposeFile": [
|
||||
"../docker-compose.local.yml"
|
||||
],
|
||||
"init": true,
|
||||
"mounts": [
|
||||
{
|
||||
"source": "./.devcontainer/bash_history",
|
||||
"target": "/home/dev-user/.bash_history",
|
||||
"type": "bind"
|
||||
},
|
||||
{
|
||||
"source": "~/.ssh",
|
||||
"target": "/home/dev-user/.ssh",
|
||||
"type": "bind"
|
||||
}
|
||||
],
|
||||
// Tells devcontainer.json supporting services / tools whether they should run
|
||||
// /bin/sh -c "while sleep 1000; do :; done" when starting the container instead of the container’s default command
|
||||
"overrideCommand": false,
|
||||
"service": "django",
|
||||
// "remoteEnv": {"PATH": "/home/dev-user/.local/bin:${containerEnv:PATH}"},
|
||||
"remoteUser": "dev-user",
|
||||
"workspaceFolder": "/app",
|
||||
// Set *default* container specific settings.json values on container create.
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"settings": {
|
||||
"editor.formatOnSave": true,
|
||||
"[python]": {
|
||||
"analysis.autoImportCompletions": true,
|
||||
"analysis.typeCheckingMode": "basic",
|
||||
"defaultInterpreterPath": "/usr/local/bin/python",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports": "always"
|
||||
},
|
||||
"editor.defaultFormatter": "charliermarsh.ruff",
|
||||
"languageServer": "Pylance",
|
||||
"linting.enabled": true,
|
||||
"linting.mypyEnabled": true,
|
||||
"linting.mypyPath": "/usr/local/bin/mypy",
|
||||
}
|
||||
},
|
||||
// https://code.visualstudio.com/docs/remote/devcontainerjson-reference#_vs-code-specific-properties
|
||||
// Add the IDs of extensions you want installed when the container is created.
|
||||
"extensions": [
|
||||
"davidanson.vscode-markdownlint",
|
||||
"mrmlnc.vscode-duplicate",
|
||||
"visualstudioexptteam.vscodeintellicode",
|
||||
"visualstudioexptteam.intellicode-api-usage-examples",
|
||||
// python
|
||||
"ms-python.python",
|
||||
"ms-python.vscode-pylance",
|
||||
"charliermarsh.ruff",
|
||||
// django
|
||||
"batisteo.vscode-django"
|
||||
]
|
||||
}
|
||||
},
|
||||
// Uncomment the next line if you want start specific services in your Docker Compose config.
|
||||
// "runServices": [],
|
||||
// Uncomment the next line if you want to keep your containers running after VS Code shuts down.
|
||||
// "shutdownAction": "none",
|
||||
// Uncomment the next line to run commands after the container is created.
|
||||
"postCreateCommand": "cat .devcontainer/bashrc.override.sh >> ~/.bashrc"
|
||||
}
|
||||
@ -2,3 +2,5 @@
|
||||
# ------------------------------------------------------------------------------
|
||||
USE_DOCKER=yes
|
||||
IPYTHONDIR=/app/.ipython
|
||||
ELASTICSEARCH_USER=elastic
|
||||
ELASTICSEARCH_PASSWORD=Euamooelasticsearch123.
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -268,7 +268,7 @@ tags
|
||||
dump.rdb
|
||||
|
||||
### Project template
|
||||
diários_oficiais_alems/media/
|
||||
diarios_oficiais_alems/media/
|
||||
|
||||
.pytest_cache/
|
||||
.ipython/
|
||||
|
||||
@ -1,50 +0,0 @@
|
||||
exclude: '^docs/|/migrations/|devcontainer.json'
|
||||
default_stages: [pre-commit]
|
||||
minimum_pre_commit_version: "3.2.0"
|
||||
|
||||
default_language_version:
|
||||
python: python3.12
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- id: check-json
|
||||
- id: check-toml
|
||||
- id: check-xml
|
||||
- id: check-yaml
|
||||
- id: debug-statements
|
||||
- id: check-builtin-literals
|
||||
- id: check-case-conflict
|
||||
- id: check-docstring-first
|
||||
- id: detect-private-key
|
||||
|
||||
- repo: https://github.com/adamchainz/django-upgrade
|
||||
rev: '1.22.2'
|
||||
hooks:
|
||||
- id: django-upgrade
|
||||
args: ['--target-version', '5.0']
|
||||
|
||||
# Run the Ruff linter.
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.9.9
|
||||
hooks:
|
||||
# Linter
|
||||
- id: ruff
|
||||
args: [--fix, --exit-non-zero-on-fix]
|
||||
# Formatter
|
||||
- id: ruff-format
|
||||
|
||||
- repo: https://github.com/Riverside-Healthcare/djLint
|
||||
rev: v1.36.4
|
||||
hooks:
|
||||
- id: djlint-reformat-django
|
||||
- id: djlint-django
|
||||
|
||||
# sets up .pre-commit-ci.yaml to ensure pre-commit dependencies stay up to date
|
||||
ci:
|
||||
autoupdate_schedule: weekly
|
||||
skip: []
|
||||
submodules: false
|
||||
@ -1,20 +0,0 @@
|
||||
# Read the Docs configuration file
|
||||
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
|
||||
|
||||
# Required
|
||||
version: 2
|
||||
|
||||
# Set the version of Python and other tools you might need
|
||||
build:
|
||||
os: ubuntu-22.04
|
||||
tools:
|
||||
python: '3.12'
|
||||
|
||||
# Build documentation in the docs/ directory with Sphinx
|
||||
sphinx:
|
||||
configuration: docs/conf.py
|
||||
|
||||
# Python requirements required to build your docs
|
||||
python:
|
||||
install:
|
||||
- requirements: requirements/local.txt
|
||||
39
README.md
39
README.md
@ -1,7 +1,3 @@
|
||||
<<<<<<< HEAD
|
||||
# Di-rios-Oficiais-Alems
|
||||
Indexação dos Diários Oficiais da ALEMS
|
||||
=======
|
||||
# Diários Oficiais ALEMS
|
||||
|
||||
Indexação dos Diários Oficiais da ALEMS
|
||||
@ -9,49 +5,48 @@ Indexação dos Diários Oficiais da ALEMS
|
||||
[](https://github.com/cookiecutter/cookiecutter-django/)
|
||||
[](https://github.com/astral-sh/ruff)
|
||||
|
||||
## Settings
|
||||
## Configurações
|
||||
|
||||
Moved to [settings](https://cookiecutter-django.readthedocs.io/en/latest/1-getting-started/settings.html).
|
||||
Consulte [settings](https://cookiecutter-django.readthedocs.io/en/latest/1-getting-started/settings.html).
|
||||
|
||||
## Basic Commands
|
||||
## Comandos Básicos
|
||||
|
||||
### Setting Up Your Users
|
||||
### Configuração de Usuários
|
||||
|
||||
- To create a **normal user account**, just go to Sign Up and fill out the form. Once you submit it, you'll see a "Verify Your E-mail Address" page. Go to your console to see a simulated email verification message. Copy the link into your browser. Now the user's email should be verified and ready to go.
|
||||
- Para criar uma **conta de usuário normal**, vá para Cadastrar e preencha o formulário. Após enviar, você verá uma página "Verifique Seu Endereço de E-mail". Verifique no console a mensagem simulada de verificação de e-mail. Copie o link no seu navegador. Agora o e-mail do usuário estará verificado e pronto para uso.
|
||||
|
||||
- To create a **superuser account**, use this command:
|
||||
- Para criar uma **conta de superusuário**, use o comando:
|
||||
|
||||
$ python manage.py createsuperuser
|
||||
|
||||
For convenience, you can keep your normal user logged in on Chrome and your superuser logged in on Firefox (or similar), so that you can see how the site behaves for both kinds of users.
|
||||
Para conveniência, você pode manter seu usuário normal logado no Chrome e seu superusuário logado no Firefox (ou similar), para ver como o site se comporta para ambos os tipos de usuários.
|
||||
|
||||
### Type checks
|
||||
### Verificação de Tipos
|
||||
|
||||
Running type checks with mypy:
|
||||
Executando verificação de tipos com mypy:
|
||||
|
||||
$ mypy diários_oficiais_alems
|
||||
|
||||
### Test coverage
|
||||
### Cobertura de Testes
|
||||
|
||||
To run the tests, check your test coverage, and generate an HTML coverage report:
|
||||
Para executar os testes, verificar a cobertura e gerar um relatório HTML de cobertura:
|
||||
|
||||
$ coverage run -m pytest
|
||||
$ coverage html
|
||||
$ open htmlcov/index.html
|
||||
|
||||
#### Running tests with pytest
|
||||
#### Executando testes com pytest
|
||||
|
||||
$ pytest
|
||||
|
||||
### Live reloading and Sass CSS compilation
|
||||
### Recarregamento automático e compilação Sass CSS
|
||||
|
||||
Moved to [Live reloading and SASS compilation](https://cookiecutter-django.readthedocs.io/en/latest/2-local-development/developing-locally.html#using-webpack-or-gulp).
|
||||
Consulte [Live reloading and SASS compilation](https://cookiecutter-django.readthedocs.io/en/latest/2-local-development/developing-locally.html#using-webpack-or-gulp).
|
||||
|
||||
## Deployment
|
||||
## Deploy
|
||||
|
||||
The following details how to deploy this application.
|
||||
A seguir estão os detalhes para fazer o deploy desta aplicação.
|
||||
|
||||
### Docker
|
||||
|
||||
See detailed [cookiecutter-django Docker documentation](https://cookiecutter-django.readthedocs.io/en/latest/3-deployment/deployment-with-docker.html).
|
||||
>>>>>>> d8d0562 (start project)
|
||||
Consulte a [documentação Docker do cookiecutter-django](https://cookiecutter-django.readthedocs.io/en/latest/3-deployment/deployment-with-docker.html).
|
||||
@ -1,28 +1,28 @@
|
||||
# define an alias for the specific python version used in this file.
|
||||
FROM docker.io/python:3.12.9-slim-bookworm AS python
|
||||
# FROM docker.io/python:3.12.9-slim-bookworm AS python # Linha removida, já definida abaixo
|
||||
|
||||
# Python build stage
|
||||
FROM python AS python-build-stage
|
||||
# Python build stage - Mantenha apenas dependências de BUILD aqui
|
||||
FROM docker.io/python:3.12.9-slim-bookworm AS python-build-stage
|
||||
|
||||
ARG BUILD_ENVIRONMENT=local
|
||||
|
||||
# Install apt packages
|
||||
# Instalar apenas dependências para construir pacotes Python
|
||||
RUN apt-get update && apt-get install --no-install-recommends -y \
|
||||
# dependencies for building Python packages
|
||||
build-essential \
|
||||
# psycopg dependencies
|
||||
libpq-dev
|
||||
build-essential \
|
||||
libpq-dev \
|
||||
# libtesseract-dev pode ser necessário aqui se alguma lib Python compila contra ele
|
||||
libtesseract-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Requirements are installed here to ensure they will be cached.
|
||||
# Requirements são instalados aqui para garantir que serão cacheados.
|
||||
COPY ./requirements .
|
||||
|
||||
# Create Python Dependency and Sub-Dependency Wheels.
|
||||
# Criar Wheels das dependências Python.
|
||||
RUN pip wheel --wheel-dir /usr/src/app/wheels \
|
||||
-r ${BUILD_ENVIRONMENT}.txt
|
||||
-r ${BUILD_ENVIRONMENT}.txt
|
||||
|
||||
|
||||
# Python 'run' stage
|
||||
FROM python AS python-run-stage
|
||||
# Python 'run' stage - ESTA É A IMAGEM FINAL
|
||||
FROM docker.io/python:3.12.9-slim-bookworm AS python-run-stage
|
||||
|
||||
ARG BUILD_ENVIRONMENT=local
|
||||
ARG APP_HOME=/app
|
||||
@ -33,48 +33,46 @@ ENV BUILD_ENV=${BUILD_ENVIRONMENT}
|
||||
|
||||
WORKDIR ${APP_HOME}
|
||||
|
||||
|
||||
# devcontainer dependencies and utils
|
||||
# Instalar TODAS as dependências de sistema necessárias em TEMPO DE EXECUÇÃO
|
||||
RUN apt-get update && apt-get install --no-install-recommends -y \
|
||||
sudo git bash-completion nano ssh
|
||||
# Dependências do psycopg e outras utilidades
|
||||
libpq-dev \
|
||||
wait-for-it \
|
||||
gettext \
|
||||
poppler-utils \
|
||||
unpaper \
|
||||
tesseract-ocr \
|
||||
tesseract-ocr-por \
|
||||
ghostscript \
|
||||
# libtesseract-dev \
|
||||
# Utilitários do devcontainer e outros
|
||||
sudo git bash-completion vim ssh \
|
||||
# Limpeza
|
||||
&& apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create devcontainer user and add it to sudoers
|
||||
# Criar usuário devcontainer (manter como está)
|
||||
RUN groupadd --gid 1000 dev-user \
|
||||
&& useradd --uid 1000 --gid dev-user --shell /bin/bash --create-home dev-user \
|
||||
&& echo dev-user ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/dev-user \
|
||||
&& chmod 0440 /etc/sudoers.d/dev-user
|
||||
&& useradd --uid 1000 --gid dev-user --shell /bin/bash --create-home dev-user \
|
||||
&& echo dev-user ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/dev-user \
|
||||
&& chmod 0440 /etc/sudoers.d/dev-user
|
||||
|
||||
|
||||
# Install required system dependencies
|
||||
RUN apt-get update && apt-get install --no-install-recommends -y \
|
||||
# psycopg dependencies
|
||||
libpq-dev \
|
||||
wait-for-it \
|
||||
# Translations dependencies
|
||||
gettext \
|
||||
# cleaning up unused files
|
||||
&& apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# All absolute dir copies ignore workdir instruction. All relative dir copies are wrt to the workdir instruction
|
||||
# copy python dependency wheels from python-build-stage
|
||||
# Instalar dependências Python a partir dos wheels (manter como está)
|
||||
COPY --from=python-build-stage /usr/src/app/wheels /wheels/
|
||||
|
||||
# use wheels to install python dependencies
|
||||
RUN pip install --no-cache-dir --no-index --find-links=/wheels/ /wheels/* \
|
||||
&& rm -rf /wheels/
|
||||
&& rm -rf /wheels/
|
||||
|
||||
# Copiar scripts e código (manter como está)
|
||||
COPY ./compose/production/django/entrypoint /entrypoint
|
||||
RUN sed -i 's/\r$//g' /entrypoint
|
||||
RUN chmod +x /entrypoint
|
||||
|
||||
COPY ./compose/local/django/start /start
|
||||
RUN sed -i 's/\r$//g' /start
|
||||
RUN chmod +x /start
|
||||
|
||||
|
||||
|
||||
# copy application code to WORKDIR
|
||||
COPY . ${APP_HOME}
|
||||
|
||||
# REMOVER esta linha, poppler-utils já foi instalado acima
|
||||
# RUN apt-get update && apt-get install -y poppler-utils
|
||||
|
||||
ENTRYPOINT ["/entrypoint"]
|
||||
|
||||
|
||||
11
config/api.py
Normal file
11
config/api.py
Normal file
@ -0,0 +1,11 @@
|
||||
from ninja import NinjaAPI
|
||||
from diarios.views import router as diarios_router
|
||||
|
||||
|
||||
api = NinjaAPI(
|
||||
title="API de Diários Oficiais",
|
||||
version="1.0.0",
|
||||
description="API para busca em diários oficiais",
|
||||
)
|
||||
|
||||
api.add_router("/diarios/", diarios_router)
|
||||
@ -7,11 +7,11 @@ from pathlib import Path
|
||||
import environ
|
||||
|
||||
BASE_DIR = Path(__file__).resolve(strict=True).parent.parent.parent
|
||||
# diários_oficiais_alems/
|
||||
APPS_DIR = BASE_DIR / "diários_oficiais_alems"
|
||||
# diarios_oficiais_alems/
|
||||
APPS_DIR = BASE_DIR / "diarios_oficiais_alems"
|
||||
env = environ.Env()
|
||||
|
||||
READ_DOT_ENV_FILE = env.bool("DJANGO_READ_DOT_ENV_FILE", default=False)
|
||||
READ_DOT_ENV_FILE = env.bool("DJANGO_READ_DOT_ENV_FILE", default=True)
|
||||
if READ_DOT_ENV_FILE:
|
||||
# OS environment variables take precedence over variables from .env
|
||||
env.read_env(str(BASE_DIR / ".env"))
|
||||
@ -47,7 +47,7 @@ LOCALE_PATHS = [str(BASE_DIR / "locale")]
|
||||
# ------------------------------------------------------------------------------
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#databases
|
||||
DATABASES = {"default": env.db("DATABASE_URL")}
|
||||
DATABASES["default"]["ATOMIC_REQUESTS"] = True
|
||||
DATABASES["default"]["ATOMIC_REQUESTS"] = False
|
||||
# https://docs.djangoproject.com/en/stable/ref/settings/#std:setting-DEFAULT_AUTO_FIELD
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
@ -81,10 +81,10 @@ THIRD_PARTY_APPS = [
|
||||
]
|
||||
|
||||
LOCAL_APPS = [
|
||||
"diários_oficiais_alems.users",
|
||||
"diarios_oficiais_alems.users",
|
||||
"diarios",
|
||||
"django_elasticsearch_dsl",
|
||||
"rest_framework",
|
||||
"corsheaders",
|
||||
]
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
|
||||
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
||||
@ -92,7 +92,7 @@ INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
||||
# MIGRATIONS
|
||||
# ------------------------------------------------------------------------------
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#migration-modules
|
||||
MIGRATION_MODULES = {"sites": "diários_oficiais_alems.contrib.sites.migrations"}
|
||||
MIGRATION_MODULES = {"sites": "diarios_oficiais_alems.contrib.sites.migrations"}
|
||||
|
||||
# AUTHENTICATION
|
||||
# ------------------------------------------------------------------------------
|
||||
@ -138,6 +138,8 @@ MIDDLEWARE = [
|
||||
"django.middleware.locale.LocaleMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"corsheaders.middleware.CorsMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
@ -187,7 +189,7 @@ TEMPLATES = [
|
||||
"django.template.context_processors.static",
|
||||
"django.template.context_processors.tz",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
"diários_oficiais_alems.users.context_processors.allauth_settings",
|
||||
"diarios_oficiais_alems.users.context_processors.allauth_settings",
|
||||
],
|
||||
},
|
||||
},
|
||||
@ -273,14 +275,14 @@ ACCOUNT_EMAIL_REQUIRED = True
|
||||
# https://docs.allauth.org/en/latest/account/configuration.html
|
||||
ACCOUNT_EMAIL_VERIFICATION = "mandatory"
|
||||
# https://docs.allauth.org/en/latest/account/configuration.html
|
||||
ACCOUNT_ADAPTER = "diários_oficiais_alems.users.adapters.AccountAdapter"
|
||||
ACCOUNT_ADAPTER = "diarios_oficiais_alems.users.adapters.AccountAdapter"
|
||||
# https://docs.allauth.org/en/latest/account/forms.html
|
||||
ACCOUNT_FORMS = {"signup": "diários_oficiais_alems.users.forms.UserSignupForm"}
|
||||
ACCOUNT_FORMS = {"signup": "diarios_oficiais_alems.users.forms.UserSignupForm"}
|
||||
# https://docs.allauth.org/en/latest/socialaccount/configuration.html
|
||||
SOCIALACCOUNT_ADAPTER = "diários_oficiais_alems.users.adapters.SocialAccountAdapter"
|
||||
SOCIALACCOUNT_ADAPTER = "diarios_oficiais_alems.users.adapters.SocialAccountAdapter"
|
||||
# https://docs.allauth.org/en/latest/socialaccount/configuration.html
|
||||
SOCIALACCOUNT_FORMS = {
|
||||
"signup": "diários_oficiais_alems.users.forms.UserSocialSignupForm"
|
||||
"signup": "diarios_oficiais_alems.users.forms.UserSocialSignupForm"
|
||||
}
|
||||
# django-compressor
|
||||
# ------------------------------------------------------------------------------
|
||||
@ -290,40 +292,67 @@ STATICFILES_FINDERS += ["compressor.finders.CompressorFinder"]
|
||||
|
||||
# Elastic Search
|
||||
# ------------------------------------------------------------------------------
|
||||
ELASTICSEARCH_USER = env.str("ELASTICSEARCH_USER")
|
||||
ELASTICSEARCH_PASSWORD = env.str("ELASTICSEARCH_PASSWORD")
|
||||
|
||||
ELASTICSEARCH_DSL = {
|
||||
"default": {"hosts": "http://elasticsearch:9200"}, # same as above
|
||||
"default": {
|
||||
"hosts": "http://elasticsearch:9200",
|
||||
"timeout": 60,
|
||||
"http_auth": (
|
||||
ELASTICSEARCH_USER,
|
||||
ELASTICSEARCH_PASSWORD,
|
||||
),
|
||||
},
|
||||
}
|
||||
ELASTICSEARCH_HOSTS = "http://elasticsearch:9200"
|
||||
|
||||
ELASTICSEARCH_INDEX_SETTINGS = {
|
||||
'number_of_shards': 1,
|
||||
'number_of_replicas': 0,
|
||||
'analysis': {
|
||||
'filter': {
|
||||
'portuguese_stop': {
|
||||
'type': 'stop',
|
||||
'stopwords': '_portuguese_'
|
||||
# Define o número de shards (partições) para o índice.
|
||||
"number_of_shards": 1,
|
||||
# Define o número de réplicas de cada shard.
|
||||
"number_of_replicas": 0,
|
||||
# Configurações de análise do Elasticsearch para processamento do texto
|
||||
"analysis": {
|
||||
# Definição de filtros de análise
|
||||
"filter": {
|
||||
# Filtro de remoção de stopwords (palavras comuns que não agregam significado)
|
||||
"portuguese_stop": {"type": "stop", "stopwords": "_portuguese_"},
|
||||
# Filtro de stemming para reduzir palavras à sua raiz (ex: "correndo" -> "correr")
|
||||
"portuguese_stemmer": {"type": "stemmer", "language": "portuguese"},
|
||||
# Filtro de sinônimos, carregando um arquivo externo com a lista de sinônimos
|
||||
"synonym_filter": {
|
||||
"type": "synonym",
|
||||
"synonyms_path": "analysis/sinonimos.txt", # Caminho para o arquivo de sinônimos
|
||||
},
|
||||
'portuguese_stemmer': {
|
||||
'type': 'stemmer',
|
||||
'language': 'portuguese'
|
||||
},
|
||||
'synonym_filter': {
|
||||
'type': 'synonym',
|
||||
'synonyms_path': 'analysis/sinonimos.txt',
|
||||
},
|
||||
# Definição de analisadores (combinações de tokenizer e filtros)
|
||||
"analyzer": {
|
||||
# Criando um analisador chamado "pt_analyzer" para português
|
||||
"pt_analyzer": {
|
||||
# Define o tokenizer como "standard", que quebra o texto em palavras básicas
|
||||
"tokenizer": "standard",
|
||||
# Lista de filtros a serem aplicados ao texto após a tokenização
|
||||
"filter": [
|
||||
# Converte todas as palavras para minúsculas
|
||||
"lowercase",
|
||||
# Aplica o filtro de remoção de stopwords em português
|
||||
"portuguese_stop",
|
||||
# Aplica o filtro de stemming para reduzir palavras à sua raiz
|
||||
"portuguese_stemmer",
|
||||
# Aplica o filtro de sinônimos, substituindo palavras por seus sinônimos conforme o arquivo
|
||||
"synonym_filter",
|
||||
],
|
||||
}
|
||||
},
|
||||
'analyzer': {
|
||||
'pt_analyzer': {
|
||||
'tokenizer': 'standard',
|
||||
'filter': [
|
||||
'lowercase',
|
||||
'portuguese_stop',
|
||||
'portuguese_stemmer',
|
||||
'synonym_filter'
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
CORS_ALLOWED_ORIGINS = [
|
||||
"http://109.199.98.226:8006",
|
||||
"http://109.199.98.226:8005",
|
||||
"http://localhost:8006",
|
||||
"http://localhost:8005",
|
||||
"http://127.0.0.1:8006",
|
||||
"http://127.0.0.1:8005",
|
||||
]
|
||||
|
||||
@ -6,21 +6,25 @@ from django.urls import include
|
||||
from django.urls import path
|
||||
from django.views import defaults as default_views
|
||||
from django.views.generic import TemplateView
|
||||
from .api import api
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("", TemplateView.as_view(template_name="pages/home.html"), name="home"),
|
||||
path('', include('diarios.urls')),
|
||||
path(
|
||||
"about/",
|
||||
TemplateView.as_view(template_name="pages/about.html"),
|
||||
name="about",
|
||||
),
|
||||
path("api/v1/", api.urls),
|
||||
# Django Admin, use {% url 'admin:index' %}
|
||||
path(settings.ADMIN_URL, admin.site.urls),
|
||||
# User management
|
||||
path("users/", include("diários_oficiais_alems.users.urls", namespace="users")),
|
||||
path("accounts/", include("allauth.urls")),
|
||||
path("users/", include("diarios_oficiais_alems.users.urls", namespace="users")),
|
||||
# path("accounts/", include("allauth.urls")),
|
||||
# Your stuff: custom urls includes go here
|
||||
path("diarios/", include("diarios.urls")),
|
||||
path("", include("diarios.urls")),
|
||||
# Media files
|
||||
*static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT),
|
||||
]
|
||||
|
||||
@ -22,9 +22,9 @@ from pathlib import Path
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
# This allows easy placement of apps within the interior
|
||||
# diários_oficiais_alems directory.
|
||||
# diarios_oficiais_alems directory.
|
||||
BASE_DIR = Path(__file__).resolve(strict=True).parent.parent
|
||||
sys.path.append(str(BASE_DIR / "diários_oficiais_alems"))
|
||||
sys.path.append(str(BASE_DIR / "diarios_oficiais_alems"))
|
||||
# We defer to a DJANGO_SETTINGS_MODULE already in the environment. This breaks
|
||||
# if running multiple sites in the same mod_wsgi process. To fix this, use
|
||||
# mod_wsgi daemon mode with each site in its own daemon process, or use
|
||||
|
||||
149
diarios/admin.py
149
diarios/admin.py
@ -1,13 +1,150 @@
|
||||
from django.contrib import admin
|
||||
from django.db import models
|
||||
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
|
||||
|
||||
from .models import DiarioOficial, TipoDiarioOficial
|
||||
|
||||
@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):
|
||||
pass
|
||||
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,)
|
||||
|
||||
@admin.register(TipoDiarioOficial)
|
||||
class TipoDiarioOficialAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
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 = ("conteudo", "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
5
diarios/api.py
Normal file
@ -0,0 +1,5 @@
|
||||
from ninja import NinjaAPI
|
||||
|
||||
|
||||
api = NinjaAPI()
|
||||
api.add_router("/diarios/", route)
|
||||
@ -4,3 +4,7 @@ from django.apps import AppConfig
|
||||
class DiariosConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "diarios"
|
||||
|
||||
def ready(self):
|
||||
import diarios.documents
|
||||
|
||||
|
||||
@ -1,8 +0,0 @@
|
||||
from django import template
|
||||
|
||||
register = template.Library()
|
||||
|
||||
@register.filter
|
||||
def get_range(value):
|
||||
return range(value)
|
||||
|
||||
@ -1,92 +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):
|
||||
tipo = fields.ObjectField(properties={
|
||||
'nome': fields.TextField()
|
||||
})
|
||||
|
||||
numero = fields.TextField()
|
||||
numero = fields.KeywordField()
|
||||
data = fields.DateField()
|
||||
link = fields.TextField()
|
||||
|
||||
# Campo para armazenar todas as páginas para busca
|
||||
content = fields.TextField(
|
||||
analyzer='pt_analyzer',
|
||||
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"),
|
||||
},
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
# Campo para armazenar páginas individualmente
|
||||
pages = fields.NestedField(properties={
|
||||
'number': fields.IntegerField(),
|
||||
'content': fields.TextField(
|
||||
analyzer='pt_analyzer',
|
||||
)
|
||||
})
|
||||
|
||||
class Index:
|
||||
name = 'diarios_oficiais'
|
||||
name = "diario_oficial"
|
||||
settings = {
|
||||
'number_of_shards': 1,
|
||||
'number_of_replicas': 0,
|
||||
'analysis': {
|
||||
'filter': {
|
||||
'portuguese_stop': {
|
||||
'type': 'stop',
|
||||
'stopwords': '_portuguese_'
|
||||
},
|
||||
'portuguese_stemmer': {
|
||||
'type': 'stemmer',
|
||||
'language': 'portuguese'
|
||||
},
|
||||
'synonym_filter': {
|
||||
'type': 'synonym',
|
||||
'synonyms': [
|
||||
'lei, legislação, norma',
|
||||
'processo, procedimento, autos',
|
||||
'contrato, acordo, convênio',
|
||||
]
|
||||
"number_of_shards": 1,
|
||||
"number_of_replicas": 0,
|
||||
"analysis": {
|
||||
"analyzer": {
|
||||
"custom_portuguese": {
|
||||
"type": "custom",
|
||||
"tokenizer": "standard",
|
||||
"filter": [
|
||||
"lowercase",
|
||||
"asciifolding",
|
||||
"portuguese_stop",
|
||||
],
|
||||
}
|
||||
},
|
||||
'analyzer': {
|
||||
'pt_analyzer': {
|
||||
'tokenizer': 'standard',
|
||||
'filter': [
|
||||
'lowercase',
|
||||
'portuguese_stop',
|
||||
'portuguese_stemmer',
|
||||
'synonym_filter'
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
"filter": {
|
||||
"portuguese_stop": {"type": "stop", "stopwords": "_portuguese_"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class Django:
|
||||
model = DiarioOficial
|
||||
fields = [
|
||||
'id'
|
||||
]
|
||||
|
||||
def prepare_tipo(self, instance):
|
||||
if instance.tipo:
|
||||
return {
|
||||
'nome': instance.tipo.nome
|
||||
}
|
||||
return {}
|
||||
|
||||
def prepare_content(self, instance):
|
||||
"""Concatena todo o conteúdo de todas as páginas em um único campo para busca"""
|
||||
if instance.page_content:
|
||||
return " ".join([page.get('content', '') for page in instance.page_content])
|
||||
return ""
|
||||
|
||||
def prepare_pages(self, instance):
|
||||
"""Prepara o campo de páginas individuais para exibição e destaque"""
|
||||
if instance.page_content:
|
||||
return instance.page_content
|
||||
return []
|
||||
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
21
diarios/forms.py
Normal 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
|
||||
105
diarios/management/commands/importar_diarios_com_ocr.py
Normal file
105
diarios/management/commands/importar_diarios_com_ocr.py
Normal 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)
|
||||
@ -1,24 +0,0 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django_elasticsearch_dsl.registries import registry
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Reindexar todos os Diários Oficiais no Elasticsearch'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.stdout.write('Iniciando reindexação...')
|
||||
|
||||
# Recria os índices
|
||||
registry.delete_indices()
|
||||
registry.create_indices()
|
||||
|
||||
# Reindexar documentos
|
||||
for index in registry.get_indices():
|
||||
self.stdout.write(f'Reindexando {index}...')
|
||||
documents = []
|
||||
for doc in registry.get_documents():
|
||||
if index == doc._index._name:
|
||||
self.stdout.write(f' + {doc.__name__}')
|
||||
doc().update()
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('Reindexação concluída!'))
|
||||
|
||||
@ -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")},
|
||||
},
|
||||
),
|
||||
]
|
||||
18
diarios/migrations/0007_diariooficial_layout_duas_colunas.py
Normal file
18
diarios/migrations/0007_diariooficial_layout_duas_colunas.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
@ -1,19 +1,21 @@
|
||||
import json
|
||||
import os
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import PyPDF2
|
||||
import requests
|
||||
from babel.dates import format_date
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db import models
|
||||
from django.core.exceptions import ValidationError
|
||||
import PyPDF2
|
||||
from asgiref.sync import async_to_sync
|
||||
|
||||
|
||||
class TipoDiarioOficial(models.Model):
|
||||
"""Representa um tipo de Diário Oficial (e.g., Municipal, Estadual, Federal)."""
|
||||
|
||||
nome = models.CharField(max_length=100, unique=True)
|
||||
|
||||
def __str__(self):
|
||||
"""Retorna o nome do tipo de Diário Oficial."""
|
||||
return self.nome
|
||||
|
||||
class Meta:
|
||||
@ -21,6 +23,16 @@ class TipoDiarioOficial(models.Model):
|
||||
|
||||
|
||||
class DiarioOficial(models.Model):
|
||||
"""Modelo que representa um Diário Oficial, contendo data, arquivo PDF, tipo e link.
|
||||
|
||||
Attributes:
|
||||
data (DateField): Data de publicação do Diário Oficial.
|
||||
arquivo (FileField): Arquivo PDF do Diário Oficial (opcional).
|
||||
tipo (ForeignKey): Tipo do Diário Oficial (Municipal, Estadual, etc.).
|
||||
numero (CharField): Número de identificação único do Diário.
|
||||
link (URLField): URL para o Diário Oficial (opcional).
|
||||
"""
|
||||
|
||||
data = models.DateField()
|
||||
arquivo = models.FileField(upload_to="diarios_oficiais/", blank=True, null=True)
|
||||
tipo = models.ForeignKey(
|
||||
@ -32,63 +44,128 @@ class DiarioOficial(models.Model):
|
||||
)
|
||||
numero = models.CharField(max_length=20, unique=True)
|
||||
link = models.URLField(blank=True, null=True, unique=True)
|
||||
page_content = models.JSONField(encoder=DjangoJSONEncoder, blank=True, null=True)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Se houver um link, baixa o PDF e extrai o conteúdo
|
||||
if self.link and not self.arquivo:
|
||||
try:
|
||||
# Faz o download do PDF
|
||||
response = requests.get(self.link)
|
||||
response.raise_for_status() # Verifica se o download foi bem-sucedido
|
||||
|
||||
# Define o nome do arquivo a partir do link
|
||||
parsed_url = urlparse(self.link)
|
||||
file_name = (
|
||||
os.path.basename(parsed_url.path) or f"diario_{self.numero}.pdf"
|
||||
)
|
||||
|
||||
# Salva o arquivo no campo `arquivo`
|
||||
self.arquivo.save(file_name, ContentFile(response.content), save=False)
|
||||
|
||||
# Extrai o conteúdo do PDF
|
||||
pdf = PyPDF2.PdfReader(self.arquivo)
|
||||
pages_data = []
|
||||
|
||||
for i, pagina in enumerate(pdf.pages):
|
||||
page_text = pagina.extract_text()
|
||||
if page_text: # Ignora páginas sem conteúdo
|
||||
pages_data.append(
|
||||
{
|
||||
"number": i + 1,
|
||||
"content": page_text,
|
||||
}
|
||||
)
|
||||
|
||||
# Salva o conteúdo das páginas no campo `page_content`
|
||||
self.page_content = pages_data
|
||||
|
||||
except requests.RequestException as e:
|
||||
print(f"Erro ao baixar o PDF: {e}")
|
||||
except PyPDF2.PdfReadError as e:
|
||||
print(f"Erro ao ler o PDF: {e}")
|
||||
except Exception as e:
|
||||
print(f"Erro inesperado: {e}")
|
||||
|
||||
# Salva o modelo
|
||||
"""Salva o Diário Oficial, baixa o PDF (se houver link) e extrai páginas."""
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
if self.link and not self.arquivo:
|
||||
self._download_pdf_from_link()
|
||||
|
||||
if self.arquivo and not self.paginas:
|
||||
self._extract_pdf_pages()
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def clean(self):
|
||||
"""Valida o modelo antes de salvar (chamado automaticamente no admin/form)."""
|
||||
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):
|
||||
"""Verifica se o link é um PDF válido."""
|
||||
if not self.link.lower().endswith(".pdf"):
|
||||
raise ValidationError("O link deve apontar para um arquivo PDF.")
|
||||
|
||||
def _download_pdf_from_link(self):
|
||||
"""Faz download do PDF a partir do link e salva no campo `arquivo`.
|
||||
|
||||
Raises:
|
||||
ValidationError: Se o download falhar.
|
||||
"""
|
||||
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):
|
||||
"""Extrai o texto de cada página do PDF e salva no modelo `PageDiarioOficial`.
|
||||
|
||||
Raises:
|
||||
ValidationError: Se a extração falhar.
|
||||
"""
|
||||
try:
|
||||
with self.arquivo.open("rb") as pdf_file:
|
||||
pdf = PyPDF2.PdfReader(pdf_file)
|
||||
self._process_pdf_pages(pdf)
|
||||
except Exception as pdf_error:
|
||||
raise ValidationError(f"Não foi possível processar o PDF: {pdf_error}")
|
||||
|
||||
def _process_pdf_pages(self, pdf):
|
||||
"""Processa cada página do PDF e salva seu conteúdo.
|
||||
|
||||
Args:
|
||||
pdf (PdfReader): Objeto PDF carregado.
|
||||
"""
|
||||
self.paginas.all().delete()
|
||||
|
||||
for i, pagina in enumerate(pdf.pages):
|
||||
try:
|
||||
page_text = pagina.extract_text()
|
||||
if page_text and page_text.strip():
|
||||
PageDiarioOficial.objects.create(
|
||||
diario=self,
|
||||
numero=i + 1,
|
||||
conteudo=page_text.strip(),
|
||||
)
|
||||
except Exception as page_error:
|
||||
PageDiarioOficial.objects.create(
|
||||
diario=self,
|
||||
numero=i + 1,
|
||||
conteudo=f"[Erro na extração do texto: {str(page_error)}]",
|
||||
)
|
||||
continue
|
||||
|
||||
@property
|
||||
def data_formatada(self):
|
||||
"""Retorna a data formatada em português (e.g., '1 de Janeiro de 2023')."""
|
||||
return format_date(self.data, format="long", locale="pt_BR")
|
||||
|
||||
@property
|
||||
def is_online(self):
|
||||
return True if self.link else False
|
||||
"""Verifica se o Diário possui um link (online)."""
|
||||
return bool(self.link)
|
||||
|
||||
def __str__(self):
|
||||
return f"Diário {self.tipo.nome} nº {self.numero}, {self.data_formatada}"
|
||||
"""Representação em string do Diário Oficial."""
|
||||
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):
|
||||
"""Representa uma página de um Diário Oficial com seu conteúdo textual.
|
||||
|
||||
Attributes:
|
||||
diario (ForeignKey): Diário Oficial associado.
|
||||
layout_duas_colunas (BooleanField): Indica se a página tem duas colunas.
|
||||
numero (PositiveIntegerField): Número da página no Diário.
|
||||
conteudo (TextField): Texto extraído da página.
|
||||
"""
|
||||
|
||||
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 __str__(self):
|
||||
"""Representação em string da página (e.g., 'Página 1 do Diário 123')."""
|
||||
return f"Página {self.numero} do Diário {self.diario.numero}"
|
||||
|
||||
|
||||
63
diarios/schemas.py
Normal file
63
diarios/schemas.py
Normal file
@ -0,0 +1,63 @@
|
||||
from ninja import Schema
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
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]
|
||||
@ -1,67 +1,733 @@
|
||||
from elasticsearch_dsl import Q, Search
|
||||
from .documents import DiarioOficialDocument
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any, List
|
||||
from elasticsearch import Elasticsearch, AsyncElasticsearch
|
||||
from .schemas import ResultadoSchema, PaginaSchema
|
||||
import unicodedata
|
||||
import asyncio
|
||||
from django.conf import settings
|
||||
|
||||
class DiarioOficialSearchService:
|
||||
@staticmethod
|
||||
def search(query, highlight=True, fuzziness=1, page=1, page_size=10, tipos=None, data_inicio=None, data_fim=None):
|
||||
# Configura a busca básica
|
||||
s = DiarioOficialDocument.search().source(excludes=['page_content.content'])
|
||||
|
||||
# Filtros
|
||||
if tipos:
|
||||
s = s.filter('terms', tipo_nome=tipos)
|
||||
if data_inicio and data_fim:
|
||||
s = s.filter('range', data={'gte': data_inicio, 'lte': data_fim})
|
||||
|
||||
# Query principal com fuzziness e sinônimos
|
||||
main_query = Q(
|
||||
'multi_match',
|
||||
query=query,
|
||||
fields=[
|
||||
'numero^3', # Maior peso para o número
|
||||
'tipo_nome^2', # Peso médio para o tipo
|
||||
'page_content.content' # Peso padrão para o conteúdo
|
||||
],
|
||||
fuzziness=fuzziness,
|
||||
analyzer='portuguese_synonyms'
|
||||
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,
|
||||
),
|
||||
)
|
||||
s = s.query(main_query)
|
||||
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}
|
||||
|
||||
# Highlighting
|
||||
if highlight:
|
||||
s = s.highlight(
|
||||
'page_content.content',
|
||||
fragment_size=150,
|
||||
number_of_fragments=3,
|
||||
pre_tags=['<mark>'],
|
||||
post_tags=['</mark>']
|
||||
)
|
||||
es_body = {
|
||||
"query": {"bool": {"must": [], "filter": []}},
|
||||
"size": page_size,
|
||||
"from": (page - 1) * page_size,
|
||||
"_source": [
|
||||
"numero",
|
||||
"data",
|
||||
"link",
|
||||
"tipo",
|
||||
"paginas.numero",
|
||||
"paginas.conteudo",
|
||||
],
|
||||
}
|
||||
|
||||
# Paginação
|
||||
start = (page - 1) * page_size
|
||||
end = start + page_size
|
||||
s = s[start:end]
|
||||
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}}
|
||||
)
|
||||
|
||||
# Executa a busca
|
||||
response = s.execute()
|
||||
if tipo_diario:
|
||||
es_body["query"]["bool"]["filter"].append({"term": {"tipo.nome": tipo_diario}})
|
||||
|
||||
# Formata os resultados
|
||||
results = []
|
||||
for hit in response:
|
||||
result = {
|
||||
'id': hit.id,
|
||||
'numero': hit.numero,
|
||||
'data': hit.data,
|
||||
'link': hit.link,
|
||||
'tipo_nome': hit.tipo_nome,
|
||||
'score': hit.meta.score
|
||||
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%",
|
||||
}
|
||||
if highlight and hasattr(hit.meta, 'highlight'):
|
||||
result['highlights'] = hit.meta.highlight['page_content.content'].to_dict()
|
||||
results.append(result)
|
||||
|
||||
return {
|
||||
'total': response.hits.total.value,
|
||||
'results': results
|
||||
}
|
||||
|
||||
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
|
||||
"""
|
||||
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)
|
||||
|
||||
@ -1,15 +0,0 @@
|
||||
from django.db.models.signals import post_save, post_delete
|
||||
from django.dispatch import receiver
|
||||
|
||||
|
||||
@receiver(post_save, sender=DiarioOficial)
|
||||
def update_document(sender, instance, **kwargs):
|
||||
"""Atualizar documento no Elasticsearch quando o objeto for salvo"""
|
||||
DiarioOficialDocument.update_document(instance)
|
||||
|
||||
|
||||
@receiver(post_delete, sender=DiarioOficial)
|
||||
def delete_document(sender, instance, **kwargs):
|
||||
"""Deletar documento do Elasticsearch quando o objeto for deletado"""
|
||||
document = DiarioOficialDocument.get(id=instance.id)
|
||||
document.delete()
|
||||
@ -1,226 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<h1>Busca de Diários Oficiais</h1>
|
||||
|
||||
<form method="GET" action="{% url 'search_diarios' %}" class="mb-4">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-12">
|
||||
<div class="input-group">
|
||||
<input type="text" name="q" class="form-control" value="{{ query }}" placeholder="Digite sua busca...">
|
||||
<button type="submit" class="btn btn-primary">Buscar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4">
|
||||
<label for="date_start" class="form-label">Data inicial:</label>
|
||||
<input type="date" id="date_start" name="date_start" class="form-control" value="{{ date_start }}">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="date_end" class="form-label">Data final:</label>
|
||||
<input type="date" id="date_end" name="date_end" class="form-control" value="{{ date_end }}">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Tipo de correspondência:</label>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="match_type" id="match_partial" value="partial" {% if match_type == 'partial' or not match_type %}checked{% endif %}>
|
||||
<label class="form-check-label" for="match_partial">
|
||||
Qualquer palavra
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="match_type" id="match_exact" value="exact" {% if match_type == 'exact' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="match_exact">
|
||||
Todas as palavras (frase exata)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-danger">
|
||||
Erro na pesquisa: {{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if query %}
|
||||
<div class="mb-3">
|
||||
<h2>Resultados para "{{ query }}"</h2>
|
||||
<p>Encontrados {{ total }} resultados</p>
|
||||
|
||||
{% if did_you_mean %}
|
||||
<div class="alert alert-info">
|
||||
Você quis dizer: <a href="?q={{ did_you_mean }}&date_start={{ date_start }}&date_end={{ date_end }}&match_type={{ match_type }}">{{ did_you_mean }}</a>?
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if search_suggestions %}
|
||||
<div class="mt-3 mb-3">
|
||||
<h5>Pesquisas relacionadas:</h5>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
{% for suggestion in search_suggestions %}
|
||||
<a href="?q={{ suggestion }}&date_start={{ date_start }}&date_end={{ date_end }}&match_type={{ match_type }}" class="badge bg-light text-dark p-2 text-decoration-none">{{ suggestion }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if results %}
|
||||
<div class="search-results">
|
||||
{% for result in results %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h5>{{ result.tipo }} nº {{ result.numero }}</h5>
|
||||
<p class="text-muted">Data: {{ result.data }}</p>
|
||||
{% if result.occurrences > 0 %}
|
||||
<span class="badge bg-info">{{ result.occurrences }} ocorrências encontradas</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if result.highlight %}
|
||||
<div class="highlight-section mb-3">
|
||||
<h6>Destaques:</h6>
|
||||
<div class="highlight-content">{{ result.highlight|safe }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if result.highlighted_pages %}
|
||||
<div class="highlighted-pages">
|
||||
<h6>Páginas com o termo buscado:</h6>
|
||||
<div class="accordion" id="pagesAccordion{{ result.id }}">
|
||||
{% for page in result.highlighted_pages %}
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#page{{ result.id }}_{{ page.number }}">
|
||||
Página {{ page.number }}
|
||||
</button>
|
||||
</h2>
|
||||
<div id="page{{ result.id }}_{{ page.number }}" class="accordion-collapse collapse"
|
||||
data-bs-parent="#pagesAccordion{{ result.id }}">
|
||||
<div class="accordion-body">
|
||||
{{ page.content|safe }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-3">
|
||||
<a href="{{ result.link }}" target="_blank" class="btn btn-sm btn-outline-primary">
|
||||
Ver Diário Online
|
||||
</a>
|
||||
<a href="{% url 'diario_detail' result.id %}" class="btn btn-sm btn-outline-secondary">
|
||||
Ver Detalhes
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Paginação aprimorada -->
|
||||
{% if total_pages > 1 %}
|
||||
<nav aria-label="Paginação">
|
||||
<ul class="pagination justify-content-center">
|
||||
<!-- Botão primeira página -->
|
||||
<li class="page-item {% if page == 1 %}disabled{% endif %}">
|
||||
<a class="page-link" href="?q={{ query }}&date_start={{ date_start }}&date_end={{ date_end }}&match_type={{ match_type }}&page=1&size={{ size }}">
|
||||
<span aria-hidden="true">««</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<!-- Botão página anterior -->
|
||||
<li class="page-item {% if page == 1 %}disabled{% endif %}">
|
||||
<a class="page-link" href="?q={{ query }}&date_start={{ date_start }}&date_end={{ date_end }}&match_type={{ match_type }}&page={{ page|add:'-1' }}&size={{ size }}">
|
||||
<span aria-hidden="true">«</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<!-- Mostrar apenas um conjunto de páginas ao redor da página atual -->
|
||||
{% with ''|center:total_pages as range %}
|
||||
{% for _ in range %}
|
||||
{% with forloop.counter as i %}
|
||||
{% if i >= page|add:'-2' and i <= page|add:'2' and i > 0 and i <= total_pages %}
|
||||
<li class="page-item {% if i == page %}active{% endif %}">
|
||||
<a class="page-link" href="?q={{ query }}&date_start={{ date_start }}&date_end={{ date_end }}&match_type={{ match_type }}&page={{ i }}&size={{ size }}">{{ i }}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
|
||||
<!-- Botão próxima página -->
|
||||
<li class="page-item {% if page >= total_pages %}disabled{% endif %}">
|
||||
<a class="page-link" href="?q={{ query }}&date_start={{ date_start }}&date_end={{ date_end }}&match_type={{ match_type }}&page={{ page|add:'1' }}&size={{ size }}">
|
||||
<span aria-hidden="true">»</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<!-- Botão última página -->
|
||||
<li class="page-item {% if page >= total_pages %}disabled{% endif %}">
|
||||
<a class="page-link" href="?q={{ query }}&date_start={{ date_start }}&date_end={{ date_end }}&match_type={{ match_type }}&page={{ total_pages }}&size={{ size }}">
|
||||
<span aria-hidden="true">»»</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="alert alert-info">
|
||||
Nenhum resultado encontrado para a sua busca.
|
||||
|
||||
{% if date_start or date_end %}
|
||||
<p class="mt-2">Tente expandir o período de busca ou remover os filtros de data.</p>
|
||||
{% endif %}
|
||||
|
||||
{% if match_type == 'exact' %}
|
||||
<p class="mt-2">Tente usar a opção "Qualquer palavra" para resultados mais abrangentes.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.highlight-content em {
|
||||
background-color: #ffeeba;
|
||||
font-style: normal;
|
||||
padding: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.accordion-body em {
|
||||
background-color: #ffeeba;
|
||||
font-style: normal;
|
||||
padding: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Validar datas ao enviar o formulário
|
||||
document.querySelector('form').addEventListener('submit', function(e) {
|
||||
const dateStart = document.getElementById('date_start').value;
|
||||
const dateEnd = document.getElementById('date_end').value;
|
||||
|
||||
if (dateStart && dateEnd && dateStart > dateEnd) {
|
||||
e.preventDefault();
|
||||
alert('A data inicial não pode ser posterior à data final.');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@ -1,173 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Busca de Diários Oficiais{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<h1 class="mb-4">Busca de Diários Oficiais</h1>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<form method="get" action="{% url 'diario-search' %}">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-9">
|
||||
<label for="q" class="form-label">Buscar por:</label>
|
||||
<input type="text" id="q" name="q" value="{{ query }}"
|
||||
class="form-control"
|
||||
placeholder="Digite palavras-chave, frases ou utilize operadores AND, OR, NOT">
|
||||
</div>
|
||||
<div class="col-md-3 d-flex align-items-end">
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="bi bi-search"></i> Buscar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<a class="btn btn-link p-0" data-bs-toggle="collapse" href="#advancedOptions" role="button">
|
||||
Opções avançadas
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="collapse" id="advancedOptions">
|
||||
<div class="row g-3 mt-2">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Tipos de Diário:</label>
|
||||
<div class="border rounded p-2" style="max-height: 200px; overflow-y: auto;">
|
||||
{% for tipo in tipos_disponiveis %}
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox"
|
||||
id="tipo_{{ tipo.id }}" name="tipos" value="{{ tipo.id }}"
|
||||
{% if tipos_selecionados and tipo.id|stringformat:"i" in tipos_selecionados %}checked{% endif %}>
|
||||
<label class="form-check-label" for="tipo_{{ tipo.id }}">
|
||||
{{ tipo.nome }}
|
||||
</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label for="data_inicio" class="form-label">Data Inicial:</label>
|
||||
<input type="date" id="data_inicio" name="data_inicio"
|
||||
value="{{ data_inicio }}" class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label for="data_fim" class="form-label">Data Final:</label>
|
||||
<input type="date" id="data_fim" name="data_fim"
|
||||
value="{{ data_fim }}" class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label for="fuzziness" class="form-label">Tolerância a erros:</label>
|
||||
<select id="fuzziness" name="fuzziness" class="form-select">
|
||||
<option value="0" {% if fuzziness == 0 %}selected{% endif %}>Sem tolerância</option>
|
||||
<option value="1" {% if fuzziness == 1 %}selected{% endif %}>Baixa tolerância</option>
|
||||
<option value="2" {% if fuzziness == 2 %}selected{% endif %}>Alta tolerância</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 d-flex align-items-end">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="highlight"
|
||||
name="highlight" value="true" {% if highlight %}checked{% endif %}>
|
||||
<label class="form-check-label" for="highlight">
|
||||
Destacar termos encontrados
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if query %}
|
||||
<div class="mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Resultados da busca</h2>
|
||||
<span class="badge bg-primary">{{ total }} resultado(s)</span>
|
||||
</div>
|
||||
|
||||
{% if results %}
|
||||
{% for result in results %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">
|
||||
<a href="{% url 'diario-detail' result.id %}?q={{ query|urlencode }}"
|
||||
class="text-decoration-none">
|
||||
{{ result.tipo_nome }} nº {{ result.numero }} - {{ result.data|date:"d/m/Y" }}
|
||||
</a>
|
||||
</h5>
|
||||
|
||||
{% if result.highlights %}
|
||||
<div class="card-text mt-2">
|
||||
{% for highlight in result.highlights %}
|
||||
<p class="mb-1">...{{ highlight|safe }}...</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-3 text-muted small">
|
||||
<span class="me-3">
|
||||
<i class="bi bi-star-fill text-warning"></i> Relevância: {{ result.score|floatformat:2 }}
|
||||
</span>
|
||||
{% if result.link %}
|
||||
<a href="{{ result.link }}" target="_blank" class="text-decoration-none">
|
||||
<i class="bi bi-box-arrow-up-right"></i> Ver original
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% if pages > 1 %}
|
||||
<nav aria-label="Page navigation">
|
||||
<ul class="pagination justify-content-center">
|
||||
{% if page > 1 %}
|
||||
<li class="page-item">
|
||||
<a class="page-link"
|
||||
href="?q={{ query }}&page={{ page|add:'-1' }}&highlight={{ highlight|lower }}&fuzziness={{ fuzziness }}{% for tipo in tipos_selecionados %}&tipos={{ tipo }}{% endfor %}{% if data_inicio %}&data_inicio={{ data_inicio }}{% endif %}{% if data_fim %}&data_fim={{ data_fim }}{% endif %}">
|
||||
Anterior
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% for i in page_range %}
|
||||
<li class="page-item {% if i == page %}active{% endif %}">
|
||||
<a class="page-link"
|
||||
href="?q={{ query }}&page={{ i }}&highlight={{ highlight|lower }}&fuzziness={{ fuzziness }}{% for tipo in tipos_selecionados %}&tipos={{ tipo }}{% endfor %}{% if data_inicio %}&data_inicio={{ data_inicio }}{% endif %}{% if data_fim %}&data_fim={{ data_fim }}{% endif %}">
|
||||
{{ i }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
||||
{% if page < pages %}
|
||||
<li class="page-item">
|
||||
<a class="page-link"
|
||||
href="?q={{ query }}&page={{ page|add:'1' }}&highlight={{ highlight|lower }}&fuzziness={{ fuzziness }}{% for tipo in tipos_selecionados %}&tipos={{ tipo }}{% endfor %}{% if data_inicio %}&data_inicio={{ data_inicio }}{% endif %}{% if data_fim %}&data_fim={{ data_fim }}{% endif %}">
|
||||
Próxima
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="alert alert-warning text-center">
|
||||
<h4 class="alert-heading">Nenhum resultado encontrado</h4>
|
||||
<p>Não encontramos resultados para "{{ query }}". Tente ajustar seus termos de busca.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-5 bg-light rounded">
|
||||
<p class="lead text-muted">Digite um termo de busca para encontrar diários oficiais</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@ -1,3 +0,0 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
31
diarios/tests/factories.py
Normal file
31
diarios/tests/factories.py
Normal 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"]
|
||||
34
diarios/tests/test_models.py
Normal file
34
diarios/tests/test_models.py
Normal 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)
|
||||
@ -2,7 +2,5 @@ from django.urls import path
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path('diario/<int:pk>/', views.diario_detail, name='diario_detail'),
|
||||
path('diarios/search/', views.search_diarios, name='search_diarios'),
|
||||
path('busca/', views.index, name='index')
|
||||
]
|
||||
|
||||
|
||||
281
diarios/views.py
281
diarios/views.py
@ -1,209 +1,88 @@
|
||||
from ninja import Router
|
||||
from typing import Optional
|
||||
from django.http import HttpRequest
|
||||
from .search_service import (
|
||||
buscar_diarios,
|
||||
sugestao_termo,
|
||||
buscar_diarios_simples,
|
||||
)
|
||||
from .schemas import BuscaDiariosResponseSchema, SugestaoResponse
|
||||
from django.shortcuts import render
|
||||
from elasticsearch_dsl import Q
|
||||
from datetime import datetime
|
||||
from .documents import DiarioOficialDocument
|
||||
from elasticsearch.exceptions import RequestError
|
||||
|
||||
def search_diarios(request):
|
||||
q = request.GET.get('q', '')
|
||||
page = int(request.GET.get('page', 1))
|
||||
size = int(request.GET.get('size', 10))
|
||||
|
||||
# Parâmetros de filtro de data
|
||||
date_start = request.GET.get('date_start', '')
|
||||
date_end = request.GET.get('date_end', '')
|
||||
|
||||
# Tipo de correspondência (exata ou parcial)
|
||||
match_type = request.GET.get('match_type', 'partial') # 'exact' ou 'partial'
|
||||
|
||||
start = (page - 1) * size
|
||||
end = start + size
|
||||
router = Router(tags=["Diários Oficiais"])
|
||||
|
||||
results = []
|
||||
total = 0
|
||||
did_you_mean = None
|
||||
search_suggestions = []
|
||||
|
||||
try:
|
||||
if q:
|
||||
# Construir a consulta base
|
||||
search = DiarioOficialDocument.search()
|
||||
|
||||
# Determinar o tipo de consulta com base no match_type
|
||||
if match_type == 'exact':
|
||||
# Correspondência exata (frase exata)
|
||||
query = Q(
|
||||
'multi_match',
|
||||
query=q,
|
||||
fields=['content^3', 'tipo.nome^2', 'numero', 'pages.content'],
|
||||
type='phrase'
|
||||
)
|
||||
else:
|
||||
# Correspondência parcial (qualquer termo)
|
||||
query = Q(
|
||||
'multi_match',
|
||||
query=q,
|
||||
fields=['content^3', 'tipo.nome^2', 'numero', 'pages.content'],
|
||||
fuzziness='AUTO',
|
||||
operator='or' # Pelo menos um termo deve corresponder
|
||||
)
|
||||
|
||||
# Aplicar a consulta principal
|
||||
search = search.query(query)
|
||||
|
||||
# Aplicar filtros de data se fornecidos
|
||||
date_filters = []
|
||||
if date_start:
|
||||
try:
|
||||
date_start_obj = datetime.strptime(date_start, '%Y-%m-%d')
|
||||
date_filters.append(Q('range', data={'gte': date_start_obj}))
|
||||
except ValueError:
|
||||
pass # Ignorar datas inválidas
|
||||
|
||||
if date_end:
|
||||
try:
|
||||
date_end_obj = datetime.strptime(date_end, '%Y-%m-%d')
|
||||
date_filters.append(Q('range', data={'lte': date_end_obj}))
|
||||
except ValueError:
|
||||
pass # Ignorar datas inválidas
|
||||
|
||||
if date_filters:
|
||||
for date_filter in date_filters:
|
||||
search = search.filter(date_filter)
|
||||
|
||||
# Configuração do highlighting
|
||||
search = search.highlight('content', fragment_size=150, number_of_fragments=3)
|
||||
search = search.highlight('pages.content', fragment_size=150, number_of_fragments=3)
|
||||
|
||||
# Paginação
|
||||
total_search = search.count()
|
||||
search = search[start:end]
|
||||
|
||||
# Executar a pesquisa
|
||||
response = search.execute()
|
||||
|
||||
total = response.hits.total.value
|
||||
|
||||
# "Você quis dizer" - sugestão para termos com erros de digitação
|
||||
if total < 3 and q: # Se poucos resultados, sugira correções
|
||||
suggestion_search = DiarioOficialDocument.search()
|
||||
suggestion_search = suggestion_search.suggest(
|
||||
'phrase_suggestion',
|
||||
q,
|
||||
phrase={
|
||||
'field': 'content',
|
||||
'size': 5,
|
||||
'highlight': {
|
||||
'pre_tag': '<em>',
|
||||
'post_tag': '</em>'
|
||||
}
|
||||
}
|
||||
)
|
||||
suggestion_result = suggestion_search.execute()
|
||||
|
||||
# Processe as sugestões
|
||||
if hasattr(suggestion_result, 'suggest') and 'phrase_suggestion' in suggestion_result.suggest:
|
||||
suggestions = suggestion_result.suggest['phrase_suggestion'][0]['options']
|
||||
if suggestions:
|
||||
for suggestion in suggestions:
|
||||
if suggestion['text'].lower() != q.lower():
|
||||
did_you_mean = suggestion['text']
|
||||
break
|
||||
# Gerar sugestões de pesquisa relacionadas
|
||||
if q:
|
||||
# Use a expansão de termos para sugerir pesquisas relacionadas
|
||||
related_search = DiarioOficialDocument.search()
|
||||
related_search = related_search.query(
|
||||
'more_like_this',
|
||||
fields=['content'],
|
||||
like=q,
|
||||
min_term_freq=1,
|
||||
max_query_terms=12
|
||||
)
|
||||
related_search = related_search[:5] # Limite para 5 sugestões
|
||||
|
||||
try:
|
||||
related_results = related_search.execute()
|
||||
|
||||
# Extraia termos relevantes dos resultados relacionados
|
||||
for hit in related_results:
|
||||
if hasattr(hit, 'content') and hit.content:
|
||||
# Extraia alguns termos significativos do conteúdo
|
||||
content_terms = hit.content.split()[:10] # Primeiros 10 termos
|
||||
suggestion = ' '.join(content_terms)
|
||||
if suggestion not in search_suggestions and suggestion != q:
|
||||
search_suggestions.append(suggestion)
|
||||
if len(search_suggestions) >= 5: # Limite para 5 sugestões
|
||||
break
|
||||
except:
|
||||
# Ignore erros de sugestões relacionadas
|
||||
pass
|
||||
|
||||
# Processar resultados
|
||||
for hit in response:
|
||||
# Adicionar destaque
|
||||
highlight = ""
|
||||
if hasattr(hit.meta, 'highlight'):
|
||||
if 'content' in hit.meta.highlight:
|
||||
highlight = "...".join(hit.meta.highlight.content)
|
||||
|
||||
# Processar páginas com destaque
|
||||
highlighted_pages = []
|
||||
total_occurrences = 0
|
||||
|
||||
if hasattr(hit.meta, 'highlight') and 'pages.content' in hit.meta.highlight:
|
||||
# Calcular o número total de ocorrências
|
||||
for content in hit.meta.highlight['pages.content']:
|
||||
# Contar o número de <em> tags, que representam termos destacados
|
||||
total_occurrences += content.count('<em>')
|
||||
|
||||
# Processar os destaques por página
|
||||
for i, content in enumerate(hit.meta.highlight['pages.content']):
|
||||
# Encontre a página correspondente
|
||||
page_number = i + 1 # Lógica simplificada, pode precisar de ajuste
|
||||
highlighted_pages.append({
|
||||
'number': page_number,
|
||||
'content': content
|
||||
})
|
||||
|
||||
# Combine dados do documento com os destaques
|
||||
result = {
|
||||
'id': hit.id,
|
||||
'tipo': hit.tipo.nome if hasattr(hit, 'tipo') and hit.tipo else '',
|
||||
'numero': hit.numero,
|
||||
'data': hit.data,
|
||||
'link': hit.link,
|
||||
'highlight': highlight,
|
||||
'highlighted_pages': highlighted_pages,
|
||||
'occurrences': total_occurrences
|
||||
}
|
||||
|
||||
results.append(result)
|
||||
except RequestError as e:
|
||||
# Tratar erros de consulta do Elasticsearch
|
||||
error_message = str(e)
|
||||
return render(request, 'diarios/diarios_search.html', {
|
||||
'error': error_message,
|
||||
'query': q
|
||||
})
|
||||
async def index(request):
|
||||
return render(request, 'diarios/busca.html')
|
||||
|
||||
context = {
|
||||
'query': q,
|
||||
'date_start': date_start,
|
||||
'date_end': date_end,
|
||||
'match_type': match_type,
|
||||
'results': results,
|
||||
'total': total,
|
||||
'page': page,
|
||||
'size': size,
|
||||
'total_pages': (total + size - 1) // size if total > 0 else 0,
|
||||
'did_you_mean': did_you_mean,
|
||||
'search_suggestions': search_suggestions[:5] # Limite para 5 sugestões
|
||||
}
|
||||
@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.
|
||||
|
||||
return render(request, 'diarios/diarios_search.html', context)
|
||||
Args:
|
||||
request (HttpRequest): Requisição HTTP.
|
||||
q (str): Termo original digitado pelo usuário.
|
||||
|
||||
def diario_detail(request, pk):
|
||||
diario = get_object_or_404(Diario, pk=pk)
|
||||
return render(request, 'diarios/diario_detail.html', {'diario': diario})
|
||||
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
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import pytest
|
||||
|
||||
from diários_oficiais_alems.users.models import User
|
||||
from diários_oficiais_alems.users.tests.factories import UserFactory
|
||||
from diarios_oficiais_alems.users.models import User
|
||||
from diarios_oficiais_alems.users.tests.factories import UserFactory
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 8.2 KiB |
@ -107,7 +107,7 @@
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="container">
|
||||
<div class="container">
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-dismissible {% if message.tags %}alert-{{ message.tags }}{% endif %}">
|
||||
301
diarios_oficiais_alems/templates/diarios/index.html
Normal file
301
diarios_oficiais_alems/templates/diarios/index.html
Normal file
@ -0,0 +1,301 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Sistema de Busca de Diários Oficiais</title>
|
||||
<!-- Bootstrap 5 CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<!-- Bootstrap Icons -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
||||
<!-- Alpine.js -->
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.1/dist/cdn.min.js"></script>
|
||||
<!-- Estilos customizados -->
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body class="bg-light-gradient">
|
||||
<div class="container py-5" x-data="searchApp">
|
||||
<div class="row justify-content-center mb-4">
|
||||
<div class="col-12 col-lg-10">
|
||||
<div class="text-center mb-5">
|
||||
<h1 class="display-5 fw-bold text-primary mb-3">
|
||||
<i class="bi bi-search me-2"></i>Sistema de Busca de Diários Oficiais
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="card search-card mb-5">
|
||||
<div class="card-body p-4">
|
||||
<form @submit.prevent="performSearch" class="row g-3">
|
||||
<div class="col-12">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||
<input type="text" class="form-control form-control-lg"
|
||||
x-model="searchParams.q"
|
||||
placeholder="Digite o termo de busca"
|
||||
aria-label="Termo de busca">
|
||||
<button class="btn btn-primary" type="submit">
|
||||
<span x-show="!isLoading">Buscar</span>
|
||||
<span x-show="isLoading" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Opções básicas de busca (sempre visíveis) -->
|
||||
<div class="col-12 mt-2">
|
||||
<div class="row align-items-end">
|
||||
<div class="col-md-4 mb-2 mb-md-0">
|
||||
<label for="numero_diario" class="form-label">Número do Diário</label>
|
||||
<input type="text" class="form-control" id="numero_diario"
|
||||
x-model="searchParams.numero_diario"
|
||||
placeholder="Ex: 1234">
|
||||
</div>
|
||||
<div class="col-md-4 mb-2 mb-md-0">
|
||||
<label for="modo_busca" class="form-label">Modo de Busca</label>
|
||||
<select class="form-select" id="modo_busca" x-model="searchParams.modo_busca">
|
||||
<option value="exata">Busca exata</option>
|
||||
<option value="qualquer">Qualquer termo</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6 col-xl-4">
|
||||
<div class="d-flex flex-column">
|
||||
<label class="form-label">Ordenar por</label>
|
||||
<div class="btn-group" role="group">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||
:class="{'active': searchParams.ordenar_por === 'relevancia'}"
|
||||
@click="changeOrder('relevancia')">
|
||||
<i class="bi bi-star me-1"></i>Relevância
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||
:class="{'active': searchParams.ordenar_por === 'data_desc'}"
|
||||
@click="changeOrder('data_desc')">
|
||||
<i class="bi bi-sort-down-alt me-1"></i>Data<br>(Decrescente)
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||
:class="{'active': searchParams.ordenar_por === 'data_asc'}"
|
||||
@click="changeOrder('data_asc')">
|
||||
<i class="bi bi-sort-down me-1"></i>Data<br>(Crescente)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 mt-3">
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" @click="showAdvanced = !showAdvanced">
|
||||
<span x-text="showAdvanced ? 'Ocultar filtros avançados' : 'Mostrar filtros avançados'"></span>
|
||||
<i class="bi" :class="showAdvanced ? 'bi-chevron-up' : 'bi-chevron-down'"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="col-12" x-show="showAdvanced" x-transition>
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-md-6">
|
||||
<label for="data_inicio" class="form-label">Data inicial</label>
|
||||
<input type="date" class="form-control date-picker" id="data_inicio" x-model="searchParams.data_inicio">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="data_fim" class="form-label">Data final</label>
|
||||
<input type="date" class="form-control date-picker" id="data_fim" x-model="searchParams.data_fim">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-md-6">
|
||||
<label for="page_size" class="form-label">Resultados por página</label>
|
||||
<select class="form-select" id="page_size" x-model="searchParams.page_size">
|
||||
<option value="10">10</option>
|
||||
<option value="20">20</option>
|
||||
<option value="30">30</option>
|
||||
<option value="50">50</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Adicionando a seção de "Você quis dizer" após iniciar a busca -->
|
||||
<template x-if="!isLoading && !error && searchResults && suggestion && shouldShowSuggestion">
|
||||
<div class="mb-3 mt-3 alert alert-info d-flex align-items-center">
|
||||
<i class="bi bi-lightbulb-fill me-2"></i>
|
||||
<span>Você quis dizer:
|
||||
<a href="#" @click.prevent="usesuggestion" class="alert-link" x-text="suggestion"></a>?
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Resultados -->
|
||||
<div x-show="hasSearched" class="mb-4">
|
||||
<template x-if="isLoading">
|
||||
<div class="d-flex justify-content-center my-5">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Carregando...</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!isLoading && error">
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
||||
<span x-text="error"></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!isLoading && !error && searchResults">
|
||||
<div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 class="h4 m-0">
|
||||
<span x-text="searchResults.total"></span> Resultados encontrados
|
||||
<template x-if="searchParams.q">
|
||||
<span>para "<span x-text="searchParams.q"></span>"</span>
|
||||
</template>
|
||||
</h2>
|
||||
<button @click="resetSearch" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-counterclockwise me-1"></i> Nova busca
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template x-if="searchResults.total === 0">
|
||||
<div class="alert alert-info" role="alert">
|
||||
<i class="bi bi-info-circle-fill me-2"></i>
|
||||
Nenhum resultado encontrado para os critérios de busca informados.
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="searchResults.total > 0">
|
||||
<div>
|
||||
<!-- Lista de resultados -->
|
||||
<div class="mb-4">
|
||||
<template x-for="(diario, diarioIndex) in searchResults.resultados" :key="diario.id">
|
||||
<div class="card result-card mb-4 border-0 shadow-sm">
|
||||
<div class="card-header bg-white py-3 d-flex justify-content-between align-items-center">
|
||||
<h5 class="card-title mb-0">
|
||||
<span class="badge bg-primary me-2" x-text="diario.tipo"></span>
|
||||
<span x-text="diario.numero"></span>
|
||||
</h5>
|
||||
<span class="text-muted" x-text="formatDate(diario.data)"></span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<template x-if="diario.paginas && diario.paginas.length > 0">
|
||||
<div>
|
||||
<!-- Melhor página encontrada (mostrada apenas se estiver ordenado por relevância e tiver score) -->
|
||||
<template x-if="hasBestMatch(diario)">
|
||||
<div class="mb-4 p-3 best-match rounded">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<div>
|
||||
<span class="page-badge me-2">Página <span x-text="getBestPage(diario.paginas).numero"></span></span>
|
||||
<span class="match-score">
|
||||
<i class="bi bi-star-fill me-1 small"></i>
|
||||
Melhor correspondência no diário
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-content">
|
||||
<div x-html="getBestPage(diario.paginas).conteudo"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Accordion de páginas -->
|
||||
<template x-if="diario.paginas.length > 1">
|
||||
<div class="accordion mt-3" :id="'accordionDiario' + diario.id">
|
||||
<div class="accordion-item border-0 mb-2">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed shadow-sm" type="button"
|
||||
data-bs-toggle="collapse"
|
||||
:data-bs-target="'#collapse' + diario.id"
|
||||
aria-expanded="false">
|
||||
<i class="bi bi-list-ul me-2"></i>
|
||||
<template x-if="hasBestMatch(diario) && diario.paginas.length > 1">
|
||||
<span>Ver mais <span class="mx-1" x-text="getOtherPages(diario.paginas).length"></span> páginas deste diário</span>
|
||||
</template>
|
||||
<template x-if="!hasBestMatch(diario) && diario.paginas.length > 1">
|
||||
<span>Ver <span class="mx-1" x-text="diario.paginas.length"></span> páginas deste diário</span>
|
||||
</template>
|
||||
</button>
|
||||
</h2>
|
||||
<div :id="'collapse' + diario.id" class="accordion-collapse collapse"
|
||||
:data-bs-parent="'#accordionDiario' + diario.id">
|
||||
<div class="accordion-body p-0">
|
||||
<div class="list-group list-group-flush">
|
||||
<template x-for="pagina in hasBestMatch(diario) ? getOtherPages(diario.paginas) : diario.paginas" :key="pagina.numero">
|
||||
<div class="list-group-item border-0 py-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<span class="page-badge">Página <span x-text="pagina.numero"></span></span>
|
||||
</div>
|
||||
<div class="page-preview" x-html="pagina.conteudo.substring(0, 200) + '...'"></div>
|
||||
<div x-show="isFullContentVisible(diarioIndex, pagina.numero)" x-transition class="mt-2 page-content border-top pt-3">
|
||||
<div x-html="pagina.conteudo"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="d-flex justify-content-end mt-3">
|
||||
<a :href="diario.link" target="_blank" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-file-earmark-pdf me-1"></i> Ver Diário Completo
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Paginação -->
|
||||
<nav aria-label="Navegação de páginas" x-show="totalPages > 1">
|
||||
<ul class="pagination justify-content-center">
|
||||
<li class="page-item" :class="{ 'disabled': searchParams.page <= 1 }">
|
||||
<a class="page-link" href="#" @click.prevent="goToPage(searchParams.page - 1)" aria-label="Anterior">
|
||||
<span aria-hidden="true">«</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<template x-for="page in paginationArray" :key="page">
|
||||
<li class="page-item" :class="{ 'active': page === searchParams.page }">
|
||||
<a class="page-link" href="#" @click.prevent="goToPage(page)" x-text="page"></a>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<li class="page-item" :class="{ 'disabled': searchParams.page >= totalPages }">
|
||||
<a class="page-link" href="#" @click.prevent="goToPage(searchParams.page + 1)" aria-label="Próximo">
|
||||
<span aria-hidden="true">»</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="bg-dark text-white py-4 mt-5">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h5>Sistema de Busca de Diários Oficiais</h5>
|
||||
<p class="small">Uma ferramenta avançada para pesquisa em diários oficiais.</p>
|
||||
</div>
|
||||
<div class="col-md-6 text-md-end">
|
||||
<p class="small mb-0">© 2025 Todos os direitos reservados</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Bootstrap Bundle with Popper -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<!-- Script da aplicação -->
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -10,7 +10,7 @@ if typing.TYPE_CHECKING:
|
||||
from allauth.socialaccount.models import SocialLogin
|
||||
from django.http import HttpRequest
|
||||
|
||||
from diários_oficiais_alems.users.models import User
|
||||
from diarios_oficiais_alems.users.models import User
|
||||
|
||||
|
||||
class AccountAdapter(DefaultAccountAdapter):
|
||||
@ -5,9 +5,9 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class UsersConfig(AppConfig):
|
||||
name = "diários_oficiais_alems.users"
|
||||
name = "diarios_oficiais_alems.users"
|
||||
verbose_name = _("Users")
|
||||
|
||||
def ready(self):
|
||||
with contextlib.suppress(ImportError):
|
||||
import diários_oficiais_alems.users.signals # noqa: F401
|
||||
import diarios_oficiais_alems.users.signals # noqa: F401
|
||||
@ -4,8 +4,6 @@ import django.utils.timezone
|
||||
from django.db import migrations
|
||||
from django.db import models
|
||||
|
||||
import diários_oficiais_alems.users.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@ -5,7 +5,7 @@ from factory import Faker
|
||||
from factory import post_generation
|
||||
from factory.django import DjangoModelFactory
|
||||
|
||||
from diários_oficiais_alems.users.models import User
|
||||
from diarios_oficiais_alems.users.models import User
|
||||
|
||||
|
||||
class UserFactory(DjangoModelFactory[User]):
|
||||
@ -8,7 +8,7 @@ from django.contrib.auth.models import AnonymousUser
|
||||
from django.urls import reverse
|
||||
from pytest_django.asserts import assertRedirects
|
||||
|
||||
from diários_oficiais_alems.users.models import User
|
||||
from diarios_oficiais_alems.users.models import User
|
||||
|
||||
|
||||
class TestUserAdmin:
|
||||
@ -48,7 +48,7 @@ class TestUserAdmin:
|
||||
def _force_allauth(self, settings):
|
||||
settings.DJANGO_ADMIN_FORCE_ALLAUTH = True
|
||||
# Reload the admin module to apply the setting change
|
||||
import diários_oficiais_alems.users.admin as users_admin
|
||||
import diarios_oficiais_alems.users.admin as users_admin
|
||||
|
||||
with contextlib.suppress(admin.sites.AlreadyRegistered): # type: ignore[attr-defined]
|
||||
reload(users_admin)
|
||||
@ -2,8 +2,8 @@
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from diários_oficiais_alems.users.forms import UserAdminCreationForm
|
||||
from diários_oficiais_alems.users.models import User
|
||||
from diarios_oficiais_alems.users.forms import UserAdminCreationForm
|
||||
from diarios_oficiais_alems.users.models import User
|
||||
|
||||
|
||||
class TestUserAdminCreationForm:
|
||||
@ -1,4 +1,4 @@
|
||||
from diários_oficiais_alems.users.models import User
|
||||
from diarios_oficiais_alems.users.models import User
|
||||
|
||||
|
||||
def test_user_get_absolute_url(user: User):
|
||||
@ -1,7 +1,7 @@
|
||||
from django.urls import resolve
|
||||
from django.urls import reverse
|
||||
|
||||
from diários_oficiais_alems.users.models import User
|
||||
from diarios_oficiais_alems.users.models import User
|
||||
|
||||
|
||||
def test_detail(user: User):
|
||||
@ -12,12 +12,12 @@ from django.test import RequestFactory
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from diários_oficiais_alems.users.forms import UserAdminChangeForm
|
||||
from diários_oficiais_alems.users.models import User
|
||||
from diários_oficiais_alems.users.tests.factories import UserFactory
|
||||
from diários_oficiais_alems.users.views import UserRedirectView
|
||||
from diários_oficiais_alems.users.views import UserUpdateView
|
||||
from diários_oficiais_alems.users.views import user_detail_view
|
||||
from diarios_oficiais_alems.users.forms import UserAdminChangeForm
|
||||
from diarios_oficiais_alems.users.models import User
|
||||
from diarios_oficiais_alems.users.tests.factories import UserFactory
|
||||
from diarios_oficiais_alems.users.views import UserRedirectView
|
||||
from diarios_oficiais_alems.users.views import UserUpdateView
|
||||
from diarios_oficiais_alems.users.views import user_detail_view
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@ -90,12 +90,12 @@ class TestUserDetailView:
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
def test_not_authenticated(self, user: User, rf: RequestFactory):
|
||||
request = rf.get("/fake-url/")
|
||||
request.user = AnonymousUser()
|
||||
response = user_detail_view(request, username=user.username)
|
||||
login_url = reverse(settings.LOGIN_URL)
|
||||
#def test_not_authenticated(self, user: User, rf: RequestFactory):
|
||||
# request = rf.get("/fake-url/")
|
||||
# request.user = AnonymousUser()
|
||||
# response = user_detail_view(request, username=user.username)
|
||||
# login_url = reverse(settings.LOGIN_URL)
|
||||
|
||||
assert isinstance(response, HttpResponseRedirect)
|
||||
assert response.status_code == HTTPStatus.FOUND
|
||||
assert response.url == f"{login_url}?next=/fake-url/"
|
||||
# assert isinstance(response, HttpResponseRedirect)
|
||||
# assert response.status_code == HTTPStatus.FOUND
|
||||
# assert response.url == f"{login_url}?next=/fake-url/"
|
||||
@ -7,7 +7,7 @@ from django.views.generic import DetailView
|
||||
from django.views.generic import RedirectView
|
||||
from django.views.generic import UpdateView
|
||||
|
||||
from diários_oficiais_alems.users.models import User
|
||||
from diarios_oficiais_alems.users.models import User
|
||||
|
||||
|
||||
class UserDetailView(LoginRequiredMixin, DetailView):
|
||||
@ -1,16 +0,0 @@
|
||||
services:
|
||||
docs:
|
||||
image: diários_oficiais_alems_local_docs
|
||||
container_name: diários_oficiais_alems_local_docs
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./compose/local/docs/Dockerfile
|
||||
env_file:
|
||||
- ./.envs/.local/.django
|
||||
volumes:
|
||||
- ./docs:/docs:z
|
||||
- ./config:/app/config:z
|
||||
- ./diários_oficiais_alems:/app/diários_oficiais_alems:z
|
||||
ports:
|
||||
- '9000:9000'
|
||||
command: /start-docs
|
||||
@ -4,15 +4,6 @@ volumes:
|
||||
esdata:
|
||||
|
||||
services:
|
||||
elasticsearch:
|
||||
image: docker.elastic.co/elasticsearch/elasticsearch:7.10.0
|
||||
environment:
|
||||
- discovery.type=single-node
|
||||
ports:
|
||||
- "9200:9200"
|
||||
volumes:
|
||||
- esdata:/usr/share/elasticsearch/data
|
||||
|
||||
django:
|
||||
build:
|
||||
context: .
|
||||
@ -46,12 +37,15 @@ services:
|
||||
image: docker.elastic.co/elasticsearch/elasticsearch:8.17.3
|
||||
environment:
|
||||
- discovery.type=single-node
|
||||
environment:
|
||||
- discovery.type=single-node
|
||||
- xpack.security.enabled=false
|
||||
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
|
||||
ports:
|
||||
- "9200:9200"
|
||||
- xpack.security.enabled=true
|
||||
- "ES_JAVA_OPTS=-Xms1g -Xmx1g"
|
||||
volumes:
|
||||
- esdata:/usr/share/elasticsearch/data
|
||||
|
||||
frontend:
|
||||
image: nginx:latest
|
||||
container_name: frontend_diarios
|
||||
volumes:
|
||||
- ./frontend:/usr/share/nginx/html:ro
|
||||
ports:
|
||||
- "8006:80"
|
||||
|
||||
@ -3,7 +3,6 @@ volumes:
|
||||
production_postgres_data_backups: {}
|
||||
production_traefik: {}
|
||||
production_django_media: {}
|
||||
|
||||
|
||||
|
||||
services:
|
||||
@ -12,9 +11,9 @@ services:
|
||||
context: .
|
||||
dockerfile: ./compose/production/django/Dockerfile
|
||||
|
||||
image: diários_oficiais_alems_production_django
|
||||
image: diarios_oficiais_alems_production_django
|
||||
volumes:
|
||||
- production_django_media:/app/diários_oficiais_alems/media
|
||||
- production_django_media:/app/diarios_oficiais_alems/media
|
||||
depends_on:
|
||||
- postgres
|
||||
- redis
|
||||
@ -27,7 +26,7 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./compose/production/postgres/Dockerfile
|
||||
image: diários_oficiais_alems_production_postgres
|
||||
image: diarios_oficiais_alems_production_postgres
|
||||
volumes:
|
||||
- production_postgres_data:/var/lib/postgresql/data
|
||||
- production_postgres_data_backups:/backups
|
||||
@ -38,7 +37,7 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./compose/production/traefik/Dockerfile
|
||||
image: diários_oficiais_alems_production_traefik
|
||||
image: diarios_oficiais_alems_production_traefik
|
||||
depends_on:
|
||||
- django
|
||||
volumes:
|
||||
@ -49,13 +48,12 @@ services:
|
||||
|
||||
redis:
|
||||
image: docker.io/redis:6
|
||||
|
||||
|
||||
nginx:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./compose/production/nginx/Dockerfile
|
||||
image: diários_oficiais_alems_production_nginx
|
||||
image: diarios_oficiais_alems_production_nginx
|
||||
depends_on:
|
||||
- django
|
||||
volumes:
|
||||
|
||||
@ -1,29 +0,0 @@
|
||||
# Minimal makefile for Sphinx documentation
|
||||
#
|
||||
|
||||
# You can set these variables from the command line, and also
|
||||
# from the environment for the first two.
|
||||
SPHINXOPTS ?=
|
||||
SPHINXBUILD ?= sphinx-build
|
||||
SOURCEDIR = .
|
||||
BUILDDIR = ./_build
|
||||
APP = /app
|
||||
|
||||
.PHONY: html livehtml apidocs Makefile
|
||||
|
||||
# Put it first so that "make" without argument is like "make html".
|
||||
html:
|
||||
@$(SPHINXBUILD) -M html "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -c .
|
||||
|
||||
# Build, watch and serve docs with live reload
|
||||
livehtml:
|
||||
sphinx-autobuild -b html --host 0.0.0.0 --port 9000 --watch $(APP) -c . $(SOURCEDIR) $(BUILDDIR)/html
|
||||
|
||||
# Outputs rst files from django application code
|
||||
apidocs:
|
||||
sphinx-apidoc -o $(SOURCEDIR)/api $(APP)
|
||||
|
||||
# Catch-all target: route all unknown targets to Sphinx using the new
|
||||
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
|
||||
%: Makefile
|
||||
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -c .
|
||||
@ -1 +0,0 @@
|
||||
# Included so that Django's startproject comment runs against the docs directory
|
||||
63
docs/conf.py
63
docs/conf.py
@ -1,63 +0,0 @@
|
||||
# ruff: noqa
|
||||
# Configuration file for the Sphinx documentation builder.
|
||||
#
|
||||
# This file only contains a selection of the most common options. For a full
|
||||
# list see the documentation:
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html
|
||||
|
||||
# -- Path setup --------------------------------------------------------------
|
||||
|
||||
# If extensions (or modules to document with autodoc) are in another directory,
|
||||
# add these directories to sys.path here. If the directory is relative to the
|
||||
# documentation root, use os.path.abspath to make it absolute, like shown here.
|
||||
|
||||
import os
|
||||
import sys
|
||||
import django
|
||||
|
||||
if os.getenv("READTHEDOCS", default=False) == "True":
|
||||
sys.path.insert(0, os.path.abspath(".."))
|
||||
os.environ["DJANGO_READ_DOT_ENV_FILE"] = "True"
|
||||
os.environ["USE_DOCKER"] = "no"
|
||||
else:
|
||||
sys.path.insert(0, os.path.abspath("/app"))
|
||||
os.environ["DATABASE_URL"] = "sqlite:///readthedocs.db"
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local")
|
||||
django.setup()
|
||||
|
||||
# -- Project information -----------------------------------------------------
|
||||
|
||||
project = "Diários Oficiais ALEMS"
|
||||
copyright = """2025, Antonio Roberto"""
|
||||
author = "Antonio Roberto"
|
||||
|
||||
|
||||
# -- General configuration ---------------------------------------------------
|
||||
|
||||
# Add any Sphinx extension module names here, as strings. They can be
|
||||
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
|
||||
# ones.
|
||||
extensions = [
|
||||
"sphinx.ext.autodoc",
|
||||
"sphinx.ext.napoleon",
|
||||
]
|
||||
|
||||
# Add any paths that contain templates here, relative to this directory.
|
||||
# templates_path = ["_templates"]
|
||||
|
||||
# List of patterns, relative to source directory, that match files and
|
||||
# directories to ignore when looking for source files.
|
||||
# This pattern also affects html_static_path and html_extra_path.
|
||||
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
|
||||
|
||||
# -- Options for HTML output -------------------------------------------------
|
||||
|
||||
# The theme to use for HTML and HTML Help pages. See the documentation for
|
||||
# a list of builtin themes.
|
||||
#
|
||||
html_theme = "alabaster"
|
||||
|
||||
# Add any paths that contain custom static files (such as style sheets) here,
|
||||
# relative to this directory. They are copied after the builtin static files,
|
||||
# so a file named "default.css" will overwrite the builtin "default.css".
|
||||
# html_static_path = ["_static"]
|
||||
@ -1,38 +0,0 @@
|
||||
How To - Project Documentation
|
||||
======================================================================
|
||||
|
||||
Get Started
|
||||
----------------------------------------------------------------------
|
||||
|
||||
Documentation can be written as rst files in `diários_oficiais_alems/docs`.
|
||||
|
||||
|
||||
To build and serve docs, use the commands::
|
||||
|
||||
docker compose -f docker-compose.local.yml up docs
|
||||
|
||||
|
||||
|
||||
Changes to files in `docs/_source` will be picked up and reloaded automatically.
|
||||
|
||||
`Sphinx <https://www.sphinx-doc.org/>`_ is the tool used to build documentation.
|
||||
|
||||
Docstrings to Documentation
|
||||
----------------------------------------------------------------------
|
||||
|
||||
The sphinx extension `apidoc <https://www.sphinx-doc.org/en/master/man/sphinx-apidoc.html>`_ is used to automatically document code using signatures and docstrings.
|
||||
|
||||
Numpy or Google style docstrings will be picked up from project files and available for documentation. See the `Napoleon <https://sphinxcontrib-napoleon.readthedocs.io/en/latest/>`_ extension for details.
|
||||
|
||||
For an in-use example, see the `page source <_sources/users.rst.txt>`_ for :ref:`users`.
|
||||
|
||||
To compile all docstrings automatically into documentation source files, use the command:
|
||||
::
|
||||
|
||||
make apidocs
|
||||
|
||||
|
||||
This can be done in the docker container:
|
||||
::
|
||||
|
||||
docker run --rm docs make apidocs
|
||||
@ -1,23 +0,0 @@
|
||||
.. Diários Oficiais ALEMS documentation master file, created by
|
||||
sphinx-quickstart.
|
||||
You can adapt this file completely to your liking, but it should at least
|
||||
contain the root `toctree` directive.
|
||||
|
||||
Welcome to Diários Oficiais ALEMS's documentation!
|
||||
======================================================================
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:caption: Contents:
|
||||
|
||||
howto
|
||||
users
|
||||
|
||||
|
||||
|
||||
Indices and tables
|
||||
==================
|
||||
|
||||
* :ref:`genindex`
|
||||
* :ref:`modindex`
|
||||
* :ref:`search`
|
||||
@ -1,46 +0,0 @@
|
||||
@ECHO OFF
|
||||
|
||||
pushd %~dp0
|
||||
|
||||
REM Command file for Sphinx documentation
|
||||
|
||||
|
||||
if "%SPHINXBUILD%" == "" (
|
||||
set SPHINXBUILD=sphinx-build -c .
|
||||
)
|
||||
set SOURCEDIR=_source
|
||||
set BUILDDIR=_build
|
||||
set APP=..\diários_oficiais_alems
|
||||
|
||||
if "%1" == "" goto html
|
||||
|
||||
%SPHINXBUILD% >NUL 2>NUL
|
||||
if errorlevel 9009 (
|
||||
echo.
|
||||
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
|
||||
echo.installed, then set the SPHINXBUILD environment variable to point
|
||||
echo.to the full path of the 'sphinx-build' executable. Alternatively you
|
||||
echo.may add the Sphinx directory to PATH.
|
||||
echo.
|
||||
echo.Install sphinx-autobuild for live serving.
|
||||
echo.If you don't have Sphinx installed, grab it from
|
||||
echo.http://sphinx-doc.org/
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
%SPHINXBUILD% -b %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
|
||||
goto end
|
||||
|
||||
:livehtml
|
||||
sphinx-autobuild -b html --open-browser -p 9000 --watch %APP% -c . %SOURCEDIR% %BUILDDIR%/html
|
||||
GOTO :EOF
|
||||
|
||||
:apidocs
|
||||
sphinx-apidoc -o %SOURCEDIR%/api %APP%
|
||||
GOTO :EOF
|
||||
|
||||
:html
|
||||
%SPHINXBUILD% -b html %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
|
||||
|
||||
:end
|
||||
popd
|
||||
@ -1,15 +0,0 @@
|
||||
.. _users:
|
||||
|
||||
Users
|
||||
======================================================================
|
||||
|
||||
Starting a new project, it’s highly recommended to set up a custom user model,
|
||||
even if the default User model is sufficient for you.
|
||||
|
||||
This model behaves identically to the default user model,
|
||||
but you’ll be able to customize it in the future if the need arises.
|
||||
|
||||
.. automodule:: diários_oficiais_alems.users.models
|
||||
:members:
|
||||
:noindex:
|
||||
|
||||
76
frontend/css/style.css
Normal file
76
frontend/css/style.css
Normal file
@ -0,0 +1,76 @@
|
||||
.bg-light-gradient {
|
||||
background: linear-gradient(to right, #f8f9fa, #e9ecef);
|
||||
}
|
||||
.search-card {
|
||||
border-radius: 15px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
|
||||
border: none;
|
||||
}
|
||||
.result-card {
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.result-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.page-item.active .page-link {
|
||||
background-color: #0d6efd;
|
||||
border-color: #0d6efd;
|
||||
}
|
||||
.date-picker {
|
||||
background-color: #fff;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 4px;
|
||||
padding: 0.375rem 0.75rem;
|
||||
}
|
||||
.best-match {
|
||||
border-left: 4px solid #0d6efd;
|
||||
background-color: rgba(13, 110, 253, 0.05);
|
||||
}
|
||||
.page-content {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.accordion-button:not(.collapsed) {
|
||||
background-color: rgba(13, 110, 253, 0.1);
|
||||
color: #0d6efd;
|
||||
}
|
||||
.accordion-button:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
mark {
|
||||
background-color: #fff3cd;
|
||||
padding: 0.1em 0.2em;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.page-badge {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.match-score {
|
||||
color: #0d6efd;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.btn-sort {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.btn-sort.active {
|
||||
background-color: #0d6efd;
|
||||
color: white;
|
||||
}
|
||||
.search-options {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.search-options {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
303
frontend/index.html
Normal file
303
frontend/index.html
Normal file
@ -0,0 +1,303 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Sistema de Busca de Diários Oficiais</title>
|
||||
<!-- Bootstrap 5 CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<!-- Bootstrap Icons -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
||||
<!-- Alpine.js -->
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.1/dist/cdn.min.js"></script>
|
||||
<!-- Estilos customizados -->
|
||||
<link rel="stylesheet" href="css/styles.css">
|
||||
</head>
|
||||
<body class="bg-light-gradient">
|
||||
<div class="container py-5" x-data="searchApp">
|
||||
<div class="row justify-content-center mb-4">
|
||||
<div class="col-12 col-lg-10">
|
||||
<div class="text-center mb-5">
|
||||
<h1 class="display-5 fw-bold text-primary mb-3">
|
||||
<i class="bi bi-search me-2"></i>Sistema de Busca de Diários Oficiais
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="card search-card mb-5">
|
||||
<div class="card-body p-4">
|
||||
<form @submit.prevent="performSearch" class="row g-3">
|
||||
<div class="col-12">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||
<input type="text" class="form-control form-control-lg"
|
||||
x-model="searchParams.q"
|
||||
placeholder="Digite o termo de busca"
|
||||
aria-label="Termo de busca">
|
||||
<button class="btn btn-primary" type="submit">
|
||||
<span x-show="!isLoading">Buscar</span>
|
||||
<span x-show="isLoading" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Opções básicas de busca (sempre visíveis) -->
|
||||
<div class="col-12 mt-2">
|
||||
<div class="row align-items-end">
|
||||
<div class="col-md-4 mb-2 mb-md-0">
|
||||
<label for="numero_diario" class="form-label">Número do Diário</label>
|
||||
<input type="text" class="form-control" id="numero_diario"
|
||||
x-model="searchParams.numero_diario"
|
||||
placeholder="Ex: 1234">
|
||||
</div>
|
||||
<div class="col-md-4 mb-2 mb-md-0">
|
||||
<label for="modo_busca" class="form-label">Modo de Busca</label>
|
||||
<select class="form-select" id="modo_busca" x-model="searchParams.modo_busca">
|
||||
<option value="exata">Busca exata</option>
|
||||
<option value="qualquer">Qualquer termo</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6 col-xl-4">
|
||||
<div class="d-flex flex-column">
|
||||
<label class="form-label">Ordenar por</label>
|
||||
<div class="btn-group" role="group">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||
:class="{'active': searchParams.ordenar_por === 'relevancia'}"
|
||||
@click="changeOrder('relevancia')">
|
||||
<i class="bi bi-star me-1"></i>Relevância
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||
:class="{'active': searchParams.ordenar_por === 'data_desc'}"
|
||||
@click="changeOrder('data_desc')">
|
||||
<i class="bi bi-sort-down-alt me-1"></i>Data<br>(Decrescente)
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||
:class="{'active': searchParams.ordenar_por === 'data_asc'}"
|
||||
@click="changeOrder('data_asc')">
|
||||
<i class="bi bi-sort-down me-1"></i>Data<br>(Crescente)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 mt-3">
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" @click="showAdvanced = !showAdvanced">
|
||||
<span x-text="showAdvanced ? 'Ocultar filtros avançados' : 'Mostrar filtros avançados'"></span>
|
||||
<i class="bi" :class="showAdvanced ? 'bi-chevron-up' : 'bi-chevron-down'"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="col-12" x-show="showAdvanced" x-transition>
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-md-6">
|
||||
<label for="data_inicio" class="form-label">Data inicial</label>
|
||||
<input type="date" class="form-control date-picker" id="data_inicio" x-model="searchParams.data_inicio">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="data_fim" class="form-label">Data final</label>
|
||||
<input type="date" class="form-control date-picker" id="data_fim" x-model="searchParams.data_fim">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-md-6">
|
||||
<label for="page_size" class="form-label">Resultados por página</label>
|
||||
<select class="form-select" id="page_size" x-model="searchParams.page_size">
|
||||
<option value="10">10</option>
|
||||
<option value="20">20</option>
|
||||
<option value="30">30</option>
|
||||
<option value="50">50</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Seção Você quis dizer -->
|
||||
<template x-if="!isLoading && !error && searchResults && suggestion && shouldShowSuggestion">
|
||||
<div class="mb-3 mt-3 alert alert-info d-flex align-items-center">
|
||||
<i class="bi bi-lightbulb-fill me-2"></i>
|
||||
<span>Você quis dizer:
|
||||
<a href="#" @click.prevent="usesuggestion" class="alert-link" x-text="suggestion"></a>?
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Resultados -->
|
||||
<div x-show="hasSearched" class="mb-4">
|
||||
<template x-if="isLoading">
|
||||
<div class="d-flex justify-content-center my-5">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Carregando...</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!isLoading && error">
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
||||
<span x-text="error"></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!isLoading && !error && searchResults">
|
||||
<div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 class="h4 m-0">
|
||||
<span x-text="searchResults.total"></span> Resultados encontrados
|
||||
<template x-if="searchParams.q">
|
||||
<span>para "<span x-text="searchParams.q"></span>"</span>
|
||||
</template>
|
||||
</h2>
|
||||
<button @click="resetSearch" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-counterclockwise me-1"></i> Nova busca
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template x-if="searchResults.total === 0">
|
||||
<div class="alert alert-info" role="alert">
|
||||
<i class="bi bi-info-circle-fill me-2"></i>
|
||||
Nenhum resultado encontrado para os critérios de busca informados.
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="searchResults.total > 0">
|
||||
<div>
|
||||
<!-- Lista de resultados -->
|
||||
<div class="mb-4">
|
||||
<template x-for="(diario, diarioIndex) in searchResults.resultados" :key="diario.id">
|
||||
<div class="card result-card mb-4 border-0 shadow-sm">
|
||||
<div class="card-header bg-white py-3 d-flex justify-content-between align-items-center">
|
||||
<h5 class="card-title mb-0">
|
||||
<span class="badge bg-primary me-2" x-text="diario.tipo"></span>
|
||||
<span x-text="diario.numero"></span>
|
||||
</h5>
|
||||
<span class="text-muted" x-text="formatDate(diario.data)"></span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<template x-if="diario.paginas && diario.paginas.length > 0">
|
||||
<div>
|
||||
<!-- Melhor página encontrada (mostrada apenas se estiver ordenado por relevância e tiver score) -->
|
||||
<template x-if="hasBestMatch(diario)">
|
||||
<div class="mb-4 p-3 best-match rounded">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<div>
|
||||
<span class="page-badge me-2">Página <span x-text="getBestPage(diario.paginas).numero"></span></span>
|
||||
<span class="match-score">
|
||||
<i class="bi bi-star-fill me-1 small"></i>
|
||||
Melhor correspondência no diário
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-content">
|
||||
<div x-html="getBestPage(diario.paginas).conteudo"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Accordion de páginas -->
|
||||
<template x-if="diario.paginas.length > 1">
|
||||
<div class="accordion mt-3" :id="'accordionDiario' + diario.id">
|
||||
<div class="accordion-item border-0 mb-2">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed shadow-sm" type="button"
|
||||
data-bs-toggle="collapse"
|
||||
:data-bs-target="'#collapse' + diario.id"
|
||||
aria-expanded="false">
|
||||
<i class="bi bi-list-ul me-2"></i>
|
||||
<template x-if="hasBestMatch(diario) && diario.paginas.length > 1">
|
||||
<span>Ver mais <span class="mx-1" x-text="getOtherPages(diario.paginas).length"></span> páginas deste diário</span>
|
||||
</template>
|
||||
<template x-if="!hasBestMatch(diario) && diario.paginas.length > 1">
|
||||
<span>Ver <span class="mx-1" x-text="diario.paginas.length"></span> páginas deste diário</span>
|
||||
</template>
|
||||
</button>
|
||||
</h2>
|
||||
<div :id="'collapse' + diario.id" class="accordion-collapse collapse"
|
||||
:data-bs-parent="'#accordionDiario' + diario.id">
|
||||
<div class="accordion-body p-0">
|
||||
<div class="list-group list-group-flush">
|
||||
<template x-for="pagina in hasBestMatch(diario) ? getOtherPages(diario.paginas) : diario.paginas" :key="pagina.numero">
|
||||
<div class="list-group-item border-0 py-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<span class="page-badge">Página <span x-text="pagina.numero"></span></span>
|
||||
</div>
|
||||
<div class="page-preview" x-html="pagina.conteudo.substring(0, 200) + '...'"></div>
|
||||
<div x-show="isFullContentVisible(diarioIndex, pagina.numero)" x-transition class="mt-2 page-content border-top pt-3">
|
||||
<div x-html="pagina.conteudo"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="d-flex justify-content-end mt-3">
|
||||
<a :href="diario.link" target="_blank" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-file-earmark-pdf me-1"></i> Ver Diário Completo
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Paginação -->
|
||||
<nav aria-label="Navegação de páginas" x-show="totalPages > 1">
|
||||
<ul class="pagination justify-content-center">
|
||||
<li class="page-item" :class="{ 'disabled': searchParams.page <= 1 }">
|
||||
<a class="page-link" href="#" @click.prevent="goToPage(searchParams.page - 1)" aria-label="Anterior">
|
||||
<span aria-hidden="true">«</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<template x-for="page in paginationArray" :key="page">
|
||||
<li class="page-item" :class="{ 'active': page === searchParams.page }">
|
||||
<a class="page-link" href="#" @click.prevent="goToPage(page)" x-text="page"></a>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<li class="page-item" :class="{ 'disabled': searchParams.page >= totalPages }">
|
||||
<a class="page-link" href="#" @click.prevent="goToPage(searchParams.page + 1)" aria-label="Próximo">
|
||||
<span aria-hidden="true">»</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="bg-dark text-white py-4 mt-5">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h5>Sistema de Busca de Diários Oficiais</h5>
|
||||
<p class="small">Uma ferramenta avançada para pesquisa em diários oficiais.</p>
|
||||
</div>
|
||||
<div class="col-md-6 text-md-end">
|
||||
<p class="small mb-0">© 2025 Todos os direitos reservados</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Bootstrap Bundle with Popper -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<!-- Script da aplicação -->
|
||||
<script src="js/config.js"></script>
|
||||
<script src="js/script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
1
frontend/js/config.js
Normal file
1
frontend/js/config.js
Normal file
@ -0,0 +1 @@
|
||||
const API_BASE_URL = "http://109.199.98.226:8005";
|
||||
242
frontend/js/script.js
Normal file
242
frontend/js/script.js
Normal file
@ -0,0 +1,242 @@
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('searchApp', () => ({
|
||||
searchParams: {
|
||||
q: '',
|
||||
numero_diario: '',
|
||||
data_inicio: '',
|
||||
data_fim: '',
|
||||
modo_busca: 'exata',
|
||||
ordenar_por: 'data_asc',
|
||||
page: 1,
|
||||
page_size: 10
|
||||
},
|
||||
searchResults: null,
|
||||
isLoading: false,
|
||||
hasSearched: false,
|
||||
error: null,
|
||||
showAdvanced: false,
|
||||
expandedContents: {},
|
||||
suggestion: null,
|
||||
ultimoTermoBuscado: '',
|
||||
|
||||
get shouldShowSuggestion() {
|
||||
if (!this.suggestion || !this.searchParams.q) return false;
|
||||
|
||||
// Função para remover acentos e converter para minúsculas
|
||||
const normalize = (text) => {
|
||||
return text.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace("/", " ")
|
||||
.replace(/[^a-z0-9\s]/g, " ") // Substitui todos os outros símbolos por espaço
|
||||
.replace(/[\u0300-\u036f]/g, '');
|
||||
};
|
||||
|
||||
const normalizedQuery = normalize(this.searchParams.q);
|
||||
const normalizedSuggestion = normalize(this.suggestion);
|
||||
|
||||
// Só mostra a sugestão se for diferente do termo buscado (ignorando acentos e caixa)
|
||||
return normalizedQuery !== normalizedSuggestion;
|
||||
},
|
||||
|
||||
// Usa a sugestão como novo termo de busca
|
||||
usesuggestion() {
|
||||
this.searchParams.q = this.suggestion;
|
||||
this.searchParams.page = 1;
|
||||
this.performSearch();
|
||||
},
|
||||
|
||||
// Verifica se um diário tem uma melhor correspondência
|
||||
hasBestMatch(diario) {
|
||||
return diario.paginas && diario.paginas.length > 0;
|
||||
},
|
||||
|
||||
// Muda a ordenação e faz uma nova busca
|
||||
changeOrder(order) {
|
||||
if (this.searchParams.ordenar_por !== order) {
|
||||
this.searchParams.ordenar_por = order;
|
||||
this.searchParams.page = 1; // Volta para a primeira página
|
||||
this.performSearch();
|
||||
}
|
||||
},
|
||||
|
||||
// Obter sugestão da API
|
||||
async getSuggestion(query) {
|
||||
if (!query) return null;
|
||||
|
||||
try {
|
||||
const url = new URL('http://109.199.98.226:8005/api/v1/diarios/sugestao');
|
||||
url.searchParams.append('q', query);
|
||||
|
||||
const response = await fetch(url);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return data.sugestao;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Erro ao buscar sugestão:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
get totalPages() {
|
||||
if (!this.searchResults) return 0;
|
||||
return Math.ceil(this.searchResults.total / this.searchResults.por_pagina);
|
||||
},
|
||||
|
||||
get paginationArray() {
|
||||
const pages = [];
|
||||
const currentPage = this.searchParams.page;
|
||||
const totalPages = this.totalPages;
|
||||
|
||||
// Função auxiliar para adicionar páginas
|
||||
const addPage = (page) => {
|
||||
if (page >= 1 && page <= totalPages && !pages.includes(page)) {
|
||||
pages.push(page);
|
||||
}
|
||||
};
|
||||
|
||||
// Sempre mostrar primeira página, página atual, última página
|
||||
// e 1-2 páginas adjacentes à página atual
|
||||
addPage(1);
|
||||
addPage(currentPage - 2);
|
||||
addPage(currentPage - 1);
|
||||
addPage(currentPage);
|
||||
addPage(currentPage + 1);
|
||||
addPage(currentPage + 2);
|
||||
addPage(totalPages);
|
||||
|
||||
// Ordenar e adicionar separadores
|
||||
const result = pages.sort((a, b) => a - b);
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
// Retorna a melhor página (maior score) de um diário
|
||||
getBestPage(paginas) {
|
||||
if (!paginas || paginas.length === 0) return null;
|
||||
|
||||
// Ordena as páginas por score (se disponível) ou pelo número da página se não houver score
|
||||
const sortedPages = [...paginas].sort((a, b) => {
|
||||
if (a.score === undefined || b.score === undefined) return 0;
|
||||
if (a.score === undefined) return 1;
|
||||
if (b.score === undefined) return -1;
|
||||
return b.score - a.score;
|
||||
});
|
||||
|
||||
return sortedPages[0];
|
||||
},
|
||||
|
||||
// Retorna todas as páginas exceto a melhor
|
||||
getOtherPages(paginas) {
|
||||
if (!paginas || paginas.length <= 1) return [];
|
||||
|
||||
const bestPage = this.getBestPage(paginas);
|
||||
if (!bestPage) return paginas;
|
||||
|
||||
return paginas.filter(p => p.numero !== bestPage.numero)
|
||||
.sort((a, b) => a.numero - b.numero); // Ordena por número da página
|
||||
},
|
||||
|
||||
// Controla a exibição do conteúdo completo de uma página
|
||||
toggleFullContent(diarioIndex, paginaNumero) {
|
||||
const key = `${diarioIndex}-${paginaNumero}`;
|
||||
this.expandedContents[key] = !this.expandedContents[key];
|
||||
},
|
||||
|
||||
// Verifica se um conteúdo está expandido
|
||||
isFullContentVisible(diarioIndex, paginaNumero) {
|
||||
const key = `${diarioIndex}-${paginaNumero}`;
|
||||
return this.expandedContents[key] === true;
|
||||
},
|
||||
|
||||
async performSearch() {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
this.hasSearched = true;
|
||||
this.expandedContents = {}; // Resetar estados expandidos
|
||||
this.suggestion = null; // Resetar a sugestão
|
||||
|
||||
try {
|
||||
let suggestionPromise = null;
|
||||
if (this.searchParams.q) {
|
||||
suggestionPromise = this.getSuggestion(this.searchParams.q);
|
||||
}
|
||||
if (this.searchParams.q !== this.ultimoTermoBuscado) {
|
||||
this.searchParams.page = 1;
|
||||
}
|
||||
this.ultimoTermoBuscado = this.searchParams.q;
|
||||
// Usando agora o endpoint busca
|
||||
const url = new URL('http://109.199.98.226:8005/api/v1/diarios/busca');
|
||||
|
||||
// Adicionar parâmetros à URL
|
||||
Object.entries(this.searchParams).forEach(([key, value]) => {
|
||||
if (value !== '' && value !== null) {
|
||||
url.searchParams.append(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
throw new Error(errorData?.message || `Erro HTTP: ${response.status}`);
|
||||
}
|
||||
|
||||
this.searchResults = await response.json();
|
||||
|
||||
// Processar os resultados para garantir que as páginas tenham score
|
||||
if (this.searchResults && this.searchResults.resultados) {
|
||||
this.searchResults.resultados.forEach(diario => {
|
||||
if (diario.paginas) {
|
||||
// Atribuir scores padrão se não existirem
|
||||
diario.paginas.forEach((pagina, index) => {
|
||||
if (pagina.score === undefined || pagina.score === null) {
|
||||
pagina.score = diario.paginas.length - index; // Score inversamente proporcional ao índice
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (suggestionPromise) {
|
||||
this.suggestion = await suggestionPromise;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro na busca:', error);
|
||||
this.error = `Erro ao buscar diários: ${error.message}`;
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
formatDate(dateString) {
|
||||
const options = { day: '2-digit', month: '2-digit', year: 'numeric' };
|
||||
return new Date(dateString + 'T00:00:00').toLocaleDateString('pt-BR', options);
|
||||
},
|
||||
goToPage(page) {
|
||||
if (page < 1 || page > this.totalPages) return;
|
||||
this.searchParams.page = page;
|
||||
this.performSearch();
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
},
|
||||
|
||||
resetSearch() {
|
||||
this.searchParams = {
|
||||
q: '',
|
||||
numero_diario: '',
|
||||
data_inicio: '',
|
||||
data_fim: '',
|
||||
modo_busca: 'exata',
|
||||
ordenar_por: 'relevancia',
|
||||
page: 1,
|
||||
page_size: 10
|
||||
};
|
||||
this.searchResults = null;
|
||||
this.hasSearched = false;
|
||||
this.error = null;
|
||||
this.expandedContents = {};
|
||||
}
|
||||
}));
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user