diff --git a/.devcontainer/bashrc.override.sh b/.devcontainer/bashrc.override.sh deleted file mode 100644 index bedddf6..0000000 --- a/.devcontainer/bashrc.override.sh +++ /dev/null @@ -1,20 +0,0 @@ - -# -# .bashrc.override.sh -# - -# persistent bash history -HISTFILE=~/.bash_history -PROMPT_COMMAND="history -a; $PROMPT_COMMAND" - -# set some django env vars -source /entrypoint - -# restore default shell options -set +o errexit -set +o pipefail -set +o nounset - -# start ssh-agent -# https://code.visualstudio.com/docs/remote/troubleshooting -eval "$(ssh-agent -s)" diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json deleted file mode 100644 index ee8fa0c..0000000 --- a/.devcontainer/devcontainer.json +++ /dev/null @@ -1,68 +0,0 @@ -// For format details, see https://containers.dev/implementors/json_reference/ -{ - "name": "diários_oficiais_alems_dev", - "dockerComposeFile": [ - "../docker-compose.local.yml" - ], - "init": true, - "mounts": [ - { - "source": "./.devcontainer/bash_history", - "target": "/home/dev-user/.bash_history", - "type": "bind" - }, - { - "source": "~/.ssh", - "target": "/home/dev-user/.ssh", - "type": "bind" - } - ], - // Tells devcontainer.json supporting services / tools whether they should run - // /bin/sh -c "while sleep 1000; do :; done" when starting the container instead of the container’s default command - "overrideCommand": false, - "service": "django", - // "remoteEnv": {"PATH": "/home/dev-user/.local/bin:${containerEnv:PATH}"}, - "remoteUser": "dev-user", - "workspaceFolder": "/app", - // Set *default* container specific settings.json values on container create. - "customizations": { - "vscode": { - "settings": { - "editor.formatOnSave": true, - "[python]": { - "analysis.autoImportCompletions": true, - "analysis.typeCheckingMode": "basic", - "defaultInterpreterPath": "/usr/local/bin/python", - "editor.codeActionsOnSave": { - "source.organizeImports": "always" - }, - "editor.defaultFormatter": "charliermarsh.ruff", - "languageServer": "Pylance", - "linting.enabled": true, - "linting.mypyEnabled": true, - "linting.mypyPath": "/usr/local/bin/mypy", - } - }, - // https://code.visualstudio.com/docs/remote/devcontainerjson-reference#_vs-code-specific-properties - // Add the IDs of extensions you want installed when the container is created. - "extensions": [ - "davidanson.vscode-markdownlint", - "mrmlnc.vscode-duplicate", - "visualstudioexptteam.vscodeintellicode", - "visualstudioexptteam.intellicode-api-usage-examples", - // python - "ms-python.python", - "ms-python.vscode-pylance", - "charliermarsh.ruff", - // django - "batisteo.vscode-django" - ] - } - }, - // Uncomment the next line if you want start specific services in your Docker Compose config. - // "runServices": [], - // Uncomment the next line if you want to keep your containers running after VS Code shuts down. - // "shutdownAction": "none", - // Uncomment the next line to run commands after the container is created. - "postCreateCommand": "cat .devcontainer/bashrc.override.sh >> ~/.bashrc" -} diff --git a/.envs/.local/.django b/.envs/.local/.django index bcde257..01811e0 100644 --- a/.envs/.local/.django +++ b/.envs/.local/.django @@ -2,3 +2,5 @@ # ------------------------------------------------------------------------------ USE_DOCKER=yes IPYTHONDIR=/app/.ipython +ELASTICSEARCH_USER=elastic +ELASTICSEARCH_PASSWORD=Euamooelasticsearch123. diff --git a/.gitignore b/.gitignore index aae343e..4e5ee75 100644 --- a/.gitignore +++ b/.gitignore @@ -268,7 +268,7 @@ tags dump.rdb ### Project template -diários_oficiais_alems/media/ +diarios_oficiais_alems/media/ .pytest_cache/ .ipython/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index 4b1c642..0000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,50 +0,0 @@ -exclude: '^docs/|/migrations/|devcontainer.json' -default_stages: [pre-commit] -minimum_pre_commit_version: "3.2.0" - -default_language_version: - python: python3.12 - -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 - hooks: - - id: trailing-whitespace - - id: end-of-file-fixer - - id: check-json - - id: check-toml - - id: check-xml - - id: check-yaml - - id: debug-statements - - id: check-builtin-literals - - id: check-case-conflict - - id: check-docstring-first - - id: detect-private-key - - - repo: https://github.com/adamchainz/django-upgrade - rev: '1.22.2' - hooks: - - id: django-upgrade - args: ['--target-version', '5.0'] - - # Run the Ruff linter. - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.9 - hooks: - # Linter - - id: ruff - args: [--fix, --exit-non-zero-on-fix] - # Formatter - - id: ruff-format - - - repo: https://github.com/Riverside-Healthcare/djLint - rev: v1.36.4 - hooks: - - id: djlint-reformat-django - - id: djlint-django - -# sets up .pre-commit-ci.yaml to ensure pre-commit dependencies stay up to date -ci: - autoupdate_schedule: weekly - skip: [] - submodules: false \ No newline at end of file diff --git a/.readthedocs.yml b/.readthedocs.yml deleted file mode 100644 index 5564388..0000000 --- a/.readthedocs.yml +++ /dev/null @@ -1,20 +0,0 @@ -# Read the Docs configuration file -# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details - -# Required -version: 2 - -# Set the version of Python and other tools you might need -build: - os: ubuntu-22.04 - tools: - python: '3.12' - -# Build documentation in the docs/ directory with Sphinx -sphinx: - configuration: docs/conf.py - -# Python requirements required to build your docs -python: - install: - - requirements: requirements/local.txt diff --git a/README.md b/README.md index 0e151fa..a1f0b75 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,3 @@ -<<<<<<< HEAD -# Di-rios-Oficiais-Alems -Indexação dos Diários Oficiais da ALEMS -======= # Diários Oficiais ALEMS Indexação dos Diários Oficiais da ALEMS @@ -9,49 +5,48 @@ Indexação dos Diários Oficiais da ALEMS [![Built with Cookiecutter Django](https://img.shields.io/badge/built%20with-Cookiecutter%20Django-ff69b4.svg?logo=cookiecutter)](https://github.com/cookiecutter/cookiecutter-django/) [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) -## Settings +## Configurações -Moved to [settings](https://cookiecutter-django.readthedocs.io/en/latest/1-getting-started/settings.html). +Consulte [settings](https://cookiecutter-django.readthedocs.io/en/latest/1-getting-started/settings.html). -## Basic Commands +## Comandos Básicos -### Setting Up Your Users +### Configuração de Usuários -- To create a **normal user account**, just go to Sign Up and fill out the form. Once you submit it, you'll see a "Verify Your E-mail Address" page. Go to your console to see a simulated email verification message. Copy the link into your browser. Now the user's email should be verified and ready to go. +- Para criar uma **conta de usuário normal**, vá para Cadastrar e preencha o formulário. Após enviar, você verá uma página "Verifique Seu Endereço de E-mail". Verifique no console a mensagem simulada de verificação de e-mail. Copie o link no seu navegador. Agora o e-mail do usuário estará verificado e pronto para uso. -- To create a **superuser account**, use this command: +- Para criar uma **conta de superusuário**, use o comando: $ python manage.py createsuperuser -For convenience, you can keep your normal user logged in on Chrome and your superuser logged in on Firefox (or similar), so that you can see how the site behaves for both kinds of users. +Para conveniência, você pode manter seu usuário normal logado no Chrome e seu superusuário logado no Firefox (ou similar), para ver como o site se comporta para ambos os tipos de usuários. -### Type checks +### Verificação de Tipos -Running type checks with mypy: +Executando verificação de tipos com mypy: $ mypy diários_oficiais_alems -### Test coverage +### Cobertura de Testes -To run the tests, check your test coverage, and generate an HTML coverage report: +Para executar os testes, verificar a cobertura e gerar um relatório HTML de cobertura: $ coverage run -m pytest $ coverage html $ open htmlcov/index.html -#### Running tests with pytest +#### Executando testes com pytest $ pytest -### Live reloading and Sass CSS compilation +### Recarregamento automático e compilação Sass CSS -Moved to [Live reloading and SASS compilation](https://cookiecutter-django.readthedocs.io/en/latest/2-local-development/developing-locally.html#using-webpack-or-gulp). +Consulte [Live reloading and SASS compilation](https://cookiecutter-django.readthedocs.io/en/latest/2-local-development/developing-locally.html#using-webpack-or-gulp). -## Deployment +## Deploy -The following details how to deploy this application. +A seguir estão os detalhes para fazer o deploy desta aplicação. ### Docker -See detailed [cookiecutter-django Docker documentation](https://cookiecutter-django.readthedocs.io/en/latest/3-deployment/deployment-with-docker.html). ->>>>>>> d8d0562 (start project) +Consulte a [documentação Docker do cookiecutter-django](https://cookiecutter-django.readthedocs.io/en/latest/3-deployment/deployment-with-docker.html). \ No newline at end of file diff --git a/compose/local/django/Dockerfile b/compose/local/django/Dockerfile index ffdf59a..3143f72 100644 --- a/compose/local/django/Dockerfile +++ b/compose/local/django/Dockerfile @@ -1,28 +1,28 @@ -# define an alias for the specific python version used in this file. -FROM docker.io/python:3.12.9-slim-bookworm AS python +# FROM docker.io/python:3.12.9-slim-bookworm AS python # Linha removida, já definida abaixo -# Python build stage -FROM python AS python-build-stage +# Python build stage - Mantenha apenas dependências de BUILD aqui +FROM docker.io/python:3.12.9-slim-bookworm AS python-build-stage ARG BUILD_ENVIRONMENT=local -# Install apt packages +# Instalar apenas dependências para construir pacotes Python RUN apt-get update && apt-get install --no-install-recommends -y \ - # dependencies for building Python packages - build-essential \ - # psycopg dependencies - libpq-dev + build-essential \ + libpq-dev \ + # libtesseract-dev pode ser necessário aqui se alguma lib Python compila contra ele + libtesseract-dev \ + && rm -rf /var/lib/apt/lists/* -# Requirements are installed here to ensure they will be cached. +# Requirements são instalados aqui para garantir que serão cacheados. COPY ./requirements . -# Create Python Dependency and Sub-Dependency Wheels. +# Criar Wheels das dependências Python. RUN pip wheel --wheel-dir /usr/src/app/wheels \ - -r ${BUILD_ENVIRONMENT}.txt + -r ${BUILD_ENVIRONMENT}.txt -# Python 'run' stage -FROM python AS python-run-stage +# Python 'run' stage - ESTA É A IMAGEM FINAL +FROM docker.io/python:3.12.9-slim-bookworm AS python-run-stage ARG BUILD_ENVIRONMENT=local ARG APP_HOME=/app @@ -33,48 +33,46 @@ ENV BUILD_ENV=${BUILD_ENVIRONMENT} WORKDIR ${APP_HOME} - -# devcontainer dependencies and utils +# Instalar TODAS as dependências de sistema necessárias em TEMPO DE EXECUÇÃO RUN apt-get update && apt-get install --no-install-recommends -y \ - sudo git bash-completion nano ssh + # Dependências do psycopg e outras utilidades + libpq-dev \ + wait-for-it \ + gettext \ + poppler-utils \ + unpaper \ + tesseract-ocr \ + tesseract-ocr-por \ + ghostscript \ + # libtesseract-dev \ + # Utilitários do devcontainer e outros + sudo git bash-completion vim ssh \ + # Limpeza + && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \ + && rm -rf /var/lib/apt/lists/* -# Create devcontainer user and add it to sudoers +# Criar usuário devcontainer (manter como está) RUN groupadd --gid 1000 dev-user \ - && useradd --uid 1000 --gid dev-user --shell /bin/bash --create-home dev-user \ - && echo dev-user ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/dev-user \ - && chmod 0440 /etc/sudoers.d/dev-user + && useradd --uid 1000 --gid dev-user --shell /bin/bash --create-home dev-user \ + && echo dev-user ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/dev-user \ + && chmod 0440 /etc/sudoers.d/dev-user - -# Install required system dependencies -RUN apt-get update && apt-get install --no-install-recommends -y \ - # psycopg dependencies - libpq-dev \ - wait-for-it \ - # Translations dependencies - gettext \ - # cleaning up unused files - && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \ - && rm -rf /var/lib/apt/lists/* - -# All absolute dir copies ignore workdir instruction. All relative dir copies are wrt to the workdir instruction -# copy python dependency wheels from python-build-stage +# Instalar dependências Python a partir dos wheels (manter como está) COPY --from=python-build-stage /usr/src/app/wheels /wheels/ - -# use wheels to install python dependencies RUN pip install --no-cache-dir --no-index --find-links=/wheels/ /wheels/* \ - && rm -rf /wheels/ + && rm -rf /wheels/ +# Copiar scripts e código (manter como está) COPY ./compose/production/django/entrypoint /entrypoint RUN sed -i 's/\r$//g' /entrypoint RUN chmod +x /entrypoint - COPY ./compose/local/django/start /start RUN sed -i 's/\r$//g' /start RUN chmod +x /start - - - -# copy application code to WORKDIR COPY . ${APP_HOME} +# REMOVER esta linha, poppler-utils já foi instalado acima +# RUN apt-get update && apt-get install -y poppler-utils + ENTRYPOINT ["/entrypoint"] + diff --git a/config/api.py b/config/api.py new file mode 100644 index 0000000..68ca1cd --- /dev/null +++ b/config/api.py @@ -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) diff --git a/config/settings/base.py b/config/settings/base.py index 9006d99..57683cc 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -7,11 +7,11 @@ from pathlib import Path import environ BASE_DIR = Path(__file__).resolve(strict=True).parent.parent.parent -# diários_oficiais_alems/ -APPS_DIR = BASE_DIR / "diários_oficiais_alems" +# diarios_oficiais_alems/ +APPS_DIR = BASE_DIR / "diarios_oficiais_alems" env = environ.Env() -READ_DOT_ENV_FILE = env.bool("DJANGO_READ_DOT_ENV_FILE", default=False) +READ_DOT_ENV_FILE = env.bool("DJANGO_READ_DOT_ENV_FILE", default=True) if READ_DOT_ENV_FILE: # OS environment variables take precedence over variables from .env env.read_env(str(BASE_DIR / ".env")) @@ -47,7 +47,7 @@ LOCALE_PATHS = [str(BASE_DIR / "locale")] # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#databases DATABASES = {"default": env.db("DATABASE_URL")} -DATABASES["default"]["ATOMIC_REQUESTS"] = True +DATABASES["default"]["ATOMIC_REQUESTS"] = False # https://docs.djangoproject.com/en/stable/ref/settings/#std:setting-DEFAULT_AUTO_FIELD DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" @@ -81,10 +81,10 @@ THIRD_PARTY_APPS = [ ] LOCAL_APPS = [ - "diários_oficiais_alems.users", + "diarios_oficiais_alems.users", "diarios", "django_elasticsearch_dsl", - "rest_framework", + "corsheaders", ] # https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS @@ -92,7 +92,7 @@ INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS # MIGRATIONS # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#migration-modules -MIGRATION_MODULES = {"sites": "diários_oficiais_alems.contrib.sites.migrations"} +MIGRATION_MODULES = {"sites": "diarios_oficiais_alems.contrib.sites.migrations"} # AUTHENTICATION # ------------------------------------------------------------------------------ @@ -138,6 +138,8 @@ MIDDLEWARE = [ "django.middleware.locale.LocaleMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", + "corsheaders.middleware.CorsMiddleware", + "django.middleware.common.CommonMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", @@ -187,7 +189,7 @@ TEMPLATES = [ "django.template.context_processors.static", "django.template.context_processors.tz", "django.contrib.messages.context_processors.messages", - "diários_oficiais_alems.users.context_processors.allauth_settings", + "diarios_oficiais_alems.users.context_processors.allauth_settings", ], }, }, @@ -273,14 +275,14 @@ ACCOUNT_EMAIL_REQUIRED = True # https://docs.allauth.org/en/latest/account/configuration.html ACCOUNT_EMAIL_VERIFICATION = "mandatory" # https://docs.allauth.org/en/latest/account/configuration.html -ACCOUNT_ADAPTER = "diários_oficiais_alems.users.adapters.AccountAdapter" +ACCOUNT_ADAPTER = "diarios_oficiais_alems.users.adapters.AccountAdapter" # https://docs.allauth.org/en/latest/account/forms.html -ACCOUNT_FORMS = {"signup": "diários_oficiais_alems.users.forms.UserSignupForm"} +ACCOUNT_FORMS = {"signup": "diarios_oficiais_alems.users.forms.UserSignupForm"} # https://docs.allauth.org/en/latest/socialaccount/configuration.html -SOCIALACCOUNT_ADAPTER = "diários_oficiais_alems.users.adapters.SocialAccountAdapter" +SOCIALACCOUNT_ADAPTER = "diarios_oficiais_alems.users.adapters.SocialAccountAdapter" # https://docs.allauth.org/en/latest/socialaccount/configuration.html SOCIALACCOUNT_FORMS = { - "signup": "diários_oficiais_alems.users.forms.UserSocialSignupForm" + "signup": "diarios_oficiais_alems.users.forms.UserSocialSignupForm" } # django-compressor # ------------------------------------------------------------------------------ @@ -290,40 +292,67 @@ STATICFILES_FINDERS += ["compressor.finders.CompressorFinder"] # Elastic Search # ------------------------------------------------------------------------------ +ELASTICSEARCH_USER = env.str("ELASTICSEARCH_USER") +ELASTICSEARCH_PASSWORD = env.str("ELASTICSEARCH_PASSWORD") + ELASTICSEARCH_DSL = { - "default": {"hosts": "http://elasticsearch:9200"}, # same as above + "default": { + "hosts": "http://elasticsearch:9200", + "timeout": 60, + "http_auth": ( + ELASTICSEARCH_USER, + ELASTICSEARCH_PASSWORD, + ), + }, } ELASTICSEARCH_HOSTS = "http://elasticsearch:9200" ELASTICSEARCH_INDEX_SETTINGS = { - 'number_of_shards': 1, - 'number_of_replicas': 0, - 'analysis': { - 'filter': { - 'portuguese_stop': { - 'type': 'stop', - 'stopwords': '_portuguese_' + # Define o número de shards (partições) para o índice. + "number_of_shards": 1, + # Define o número de réplicas de cada shard. + "number_of_replicas": 0, + # Configurações de análise do Elasticsearch para processamento do texto + "analysis": { + # Definição de filtros de análise + "filter": { + # Filtro de remoção de stopwords (palavras comuns que não agregam significado) + "portuguese_stop": {"type": "stop", "stopwords": "_portuguese_"}, + # Filtro de stemming para reduzir palavras à sua raiz (ex: "correndo" -> "correr") + "portuguese_stemmer": {"type": "stemmer", "language": "portuguese"}, + # Filtro de sinônimos, carregando um arquivo externo com a lista de sinônimos + "synonym_filter": { + "type": "synonym", + "synonyms_path": "analysis/sinonimos.txt", # Caminho para o arquivo de sinônimos }, - 'portuguese_stemmer': { - 'type': 'stemmer', - 'language': 'portuguese' - }, - 'synonym_filter': { - 'type': 'synonym', - 'synonyms_path': 'analysis/sinonimos.txt', + }, + # Definição de analisadores (combinações de tokenizer e filtros) + "analyzer": { + # Criando um analisador chamado "pt_analyzer" para português + "pt_analyzer": { + # Define o tokenizer como "standard", que quebra o texto em palavras básicas + "tokenizer": "standard", + # Lista de filtros a serem aplicados ao texto após a tokenização + "filter": [ + # Converte todas as palavras para minúsculas + "lowercase", + # Aplica o filtro de remoção de stopwords em português + "portuguese_stop", + # Aplica o filtro de stemming para reduzir palavras à sua raiz + "portuguese_stemmer", + # Aplica o filtro de sinônimos, substituindo palavras por seus sinônimos conforme o arquivo + "synonym_filter", + ], } }, - 'analyzer': { - 'pt_analyzer': { - 'tokenizer': 'standard', - 'filter': [ - 'lowercase', - 'portuguese_stop', - 'portuguese_stemmer', - 'synonym_filter' - ] - } - } - } + }, } +CORS_ALLOWED_ORIGINS = [ + "http://109.199.98.226:8006", + "http://109.199.98.226:8005", + "http://localhost:8006", + "http://localhost:8005", + "http://127.0.0.1:8006", + "http://127.0.0.1:8005", +] diff --git a/config/urls.py b/config/urls.py index c48dcde..77dcb3e 100644 --- a/config/urls.py +++ b/config/urls.py @@ -6,21 +6,25 @@ from django.urls import include from django.urls import path from django.views import defaults as default_views from django.views.generic import TemplateView +from .api import api + urlpatterns = [ path("", TemplateView.as_view(template_name="pages/home.html"), name="home"), + path('', include('diarios.urls')), path( "about/", TemplateView.as_view(template_name="pages/about.html"), name="about", ), + path("api/v1/", api.urls), # Django Admin, use {% url 'admin:index' %} path(settings.ADMIN_URL, admin.site.urls), # User management - path("users/", include("diários_oficiais_alems.users.urls", namespace="users")), - path("accounts/", include("allauth.urls")), + path("users/", include("diarios_oficiais_alems.users.urls", namespace="users")), + # path("accounts/", include("allauth.urls")), # Your stuff: custom urls includes go here - path("diarios/", include("diarios.urls")), + path("", include("diarios.urls")), # Media files *static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT), ] diff --git a/config/wsgi.py b/config/wsgi.py index b053389..9350cc6 100644 --- a/config/wsgi.py +++ b/config/wsgi.py @@ -22,9 +22,9 @@ from pathlib import Path from django.core.wsgi import get_wsgi_application # This allows easy placement of apps within the interior -# diários_oficiais_alems directory. +# diarios_oficiais_alems directory. BASE_DIR = Path(__file__).resolve(strict=True).parent.parent -sys.path.append(str(BASE_DIR / "diários_oficiais_alems")) +sys.path.append(str(BASE_DIR / "diarios_oficiais_alems")) # We defer to a DJANGO_SETTINGS_MODULE already in the environment. This breaks # if running multiple sites in the same mod_wsgi process. To fix this, use # mod_wsgi daemon mode with each site in its own daemon process, or use diff --git a/diarios/admin.py b/diarios/admin.py index 08af8ce..2db1dff 100644 --- a/diarios/admin.py +++ b/diarios/admin.py @@ -1,13 +1,150 @@ from django.contrib import admin -from django.db import models +from django.utils.html import format_html +from django.urls import reverse +from django.utils.safestring import mark_safe +from .models import TipoDiarioOficial, DiarioOficial, PageDiarioOficial +from .forms import PageDiarioOficialInlineForm -from .models import DiarioOficial, TipoDiarioOficial + +@admin.register(TipoDiarioOficial) +class TipoDiarioOficialAdmin(admin.ModelAdmin): + list_display = ("nome", "quantidade_diarios") + search_fields = ("nome",) + ordering = ("nome",) + + def quantidade_diarios(self, obj): + return obj.diarios.count() + + quantidade_diarios.short_description = "Nº de Diários" + + +class PageDiarioOficialInline(admin.TabularInline): + form = PageDiarioOficialInlineForm + model = PageDiarioOficial + extra = 0 + fields = ("id", "numero", "conteudo") + can_delete = False + + def numero_link(self, instance): + if instance.id: + url = reverse("admin:diarios_pagediariooficial_change", args=[instance.id]) + return mark_safe(f'{instance.numero}') + return instance.numero + + def conteudo_resumido(self, instance): + return ( + (instance.conteudo[:100] + "...") + if len(instance.conteudo) > 100 + else instance.conteudo + ) + + conteudo_resumido.short_description = "Conteúdo (resumo)" @admin.register(DiarioOficial) class DiarioOficialAdmin(admin.ModelAdmin): - pass + list_display = ( + "numero", + "tipo_nome", + "data_formatada_admin", + "arquivo_link", + "link_externo", + "paginas_count", + ) + list_filter = ("tipo", "data") + search_fields = ("numero", "tipo__nome", "data") + date_hierarchy = "data" + ordering = ("-data", "-numero") + readonly_fields = ( + "data_formatada_admin", + "arquivo_preview", + "paginas_count", + "link_externo", + ) + fieldsets = ( + ( + "Informações Básicas", + {"fields": ("numero", "tipo", "data", "data_formatada_admin")}, + ), + ( + "Arquivos e Links", + {"fields": ("arquivo", "arquivo_preview", "link", "link_externo")}, + ), + ("Estatísticas", {"fields": ("paginas_count",), "classes": ("collapse",)}), + ) + # inlines = (PageDiarioOficialInline,) -@admin.register(TipoDiarioOficial) -class TipoDiarioOficialAdmin(admin.ModelAdmin): - pass + def tipo_nome(self, obj): + return obj.tipo.nome if obj.tipo else "-" + + tipo_nome.short_description = "Tipo" + tipo_nome.admin_order_field = "tipo__nome" + + def data_formatada_admin(self, obj): + return obj.data_formatada + + data_formatada_admin.short_description = "Data" + + def arquivo_link(self, obj): + if obj.arquivo: + return mark_safe( + f'Download PDF' + ) + return "-" + + arquivo_link.short_description = "Arquivo" + arquivo_link.allow_tags = True + + def link_externo(self, obj): + if obj.link: + return mark_safe(f'Acessar Online') + return "-" + + link_externo.short_description = "Link Externo" + link_externo.allow_tags = True + + def arquivo_preview(self, obj): + if obj.arquivo: + return mark_safe( + f'Visualizar PDF' + ) + return "-" + + arquivo_preview.short_description = "Pré-visualização" + arquivo_preview.allow_tags = True + + def paginas_count(self, obj): + return obj.paginas.count() + + paginas_count.short_description = "Nº de Páginas" + + def get_queryset(self, request): + return super().get_queryset(request).prefetch_related("paginas") + + +@admin.register(PageDiarioOficial) +class PageDiarioOficialAdmin(admin.ModelAdmin): + autocomplete_fields = ("diario",) + list_display = ("id", "diario_link", "numero", "conteudo_resumido") + list_display_links = ('id', 'numero') + list_filter = ("diario__tipo", "diario__data", "layout_duas_colunas") + search_fields = ("conteudo", "diario__numero") + readonly_fields = ( + "diario_link", + ) + + + def diario_link(self, obj): + url = reverse("admin:diarios_diariooficial_change", args=[obj.diario.id]) + return mark_safe(f'{obj.diario}') + + 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") diff --git a/diarios/api.py b/diarios/api.py new file mode 100644 index 0000000..135c01e --- /dev/null +++ b/diarios/api.py @@ -0,0 +1,5 @@ +from ninja import NinjaAPI + + +api = NinjaAPI() +api.add_router("/diarios/", route) diff --git a/diarios/apps.py b/diarios/apps.py index 8af1284..8a7f41e 100644 --- a/diarios/apps.py +++ b/diarios/apps.py @@ -4,3 +4,7 @@ from django.apps import AppConfig class DiariosConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "diarios" + + def ready(self): + import diarios.documents + diff --git a/diarios/custom_filters.py b/diarios/custom_filters.py deleted file mode 100644 index 7f0b7d1..0000000 --- a/diarios/custom_filters.py +++ /dev/null @@ -1,8 +0,0 @@ -from django import template - -register = template.Library() - -@register.filter -def get_range(value): - return range(value) - diff --git a/diarios/documents.py b/diarios/documents.py index 21351a7..e8a5990 100644 --- a/diarios/documents.py +++ b/diarios/documents.py @@ -1,92 +1,67 @@ from django_elasticsearch_dsl import Document, fields from django_elasticsearch_dsl.registries import registry - from .models import DiarioOficial @registry.register_document class DiarioOficialDocument(Document): - tipo = fields.ObjectField(properties={ - 'nome': fields.TextField() - }) - - numero = fields.TextField() + numero = fields.KeywordField() data = fields.DateField() - link = fields.TextField() - - # Campo para armazenar todas as páginas para busca - content = fields.TextField( - analyzer='pt_analyzer', + link = fields.KeywordField() + tipo = fields.ObjectField(properties={"nome": fields.KeywordField()}) + + # Campo para páginas + paginas = fields.NestedField( + properties={ + "id": fields.IntegerField(), + "numero": fields.IntegerField(), + "conteudo": fields.TextField( + analyzer="custom_portuguese", + fields={ + "keyword": fields.KeywordField(), + "search": fields.TextField(analyzer="custom_portuguese"), + }, + ), + } ) - - # Campo para armazenar páginas individualmente - pages = fields.NestedField(properties={ - 'number': fields.IntegerField(), - 'content': fields.TextField( - analyzer='pt_analyzer', - ) - }) class Index: - name = 'diarios_oficiais' + name = "diario_oficial" settings = { - 'number_of_shards': 1, - 'number_of_replicas': 0, - 'analysis': { - 'filter': { - 'portuguese_stop': { - 'type': 'stop', - 'stopwords': '_portuguese_' - }, - 'portuguese_stemmer': { - 'type': 'stemmer', - 'language': 'portuguese' - }, - 'synonym_filter': { - 'type': 'synonym', - 'synonyms': [ - 'lei, legislação, norma', - 'processo, procedimento, autos', - 'contrato, acordo, convênio', - ] + "number_of_shards": 1, + "number_of_replicas": 0, + "analysis": { + "analyzer": { + "custom_portuguese": { + "type": "custom", + "tokenizer": "standard", + "filter": [ + "lowercase", + "asciifolding", + "portuguese_stop", + ], } }, - 'analyzer': { - 'pt_analyzer': { - 'tokenizer': 'standard', - 'filter': [ - 'lowercase', - 'portuguese_stop', - 'portuguese_stemmer', - 'synonym_filter' - ] - } - } - } + "filter": { + "portuguese_stop": {"type": "stop", "stopwords": "_portuguese_"}, + }, + }, } - + class Django: model = DiarioOficial - fields = [ - 'id' - ] - - def prepare_tipo(self, instance): - if instance.tipo: - return { - 'nome': instance.tipo.nome - } - return {} - - def prepare_content(self, instance): - """Concatena todo o conteúdo de todas as páginas em um único campo para busca""" - if instance.page_content: - return " ".join([page.get('content', '') for page in instance.page_content]) - return "" - - def prepare_pages(self, instance): - """Prepara o campo de páginas individuais para exibição e destaque""" - if instance.page_content: - return instance.page_content - return [] + fields = ["id"] + def prepare_tipo(self, instance): + return {"nome": instance.tipo.nome if instance.tipo else "Sem Tipo"} + + def prepare_link(self, instance): + return instance.link or "" + + def prepare_paginas(self, instance): + # Preparar páginas ordenadas + paginas = instance.paginas.all().order_by("numero") + return [ + {"id": pagina.id, "numero": pagina.numero, "conteudo": pagina.conteudo} + for pagina in paginas + ] diff --git a/diarios/forms.py b/diarios/forms.py new file mode 100644 index 0000000..d8c50d2 --- /dev/null +++ b/diarios/forms.py @@ -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 diff --git a/diarios/management/commands/importar_diarios_com_ocr.py b/diarios/management/commands/importar_diarios_com_ocr.py new file mode 100644 index 0000000..0ca8ecf --- /dev/null +++ b/diarios/management/commands/importar_diarios_com_ocr.py @@ -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) diff --git a/diarios/management/commands/reindex_diarios.py b/diarios/management/commands/reindex_diarios.py deleted file mode 100644 index 6ccf046..0000000 --- a/diarios/management/commands/reindex_diarios.py +++ /dev/null @@ -1,24 +0,0 @@ -from django.core.management.base import BaseCommand -from django_elasticsearch_dsl.registries import registry - -class Command(BaseCommand): - help = 'Reindexar todos os Diários Oficiais no Elasticsearch' - - def handle(self, *args, **options): - self.stdout.write('Iniciando reindexação...') - - # Recria os índices - registry.delete_indices() - registry.create_indices() - - # Reindexar documentos - for index in registry.get_indices(): - self.stdout.write(f'Reindexando {index}...') - documents = [] - for doc in registry.get_documents(): - if index == doc._index._name: - self.stdout.write(f' + {doc.__name__}') - doc().update() - - self.stdout.write(self.style.SUCCESS('Reindexação concluída!')) - diff --git a/diarios/migrations/0006_remove_diariooficial_page_content_pagediariooficial.py b/diarios/migrations/0006_remove_diariooficial_page_content_pagediariooficial.py new file mode 100644 index 0000000..8ea5de1 --- /dev/null +++ b/diarios/migrations/0006_remove_diariooficial_page_content_pagediariooficial.py @@ -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")}, + }, + ), + ] diff --git a/diarios/migrations/0007_diariooficial_layout_duas_colunas.py b/diarios/migrations/0007_diariooficial_layout_duas_colunas.py new file mode 100644 index 0000000..60e83cf --- /dev/null +++ b/diarios/migrations/0007_diariooficial_layout_duas_colunas.py @@ -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), + ), + ] diff --git a/diarios/migrations/0008_remove_diariooficial_layout_duas_colunas_and_more.py b/diarios/migrations/0008_remove_diariooficial_layout_duas_colunas_and_more.py new file mode 100644 index 0000000..5227f73 --- /dev/null +++ b/diarios/migrations/0008_remove_diariooficial_layout_duas_colunas_and_more.py @@ -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), + ), + ] diff --git a/diarios/models.py b/diarios/models.py index 1bdcfe8..83025fb 100644 --- a/diarios/models.py +++ b/diarios/models.py @@ -1,19 +1,21 @@ -import json import os from urllib.parse import urlparse - -import PyPDF2 import requests from babel.dates import format_date from django.core.files.base import ContentFile -from django.core.serializers.json import DjangoJSONEncoder from django.db import models +from django.core.exceptions import ValidationError +import PyPDF2 +from asgiref.sync import async_to_sync class TipoDiarioOficial(models.Model): + """Representa um tipo de Diário Oficial (e.g., Municipal, Estadual, Federal).""" + nome = models.CharField(max_length=100, unique=True) def __str__(self): + """Retorna o nome do tipo de Diário Oficial.""" return self.nome class Meta: @@ -21,6 +23,16 @@ class TipoDiarioOficial(models.Model): class DiarioOficial(models.Model): + """Modelo que representa um Diário Oficial, contendo data, arquivo PDF, tipo e link. + + Attributes: + data (DateField): Data de publicação do Diário Oficial. + arquivo (FileField): Arquivo PDF do Diário Oficial (opcional). + tipo (ForeignKey): Tipo do Diário Oficial (Municipal, Estadual, etc.). + numero (CharField): Número de identificação único do Diário. + link (URLField): URL para o Diário Oficial (opcional). + """ + data = models.DateField() arquivo = models.FileField(upload_to="diarios_oficiais/", blank=True, null=True) tipo = models.ForeignKey( @@ -32,63 +44,128 @@ class DiarioOficial(models.Model): ) numero = models.CharField(max_length=20, unique=True) link = models.URLField(blank=True, null=True, unique=True) - page_content = models.JSONField(encoder=DjangoJSONEncoder, blank=True, null=True) def save(self, *args, **kwargs): - # Se houver um link, baixa o PDF e extrai o conteúdo - if self.link and not self.arquivo: - try: - # Faz o download do PDF - response = requests.get(self.link) - response.raise_for_status() # Verifica se o download foi bem-sucedido - - # Define o nome do arquivo a partir do link - parsed_url = urlparse(self.link) - file_name = ( - os.path.basename(parsed_url.path) or f"diario_{self.numero}.pdf" - ) - - # Salva o arquivo no campo `arquivo` - self.arquivo.save(file_name, ContentFile(response.content), save=False) - - # Extrai o conteúdo do PDF - pdf = PyPDF2.PdfReader(self.arquivo) - pages_data = [] - - for i, pagina in enumerate(pdf.pages): - page_text = pagina.extract_text() - if page_text: # Ignora páginas sem conteúdo - pages_data.append( - { - "number": i + 1, - "content": page_text, - } - ) - - # Salva o conteúdo das páginas no campo `page_content` - self.page_content = pages_data - - except requests.RequestException as e: - print(f"Erro ao baixar o PDF: {e}") - except PyPDF2.PdfReadError as e: - print(f"Erro ao ler o PDF: {e}") - except Exception as e: - print(f"Erro inesperado: {e}") - - # Salva o modelo + """Salva o Diário Oficial, baixa o PDF (se houver link) e extrai páginas.""" super().save(*args, **kwargs) + if self.link and not self.arquivo: + self._download_pdf_from_link() + + if self.arquivo and not self.paginas: + self._extract_pdf_pages() + + super().save(*args, **kwargs) + + def clean(self): + """Valida o modelo antes de salvar (chamado automaticamente no admin/form).""" + super().clean() + if not self.arquivo and not self.link: + raise ValidationError("Informe um arquivo ou um link para o Diário.") + + def _validar_link(self): + """Verifica se o link é um PDF válido.""" + if not self.link.lower().endswith(".pdf"): + raise ValidationError("O link deve apontar para um arquivo PDF.") + + def _download_pdf_from_link(self): + """Faz download do PDF a partir do link e salva no campo `arquivo`. + + Raises: + ValidationError: Se o download falhar. + """ + try: + response = requests.get(self.link) + response.raise_for_status() + + parsed_url = urlparse(self.link) + file_name = os.path.basename(parsed_url.path) or f"diario_{self.numero}.pdf" + + self.arquivo.save(file_name, ContentFile(response.content), save=True) + except requests.RequestException as e: + raise ValidationError(f"Não foi possível baixar o PDF: {e}") + + def _extract_pdf_pages(self): + """Extrai o texto de cada página do PDF e salva no modelo `PageDiarioOficial`. + + Raises: + ValidationError: Se a extração falhar. + """ + try: + with self.arquivo.open("rb") as pdf_file: + pdf = PyPDF2.PdfReader(pdf_file) + self._process_pdf_pages(pdf) + except Exception as pdf_error: + raise ValidationError(f"Não foi possível processar o PDF: {pdf_error}") + + def _process_pdf_pages(self, pdf): + """Processa cada página do PDF e salva seu conteúdo. + + Args: + pdf (PdfReader): Objeto PDF carregado. + """ + self.paginas.all().delete() + + for i, pagina in enumerate(pdf.pages): + try: + page_text = pagina.extract_text() + if page_text and page_text.strip(): + PageDiarioOficial.objects.create( + diario=self, + numero=i + 1, + conteudo=page_text.strip(), + ) + except Exception as page_error: + PageDiarioOficial.objects.create( + diario=self, + numero=i + 1, + conteudo=f"[Erro na extração do texto: {str(page_error)}]", + ) + continue + @property def data_formatada(self): + """Retorna a data formatada em português (e.g., '1 de Janeiro de 2023').""" return format_date(self.data, format="long", locale="pt_BR") @property def is_online(self): - return True if self.link else False + """Verifica se o Diário possui um link (online).""" + return bool(self.link) def __str__(self): - return f"Diário {self.tipo.nome} nº {self.numero}, {self.data_formatada}" + """Representação em string do Diário Oficial.""" + tipo_nome = self.tipo.nome if self.tipo else "Sem Tipo" + return f"Diário {tipo_nome} nº {self.numero}, {self.data_formatada}" class Meta: constraints = [models.UniqueConstraint(fields=["numero"], name="unique_numero")] verbose_name_plural = "Diários Oficiais" + + +class PageDiarioOficial(models.Model): + """Representa uma página de um Diário Oficial com seu conteúdo textual. + + Attributes: + diario (ForeignKey): Diário Oficial associado. + layout_duas_colunas (BooleanField): Indica se a página tem duas colunas. + numero (PositiveIntegerField): Número da página no Diário. + conteudo (TextField): Texto extraído da página. + """ + + diario = models.ForeignKey( + DiarioOficial, on_delete=models.CASCADE, related_name="paginas" + ) + layout_duas_colunas = models.BooleanField(default=False) + numero = models.PositiveIntegerField() + conteudo = models.TextField() + + class Meta: + unique_together = ("diario", "numero") + verbose_name = "Página de Diário Oficial" + verbose_name_plural = "Páginas de Diários Oficiais" + + def __str__(self): + """Representação em string da página (e.g., 'Página 1 do Diário 123').""" + return f"Página {self.numero} do Diário {self.diario.numero}" + diff --git a/diarios/schemas.py b/diarios/schemas.py new file mode 100644 index 0000000..0847b26 --- /dev/null +++ b/diarios/schemas.py @@ -0,0 +1,63 @@ +from ninja import Schema +from typing import List, Optional + + +class PaginaSchema(Schema): + """Schema que representa uma página de um Diário Oficial. + + Attributes: + numero (int): Número ordinal da página no Diário (começa em 1). + conteudo (str): Texto extraído da página. + """ + + numero: int + conteudo: str + + +class ResultadoSchema(Schema): + """Schema de resposta para um Diário Oficial em resultados de busca. + + Attributes: + id (int): ID único do Diário no banco de dados. + numero (str): Número de identificação oficial do Diário (e.g., '123-A'). + data (str): Data de publicação no formato ISO (YYYY-MM-DD). + link (str): URL para acessar o Diário Oficial online. + tipo (str): Nome do tipo de Diário (e.g., 'Municipal', 'Federal'). + paginas (List[PaginaSchema]): Lista de páginas com conteúdo extraído. + score (Optional[float]): Relevância do resultado (0 a 1), se aplicável. + """ + + id: int + numero: str + data: str + link: str + tipo: str + paginas: List[PaginaSchema] + score: Optional[float] = None + + +class BuscaDiariosResponseSchema(Schema): + """Schema de resposta para buscas paginadas em Diários Oficiais. + + Attributes: + total (int): Total de resultados disponíveis (ignorando paginação). + resultados (List[ResultadoSchema]): Lista de Diários encontrados. + pagina (int): Número da página atual (começa em 1). + por_pagina (int): Quantidade de resultados por página. + """ + + total: int + resultados: List[ResultadoSchema] + pagina: int + por_pagina: int + + +class SugestaoResponse(Schema): + """Schema para sugestões de correção de busca (e.g., 'Voc quis dizer...?'). + + Attributes: + sugestao (Optional[str]): Termo sugerido para refinar a busca. + None se nenhuma sugestão for relevante. + """ + + sugestao: Optional[str] diff --git a/diarios/search_service.py b/diarios/search_service.py index f72c755..0f92888 100644 --- a/diarios/search_service.py +++ b/diarios/search_service.py @@ -1,67 +1,733 @@ -from elasticsearch_dsl import Q, Search -from .documents import DiarioOficialDocument +import re +from datetime import datetime +from typing import Optional, Dict, Any, List +from elasticsearch import Elasticsearch, AsyncElasticsearch +from .schemas import ResultadoSchema, PaginaSchema +import unicodedata +import asyncio +from django.conf import settings -class DiarioOficialSearchService: - @staticmethod - def search(query, highlight=True, fuzziness=1, page=1, page_size=10, tipos=None, data_inicio=None, data_fim=None): - # Configura a busca básica - s = DiarioOficialDocument.search().source(excludes=['page_content.content']) - - # Filtros - if tipos: - s = s.filter('terms', tipo_nome=tipos) - if data_inicio and data_fim: - s = s.filter('range', data={'gte': data_inicio, 'lte': data_fim}) - # Query principal com fuzziness e sinônimos - main_query = Q( - 'multi_match', - query=query, - fields=[ - 'numero^3', # Maior peso para o número - 'tipo_nome^2', # Peso médio para o tipo - 'page_content.content' # Peso padrão para o conteúdo - ], - fuzziness=fuzziness, - analyzer='portuguese_synonyms' +async def is_fuzzy_appropriate(term: str) -> bool: + """ + Determina se a fuzziness é apropriada para o termo de busca. + + Args: + term: Termo de busca a ser avaliado + + Returns: + bool: True se fuzziness é apropriada, False caso contrário + """ + return not re.match(r"^\d+/\d+$", term.strip()) + + +async def parse_date(date_str: Optional[str]) -> Optional[str]: + """ + Converte string de data para formato ISO para ElasticSearch. + + Args: + date_str: String de data no formato YYYY-MM-DD + + Returns: + Optional[str]: Data formatada ou None se inválida + """ + if not date_str: + return None + try: + dt = datetime.strptime(date_str, "%Y-%m-%d") + return dt.strftime("%Y-%m-%d") + except ValueError: + print(f"Alerta: Formato de data inválido recebido: {date_str}") + return None + + +async def buscar_diarios( + query: Optional[str] = None, + data_inicio: Optional[str] = None, + data_fim: Optional[str] = None, + tipo_diario: Optional[str] = None, + page: int = 1, + page_size: int = 10, +) -> Dict[str, Any]: + """ + Realiza busca nos diários oficiais com os parâmetros fornecidos. + + Args: + query: Termo de busca + data_inicio: Data inicial no formato YYYY-MM-DD + data_fim: Data final no formato YYYY-MM-DD + tipo_diario: Tipo de diário a ser filtrado + page: Número da página de resultados + page_size: Quantidade de resultados por página + + Returns: + Dict[str, Any]: Dicionário com resultados da busca + """ + try: + es = AsyncElasticsearch( + "http://elasticsearch:9200", + request_timeout=30, + basic_auth=( + settings.ELASTICSEARCH_USER, + settings.ELASTICSEARCH_PASSWORD, + ), ) - s = s.query(main_query) + if not await es.ping(): + raise ConnectionError("Não foi possível conectar ao Elasticsearch") + except Exception as e: + print(f"Erro ao conectar com Elasticsearch: {e}") + return {"total": 0, "resultados": [], "pagina": page, "por_pagina": page_size} - # Highlighting - if highlight: - s = s.highlight( - 'page_content.content', - fragment_size=150, - number_of_fragments=3, - pre_tags=[''], - post_tags=[''] - ) + es_body = { + "query": {"bool": {"must": [], "filter": []}}, + "size": page_size, + "from": (page - 1) * page_size, + "_source": [ + "numero", + "data", + "link", + "tipo", + "paginas.numero", + "paginas.conteudo", + ], + } - # Paginação - start = (page - 1) * page_size - end = start + page_size - s = s[start:end] + parsed_dt_inicio = await parse_date(data_inicio) + parsed_dt_fim = await parse_date(data_fim) + if parsed_dt_inicio or parsed_dt_fim: + date_range_filter = {} + if parsed_dt_inicio: + date_range_filter["gte"] = parsed_dt_inicio + if parsed_dt_fim: + date_range_filter["lte"] = parsed_dt_fim + es_body["query"]["bool"]["filter"].append( + {"range": {"data": date_range_filter}} + ) - # Executa a busca - response = s.execute() + if tipo_diario: + es_body["query"]["bool"]["filter"].append({"term": {"tipo.nome": tipo_diario}}) - # Formata os resultados - results = [] - for hit in response: - result = { - 'id': hit.id, - 'numero': hit.numero, - 'data': hit.data, - 'link': hit.link, - 'tipo_nome': hit.tipo_nome, - 'score': hit.meta.score + if query: + aplicar_fuzziness = await is_fuzzy_appropriate(query) + text_query_bool = { + "bool": { + "should": [ + { + "match_phrase": { + "paginas.conteudo.search": { + "query": query, + "slop": 4, + "boost": 5.0, + } + } + }, + { + "match": { + "paginas.conteudo.search": { + "query": query, + "fuzziness": "AUTO", + "operator": "and", + "prefix_length": 3, + } + } + }, + ], + "minimum_should_match": "75%", } - if highlight and hasattr(hit.meta, 'highlight'): - result['highlights'] = hit.meta.highlight['page_content.content'].to_dict() - results.append(result) - - return { - 'total': response.hits.total.value, - 'results': results } + nested_query = { + "nested": { + "path": "paginas", + "query": text_query_bool, + "inner_hits": { + "highlight": { + "fields": {"paginas.conteudo.search": {}}, + "fragment_size": 500, + "number_of_fragments": 1, + "pre_tags": [""], + "post_tags": [""], + }, + "_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": [""], + "post_tags": [""], + }, + "_source": ["paginas.numero"], + "size": 100, # Aumentar para retornar mais páginas correspondentes + }, + } + } + + +async def construir_request_body( + query: Optional[str], + modo_busca: str, + ordenar_por: str, + data_inicio: Optional[str], + data_fim: Optional[str], + tipo_diario: Optional[str], + numero_diario: Optional[str], + page: int, + page_size: int, +) -> Dict[str, Any]: + """ + Constrói o corpo da requisição para o Elasticsearch. + + Args: + query: Termo de busca + modo_busca: Modo de busca (exata ou qualquer) + ordenar_por: Critério de ordenação + data_inicio: Data inicial + data_fim: Data final + tipo_diario: Tipo de diário + numero_diario: Número do diário + page: Número da página + page_size: Tamanho da página + + Returns: + Dict[str, Any]: Corpo da requisição para Elasticsearch + """ + es_body = { + "query": {"bool": {"must": [], "filter": []}}, + "size": page_size, + "from": (page - 1) * page_size, + "_source": [ + "numero", + "data", + "link", + "tipo", + "paginas.numero", + "paginas.conteudo", + ], + } + + # Adicionar ordenação + es_body["sort"] = await construir_ordenacao(ordenar_por) + + # Adicionar filtros + es_body["query"]["bool"]["filter"] = await construir_filtros( + data_inicio, data_fim, tipo_diario, numero_diario + ) + + # Adicionar query principal + query_principal = await construir_query_busca(query, modo_busca) + es_body["query"]["bool"]["must"].append(query_principal) + + return es_body + + +async def processar_paginas_encontradas( + hit: Dict[str, Any], query: Optional[str] +) -> List[PaginaSchema]: + """ + Processa as páginas encontradas em um hit, retornando todas as correspondências. + + Args: + hit: Item de resultado do Elasticsearch + query: Termo de busca original + + Returns: + List[PaginaSchema]: Lista de páginas encontradas + """ + paginas = [] + source = hit.get("_source", {}) + + if not query and "paginas" in source: + for pagina in source["paginas"]: + paginas.append( + PaginaSchema( + numero=pagina.get("numero", 0), conteudo=pagina.get("conteudo", "") + ) + ) + # Se temos uma query e há inner_hits, processamos todas as páginas correspondentes + if query and "inner_hits" in hit and "paginas" in hit["inner_hits"]: + paginas_inner_hits = hit["inner_hits"]["paginas"]["hits"]["hits"] + + # Processar todas as páginas encontradas, não apenas a primeira + for inner_hit in paginas_inner_hits: + inner_source = inner_hit.get("_source", {}) + page_num = inner_source.get("numero", "N/A") + highlights = inner_hit.get("highlight", {}).get( + "paginas.conteudo.search", [] + ) + highlight_content = " ... ".join(highlights) if highlights else "" + + if page_num != "N/A" and highlight_content: + paginas.append( + PaginaSchema(numero=page_num, conteudo=highlight_content) + ) + + # Se não há query ou não encontramos páginas nos inner_hits, usamos a primeira página do documento + if not paginas and "paginas" in source and source["paginas"]: + primeira_pagina_source = source["paginas"][0] + conteudo_orig = primeira_pagina_source.get("conteudo", "") + paginas.append( + PaginaSchema( + numero=primeira_pagina_source.get("numero", 0), + conteudo=conteudo_orig, + ) + ) + + return paginas + + +async def processar_resultados( + response: Dict[str, Any], query: Optional[str], page: int, page_size: int +) -> Dict[str, Any]: + """ + Processa os resultados da busca no Elasticsearch. + + Args: + response: Resposta do Elasticsearch + query: Termo de busca original + page: Número da página atual + page_size: Tamanho da página + + Returns: + Dict[str, Any]: Resultados processados + """ + hits = response.get("hits", {}) + total = hits.get("total", {}).get("value", 0) + resultados_formatados = [] + + for hit in hits.get("hits", []): + source = hit.get("_source", {}) + resultado_data = { + "id": hit.get("_id"), + "numero": source.get("numero", ""), + "data": source.get("data"), + "link": source.get("link", ""), + "tipo": source.get("tipo", {}).get("nome", "Sem Tipo"), + "score": hit.get("_score"), + "paginas": [], + } + + # Processar todas as páginas encontradas + resultado_data["paginas"] = await processar_paginas_encontradas(hit, query) + + resultados_formatados.append(ResultadoSchema(**resultado_data)) + + return { + "total": total, + "resultados": resultados_formatados, + "pagina": page, + "por_pagina": page_size, + } + + +async def buscar_diarios_simples( + query: Optional[str] = None, + numero_diario: Optional[str] = None, + modo_busca: str = "exata", # "exata" ou "qualquer" + ordenar_por: str = "data_asc", # "relevancia" ou "data" + data_inicio: Optional[str] = None, + data_fim: Optional[str] = None, + tipo_diario: Optional[str] = None, + page: int = 1, + page_size: int = 10, +) -> Dict[str, Any]: + """ + Função principal para buscar diários oficiais. + + Args: + query: Termo de busca + numero_diario: Número do diário oficial + modo_busca: Modo de busca (exata ou qualquer) + ordenar_por: Critério de ordenação + data_inicio: Data inicial + data_fim: Data final + tipo_diario: Tipo de diário + page: Número da página + page_size: Tamanho da página + + Returns: + Dict[str, Any]: Dicionário com os resultados da busca + """ + # Conectar ao Elasticsearch + es = await conectar_elasticsearch() + if not es: + return {"total": 0, "resultados": [], "pagina": page, "por_pagina": page_size} + + # Construir o corpo da requisição + es_body = await construir_request_body( + query, + modo_busca, + ordenar_por, + data_inicio, + data_fim, + tipo_diario, + numero_diario, + page, + page_size, + ) + + # Executar a busca + try: + response = await es.search(index="diario_oficial", body=es_body) + except Exception as e: + print(f"Erro ao executar busca no Elasticsearch: {e}") + return {"total": 0, "resultados": [], "pagina": page, "por_pagina": page_size} + finally: + await es.close() + + # Processar e retornar os resultados + return await processar_resultados(response, query, page, page_size) diff --git a/diarios/signals.py b/diarios/signals.py deleted file mode 100644 index ec755b1..0000000 --- a/diarios/signals.py +++ /dev/null @@ -1,15 +0,0 @@ -from django.db.models.signals import post_save, post_delete -from django.dispatch import receiver - - -@receiver(post_save, sender=DiarioOficial) -def update_document(sender, instance, **kwargs): - """Atualizar documento no Elasticsearch quando o objeto for salvo""" - DiarioOficialDocument.update_document(instance) - - -@receiver(post_delete, sender=DiarioOficial) -def delete_document(sender, instance, **kwargs): - """Deletar documento do Elasticsearch quando o objeto for deletado""" - document = DiarioOficialDocument.get(id=instance.id) - document.delete() diff --git a/diarios/templates/diarios/diarios_search.html b/diarios/templates/diarios/diarios_search.html deleted file mode 100644 index f2e91e6..0000000 --- a/diarios/templates/diarios/diarios_search.html +++ /dev/null @@ -1,226 +0,0 @@ -{% extends "base.html" %} - -{% block content %} -
-

