1515from rest_framework import serializers
1616
1717from .constants import Brand , DrinkType
18- from .models import Drink
18+ from .models import CaffeineLog , Drink
1919from .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