feat(snippets): 이름 규칙(영문 대문자·숫자·_, 3~50자)·실시간 중복/저장될 이름 표시, 설명 필수, 검색에 본문 ★머리줄·주석 포함(한글 검색), 메타 일괄 반영 명령 apply_snippet_meta

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
lee-hyeon-cheol
2026-09-23 13:42:32 +09:00
co-authored by Claude Opus 5.5
parent aa7481ffe9
commit 7a1498a791
10 changed files with 355 additions and 27 deletions
@@ -0,0 +1,73 @@
"""검토한 스니펫 메타(한글 설명·새 이름)를 DB 에 반영. CSV 는 docs 의 검토 파일(UTF-8, 헤더 name,new_name,desc).
python manage.py apply_snippet_meta snippet_meta_review.csv --dry-run # 뭐가 바뀌는지만
python manage.py apply_snippet_meta snippet_meta_review.csv # 반영
- desc: 비어 있거나 한글이 없는(옛 이름 복사 등) 설명만 채움. --overwrite 면 한글 설명도 덮어씀.
- new_name: 옛 규칙 이름을 새 이름으로. 사람별 사용 기록(SnippetUsage)도 같이 옮김. 새 이름이 이미 있으면 건너뜀.
- DB 에 없는 name 은 건너뜀(사용자가 지웠을 수 있음). 여러 번 돌려도 결과 같음.
"""
import csv
import re
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from apps.snippets.models import Snippet, SnippetUsage
from apps.snippets.views import name_problem, normalize_name
KO = re.compile(r"[가-힣]")
class Command(BaseCommand):
help = "검토한 스니펫 한글 설명·새 이름을 반영"
def add_arguments(self, parser):
parser.add_argument("path")
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--overwrite", action="store_true", help="이미 한글 설명이 있어도 덮어씀")
def handle(self, path, dry_run, overwrite, **_):
try:
with open(path, encoding="utf-8-sig", newline="") as f:
rows = list(csv.DictReader(f))
except OSError as e:
raise CommandError(f"CSV 못 읽음: {e}") from e
if rows and not {"name", "desc"} <= set(rows[0]):
raise CommandError("CSV 헤더는 name,new_name,desc 여야 함")
stat = {"desc": 0, "rename": 0, "missing": 0, "skip": 0}
with transaction.atomic():
for r in rows:
old = (r.get("name") or "").strip()
s = Snippet.objects.filter(name=old).first()
if not s:
stat["missing"] += 1
continue
desc = (r.get("desc") or "").strip()
if desc and (overwrite or not KO.search(s.desc or "")) and desc != s.desc:
self.stdout.write(f"설명 {old}: {s.desc!r}{desc!r}")
s.desc = desc
stat["desc"] += 1
if not dry_run:
s.save(update_fields=["desc", "updated_at"])
new = normalize_name(r.get("new_name") or "")
if new and new != old:
if name_problem(new) or Snippet.objects.filter(name=new).exists():
self.stdout.write(self.style.WARNING(f"이름 {old}{new}: 규칙 위반이거나 이미 있음 — 건너뜀"))
stat["skip"] += 1
continue
self.stdout.write(f"이름 {old}{new}")
stat["rename"] += 1
if not dry_run:
# PK 가 이름이라 새로 만들고 사용 기록을 옮긴 뒤 옛 것을 지움
Snippet.objects.create(name=new, desc=s.desc, body=s.body, category=s.category, created_by=s.created_by)
SnippetUsage.objects.filter(snippet_id=old).update(snippet_id=new)
s.delete()
if dry_run:
transaction.set_rollback(True)
self.stdout.write(
f"{'(미리보기) ' if dry_run else ''}설명 {stat['desc']} / 이름 변경 {stat['rename']} / "
f"건너뜀 {stat['skip']} / DB 에 없음 {stat['missing']} (총 {len(rows)}줄)"
)
+37 -6
View File
@@ -7,6 +7,8 @@
POST /api/v1/snippets/{name}/use → {name, usageCount, lastUsed}
"""
import re
from django.db import IntegrityError, transaction
from django.shortcuts import get_object_or_404
from django.utils import timezone
@@ -18,19 +20,48 @@ from common.envelope import CodedError
from .models import Snippet, SnippetUsage
# 이름 규칙 — 프론트 features/snippets/core/name.ts 와 같음(바꾸면 양쪽 같이).
# 영문 대문자·숫자·_, 문자로 시작, 3~50자, _ 연속·앞뒤 금지. 권장 "영역_대상_동작". 한글은 설명에.
NAME_RE = re.compile(r"^[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$")
NAME_MIN, NAME_MAX = 3, 50
def normalize_name(name: str) -> str:
"""입력 → 저장될 이름. 대문자, 공백·하이픈·점→_, 그 외 기호·한글 버림, _ 정리."""
s = re.sub(r"[\s\-.]+", "_", (name or "").upper())
s = re.sub(r"[^A-Z0-9_]", "", s)
return re.sub(r"_+", "_", s).strip("_")
def lookup_name(name: str) -> str:
"""URL 의 이름 → DB 키. 옛 규칙 이름(IF_SUBRC-ABAP 등)이 남아 있으면 그대로 찾아야 해서 정규화 안 함(대문자·공백만)."""
return (name or "").strip().upper().replace(" ", "_")
def name_problem(raw: str) -> str | None:
name = normalize_name(raw)
if not name:
return "이름은 영문으로 — 한글은 설명에 적어" if re.search(r"[가-힣]", raw or "") else "이름을 입력해야 함"
if not name[0].isalpha():
return "이름은 영문자로 시작해야 함"
if not NAME_MIN <= len(name) <= NAME_MAX:
return f"이름은 {NAME_MIN}~{NAME_MAX}"
if not NAME_RE.match(name):
return "영문 대문자·숫자·_ 만 쓸 수 있음"
return None
def _validate(body: dict, *, need_name: bool) -> dict:
name = normalize_name(body.get("name", ""))
if need_name and not name:
raise CodedError(400, "VALIDATION_ERROR", "이름을 입력해야 함")
if need_name and (problem := name_problem(body.get("name", ""))):
raise CodedError(400, "VALIDATION_ERROR", problem)
if not (body.get("desc") or "").strip():
raise CodedError(400, "VALIDATION_ERROR", "설명을 입력해야 함 — 한글로 무엇을 하는 코드인지")
if not (body.get("body") or "").strip():
raise CodedError(400, "VALIDATION_ERROR", "내용을 입력해야 함")
return {
"name": name,
"desc": body.get("desc") or "",
"desc": body["desc"].strip(),
"body": body["body"],
"category": (body.get("category") or "").strip() or "코드",
}
@@ -53,7 +84,7 @@ class SnippetListView(APIView):
class SnippetDetailView(APIView):
def put(self, request, name: str):
s = get_object_or_404(Snippet, name=normalize_name(name))
s = get_object_or_404(Snippet, name=lookup_name(name))
d = _validate({**request.data, "name": s.name}, need_name=False)
s.desc, s.body, s.category = d["desc"], d["body"], d["category"]
s.save(update_fields=["desc", "body", "category", "updated_at"])
@@ -61,7 +92,7 @@ class SnippetDetailView(APIView):
return Response(s.as_dto(usage))
def delete(self, request, name: str):
s = get_object_or_404(Snippet, name=normalize_name(name))
s = get_object_or_404(Snippet, name=lookup_name(name))
deleted = s.name # delete() 가 pk 를 None 으로 지움
s.delete()
return Response({"name": deleted})
@@ -69,7 +100,7 @@ class SnippetDetailView(APIView):
class SnippetUseView(APIView):
def post(self, request, name: str):
s = get_object_or_404(Snippet, name=normalize_name(name))
s = get_object_or_404(Snippet, name=lookup_name(name))
u, _ = SnippetUsage.objects.get_or_create(snippet=s, user=request.user)
u.count += 1
u.last_used = timezone.now()
+49 -4
View File
@@ -8,12 +8,17 @@ from apps.snippets.models import Snippet
def test_crud_and_usage(auth_api, user):
r = auth_api.post("/api/v1/snippets", {"name": " alv filter ", "body": "x", "category": ""}, format="json")
r = auth_api.post("/api/v1/snippets", {"name": " alv filter ", "desc": "ALV 필터", "body": "x", "category": ""}, format="json")
assert r.status_code == 201 and r.json()["data"]["name"] == "ALV_FILTER" and r.json()["data"]["category"] == "코드"
assert auth_api.post("/api/v1/snippets", {"name": "ALV_FILTER", "body": "y"}, format="json").status_code == 409
assert auth_api.post("/api/v1/snippets", {"name": "", "body": "y"}, format="json").status_code == 400
assert auth_api.post("/api/v1/snippets", {"name": "B", "body": " "}, format="json").status_code == 400
assert auth_api.post("/api/v1/snippets", {"name": "alv-filter", "desc": "d", "body": "y"}, format="json").status_code == 409 # 하이픈도 같은 이름
assert auth_api.post("/api/v1/snippets", {"name": "", "desc": "d", "body": "y"}, format="json").status_code == 400
assert auth_api.post("/api/v1/snippets", {"name": "BBB", "desc": "d", "body": " "}, format="json").status_code == 400
assert auth_api.post("/api/v1/snippets", {"name": "CCC_OK", "body": "y"}, format="json").status_code == 400 # 설명 필수
r = auth_api.post("/api/v1/snippets", {"name": "필터", "desc": "d", "body": "y"}, format="json")
assert r.status_code == 400 and "설명" in r.json()["message"] # 한글 이름 → 설명으로 안내
assert auth_api.post("/api/v1/snippets", {"name": "1ABC", "desc": "d", "body": "y"}, format="json").status_code == 400
assert auth_api.put("/api/v1/snippets/alv_filter", {"desc": " ", "body": "z"}, format="json").status_code == 400
r = auth_api.put("/api/v1/snippets/alv_filter", {"desc": "d", "body": "z", "category": "ALV"}, format="json")
assert r.status_code == 200 and r.json()["data"]["body"] == "z" and r.json()["data"]["category"] == "ALV"
@@ -46,3 +51,43 @@ def test_import_from_sqlite(db, tmp_path):
assert set(names) == {"A_B", "C"} and names["C"].category == "코드"
call_command("import_snippets", str(p)) # 재실행해도 중복 없음
assert Snippet.objects.count() == 2
def test_legacy_name_still_editable(auth_api, user):
Snippet.objects.create(name="IF_SUBRC-ABAP", desc="", body="IF sy-subrc = 0.")
r = auth_api.put("/api/v1/snippets/IF_SUBRC-ABAP", {"desc": "sy-subrc 분기", "body": "IF sy-subrc = 0."}, format="json")
assert r.status_code == 200 and r.json()["data"]["name"] == "IF_SUBRC-ABAP"
def test_apply_snippet_meta(db, user, tmp_path):
from apps.snippets.models import SnippetUsage
Snippet.objects.create(name="IF_SUBRC-ABAP", desc="", body="IF sy-subrc = 0.")
Snippet.objects.create(name="ALV_FILTER_ROWS", desc="ALV_FILTER", body="x") # 한글 없는 옛 설명 → 채움
Snippet.objects.create(name="KEEP_KO", desc="이미 한글", body="x") # 한글 설명 → 안 덮음
Snippet.objects.create(name="TAKEN", desc="d", body="x")
Snippet.objects.create(name="OLD_ONE", desc="", body="x")
SnippetUsage.objects.create(snippet_id="IF_SUBRC-ABAP", user=user, count=3)
p = tmp_path / "meta.csv"
p.write_text(
"\n".join([
"name,new_name,desc",
"IF_SUBRC-ABAP,CONTROL_IF_SUBRC_CHECK,sy-subrc 로 성공/실패 분기",
"ALV_FILTER_ROWS,,ALV 에서 조건에 맞는 행만 필터",
"KEEP_KO,,새 설명",
"OLD_ONE,TAKEN,x",
"GONE,,없는 것",
]),
encoding="utf-8",
)
call_command("apply_snippet_meta", str(p), "--dry-run")
assert Snippet.objects.filter(name="IF_SUBRC-ABAP").exists() # 미리보기는 안 바꿈
call_command("apply_snippet_meta", str(p))
new = Snippet.objects.get(name="CONTROL_IF_SUBRC_CHECK")
assert new.desc == "sy-subrc 로 성공/실패 분기" and not Snippet.objects.filter(name="IF_SUBRC-ABAP").exists()
assert SnippetUsage.objects.get(snippet_id="CONTROL_IF_SUBRC_CHECK").count == 3 # 사용 기록 같이 옮김
assert Snippet.objects.get(name="ALV_FILTER_ROWS").desc == "ALV 에서 조건에 맞는 행만 필터"
assert Snippet.objects.get(name="KEEP_KO").desc == "이미 한글"
assert Snippet.objects.filter(name="OLD_ONE").exists() # 새 이름이 이미 있으면 건너뜀
call_command("apply_snippet_meta", str(p)) # 다시 돌려도 결과 같음
assert Snippet.objects.filter(name="CONTROL_IF_SUBRC_CHECK").count() == 1