@su-record/vibe 2.3.0 → 2.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.json +35 -35
- package/.claude/settings.local.json +24 -25
- package/.claude/vibe/constitution.md +184 -184
- package/.claude/vibe/rules/core/communication-guide.md +104 -104
- package/.claude/vibe/rules/core/development-philosophy.md +52 -52
- package/.claude/vibe/rules/core/quick-start.md +120 -120
- package/.claude/vibe/rules/languages/dart-flutter.md +509 -509
- package/.claude/vibe/rules/languages/go.md +396 -396
- package/.claude/vibe/rules/languages/java-spring.md +586 -586
- package/.claude/vibe/rules/languages/kotlin-android.md +491 -491
- package/.claude/vibe/rules/languages/python-django.md +371 -371
- package/.claude/vibe/rules/languages/python-fastapi.md +386 -386
- package/.claude/vibe/rules/languages/rust.md +425 -425
- package/.claude/vibe/rules/languages/swift-ios.md +516 -516
- package/.claude/vibe/rules/languages/typescript-nextjs.md +441 -441
- package/.claude/vibe/rules/languages/typescript-node.md +375 -375
- package/.claude/vibe/rules/languages/typescript-nuxt.md +521 -521
- package/.claude/vibe/rules/languages/typescript-react-native.md +446 -446
- package/.claude/vibe/rules/languages/typescript-react.md +525 -525
- package/.claude/vibe/rules/languages/typescript-vue.md +353 -353
- package/.claude/vibe/rules/quality/bdd-contract-testing.md +388 -388
- package/.claude/vibe/rules/quality/checklist.md +276 -276
- package/.claude/vibe/rules/quality/testing-strategy.md +437 -437
- package/.claude/vibe/rules/standards/anti-patterns.md +369 -369
- package/.claude/vibe/rules/standards/code-structure.md +291 -291
- package/.claude/vibe/rules/standards/complexity-metrics.md +312 -312
- package/.claude/vibe/rules/standards/naming-conventions.md +198 -198
- package/.claude/vibe/setup.sh +31 -31
- package/.claude/vibe/templates/constitution-template.md +184 -184
- package/.claude/vibe/templates/contract-backend-template.md +517 -517
- package/.claude/vibe/templates/contract-frontend-template.md +594 -594
- package/.claude/vibe/templates/feature-template.md +96 -96
- package/.claude/vibe/templates/spec-template.md +199 -199
- package/CLAUDE.md +345 -323
- package/LICENSE +21 -21
- package/README.md +744 -724
- package/agents/compounder.md +261 -261
- package/agents/diagrammer.md +178 -178
- package/agents/e2e-tester.md +266 -266
- package/agents/explorer.md +48 -48
- package/agents/implementer.md +53 -53
- package/agents/research/best-practices-agent.md +139 -139
- package/agents/research/codebase-patterns-agent.md +147 -147
- package/agents/research/framework-docs-agent.md +181 -181
- package/agents/research/security-advisory-agent.md +167 -167
- package/agents/review/architecture-reviewer.md +107 -107
- package/agents/review/complexity-reviewer.md +116 -116
- package/agents/review/data-integrity-reviewer.md +88 -88
- package/agents/review/git-history-reviewer.md +103 -103
- package/agents/review/performance-reviewer.md +86 -86
- package/agents/review/python-reviewer.md +152 -152
- package/agents/review/rails-reviewer.md +139 -139
- package/agents/review/react-reviewer.md +144 -144
- package/agents/review/security-reviewer.md +80 -80
- package/agents/review/simplicity-reviewer.md +140 -140
- package/agents/review/test-coverage-reviewer.md +116 -116
- package/agents/review/typescript-reviewer.md +127 -127
- package/agents/searcher.md +54 -54
- package/agents/simplifier.md +119 -119
- package/agents/tester.md +49 -49
- package/agents/ui-previewer.md +137 -137
- package/commands/vibe.analyze.md +245 -180
- package/commands/vibe.reason.md +223 -183
- package/commands/vibe.review.md +200 -136
- package/commands/vibe.run.md +838 -836
- package/commands/vibe.spec.md +419 -383
- package/commands/vibe.utils.md +101 -101
- package/commands/vibe.verify.md +282 -241
- package/dist/cli/index.js +385 -385
- package/dist/lib/MemoryManager.d.ts.map +1 -1
- package/dist/lib/MemoryManager.js +119 -114
- package/dist/lib/MemoryManager.js.map +1 -1
- package/dist/lib/PythonParser.js +108 -108
- package/dist/lib/gemini-mcp.js +15 -15
- package/dist/lib/gemini-oauth.js +35 -35
- package/dist/lib/gpt-mcp.js +17 -17
- package/dist/lib/gpt-oauth.js +44 -44
- package/dist/tools/analytics/getUsageAnalytics.js +12 -12
- package/dist/tools/index.d.ts +50 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +61 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/memory/createMemoryTimeline.js +10 -10
- package/dist/tools/memory/getMemoryGraph.js +12 -12
- package/dist/tools/memory/getSessionContext.js +9 -9
- package/dist/tools/memory/linkMemories.js +14 -14
- package/dist/tools/memory/listMemories.js +4 -4
- package/dist/tools/memory/recallMemory.js +4 -4
- package/dist/tools/memory/saveMemory.js +4 -4
- package/dist/tools/memory/searchMemoriesAdvanced.js +22 -22
- package/dist/tools/planning/generatePrd.js +46 -46
- package/dist/tools/prompt/enhancePromptGemini.js +160 -160
- package/dist/tools/reasoning/applyReasoningFramework.js +56 -56
- package/dist/tools/semantic/analyzeDependencyGraph.js +12 -12
- package/hooks/hooks.json +121 -103
- package/package.json +73 -69
- package/skills/git-worktree.md +178 -178
- package/skills/priority-todos.md +236 -236
|
@@ -1,371 +1,371 @@
|
|
|
1
|
-
# 🐍 Python + Django 품질 규칙
|
|
2
|
-
|
|
3
|
-
## 핵심 원칙 (core에서 상속)
|
|
4
|
-
|
|
5
|
-
```markdown
|
|
6
|
-
✅ 단일 책임 (SRP)
|
|
7
|
-
✅ 중복 제거 (DRY)
|
|
8
|
-
✅ 재사용성
|
|
9
|
-
✅ 낮은 복잡도
|
|
10
|
-
✅ 함수 ≤ 30줄
|
|
11
|
-
✅ 중첩 ≤ 3단계
|
|
12
|
-
✅ Cyclomatic complexity ≤ 10
|
|
13
|
-
```
|
|
14
|
-
|
|
15
|
-
## Django 특화 규칙
|
|
16
|
-
|
|
17
|
-
### 1. Model 설계
|
|
18
|
-
|
|
19
|
-
```python
|
|
20
|
-
# ✅ models.py
|
|
21
|
-
from django.db import models
|
|
22
|
-
from django.contrib.auth.models import AbstractUser
|
|
23
|
-
from django.utils import timezone
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
class BaseModel(models.Model):
|
|
27
|
-
"""공통 필드를 가진 추상 모델"""
|
|
28
|
-
created_at = models.DateTimeField(auto_now_add=True)
|
|
29
|
-
updated_at = models.DateTimeField(auto_now=True)
|
|
30
|
-
|
|
31
|
-
class Meta:
|
|
32
|
-
abstract = True
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
class User(AbstractUser):
|
|
36
|
-
"""커스텀 사용자 모델"""
|
|
37
|
-
email = models.EmailField(unique=True)
|
|
38
|
-
phone = models.CharField(max_length=20, blank=True)
|
|
39
|
-
profile_image = models.ImageField(upload_to='profiles/', blank=True)
|
|
40
|
-
|
|
41
|
-
USERNAME_FIELD = 'email'
|
|
42
|
-
REQUIRED_FIELDS = ['username']
|
|
43
|
-
|
|
44
|
-
class Meta:
|
|
45
|
-
db_table = 'users'
|
|
46
|
-
verbose_name = '사용자'
|
|
47
|
-
verbose_name_plural = '사용자들'
|
|
48
|
-
|
|
49
|
-
def __str__(self):
|
|
50
|
-
return self.email
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
class Post(BaseModel):
|
|
54
|
-
"""게시글 모델"""
|
|
55
|
-
author = models.ForeignKey(
|
|
56
|
-
User,
|
|
57
|
-
on_delete=models.CASCADE,
|
|
58
|
-
related_name='posts',
|
|
59
|
-
verbose_name='작성자'
|
|
60
|
-
)
|
|
61
|
-
title = models.CharField(max_length=200, verbose_name='제목')
|
|
62
|
-
content = models.TextField(verbose_name='내용')
|
|
63
|
-
is_published = models.BooleanField(default=False, verbose_name='게시 여부')
|
|
64
|
-
published_at = models.DateTimeField(null=True, blank=True)
|
|
65
|
-
|
|
66
|
-
class Meta:
|
|
67
|
-
db_table = 'posts'
|
|
68
|
-
ordering = ['-created_at']
|
|
69
|
-
verbose_name = '게시글'
|
|
70
|
-
verbose_name_plural = '게시글들'
|
|
71
|
-
|
|
72
|
-
def __str__(self):
|
|
73
|
-
return self.title
|
|
74
|
-
|
|
75
|
-
def publish(self):
|
|
76
|
-
"""게시글 발행"""
|
|
77
|
-
self.is_published = True
|
|
78
|
-
self.published_at = timezone.now()
|
|
79
|
-
self.save(update_fields=['is_published', 'published_at'])
|
|
80
|
-
```
|
|
81
|
-
|
|
82
|
-
### 2. View (Class-Based Views 권장)
|
|
83
|
-
|
|
84
|
-
```python
|
|
85
|
-
# ✅ views.py
|
|
86
|
-
from django.views.generic import ListView, DetailView, CreateView, UpdateView
|
|
87
|
-
from django.contrib.auth.mixins import LoginRequiredMixin
|
|
88
|
-
from django.urls import reverse_lazy
|
|
89
|
-
from .models import Post
|
|
90
|
-
from .forms import PostForm
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
class PostListView(ListView):
|
|
94
|
-
"""게시글 목록 뷰"""
|
|
95
|
-
model = Post
|
|
96
|
-
template_name = 'posts/list.html'
|
|
97
|
-
context_object_name = 'posts'
|
|
98
|
-
paginate_by = 10
|
|
99
|
-
|
|
100
|
-
def get_queryset(self):
|
|
101
|
-
queryset = super().get_queryset()
|
|
102
|
-
return queryset.filter(is_published=True).select_related('author')
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
class PostDetailView(DetailView):
|
|
106
|
-
"""게시글 상세 뷰"""
|
|
107
|
-
model = Post
|
|
108
|
-
template_name = 'posts/detail.html'
|
|
109
|
-
context_object_name = 'post'
|
|
110
|
-
|
|
111
|
-
def get_queryset(self):
|
|
112
|
-
return super().get_queryset().select_related('author')
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
class PostCreateView(LoginRequiredMixin, CreateView):
|
|
116
|
-
"""게시글 생성 뷰"""
|
|
117
|
-
model = Post
|
|
118
|
-
form_class = PostForm
|
|
119
|
-
template_name = 'posts/form.html'
|
|
120
|
-
success_url = reverse_lazy('posts:list')
|
|
121
|
-
|
|
122
|
-
def form_valid(self, form):
|
|
123
|
-
form.instance.author = self.request.user
|
|
124
|
-
return super().form_valid(form)
|
|
125
|
-
```
|
|
126
|
-
|
|
127
|
-
### 3. Django REST Framework
|
|
128
|
-
|
|
129
|
-
```python
|
|
130
|
-
# ✅ serializers.py
|
|
131
|
-
from rest_framework import serializers
|
|
132
|
-
from .models import Post, User
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
class UserSerializer(serializers.ModelSerializer):
|
|
136
|
-
"""사용자 시리얼라이저"""
|
|
137
|
-
class Meta:
|
|
138
|
-
model = User
|
|
139
|
-
fields = ['id', 'email', 'username', 'profile_image']
|
|
140
|
-
read_only_fields = ['id']
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
class PostSerializer(serializers.ModelSerializer):
|
|
144
|
-
"""게시글 시리얼라이저"""
|
|
145
|
-
author = UserSerializer(read_only=True)
|
|
146
|
-
author_id = serializers.PrimaryKeyRelatedField(
|
|
147
|
-
queryset=User.objects.all(),
|
|
148
|
-
source='author',
|
|
149
|
-
write_only=True
|
|
150
|
-
)
|
|
151
|
-
|
|
152
|
-
class Meta:
|
|
153
|
-
model = Post
|
|
154
|
-
fields = [
|
|
155
|
-
'id', 'title', 'content', 'author', 'author_id',
|
|
156
|
-
'is_published', 'created_at', 'updated_at'
|
|
157
|
-
]
|
|
158
|
-
read_only_fields = ['id', 'created_at', 'updated_at']
|
|
159
|
-
|
|
160
|
-
def validate_title(self, value):
|
|
161
|
-
if len(value) < 5:
|
|
162
|
-
raise serializers.ValidationError('제목은 5자 이상이어야 합니다')
|
|
163
|
-
return value
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
# ✅ views.py (DRF)
|
|
167
|
-
from rest_framework import viewsets, permissions, status
|
|
168
|
-
from rest_framework.decorators import action
|
|
169
|
-
from rest_framework.response import Response
|
|
170
|
-
from django_filters.rest_framework import DjangoFilterBackend
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
class PostViewSet(viewsets.ModelViewSet):
|
|
174
|
-
"""게시글 ViewSet"""
|
|
175
|
-
queryset = Post.objects.all()
|
|
176
|
-
serializer_class = PostSerializer
|
|
177
|
-
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
|
|
178
|
-
filter_backends = [DjangoFilterBackend]
|
|
179
|
-
filterset_fields = ['is_published', 'author']
|
|
180
|
-
|
|
181
|
-
def get_queryset(self):
|
|
182
|
-
queryset = super().get_queryset()
|
|
183
|
-
return queryset.select_related('author')
|
|
184
|
-
|
|
185
|
-
def perform_create(self, serializer):
|
|
186
|
-
serializer.save(author=self.request.user)
|
|
187
|
-
|
|
188
|
-
@action(detail=True, methods=['post'])
|
|
189
|
-
def publish(self, request, pk=None):
|
|
190
|
-
"""게시글 발행 액션"""
|
|
191
|
-
post = self.get_object()
|
|
192
|
-
|
|
193
|
-
if post.author != request.user:
|
|
194
|
-
return Response(
|
|
195
|
-
{'error': '작성자만 발행할 수 있습니다'},
|
|
196
|
-
status=status.HTTP_403_FORBIDDEN
|
|
197
|
-
)
|
|
198
|
-
|
|
199
|
-
post.publish()
|
|
200
|
-
return Response({'status': '발행되었습니다'})
|
|
201
|
-
```
|
|
202
|
-
|
|
203
|
-
### 4. Service 레이어 (Fat Model 방지)
|
|
204
|
-
|
|
205
|
-
```python
|
|
206
|
-
# ✅ services/post_service.py
|
|
207
|
-
from django.db import transaction
|
|
208
|
-
from django.core.exceptions import PermissionDenied
|
|
209
|
-
from ..models import Post, User
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
class PostService:
|
|
213
|
-
"""게시글 관련 비즈니스 로직"""
|
|
214
|
-
|
|
215
|
-
@staticmethod
|
|
216
|
-
def create_post(author: User, title: str, content: str) -> Post:
|
|
217
|
-
"""게시글 생성"""
|
|
218
|
-
post = Post.objects.create(
|
|
219
|
-
author=author,
|
|
220
|
-
title=title,
|
|
221
|
-
content=content
|
|
222
|
-
)
|
|
223
|
-
return post
|
|
224
|
-
|
|
225
|
-
@staticmethod
|
|
226
|
-
def publish_post(post: Post, user: User) -> Post:
|
|
227
|
-
"""게시글 발행"""
|
|
228
|
-
if post.author != user:
|
|
229
|
-
raise PermissionDenied('작성자만 발행할 수 있습니다')
|
|
230
|
-
|
|
231
|
-
post.publish()
|
|
232
|
-
return post
|
|
233
|
-
|
|
234
|
-
@staticmethod
|
|
235
|
-
@transaction.atomic
|
|
236
|
-
def bulk_publish(post_ids: list[int], user: User) -> int:
|
|
237
|
-
"""여러 게시글 일괄 발행"""
|
|
238
|
-
posts = Post.objects.filter(
|
|
239
|
-
id__in=post_ids,
|
|
240
|
-
author=user,
|
|
241
|
-
is_published=False
|
|
242
|
-
)
|
|
243
|
-
|
|
244
|
-
count = posts.update(
|
|
245
|
-
is_published=True,
|
|
246
|
-
published_at=timezone.now()
|
|
247
|
-
)
|
|
248
|
-
return count
|
|
249
|
-
```
|
|
250
|
-
|
|
251
|
-
### 5. Form 및 Validation
|
|
252
|
-
|
|
253
|
-
```python
|
|
254
|
-
# ✅ forms.py
|
|
255
|
-
from django import forms
|
|
256
|
-
from django.core.exceptions import ValidationError
|
|
257
|
-
from .models import Post
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
class PostForm(forms.ModelForm):
|
|
261
|
-
"""게시글 폼"""
|
|
262
|
-
class Meta:
|
|
263
|
-
model = Post
|
|
264
|
-
fields = ['title', 'content', 'is_published']
|
|
265
|
-
widgets = {
|
|
266
|
-
'title': forms.TextInput(attrs={
|
|
267
|
-
'class': 'form-control',
|
|
268
|
-
'placeholder': '제목을 입력하세요'
|
|
269
|
-
}),
|
|
270
|
-
'content': forms.Textarea(attrs={
|
|
271
|
-
'class': 'form-control',
|
|
272
|
-
'rows': 10
|
|
273
|
-
}),
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
def clean_title(self):
|
|
277
|
-
title = self.cleaned_data.get('title')
|
|
278
|
-
if len(title) < 5:
|
|
279
|
-
raise ValidationError('제목은 5자 이상이어야 합니다')
|
|
280
|
-
return title
|
|
281
|
-
|
|
282
|
-
def clean(self):
|
|
283
|
-
cleaned_data = super().clean()
|
|
284
|
-
title = cleaned_data.get('title')
|
|
285
|
-
content = cleaned_data.get('content')
|
|
286
|
-
|
|
287
|
-
if title and content and title in content:
|
|
288
|
-
raise ValidationError('본문에 제목이 포함되면 안 됩니다')
|
|
289
|
-
|
|
290
|
-
return cleaned_data
|
|
291
|
-
```
|
|
292
|
-
|
|
293
|
-
### 6. Custom Manager와 QuerySet
|
|
294
|
-
|
|
295
|
-
```python
|
|
296
|
-
# ✅ managers.py
|
|
297
|
-
from django.db import models
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
class PostQuerySet(models.QuerySet):
|
|
301
|
-
"""게시글 QuerySet"""
|
|
302
|
-
|
|
303
|
-
def published(self):
|
|
304
|
-
return self.filter(is_published=True)
|
|
305
|
-
|
|
306
|
-
def by_author(self, user):
|
|
307
|
-
return self.filter(author=user)
|
|
308
|
-
|
|
309
|
-
def recent(self, days=7):
|
|
310
|
-
from django.utils import timezone
|
|
311
|
-
from datetime import timedelta
|
|
312
|
-
cutoff = timezone.now() - timedelta(days=days)
|
|
313
|
-
return self.filter(created_at__gte=cutoff)
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
class PostManager(models.Manager):
|
|
317
|
-
"""게시글 Manager"""
|
|
318
|
-
|
|
319
|
-
def get_queryset(self):
|
|
320
|
-
return PostQuerySet(self.model, using=self._db)
|
|
321
|
-
|
|
322
|
-
def published(self):
|
|
323
|
-
return self.get_queryset().published()
|
|
324
|
-
|
|
325
|
-
def by_author(self, user):
|
|
326
|
-
return self.get_queryset().by_author(user)
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
# 모델에서 사용
|
|
330
|
-
class Post(BaseModel):
|
|
331
|
-
# ... fields ...
|
|
332
|
-
objects = PostManager()
|
|
333
|
-
```
|
|
334
|
-
|
|
335
|
-
## 파일 구조
|
|
336
|
-
|
|
337
|
-
```
|
|
338
|
-
app_name/
|
|
339
|
-
├── migrations/ # DB 마이그레이션
|
|
340
|
-
├── management/
|
|
341
|
-
│ └── commands/ # 커스텀 명령어
|
|
342
|
-
├── services/ # 비즈니스 로직
|
|
343
|
-
├── api/
|
|
344
|
-
│ ├── serializers.py # DRF 시리얼라이저
|
|
345
|
-
│ ├── views.py # DRF 뷰
|
|
346
|
-
│ └── urls.py # API 라우팅
|
|
347
|
-
├── templates/ # HTML 템플릿
|
|
348
|
-
├── static/ # 정적 파일
|
|
349
|
-
├── tests/
|
|
350
|
-
│ ├── test_models.py
|
|
351
|
-
│ ├── test_views.py
|
|
352
|
-
│ └── test_services.py
|
|
353
|
-
├── models.py # 모델 (또는 models/ 디렉토리)
|
|
354
|
-
├── views.py # 뷰
|
|
355
|
-
├── forms.py # 폼
|
|
356
|
-
├── managers.py # 커스텀 매니저
|
|
357
|
-
├── admin.py # Admin 설정
|
|
358
|
-
├── urls.py # URL 라우팅
|
|
359
|
-
└── apps.py # 앱 설정
|
|
360
|
-
```
|
|
361
|
-
|
|
362
|
-
## 체크리스트
|
|
363
|
-
|
|
364
|
-
- [ ] Model에 `__str__`, `Meta` 정의
|
|
365
|
-
- [ ] CBV 사용 (권장)
|
|
366
|
-
- [ ] Service 레이어로 비즈니스 로직 분리
|
|
367
|
-
- [ ] select_related/prefetch_related로 N+1 방지
|
|
368
|
-
- [ ] DRF Serializer로 입출력 검증
|
|
369
|
-
- [ ] Custom Manager/QuerySet 활용
|
|
370
|
-
- [ ] Type hints 사용 (Python 3.10+)
|
|
371
|
-
- [ ] 한글 verbose_name 설정
|
|
1
|
+
# 🐍 Python + Django 품질 규칙
|
|
2
|
+
|
|
3
|
+
## 핵심 원칙 (core에서 상속)
|
|
4
|
+
|
|
5
|
+
```markdown
|
|
6
|
+
✅ 단일 책임 (SRP)
|
|
7
|
+
✅ 중복 제거 (DRY)
|
|
8
|
+
✅ 재사용성
|
|
9
|
+
✅ 낮은 복잡도
|
|
10
|
+
✅ 함수 ≤ 30줄
|
|
11
|
+
✅ 중첩 ≤ 3단계
|
|
12
|
+
✅ Cyclomatic complexity ≤ 10
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Django 특화 규칙
|
|
16
|
+
|
|
17
|
+
### 1. Model 설계
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
# ✅ models.py
|
|
21
|
+
from django.db import models
|
|
22
|
+
from django.contrib.auth.models import AbstractUser
|
|
23
|
+
from django.utils import timezone
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class BaseModel(models.Model):
|
|
27
|
+
"""공통 필드를 가진 추상 모델"""
|
|
28
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
29
|
+
updated_at = models.DateTimeField(auto_now=True)
|
|
30
|
+
|
|
31
|
+
class Meta:
|
|
32
|
+
abstract = True
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class User(AbstractUser):
|
|
36
|
+
"""커스텀 사용자 모델"""
|
|
37
|
+
email = models.EmailField(unique=True)
|
|
38
|
+
phone = models.CharField(max_length=20, blank=True)
|
|
39
|
+
profile_image = models.ImageField(upload_to='profiles/', blank=True)
|
|
40
|
+
|
|
41
|
+
USERNAME_FIELD = 'email'
|
|
42
|
+
REQUIRED_FIELDS = ['username']
|
|
43
|
+
|
|
44
|
+
class Meta:
|
|
45
|
+
db_table = 'users'
|
|
46
|
+
verbose_name = '사용자'
|
|
47
|
+
verbose_name_plural = '사용자들'
|
|
48
|
+
|
|
49
|
+
def __str__(self):
|
|
50
|
+
return self.email
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Post(BaseModel):
|
|
54
|
+
"""게시글 모델"""
|
|
55
|
+
author = models.ForeignKey(
|
|
56
|
+
User,
|
|
57
|
+
on_delete=models.CASCADE,
|
|
58
|
+
related_name='posts',
|
|
59
|
+
verbose_name='작성자'
|
|
60
|
+
)
|
|
61
|
+
title = models.CharField(max_length=200, verbose_name='제목')
|
|
62
|
+
content = models.TextField(verbose_name='내용')
|
|
63
|
+
is_published = models.BooleanField(default=False, verbose_name='게시 여부')
|
|
64
|
+
published_at = models.DateTimeField(null=True, blank=True)
|
|
65
|
+
|
|
66
|
+
class Meta:
|
|
67
|
+
db_table = 'posts'
|
|
68
|
+
ordering = ['-created_at']
|
|
69
|
+
verbose_name = '게시글'
|
|
70
|
+
verbose_name_plural = '게시글들'
|
|
71
|
+
|
|
72
|
+
def __str__(self):
|
|
73
|
+
return self.title
|
|
74
|
+
|
|
75
|
+
def publish(self):
|
|
76
|
+
"""게시글 발행"""
|
|
77
|
+
self.is_published = True
|
|
78
|
+
self.published_at = timezone.now()
|
|
79
|
+
self.save(update_fields=['is_published', 'published_at'])
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### 2. View (Class-Based Views 권장)
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
# ✅ views.py
|
|
86
|
+
from django.views.generic import ListView, DetailView, CreateView, UpdateView
|
|
87
|
+
from django.contrib.auth.mixins import LoginRequiredMixin
|
|
88
|
+
from django.urls import reverse_lazy
|
|
89
|
+
from .models import Post
|
|
90
|
+
from .forms import PostForm
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class PostListView(ListView):
|
|
94
|
+
"""게시글 목록 뷰"""
|
|
95
|
+
model = Post
|
|
96
|
+
template_name = 'posts/list.html'
|
|
97
|
+
context_object_name = 'posts'
|
|
98
|
+
paginate_by = 10
|
|
99
|
+
|
|
100
|
+
def get_queryset(self):
|
|
101
|
+
queryset = super().get_queryset()
|
|
102
|
+
return queryset.filter(is_published=True).select_related('author')
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class PostDetailView(DetailView):
|
|
106
|
+
"""게시글 상세 뷰"""
|
|
107
|
+
model = Post
|
|
108
|
+
template_name = 'posts/detail.html'
|
|
109
|
+
context_object_name = 'post'
|
|
110
|
+
|
|
111
|
+
def get_queryset(self):
|
|
112
|
+
return super().get_queryset().select_related('author')
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class PostCreateView(LoginRequiredMixin, CreateView):
|
|
116
|
+
"""게시글 생성 뷰"""
|
|
117
|
+
model = Post
|
|
118
|
+
form_class = PostForm
|
|
119
|
+
template_name = 'posts/form.html'
|
|
120
|
+
success_url = reverse_lazy('posts:list')
|
|
121
|
+
|
|
122
|
+
def form_valid(self, form):
|
|
123
|
+
form.instance.author = self.request.user
|
|
124
|
+
return super().form_valid(form)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### 3. Django REST Framework
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
# ✅ serializers.py
|
|
131
|
+
from rest_framework import serializers
|
|
132
|
+
from .models import Post, User
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class UserSerializer(serializers.ModelSerializer):
|
|
136
|
+
"""사용자 시리얼라이저"""
|
|
137
|
+
class Meta:
|
|
138
|
+
model = User
|
|
139
|
+
fields = ['id', 'email', 'username', 'profile_image']
|
|
140
|
+
read_only_fields = ['id']
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class PostSerializer(serializers.ModelSerializer):
|
|
144
|
+
"""게시글 시리얼라이저"""
|
|
145
|
+
author = UserSerializer(read_only=True)
|
|
146
|
+
author_id = serializers.PrimaryKeyRelatedField(
|
|
147
|
+
queryset=User.objects.all(),
|
|
148
|
+
source='author',
|
|
149
|
+
write_only=True
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
class Meta:
|
|
153
|
+
model = Post
|
|
154
|
+
fields = [
|
|
155
|
+
'id', 'title', 'content', 'author', 'author_id',
|
|
156
|
+
'is_published', 'created_at', 'updated_at'
|
|
157
|
+
]
|
|
158
|
+
read_only_fields = ['id', 'created_at', 'updated_at']
|
|
159
|
+
|
|
160
|
+
def validate_title(self, value):
|
|
161
|
+
if len(value) < 5:
|
|
162
|
+
raise serializers.ValidationError('제목은 5자 이상이어야 합니다')
|
|
163
|
+
return value
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ✅ views.py (DRF)
|
|
167
|
+
from rest_framework import viewsets, permissions, status
|
|
168
|
+
from rest_framework.decorators import action
|
|
169
|
+
from rest_framework.response import Response
|
|
170
|
+
from django_filters.rest_framework import DjangoFilterBackend
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class PostViewSet(viewsets.ModelViewSet):
|
|
174
|
+
"""게시글 ViewSet"""
|
|
175
|
+
queryset = Post.objects.all()
|
|
176
|
+
serializer_class = PostSerializer
|
|
177
|
+
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
|
|
178
|
+
filter_backends = [DjangoFilterBackend]
|
|
179
|
+
filterset_fields = ['is_published', 'author']
|
|
180
|
+
|
|
181
|
+
def get_queryset(self):
|
|
182
|
+
queryset = super().get_queryset()
|
|
183
|
+
return queryset.select_related('author')
|
|
184
|
+
|
|
185
|
+
def perform_create(self, serializer):
|
|
186
|
+
serializer.save(author=self.request.user)
|
|
187
|
+
|
|
188
|
+
@action(detail=True, methods=['post'])
|
|
189
|
+
def publish(self, request, pk=None):
|
|
190
|
+
"""게시글 발행 액션"""
|
|
191
|
+
post = self.get_object()
|
|
192
|
+
|
|
193
|
+
if post.author != request.user:
|
|
194
|
+
return Response(
|
|
195
|
+
{'error': '작성자만 발행할 수 있습니다'},
|
|
196
|
+
status=status.HTTP_403_FORBIDDEN
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
post.publish()
|
|
200
|
+
return Response({'status': '발행되었습니다'})
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### 4. Service 레이어 (Fat Model 방지)
|
|
204
|
+
|
|
205
|
+
```python
|
|
206
|
+
# ✅ services/post_service.py
|
|
207
|
+
from django.db import transaction
|
|
208
|
+
from django.core.exceptions import PermissionDenied
|
|
209
|
+
from ..models import Post, User
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class PostService:
|
|
213
|
+
"""게시글 관련 비즈니스 로직"""
|
|
214
|
+
|
|
215
|
+
@staticmethod
|
|
216
|
+
def create_post(author: User, title: str, content: str) -> Post:
|
|
217
|
+
"""게시글 생성"""
|
|
218
|
+
post = Post.objects.create(
|
|
219
|
+
author=author,
|
|
220
|
+
title=title,
|
|
221
|
+
content=content
|
|
222
|
+
)
|
|
223
|
+
return post
|
|
224
|
+
|
|
225
|
+
@staticmethod
|
|
226
|
+
def publish_post(post: Post, user: User) -> Post:
|
|
227
|
+
"""게시글 발행"""
|
|
228
|
+
if post.author != user:
|
|
229
|
+
raise PermissionDenied('작성자만 발행할 수 있습니다')
|
|
230
|
+
|
|
231
|
+
post.publish()
|
|
232
|
+
return post
|
|
233
|
+
|
|
234
|
+
@staticmethod
|
|
235
|
+
@transaction.atomic
|
|
236
|
+
def bulk_publish(post_ids: list[int], user: User) -> int:
|
|
237
|
+
"""여러 게시글 일괄 발행"""
|
|
238
|
+
posts = Post.objects.filter(
|
|
239
|
+
id__in=post_ids,
|
|
240
|
+
author=user,
|
|
241
|
+
is_published=False
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
count = posts.update(
|
|
245
|
+
is_published=True,
|
|
246
|
+
published_at=timezone.now()
|
|
247
|
+
)
|
|
248
|
+
return count
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
### 5. Form 및 Validation
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
# ✅ forms.py
|
|
255
|
+
from django import forms
|
|
256
|
+
from django.core.exceptions import ValidationError
|
|
257
|
+
from .models import Post
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class PostForm(forms.ModelForm):
|
|
261
|
+
"""게시글 폼"""
|
|
262
|
+
class Meta:
|
|
263
|
+
model = Post
|
|
264
|
+
fields = ['title', 'content', 'is_published']
|
|
265
|
+
widgets = {
|
|
266
|
+
'title': forms.TextInput(attrs={
|
|
267
|
+
'class': 'form-control',
|
|
268
|
+
'placeholder': '제목을 입력하세요'
|
|
269
|
+
}),
|
|
270
|
+
'content': forms.Textarea(attrs={
|
|
271
|
+
'class': 'form-control',
|
|
272
|
+
'rows': 10
|
|
273
|
+
}),
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
def clean_title(self):
|
|
277
|
+
title = self.cleaned_data.get('title')
|
|
278
|
+
if len(title) < 5:
|
|
279
|
+
raise ValidationError('제목은 5자 이상이어야 합니다')
|
|
280
|
+
return title
|
|
281
|
+
|
|
282
|
+
def clean(self):
|
|
283
|
+
cleaned_data = super().clean()
|
|
284
|
+
title = cleaned_data.get('title')
|
|
285
|
+
content = cleaned_data.get('content')
|
|
286
|
+
|
|
287
|
+
if title and content and title in content:
|
|
288
|
+
raise ValidationError('본문에 제목이 포함되면 안 됩니다')
|
|
289
|
+
|
|
290
|
+
return cleaned_data
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
### 6. Custom Manager와 QuerySet
|
|
294
|
+
|
|
295
|
+
```python
|
|
296
|
+
# ✅ managers.py
|
|
297
|
+
from django.db import models
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class PostQuerySet(models.QuerySet):
|
|
301
|
+
"""게시글 QuerySet"""
|
|
302
|
+
|
|
303
|
+
def published(self):
|
|
304
|
+
return self.filter(is_published=True)
|
|
305
|
+
|
|
306
|
+
def by_author(self, user):
|
|
307
|
+
return self.filter(author=user)
|
|
308
|
+
|
|
309
|
+
def recent(self, days=7):
|
|
310
|
+
from django.utils import timezone
|
|
311
|
+
from datetime import timedelta
|
|
312
|
+
cutoff = timezone.now() - timedelta(days=days)
|
|
313
|
+
return self.filter(created_at__gte=cutoff)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class PostManager(models.Manager):
|
|
317
|
+
"""게시글 Manager"""
|
|
318
|
+
|
|
319
|
+
def get_queryset(self):
|
|
320
|
+
return PostQuerySet(self.model, using=self._db)
|
|
321
|
+
|
|
322
|
+
def published(self):
|
|
323
|
+
return self.get_queryset().published()
|
|
324
|
+
|
|
325
|
+
def by_author(self, user):
|
|
326
|
+
return self.get_queryset().by_author(user)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# 모델에서 사용
|
|
330
|
+
class Post(BaseModel):
|
|
331
|
+
# ... fields ...
|
|
332
|
+
objects = PostManager()
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
## 파일 구조
|
|
336
|
+
|
|
337
|
+
```
|
|
338
|
+
app_name/
|
|
339
|
+
├── migrations/ # DB 마이그레이션
|
|
340
|
+
├── management/
|
|
341
|
+
│ └── commands/ # 커스텀 명령어
|
|
342
|
+
├── services/ # 비즈니스 로직
|
|
343
|
+
├── api/
|
|
344
|
+
│ ├── serializers.py # DRF 시리얼라이저
|
|
345
|
+
│ ├── views.py # DRF 뷰
|
|
346
|
+
│ └── urls.py # API 라우팅
|
|
347
|
+
├── templates/ # HTML 템플릿
|
|
348
|
+
├── static/ # 정적 파일
|
|
349
|
+
├── tests/
|
|
350
|
+
│ ├── test_models.py
|
|
351
|
+
│ ├── test_views.py
|
|
352
|
+
│ └── test_services.py
|
|
353
|
+
├── models.py # 모델 (또는 models/ 디렉토리)
|
|
354
|
+
├── views.py # 뷰
|
|
355
|
+
├── forms.py # 폼
|
|
356
|
+
├── managers.py # 커스텀 매니저
|
|
357
|
+
├── admin.py # Admin 설정
|
|
358
|
+
├── urls.py # URL 라우팅
|
|
359
|
+
└── apps.py # 앱 설정
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
## 체크리스트
|
|
363
|
+
|
|
364
|
+
- [ ] Model에 `__str__`, `Meta` 정의
|
|
365
|
+
- [ ] CBV 사용 (권장)
|
|
366
|
+
- [ ] Service 레이어로 비즈니스 로직 분리
|
|
367
|
+
- [ ] select_related/prefetch_related로 N+1 방지
|
|
368
|
+
- [ ] DRF Serializer로 입출력 검증
|
|
369
|
+
- [ ] Custom Manager/QuerySet 활용
|
|
370
|
+
- [ ] Type hints 사용 (Python 3.10+)
|
|
371
|
+
- [ ] 한글 verbose_name 설정
|