Skip to content

Commit 6a4b692

Browse files
authored
Merge pull request #58 from pirogramming/fix/data-linking
[Fix] 프론트 fetch로직 작성 + 백엔드 수정
2 parents 1bff0e7 + 15089de commit 6a4b692

26 files changed

Lines changed: 899 additions & 254 deletions

‎accounts/adapters.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,28 @@
11
# accounts/adapters.py
2+
from allauth.account.adapter import DefaultAccountAdapter
23
from allauth.account.utils import user_username
34
from allauth.core.exceptions import ImmediateHttpResponse
45
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
56
from allauth.socialaccount.providers.base import AuthError
67
from django.http import HttpResponseRedirect
8+
from django.urls import reverse
9+
10+
from .models import UserProfile
11+
12+
13+
class AccountAdapter(DefaultAccountAdapter):
14+
"""소셜 로그인(카카오 등)의 로그인 후 리다이렉트 목적지를 정한다.
15+
16+
일반 로그인(accounts.views.login)은 allauth를 거치지 않는 자체 API라
17+
"next": "/" if has_profile else "/signup/profile" 을 프론트가 직접
18+
처리하지만, 소셜 로그인은 allauth의 로그인 완료 흐름을 그대로 타서
19+
이 어댑터가 없으면 LOGIN_REDIRECT_URL(마케팅 첫 화면 "/")로 떨어진다.
20+
"""
21+
22+
def get_login_redirect_url(self, request):
23+
if UserProfile.objects.filter(user=request.user).exists():
24+
return reverse("diary:feed")
25+
return reverse("diary:survey-start")
726

827

928
class KakaoSocialAccountAdapter(DefaultSocialAccountAdapter):

