Skip to content

Commit e7edf8e

Browse files
feat: 유저프로필API (목표취침시각, 체중, 즐겨찾는음료)
1 parent 7194461 commit e7edf8e

5 files changed

Lines changed: 468 additions & 5 deletions

File tree

‎accounts/serializers.py‎

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
# accounts/serializers.py
2+
"""accounts 앱 시리얼라이저.
3+
4+
UserProfile 조회/생성/수정을 다룬다. 명세(Notion API 명세서, PROF-001~002)
5+
기준이며, calcs 엔진(CALC-002~003)이 쓰는 target_bedtime·body_weight_kg가
6+
핵심 입력값이다.
7+
"""
8+
9+
from rest_framework import serializers
10+
11+
from diary.constants import Brand, DrinkType
12+
from diary.models import Drink
13+
14+
from .models import UserProfile
15+
16+
17+
class ProfileDrinkSerializer(serializers.Serializer):
18+
"""프로필 최초 생성 시 함께 등록하는 즐겨찾는 음료 입력.
19+
20+
Drink는 유저 소유 모델이라 전역 마스터가 없다. 여기서 받은 값이 곧
21+
그 사용자의 Drink 레코드가 된다(명세 PROF-001).
22+
"""
23+
24+
name = serializers.CharField()
25+
caffeine_mg = serializers.FloatField()
26+
27+
def validate_name(self, value):
28+
name = value.strip()
29+
if not name:
30+
raise serializers.ValidationError("음료 이름을 입력해주세요.")
31+
return name
32+
33+
def validate_caffeine_mg(self, value):
34+
if not 0 < value <= 1000:
35+
raise serializers.ValidationError("카페인량은 1~1000mg 사이여야 합니다.")
36+
return value
37+
38+
39+
class UserProfileSerializer(serializers.ModelSerializer):
40+
"""UserProfile 조회/생성/수정.
41+
42+
target_bedtime(명세 필드명) <-> target_sleeptime(모델 필드명)을 매핑한다.
43+
두 이름이 다른 이유는 모델이 명세보다 먼저 존재했기 때문이다 — 모델을
44+
바꾸면 기존 마이그레이션 이력이 깨지므로, 여기서 이름만 맞춘다.
45+
46+
drinks는 생성 시에만 받는 쓰기 전용 입력이다(PROF-002 PATCH는 프로필
47+
필드만 바꾸고, 음료 목록은 diary의 Drink API로 별도 관리한다).
48+
"""
49+
50+
target_bedtime = serializers.TimeField(
51+
source="target_sleeptime", format="%H:%M", input_formats=["%H:%M", "%H:%M:%S"]
52+
)
53+
drinks = ProfileDrinkSerializer(many=True, write_only=True, required=False)
54+
55+
class Meta:
56+
model = UserProfile
57+
fields = ["target_bedtime", "body_weight_kg", "drinks"]
58+
59+
def validate_drinks(self, value):
60+
"""최초 생성 시에는 즐겨찾는 음료가 최소 1개 필요하고, 이름 중복도 막는다.
61+
62+
Args:
63+
value (list[dict]): 검증된 개별 음료 입력들
64+
65+
Returns:
66+
list[dict]: 그대로 통과된 값
67+
68+
Raises:
69+
serializers.ValidationError: 생성인데 0개이거나, 이름이 중복된 경우
70+
"""
71+
if self.instance is None and not value:
72+
raise serializers.ValidationError("즐겨찾는 음료를 최소 1개 이상 등록해주세요.")
73+
names = [d["name"] for d in value]
74+
if len(names) != len(set(names)):
75+
raise serializers.ValidationError("음료 이름이 중복되었습니다.")
76+
return value
77+
78+
def create(self, validated_data):
79+
"""UserProfile을 만들고, 함께 받은 음료들을 사용자 소유 Drink로 생성한다.
80+
81+
Args:
82+
validated_data (dict): target_sleeptime, body_weight_kg, drinks
83+
84+
Returns:
85+
UserProfile: 생성된 인스턴스
86+
"""
87+
drinks = validated_data.pop("drinks", [])
88+
user = self.context["request"].user
89+
profile = UserProfile.objects.create(user=user, **validated_data)
90+
for d in drinks:
91+
Drink.objects.create(
92+
user=user,
93+
name=d["name"],
94+
caffeine_mg=d["caffeine_mg"],
95+
brand=Brand.CUSTOM,
96+
type=DrinkType.COFFEE,
97+
is_favorite=True,
98+
)
99+
return profile

‎accounts/tests.py‎

