@@ -154,8 +154,8 @@ class CaffeineLogListCreateView(generics.ListCreateAPIView):
154154 기간을 좁히고 page/page_size로 페이지네이션한다.
155155 POST /caffeine-logs/ - 한잔(drink_id) 또는 커스텀(caffeine_mg+name) 기록
156156
157- 기간 필터는 created_at의 날짜(UTC ) 기준이다. start_date/end_date는
158- YYYY-MM-DD 형식이며 파싱에 실패하면 무시한다.
157+ 기간 필터는 created_at의 날짜(서버 타임존 Asia/Seoul ) 기준이다.
158+ start_date/end_date는 YYYY-MM-DD 형식이며 파싱에 실패하면 무시한다.
159159 """
160160
161161 serializer_class = CaffeineLogSerializer
@@ -208,8 +208,8 @@ class SleepLogListCreateView(generics.ListCreateAPIView):
208208 POST /sleep-logs/ - 수면기록 생성. actual_bedtime을 보내면 그 시각의
209209 잔류 카페인량을 서버가 계산해 함께 저장한다.
210210
211- 기간 필터는 created_at의 날짜(UTC ) 기준이다. start_date/end_date는
212- YYYY-MM-DD 형식이며 파싱에 실패하면 무시한다.
211+ 기간 필터는 created_at의 날짜(서버 타임존 Asia/Seoul ) 기준이다.
212+ start_date/end_date는 YYYY-MM-DD 형식이며 파싱에 실패하면 무시한다.
213213 """
214214
215215 serializer_class = SleepLogSerializer
@@ -246,6 +246,139 @@ def get_queryset(self):
246246 return SleepLog .objects .filter (user = self .request .user )
247247
248248
249+ class FeedStatusView (APIView ):
250+ """메인피드용 계산 결과 조회.
251+
252+ GET /feed-status/ - CALC-001(잔류량+그래프), CALC-002(마감시각),
253+ CALC-003(남은허용량/잔수), CALC-005(개인화 θ),
254+ CALC-006(한계효용)을 이 안에서 호출해 한 번에 내려준다.
255+ ?compact=true면 drinks/graph를 생략한다.
256+
257+ 명세(Notion API 명세서) URI는 /feed이지만, 그 경로는 diary/urls.py의
258+ "feed/"에서 프론트가 작업 중인 HTML 화면(FeedView)이 이미 쓰고 있다.
259+ 같은 경로에 화면(HTML)과 데이터(JSON)를 동시에 둘 수 없어, 프론트와
260+ 최종 경로를 맞추기 전까지 feed-status/에 임시로 둔다.
261+
262+ 선행조건: UserProfile 존재. 없으면 404 PROFILE_NOT_FOUND.
263+ """
264+
265+ permission_classes = [permissions .IsAuthenticated ]
266+
267+ def get (self , request ):
268+ user = request .user
269+ try :
270+ profile = user .profile
271+ except UserProfile .DoesNotExist :
272+ return Response (
273+ {
274+ "code" : "PROFILE_NOT_FOUND" ,
275+ "message" : "프로필이 없습니다." ,
276+ "next" : "/signup/profile" ,
277+ },
278+ status = status .HTTP_404_NOT_FOUND ,
279+ )
280+
281+ compact = request .query_params .get ("compact" , "" ).lower () == "true"
282+ now = timezone .now ()
283+ # 서비스일 경계(05:00)는 서버 로컬시각(Asia/Seoul) 기준이다. now는 항상 UTC를
284+ # 담고 있으므로(Django의 timezone.now() 규약), .date()/.hour를 직접 쓰면 안 되고
285+ # localtime으로 변환한 값을 써야 한다.
286+ local_now = timezone .localtime (now )
287+ today = service_date (local_now )
288+ day_start = timezone .make_aware (datetime .combine (today , dt_time (hour = SERVICE_DAY_START_HOUR )))
289+
290+ sleep_survey_required = self ._sleep_survey_required (user , local_now , today , day_start )
291+ night_session_active = AllNightSession .objects .filter (
292+ user = user , status = AllNightSession .Status .ACTIVE
293+ ).exists ()
294+
295+ theta_mg = theta_for_user (user ).theta_mg
296+ doses = doses_for_user (user , now , since = day_start )
297+ today_total_mg = sum (d .amount_mg for d in doses )
298+ current_mg = round (concentration_at (doses , now ), 1 )
299+
300+ bedtime = resolve_next_occurrence (profile .target_sleeptime , now )
301+ ref_dose = reference_dose_mg (user )
302+
303+ cutoff_result = calc_cutoff (now , bedtime , ref_dose , doses , theta_mg )
304+ allowance_result = calc_allowance (bedtime , doses , theta_mg , ref_dose )
305+ marginal_result = calc_marginal_utility (today_total_mg , ref_dose , profile .body_weight_kg )
306+
307+ warnings = []
308+ if cutoff_result .status in (CutoffStatus .OVER_THRESHOLD , CutoffStatus .CUTOFF_PASSED ):
309+ warnings .append (
310+ {"code" : "CUTOFF_EXCEEDED" , "message" : "오늘의 카페인 마감시간이 지났습니다." }
311+ )
312+ if marginal_result .over_daily_limit :
313+ warnings .append (
314+ {"code" : "DAILY_LIMIT_EXCEEDED" , "message" : "일일 권장 상한을 초과했습니다." }
315+ )
316+ elif marginal_result .negligible :
317+ warnings .append (
318+ {"code" : "MARGINAL_EFFECT" , "message" : "추가 섭취는 각성 효과가 거의 없습니다." }
319+ )
320+
321+ cutoff_at_local = timezone .localtime (cutoff_result .cutoff_at ) if cutoff_result .cutoff_at else None
322+
323+ data = {
324+ "date" : today .isoformat (),
325+ "now" : local_now .isoformat (),
326+ "sleep_survey_required" : sleep_survey_required ,
327+ "night_session_active" : night_session_active ,
328+ "target_bedtime" : profile .target_sleeptime .strftime ("%H:%M" ),
329+ "threshold_mg" : theta_mg ,
330+ "current_mg" : current_mg ,
331+ "cutoff_at" : cutoff_at_local .isoformat () if cutoff_at_local else None ,
332+ "remaining_mg" : allowance_result .remaining_mg ,
333+ "remaining_cups" : allowance_result .cups ,
334+ "today_total_mg" : round (today_total_mg , 1 ),
335+ "today_log_count" : len (doses ),
336+ "marginal" : {
337+ "g_pd_min" : marginal_result .g_pd_after ,
338+ "delta_g" : marginal_result .delta_g ,
339+ "warning" : marginal_result .warning is not None ,
340+ },
341+ "warnings" : warnings ,
342+ }
343+
344+ if not compact :
345+ data ["drinks" ] = [
346+ {"drink_id" : d .id , "name" : d .name , "caffeine_mg" : d .caffeine_mg }
347+ for d in Drink .objects .filter (user = user , is_active = True )
348+ ]
349+ data ["graph" ] = build_curve_payload (
350+ doses ,
351+ start = day_start ,
352+ threshold_mg = theta_mg ,
353+ threshold_role = "max" ,
354+ now = now ,
355+ bedtime = bedtime ,
356+ cutoff_at = cutoff_result .cutoff_at ,
357+ )
358+
359+ return Response (data , status = status .HTTP_200_OK )
360+
361+ @staticmethod
362+ def _sleep_survey_required (user , local_now , today , day_start ):
363+ """SLEEP-001: 수면설문 노출 판정.
364+
365+ 가입 당일, 서비스일 경계(05:00) 이전, 오늘자 SleepLog가 이미 있으면
366+ 노출하지 않는다. "전날 밤샘모드면 건너뛴다"는 명세 조건은 AllNightSession과
367+ CaffeineLog를 잇는 연결이 아직 없어 반영하지 못한다 — 밤샘모드 API를
368+ 만들 때 마저 구현해야 한다.
369+
370+ local_now는 서버 로컬시각(Asia/Seoul)으로 변환된 값이어야 한다.
371+ date_joined는 DB에 UTC로 저장되므로 비교 전에 같은 방식으로 변환한다.
372+ """
373+ if service_date (timezone .localtime (user .date_joined )) == today :
374+ return False
375+ if local_now .hour < SERVICE_DAY_START_HOUR :
376+ return False
377+ if SleepLog .objects .filter (user = user , created_at__gte = day_start ).exists ():
378+ return False
379+ return True
380+
381+
249382# main(맨 처음 들어갔을 때 화면) view 추가 (임시)
250383class MainView (LoginRequiredMixin , TemplateView ):
251384 login_url = "/login"
0 commit comments