Skip to content

Commit a2288b3

Browse files
authored
Merge pull request #23 from KKU-NoteFlow/fix/upload
upload/preview
2 parents e4f73cb + 01f25fc commit a2288b3

File tree

4 files changed

+113
-72
lines changed

4 files changed

+113
-72
lines changed

models/file.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
# Backend/models/file.py
21
from sqlalchemy import Column, Integer, String, ForeignKey, TIMESTAMP, text
32
from .base import Base
43

@@ -8,7 +7,8 @@ class File(Base):
87
id = Column(Integer, primary_key=True, autoincrement=True)
98
user_id = Column(Integer, ForeignKey('user.u_id', ondelete='CASCADE'), nullable=False)
109
folder_id = Column(Integer, ForeignKey('folder.id', ondelete='SET NULL'), nullable=True)
10+
note_id = Column(Integer, ForeignKey('note.id', ondelete='SET NULL'), nullable=True) # ✅ 첨부된 노트 ID
1111
original_name = Column(String(255), nullable=False) # 유저가 업로드한 원본 파일 이름
12-
saved_path = Column(String(512), nullable=False) # 서버에 저장된(실제) 경로
13-
content_type = Column(String(100), nullable=False) # MIME 타입
12+
saved_path = Column(String(512), nullable=False) # 서버에 저장된(실제) 경로
13+
content_type = Column(String(100), nullable=False) # MIME 타입
1414
created_at = Column(TIMESTAMP, nullable=False, server_default=text('CURRENT_TIMESTAMP'))

routers/file.py

Lines changed: 32 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
# ~/noteflow/Backend/routers/file.py
2-
31
import os
42
from datetime import datetime
53
from typing import Optional, List
@@ -71,11 +69,12 @@ def run(cmd: list[str]):
7169