Lines changed: 129 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,130 @@
1-
from django.test import TestCase
1+
"""accounts 앱 테스트.
22
3-
# Create your tests here.
3+
UserProfile API(/users/me/profile/)의 계약을 고정한다.
4+
"""
5+
6+
from datetime import timedelta
7+
8+
from django.contrib.auth import get_user_model
9+
from django.urls import reverse
10+
from django.utils import timezone
11+
from rest_framework import status
12+
from rest_framework.test import APITestCase
13+
14+
from diary.models import Drink
15+
16+
from .models import UserProfile
17+
18+
User = get_user_model()
19+
20+
21+
class UserProfileAPITests(APITestCase):
22+
"""/users/me/profile/ 엔드포인트의 생성/조회/수정 계약."""
23+
24+
def setUp(self):
25+
self.user = User.objects.create_user(username="tester", password="pw12345!")
26+
self.url = reverse("user-profile")
27+
self.client.force_authenticate(self.user)
28+
29+
def _create_profile(self, target_bedtime="23:30", body_weight_kg=65.0, drinks=None):
30+
if drinks is None:
31+
drinks = [{"name": "아이스아메리카노", "caffeine_mg": 180}]
32+
return self.client.post(
33+
self.url,
34+
{"target_bedtime": target_bedtime, "body_weight_kg": body_weight_kg, "drinks": drinks},
35+
format="json",
36+
)
37+
38+
# ------------------------------------------------------------------ 생성
39+
40+
def test_post_creates_profile_and_drinks(self):
41+
"""프로필 생성 시 함께 보낸 음료가 사용자 소유 Drink로 만들어진다."""
42+
res = self._create_profile(
43+
drinks=[
44+
{"name": "아이스아메리카노", "caffeine_mg": 180},
45+
{"name": "동네 카페 콜드브루", "caffeine_mg": 180},
46+
]
47+
)
48+
self.assertEqual(res.status_code, status.HTTP_201_CREATED)
49+
self.assertEqual(res.data["target_bedtime"], "23:30")
50+
self.assertEqual(len(res.data["drinks"]), 2)
51+
self.assertEqual(Drink.objects.filter(user=self.user, is_favorite=True).count(), 2)
52+
53+
def test_post_requires_at_least_one_drink(self):
54+
"""음료 없이 생성하면 400."""
55+
res = self._create_profile(drinks=[])
56+
self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST)
57+
58+
def test_post_rejects_duplicate_drink_names(self):
59+
"""같은 이름의 음료가 중복되면 400."""
60+
res = self._create_profile(
61+
drinks=[
62+
{"name": "아메리카노", "caffeine_mg": 150},
63+
{"name": "아메리카노", "caffeine_mg": 200},
64+
]
65+
)
66+
self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST)
67+
68+
def test_post_conflict_when_profile_exists(self):
69+
"""이미 프로필이 있으면 두 번째 POST는 409."""
70+
self._create_profile()
71+
res = self._create_profile()
72+
self.assertEqual(res.status_code, status.HTTP_409_CONFLICT)
73+
74+
# ------------------------------------------------------------------ 조회
75+
76+
def test_get_without_profile_404(self):
77+
"""프로필 생성 전 조회는 404."""
78+
res = self.client.get(self.url)
79+
self.assertEqual(res.status_code, status.HTTP_404_NOT_FOUND)
80+
self.assertEqual(res.data["code"], "PROFILE_NOT_FOUND")
81+
82+
def test_get_returns_profile_with_theta_and_drinks(self):
83+
"""설문 기록이 없으면 threshold_mg는 θ_pop(80.0) 기본값이다."""
84+
self._create_profile()
85+
res = self.client.get(self.url)
86+
self.assertEqual(res.status_code, status.HTTP_200_OK)
87+
self.assertEqual(res.data["threshold_mg"], 80.0)
88+
self.assertEqual(res.data["daily_log_count"], 0)
89+
self.assertEqual(len(res.data["drinks"]), 1)
90+
91+
def test_get_requires_authentication(self):
92+
"""비로그인 요청은 거부된다."""
93+
self.client.force_authenticate(None)
94+
res = self.client.get(self.url)
95+
self.assertIn(
96+
res.status_code, (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN)
97+
)
98+
99+
# ------------------------------------------------------------------ 수정
100+
101+
def test_patch_without_profile_404(self):
102+
"""프로필 생성 전 수정은 404."""
103+
res = self.client.patch(self.url, {"body_weight_kg": 70}, format="json")
104+
self.assertEqual(res.status_code, status.HTTP_404_NOT_FOUND)
105+
106+
def test_patch_body_weight_only_does_not_touch_bedtime_calc(self):
107+
"""target_bedtime을 건드리지 않는 수정은 오늘 적용으로 표시되고 cutoff는 계산하지 않는다."""
108+
self._create_profile()
109+
res = self.client.patch(self.url, {"body_weight_kg": 70.5}, format="json")
110+
self.assertEqual(res.status_code, status.HTTP_200_OK)
111+
self.assertEqual(res.data["body_weight_kg"], 70.5)
112+
self.assertEqual(res.data["applied_from"], "today")
113+
self.assertIsNone(res.data["cutoff_at"])
114+
115+
def test_patch_bedtime_change_recomputes_cutoff(self):
116+
"""target_bedtime을 바꾸면 CALC-002가 재계산되어 cutoff_at이 채워진다."""
117+
self._create_profile()
118+
future_bedtime = (timezone.now() + timedelta(hours=5)).strftime("%H:%M")
119+
res = self.client.patch(self.url, {"target_bedtime": future_bedtime}, format="json")
120+
self.assertEqual(res.status_code, status.HTTP_200_OK)
121+
self.assertEqual(res.data["target_bedtime"], future_bedtime)
122+
self.assertEqual(res.data["applied_from"], "today")
123+
self.assertIsNotNone(res.data["cutoff_at"])
124+
125+
def test_patch_updates_underlying_model(self):
126+
"""PATCH 결과가 실제로 DB에 반영된다."""
127+
self._create_profile()
128+
self.client.patch(self.url, {"body_weight_kg": 72}, format="json")
129+
profile = UserProfile.objects.get(user=self.user)
130+
self.assertEqual(profile.body_weight_kg, 72)

