Files

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()