7270
@router.post(
7371
"/upload",
74-
summary="폴더에 파일 업로드",
72+
summary="폴더/노트에 파일 업로드 (note_id 있으면 노트 본문에도 삽입)",
7573
status_code=status.HTTP_201_CREATED
7674
)
7775
async def upload_file(
7876
folder_id: Optional[int] = Form(None),
77+
note_id: Optional[int] = Form(None),
7978
upload_file: UploadFile = File(...),
8079
db: Session = Depends(get_db),
8180
current_user = Depends(get_current_user)
@@ -87,7 +86,7 @@ async def upload_file(
8786
user_dir = os.path.join(BASE_UPLOAD_DIR, str(current_user.u_id))
8887
os.makedirs(user_dir, exist_ok=True)
8988

90-
# 원본 파일명 그대로 저장 (동명이인 방지)
89+
# 원본 파일명 그대로 저장 (중복 시 _1, _2 붙임)
9190
saved_filename = orig_filename
9291
saved_path = os.path.join(user_dir, saved_filename)
9392
if os.path.exists(saved_path):
@@ -110,10 +109,22 @@ async def upload_file(
110109
except Exception as e:
111110
raise HTTPException(status_code=500, detail=f"파일 저장 실패: {e}")
112111

112+
# note_id가 있으면 해당 노트 확인
113+
note_obj = None
114+
if note_id is not None:
115+
note_obj = (
116+
db.query(NoteModel)
117+
.filter(NoteModel.id == note_id, NoteModel.user_id == current_user.u_id)
118+
.first()
119+
)
120+
if not note_obj:
121+
raise HTTPException(status_code=404, detail="해당 노트를 찾을 수 없습니다.")
122+
113123
# DB에 메타데이터 기록
114124
new_file = FileModel(
115125
user_id=current_user.u_id,
116-
folder_id=folder_id,
126+
folder_id=None if note_id else folder_id,
127+
note_id=note_id,
117128
original_name=orig_filename,
118129
saved_path=saved_path,
119130
content_type=content_type
@@ -125,11 +136,25 @@ async def upload_file(
125136
base_url = os.getenv("BASE_API_URL", "http://localhost:8000")
126137
download_url = f"{base_url}/api/v1/files/download/{new_file.id}"
127138

139+
# note_id가 있으면 content에도 삽입
140+
if note_obj:
141+
if content_type.startswith("image/"):
142+
embed = f"\n\n![{new_file.original_name}]({download_url})\n\n"
143+
elif content_type == "application/pdf":
144+
embed = f"\n\n[{new_file.original_name}]({download_url}) (PDF 보기)\n\n"
145+
else:
146+
embed = f"\n\n[{new_file.original_name}]({download_url})\n\n"
147+
148+
note_obj.content = (note_obj.content or "") + embed
149+
db.commit()
150+
db.refresh(note_obj)
151+
128152
return {
129153
"file_id": new_file.id,
130154
"url": download_url,
131155
"original_name": new_file.original_name,
132156
"folder_id": new_file.folder_id,
157+
"note_id": new_file.note_id,
133158
"content_type": new_file.content_type,
134159
"created_at": new_file.created_at
135160
}
@@ -180,13 +205,6 @@ def download_file(
180205
if not os.path.exists(file_path):
181206
raise HTTPException(status_code=404, detail="서버에 파일이 존재하지 않습니다.")
182207

183-
# filename_star = file_obj.original_name
184-
# return FileResponse(
185-
# path=file_path,
186-
# media_type=file_obj.content_type,
187-
# headers={"Content-Disposition": f"inline; filename*=UTF-8''{filename_star}"}
188-
# )
189-
# FastAPI가 내부에서 UTF-8로 인코딩된 Content-Disposition 헤더를 생성해 줌
190208
return FileResponse(
191209
path=file_path,
192210
media_type=file_obj.content_type,
@@ -201,7 +219,6 @@ def download_file(
201219
response_model=OCRResponse
202220
)
203221
async def ocr_and_create_note(
204-
# 변경: 업로드 필드명 'file' 기본 + 과거 호환 'ocr_file' 동시 허용
205222
file: Optional[UploadFile] = File(None, description="기본 업로드 필드명"),
206223
ocr_file: Optional[UploadFile] = File(None, description="과거 호환 업로드 필드명"),
207224
folder_id: Optional[int] = Form(None),
@@ -210,21 +227,13 @@ async def ocr_and_create_note(
210227
db: Session = Depends(get_db),
211228
current_user = Depends(get_current_user)
212229
):
213-
"""
214-
변경 전: 이미지 전용 EasyOCR/TrOCR로 텍스트 추출 후 노트 생성.
215-
변경 후(추가/변경): 공통 파이프라인(utils.ocr.run_pipeline)으로 이미지/PDF/DOC/DOCX/HWP 처리.
216-
- 예외는 200으로 내려가며, results=[] + warnings에 사유 기입.
217-
- 결과 텍스트를 합쳐 비어있지 않으면 기존과 동일하게 노트를 생성.
218-
"""
219-
# 업로드 파일 결정
220230
upload = file or ocr_file
221231
if upload is None:
222232
raise HTTPException(status_code=400, detail="업로드 파일이 필요합니다. 필드명은 'file' 또는 'ocr_file'을 사용하세요.")
223233

224234
filename = upload.filename or "uploaded"
225235
mime = upload.content_type
226236

227-
# 허용 확장자 확인 (불일치 시 200 + warnings)
228237
_, ext = os.path.splitext(filename)
229238
ext = ext.lower()
230239
if ext and ext not in ALLOWED_ALL_EXTS:
@@ -238,7 +247,6 @@ async def ocr_and_create_note(
238247
text=None,
239248
)
240249

241-
# 타입 판별 (보조적으로 unknown 방지)
242250
ftype = detect_type(filename, mime)
243251
if ftype == "unknown":
244252
return OCRResponse(
@@ -268,7 +276,6 @@ async def ocr_and_create_note(
268276
note_id: Optional[int] = None
269277
if merged_text:
270278
try:
271-
# 추가/변경: 노트 제목을 업로드한 파일 이름으로 설정 (확장자 제거)
272279
base_title = os.path.splitext(filename)[0].strip() or "OCR 결과"
273280
new_note = NoteModel(
274281
user_id=current_user.u_id,
@@ -297,24 +304,21 @@ async def upload_audio_and_transcribe(
297304
db: Session = Depends(get_db),
298305
user=Depends(get_current_user)
299306
):
300-
# 📁 저장 경로 생성
301307
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
302308
filename = f"user{user.u_id}_{timestamp}_{file.filename}"
303309
save_dir = os.path.join(BASE_UPLOAD_DIR, str(user.u_id))
304310
os.makedirs(save_dir, exist_ok=True)
305311
save_path = os.path.join(save_dir, filename)
306312

307-
# 📥 파일 저장
308313
with open(save_path, "wb") as f:
309314
f.write(await file.read())
310315

311-
# ✅ note_id가 있으면 folder_id는 무시
312316
folder_id_to_use = folder_id if note_id is None else None
313317

314-
# 📦 files 테이블에 기록
315318
new_file = FileModel(
316319
user_id=user.u_id,
317320
folder_id=folder_id_to_use,
321+
note_id=note_id,
318322
original_name=filename,
319323
saved_path=save_path,
320324
content_type="audio"
@@ -323,7 +327,6 @@ async def upload_audio_and_transcribe(
323327
db.commit()
324328
db.refresh(new_file)
325329

326-
# 🧠 STT 처리
327330
try:
328331
import whisper
329332
model = whisper.load_model("base")
@@ -332,9 +335,7 @@ async def upload_audio_and_transcribe(
332335
except Exception as e:
333336
raise HTTPException(status_code=500, detail=f"STT 처리 실패: {e}")
334337

335-
# 📝 노트 처리
336338
if note_id:
337-
# 기존 노트에 텍스트 추가
338339
note = db.query(NoteModel).filter(
339340
NoteModel.id == note_id,
340341
NoteModel.user_id == user.u_id
@@ -349,7 +350,6 @@ async def upload_audio_and_transcribe(
349350
db.refresh(note)
350351

351352
else:
352-
# 새 노트 생성
353353
new_note = NoteModel(
354354
user_id=user.u_id,
355355
folder_id=folder_id_to_use,
@@ -364,10 +364,7 @@ async def upload_audio_and_transcribe(
364364
"message": "STT 및 노트 저장 완료",
365365
"transcript": transcript
366366
}
367+
367368
@router.options("/ocr")
368369
def ocr_cors_preflight() -> Response:
369-
"""CORS preflight용 OPTIONS 응답. 일부 프록시/클라이언트에서 405 회피.
370-
변경 전: 별도 OPTIONS 라우트 없음(미들웨어에 의존)
371-
변경 후(추가): 명시적으로 200을 반환
372-
"""
373370
return Response(status_code=200)

routers/note.py

Lines changed: 57 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -9,29 +9,69 @@
99

1010
from db import get_db, SessionLocal
1111
from models.note import Note
12-
from schemas.note import NoteCreate, NoteUpdate, NoteResponse, FavoriteUpdate
12+
from models.file import File as FileModel
13+
from schemas.note import NoteCreate, NoteUpdate, NoteResponse, FavoriteUpdate, NoteFile
1314
from utils.jwt_utils import get_current_user
14-
from fastapi.responses import StreamingResponse
1515
from utils.llm import stream_summary_with_langchain
1616

1717
load_dotenv()
1818
HF_TOKEN = os.getenv("HF_API_TOKEN")
1919

2020
router = APIRouter(prefix="/api/v1", tags=["Notes"])
2121

22+
# 환경변수에서 BASE_API_URL 가져와 파일 다운로드 URL 구성
23+
BASE_API_URL = os.getenv("BASE_API_URL", "http://localhost:8000")
24+
25+
26+
# ─────────────────────────────────────────────
27+
# 공통: Note → NoteResponse 직렬화 + files 채우기
28+
# ─────────────────────────────────────────────
29+
def serialize_note(db: Session, note: Note) -> NoteResponse:
30+
"""
31+
Note ORM 객체를 NoteResponse로 변환하면서
32+
note_id로 연결된 File들을 찾아 files 배열에 채워 넣는다.
33+
"""
34+
# 파일 목록 조회 (노트 첨부)
35+
files = (
36+
db.query(FileModel)
37+
.filter(FileModel.note_id == note.id, FileModel.user_id == note.user_id)
38+
.order_by(FileModel.created_at.desc())
39+
.all()
40+
)
41+
42+
file_items: List[NoteFile] = []
43+
for f in files:
44+
file_items.append(
45+
NoteFile(
46+
file_id=f.id,
47+
original_name=f.original_name,
48+
content_type=f.content_type,
49+
url=f"{BASE_API_URL}/api/v1/files/download/{f.id}",
50+
created_at=f.created_at,
51+
)
52+
)
53+
54+
# Pydantic 모델 생성 (from_attributes=True로 기본 필드 매핑)
55+
data = NoteResponse.model_validate(note, from_attributes=True)
56+
# files 필드 교체
57+
data.files = file_items
58+
return data
59+
2260

2361
# 1) 모든 노트 조회
2462
@router.get("/notes", response_model=List[NoteResponse])
2563
def list_notes(
2664
db: Session = Depends(get_db),
2765
user = Depends(get_current_user)
2866
):
29-
return (
67+
notes = (
3068
db.query(Note)
3169
.filter(Note.user_id == user.u_id)
3270
.order_by(Note.created_at.desc())
3371
.all()
3472
)
73+
# 각 노트의 files도 채워 반환
74+
return [serialize_note(db, n) for n in notes]
3575

3676

3777
# 2) 최근 접근한 노트 조회 (상위 10개)
@@ -40,13 +80,14 @@ def recent_notes(
4080
db: Session = Depends(get_db),
4181
user = Depends(get_current_user)
4282
):
43-
return (
83+
notes = (
4484
db.query(Note)
4585
.filter(Note.user_id == user.u_id, Note.last_accessed.isnot(None))
4686
.order_by(Note.last_accessed.desc())
4787
.limit(10)
4888
.all()
4989
)
90+
return [serialize_note(db, n) for n in notes]
5091

5192

5293
# 3) 노트 생성
@@ -65,7 +106,7 @@ def create_note(
65106
db.add(note)
66107
db.commit()
67108
db.refresh(note)
68-
return note
109+
return serialize_note(db, note)
69110

70111

71112
# 4) 노트 수정 (제목/내용/폴더)
@@ -86,11 +127,13 @@ def update_note(
86127
note.content = req.content
87128
if req.folder_id is not None:
88129
note.folder_id = req.folder_id
130+
if req.is_favorite is not None:
131+
note.is_favorite = req.is_favorite
89132

90133
note.updated_at = datetime.utcnow()
91134
db.commit()
92135
db.refresh(note)
93-
return note
136+
return serialize_note(db, note)
94137

95138

96139
# 5) 노트 단일 조회 (마지막 접근 시간 업데이트 포함)
@@ -107,7 +150,7 @@ def get_note(
107150
note.last_accessed = datetime.utcnow()
108151
db.commit()
109152
db.refresh(note)
110-
return note
153+
return serialize_note(db, note)
111154

112155

113156
# 6) 노트 삭제
@@ -142,30 +185,13 @@ def toggle_favorite(
142185
note.updated_at = datetime.utcnow()
143186
db.commit()
144187
db.refresh(note)
145-
return note
146-
147-
def save_summary(note_id: int, text: str):
148-
"""Deprecated: kept for reference. No longer overwrites original note."""
149-
db2 = SessionLocal()
150-
try:
151-
tgt = db2.query(Note).filter(Note.id == note_id).first()
152-
if not tgt:
153-
return
154-
# Create a new note in the same folder with title '<original>요약'
155-
title = (tgt.title or "").strip() + "요약"
156-
if len(title) > 255:
157-
title = title[:255]
158-
new_note = Note(
159-
user_id=tgt.user_id,
160-
folder_id=tgt.folder_id,
161-
title=title,
162-
content=text,
163-
)
164-
db2.add(new_note)
165-
db2.commit()
166-
finally:
167-
db2.close()
188+
return serialize_note(db, note)
189+
168190

191+
# ─────────────────────────────────────────────
192+
# (참고) 요약 스트리밍 API - 완료 후에도 serialize_note 사용 안 함
193+
# (요약은 새 노트를 생성하고 SSE로 알림만 보냄)
194+
# ─────────────────────────────────────────────
169195
@router.post("/notes/{note_id}/summarize")
170196
async def summarize_stream_langchain(
171197
note_id: int,
@@ -183,7 +209,7 @@ async def event_gen():
183209
parts = []
184210
async for sse in stream_summary_with_langchain(note.content, domain=domain, longdoc=longdoc):
185211
parts.append(sse.removeprefix("data: ").strip())
186-
yield sse.encode()
212+
yield sse.encode()
187213
full = "".join(parts).strip()
188214
if full:
189215
# Create a new summary note in the same folder with title '<original>요약'

0 commit comments

Comments
 (0)