‎accounts/views.py‎

Lines changed: 120 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,22 @@
55
from rest_framework import status
66
from django.contrib.auth import authenticate, login as auth_login, logout as auth_logout
77
from django.contrib.auth.models import User
8+
from django.utils import timezone
89
from django.views.decorators.csrf import ensure_csrf_cookie
910
from django.utils.decorators import method_decorator
1011

12+
from calcs.cutoff import calc_cutoff
13+
from calcs.pharmacokinetics import resolve_next_occurrence
14+
from diary.calc_bridge import doses_for_user, reference_dose_mg, theta_for_user
15+
from diary.models import Drink
16+
17+
from .models import UserProfile
18+
from .serializers import UserProfileSerializer
19+
1120
# Create your views here.
1221

1322
def _has_profile(user) -> bool:
14-
# TODO: 프로필 모델 확정 후 구현
15-
return False
23+
return UserProfile.objects.filter(user=user).exists()
1624

1725
# 회원가입
1826
@api_view(["POST"])
@@ -90,9 +98,118 @@ def session(request):
9098
)
9199

92100
# 인증됨
93-
has_profile = _has_profile(request.user) # TODO: 프로필 이슈에서 구현
101+
has_profile = _has_profile(request.user)
94102
return Response(
95103
{"is_authenticated": True, "user_id": request.user.id,
96104
"has_profile": has_profile, "next": "/" if has_profile else "/signup/profile"},
97105
status=status.HTTP_200_OK,
98106
)
107+
108+
109+
def _serialize_profile(profile, *, include_calc=True):
110+
"""UserProfile을 명세(GET /users/me/profile) 응답 형태로 변환한다.
111+
112+
include_calc=False는 PATCH 응답처럼 threshold_mg/daily_log_count/drinks가
113+
필요 없는 경우에 계산 비용을 아끼기 위한 것이다.
114+
"""
115+
data = {
116+
"profile_id": profile.id,
117+
"username": profile.user.username,
118+
"target_bedtime": profile.target_sleeptime.strftime("%H:%M"),
119+
"body_weight_kg": profile.body_weight_kg,
120+
}
121+
if include_calc:
122+
theta = theta_for_user(profile.user)
123+
drinks = Drink.objects.filter(user=profile.user, is_active=True)
124+
data["threshold_mg"] = theta.theta_mg
125+
data["daily_log_count"] = theta.n_records
126+
data["drinks"] = [
127+
{"drink_id": d.id, "name": d.name, "caffeine_mg": d.caffeine_mg} for d in drinks
128+
]
129+
return data
130+
131+
132+
# 프로필 조회 / 생성 / 수정
133+
@api_view(["GET", "POST", "PATCH"])
134+
@permission_classes([IsAuthenticated])
135+
def profile(request):
136+
"""GET/POST/PATCH /users/me/profile/.
137+
138+
UserProfile은 사용자당 1개(OneToOne)라 리스트가 아닌 단일 리소스로 다룬다.
139+
calcs 엔진(CALC-002~003, CALC-005)의 입력값(체중, 목표 취침시각)을 다루므로,
140+
조회 응답에는 계산된 θ(threshold_mg)와 설문 누적 수(daily_log_count)도 함께 싣는다.
141+
"""
142+
existing = UserProfile.objects.filter(user=request.user).first()
143+
144+
if request.method == "GET":
145+
if existing is None:
146+
return Response(
147+
{"code": "PROFILE_NOT_FOUND", "message": "프로필이 없습니다.",
148+
"next": "/signup/profile"},
149+
status=status.HTTP_404_NOT_FOUND,
150+
)
151+
return Response(_serialize_profile(existing), status=status.HTTP_200_OK)
152+
153+
if request.method == "POST":
154+
if existing is not None:
155+
return Response(
156+
{"code": "PROFILE_ALREADY_EXISTS", "message": "이미 프로필이 생성되어 있습니다."},
157+
status=status.HTTP_409_CONFLICT,
158+
)
159+
serializer = UserProfileSerializer(data=request.data, context={"request": request})
160+
serializer.is_valid(raise_exception=True)
161+
new_profile = serializer.save()
162+
data = _serialize_profile(new_profile, include_calc=False)
163+
data["drinks"] = [
164+
{"drink_id": d.id, "name": d.name, "caffeine_mg": d.caffeine_mg}
165+
for d in Drink.objects.filter(user=request.user, is_active=True)
166+
]
167+
data["next"] = "/"
168+
return Response(data, status=status.HTTP_201_CREATED)
169+
170+
# PATCH
171+
if existing is None:
172+
return Response(
173+
{"code": "PROFILE_NOT_FOUND", "message": "프로필이 없습니다.",
174+
"next": "/signup/profile"},
175+
status=status.HTTP_404_NOT_FOUND,
176+
)
177+
178+
old_bedtime = existing.target_sleeptime
179+
serializer = UserProfileSerializer(
180+
existing, data=request.data, partial=True, context={"request": request}
181+
)
182+
serializer.is_valid(raise_exception=True)
183+
updated = serializer.save()
184+
185+
data = _serialize_profile(updated, include_calc=False)
186+
187+
bedtime_changed = "target_bedtime" in request.data and updated.target_sleeptime != old_bedtime
188+
if not bedtime_changed:
189+
data["applied_from"] = "today"
190+
data["cutoff_at"] = None
191+
return Response(data, status=status.HTTP_200_OK)
192+
193+
# target_bedtime이 바뀐 경우에만 CALC-002를 재계산한다. 기존(변경 전) 마감시각이
194+
# 이미 지났으면 오늘 계산엔 개입하지 않고 내일부터 적용한다(명세 PROF-002).
195+
now = timezone.now()
196+
doses = doses_for_user(request.user, now)
197+
theta_mg = theta_for_user(request.user).theta_mg
198+
ref_dose = reference_dose_mg(request.user)
199+
200+
old_next_bedtime = resolve_next_occurrence(old_bedtime, now)
201+
old_result = calc_cutoff(now, old_next_bedtime, ref_dose, doses, theta_mg)
202+
203+
if old_result.cutoff_at is not None and now > old_result.cutoff_at:
204+
data["applied_from"] = "tomorrow"
205+
data["cutoff_at"] = None
206+
data["message"] = "오늘의 마감시간은 이미 지나 변경은 내일부터 적용됩니다."
207+
return Response(data, status=status.HTTP_200_OK)
208+
209+
new_bedtime = resolve_next_occurrence(updated.target_sleeptime, now)
210+
new_result = calc_cutoff(now, new_bedtime, ref_dose, doses, theta_mg)
211+
data["applied_from"] = "today"
212+
data["cutoff_at"] = (
213+
timezone.localtime(new_result.cutoff_at).isoformat() if new_result.cutoff_at else None
214+
)
215+
return Response(data, status=status.HTTP_200_OK)

‎config/urls.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
from django.urls import path, include
2424
from django.views.generic import TemplateView #임시
2525

26+
from accounts import views as accounts_views
27+
2628
def home(request):
2729
# TODO: 프론트엔드 구현 후 SPA index 서빙으로 교체
2830
return JsonResponse({"is_authenticated": request.user.is_authenticated})
@@ -39,6 +41,9 @@ def login_placeholder(request):
3941
path('login', login_placeholder, name='login-placeholder'),
4042
path('auth/', include('accounts.urls')),
4143
path('auth/social/', include('allauth.urls')),
44+
# API 명세서 URI가 /users/me/profile(accounts 앱 소유 모델이지만 /auth/ 밖에 있음)라
45+
# accounts.urls(prefix: auth/)에 넣지 않고 여기서 직접 마운트한다.
46+
path('users/me/profile/', accounts_views.profile, name='user-profile'),
4247
path('mypage/', TemplateView.as_view(template_name='mypage.html'), name='mypage'),
4348
path('allnight_mode/', TemplateView.as_view(template_name='allnight_mode.html'), name='allnight_mode'),
4449
path('allnight_setup/', TemplateView.as_view(template_name='allnight_setup.html'), name='allnight_setup'),

0 commit comments

Comments
 (0)