Skip to content

Commit 7e809f1

Browse files
committed
feat: 처음 페이지 반응형 구현
2 parents 5b06ca9 + 294ae22 commit 7e809f1

18 files changed

Lines changed: 1801 additions & 16 deletions

‎config/urls.py‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,12 @@
1414
1. Import the include() function: from django.urls import include, path
1515
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
1616
"""
17+
from django.conf import settings
1718
from django.contrib import admin
1819
from django.http import JsonResponse
20+
from django.urls import path, include, re_path
21+
from django.views.static import serve as static_serve
22+
1923
from django.urls import path, include
2024
from django.views.generic import TemplateView #임시
2125

@@ -41,4 +45,14 @@ def login_placeholder(request):
4145
path('allnight_mode/', TemplateView.as_view(template_name='allnight_mode.html'), name='allnight_mode'),
4246
path('allnight_setup/', TemplateView.as_view(template_name='allnight_setup.html'), name='allnight_setup'),
4347
path('', include('diary.urls')),
44-
]
48+
]
49+
50+
if settings.DEBUG:
51+
# 개발용 테스트 프론트(testpages/)를 같은 origin에서 서빙 - 세션/CSRF 쿠키 공유 목적
52+
urlpatterns += [
53+
re_path(
54+
r"^testpages/(?P<path>.*)$",
55+
static_serve,
56+
{"document_root": settings.BASE_DIR / "testpages"},
57+
),
58+
]
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Generated by Django 6.0.8 on 2026-08-13 07:44
2+
3+
from django.conf import settings
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('diary', '0004_alter_drink_brand'),
11+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12+
]
13+
14+
operations = [
15+
migrations.RemoveConstraint(
16+
model_name='drink',
17+
name='uniq_active_drink_per_user',
18+
),
19+
migrations.AddField(
20+
model_name='drink',
21+
name='custom_brand_name',
22+
field=models.CharField(blank=True, max_length=100),
23+
),
24+
migrations.AddConstraint(
25+
model_name='drink',
26+
constraint=models.UniqueConstraint(condition=models.Q(('is_active', True)), fields=('user', 'brand', 'custom_brand_name', 'name', 'size'), name='uniq_active_drink_per_user'),
27+
),
28+
]
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 6.0.8 on 2026-08-14 03:21
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('diary', '0005_remove_drink_uniq_active_drink_per_user_and_more'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='caffeinelog',
15+
name='name',
16+
field=models.CharField(blank=True, max_length=100),
17+
),
18+
]

‎diary/models.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ class Drink(models.Model):
1818
brand = models.CharField(
1919
max_length=20, choices=Brand.choices, default=Brand.CUSTOM
2020
)
21+
custom_brand_name = models.CharField(max_length=100, blank=True) # brand=custom일 때만 사용하는 자유 입력 브랜드명
2122
name = models.CharField(max_length=100)
2223
size = models.CharField(max_length=50, blank=True) # 브랜드별 라벨, 검증은 serializer
2324

@@ -29,7 +30,7 @@ class Drink(models.Model):
2930
class Meta:
3031
constraints = [
3132
models.UniqueConstraint(
32-
fields=["user", "brand", "name", "size"],
33+
fields=["user", "brand", "custom_brand_name", "name", "size"],
3334
condition=models.Q(is_active=True),
3435
name="uniq_active_drink_per_user",
3536
)
@@ -47,6 +48,7 @@ class CaffeineLog(models.Model):
4748
null=True, blank=True,
4849
related_name="logs",
4950
)
51+
name = models.CharField(max_length=100, blank=True) # 스냅샷: 기록 시점 음료명(커스텀/Drink 삭제 대비)
5052
caffeine_mg = models.FloatField() # 스냅샷: 기록 시점 카페인량
5153

5254
created_at = models.DateTimeField(auto_now_add=True)

‎diary/serializers.py‎

Lines changed: 129 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from rest_framework import serializers
1616

1717
from .constants import Brand, DrinkType
18-
from .models import Drink
18+
from .models import CaffeineLog, Drink
1919
from .presets import find_preset, size_labels
2020

2121

@@ -30,7 +30,7 @@ class DrinkSerializer(serializers.ModelSerializer):
3030
"""
3131

3232
type_display = serializers.CharField(source="get_type_display", read_only=True)
33-
brand_display = serializers.CharField(source="get_brand_display", read_only=True)
33+
brand_display = serializers.SerializerMethodField()
3434

