Skip to content

Commit 730dfeb

Browse files
authored
Merge pull request #39 from 9git9git/SCRUM-140-BE-분석-페이지-서비스-API-개발
Scrum 140 be 분석 페이지 서비스 api 개발
2 parents 860b3bf + 3a251a5 commit 730dfeb

File tree

5 files changed

+225
-7
lines changed

5 files changed

+225
-7
lines changed

app/api/v1/endpoints/character.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ async def delete_character(
100100
# 서비스 로직
101101

102102

103-
@router.get("/characters", response_model=ResponseBase[List[CharacterResponse]])
103+
@router.get("/", response_model=ResponseBase[List[CharacterResponse]])
104104
async def get_user_character_collection(
105105
user_id: UUID,
106106
db: AsyncSession = Depends(get_db),

app/api/v1/endpoints/chart.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
from fastapi import APIRouter, Depends, HTTPException, Query, status
2+
from sqlalchemy.ext.asyncio import AsyncSession
3+
from uuid import UUID
4+
from typing import List
5+
6+
from app.db.session import get_db
7+
from app.services.chart import get_daily_achievement, get_monthly_achievement
8+
from app.schemas.chart import DailyAchievementResponse, MonthlyAchievementResponse
9+
from app.schemas.base import ResponseBase
10+
11+
router = APIRouter()
12+
13+
14+
@router.get("/chart/daily", response_model=ResponseBase[List[DailyAchievementResponse]])
15+
async def get_daily_chart_data(
16+
user_id: UUID,
17+
year: int = Query(..., description="연도 기준 필터 (예: 2025)"),
18+
db: AsyncSession = Depends(get_db),
19+
) -> ResponseBase[List[DailyAchievementResponse]]:
20+
try:
21+
data = await get_daily_achievement(db, user_id=user_id, year=year)
22+
return ResponseBase(status_code=status.HTTP_200_OK, data=data)
23+
except HTTPException as e:
24+
return ResponseBase(status_code=e.status_code, error=e.detail)
25+
except Exception as e:
26+
return ResponseBase(
27+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
28+
error="일별 차트 데이터를 가져오는 중 오류가 발생했습니다.",
29+
)
30+
31+
32+
@router.get(
33+
"/chart/monthly", response_model=ResponseBase[List[MonthlyAchievementResponse]]
34+
)
35+
async def get_monthly_chart_data(
36+
user_id: UUID,
37+
year: int = Query(..., description="연도 기준 필터 (예: 2025)"),
38+
db: AsyncSession = Depends(get_db),
39+
) -> ResponseBase[List[MonthlyAchievementResponse]]:
40+
try:
41+
data = await get_monthly_achievement(db, user_id=user_id, year=year)
42+
return ResponseBase(status_code=status.HTTP_200_OK, data=data)
43+
except HTTPException as e:
44+
return ResponseBase(status_code=e.status_code, error=e.detail)
45+
except Exception as e:
46+
return ResponseBase(
47+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
48+
error="월별 차트 데이터를 가져오는 중 오류가 발생했습니다.",
49+
)

app/api/v1/router.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
todo,
1717
main,
1818
analyze,
19+
chart,
1920
)
2021

2122
router = APIRouter()
@@ -69,17 +70,18 @@
6970
)
7071
router.include_router(main.router, prefix="/users/{user_id}", tags=["main"])
7172

72-
# 일반 캐릭터 CRUD
73-
router.include_router(character.router, prefix="/characters", tags=["characters"])
74-
7573
# 유저 도감 전용 API (GET /users/{user_id}/characters)
7674
router.include_router(
77-
character.router,
78-
prefix="/users/{user_id}",
79-
tags=["user_characters"], # or "characters" if you want to group together
75+
character.router, prefix="/users/{user_id}/characters", tags=["characters"]
8076
)
8177
router.include_router(
8278
analyze.router,
8379
prefix="/users/{user_id}/analyze",
8480
tags=["analyze"],
8581
)
82+
83+
router.include_router(
84+
chart.router,
85+
prefix="/users/{user_id}",
86+
tags=["Chart"],
87+
)

app/schemas/chart.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from app.schemas.base import BaseModel
2+
from typing import Literal
3+
from datetime import date
4+
5+
6+
# 일자별 달성률 응답
7+
class DailyAchievementResponse(BaseModel):
8+
date: date
9+
english: float
10+
exercise: float
11+
coding: float
12+
13+
14+
# 월별 달성률 응답 (optional로 구성 가능)
15+
class MonthlyAchievementResponse(BaseModel):
16+
month: str # "2025-01", "2025-02" 형식
17+
english: float
18+
exercise: float
19+
coding: float

