Compare commits

..

10 Commits

144 changed files with 12673 additions and 1 deletions

12
.dockerignore Normal file
View File

@ -0,0 +1,12 @@
.editorconfig
.gitattributes
.github
.gitignore
.gitlab-ci.yml
.idea
.pre-commit-config.yaml
.readthedocs.yml
.travis.yml
venv
.git
.envs/

27
.editorconfig Normal file
View File

@ -0,0 +1,27 @@
# http://editorconfig.org
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.{py,rst,ini}]
indent_style = space
indent_size = 4
[*.{html,css,scss,json,yml,xml,toml}]
indent_style = space
indent_size = 2
[*.md]
trim_trailing_whitespace = false
[Makefile]
indent_style = tab
[default.conf]
indent_style = space
indent_size = 2

9
.envs/.local/.django Normal file
View File

@ -0,0 +1,9 @@
# General
# ------------------------------------------------------------------------------
USE_DOCKER=yes
IPYTHONDIR=/app/.ipython
ELASTICSEARCH_USER=elastic
ELASTICSEARCH_PASSWORD=RwtC6t+BLWkLj44=cp-*
DJANGO_SECRET_KEY=tYdYl0MP5zgpMlMmjBuYHvH4Dp3JDN5q3sxWBdFejemZSr0qpI9IrvrvTm17F0aW
DJANGO_ADMIN_URL=manage-panel/
DJANGO_ALLOWED_HOSTS=192.168.235.234,localhost,127.0.0.1,django

7
.envs/.local/.postgres Normal file
View File

@ -0,0 +1,7 @@
# PostgreSQL
# ------------------------------------------------------------------------------
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
POSTGRES_DB=diários_oficiais_alems
POSTGRES_USER=debug
POSTGRES_PASSWORD=debug

2
.gitattributes vendored Normal file
View File

@ -0,0 +1,2 @@
* text=auto
*.pdf filter=lfs diff=lfs merge=lfs -text

277
.gitignore vendored Normal file
View File