3535
class Meta:
3636
model = Drink
@@ -39,6 +39,7 @@ class Meta:
3939
"type",
4040
"type_display",
4141
"brand",
42+
"custom_brand_name",
4243
"brand_display",
4344
"name",
4445
"size",
@@ -49,6 +50,19 @@ class Meta:
4950
]
5051
read_only_fields = ["id", "is_recent", "created_at"]
5152

53+
def get_brand_display(self, obj):
54+
"""brand=custom이고 custom_brand_name이 있으면 그 값을, 아니면 choices 라벨을 보여준다.
55+
56+
Args:
57+
obj (Drink): 직렬화 대상 인스턴스
58+
59+
Returns:
60+
str: 화면에 보여줄 브랜드명
61+
"""
62+
if obj.brand == Brand.CUSTOM and obj.custom_brand_name:
63+
return obj.custom_brand_name
64+
return obj.get_brand_display()
65+
5266
def validate_name(self, value):
5367
"""음료명 공백을 정리하고 빈 문자열을 거부한다.
5468
@@ -105,6 +119,10 @@ def validate(self, attrs):
105119
brand = attrs.get("brand") or getattr(self.instance, "brand", Brand.CUSTOM)
106120
size = attrs.get("size", getattr(self.instance, "size", ""))
107121

122+
# custom이 아닌 브랜드에는 자유 입력 브랜드명이 의미가 없으므로 비워둔다.
123+
if brand != Brand.CUSTOM:
124+
attrs["custom_brand_name"] = ""
125+
108126
labels = size_labels(brand)
109127
if not labels:
110128
return attrs
@@ -136,6 +154,7 @@ def create(self, validated_data):
136154
return _create_or_revive(
137155
user=user,
138156
brand=validated_data.get("brand", Brand.CUSTOM),
157+
custom_brand_name=validated_data.get("custom_brand_name", ""),
139158
name=validated_data["name"],
140159
size=validated_data.get("size", ""),
141160
defaults={
@@ -226,6 +245,7 @@ def create(self, validated_data):
226245
return _create_or_revive(
227246
user=self.context["request"].user,
228247
brand=preset["brand"],
248+
custom_brand_name="",
229249
name=preset["name"],
230250
size=preset["size"] or "",
231251
defaults={
@@ -250,8 +270,111 @@ def to_representation(self, instance):
250270
return DrinkSerializer(instance, context=self.context).data
251271

252272

273+
class CaffeineLogSerializer(serializers.ModelSerializer):
274+
"""카페인 섭취기록 조회/생성/수정.
275+
276+
두 가지 기록 방식을 하나의 스키마로 받는다.
277+
- 한잔 : drink_id로 등록된 음료를 지목한다. caffeine_mg와 name은
278+
서버가 해당 Drink에서 스냅샷으로 박제하며, 클라이언트가 보낸
279+
값은 무시한다("스타벅스 라떼 = 5mg" 같은 위조를 막는다).
280+
- 커스텀: drink_id 없이 caffeine_mg와 name을 직접 입력한다.
281+
282+
스냅샷 정책: 기록 이후 Drink가 수정/삭제되어도 이 로그의 값은 보존된다.
283+
drink는 SET_NULL이라 연결이 끊길 수 있으므로 name도 함께 박제한다.
284+
285+
user는 fields에서 제외했다. 클라이언트가 보낸 값을 신뢰하면 다른 계정에
286+
기록을 남길 수 있으므로, create에서 context의 request.user로만 주입한다.
287+
"""
288+
289+
drink_id = serializers.PrimaryKeyRelatedField(
290+
source="drink",
291+
queryset=Drink.objects.none(), # __init__에서 요청 사용자 소유로 좁힌다
292+
required=False,
293+
allow_null=True,
294+
)
295+
caffeine_mg = serializers.FloatField(required=False)
296+
297+
class Meta:
298+
model = CaffeineLog
299+
fields = ["id", "drink_id", "name", "caffeine_mg", "created_at"]
300+
read_only_fields = ["id", "created_at"]
301+
302+
def __init__(self, *args, **kwargs):
303+
"""drink_id 후보를 요청 사용자의 활성 음료로 제한한다.
304+
305+
queryset을 좁히면 타인 소유 drink_id는 존재하지 않는 값으로 취급되어
306+
검증 단계에서 걸러진다(다른 사람 음료로 기록을 남길 수 없다).
307+
"""
308+
super().__init__(*args, **kwargs)
309+
request = self.context.get("request")
310+
if request is not None and getattr(request.user, "is_authenticated", False):
311+
self.fields["drink_id"].queryset = Drink.objects.filter(
312+
user=request.user, is_active=True
313+
)
314+
315+
def validate(self, attrs):
316+
"""한잔/커스텀을 구분해 스냅샷을 채우거나 입력값을 검증한다.
317+
318+
이번 요청에 drink가 지정되면 '한잔'으로 보고 서버가 caffeine_mg/name을
319+
박제한다. drink가 없으면 '커스텀'이며, 생성 시에는 두 값이 필수다.
320+
drink를 보내지 않는 PATCH에서는 기존 스냅샷을 임의로 다시 덮어쓰지
321+
않는다(과거 기록의 caffeine_mg 불변성을 지키기 위해서다).
322+
323+
Args:
324+
attrs (dict): 필드 단위 검증을 통과한 값들
325+
326+
Returns:
327+
dict: 스냅샷이 채워졌거나 정리된 attrs
328+
329+
Raises:
330+
serializers.ValidationError: 커스텀 생성에 필수값이 없거나,
331+
카페인량이 범위를 벗어나거나, 이름이 공백인 경우
332+
"""
333+
if attrs.get("drink") is not None:
334+
drink = attrs["drink"]
335+
attrs["caffeine_mg"] = drink.caffeine_mg
336+
attrs["name"] = drink.name
337+
return attrs
338+
339+
if self.instance is None: # 커스텀 생성
340+
if attrs.get("caffeine_mg") is None:
341+
raise serializers.ValidationError(
342+
{"caffeine_mg": "카페인량을 입력해주세요."}
343+
)
344+
if not (attrs.get("name") or "").strip():
345+
raise serializers.ValidationError({"name": "음료 이름을 입력해주세요."})
346+
347+
# 넘어온 값만 범위/공백 검증한다(생성·수정 공통).
348+
if attrs.get("caffeine_mg") is not None and not 0 < attrs["caffeine_mg"] <= 1000:
349+
raise serializers.ValidationError(
350+
{"caffeine_mg": "카페인량은 1~1000mg 사이여야 합니다."}
351+
)
352+
if "name" in attrs:
353+
name = attrs["name"].strip()
354+
if not name:
355+
raise serializers.ValidationError({"name": "음료 이름을 입력해주세요."})
356+
attrs["name"] = name
357+
return attrs
358+
359+
def create(self, validated_data):
360+
"""요청 사용자 소유로 기록을 생성하고 참조 음료를 '최근'으로 표시한다.
361+
362+
Args:
363+
validated_data (dict): 스냅샷이 채워진 검증 결과
364+
365+
Returns:
366+
CaffeineLog: 생성된 인스턴스
367+
"""
368+
validated_data["user"] = self.context["request"].user
369+
log = super().create(validated_data)
370+
if log.drink_id and not log.drink.is_recent:
371+
log.drink.is_recent = True
372+
log.drink.save(update_fields=["is_recent"])
373+
return log
374+
375+
253376
@transaction.atomic
254-
def _create_or_revive(user, brand, name, size, defaults):
377+
def _create_or_revive(user, brand, custom_brand_name, name, size, defaults):
255378
"""중복 음료를 새로 만들지 않고 되살리거나 갱신한다.
256379
257380
Drink에 걸린 UniqueConstraint는 is_active=True 조건부라서 DRF의
@@ -266,6 +389,7 @@ def _create_or_revive(user, brand, name, size, defaults):
266389
Args:
267390
user (User): 소유자
268391
brand (str): 브랜드 코드
392+
custom_brand_name (str): brand=custom일 때의 자유 입력 브랜드명. 그 외엔 빈 문자열
269393
name (str): 음료명
270394
size (str): 사이즈 라벨. 사이즈가 없으면 빈 문자열
271395
defaults (dict): type, caffeine_mg, is_favorite
@@ -275,14 +399,14 @@ def _create_or_revive(user, brand, name, size, defaults):
275399
"""
276400
existing = (
277401
Drink.objects.select_for_update()
278-
.filter(user=user, brand=brand, name=name, size=size)
402+
.filter(user=user, brand=brand, custom_brand_name=custom_brand_name, name=name, size=size)
279403
.order_by("-is_active", "-created_at")
280404
.first()
281405
)
282406

283407
if existing is None:
284408
return Drink.objects.create(
285-
user=user, brand=brand, name=name, size=size, **defaults
409+
user=user, brand=brand, custom_brand_name=custom_brand_name, name=name, size=size, **defaults
286410
)
287411

288412
if not existing.is_active:

0 commit comments

Comments
 (0)