feat: 스니펫 공용화 — 서버 PostgreSQL 테이블 + API, 프론트는 브릿지 대신 HTTP
결정(9/21): 공용 하나, 개인 로컬 없음.
- backend apps/snippets: Snippet(name PK·desc·body·category) + SnippetUsage(사람별 count/last_used).
GET/POST /api/v1/snippets, PUT/DELETE /snippets/{name}, POST /snippets/{name}/use — 옛 브릿지 5개와 1:1,
이름 정규화·중복 409·빈 내용 400 규칙 동일. import_snippets 커맨드로 옛 snippets.db 적재. 테스트 3개
- frontend snippets.api.ts 만 HTTP 로 교체(훅·페이지·타입 무변경). 테스트 axios-mock-adapter 로
- ponytail: Tauri 로컬 SQLite 커맨드는 미사용 상태로 남김. 지울 때 Rust+브릿지 테스트 함께
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
df5feaad3a
commit
2e7bae4c06
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class SnippetsConfig(AppConfig):
|
||||
name = "apps.snippets"
|
||||
verbose_name = "공용 스니펫"
|
||||
@@ -0,0 +1,39 @@
|
||||
"""옛 로컬 snippets.db(SQLite) → 서버. 같은 이름은 건너뜀(--overwrite 로 덮어씀).
|
||||
|
||||
python manage.py import_snippets ../CODE_ASSISTANT_DB/snippets.db
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from apps.snippets.models import Snippet
|
||||
from apps.snippets.views import normalize_name
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "로컬 snippets.db 를 서버 스니펫으로 가져옴"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("path")
|
||||
parser.add_argument("--overwrite", action="store_true")
|
||||
|
||||
def handle(self, path, overwrite, **_):
|
||||
con = sqlite3.connect(path)
|
||||
rows = con.execute("SELECT name, desc, body, category FROM snippets").fetchall()
|
||||
added = updated = skipped = 0
|
||||
for name, desc, body, category in rows:
|
||||
name = normalize_name(name)
|
||||
if not name or not (body or "").strip():
|
||||
skipped += 1
|
||||
continue
|
||||
d = {"desc": desc or "", "body": body, "category": (category or "").strip() or "코드"}
|
||||
if overwrite:
|
||||
_, created = Snippet.objects.update_or_create(name=name, defaults=d)
|
||||
added += created
|
||||
updated += not created
|
||||
else:
|
||||
_, created = Snippet.objects.get_or_create(name=name, defaults=d)
|
||||
added += created
|
||||
skipped += not created
|
||||
self.stdout.write(f"추가 {added} / 갱신 {updated} / 건너뜀 {skipped} (총 {len(rows)})")
|
||||
@@ -0,0 +1,44 @@
|
||||
# Generated by Django 5.1.15 on 2026-09-21 07:01
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Snippet',
|
||||
fields=[
|
||||
('name', models.CharField(max_length=200, primary_key=True, serialize=False)),
|
||||
('desc', models.CharField(blank=True, default='', max_length=500)),
|
||||
('body', models.TextField()),
|
||||
('category', models.CharField(default='코드', max_length=100)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SnippetUsage',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('count', models.IntegerField(default=0)),
|
||||
('last_used', models.DateTimeField(null=True)),
|
||||
('snippet', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='usages', to='snippets.snippet')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('snippet', 'user')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,38 @@
|
||||
"""공용 스니펫 — 로컬 SQLite(snippets/usage) 를 그대로 서버 PostgreSQL 로. 전원 읽기, 사용 기록은 사람별."""
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Snippet(models.Model):
|
||||
name = models.CharField(max_length=200, primary_key=True) # 대문자·공백→_ 정규화된 이름(옛 DB 와 동일)
|
||||
desc = models.CharField(max_length=500, blank=True, default="")
|
||||
body = models.TextField()
|
||||
category = models.CharField(max_length=100, default="코드")
|
||||
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL, related_name="+")
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["name"]
|
||||
|
||||
def as_dto(self, usage: "SnippetUsage | None" = None) -> dict:
|
||||
return {
|
||||
"name": self.name,
|
||||
"desc": self.desc,
|
||||
"body": self.body,
|
||||
"category": self.category,
|
||||
"usageCount": usage.count if usage else 0,
|
||||
"lastUsed": int(usage.last_used.timestamp()) if usage and usage.last_used else 0,
|
||||
}
|
||||
|
||||
|
||||
class SnippetUsage(models.Model):
|
||||
"""사람별 사용 횟수 — 랭킹은 내 기준으로."""
|
||||
|
||||
snippet = models.ForeignKey(Snippet, on_delete=models.CASCADE, related_name="usages")
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="+")
|
||||
count = models.IntegerField(default=0)
|
||||
last_used = models.DateTimeField(null=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = [("snippet", "user")]
|
||||
@@ -0,0 +1,9 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import SnippetDetailView, SnippetListView, SnippetUseView
|
||||
|
||||
urlpatterns = [
|
||||
path("", SnippetListView.as_view()),
|
||||
path("/<str:name>", SnippetDetailView.as_view()),
|
||||
path("/<str:name>/use", SnippetUseView.as_view()),
|
||||
]
|
||||
@@ -0,0 +1,77 @@
|
||||
"""스니펫 API — 프론트 `features/snippets/api/snippets.api.ts` 5개 호출과 1:1. 옛 Tauri 브릿지 규칙 그대로.
|
||||
|
||||
GET /api/v1/snippets → Snippet[] (내 usage 합쳐서)
|
||||
POST /api/v1/snippets {name,desc,body,category} → Snippet (이름 중복이면 409)
|
||||
PUT /api/v1/snippets/{name} {desc,body,category} → Snippet
|
||||
DELETE /api/v1/snippets/{name} → {name}
|
||||
POST /api/v1/snippets/{name}/use → {name, usageCount, lastUsed}
|
||||
"""
|
||||
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils import timezone
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from common.envelope import CodedError
|
||||
|
||||
from .models import Snippet, SnippetUsage
|
||||
|
||||
|
||||
def normalize_name(name: str) -> str:
|
||||
return (name or "").strip().upper().replace(" ", "_")
|
||||
|
||||
|
||||
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 not (body.get("body") or "").strip():
|
||||
raise CodedError(400, "VALIDATION_ERROR", "내용을 입력해야 함")
|
||||
return {
|
||||
"name": name,
|
||||
"desc": body.get("desc") or "",
|
||||
"body": body["body"],
|
||||
"category": (body.get("category") or "").strip() or "코드",
|
||||
}
|
||||
|
||||
|
||||
class SnippetListView(APIView):
|
||||
def get(self, request):
|
||||
usage = {u.snippet_id: u for u in SnippetUsage.objects.filter(user=request.user)}
|
||||
return Response([s.as_dto(usage.get(s.name)) for s in Snippet.objects.all()])
|
||||
|
||||
def post(self, request):
|
||||
d = _validate(request.data, need_name=True)
|
||||
try:
|
||||
with transaction.atomic(): # 중복 INSERT 실패가 바깥 트랜잭션을 깨지 않게(savepoint)
|
||||
s = Snippet.objects.create(**d, created_by=request.user)
|
||||
except IntegrityError:
|
||||
raise CodedError(409, "CONFLICT", f"이미 있는 이름임: {d['name']}")
|
||||
return Response(s.as_dto(), status=201)
|
||||
|
||||
|
||||
class SnippetDetailView(APIView):
|
||||
def put(self, request, name: str):
|
||||
s = get_object_or_404(Snippet, name=normalize_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"])
|
||||
usage = SnippetUsage.objects.filter(snippet=s, user=request.user).first()
|
||||
return Response(s.as_dto(usage))
|
||||
|
||||
def delete(self, request, name: str):
|
||||
s = get_object_or_404(Snippet, name=normalize_name(name))
|
||||
deleted = s.name # delete() 가 pk 를 None 으로 지움
|
||||
s.delete()
|
||||
return Response({"name": deleted})
|
||||
|
||||
|
||||
class SnippetUseView(APIView):
|
||||
def post(self, request, name: str):
|
||||
s = get_object_or_404(Snippet, name=normalize_name(name))
|
||||
u, _ = SnippetUsage.objects.get_or_create(snippet=s, user=request.user)
|
||||
u.count += 1
|
||||
u.last_used = timezone.now()
|
||||
u.save(update_fields=["count", "last_used"])
|
||||
return Response({"name": s.name, "usageCount": u.count, "lastUsed": int(u.last_used.timestamp())})
|
||||
Reference in New Issue
Block a user