Busca de Diários Oficiais

- -
-
-
-
- - -
-
-
- -
-
- - -
-
- - -
-
- -
- - -
-
- - -
-
-
-
- - {% if error %} -
- Erro na pesquisa: {{ error }} -
- {% endif %} - - {% if query %} -
-

Resultados para "{{ query }}"

-

Encontrados {{ total }} resultados

- - {% if did_you_mean %} -
- Você quis dizer: {{ did_you_mean }}? -
- {% endif %} - - {% if search_suggestions %} -
-
Pesquisas relacionadas:
-
- {% for suggestion in search_suggestions %} - {{ suggestion }} - {% endfor %} -
-
- {% endif %} -
- - {% if results %} -
- {% for result in results %} -
-
-
{{ result.tipo }} nº {{ result.numero }}
-

Data: {{ result.data }}

- {% if result.occurrences > 0 %} - {{ result.occurrences }} ocorrências encontradas - {% endif %} -
-
- {% if result.highlight %} -
-
Destaques:
-
{{ result.highlight|safe }}
-
- {% endif %} - - {% if result.highlighted_pages %} -
-
Páginas com o termo buscado:
-
- {% for page in result.highlighted_pages %} -
-

- -

-
-
- {{ page.content|safe }} -
-
-
- {% endfor %} -
-
- {% endif %} - - -
-
- {% endfor %} -
- - - {% if total_pages > 1 %} - - {% endif %} - - {% else %} -
- Nenhum resultado encontrado para a sua busca. - - {% if date_start or date_end %} -

