|
5 | 5 | from rest_framework import status |
6 | 6 | from django.contrib.auth import authenticate, login as auth_login, logout as auth_logout |
7 | 7 | from django.contrib.auth.models import User |
| 8 | +from django.utils import timezone |
8 | 9 | from django.views.decorators.csrf import ensure_csrf_cookie |
9 | 10 | from django.utils.decorators import method_decorator |
10 | 11 |
|
| 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 | + |
11 | 20 | # Create your views here. |
12 | 21 |
|
13 | 22 | def _has_profile(user) -> bool: |
14 | | - # TODO: 프로필 모델 확정 후 구현 |
15 | | - return False |
| 23 | + return UserProfile.objects.filter(user=user).exists() |
16 | 24 |
|
17 | 25 | # 회원가입 |
18 | 26 | @api_view(["POST"]) |
@@ -90,9 +98,118 @@ def session(request): |
90 | 98 | ) |
91 | 99 |
|
92 | 100 | # 인증됨 |
93 | | - has_profile = _has_profile(request.user) # TODO: 프로필 이슈에서 구현 |
| 101 | + has_profile = _has_profile(request.user) |
94 | 102 | return Response( |
95 | 103 | {"is_authenticated": True, "user_id": request.user.id, |
96 | 104 | "has_profile": has_profile, "next": "/" if has_profile else "/signup/profile"}, |
97 | 105 | status=status.HTTP_200_OK, |
98 | 106 | ) |
| 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) |
0 commit comments