feat(M01): 끝수 계산 붙임 · 절 목록·절 거르기·표 조건 값·소유 거름·자체 로직 API
- 엔진 — 로직 줄의 묶음 `끝수`{대상, 자리, 방법}를 계산 끝에 붙임(비목 합마다 끊고 계 = 그 합 · 돈 아닌 로직은 결과) · 글자 끝수는 설명 그대로 · 검사가 묶음 모양을 봄
- 서버 — `GET /sections`(원문 본문 절 목록 + 그 절의 표·로직 수) · `/tables` 의 `section`(13-4 가 13-40 에 안 걸림) · `GET /table/options`(조건 값만) · `/logics` 의 `owner`
- 자체 로직 — `POST /logic/new|copy|edit|delete` · 파일 `로직_자체.json` · 키 GX 서버 발급(원문번호 같아도 줄마다 다른 키) · 정본은 403 · 저장 안 한 초안은 `/calc` 에 `key` "" 로
- 저장소가 700줄을 넘어 쓰기 모양(`keep_shape`·`dump`)을 `M01_MasterData_Store_Shape.py` 로 가름
- 시험 `resources/tester/test_m01_make.py` 8건 통과 · 기존 M01 시험 25건 통과 · 일괄 시험 계산 1,291 그대로
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj
This commit is contained in:
@@ -194,7 +194,7 @@
|
||||
"중간": [{ "이름": "증가율", "식": "…" }],
|
||||
"호표": [{ "종류": "인력", "요소": "LB000002", "단위": "인", "수량": "…", "비목": "노무비" }],
|
||||
"덧줄": [{ "이름": "잡재료비", "식": "노무비 * 0.05", "비목": "재료비", "출처": "…" }],
|
||||
"끝수": "원 미만 버림"
|
||||
"끝수": { "대상": "계", "자리": 0, "방법": "버림", "출처": "산림품셈 13-4-1" }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -204,6 +204,7 @@
|
||||
- `종류` = 인력 · 재료 · 기계 · 로직(하위 로직의 결과를 단가로 씀) — 요소 키의 테이블ID 그룹과 같음.
|
||||
- `비목` = 노무비 · 재료비 · 경비. `덧줄` 식에서 비목 합을 이름으로 씀.
|
||||
- `결과단위` 가 돈이 아닌 로직(㎥/시간)은 `호표` 없이 `중간` + `결과` 식.
|
||||
- `끝수` = 묶음 `{대상, 자리, 방법, 출처?, 비고?}` — 엔진이 계산 맨 끝에 붙임. `대상` 은 `계`(비목 합 셋을 각각 끊고 계 = 그 합) · `결과`(돈 아닌 로직) · `자리` 는 소수 자리(0 = 원 미만) · `방법` 은 `버림`·`올림`·`반올림`. 글자 `끝수`(「손료 원 미만 버림(…)」)는 원문 근거를 적은 설명일 뿐 — 그 끊기는 식 안에 이미 있음.
|
||||
- `종류` 가 로직이고 `비목` 칸이 없는 호표 줄은 불러온 결과를 비목별로 나눠 얹음.
|
||||
재료 고르기 — 호표 재료 줄의 `요소` 를 키 대신 조건 묶음으로 적음:
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
| `GET /tables` | `file` 또는 `group` · `sub` · `detail` · `section` · `q` · `page`(1) · `size`(30) | `{file, version, total, page, size, tables: [{file, 키, 원문번호, 구분, 상세구분, 이름, 기준, 출처, 조건, 값칸, count}]}` — 파일을 가로지른 한 목록 |
|
||||
| `GET /table` | `file` · `key` | `{file, version, table: {표 하나 통째}}` |
|
||||
| `GET /table/options` | `file` · `key` | `{file, key, 이름, 조건, 범위규칙, 값칸, count, 값: {조건 이름: [값…]}}` — 표 줄을 통째로 안 받고 조건 칸의 값만 |
|
||||
| `GET /sections` | `book`(필수) · `division` · `chapter` · `q` · `limit`(200 · 1000까지) | `{total, sections: [{book, division, chapter, section, title, tables, logics}]}` — 원문 절 목록 |
|
||||
| `GET /sections` | `book`(필수) · `division` · `chapter` · `q` · `limit`(200 · 1000까지) | `{total, sections: [{book, division, chapter, chapterNo, section, title, tables, logics}]}` — 원문 절 목록 |
|
||||
| `GET /logics` | `sub` · `detail` · `q` · `blocked`(0/1) · `owner` | `{logics: [{file, 키, 원문번호, 구분, 상세구분, 이름, 결과단위, 출처, 소유, blocked, reasons}]}` — 장 차례대로 |
|
||||
| `GET /logic` | `key` | `{file, version, logic: {로직 한 줄 통째}, blocked, reasons, prices: {요소: 요약 \| null}}` |
|
||||
| `GET /elements` | `group` · `q` · `limit`(50 · 200까지) | `{total, items: [{ref, file, 원문번호, 구분, 상세구분, 이름, 규격, 단위, 값, 값칸}]}` |
|
||||
@@ -39,7 +39,7 @@
|
||||
- `materials` = 재료 고르기 단계 — 로직 재료 줄 `요소` 가 조건 `{구분, 상세구분, 규격}` 이면 이 API 로 후보(방식 A·B·C 가 부품 `M01_MasterData_UI_Test_Pick.ts` 하나를 같이 씀 · 구분 → 상세구분 → 후보 순으로 좁힘). `값` 이 비어 있어도 후보로 둠. 옛 재료 줄(품셈재료 키)은 품셈재료 이름(없으면 이름 첫 낱말)으로 `pick?kind=price` 후보. 후보 글 = 이름 규격 · 값 있는 열 전부(물가자료 · 유통물가 …). 멈춤 까닭 `입력 「X」 없음` · `맞는 줄 0개` 는 방식 A·B·C 가 쉬운 말로 바꿔 보임(`plainReason`) · 빈 수 칸은 「값을 넣어 주세요」 안내 글.
|
||||
- 소요량·계수 표의 `용도`({공종, 대상, 로직키}) = 방식 B 원문 표 미리보기 머리에 안내 · 칸이 없으면 안 보임 · `/tables` 는 `usage` 로 `용도.대상` 거름(기계 `sub`·`detail` 은 인력과 같이 구분 → 상세구분).
|
||||
- `section` = 절 번호 거름 — 그 절과 그 아래 절만(`13-4` 는 `13-4` · `13-4-1` 만 · `13-40` 은 안 걸림). 「만들기」가 절을 고르면 그 절의 표만 보임.
|
||||
- `/sections` = 만들기 첫 걸음 — 원문 본문의 절 제목 줄에서 모음(`book` = 산림품셈 · 건설품셈 · `division` = 건설품셈 부문(공통 · 토목 · 건축 · 기계설비 · 유지관리) · `chapter` = 장 번호 두 자리 「13」 · `q` = 절 번호·제목 찾기). `tables` · `logics` = 그 절(아래 절 포함)의 마스터 표·로직 수 — 0 이면 아직 안 올린 절.
|
||||
- `/sections` = 만들기 첫 걸음 — 원문 본문의 절 제목 줄에서 모음(`book` = 산림품셈 · 건설품셈 · `division` = 건설품셈 부문(공통 · 토목 · 건축 · 기계설비 · 유지관리) · `chapter` = 장 번호 두 자리 「13」 · `q` = 절 번호·제목 찾기). `chapter` = 「부문 NN장 장 제목」 · `chapterNo` = 「NN」 · `tables` · `logics` = 그 절(아래 절 포함)의 마스터 표·로직 수 — 0 이면 아직 안 올린 절.
|
||||
- `/table/options` = 조건 값 고르기 — `고르기` 조건은 줄에 나온 값 차례대로(중복 없음) · `범위` 조건은 `[[아래, 위]…]` · `count` = 줄 수. 표 몸을 통째로 받지 않으려는 자리에만 씀(통째는 `/table`).
|
||||
- `owner` = 로직 `소유` 거름(`공용` 정본 · `현장` 자체) · 빈 글 = 전체.
|
||||
- `pick` = 고르기 창 — `price` = `재료_자재품목.json`(값 = 값 열 다섯 가운데 낮은 값 · `관급` = 관급 값 있음) ref 키 · `job` = `인력.json` 값 있는 공표 직종(구분 「미확보」 빼고) ref 키.
|
||||
@@ -80,9 +80,9 @@
|
||||
|
||||
정본 로직(`소유` 「공용」 · `로직_산림품셈…` · `로직_건설품셈…`)은 이 길로 못 고침 — 403. 고치려면 먼저 복제.
|
||||
|
||||
- 자리 = `로직_자체_NN장_<장 제목>.json`(`NN` = 원문 절 번호의 장 · 원문 절이 없으면 `로직_자체_00장_자체.json`) · 없으면 서버가 머리(`{"그룹": "로직", "원문": "자체", "차례": 90, "판": <올해>, "줄": []}`)와 함께 만듦.
|
||||
- 키 = 서버가 `_키대장.json` 에서 `GX` 다음 번호로 줌 — 화면은 키를 만들지 않음.
|
||||
- `소유` = `현장`(기본) · `공용`. 줄의 `구분` = 「자체」 · `상세구분` = 「NN장 장 제목」 은 서버가 넣음.
|
||||
- 자리 = `로직_자체.json` 한 파일(원문 장으로 나누지 않음 — 틀이 「자체」 파일에 장 이름을 두지 않음) · 없으면 서버가 머리(`{"그룹": "로직", "원문": "자체", "판": <올해>, "줄": []}`)와 함께 만듦.
|
||||
- 키 = 서버가 `_키대장.json` 에서 `GX` 다음 번호로 줌 — 화면은 키를 만들지 않음. 원문 절이 같아도 줄마다 다른 키.
|
||||
- `소유` = `현장`(기본) · `공용`. 줄의 `구분` = 「자체」 · `상세구분` = 「NN장 장 제목」(`출처`·`원문번호` 에서 서버가 찾음 · 원문 절이 없으면 빈 글)은 서버가 넣음.
|
||||
|
||||
| 길 | 받음 | 줌 |
|
||||
| -------------------- | ----------------------- | ------------------------------------------------------------ |
|
||||
|
||||
@@ -426,6 +426,21 @@ def evaluate(node, env: dict, master: Master, depth: int = 0):
|
||||
raise FormulaError(f"모르는 마디 {kind}")
|
||||
|
||||
|
||||
ROUND_WAYS = {"올림": ROUND_UP, "버림": ROUND_DOWN, "반올림": ROUND_HALF_UP}
|
||||
ROUND_ON = ("계", "결과")
|
||||
|
||||
|
||||
def cut(row: dict, value: Decimal, on: str) -> Decimal:
|
||||
"""줄의 `끝수` 를 붙임 — 묶음 `{대상, 자리, 방법}` 만 · 글자 끝수는 원문 근거를 적은 설명."""
|
||||
rule = row.get("끝수")
|
||||
if not isinstance(rule, dict) or rule.get("대상") != on:
|
||||
return value
|
||||
way = ROUND_WAYS.get(str(rule.get("방법")))
|
||||
if way is None:
|
||||
raise FormulaError(f"「{_name(row)}」 끝수 방법 「{rule.get('방법')}」")
|
||||
return _round(value, rule.get("자리") or 0, way)
|
||||
|
||||
|
||||
def _inputs(row: dict, given: dict) -> dict:
|
||||
env = {}
|
||||
for spec in row.get("입력", []):
|
||||
@@ -463,7 +478,8 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict:
|
||||
for step in row.get("중간", []):
|
||||
env[step["이름"]] = evaluate(parse(step["식"]), env, master, depth)
|
||||
if "결과" in row:
|
||||
return {"결과": evaluate(parse(row["결과"]["식"]), env, master, depth), "중간": env}
|
||||
value = evaluate(parse(row["결과"]["식"]), env, master, depth)
|
||||
return {"결과": cut(row, value, "결과"), "중간": env}
|
||||
sums = dict.fromkeys(COST_ITEMS, Decimal(0))
|
||||
lines = []
|
||||
for item in row.get("호표", []):
|
||||
@@ -526,6 +542,7 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict:
|
||||
"비목": {extra["비목"]: value},
|
||||
}
|
||||
)
|
||||
sums = {k: cut(row, v, "계") for k, v in sums.items()} # 비목 합마다 끊고 계는 그 합
|
||||
return {"줄": lines, **sums, "계": sum(sums.values()), "중간": env}
|
||||
|
||||
|
||||
@@ -679,9 +696,34 @@ def check_logic(master: Master, row: dict) -> tuple[list[str], set]:
|
||||
names = names | {extra["이름"]}
|
||||
if extra.get("비목") not in COST_ITEMS:
|
||||
out.append(f"{key} 덧줄 {extra['이름']} · 비목 「{extra.get('비목')}」")
|
||||
out += _check_cut(row, key)
|
||||
return out, calls
|
||||
|
||||
|
||||
def _check_cut(row: dict, key: str) -> list[str]:
|
||||
"""끝수 — 묶음이면 칸·대상·방법·자리 · 글자는 설명(검사 안 함)."""
|
||||
rule = row.get("끝수")
|
||||
if rule is None or isinstance(rule, str):
|
||||
return []
|
||||
if not isinstance(rule, dict):
|
||||
return [f"{key} 끝수 · 묶음도 글자도 아님"]
|
||||
out = []
|
||||
unknown = set(rule) - {"대상", "자리", "방법", "출처", "비고"}
|
||||
if unknown:
|
||||
out.append(f"{key} 끝수 · 모르는 칸 {sorted(unknown)}")
|
||||
on = rule.get("대상")
|
||||
if on not in ROUND_ON:
|
||||
out.append(f"{key} 끝수 · 대상 「{on}」 — {' · '.join(ROUND_ON)}")
|
||||
elif ("결과" in row) != (on == "결과"):
|
||||
out.append(f"{key} 끝수 · 대상 「{on}」 이 로직 갈래와 안 맞음")
|
||||
if rule.get("방법") not in ROUND_WAYS:
|
||||
out.append(f"{key} 끝수 · 방법 「{rule.get('방법')}」 — {' · '.join(ROUND_WAYS)}")
|
||||
place = rule.get("자리", 0)
|
||||
if not isinstance(place, (int, Decimal)) or place != int(place) or not 0 <= int(place) <= 6:
|
||||
out.append(f"{key} 끝수 · 자리 「{place}」 — 0∼6 정수")
|
||||
return out
|
||||
|
||||
|
||||
def find_loops(graph: dict[str, set]) -> list[list[str]]:
|
||||
"""로직 부르기의 돌고 도는 길."""
|
||||
loops, state = [], {}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
"""M01 「만들기」 API — 절 목록 · 절 거르기 · 표 조건 값 · 소유 거름 · 자체 로직 · 끝수.
|
||||
|
||||
계약 `resources/master_data/_화면_계약.md` 2장·5장·6장. 임시 폴더 사본에서 씀.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from M01_MasterData import M01_MasterData_Router as router_module
|
||||
from M01_MasterData import M01_MasterData_Store as store
|
||||
from M01_MasterData import M01_MasterData_Store_Make as make
|
||||
|
||||
REAL = store.FOLDER
|
||||
LABOR = "LB000002" # 보통인부
|
||||
SAMPLE = "GF000219" # 산림 13-4-1 메쌓기(인력)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path: Path, monkeypatch) -> TestClient:
|
||||
for path in REAL.glob("*.json"):
|
||||
if not path.name.startswith("_") or path.name == store.mk.BOOK.name:
|
||||
shutil.copy(path, tmp_path / path.name)
|
||||
monkeypatch.setattr(store, "FOLDER", tmp_path)
|
||||
app = FastAPI()
|
||||
app.include_router(router_module.router)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _get(client: TestClient, url: str, **params) -> dict:
|
||||
res = client.get(url, params=params)
|
||||
assert res.status_code == 200, res.text
|
||||
return res.json()
|
||||
|
||||
|
||||
def _post(client: TestClient, url: str, body: dict, want: int = 200) -> dict:
|
||||
res = client.post(url, json=body)
|
||||
assert res.status_code == want, res.text
|
||||
return res.json()
|
||||
|
||||
|
||||
def _draft(끝수=None) -> dict:
|
||||
"""보통인부 1/7 인 한 줄짜리 초안 — 저장 안 함."""
|
||||
return {
|
||||
"원문번호": "",
|
||||
"이름": "시험 초안",
|
||||
"결과단위": "원/㎡",
|
||||
"출처": "자체",
|
||||
"소유": "현장",
|
||||
"입력": [],
|
||||
"중간": [],
|
||||
"호표": [
|
||||
{
|
||||
"종류": "인력",
|
||||
"요소": LABOR,
|
||||
"이름": "보통인부",
|
||||
"단위": "인",
|
||||
"수량": "1 / 7",
|
||||
"비목": "노무비",
|
||||
}
|
||||
],
|
||||
"덧줄": [],
|
||||
"끝수": 끝수,
|
||||
}
|
||||
|
||||
|
||||
# ── 끝수 ──────────────────────────────────────────────────────────────
|
||||
def test_끝수_묶음이_계에_붙고_글자는_안_붙는다(client: TestClient) -> None:
|
||||
plain = _post(client, "/api/m01/calc", {"key": "", "inputs": {}, "row": _draft()})
|
||||
assert plain["ok"] is True, plain
|
||||
raw = Decimal(str(plain["sums"]["계"]))
|
||||
assert raw != raw.to_integral_value() # 172068 / 7 — 원 미만이 남음
|
||||
|
||||
cut = _post(
|
||||
client,
|
||||
"/api/m01/calc",
|
||||
{"key": "", "inputs": {}, "row": _draft({"대상": "계", "자리": 0, "방법": "버림"})},
|
||||
)
|
||||
assert Decimal(str(cut["sums"]["계"])) == raw.to_integral_value(rounding="ROUND_DOWN")
|
||||
assert cut["sums"]["노무비"] == cut["sums"]["계"] # 비목 합마다 끊고 계 = 그 합
|
||||
|
||||
text = _post(client, "/api/m01/calc", {"key": "", "inputs": {}, "row": _draft("원 미만 버림")})
|
||||
assert Decimal(str(text["sums"]["계"])) == raw # 글자 끝수는 설명일 뿐
|
||||
|
||||
|
||||
def test_끝수_묶음_모양을_검사가_잡는다() -> None:
|
||||
bad = {"키": "GX000001", "원문번호": "", "호표": [], "끝수": {"대상": "줄", "방법": "내림"}}
|
||||
found = store.cm.mf.check_logic(store.cm.master(REAL), bad)[0]
|
||||
assert any("대상" in x for x in found) and any("방법" in x for x in found)
|
||||
|
||||
|
||||
# ── 절 목록 · 절 거르기 · 표 조건 값 ──────────────────────────────────
|
||||
def test_절_목록과_절로_표_거르기(client: TestClient) -> None:
|
||||
got = _get(client, "/api/m01/sections", book="산림품셈", chapter="13")
|
||||
one = next(s for s in got["sections"] if s["section"] == "13-4-1")
|
||||
assert one["title"].startswith("메쌓기") and one["chapter"] == "13장 구조물"
|
||||
assert one["tables"] >= 1 and one["logics"] >= 1
|
||||
assert client.get("/api/m01/sections", params={"book": "없는품셈"}).status_code == 404
|
||||
|
||||
hits = _get(client, "/api/m01/tables", group="소요량", section="13-4", size=500)["tables"]
|
||||
assert hits and all(store.in_section(t["원문번호"], "13-4") for t in hits)
|
||||
assert not store.in_section("13-40", "13-4") and store.in_section("13-4", "13-4")
|
||||
|
||||
|
||||
def test_표_조건_값만_받는다(client: TestClient) -> None:
|
||||
listed = _get(client, "/api/m01/tables", group="소요량", section="13-4-1", size=50, q="메쌓기")[
|
||||
"tables"
|
||||
]
|
||||
one = listed[0]
|
||||
got = _get(client, "/api/m01/table/options", file=one["file"], key=one["키"])
|
||||
assert got["count"] >= 1 and "줄" not in got
|
||||
for name, kind in got["조건"].items():
|
||||
assert name in got["값"]
|
||||
if kind == "고르기":
|
||||
assert len(got["값"][name]) == len(set(map(str, got["값"][name])))
|
||||
|
||||
|
||||
def test_로직_목록을_소유로_거른다(client: TestClient) -> None:
|
||||
every = _get(client, "/api/m01/logics")["logics"]
|
||||
assert all("소유" in x for x in every)
|
||||
only = _get(client, "/api/m01/logics", owner="공용")["logics"]
|
||||
assert only and all(x["소유"] == "공용" for x in only)
|
||||
assert _get(client, "/api/m01/logics", owner="현장")["logics"] == []
|
||||
|
||||
|
||||
# ── 자체 로직 ─────────────────────────────────────────────────────────
|
||||
def test_자체_로직_만들기_본뜨기_고치기_지우기(client: TestClient) -> None:
|
||||
made = _post(client, "/api/m01/logic/new", {"logic": _draft(), "owner": "현장"})
|
||||
assert made["key"].startswith("GX") and made["file"] == make.OWN
|
||||
assert made["logic"]["구분"] == "자체" and made["logic"]["소유"] == "현장"
|
||||
assert (store.FOLDER / make.OWN).is_file()
|
||||
|
||||
again = _post(client, "/api/m01/logic/new", {"logic": _draft(), "owner": "현장"})
|
||||
assert again["key"] != made["key"] # 원문번호가 같아도 키는 하나씩
|
||||
|
||||
copied = _post(client, "/api/m01/logic/copy", {"key": SAMPLE, "이름": "메쌓기 본뜸"})
|
||||
assert copied["key"] != SAMPLE and f"{SAMPLE} 를 본뜸" in copied["logic"]["비고"]
|
||||
assert copied["logic"]["이름"] == "메쌓기 본뜸" and copied["logic"]["소유"] == "현장"
|
||||
assert copied["logic"]["상세구분"] == "13장 구조물"
|
||||
|
||||
fixed = dict(copied["logic"], 이름="고친 이름")
|
||||
done = _post(
|
||||
client,
|
||||
"/api/m01/logic/edit",
|
||||
{"key": copied["key"], "version": copied["version"], "logic": fixed},
|
||||
)
|
||||
assert done["logic"]["이름"] == "고친 이름" and done["logic"]["키"] == copied["key"]
|
||||
|
||||
stale = {"key": copied["key"], "version": copied["version"], "logic": fixed}
|
||||
_post(client, "/api/m01/logic/edit", stale, want=409)
|
||||
|
||||
gone = _post(
|
||||
client, "/api/m01/logic/delete", {"key": copied["key"], "version": done["version"]}
|
||||
)
|
||||
assert gone["file"] == make.OWN
|
||||
assert client.get("/api/m01/logic", params={"key": copied["key"]}).status_code == 404
|
||||
|
||||
|
||||
def test_정본_로직은_못_고친다(client: TestClient) -> None:
|
||||
got = _get(client, "/api/m01/logic", key=SAMPLE)
|
||||
body = {"key": SAMPLE, "version": got["version"], "logic": got["logic"]}
|
||||
_post(client, "/api/m01/logic/edit", body, want=403)
|
||||
_post(client, "/api/m01/logic/delete", {"key": SAMPLE, "version": got["version"]}, want=403)
|
||||
_post(client, "/api/m01/logic/new", {"logic": _draft(), "owner": "남의것"}, want=400)
|
||||
|
||||
|
||||
def test_만든_자체_로직이_시험_계산에_돈다(client: TestClient) -> None:
|
||||
made = _post(client, "/api/m01/logic/new", {"logic": _draft(), "owner": "공용"})
|
||||
got = _post(client, "/api/m01/calc", {"key": made["key"], "inputs": {}})
|
||||
assert got["ok"] is True and Decimal(str(got["sums"]["노무비"])) > 0
|
||||
listed = _get(client, "/api/m01/logics", owner="공용")["logics"]
|
||||
assert made["key"] in [x["키"] for x in listed]
|
||||
Reference in New Issue
Block a user