Skip to content

Commit e36e622

Browse files
chore: API확인용 문서/테스트 프론트 페이지 추가
1 parent 09f54e2 commit e36e622

9 files changed

Lines changed: 1109 additions & 2 deletions

File tree

‎config/urls.py‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,11 @@
1414
1. Import the include() function: from django.urls import include, path
1515
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
1616
"""
17+
from django.conf import settings
1718
from django.contrib import admin
1819
from django.http import JsonResponse
19-
from django.urls import path, include
20+
from django.urls import path, include, re_path
21+
from django.views.static import serve as static_serve
2022

2123

2224
def home(request):
@@ -36,4 +38,14 @@ def login_placeholder(request):
3638
path('auth/', include('accounts.urls')),
3739
path('auth/social/', include('allauth.urls')),
3840
path('', include('diary.urls')),
39-
]
41+
]
42+
43+
if settings.DEBUG:
44+
# 개발용 테스트 프론트(testpages/)를 같은 origin에서 서빙 - 세션/CSRF 쿠키 공유 목적
45+
urlpatterns += [
46+
re_path(
47+
r"^testpages/(?P<path>.*)$",
48+
static_serve,
49+
{"document_root": settings.BASE_DIR / "testpages"},
50+
),
51+
]

‎docs/api_overview.html‎

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
<!DOCTYPE html>
2+
<html lang="ko">
3+
<head>
4+
<meta charset="UTF-8">
5+
<title>CaffeineDiary 백엔드 API 구조</title>
6+
</head>
7+
<body>
8+
9+
<h1>CaffeineDiary 백엔드 API 구조</h1>
10+
<p>기준 커밋: <code>8b7c1d9 feat: drink api 구현</code> (branch: feature/drink-api)</p>
11+
<p>브라우저에서 직접 확인할 때는 로그인 세션 상태에서 각 GET URL을 열면 DRF Browsable API 화면으로 응답을 볼 수 있습니다.</p>
12+
13+
<h2>목차</h2>
14+
<ul>
15+
<li><a href="#routing">1. 전체 URL 라우팅</a></li>
16+
<li><a href="#accounts">2. accounts 앱 - 인증 API</a></li>
17+
<li><a href="#diary">3. diary 앱 - 음료(Drink) API</a></li>
18+
<li><a href="#flow">4. 사용자 흐름</a></li>
19+
<li><a href="#todo">5. 모델은 있지만 API가 없는 부분</a></li>
20+
<li><a href="#note">6. 확인이 필요한 부분</a></li>
21+
</ul>
22+
23+
<hr>
24+
25+
<h2 id="routing">1. 전체 URL 라우팅 (config/urls.py)</h2>
26+
<table border="1" cellpadding="6" cellspacing="0">
27+
<thead>
28+
<tr><th>Path</th><th>연결</th><th>설명</th></tr>
29+
</thead>
30+
<tbody>
31+
<tr><td>/admin/</td><td>Django admin</td><td>기본 관리자 페이지</td></tr>
32+
<tr><td>/</td><td>config.views.home (함수)</td><td>JsonResponse({"is_authenticated": bool}) - TODO: SPA index로 교체 예정</td></tr>
33+
<tr><td>/login</td><td>config.views.login_placeholder (함수)</td><td>JsonResponse({"error": ...}) - TODO: 로그인 페이지로 교체 예정</td></tr>
34+
<tr><td>/auth/...</td><td>accounts.urls</td><td>아래 2번 참고</td></tr>
35+
<tr><td>/auth/social/...</td><td>allauth.urls</td><td>소셜 로그인 - 6번 "확인이 필요한 부분" 참고</td></tr>
36+
<tr><td>/users/me/drinks/...</td><td>diary.urls</td><td>아래 3번 참고</td></tr>
37+
</tbody>
38+
</table>
39+
40+
<hr>
41+
42+
<h2 id="accounts">2. accounts 앱 - 인증 API</h2>
43+
<p>전부 <code>@api_view</code> 함수형 뷰 (accounts/views.py). 세션 인증(SessionAuthentication) 기반.</p>
44+
45+
<table border="1" cellpadding="6" cellspacing="0">
46+
<thead>
47+
<tr><th>Method</th><th>URL</th><th>권한</th><th>Request Body</th><th>성공 응답</th><th>실패 응답</th></tr>
48+
</thead>
49+
<tbody>
50+
51+
<tr>
52+
<td>POST</td>
53+
<td>/auth/signup/</td>
54+
<td>AllowAny</td>
55+
<td>{"username": str, "password": str}</td>
56+
<td>201<br>{"user_id": int, "username": str, "has_profile": false, "next": "/signup/profile"}</td>
57+
<td>- (입력 검증 TODO 미구현: username 중복, 빈값 등 아직 안 막힘)</td>
58+
</tr>
59+
60+
<tr>
61+
<td>POST</td>
62+
<td>/auth/login/</td>
63+
<td>AllowAny</td>
64+
<td>{"username": str, "password": str}</td>
65+
<td>200<br>{"user_id": int, "username": str, "has_profile": bool, "next": "/" or "/signup/profile"}</td>
66+
<td>400 {"code": "INVALID_INPUT", "message": "..."} (필드 누락)<br>401 {"code": "INVALID_CREDENTIALS", "message": "..."} (인증 실패)</td>
67+
</tr>
68+
69+
<tr>
70+
<td>POST</td>
71+
<td>/auth/logout/</td>
72+
<td>IsAuthenticated</td>
73+
<td>-</td>
74+
<td>200<br>{"success": true, "night_session_active": true, "next": "/login"}</td>
75+
<td>401 {"code": "NOT_AUTHENTICATED", "message": "로그인 상태가 아닙니다."} (미로그인)</td>
76+
</tr>
77+
78+
<tr>
79+
<td>GET</td>
80+
<td>/auth/session/</td>
81+
<td>AllowAny (+ CSRF 쿠키 발급)</td>
82+
<td>-</td>
83+
<td>200<br>미인증: {"is_authenticated": false, "user_id": null, "has_profile": false, "next": "/login"}<br>인증됨: {"is_authenticated": true, "user_id": int, "has_profile": bool, "next": "/" or "/signup/profile"}</td>
84+
<td>-</td>
85+
</tr>
86+
87+
</tbody>
88+
</table>
89+
90+
<p><b>has_profile</b>은 accounts/views.py의 <code>_has_profile()</code>이 계산하는데, 지금은 <code>TODO</code> 상태로 <b>항상 False</b>를 반환합니다. UserProfile 모델(body_weight_kg, target_sleeptime, profile_image)은 만들어져 있지만 실제 존재 여부를 확인하는 로직은 아직 없습니다.</p>
91+
<p><b>logout의 night_session_active</b>도 로직 없이 항상 true로 고정된 값입니다. (allnight 기능 연동 전 임시값으로 보임)</p>
92+
93+
<hr>
94+
95+
<h2 id="diary">3. diary 앱 - 음료(Drink) API</h2>
96+
<p>DRF 제네릭 클래스 뷰 (diary/views.py). 전부 <code>IsAuthenticated</code> 필요, 응답은 요청 유저 소유 데이터로 자동 필터링됩니다.</p>
97+
98+
<table border="1" cellpadding="6" cellspacing="0">
99+
<thead>
100+
<tr><th>Method</th><th>URL</th><th>View</th><th>Request Body</th><th>성공 응답</th><th>비고</th></tr>
101+
</thead>
102+
<tbody>
103+
104+
<tr>
105+
<td>GET</td>
106+
<td>/users/me/drinks/</td>
107+
<td>DrinkListCreateView</td>
108+
<td>-</td>
109+
<td>200<br>[DrinkSerializer, ...] (즐겨찾기 우선 → 최신순)</td>
110+
<td>본인의 is_active=True 음료만</td>
111+
</tr>
112+
113+
<tr>
114+
<td>POST</td>
115+
<td>/users/me/drinks/</td>
116+
<td>DrinkListCreateView</td>
117+
<td>{"type": str, "brand": str, "name": str, "size": str, "caffeine_mg": int, "is_favorite"?: bool}</td>
118+
<td>201 DrinkSerializer</td>
119+
<td>이름/사이즈/카페인량 전부 직접 입력. 프리셋 브랜드를 골랐다면 size는 해당 브랜드 라벨과 일치해야 함(그 외 검증은 없음)</td>
120+
</tr>
121+
122+
<tr>
123+
<td>GET</td>
124+
<td>/users/me/drinks/&lt;id&gt;/</td>
125+
<td>DrinkDetailView</td>
126+
<td>-</td>
127+
<td>200 DrinkSerializer</td>
128+
<td>남의 음료거나 삭제된 음료면 404</td>
129+
</tr>
130+
131+
<tr>
132+
<td>PATCH</td>
133+
<td>/users/me/drinks/&lt;id&gt;/</td>
134+
<td>DrinkDetailView</td>
135+
<td>수정할 필드만 (예: {"is_favorite": true})</td>
136+
<td>200 DrinkSerializer</td>
137+
<td>-</td>
138+
</tr>
139+
140+
<tr>
141+
<td>DELETE</td>
142+
<td>/users/me/drinks/&lt;id&gt;/</td>
143+
<td>DrinkDetailView</td>
144+
<td>-</td>
145+
<td>204 No Content</td>
146+
<td>실제 삭제 아님. is_active=False로 소프트 삭제 (CaffeineLog의 drink_id 참조 유지 목적)</td>
147+
</tr>
148+
149+
<tr>
150+
<td>GET</td>
151+
<td>/users/me/drinks/presets/</td>
152+
<td>PresetDrinkListView</td>
153+
<td>쿼리파라미터 ?brand=starbucks (생략 시 전체)</td>
154+
<td>200<br>[{"brand","brand_name","type","name","size","caffeine_mg"}, ...]</td>
155+
<td>DB 아님. presets 패키지의 상수 카탈로그. 카페인량 미입력 사이즈는 자동 제외</td>
156+
</tr>
157+
158+
<tr>
159+
<td>POST</td>
160+
<td>/users/me/drinks/from-preset/</td>
161+
<td>DrinkFromPresetView</td>
162+
<td>{"brand": str, "name": str, "size"?: str, "is_favorite"?: bool}</td>
163+
<td>201 DrinkSerializer (id, caffeine_mg 자동 포함)</td>
164+
<td>caffeine_mg는 절대 클라이언트가 안 보냄. 서버가 카탈로그(find_preset)에서 채움. 이미 같은 음료를 삭제했었다면 새로 만들지 않고 되살림</td>
165+
</tr>
166+
167+
</tbody>
168+
</table>
169+
170+
<p><b>DrinkSerializer 응답 필드:</b> id, type, type_display, brand, brand_display, name, size, caffeine_mg, is_favorite, is_recent, created_at</p>
171+
<p><b>지원 브랜드 (프리셋 있음):</b> starbucks, compose, baik, mammoth, ediya, mega, gongcha, pepsi, cocacola / <b>custom</b>은 프리셋 없이 자유 입력용</p>
172+
173+
<hr>
174+
175+
<h2 id="flow">4. 사용자 흐름</h2>
176+
177+
<h3>가입 ~ 로그인</h3>
178+
<pre>
179+
POST /auth/signup/ 또는 POST /auth/login/
180+
-> 세션 쿠키 생성, has_profile로 다음 화면 안내(next)
181+
182+
새로고침 / 앱 재진입 시
183+
GET /auth/session/ -> 로그인 유지 여부 확인
184+
</pre>
185+
186+
<h3>음료 등록 (두 갈래 중 선택)</h3>
187+
<pre>
188+
[A] 카탈로그에서 선택
189+
GET /users/me/drinks/presets/?brand=starbucks -> 목록에서 이름/사이즈 고르기
190+
POST /users/me/drinks/from-preset/ {brand,name,size}
191+
-> caffeine_mg는 서버가 자동으로 채워서 응답
192+
193+
[B] 프리셋에 없는 음료를 직접 입력
194+
POST /users/me/drinks/ {type,brand,name,size,caffeine_mg}
195+
-> 전부 수동 입력
196+
</pre>
197+
198+
<h3>등록 이후 공통</h3>
199+
<pre>
200+
GET /users/me/drinks/ -> 내 음료 목록 (즐겨찾기 우선)
201+
PATCH /users/me/drinks/&lt;id&gt;/ -> 즐겨찾기 토글 등 수정
202+
DELETE /users/me/drinks/&lt;id&gt;/ -> 소프트 삭제
203+
</pre>
204+
205+
<hr>
206+
207+
<h2 id="todo">5. 모델은 있지만 API가 없는 부분</h2>
208+
<table border="1" cellpadding="6" cellspacing="0">
209+
<thead>
210+
<tr><th>앱</th><th>모델 / 모듈</th><th>상태</th></tr>
211+
</thead>
212+
<tbody>
213+
<tr><td>accounts</td><td>UserProfile (body_weight_kg, target_sleeptime, profile_image)</td><td>모델만 존재. 조회/수정 API 없음. has_profile 판별 로직도 TODO</td></tr>
214+
<tr><td>diary</td><td>CaffeineLog (caffeine_mg 스냅샷, drink FK)</td><td>모델만 존재. 로그 남기기/조회 API 없음</td></tr>
215+
<tr><td>diary</td><td>SleepLog (sleep_quality, actual_bedtime, wakeup_time)</td><td>모델만 존재. API 없음</td></tr>
216+
<tr><td>allnight</td><td>AllNightSession (status, started_at, ended_at, target_time)</td><td>모델만 존재. views.py / urls.py가 빈 stub(<code>render</code> import만 있음). URL 자체가 config/urls.py에 include도 안 돼있음</td></tr>
217+
<tr><td>calcs</td><td>pharmacokinetics.py, cutoff.py, allowance.py, marginal_utility.py, night_schedule.py, personalization.py</td><td>순수 계산 함수 모음. 어느 뷰에서도 아직 호출하지 않음 (엔드포인트로 연결 안 됨)</td></tr>
218+
</tbody>
219+
</table>
220+
221+
<hr>
222+
223+
<h2 id="note">6. 확인이 필요한 부분</h2>
224+
<ul>
225+
<li><b>/auth/social/ (django-allauth)</b>: config/urls.py에 <code>include('allauth.urls')</code>가 있지만, <code>allauth</code> 계열 앱이 INSTALLED_APPS에 없고 requirements.txt에도 없습니다(venv에는 설치되어 있음). SITE_ID, AUTHENTICATION_BACKENDS, allauth 미들웨어도 settings.py에 없어서 이 경로는 현재 서버 구동/요청 시 에러가 날 가능성이 있습니다. 실제로 열어보기 전엔 "구현됨"으로 보지 않는 게 안전합니다.</li>
226+
<li>accounts/serializers.py 파일은 존재하지만 내용이 비어 있습니다. 지금 accounts 뷰들은 serializer 없이 request.data를 직접 다룹니다.</li>
227+
</ul>
228+
229+
</body>
230+
</html>

‎testpages/common.js‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// 모든 테스트 페이지 공용 스크립트. 디자인 없음, fetch 결과를 그대로 화면에 출력하는 용도.
2+
3+
function getCookie(name) {
4+
const m = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)"));
5+
return m ? decodeURIComponent(m[1]) : null;
6+
}
7+
8+
async function apiFetch(path, method = "GET", body = null) {
9+
const headers = { "Content-Type": "application/json" };
10+
if (method !== "GET") {
11+
const csrftoken = getCookie("csrftoken");
12+
if (csrftoken) headers["X-CSRFToken"] = csrftoken;
13+
}
14+
const res = await fetch(path, {
15+
method,
16+
headers,
17+
credentials: "include",
18+
body: body !== null ? JSON.stringify(body) : undefined,
19+
});
20+
let data = null;
21+
try {
22+
data = await res.json();
23+
} catch (e) {
24+
// 204 No Content 등 본문이 없는 응답
25+
}
26+
return { status: res.status, ok: res.ok, data };
27+
}
28+
29+
function renderResult(elId, result) {
30+
document.getElementById(elId).textContent =
31+
"HTTP " + result.status + "\n\n" + JSON.stringify(result.data, null, 2);
32+
}
33+
34+
// 상단 nav의 로그인 상태 표시 + 모든 페이지 최초 진입 시 CSRF 쿠키 확보(session은 ensure_csrf_cookie)
35+
async function initNav() {
36+
const result = await apiFetch("/auth/session/");
37+
const el = document.getElementById("auth-status");
38+
if (!el) return;
39+
if (result.data && result.data.is_authenticated) {
40+
el.textContent = "로그인됨 (user_id=" + result.data.user_id + ")";
41+
} else {
42+
el.textContent = "로그인 안 됨";
43+
}
44+
}
45+
46+
async function doLogout() {
47+
const result = await apiFetch("/auth/logout/", "POST");
48+
alert("POST /auth/logout/\nHTTP " + result.status + "\n" + JSON.stringify(result.data));
49+
initNav();
50+
}

‎testpages/drink_detail.html‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
<!DOCTYPE html>
2+
<html lang="ko">
3+
<head>
4+
<meta charset="UTF-8">
5+
<title>[음료 상세] 화면</title>
6+
</head>
7+
<body>
8+
9+
<h1>[음료 상세] 화면</h1>
10+
11+
<nav>
12+
<a href="/testpages/index.html">홈</a> |
13+
<a href="/testpages/signup.html">회원가입</a> |
14+
<a href="/testpages/login.html">로그인</a> |
15+
<a href="/testpages/drinks.html">내 음료</a> |
16+
<a href="/testpages/presets.html">프리셋</a> |
17+
<button onclick="doLogout()">로그아웃</button>
18+
&nbsp;&nbsp;[<span id="auth-status">확인중...</span>]
19+
</nav>
20+
<hr>
21+
22+
<p>id: <span id="current-id">-</span> (<a href="/testpages/drinks.html">목록으로</a>)</p>
23+
24+
<h2>GET /users/me/drinks/&lt;id&gt;/</h2>
25+
<pre id="detail-result">로딩중...</pre>
26+
27+
<hr>
28+
29+
<h2>PATCH /users/me/drinks/&lt;id&gt;/</h2>
30+
<p>수정할 필드만 JSON으로 입력 (예: {"is_favorite": true})</p>
31+
<textarea id="patch-body" rows="4" cols="50">{"is_favorite": true}</textarea><br><br>
32+
<button onclick="doPatch()">수정 적용</button>
33+
<pre id="patch-result">(아직 요청 없음)</pre>
34+
35+
<hr>
36+
37+
<h2>DELETE /users/me/drinks/&lt;id&gt;/</h2>
38+
<p>실제 삭제가 아니라 is_active=False로 바뀌는 소프트 삭제입니다.</p>
39+
<button onclick="doDelete()">삭제</button>
40+
<pre id="delete-result">(아직 요청 없음)</pre>
41+
42+
<script src="common.js"></script>
43+
<script>
44+
initNav();
45+
46+
const id = new URLSearchParams(location.search).get("id");
47+
document.getElementById("current-id").textContent = id || "(없음 - drinks.html에서 링크로 진입하세요)";
48+
49+
async function loadDetail() {
50+
if (!id) return;
51+
const result = await apiFetch("/users/me/drinks/" + id + "/");
52+
renderResult("detail-result", result);
53+
}
54+
55+
async function doPatch() {
56+
if (!id) return;
57+
let body;
58+
try {
59+
body = JSON.parse(document.getElementById("patch-body").value);
60+
} catch (e) {
61+
alert("JSON 형식이 올바르지 않습니다: " + e.message);
62+
return;
63+
}
64+
const result = await apiFetch("/users/me/drinks/" + id + "/", "PATCH", body);
65+
renderResult("patch-result", result);
66+
loadDetail();
67+
}
68+
69+
async function doDelete() {
70+
if (!id) return;
71+
if (!confirm("정말 삭제(소프트 삭제)하시겠습니까?")) return;
72+
const result = await apiFetch("/users/me/drinks/" + id + "/", "DELETE");
73+
renderResult("delete-result", result);
74+
}
75+
76+
window.addEventListener("DOMContentLoaded", loadDetail);
77+
</script>
78+
79+
</body>
80+
</html>

0 commit comments

Comments
 (0)