‎accounts/tests.py‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"""
77

88
from datetime import timedelta
9+
from unittest.mock import patch
910

1011
from django.contrib.auth import get_user_model
1112
from django.db import IntegrityError, transaction
@@ -209,10 +210,22 @@ def test_patch_body_weight_only_does_not_touch_bedtime_calc(self):
209210
self.assertIsNone(res.data["cutoff_at"])
210211

211212
def test_patch_bedtime_change_recomputes_cutoff(self):
212-
"""target_bedtime을 바꾸면 CALC-002가 재계산되어 cutoff_at이 채워진다."""
213-
self._create_profile()
214-
future_bedtime = (timezone.now() + timedelta(hours=5)).strftime("%H:%M")
215-
res = self.client.patch(self.url, {"target_bedtime": future_bedtime}, format="json")
213+
"""target_bedtime을 바꾸면 CALC-002가 재계산되어 cutoff_at이 채워진다.
214+
215+
old_bedtime(기본값 23:30 KST)의 마감시각이 이미 지났으면 "tomorrow" 분기로
216+
빠지므로(PROF-002), 실행 시각(KST)에 좌우되지 않도록 09:00 KST로 고정해
217+
old_bedtime의 마감시각이 항상 아직 남아있는 상태에서 검증한다.
218+
"""
219+
self._create_profile() # target_bedtime 기본값 23:30
220+
fixed_local_now = timezone.localtime(timezone.now()).replace(
221+
hour=9, minute=0, second=0, microsecond=0
222+
)
223+
fixed_now = fixed_local_now.astimezone(timezone.UTC)
224+
future_bedtime = (fixed_local_now + timedelta(hours=5)).strftime("%H:%M")
225+
226+
with patch("accounts.views.timezone.now", return_value=fixed_now):
227+
res = self.client.patch(self.url, {"target_bedtime": future_bedtime}, format="json")
228+
216229
self.assertEqual(res.status_code, status.HTTP_200_OK)
217230
self.assertEqual(res.data["target_bedtime"], future_bedtime)
218231
self.assertEqual(res.data["applied_from"], "today")

‎accounts/views.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ def login(request):
9898
def logout(request):
9999
auth_logout(request) # 세션 삭제
100100
return Response(
101-
{"success": True, "night_session_active": True, "next": "/auth/login"},
101+
{"success": True, "night_session_active": True, "next": "/"},
102102
status=status.HTTP_200_OK
103103
)
104104

@@ -219,11 +219,12 @@ def profile(request):
219219
# target_bedtime이 바뀐 경우에만 CALC-002를 재계산한다. 기존(변경 전) 마감시각이
220220
# 이미 지났으면 오늘 계산엔 개입하지 않고 내일부터 적용한다(명세 PROF-002).
221221
now = timezone.now()
222+
local_now = timezone.localtime(now)
222223
doses = doses_for_user(request.user, now)
223224
theta_mg = theta_for_user(request.user).theta_mg
224225
ref_dose = reference_dose_mg(request.user)
225226

226-
old_next_bedtime = resolve_next_occurrence(old_bedtime, now)
227+
old_next_bedtime = resolve_next_occurrence(old_bedtime, local_now)
227228
old_result = calc_cutoff(now, old_next_bedtime, ref_dose, doses, theta_mg)
228229

229230
if old_result.cutoff_at is not None and now > old_result.cutoff_at:
@@ -232,7 +233,7 @@ def profile(request):
232233
data["message"] = "오늘의 마감시간은 이미 지나 변경은 내일부터 적용됩니다."
233234
return Response(data, status=status.HTTP_200_OK)
234235

235-
new_bedtime = resolve_next_occurrence(updated.target_sleeptime, now)
236+
new_bedtime = resolve_next_occurrence(updated.target_sleeptime, local_now)
236237
new_result = calc_cutoff(now, new_bedtime, ref_dose, doses, theta_mg)
237238
data["applied_from"] = "today"
238239
data["cutoff_at"] = (

‎config/settings.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,8 @@ def _env_list(key, default):
9898
SOCIALACCOUNT_EMAIL_REQUIRED = False # 카카오가 이메일 안줘도 가입가능
9999
SOCIALACCOUNT_EMAIL_VERIFICATION = 'none' # 소셜가입시 이메일 인증절차 생략
100100
SOCIALACCOUNT_EMAIL_AUTHENTICATION = False # 자동연결차단
101-
LOGIN_REDIRECT_URL = "/" # 로그인 후 리다이렉트 주소
101+
LOGIN_REDIRECT_URL = "/" # 로그인 후 리다이렉트 주소 (ACCOUNT_ADAPTER가 없는 예외 상황용 폴백)
102+
ACCOUNT_ADAPTER = "accounts.adapters.AccountAdapter"
102103
SOCIALACCOUNT_ADAPTER = "accounts.adapters.KakaoSocialAccountAdapter"
103104
ROOT_URLCONF = 'config.urls'
104105

‎config/urls.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ def login_placeholder(request):
4343
path('login', login_placeholder, name='login-placeholder'),
4444
path('auth/', include('accounts.urls')),
4545
path('auth/social/', include('allauth.urls')),
46+
path('users/me/profile/', accounts_views.profile, name='user-profile'),
4647
path('', include('diary.urls')),
4748
path('', include('allnight.urls')),
4849
]

‎diary/constants.py‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,18 @@ class Brand(models.TextChoices):
4242
GONGCHA = "gongcha", "공차"
4343
PEPSI = "pepsi", "펩시"
4444
COCACOLA = "cocacola", "코카콜라"
45-
CUSTOM = "custom", "직접 입력"
45+
ENERGY = "energy", "에너지드링크"
46+
CUSTOM = "custom", "직접 입력"
47+
48+
49+
# 음료 종류(DrinkType)별로 고를 수 있는 아이콘 키 목록. 파일 실체는
50+
# static/images/icons/<key>.svg 이다. Drink.icon_key가 이 목록에 있는
51+
# 값인지 serializer가 검증한다(음료 종류와 안 맞는 아이콘은 못 고르게).
52+
DRINK_ICON_KEYS = {
53+
DrinkType.COFFEE: [f"coffee_icon{i}" for i in range(1, 7)],
54+
DrinkType.TEA: ["tea_icon"],
55+
DrinkType.ENERGY: [f"energydrink_icon{i}" for i in range(1, 8)],
56+
DrinkType.SODA: [f"cola_icon{i}" for i in range(1, 3)],
57+
}
58+
# "기타"는 전용 아이콘이 없어 커피 아이콘 세트를 그대로 재사용한다.
59+
DRINK_ICON_KEYS[DrinkType.OTHER] = DRINK_ICON_KEYS[DrinkType.COFFEE]
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-17 12:06
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('diary', '0007_sleeplog_residual_mg_at_sleep'),
10+
]
11+
12+
operations = [
13+
migrations.AlterField(
14+
model_name='drink',
15+
name='brand',
16+
field=models.CharField(choices=[('starbucks', '스타벅스'), ('compose', '컴포즈커피'), ('baik', '빽다방'), ('mammoth', '매머드커피'), ('ediya', '이디야커피'), ('mega', '메가엠지씨커피'), ('gongcha', '공차'), ('pepsi', '펩시'), ('cocacola', '코카콜라'), ('energy', '에너지드링크'), ('custom', '직접 입력')], default='custom', max_length=20),
17+
),
18+
]
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-17 18:39
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('diary', '0008_alter_drink_brand'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='drink',
15+
name='icon_key',
16+
field=models.CharField(blank=True, max_length=30),
17+
),
18+
]

‎diary/models.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ class Drink(models.Model):
2121
custom_brand_name = models.CharField(max_length=100, blank=True) # brand=custom일 때만 사용하는 자유 입력 브랜드명
2222
name = models.CharField(max_length=100)
2323
size = models.CharField(max_length=50, blank=True) # 브랜드별 라벨, 검증은 serializer
24+
# static/images/icons/<icon_key>.svg. 비어있으면 프론트가 type 기본 아이콘으로 대체한다.
25+
# 종류(type)에 안 맞는 값은 못 들어오게 constants.DRINK_ICON_KEYS로 검증(serializer).
26+
icon_key = models.CharField(max_length=30, blank=True)
2427

2528
is_favorite = models.BooleanField(default=False)
2629
is_recent = models.BooleanField(default=False)

‎diary/serializers.py‎

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from calcs.pharmacokinetics import concentration_at
1919

2020
from .calc_bridge import doses_for_user
21-
from .constants import Brand, DrinkType
21+
from .constants import Brand, DRINK_ICON_KEYS, DrinkType
2222
from .models import CaffeineLog, Drink, SleepLog
2323
from .presets import find_preset, size_labels
2424

@@ -47,6 +47,7 @@ class Meta:
4747
"brand_display",
4848
"name",
4949
"size",
50+
"icon_key",
5051
"caffeine_mg",
5152
"is_favorite",
5253
"is_recent",
@@ -127,6 +128,11 @@ def validate(self, attrs):
127128
if brand != Brand.CUSTOM:
128129
attrs["custom_brand_name"] = ""
129130

131+
icon_key = attrs.get("icon_key", getattr(self.instance, "icon_key", ""))
132+
if icon_key:
133+
drink_type = attrs.get("type", getattr(self.instance, "type", DrinkType.COFFEE))
134+
_validate_icon_key(icon_key, drink_type)
135+
130136
labels = size_labels(brand)
131137
if not labels:
132138
return attrs
@@ -165,6 +171,7 @@ def create(self, validated_data):
165171
"type": validated_data.get("type", DrinkType.COFFEE),
166172
"caffeine_mg": validated_data["caffeine_mg"],
167173
"is_favorite": validated_data.get("is_favorite", False),
174+
"icon_key": validated_data.get("icon_key", ""),
168175
},
169176
)
170177

@@ -202,6 +209,7 @@ class DrinkFromPresetSerializer(serializers.Serializer):
202209
brand = serializers.CharField()
203210
name = serializers.CharField()
204211
size = serializers.CharField(required=False, allow_blank=True)
212+
icon_key = serializers.CharField(required=False, allow_blank=True, default="")
205213
is_favorite = serializers.BooleanField(default=False)
206214

207215
def validate(self, attrs):
@@ -211,7 +219,7 @@ def validate(self, attrs):
211219
다시 조회하면 카탈로그를 두 번 읽게 되므로 여기서 한 번만 읽는다.
212220
213221
Args:
214-
attrs (dict): brand, name, size, is_favorite
222+
attrs (dict): brand, name, size, icon_key, is_favorite
215223
216224
Returns:
217225
dict: _preset 키가 추가된 attrs
@@ -229,6 +237,8 @@ def validate(self, attrs):
229237
raise serializers.ValidationError(
230238
{"size": "해당 사이즈의 카페인 정보가 아직 등록되지 않았습니다."}
231239
)
240+
if attrs.get("icon_key"):
241+
_validate_icon_key(attrs["icon_key"], preset["type"])
232242

233243
attrs["_preset"] = preset
234244
return attrs
@@ -237,7 +247,7 @@ def create(self, validated_data):
237247
"""검증된 프리셋 값으로 Drink를 생성한다.
238248
239249
type, caffeine_mg는 전부 프리셋에서 가져오고, 클라이언트가 정할 수
240-
있는 값은 is_favorite뿐이다.
250+
있는 값은 icon_key·is_favorite뿐이다.
241251
242252
Args:
243253
validated_data (dict): _preset이 포함된 검증 결과
@@ -256,6 +266,7 @@ def create(self, validated_data):
256266
"type": preset["type"],
257267
"caffeine_mg": preset["caffeine_mg"],
258268
"is_favorite": validated_data["is_favorite"],
269+
"icon_key": validated_data.get("icon_key", ""),
259270
},
260271
)
261272

