|
12 | 12 | POST /upload-tgz → ingest_padata |
13 | 13 | """ |
14 | 14 |
|
15 | | -from flask import Blueprint, request, abort, current_app, jsonify |
| 15 | +from flask import Blueprint, request, abort, current_app, jsonify, send_file |
16 | 16 | import os |
17 | 17 | import json |
18 | 18 | import re |
19 | 19 | import uuid |
20 | 20 | import shutil |
| 21 | +import io |
| 22 | +import tarfile |
21 | 23 | from datetime import datetime |
22 | 24 |
|
23 | 25 | api_bp = Blueprint("api", __name__) |
@@ -124,6 +126,38 @@ def _load_json_by_uuid(directory, field_path, uuid_value): |
124 | 126 | return None |
125 | 127 |
|
126 | 128 |
|
| 129 | +def _find_result_file_by_uuid(received_dir, uuid_value): |
| 130 | + json_files = sorted( |
| 131 | + [f for f in os.listdir(received_dir) if f.endswith(".json")], |
| 132 | + reverse=True, |
| 133 | + ) |
| 134 | + |
| 135 | + for json_file in json_files: |
| 136 | + path = os.path.join(received_dir, json_file) |
| 137 | + try: |
| 138 | + with open(path, "r", encoding="utf-8") as f: |
| 139 | + data = json.load(f) |
| 140 | + except Exception: |
| 141 | + continue |
| 142 | + |
| 143 | + if data.get("_server_uuid") == uuid_value or uuid_value in json_file: |
| 144 | + return json_file, path, data |
| 145 | + |
| 146 | + return None, None, None |
| 147 | + |
| 148 | + |
| 149 | +def _safe_extract_tar_bytes(file_storage, target_dir): |
| 150 | + with tarfile.open(fileobj=file_storage.stream, mode="r:*") as tar: |
| 151 | + for member in tar.getmembers(): |
| 152 | + normalized = os.path.normpath(member.name) |
| 153 | + if os.path.isabs(normalized) or normalized.startswith(".."): |
| 154 | + abort(400, description="Unsafe archive entry") |
| 155 | + try: |
| 156 | + tar.extractall(target_dir, filter="data") |
| 157 | + except TypeError: |
| 158 | + tar.extractall(target_dir) |
| 159 | + |
| 160 | + |
127 | 161 | # ========================================== |
128 | 162 | # 新パス: /api/ingest/* |
129 | 163 | # ========================================== |
@@ -172,7 +206,7 @@ def ingest_padata(): |
172 | 206 | if not uploaded_file: |
173 | 207 | abort(400, description="No file uploaded") |
174 | 208 |
|
175 | | - received_dir = current_app.config["RECEIVED_DIR"] |
| 209 | + received_dir = current_app.config["RECEIVED_PADATA_DIR"] |
176 | 210 |
|
177 | 211 | matched_files = [ |
178 | 212 | f for f in os.listdir(received_dir) |
@@ -204,6 +238,43 @@ def ingest_padata(): |
204 | 238 | }, 200 |
205 | 239 |
|
206 | 240 |
|
| 241 | +@api_bp.route("/api/ingest/estimation-inputs", methods=["POST"]) |
| 242 | +def ingest_estimation_inputs(): |
| 243 | + """Estimation input archive (tgz) upload and expansion.""" |
| 244 | + require_api_key() |
| 245 | + |
| 246 | + uuid_str = request.form.get("id") |
| 247 | + if not uuid_str or not is_valid_uuid(uuid_str): |
| 248 | + abort(400, description="Invalid or missing UUID") |
| 249 | + |
| 250 | + uploaded_file = request.files.get("file") |
| 251 | + if not uploaded_file: |
| 252 | + abort(400, description="No file uploaded") |
| 253 | + |
| 254 | + received_dir = current_app.config["RECEIVED_DIR"] |
| 255 | + result_filename, _, _ = _find_result_file_by_uuid(received_dir, uuid_str) |
| 256 | + if not result_filename: |
| 257 | + abort(404, description=f"No result found for uuid={uuid_str}") |
| 258 | + |
| 259 | + result_stem = os.path.splitext(result_filename)[0] |
| 260 | + inputs_root = current_app.config["RECEIVED_ESTIMATION_INPUTS_DIR"] |
| 261 | + target_dir = os.path.join(inputs_root, result_stem) |
| 262 | + replaced = os.path.isdir(target_dir) |
| 263 | + if replaced: |
| 264 | + shutil.rmtree(target_dir) |
| 265 | + os.makedirs(target_dir, exist_ok=True) |
| 266 | + |
| 267 | + _safe_extract_tar_bytes(uploaded_file, target_dir) |
| 268 | + |
| 269 | + print(f"Saved estimation inputs: {target_dir}", flush=True) |
| 270 | + return { |
| 271 | + "status": "uploaded", |
| 272 | + "id": uuid_str, |
| 273 | + "directory": result_stem, |
| 274 | + "replaced": replaced, |
| 275 | + }, 200 |
| 276 | + |
| 277 | + |
207 | 278 | # ========================================== |
208 | 279 | # Query API: /api/query/* |
209 | 280 | # ========================================== |
@@ -285,6 +356,45 @@ def query_result(): |
285 | 356 | abort(404, description=f"No result found for system={system}, code={code}, exp={exp}") |
286 | 357 |
|
287 | 358 |
|
| 359 | +@api_bp.route("/api/query/estimation-inputs", methods=["GET"]) |
| 360 | +def query_estimation_inputs(): |
| 361 | + """Return estimation input artifacts for a result UUID as a tar.gz archive.""" |
| 362 | + require_api_key() |
| 363 | + |
| 364 | + uuid_value = request.args.get("uuid") |
| 365 | + if not uuid_value or not is_valid_uuid(uuid_value): |
| 366 | + abort(400, description="Invalid UUID") |
| 367 | + |
| 368 | + result_filename, _, _ = _find_result_file_by_uuid( |
| 369 | + current_app.config["RECEIVED_DIR"], uuid_value |
| 370 | + ) |
| 371 | + if not result_filename: |
| 372 | + abort(404, description=f"No result found for uuid={uuid_value}") |
| 373 | + |
| 374 | + result_stem = os.path.splitext(result_filename)[0] |
| 375 | + source_dir = os.path.join( |
| 376 | + current_app.config["RECEIVED_ESTIMATION_INPUTS_DIR"], result_stem |
| 377 | + ) |
| 378 | + if not os.path.isdir(source_dir): |
| 379 | + abort(404, description=f"No estimation inputs found for uuid={uuid_value}") |
| 380 | + |
| 381 | + buffer = io.BytesIO() |
| 382 | + with tarfile.open(fileobj=buffer, mode="w:gz") as tar: |
| 383 | + for entry in sorted(os.listdir(source_dir)): |
| 384 | + tar.add( |
| 385 | + os.path.join(source_dir, entry), |
| 386 | + arcname=entry, |
| 387 | + ) |
| 388 | + buffer.seek(0) |
| 389 | + |
| 390 | + return send_file( |
| 391 | + buffer, |
| 392 | + mimetype="application/gzip", |
| 393 | + as_attachment=True, |
| 394 | + download_name=f"estimation_inputs_{result_stem}.tgz", |
| 395 | + ) |
| 396 | + |
| 397 | + |
288 | 398 | @api_bp.route("/api/query/estimate", methods=["GET"]) |
289 | 399 | def query_estimate(): |
290 | 400 | """UUID で単一 Estimate JSON を返す。""" |
|
0 commit comments