orgm-bt 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- app/__init__.py +10 -0
- app/adapters/__init__.py +1 -0
- app/api.py +607 -0
- app/api_models.py +189 -0
- app/cli.py +522 -0
- app/config.py +96 -0
- app/db/__init__.py +3 -0
- app/db/alembic/env.py +95 -0
- app/db/alembic/versions/20250101000000_create_calc_api_tables.py +212 -0
- app/db/alembic/versions/90c3cdb1d7d5_create_empresas_and_proyectos_tables.py +56 -0
- app/db/alembic/versions/c6020725a319_seed_default_empresa_orgm.py +54 -0
- app/db/database.py +31 -0
- app/db/models.py +46 -0
- app/default_assets/memoria.svg +72 -0
- app/default_data/CEPM.json +1 -0
- app/default_data/MOPC.json +6242 -0
- app/default_data/NEC.json +1 -0
- app/default_data/aires_predeterminados.json +806 -0
- app/default_data/breakers.json +24 -0
- app/default_data/cargas_predeterminadas.json +821 -0
- app/default_data/categorias.json +10 -0
- app/default_data/circuitos_panel.json +52 -0
- app/default_data/conexiones_cables.json +190 -0
- app/default_data/estimacion_area.json +216 -0
- app/default_data/factores_demanda.json +961 -0
- app/default_data/motores_predeterminados.json +1055 -0
- app/default_data/nec_250_66.json +101 -0
- app/default_data/nema.json +36 -0
- app/default_data/norma.json +65 -0
- app/default_data/panel.json +178 -0
- app/default_data/proposito.json +21 -0
- app/du/alimentador.py +576 -0
- app/du/dibujante.py +400 -0
- app/du/du.py +997 -0
- app/du/du_json.py +433 -0
- app/importers/__init__.py +1 -0
- app/importers/csv_paneles.py +308 -0
- app/importers/csv_parser.py +156 -0
- app/importers/entity_detector.py +242 -0
- app/importers/errors.py +235 -0
- app/importers/hierarchy_builder.py +181 -0
- app/importers/mapping.py +213 -0
- app/local_config.py +94 -0
- app/models.py +105 -0
- app/paneles_editor.py +1789 -0
- app/runtime_paths.py +43 -0
- app/schemas.py +73 -0
- app/services/__init__.py +3 -0
- app/services/generador_pdf.py +6363 -0
- app/services/job_store.py +537 -0
- app/services/job_worker.py +457 -0
- app/services/local_operations.py +594 -0
- app/services/logo_url.py +83 -0
- app/services/project_store.py +301 -0
- app/services/redaction.py +42 -0
- app/services/remote_operations.py +445 -0
- app/services/validator.py +365 -0
- app/services/webdav_storage.py +277 -0
- app/static/css/templates/alimentadores.css +76 -0
- app/static/css/templates/cuadros-base.css +242 -0
- app/static/css/templates/electrodo.css +105 -0
- app/static/css/templates/equipos.css +117 -0
- app/static/css/templates/memoria.css +369 -0
- app/static/css/templates/menu.css +127 -0
- app/static/css/templates/mpm.css +148 -0
- app/static/css/templates/paneles.css +140 -0
- app/static/css/templates/print-content.css +74 -0
- app/static/css/templates/print-menu.css +38 -0
- app/static/css/templates/reportes-common.css +104 -0
- app/static/css/templates/trf.css +211 -0
- app/static/js/menu-reportes.js +261 -0
- app/templates/memoria-calculo.html +556 -0
- app/templates/menu.html +40 -0
- app/templates/template-alimentadores.html +91 -0
- app/templates/template-cuadro-distribucion.html +334 -0
- app/templates/template-electrodo.html +66 -0
- app/templates/template-equipos.html +491 -0
- app/templates/template-memoria.html +2378 -0
- app/templates/template-mpm.html +248 -0
- app/templates/template-paneles.html +101 -0
- app/templates/template-trf-standalone.html +89 -0
- app/templates/template-trf.html +604 -0
- app/utils/__init__.py +1 -0
- app/utils/aireacondicionado.py +57 -0
- app/utils/cables.py +2053 -0
- app/utils/carga.py +1897 -0
- app/utils/chromium_pdf.py +226 -0
- app/utils/circuitos.py +489 -0
- app/utils/comercio.py +28 -0
- app/utils/conduit_rules.py +178 -0
- app/utils/device_type_catalog.py +231 -0
- app/utils/electrodo.py +503 -0
- app/utils/equipos.py +1421 -0
- app/utils/factores_demanda.py +362 -0
- app/utils/firma.py +116 -0
- app/utils/load_semantics.py +53 -0
- app/utils/logger.py +296 -0
- app/utils/motores.py +69 -0
- app/utils/mpm.py +1504 -0
- app/utils/norma.py +331 -0
- app/utils/panel.py +4024 -0
- app/utils/pdf_output.py +45 -0
- app/utils/print_pdf.py +122 -0
- app/utils/residencial.py +565 -0
- app/utils/tablas.py +447 -0
- app/utils/trf.py +1690 -0
- app/utils/weasyprint_fetcher.py +89 -0
- app/utils/weasyprint_pdf.py +96 -0
- app/validators/__init__.py +1 -0
- app/validators/paneles_integrity.py +245 -0
- app/validators/paneles_schema.py +384 -0
- app/validators/paneles_validator.py +194 -0
- app/workspace.py +543 -0
- local.py +998 -0
- main.py +2904 -0
- orgm_bt-0.4.0.dist-info/METADATA +634 -0
- orgm_bt-0.4.0.dist-info/RECORD +122 -0
- orgm_bt-0.4.0.dist-info/WHEEL +5 -0
- orgm_bt-0.4.0.dist-info/entry_points.txt +2 -0
- orgm_bt-0.4.0.dist-info/top_level.txt +4 -0
- storage/__init__.py +3 -0
- storage/s3.py +191 -0
app/__init__.py
ADDED
app/adapters/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Adapters de paneles.json a modelos internos del motor."""
|
app/api.py
ADDED
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
"""HTTP API for persisted project configuration and serialized remote jobs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import stat
|
|
9
|
+
import tempfile
|
|
10
|
+
import uuid
|
|
11
|
+
from contextlib import asynccontextmanager
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path, PurePosixPath
|
|
14
|
+
from typing import Annotated, Any, Literal, Mapping
|
|
15
|
+
|
|
16
|
+
from fastapi import FastAPI, HTTPException, Query, Response, status
|
|
17
|
+
from fastapi import Path as ApiPath
|
|
18
|
+
from fastapi.responses import FileResponse
|
|
19
|
+
|
|
20
|
+
from app.api_models import (
|
|
21
|
+
DuRequest,
|
|
22
|
+
EmptyActionRequest,
|
|
23
|
+
HealthResponse,
|
|
24
|
+
InitRequest,
|
|
25
|
+
JobAccepted,
|
|
26
|
+
JobLogsResponse,
|
|
27
|
+
JobResponse,
|
|
28
|
+
ProjectCreate,
|
|
29
|
+
ProjectPatch,
|
|
30
|
+
ProjectResponse,
|
|
31
|
+
RemoteDirectoryResponse,
|
|
32
|
+
VersionResponse,
|
|
33
|
+
)
|
|
34
|
+
from app.config import Settings
|
|
35
|
+
from app.services.job_store import JobManifest, JobStatus, JobStore
|
|
36
|
+
from app.services.job_worker import JobWorker
|
|
37
|
+
from app.services.project_store import ProjectStore, normalize_webdav_path
|
|
38
|
+
from app.services.redaction import redact_text
|
|
39
|
+
from app.services.remote_operations import RemoteOperations
|
|
40
|
+
from app.services.webdav_storage import WebDAVStorage
|
|
41
|
+
|
|
42
|
+
_LOG_TAIL_BYTES = 16 * 1024
|
|
43
|
+
_MANIFEST_ERROR_BYTES = 4 * 1024
|
|
44
|
+
_ACTIONS = frozenset({"init", "calculate", "du", "print-pdf", "finalize"})
|
|
45
|
+
_UUID4_PATTERN = (
|
|
46
|
+
r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
|
|
47
|
+
)
|
|
48
|
+
UUID4Path = Annotated[str, ApiPath(pattern=_UUID4_PATTERN)]
|
|
49
|
+
NumericVersionPath = Annotated[str, ApiPath(pattern=r"^[0-9]+$")]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _UnavailableWebDAVStorage:
|
|
53
|
+
"""Safe degraded default until deployment credentials are configured."""
|
|
54
|
+
|
|
55
|
+
def check_connection(self) -> bool:
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
def __getattr__(self, _: str) -> Any:
|
|
59
|
+
raise RuntimeError("WebDAV storage is not configured")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def create_app(
|
|
63
|
+
*,
|
|
64
|
+
settings: Settings | None = None,
|
|
65
|
+
project_store: ProjectStore | None = None,
|
|
66
|
+
storage: Any | None = None,
|
|
67
|
+
job_store: JobStore | None = None,
|
|
68
|
+
executor: RemoteOperations | None = None,
|
|
69
|
+
worker: JobWorker | None = None,
|
|
70
|
+
) -> FastAPI:
|
|
71
|
+
"""Build an injectable API application with one managed job worker."""
|
|
72
|
+
runtime_settings = settings or Settings()
|
|
73
|
+
projects = project_store or ProjectStore(
|
|
74
|
+
runtime_settings.CALC_DATA_DIR / "local.json"
|
|
75
|
+
)
|
|
76
|
+
if storage is None:
|
|
77
|
+
try:
|
|
78
|
+
storage = WebDAVStorage(runtime_settings)
|
|
79
|
+
except ValueError:
|
|
80
|
+
storage = _UnavailableWebDAVStorage()
|
|
81
|
+
jobs = job_store or (
|
|
82
|
+
worker.store
|
|
83
|
+
if worker is not None
|
|
84
|
+
else JobStore(
|
|
85
|
+
runtime_settings.CALC_JOB_DIR,
|
|
86
|
+
retention_days=runtime_settings.CALC_JOB_RETENTION_DAYS,
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
remote = executor or RemoteOperations(
|
|
90
|
+
projects, storage, jobs, settings=runtime_settings
|
|
91
|
+
)
|
|
92
|
+
job_worker = worker or JobWorker(
|
|
93
|
+
jobs,
|
|
94
|
+
remote.run_stage,
|
|
95
|
+
remote.upload_stage,
|
|
96
|
+
success_stage=remote.record_success,
|
|
97
|
+
failure_stage=remote.record_failure,
|
|
98
|
+
error_sanitizer=remote.sanitize_error,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
@asynccontextmanager
|
|
102
|
+
async def lifespan(app: FastAPI):
|
|
103
|
+
await asyncio.to_thread(projects.ensure)
|
|
104
|
+
await job_worker.start()
|
|
105
|
+
try:
|
|
106
|
+
yield
|
|
107
|
+
finally:
|
|
108
|
+
await job_worker.stop()
|
|
109
|
+
|
|
110
|
+
app = FastAPI(title=runtime_settings.PROJECT_NAME, lifespan=lifespan)
|
|
111
|
+
app.state.settings = runtime_settings
|
|
112
|
+
app.state.project_store = projects
|
|
113
|
+
app.state.storage = storage
|
|
114
|
+
app.state.job_store = jobs
|
|
115
|
+
app.state.executor = remote
|
|
116
|
+
app.state.worker = job_worker
|
|
117
|
+
prefix = runtime_settings.API_V1_PREFIX.rstrip("/")
|
|
118
|
+
|
|
119
|
+
@app.get(f"{prefix}/healthz", response_model=HealthResponse)
|
|
120
|
+
async def healthz() -> HealthResponse:
|
|
121
|
+
data_ok, jobs_ok = await asyncio.gather(
|
|
122
|
+
asyncio.to_thread(_directory_writable, runtime_settings.CALC_DATA_DIR),
|
|
123
|
+
asyncio.to_thread(_directory_writable, runtime_settings.CALC_JOB_DIR),
|
|
124
|
+
)
|
|
125
|
+
try:
|
|
126
|
+
webdav_ok = await asyncio.to_thread(storage.check_connection)
|
|
127
|
+
except Exception:
|
|
128
|
+
webdav_ok = False
|
|
129
|
+
return HealthResponse(
|
|
130
|
+
status="ok" if data_ok and jobs_ok and webdav_ok else "degraded",
|
|
131
|
+
data_writable=data_ok,
|
|
132
|
+
jobs_writable=jobs_ok,
|
|
133
|
+
webdav=webdav_ok,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
@app.get(f"{prefix}/projects", response_model=list[ProjectResponse])
|
|
137
|
+
async def list_projects() -> list[ProjectResponse]:
|
|
138
|
+
try:
|
|
139
|
+
return [
|
|
140
|
+
_project_response(item)
|
|
141
|
+
for item in await asyncio.to_thread(projects.list)
|
|
142
|
+
]
|
|
143
|
+
except Exception:
|
|
144
|
+
raise HTTPException(
|
|
145
|
+
status_code=500, detail="Project configuration is unavailable"
|
|
146
|
+
) from None
|
|
147
|
+
|
|
148
|
+
@app.get(f"{prefix}/folders", response_model=list[RemoteDirectoryResponse])
|
|
149
|
+
async def list_folders(
|
|
150
|
+
path: Annotated[str, Query(min_length=1)] = "Proyectos",
|
|
151
|
+
) -> list[RemoteDirectoryResponse]:
|
|
152
|
+
try:
|
|
153
|
+
canonical_path = normalize_webdav_path(path)
|
|
154
|
+
if not PurePosixPath(canonical_path).is_relative_to(
|
|
155
|
+
PurePosixPath("Proyectos")
|
|
156
|
+
):
|
|
157
|
+
raise ValueError("Folder path must be within Proyectos")
|
|
158
|
+
entries = await asyncio.to_thread(storage.list_directory, canonical_path)
|
|
159
|
+
except ValueError as error:
|
|
160
|
+
raise HTTPException(status_code=422, detail=str(error)) from None
|
|
161
|
+
except Exception:
|
|
162
|
+
raise HTTPException(
|
|
163
|
+
status_code=502, detail="Could not list remote folders"
|
|
164
|
+
) from None
|
|
165
|
+
return [
|
|
166
|
+
RemoteDirectoryResponse(path=entry_path, name=entry_path.rsplit("/", 1)[-1])
|
|
167
|
+
for entry_path, entry_type in entries
|
|
168
|
+
if entry_type == "directory"
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
@app.post(
|
|
172
|
+
f"{prefix}/projects",
|
|
173
|
+
response_model=ProjectResponse,
|
|
174
|
+
status_code=status.HTTP_201_CREATED,
|
|
175
|
+
)
|
|
176
|
+
async def create_project(payload: ProjectCreate) -> ProjectResponse:
|
|
177
|
+
try:
|
|
178
|
+
project = payload.model_dump(exclude_none=True)
|
|
179
|
+
project["fecha_creacion"] = datetime.now(timezone.utc).isoformat()
|
|
180
|
+
created = await asyncio.to_thread(projects.create, project)
|
|
181
|
+
return _project_response(created)
|
|
182
|
+
except ValueError as error:
|
|
183
|
+
raise HTTPException(status_code=422, detail=str(error)) from None
|
|
184
|
+
except Exception:
|
|
185
|
+
raise HTTPException(
|
|
186
|
+
status_code=500, detail="Could not create project"
|
|
187
|
+
) from None
|
|
188
|
+
|
|
189
|
+
@app.get(f"{prefix}/projects/{{project_id}}", response_model=ProjectResponse)
|
|
190
|
+
async def get_project(project_id: UUID4Path) -> ProjectResponse:
|
|
191
|
+
project = await _get_project(projects, project_id)
|
|
192
|
+
return _project_response(project)
|
|
193
|
+
|
|
194
|
+
@app.patch(f"{prefix}/projects/{{project_id}}", response_model=ProjectResponse)
|
|
195
|
+
async def patch_project(
|
|
196
|
+
project_id: UUID4Path, payload: ProjectPatch
|
|
197
|
+
) -> ProjectResponse:
|
|
198
|
+
canonical_id = _uuid4_or_422(project_id)
|
|
199
|
+
changes = payload.model_dump(exclude_unset=True, exclude_none=False)
|
|
200
|
+
if not changes:
|
|
201
|
+
raise HTTPException(
|
|
202
|
+
status_code=422, detail="Patch body must contain at least one field"
|
|
203
|
+
)
|
|
204
|
+
try:
|
|
205
|
+
updated = await asyncio.to_thread(projects.update, canonical_id, changes)
|
|
206
|
+
except ValueError as error:
|
|
207
|
+
raise HTTPException(status_code=422, detail=str(error)) from None
|
|
208
|
+
except Exception:
|
|
209
|
+
raise HTTPException(
|
|
210
|
+
status_code=500, detail="Could not update project"
|
|
211
|
+
) from None
|
|
212
|
+
if updated is None:
|
|
213
|
+
raise HTTPException(status_code=404, detail="Project not found")
|
|
214
|
+
return _project_response(updated)
|
|
215
|
+
|
|
216
|
+
@app.delete(
|
|
217
|
+
f"{prefix}/projects/{{project_id}}", status_code=status.HTTP_204_NO_CONTENT
|
|
218
|
+
)
|
|
219
|
+
async def delete_project(project_id: UUID4Path) -> Response:
|
|
220
|
+
canonical_id = _uuid4_or_422(project_id)
|
|
221
|
+
try:
|
|
222
|
+
deleted = await asyncio.to_thread(projects.delete, canonical_id)
|
|
223
|
+
except Exception:
|
|
224
|
+
raise HTTPException(
|
|
225
|
+
status_code=500, detail="Could not delete project"
|
|
226
|
+
) from None
|
|
227
|
+
if not deleted:
|
|
228
|
+
raise HTTPException(status_code=404, detail="Project not found")
|
|
229
|
+
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
230
|
+
|
|
231
|
+
@app.get(
|
|
232
|
+
f"{prefix}/projects/{{project_id}}/versions",
|
|
233
|
+
response_model=list[VersionResponse],
|
|
234
|
+
)
|
|
235
|
+
async def list_versions(project_id: UUID4Path) -> list[VersionResponse]:
|
|
236
|
+
project = await _get_project(projects, project_id)
|
|
237
|
+
try:
|
|
238
|
+
return await asyncio.to_thread(
|
|
239
|
+
_remote_versions, storage, project, runtime_settings.CALC_JOB_DIR
|
|
240
|
+
)
|
|
241
|
+
except ValueError as error:
|
|
242
|
+
raise HTTPException(status_code=422, detail=str(error)) from None
|
|
243
|
+
except Exception:
|
|
244
|
+
raise HTTPException(
|
|
245
|
+
status_code=502, detail="Could not list remote project versions"
|
|
246
|
+
) from None
|
|
247
|
+
|
|
248
|
+
@app.post(
|
|
249
|
+
f"{prefix}/projects/{{project_id}}/init",
|
|
250
|
+
response_model=JobAccepted,
|
|
251
|
+
status_code=status.HTTP_202_ACCEPTED,
|
|
252
|
+
)
|
|
253
|
+
async def initialize(
|
|
254
|
+
project_id: UUID4Path, payload: InitRequest | None = None
|
|
255
|
+
) -> JobAccepted:
|
|
256
|
+
return await _launch(
|
|
257
|
+
projects,
|
|
258
|
+
jobs,
|
|
259
|
+
remote,
|
|
260
|
+
job_worker,
|
|
261
|
+
project_id,
|
|
262
|
+
"init",
|
|
263
|
+
prefix=prefix,
|
|
264
|
+
options=(payload or InitRequest()).model_dump(),
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
@app.post(
|
|
268
|
+
f"{prefix}/projects/{{project_id}}/calculate",
|
|
269
|
+
response_model=JobAccepted,
|
|
270
|
+
status_code=status.HTTP_202_ACCEPTED,
|
|
271
|
+
)
|
|
272
|
+
async def calculate(
|
|
273
|
+
project_id: UUID4Path, _: EmptyActionRequest | None = None
|
|
274
|
+
) -> JobAccepted:
|
|
275
|
+
return await _launch(
|
|
276
|
+
projects, jobs, remote, job_worker, project_id, "calculate", prefix=prefix
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
@app.post(
|
|
280
|
+
f"{prefix}/projects/{{project_id}}/versions/{{version}}/du",
|
|
281
|
+
response_model=JobAccepted,
|
|
282
|
+
status_code=status.HTTP_202_ACCEPTED,
|
|
283
|
+
)
|
|
284
|
+
async def du(
|
|
285
|
+
project_id: UUID4Path,
|
|
286
|
+
version: NumericVersionPath,
|
|
287
|
+
payload: DuRequest | None = None,
|
|
288
|
+
) -> JobAccepted:
|
|
289
|
+
return await _launch(
|
|
290
|
+
projects,
|
|
291
|
+
jobs,
|
|
292
|
+
remote,
|
|
293
|
+
job_worker,
|
|
294
|
+
project_id,
|
|
295
|
+
"du",
|
|
296
|
+
prefix=prefix,
|
|
297
|
+
version=_version_or_422(version),
|
|
298
|
+
options=(payload or DuRequest()).model_dump(exclude_none=True),
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
@app.post(
|
|
302
|
+
f"{prefix}/projects/{{project_id}}/versions/{{version}}/print-pdf",
|
|
303
|
+
response_model=JobAccepted,
|
|
304
|
+
status_code=status.HTTP_202_ACCEPTED,
|
|
305
|
+
)
|
|
306
|
+
async def print_pdf(
|
|
307
|
+
project_id: UUID4Path,
|
|
308
|
+
version: NumericVersionPath,
|
|
309
|
+
_: EmptyActionRequest | None = None,
|
|
310
|
+
) -> JobAccepted:
|
|
311
|
+
return await _launch(
|
|
312
|
+
projects,
|
|
313
|
+
jobs,
|
|
314
|
+
remote,
|
|
315
|
+
job_worker,
|
|
316
|
+
project_id,
|
|
317
|
+
"print-pdf",
|
|
318
|
+
prefix=prefix,
|
|
319
|
+
version=_version_or_422(version),
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
@app.post(
|
|
323
|
+
f"{prefix}/projects/{{project_id}}/versions/{{version}}/finalize",
|
|
324
|
+
response_model=JobAccepted,
|
|
325
|
+
status_code=status.HTTP_202_ACCEPTED,
|
|
326
|
+
)
|
|
327
|
+
async def finalize(
|
|
328
|
+
project_id: UUID4Path,
|
|
329
|
+
version: NumericVersionPath,
|
|
330
|
+
_: EmptyActionRequest | None = None,
|
|
331
|
+
) -> JobAccepted:
|
|
332
|
+
return await _launch(
|
|
333
|
+
projects,
|
|
334
|
+
jobs,
|
|
335
|
+
remote,
|
|
336
|
+
job_worker,
|
|
337
|
+
project_id,
|
|
338
|
+
"finalize",
|
|
339
|
+
prefix=prefix,
|
|
340
|
+
version=_version_or_422(version),
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
@app.get(f"{prefix}/jobs/{{job_id}}", response_model=JobResponse)
|
|
344
|
+
async def get_job(job_id: UUID4Path) -> JobResponse:
|
|
345
|
+
manifest = await _get_job(jobs, job_id)
|
|
346
|
+
directory = await asyncio.to_thread(jobs.job_directory, manifest.id)
|
|
347
|
+
return await asyncio.to_thread(
|
|
348
|
+
_job_response, manifest, directory, runtime_settings
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
@app.get(f"{prefix}/jobs/{{job_id}}/logs", response_model=JobLogsResponse)
|
|
352
|
+
async def job_logs(job_id: UUID4Path) -> JobLogsResponse:
|
|
353
|
+
manifest = await _get_job(jobs, job_id)
|
|
354
|
+
directory = await asyncio.to_thread(jobs.job_directory, manifest.id)
|
|
355
|
+
return await asyncio.to_thread(_job_logs_response, directory, runtime_settings)
|
|
356
|
+
|
|
357
|
+
@app.get(
|
|
358
|
+
f"{prefix}/jobs/{{job_id}}/download",
|
|
359
|
+
response_class=FileResponse,
|
|
360
|
+
responses={200: {"content": {"application/zip": {}}}},
|
|
361
|
+
)
|
|
362
|
+
async def download(job_id: UUID4Path) -> FileResponse:
|
|
363
|
+
manifest = await _get_job(jobs, job_id)
|
|
364
|
+
artifact = await asyncio.to_thread(jobs.job_directory, manifest.id)
|
|
365
|
+
artifact = artifact / "result.zip"
|
|
366
|
+
if not manifest.artifact_ready:
|
|
367
|
+
raise HTTPException(status_code=409, detail="Job artifact is not ready")
|
|
368
|
+
if not await asyncio.to_thread(_is_regular_file, artifact):
|
|
369
|
+
raise HTTPException(status_code=409, detail="Job artifact is not ready")
|
|
370
|
+
return FileResponse(
|
|
371
|
+
artifact, filename=f"calc-{manifest.id}.zip", media_type="application/zip"
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
return app
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
async def _launch(
|
|
378
|
+
projects: ProjectStore,
|
|
379
|
+
jobs: JobStore,
|
|
380
|
+
remote: RemoteOperations,
|
|
381
|
+
worker: JobWorker,
|
|
382
|
+
project_id: str,
|
|
383
|
+
operation: str,
|
|
384
|
+
*,
|
|
385
|
+
prefix: str,
|
|
386
|
+
version: str | None = None,
|
|
387
|
+
options: Mapping[str, Any] | None = None,
|
|
388
|
+
) -> JobAccepted:
|
|
389
|
+
if operation not in _ACTIONS:
|
|
390
|
+
raise HTTPException(status_code=500, detail="Unsupported action")
|
|
391
|
+
project = await _get_project(projects, project_id)
|
|
392
|
+
try:
|
|
393
|
+
remote_path = normalize_webdav_path(str(project["path"]))
|
|
394
|
+
manifest = await asyncio.to_thread(
|
|
395
|
+
jobs.create,
|
|
396
|
+
operation=operation,
|
|
397
|
+
project_id=str(project["id"]),
|
|
398
|
+
remote_path=remote_path,
|
|
399
|
+
requested_version=version,
|
|
400
|
+
)
|
|
401
|
+
except ValueError as error:
|
|
402
|
+
raise HTTPException(status_code=422, detail=str(error)) from None
|
|
403
|
+
except Exception:
|
|
404
|
+
raise HTTPException(status_code=500, detail="Could not create job") from None
|
|
405
|
+
try:
|
|
406
|
+
remote.register(manifest.id, project, options=options)
|
|
407
|
+
await worker.enqueue(manifest)
|
|
408
|
+
except Exception:
|
|
409
|
+
try:
|
|
410
|
+
await asyncio.to_thread(
|
|
411
|
+
jobs.transition, manifest.id, JobStatus.FAILED, error="enqueue_failed"
|
|
412
|
+
)
|
|
413
|
+
except Exception:
|
|
414
|
+
pass
|
|
415
|
+
raise HTTPException(
|
|
416
|
+
status_code=503, detail="Job worker is unavailable"
|
|
417
|
+
) from None
|
|
418
|
+
return JobAccepted(
|
|
419
|
+
job_id=uuid.UUID(manifest.id),
|
|
420
|
+
status_url=f"{prefix}/jobs/{manifest.id}",
|
|
421
|
+
download_url=f"{prefix}/jobs/{manifest.id}/download",
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
async def _get_project(store: ProjectStore, project_id: str) -> dict[str, Any]:
|
|
426
|
+
canonical_id = _uuid4_or_422(project_id)
|
|
427
|
+
try:
|
|
428
|
+
project = await asyncio.to_thread(store.get, canonical_id)
|
|
429
|
+
except Exception:
|
|
430
|
+
raise HTTPException(
|
|
431
|
+
status_code=500, detail="Project configuration is unavailable"
|
|
432
|
+
) from None
|
|
433
|
+
if project is None:
|
|
434
|
+
raise HTTPException(status_code=404, detail="Project not found")
|
|
435
|
+
return project
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
async def _get_job(store: JobStore, job_id: str) -> JobManifest:
|
|
439
|
+
canonical_id = _uuid4_or_422(job_id)
|
|
440
|
+
try:
|
|
441
|
+
manifest = await asyncio.to_thread(store.get, canonical_id)
|
|
442
|
+
except Exception:
|
|
443
|
+
raise HTTPException(
|
|
444
|
+
status_code=500, detail="Job state is unavailable"
|
|
445
|
+
) from None
|
|
446
|
+
if manifest is None:
|
|
447
|
+
raise HTTPException(status_code=404, detail="Job not found")
|
|
448
|
+
return manifest
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _uuid4_or_422(value: str) -> str:
|
|
452
|
+
try:
|
|
453
|
+
parsed = uuid.UUID(value)
|
|
454
|
+
except ValueError:
|
|
455
|
+
raise HTTPException(
|
|
456
|
+
status_code=422, detail="Identifier must be a UUID4"
|
|
457
|
+
) from None
|
|
458
|
+
if parsed.version != 4 or str(parsed) != value:
|
|
459
|
+
raise HTTPException(
|
|
460
|
+
status_code=422, detail="Identifier must be a canonical UUID4"
|
|
461
|
+
)
|
|
462
|
+
return value
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _version_or_422(value: str) -> str:
|
|
466
|
+
if not value.isascii() or not value.isdecimal():
|
|
467
|
+
raise HTTPException(status_code=422, detail="Version must be numeric")
|
|
468
|
+
return value
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _project_response(project: Mapping[str, Any]) -> ProjectResponse:
|
|
472
|
+
try:
|
|
473
|
+
return ProjectResponse.model_validate(project)
|
|
474
|
+
except Exception:
|
|
475
|
+
raise HTTPException(
|
|
476
|
+
status_code=500, detail="Stored project has an invalid schema"
|
|
477
|
+
) from None
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _job_response(
|
|
481
|
+
manifest: JobManifest, directory: Path, settings: Settings
|
|
482
|
+
) -> JobResponse:
|
|
483
|
+
document = manifest.to_dict()
|
|
484
|
+
document.pop("revision", None)
|
|
485
|
+
document.pop("calculation_started", None)
|
|
486
|
+
if document["error"] is not None:
|
|
487
|
+
document["error"] = _bounded_manifest_error(
|
|
488
|
+
_safe_log(document["error"], settings)
|
|
489
|
+
)
|
|
490
|
+
return JobResponse(
|
|
491
|
+
**document,
|
|
492
|
+
stdout_tail=_safe_log(_read_log_tail(directory / "stdout.log"), settings),
|
|
493
|
+
stderr_tail=_safe_log(_read_log_tail(directory / "stderr.log"), settings),
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _job_logs_response(directory: Path, settings: Settings) -> JobLogsResponse:
|
|
498
|
+
return JobLogsResponse(
|
|
499
|
+
stdout=_safe_log(_read_log(directory / "stdout.log"), settings),
|
|
500
|
+
stderr=_safe_log(_read_log(directory / "stderr.log"), settings),
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _is_regular_file(path: Path) -> bool:
|
|
505
|
+
try:
|
|
506
|
+
return stat.S_ISREG(path.lstat().st_mode)
|
|
507
|
+
except FileNotFoundError:
|
|
508
|
+
return False
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _remote_versions(
|
|
512
|
+
storage: Any, project: Mapping[str, Any], job_root: Path
|
|
513
|
+
) -> list[VersionResponse]:
|
|
514
|
+
remote_project = normalize_webdav_path(str(project["path"]))
|
|
515
|
+
remote_results = f"{remote_project}/RESULTADOS"
|
|
516
|
+
if not storage.exists(remote_results):
|
|
517
|
+
return []
|
|
518
|
+
|
|
519
|
+
job_root.mkdir(parents=True, exist_ok=True)
|
|
520
|
+
with tempfile.TemporaryDirectory(prefix="versions-", dir=job_root) as temporary:
|
|
521
|
+
temporary_root = Path(temporary)
|
|
522
|
+
response: list[VersionResponse] = []
|
|
523
|
+
numeric_versions = sorted(
|
|
524
|
+
(
|
|
525
|
+
(remote_version, remote_version.rsplit("/", 1)[-1])
|
|
526
|
+
for remote_version, entry_type in storage.list_directory(remote_results)
|
|
527
|
+
if entry_type == "directory"
|
|
528
|
+
and (version := remote_version.rsplit("/", 1)[-1]).isascii()
|
|
529
|
+
and version.isdecimal()
|
|
530
|
+
),
|
|
531
|
+
key=lambda item: (
|
|
532
|
+
len(item[1].lstrip("0") or "0"),
|
|
533
|
+
item[1].lstrip("0") or "0",
|
|
534
|
+
),
|
|
535
|
+
)
|
|
536
|
+
for remote_version, version in numeric_versions:
|
|
537
|
+
state: Any = {}
|
|
538
|
+
remote_version_json = f"{remote_version}/version.json"
|
|
539
|
+
entries = storage.list_directory(remote_version)
|
|
540
|
+
if (remote_version_json, "file") in entries:
|
|
541
|
+
local_version_json = temporary_root / "version.json"
|
|
542
|
+
storage.download_file(remote_version_json, local_version_json)
|
|
543
|
+
try:
|
|
544
|
+
state = json.loads(local_version_json.read_text(encoding="utf-8"))
|
|
545
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
546
|
+
state = {}
|
|
547
|
+
|
|
548
|
+
raw_estado = state.get("estado") if isinstance(state, dict) else None
|
|
549
|
+
estado: Literal["calculando", "finalizada"] | None
|
|
550
|
+
if raw_estado == "calculando":
|
|
551
|
+
estado = "calculando"
|
|
552
|
+
elif raw_estado == "finalizada":
|
|
553
|
+
estado = "finalizada"
|
|
554
|
+
else:
|
|
555
|
+
estado = None
|
|
556
|
+
response.append(VersionResponse(version=version, estado=estado))
|
|
557
|
+
return response
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _directory_writable(directory: Path) -> bool:
|
|
561
|
+
try:
|
|
562
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
563
|
+
with tempfile.NamedTemporaryFile(
|
|
564
|
+
dir=directory, prefix=".health-", delete=True
|
|
565
|
+
) as output:
|
|
566
|
+
output.write(b"ok")
|
|
567
|
+
output.flush()
|
|
568
|
+
os.fsync(output.fileno())
|
|
569
|
+
return True
|
|
570
|
+
except OSError:
|
|
571
|
+
return False
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _read_log(path: Path) -> str:
|
|
575
|
+
try:
|
|
576
|
+
return path.read_text(encoding="utf-8", errors="replace")
|
|
577
|
+
except FileNotFoundError:
|
|
578
|
+
return ""
|
|
579
|
+
except OSError:
|
|
580
|
+
return ""
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _read_log_tail(path: Path) -> str:
|
|
584
|
+
try:
|
|
585
|
+
with path.open("rb") as source:
|
|
586
|
+
source.seek(0, os.SEEK_END)
|
|
587
|
+
size = source.tell()
|
|
588
|
+
source.seek(max(0, size - _LOG_TAIL_BYTES))
|
|
589
|
+
return source.read().decode("utf-8", errors="replace")
|
|
590
|
+
except FileNotFoundError:
|
|
591
|
+
return ""
|
|
592
|
+
except OSError:
|
|
593
|
+
return ""
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _safe_log(value: str, settings: Settings) -> str:
|
|
597
|
+
return redact_text(value, settings)
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _bounded_manifest_error(value: str) -> str:
|
|
601
|
+
encoded = value.encode("utf-8")
|
|
602
|
+
if len(encoded) <= _MANIFEST_ERROR_BYTES:
|
|
603
|
+
return value
|
|
604
|
+
return encoded[:_MANIFEST_ERROR_BYTES].decode("utf-8", errors="ignore") + "…"
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
app = create_app()
|