197 lines
7.1 KiB
Python
197 lines
7.1 KiB
Python
"""
|
|
AR-ElecArrangement — entrypoint del servidor.
|
|
|
|
Lanza FastAPI/uvicorn en ``0.0.0.0:5505`` (accesible desde la LAN para que
|
|
los tablets se conecten) y sirve el frontend compilado de ``frontend/dist/``.
|
|
|
|
Cuando se ejecuta como ``python -m backend.main`` o desde el ``.exe`` empacado
|
|
por PyInstaller, después de iniciar el server espera 1 s y abre el browser
|
|
predeterminado apuntando a ``http://localhost:5505``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import webbrowser
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import uvicorn
|
|
from fastapi import FastAPI, HTTPException, UploadFile, File
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.middleware.gzip import GZipMiddleware
|
|
from fastapi.responses import FileResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
from arelec import __version__
|
|
from arelec.core.project import Project
|
|
|
|
# ── Paths ────────────────────────────────────────────────────────────────────
|
|
# En desarrollo: __file__ = backend/main.py → BACKEND_DIR = backend/
|
|
# Empacado: __file__ está dentro de _MEIPASS → BACKEND_DIR = sys._MEIPASS/backend
|
|
if getattr(sys, "frozen", False):
|
|
BASE_DIR = Path(sys._MEIPASS) # type: ignore[attr-defined]
|
|
else:
|
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
|
|
|
FRONTEND_DIST = BASE_DIR / "frontend" / "dist"
|
|
DATA_DIR = BASE_DIR / "data"
|
|
|
|
HOST = "0.0.0.0"
|
|
PORT = 5505
|
|
|
|
|
|
# ── FastAPI app ──────────────────────────────────────────────────────────────
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
"""Hook de arranque/apagado del server."""
|
|
print(f"[arelec] Servidor iniciado en http://localhost:{PORT} (v{__version__})")
|
|
print(f"[arelec] Tablets en LAN: http://<ip-de-este-pc>:{PORT}")
|
|
yield
|
|
print("[arelec] Servidor detenido.")
|
|
|
|
|
|
app = FastAPI(
|
|
title="AR-ElecArrangement",
|
|
version=__version__,
|
|
description="Diseño eléctrico de buques — backend",
|
|
lifespan=lifespan,
|
|
)
|
|
|
|
# Acceso LAN sin restricciones (la app está pensada para LAN privada del usuario,
|
|
# no para Internet pública). Si en el futuro se ofrece cloud, restringir aquí.
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"],
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
app.add_middleware(GZipMiddleware, minimum_size=1024)
|
|
|
|
|
|
# ── API ──────────────────────────────────────────────────────────────────────
|
|
@app.get("/api/health")
|
|
def health() -> dict[str, str]:
|
|
"""Heartbeat para clientes que verifican que el server está vivo."""
|
|
return {"status": "ok", "version": __version__}
|
|
|
|
|
|
# ── Project I/O (Sprint 0 — sólo metadata, expandirá en Sprint 1+) ──────────
|
|
@app.post("/api/project/new")
|
|
def project_new() -> dict[str, Any]:
|
|
"""Crear un proyecto vacío y devolver su JSON serializado."""
|
|
return Project().to_json()
|
|
|
|
|
|
@app.post("/api/project/open")
|
|
async def project_open(file: UploadFile = File(...)) -> dict[str, Any]:
|
|
"""
|
|
Abrir un archivo ``.area`` subido por el cliente. Lo guarda en un tempfile,
|
|
lo carga vía ``Project.load``, devuelve el JSON serializado.
|
|
"""
|
|
if not (file.filename or "").lower().endswith(".area"):
|
|
raise HTTPException(400, "El archivo debe tener extensión .area")
|
|
data = await file.read()
|
|
if not data:
|
|
raise HTTPException(400, "Archivo vacío")
|
|
with tempfile.NamedTemporaryFile(suffix=".area", delete=False) as tmp:
|
|
tmp.write(data)
|
|
tmp_path = Path(tmp.name)
|
|
try:
|
|
proj = Project.load(tmp_path)
|
|
return proj.to_json()
|
|
except Exception as e: # noqa: BLE001
|
|
# No exponemos str(e) al cliente para evitar filtrar rutas internas
|
|
# o detalles de implementación. El tipo de error es suficiente.
|
|
raise HTTPException(400, "No es un .area válido o el archivo está dañado.") from e
|
|
finally:
|
|
tmp_path.unlink(missing_ok=True)
|
|
|
|
|
|
_WINDOWS_RESERVED_RE = re.compile(
|
|
r"^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)", re.IGNORECASE
|
|
)
|
|
|
|
|
|
def _safe_filename(name: str) -> str:
|
|
"""Sanitiza el nombre del proyecto para uso seguro como nombre de archivo.
|
|
|
|
- Elimina caracteres no permitidos en Windows/POSIX (``/``, ``\\``, ``:``,
|
|
``*``, ``?``, ``"``, ``<``, ``>``, ``|``, bytes nulos).
|
|
- Colapsa espacios/guiones bajos múltiples consecutivos.
|
|
- Trunca a 200 caracteres para evitar rutas demasiado largas.
|
|
- Sustituye cadena vacía o nombres reservados de Windows por ``proyecto``.
|
|
"""
|
|
safe = re.sub(r'[\\/:*?"<>|\x00]', "_", name)
|
|
safe = re.sub(r"[_\s]{2,}", "_", safe).strip("_").strip()
|
|
safe = safe[:200]
|
|
if not safe or _WINDOWS_RESERVED_RE.match(safe):
|
|
safe = "proyecto"
|
|
return safe
|
|
|
|
|
|
@app.post("/api/project/save")
|
|
async def project_save(payload: dict[str, Any]) -> FileResponse:
|
|
"""
|
|
Recibe un proyecto en JSON, lo serializa a ``.area`` y lo devuelve para
|
|
descarga directa por el browser.
|
|
"""
|
|
proj = Project.from_json(payload)
|
|
out_dir = Path(tempfile.mkdtemp(prefix="arelec_save_"))
|
|
out_path = out_dir / (_safe_filename(proj.metadata.name) + ".area")
|
|
proj.save(out_path)
|
|
return FileResponse(
|
|
path=out_path,
|
|
media_type="application/zip",
|
|
filename=out_path.name,
|
|
)
|
|
|
|
|
|
# ── Frontend (montaje al final para que /api/* tenga precedencia) ────────────
|
|
if FRONTEND_DIST.exists():
|
|
app.mount("/", StaticFiles(directory=str(FRONTEND_DIST), html=True), name="frontend")
|
|
else:
|
|
@app.get("/")
|
|
def _no_frontend_msg() -> dict[str, str]:
|
|
return {
|
|
"warning": "frontend/dist no encontrado",
|
|
"hint": "Compilar frontend: cd frontend && npm install && npm run build",
|
|
}
|
|
|
|
|
|
# ── Auto-launch del browser cuando corre como .exe ───────────────────────────
|
|
def _open_browser_when_ready() -> None:
|
|
"""Espera 1 s y abre el browser default. Llamado solo desde .exe / __main__."""
|
|
time.sleep(1.0)
|
|
url = f"http://localhost:{PORT}"
|
|
try:
|
|
webbrowser.open(url)
|
|
except Exception as e: # noqa: BLE001
|
|
print(f"[arelec] No se pudo abrir el browser automáticamente: {e}")
|
|
print(f"[arelec] Abre manualmente: {url}")
|
|
|
|
|
|
def main() -> None:
|
|
"""Entrypoint cuando se corre como ``python -m backend.main`` o ``.exe``."""
|
|
# Solo abrir browser cuando no estamos en modo --reload (dev)
|
|
if os.environ.get("ARELEC_NO_BROWSER") != "1":
|
|
threading.Thread(target=_open_browser_when_ready, daemon=True).start()
|
|
|
|
uvicorn.run(
|
|
"backend.main:app" if not getattr(sys, "frozen", False) else app,
|
|
host=HOST,
|
|
port=PORT,
|
|
log_level="info",
|
|
reload=False,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|