@@ -497,6 +508,23 @@ def update(self, instance, validated_data):
497508
return super().update(instance, validated_data)
498509

499510

511+
def _validate_icon_key(icon_key, drink_type):
512+
"""icon_key가 drink_type에서 고를 수 있는 아이콘인지 확인한다.
513+
514+
Args:
515+
icon_key (str): static/images/icons/<icon_key>.svg
516+
drink_type (str): constants.DrinkType 값
517+
518+
Raises:
519+
serializers.ValidationError: 해당 종류에 없는 아이콘 키인 경우
520+
"""
521+
valid_keys = DRINK_ICON_KEYS.get(drink_type, [])
522+
if icon_key not in valid_keys:
523+
raise serializers.ValidationError(
524+
{"icon_key": f"해당 종류에서 고를 수 없는 아이콘입니다. 가능: {', '.join(valid_keys)}"}
525+
)
526+
527+
500528
@transaction.atomic
501529
def _create_or_revive(user, brand, custom_brand_name, name, size, defaults):
502530
"""중복 음료를 새로 만들지 않고 되살리거나 갱신한다.
@@ -516,7 +544,7 @@ def _create_or_revive(user, brand, custom_brand_name, name, size, defaults):
516544
custom_brand_name (str): brand=custom일 때의 자유 입력 브랜드명. 그 외엔 빈 문자열
517545
name (str): 음료명
518546
size (str): 사이즈 라벨. 사이즈가 없으면 빈 문자열
519-
defaults (dict): type, caffeine_mg, is_favorite
547+
defaults (dict): type, caffeine_mg, is_favorite, icon_key
520548
521549
Returns:
522550
Drink: 생성/복구/재사용된 인스턴스
@@ -538,6 +566,7 @@ def _create_or_revive(user, brand, custom_brand_name, name, size, defaults):
538566
existing.is_active = True
539567
existing.type = defaults["type"]
540568
existing.caffeine_mg = defaults["caffeine_mg"]
569+
existing.icon_key = defaults.get("icon_key", "")
541570

542571
# 이미 활성 상태라면 즐겨찾기 요청만 반영한다.
543572
# 과거 로그가 참조하는 caffeine_mg를 임의로 덮어쓰지 않기 위해서다.

0 commit comments

Comments
 (0)