Compare commits
10 Commits
e952388d57
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 277acc1f31 | |||
| f196773705 | |||
| 9dca0d6022 | |||
| 78e994eb6a | |||
| f2e5cd73b7 | |||
| 8d1f6feeaf | |||
| 6471ee6152 | |||
| 3f5ac79051 | |||
| 1cd93f7955 | |||
| d3081f81f3 |
12
.dockerignore
Normal file
12
.dockerignore
Normal 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
27
.editorconfig
Normal 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
9
.envs/.local/.django
Normal 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
7
.envs/.local/.postgres
Normal 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
2
.gitattributes
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
* text=auto
|
||||||
|
*.pdf filter=lfs diff=lfs merge=lfs -text
|
||||||
277
.gitignore
vendored
Normal file
277
.gitignore
vendored
Normal 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
1
.python-version
Normal file
@ -0,0 +1 @@
|
|||||||
|
3.12
|
||||||
52
README.md
52
README.md
@ -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
|
||||||
|
|
||||||
|
[](https://github.com/cookiecutter/cookiecutter-django/)
|
||||||
|
[](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
1
backup.json
Normal file
File diff suppressed because one or more lines are too long
77
compose/local/django/Dockerfile
Normal file
77
compose/local/django/Dockerfile
Normal 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"]
|
||||||
|
|
||||||
20
compose/local/django/start
Normal file
20
compose/local/django/start
Normal 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
|
||||||
62
compose/local/docs/Dockerfile
Normal file
62
compose/local/docs/Dockerfile
Normal 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
7
compose/local/docs/start
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -o errexit
|
||||||
|
set -o pipefail
|
||||||
|
set -o nounset
|
||||||
|
|
||||||
|
exec make livehtml
|
||||||
44
compose/local/nginx/nginx.conf
Normal file
44
compose/local/nginx/nginx.conf
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
73
compose/production/django/Dockerfile
Normal file
73
compose/production/django/Dockerfile
Normal 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"]
|
||||||
17
compose/production/django/entrypoint
Normal file
17
compose/production/django/entrypoint
Normal 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 "$@"
|
||||||
51
compose/production/django/start
Normal file
51
compose/production/django/start
Normal 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
|
||||||
|
|
||||||
2
compose/production/nginx/Dockerfile
Normal file
2
compose/production/nginx/Dockerfile
Normal 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
|
||||||
7
compose/production/nginx/default.conf
Normal file
7
compose/production/nginx/default.conf
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
location /media/ {
|
||||||
|
alias /usr/share/nginx/media/;
|
||||||
|
}
|
||||||
|
}
|
||||||
39
compose/production/nginx/nginx.conf
Normal file
39
compose/production/nginx/nginx.conf
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
compose/production/postgres/Dockerfile
Normal file
6
compose/production/postgres/Dockerfile
Normal 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
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
|
||||||
|
BACKUP_DIR_PATH='/backups'
|
||||||
|
BACKUP_FILE_PREFIX='backup'
|
||||||
@ -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
|
||||||
|
}
|
||||||
41
compose/production/postgres/maintenance/_sourced/messages.sh
Normal file
41
compose/production/postgres/maintenance/_sourced/messages.sh
Normal 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: ${@}"
|
||||||
|
}
|
||||||
16
compose/production/postgres/maintenance/_sourced/yes_no.sh
Normal file
16
compose/production/postgres/maintenance/_sourced/yes_no.sh
Normal 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
|
||||||
|
}
|
||||||
38
compose/production/postgres/maintenance/backup
Normal file
38
compose/production/postgres/maintenance/backup
Normal 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}'."
|
||||||
22
compose/production/postgres/maintenance/backups
Normal file
22
compose/production/postgres/maintenance/backups
Normal 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}"
|
||||||
55
compose/production/postgres/maintenance/restore
Normal file
55
compose/production/postgres/maintenance/restore
Normal 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."
|
||||||
36
compose/production/postgres/maintenance/rmbackup
Normal file
36
compose/production/postgres/maintenance/rmbackup
Normal 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."
|
||||||
5
compose/production/traefik/Dockerfile
Normal file
5
compose/production/traefik/Dockerfile
Normal 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
|
||||||
73
compose/production/traefik/traefik.yml
Normal file
73
compose/production/traefik/traefik.yml
Normal 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
0
config/__init__.py
Normal file
11
config/api.py
Normal file
11
config/api.py
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
from ninja import NinjaAPI
|
||||||
|
from diarios.views import router as diarios_router
|
||||||
|
|
||||||
|
|
||||||
|
api = NinjaAPI(
|
||||||
|
title="API de Diários Oficiais",
|
||||||
|
version="1.0.0",
|
||||||
|
description="API para busca em diários oficiais",
|
||||||
|
)
|
||||||
|
|
||||||
|
api.add_router("/diarios/", diarios_router)
|
||||||
13
config/asgi.py
Normal file
13
config/asgi.py
Normal 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()
|
||||||
|
|
||||||
0
config/settings/__init__.py
Normal file
0
config/settings/__init__.py
Normal file
375
config/settings/base.py
Normal file
375
config/settings/base.py
Normal 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
80
config/settings/local.py
Normal 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...
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
172
config/settings/production.py
Normal file
172
config/settings/production.py
Normal 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
38
config/settings/test.py
Normal 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
55
config/urls.py
Normal 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
40
config/wsgi.py
Normal 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
0
diarios/__init__.py
Normal file
150
diarios/admin.py
Normal file
150
diarios/admin.py
Normal 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
5
diarios/api.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from ninja import NinjaAPI
|
||||||
|
|
||||||
|
|
||||||
|
api = NinjaAPI()
|
||||||
|
api.add_router("/diarios/", route)
|
||||||
10
diarios/apps.py
Normal file
10
diarios/apps.py
Normal 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
179
diarios/crud_service.py
Normal 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
67
diarios/documents.py
Normal 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
21
diarios/forms.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
from django import forms
|
||||||
|
from .models import PageDiarioOficial
|
||||||
|
|
||||||
|
|
||||||
|
class PageDiarioOficialInlineForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = PageDiarioOficial
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
if "numero" in cleaned_data and self.instance.diario:
|
||||||
|
if (
|
||||||
|
PageDiarioOficial.objects.filter(
|
||||||
|
diario=self.instance.diario, numero=cleaned_data["numero"]
|
||||||
|
)
|
||||||
|
.exclude(pk=self.instance.pk)
|
||||||
|
.exists()
|
||||||
|
):
|
||||||
|
self.add_error("numero", "Já existe uma página com este número")
|
||||||
|
return cleaned_data
|
||||||
105
diarios/management/commands/importar_diarios_com_ocr.py
Normal file
105
diarios/management/commands/importar_diarios_com_ocr.py
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import os
|
||||||
|
import time
|
||||||
|
from django.core.files import File
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from diarios.models import DiarioOficial
|
||||||
|
from diarios.signals import update_document, delete_document
|
||||||
|
from django.db.models.signals import post_save, post_delete
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Importa arquivos PDF de diários oficiais e associa aos objetos existentes no banco."
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
"pasta",
|
||||||
|
type=str,
|
||||||
|
help="Caminho para a pasta contendo os arquivos PDF (nomes devem conter o número do diário)",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
post_save.disconnect(update_document, sender=DiarioOficial)
|
||||||
|
post_delete.disconnect(delete_document, sender=DiarioOficial)
|
||||||
|
|
||||||
|
pasta = options["pasta"]
|
||||||
|
if not os.path.isdir(pasta):
|
||||||
|
self.stderr.write(
|
||||||
|
self.style.ERROR(f"A pasta fornecida não existe: {pasta}")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
arquivos_pdf = [
|
||||||
|
f
|
||||||
|
for f in os.listdir(pasta)
|
||||||
|
if f.lower().endswith(".pdf") and "Diário_Oficial_Eletrônico_nº_" in f
|
||||||
|
]
|
||||||
|
|
||||||
|
if not arquivos_pdf:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING("Nenhum arquivo PDF válido encontrado na pasta.")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
total = len(arquivos_pdf)
|
||||||
|
erros = []
|
||||||
|
atualizados = []
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
# Mapeia os números aos nomes de arquivos
|
||||||
|
numero_para_arquivo = {
|
||||||
|
f.replace("Diário_Oficial_Eletrônico_nº_", "")
|
||||||
|
.replace(".pdf", "")
|
||||||
|
.strip("_-"): f
|
||||||
|
for f in arquivos_pdf
|
||||||
|
}
|
||||||
|
|
||||||
|
# Busca todos os objetos de uma vez
|
||||||
|
diarios_existentes = DiarioOficial.objects.in_bulk(
|
||||||
|
numero_para_arquivo.keys(), field_name="numero"
|
||||||
|
)
|
||||||
|
|
||||||
|
for idx, (numero, nome_arquivo) in enumerate(
|
||||||
|
numero_para_arquivo.items(), start=1
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
diario = diarios_existentes.get(numero)
|
||||||
|
if not diario:
|
||||||
|
raise DiarioOficial.DoesNotExist()
|
||||||
|
|
||||||
|
caminho_pdf = os.path.join(pasta, nome_arquivo)
|
||||||
|
with open(caminho_pdf, "rb") as f:
|
||||||
|
diario.arquivo.save(nome_arquivo, File(f), save=True)
|
||||||
|
|
||||||
|
atualizados.append(diario.pk)
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
remaining = (elapsed / idx) * (total - idx)
|
||||||
|
self.stdout.write(
|
||||||
|
f"[{idx}/{total}] Atualizado: {nome_arquivo} | Estimativa restante: {remaining:.1f}s"
|
||||||
|
)
|
||||||
|
|
||||||
|
except DiarioOficial.DoesNotExist:
|
||||||
|
msg = f"Não encontrado no banco: {nome_arquivo}"
|
||||||
|
erros.append(msg)
|
||||||
|
self.stderr.write(self.style.WARNING(msg))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
msg = f"Erro ao processar {nome_arquivo}: {str(e)}"
|
||||||
|
erros.append(msg)
|
||||||
|
self.stderr.write(self.style.ERROR(msg))
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f"{len(atualizados)} arquivos importados com sucesso.")
|
||||||
|
)
|
||||||
|
self.stdout.write(self.style.WARNING(f"{len(erros)} arquivos com erro."))
|
||||||
|
|
||||||
|
if erros:
|
||||||
|
caminho_log = os.path.join(pasta, "erros_importacao.txt")
|
||||||
|
with open(caminho_log, "w", encoding="utf-8") as erro_file:
|
||||||
|
for linha in erros:
|
||||||
|
erro_file.write(f"{linha}\n")
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING(f"Erros registrados em: {caminho_log}")
|
||||||
|
)
|
||||||
|
|
||||||
|
post_save.connect(update_document, sender=DiarioOficial)
|
||||||
|
post_delete.connect(delete_document, sender=DiarioOficial)
|
||||||
31
diarios/migrations/0001_initial.py
Normal file
31
diarios/migrations/0001_initial.py
Normal 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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
18
diarios/migrations/0002_pdfdocument_page_content.py
Normal file
18
diarios/migrations/0002_pdfdocument_page_content.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
16
diarios/migrations/0005_delete_pdfdocument.py
Normal file
16
diarios/migrations/0005_delete_pdfdocument.py
Normal 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",
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
# Generated by Django 5.0.12 on 2025-03-27 12:30
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("diarios", "0005_delete_pdfdocument"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="diariooficial",
|
||||||
|
name="page_content",
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="PageDiarioOficial",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("numero", models.PositiveIntegerField()),
|
||||||
|
("conteudo", models.TextField()),
|
||||||
|
(
|
||||||
|
"diario",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="paginas",
|
||||||
|
to="diarios.diariooficial",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Página de Diário Oficial",
|
||||||
|
"verbose_name_plural": "Páginas de Diários Oficiais",
|
||||||
|
"unique_together": {("diario", "numero")},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
18
diarios/migrations/0007_diariooficial_layout_duas_colunas.py
Normal file
18
diarios/migrations/0007_diariooficial_layout_duas_colunas.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.0.12 on 2025-04-28 13:35
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("diarios", "0006_remove_diariooficial_page_content_pagediariooficial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="diariooficial",
|
||||||
|
name="layout_duas_colunas",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
# Generated by Django 5.0.12 on 2025-04-28 13:43
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("diarios", "0007_diariooficial_layout_duas_colunas"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="diariooficial",
|
||||||
|
name="layout_duas_colunas",
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="pagediariooficial",
|
||||||
|
name="layout_duas_colunas",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
diarios/migrations/__init__.py
Normal file
0
diarios/migrations/__init__.py
Normal file
167
diarios/models.py
Normal file
167
diarios/models.py
Normal 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} nº {self.numero}, {self.data_formatada}"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints = [models.UniqueConstraint(fields=["numero"], name="unique_numero")]
|
||||||
|
verbose_name_plural = "Diários Oficiais"
|
||||||
|
|
||||||
|
|
||||||
|
class PageDiarioOficial(models.Model):
|
||||||
|
diario = models.ForeignKey(
|
||||||
|
DiarioOficial, on_delete=models.CASCADE, related_name="paginas"
|
||||||
|
)
|
||||||
|
layout_duas_colunas = models.BooleanField(default=False)
|
||||||
|
numero = models.PositiveIntegerField()
|
||||||
|
conteudo = models.TextField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ("diario", "numero")
|
||||||
|
verbose_name = "Página de Diário Oficial"
|
||||||
|
verbose_name_plural = "Páginas de Diários Oficiais"
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
if self.diario:
|
||||||
|
self.diario.save()
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Página {self.numero} do Diário {self.diario.numero}"
|
||||||
|
|
||||||
126
diarios/schemas.py
Normal file
126
diarios/schemas.py
Normal 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
848
diarios/search_service.py
Normal 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": []
|
||||||
|
}
|
||||||
|
|
||||||
0
diarios/tests/__init__.py
Normal file
0
diarios/tests/__init__.py
Normal file
31
diarios/tests/factories.py
Normal file
31
diarios/tests/factories.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
from factory.django import DjangoModelFactory
|
||||||
|
from factory import Faker, SubFactory, LazyAttribute, Sequence
|
||||||
|
import datetime
|
||||||
|
from diarios.models import PageDiarioOficial, DiarioOficial, TipoDiarioOficial
|
||||||
|
|
||||||
|
|
||||||
|
class TipoDiarioOficialFactory(DjangoModelFactory):
|
||||||
|
nome = Faker("word")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = TipoDiarioOficial
|
||||||
|
django_get_or_create = ["nome"]
|
||||||
|
|
||||||
|
class DiarioOficialFactory(DjangoModelFactory):
|
||||||
|
data = Faker("date_this_decade")
|
||||||
|
numero = Sequence(lambda n: f"{n:04d}-DO") # ex: 0001-DO, 0002-DO
|
||||||
|
tipo = SubFactory(TipoDiarioOficialFactory)
|
||||||
|
link = None
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = DiarioOficial
|
||||||
|
|
||||||
|
class PageDiarioOficialFactory(DjangoModelFactory):
|
||||||
|
diario = SubFactory(DiarioOficialFactory)
|
||||||
|
numero = Sequence(lambda n: n + 1)
|
||||||
|
layout_duas_colunas = False
|
||||||
|
conteudo = Faker("text", max_nb_chars=3000)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = PageDiarioOficial
|
||||||
|
django_get_or_create = ["diario", "numero"]
|
||||||
34
diarios/tests/test_models.py
Normal file
34
diarios/tests/test_models.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
from diarios.models import TipoDiarioOficial, DiarioOficial, PageDiarioOficial
|
||||||
|
from .factories import (
|
||||||
|
TipoDiarioOficialFactory,
|
||||||
|
DiarioOficialFactory,
|
||||||
|
PageDiarioOficialFactory,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TipoDiarioOficialFactoryTest(TestCase):
|
||||||
|
def test_create_tipo_diario_oficial(self):
|
||||||
|
tipo = TipoDiarioOficialFactory()
|
||||||
|
self.assertIsInstance(tipo, TipoDiarioOficial)
|
||||||
|
self.assertIsNotNone(tipo.pk)
|
||||||
|
self.assertTrue(tipo.nome)
|
||||||
|
|
||||||
|
|
||||||
|
class DiarioOficialFactoryTest(TestCase):
|
||||||
|
def test_create_diario_oficial(self):
|
||||||
|
diario = DiarioOficialFactory()
|
||||||
|
self.assertIsInstance(diario, DiarioOficial)
|
||||||
|
self.assertIsNotNone(diario.pk)
|
||||||
|
self.assertIsNotNone(diario.numero)
|
||||||
|
self.assertIsInstance(diario.tipo, TipoDiarioOficial)
|
||||||
|
|
||||||
|
|
||||||
|
class PageDiarioOficialFactoryTest(TestCase):
|
||||||
|
def test_create_page_diario_oficial(self):
|
||||||
|
page = PageDiarioOficialFactory()
|
||||||
|
self.assertIsInstance(page, PageDiarioOficial)
|
||||||
|
self.assertIsNotNone(page.pk)
|
||||||
|
self.assertIsInstance(page.diario, DiarioOficial)
|
||||||
|
self.assertIsInstance(page.conteudo, str)
|
||||||
|
self.assertGreater(len(page.conteudo), 0)
|
||||||
6
diarios/urls.py
Normal file
6
diarios/urls.py
Normal 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
113
diarios/views.py
Normal 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
|
||||||
|
)
|
||||||
5
diarios_oficiais_alems/__init__.py
Normal file
5
diarios_oficiais_alems/__init__.py
Normal 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(".")
|
||||||
|
)
|
||||||
14
diarios_oficiais_alems/conftest.py
Normal file
14
diarios_oficiais_alems/conftest.py
Normal 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()
|
||||||
5
diarios_oficiais_alems/contrib/__init__.py
Normal file
5
diarios_oficiais_alems/contrib/__init__.py
Normal 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
|
||||||
|
"""
|
||||||
5
diarios_oficiais_alems/contrib/sites/__init__.py
Normal file
5
diarios_oficiais_alems/contrib/sites/__init__.py
Normal 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
|
||||||
|
"""
|
||||||
@ -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())],
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
]
|
||||||
@ -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)]
|
||||||
@ -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",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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
|
||||||
|
"""
|
||||||
5
diarios_oficiais_alems/static/css/bootstrap-custom.min.css
vendored
Normal file
5
diarios_oficiais_alems/static/css/bootstrap-custom.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2106
diarios_oficiais_alems/static/css/bootstrap-icons.css
vendored
Normal file
2106
diarios_oficiais_alems/static/css/bootstrap-icons.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
BIN
diarios_oficiais_alems/static/css/fonts/bootstrap-icons.woff
Normal file
BIN
diarios_oficiais_alems/static/css/fonts/bootstrap-icons.woff
Normal file
Binary file not shown.
BIN
diarios_oficiais_alems/static/css/fonts/bootstrap-icons.woff2
Normal file
BIN
diarios_oficiais_alems/static/css/fonts/bootstrap-icons.woff2
Normal file
Binary file not shown.
13
diarios_oficiais_alems/static/css/project.css
Normal file
13
diarios_oficiais_alems/static/css/project.css
Normal 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;
|
||||||
|
}
|
||||||
76
diarios_oficiais_alems/static/css/style.css
Normal file
76
diarios_oficiais_alems/static/css/style.css
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
.bg-light-gradient {
|
||||||
|
background: linear-gradient(to right, #f8f9fa, #e9ecef);
|
||||||
|
}
|
||||||
|
.search-card {
|
||||||
|
border-radius: 15px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.result-card {
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.result-card:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
.page-item.active .page-link {
|
||||||
|
background-color: #0d6efd;
|
||||||
|
border-color: #0d6efd;
|
||||||
|
}
|
||||||
|
.date-picker {
|
||||||
|
background-color: #fff;
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
}
|
||||||
|
.best-match {
|
||||||
|
border-left: 4px solid #0d6efd;
|
||||||
|
background-color: rgba(13, 110, 253, 0.05);
|
||||||
|
}
|
||||||
|
.page-content {
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.accordion-button:not(.collapsed) {
|
||||||
|
background-color: rgba(13, 110, 253, 0.1);
|
||||||
|
color: #0d6efd;
|
||||||
|
}
|
||||||
|
.accordion-button:focus {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
mark {
|
||||||
|
background-color: #fff3cd;
|
||||||
|
padding: 0.1em 0.2em;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.page-badge {
|
||||||
|
background-color: #6c757d;
|
||||||
|
color: white;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.match-score {
|
||||||
|
color: #0d6efd;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.btn-sort {
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.btn-sort.active {
|
||||||
|
background-color: #0d6efd;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.search-options {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.search-options {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
0
diarios_oficiais_alems/static/fonts/.gitkeep
Normal file
0
diarios_oficiais_alems/static/fonts/.gitkeep
Normal file
BIN
diarios_oficiais_alems/static/images/favicons/favicon.ico
Normal file
BIN
diarios_oficiais_alems/static/images/favicons/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
BIN
diarios_oficiais_alems/static/images/logo.jpg
Normal file
BIN
diarios_oficiais_alems/static/images/logo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
BIN
diarios_oficiais_alems/static/images/risco.jpg
Normal file
BIN
diarios_oficiais_alems/static/images/risco.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
5
diarios_oficiais_alems/static/js/alpine.min.js
vendored
Normal file
5
diarios_oficiais_alems/static/js/alpine.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
7
diarios_oficiais_alems/static/js/bootstrap.bundle.min.js
vendored
Normal file
7
diarios_oficiais_alems/static/js/bootstrap.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
diarios_oficiais_alems/static/js/config.js
Normal file
1
diarios_oficiais_alems/static/js/config.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
const API_BASE_URL = "http://109.199.98.226";
|
||||||
1
diarios_oficiais_alems/static/js/project.js
Normal file
1
diarios_oficiais_alems/static/js/project.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
/* Project specific Javascript goes here. */
|
||||||
242
diarios_oficiais_alems/static/js/script.js
Normal file
242
diarios_oficiais_alems/static/js/script.js
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.data('searchApp', () => ({
|
||||||
|
searchParams: {
|
||||||
|
q: '',
|
||||||
|
numero_diario: '',
|
||||||
|
data_inicio: '',
|
||||||
|
data_fim: '',
|
||||||
|
modo_busca: 'exata',
|
||||||
|
ordenar_por: 'data_asc',
|
||||||
|
page: 1,
|
||||||
|
page_size: 10
|
||||||
|
},
|
||||||
|
searchResults: null,
|
||||||
|
isLoading: false,
|
||||||
|
hasSearched: false,
|
||||||
|
error: null,
|
||||||
|
showAdvanced: false,
|
||||||
|
expandedContents: {},
|
||||||
|
suggestion: null,
|
||||||
|
ultimoTermoBuscado: '',
|
||||||
|
|
||||||
|
get shouldShowSuggestion() {
|
||||||
|
if (!this.suggestion || !this.searchParams.q) return false;
|
||||||
|
|
||||||
|
// Função para remover acentos e converter para minúsculas
|
||||||
|
const normalize = (text) => {
|
||||||
|
return text.toLowerCase()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace("/", " ")
|
||||||
|
.replace(/[^a-z0-9\s]/g, " ") // Substitui todos os outros símbolos por espaço
|
||||||
|
.replace(/[\u0300-\u036f]/g, '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizedQuery = normalize(this.searchParams.q);
|
||||||
|
const normalizedSuggestion = normalize(this.suggestion);
|
||||||
|
|
||||||
|
// Só mostra a sugestão se for diferente do termo buscado (ignorando acentos e caixa)
|
||||||
|
return normalizedQuery !== normalizedSuggestion;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Usa a sugestão como novo termo de busca
|
||||||
|
usesuggestion() {
|
||||||
|
this.searchParams.q = this.suggestion;
|
||||||
|
this.searchParams.page = 1;
|
||||||
|
this.performSearch();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Verifica se um diário tem uma melhor correspondência
|
||||||
|
hasBestMatch(diario) {
|
||||||
|
return diario.paginas && diario.paginas.length > 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Muda a ordenação e faz uma nova busca
|
||||||
|
changeOrder(order) {
|
||||||
|
if (this.searchParams.ordenar_por !== order) {
|
||||||
|
this.searchParams.ordenar_por = order;
|
||||||
|
this.searchParams.page = 1; // Volta para a primeira página
|
||||||
|
this.performSearch();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Obter sugestão da API
|
||||||
|
async getSuggestion(query) {
|
||||||
|
if (!query) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL('http://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 = {};
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
13
diarios_oficiais_alems/templates/403.html
Normal file
13
diarios_oficiais_alems/templates/403.html
Normal 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 %}
|
||||||
13
diarios_oficiais_alems/templates/403_csrf.html
Normal file
13
diarios_oficiais_alems/templates/403_csrf.html
Normal 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 %}
|
||||||
13
diarios_oficiais_alems/templates/404.html
Normal file
13
diarios_oficiais_alems/templates/404.html
Normal 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 %}
|
||||||
10
diarios_oficiais_alems/templates/500.html
Normal file
10
diarios_oficiais_alems/templates/500.html
Normal 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 %}
|
||||||
@ -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 %}
|
||||||
|
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
{% load allauth %}
|
||||||
|
|
||||||
|
<div class="alert alert-error">
|
||||||
|
{% slot message %}
|
||||||
|
{% endslot %}
|
||||||
|
</div>
|
||||||
@ -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>
|
||||||
@ -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 %}>
|
||||||
66
diarios_oficiais_alems/templates/allauth/elements/field.html
Normal file
66
diarios_oficiais_alems/templates/allauth/elements/field.html
Normal 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
Reference in New Issue
Block a user