Skip to content

Commit 362e645

Browse files
fix: 서버 타임존 Asia/Seoul로 변경
1 parent e6ab59b commit 362e645

2 files changed

Lines changed: 138 additions & 5 deletions

File tree

‎config/settings.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@
137137

138138
LANGUAGE_CODE = 'en-us'
139139

140-
TIME_ZONE = 'UTC'
140+
TIME_ZONE = 'Asia/Seoul'
141141

142142
USE_I18N = True
143143

‎diary/views.py‎

Lines changed: 137 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -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 추가 (임시)
250383
class MainView(LoginRequiredMixin, TemplateView):
251384
login_url = "/login"

0 commit comments

Comments
 (0)