@ -0,0 +1,277 @@
### Python template
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
# Translations
*.mo
*.pot
# Django stuff:
staticfiles/
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Environments
.venv
venv/
ENV/
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
### Node template
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Typescript v1 declaration files
typings/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
### Linux template
*~
# temporary files which can be created if a process still has a handle open of a deleted file
.fuse_hidden*
# KDE directory preferences
.directory
# Linux trash folder which might appear on any partition or disk
.Trash-*
# .nfs files are created when an open file is removed but is still being accessed
.nfs*
### VisualStudioCode template
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Local History for devcontainer
.devcontainer/bash_history
### Windows template
# Windows thumbnail cache files
Thumbs.db
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
Desktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msm
*.msp
# Windows shortcuts
*.lnk
### macOS template
# General
*.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
### SublimeText template
# Cache files for Sublime Text
*.tmlanguage.cache
*.tmPreferences.cache
*.stTheme.cache
# Workspace files are user-specific
*.sublime-workspace
# Project files should be checked into the repository, unless a significant
# proportion of contributors will probably not be using Sublime Text
# *.sublime-project
# SFTP configuration file
sftp-config.json
# Package control specific files
Package Control.last-run
Package Control.ca-list
Package Control.ca-bundle
Package Control.system-ca-bundle
Package Control.cache/
Package Control.ca-certs/
Package Control.merged-ca-bundle
Package Control.user-ca-bundle
oscrypto-ca-bundle.crt
bh_unicode_properties.cache
# Sublime-github package stores a github token in this file
# https://packagecontrol.io/packages/sublime-github
GitHub.sublime-settings
### Vim template
# Swap
[._]*.s[a-v][a-z]
[._]*.sw[a-p]
[._]s[a-v][a-z]
[._]sw[a-p]
# Session
Session.vim
# Temporary
.netrwhist
# Auto-generated tag files
tags
# Redis dump file
dump.rdb
### Project template
diarios_oficiais_alems/media/
.pytest_cache/
.ipython/
.env
.envs/*
!.envs/.local/

1
.python-version Normal file
View File

@ -0,0 +1 @@
3.12

View File

@ -1,2 +1,52 @@
# Di-rios-Oficiais-Alems # Diários Oficiais ALEMS
Indexação dos Diários Oficiais da ALEMS Indexação dos Diários Oficiais da ALEMS
[![Built with Cookiecutter Django](https://img.shields.io/badge/built%20with-Cookiecutter%20Django-ff69b4.svg?logo=cookiecutter)](https://github.com/cookiecutter/cookiecutter-django/)
[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
## Configurações
Consulte [settings](https://cookiecutter-django.readthedocs.io/en/latest/1-getting-started/settings.html).
## Comandos Básicos
### Configuração de Usuários
- 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.
- Para criar uma **conta de superusuário**, use o comando:
$ python manage.py createsuperuser
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.
### Verificação de Tipos
Executando verificação de tipos com mypy:
$ mypy diários_oficiais_alems
### Cobertura de Testes
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
#### Executando testes com pytest
$ pytest
### Recarregamento automático e compilação Sass CSS
Consulte [Live reloading and SASS compilation](https://cookiecutter-django.readthedocs.io/en/latest/2-local-development/developing-locally.html#using-webpack-or-gulp).
## Deploy
A seguir estão os detalhes para fazer o deploy desta aplicação.
### Docker
Consulte a [documentação Docker do cookiecutter-django](https://cookiecutter-django.readthedocs.io/en/latest/3-deployment/deployment-with-docker.html).

1
backup.json Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,77 @@
FROM docker.io/python:3.12.9-slim-bookworm AS python-build-stage
ARG BUILD_ENVIRONMENT=local
# Instalar apenas dependências para construir pacotes Python
RUN apt-get update && apt-get install --no-install-recommends -y \
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 são instalados aqui para garantir que serão cacheados.
COPY ./requirements .
# Criar Wheels das dependências Python.
RUN pip wheel --wheel-dir /usr/src/app/wheels \
-r ${BUILD_ENVIRONMENT}.txt
# 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
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
ENV BUILD_ENV=${BUILD_ENVIRONMENT}
WORKDIR ${APP_HOME}
# 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 \
# Dependências do psycopg e outras utilidades
libpq-dev \
wait-for-it \
gettext \
poppler-utils \
unpaper \
tesseract-ocr \
tesseract-ocr-por \
ghostscript \
openjdk-17-jdk \
# libtesseract-dev \
# Utilitários do devcontainer e outros
sudo git bash-completion vim ssh curl \
# Limpeza
&& apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \
&& rm -rf /var/lib/apt/lists/*
# 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
# Instalar dependências Python a partir dos wheels (manter como está)
COPY --from=python-build-stage /usr/src/app/wheels /wheels/
RUN pip install --no-cache-dir --no-index --find-links=/wheels/ /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 . ${APP_HOME}
# REMOVER esta linha, poppler-utils já foi instalado acima
# RUN apt-get update && apt-get install -y poppler-utils
ENTRYPOINT ["/entrypoint"]

View File

@ -0,0 +1,20 @@
#!/bin/bash
set -o errexit
set -o pipefail
set -o nounset
# Aplica migrações antes de iniciar o servidor
python manage.py migrate
# Coleta arquivos estáticos
python manage.py collectstatic --noinput
python manage.py compress --force
# Inicia o servidor com Gunicorn
exec gunicorn config.wsgi:application \
--bind 0.0.0.0:8005 \
--workers 4 \
--timeout 120
# python manage.py runserver_plus 0.0.0.0:8005

View File

@ -0,0 +1,62 @@
# define an alias for the specific python version used in this file.
FROM docker.io/python:3.12.9-slim-bookworm AS python
# Python build stage
FROM python AS python-build-stage
ENV PYTHONDONTWRITEBYTECODE=1
RUN apt-get update && apt-get install --no-install-recommends -y \
# dependencies for building Python packages
build-essential \
# psycopg dependencies
libpq-dev \
# cleaning up unused files
&& apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \
&& rm -rf /var/lib/apt/lists/*
# Requirements are installed here to ensure they will be cached.
COPY ./requirements /requirements
# create python dependency wheels
RUN pip wheel --no-cache-dir --wheel-dir /usr/src/app/wheels \
-r /requirements/local.txt -r /requirements/production.txt \
&& rm -rf /requirements
# Python 'run' stage
FROM python AS python-run-stage
ARG BUILD_ENVIRONMENT
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
RUN apt-get update && apt-get install --no-install-recommends -y \
# To run the Makefile
make \
# psycopg dependencies
libpq-dev \
# Translations dependencies
gettext \
# Uncomment below lines to enable Sphinx output to latex and pdf
# texlive-latex-recommended \
# texlive-fonts-recommended \
# texlive-latex-extra \
# latexmk \
# cleaning up unused files
&& apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \
&& rm -rf /var/lib/apt/lists/*
# copy python dependency wheels from python-build-stage
COPY --from=python-build-stage /usr/src/app/wheels /wheels
# use wheels to install python dependencies
RUN pip install --no-cache /wheels/* \
&& rm -rf /wheels
COPY ./compose/local/docs/start /start-docs
RUN sed -i 's/\r$//g' /start-docs
RUN chmod +x /start-docs
WORKDIR /docs

7
compose/local/docs/start Normal file
View File

@ -0,0 +1,7 @@
#!/bin/bash
set -o errexit
set -o pipefail
set -o nounset
exec make livehtml

View File

@ -0,0 +1,44 @@
upstream django {
server django:8005;
}
server {
listen 80;
server_name 192.168.235.234 localhost;
client_max_body_size 10M;
location / {
proxy_pass http://django;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_redirect off;
# Timeouts
proxy_connect_timeout 75s;
proxy_read_timeout 300s;
}
location /static/ {
alias /app/staticfiles/;
expires 30d;
access_log off;
add_header Cache-Control "public, no-transform";
}
location /media/ {
alias /app/media/;
expires 30d;
access_log off;
add_header Cache-Control "public, no-transform";
}
# Bloqueia acesso a arquivos ocultos
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
}

View File

@ -0,0 +1,73 @@
# define an alias for the specific python version used in this file.
FROM docker.io/python:3.12.9-slim-bookworm AS python
# Python build stage
FROM python AS python-build-stage
ARG BUILD_ENVIRONMENT=production
# Install apt packages
RUN apt-get update && apt-get install --no-install-recommends -y \
build-essential \
libpq-dev \
wait-for-it \
&& rm -rf /var/lib/apt/lists/*
# Requirements are installed here to ensure they will be cached.
COPY ./requirements .
# Create Python Dependency and Sub-Dependency Wheels.
RUN pip wheel --wheel-dir /usr/src/app/wheels \
-r ${BUILD_ENVIRONMENT}.txt
# Python 'run' stage
FROM python AS python-run-stage
ARG BUILD_ENVIRONMENT=production
ARG APP_HOME=/app
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
BUILD_ENV=${BUILD_ENVIRONMENT} \
PATH="/home/django/.local/bin:${PATH}"
WORKDIR ${APP_HOME}
# Create system user
RUN addgroup --system django && \
adduser --system --ingroup django django
# Install required system dependencies
RUN apt-get update && apt-get install --no-install-recommends -y \
libpq-dev \
gettext \
&& apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \
&& rm -rf /var/lib/apt/lists/*
# Copy python dependency wheels
COPY --from=python-build-stage /usr/src/app/wheels /wheels/
# Install python dependencies
RUN pip install --no-cache-dir --no-index --find-links=/wheels/ /wheels/* \
&& rm -rf /wheels/
# Copy entrypoint and start scripts
COPY --chown=django:django ./compose/production/django/entrypoint /entrypoint
RUN sed -i 's/\r$//g' /entrypoint && \
chmod +x /entrypoint
COPY --chown=django:django ./compose/production/django/start /start
RUN sed -i 's/\r$//g' /start && \
chmod +x /start
# Copy application code
COPY --chown=django:django . ${APP_HOME}
# Fix permissions
RUN chown -R django:django ${APP_HOME} && \
find ${APP_HOME} -type d -exec chmod 755 {} \; && \
find ${APP_HOME} -type f -exec chmod 644 {} \;
USER django
ENTRYPOINT ["/entrypoint"]

View File

@ -0,0 +1,17 @@
#!/bin/bash
set -o errexit
set -o pipefail
set -o nounset
if [ -z "${POSTGRES_USER}" ]; then
base_postgres_image_default_user='postgres'
export POSTGRES_USER="${base_postgres_image_default_user}"
fi
export DATABASE_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}"
wait-for-it "${POSTGRES_HOST}:${POSTGRES_PORT}" -t 30
>&2 echo 'PostgreSQL is available'
exec "$@"

View File

@ -0,0 +1,51 @@
#!/bin/bash
#!/bin/bash
set -o errexit
set -o pipefail
set -o nounset
# Configurações ajustáveis via variáveis de ambiente
PORT=${GUNICORN_PORT:-8005}
WORKERS=${GUNICORN_WORKERS:-$(( $(nproc) * 2 + 1 ))}
TIMEOUT=${GUNICORN_TIMEOUT:-120}
MAX_REQUESTS=${GUNICORN_MAX_REQUESTS:-1000}
# Aplica migrações do banco de dados (com tratamento de erro)
python /app/manage.py migrate --noinput || {
echo "⚠️ Falha nas migrações do banco de dados!";
exit 1;
}
# Coleta arquivos estáticos
python /app/manage.py collectstatic --noinput
# Verifica e comprime arquivos (se compressor ativado)
compress_enabled() {
python << END
import sys
from environ import Env
env = Env(COMPRESS_ENABLED=(bool, True))
sys.exit(0 if env('COMPRESS_ENABLED') else 1)
END
}
if compress_enabled; then
python /app/manage.py compress --verbosity=0 || {
echo "⚠️ Falha ao comprimir arquivos (django-compressor pode estar desativado)";
}
fi
# Inicia o Gunicorn com configurações otimizadas
exec /usr/local/bin/gunicorn config.wsgi:application \
--bind 0.0.0.0:${PORT} \
--workers ${WORKERS} \
--timeout ${TIMEOUT} \
--max-requests ${MAX_REQUESTS} \
--worker-class sync \
--name diarios_oficiais_alems \
--access-logfile - \
--error-logfile - \
--chdir=/app

View File

@ -0,0 +1,2 @@
FROM docker.io/nginx:1.17.8-alpine
COPY ./compose/production/nginx/default.conf /etc/nginx/conf.d/default.conf

View File

@ -0,0 +1,7 @@
server {
listen 80;
server_name localhost;
location /media/ {
alias /usr/share/nginx/media/;
}
}

View File

@ -0,0 +1,39 @@
worker_processes auto;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 180;
gzip on;
server {
listen 80;
server_name _;
location / {
proxy_pass http://django:8005;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /static/ {
alias /usr/share/nginx/static/;
expires 30d;
access_log off;
}
location /media/ {
alias /usr/share/nginx/media/;
expires 30d;
access_log off;
}
}
}

View File

@ -0,0 +1,6 @@
FROM docker.io/postgres:16
COPY ./compose/production/postgres/maintenance /usr/local/bin/maintenance
RUN chmod +x /usr/local/bin/maintenance/*
RUN mv /usr/local/bin/maintenance/* /usr/local/bin \
&& rmdir /usr/local/bin/maintenance

View File

@ -0,0 +1,5 @@
#!/usr/bin/env bash
BACKUP_DIR_PATH='/backups'
BACKUP_FILE_PREFIX='backup'

View File

@ -0,0 +1,12 @@
#!/usr/bin/env bash
countdown() {
declare desc="A simple countdown. Source: https://superuser.com/a/611582"
local seconds="${1}"
local d=$(($(date +%s) + "${seconds}"))
while [ "$d" -ge `date +%s` ]; do
echo -ne "$(date -u --date @$(($d - `date +%s`)) +%H:%M:%S)\r";
sleep 0.1
done
}

View File

@ -0,0 +1,41 @@
#!/usr/bin/env bash
message_newline() {
echo
}
message_debug()
{
echo -e "DEBUG: ${@}"
}
message_welcome()
{
echo -e "\e[1m${@}\e[0m"
}
message_warning()
{
echo -e "\e[33mWARNING\e[0m: ${@}"
}
message_error()
{
echo -e "\e[31mERROR\e[0m: ${@}"
}
message_info()
{
echo -e "\e[37mINFO\e[0m: ${@}"
}
message_suggestion()
{
echo -e "\e[33mSUGGESTION\e[0m: ${@}"
}
message_success()
{
echo -e "\e[32mSUCCESS\e[0m: ${@}"
}

View File

@ -0,0 +1,16 @@
#!/usr/bin/env bash
yes_no() {
declare desc="Prompt for confirmation. \$\"\{1\}\": confirmation message."
local arg1="${1}"
local response=
read -r -p "${arg1} (y/[n])? " response
if [[ "${response}" =~ ^[Yy]$ ]]
then
exit 0
else
exit 1
fi
}

View File

@ -0,0 +1,38 @@
#!/usr/bin/env bash
### Create a database backup.
###
### Usage:
### $ docker compose -f <environment>.yml (exec |run --rm) postgres backup
set -o errexit
set -o pipefail
set -o nounset
working_dir="$(dirname ${0})"
source "${working_dir}/_sourced/constants.sh"
source "${working_dir}/_sourced/messages.sh"
message_welcome "Backing up the '${POSTGRES_DB}' database..."
if [[ "${POSTGRES_USER}" == "postgres" ]]; then
message_error "Backing up as 'postgres' user is not supported. Assign 'POSTGRES_USER' env with another one and try again."
exit 1
fi
export PGHOST="${POSTGRES_HOST}"
export PGPORT="${POSTGRES_PORT}"
export PGUSER="${POSTGRES_USER}"
export PGPASSWORD="${POSTGRES_PASSWORD}"
export PGDATABASE="${POSTGRES_DB}"
backup_filename="${BACKUP_FILE_PREFIX}_$(date +'%Y_%m_%dT%H_%M_%S').sql.gz"
pg_dump | gzip > "${BACKUP_DIR_PATH}/${backup_filename}"
message_success "'${POSTGRES_DB}' database backup '${backup_filename}' has been created and placed in '${BACKUP_DIR_PATH}'."

View File

@ -0,0 +1,22 @@
#!/usr/bin/env bash
### View backups.
###
### Usage:
### $ docker compose -f <environment>.yml (exec |run --rm) postgres backups
set -o errexit
set -o pipefail
set -o nounset
working_dir="$(dirname ${0})"
source "${working_dir}/_sourced/constants.sh"
source "${working_dir}/_sourced/messages.sh"
message_welcome "These are the backups you have got:"
ls -lht "${BACKUP_DIR_PATH}"

View File

@ -0,0 +1,55 @@
#!/usr/bin/env bash
### Restore database from a backup.
###
### Parameters:
### <1> filename of an existing backup.
###
### Usage:
### $ docker compose -f <environment>.yml (exec |run --rm) postgres restore <1>
set -o errexit
set -o pipefail
set -o nounset
working_dir="$(dirname ${0})"
source "${working_dir}/_sourced/constants.sh"
source "${working_dir}/_sourced/messages.sh"
if [[ -z ${1+x} ]]; then
message_error "Backup filename is not specified yet it is a required parameter. Make sure you provide one and try again."
exit 1
fi
backup_filename="${BACKUP_DIR_PATH}/${1}"
if [[ ! -f "${backup_filename}" ]]; then
message_error "No backup with the specified filename found. Check out the 'backups' maintenance script output to see if there is one and try again."
exit 1
fi
message_welcome "Restoring the '${POSTGRES_DB}' database from the '${backup_filename}' backup..."
if [[ "${POSTGRES_USER}" == "postgres" ]]; then
message_error "Restoring as 'postgres' user is not supported. Assign 'POSTGRES_USER' env with another one and try again."
exit 1
fi
export PGHOST="${POSTGRES_HOST}"
export PGPORT="${POSTGRES_PORT}"
export PGUSER="${POSTGRES_USER}"
export PGPASSWORD="${POSTGRES_PASSWORD}"
export PGDATABASE="${POSTGRES_DB}"
message_info "Dropping the database..."
dropdb "${PGDATABASE}"
message_info "Creating a new database..."
createdb --owner="${POSTGRES_USER}"
message_info "Applying the backup to the new database..."
gunzip -c "${backup_filename}" | psql "${POSTGRES_DB}"
message_success "The '${POSTGRES_DB}' database has been restored from the '${backup_filename}' backup."

View File

@ -0,0 +1,36 @@
#!/usr/bin/env bash
### Remove a database backup.
###
### Parameters:
### <1> filename of a backup to remove.
###
### Usage:
### $ docker-compose -f <environment>.yml (exec |run --rm) postgres rmbackup <1>
set -o errexit
set -o pipefail
set -o nounset
working_dir="$(dirname ${0})"
source "${working_dir}/_sourced/constants.sh"
source "${working_dir}/_sourced/messages.sh"
if [[ -z ${1+x} ]]; then
message_error "Backup filename is not specified yet it is a required parameter. Make sure you provide one and try again."
exit 1
fi
backup_filename="${BACKUP_DIR_PATH}/${1}"
if [[ ! -f "${backup_filename}" ]]; then
message_error "No backup with the specified filename found. Check out the 'backups' maintenance script output to see if there is one and try again."
exit 1
fi
message_welcome "Removing the '${backup_filename}' backup file..."
rm -r "${backup_filename}"
message_success "The '${backup_filename}' database backup has been removed."

View File

@ -0,0 +1,5 @@
FROM docker.io/traefik:3.3.4
RUN mkdir -p /etc/traefik/acme \
&& touch /etc/traefik/acme/acme.json \
&& chmod 600 /etc/traefik/acme/acme.json
COPY ./compose/production/traefik/traefik.yml /etc/traefik

View File

@ -0,0 +1,73 @@
log:
level: INFO
entryPoints:
web:
# http
address: ':80'
http:
# https://doc.traefik.io/traefik/routing/entrypoints/#entrypoint
redirections:
entryPoint:
to: web-secure
web-secure:
# https
address: ':443'
certificatesResolvers:
letsencrypt:
# https://doc.traefik.io/traefik/https/acme/#lets-encrypt
acme:
email: 'antonio-roberto@example.com'
storage: /etc/traefik/acme/acme.json
# https://doc.traefik.io/traefik/https/acme/#httpchallenge
httpChallenge:
entryPoint: web
http:
routers:
web-secure-router:
rule: 'Host(`example.com`) || Host(`www.example.com`)'
entryPoints:
- web-secure
middlewares:
- csrf
service: django
tls:
# https://doc.traefik.io/traefik/routing/routers/#certresolver
certResolver: letsencrypt
web-media-router:
rule: '(Host(`example.com`) || Host(`www.example.com`)) && PathPrefix(`/media/`)'
entryPoints:
- web-secure
middlewares:
- csrf
service: django-media
tls:
certResolver: letsencrypt
middlewares:
csrf:
# https://doc.traefik.io/traefik/master/middlewares/http/headers/#hostsproxyheaders
# https://docs.djangoproject.com/en/dev/ref/csrf/#ajax
headers:
hostsProxyHeaders: ['X-CSRFToken']
services:
django:
loadBalancer:
servers:
- url: http://django:5000
django-media:
loadBalancer:
servers:
- url: http://nginx:80
providers:
# https://doc.traefik.io/traefik/master/providers/file/
file:
filename: /etc/traefik/traefik.yml
watch: true

0
config/__init__.py Normal file
View File

11
config/api.py Normal file
View 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)

13
config/asgi.py Normal file
View File

@ -0,0 +1,13 @@
import os
from pathlib import Path
import sys
from django.core.asgi import get_asgi_application
BASE_DIR = Path(__file__).resolve(strict=True).parent.parent
sys.path.append(str(BASE_DIR / "diarios_oficiais_alems"))
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production")
application = get_asgi_application()

View File

375
config/settings/base.py Normal file
View File

@ -0,0 +1,375 @@
# ruff: noqa: ERA001, E501
"""Base settings to build other settings files upon."""
from pathlib import Path
import environ
BASE_DIR = Path(__file__).resolve(strict=True).parent.parent.parent
# 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=True)
if READ_DOT_ENV_FILE:
# OS environment variables take precedence over variables from .env
env.read_env(str(BASE_DIR / ".env"))
# GENERAL
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#debug
DEBUG = env.bool("DJANGO_DEBUG", True)
# Local time zone. Choices are
# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
# though not all of them may be available with every OS.
# In Windows, this must be set to your system time zone.
TIME_ZONE = "UTC"
# https://docs.djangoproject.com/en/dev/ref/settings/#language-code
LANGUAGE_CODE = "en-us"
# https://docs.djangoproject.com/en/dev/ref/settings/#languages
# from django.utils.translation import gettext_lazy as _
# LANGUAGES = [
# ('en', _('English')),
# ('fr-fr', _('French')),
# ('pt-br', _('Portuguese')),
# ]
# https://docs.djangoproject.com/en/dev/ref/settings/#site-id
SITE_ID = 1
# https://docs.djangoproject.com/en/dev/ref/settings/#use-i18n
USE_I18N = True
# https://docs.djangoproject.com/en/dev/ref/settings/#use-tz
USE_TZ = True
# https://docs.djangoproject.com/en/dev/ref/settings/#locale-paths
LOCALE_PATHS = [str(BASE_DIR / "locale")]
# DATABASES
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#databases
DATABASES = {"default": env.db("DATABASE_URL")}
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"
# URLS
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#root-urlconf
ROOT_URLCONF = "config.urls"
# https://docs.djangoproject.com/en/dev/ref/settings/#wsgi-application
WSGI_APPLICATION = "config.wsgi.application"
# APPS
# ------------------------------------------------------------------------------
DJANGO_APPS = [
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.sites",
"django.contrib.messages",
"django.contrib.staticfiles",
"django.contrib.humanize",
"django.contrib.admin",
"django.forms",
]
THIRD_PARTY_APPS = [
"crispy_forms",
"crispy_bootstrap5",
"allauth",
"allauth.account",
"allauth.mfa",
"allauth.socialaccount",
]
LOCAL_APPS = [
"diarios_oficiais_alems.users",
"diarios",
"django_elasticsearch_dsl",
"corsheaders",
]
# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
# MIGRATIONS
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#migration-modules
MIGRATION_MODULES = {"sites": "diarios_oficiais_alems.contrib.sites.migrations"}
# AUTHENTICATION
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#authentication-backends
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend",
"allauth.account.auth_backends.AuthenticationBackend",
]
# https://docs.djangoproject.com/en/dev/ref/settings/#auth-user-model
AUTH_USER_MODEL = "users.User"
# https://docs.djangoproject.com/en/dev/ref/settings/#login-redirect-url
LOGIN_REDIRECT_URL = "users:redirect"
# https://docs.djangoproject.com/en/dev/ref/settings/#login-url
LOGIN_URL = "account_login"
# PASSWORDS
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#password-hashers
PASSWORD_HASHERS = [
# https://docs.djangoproject.com/en/dev/topics/auth/passwords/#using-argon2-with-django
"django.contrib.auth.hashers.Argon2PasswordHasher",
"django.contrib.auth.hashers.PBKDF2PasswordHasher",
"django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher",
"django.contrib.auth.hashers.BCryptSHA256PasswordHasher",
]
# https://docs.djangoproject.com/en/dev/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
# MIDDLEWARE
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#middleware
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.locale.LocaleMiddleware",
"django.middleware.common.CommonMiddleware",
"corsheaders.middleware.CorsMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"allauth.account.middleware.AccountMiddleware",
]
# STATIC
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#static-root
STATIC_ROOT = str(BASE_DIR / "staticfiles")
# https://docs.djangoproject.com/en/dev/ref/settings/#static-url
STATIC_URL = "/static/"
# https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#std:setting-STATICFILES_DIRS
STATICFILES_DIRS = [str(APPS_DIR / "static")]
# https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#staticfiles-finders
STATICFILES_FINDERS = [
"django.contrib.staticfiles.finders.FileSystemFinder",
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
]
# MEDIA
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#media-root
MEDIA_ROOT = str(APPS_DIR / "media")
# https://docs.djangoproject.com/en/dev/ref/settings/#media-url
MEDIA_URL = "/media/"
# TEMPLATES
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#templates
TEMPLATES = [
{
# https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-TEMPLATES-BACKEND
"BACKEND": "django.template.backends.django.DjangoTemplates",
# https://docs.djangoproject.com/en/dev/ref/settings/#dirs
"DIRS": [str(APPS_DIR / "templates")],
# https://docs.djangoproject.com/en/dev/ref/settings/#app-dirs
"APP_DIRS": True,
"OPTIONS": {
# https://docs.djangoproject.com/en/dev/ref/settings/#template-context-processors
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.template.context_processors.i18n",
"django.template.context_processors.media",
"django.template.context_processors.static",
"django.template.context_processors.tz",
"django.contrib.messages.context_processors.messages",
"diarios_oficiais_alems.users.context_processors.allauth_settings",
],
},
},
]
# https://docs.djangoproject.com/en/dev/ref/settings/#form-renderer
FORM_RENDERER = "django.forms.renderers.TemplatesSetting"
# http://django-crispy-forms.readthedocs.io/en/latest/install.html#template-packs
CRISPY_TEMPLATE_PACK = "bootstrap5"
CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5"
# FIXTURES
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#fixture-dirs
FIXTURE_DIRS = (str(APPS_DIR / "fixtures"),)
# SECURITY
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#session-cookie-httponly
# https://docs.djangoproject.com/en/dev/ref/settings/#csrf-cookie-httponly
CSRF_COOKIE_SECURE = False
CSRF_COOKIE_HTTPONLY = False
CSRF_COOKIE_SAMESITE = "Lax"
SESSION_COOKIE_SECURE = False
SESSION_COOKIE_SAMESITE = "Lax"
CSRF_TRUSTED_ORIGINS = [
'http://192.168.235.234:8005',
'http://192.168.235.234:80',
'http://192.168.235.234',
'http://localhost:8005',
'http://localhost:80',
'http://localhost',
'http://127.0.0.1:8005',
'http://127.0.0.1:80',
'http://127.0.0.1',
]
# https://docs.djangoproject.com/en/dev/ref/settings/#x-frame-options
X_FRAME_OPTIONS = "DENY"
USE_X_FORWARDED_HOST = True
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'http')
# EMAIL
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#email-backend
EMAIL_BACKEND = env(
"DJANGO_EMAIL_BACKEND",
default="django.core.mail.backends.smtp.EmailBackend",
)
# https://docs.djangoproject.com/en/dev/ref/settings/#email-timeout
EMAIL_TIMEOUT = 5
# ADMIN
# ------------------------------------------------------------------------------
# Django Admin URL.
ADMIN_URL = "manage-panel/"
# https://docs.djangoproject.com/en/dev/ref/settings/#admins
ADMINS = [("""Antonio Roberto""", "antonio-roberto@example.com")]
# https://docs.djangoproject.com/en/dev/ref/settings/#managers
MANAGERS = ADMINS
# https://cookiecutter-django.readthedocs.io/en/latest/settings.html#other-environment-settings
# Force the `admin` sign in process to go through the `django-allauth` workflow
DJANGO_ADMIN_FORCE_ALLAUTH = env.bool("DJANGO_ADMIN_FORCE_ALLAUTH", default=False)
# LOGGING
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#logging
# See https://docs.djangoproject.com/en/dev/topics/logging for
# more details on how to customize your logging configuration.
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"verbose": {
"format": "%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s",
},
},
"handlers": {
"console": {
"level": "DEBUG",
"class": "logging.StreamHandler",
"formatter": "verbose",
},
},
"root": {"level": "INFO", "handlers": ["console"]},
}
REDIS_URL = env("REDIS_URL", default="redis://redis:6379/0")
REDIS_SSL = REDIS_URL.startswith("rediss://")
# django-allauth
# ------------------------------------------------------------------------------
ACCOUNT_ALLOW_REGISTRATION = False
# https://docs.allauth.org/en/latest/account/configuration.html
ACCOUNT_LOGIN_METHODS = {"username"}
# https://docs.allauth.org/en/latest/account/configuration.html
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 = "diarios_oficiais_alems.users.adapters.AccountAdapter"
# https://docs.allauth.org/en/latest/account/forms.html
ACCOUNT_FORMS = {"signup": "diarios_oficiais_alems.users.forms.UserSignupForm"}
# https://docs.allauth.org/en/latest/socialaccount/configuration.html
SOCIALACCOUNT_ADAPTER = "diarios_oficiais_alems.users.adapters.SocialAccountAdapter"
# https://docs.allauth.org/en/latest/socialaccount/configuration.html
SOCIALACCOUNT_FORMS = {
"signup": "diarios_oficiais_alems.users.forms.UserSocialSignupForm"
}
# django-compressor
# ------------------------------------------------------------------------------
# https://django-compressor.readthedocs.io/en/latest/quickstart/#installation
INSTALLED_APPS += ["compressor"]
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",
"timeout": 60,
"http_auth": (
ELASTICSEARCH_USER,
ELASTICSEARCH_PASSWORD,
),
},
}
ELASTICSEARCH_HOSTS = "http://elasticsearch:9200"
ELASTICSEARCH_INDEX_SETTINGS = {
# 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
},
},
# 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",
],
}
},
},
}
CORS_ALLOWED_ORIGINS = [
"http://192.168.235.234",
"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",
]

80
config/settings/local.py Normal file
View File

@ -0,0 +1,80 @@
# ruff: noqa: E501
from .base import * # noqa: F403
from .base import INSTALLED_APPS
from .base import MIDDLEWARE
from .base import env
# GENERAL
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#debug
DEBUG = True
# https://docs.djangoproject.com/en/dev/ref/settings/#secret-key
SECRET_KEY = env(
"DJANGO_SECRET_KEY",
default="tYdYl0MP5zgpMlMmjBuYHvH4Dp3JDN5q3sxWBdFejemZSr0qpI9IrvrvTm17F0aW",
)
# https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts
ALLOWED_HOSTS = ["localhost", "0.0.0.0", "127.0.0.1", "192.168.235.234"] # noqa: S104
# CACHES
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#caches
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": "",
},
}
# EMAIL
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#email-backend
EMAIL_BACKEND = env(
"DJANGO_EMAIL_BACKEND",
default="django.core.mail.backends.console.EmailBackend",
)
# WhiteNoise
# ------------------------------------------------------------------------------
# http://whitenoise.evans.io/en/latest/django.html#using-whitenoise-in-development
INSTALLED_APPS = ["whitenoise.runserver_nostatic", *INSTALLED_APPS]
# django-debug-toolbar
# ------------------------------------------------------------------------------
# https://django-debug-toolbar.readthedocs.io/en/latest/installation.html#prerequisites
INSTALLED_APPS += ["debug_toolbar"]
# https://django-debug-toolbar.readthedocs.io/en/latest/installation.html#middleware
MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"]
# https://django-debug-toolbar.readthedocs.io/en/latest/configuration.html#debug-toolbar-config
DEBUG_TOOLBAR_CONFIG = {
"DISABLE_PANELS": [
"debug_toolbar.panels.redirects.RedirectsPanel",
# Disable profiling panel due to an issue with Python 3.12:
# https://github.com/jazzband/django-debug-toolbar/issues/1875
"debug_toolbar.panels.profiling.ProfilingPanel",
],
"SHOW_TEMPLATE_CONTEXT": True,
}
# https://django-debug-toolbar.readthedocs.io/en/latest/installation.html#internal-ips
INTERNAL_IPS = ["127.0.0.1", "10.0.2.2"]
if env("USE_DOCKER") == "yes":
import socket
hostname, _, ips = socket.gethostbyname_ex(socket.gethostname())
INTERNAL_IPS += [".".join(ip.split(".")[:-1] + ["1"]) for ip in ips]
# RunServerPlus
# ------------------------------------------------------------------------------
# This is a custom setting for RunServerPlus to fix reloader issue in Windows docker environment
# Werkzeug reloader type [auto, watchdog, or stat]
RUNSERVERPLUS_POLLER_RELOADER_TYPE = "stat"
# If you have CPU and IO load issues, you can increase this poller interval e.g) 5
RUNSERVERPLUS_POLLER_RELOADER_INTERVAL = 1
# django-extensions
# ------------------------------------------------------------------------------
# https://django-extensions.readthedocs.io/en/latest/installation_instructions.html#configuration
INSTALLED_APPS += ["django_extensions"]
# Your stuff...
# ------------------------------------------------------------------------------

View File

@ -0,0 +1,172 @@
# ruff: noqa: E501
from .base import * # noqa: F403
from .base import DATABASES
from .base import INSTALLED_APPS
from .base import REDIS_URL
from .base import env
# GENERAL
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#secret-key
SECRET_KEY = env("DJANGO_SECRET_KEY")
# https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts
ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS", default=[])
# DATABASES
# ------------------------------------------------------------------------------
DATABASES["default"]["CONN_MAX_AGE"] = env.int("CONN_MAX_AGE", default=60)
# CACHES
# ------------------------------------------------------------------------------
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": REDIS_URL,
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
# Mimicking memcache behavior.
# https://github.com/jazzband/django-redis#memcached-exceptions-behavior
"IGNORE_EXCEPTIONS": True,
},
},
}
# SECURITY
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#secure-proxy-ssl-header
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
# https://docs.djangoproject.com/en/dev/ref/settings/#secure-ssl-redirect
SECURE_SSL_REDIRECT = env.bool("DJANGO_SECURE_SSL_REDIRECT", default=False)
# https://docs.djangoproject.com/en/dev/ref/settings/#session-cookie-secure
SESSION_COOKIE_SECURE = True
# https://docs.djangoproject.com/en/dev/ref/settings/#session-cookie-name
SESSION_COOKIE_NAME = "__Secure-sessionid"
# https://docs.djangoproject.com/en/dev/ref/settings/#csrf-cookie-secure
CSRF_COOKIE_SECURE = True
# https://docs.djangoproject.com/en/dev/ref/settings/#csrf-cookie-name
CSRF_COOKIE_NAME = "__Secure-csrftoken"
# https://docs.djangoproject.com/en/dev/topics/security/#ssl-https
# https://docs.djangoproject.com/en/dev/ref/settings/#secure-hsts-seconds
# TODO: set this to 60 seconds first and then to 518400 once you prove the former works
SECURE_HSTS_SECONDS = 60
# https://docs.djangoproject.com/en/dev/ref/settings/#secure-hsts-include-subdomains
SECURE_HSTS_INCLUDE_SUBDOMAINS = env.bool(
"DJANGO_SECURE_HSTS_INCLUDE_SUBDOMAINS",
default=True,
)
# https://docs.djangoproject.com/en/dev/ref/settings/#secure-hsts-preload
SECURE_HSTS_PRELOAD = env.bool("DJANGO_SECURE_HSTS_PRELOAD", default=True)
# https://docs.djangoproject.com/en/dev/ref/middleware/#x-content-type-options-nosniff
SECURE_CONTENT_TYPE_NOSNIFF = env.bool(
"DJANGO_SECURE_CONTENT_TYPE_NOSNIFF",
default=True,
)
# STATIC & MEDIA
# ------------------------
STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
}
# EMAIL
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#default-from-email
DEFAULT_FROM_EMAIL = env(
"DJANGO_DEFAULT_FROM_EMAIL",
default="Diários Oficiais ALEMS <noreply@example.com>",
)
# https://docs.djangoproject.com/en/dev/ref/settings/#server-email
SERVER_EMAIL = env("DJANGO_SERVER_EMAIL", default=DEFAULT_FROM_EMAIL)
# https://docs.djangoproject.com/en/dev/ref/settings/#email-subject-prefix
EMAIL_SUBJECT_PREFIX = env(
"DJANGO_EMAIL_SUBJECT_PREFIX",
default="[Diários Oficiais ALEMS] ",
)
ACCOUNT_EMAIL_SUBJECT_PREFIX = EMAIL_SUBJECT_PREFIX
# ADMIN
# ------------------------------------------------------------------------------
# Django Admin URL regex.
ADMIN_URL = env("DJANGO_ADMIN_URL")
# Anymail
# ------------------------------------------------------------------------------
# https://anymail.readthedocs.io/en/stable/installation/#installing-anymail
INSTALLED_APPS += ["anymail"]
# https://docs.djangoproject.com/en/dev/ref/settings/#email-backend
# https://anymail.readthedocs.io/en/stable/installation/#anymail-settings-reference
# https://anymail.readthedocs.io/en/stable/esps
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
ANYMAIL = {}
# django-compressor
# ------------------------------------------------------------------------------
# https://django-compressor.readthedocs.io/en/latest/settings/#django.conf.settings.COMPRESS_ENABLED
COMPRESS_ENABLED = env.bool("COMPRESS_ENABLED", default=True)
# https://django-compressor.readthedocs.io/en/latest/settings/#django.conf.settings.COMPRESS_STORAGE
COMPRESS_STORAGE = "compressor.storage.GzipCompressorFileStorage"
# https://django-compressor.readthedocs.io/en/latest/settings/#django.conf.settings.COMPRESS_URL
COMPRESS_URL = STATIC_URL # noqa: F405
# https://django-compressor.readthedocs.io/en/latest/settings/#django.conf.settings.COMPRESS_OFFLINE
COMPRESS_OFFLINE = True # Offline compression is required when using Whitenoise
# https://django-compressor.readthedocs.io/en/latest/settings/#django.conf.settings.COMPRESS_FILTERS
COMPRESS_FILTERS = {
"css": [
"compressor.filters.css_default.CssAbsoluteFilter",
"compressor.filters.cssmin.rCSSMinFilter",
],
"js": ["compressor.filters.jsmin.JSMinFilter"],
}
# LOGGING
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#logging
# See https://docs.djangoproject.com/en/dev/topics/logging for
# more details on how to customize your logging configuration.
# A sample logging configuration. The only tangible logging
# performed by this configuration is to send an email to
# the site admins on every HTTP 500 error when DEBUG=False.
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"filters": {"require_debug_false": {"()": "django.utils.log.RequireDebugFalse"}},
"formatters": {
"verbose": {
"format": "%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s",
},
},
"handlers": {
"mail_admins": {
"level": "ERROR",
"filters": ["require_debug_false"],
"class": "django.utils.log.AdminEmailHandler",
},
"console": {
"level": "DEBUG",
"class": "logging.StreamHandler",
"formatter": "verbose",
},
},
"root": {"level": "INFO", "handlers": ["console"]},
"loggers": {
"django.request": {
"handlers": ["mail_admins"],
"level": "ERROR",
"propagate": True,
},
"django.security.DisallowedHost": {
"level": "ERROR",
"handlers": ["console", "mail_admins"],
"propagate": True,
},
},
}
# Your stuff...
# ------------------------------------------------------------------------------

38
config/settings/test.py Normal file
View File

@ -0,0 +1,38 @@
"""
With these settings, tests run faster.
"""
from .base import * # noqa: F403
from .base import TEMPLATES
from .base import env
# GENERAL
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#secret-key
SECRET_KEY = env(
"DJANGO_SECRET_KEY",
default="zxqr3Ze2WN38ZLJaQLVByrC5TQ0sQHejMO3yiAv5tClsfNjkb9VjrgjNGvA6U002",
)
# https://docs.djangoproject.com/en/dev/ref/settings/#test-runner
TEST_RUNNER = "django.test.runner.DiscoverRunner"
# PASSWORDS
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#password-hashers
PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
# EMAIL
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#email-backend
EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
# DEBUGGING FOR TEMPLATES
# ------------------------------------------------------------------------------
TEMPLATES[0]["OPTIONS"]["debug"] = True # type: ignore[index]
# MEDIA
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#media-url
MEDIA_URL = "http://media.testserver/"
# Your stuff...
# ------------------------------------------------------------------------------

55
config/urls.py Normal file
View File

@ -0,0 +1,55 @@
# ruff: noqa
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
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("diarios_oficiais_alems.users.urls", namespace="users")),
# path("accounts/", include("allauth.urls")),
# Media files
*static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT),
]
if settings.DEBUG:
# This allows the error pages to be debugged during development, just visit
# these url in browser to see how these error pages look like.
urlpatterns += [
path(
"400/",
default_views.bad_request,
kwargs={"exception": Exception("Bad Request!")},
),
path(
"403/",
default_views.permission_denied,
kwargs={"exception": Exception("Permission Denied")},
),
path(
"404/",
default_views.page_not_found,
kwargs={"exception": Exception("Page not Found")},
),
path("500/", default_views.server_error),
]
if "debug_toolbar" in settings.INSTALLED_APPS:
import debug_toolbar
urlpatterns = [path("__debug__/", include(debug_toolbar.urls))] + urlpatterns

40
config/wsgi.py Normal file
View File

@ -0,0 +1,40 @@
# ruff: noqa
"""
WSGI config for Diários Oficiais ALEMS project.
This module contains the WSGI application used by Django's development server
and any production WSGI deployments. It should expose a module-level variable
named ``application``. Django's ``runserver`` and ``runfcgi`` commands discover
this application via the ``WSGI_APPLICATION`` setting.
Usually you will have the standard Django WSGI application here, but it also
might make sense to replace the whole Django WSGI application with a custom one
that later delegates to the Django one. For example, you could introduce WSGI
middleware here, or combine a Django application with an application of another
framework.
"""
import os
import sys
from pathlib import Path
from django.core.wsgi import get_wsgi_application
# This allows easy placement of apps within the interior
# diarios_oficiais_alems directory.
BASE_DIR = Path(__file__).resolve(strict=True).parent.parent
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
# os.environ["DJANGO_SETTINGS_MODULE"] = "config.settings.production"
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production")
# This application object is used by any WSGI server configured to use this
# file. This includes Django's development server, if the WSGI_APPLICATION
# setting points here.
application = get_wsgi_application()
# Apply WSGI middleware here.
# from helloworld.wsgi import HelloWorldApplication
# application = HelloWorldApplication(application)

0
diarios/__init__.py Normal file
View File

150
diarios/admin.py Normal file
View File

@ -0,0 +1,150 @@
from django.contrib import admin
from django.utils.html import format_html
from django.urls import reverse
from django.utils.safestring import mark_safe
from .models import TipoDiarioOficial, DiarioOficial, PageDiarioOficial
from .forms import PageDiarioOficialInlineForm
@admin.register(TipoDiarioOficial)
class TipoDiarioOficialAdmin(admin.ModelAdmin):
list_display = ("nome", "quantidade_diarios")
search_fields = ("nome",)
ordering = ("nome",)
def quantidade_diarios(self, obj):
return obj.diarios.count()
quantidade_diarios.short_description = "Nº de Diários"
class PageDiarioOficialInline(admin.TabularInline):
form = PageDiarioOficialInlineForm
model = PageDiarioOficial
extra = 0
fields = ("id", "numero", "conteudo")
can_delete = False
def numero_link(self, instance):
if instance.id:
url = reverse("admin:diarios_pagediariooficial_change", args=[instance.id])
return mark_safe(f'<a href="{url}">{instance.numero}</a>')
return instance.numero
def conteudo_resumido(self, instance):
return (
(instance.conteudo[:100] + "...")
if len(instance.conteudo) > 100
else instance.conteudo
)
conteudo_resumido.short_description = "Conteúdo (resumo)"
@admin.register(DiarioOficial)
class DiarioOficialAdmin(admin.ModelAdmin):
list_display = (
"numero",
"tipo_nome",
"data_formatada_admin",
"arquivo_link",
"link_externo",
"paginas_count",
)
list_filter = ("tipo", "data")
search_fields = ("numero", "tipo__nome", "data")
date_hierarchy = "data"
ordering = ("-data", "-numero")
readonly_fields = (
"data_formatada_admin",
"arquivo_preview",
"paginas_count",
"link_externo",
)
fieldsets = (
(
"Informações Básicas",
{"fields": ("numero", "tipo", "data", "data_formatada_admin")},
),
(
"Arquivos e Links",
{"fields": ("arquivo", "arquivo_preview", "link", "link_externo")},
),
("Estatísticas", {"fields": ("paginas_count",), "classes": ("collapse",)}),
)
# inlines = (PageDiarioOficialInline,)
def tipo_nome(self, obj):
return obj.tipo.nome if obj.tipo else "-"
tipo_nome.short_description = "Tipo"
tipo_nome.admin_order_field = "tipo__nome"
def data_formatada_admin(self, obj):
return obj.data_formatada
data_formatada_admin.short_description = "Data"
def arquivo_link(self, obj):
if obj.arquivo:
return mark_safe(
f'<a href="{obj.arquivo.url}" target="_blank">Download PDF</a>'
)
return "-"
arquivo_link.short_description = "Arquivo"
arquivo_link.allow_tags = True
def link_externo(self, obj):
if obj.link:
return mark_safe(f'<a href="{obj.link}" target="_blank">Acessar Online</a>')
return "-"
link_externo.short_description = "Link Externo"
link_externo.allow_tags = True
def arquivo_preview(self, obj):
if obj.arquivo:
return mark_safe(
f'<a href="{obj.arquivo.url}" target="_blank">Visualizar PDF</a>'
)
return "-"
arquivo_preview.short_description = "Pré-visualização"
arquivo_preview.allow_tags = True
def paginas_count(self, obj):
return obj.paginas.count()
paginas_count.short_description = "Nº de Páginas"
def get_queryset(self, request):
return super().get_queryset(request).prefetch_related("paginas")
@admin.register(PageDiarioOficial)
class PageDiarioOficialAdmin(admin.ModelAdmin):
autocomplete_fields = ("diario",)
list_display = ("id", "diario_link", "numero", "conteudo_resumido")
list_display_links = ('id', 'numero')
list_filter = ("diario__tipo", "diario__data", "layout_duas_colunas")
search_fields = ("diario__numero",)
readonly_fields = (
"diario_link",
)
def diario_link(self, obj):
url = reverse("admin:diarios_diariooficial_change", args=[obj.diario.id])
return mark_safe(f'<a href="{url}">{obj.diario}</a>')
diario_link.short_description = "Diário Oficial"
diario_link.allow_tags = True
def conteudo_resumido(self, obj):
return (obj.conteudo[:100] + "...") if len(obj.conteudo) > 100 else obj.conteudo
conteudo_resumido.short_description = "Conteúdo"
def get_queryset(self, request):
return super().get_queryset(request).select_related("diario")

5
diarios/api.py Normal file
View File

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

10
diarios/apps.py Normal file
View File

@ -0,0 +1,10 @@
from django.apps import AppConfig
class DiariosConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "diarios"
def ready(self):
import diarios.documents

179
diarios/crud_service.py Normal file
View File

@ -0,0 +1,179 @@
from typing import Optional, List
from datetime import date
from diarios.models import DiarioOficial, TipoDiarioOficial
from django.core.exceptions import ObjectDoesNotExist
from ninja.errors import HttpError
class DiarioOficialService:
@staticmethod
def criar_diario(
data: date,
numero: str,
tipo_id: Optional[int] = None,
arquivo=None,
link: Optional[str] = None
) -> DiarioOficial:
"""
Cria um novo diário oficial
Args:
data: Data do diário
numero: Número do diário
tipo_id: ID do tipo de diário
arquivo: Arquivo PDF (opcional)
link: URL do PDF (opcional)
Returns:
DiarioOficial: O diário criado
Raises:
HttpError: Se ocorrer algum erro na criação
"""
try:
tipo = TipoDiarioOficial.objects.get(pk=tipo_id) if tipo_id else None
diario = DiarioOficial(
data=data,
numero=numero,
tipo=tipo,
link=link
)
if arquivo:
diario.arquivo = arquivo
diario.full_clean()
diario.save()
return diario
except ObjectDoesNotExist:
raise HttpError(404, "Tipo de diário não encontrado")
except Exception as e:
raise HttpError(400, f"Erro ao criar diário: {str(e)}")
@staticmethod
def obter_diario_por_id(id: int) -> DiarioOficial:
"""
Obtém um diário pelo ID
Args:
id: ID do diário
Returns:
DiarioOficial: O diário encontrado
Raises:
HttpError: Se o diário não for encontrado
"""
try:
return DiarioOficial.objects.get(pk=id)
except ObjectDoesNotExist:
raise HttpError(404, "Diário não encontrado")
@staticmethod
def listar_diarios(
tipo_id: Optional[int] = None,
data_inicio: Optional[date] = None,
data_fim: Optional[date] = None,
numero: Optional[str] = None
) -> List[DiarioOficial]:
"""
Lista diários com filtros opcionais
Args:
tipo_id: ID do tipo de diário para filtrar
data_inicio: Data inicial para filtrar
data_fim: Data final para filtrar
numero: Número do diário para filtrar
Returns:
List[DiarioOficial]: Lista de diários filtrados
"""
queryset = DiarioOficial.objects.all().order_by('-data')
if tipo_id:
queryset = queryset.filter(tipo_id=tipo_id)
if data_inicio and data_fim:
queryset = queryset.filter(data__range=[data_inicio, data_fim])
elif data_inicio:
queryset = queryset.filter(data__gte=data_inicio)
elif data_fim:
queryset = queryset.filter(data__lte=data_fim)
if numero:
queryset = queryset.filter(numero__icontains=numero)
return list(queryset)
@staticmethod
def atualizar_diario(
id: int,
data: Optional[date] = None,
numero: Optional[str] = None,
tipo_id: Optional[int] = None,
arquivo=None,
link: Optional[str] = None
) -> DiarioOficial:
"""
Atualiza um diário existente
Args:
id: ID do diário a ser atualizado
data: Nova data (opcional)
numero: Novo número (opcional)
tipo_id: Novo tipo (opcional)
arquivo: Novo arquivo (opcional)
link: Novo link (opcional)
Returns:
DiarioOficial: O diário atualizado
Raises:
HttpError: Se ocorrer algum erro na atualização
"""
try:
diario = DiarioOficial.objects.get(pk=id)
if data is not None:
diario.data = data
if numero is not None:
diario.numero = numero
if tipo_id is not None:
tipo = TipoDiarioOficial.objects.get(pk=tipo_id) if tipo_id else None
diario.tipo = tipo
if arquivo is not None:
diario.arquivo = arquivo
if link is not None:
diario.link = link
diario.full_clean()
diario.save()
return diario
except ObjectDoesNotExist:
raise HttpError(404, "Diário ou tipo não encontrado")
except Exception as e:
raise HttpError(400, f"Erro ao atualizar diário: {str(e)}")
@staticmethod
def deletar_diario(id: int) -> None:
"""
Remove um diário
Args:
id: ID do diário a ser removido
Raises:
HttpError: Se o diário não for encontrado
"""
try:
diario = DiarioOficial.objects.get(pk=id)
diario.delete()
except ObjectDoesNotExist:
raise HttpError(404, "Diário não encontrado")

67
diarios/documents.py Normal file
View File

@ -0,0 +1,67 @@
from django_elasticsearch_dsl import Document, fields
from django_elasticsearch_dsl.registries import registry
from .models import DiarioOficial
@registry.register_document
class DiarioOficialDocument(Document):
numero = fields.KeywordField()
data = fields.DateField()
link = fields.KeywordField()
tipo = fields.ObjectField(properties={"nome": fields.KeywordField()})
# Campo para páginas
paginas = fields.NestedField(
properties={
"id": fields.IntegerField(),
"numero": fields.IntegerField(),
"conteudo": fields.TextField(
analyzer="custom_portuguese",
fields={
"keyword": fields.KeywordField(),
"search": fields.TextField(analyzer="custom_portuguese"),
},
),
}
)
class Index:
name = "diario_oficial"
settings = {
"number_of_shards": 1,
"number_of_replicas": 0,
"analysis": {
"analyzer": {
"custom_portuguese": {
"type": "custom",
"tokenizer": "standard",
"filter": [
"lowercase",
"asciifolding",
"portuguese_stop",
],
}
},
"filter": {
"portuguese_stop": {"type": "stop", "stopwords": "_portuguese_"},
},
},
}
class Django:
model = DiarioOficial
fields = ["id"]
def prepare_tipo(self, instance):
return {"nome": instance.tipo.nome if instance.tipo else "Sem Tipo"}
def prepare_link(self, instance):
return instance.link or ""
def prepare_paginas(self, instance):
# Preparar páginas ordenadas
paginas = instance.paginas.all().order_by("numero")
return [
{"id": pagina.id, "numero": pagina.numero, "conteudo": pagina.conteudo}
for pagina in paginas
]

21
diarios/forms.py Normal file
View File

@ -0,0 +1,21 @@
from django import forms
from .models import PageDiarioOficial
class PageDiarioOficialInlineForm(forms.ModelForm):
class Meta:
model = PageDiarioOficial
fields = "__all__"
def clean(self):
cleaned_data = super().clean()
if "numero" in cleaned_data and self.instance.diario:
if (
PageDiarioOficial.objects.filter(
diario=self.instance.diario, numero=cleaned_data["numero"]
)
.exclude(pk=self.instance.pk)
.exists()
):
self.add_error("numero", "Já existe uma página com este número")
return cleaned_data

View File

@ -0,0 +1,105 @@
import os
import time
from django.core.files import File
from django.core.management.base import BaseCommand
from diarios.models import DiarioOficial
from diarios.signals import update_document, delete_document
from django.db.models.signals import post_save, post_delete
class Command(BaseCommand):
help = "Importa arquivos PDF de diários oficiais e associa aos objetos existentes no banco."
def add_arguments(self, parser):
parser.add_argument(
"pasta",
type=str,
help="Caminho para a pasta contendo os arquivos PDF (nomes devem conter o número do diário)",
)
def handle(self, *args, **options):
post_save.disconnect(update_document, sender=DiarioOficial)
post_delete.disconnect(delete_document, sender=DiarioOficial)
pasta = options["pasta"]
if not os.path.isdir(pasta):
self.stderr.write(
self.style.ERROR(f"A pasta fornecida não existe: {pasta}")
)
return
arquivos_pdf = [
f
for f in os.listdir(pasta)
if f.lower().endswith(".pdf") and "Diário_Oficial_Eletrônico_nº_" in f
]
if not arquivos_pdf:
self.stdout.write(
self.style.WARNING("Nenhum arquivo PDF válido encontrado na pasta.")
)
return
total = len(arquivos_pdf)
erros = []
atualizados = []
start_time = time.time()
# Mapeia os números aos nomes de arquivos
numero_para_arquivo = {
f.replace("Diário_Oficial_Eletrônico_nº_", "")
.replace(".pdf", "")
.strip("_-"): f
for f in arquivos_pdf
}
# Busca todos os objetos de uma vez
diarios_existentes = DiarioOficial.objects.in_bulk(
numero_para_arquivo.keys(), field_name="numero"
)
for idx, (numero, nome_arquivo) in enumerate(
numero_para_arquivo.items(), start=1
):
try:
diario = diarios_existentes.get(numero)
if not diario:
raise DiarioOficial.DoesNotExist()
caminho_pdf = os.path.join(pasta, nome_arquivo)
with open(caminho_pdf, "rb") as f:
diario.arquivo.save(nome_arquivo, File(f), save=True)
atualizados.append(diario.pk)
elapsed = time.time() - start_time
remaining = (elapsed / idx) * (total - idx)
self.stdout.write(
f"[{idx}/{total}] Atualizado: {nome_arquivo} | Estimativa restante: {remaining:.1f}s"
)
except DiarioOficial.DoesNotExist:
msg = f"Não encontrado no banco: {nome_arquivo}"
erros.append(msg)
self.stderr.write(self.style.WARNING(msg))
except Exception as e:
msg = f"Erro ao processar {nome_arquivo}: {str(e)}"
erros.append(msg)
self.stderr.write(self.style.ERROR(msg))
self.stdout.write(
self.style.SUCCESS(f"{len(atualizados)} arquivos importados com sucesso.")
)
self.stdout.write(self.style.WARNING(f"{len(erros)} arquivos com erro."))
if erros:
caminho_log = os.path.join(pasta, "erros_importacao.txt")
with open(caminho_log, "w", encoding="utf-8") as erro_file:
for linha in erros:
erro_file.write(f"{linha}\n")
self.stdout.write(
self.style.WARNING(f"Erros registrados em: {caminho_log}")
)
post_save.connect(update_document, sender=DiarioOficial)
post_delete.connect(delete_document, sender=DiarioOficial)

View File

@ -0,0 +1,31 @@
# Generated by Django 5.0.12 on 2025-03-06 16:00
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="PDFDocument",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("title", models.CharField(max_length=255)),
("file", models.FileField(upload_to="pdfs/")),
("content", models.TextField(blank=True)),
("uploaded_at", models.DateTimeField(auto_now_add=True)),
],
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.0.12 on 2025-03-07 13:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("diarios", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="pdfdocument",
name="page_content",
field=models.TextField(blank=True),
),
]

View File

@ -0,0 +1,75 @@
# Generated by Django 5.0.12 on 2025-03-07 14:58
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("diarios", "0002_pdfdocument_page_content"),
]
operations = [
migrations.CreateModel(
name="TipoDiarioOficial",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("nome", models.CharField(max_length=100, unique=True)),
],
options={
"verbose_name_plural": "Tipos de Diários Oficiais",
},
),
migrations.CreateModel(
name="DiarioOficial",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("data", models.DateField()),
(
"arquivo",
models.FileField(
blank=True, null=True, upload_to="diarios_oficiais/"
),
),
("numero", models.CharField(max_length=20, unique=True)),
("link", models.URLField(blank=True, null=True, unique=True)),
("finalizado", models.BooleanField(default=False)),
(
"tipo",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="diarios",
to="diarios.tipodiariooficial",
),
),
],
options={
"verbose_name_plural": "Diários Oficiais",
},
),
migrations.AddConstraint(
model_name="diariooficial",
constraint=models.UniqueConstraint(
fields=("numero",), name="unique_numero"
),
),
]

View File

@ -0,0 +1,39 @@
# Generated by Django 5.0.12 on 2025-03-07 15:25
import django.core.serializers.json
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("diarios", "0003_tipodiariooficial_diariooficial_and_more"),
]
operations = [
migrations.RemoveField(
model_name="diariooficial",
name="finalizado",
),
migrations.AddField(
model_name="diariooficial",
name="page_content",
field=models.JSONField(
blank=True,
encoder=django.core.serializers.json.DjangoJSONEncoder,
null=True,
),
),
migrations.AlterField(
model_name="diariooficial",
name="tipo",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="diarios",
to="diarios.tipodiariooficial",
),
),
]

View File

@ -0,0 +1,16 @@
# Generated by Django 5.0.12 on 2025-03-15 15:51
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("diarios", "0004_remove_diariooficial_finalizado_and_more"),
]
operations = [
migrations.DeleteModel(
name="PDFDocument",
),
]

View File

@ -0,0 +1,47 @@
# Generated by Django 5.0.12 on 2025-03-27 12:30
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("diarios", "0005_delete_pdfdocument"),
]
operations = [
migrations.RemoveField(
model_name="diariooficial",
name="page_content",
),
migrations.CreateModel(
name="PageDiarioOficial",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("numero", models.PositiveIntegerField()),
("conteudo", models.TextField()),
(
"diario",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="paginas",
to="diarios.diariooficial",
),
),
],
options={
"verbose_name": "Página de Diário Oficial",
"verbose_name_plural": "Páginas de Diários Oficiais",
"unique_together": {("diario", "numero")},
},
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.0.12 on 2025-04-28 13:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("diarios", "0006_remove_diariooficial_page_content_pagediariooficial"),
]
operations = [
migrations.AddField(
model_name="diariooficial",
name="layout_duas_colunas",
field=models.BooleanField(default=False),
),
]

View File

@ -0,0 +1,22 @@
# Generated by Django 5.0.12 on 2025-04-28 13:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("diarios", "0007_diariooficial_layout_duas_colunas"),
]
operations = [
migrations.RemoveField(
model_name="diariooficial",
name="layout_duas_colunas",
),
migrations.AddField(
model_name="pagediariooficial",
name="layout_duas_colunas",
field=models.BooleanField(default=False),
),
]

View File

167
diarios/models.py Normal file
View File

@ -0,0 +1,167 @@
import os
from urllib.parse import urlparse
import requests
from babel.dates import format_date
from django.core.files.base import ContentFile
from django.db import models, transaction
from django.core.exceptions import ValidationError
import fitz # PyMuPDF
from asgiref.sync import async_to_sync
class TipoDiarioOficial(models.Model):
nome = models.CharField(max_length=100, unique=True)
def __str__(self):
return self.nome
class Meta:
verbose_name_plural = "Tipos de Diários Oficiais"
class DiarioOficial(models.Model):
data = models.DateField()
arquivo = models.FileField(upload_to="diarios_oficiais/", blank=True, null=True)
tipo = models.ForeignKey(
TipoDiarioOficial,
blank=True,
null=True,
on_delete=models.SET_NULL,
related_name="diarios",
)
numero = models.CharField(max_length=20, unique=True)
link = models.URLField(blank=True, null=True, unique=True)
def save(self, *args, **kwargs):
updated = False
super().save(*args, **kwargs)
if self.link and not self.arquivo:
self._download_pdf_from_link()
updated = True
if self.arquivo and not self.paginas.exists():
self._extract_pdf_pages()
updated = True
if updated:
super().save(*args, **kwargs)
def clean(self):
super().clean()
if not self.arquivo and not self.link:
raise ValidationError("Informe um arquivo ou um link para o Diário.")
def _validar_link(self):
if not self.link.lower().endswith(".pdf"):
raise ValidationError("O link deve apontar para um arquivo PDF.")
def _download_pdf_from_link(self):
try:
response = requests.get(self.link)
response.raise_for_status()
parsed_url = urlparse(self.link)
file_name = os.path.basename(parsed_url.path) or f"diario_{self.numero}.pdf"
self.arquivo.save(file_name, ContentFile(response.content), save=True)
except requests.RequestException as e:
raise ValidationError(f"Não foi possível baixar o PDF: {e}")
def _extract_pdf_pages(self):
try:
# Salvar temporariamente o PDF para abrir com o PyMuPDF
with self.arquivo.open("rb") as pdf_file:
temp_pdf_path = f"/tmp/diario_{self.id}.pdf"
with open(temp_pdf_path, "wb") as temp_file:
temp_file.write(pdf_file.read())
# Abrir e processar com fitz
doc = fitz.open(temp_pdf_path)
self._process_pdf_pages(doc)
doc.close()
# Remover arquivo temporário
os.remove(temp_pdf_path)
except Exception as pdf_error:
raise ValidationError(f"Não foi possível processar o PDF: {pdf_error}")
def _process_pdf_pages(self, doc):
with transaction.atomic():
self.paginas.all().delete()
for i, page in enumerate(doc):
try:
blocks = page.get_text("blocks")
# Ordenar os blocos por coordenadas (y, x) para manter a ordem de leitura
blocks.sort(key=lambda b: (b[1], b[0]))
page_text = ""
for block in blocks:
text = block[4].strip()
if text:
page_text += text + "\n"
# Crucial: Remove NULL bytes from the extracted text
# PostgreSQL text fields cannot contain NUL (0x00) bytes
cleaned_text = page_text.strip().replace('\x00', '')
if cleaned_text:
PageDiarioOficial.objects.create(
diario=self,
numero=i + 1,
conteudo=cleaned_text,
)
else:
PageDiarioOficial.objects.create(
diario=self,
numero=i + 1,
conteudo="[Conteúdo não extraído ou vazio]",
)
except Exception as page_error:
PageDiarioOficial.objects.create(
diario=self,
numero=i + 1,
conteudo=f"[Erro na extração do texto: {str(page_error)}]",
)
print(f"Erro ao processar a página {i+1} no Diario ID {self.id}: {page_error}")
@property
def data_formatada(self):
return format_date(self.data, format="long", locale="pt_BR")
@property
def is_online(self):
return bool(self.link)
def __str__(self):
tipo_nome = self.tipo.nome if self.tipo else "Sem Tipo"
return f"Diário {tipo_nome}{self.numero}, {self.data_formatada}"
class Meta:
constraints = [models.UniqueConstraint(fields=["numero"], name="unique_numero")]
verbose_name_plural = "Diários Oficiais"
class PageDiarioOficial(models.Model):
diario = models.ForeignKey(
DiarioOficial, on_delete=models.CASCADE, related_name="paginas"
)
layout_duas_colunas = models.BooleanField(default=False)
numero = models.PositiveIntegerField()
conteudo = models.TextField()
class Meta:
unique_together = ("diario", "numero")
verbose_name = "Página de Diário Oficial"
verbose_name_plural = "Páginas de Diários Oficiais"
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
if self.diario:
self.diario.save()
def __str__(self):
return f"Página {self.numero} do Diário {self.diario.numero}"

126
diarios/schemas.py Normal file
View File

@ -0,0 +1,126 @@
from ninja import Schema, ModelSchema, UploadedFile
from typing import List, Optional
from .models import TipoDiarioOficial
from datetime import date
class PaginaSchema(Schema):
"""Schema que representa uma página de um Diário Oficial.
Attributes:
numero (int): Número ordinal da página no Diário (começa em 1).
conteudo (str): Texto extraído da página.
"""
numero: int
conteudo: str
class ResultadoSchema(Schema):
"""Schema de resposta para um Diário Oficial em resultados de busca.
Attributes:
id (int): ID único do Diário no banco de dados.
numero (str): Número de identificação oficial do Diário (e.g., '123-A').
data (str): Data de publicação no formato ISO (YYYY-MM-DD).
link (str): URL para acessar o Diário Oficial online.
tipo (str): Nome do tipo de Diário (e.g., 'Municipal', 'Federal').
paginas (List[PaginaSchema]): Lista de páginas com conteúdo extraído.
score (Optional[float]): Relevância do resultado (0 a 1), se aplicável.
"""
id: int
numero: str
data: str
link: str
tipo: str
paginas: List[PaginaSchema]
score: Optional[float] = None
class BuscaDiariosResponseSchema(Schema):
"""Schema de resposta para buscas paginadas em Diários Oficiais.
Attributes:
total (int): Total de resultados disponíveis (ignorando paginação).
resultados (List[ResultadoSchema]): Lista de Diários encontrados.
pagina (int): Número da página atual (começa em 1).
por_pagina (int): Quantidade de resultados por página.
"""
total: int
resultados: List[ResultadoSchema]
pagina: int
por_pagina: int
class SugestaoResponse(Schema):
"""Schema para sugestões de correção de busca (e.g., 'Voc quis dizer...?').
Attributes:
sugestao (Optional[str]): Termo sugerido para refinar a busca.
None se nenhuma sugestão for relevante.
"""
sugestao: Optional[str]
class TipoDiarioSchema(ModelSchema):
class Config:
model = TipoDiarioOficial
model_fields = ["id", "nome"]
class DiarioOficialIn(Schema):
"""Schema para criação de Diário Oficial"""
data: date
numero: str
tipo_id: Optional[int] = None
link: Optional[str] = None
arquivo: Optional[UploadedFile] = None # Adicionando o campo de arquivo
class Config:
# Configuração adicional para o Swagger UI
json_schema_extra = {
"example": {
"data": "2023-12-01",
"numero": "1234/2023",
}
}
class DiarioOficialOut(Schema):
"""Schema para retorno de Diário Oficial (com detalhes completos)"""
id: int
data: str # Será formatado como ISO (YYYY-MM-DD)
numero: str
link: Optional[str]
tipo: Optional[TipoDiarioSchema]
total_paginas: int
@staticmethod
def resolve_data(obj):
return obj.data.isoformat()
@staticmethod
def resolve_total_paginas(obj):
return obj.paginas.count()
class DiarioOficialUpdate(Schema):
"""Schema para atualização de Diário Oficial"""
data: Optional[date] = None
numero: Optional[str] = None
tipo_id: Optional[int] = None
link: Optional[str] = None
class DiarioListagem(Schema):
"""Schema simplificado para listagem de Diários"""
id: int
data: str
numero: str
tipo_nome: Optional[str]
@staticmethod
def resolve_data(obj):
return obj.data.isoformat()
@staticmethod
def resolve_tipo_nome(obj):
return obj.tipo.nome if obj.tipo else None

848
diarios/search_service.py Normal file
View File

@ -0,0 +1,848 @@
import re
from datetime import datetime
from typing import Optional, Dict, Any, List
from elasticsearch import Elasticsearch, AsyncElasticsearch
from .schemas import BuscaDiariosResponseSchema, ResultadoSchema, PaginaSchema
import unicodedata
import asyncio
from django.conf import settings
async def is_fuzzy_appropriate(term: str) -> bool:
"""
Determina se a fuzziness é apropriada para o termo de busca.
Args:
term: Termo de busca a ser avaliado
Returns:
bool: True se fuzziness é apropriada, False caso contrário
"""
return not re.match(r"^\d+/\d+$", term.strip())
async def parse_date(date_str: Optional[str]) -> Optional[str]:
"""
Converte string de data para formato ISO para ElasticSearch.
Args:
date_str: String de data no formato YYYY-MM-DD
Returns:
Optional[str]: Data formatada ou None se inválida
"""
if not date_str:
return None
try:
dt = datetime.strptime(date_str, "%Y-%m-%d")
return dt.strftime("%Y-%m-%d")
except ValueError:
print(f"Alerta: Formato de data inválido recebido: {date_str}")
return None
async def buscar_diarios(
query: Optional[str] = None,
data_inicio: Optional[str] = None,
data_fim: Optional[str] = None,
tipo_diario: Optional[str] = None,
page: int = 1,
page_size: int = 10,
) -> Dict[str, Any]:
"""
Realiza busca nos diários oficiais com os parâmetros fornecidos.
Args:
query: Termo de busca
data_inicio: Data inicial no formato YYYY-MM-DD
data_fim: Data final no formato YYYY-MM-DD
tipo_diario: Tipo de diário a ser filtrado
page: Número da página de resultados
page_size: Quantidade de resultados por página
Returns:
Dict[str, Any]: Dicionário com resultados da busca
"""
try:
es = AsyncElasticsearch(
"http://elasticsearch:9200",
request_timeout=30,
basic_auth=(
settings.ELASTICSEARCH_USER,
settings.ELASTICSEARCH_PASSWORD,
),
)
if not await es.ping():
raise ConnectionError("Não foi possível conectar ao Elasticsearch")
except Exception as e:
print(f"Erro ao conectar com Elasticsearch: {e}")
return {"total": 0, "resultados": [], "pagina": page, "por_pagina": page_size}
es_body = {
"query": {"bool": {"must": [], "filter": []}},
"size": page_size,
"from": (page - 1) * page_size,
"_source": [
"numero",
"data",
"link",
"tipo",
"paginas.numero",
"paginas.conteudo",
],
}
parsed_dt_inicio = await parse_date(data_inicio)
parsed_dt_fim = await parse_date(data_fim)
if parsed_dt_inicio or parsed_dt_fim:
date_range_filter = {}
if parsed_dt_inicio:
date_range_filter["gte"] = parsed_dt_inicio
if parsed_dt_fim:
date_range_filter["lte"] = parsed_dt_fim
es_body["query"]["bool"]["filter"].append(
{"range": {"data": date_range_filter}}
)
if tipo_diario:
es_body["query"]["bool"]["filter"].append({"term": {"tipo.nome": tipo_diario}})
if query:
aplicar_fuzziness = await is_fuzzy_appropriate(query)
text_query_bool = {
"bool": {
"should": [
{
"match_phrase": {
"paginas.conteudo.search": {
"query": query,
"slop": 4,
"boost": 5.0,
}
}
},
{
"match": {
"paginas.conteudo.search": {
"query": query,
"fuzziness": "AUTO",
"operator": "and",
"prefix_length": 3,
}
}
},
],
"minimum_should_match": "75%",
}
}
nested_query = {
"nested": {
"path": "paginas",
"query": text_query_bool,
"inner_hits": {
"highlight": {
"fields": {"paginas.conteudo.search": {}},
"fragment_size": 500,
"number_of_fragments": 1,
"pre_tags": ["<mark>"],
"post_tags": ["</mark>"],
},
"_source": ["paginas.numero"],
},
}
}
es_body["query"]["bool"]["must"].append(nested_query)
else:
es_body["query"]["bool"]["must"].append({"match_all": {}})
# Adicionar min_score
if query:
es_body["min_score"] = 2.5 * len(query.split())
try:
response = await es.search(index="diario_oficial", body=es_body)
except Exception as e:
print(f"Erro ao executar busca no Elasticsearch: {e}")
return {"total": 0, "resultados": [], "pagina": page, "por_pagina": page_size}
finally:
await es.close()
hits = response.get("hits", {})
total = hits.get("total", {}).get("value", 0)
resultados_formatados = []
for hit in hits.get("hits", []):
source = hit.get("_source", {})
resultado_data = {
"id": hit.get("_id"),
"numero": source.get("numero", ""),
"data": source.get("data"),
"link": source.get("link", ""),
"tipo": source.get("tipo", {}).get("nome", "Sem Tipo"),
"score": hit.get("_score"),
"paginas": [],
}
pagina_match = None
if query and "inner_hits" in hit and "paginas" in hit["inner_hits"]:
paginas_inner_hits = hit["inner_hits"]["paginas"]["hits"]["hits"]
if paginas_inner_hits:
inner_hit = paginas_inner_hits[0]
inner_source = inner_hit.get("_source", {})
page_num = inner_source.get("numero", "N/A")
highlights = inner_hit.get("highlight", {}).get(
"paginas.conteudo.search", []
)
highlight_content = " ... ".join(highlights) if highlights else ""
if page_num != "N/A" and highlight_content:
pagina_match = PaginaSchema(
numero=page_num, conteudo=highlight_content
)
if pagina_match:
resultado_data["paginas"].append(pagina_match)
elif not query and "paginas" in source and source["paginas"]:
primeira_pagina_source = source["paginas"][0]
conteudo_orig = primeira_pagina_source.get("conteudo", "")
resultado_data["paginas"].append(
PaginaSchema(
numero=primeira_pagina_source.get("numero", 0),
conteudo=conteudo_orig,
)
)
resultados_formatados.append(ResultadoSchema(**resultado_data))
return {
"total": total,
"resultados": resultados_formatados,
"pagina": page,
"por_pagina": page_size,
}
async def remover_acentos(texto: str) -> str:
"""
Remove acentos de uma string para comparações mais neutras.
Args:
texto: Texto a ser normalizado
Returns:
str: Texto sem acentos
"""
return "".join(
c
for c in unicodedata.normalize("NFD", texto)
if unicodedata.category(c) != "Mn"
)
async def processar_query(query: str) -> str:
"""
Faz pré-processamento da query para separar termos como 'processonº404'.
Args:
query: Texto da consulta original
Returns:
str: Consulta processada
"""
return re.sub(r"([a-zA-Z]+)(nº|n°|no)(\d+)", r"\1 \2 \3", query)
async def montar_suggest_body(query_processada: str) -> dict:
"""
Monta o corpo da sugestão para a requisição ao Elasticsearch.
Args:
query_processada: Query processada para sugestão
Returns:
dict: Corpo da requisição para suggest
"""
return {
"suggest": {
"text": query_processada,
"correcao": {
"phrase": {
"field": "paginas.conteudo.search",
"size": 3,
"gram_size": 2,
"confidence": 0.8,
"max_errors": 4,
"highlight": {"pre_tag": "**", "post_tag": "**"},
"collate": {
"query": {
"source": {
"nested": {
"path": "paginas",
"query": {
"bool": {
"should": [
{
"match_phrase": {
"paginas.conteudo.search": {
"query": "{{suggestion}}",
"slop": 1,
}
}
}
]
}
},
}
}
},
"params": {"field_name": "paginas.conteudo.search"},
"prune": True,
},
}
},
},
"size": 0,
}
async def sugestao_termo(query: str) -> Optional[str]:
"""
Oferece sugestões de correção para a query, verificando se a sugestão
realmente retorna resultados antes de apresentá-la ao usuário.
Args:
query: Texto da consulta original
Returns:
Optional[str]: Sugestão para a consulta ou None
"""
es = await conectar_elasticsearch()
if not es:
return None
query_processada = await processar_query(query)
suggest_body = await montar_suggest_body(query_processada)
try:
response = await es.search(index="diario_oficial", body=suggest_body)
suggestions = response.get("suggest", {}).get("correcao", [])
for sug in suggestions:
for option in sug.get("options", []):
if option.get("collate_match", False):
sugestao = option["text"]
# Verifica se a sugestão é idêntica à query (ignorando acentos e case)
if sugestao.lower() == query.lower():
continue
if await remover_acentos(sugestao.lower()) == await remover_acentos(
query.lower()
):
continue
return sugestao
return None
except Exception as e:
print(f"Erro ao buscar sugestão: {e}")
return None
finally:
await es.close()
async def conectar_elasticsearch() -> Optional[AsyncElasticsearch]:
"""
Conecta ao Elasticsearch e retorna o cliente.
Returns:
Optional[AsyncElasticsearch]: Cliente Elasticsearch ou None se falhar
"""
try:
es = AsyncElasticsearch(
"http://elasticsearch:9200",
request_timeout=30,
basic_auth=(
settings.ELASTICSEARCH_USER,
settings.ELASTICSEARCH_PASSWORD,
),
)
if not await es.ping():
raise ConnectionError("Não foi possível conectar ao Elasticsearch")
return es
except Exception as e:
print(f"Erro ao conectar com Elasticsearch: {e}")
return None
async def construir_ordenacao(ordenar_por: str) -> List[Dict[str, Any]]:
"""
Constrói a cláusula de ordenação para a consulta.
Args:
ordenar_por: Critério de ordenação
Returns:
List[Dict[str, Any]]: Lista de ordenação para Elasticsearch
"""
if ordenar_por == "data_asc":
return [{"data": {"order": "asc"}}]
elif ordenar_por == "data_desc":
return [{"data": {"order": "desc"}}]
else: # relevancia
return ["_score"]
async def preencher_numero_do_diario_com_zeros(numero_diario: str) -> str:
"""
Preenche o número do diário com zeros à esquerda.
Args:
numero_diario: Número do diário
Returns:
str: Número formatado com zeros à esquerda
"""
return numero_diario.zfill(4)
async def construir_filtros(
data_inicio: Optional[str],
data_fim: Optional[str],
tipo_diario: Optional[str],
numero_diario: Optional[str],
) -> List[Dict[str, Any]]:
"""
Constrói os filtros de data, tipo e número do diário.
Args:
data_inicio: Data inicial no formato YYYY-MM-DD
data_fim: Data final no formato YYYY-MM-DD
tipo_diario: Tipo de diário a ser filtrado
numero_diario: Número do diário
Returns:
List[Dict[str, Any]]: Lista de filtros para Elasticsearch
"""
filtros = []
parsed_dt_inicio = await parse_date(data_inicio)
parsed_dt_fim = await parse_date(data_fim)
if parsed_dt_inicio or parsed_dt_fim:
date_range = {}
if parsed_dt_inicio:
date_range["gte"] = parsed_dt_inicio
if parsed_dt_fim:
date_range["lte"] = parsed_dt_fim
filtros.append({"range": {"data": date_range}})
if tipo_diario:
filtros.append({"term": {"tipo.nome": tipo_diario}})
if numero_diario:
# numero_diario = await preencher_numero_do_diario_com_zeros(numero_diario)
filtros.append({"wildcard": {"numero": f"*{numero_diario}*"}})
return filtros
async def construir_query_busca(
query: Optional[str], modo_busca: str
) -> Dict[str, Any]:
"""
Constrói a query de busca com base no termo e modo de busca.
Args:
query: Termo de busca
modo_busca: Modo de busca (exata ou qualquer)
Returns:
Dict[str, Any]: Query de busca para Elasticsearch
"""
if not query:
return {
"nested": {
"path": "paginas",
"query": {"match_all": {}},
"inner_hits": {
"_source": ["paginas.numero", "paginas.conteudo"],
"size": 100,
},
}
}
should_queries = []
# Busca exata (boost maior)
should_queries.append(
{
"match_phrase": {
"paginas.conteudo.search": {"query": query, "slop": 3, "boost": 5.0}
}
}
)
# Busca mais leve (separando termos), se modo_busca permitir
if modo_busca == "qualquer":
should_queries.append(
{
"match": {
"paginas.conteudo.search": {
"query": query,
"operator": "or",
"fuzziness": "AUTO",
"prefix_length": 3,
"boost": 1.0,
}
}
}
)
return {
"nested": {
"path": "paginas",
"query": {"bool": {"should": should_queries, "minimum_should_match": 1}},
"inner_hits": {
"highlight": {
"fields": {"paginas.conteudo.search": {}},
"fragment_size": 500,
"number_of_fragments": 1,
"pre_tags": ["<mark>"],
"post_tags": ["</mark>"],
},
"_source": ["paginas.numero"],
"size": 100, # Aumentar para retornar mais páginas correspondentes
},
}
}
async def construir_request_body(
query: Optional[str],
modo_busca: str,
ordenar_por: str,
data_inicio: Optional[str],
data_fim: Optional[str],
tipo_diario: Optional[str],
numero_diario: Optional[str],
page: int,
page_size: int,
) -> Dict[str, Any]:
"""
Constrói o corpo da requisição para o Elasticsearch.
Args:
query: Termo de busca
modo_busca: Modo de busca (exata ou qualquer)
ordenar_por: Critério de ordenação
data_inicio: Data inicial
data_fim: Data final
tipo_diario: Tipo de diário
numero_diario: Número do diário
page: Número da página
page_size: Tamanho da página
Returns:
Dict[str, Any]: Corpo da requisição para Elasticsearch
"""
es_body = {
"query": {"bool": {"must": [], "filter": []}},
"size": page_size,
"from": (page - 1) * page_size,
"_source": [
"numero",
"data",
"link",
"tipo",
"paginas.numero",
"paginas.conteudo",
],
}
# Adicionar ordenação
es_body["sort"] = await construir_ordenacao(ordenar_por)
# Adicionar filtros
es_body["query"]["bool"]["filter"] = await construir_filtros(
data_inicio, data_fim, tipo_diario, numero_diario
)
# Adicionar query principal
query_principal = await construir_query_busca(query, modo_busca)
es_body["query"]["bool"]["must"].append(query_principal)
return es_body
async def processar_paginas_encontradas(
hit: Dict[str, Any], query: Optional[str]
) -> List[PaginaSchema]:
"""
Processa as páginas encontradas em um hit, retornando todas as correspondências.
Args:
hit: Item de resultado do Elasticsearch
query: Termo de busca original
Returns:
List[PaginaSchema]: Lista de páginas encontradas
"""
if not query:
return []
paginas = []
source = hit.get("_source", {})
#if not query and "paginas" in source:
# for pagina in source["paginas"]:
# paginas.append(
# PaginaSchema(
# numero=pagina.get("numero", 0), conteudo=pagina.get("conteudo", "")
# )
# )
# Se temos uma query e há inner_hits, processamos todas as páginas correspondentes
if query and "inner_hits" in hit and "paginas" in hit["inner_hits"]:
paginas_inner_hits = hit["inner_hits"]["paginas"]["hits"]["hits"]
# Processar todas as páginas encontradas, não apenas a primeira
for inner_hit in paginas_inner_hits:
inner_source = inner_hit.get("_source", {})
page_num = inner_source.get("numero", "N/A")
highlights = inner_hit.get("highlight", {}).get(
"paginas.conteudo.search", []
)
highlight_content = " ... ".join(highlights) if highlights else ""
if page_num != "N/A" and highlight_content:
paginas.append(
PaginaSchema(numero=page_num, conteudo=highlight_content)
)
# Se não há query ou não encontramos páginas nos inner_hits, usamos a primeira página do documento
if not paginas and "paginas" in source and source["paginas"]:
primeira_pagina_source = source["paginas"][0]
conteudo_orig = primeira_pagina_source.get("conteudo", "")
paginas.append(
PaginaSchema(
numero=primeira_pagina_source.get("numero", 0),
conteudo=conteudo_orig,
)
)
return paginas
async def processar_resultados(
response: Dict[str, Any], query: Optional[str], page: int, page_size: int
) -> Dict[str, Any]:
"""
Processa os resultados da busca no Elasticsearch.
Args:
response: Resposta do Elasticsearch
query: Termo de busca original
page: Número da página atual
page_size: Tamanho da página
Returns:
Dict[str, Any]: Resultados processados
"""
hits = response.get("hits", {})
total = hits.get("total", {}).get("value", 0)
resultados_formatados = []
for hit in hits.get("hits", []):
source = hit.get("_source", {})
resultado_data = {
"id": hit.get("_id"),
"numero": source.get("numero", ""),
"data": source.get("data"),
"link": source.get("link", ""),
"tipo": source.get("tipo", {}).get("nome", "Sem Tipo"),
"score": hit.get("_score"),
"paginas": [],
}
# Processar todas as páginas encontradas
resultado_data["paginas"] = await processar_paginas_encontradas(hit, query)
resultados_formatados.append(ResultadoSchema(**resultado_data))
return {
"total": total,
"resultados": resultados_formatados,
"pagina": page,
"por_pagina": page_size,
}
async def buscar_diarios_simples(
query: Optional[str] = None,
numero_diario: Optional[str] = None,
modo_busca: str = "exata", # "exata" ou "qualquer"
ordenar_por: str = "data_asc", # "relevancia" ou "data"
data_inicio: Optional[str] = None,
data_fim: Optional[str] = None,
tipo_diario: Optional[str] = None,
page: int = 1,
page_size: int = 10,
) -> Dict[str, Any]:
"""
Função principal para buscar diários oficiais.
Args:
query: Termo de busca
numero_diario: Número do diário oficial
modo_busca: Modo de busca (exata ou qualquer)
ordenar_por: Critério de ordenação
data_inicio: Data inicial
data_fim: Data final
tipo_diario: Tipo de diário
page: Número da página
page_size: Tamanho da página
Returns:
Dict[str, Any]: Dicionário com os resultados da busca
"""
# Conectar ao Elasticsearch
es = await conectar_elasticsearch()
if not es:
return {"total": 0, "resultados": [], "pagina": page, "por_pagina": page_size}
# Construir o corpo da requisição
es_body = await construir_request_body(
query,
modo_busca,
ordenar_por,
data_inicio,
data_fim,
tipo_diario,
numero_diario,
page,
page_size,
)
# Executar a busca
try:
response = await es.search(index="diario_oficial", body=es_body)
except Exception as e:
print(f"Erro ao executar busca no Elasticsearch: {e}")
return {"total": 0, "resultados": [], "pagina": page, "por_pagina": page_size}
finally:
await es.close()
# Processar e retornar os resultados
return await processar_resultados(response, query, page, page_size)
async def list_diarios(
es_client: AsyncElasticsearch,
page: int = 1,
page_size: int = 10,
data_inicio: Optional[str] = None,
data_fim: Optional[str] = None,
tipo_diario: Optional[str] = None,
numero_diario: Optional[str] = None,
ordenar_por: str = "data_desc"
) -> Dict[str, any]:
"""
Lista diários com paginação e filtros opcionais
Args:
es_client: Cliente do Elasticsearch
page: Número da página
page_size: Itens por página
data_inicio: Data inicial (YYYY-MM-DD)
data_fim: Data final (YYYY-MM-DD)
tipo_diario: Filtro por tipo de diário
numero_diario: Filtro por número do diário
ordenar_por: Campo para ordenação (data_desc, data_asc)
Returns:
Dict com total de itens e lista de diários
"""
# Construir query de filtros
filters = []
# Filtro por data
if data_inicio or data_fim:
date_range = {}
if data_inicio:
try:
datetime.strptime(data_inicio, "%Y-%m-%d")
date_range["gte"] = data_inicio
except ValueError:
pass
if data_fim:
try:
datetime.strptime(data_fim, "%Y-%m-%d")
date_range["lte"] = data_fim
except ValueError:
pass
if date_range:
filters.append({"range": {"data": date_range}})
# Filtro por tipo
if tipo_diario:
filters.append({"term": {"tipo.nome": tipo_diario}})
# Filtro por número
if numero_diario:
filters.append({"wildcard": {"numero": f"*{numero_diario}*"}})
# Construir query completa
query = {
"bool": {
"must": [{"match_all": {}}],
"filter": filters
}
}
# Definir ordenação
sort = [{"data": {"order": "asc" if ordenar_por == "data_asc" else "desc"}}]
# Executar consulta
try:
response = await es_client.search(
index="diario_oficial",
body={
"query": query,
"sort": sort,
"from": (page - 1) * page_size,
"size": page_size,
"_source": ["numero", "data", "tipo", "link"]
}
)
hits = response["hits"]["hits"]
total = response["hits"]["total"]["value"]
# Processar resultados
diarios = []
for hit in hits:
source = hit["_source"]
diarios.append(BuscaDiariosResponseSchema(
id=hit["_id"],
numero=source.get("numero"),
data=source.get("data"),
tipo=source.get("tipo", {}).get("nome"),
link=source.get("link")
))
return {
"total": total,
"page": page,
"page_size": page_size,
"results": diarios
}
except Exception as e:
print(f"Erro ao listar diários: {e}")
return {
"total": 0,
"page": page,
"page_size": page_size,
"results": []
}

View File

View File

@ -0,0 +1,31 @@
from factory.django import DjangoModelFactory
from factory import Faker, SubFactory, LazyAttribute, Sequence
import datetime
from diarios.models import PageDiarioOficial, DiarioOficial, TipoDiarioOficial
class TipoDiarioOficialFactory(DjangoModelFactory):
nome = Faker("word")
class Meta:
model = TipoDiarioOficial
django_get_or_create = ["nome"]
class DiarioOficialFactory(DjangoModelFactory):
data = Faker("date_this_decade")
numero = Sequence(lambda n: f"{n:04d}-DO") # ex: 0001-DO, 0002-DO
tipo = SubFactory(TipoDiarioOficialFactory)
link = None
class Meta:
model = DiarioOficial
class PageDiarioOficialFactory(DjangoModelFactory):
diario = SubFactory(DiarioOficialFactory)
numero = Sequence(lambda n: n + 1)
layout_duas_colunas = False
conteudo = Faker("text", max_nb_chars=3000)
class Meta:
model = PageDiarioOficial
django_get_or_create = ["diario", "numero"]

View File

@ -0,0 +1,34 @@
from django.test import TestCase
from diarios.models import TipoDiarioOficial, DiarioOficial, PageDiarioOficial
from .factories import (
TipoDiarioOficialFactory,
DiarioOficialFactory,
PageDiarioOficialFactory,
)
class TipoDiarioOficialFactoryTest(TestCase):
def test_create_tipo_diario_oficial(self):
tipo = TipoDiarioOficialFactory()
self.assertIsInstance(tipo, TipoDiarioOficial)
self.assertIsNotNone(tipo.pk)
self.assertTrue(tipo.nome)
class DiarioOficialFactoryTest(TestCase):
def test_create_diario_oficial(self):
diario = DiarioOficialFactory()
self.assertIsInstance(diario, DiarioOficial)
self.assertIsNotNone(diario.pk)
self.assertIsNotNone(diario.numero)
self.assertIsInstance(diario.tipo, TipoDiarioOficial)
class PageDiarioOficialFactoryTest(TestCase):
def test_create_page_diario_oficial(self):
page = PageDiarioOficialFactory()
self.assertIsInstance(page, PageDiarioOficial)
self.assertIsNotNone(page.pk)
self.assertIsInstance(page.diario, DiarioOficial)
self.assertIsInstance(page.conteudo, str)
self.assertGreater(len(page.conteudo), 0)

6
diarios/urls.py Normal file
View File

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

113
diarios/views.py Normal file
View File

@ -0,0 +1,113 @@
from ninja import Router, File, Form
from ninja.files import UploadedFile
from typing import Optional
from django.http import HttpRequest, HttpResponse
from .search_service import (
buscar_diarios,
sugestao_termo,
buscar_diarios_simples,
)
from .schemas import BuscaDiariosResponseSchema, SugestaoResponse, DiarioOficialIn, DiarioOficialOut
from django.shortcuts import render
router = Router(tags=["Diários Oficiais"])
async def home(request):
return render(request, 'diarios/index.html')
@router.get(
"/sugestao",
response=SugestaoResponse,
summary="Sugestão de correção para termo de busca",
)
async def sugestao_busca(request: HttpRequest, q: str) -> SugestaoResponse:
"""
Sugere correção para o termo buscado, se necessário.
Args:
request (HttpRequest): Requisição HTTP.
q (str): Termo original digitado pelo usuário.
Returns:
SugestaoResponse: Termo corrigido.
"""
sugestao = await sugestao_termo(q)
return {"sugestao": sugestao}
@router.get(
"/busca",
response=BuscaDiariosResponseSchema,
summary="Busca simplificada com modos e ordenação",
)
async def busca_diarios_oficiais_simples(
request: HttpRequest,
q: Optional[str] = None,
numero_diario: Optional[str] = None,
data_inicio: Optional[str] = None,
data_fim: Optional[str] = None,
tipo: Optional[str] = None,
ordenar_por: str = "relevancia", # "relevancia", "data_asc", "data_desc"
modo_busca: str = "exata", # "exata" ou "qualquer"
page: int = 1,
page_size: int = 10,
) -> BuscaDiariosResponseSchema:
"""
Busca com modo de correspondência, ordenação e número do diário.
Args:
request (HttpRequest): Requisição HTTP.
q (Optional[str]): Termo de busca.
numero_diario (Optional[str]): Número exato do diário (ex: 1234/2024).
data_inicio (Optional[str]): Data inicial (YYYY-MM-DD).
data_fim (Optional[str]): Data final (YYYY-MM-DD).
tipo (Optional[str]): Tipo exato do diário.
ordenar_por (str): "relevancia", "data_asc" ou "data_desc".
modo_busca (str): "exata" ou "qualquer".
page (int): Página atual (mínimo: 1).
page_size (int): Itens por página (mínimo: 1, máximo: 50).
Returns:
BuscaDiariosResponseSchema: Resultado paginado da busca.
"""
page_size = min(max(page_size, 1), 50)
page = max(page, 1)
resultado = await buscar_diarios_simples(
query=q,
numero_diario=numero_diario,
data_inicio=data_inicio,
data_fim=data_fim,
tipo_diario=tipo,
ordenar_por=ordenar_por,
modo_busca=modo_busca,
page=page,
page_size=page_size,
)
return resultado
@router.post("/", response=DiarioOficialOut, summary="Criar novo diário oficial")
def criar_diario(
request: HttpRequest,
# Usamos Form para os dados normais e File para o upload
payload: DiarioOficialIn = Form(...),
arquivo: UploadedFile = File(None)
):
"""
Cria um novo diário oficial.
Observações:
- Aceita tanto upload de arquivo PDF quanto link para o diário
- Se ambos (arquivo e link) forem fornecidos, o arquivo terá prioridade
- O arquivo deve ser um PDF válido
"""
# Prioriza o arquivo se ambos existirem
arquivo_final = arquivo if arquivo else payload.arquivo
return DiarioOficialService.criar_diario(
data=payload.data,
numero=payload.numero,
tipo_id=payload.tipo_id,
arquivo=arquivo_final,
link=payload.link
)

View File

@ -0,0 +1,5 @@
__version__ = "0.1.0"
__version_info__ = tuple(
int(num) if num.isdigit() else num
for num in __version__.replace("-", ".", 1).split(".")
)

View File

@ -0,0 +1,14 @@
import pytest
from diarios_oficiais_alems.users.models import User
from diarios_oficiais_alems.users.tests.factories import UserFactory
@pytest.fixture(autouse=True)
def _media_storage(settings, tmpdir) -> None:
settings.MEDIA_ROOT = tmpdir.strpath
@pytest.fixture
def user(db) -> User:
return UserFactory()

View File

@ -0,0 +1,5 @@
"""
To understand why this file is here, please read:
https://cookiecutter-django.readthedocs.io/en/latest/5-help/faq.html#why-is-there-a-django-contrib-sites-directory-in-cookiecutter-django
"""

View File

@ -0,0 +1,5 @@
"""
To understand why this file is here, please read:
https://cookiecutter-django.readthedocs.io/en/latest/5-help/faq.html#why-is-there-a-django-contrib-sites-directory-in-cookiecutter-django
"""

View File

@ -0,0 +1,43 @@
import django.contrib.sites.models
from django.contrib.sites.models import _simple_domain_name_validator
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = []
operations = [
migrations.CreateModel(
name="Site",
fields=[
(
"id",
models.AutoField(
verbose_name="ID",
serialize=False,
auto_created=True,
primary_key=True,
),
),
(
"domain",
models.CharField(
max_length=100,
verbose_name="domain name",
validators=[_simple_domain_name_validator],
),
),
("name", models.CharField(max_length=50, verbose_name="display name")),
],
options={
"ordering": ("domain",),
"db_table": "django_site",
"verbose_name": "site",
"verbose_name_plural": "sites",
},
bases=(models.Model,),
managers=[("objects", django.contrib.sites.models.SiteManager())],
),
]

View File

@ -0,0 +1,21 @@
import django.contrib.sites.models
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [("sites", "0001_initial")]
operations = [
migrations.AlterField(
model_name="site",
name="domain",
field=models.CharField(
max_length=100,
unique=True,
validators=[django.contrib.sites.models._simple_domain_name_validator],
verbose_name="domain name",
),
)
]

View File

@ -0,0 +1,64 @@
"""
To understand why this file is here, please read:
https://cookiecutter-django.readthedocs.io/en/latest/5-help/faq.html#why-is-there-a-django-contrib-sites-directory-in-cookiecutter-django
"""
from django.conf import settings
from django.db import migrations
def _update_or_create_site_with_sequence(site_model, connection, domain, name):
"""Update or create the site with default ID and keep the DB sequence in sync."""
site, created = site_model.objects.update_or_create(
id=settings.SITE_ID,
defaults={
"domain": domain,
"name": name,
},
)
if created:
# We provided the ID explicitly when creating the Site entry, therefore the DB
# sequence to auto-generate them wasn't used and is now out of sync. If we
# don't do anything, we'll get a unique constraint violation the next time a
# site is created.
# To avoid this, we need to manually update DB sequence and make sure it's
# greater than the maximum value.
max_id = site_model.objects.order_by("-id").first().id
with connection.cursor() as cursor:
cursor.execute("SELECT last_value from django_site_id_seq")
(current_id,) = cursor.fetchone()
if current_id <= max_id:
cursor.execute(
"alter sequence django_site_id_seq restart with %s",
[max_id + 1],
)
def update_site_forward(apps, schema_editor):
"""Set site domain and name."""
Site = apps.get_model("sites", "Site")
_update_or_create_site_with_sequence(
Site,
schema_editor.connection,
"example.com",
"Diários Oficiais ALEMS",
)
def update_site_backward(apps, schema_editor):
"""Revert site domain and name to default."""
Site = apps.get_model("sites", "Site")
_update_or_create_site_with_sequence(
Site,
schema_editor.connection,
"example.com",
"example.com",
)
class Migration(migrations.Migration):
dependencies = [("sites", "0002_alter_domain_unique")]
operations = [migrations.RunPython(update_site_forward, update_site_backward)]

View File

@ -0,0 +1,21 @@
# Generated by Django 3.1.7 on 2021-02-04 14:49
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("sites", "0003_set_site_domain_and_name"),
]
operations = [
migrations.AlterModelOptions(
name="site",
options={
"ordering": ["domain"],
"verbose_name": "site",
"verbose_name_plural": "sites",
},
),
]

View File

@ -0,0 +1,5 @@
"""
To understand why this file is here, please read:
https://cookiecutter-django.readthedocs.io/en/latest/5-help/faq.html#why-is-there-a-django-contrib-sites-directory-in-cookiecutter-django
"""

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,13 @@
/* These styles are generated from project.scss. */
.alert-debug {
color: black;
background-color: white;
border-color: #d6e9c6;
}
.alert-error {
color: #b94a48;
background-color: #f2dede;
border-color: #eed3d7;
}