Tente expandir o período de busca ou remover os filtros de data.

- {% endif %} - - {% if match_type == 'exact' %} -

Tente usar a opção "Qualquer palavra" para resultados mais abrangentes.

- {% endif %} -
- {% endif %} - {% endif %} -
- - - - -{% endblock %} - diff --git a/diarios/templates/diarios/search.html b/diarios/templates/diarios/search.html deleted file mode 100644 index c3d91e3..0000000 --- a/diarios/templates/diarios/search.html +++ /dev/null @@ -1,173 +0,0 @@ -{% extends "base.html" %} -{% load static %} - -{% block title %}Busca de Diários Oficiais{% endblock %} - -{% block content %} -
-

Busca de Diários Oficiais

- -
-
-
-
-
- - -
-
- -
-
- - - -
-
-
- -
- {% for tipo in tipos_disponiveis %} -
- - -
- {% endfor %} -
-
- -
- - -
- -
- - -
- -
- - -
- -
-
- - -
-
-
-
-
-
-
- - {% if query %} -
-
-

Resultados da busca

- {{ total }} resultado(s) -
- - {% if results %} - {% for result in results %} -
-
-
- - {{ result.tipo_nome }} nº {{ result.numero }} - {{ result.data|date:"d/m/Y" }} - -
- - {% if result.highlights %} -
- {% for highlight in result.highlights %} -

