feat(M02): 도면 양식 서버 길 둘 — POST /api/m02/drawing-import · GET /api/m02/drawing-fields (PLAN 10-3)

- M02_MasterTemplete_Router_Drawing.py — B07 Engine_Frame_Import · title_block_fields 다시 씀
- 자리표 키 목록 18 개(프로젝트 14 · 도면 4) · project_id 를 주면 표제란 값 함께
- main.py include 는 sub1 몫
- 시험 test_m02_router_drawing.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxvYb5ufV1kWdBvZbpDfu6
This commit is contained in:
2026-09-25 09:37:04 +09:00
co-authored by Claude Opus 5.5
parent df0b7d0aa2
commit 1778dbb505
2 changed files with 153 additions and 0 deletions
@@ -0,0 +1,95 @@
"""M02 도면 양식 서버 길 — 외부 도각 파일 불러오기 · 자리표 키 목록 (PLAN 10-3).
양식을 읽고 쓰는 길은 `M02_MasterTemplete_Router.py`(시스템 층)·`_Router_Layers.py`(층)가 맡는다.
여기는 도면 양식 편집 화면만 쓰는 두 길 — B07 의 불러오기 엔진 · 표제란 값을 그대로 다시 쓴다.
"""
import asyncio
import logging
from uuid import UUID
from fastapi import APIRouter, File, UploadFile
from fastapi.responses import JSONResponse
from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Import import import_frame_file
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
frame_document,
validate_template_entities,
)
from B07_DesignDetail.B07_DesignDetail_Router import title_block_fields
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete"])
# 도각 파일 상한 — A1 도각 한 장은 보통 1MB 아래다. 큰 도면 전체를 올리는 실수를 막는다.
_IMPORT_MAX_BYTES = 20 * 1024 * 1024
# 자리표 `{{키}}` 로 쓸 수 있는 키 — 도면을 그릴 때 채워지는 것만 둔다.
# 프로젝트 값은 B07 `title_block_fields` 가 · 도면 값은 도면을 그리는 엔진이 채운다.
DRAWING_FIELDS: tuple[tuple[str, str, str], ...] = (
("공사명", "프로젝트", "프로젝트 이름"),
("위치", "프로젝트", "사업 위치"),
("시행청", "프로젝트", "발주 기관"),
("연도기번", "프로젝트", "프로젝트 번호"),
("사업량", "프로젝트", "사업량"),
("설계일자", "프로젝트", "설계 일자"),
("용역회사", "프로젝트", "회사 이름"),
("설계자", "프로젝트", "설계자 이름"),
("과업책임자", "프로젝트", "과업책임자 이름"),
("분야별책임자", "프로젝트", "분야별책임자 이름"),
("회사로고", "프로젝트", "회사 로고 그림"),
("설계자서명", "프로젝트", "설계자 서명 그림"),
("과업책임자서명", "프로젝트", "과업책임자 서명 그림"),
("분야별책임자서명", "프로젝트", "분야별책임자 서명 그림"),
("도면명", "도면", "도면 이름"),
("도면번호", "도면", "도면 목록 순번"),
("축척_A1", "도면", "A1 축척 분모"),
("축척_A3", "도면", "A3 축척 분모"),
)
@router.post("/drawing-import")
async def import_drawing_file(file: UploadFile = File(...)) -> JSONResponse:
"""외부 도각 파일(DXF·DWG)을 읽어 **편집 화면에 실을 도면**으로 돌려준다 — 저장하지 않는다."""
try:
data = await file.read()
if len(data) > _IMPORT_MAX_BYTES:
raise ValueError("도각 파일이 너무 큽니다(20MB 넘음).")
entities = await asyncio.to_thread(import_frame_file, file.filename or "", data)
validate_template_entities(entities)
return JSONResponse(
{
"status": "success",
"drawing": frame_document(entities),
"entity_count": len(entities),
}
)
except ValueError as exc:
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
except Exception:
logger.exception("M02 도각 불러오기 실패: %s", file.filename)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "도각 파일을 읽지 못했습니다."},
)
@router.get("/drawing-fields")
async def get_drawing_fields(project_id: UUID | None = None) -> JSONResponse:
"""자리표 키 목록. project_id 를 주면 그 프로젝트의 표제란 값도 함께(미리보기용)."""
try:
values = await title_block_fields(project_id) if project_id else {}
except Exception:
logger.exception("M02 자리표 값 조회 실패: project_id=%s", project_id)
values = {}
return JSONResponse(
{
"status": "success",
"fields": [
{"key": key, "source": source, "label": label}
for key, source, label in DRAWING_FIELDS
],
"values": values,
}
)
@@ -0,0 +1,58 @@
"""M02 도면 양식 서버 길 — 자리표 키 목록 · 도각 파일 불러오기 (PLAN 10-3)."""
import io
import json
import re
from pathlib import Path
from fastapi import FastAPI
from fastapi.testclient import TestClient
from M02_MasterTemplete.M02_MasterTemplete_Router_Drawing import DRAWING_FIELDS, router
app = FastAPI()
app.include_router(router)
client = TestClient(app)
def test_fields_cover_system_placeholders():
# 시스템 도면 양식에 박힌 자리표는 전부 키 목록에 있다 — 없으면 화면이 모르는 칸이 생긴다.
keys = {key for key, _, _ in DRAWING_FIELDS}
used = set()
for path in Path("resources/master_template/drawing").glob("00_*.json"):
text = json.dumps(json.loads(path.read_text(encoding="utf-8")), ensure_ascii=False)
used |= {match.strip() for match in re.findall(r"\{\{\s*([^}]+?)\s*\}\}", text)}
assert used and used <= keys, used - keys
def test_fields_without_project():
response = client.get("/api/m02/drawing-fields")
assert response.status_code == 200
body = response.json()
assert [item["key"] for item in body["fields"]] == [key for key, _, _ in DRAWING_FIELDS]
assert body["values"] == {}
def test_import_rejects_other_files():
response = client.post(
"/api/m02/drawing-import", files={"file": ("a.txt", b"hello", "text/plain")}
)
assert response.status_code == 400
assert "DXF" in response.json()["message"]
def test_import_dxf():
import ezdxf
document = ezdxf.new()
document.modelspace().add_line((0, 0), (100, 50))
stream = io.StringIO()
document.write(stream)
response = client.post(
"/api/m02/drawing-import",
files={"file": ("frame.dxf", stream.getvalue().encode("utf-8"), "application/dxf")},
)
assert response.status_code == 200, response.text
body = response.json()
assert body["entity_count"] == 1
assert body["drawing"]["entities"][0]["type"] == "Line"