View 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;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
const API_BASE_URL = "http://109.199.98.226";

View File

@ -0,0 +1 @@
/* Project specific Javascript goes here. */

View 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://192.168.235.234/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://192.168.235.234/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 = {};
}
}));
});

View File

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block title %}Forbidden (403){% endblock title %}
{% block content %}
<h1>Forbidden (403)</h1>
<p>
{% if exception %}
{{ exception }}
{% else %}
You're not allowed to access this page.
{% endif %}
</p>
{% endblock content %}

View File

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block title %}Forbidden (403){% endblock title %}
{% block content %}
<h1>Forbidden (403)</h1>
<p>
{% if exception %}
{{ exception }}
{% else %}
You're not allowed to access this page.
{% endif %}
</p>
{% endblock content %}

View File

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block title %}Page not found{% endblock title %}
{% block content %}
<h1>Page not found</h1>
<p>
{% if exception %}
{{ exception }}
{% else %}
This is not the page you were looking for.
{% endif %}
</p>
{% endblock content %}

View File

@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block title %}Server Error{% endblock title %}
{% block content %}
<h1>Ooops!!! 500</h1>
<h3>Looks like something went wrong!</h3>
<p>
We track these errors automatically, but if the problem persists feel free to contact us. In the meantime, try refreshing.
</p>
{% endblock content %}

