diff --git a/.github/workflows/build-windows-portable.yml b/.github/workflows/build-windows-portable.yml new file mode 100644 index 0000000000000000000000000000000000000000..ade3313f09398d479ef8a783ff6683ca726d1ad7 --- /dev/null +++ b/.github/workflows/build-windows-portable.yml @@ -0,0 +1,51 @@ +name: build-windows-portable + +on: + workflow_dispatch: + +jobs: + build: + runs-on: windows-latest + timeout-minutes: 45 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: backend/requirements.txt + + - name: Build portable folder + shell: pwsh + run: ./build/windows/build_portable.ps1 + + - name: Smoke test executable + shell: pwsh + run: ./build/windows/smoke_test_portable.ps1 + + - name: Archive portable folder + shell: pwsh + run: | + if (Test-Path "dist/MesaFrame-portable.zip") { + Remove-Item "dist/MesaFrame-portable.zip" -Force + } + Compress-Archive -Path "dist/MesaFrame/*" -DestinationPath "dist/MesaFrame-portable.zip" + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: MesaFrame-portable + path: | + dist/MesaFrame + dist/MesaFrame-portable.zip diff --git a/.gitignore b/.gitignore index 564a97a02a4f91fa48ddaf7c52ef3a41b051f6df..02ae7ff5d201bc74b72c4fc7fe4bc0f7710e4954 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,10 @@ __pycache__/ # Node node_modules/ frontend/dist/ +/dist/ +/build/* +!/build/windows/ +!/build/windows/** # System .DS_Store @@ -19,3 +23,6 @@ logs/**/*.jsonl backend/local_data/*.sqlite3 backend/local_data/*.sqlite3-shm backend/local_data/*.sqlite3-wal + +# Local .dai model repository +backend/app/core/pesquisa/modelos_dai/*.dai diff --git a/README.md b/README.md index 67eaeef33c59649b37049177e87f7e3569301fdb..42195ed5b732132de9f2091979a1c2dd5e14fa50 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,8 @@ Para apontar para outro backend: VITE_API_BASE=http://localhost:8000 npm run dev ``` +No build de producao servido pelo proprio backend, o frontend passa a usar a mesma origem (`/api`) por padrao. + ## Repositório de modelos `.dai` Os modelos usados em **Pesquisa**, **Elaboração** (carregar modelo existente) e @@ -129,3 +131,49 @@ Comportamento por ambiente: Variável opcional: - `APP_LOGS_MODE` (`auto`/`enabled`/`disabled`) para forçar o modo de logs. + +## Modo portátil Windows + +Planejamento atual da distribuicao: + +- o app continuara sendo desenvolvido normalmente no macOS; +- a pasta portatil de Windows sera gerada em um ambiente Windows; +- o artefato final sera uma pasta `MesaFrame/` gerada via `PyInstaller` em modo `onedir`; +- a configuracao externa fica em `config/appsettings.json`; +- o banco SQLite atual continua sendo reutilizado como esta; +- os dados compartilhados podem ficar em pasta de rede e o runtime temporario fica local na maquina do usuario. + +Arquivos-base dessa estrategia: + +- `backend/portable_app.py`: entrada do modo portatil +- `backend/app/portable_launcher.py`: sobe o backend local e abre o navegador +- `backend/app/runtime_config.py`: aplica a configuracao externa +- `build/windows/appsettings.example.json`: exemplo de configuracao +- `build/windows/build_portable.ps1`: script de build para ambiente Windows +- `build/windows/smoke_test_portable.ps1`: smoke test do executavel portatil +- `build/windows/mesa_frame_portable.spec`: spec inicial do PyInstaller +- `.github/workflows/build-windows-portable.yml`: workflow para gerar a pasta portatil em runner Windows + +Fluxo recorrente de release: + +1. desenvolver e validar normalmente no macOS +2. fazer push da branch que sera empacotada +3. disparar manualmente o workflow `.github/workflows/build-windows-portable.yml` +4. baixar o artefato `MesaFrame-portable` +5. copiar a pasta `MesaFrame/` para a rede +6. ajustar `MesaFrame/config/appsettings.json` para os caminhos compartilhados da operacao +7. no primeiro deploy, copiar manualmente a pasta de modelos `.dai` para o caminho configurado em `paths.models_dir` + +Estrutura esperada do artefato: + +- `MesaFrame/MesaFrame.exe`: executavel principal +- `MesaFrame/_internal/`: runtime embutido do Python e dependencias +- `MesaFrame/config/appsettings.example.json`: exemplo de configuracao externa +- `MesaFrame/config/appsettings.json`: configuracao efetiva da instalacao +- `MesaFrame/runtime/`: runtime temporario local usado nos testes automatizados + +Observacao sobre GitHub e dados: + +- o repositorio pode ficar somente com codigo e arquivos de suporte ao build +- os modelos `.dai` devem ficar fora do Git e ser copiados manualmente para a pasta compartilhada da operacao +- o `appsettings.json` publicado deve apontar `paths.models_dir` para essa pasta externa diff --git a/backend/app/core/elaboracao/app.py b/backend/app/core/elaboracao/app.py index 43beaff8b8da584d93166946803197f4f9e66e9f..6099639d28e9df87af9d400dd84dab88d58a855f 100644 --- a/backend/app/core/elaboracao/app.py +++ b/backend/app/core/elaboracao/app.py @@ -11,7 +11,9 @@ import pandas as pd import os import json -_avaliadores_path = os.path.join(os.path.dirname(__file__), "avaliadores.json") +from app.runtime_config import resolve_core_path + +_avaliadores_path = resolve_core_path("elaboracao", "avaliadores.json") with open(_avaliadores_path, encoding="utf-8") as _f: _avaliadores_raw = json.load(_f) _avaliadores_lista = _avaliadores_raw.get("avaliadores", []) diff --git a/backend/app/core/elaboracao/formatadores.py b/backend/app/core/elaboracao/formatadores.py index 183bde1c6121c405ebdaed4808e1b6e5214ac2dc..60134342289a3482d8b5166ef086af56a8bf80ce 100644 --- a/backend/app/core/elaboracao/formatadores.py +++ b/backend/app/core/elaboracao/formatadores.py @@ -9,6 +9,7 @@ Sem dependência de Gradio. import os import numpy as np import pandas as pd +from app.runtime_config import resolve_core_path # ============================================================ @@ -43,7 +44,7 @@ def arredondar_df(df, decimais=4): def carregar_css(): """Carrega CSS externo.""" - css_path = os.path.join(os.path.dirname(__file__), "styles.css") + css_path = resolve_core_path("elaboracao", "styles.css") try: with open(css_path, "r", encoding="utf-8") as f: return f.read() diff --git a/backend/app/core/elaboracao/geocodificacao.py b/backend/app/core/elaboracao/geocodificacao.py index 106170d974952ec67e30f5b8e3b04db6639fdb43..2ff13d6fae97c7056dc3dca7fa83c2acfa5eba44 100644 --- a/backend/app/core/elaboracao/geocodificacao.py +++ b/backend/app/core/elaboracao/geocodificacao.py @@ -11,14 +11,14 @@ import os import numpy as np import pandas as pd +from app.runtime_config import resolve_core_path from .core import NOMES_LAT, NOMES_LON # ============================================================ # CAMINHO DO SHAPEFILE # ============================================================ -_BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # raiz MESA/ -_SHAPEFILE = os.path.join(_BASE, "dados", "EixosLogradouros.shp") +_SHAPEFILE = str(resolve_core_path("dados", "EixosLogradouros.shp")) # Cache em módulo — carregado uma vez por sessão _gdf_eixos = None diff --git a/backend/app/core/map_layers.py b/backend/app/core/map_layers.py index c0c925a97bb365ed3bbaf5c659b351f475f4ca95..c82cda9fbb45e13e1088caff62ef089223b2e15e 100644 --- a/backend/app/core/map_layers.py +++ b/backend/app/core/map_layers.py @@ -8,9 +8,9 @@ from typing import Any import folium from branca.element import Element +from app.runtime_config import resolve_core_path -_BASE_DIR = Path(__file__).resolve().parent -_BAIRROS_SHP_PATH = _BASE_DIR / "dados" / "Bairros_LC12112_16.shp" +_BAIRROS_SHP_PATH = resolve_core_path("dados", "Bairros_LC12112_16.shp") _TOOLTIP_FIELDS = ("NOME", "BAIRRO", "NME_BAI", "NOME_BAIRRO") _SIMPLIFY_TOLERANCE = 0.00005 diff --git a/backend/app/core/pesquisa/modelos_dai/MOD_A_CCOM_Z2_004D.dai b/backend/app/core/pesquisa/modelos_dai/MOD_A_CCOM_Z2_004D.dai deleted file mode 100644 index 50a494080e644c9b2251b99879fc8f83dd0033d6..0000000000000000000000000000000000000000 --- a/backend/app/core/pesquisa/modelos_dai/MOD_A_CCOM_Z2_004D.dai +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ff221a8b0b6bec00a00f4c5ec0ff37429943328c943640fc98d3979074eb762c -size 61298 diff --git a/backend/app/core/pesquisa/modelos_dai/MOD_A_CCOM_Z2_005.dai b/backend/app/core/pesquisa/modelos_dai/MOD_A_CCOM_Z2_005.dai deleted file mode 100644 index 43745180334b388e4b5654c9045bae75d77f65bb..0000000000000000000000000000000000000000 --- a/backend/app/core/pesquisa/modelos_dai/MOD_A_CCOM_Z2_005.dai +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:32bccd0c76e37878c83697700c3cd1cb1fa2cf8a25b714ef8a3609b031377487 -size 266364 diff --git a/backend/app/core/pesquisa/modelos_dai/MOD_A_CCOM_Z4_002N.dai b/backend/app/core/pesquisa/modelos_dai/MOD_A_CCOM_Z4_002N.dai deleted file mode 100644 index 2862c3d7abf23f08a091bb6786dfa6087aa7c8fa..0000000000000000000000000000000000000000 --- a/backend/app/core/pesquisa/modelos_dai/MOD_A_CCOM_Z4_002N.dai +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:92939746e3e2a2f1d455de0531c31b893c32415c22161478135321558a79ec8c -size 78623 diff --git a/backend/app/core/pesquisa/modelos_dai/MOD_A_DEP_Z1_Z2_Z3_Z4_003B.dai b/backend/app/core/pesquisa/modelos_dai/MOD_A_DEP_Z1_Z2_Z3_Z4_003B.dai deleted file mode 100644 index 68ec404cc06aeb1837ee47f3db1b7cb52d45869e..0000000000000000000000000000000000000000 --- a/backend/app/core/pesquisa/modelos_dai/MOD_A_DEP_Z1_Z2_Z3_Z4_003B.dai +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:77ff58a2844f5c54863eb0b06d8530e48db9af48d5f2255e3ca12d119d19d232 -size 105949 diff --git a/backend/app/core/pesquisa/modelos_dai/MOD_A_EDIF_Z1-Z2_001F.dai b/backend/app/core/pesquisa/modelos_dai/MOD_A_EDIF_Z1-Z2_001F.dai deleted file mode 100644 index 38319044382190c7dc902eb3b0383b3d0f37697a..0000000000000000000000000000000000000000 --- a/backend/app/core/pesquisa/modelos_dai/MOD_A_EDIF_Z1-Z2_001F.dai +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:8053723e7c7e917e32709509bfb093ea718a65fec56ca095f0a99252cef69f75 -size 237021 diff --git a/backend/app/core/pesquisa/modelos_dai/MOD_A_EDIF_Z1_005D.dai b/backend/app/core/pesquisa/modelos_dai/MOD_A_EDIF_Z1_005D.dai deleted file mode 100644 index 7fd64cb57bbf073489d85ca4c656b0fc8a9510b7..0000000000000000000000000000000000000000 --- a/backend/app/core/pesquisa/modelos_dai/MOD_A_EDIF_Z1_005D.dai +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:df52dbf6e9902c69cf770c2149548b2c13b7f637a6a696b467a49601e5b8a50d -size 62078 diff --git a/backend/app/core/pesquisa/modelos_dai/MOD_A_EDIF_Z1_Z2_001E.dai b/backend/app/core/pesquisa/modelos_dai/MOD_A_EDIF_Z1_Z2_001E.dai deleted file mode 100644 index e5612b61f39ecdae9bb53b861aafc4ac43ab601f..0000000000000000000000000000000000000000 --- a/backend/app/core/pesquisa/modelos_dai/MOD_A_EDIF_Z1_Z2_001E.dai +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:df8c18d84ecc169b41511e2743aca950680ebca0aadeee2b1978c2fd19716871 -size 63789 diff --git a/backend/app/core/pesquisa/modelos_dai/MOD_A_LOJA_Z1_003F.dai b/backend/app/core/pesquisa/modelos_dai/MOD_A_LOJA_Z1_003F.dai deleted file mode 100644 index 06401b012db0f95db49ee1a9c403f0f32803e44f..0000000000000000000000000000000000000000 --- a/backend/app/core/pesquisa/modelos_dai/MOD_A_LOJA_Z1_003F.dai +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:38580da29bd30d5c4cf5a20b008f3a852c6769f3915fa07ff46dbac4bf40be60 -size 124431 diff --git a/backend/app/core/pesquisa/modelos_dai/MOD_A_LOJA_Z1_004C.dai b/backend/app/core/pesquisa/modelos_dai/MOD_A_LOJA_Z1_004C.dai deleted file mode 100644 index 9c234e7f3c81cf10a93efe903a8d48a0ca696a24..0000000000000000000000000000000000000000 --- a/backend/app/core/pesquisa/modelos_dai/MOD_A_LOJA_Z1_004C.dai +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b4bdcc13ef212a014deda190957a7c30beb8f2b42cd3c2518ee9dd0323e87930 -size 120063 diff --git a/backend/app/core/pesquisa/modelos_dai/MOD_A_LOJA_Z1_006B.dai b/backend/app/core/pesquisa/modelos_dai/MOD_A_LOJA_Z1_006B.dai deleted file mode 100644 index d3ff694d78931bfd46c0f732f264dfad72c8228d..0000000000000000000000000000000000000000 --- a/backend/app/core/pesquisa/modelos_dai/MOD_A_LOJA_Z1_006B.dai +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:1f6a14fe402b1ec5b3be1492492e01fb0b9050a03e049e22cc80ebbf7b4f88d2 -size 75212 diff --git a/backend/app/core/pesquisa/modelos_dai/MOD_A_LOJA_Z1_007C.dai b/backend/app/core/pesquisa/modelos_dai/MOD_A_LOJA_Z1_007C.dai deleted file mode 100644 index 819306f7a3180727bda966ba8039e01f8eb9e0cf..0000000000000000000000000000000000000000 --- a/backend/app/core/pesquisa/modelos_dai/MOD_A_LOJA_Z1_007C.dai +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ce78dbb834e496a566e7d695a620ae96261ddc65f9fe397ab3ac0f65718f16e3 -size 82416 diff --git a/backend/app/core/pesquisa/modelos_dai/MOD_A_SALA_Z1_006B.dai b/backend/app/core/pesquisa/modelos_dai/MOD_A_SALA_Z1_006B.dai deleted file mode 100644 index fe5ab646f9ba5a066d6f00d89bd3960a9dfa8e3f..0000000000000000000000000000000000000000 --- a/backend/app/core/pesquisa/modelos_dai/MOD_A_SALA_Z1_006B.dai +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:4d241fad2e540fa24a79edc7ea798ddd22fe456af0ee7e59465c138c10265d6f -size 122342 diff --git a/backend/app/core/pesquisa/modelos_dai/MOD_A_SALA_Z1_006C.dai b/backend/app/core/pesquisa/modelos_dai/MOD_A_SALA_Z1_006C.dai deleted file mode 100644 index 6114d1257883b2b02767475a0f59e3420d1a9141..0000000000000000000000000000000000000000 --- a/backend/app/core/pesquisa/modelos_dai/MOD_A_SALA_Z1_006C.dai +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b7e81622c34e2333ba1a628d7f275a8fa71912f7da8b9720f37298ac285faf0b -size 305073 diff --git a/backend/app/core/pesquisa/modelos_dai/MOD_A_SALA_Z1_Z3_001.dai b/backend/app/core/pesquisa/modelos_dai/MOD_A_SALA_Z1_Z3_001.dai deleted file mode 100644 index 7713406028142f4a0479aced8d595b39a8c4c981..0000000000000000000000000000000000000000 --- a/backend/app/core/pesquisa/modelos_dai/MOD_A_SALA_Z1_Z3_001.dai +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d33b33df63a1d618ab2bcc26c5c111c698e8db9506d34622c0c1ed97fb18ad2c -size 44681 diff --git a/backend/app/core/pesquisa/modelos_dai/MOD_V_AP_Z1_011D.dai b/backend/app/core/pesquisa/modelos_dai/MOD_V_AP_Z1_011D.dai deleted file mode 100644 index 65cd06a28cbc11f339196233d88956a546e71425..0000000000000000000000000000000000000000 --- a/backend/app/core/pesquisa/modelos_dai/MOD_V_AP_Z1_011D.dai +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:49c49b0a46dd373ae7d16307516fa748ae4acc540462570f0c5e567928a29383 -size 5307946 diff --git a/backend/app/core/pesquisa/modelos_dai/MOD_V_AP_Z1_020B.dai b/backend/app/core/pesquisa/modelos_dai/MOD_V_AP_Z1_020B.dai deleted file mode 100644 index f0bb7fb1875cbee4c2c950c570d3e2d9927951f2..0000000000000000000000000000000000000000 --- a/backend/app/core/pesquisa/modelos_dai/MOD_V_AP_Z1_020B.dai +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f9f69d57ff5949afa8e43e6fe754b86b7d71098db740a70656db63ff003d46db -size 1999539 diff --git a/backend/app/core/pesquisa/modelos_dai/MOD_V_AP_Z1_022.dai b/backend/app/core/pesquisa/modelos_dai/MOD_V_AP_Z1_022.dai deleted file mode 100644 index 829c6993348c7c75bef14c7ba5582eb9d20eae74..0000000000000000000000000000000000000000 --- a/backend/app/core/pesquisa/modelos_dai/MOD_V_AP_Z1_022.dai +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:17944f8132fce958a7168d0efc73f4aa07575c16993a16022f89c2d7596c05d0 -size 1318594 diff --git a/backend/app/core/pesquisa/modelos_dai/MOD_V_EDIF_Z1_Z2_Z3_Z4_002E.dai b/backend/app/core/pesquisa/modelos_dai/MOD_V_EDIF_Z1_Z2_Z3_Z4_002E.dai deleted file mode 100644 index 728f2b50b669e7776b36d8663e51265c5c5f6ca7..0000000000000000000000000000000000000000 --- a/backend/app/core/pesquisa/modelos_dai/MOD_V_EDIF_Z1_Z2_Z3_Z4_002E.dai +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:7d64c816f183f6c18952eacf76fbce781d621d5b5ec5f4b51f40a85b0983e40d -size 1577576 diff --git a/backend/app/core/pesquisa/modelos_dai/MOD_V_RCOND_Z4_004.dai b/backend/app/core/pesquisa/modelos_dai/MOD_V_RCOND_Z4_004.dai deleted file mode 100644 index bdac4ba94245b4cf1a08832f506f7829deb25e6a..0000000000000000000000000000000000000000 --- a/backend/app/core/pesquisa/modelos_dai/MOD_V_RCOND_Z4_004.dai +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:70e655434ec5441708a1d0f108ae7b8af702cfb7577110db59846c7b4685879e -size 836586 diff --git a/backend/app/core/pesquisa/modelos_dai/MOD_V_SALA_Z1_002E.dai b/backend/app/core/pesquisa/modelos_dai/MOD_V_SALA_Z1_002E.dai deleted file mode 100644 index 4cb0e772d89c100024f1f97a429d342eb962792c..0000000000000000000000000000000000000000 --- a/backend/app/core/pesquisa/modelos_dai/MOD_V_SALA_Z1_002E.dai +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a81098b6a7206d18f23e0676952fa60583283ea84293ea94ad0f84b1414a4210 -size 4622543 diff --git a/backend/app/core/pesquisa/modelos_dai/MOD_V_TER_GENERICO_2016_2026_001.dai b/backend/app/core/pesquisa/modelos_dai/MOD_V_TER_GENERICO_2016_2026_001.dai deleted file mode 100644 index 2fe73bfb3e260f47da7bf57142f9ecdb6f49ba79..0000000000000000000000000000000000000000 --- a/backend/app/core/pesquisa/modelos_dai/MOD_V_TER_GENERICO_2016_2026_001.dai +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:30565ca1281c1abe3f224f7309e40479fa20224fe68fade4ce146f804e728c09 -size 19220124 diff --git a/backend/app/core/pesquisa/modelos_dai/MOD_V_TER_Z1_006L.dai b/backend/app/core/pesquisa/modelos_dai/MOD_V_TER_Z1_006L.dai deleted file mode 100644 index bcc32de26fb8e36ba4bfece8beb65b3fb621aee2..0000000000000000000000000000000000000000 --- a/backend/app/core/pesquisa/modelos_dai/MOD_V_TER_Z1_006L.dai +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:47e009f401dd9644d030f90a0111ac4765bd1054a44027c2fb9f327fca7ad380 -size 1906640 diff --git a/backend/app/core/pesquisa/modelos_dai/MOD_V_TER_Z2_008C.dai b/backend/app/core/pesquisa/modelos_dai/MOD_V_TER_Z2_008C.dai deleted file mode 100644 index cc5c73adec216c735227b89ef45bdf388903818e..0000000000000000000000000000000000000000 --- a/backend/app/core/pesquisa/modelos_dai/MOD_V_TER_Z2_008C.dai +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ceaae08485539da6b97aded2086fa292a86c1f557cdd7875ba36f0b5f270d60c -size 824103 diff --git a/backend/app/core/pesquisa/modelos_dai/MOD_V_TER_Z2_013C.dai b/backend/app/core/pesquisa/modelos_dai/MOD_V_TER_Z2_013C.dai deleted file mode 100644 index a3a3e7912ced6eeea0cdd08a47a5bca94eef5445..0000000000000000000000000000000000000000 --- a/backend/app/core/pesquisa/modelos_dai/MOD_V_TER_Z2_013C.dai +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:46137a963d90e5697ef2e8c01ce5b56af6237ee426fc20662bbf99b14acb2743 -size 1481702 diff --git a/backend/app/core/pesquisa/modelos_dai/MOD_V_TER_Z2_013F.dai b/backend/app/core/pesquisa/modelos_dai/MOD_V_TER_Z2_013F.dai deleted file mode 100644 index 8163ec6e9bbacd609f43e54bc7af927f7a2d5cad..0000000000000000000000000000000000000000 --- a/backend/app/core/pesquisa/modelos_dai/MOD_V_TER_Z2_013F.dai +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:cc522cf1e32ed0a3ff25157492accde2c0bc87e2013a2ea8ed3387317317de36 -size 2327452 diff --git a/backend/app/core/pesquisa/modelos_dai/MOD_V_TER_Z2_015C_PORTO_SECO.dai b/backend/app/core/pesquisa/modelos_dai/MOD_V_TER_Z2_015C_PORTO_SECO.dai deleted file mode 100644 index 23ed5a411da87afca2519115b081e4b0c59f6e3e..0000000000000000000000000000000000000000 --- a/backend/app/core/pesquisa/modelos_dai/MOD_V_TER_Z2_015C_PORTO_SECO.dai +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d71651a7f845c893895bfd6b73c4209b597e1ba78b4c15c0924987f360c89108 -size 3635197 diff --git a/backend/app/core/pesquisa/modelos_dai/MOD_V_TER_Z2_Z3_Z4_001H.dai b/backend/app/core/pesquisa/modelos_dai/MOD_V_TER_Z2_Z3_Z4_001H.dai deleted file mode 100644 index dce543e655c30851d7e71f05bda89411c3eb9c02..0000000000000000000000000000000000000000 --- a/backend/app/core/pesquisa/modelos_dai/MOD_V_TER_Z2_Z3_Z4_001H.dai +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:749c833d7cd4190ea7cd0453410c63701746c8ac34b76444f0be55970700b185 -size 1731659 diff --git a/backend/app/core/pesquisa/modelos_dai/MOD_V_TER_Z2_Z3_Z4_001i.dai b/backend/app/core/pesquisa/modelos_dai/MOD_V_TER_Z2_Z3_Z4_001i.dai deleted file mode 100644 index a365741505a7deabf1957e13ffddb784ca951924..0000000000000000000000000000000000000000 --- a/backend/app/core/pesquisa/modelos_dai/MOD_V_TER_Z2_Z3_Z4_001i.dai +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:fff0145b9990bb251401f85e5c1614fd84936801d6757482443987e1d880a099 -size 1969416 diff --git a/backend/app/core/pesquisa/modelos_dai/MOD_V_TER_Z2_Z3_Z4_002A.dai b/backend/app/core/pesquisa/modelos_dai/MOD_V_TER_Z2_Z3_Z4_002A.dai deleted file mode 100644 index dda8232448492c8ddaee9d1c95daae78ed41f410..0000000000000000000000000000000000000000 --- a/backend/app/core/pesquisa/modelos_dai/MOD_V_TER_Z2_Z3_Z4_002A.dai +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:762b80e17c476218ceb1f146e816c6b7a68f0011f35ec27f0535970602c5ea64 -size 1586112 diff --git a/backend/app/core/pesquisa/modelos_dai/MOD_V_TER_Z2_Z3_Z4_002B.dai b/backend/app/core/pesquisa/modelos_dai/MOD_V_TER_Z2_Z3_Z4_002B.dai deleted file mode 100644 index 89b71e76fe09554db551e7ebfd630fdc6fa3e76b..0000000000000000000000000000000000000000 --- a/backend/app/core/pesquisa/modelos_dai/MOD_V_TER_Z2_Z3_Z4_002B.dai +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3468371b064f43da5b39d992bcccdb8d7c698e5f127e8040932e1bdf40ddcc1d -size 1998609 diff --git a/backend/app/core/pesquisa/modelos_dai/MOD_V_TER_Z4_003J.dai b/backend/app/core/pesquisa/modelos_dai/MOD_V_TER_Z4_003J.dai deleted file mode 100644 index 81a1118a0fc9c064d4d67e459821bb94ac4e4605..0000000000000000000000000000000000000000 --- a/backend/app/core/pesquisa/modelos_dai/MOD_V_TER_Z4_003J.dai +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:63059cf719829b1ab76852e6078b4d2808b6da734fa780e429bd852d000a24f6 -size 1559917 diff --git a/backend/app/core/pesquisa/modelos_dai/MOD_V_TER_Z4_016E.dai b/backend/app/core/pesquisa/modelos_dai/MOD_V_TER_Z4_016E.dai deleted file mode 100644 index 32f8807c3937567a1d8832187526ae9858105088..0000000000000000000000000000000000000000 --- a/backend/app/core/pesquisa/modelos_dai/MOD_V_TER_Z4_016E.dai +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ddbe47357238199b28515d42b6cc2730df5b9158b999431515947ae74cd73cdf -size 5780373 diff --git a/backend/app/core/pesquisa/modelos_dai/MOD_V_TER_Z5_007C.dai b/backend/app/core/pesquisa/modelos_dai/MOD_V_TER_Z5_007C.dai deleted file mode 100644 index 214a586b3bf9eab8e2b02dd007c508dd949fc940..0000000000000000000000000000000000000000 --- a/backend/app/core/pesquisa/modelos_dai/MOD_V_TER_Z5_007C.dai +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:393b92b31396916b0cc500983649592f5ab0bdb6ed6d521b3abed2b9e5cf5235 -size 1372051 diff --git a/backend/app/core/pesquisa/modelos_dai/README.md b/backend/app/core/pesquisa/modelos_dai/README.md index 20dfa2c2f6371adf54d1d42d879a66b56b1eaab0..f38cb54aa58201bb61bd156d7a765c2fc24de9f2 100644 --- a/backend/app/core/pesquisa/modelos_dai/README.md +++ b/backend/app/core/pesquisa/modelos_dai/README.md @@ -5,6 +5,11 @@ como `local` (`MODELOS_REPOSITORIO_PROVIDER=local`). Coloque nesta pasta os arquivos `.dai` que devem aparecer na aba **Pesquisa**. +Quando o projeto for espelhado em um repositório GitHub para gerar o build +portátil, os arquivos `.dai` nao devem ser versionados. Nesse fluxo, eles podem +ser mantidos apenas localmente ou copiados manualmente para a pasta externa +configurada em `paths.models_dir` no `config/appsettings.json`. + ## Estrutura - `NOME_MODELO.dai` diff --git a/backend/app/core/visualizacao/app.py b/backend/app/core/visualizacao/app.py index a860eb06e8ba5b64dc281bce503910e64f7a5f7d..7b4c1319255984c6955a64c2de0da8aa6cb884d1 100644 --- a/backend/app/core/visualizacao/app.py +++ b/backend/app/core/visualizacao/app.py @@ -1,7 +1,8 @@ +from __future__ import annotations + # ============================================================ # IMPORTAÇÕES # ============================================================ -import gradio as gr import pandas as pd import numpy as np import folium @@ -13,6 +14,23 @@ import traceback from datetime import datetime from html import escape +from app.runtime_config import resolve_core_path + +try: + import gradio as gr +except Exception: # pragma: no cover - runtime portatil nao precisa da UI gradio + class _GradioPlaceholder: + class SelectData: + pass + + @staticmethod + def update(*args, **kwargs): + raise RuntimeError("Gradio indisponivel neste runtime") + + def __getattr__(self, name: str): + raise RuntimeError(f"Gradio indisponivel neste runtime: {name}") + + gr = _GradioPlaceholder() # Importações para gráficos (trazidas de graficos.py) from scipy import stats @@ -50,7 +68,7 @@ COR_LINHA = '#dc3545' # Vermelho para linhas de referência # ============================================================ def carregar_css(): """Carrega o arquivo CSS externo.""" - css_path = os.path.join(os.path.dirname(__file__), "styles.css") + css_path = resolve_core_path("visualizacao", "styles.css") try: with open(css_path, "r", encoding="utf-8") as f: return f.read() diff --git a/backend/app/main.py b/backend/app/main.py index f6d4884719ae6c79ae1036e64f710fbfac5208a7..0eb19c4d46408629957eda43e00adf0699a4c612 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -8,9 +8,12 @@ from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles from app.api import auth, elaboracao, health, logs, pesquisa, repositorio, session, trabalhos_tecnicos, visualizacao +from app.runtime_config import apply_runtime_config, resolve_frontend_dist_dir from app.services import auth_service +apply_runtime_config() + app = FastAPI( title="MESA Frame API", version="1.0.0", @@ -58,7 +61,7 @@ app.include_router(logs.router) def _mount_frontend_if_exists() -> None: - frontend_dist = Path(__file__).resolve().parents[2] / "frontend" / "dist" + frontend_dist = resolve_frontend_dist_dir() index_file = frontend_dist / "index.html" if not index_file.exists(): return diff --git a/backend/app/portable_launcher.py b/backend/app/portable_launcher.py new file mode 100644 index 0000000000000000000000000000000000000000..46360f0348c819dfc55cd8ee1cd522f6cabd7b65 --- /dev/null +++ b/backend/app/portable_launcher.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import socket +import threading +import time +import urllib.request +import webbrowser + +import uvicorn + +from app.runtime_config import RuntimeSettings, apply_runtime_config + + +def _first_available_port(host: str, preferred_port: int, max_attempts: int = 20) -> int: + for offset in range(max_attempts): + port = preferred_port + offset + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + sock.bind((host, port)) + except OSError: + continue + return port + raise RuntimeError(f"Nao foi possivel reservar uma porta local a partir de {preferred_port}") + + +def _wait_for_server_and_open_browser(url: str, timeout_s: float = 45.0) -> None: + health_url = f"{url.rstrip('/')}/api/health" + deadline = time.time() + timeout_s + while time.time() < deadline: + try: + with urllib.request.urlopen(health_url, timeout=1.5) as response: + if response.status == 200: + webbrowser.open(url) + return + except Exception: + time.sleep(0.5) + + +def _build_server(settings: RuntimeSettings) -> tuple[uvicorn.Server, str]: + port = _first_available_port(settings.host, settings.port) + url = f"http://{settings.host}:{port}" + + from app.main import app + + config = uvicorn.Config( + app=app, + host=settings.host, + port=port, + reload=False, + log_level="info", + ) + return uvicorn.Server(config), url + + +def main() -> int: + settings = apply_runtime_config() + server, url = _build_server(settings) + + print(f"[mesa] iniciando servidor local em {url}") + if settings.config_path is not None: + print(f"[mesa] configuracao carregada de {settings.config_path}") + print(f"[mesa] runtime local em {settings.runtime_dir}") + + if settings.open_browser: + opener = threading.Thread(target=_wait_for_server_and_open_browser, args=(url,), daemon=True) + opener.start() + + try: + server.run() + except KeyboardInterrupt: + print("[mesa] encerrando aplicativo") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/app/runtime_config.py b/backend/app/runtime_config.py new file mode 100644 index 0000000000000000000000000000000000000000..d0650a9a98a9842dca9de8765edc9a1a5e5a6ef1 --- /dev/null +++ b/backend/app/runtime_config.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +import json +import os +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +_APPLIED_SETTINGS: "RuntimeSettings | None" = None + + +@dataclass(frozen=True) +class RuntimeSettings: + config_path: Path | None + app_root: Path + runtime_dir: Path + host: str + port: int + open_browser: bool + + +def resolve_app_root() -> Path: + if getattr(sys, "frozen", False): + return Path(sys.executable).resolve().parent + return Path(__file__).resolve().parents[2] + + +def resolve_bundle_root() -> Path: + meipass = getattr(sys, "_MEIPASS", None) + if meipass: + return Path(str(meipass)).resolve() + if getattr(sys, "frozen", False): + return Path(sys.executable).resolve().parent + return resolve_app_root() + + +def resolve_frontend_dist_dir() -> Path: + base = resolve_bundle_root() if getattr(sys, "frozen", False) else resolve_app_root() + return base / "frontend" / "dist" + + +def resolve_core_path(*parts: str) -> Path: + if getattr(sys, "frozen", False): + return resolve_bundle_root().joinpath("app", "core", *parts) + return resolve_app_root().joinpath("backend", "app", "core", *parts) + + +def resolve_local_data_path(*parts: str) -> Path: + if getattr(sys, "frozen", False): + return resolve_bundle_root().joinpath("local_data", *parts) + return resolve_app_root().joinpath("backend", "local_data", *parts) + + +def _default_runtime_dir() -> Path: + app_name = "MesaFrame" + if sys.platform == "win32": + base = Path(str(os.getenv("LOCALAPPDATA") or Path.home() / "AppData" / "Local")) + return base / app_name + if sys.platform == "darwin": + return Path.home() / "Library" / "Application Support" / app_name + return Path(str(os.getenv("XDG_STATE_HOME") or (Path.home() / ".local" / "state"))) / app_name + + +def _expand_path(value: Any, base_dir: Path | None = None) -> Path | None: + text = str(value or "").strip() + if not text: + return None + expanded = os.path.expandvars(text) + path = Path(expanded).expanduser() + if not path.is_absolute(): + anchor = base_dir or resolve_app_root() + path = anchor / path + return path.resolve() + + +def _as_bool(value: Any, default: bool) -> bool: + if value is None: + return default + text = str(value).strip().lower() + if text in {"1", "true", "yes", "y", "on", "sim"}: + return True + if text in {"0", "false", "no", "n", "off", "nao", "não"}: + return False + return default + + +def _as_int(value: Any, default: int) -> int: + if value is None: + return default + try: + parsed = int(str(value).strip()) + except Exception: + return default + return parsed if parsed > 0 else default + + +def _read_config_file(config_path: Path | None) -> dict[str, Any]: + if config_path is None or not config_path.exists(): + return {} + try: + payload = json.loads(config_path.read_text(encoding="utf-8")) + except Exception as exc: # pragma: no cover - surfaced to caller in packaged runtime + raise RuntimeError(f"Falha ao ler arquivo de configuracao '{config_path}': {exc}") from exc + if not isinstance(payload, dict): + raise RuntimeError(f"Arquivo de configuracao invalido: '{config_path}'") + return payload + + +def _default_config_path(app_root: Path) -> Path: + return app_root / "config" / "appsettings.json" + + +def _set_env_if_value(key: str, value: Any) -> None: + text = str(value or "").strip() + if text: + os.environ[key] = text + + +def apply_runtime_config(config_path: str | Path | None = None) -> RuntimeSettings: + global _APPLIED_SETTINGS + if _APPLIED_SETTINGS is not None: + return _APPLIED_SETTINGS + + app_root = resolve_app_root() + config_source = config_path or os.getenv("MESA_APP_CONFIG") or _default_config_path(app_root) + config_file = _expand_path(config_source) + payload = _read_config_file(config_file) + config_base_dir = config_file.parent if config_file is not None else app_root + + server_cfg = payload.get("server") if isinstance(payload.get("server"), dict) else {} + paths_cfg = payload.get("paths") if isinstance(payload.get("paths"), dict) else {} + env_cfg = payload.get("env") if isinstance(payload.get("env"), dict) else {} + + runtime_dir = _expand_path(paths_cfg.get("runtime_dir"), base_dir=config_base_dir) or _default_runtime_dir() + runtime_dir.mkdir(parents=True, exist_ok=True) + (runtime_dir / "sessions").mkdir(parents=True, exist_ok=True) + (runtime_dir / "logs").mkdir(parents=True, exist_ok=True) + + os.environ.setdefault("MESA_RUNTIME_DIR", str(runtime_dir)) + + models_dir = _expand_path(paths_cfg.get("models_dir"), base_dir=config_base_dir) + if models_dir is not None: + os.environ["MODELOS_REPOSITORIO_PROVIDER"] = "local" + os.environ["MODELOS_REPOSITORIO_LOCAL_DIR"] = str(models_dir) + + trabalhos_db = _expand_path(paths_cfg.get("trabalhos_tecnicos_db"), base_dir=config_base_dir) + if trabalhos_db is not None: + os.environ["TRABALHOS_TECNICOS_PROVIDER"] = "local" + os.environ["TRABALHOS_TECNICOS_DB_LOCAL_PATH"] = str(trabalhos_db) + + users_file = _expand_path(paths_cfg.get("users_file"), base_dir=config_base_dir) + if users_file is not None: + os.environ["APP_USERS_FILE"] = str(users_file) + + logs_mode = env_cfg.get("APP_LOGS_MODE") + if logs_mode is None and getattr(sys, "frozen", False): + logs_mode = "disabled" + _set_env_if_value("APP_LOGS_MODE", logs_mode) + + for key, value in env_cfg.items(): + if key == "APP_LOGS_MODE": + continue + _set_env_if_value(key, value) + + settings = RuntimeSettings( + config_path=config_file if config_file and config_file.exists() else None, + app_root=app_root, + runtime_dir=runtime_dir, + host=str(server_cfg.get("host") or "127.0.0.1").strip() or "127.0.0.1", + port=_as_int(server_cfg.get("port"), 8000), + open_browser=_as_bool(server_cfg.get("open_browser"), True), + ) + _APPLIED_SETTINGS = settings + return settings diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index 42409823f368e2679abeb895bc508c5c995038dc..82c9b1d7231d25f081ae00c6363fe425c0189f91 100644 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -10,7 +10,10 @@ from typing import Any from fastapi import HTTPException, Request -DEFAULT_USERS_FILE = Path(__file__).resolve().parent.parent / "core" / "auth" / "usuarios.json" +from app.runtime_config import resolve_core_path + + +DEFAULT_USERS_FILE = resolve_core_path("auth", "usuarios.json") _USERS_LOCK = Lock() _SESSIONS_LOCK = Lock() diff --git a/backend/app/services/elaboracao_service.py b/backend/app/services/elaboracao_service.py index da052f52a5d51f3d4e553b889affa2e39aeb0ee1..78e1eb9381d361c1ccfa70d09cacdde37eab115c 100644 --- a/backend/app/services/elaboracao_service.py +++ b/backend/app/services/elaboracao_service.py @@ -54,12 +54,13 @@ from app.core.elaboracao.formatadores import ( formatar_outliers_anteriores_html, ) from app.models.session import SessionState +from app.runtime_config import resolve_core_path from app.services import model_repository from app.services.equacao_service import build_equacoes_payload, exportar_planilha_equacao from app.services.knn_avaliacao_service import estimar_valor_knn_avaliacao from app.services.serializers import dataframe_to_payload, figure_to_payload, sanitize_value -_AVALIADORES_PATH = Path(__file__).resolve().parent.parent / "core" / "elaboracao" / "avaliadores.json" +_AVALIADORES_PATH = resolve_core_path("elaboracao", "avaliadores.json") _AVALIADORES_CACHE: list[dict[str, Any]] | None = None LIMIAR_DISPERSAO_PNG = 1500 LOGGER = logging.getLogger(__name__) diff --git a/backend/app/services/model_repository.py b/backend/app/services/model_repository.py index af015c033c185a79c6b6fa33bd3656bbda771797..7a0237e15967ff82c7a18f0b20c55ee35644fc17 100644 --- a/backend/app/services/model_repository.py +++ b/backend/app/services/model_repository.py @@ -10,6 +10,7 @@ from threading import Lock from typing import Any from fastapi import HTTPException +from app.runtime_config import resolve_core_path try: from huggingface_hub import CommitOperationAdd, CommitOperationDelete, HfApi, snapshot_download @@ -20,7 +21,7 @@ except Exception: # pragma: no cover - dependência opcional em tempo de import snapshot_download = None # type: ignore[assignment] -DEFAULT_LOCAL_MODELOS_DIR = Path(__file__).resolve().parent.parent / "core" / "pesquisa" / "modelos_dai" +DEFAULT_LOCAL_MODELOS_DIR = resolve_core_path("pesquisa", "modelos_dai") DEFAULT_HF_REPO_ID = "gui-sparim/repositorio_mesa" DEFAULT_HF_REVISION = "main" DEFAULT_HF_SUBDIR = "modelos_dai" diff --git a/backend/app/services/session_store.py b/backend/app/services/session_store.py index b292b945737a96120697b14d1ffc4f98e88a5783..e0edd110faf175c287f62fb90a3fd88d099cf9bf 100644 --- a/backend/app/services/session_store.py +++ b/backend/app/services/session_store.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os import shutil import tempfile import uuid @@ -16,7 +17,12 @@ class SessionStore: def create(self) -> SessionState: session_id = uuid.uuid4().hex - workdir = Path(tempfile.mkdtemp(prefix=f"mesa_{session_id[:8]}_")) + runtime_root = str(os.getenv("MESA_RUNTIME_DIR") or "").strip() + temp_root = None + if runtime_root: + temp_root = Path(runtime_root).expanduser().resolve() / "sessions" + temp_root.mkdir(parents=True, exist_ok=True) + workdir = Path(tempfile.mkdtemp(prefix=f"mesa_{session_id[:8]}_", dir=str(temp_root) if temp_root else None)) state = SessionState(session_id=session_id, workdir=workdir) self._sessions[session_id] = state return state diff --git a/backend/app/services/trabalhos_tecnicos_repository.py b/backend/app/services/trabalhos_tecnicos_repository.py index 1e8f2f33449fdaf1d1aa227be01d08b1f2e3dc0a..0db5fe9b271c00e067ec888656cfe989bf76c34f 100644 --- a/backend/app/services/trabalhos_tecnicos_repository.py +++ b/backend/app/services/trabalhos_tecnicos_repository.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from pathlib import Path from fastapi import HTTPException +from app.runtime_config import resolve_local_data_path try: from huggingface_hub import HfApi, hf_hub_download @@ -58,7 +59,7 @@ def _local_db_path() -> Path: raw = str(os.getenv("TRABALHOS_TECNICOS_DB_LOCAL_PATH") or "").strip() if raw: return Path(raw).expanduser().resolve() - return (Path(__file__).resolve().parents[2] / "local_data" / DEFAULT_LOCAL_DB_FILE).resolve() + return resolve_local_data_path(DEFAULT_LOCAL_DB_FILE).resolve() def _hf_repo_id() -> str: diff --git a/backend/portable_app.py b/backend/portable_app.py new file mode 100644 index 0000000000000000000000000000000000000000..26f71de03f58c82f5baf9a5986c6a3b579bdad74 --- /dev/null +++ b/backend/portable_app.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from app.portable_launcher import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/windows/appsettings.example.json b/build/windows/appsettings.example.json new file mode 100644 index 0000000000000000000000000000000000000000..445aa12acb845836b31f807426c0c1bf9941017d --- /dev/null +++ b/build/windows/appsettings.example.json @@ -0,0 +1,16 @@ +{ + "server": { + "host": "127.0.0.1", + "port": 8000, + "open_browser": true + }, + "paths": { + "runtime_dir": "%LOCALAPPDATA%\\MesaFrame", + "models_dir": "\\\\servidor\\mesa\\dados\\modelos_dai", + "trabalhos_tecnicos_db": "\\\\servidor\\mesa\\dados\\trabalhos_tecnicos\\trabalhos_tecnicos.sqlite3", + "users_file": "\\\\servidor\\mesa\\dados\\usuarios\\usuarios.json" + }, + "env": { + "APP_LOGS_MODE": "disabled" + } +} diff --git a/build/windows/build_portable.ps1 b/build/windows/build_portable.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..848572a523712043890b9c3abac72e4d0aa292a7 --- /dev/null +++ b/build/windows/build_portable.ps1 @@ -0,0 +1,29 @@ +$ErrorActionPreference = "Stop" + +$projectRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +Set-Location $projectRoot + +Write-Host "[mesa] build frontend" +Push-Location frontend +npm ci +npm run build +Pop-Location + +Write-Host "[mesa] install backend dependencies" +python -m pip install --upgrade pip +python -m pip install -r backend/requirements.txt +python -m pip install pyinstaller + +Write-Host "[mesa] build portable folder" +python -m PyInstaller build/windows/mesa_frame_portable.spec --noconfirm --clean + +Write-Host "[mesa] provision external config folder" +New-Item -ItemType Directory -Force -Path "dist/MesaFrame/config" | Out-Null +Copy-Item "build/windows/appsettings.example.json" "dist/MesaFrame/config/appsettings.example.json" -Force + +if (Test-Path "dist/MesaFrame-portable.zip") { + Remove-Item "dist/MesaFrame-portable.zip" -Force +} + +Write-Host "[mesa] archive portable folder" +Compress-Archive -Path "dist/MesaFrame/*" -DestinationPath "dist/MesaFrame-portable.zip" diff --git a/build/windows/mesa_frame_portable.spec b/build/windows/mesa_frame_portable.spec new file mode 100644 index 0000000000000000000000000000000000000000..1b867a1afb34402ac00c70db7215adaca65ca9f5 --- /dev/null +++ b/build/windows/mesa_frame_portable.spec @@ -0,0 +1,82 @@ +# -*- mode: python ; coding: utf-8 -*- + +from pathlib import Path + +from PyInstaller.utils.hooks import collect_data_files, collect_submodules + + +PROJECT_ROOT = Path.cwd().resolve() +BACKEND_DIR = PROJECT_ROOT / "backend" +LOCAL_DATA_DIR = PROJECT_ROOT / "backend" / "local_data" + +datas = [ + (str(PROJECT_ROOT / "frontend" / "dist"), "frontend/dist"), + (str(PROJECT_ROOT / "backend" / "app" / "core"), "app/core"), +] +if LOCAL_DATA_DIR.exists(): + datas.append((str(LOCAL_DATA_DIR), "local_data")) +datas += collect_data_files("safehttpx") +datas += collect_data_files("gradio") +datas += collect_data_files("gradio_client") + +hiddenimports = [] +hiddenimports += collect_submodules("app") +hiddenimports += [ + "uvicorn.loops.auto", + "uvicorn.protocols.http.auto", + "uvicorn.protocols.websockets.auto", + "uvicorn.lifespan.on", +] + +a = Analysis( + [str(PROJECT_ROOT / "backend" / "portable_app.py")], + pathex=[str(BACKEND_DIR)], + binaries=[], + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[ + "IPython", + "PyQt5", + "PyQt6", + "PySide2", + "PySide6", + "jedi", + "matplotlib", + "numpy.tests", + "pandas.tests", + "pytest", + "geopandas.tests", + "geopandas.io.tests", + "scipy.tests", + "statsmodels.tests", + "tkinter", + "torch", + ], + noarchive=False, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name="MesaFrame", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + console=True, +) + +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=False, + name="MesaFrame", +) diff --git a/build/windows/smoke_test_portable.ps1 b/build/windows/smoke_test_portable.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..abdc51a2efa71e1f38a80c97ce362484ac238fe3 --- /dev/null +++ b/build/windows/smoke_test_portable.ps1 @@ -0,0 +1,101 @@ +$ErrorActionPreference = "Stop" + +param( + [string]$AppDir = "dist/MesaFrame", + [int]$Port = 8876 +) + +$appDirResolved = (Resolve-Path $AppDir).Path +$configDir = Join-Path $appDirResolved "config" +$runtimeDir = Join-Path $appDirResolved "runtime" +$exePath = Join-Path $appDirResolved "MesaFrame.exe" +$usersPath = Join-Path $configDir "smoke-users.json" +$settingsPath = Join-Path $configDir "appsettings.json" +$rootUrl = "http://127.0.0.1:$Port" +$healthUrl = "$rootUrl/api/health" +$loginUrl = "$rootUrl/api/auth/login" +$meUrl = "$rootUrl/api/auth/me" + +New-Item -ItemType Directory -Force -Path $configDir | Out-Null +New-Item -ItemType Directory -Force -Path $runtimeDir | Out-Null + +@' +{ + "usuarios": [ + { + "usuario": "smoke", + "matricula": "123456", + "nome": "Smoke Test", + "perfil": "admin" + } + ] +} +'@ | Set-Content -Path $usersPath -Encoding UTF8 + +$appSettings = @{ + server = @{ + host = "127.0.0.1" + port = $Port + open_browser = $false + } + paths = @{ + runtime_dir = "../runtime" + users_file = "./smoke-users.json" + } + env = @{ + APP_LOGS_MODE = "disabled" + } +} + +$appSettings | ConvertTo-Json -Depth 10 | Set-Content -Path $settingsPath -Encoding UTF8 + +if (-not (Test-Path $exePath)) { + throw "Executavel nao encontrado: $exePath" +} + +$proc = Start-Process -FilePath $exePath -WorkingDirectory $appDirResolved -PassThru + +try { + $ok = $false + for ($i = 0; $i -lt 60; $i++) { + Start-Sleep -Seconds 1 + try { + $resp = Invoke-RestMethod -Uri $healthUrl -TimeoutSec 2 + if ($resp.status -eq "ok") { + $ok = $true + break + } + } catch { + } + } + + if (-not $ok) { + throw "Smoke test failed: /api/health did not respond in time." + } + + $rootResp = Invoke-WebRequest -Uri $rootUrl -UseBasicParsing -TimeoutSec 5 + if ($rootResp.StatusCode -ne 200) { + throw "Smoke test failed: root path returned status $($rootResp.StatusCode)." + } + + $loginBody = @{ + usuario = "smoke" + matricula = "123456" + } | ConvertTo-Json + + $loginResp = Invoke-RestMethod -Uri $loginUrl -Method Post -ContentType "application/json" -Body $loginBody -TimeoutSec 5 + $token = [string]$loginResp.token + if ([string]::IsNullOrWhiteSpace($token)) { + throw "Smoke test failed: login did not return a token." + } + + $meResp = Invoke-RestMethod -Uri $meUrl -Headers @{ "X-Auth-Token" = $token } -TimeoutSec 5 + if ($meResp.usuario.usuario -ne "smoke") { + throw "Smoke test failed: /api/auth/me returned unexpected user." + } +} +finally { + if ($proc -and -not $proc.HasExited) { + Stop-Process -Id $proc.Id -Force + } +} diff --git a/frontend/src/api.js b/frontend/src/api.js index 8b9e076e745da1359c781f8e6012fba948028ccc..f59e7c17be0b390c133c64373cb58eddf5eda5a1 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1,4 +1,12 @@ -const API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000' +function normalizeApiBase(value) { + if (value === undefined || value === null) return null + const text = String(value).trim() + if (!text || text === '.' || text === './') return '' + return text.endsWith('/') ? text.slice(0, -1) : text +} + +const API_BASE = normalizeApiBase(import.meta.env.VITE_API_BASE) + ?? (import.meta.env.DEV ? 'http://localhost:8000' : '') const AUTH_TOKEN_STORAGE_KEY = 'mesa_auth_token' let AUTH_TOKEN = ''