app/services/chart.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
from uuid import UUID
2+
from sqlalchemy.ext.asyncio import AsyncSession
3+
from sqlalchemy import select, extract, func
4+
from sqlalchemy.orm import selectinload
5+
from app.models.category import Todo, Category
6+
from app.schemas.chart import DailyAchievementResponse
7+
from collections import defaultdict
8+
from typing import List
9+
10+
11+
async def get_daily_achievement(
12+
db: AsyncSession, user_id: UUID, year: int
13+
) -> List[DailyAchievementResponse]:
14+
# 해당 연도의 투두 불러오기
15+
result = await db.execute(
16+
select(Todo)
17+
.join(Category)
18+
.options(selectinload(Todo.category))
19+
.where(Todo.user_id == user_id, extract("year", Todo.start_date) == year)
20+
)
21+
todos = result.scalars().all()
22+
23+
# 일자별 → 카테고리별 → [완료된 수, 전체 수] 누적
24+
data = defaultdict(
25+
lambda: {"english": [0, 0], "exercise": [0, 0], "coding": [0, 0]}
26+
)
27+
28+
for todo in todos:
29+
key_date = todo.start_date
30+
category = todo.category.category_name.value # "영어", "운동", "코딩"
31+
32+
if category == "영어":
33+
key = "english"
34+
elif category == "운동":
35+
key = "exercise"
36+
elif category == "코딩":
37+
key = "coding"
38+
else:
39+
continue # 혹시 모를 예외
40+
41+
data[key_date][key][1] += 1 # 전체 수
42+
if todo.is_completed:
43+
data[key_date][key][0] += 1 # 완료 수
44+
45+
# 응답 데이터 구성
46+
responses: List[DailyAchievementResponse] = []
47+
48+
for date_key in sorted(data.keys()):
49+
entry = data[date_key]
50+
responses.append(
51+
DailyAchievementResponse(
52+
date=date_key,
53+
english=(
54+
round((entry["english"][0] / entry["english"][1] * 100), 2)
55+
if entry["english"][1]
56+
else 0
57+
),
58+
exercise=(
59+
round((entry["exercise"][0] / entry["exercise"][1] * 100), 2)
60+
if entry["exercise"][1]
61+
else 0
62+
),
63+
coding=(
64+
round((entry["coding"][0] / entry["coding"][1] * 100), 2)
65+
if entry["coding"][1]
66+
else 0
67+
),
68+
)
69+
)
70+
71+
return responses
72+
73+
74+
from app.models.category import Todo, Category
75+
from app.schemas.chart import MonthlyAchievementResponse
76+
from sqlalchemy import extract, select
77+
from sqlalchemy.ext.asyncio import AsyncSession
78+
from sqlalchemy.orm import selectinload
79+
from uuid import UUID
80+
from collections import defaultdict
81+
from typing import List
82+
83+
84+
async def get_monthly_achievement(
85+
db: AsyncSession, user_id: UUID, year: int
86+
) -> List[MonthlyAchievementResponse]:
87+
# 1. 연도에 해당하는 Todo 불러오기 (Category 조인 포함)
88+
result = await db.execute(
89+
select(Todo)
90+
.join(Category)
91+
.options(selectinload(Todo.category))
92+
.where(Todo.user_id == user_id, extract("year", Todo.start_date) == year)
93+
)
94+
todos = result.scalars().all()
95+
96+
# 2. 월별 데이터 누적용 딕셔너리 초기화
97+
data = defaultdict(
98+
lambda: {
99+
"english": [0, 0],
100+
"exercise": [0, 0],
101+
"coding": [0, 0],
102+
}
103+
)
104+
105+
# 3. 데이터 누적
106+
for todo in todos:
107+
month_str = todo.start_date.strftime("%Y-%m") # 예: "2025-01"
108+
category = todo.category.category_name.value # "코딩", "영어", "운동"
109+
110+
if category == "영어":
111+
key = "english"
112+
elif category == "운동":
113+
key = "exercise"
114+
elif category == "코딩":
115+
key = "coding"
116+
else:
117+
continue
118+
119+
data[month_str][key][1] += 1 # 전체 수 증가
120+
if todo.is_completed:
121+
data[month_str][key][0] += 1 # 완료 수 증가
122+
123+
# 4. 응답 스키마에 맞게 가공
124+
responses: List[MonthlyAchievementResponse] = []
125+
for month_key in sorted(data.keys()):
126+
entry = data[month_key]
127+
responses.append(
128+
MonthlyAchievementResponse(
129+
month=month_key,
130+
english=(
131+
round((entry["english"][0] / entry["english"][1] * 100), 2)
132+
if entry["english"][1]
133+
else 0
134+
),
135+
exercise=(
136+
round((entry["exercise"][0] / entry["exercise"][1] * 100), 2)
137+
if entry["exercise"][1]
138+
else 0
139+
),
140+
coding=(
141+
round((entry["coding"][0] / entry["coding"][1] * 100), 2)
142+
if entry["coding"][1]
143+
else 0
144+
),
145+
)
146+
)
147+
148+
return responses

0 commit comments

Comments
 (0)