View File

@ -0,0 +1,11 @@
{% extends "account/base_manage.html" %}
{% block main %}
<div class="card">
<div class="card-body">
{% block content %}
{% endblock content %}
</div>
</div>
{% endblock main %}

View File

@ -0,0 +1,7 @@
{% load i18n %}
{% load allauth %}
<div class="alert alert-error">
{% slot message %}
{% endslot %}
</div>

View File

@ -0,0 +1,6 @@
{% load allauth %}
<span class="badge {% if 'success' in attrs.tags %}bg-success{% endif %} {% if 'warning' in attrs.tags %}bg-warning{% endif %} {% if 'secondary' in attrs.tags %}bg-secondary{% endif %} {% if 'danger' in attrs.tags %}bg-danger{% endif %} {% if 'primary' in attrs.tags %}bg-primary{% endif %}">
{% slot %}
{% endslot %}
</span>

View File

@ -0,0 +1,20 @@
{% load allauth %}
{% comment %} djlint:off {% endcomment %}
<{% if attrs.href %}a href="{{ attrs.href }}"{% else %}button{% endif %}
{% if attrs.form %}form="{{ attrs.form }}"{% endif %}
{% if attrs.id %}id="{{ attrs.id }}"{% endif %}
{% if attrs.name %}name="{{ attrs.name }}"{% endif %}
{% if attrs.type %}type="{{ attrs.type }}"{% endif %}
class="btn
{% if 'success' in attrs.tags %}btn-success
{% elif 'warning' in attrs.tags %}btn-warning
{% elif 'secondary' in attrs.tags %}btn-secondary
{% elif 'danger' in attrs.tags %}btn-danger
{% elif 'primary' in attrs.tags %}btn-primary
{% else %}btn-primary
{% endif %}"
>
{% slot %}
{% endslot %}
</{% if attrs.href %}a{% else %}button{% endif %}>

