feat(b08): 라이브러리 [복제해서 내 것으로] — 기본·회사 항목을 개인 단에 베낌(출처 cloned_from 기록 · 새 코드 · 프로젝트 작업본 안 바꿈 · 개인 단 항목에선 안 눌림 · 같은 종류 내 것은 덮어씀을 먼저 물음)
화면(936be972 돌쌓기(찰) 장): 기본 항목 복제 → 목록 「개인 · [양식형] 돌쌓기(찰)」 + 「내 라이브러리에 베낌 — 가져와 고친 뒤 [내 라이브러리에 저장]」 · 프로젝트 라이브러리 폴더 안 생김 · [내 것 지우기]로 되돌림 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
This commit is contained in:
@@ -6,15 +6,18 @@
|
||||
② [넣기] 같은 파일 + 고른 호표 차례 + 우리 구조물 종류 → 서버가 **파일을 다시 읽어** 개인 단에 씀.
|
||||
⚠ 브라우저가 보낸 줄을 받아 적지 않음(CLAUDE.md 5장) · 개인 단만(판정 Ⓗ) · 종류당 하나라 같은 종류 내 것은
|
||||
덮어씀(판정 Ⓐ) · 종류는 사용자가 고름 — 이름으로 자동으로 안 붙임(판정 Ⓒ).
|
||||
③ [복제해서 내 것으로](PLAN 4장) — 기본·회사 단 항목을 개인 단에 베낌. 프로젝트 작업본은 안 바꿈.
|
||||
(구조물도 라우터가 700줄에 닿아 개인 단으로 넣는 창구를 이 파일에 모음.)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from common_util.common_util_auth import verify_session
|
||||
|
||||
@@ -110,3 +113,39 @@ async def save_stmate_recipe(
|
||||
"note": item["note"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class LibraryCloneRequest(BaseModel):
|
||||
"""베낄 항목 — 단과 코드(이름은 겹칠 수 있음). 개인 단 것은 이미 내 것이라 안 받음."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
type_id: str = Field(min_length=1, max_length=100)
|
||||
tier: Literal["company", "program"]
|
||||
code: str = Field(pattern=r"^AX-ST-[0-9a-f]{8}$")
|
||||
|
||||
|
||||
@router.put("/{project_id}/quantity/structure-sheets/library/clone")
|
||||
async def clone_library_item(
|
||||
project_id: UUID,
|
||||
payload: LibraryCloneRequest,
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
) -> JSONResponse:
|
||||
"""③ 기본·회사 항목을 **로그인한 사람 개인 단**에 베낌 — 가져와 고친 뒤 [내 라이브러리에 저장]할 길을 한 번에."""
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import (
|
||||
find_item,
|
||||
save_item_personal,
|
||||
tier_dirs,
|
||||
)
|
||||
|
||||
dirs = tier_dirs(session.get("company_id"), session.get("user_id"))
|
||||
folder = dirs.get("personal")
|
||||
if folder is None:
|
||||
return _error(403, "개인 라이브러리는 회사에 속한 사용자만 씁니다.")
|
||||
item = await asyncio.to_thread(find_item, dirs, payload.tier, payload.code)
|
||||
if item is None or item.get("type_id") != payload.type_id:
|
||||
return _error(404, "베낄 항목을 찾지 못했습니다.")
|
||||
body = {k: v for k, v in item.items() if k not in ("code", "imported_from", "library_tier")}
|
||||
body["cloned_from"] = {"tier": payload.tier, "code": payload.code, "name": item.get("name")}
|
||||
code = await asyncio.to_thread(save_item_personal, folder, body)
|
||||
return JSONResponse(content={"status": "success", "code": code, "name": item.get("name")})
|
||||
|
||||
@@ -80,6 +80,49 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement {
|
||||
take.className = "b08-spec__save";
|
||||
take.textContent = "가져오기";
|
||||
take.hidden = true;
|
||||
// 복제 — 기본·회사 항목을 개인 단에 베낌(PLAN 4장). 개인 단 것은 이미 내 것이라 안 눌림.
|
||||
const clone = document.createElement("button");
|
||||
clone.type = "button";
|
||||
clone.className = "b08-quantity__tab";
|
||||
clone.textContent = "복제해서 내 것으로";
|
||||
clone.hidden = true;
|
||||
/** 목록을 다시 받은 뒤 보일 한 줄 — 받는 동안 상태 줄이 지워져 복제 결과가 사라지지 않게. */
|
||||
let afterLoad = "";
|
||||
const syncClone = (): void => {
|
||||
const [tier] = list.value.split("|");
|
||||
clone.disabled = tier === "personal";
|
||||
};
|
||||
list.addEventListener("change", syncClone);
|
||||
clone.addEventListener("click", () => {
|
||||
const [tier, code] = list.value.split("|");
|
||||
const label = list.selectedOptions[0]?.textContent ?? "";
|
||||
if (!tier || !code || tier === "personal") return;
|
||||
if (
|
||||
!window.confirm(
|
||||
`「${label}」을 내 라이브러리로 베낌 — 같은 종류 내 것이 있으면 덮어씀 · 프로젝트 값은 안 바뀜`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
clone.disabled = true;
|
||||
try {
|
||||
await readJson(
|
||||
await fetch(libraryUrl(projectId, "/clone"), {
|
||||
method: "PUT",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type_id: typeId, tier, code }),
|
||||
}),
|
||||
);
|
||||
afterLoad = "내 라이브러리에 베낌 — 가져와 고친 뒤 [내 라이브러리에 저장]";
|
||||
load.click();
|
||||
} catch (error) {
|
||||
status.textContent = error instanceof Error ? error.message : "복제 못함";
|
||||
syncClone();
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
load.addEventListener("click", () => {
|
||||
void (async () => {
|
||||
@@ -101,8 +144,10 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement {
|
||||
return option;
|
||||
}),
|
||||
);
|
||||
list.hidden = take.hidden = items.length === 0;
|
||||
status.textContent = items.length ? "" : "가져올 항목이 없음";
|
||||
list.hidden = take.hidden = clone.hidden = items.length === 0;
|
||||
syncClone();
|
||||
status.textContent = items.length ? afterLoad : "가져올 항목이 없음";
|
||||
afterLoad = "";
|
||||
} catch (error) {
|
||||
status.textContent = error instanceof Error ? error.message : "목록을 받지 못함";
|
||||
} finally {
|
||||
@@ -191,7 +236,7 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement {
|
||||
mine.className = "b08-sheet__actions";
|
||||
mine.append(save, remove);
|
||||
|
||||
panel.append(title, scope, load, list, take, mine, status);
|
||||
panel.append(title, scope, load, list, take, clone, mine, status);
|
||||
// 고정형 항목을 만드는 둘째 길 — STmate 출력 엑셀에서 호표 하나를 뽑아 개인 단에(PLAN 4장).
|
||||
panel.append(
|
||||
buildStmatePanel({
|
||||
|
||||
@@ -216,6 +216,41 @@ def test_확정_때_기본_양식을_박는다(project: Path) -> None:
|
||||
assert library_module.pin_program_templates(project) == 0 # 이미 박힌 것은 그대로
|
||||
|
||||
|
||||
def test_복제는_고른_항목을_개인_단에_베끼고_프로젝트는_안_바꾼다(
|
||||
client: TestClient, project: Path, storage: Path
|
||||
) -> None:
|
||||
"""PLAN 4장 「복제해서 내 것 만들기」 — 기본(또는 회사) 항목을 개인 단으로 한 번에 베낌."""
|
||||
import B08_Quantity.B08_Quantity_Router_StmateLibrary as extra_router
|
||||
|
||||
client.app.include_router(extra_router.router)
|
||||
base = load_template("masonry_wet")
|
||||
folder = storage / "7" / "42" / "library"
|
||||
for path in folder.glob("*.json"):
|
||||
path.unlink() # 개인 단을 비워 새로 생기는지 봄
|
||||
response = client.put(
|
||||
f"{SHEETS}/library/clone",
|
||||
json={"type_id": "masonry_wet", "tier": "program", "code": base["code"]},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
code = response.json()["code"]
|
||||
saved = json.loads((folder / f"{code}.json").read_text(encoding="utf-8"))
|
||||
assert saved["library_tier"] == "personal" and saved["code"] != base["code"]
|
||||
assert saved["cloned_from"] == {"tier": "program", "code": base["code"], "name": base["name"]}
|
||||
assert saved["rows"] == base["rows"]
|
||||
assert not (project / "B08_Quantity" / "library").exists() # 프로젝트 작업본은 그대로
|
||||
missing = client.put(
|
||||
f"{SHEETS}/library/clone",
|
||||
json={"type_id": "masonry_wet", "tier": "program", "code": "AX-ST-00000000"},
|
||||
)
|
||||
assert missing.status_code == 404
|
||||
client.app.dependency_overrides[verify_session] = lambda: {"company_id": None, "user_id": 1}
|
||||
denied = client.put(
|
||||
f"{SHEETS}/library/clone",
|
||||
json={"type_id": "masonry_wet", "tier": "program", "code": base["code"]},
|
||||
)
|
||||
assert denied.status_code == 403
|
||||
|
||||
|
||||
def test_항목마다_양식형_고정형_종류가_붙는다(tmp_path: Path) -> None:
|
||||
"""PLAN 4장 「항목마다 종류 배지」 — 명세 13장: 칸은 같고 `formula` 유무로만 갈림."""
|
||||
folder = tmp_path / "personal"
|
||||
@@ -236,6 +271,14 @@ def test_항목마다_양식형_고정형_종류가_붙는다(tmp_path: Path) ->
|
||||
assert "양식형" in ui and "고정형" in ui and "item.kind" in ui
|
||||
|
||||
|
||||
def test_복제_단추는_개인_단_항목에선_안_눌린다() -> None:
|
||||
ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet_Library.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "/clone" in ui and "복제해서 내 것으로" in ui
|
||||
assert 'clone.disabled = tier === "personal"' in ui
|
||||
|
||||
|
||||
def test_코드_모양이_아니면_박지_않는다(project: Path) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
library_module.import_item(project, {"type_id": "masonry_wet", "code": "../x"}, "personal")
|
||||
|
||||
Reference in New Issue
Block a user