feat(회사에 올리기): 요청 장부 · 요청 · 목록 · 승인 · 거절 · 알림 봄 길 + 임시 storage 시험 (PLAN 706-1)

- 회사 층 폴더 올리기요청.json 하나 · DB 안 바꿈
- 승인 = 「회사로 복사…」와 같은 길(구조물 = 붙여넣기 · 사전조합 = 서고 복사 + 폴더 자리) · 원본 개인 것 그대로
- 권한 = M02_Layer_Access 재사용 · 일반 승인 403 · 다른 회사 장부 안 봄 · 같은 것 다시 요청 409

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 14:52:30 +09:00
co-authored by Claude Opus 5.5
parent f91ad85826
commit 74352ec6b8
3 changed files with 377 additions and 0 deletions
+243
View File
@@ -0,0 +1,243 @@
"""회사에 올리기 요청 · 승인(PLAN 706-1 · 요구서 4장 6) — 일반 사용자가 개인 층 항목 ·
사전조합을 회사 층에 올려 달라고 요청 · 회사 관리자가 승인(= 「회사로 복사…」와 같은 길 ·
원본은 그대로) · 거절.
장부 = 회사 층 폴더 `올리기요청.json` 하나(DB 안 씀) · `{줄: [요청 …]}`.
요청 = `{id, 종류(item · precombo), 키, 이름, 경로(항목 폴더), 주인 C/U, 요청자, 요청자이름,
글, 날짜, 상태(요청 · 승인 · 거절), 처리자, 처리날짜, 사유, 사본, 봄}` · `봄` = 요청자가 결과
알림을 닫음. 권한 = `M02_Layer_Access` 한 벌 — 요청 = 자기 개인 층 · 목록 = 회사 관리자는
그 회사 전부 · 일반은 자기 것 ·
승인 · 거절 = 회사 층 쓰기(일반 403) · 다른 회사 장부는 안 봄(시스템 관리자만 `company`).
같은 항목(주인 · 종류 · 키)이 「요청」 상태면 다시 요청 = 409 「요청 중」.
⚠ 등록은 `main.py`(로그인만).
"""
from __future__ import annotations
import json
import threading
import uuid
from datetime import datetime
from pathlib import Path
from typing import Any, Literal
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from common_util.common_util_auth import verify_session
from common_util.common_util_json import atomic_write_json
from M02_MasterTemplete import M02_Item_Paste as paste_store
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_Layer_Access as access
from M02_MasterTemplete import M02_MasterTemplete_Items as items_store
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Precombo_Folders as pre_folders
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import (
NotedRoute,
Session,
_call,
_root,
)
router = APIRouter(prefix="/api/m02", tags=["M02 Share Request"], route_class=NotedRoute)
FILE = "올리기요청.json"
# ponytail: 한 프로세스 잠금 — 서버를 여럿 띄우면 파일 잠금으로
_LOCK = threading.Lock()
class AskBody(BaseModel):
종류: Literal["item", "precombo"]
키: str
글: str = ""
class ApproveBody(BaseModel):
경로: list[str] = []
class RejectBody(BaseModel):
사유: str = ""
def _company(session: Session, company: str | None) -> str:
"""장부 회사 — 시스템 관리자만 `company` 로 남의 회사 · 그 밖은 자기 회사(없으면 409)."""
if company and access.is_system_admin(session):
return str(company)
own = session.get("company_id")
if own is None:
raise HTTPException(status_code=409, detail=access.NO_COMPANY)
return str(own)
def _path(company: str) -> Path:
return access.folder(access.Grant("company", company, False, True)) / FILE
def _read(company: str) -> list[dict[str, Any]]:
path = _path(company)
if not path.is_file():
return []
return list(json.loads(path.read_bytes()).get("줄") or [])
def _write(company: str, rows: list[dict[str, Any]]) -> None:
atomic_write_json(_path(company), {"줄": rows})
def _now() -> str:
return datetime.now().isoformat(timespec="seconds")
def _source(session: Session, kind: str, key: str) -> dict[str, Any]:
"""자기 개인 층 원본 — `{이름, 경로}`(없으면 404)."""
folder = _root(session, "personal")
if kind == "item":
found = next((r for r in items_store.items(folder) if r.get("key") == key), None)
if found is None:
raise HTTPException(status_code=404, detail=f"없는 항목 「{key}」")
path = list(found.get("경로") or [])
return {"이름": path[-1] if path else key, "경로": path[:-1]}
owner = access.own_owner(session, "personal")
doc = (
refs.read_ref({"층": "personal", "주인": owner, "키": key})
if refs._KEY.match(key)
else None
)
if doc is None or refs.kind_of(key) != "precombo":
raise HTTPException(status_code=404, detail=f"없는 사전조합 「{key}」")
return {"이름": str(doc.get("이름") or key), "경로": []}
@router.post("/share-requests")
def ask(body: AskBody, session: Session = Depends(verify_session)) -> dict[str, Any]:
"""요청 — 자기 개인 층 것만 · 같은 것이 「요청」 이면 409 「요청 중」."""
owner = access.own_owner(session, "personal")
if owner is None:
raise HTTPException(status_code=409, detail=access.NO_COMPANY)
src = _source(session, body.종류, body.키)
company = owner.split("/")[0]
row = {
"id": uuid.uuid4().hex[:12],
"종류": body.종류,
"키": body.키,
**src,
"주인": owner,
"요청자": session.get("user_id"),
"요청자이름": session.get("name") or "",
"글": body.글.strip()[:200],
"날짜": _now(),
"상태": "요청",
}
with _LOCK:
rows = _read(company)
same = (owner, body.종류, body.키)
if any((r["주인"], r["종류"], r["키"]) == same and r["상태"] == "요청" for r in rows):
raise HTTPException(status_code=409, detail="요청 중 — 회사 관리자가 아직 안 봄")
_write(company, [*rows, row])
return row
@router.get("/share-requests")
def listing(
company: str | None = None, session: Session = Depends(verify_session)
) -> dict[str, Any]:
"""목록 — 회사를 맡은 사람 = 그 회사 전부 · 그 밖 = 자기 요청만 · `관리` = 승인할 수 있나."""
at = _company(session, company)
rows = _read(at)
boss = access.manages(session, at)
if not boss:
me = session.get("user_id")
rows = [r for r in rows if r.get("요청자") == me]
return {"회사": at, "관리": boss, "줄": rows}
def _settle(session: Session, company: str | None, rid: str, done: dict[str, Any]) -> dict:
"""요청 한 줄을 끝냄 — 회사 층 쓰기 권한(일반 403) · 「요청」 상태만(아니면 409)."""
at = _company(session, company)
access.require(session, "company", at, write=True)
with _LOCK:
rows = _read(at)
row = next((r for r in rows if r.get("id") == rid), None)
if row is None:
raise HTTPException(status_code=404, detail="없는 요청")
if row["상태"] != "요청":
raise HTTPException(status_code=409, detail=f"이미 {row['상태']}")
extra = done.pop("run", None)
if extra:
done["사본"] = extra(row, at)
row.update({**done, "처리자": session.get("name") or "", "처리날짜": _now(), "봄": False})
_write(at, rows)
return row
def _copy(session: Session, dest: list[str]):
"""승인 = 「회사로 복사…」와 같은 길 — 구조물 항목 = 붙여넣기 ·
사전조합 = 서고 복사(+ 폴더 자리)."""
def run(row: dict[str, Any], company: str) -> str:
target = _root(session, "company", company, write=True)
source = _root(session, "personal", row["주인"])
if row["종류"] == "item":
ref = [{"경로": row.get("경로") or [], "키": row["키"]}]
origin = {"layer": "personal", "owner": row["주인"]}
try:
note = paste_store.paste(source, target, ref, dest, origin)
except store.StoreError as e:
raise HTTPException(status_code=e.status, detail=e.detail) from e
return str(note["열"][0])
ref = {"층": "personal", "주인": row["주인"], "키": row["키"]}
doc = refs.read_ref(ref)
if doc is None:
raise HTTPException(status_code=404, detail=f"원본이 없음 「{row['키']}」")
def make() -> dict[
str, Any
]: # 못 읽는 참조 같이 복사 = 받는 층 안에서(`new_library` 와 같음)
return refs.create("precombo", refs.carry(refs.origin_mark(doc, ref), here))
here = ("personal", row["주인"])
made = _call(target, make)
if dest:
view = _call(target, pre_folders.view)
move = {"op": "move", "키": made["이름"], "새경로": dest, "판": view["판"]}
_call(target, pre_folders.edit, move)
return str(made["이름"])
return run
@router.post("/share-requests/{rid}/approve")
def approve(
rid: str,
body: ApproveBody,
company: str | None = None,
session: Session = Depends(verify_session),
) -> dict[str, Any]:
"""승인 — 회사 층 `경로` 폴더에 사본(새 키 · 같은 이름 「(1)」) · 원본 개인 것은 그대로."""
return _settle(session, company, rid, {"상태": "승인", "run": _copy(session, body.경로)})
@router.post("/share-requests/{rid}/reject")
def reject(
rid: str,
body: RejectBody,
company: str | None = None,
session: Session = Depends(verify_session),
) -> dict[str, Any]:
return _settle(session, company, rid, {"상태": "거절", "사유": body.사유.strip()[:200]})
@router.post("/share-requests/seen")
def seen(session: Session = Depends(verify_session)) -> dict[str, int]:
"""요청자가 결과 알림 줄을 닫음 — 자기 요청 가운데 끝난 것 `봄` = true."""
at = _company(session, None)
me = session.get("user_id")
with _LOCK:
rows = _read(at)
hit = [r for r in rows if r.get("요청자") == me and r.get("봄") is False]
for r in hit:
r["봄"] = True
if hit:
_write(at, rows)
return {"봄": len(hit)}
+5
View File
@@ -547,6 +547,11 @@ app.include_router(m02_items_router, dependencies=protected)
app.include_router(m02_combos_router, dependencies=protected)
app.include_router(m02_drawing_router, dependencies=protected)
app.include_router(m02_labor_router, dependencies=protected)
# 회사에 올리기 요청 · 승인(PLAN 706-1) — 로그인만 · 층 권한은 길 안에서
app.include_router(
__import__("M02_MasterTemplete.M02_Share_Request", fromlist=["router"]).router,
dependencies=protected,
)
# 프로젝트 집계 항목 복사본(PLAN 25-2b) — 로그인만 · 프로젝트 권한은 길 안에서.
app.include_router(m02_project_items_router, dependencies=protected)
# 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근).
@@ -0,0 +1,129 @@
"""회사에 올리기 요청 · 승인(PLAN 706-1 · `M02_Share_Request`) — 임시 storage.
① 일반 사용자 요청(구조물 항목 · 사전조합) → 장부 한 줄 · 같은 것 다시 = 409 「요청 중」.
② 회사 관리자 승인 = 회사 층 고른 폴더에 사본(원본 개인 것 그대로) · 거절 = 사유 · 요청자 알림 → 봄.
③ 권한 — 일반 승인 403 · 다른 회사 관리자는 그 요청을 못 봄(404) · 남의 회사 장부 안 봄 ·
목록은 자기 것만.
"""
from __future__ import annotations
import json
from typing import Any
from fastapi import FastAPI
from fastapi.testclient import TestClient
from common_util.common_util_auth import verify_session
from M02_MasterTemplete import M02_Labor_Router as labor_router
from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router
from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layers_router
from M02_MasterTemplete import M02_Share_Request as share
from resources.tester import test_m02_tree as base
from resources.tester.test_m02_tree import Tree
world = base.world # 같은 임시 storage 판
URL = "/api/m02/share-requests"
def _client(world: dict[str, Any]) -> TestClient:
app = FastAPI()
for one in (layers_router, items_router, labor_router, share):
app.include_router(one.router)
app.dependency_overrides[verify_session] = lambda: world["session"]
return TestClient(app)
def _as(world: dict[str, Any], role: str = "USER", user: int = 42, company: int = 7) -> None:
world["session"].update(role=role, user_id=user, company_id=company, name=f"사람{user}")
def _mine(world: dict[str, Any], client: TestClient) -> tuple[str, str]:
"""일반 사용자 42 개인 층 — 항목 「배수/sub7임시」 · 사전조합 하나 → (항목 키, 사전조합 키)."""
_as(world)
t = Tree(client, "personal")
t.do(op="add-folder", 경로=[], 이름="배수")
t.do(op="add-item", 경로=["배수"], 이름="sub7임시", 단위="m")
doc = {
"문서": {"이름": "sub7임시 사전", "구분": "C 사면안정", "상세구분": "옹벽", "담은로직": []}
}
pre = client.post("/api/m02/layers/personal/library/precombo", json=doc).json()["이름"]
return t.item(["배수", "sub7임시"])["key"], pre
def test_요청_승인_거절_알림(world):
client = _client(world)
item, pre = _mine(world, client)
got = client.post(URL, json={"종류": "item", "키": item, "글": " 회사 공용으로 "})
assert got.status_code == 200, got.text
one = got.json()
assert (one["이름"], one["경로"], one["주인"], one["글"]) == (
"sub7임시",
["배수"],
"7/42",
"회사 공용으로",
)
assert client.post(URL, json={"종류": "item", "키": item}).status_code == 409 # 요청 중
two = client.post(URL, json={"종류": "precombo", "키": pre}).json()
assert client.post(URL, json={"종류": "item", "키": "SP42-999999"}).status_code == 404
assert client.post(URL, json={"종류": "precombo", "키": "SC-999999"}).status_code == 404
mine = client.get(URL).json()
assert mine["관리"] is False and [r["id"] for r in mine["줄"]] == [one["id"], two["id"]]
_as(world, "ADMIN", user=50) # 회사 7 관리자
company = Tree(client, "company")
company.do(op="add-folder", 경로=[], 이름="공용")
boss = client.get(URL).json()
assert boss["관리"] is True and len(boss["줄"]) == 2
ok = client.post(f"{URL}/{one['id']}/approve", json={"경로": ["공용"]})
assert ok.status_code == 200, ok.text
assert ok.json()["상태"] == "승인" and ok.json()["봄"] is False
company = Tree(client, "company")
assert ["공용", "sub7임시"] in company.paths() # 고른 폴더에 사본
again = client.post(f"{URL}/{one['id']}/approve", json={"경로": []})
assert again.status_code == 409 # 이미 승인
no = client.post(f"{URL}/{two['id']}/reject", json={"사유": "이름 다시"})
assert no.json()["상태"] == "거절" and no.json()["사유"] == "이름 다시"
_as(world) # 요청자 — 원본 그대로 · 결과 둘 · 봄 뒤 0
assert ["배수", "sub7임시"] in Tree(client, "personal").paths()
rows = client.get(URL).json()["줄"]
assert [(r["상태"], r["봄"]) for r in rows] == [("승인", False), ("거절", False)]
assert client.post(f"{URL}/seen").json() == {"봄": 2}
assert client.post(f"{URL}/seen").json() == {"봄": 0}
# 거절된 것은 다시 요청할 수 있음
assert client.post(URL, json={"종류": "precombo", "키": pre}).status_code == 200
def test_사전조합_승인_폴더_자리(world):
client = _client(world)
_, pre = _mine(world, client)
rid = client.post(URL, json={"종류": "precombo", "키": pre}).json()["id"]
_as(world, "ADMIN", user=50)
tree_url = "/api/m02/layers/company/precombo-tree"
now = client.get(tree_url).json()
client.post(tree_url, json={"op": "add-folder", "경로": [], "이름": "옹벽", "판": now["판"]})
made = client.post(f"{URL}/{rid}/approve", json={"경로": ["옹벽"]}).json()["사본"]
assert client.get(tree_url).json()["자리"] == {made: ["옹벽"]}
doc = client.get(f"/api/m02/layers/company/templates/precombo/{made}").json()["문서"]
assert doc["이름"] == "sub7임시 사전"
assert doc["본뜬"]["주인"] == "7/42" # 원본 표시
def test_권한_일반_승인_403_다른_회사(world):
client = _client(world)
item, _ = _mine(world, client)
rid = client.post(URL, json={"종류": "item", "키": item}).json()["id"]
assert client.post(f"{URL}/{rid}/approve", json={}).status_code == 403 # 일반 = 승인 못 함
assert client.post(f"{URL}/{rid}/reject", json={}).status_code == 403
_as(world, "USER", user=43) # 같은 회사 다른 일반 — 남의 요청 안 보임
assert client.get(URL).json()["줄"] == []
_as(world, "ADMIN", user=90, company=9) # 다른 회사 관리자
assert client.get(URL).json()["줄"] == []
assert client.post(f"{URL}/{rid}/approve", json={}).status_code == 404 # 자기 장부에 없음
assert client.get(URL, params={"company": "7"}).json()["회사"] == "9" # 남의 회사 고르기 무시
_as(world, "SYSTEM_ADMIN", user=1, company=None)
assert len(client.get(URL, params={"company": "7"}).json()["줄"]) == 1
ledger = world["storage"] / "7" / "templates" / share.FILE
assert json.loads(ledger.read_bytes())["줄"][0]["상태"] == "요청"