View File

@ -0,0 +1,66 @@
{% load allauth %}
{% load crispy_forms_tags %}
{% if attrs.type == "textarea" %}
<div class="row mb-3">
<div class="col-sm-10">
<label for="{{ attrs.id }}">
{% slot label %}
{% endslot %}
</label>
</div>
<textarea {% if attrs.required %}required{% endif %}
{% if attrs.rows %}rows="{{ attrs.rows }}"{% endif %}
{% if attrs.disabled %}disabled{% endif %}
{% if attrs.readonly %}readonly{% endif %}
{% if attrs.checked %}checked{% endif %}
{% if attrs.name %}name="{{ attrs.name }}"{% endif %}
{% if attrs.id %}id="{{ attrs.id }}"{% endif %}
{% if attrs.placeholder %}placeholder="{{ attrs.placeholder }}"{% endif %}
class="form-control">{% slot value %}{% endslot %}</textarea>
</div>
{% elif attrs.type == "radio" %}
<div class="row mb-3">
<div class="col-sm-10">
<div class="form-check">
<input {% if attrs.required %}required{% endif %}
{% if attrs.disabled %}disabled{% endif %}
{% if attrs.readonly %}readonly{% endif %}
{% if attrs.checked %}checked{% endif %}
{% if attrs.name %}name="{{ attrs.name }}"{% endif %}
{% if attrs.id %}id="{{ attrs.id }}"{% endif %}
{% if attrs.placeholder %}placeholder="{{ attrs.placeholder }}"{% endif %}
{% if attrs.autocomplete %}autocomplete="{{ attrs.autocomplete }}"{% endif %}
value="{{ attrs.value|default_if_none:"" }}"
type="{{ attrs.type }}" />
<label class="form-check-label" for="{{ attrs.id }}">
{% slot label %}
{% endslot %}
</label>
</div>
</div>
</div>
{% else %}
<div class="col-sm-10">
<label for="{{ attrs.id }}">
{% slot label %}
{% endslot %}
</label>
</div>
<div class="col-sm-10">
<input {% if attrs.required %}required{% endif %}
{% if attrs.disabled %}disabled{% endif %}
{% if attrs.readonly %}readonly{% endif %}
{% if attrs.checked %}checked{% endif %}
{% if attrs.name %}name="{{ attrs.name }}"{% endif %}
{% if attrs.id %}id="{{ attrs.id }}"{% endif %}
{% if attrs.placeholder %}placeholder="{{ attrs.placeholder }}"{% endif %}
{% if attrs.autocomplete %}autocomplete="{{ attrs.autocomplete }}"{% endif %}
value="{{ attrs.value|default_if_none:"" }}"
type="{{ attrs.type }}"
class="form-control" />
</div>
{% endif %}
{% if slots.help_text %}
<div class="form-text">{% slot help_text %}{% endslot %}</div>
{% endif %}

Some files were not shown because too many files have changed in this diff Show More