...{{ highlight|safe }}...

- {% endfor %} -
- {% endif %} - -
- - Relevância: {{ result.score|floatformat:2 }} - - {% if result.link %} - - Ver original - - {% endif %} -
-
-
- {% endfor %} - - {% if pages > 1 %} - - {% endif %} - {% else %} -
-

Nenhum resultado encontrado

-

Não encontramos resultados para "{{ query }}". Tente ajustar seus termos de busca.

-
- {% endif %} -
- {% else %} -
-

Digite um termo de busca para encontrar diários oficiais

-
- {% endif %} -
-{% endblock %} - diff --git a/diarios/tests.py b/diarios/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/diarios/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/diários_oficiais_alems/users/__init__.py b/diarios/tests/__init__.py similarity index 100% rename from diários_oficiais_alems/users/__init__.py rename to diarios/tests/__init__.py diff --git a/diarios/tests/factories.py b/diarios/tests/factories.py new file mode 100644 index 0000000..2d9b8eb --- /dev/null +++ b/diarios/tests/factories.py @@ -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"] \ No newline at end of file diff --git a/diarios/tests/test_models.py b/diarios/tests/test_models.py new file mode 100644 index 0000000..9c16ba1 --- /dev/null +++ b/diarios/tests/test_models.py @@ -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) diff --git a/diarios/urls.py b/diarios/urls.py index 60efb2a..1a5567c 100644 --- a/diarios/urls.py +++ b/diarios/urls.py @@ -2,7 +2,5 @@ from django.urls import path from . import views urlpatterns = [ - path('diario//', views.diario_detail, name='diario_detail'), - path('diarios/search/', views.search_diarios, name='search_diarios'), + path('busca/', views.index, name='index') ] - diff --git a/diarios/views.py b/diarios/views.py index 2d99dee..8e8a278 100644 --- a/diarios/views.py +++ b/diarios/views.py @@ -1,209 +1,88 @@ +from ninja import Router +from typing import Optional +from django.http import HttpRequest +from .search_service import ( + buscar_diarios, + sugestao_termo, + buscar_diarios_simples, +) +from .schemas import BuscaDiariosResponseSchema, SugestaoResponse from django.shortcuts import render -from elasticsearch_dsl import Q -from datetime import datetime -from .documents import DiarioOficialDocument -from elasticsearch.exceptions import RequestError -def search_diarios(request): - q = request.GET.get('q', '') - page = int(request.GET.get('page', 1)) - size = int(request.GET.get('size', 10)) - - # Parâmetros de filtro de data - date_start = request.GET.get('date_start', '') - date_end = request.GET.get('date_end', '') - - # Tipo de correspondência (exata ou parcial) - match_type = request.GET.get('match_type', 'partial') # 'exact' ou 'partial' - start = (page - 1) * size - end = start + size +router = Router(tags=["Diários Oficiais"]) - results = [] - total = 0 - did_you_mean = None - search_suggestions = [] - - try: - if q: - # Construir a consulta base - search = DiarioOficialDocument.search() - - # Determinar o tipo de consulta com base no match_type - if match_type == 'exact': - # Correspondência exata (frase exata) - query = Q( - 'multi_match', - query=q, - fields=['content^3', 'tipo.nome^2', 'numero', 'pages.content'], - type='phrase' - ) - else: - # Correspondência parcial (qualquer termo) - query = Q( - 'multi_match', - query=q, - fields=['content^3', 'tipo.nome^2', 'numero', 'pages.content'], - fuzziness='AUTO', - operator='or' # Pelo menos um termo deve corresponder - ) - - # Aplicar a consulta principal - search = search.query(query) - - # Aplicar filtros de data se fornecidos - date_filters = [] - if date_start: - try: - date_start_obj = datetime.strptime(date_start, '%Y-%m-%d') - date_filters.append(Q('range', data={'gte': date_start_obj})) - except ValueError: - pass # Ignorar datas inválidas - - if date_end: - try: - date_end_obj = datetime.strptime(date_end, '%Y-%m-%d') - date_filters.append(Q('range', data={'lte': date_end_obj})) - except ValueError: - pass # Ignorar datas inválidas - - if date_filters: - for date_filter in date_filters: - search = search.filter(date_filter) - - # Configuração do highlighting - search = search.highlight('content', fragment_size=150, number_of_fragments=3) - search = search.highlight('pages.content', fragment_size=150, number_of_fragments=3) - - # Paginação - total_search = search.count() - search = search[start:end] - - # Executar a pesquisa - response = search.execute() - - total = response.hits.total.value - - # "Você quis dizer" - sugestão para termos com erros de digitação - if total < 3 and q: # Se poucos resultados, sugira correções - suggestion_search = DiarioOficialDocument.search() - suggestion_search = suggestion_search.suggest( - 'phrase_suggestion', - q, - phrase={ - 'field': 'content', - 'size': 5, - 'highlight': { - 'pre_tag': '', - 'post_tag': '' - } - } - ) - suggestion_result = suggestion_search.execute() - - # Processe as sugestões - if hasattr(suggestion_result, 'suggest') and 'phrase_suggestion' in suggestion_result.suggest: - suggestions = suggestion_result.suggest['phrase_suggestion'][0]['options'] - if suggestions: - for suggestion in suggestions: - if suggestion['text'].lower() != q.lower(): - did_you_mean = suggestion['text'] - break - # Gerar sugestões de pesquisa relacionadas - if q: - # Use a expansão de termos para sugerir pesquisas relacionadas - related_search = DiarioOficialDocument.search() - related_search = related_search.query( - 'more_like_this', - fields=['content'], - like=q, - min_term_freq=1, - max_query_terms=12 - ) - related_search = related_search[:5] # Limite para 5 sugestões - - try: - related_results = related_search.execute() - - # Extraia termos relevantes dos resultados relacionados - for hit in related_results: - if hasattr(hit, 'content') and hit.content: - # Extraia alguns termos significativos do conteúdo - content_terms = hit.content.split()[:10] # Primeiros 10 termos - suggestion = ' '.join(content_terms) - if suggestion not in search_suggestions and suggestion != q: - search_suggestions.append(suggestion) - if len(search_suggestions) >= 5: # Limite para 5 sugestões - break - except: - # Ignore erros de sugestões relacionadas - pass - - # Processar resultados - for hit in response: - # Adicionar destaque - highlight = "" - if hasattr(hit.meta, 'highlight'): - if 'content' in hit.meta.highlight: - highlight = "...".join(hit.meta.highlight.content) - - # Processar páginas com destaque - highlighted_pages = [] - total_occurrences = 0 - - if hasattr(hit.meta, 'highlight') and 'pages.content' in hit.meta.highlight: - # Calcular o número total de ocorrências - for content in hit.meta.highlight['pages.content']: - # Contar o número de tags, que representam termos destacados - total_occurrences += content.count('') - - # Processar os destaques por página - for i, content in enumerate(hit.meta.highlight['pages.content']): - # Encontre a página correspondente - page_number = i + 1 # Lógica simplificada, pode precisar de ajuste - highlighted_pages.append({ - 'number': page_number, - 'content': content - }) - - # Combine dados do documento com os destaques - result = { - 'id': hit.id, - 'tipo': hit.tipo.nome if hasattr(hit, 'tipo') and hit.tipo else '', - 'numero': hit.numero, - 'data': hit.data, - 'link': hit.link, - 'highlight': highlight, - 'highlighted_pages': highlighted_pages, - 'occurrences': total_occurrences - } - - results.append(result) - except RequestError as e: - # Tratar erros de consulta do Elasticsearch - error_message = str(e) - return render(request, 'diarios/diarios_search.html', { - 'error': error_message, - 'query': q - }) +async def index(request): + return render(request, 'diarios/busca.html') - context = { - 'query': q, - 'date_start': date_start, - 'date_end': date_end, - 'match_type': match_type, - 'results': results, - 'total': total, - 'page': page, - 'size': size, - 'total_pages': (total + size - 1) // size if total > 0 else 0, - 'did_you_mean': did_you_mean, - 'search_suggestions': search_suggestions[:5] # Limite para 5 sugestões - } +@router.get( + "/sugestao", + response=SugestaoResponse, + summary="Sugestão de correção para termo de busca", +) +async def sugestao_busca(request: HttpRequest, q: str) -> SugestaoResponse: + """ + Sugere correção para o termo buscado, se necessário. - return render(request, 'diarios/diarios_search.html', context) + Args: + request (HttpRequest): Requisição HTTP. + q (str): Termo original digitado pelo usuário. -def diario_detail(request, pk): - diario = get_object_or_404(Diario, pk=pk) - return render(request, 'diarios/diario_detail.html', {'diario': diario}) + Returns: + SugestaoResponse: Termo corrigido. + """ + sugestao = await sugestao_termo(q) + return {"sugestao": sugestao} + + +@router.get( + "/busca", + response=BuscaDiariosResponseSchema, + summary="Busca simplificada com modos e ordenação", +) +async def busca_diarios_oficiais_simples( + request: HttpRequest, + q: Optional[str] = None, + numero_diario: Optional[str] = None, + data_inicio: Optional[str] = None, + data_fim: Optional[str] = None, + tipo: Optional[str] = None, + ordenar_por: str = "relevancia", # "relevancia", "data_asc", "data_desc" + modo_busca: str = "exata", # "exata" ou "qualquer" + page: int = 1, + page_size: int = 10, +) -> BuscaDiariosResponseSchema: + """ + Busca com modo de correspondência, ordenação e número do diário. + + Args: + request (HttpRequest): Requisição HTTP. + q (Optional[str]): Termo de busca. + numero_diario (Optional[str]): Número exato do diário (ex: 1234/2024). + data_inicio (Optional[str]): Data inicial (YYYY-MM-DD). + data_fim (Optional[str]): Data final (YYYY-MM-DD). + tipo (Optional[str]): Tipo exato do diário. + ordenar_por (str): "relevancia", "data_asc" ou "data_desc". + modo_busca (str): "exata" ou "qualquer". + page (int): Página atual (mínimo: 1). + page_size (int): Itens por página (mínimo: 1, máximo: 50). + + Returns: + BuscaDiariosResponseSchema: Resultado paginado da busca. + """ + page_size = min(max(page_size, 1), 50) + page = max(page, 1) + + resultado = await buscar_diarios_simples( + query=q, + numero_diario=numero_diario, + data_inicio=data_inicio, + data_fim=data_fim, + tipo_diario=tipo, + ordenar_por=ordenar_por, + modo_busca=modo_busca, + page=page, + page_size=page_size, + ) + return resultado diff --git a/diários_oficiais_alems/__init__.py b/diarios_oficiais_alems/__init__.py similarity index 100% rename from diários_oficiais_alems/__init__.py rename to diarios_oficiais_alems/__init__.py diff --git a/diários_oficiais_alems/conftest.py b/diarios_oficiais_alems/conftest.py similarity index 61% rename from diários_oficiais_alems/conftest.py rename to diarios_oficiais_alems/conftest.py index 0961a8f..6f256dc 100644 --- a/diários_oficiais_alems/conftest.py +++ b/diarios_oficiais_alems/conftest.py @@ -1,7 +1,7 @@ import pytest -from diários_oficiais_alems.users.models import User -from diários_oficiais_alems.users.tests.factories import UserFactory +from diarios_oficiais_alems.users.models import User +from diarios_oficiais_alems.users.tests.factories import UserFactory @pytest.fixture(autouse=True) diff --git a/diários_oficiais_alems/contrib/__init__.py b/diarios_oficiais_alems/contrib/__init__.py similarity index 100% rename from diários_oficiais_alems/contrib/__init__.py rename to diarios_oficiais_alems/contrib/__init__.py diff --git a/diários_oficiais_alems/contrib/sites/__init__.py b/diarios_oficiais_alems/contrib/sites/__init__.py similarity index 100% rename from diários_oficiais_alems/contrib/sites/__init__.py rename to diarios_oficiais_alems/contrib/sites/__init__.py diff --git a/diários_oficiais_alems/contrib/sites/migrations/0001_initial.py b/diarios_oficiais_alems/contrib/sites/migrations/0001_initial.py similarity index 100% rename from diários_oficiais_alems/contrib/sites/migrations/0001_initial.py rename to diarios_oficiais_alems/contrib/sites/migrations/0001_initial.py diff --git a/diários_oficiais_alems/contrib/sites/migrations/0002_alter_domain_unique.py b/diarios_oficiais_alems/contrib/sites/migrations/0002_alter_domain_unique.py similarity index 100% rename from diários_oficiais_alems/contrib/sites/migrations/0002_alter_domain_unique.py rename to diarios_oficiais_alems/contrib/sites/migrations/0002_alter_domain_unique.py diff --git a/diários_oficiais_alems/contrib/sites/migrations/0003_set_site_domain_and_name.py b/diarios_oficiais_alems/contrib/sites/migrations/0003_set_site_domain_and_name.py similarity index 100% rename from diários_oficiais_alems/contrib/sites/migrations/0003_set_site_domain_and_name.py rename to diarios_oficiais_alems/contrib/sites/migrations/0003_set_site_domain_and_name.py diff --git a/diários_oficiais_alems/contrib/sites/migrations/0004_alter_options_ordering_domain.py b/diarios_oficiais_alems/contrib/sites/migrations/0004_alter_options_ordering_domain.py similarity index 100% rename from diários_oficiais_alems/contrib/sites/migrations/0004_alter_options_ordering_domain.py rename to diarios_oficiais_alems/contrib/sites/migrations/0004_alter_options_ordering_domain.py diff --git a/diários_oficiais_alems/contrib/sites/migrations/__init__.py b/diarios_oficiais_alems/contrib/sites/migrations/__init__.py similarity index 100% rename from diários_oficiais_alems/contrib/sites/migrations/__init__.py rename to diarios_oficiais_alems/contrib/sites/migrations/__init__.py diff --git a/diários_oficiais_alems/static/css/project.css b/diarios_oficiais_alems/static/css/project.css similarity index 100% rename from diários_oficiais_alems/static/css/project.css rename to diarios_oficiais_alems/static/css/project.css diff --git a/diários_oficiais_alems/static/fonts/.gitkeep b/diarios_oficiais_alems/static/fonts/.gitkeep similarity index 100% rename from diários_oficiais_alems/static/fonts/.gitkeep rename to diarios_oficiais_alems/static/fonts/.gitkeep diff --git a/diários_oficiais_alems/static/images/favicons/favicon.ico b/diarios_oficiais_alems/static/images/favicons/favicon.ico similarity index 100% rename from diários_oficiais_alems/static/images/favicons/favicon.ico rename to diarios_oficiais_alems/static/images/favicons/favicon.ico diff --git a/diários_oficiais_alems/static/js/project.js b/diarios_oficiais_alems/static/js/project.js similarity index 100% rename from diários_oficiais_alems/static/js/project.js rename to diarios_oficiais_alems/static/js/project.js diff --git a/diários_oficiais_alems/templates/403.html b/diarios_oficiais_alems/templates/403.html similarity index 100% rename from diários_oficiais_alems/templates/403.html rename to diarios_oficiais_alems/templates/403.html diff --git a/diários_oficiais_alems/templates/403_csrf.html b/diarios_oficiais_alems/templates/403_csrf.html similarity index 100% rename from diários_oficiais_alems/templates/403_csrf.html rename to diarios_oficiais_alems/templates/403_csrf.html diff --git a/diários_oficiais_alems/templates/404.html b/diarios_oficiais_alems/templates/404.html similarity index 100% rename from diários_oficiais_alems/templates/404.html rename to diarios_oficiais_alems/templates/404.html diff --git a/diários_oficiais_alems/templates/500.html b/diarios_oficiais_alems/templates/500.html similarity index 100% rename from diários_oficiais_alems/templates/500.html rename to diarios_oficiais_alems/templates/500.html diff --git a/diários_oficiais_alems/templates/account/base_manage_password.html b/diarios_oficiais_alems/templates/account/base_manage_password.html similarity index 100% rename from diários_oficiais_alems/templates/account/base_manage_password.html rename to diarios_oficiais_alems/templates/account/base_manage_password.html diff --git a/diários_oficiais_alems/templates/allauth/elements/alert.html b/diarios_oficiais_alems/templates/allauth/elements/alert.html similarity index 100% rename from diários_oficiais_alems/templates/allauth/elements/alert.html rename to diarios_oficiais_alems/templates/allauth/elements/alert.html diff --git a/diários_oficiais_alems/templates/allauth/elements/badge.html b/diarios_oficiais_alems/templates/allauth/elements/badge.html similarity index 100% rename from diários_oficiais_alems/templates/allauth/elements/badge.html rename to diarios_oficiais_alems/templates/allauth/elements/badge.html diff --git a/diários_oficiais_alems/templates/allauth/elements/button.html b/diarios_oficiais_alems/templates/allauth/elements/button.html similarity index 100% rename from diários_oficiais_alems/templates/allauth/elements/button.html rename to diarios_oficiais_alems/templates/allauth/elements/button.html diff --git a/diários_oficiais_alems/templates/allauth/elements/field.html b/diarios_oficiais_alems/templates/allauth/elements/field.html similarity index 100% rename from diários_oficiais_alems/templates/allauth/elements/field.html rename to diarios_oficiais_alems/templates/allauth/elements/field.html diff --git a/diários_oficiais_alems/templates/allauth/elements/fields.html b/diarios_oficiais_alems/templates/allauth/elements/fields.html similarity index 100% rename from diários_oficiais_alems/templates/allauth/elements/fields.html rename to diarios_oficiais_alems/templates/allauth/elements/fields.html diff --git a/diários_oficiais_alems/templates/allauth/elements/panel.html b/diarios_oficiais_alems/templates/allauth/elements/panel.html similarity index 100% rename from diários_oficiais_alems/templates/allauth/elements/panel.html rename to diarios_oficiais_alems/templates/allauth/elements/panel.html diff --git a/diários_oficiais_alems/templates/allauth/elements/table.html b/diarios_oficiais_alems/templates/allauth/elements/table.html similarity index 100% rename from diários_oficiais_alems/templates/allauth/elements/table.html rename to diarios_oficiais_alems/templates/allauth/elements/table.html diff --git a/diários_oficiais_alems/templates/allauth/layouts/entrance.html b/diarios_oficiais_alems/templates/allauth/layouts/entrance.html similarity index 100% rename from diários_oficiais_alems/templates/allauth/layouts/entrance.html rename to diarios_oficiais_alems/templates/allauth/layouts/entrance.html diff --git a/diários_oficiais_alems/templates/allauth/layouts/manage.html b/diarios_oficiais_alems/templates/allauth/layouts/manage.html similarity index 100% rename from diários_oficiais_alems/templates/allauth/layouts/manage.html rename to diarios_oficiais_alems/templates/allauth/layouts/manage.html diff --git a/diários_oficiais_alems/templates/base.html b/diarios_oficiais_alems/templates/base.html similarity index 99% rename from diários_oficiais_alems/templates/base.html rename to diarios_oficiais_alems/templates/base.html index c743df7..680074f 100644 --- a/diários_oficiais_alems/templates/base.html +++ b/diarios_oficiais_alems/templates/base.html @@ -107,7 +107,7 @@ -
+
{% if messages %} {% for message in messages %}
diff --git a/diarios_oficiais_alems/templates/diarios/index.html b/diarios_oficiais_alems/templates/diarios/index.html new file mode 100644 index 0000000..2711f1a --- /dev/null +++ b/diarios_oficiais_alems/templates/diarios/index.html @@ -0,0 +1,301 @@ + + + + + + Sistema de Busca de Diários Oficiais + + + + + + + + + + +
+
+
+
+

+ Sistema de Busca de Diários Oficiais +

+
+ +
+
+
+
+
+ + + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+ + + +
+
+
+
+
+ +
+ +
+ +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+
+
+ + + +
+ + + + + +
+
+
+
+ + +
+
+
+
+
Sistema de Busca de Diários Oficiais
+

Uma ferramenta avançada para pesquisa em diários oficiais.

+
+
+

© 2025 Todos os direitos reservados

+
+
+
+
+ + + + + + + \ No newline at end of file diff --git a/diários_oficiais_alems/templates/pages/about.html b/diarios_oficiais_alems/templates/pages/about.html similarity index 100% rename from diários_oficiais_alems/templates/pages/about.html rename to diarios_oficiais_alems/templates/pages/about.html diff --git a/diários_oficiais_alems/templates/pages/home.html b/diarios_oficiais_alems/templates/pages/home.html similarity index 100% rename from diários_oficiais_alems/templates/pages/home.html rename to diarios_oficiais_alems/templates/pages/home.html diff --git a/diários_oficiais_alems/templates/users/user_detail.html b/diarios_oficiais_alems/templates/users/user_detail.html similarity index 100% rename from diários_oficiais_alems/templates/users/user_detail.html rename to diarios_oficiais_alems/templates/users/user_detail.html diff --git a/diários_oficiais_alems/templates/users/user_form.html b/diarios_oficiais_alems/templates/users/user_form.html similarity index 100% rename from diários_oficiais_alems/templates/users/user_form.html rename to diarios_oficiais_alems/templates/users/user_form.html diff --git a/diários_oficiais_alems/users/migrations/__init__.py b/diarios_oficiais_alems/users/__init__.py similarity index 100% rename from diários_oficiais_alems/users/migrations/__init__.py rename to diarios_oficiais_alems/users/__init__.py diff --git a/diários_oficiais_alems/users/adapters.py b/diarios_oficiais_alems/users/adapters.py similarity index 96% rename from diários_oficiais_alems/users/adapters.py rename to diarios_oficiais_alems/users/adapters.py index 2d8cd23..c60310f 100644 --- a/diários_oficiais_alems/users/adapters.py +++ b/diarios_oficiais_alems/users/adapters.py @@ -10,7 +10,7 @@ if typing.TYPE_CHECKING: from allauth.socialaccount.models import SocialLogin from django.http import HttpRequest - from diários_oficiais_alems.users.models import User + from diarios_oficiais_alems.users.models import User class AccountAdapter(DefaultAccountAdapter): diff --git a/diários_oficiais_alems/users/admin.py b/diarios_oficiais_alems/users/admin.py similarity index 100% rename from diários_oficiais_alems/users/admin.py rename to diarios_oficiais_alems/users/admin.py diff --git a/diários_oficiais_alems/users/apps.py b/diarios_oficiais_alems/users/apps.py similarity index 67% rename from diários_oficiais_alems/users/apps.py rename to diarios_oficiais_alems/users/apps.py index e8bb812..c8ee9ba 100644 --- a/diários_oficiais_alems/users/apps.py +++ b/diarios_oficiais_alems/users/apps.py @@ -5,9 +5,9 @@ from django.utils.translation import gettext_lazy as _ class UsersConfig(AppConfig): - name = "diários_oficiais_alems.users" + name = "diarios_oficiais_alems.users" verbose_name = _("Users") def ready(self): with contextlib.suppress(ImportError): - import diários_oficiais_alems.users.signals # noqa: F401 + import diarios_oficiais_alems.users.signals # noqa: F401 diff --git a/diários_oficiais_alems/users/context_processors.py b/diarios_oficiais_alems/users/context_processors.py similarity index 100% rename from diários_oficiais_alems/users/context_processors.py rename to diarios_oficiais_alems/users/context_processors.py diff --git a/diários_oficiais_alems/users/forms.py b/diarios_oficiais_alems/users/forms.py similarity index 100% rename from diários_oficiais_alems/users/forms.py rename to diarios_oficiais_alems/users/forms.py diff --git a/diários_oficiais_alems/users/migrations/0001_initial.py b/diarios_oficiais_alems/users/migrations/0001_initial.py similarity index 99% rename from diários_oficiais_alems/users/migrations/0001_initial.py rename to diarios_oficiais_alems/users/migrations/0001_initial.py index 073ee0d..749f3fb 100644 --- a/diários_oficiais_alems/users/migrations/0001_initial.py +++ b/diarios_oficiais_alems/users/migrations/0001_initial.py @@ -4,8 +4,6 @@ import django.utils.timezone from django.db import migrations from django.db import models -import diários_oficiais_alems.users.models - class Migration(migrations.Migration): diff --git a/diários_oficiais_alems/users/tests/__init__.py b/diarios_oficiais_alems/users/migrations/__init__.py similarity index 100% rename from diários_oficiais_alems/users/tests/__init__.py rename to diarios_oficiais_alems/users/migrations/__init__.py diff --git a/diários_oficiais_alems/users/models.py b/diarios_oficiais_alems/users/models.py similarity index 100% rename from diários_oficiais_alems/users/models.py rename to diarios_oficiais_alems/users/models.py diff --git a/synonyms.txt b/diarios_oficiais_alems/users/tests/__init__.py similarity index 100% rename from synonyms.txt rename to diarios_oficiais_alems/users/tests/__init__.py diff --git a/diários_oficiais_alems/users/tests/factories.py b/diarios_oficiais_alems/users/tests/factories.py similarity index 95% rename from diários_oficiais_alems/users/tests/factories.py rename to diarios_oficiais_alems/users/tests/factories.py index f7b49f6..c6b553e 100644 --- a/diários_oficiais_alems/users/tests/factories.py +++ b/diarios_oficiais_alems/users/tests/factories.py @@ -5,7 +5,7 @@ from factory import Faker from factory import post_generation from factory.django import DjangoModelFactory -from diários_oficiais_alems.users.models import User +from diarios_oficiais_alems.users.models import User class UserFactory(DjangoModelFactory[User]): diff --git a/diários_oficiais_alems/users/tests/test_admin.py b/diarios_oficiais_alems/users/tests/test_admin.py similarity index 94% rename from diários_oficiais_alems/users/tests/test_admin.py rename to diarios_oficiais_alems/users/tests/test_admin.py index ecff91f..8646a8a 100644 --- a/diários_oficiais_alems/users/tests/test_admin.py +++ b/diarios_oficiais_alems/users/tests/test_admin.py @@ -8,7 +8,7 @@ from django.contrib.auth.models import AnonymousUser from django.urls import reverse from pytest_django.asserts import assertRedirects -from diários_oficiais_alems.users.models import User +from diarios_oficiais_alems.users.models import User class TestUserAdmin: @@ -48,7 +48,7 @@ class TestUserAdmin: def _force_allauth(self, settings): settings.DJANGO_ADMIN_FORCE_ALLAUTH = True # Reload the admin module to apply the setting change - import diários_oficiais_alems.users.admin as users_admin + import diarios_oficiais_alems.users.admin as users_admin with contextlib.suppress(admin.sites.AlreadyRegistered): # type: ignore[attr-defined] reload(users_admin) diff --git a/diários_oficiais_alems/users/tests/test_forms.py b/diarios_oficiais_alems/users/tests/test_forms.py similarity index 89% rename from diários_oficiais_alems/users/tests/test_forms.py rename to diarios_oficiais_alems/users/tests/test_forms.py index 017f542..cac3292 100644 --- a/diários_oficiais_alems/users/tests/test_forms.py +++ b/diarios_oficiais_alems/users/tests/test_forms.py @@ -2,8 +2,8 @@ from django.utils.translation import gettext_lazy as _ -from diários_oficiais_alems.users.forms import UserAdminCreationForm -from diários_oficiais_alems.users.models import User +from diarios_oficiais_alems.users.forms import UserAdminCreationForm +from diarios_oficiais_alems.users.models import User class TestUserAdminCreationForm: diff --git a/diários_oficiais_alems/users/tests/test_models.py b/diarios_oficiais_alems/users/tests/test_models.py similarity index 67% rename from diários_oficiais_alems/users/tests/test_models.py rename to diarios_oficiais_alems/users/tests/test_models.py index fb88cbe..b43680c 100644 --- a/diários_oficiais_alems/users/tests/test_models.py +++ b/diarios_oficiais_alems/users/tests/test_models.py @@ -1,4 +1,4 @@ -from diários_oficiais_alems.users.models import User +from diarios_oficiais_alems.users.models import User def test_user_get_absolute_url(user: User): diff --git a/diários_oficiais_alems/users/tests/test_urls.py b/diarios_oficiais_alems/users/tests/test_urls.py similarity index 91% rename from diários_oficiais_alems/users/tests/test_urls.py rename to diarios_oficiais_alems/users/tests/test_urls.py index 5dc0d6d..57d6279 100644 --- a/diários_oficiais_alems/users/tests/test_urls.py +++ b/diarios_oficiais_alems/users/tests/test_urls.py @@ -1,7 +1,7 @@ from django.urls import resolve from django.urls import reverse -from diários_oficiais_alems.users.models import User +from diarios_oficiais_alems.users.models import User def test_detail(user: User): diff --git a/diários_oficiais_alems/users/tests/test_views.py b/diarios_oficiais_alems/users/tests/test_views.py similarity index 76% rename from diários_oficiais_alems/users/tests/test_views.py rename to diarios_oficiais_alems/users/tests/test_views.py index 147636b..f1cf0b3 100644 --- a/diários_oficiais_alems/users/tests/test_views.py +++ b/diarios_oficiais_alems/users/tests/test_views.py @@ -12,12 +12,12 @@ from django.test import RequestFactory from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from diários_oficiais_alems.users.forms import UserAdminChangeForm -from diários_oficiais_alems.users.models import User -from diários_oficiais_alems.users.tests.factories import UserFactory -from diários_oficiais_alems.users.views import UserRedirectView -from diários_oficiais_alems.users.views import UserUpdateView -from diários_oficiais_alems.users.views import user_detail_view +from diarios_oficiais_alems.users.forms import UserAdminChangeForm +from diarios_oficiais_alems.users.models import User +from diarios_oficiais_alems.users.tests.factories import UserFactory +from diarios_oficiais_alems.users.views import UserRedirectView +from diarios_oficiais_alems.users.views import UserUpdateView +from diarios_oficiais_alems.users.views import user_detail_view pytestmark = pytest.mark.django_db @@ -90,12 +90,12 @@ class TestUserDetailView: assert response.status_code == HTTPStatus.OK - def test_not_authenticated(self, user: User, rf: RequestFactory): - request = rf.get("/fake-url/") - request.user = AnonymousUser() - response = user_detail_view(request, username=user.username) - login_url = reverse(settings.LOGIN_URL) + #def test_not_authenticated(self, user: User, rf: RequestFactory): + # request = rf.get("/fake-url/") + # request.user = AnonymousUser() + # response = user_detail_view(request, username=user.username) + # login_url = reverse(settings.LOGIN_URL) - assert isinstance(response, HttpResponseRedirect) - assert response.status_code == HTTPStatus.FOUND - assert response.url == f"{login_url}?next=/fake-url/" + # assert isinstance(response, HttpResponseRedirect) + # assert response.status_code == HTTPStatus.FOUND + # assert response.url == f"{login_url}?next=/fake-url/" diff --git a/diários_oficiais_alems/users/urls.py b/diarios_oficiais_alems/users/urls.py similarity index 100% rename from diários_oficiais_alems/users/urls.py rename to diarios_oficiais_alems/users/urls.py diff --git a/diários_oficiais_alems/users/views.py b/diarios_oficiais_alems/users/views.py similarity index 96% rename from diários_oficiais_alems/users/views.py rename to diarios_oficiais_alems/users/views.py index a2c815c..a643f6b 100644 --- a/diários_oficiais_alems/users/views.py +++ b/diarios_oficiais_alems/users/views.py @@ -7,7 +7,7 @@ from django.views.generic import DetailView from django.views.generic import RedirectView from django.views.generic import UpdateView -from diários_oficiais_alems.users.models import User +from diarios_oficiais_alems.users.models import User class UserDetailView(LoginRequiredMixin, DetailView): diff --git a/docker-compose.docs.yml b/docker-compose.docs.yml deleted file mode 100644 index 7b0e88a..0000000 --- a/docker-compose.docs.yml +++ /dev/null @@ -1,16 +0,0 @@ -services: - docs: - image: diários_oficiais_alems_local_docs - container_name: diários_oficiais_alems_local_docs - build: - context: . - dockerfile: ./compose/local/docs/Dockerfile - env_file: - - ./.envs/.local/.django - volumes: - - ./docs:/docs:z - - ./config:/app/config:z - - ./diários_oficiais_alems:/app/diários_oficiais_alems:z - ports: - - '9000:9000' - command: /start-docs diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 81f388b..bcdcc0e 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -4,15 +4,6 @@ volumes: esdata: services: - elasticsearch: - image: docker.elastic.co/elasticsearch/elasticsearch:7.10.0 - environment: - - discovery.type=single-node - ports: - - "9200:9200" - volumes: - - esdata:/usr/share/elasticsearch/data - django: build: context: . @@ -46,12 +37,15 @@ services: image: docker.elastic.co/elasticsearch/elasticsearch:8.17.3 environment: - discovery.type=single-node - environment: - - discovery.type=single-node - - xpack.security.enabled=false - - "ES_JAVA_OPTS=-Xms512m -Xmx512m" - ports: - - "9200:9200" + - xpack.security.enabled=true + - "ES_JAVA_OPTS=-Xms1g -Xmx1g" volumes: - esdata:/usr/share/elasticsearch/data + frontend: + image: nginx:latest + container_name: frontend_diarios + volumes: + - ./frontend:/usr/share/nginx/html:ro + ports: + - "8006:80" diff --git a/docker-compose.production.yml b/docker-compose.production.yml index d91e23a..e406282 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -3,7 +3,6 @@ volumes: production_postgres_data_backups: {} production_traefik: {} production_django_media: {} - services: @@ -12,9 +11,9 @@ services: context: . dockerfile: ./compose/production/django/Dockerfile - image: diários_oficiais_alems_production_django + image: diarios_oficiais_alems_production_django volumes: - - production_django_media:/app/diários_oficiais_alems/media + - production_django_media:/app/diarios_oficiais_alems/media depends_on: - postgres - redis @@ -27,7 +26,7 @@ services: build: context: . dockerfile: ./compose/production/postgres/Dockerfile - image: diários_oficiais_alems_production_postgres + image: diarios_oficiais_alems_production_postgres volumes: - production_postgres_data:/var/lib/postgresql/data - production_postgres_data_backups:/backups @@ -38,7 +37,7 @@ services: build: context: . dockerfile: ./compose/production/traefik/Dockerfile - image: diários_oficiais_alems_production_traefik + image: diarios_oficiais_alems_production_traefik depends_on: - django volumes: @@ -49,13 +48,12 @@ services: redis: image: docker.io/redis:6 - nginx: build: context: . dockerfile: ./compose/production/nginx/Dockerfile - image: diários_oficiais_alems_production_nginx + image: diarios_oficiais_alems_production_nginx depends_on: - django volumes: diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index 67e63cb..0000000 --- a/docs/Makefile +++ /dev/null @@ -1,29 +0,0 @@ -# Minimal makefile for Sphinx documentation -# - -# You can set these variables from the command line, and also -# from the environment for the first two. -SPHINXOPTS ?= -SPHINXBUILD ?= sphinx-build -SOURCEDIR = . -BUILDDIR = ./_build -APP = /app - -.PHONY: html livehtml apidocs Makefile - -# Put it first so that "make" without argument is like "make html". -html: - @$(SPHINXBUILD) -M html "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -c . - -# Build, watch and serve docs with live reload -livehtml: - sphinx-autobuild -b html --host 0.0.0.0 --port 9000 --watch $(APP) -c . $(SOURCEDIR) $(BUILDDIR)/html - -# Outputs rst files from django application code -apidocs: - sphinx-apidoc -o $(SOURCEDIR)/api $(APP) - -# Catch-all target: route all unknown targets to Sphinx using the new -# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). -%: Makefile - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -c . diff --git a/docs/__init__.py b/docs/__init__.py deleted file mode 100644 index 8772c82..0000000 --- a/docs/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Included so that Django's startproject comment runs against the docs directory diff --git a/docs/conf.py b/docs/conf.py deleted file mode 100644 index 8dae148..0000000 --- a/docs/conf.py +++ /dev/null @@ -1,63 +0,0 @@ -# ruff: noqa -# Configuration file for the Sphinx documentation builder. -# -# This file only contains a selection of the most common options. For a full -# list see the documentation: -# https://www.sphinx-doc.org/en/master/usage/configuration.html - -# -- Path setup -------------------------------------------------------------- - -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. - -import os -import sys -import django - -if os.getenv("READTHEDOCS", default=False) == "True": - sys.path.insert(0, os.path.abspath("..")) - os.environ["DJANGO_READ_DOT_ENV_FILE"] = "True" - os.environ["USE_DOCKER"] = "no" -else: - sys.path.insert(0, os.path.abspath("/app")) -os.environ["DATABASE_URL"] = "sqlite:///readthedocs.db" -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local") -django.setup() - -# -- Project information ----------------------------------------------------- - -project = "Diários Oficiais ALEMS" -copyright = """2025, Antonio Roberto""" -author = "Antonio Roberto" - - -# -- General configuration --------------------------------------------------- - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. -extensions = [ - "sphinx.ext.autodoc", - "sphinx.ext.napoleon", -] - -# Add any paths that contain templates here, relative to this directory. -# templates_path = ["_templates"] - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -# This pattern also affects html_static_path and html_extra_path. -exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] - -# -- Options for HTML output ------------------------------------------------- - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -# -html_theme = "alabaster" - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -# html_static_path = ["_static"] diff --git a/docs/howto.rst b/docs/howto.rst deleted file mode 100644 index bdeef81..0000000 --- a/docs/howto.rst +++ /dev/null @@ -1,38 +0,0 @@ -How To - Project Documentation -====================================================================== - -Get Started ----------------------------------------------------------------------- - -Documentation can be written as rst files in `diários_oficiais_alems/docs`. - - -To build and serve docs, use the commands:: - - docker compose -f docker-compose.local.yml up docs - - - -Changes to files in `docs/_source` will be picked up and reloaded automatically. - -`Sphinx `_ is the tool used to build documentation. - -Docstrings to Documentation ----------------------------------------------------------------------- - -The sphinx extension `apidoc `_ is used to automatically document code using signatures and docstrings. - -Numpy or Google style docstrings will be picked up from project files and available for documentation. See the `Napoleon `_ extension for details. - -For an in-use example, see the `page source <_sources/users.rst.txt>`_ for :ref:`users`. - -To compile all docstrings automatically into documentation source files, use the command: - :: - - make apidocs - - -This can be done in the docker container: - :: - - docker run --rm docs make apidocs diff --git a/docs/index.rst b/docs/index.rst deleted file mode 100644 index c652b4a..0000000 --- a/docs/index.rst +++ /dev/null @@ -1,23 +0,0 @@ -.. Diários Oficiais ALEMS documentation master file, created by - sphinx-quickstart. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. - -Welcome to Diários Oficiais ALEMS's documentation! -====================================================================== - -.. toctree:: - :maxdepth: 2 - :caption: Contents: - - howto - users - - - -Indices and tables -================== - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` diff --git a/docs/make.bat b/docs/make.bat deleted file mode 100644 index 1af11ae..0000000 --- a/docs/make.bat +++ /dev/null @@ -1,46 +0,0 @@ -@ECHO OFF - -pushd %~dp0 - -REM Command file for Sphinx documentation - - -if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=sphinx-build -c . -) -set SOURCEDIR=_source -set BUILDDIR=_build -set APP=..\diários_oficiais_alems - -if "%1" == "" goto html - -%SPHINXBUILD% >NUL 2>NUL -if errorlevel 9009 ( - echo. - echo.The 'sphinx-build' command was not found. Make sure you have Sphinx - echo.installed, then set the SPHINXBUILD environment variable to point - echo.to the full path of the 'sphinx-build' executable. Alternatively you - echo.may add the Sphinx directory to PATH. - echo. - echo.Install sphinx-autobuild for live serving. - echo.If you don't have Sphinx installed, grab it from - echo.http://sphinx-doc.org/ - exit /b 1 -) - -%SPHINXBUILD% -b %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% -goto end - -:livehtml -sphinx-autobuild -b html --open-browser -p 9000 --watch %APP% -c . %SOURCEDIR% %BUILDDIR%/html -GOTO :EOF - -:apidocs -sphinx-apidoc -o %SOURCEDIR%/api %APP% -GOTO :EOF - -:html -%SPHINXBUILD% -b html %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% - -:end -popd diff --git a/docs/users.rst b/docs/users.rst deleted file mode 100644 index 0656d79..0000000 --- a/docs/users.rst +++ /dev/null @@ -1,15 +0,0 @@ - .. _users: - -Users -====================================================================== - -Starting a new project, it’s highly recommended to set up a custom user model, -even if the default User model is sufficient for you. - -This model behaves identically to the default user model, -but you’ll be able to customize it in the future if the need arises. - -.. automodule:: diários_oficiais_alems.users.models - :members: - :noindex: - diff --git a/frontend/css/style.css b/frontend/css/style.css new file mode 100644 index 0000000..93ccc3d --- /dev/null +++ b/frontend/css/style.css @@ -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; + } +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..e7909ba --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,303 @@ + + + + + + Sistema de Busca de Diários Oficiais + + + + + + + + + + +
+
+
+
+

+ Sistema de Busca de Diários Oficiais +

+
+ +
+
+
+
+
+ + + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+ + + +
+
+
+
+
+ +
+ +
+ +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+
+
+ + + +
+ + + + + +
+
+
+
+ + +
+
+
+
+
Sistema de Busca de Diários Oficiais
+

Uma ferramenta avançada para pesquisa em diários oficiais.

+
+
+

© 2025 Todos os direitos reservados

+
+
+
+
+ + + + + + + + + diff --git a/frontend/js/config.js b/frontend/js/config.js new file mode 100644 index 0000000..87614fc --- /dev/null +++ b/frontend/js/config.js @@ -0,0 +1 @@ +const API_BASE_URL = "http://109.199.98.226:8005"; \ No newline at end of file diff --git a/frontend/js/script.js b/frontend/js/script.js new file mode 100644 index 0000000..8c8359b --- /dev/null +++ b/frontend/js/script.js @@ -0,0 +1,242 @@ +document.addEventListener('alpine:init', () => { + Alpine.data('searchApp', () => ({ + searchParams: { + q: '', + numero_diario: '', + data_inicio: '', + data_fim: '', + modo_busca: 'exata', + ordenar_por: 'data_asc', + page: 1, + page_size: 10 + }, + searchResults: null, + isLoading: false, + hasSearched: false, + error: null, + showAdvanced: false, + expandedContents: {}, + suggestion: null, + ultimoTermoBuscado: '', + + get shouldShowSuggestion() { + if (!this.suggestion || !this.searchParams.q) return false; + + // Função para remover acentos e converter para minúsculas + const normalize = (text) => { + return text.toLowerCase() + .normalize('NFD') + .replace("/", " ") + .replace(/[^a-z0-9\s]/g, " ") // Substitui todos os outros símbolos por espaço + .replace(/[\u0300-\u036f]/g, ''); + }; + + const normalizedQuery = normalize(this.searchParams.q); + const normalizedSuggestion = normalize(this.suggestion); + + // Só mostra a sugestão se for diferente do termo buscado (ignorando acentos e caixa) + return normalizedQuery !== normalizedSuggestion; + }, + + // Usa a sugestão como novo termo de busca + usesuggestion() { + this.searchParams.q = this.suggestion; + this.searchParams.page = 1; + this.performSearch(); + }, + + // Verifica se um diário tem uma melhor correspondência + hasBestMatch(diario) { + return diario.paginas && diario.paginas.length > 0; + }, + + // Muda a ordenação e faz uma nova busca + changeOrder(order) { + if (this.searchParams.ordenar_por !== order) { + this.searchParams.ordenar_por = order; + this.searchParams.page = 1; // Volta para a primeira página + this.performSearch(); + } + }, + + // Obter sugestão da API + async getSuggestion(query) { + if (!query) return null; + + try { + const url = new URL('http://109.199.98.226:8005/api/v1/diarios/sugestao'); + url.searchParams.append('q', query); + + const response = await fetch(url); + + if (response.ok) { + const data = await response.json(); + return data.sugestao; + } + return null; + } catch (error) { + console.error('Erro ao buscar sugestão:', error); + return null; + } + }, + + get totalPages() { + if (!this.searchResults) return 0; + return Math.ceil(this.searchResults.total / this.searchResults.por_pagina); + }, + + get paginationArray() { + const pages = []; + const currentPage = this.searchParams.page; + const totalPages = this.totalPages; + + // Função auxiliar para adicionar páginas + const addPage = (page) => { + if (page >= 1 && page <= totalPages && !pages.includes(page)) { + pages.push(page); + } + }; + + // Sempre mostrar primeira página, página atual, última página + // e 1-2 páginas adjacentes à página atual + addPage(1); + addPage(currentPage - 2); + addPage(currentPage - 1); + addPage(currentPage); + addPage(currentPage + 1); + addPage(currentPage + 2); + addPage(totalPages); + + // Ordenar e adicionar separadores + const result = pages.sort((a, b) => a - b); + + return result; + }, + + // Retorna a melhor página (maior score) de um diário + getBestPage(paginas) { + if (!paginas || paginas.length === 0) return null; + + // Ordena as páginas por score (se disponível) ou pelo número da página se não houver score + const sortedPages = [...paginas].sort((a, b) => { + if (a.score === undefined || b.score === undefined) return 0; + if (a.score === undefined) return 1; + if (b.score === undefined) return -1; + return b.score - a.score; + }); + + return sortedPages[0]; + }, + + // Retorna todas as páginas exceto a melhor + getOtherPages(paginas) { + if (!paginas || paginas.length <= 1) return []; + + const bestPage = this.getBestPage(paginas); + if (!bestPage) return paginas; + + return paginas.filter(p => p.numero !== bestPage.numero) + .sort((a, b) => a.numero - b.numero); // Ordena por número da página + }, + + // Controla a exibição do conteúdo completo de uma página + toggleFullContent(diarioIndex, paginaNumero) { + const key = `${diarioIndex}-${paginaNumero}`; + this.expandedContents[key] = !this.expandedContents[key]; + }, + + // Verifica se um conteúdo está expandido + isFullContentVisible(diarioIndex, paginaNumero) { + const key = `${diarioIndex}-${paginaNumero}`; + return this.expandedContents[key] === true; + }, + + async performSearch() { + this.isLoading = true; + this.error = null; + this.hasSearched = true; + this.expandedContents = {}; // Resetar estados expandidos + this.suggestion = null; // Resetar a sugestão + + try { + let suggestionPromise = null; + if (this.searchParams.q) { + suggestionPromise = this.getSuggestion(this.searchParams.q); + } + if (this.searchParams.q !== this.ultimoTermoBuscado) { + this.searchParams.page = 1; + } + this.ultimoTermoBuscado = this.searchParams.q; + // Usando agora o endpoint busca + const url = new URL('http://109.199.98.226:8005/api/v1/diarios/busca'); + + // Adicionar parâmetros à URL + Object.entries(this.searchParams).forEach(([key, value]) => { + if (value !== '' && value !== null) { + url.searchParams.append(key, value); + } + }); + + const response = await fetch(url); + + if (!response.ok) { + const errorData = await response.json().catch(() => null); + throw new Error(errorData?.message || `Erro HTTP: ${response.status}`); + } + + this.searchResults = await response.json(); + + // Processar os resultados para garantir que as páginas tenham score + if (this.searchResults && this.searchResults.resultados) { + this.searchResults.resultados.forEach(diario => { + if (diario.paginas) { + // Atribuir scores padrão se não existirem + diario.paginas.forEach((pagina, index) => { + if (pagina.score === undefined || pagina.score === null) { + pagina.score = diario.paginas.length - index; // Score inversamente proporcional ao índice + } + }); + } + }); + } + + if (suggestionPromise) { + this.suggestion = await suggestionPromise; + } + } catch (error) { + console.error('Erro na busca:', error); + this.error = `Erro ao buscar diários: ${error.message}`; + } finally { + this.isLoading = false; + } + }, + + formatDate(dateString) { + const options = { day: '2-digit', month: '2-digit', year: 'numeric' }; + return new Date(dateString + 'T00:00:00').toLocaleDateString('pt-BR', options); + }, + goToPage(page) { + if (page < 1 || page > this.totalPages) return; + this.searchParams.page = page; + this.performSearch(); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }, + + resetSearch() { + this.searchParams = { + q: '', + numero_diario: '', + data_inicio: '', + data_fim: '', + modo_busca: 'exata', + ordenar_por: 'relevancia', + page: 1, + page_size: 10 + }; + this.searchResults = null; + this.hasSearched = false; + this.error = null; + this.expandedContents = {}; + } + })); +}); diff --git a/justfile b/justfile deleted file mode 100644 index e9ce737..0000000 --- a/justfile +++ /dev/null @@ -1,38 +0,0 @@ -export COMPOSE_FILE := "docker-compose.local.yml" - -## Just does not yet manage signals for subprocesses reliably, which can lead to unexpected behavior. -## Exercise caution before expanding its usage in production environments. -## For more information, see https://github.com/casey/just/issues/2473 . - - -# Default command to list all available commands. -default: - @just --list - -# build: Build python image. -build: - @echo "Building python image..." - @docker compose build - -# up: Start up containers. -up: - @echo "Starting up containers..." - @docker compose up -d --remove-orphans - -# down: Stop containers. -down: - @echo "Stopping containers..." - @docker compose down - -# prune: Remove containers and their volumes. -prune *args: - @echo "Killing containers and removing volumes..." - @docker compose down -v {{args}} - -# logs: View container logs -logs *args: - @docker compose logs -f {{args}} - -# manage: Executes `manage.py` command. -manage +args: - @docker compose run --rm django python ./manage.py {{args}} diff --git a/locale/README.md b/locale/README.md deleted file mode 100644 index 8a220a9..0000000 --- a/locale/README.md +++ /dev/null @@ -1,32 +0,0 @@ -# Translations - -Start by configuring the `LANGUAGES` settings in `base.py`, by uncommenting languages you are willing to support. Then, translation strings will be placed in this folder when running: - -```bash -docker compose -f docker-compose.local.yml run --rm django python manage.py makemessages --all --no-location -``` - -This should generate `django.po` (stands for Portable Object) files under each locale `/LC_MESSAGES/django.po`. Each translatable string in the codebase is collected with its `msgid` and need to be translated as `msgstr`, for example: - -```po -msgid "users" -msgstr "utilisateurs" -``` - -Once all translations are done, they need to be compiled into `.mo` files (stands for Machine Object), which are the actual binary files used by the application: - -```bash -docker compose -f docker-compose.local.yml run --rm django python manage.py compilemessages -``` - -Note that the `.po` files are NOT used by the application directly, so if the `.mo` files are out of date, the content won't appear as translated even if the `.po` files are up-to-date. - -## Production - -The production image runs `compilemessages` automatically at build time, so as long as your translated source files (PO) are up-to-date, you're good to go. - -## Add a new language - -1. Update the [`LANGUAGES` setting](https://docs.djangoproject.com/en/stable/ref/settings/#std-setting-LANGUAGES) to your project's base settings. -2. Create the locale folder for the language next to this file, e.g. `fr_FR` for French. Make sure the case is correct. -3. Run `makemessages` (as instructed above) to generate the PO files for the new language. diff --git a/locale/en_US/LC_MESSAGES/django.po b/locale/en_US/LC_MESSAGES/django.po deleted file mode 100644 index 2931dc6..0000000 --- a/locale/en_US/LC_MESSAGES/django.po +++ /dev/null @@ -1,12 +0,0 @@ -# Translations for the Diários Oficiais ALEMS project -# Copyright (C) 2025 Antonio Roberto -# Antonio Roberto , 2025. -# -#, fuzzy -msgid "" -msgstr "" -"Project-Id-Version: 0.1.0\n" -"Language: en-US\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" diff --git a/locale/fr_FR/LC_MESSAGES/django.po b/locale/fr_FR/LC_MESSAGES/django.po deleted file mode 100644 index 808ca1e..0000000 --- a/locale/fr_FR/LC_MESSAGES/django.po +++ /dev/null @@ -1,335 +0,0 @@ -# Translations for the Diários Oficiais ALEMS project -# Copyright (C) 2025 Antonio Roberto -# Antonio Roberto , 2025. -# -#, fuzzy -msgid "" -msgstr "" -"Project-Id-Version: 0.1.0\n" -"Language: fr-FR\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=(n > 1);\n" -#: diários_oficiais_alems/templates/account/account_inactive.html:5 -#: diários_oficiais_alems/templates/account/account_inactive.html:8 -msgid "Account Inactive" -msgstr "Compte inactif" - -#: diários_oficiais_alems/templates/account/account_inactive.html:10 -msgid "This account is inactive." -msgstr "Ce compte est inactif." - -#: diários_oficiais_alems/templates/account/email.html:7 -msgid "Account" -msgstr "Compte" - -#: diários_oficiais_alems/templates/account/email.html:10 -msgid "E-mail Addresses" -msgstr "Adresses e-mail" - -#: diários_oficiais_alems/templates/account/email.html:13 -msgid "The following e-mail addresses are associated with your account:" -msgstr "Les adresses e-mail suivantes sont associées à votre compte :" - -#: diários_oficiais_alems/templates/account/email.html:27 -msgid "Verified" -msgstr "Vérifié" - -#: diários_oficiais_alems/templates/account/email.html:29 -msgid "Unverified" -msgstr "Non vérifié" - -#: diários_oficiais_alems/templates/account/email.html:31 -msgid "Primary" -msgstr "Primaire" - -#: diários_oficiais_alems/templates/account/email.html:37 -msgid "Make Primary" -msgstr "Changer Primaire" - -#: diários_oficiais_alems/templates/account/email.html:38 -msgid "Re-send Verification" -msgstr "Renvoyer vérification" - -#: diários_oficiais_alems/templates/account/email.html:39 -msgid "Remove" -msgstr "Supprimer" - -#: diários_oficiais_alems/templates/account/email.html:46 -msgid "Warning:" -msgstr "Avertissement:" - -#: diários_oficiais_alems/templates/account/email.html:46 -msgid "" -"You currently do not have any e-mail address set up. You should really add " -"an e-mail address so you can receive notifications, reset your password, etc." -msgstr "" -"Vous n'avez actuellement aucune adresse e-mail configurée. Vous devriez ajouter " -"une adresse e-mail pour reçevoir des notifications, réinitialiser votre mot " -"de passe, etc." - -#: diários_oficiais_alems/templates/account/email.html:51 -msgid "Add E-mail Address" -msgstr "Ajouter une adresse e-mail" - -#: diários_oficiais_alems/templates/account/email.html:56 -msgid "Add E-mail" -msgstr "Ajouter e-mail" - -#: diários_oficiais_alems/templates/account/email.html:66 -msgid "Do you really want to remove the selected e-mail address?" -msgstr "Voulez-vous vraiment supprimer l'adresse e-mail sélectionnée ?" - -#: diários_oficiais_alems/templates/account/email_confirm.html:6 -#: diários_oficiais_alems/templates/account/email_confirm.html:10 -msgid "Confirm E-mail Address" -msgstr "Confirmez votre adresse email" - -#: diários_oficiais_alems/templates/account/email_confirm.html:16 -#, python-format -msgid "" -"Please confirm that %(email)s is an e-mail " -"address for user %(user_display)s." -msgstr "" -"Veuillez confirmer que %(email)s est un e-mail " -"adresse de l'utilisateur %(user_display)s." - -#: diários_oficiais_alems/templates/account/email_confirm.html:20 -msgid "Confirm" -msgstr "Confirm" - -#: diários_oficiais_alems/templates/account/email_confirm.html:27 -#, python-format -msgid "" -"This e-mail confirmation link expired or is invalid. Please issue a new e-mail confirmation request." -msgstr "" -"Ce lien de confirmation par e-mail a expiré ou n'est pas valide. Veuillez" - "émettre une nouvelle demande de confirmation " -"par e-mail." - -#: diários_oficiais_alems/templates/account/login.html:7 -#: diários_oficiais_alems/templates/account/login.html:11 -#: diários_oficiais_alems/templates/account/login.html:56 -#: diários_oficiais_alems/templates/base.html:72 -msgid "Sign In" -msgstr "S'identifier" - -#: diários_oficiais_alems/templates/account/login.html:17 -msgid "Please sign in with one of your existing third party accounts:" -msgstr "Veuillez vous connecter avec l'un de vos comptes tiers existants :" - -#: diários_oficiais_alems/templates/account/login.html:19 -#, python-format -msgid "" -"Or, sign up for a %(site_name)s account and " -"sign in below:" -msgstr "" -"Ou, créez un compte %(site_name)s et " -"connectez-vous ci-dessous :" - -#: diários_oficiais_alems/templates/account/login.html:32 -msgid "or" -msgstr "ou" - -#: diários_oficiais_alems/templates/account/login.html:41 -#, python-format -msgid "" -"If you have not created an account yet, then please sign up first." -msgstr "" -"Si vous n'avez pas encore créé de compte, veuillez d'abord vous inscrire." - -#: diários_oficiais_alems/templates/account/login.html:55 -msgid "Forgot Password?" -msgstr "Mot de passe oublié?" - -#: diários_oficiais_alems/templates/account/logout.html:5 -#: diários_oficiais_alems/templates/account/logout.html:8 -#: diários_oficiais_alems/templates/account/logout.html:17 -#: diários_oficiais_alems/templates/base.html:61 -msgid "Sign Out" -msgstr "Se déconnecter" - -#: diários_oficiais_alems/templates/account/logout.html:10 -msgid "Are you sure you want to sign out?" -msgstr "Êtes-vous certain de vouloir vous déconnecter?" - -#: diários_oficiais_alems/templates/account/password_change.html:6 -#: diários_oficiais_alems/templates/account/password_change.html:9 -#: diários_oficiais_alems/templates/account/password_change.html:14 -#: diários_oficiais_alems/templates/account/password_reset_from_key.html:5 -#: diários_oficiais_alems/templates/account/password_reset_from_key.html:8 -#: diários_oficiais_alems/templates/account/password_reset_from_key_done.html:4 -#: diários_oficiais_alems/templates/account/password_reset_from_key_done.html:7 -msgid "Change Password" -msgstr "Changer le mot de passe" - -#: diários_oficiais_alems/templates/account/password_reset.html:7 -#: diários_oficiais_alems/templates/account/password_reset.html:11 -#: diários_oficiais_alems/templates/account/password_reset_done.html:6 -#: diários_oficiais_alems/templates/account/password_reset_done.html:9 -msgid "Password Reset" -msgstr "Réinitialisation du mot de passe" - -#: diários_oficiais_alems/templates/account/password_reset.html:16 -msgid "" -"Forgotten your password? Enter your e-mail address below, and we'll send you " -"an e-mail allowing you to reset it." -msgstr "" -"Mot de passe oublié? Entrez votre adresse e-mail ci-dessous, et nous vous " -"enverrons un e-mail vous permettant de le réinitialiser." - -#: diários_oficiais_alems/templates/account/password_reset.html:21 -msgid "Reset My Password" -msgstr "Réinitialiser mon mot de passe" - -#: diários_oficiais_alems/templates/account/password_reset.html:24 -msgid "Please contact us if you have any trouble resetting your password." -msgstr "" -"Veuillez nous contacter si vous rencontrez des difficultés pour réinitialiser" -"votre mot de passe." - -#: diários_oficiais_alems/templates/account/password_reset_done.html:15 -msgid "" -"We have sent you an e-mail. Please contact us if you do not receive it " -"within a few minutes." -msgstr "" -"Nous vous avons envoyé un e-mail. Veuillez nous contacter si vous ne le " -"recevez pas d'ici quelques minutes." - -#: diários_oficiais_alems/templates/account/password_reset_from_key.html:8 -msgid "Bad Token" -msgstr "Token Invalide" - -#: diários_oficiais_alems/templates/account/password_reset_from_key.html:12 -#, python-format -msgid "" -"The password reset link was invalid, possibly because it has already been " -"used. Please request a new password reset." -msgstr "" -"Le lien de réinitialisation du mot de passe n'était pas valide, peut-être parce " -"qu'il a déjà été utilisé. Veuillez faire une " -"nouvelle demande de réinitialisation de mot de passe." - -#: diários_oficiais_alems/templates/account/password_reset_from_key.html:18 -msgid "change password" -msgstr "changer le mot de passe" - -#: diários_oficiais_alems/templates/account/password_reset_from_key.html:21 -#: diários_oficiais_alems/templates/account/password_reset_from_key_done.html:8 -msgid "Your password is now changed." -msgstr "Votre mot de passe est maintenant modifié." - -#: diários_oficiais_alems/templates/account/password_set.html:6 -#: diários_oficiais_alems/templates/account/password_set.html:9 -#: diários_oficiais_alems/templates/account/password_set.html:14 -msgid "Set Password" -msgstr "Définir le mot de passe" - -#: diários_oficiais_alems/templates/account/signup.html:6 -msgid "Signup" -msgstr "S'inscrire" - -#: diários_oficiais_alems/templates/account/signup.html:9 -#: diários_oficiais_alems/templates/account/signup.html:19 -#: diários_oficiais_alems/templates/base.html:67 -msgid "Sign Up" -msgstr "S'inscrire" - -#: diários_oficiais_alems/templates/account/signup.html:11 -#, python-format -msgid "" -"Already have an account? Then please sign in." -msgstr "" -"Vous avez déjà un compte? Alors veuillez vous connecter." - -#: diários_oficiais_alems/templates/account/signup_closed.html:5 -#: diários_oficiais_alems/templates/account/signup_closed.html:8 -msgid "Sign Up Closed" -msgstr "Inscriptions closes" - -#: diários_oficiais_alems/templates/account/signup_closed.html:10 -msgid "We are sorry, but the sign up is currently closed." -msgstr "Désolé, mais l'inscription est actuellement fermée." - -#: diários_oficiais_alems/templates/account/verification_sent.html:5 -#: diários_oficiais_alems/templates/account/verification_sent.html:8 -#: diários_oficiais_alems/templates/account/verified_email_required.html:5 -#: diários_oficiais_alems/templates/account/verified_email_required.html:8 -msgid "Verify Your E-mail Address" -msgstr "Vérifiez votre adresse e-mail" - -#: diários_oficiais_alems/templates/account/verification_sent.html:10 -msgid "" -"We have sent an e-mail to you for verification. Follow the link provided to " -"finalize the signup process. Please contact us if you do not receive it " -"within a few minutes." -msgstr "Nous vous avons envoyé un e-mail pour vérification. Suivez le lien fourni " -"pour finalisez le processus d'inscription. Veuillez nous contacter si vous ne le " -"recevez pas d'ici quelques minutes." - -#: diários_oficiais_alems/templates/account/verified_email_required.html:12 -msgid "" -"This part of the site requires us to verify that\n" -"you are who you claim to be. For this purpose, we require that you\n" -"verify ownership of your e-mail address. " -msgstr "" -"Cette partie du site nous oblige à vérifier que\n" -"vous êtes qui vous prétendez être. Nous vous demandons donc de\n" -"vérifier la propriété de votre adresse e-mail." - -#: diários_oficiais_alems/templates/account/verified_email_required.html:16 -msgid "" -"We have sent an e-mail to you for\n" -"verification. Please click on the link inside this e-mail. Please\n" -"contact us if you do not receive it within a few minutes." -msgstr "" -"Nous vous avons envoyé un e-mail pour\n" -"vérification. Veuillez cliquer sur le lien contenu dans cet e-mail. Veuillez nous\n" -"contacter si vous ne le recevez pas d'ici quelques minutes." - -#: diários_oficiais_alems/templates/account/verified_email_required.html:20 -#, python-format -msgid "" -"Note: you can still change your e-" -"mail address." -msgstr "" -"Remarque : vous pouvez toujours changer votre e-" -"adresse e-mail." - -#: diários_oficiais_alems/templates/base.html:57 -msgid "My Profile" -msgstr "Mon Profil" - -#: diários_oficiais_alems/users/admin.py:17 -msgid "Personal info" -msgstr "Personal info" - -#: diários_oficiais_alems/users/admin.py:19 -msgid "Permissions" -msgstr "Permissions" - -#: diários_oficiais_alems/users/admin.py:30 -msgid "Important dates" -msgstr "Dates importantes" - -#: diários_oficiais_alems/users/apps.py:7 -msgid "Users" -msgstr "Utilisateurs" - -#: diários_oficiais_alems/users/forms.py:24 -#: diários_oficiais_alems/users/tests/test_forms.py:36 -msgid "This username has already been taken." -msgstr "Ce nom d'utilisateur est déjà pris." - -#: diários_oficiais_alems/users/models.py:15 -msgid "Name of User" -msgstr "Nom de l'utilisateur" - -#: diários_oficiais_alems/users/views.py:23 -msgid "Information successfully updated" -msgstr "Informations mises à jour avec succès" diff --git a/locale/pt_BR/LC_MESSAGES/django.po b/locale/pt_BR/LC_MESSAGES/django.po deleted file mode 100644 index bd2500b..0000000 --- a/locale/pt_BR/LC_MESSAGES/django.po +++ /dev/null @@ -1,315 +0,0 @@ -# Translations for the Diários Oficiais ALEMS project -# Copyright (C) 2025 Antonio Roberto -# Antonio Roberto , 2025. -# -#, fuzzy -msgid "" -msgstr "" -"Project-Id-Version: 0.1.0\n" -"Language: pt-BR\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=(n > 1);\n" -#: diários_oficiais_alems/templates/account/account_inactive.html:5 -#: diários_oficiais_alems/templates/account/account_inactive.html:8 -msgid "Account Inactive" -msgstr "Conta Inativa" - -#: diários_oficiais_alems/templates/account/account_inactive.html:10 -msgid "This account is inactive." -msgstr "Esta conta está inativa." - -#: diários_oficiais_alems/templates/account/email.html:7 -msgid "Account" -msgstr "Conta" - -#: diários_oficiais_alems/templates/account/email.html:10 -msgid "E-mail Addresses" -msgstr "Endereços de E-mail" - -#: diários_oficiais_alems/templates/account/email.html:13 -msgid "The following e-mail addresses are associated with your account:" -msgstr "Os seguintes endereços de e-mail estão associados à sua conta:" - -#: diários_oficiais_alems/templates/account/email.html:27 -msgid "Verified" -msgstr "Verificado" - -#: diários_oficiais_alems/templates/account/email.html:29 -msgid "Unverified" -msgstr "Não verificado" - -#: diários_oficiais_alems/templates/account/email.html:31 -msgid "Primary" -msgstr "Primário" - -#: diários_oficiais_alems/templates/account/email.html:37 -msgid "Make Primary" -msgstr "Tornar Primário" - -#: diários_oficiais_alems/templates/account/email.html:38 -msgid "Re-send Verification" -msgstr "Reenviar verificação" - -#: diários_oficiais_alems/templates/account/email.html:39 -msgid "Remove" -msgstr "Remover" - -#: diários_oficiais_alems/templates/account/email.html:46 -msgid "Warning:" -msgstr "Aviso:" - -#: diários_oficiais_alems/templates/account/email.html:46 -msgid "" -"You currently do not have any e-mail address set up. You should really add " -"an e-mail address so you can receive notifications, reset your password, etc." -msgstr "" -"No momento, você não tem nenhum endereço de e-mail configurado. Você " -"realmente deve adicionar um endereço de e-mail para receber notificações, " -"redefinir sua senha etc." - -#: diários_oficiais_alems/templates/account/email.html:51 -msgid "Add E-mail Address" -msgstr "Adicionar Endereço de E-mail" - -#: diários_oficiais_alems/templates/account/email.html:56 -msgid "Add E-mail" -msgstr "Adicionar E-mail" - -#: diários_oficiais_alems/templates/account/email.html:66 -msgid "Do you really want to remove the selected e-mail address?" -msgstr "Você realmente deseja remover o endereço de e-mail selecionado?" - -#: diários_oficiais_alems/templates/account/email_confirm.html:6 -#: diários_oficiais_alems/templates/account/email_confirm.html:10 -msgid "Confirm E-mail Address" -msgstr "Confirme o endereço de e-mail" - -#: diários_oficiais_alems/templates/account/email_confirm.html:16 -#, python-format -msgid "" -"Please confirm that %(email)s is an e-mail " -"address for user %(user_display)s." -msgstr "" -"Confirme se %(email)s é um endereço de " -"e-mail do usuário %(user_display)s." - -#: diários_oficiais_alems/templates/account/email_confirm.html:20 -msgid "Confirm" -msgstr "Confirmar" - -#: diários_oficiais_alems/templates/account/email_confirm.html:27 -#, python-format -msgid "" -"This e-mail confirmation link expired or is invalid. Please issue a new e-mail confirmation request." -msgstr "Este link de confirmação de e-mail expirou ou é inválido. " -"Por favor, emita um novo pedido de confirmação por e-mail." - -#: diários_oficiais_alems/templates/account/login.html:7 -#: diários_oficiais_alems/templates/account/login.html:11 -#: diários_oficiais_alems/templates/account/login.html:56 -#: diários_oficiais_alems/templates/base.html:72 -msgid "Sign In" -msgstr "Entrar" - -#: diários_oficiais_alems/templates/account/login.html:17 -msgid "Please sign in with one of your existing third party accounts:" -msgstr "Faça login com uma de suas contas de terceiros existentes:" - -#: diários_oficiais_alems/templates/account/login.html:19 -#, python-format -msgid "" -"Or, sign up for a %(site_name)s account and " -"sign in below:" -msgstr "Ou, cadastre-se para uma conta em %(site_name)s e entre abaixo:" - -#: diários_oficiais_alems/templates/account/login.html:32 -msgid "or" -msgstr "ou" - -#: diários_oficiais_alems/templates/account/login.html:41 -#, python-format -msgid "" -"If you have not created an account yet, then please sign up first." -msgstr "Se você ainda não criou uma conta, registre-se primeiro." - -#: diários_oficiais_alems/templates/account/login.html:55 -msgid "Forgot Password?" -msgstr "Esqueceu sua senha?" - -#: diários_oficiais_alems/templates/account/logout.html:5 -#: diários_oficiais_alems/templates/account/logout.html:8 -#: diários_oficiais_alems/templates/account/logout.html:17 -#: diários_oficiais_alems/templates/base.html:61 -msgid "Sign Out" -msgstr "Sair" - -#: diários_oficiais_alems/templates/account/logout.html:10 -msgid "Are you sure you want to sign out?" -msgstr "Você tem certeza que deseja sair?" - -#: diários_oficiais_alems/templates/account/password_change.html:6 -#: diários_oficiais_alems/templates/account/password_change.html:9 -#: diários_oficiais_alems/templates/account/password_change.html:14 -#: diários_oficiais_alems/templates/account/password_reset_from_key.html:5 -#: diários_oficiais_alems/templates/account/password_reset_from_key.html:8 -#: diários_oficiais_alems/templates/account/password_reset_from_key_done.html:4 -#: diários_oficiais_alems/templates/account/password_reset_from_key_done.html:7 -msgid "Change Password" -msgstr "Alterar Senha" - -#: diários_oficiais_alems/templates/account/password_reset.html:7 -#: diários_oficiais_alems/templates/account/password_reset.html:11 -#: diários_oficiais_alems/templates/account/password_reset_done.html:6 -#: diários_oficiais_alems/templates/account/password_reset_done.html:9 -msgid "Password Reset" -msgstr "Redefinição de senha" - -#: diários_oficiais_alems/templates/account/password_reset.html:16 -msgid "" -"Forgotten your password? Enter your e-mail address below, and we'll send you " -"an e-mail allowing you to reset it." -msgstr "Esqueceu sua senha? Digite seu endereço de e-mail abaixo e enviaremos um e-mail permitindo que você o redefina." - -#: diários_oficiais_alems/templates/account/password_reset.html:21 -msgid "Reset My Password" -msgstr "Redefinir minha senha" - -#: diários_oficiais_alems/templates/account/password_reset.html:24 -msgid "Please contact us if you have any trouble resetting your password." -msgstr "Entre em contato conosco se tiver algum problema para redefinir sua senha." - -#: diários_oficiais_alems/templates/account/password_reset_done.html:15 -msgid "" -"We have sent you an e-mail. Please contact us if you do not receive it " -"within a few minutes." -msgstr "Enviamos um e-mail para você. Entre em contato conosco se você não recebê-lo dentro de alguns minutos." - -#: diários_oficiais_alems/templates/account/password_reset_from_key.html:8 -msgid "Bad Token" -msgstr "Token Inválido" - -#: diários_oficiais_alems/templates/account/password_reset_from_key.html:12 -#, python-format -msgid "" -"The password reset link was invalid, possibly because it has already been " -"used. Please request a new password reset." -msgstr "O link de redefinição de senha era inválido, possivelmente porque já foi usado. " -"Solicite uma nova redefinição de senha." - -#: diários_oficiais_alems/templates/account/password_reset_from_key.html:18 -msgid "change password" -msgstr "alterar senha" - -#: diários_oficiais_alems/templates/account/password_reset_from_key.html:21 -#: diários_oficiais_alems/templates/account/password_reset_from_key_done.html:8 -msgid "Your password is now changed." -msgstr "Sua senha agora foi alterada." - -#: diários_oficiais_alems/templates/account/password_set.html:6 -#: diários_oficiais_alems/templates/account/password_set.html:9 -#: diários_oficiais_alems/templates/account/password_set.html:14 -msgid "Set Password" -msgstr "Definir Senha" - -#: diários_oficiais_alems/templates/account/signup.html:6 -msgid "Signup" -msgstr "Cadastro" - -#: diários_oficiais_alems/templates/account/signup.html:9 -#: diários_oficiais_alems/templates/account/signup.html:19 -#: diários_oficiais_alems/templates/base.html:67 -msgid "Sign Up" -msgstr "Cadastro" - -#: diários_oficiais_alems/templates/account/signup.html:11 -#, python-format -msgid "" -"Already have an account? Then please sign in." -msgstr "já tem uma conta? Então, por favor, faça login." - -#: diários_oficiais_alems/templates/account/signup_closed.html:5 -#: diários_oficiais_alems/templates/account/signup_closed.html:8 -msgid "Sign Up Closed" -msgstr "Inscrições encerradas" - -#: diários_oficiais_alems/templates/account/signup_closed.html:10 -msgid "We are sorry, but the sign up is currently closed." -msgstr "Lamentamos, mas as inscrições estão encerradas no momento." - -#: diários_oficiais_alems/templates/account/verification_sent.html:5 -#: diários_oficiais_alems/templates/account/verification_sent.html:8 -#: diários_oficiais_alems/templates/account/verified_email_required.html:5 -#: diários_oficiais_alems/templates/account/verified_email_required.html:8 -msgid "Verify Your E-mail Address" -msgstr "Verifique seu endereço de e-mail" - -#: diários_oficiais_alems/templates/account/verification_sent.html:10 -msgid "" -"We have sent an e-mail to you for verification. Follow the link provided to " -"finalize the signup process. Please contact us if you do not receive it " -"within a few minutes." -msgstr "Enviamos um e-mail para você para verificação. Siga o link fornecido para finalizar o processo de inscrição. Entre em contato conosco se você não recebê-lo dentro de alguns minutos." - -#: diários_oficiais_alems/templates/account/verified_email_required.html:12 -msgid "" -"This part of the site requires us to verify that\n" -"you are who you claim to be. For this purpose, we require that you\n" -"verify ownership of your e-mail address. " -msgstr "Esta parte do site exige que verifiquemos se você é quem afirma ser.\n" -"Para esse fim, exigimos que você verifique a propriedade\n" -"do seu endereço de e-mail." - -#: diários_oficiais_alems/templates/account/verified_email_required.html:16 -msgid "" -"We have sent an e-mail to you for\n" -"verification. Please click on the link inside this e-mail. Please\n" -"contact us if you do not receive it within a few minutes." -msgstr "Enviamos um e-mail para você para verificação.\n" -"Por favor, clique no link dentro deste e-mail.\n" -"Entre em contato conosco se você não recebê-lo dentro de alguns minutos." - -#: diários_oficiais_alems/templates/account/verified_email_required.html:20 -#, python-format -msgid "" -"Note: you can still change your e-" -"mail address." -msgstr "Nota: você ainda pode alterar seu endereço de e-mail." - -#: diários_oficiais_alems/templates/base.html:57 -msgid "My Profile" -msgstr "Meu perfil" - -#: diários_oficiais_alems/users/admin.py:17 -msgid "Personal info" -msgstr "Informação pessoal" - -#: diários_oficiais_alems/users/admin.py:19 -msgid "Permissions" -msgstr "Permissões" - -#: diários_oficiais_alems/users/admin.py:30 -msgid "Important dates" -msgstr "Datas importantes" - -#: diários_oficiais_alems/users/apps.py:7 -msgid "Users" -msgstr "Usuários" - -#: diários_oficiais_alems/users/forms.py:24 -#: diários_oficiais_alems/users/tests/test_forms.py:36 -msgid "This username has already been taken." -msgstr "Este nome de usuário já foi usado." - -#: diários_oficiais_alems/users/models.py:15 -msgid "Name of User" -msgstr "Nome do Usuário" - -#: diários_oficiais_alems/users/views.py:23 -msgid "Information successfully updated" -msgstr "Informação atualizada com sucesso" diff --git a/manage.py b/manage.py index 05d05cc..a6c4eee 100644 --- a/manage.py +++ b/manage.py @@ -14,7 +14,7 @@ if __name__ == "__main__": # issue is really that Django is missing to avoid masking other # exceptions on Python 2. try: - import django + pass except ImportError: raise ImportError( "Couldn't import Django. Are you sure it's installed and " @@ -25,8 +25,8 @@ if __name__ == "__main__": raise # This allows easy placement of apps within the interior - # diários_oficiais_alems directory. + # diarios_oficiais_alems directory. current_path = Path(__file__).parent.resolve() - sys.path.append(str(current_path / "diários_oficiais_alems")) + sys.path.append(str(current_path / "diarios_oficiais_alems")) execute_from_command_line(sys.argv) diff --git a/requirements/base.txt b/requirements/base.txt index 28863e1..c720717 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -11,14 +11,24 @@ hiredis==3.1.0 # https://github.com/redis/hiredis-py django==5.0.12 # pyup: < 5.1 # https://www.djangoproject.com/ django-environ==0.12.0 # https://github.com/joke2k/django-environ django-model-utils==5.0.0 # https://github.com/jazzband/django-model-utils -django-allauth[mfa]==65.4.1 # https://github.com/pennersr/django-allauth +django-allauth[mfa]==65.8.0 # https://github.com/pennersr/django-allauth django-crispy-forms==2.3 # https://github.com/django-crispy-forms/django-crispy-forms crispy-bootstrap5==2024.10 # https://github.com/django-crispy-forms/crispy-bootstrap5 django-compressor==4.5.1 # https://github.com/django-compressor/django-compressor django-redis==5.4.0 # https://github.com/jazzband/django-redis -djangorestframework -elasticsearch +# Outros +# ------------------------------------------------------------------------------ django-elasticsearch-dsl PyPDF2 babel +django-ninja +remote-pdb +aiohttp +asyncio +django-cors-headers +pytesseract +pdf2image +pillow +ocrmypdf + diff --git a/sinonimos.txt b/sinonimos.txt deleted file mode 100644 index 8cd9587..0000000 --- a/sinonimos.txt +++ /dev/null @@ -1,4 +0,0 @@ -lei, legislação, norma -processo, procedimento, autos -contrato, acordo, convênio -